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