1#!/bin/bash
2set -e
3
4# This script reformats source files using various formatters and linters.
5#
6# Files are changed in-place, so make sure you don't have anything open in an
7# editor, and you may want to commit before formatting in case of awryness.
8#
9# This must be run on a clean repository to succeed
10#
11function display_help()
12{
13    echo "usage: format-code.sh [-h | --help] [--no-diff] [--list-tools]"
14    echo "                      [--disable <tool>] [--enable <tool>] [<path>]"
15    echo
16    echo "Format and lint a repository."
17    echo
18    echo "Arguments:"
19    echo "    --list-tools      Display available linters and formatters"
20    echo "    --no-diff         Don't show final diff output"
21    echo "    --disable <tool>  Disable linter"
22    echo "    --enable <tool>   Enable only specific linters"
23    echo "    --allow-missing   Run even if linters are not all present"
24    echo "    path              Path to git repository (default to pwd)"
25}
26
27LINTERS_ALL=( \
28        commit_gitlint \
29        commit_spelling \
30        clang_format \
31        eslint \
32        flake8 \
33        prettier \
34        shellcheck \
35    )
36LINTERS_DISABLED=()
37LINTERS_ENABLED=()
38declare -A LINTERS_FAILED=()
39
40eval set -- "$(getopt -o 'h' --long 'help,list-tools,no-diff,disable:,enable:,allow-missing' -n 'format-code.sh' -- "$@")"
41while true; do
42    case "$1" in
43        '-h'|'--help')
44            display_help && exit 0
45            ;;
46
47        '--list-tools')
48            echo "Available tools:"
49            for t in "${LINTERS_ALL[@]}"; do
50                echo "    $t"
51            done
52            exit 0
53            ;;
54
55        '--no-diff')
56            OPTION_NO_DIFF=1
57            shift
58            ;;
59
60        '--disable')
61            LINTERS_DISABLED+=("$2")
62            shift && shift
63            ;;
64
65        '--enable')
66            LINTERS_ENABLED+=("$2")
67            shift && shift
68            ;;
69
70        '--allow-missing')
71            ALLOW_MISSING=yes
72            shift
73            ;;
74
75        '--')
76            shift
77            break
78            ;;
79
80        *)
81            echo "unknown option: $1"
82            display_help && exit 1
83            ;;
84    esac
85done
86
87# Detect tty and set nicer colors.
88if [ -t 1 ]; then
89    BLUE="\e[34m"
90    GREEN="\e[32m"
91    NORMAL="\e[0m"
92    RED="\e[31m"
93    YELLOW="\e[33m"
94else # non-tty, no escapes.
95    BLUE=""
96    GREEN=""
97    NORMAL=""
98    RED=""
99    YELLOW=""
100fi
101
102# Allow called scripts to know which clang format we are using
103export CLANG_FORMAT="clang-format"
104
105# Path to default config files for linters.
106CONFIG_PATH="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/config"
107
108# Find repository root for `pwd` or $1.
109if [ -z "$1" ]; then
110    DIR="$(git rev-parse --show-toplevel || pwd)"
111else
112    DIR="$(git -C "$1" rev-parse --show-toplevel)"
113fi
114if [ ! -e "$DIR/.git" ]; then
115    echo -e "${RED}Error:${NORMAL} Directory ($DIR) does not appear to be a git repository"
116    exit 1
117fi
118
119cd "${DIR}"
120echo -e "    ${BLUE}Formatting code under${NORMAL} $DIR"
121
122# Config hashes:
123#   LINTER_REQUIRE - The requirements to run a linter, semi-colon separated.
124#       1. Executable.
125#       2. [optional] Configuration file.
126#       3. [optional] Global fallback configuration file.
127#
128#   LINTER_IGNORE - An optional set of semi-colon separated ignore-files
129#       specific to the linter.
130#
131#   LINTER_TYPES - The file types supported by the linter, semi-colon separated.
132#
133#   LINTER_CONFIG - The config (from LINTER_REQUIRE) chosen for the repository.
134#
135declare -A LINTER_REQUIRE=()
136declare -A LINTER_IGNORE=()
137declare -A LINTER_TYPES=()
138declare -A LINTER_CONFIG=()
139
140LINTER_REQUIRE+=([commit_spelling]="codespell")
141LINTER_TYPES+=([commit_spelling]="commit")
142function do_commit_spelling() {
143    # Run the codespell with openbmc spcific spellings on the patchset
144    echo -n "openbmc-dictionary - misspelling count >> "
145    sed "s/Signed-off-by.*//" "$@" | \
146        codespell -D "${CONFIG_PATH}/openbmc-spelling.txt" -d --count -
147
148    # Run the codespell with generic dictionary on the patchset
149    echo -n "generic-dictionary - misspelling count >> "
150    sed "s/Signed-off-by.*//" "$@" | \
151        codespell --builtin clear,rare,en-GB_to_en-US -d --count -
152}
153
154LINTER_REQUIRE+=([commit_gitlint]="gitlint")
155LINTER_TYPES+=([commit_gitlint]="commit")
156function do_commit_gitlint() {
157    gitlint --extra-path "${CONFIG_PATH}/gitlint/" \
158        --config "${CONFIG_PATH}/.gitlint"
159}
160
161LINTER_REQUIRE+=([eslint]="eslint;.eslintrc.json;${CONFIG_PATH}/eslint-global-config.json")
162LINTER_IGNORE+=([eslint]=".eslintignore")
163LINTER_TYPES+=([eslint]="json")
164function do_eslint() {
165    eslint --no-eslintrc -c "${LINTER_CONFIG[eslint]}" \
166        --ext .json --format=stylish \
167        --resolve-plugins-relative-to /usr/local/lib/node_modules \
168        --no-error-on-unmatched-pattern "$@"
169}
170
171LINTER_REQUIRE+=([flake8]="flake8")
172LINTER_IGNORE+=([flake8]=".flake8-ignore")
173LINTER_TYPES+=([flake8]="python")
174function do_flake8() {
175    flake8 --show-source --extend-ignore=E203,E501 "$@"
176    # We disable E203 and E501 because 'black' is handling these and they
177    # disagree on best practices.
178}
179
180LINTER_REQUIRE+=([prettier]="prettier;.prettierrc.yaml;${CONFIG_PATH}/prettierrc.yaml")
181LINTER_IGNORE+=([prettier]=".prettierignore")
182LINTER_TYPES+=([prettier]="json;markdown;yaml")
183function do_prettier() {
184    prettier --config "${LINTER_CONFIG[prettier]}" --write "$@"
185}
186
187LINTER_REQUIRE+=([shellcheck]="shellcheck")
188LINTER_IGNORE+=([shellcheck]=".shellcheck-ignore")
189LINTER_TYPES+=([shellcheck]="bash;sh")
190function do_shellcheck() {
191    shellcheck --color=never -x "$@"
192}
193
194LINTER_REQUIRE+=([clang_format]="clang-format;.clang-format")
195LINTER_IGNORE+=([clang_format]=".clang-ignore;.clang-format-ignore")
196LINTER_TYPES+=([clang_format]="c;cpp")
197function do_clang_format() {
198    "${CLANG_FORMAT}" -i "$@"
199}
200
201function get_file_type()
202{
203    case "$(basename "$1")" in
204            # First to early detect template files.
205        *.in | *.meson) echo "meson-template" && return ;;
206        *.mako | *.mako.*) echo "mako" && return ;;
207
208        *.ac) echo "autoconf" && return ;;
209        *.[ch]) echo "c" && return ;;
210        *.[ch]pp) echo "cpp" &&  return ;;
211        *.json) echo "json" && return ;;
212        *.md) echo "markdown" && return ;;
213        *.py) echo "python" && return ;;
214        *.tcl) echo "tcl" && return ;;
215        *.yaml | *.yml) echo "yaml" && return ;;
216
217            # Special files.
218        .git/COMMIT_EDITMSG) echo "commit" && return ;;
219        meson.build) echo "meson" && return ;;
220    esac
221
222    case "$(file "$1")" in
223        *Bourne-Again\ shell*) echo "bash" && return ;;
224        *C++\ source*) echo "cpp" && return ;;
225        *C\ source*) echo "c" && return ;;
226        *JSON\ data*) echo "json" && return ;;
227        *POSIX\ shell*) echo "sh" && return ;;
228        *Python\ script*) echo "python" && return ;;
229        *python3\ script*) echo "python" && return ;;
230        *zsh\ shell*) echo "zsh" && return ;;
231    esac
232
233    echo "unknown"
234}
235
236LINTERS_AVAILABLE=()
237function check_linter()
238{
239    TITLE="$1"
240    IFS=";" read -r -a ARGS <<< "$2"
241
242    if [[ "${LINTERS_DISABLED[*]}" =~ $1 ]]; then
243        return
244    fi
245
246    if [ 0 -ne "${#LINTERS_ENABLED[@]}" ]; then
247        if ! [[ "${LINTERS_ENABLED[*]}" =~ $1 ]]; then
248            return
249        fi
250    fi
251
252    EXE="${ARGS[0]}"
253    if [ ! -x "${EXE}" ]; then
254        if ! which "${EXE}" > /dev/null 2>&1 ; then
255            echo -e "    ${YELLOW}${TITLE}:${NORMAL} cannot find ${EXE}"
256            if [ -z "$ALLOW_MISSING" ]; then
257                exit 1
258            fi
259            return
260        fi
261    fi
262
263    CONFIG="${ARGS[1]}"
264    FALLBACK="${ARGS[2]}"
265
266    if [ -n "${CONFIG}" ]; then
267        if [ -e "${CONFIG}" ]; then
268            LINTER_CONFIG+=( [${TITLE}]="${CONFIG}" )
269        elif [ -n "${FALLBACK}" ] && [ -e "${FALLBACK}" ]; then
270            echo -e "    ${YELLOW}${TITLE}:${NORMAL} cannot find ${CONFIG}; using ${FALLBACK}"
271            LINTER_CONFIG+=( [${TITLE}]="${FALLBACK}" )
272        else
273            echo -e "    ${YELLOW}${TITLE}:${NORMAL} cannot find config ${CONFIG}"
274            return
275        fi
276    fi
277
278    LINTERS_AVAILABLE+=( "${TITLE}" )
279}
280
281# Check for a global .linter-ignore file.
282GLOBAL_IGNORE=("cat")
283if [ -e ".linter-ignore" ]; then
284    GLOBAL_IGNORE=("${CONFIG_PATH}/lib/ignore-filter" ".linter-ignore")
285fi
286
287# Find all the files in the git repository and organize by type.
288declare -A FILES=()
289if [ -e .git/COMMIT_EDITMSG ]; then
290    FILES+=([commit]=".git/COMMIT_EDITMSG")
291fi
292while read -r file; do
293    ftype="$(get_file_type "$file")"
294    FILES+=([$ftype]="$(echo -ne "$file;${FILES[$ftype]:-}")")
295done < <(git ls-files | "${GLOBAL_IGNORE[@]}")
296
297# For each linter, check if there are an applicable files and if it can
298# be enabled.
299for op in "${LINTERS_ALL[@]}"; do
300    for ftype in ${LINTER_TYPES[$op]//;/ }; do
301        if [[ -v FILES["$ftype"] ]]; then
302            check_linter "$op" "${LINTER_REQUIRE[${op}]}"
303            break
304        fi
305    done
306done
307
308# Call each linter.
309for op in "${LINTERS_AVAILABLE[@]}"; do
310
311    # Determine the linter-specific ignore file(s).
312    LOCAL_IGNORE=("${CONFIG_PATH}/lib/ignore-filter")
313    if [[ -v LINTER_IGNORE["$op"] ]]; then
314        for ignorefile in ${LINTER_IGNORE["$op"]//;/ } ; do
315            if [ -e "$ignorefile" ]; then
316                LOCAL_IGNORE+=("$ignorefile")
317            fi
318        done
319    fi
320    if [ 1 -eq ${#LOCAL_IGNORE[@]} ]; then
321        LOCAL_IGNORE=("cat")
322    fi
323
324    # Find all the files for this linter, filtering out the ignores.
325    LINTER_FILES=()
326    while read -r file ; do
327        if [ -e "$file" ]; then
328            LINTER_FILES+=("$file")
329        fi
330        done < <(for ftype in ${LINTER_TYPES[$op]//;/ }; do
331            # shellcheck disable=SC2001
332            echo "${FILES["$ftype"]:-}" | sed "s/;/\\n/g"
333    done | "${LOCAL_IGNORE[@]}")
334
335    # Call the linter now with all the files.
336    if [ 0 -ne ${#LINTER_FILES[@]} ]; then
337        echo -e "    ${BLUE}Running $op${NORMAL}"
338        if ! "do_$op" "${LINTER_FILES[@]}" ; then
339            LINTERS_FAILED+=([$op]=1)
340        fi
341    else
342        echo -e "    ${YELLOW}${op}:${NORMAL} all applicable files are on ignore-lists"
343    fi
344done
345
346# Check for failing linters.
347if [ 0 -ne ${#LINTERS_FAILED[@]} ]; then
348    for op in "${!LINTERS_FAILED[@]}"; do
349        echo -e "$op: ${RED}FAILED${NORMAL}"
350    done
351    exit 1
352fi
353
354# Check for differences.
355if [ -z "$OPTION_NO_DIFF" ]; then
356    echo -e "    ${BLUE}Result differences...${NORMAL}"
357    if ! git --no-pager diff --exit-code ; then
358        echo -e "Format: ${RED}FAILED${NORMAL}"
359        exit 1
360    else
361        echo -e "Format: ${GREEN}PASSED${NORMAL}"
362    fi
363fi
364
365# Sometimes your situation is terrible enough that you need the flexibility.
366# For example, phosphor-mboxd.
367for formatter in "format-code.sh" "format-code"; do
368    if [[ -x "${formatter}" ]]; then
369        echo -e "    ${BLUE}Calling secondary formatter:${NORMAL} ${formatter}"
370        "./${formatter}"
371        if [ -z "$OPTION_NO_DIFF" ]; then
372            git --no-pager diff --exit-code
373        fi
374    fi
375done
376