1#!/usr/bin/env python3
2"""Check or fix the code style by running Uncrustify.
3
4This script must be run from the root of a Git work tree containing Mbed TLS.
5"""
6# Copyright The Mbed TLS Contributors
7# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
8import argparse
9import os
10import re
11import subprocess
12import sys
13from typing import FrozenSet, List, Optional
14
15UNCRUSTIFY_SUPPORTED_VERSION = "0.75.1"
16CONFIG_FILE = ".uncrustify.cfg"
17UNCRUSTIFY_EXE = "uncrustify"
18UNCRUSTIFY_ARGS = ["-c", CONFIG_FILE]
19CHECK_GENERATED_FILES = "tests/scripts/check-generated-files.sh"
20
21def print_err(*args):
22    print("Error: ", *args, file=sys.stderr)
23
24# Print the file names that will be skipped and the help message
25def print_skip(files_to_skip):
26    print()
27    print(*files_to_skip, sep=", SKIP\n", end=", SKIP\n")
28    print("Warning: The listed files will be skipped because\n"
29          "they are not known to git.")
30    print()
31
32# Match FILENAME(s) in "check SCRIPT (FILENAME...)"
33CHECK_CALL_RE = re.compile(r"\n\s*check\s+[^\s#$&*?;|]+([^\n#$&*?;|]+)",
34                           re.ASCII)
35def list_generated_files() -> FrozenSet[str]:
36    """Return the names of generated files.
37
38    We don't reformat generated files, since the result might be different
39    from the output of the generator. Ideally the result of the generator
40    would conform to the code style, but this would be difficult, especially
41    with respect to the placement of line breaks in long logical lines.
42    """
43    # Parse check-generated-files.sh to get an up-to-date list of
44    # generated files. Read the file rather than calling it so that
45    # this script only depends on Git, Python and uncrustify, and not other
46    # tools such as sh or grep which might not be available on Windows.
47    # This introduces a limitation: check-generated-files.sh must have
48    # the expected format and must list the files explicitly, not through
49    # wildcards or command substitution.
50    content = open(CHECK_GENERATED_FILES, encoding="utf-8").read()
51    checks = re.findall(CHECK_CALL_RE, content)
52    return frozenset(word for s in checks for word in s.split())
53
54def get_src_files(since: Optional[str]) -> List[str]:
55    """
56    Use git to get a list of the source files.
57
58    The optional argument since is a commit, indicating to only list files
59    that have changed since that commit. Without this argument, list all
60    files known to git.
61
62    Only C files are included, and certain files (generated, or 3rdparty)
63    are excluded.
64    """
65    file_patterns = ["*.[hc]",
66                     "tests/suites/*.function",
67                     "scripts/data_files/*.fmt"]
68    output = subprocess.check_output(["git", "ls-files"] + file_patterns,
69                                     universal_newlines=True)
70    src_files = output.split()
71    if since:
72        # get all files changed in commits since the starting point
73        cmd = ["git", "log", since + "..HEAD", "--name-only", "--pretty=", "--"] + src_files
74        output = subprocess.check_output(cmd, universal_newlines=True)
75        committed_changed_files = output.split()
76        # and also get all files with uncommitted changes
77        cmd = ["git", "diff", "--name-only", "--"] + src_files
78        output = subprocess.check_output(cmd, universal_newlines=True)
79        uncommitted_changed_files = output.split()
80        src_files = list(set(committed_changed_files + uncommitted_changed_files))
81
82    generated_files = list_generated_files()
83    # Don't correct style for third-party files (and, for simplicity,
84    # companion files in the same subtree), or for automatically
85    # generated files (we're correcting the templates instead).
86    src_files = [filename for filename in src_files
87                 if not (filename.startswith("3rdparty/") or
88                         filename in generated_files)]
89    return src_files
90
91def get_uncrustify_version() -> str:
92    """
93    Get the version string from Uncrustify
94    """
95    result = subprocess.run([UNCRUSTIFY_EXE, "--version"],
96                            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
97                            check=False)
98    if result.returncode != 0:
99        print_err("Could not get Uncrustify version:", str(result.stderr, "utf-8"))
100        return ""
101    else:
102        return str(result.stdout, "utf-8")
103
104def check_style_is_correct(src_file_list: List[str]) -> bool:
105    """
106    Check the code style and output a diff for each file whose style is
107    incorrect.
108    """
109    style_correct = True
110    for src_file in src_file_list:
111        uncrustify_cmd = [UNCRUSTIFY_EXE] + UNCRUSTIFY_ARGS + [src_file]
112        result = subprocess.run(uncrustify_cmd, stdout=subprocess.PIPE,
113                                stderr=subprocess.PIPE, check=False)
114        if result.returncode != 0:
115            print_err("Uncrustify returned " + str(result.returncode) +
116                      " correcting file " + src_file)
117            return False
118
119        # Uncrustify makes changes to the code and places the result in a new
120        # file with the extension ".uncrustify". To get the changes (if any)
121        # simply diff the 2 files.
122        diff_cmd = ["diff", "-u", src_file, src_file + ".uncrustify"]
123        cp = subprocess.run(diff_cmd, check=False)
124
125        if cp.returncode == 1:
126            print(src_file + " changed - code style is incorrect.")
127            style_correct = False
128        elif cp.returncode != 0:
129            raise subprocess.CalledProcessError(cp.returncode, cp.args,
130                                                cp.stdout, cp.stderr)
131
132        # Tidy up artifact
133        os.remove(src_file + ".uncrustify")
134
135    return style_correct
136
137def fix_style_single_pass(src_file_list: List[str]) -> bool:
138    """
139    Run Uncrustify once over the source files.
140    """
141    code_change_args = UNCRUSTIFY_ARGS + ["--no-backup"]
142    for src_file in src_file_list:
143        uncrustify_cmd = [UNCRUSTIFY_EXE] + code_change_args + [src_file]
144        result = subprocess.run(uncrustify_cmd, check=False)
145        if result.returncode != 0:
146            print_err("Uncrustify with file returned: " +
147                      str(result.returncode) + " correcting file " +
148                      src_file)
149            return False
150    return True
151
152def fix_style(src_file_list: List[str]) -> int:
153    """
154    Fix the code style. This takes 2 passes of Uncrustify.
155    """
156    if not fix_style_single_pass(src_file_list):
157        return 1
158    if not fix_style_single_pass(src_file_list):
159        return 1
160
161    # Guard against future changes that cause the codebase to require
162    # more passes.
163    if not check_style_is_correct(src_file_list):
164        print_err("Code style still incorrect after second run of Uncrustify.")
165        return 1
166    else:
167        return 0
168
169def main() -> int:
170    """
171    Main with command line arguments.
172    """
173    uncrustify_version = get_uncrustify_version().strip()
174    if UNCRUSTIFY_SUPPORTED_VERSION not in uncrustify_version:
175        print("Warning: Using unsupported Uncrustify version '" +
176              uncrustify_version + "'")
177        print("Note: The only supported version is " +
178              UNCRUSTIFY_SUPPORTED_VERSION)
179
180    parser = argparse.ArgumentParser()
181    parser.add_argument('-f', '--fix', action='store_true',
182                        help=('modify source files to fix the code style '
183                              '(default: print diff, do not modify files)'))
184    parser.add_argument('-s', '--since', metavar='COMMIT', const='development', nargs='?',
185                        help=('only check files modified since the specified commit'
186                              ' (e.g. --since=HEAD~3 or --since=development). If no'
187                              ' commit is specified, default to development.'))
188    # --subset is almost useless: it only matters if there are no files
189    # ('code_style.py' without arguments checks all files known to Git,
190    # 'code_style.py --subset' does nothing). In particular,
191    # 'code_style.py --fix --subset ...' is intended as a stable ("porcelain")
192    # way to restyle a possibly empty set of files.
193    parser.add_argument('--subset', action='store_true',
194                        help='only check the specified files (default with non-option arguments)')
195    parser.add_argument('operands', nargs='*', metavar='FILE',
196                        help='files to check (files MUST be known to git, if none: check all)')
197
198    args = parser.parse_args()
199
200    covered = frozenset(get_src_files(args.since))
201    # We only check files that are known to git
202    if args.subset or args.operands:
203        src_files = [f for f in args.operands if f in covered]
204        skip_src_files = [f for f in args.operands if f not in covered]
205        if skip_src_files:
206            print_skip(skip_src_files)
207    else:
208        src_files = list(covered)
209
210    if args.fix:
211        # Fix mode
212        return fix_style(src_files)
213    else:
214        # Check mode
215        if check_style_is_correct(src_files):
216            print("Checked {} files, style ok.".format(len(src_files)))
217            return 0
218        else:
219            return 1
220
221if __name__ == '__main__':
222    sys.exit(main())
223