xref: /openbmc/openbmc-build-scripts/scripts/format-code.sh (revision 05ac17916da3c2b27f06a1fe489150aed436ef4d)
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        beautysh \
31        beautysh_sh \
32        black \
33        clang_format \
34        eslint \
35        flake8 \
36        isort \
37        markdownlint \
38        meson \
39        prettier \
40        shellcheck \
41    )
42LINTERS_DISABLED=()
43LINTERS_ENABLED=()
44declare -A LINTERS_FAILED=()
45
46eval set -- "$(getopt -o 'h' --long 'help,list-tools,no-diff,disable:,enable:,allow-missing' -n 'format-code.sh' -- "$@")"
47while true; do
48    case "$1" in
49        '-h'|'--help')
50            display_help && exit 0
51            ;;
52
53        '--list-tools')
54            echo "Available tools:"
55            for t in "${LINTERS_ALL[@]}"; do
56                echo "    $t"
57            done
58            exit 0
59            ;;
60
61        '--no-diff')
62            OPTION_NO_DIFF=1
63            shift
64            ;;
65
66        '--disable')
67            LINTERS_DISABLED+=("$2")
68            shift && shift
69            ;;
70
71        '--enable')
72            LINTERS_ENABLED+=("$2")
73            shift && shift
74            ;;
75
76        '--allow-missing')
77            ALLOW_MISSING=yes
78            shift
79            ;;
80
81        '--')
82            shift
83            break
84            ;;
85
86        *)
87            echo "unknown option: $1"
88            display_help && exit 1
89            ;;
90    esac
91done
92
93# Detect tty and set nicer colors.
94if [ -t 1 ]; then
95    BLUE="\e[34m"
96    GREEN="\e[32m"
97    NORMAL="\e[0m"
98    RED="\e[31m"
99    YELLOW="\e[33m"
100else # non-tty, no escapes.
101    BLUE=""
102    GREEN=""
103    NORMAL=""
104    RED=""
105    YELLOW=""
106fi
107
108# Allow called scripts to know which clang format we are using
109export CLANG_FORMAT="clang-format"
110
111# Path to default config files for linters.
112CONFIG_PATH="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/config"
113
114# Find repository root for `pwd` or $1.
115if [ -z "$1" ]; then
116    DIR="$(git rev-parse --show-toplevel || pwd)"
117else
118    DIR="$(git -C "$1" rev-parse --show-toplevel)"
119fi
120if [ ! -e "$DIR/.git" ]; then
121    echo -e "${RED}Error:${NORMAL} Directory ($DIR) does not appear to be a git repository"
122    exit 1
123fi
124
125cd "${DIR}"
126echo -e "    ${BLUE}Formatting code under${NORMAL} $DIR"
127
128# Config hashes:
129#   LINTER_REQUIRE - The requirements to run a linter, semi-colon separated.
130#       1. Executable.
131#       2. [optional] Configuration file.
132#       3. [optional] Global fallback configuration file.
133#
134#   LINTER_IGNORE - An optional set of semi-colon separated ignore-files
135#       specific to the linter.
136#
137#   LINTER_TYPES - The file types supported by the linter, semi-colon separated.
138#
139#   LINTER_CONFIG - The config (from LINTER_REQUIRE) chosen for the repository.
140#
141declare -A LINTER_REQUIRE=()
142declare -A LINTER_IGNORE=()
143declare -A LINTER_TYPES=()
144declare -A LINTER_CONFIG=()
145
146LINTER_REQUIRE+=([commit_spelling]="codespell")
147LINTER_TYPES+=([commit_spelling]="commit")
148
149commit_filename="$(mktemp)"
150function clean_up_file() {
151    rm "$commit_filename"
152}
153trap clean_up_file EXIT
154
155function find_codespell_dict_file() {
156    local python_codespell_dict
157    # @formatter:off
158    python_codespell_dict=$(python3 -c "
159import os.path as op
160import codespell_lib
161codespell_dir = op.dirname(codespell_lib.__file__)
162codespell_file = op.join(codespell_dir, 'data', 'dictionary.txt')
163print(codespell_file if op.isfile(codespell_file) else '', end='')
164" 2> /dev/null)
165    # @formatter:on
166
167    # Return the path if found, otherwise return an empty string
168    echo "$python_codespell_dict"
169}
170
171function do_commit_spelling() {
172    # Write the commit message to a temporary file
173    git log --format='%B' -1 > "$commit_filename"
174
175    # Some names or emails appear as false-positive misspellings, remove them
176    sed -i "s/Signed-off-by.*//" "$commit_filename"
177
178    # Get the path to the dictionary.txt file
179    local codespell_dict
180    codespell_dict=$(find_codespell_dict_file)
181
182    # Check if the dictionary file was found
183    if [[ -z "$codespell_dict" ]]; then
184        echo "Error: Could not find dictionary.txt file"
185        exit 1
186    fi
187
188    # Run the codespell with codespell dictionary on the patchset
189    echo -n "codespell-dictionary - misspelling count >> "
190    codespell -D "$codespell_dict" -d --count "$commit_filename"
191
192    # Run the codespell with builtin dictionary on the patchset
193    echo -n "generic-dictionary - misspelling count >> "
194    codespell --builtin clear,rare,en-GB_to_en-US -d --count "$commit_filename"
195}
196
197LINTER_REQUIRE+=([commit_gitlint]="gitlint")
198LINTER_TYPES+=([commit_gitlint]="commit")
199function do_commit_gitlint() {
200    gitlint --extra-path "${CONFIG_PATH}/gitlint/" \
201        --config "${CONFIG_PATH}/.gitlint"
202}
203
204# We need different function style for bash/zsh vs plain sh, so beautysh is
205# split into two linters.  "function foo()" is not traditionally accepted
206# POSIX-shell syntax, so shellcheck barfs on it.
207LINTER_REQUIRE+=([beautysh]="beautysh")
208LINTER_IGNORE+=([beautysh]=".beautysh-ignore")
209LINTER_TYPES+=([beautysh]="bash;zsh")
210function do_beautysh() {
211    beautysh --force-function-style fnpar "$@"
212}
213LINTER_REQUIRE+=([beautysh_sh]="beautysh")
214LINTER_IGNORE+=([beautysh_sh]=".beautysh-ignore")
215LINTER_TYPES+=([beautysh_sh]="sh")
216function do_beautysh_sh() {
217    beautysh --force-function-style paronly "$@"
218}
219
220LINTER_REQUIRE+=([black]="black")
221LINTER_TYPES+=([black]="python")
222function do_black() {
223    black -l 79 "$@"
224}
225
226LINTER_REQUIRE+=([eslint]="eslint;.eslintrc.json;${CONFIG_PATH}/eslint-global-config.json")
227LINTER_IGNORE+=([eslint]=".eslintignore")
228LINTER_TYPES+=([eslint]="json")
229function do_eslint() {
230    eslint --no-eslintrc -c "${LINTER_CONFIG[eslint]}" \
231        --ext .json --format=stylish \
232        --resolve-plugins-relative-to /usr/local/lib/node_modules \
233        --no-error-on-unmatched-pattern "$@"
234}
235
236LINTER_REQUIRE+=([flake8]="flake8")
237LINTER_IGNORE+=([flake8]=".flake8-ignore")
238LINTER_TYPES+=([flake8]="python")
239function do_flake8() {
240    flake8 --show-source --extend-ignore=E203,E501 "$@"
241    # We disable E203 and E501 because 'black' is handling these and they
242    # disagree on best practices.
243}
244
245LINTER_REQUIRE+=([isort]="isort")
246LINTER_TYPES+=([isort]="python")
247function do_isort() {
248    isort --profile black "$@"
249}
250
251LINTER_REQUIRE+=([markdownlint]="markdownlint;.markdownlint.yaml;${CONFIG_PATH}/markdownlint.yaml")
252LINTER_IGNORE+=([markdownlint]=".markdownlint-ignore")
253LINTER_TYPES+=([markdownlint]="markdown")
254function do_markdownlint() {
255    markdownlint --config "${LINTER_CONFIG[markdownlint]}" -- "$@" || \
256        echo -e "    ${YELLOW}Failed markdownlint; temporarily ignoring."
257    # We disable line-length because prettier should handle prose wrap for us.
258}
259
260LINTER_REQUIRE+=([meson]="meson;meson.build")
261LINTER_TYPES+=([meson]="meson")
262function do_meson() {
263    meson format -i "$@"
264}
265
266LINTER_REQUIRE+=([prettier]="prettier;.prettierrc.yaml;${CONFIG_PATH}/prettierrc.yaml")
267LINTER_IGNORE+=([prettier]=".prettierignore")
268LINTER_TYPES+=([prettier]="json;markdown;yaml")
269function do_prettier() {
270    prettier --config "${LINTER_CONFIG[prettier]}" --write "$@"
271}
272
273LINTER_REQUIRE+=([shellcheck]="shellcheck")
274LINTER_IGNORE+=([shellcheck]=".shellcheck-ignore")
275LINTER_TYPES+=([shellcheck]="bash;sh")
276function do_shellcheck() {
277    shellcheck --color=never -x "$@"
278}
279
280LINTER_REQUIRE+=([clang_format]="clang-format;.clang-format")
281LINTER_IGNORE+=([clang_format]=".clang-ignore;.clang-format-ignore")
282LINTER_TYPES+=([clang_format]="c;cpp")
283function do_clang_format() {
284    "${CLANG_FORMAT}" -i "$@"
285}
286
287function get_file_type()
288{
289    case "$(basename "$1")" in
290            # First to early detect template files.
291        *.in | *.meson) echo "meson-template" && return ;;
292        *.mako | *.mako.*) echo "mako" && return ;;
293
294        *.ac) echo "autoconf" && return ;;
295        *.[ch]) echo "c" && return ;;
296        *.[ch]pp) echo "cpp" &&  return ;;
297        *.json) echo "json" && return ;;
298        *.md) echo "markdown" && return ;;
299        *.py) echo "python" && return ;;
300        *.tcl) echo "tcl" && return ;;
301        *.yaml | *.yml) echo "yaml" && return ;;
302
303            # Special files.
304        .git/COMMIT_EDITMSG) echo "commit" && return ;;
305        meson.build) echo "meson" && return ;;
306        meson.options) echo "meson" && return ;;
307    esac
308
309    case "$(file "$1")" in
310        *Bourne-Again\ shell*) echo "bash" && return ;;
311        *C++\ source*) echo "cpp" && return ;;
312        *C\ source*) echo "c" && return ;;
313        *JSON\ data*) echo "json" && return ;;
314        *POSIX\ shell*) echo "sh" && return ;;
315        *Python\ script*) echo "python" && return ;;
316        *python3\ script*) echo "python" && return ;;
317        *zsh\ shell*) echo "zsh" && return ;;
318    esac
319
320    echo "unknown"
321}
322
323LINTERS_AVAILABLE=()
324function check_linter()
325{
326    TITLE="$1"
327    IFS=";" read -r -a ARGS <<< "$2"
328
329    if [[ "${LINTERS_DISABLED[*]}" =~ $1 ]]; then
330        return
331    fi
332
333    if [ 0 -ne "${#LINTERS_ENABLED[@]}" ]; then
334        if ! [[ "${LINTERS_ENABLED[*]}" =~ $1 ]]; then
335            return
336        fi
337    fi
338
339    EXE="${ARGS[0]}"
340    if [ ! -x "${EXE}" ]; then
341        if ! which "${EXE}" > /dev/null 2>&1 ; then
342            echo -e "    ${YELLOW}${TITLE}:${NORMAL} cannot find ${EXE}"
343            if [ -z "$ALLOW_MISSING" ]; then
344                exit 1
345            fi
346            return
347        fi
348    fi
349
350    CONFIG="${ARGS[1]}"
351    FALLBACK="${ARGS[2]}"
352
353    if [ -n "${CONFIG}" ]; then
354        if [ -e "${CONFIG}" ]; then
355            LINTER_CONFIG+=( [${TITLE}]="${CONFIG}" )
356        elif [ -n "${FALLBACK}" ] && [ -e "${FALLBACK}" ]; then
357            echo -e "    ${YELLOW}${TITLE}:${NORMAL} cannot find ${CONFIG}; using ${FALLBACK}"
358            LINTER_CONFIG+=( [${TITLE}]="${FALLBACK}" )
359        else
360            echo -e "    ${YELLOW}${TITLE}:${NORMAL} cannot find config ${CONFIG}"
361            return
362        fi
363    fi
364
365    LINTERS_AVAILABLE+=( "${TITLE}" )
366}
367
368# Check for a global .linter-ignore file.
369GLOBAL_IGNORE=("cat")
370if [ -e ".linter-ignore" ]; then
371    GLOBAL_IGNORE=("${CONFIG_PATH}/lib/ignore-filter" ".linter-ignore")
372fi
373
374# Find all the files in the git repository and organize by type.
375declare -A FILES=()
376FILES+=([commit]=".git")
377
378while read -r file; do
379    ftype="$(get_file_type "$file")"
380    FILES+=([$ftype]="$(echo -ne "$file;${FILES[$ftype]:-}")")
381done < <(git ls-files | xargs realpath --relative-base=. | "${GLOBAL_IGNORE[@]}")
382
383# For each linter, check if there are an applicable files and if it can
384# be enabled.
385for op in "${LINTERS_ALL[@]}"; do
386    for ftype in ${LINTER_TYPES[$op]//;/ }; do
387        if [[ -v FILES["$ftype"] ]]; then
388            check_linter "$op" "${LINTER_REQUIRE[${op}]}"
389            break
390        fi
391    done
392done
393
394# Call each linter.
395for op in "${LINTERS_AVAILABLE[@]}"; do
396
397    # Determine the linter-specific ignore file(s).
398    LOCAL_IGNORE=("${CONFIG_PATH}/lib/ignore-filter")
399    if [[ -v LINTER_IGNORE["$op"] ]]; then
400        for ignorefile in ${LINTER_IGNORE["$op"]//;/ } ; do
401            if [ -e "$ignorefile" ]; then
402                LOCAL_IGNORE+=("$ignorefile")
403            fi
404        done
405    fi
406    if [ 1 -eq ${#LOCAL_IGNORE[@]} ]; then
407        LOCAL_IGNORE=("cat")
408    fi
409
410    # Find all the files for this linter, filtering out the ignores.
411    LINTER_FILES=()
412    while read -r file ; do
413        if [ -e "$file" ]; then
414            LINTER_FILES+=("$file")
415        fi
416        done < <(for ftype in ${LINTER_TYPES[$op]//;/ }; do
417            # shellcheck disable=SC2001
418            echo "${FILES["$ftype"]:-}" | sed "s/;/\\n/g"
419    done | "${LOCAL_IGNORE[@]}")
420
421    # Call the linter now with all the files.
422    if [ 0 -ne ${#LINTER_FILES[@]} ]; then
423        echo -e "    ${BLUE}Running $op${NORMAL}"
424        if ! "do_$op" "${LINTER_FILES[@]}" ; then
425            LINTERS_FAILED+=([$op]=1)
426            echo -e "    ${RED}$op - FAILED${NORMAL}"
427        fi
428    else
429        echo -e "    ${YELLOW}${op}:${NORMAL} all applicable files are on ignore-lists"
430    fi
431done
432
433# Check for failing linters.
434if [ 0 -ne ${#LINTERS_FAILED[@]} ]; then
435    for op in "${!LINTERS_FAILED[@]}"; do
436        echo -e "$op: ${RED}FAILED${NORMAL} (see prior failure)"
437    done
438    exit 1
439fi
440
441# Check for differences.
442if [ -z "$OPTION_NO_DIFF" ]; then
443    echo -e "    ${BLUE}Result differences...${NORMAL}"
444    if ! git --no-pager diff --exit-code ; then
445        echo -e "Format: ${RED}FAILED${NORMAL}"
446        exit 1
447    else
448        echo -e "Format: ${GREEN}PASSED${NORMAL}"
449    fi
450fi
451
452# Sometimes your situation is terrible enough that you need the flexibility.
453# For example, phosphor-mboxd.
454for formatter in "format-code.sh" "format-code"; do
455    if [[ -x "${formatter}" ]]; then
456        echo -e "    ${BLUE}Calling secondary formatter:${NORMAL} ${formatter}"
457        "./${formatter}"
458        if [ -z "$OPTION_NO_DIFF" ]; then
459            git --no-pager diff --exit-code
460        fi
461    fi
462done
463