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