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