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