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