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
54# Check for comment string indicating an auto-generated file
55AUTOGEN_RE = re.compile(r"Warning[ :-]+This file is (now )?auto[ -]?generated",
56                        re.ASCII | re.IGNORECASE)
57def is_file_autogenerated(filename):
58    content = open(filename, encoding="utf-8").read()
59    return AUTOGEN_RE.search(content) is not None
60
61def get_src_files(since: Optional[str]) -> List[str]:
62    """
63    Use git to get a list of the source files.
64
65    The optional argument since is a commit, indicating to only list files
66    that have changed since that commit. Without this argument, list all
67    files known to git.
68
69    Only C files are included, and certain files (generated, or 3rdparty)
70    are excluded.
71    """
72    file_patterns = ["*.[hc]",
73                     "tests/suites/*.function",
74                     "scripts/data_files/*.fmt"]
75    output = subprocess.check_output(["git", "ls-files"] + file_patterns,
76                                     universal_newlines=True)
77    src_files = output.split()
78    if since:
79        # get all files changed in commits since the starting point
80        cmd = ["git", "log", since + "..HEAD", "--name-only", "--pretty=", "--"] + src_files
81        output = subprocess.check_output(cmd, universal_newlines=True)
82        committed_changed_files = output.split()
83        # and also get all files with uncommitted changes
84        cmd = ["git", "diff", "--name-only", "--"] + src_files
85        output = subprocess.check_output(cmd, universal_newlines=True)
86        uncommitted_changed_files = output.split()
87        src_files = list(set(committed_changed_files + uncommitted_changed_files))
88
89    generated_files = list_generated_files()
90    # Don't correct style for third-party files (and, for simplicity,
91    # companion files in the same subtree), or for automatically
92    # generated files (we're correcting the templates instead).
93    src_files = [filename for filename in src_files
94                 if not (filename.startswith("3rdparty/") or
95                         filename in generated_files or
96                         is_file_autogenerated(filename))]
97    return src_files
98
99def get_uncrustify_version() -> str:
100    """
101    Get the version string from Uncrustify
102    """
103    result = subprocess.run([UNCRUSTIFY_EXE, "--version"],
104                            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
105                            check=False)
106    if result.returncode != 0:
107        print_err("Could not get Uncrustify version:", str(result.stderr, "utf-8"))
108        return ""
109    else:
110        return str(result.stdout, "utf-8")
111
112def check_style_is_correct(src_file_list: List[str]) -> bool:
113    """
114    Check the code style and output a diff for each file whose style is
115    incorrect.
116    """
117    style_correct = True
118    for src_file in src_file_list:
119        uncrustify_cmd = [UNCRUSTIFY_EXE] + UNCRUSTIFY_ARGS + [src_file]
120        result = subprocess.run(uncrustify_cmd, stdout=subprocess.PIPE,
121                                stderr=subprocess.PIPE, check=False)
122        if result.returncode != 0:
123            print_err("Uncrustify returned " + str(result.returncode) +
124                      " correcting file " + src_file)
125            return False
126
127        # Uncrustify makes changes to the code and places the result in a new
128        # file with the extension ".uncrustify". To get the changes (if any)
129        # simply diff the 2 files.
130        diff_cmd = ["diff", "-u", src_file, src_file + ".uncrustify"]
131        cp = subprocess.run(diff_cmd, check=False)
132
133        if cp.returncode == 1:
134            print(src_file + " changed - code style is incorrect.")
135            style_correct = False
136        elif cp.returncode != 0:
137            raise subprocess.CalledProcessError(cp.returncode, cp.args,
138                                                cp.stdout, cp.stderr)
139
140        # Tidy up artifact
141        os.remove(src_file + ".uncrustify")
142
143    return style_correct
144
145def fix_style_single_pass(src_file_list: List[str]) -> bool:
146    """
147    Run Uncrustify once over the source files.
148    """
149    code_change_args = UNCRUSTIFY_ARGS + ["--no-backup"]
150    for src_file in src_file_list:
151        uncrustify_cmd = [UNCRUSTIFY_EXE] + code_change_args + [src_file]
152        result = subprocess.run(uncrustify_cmd, check=False)
153        if result.returncode != 0:
154            print_err("Uncrustify with file returned: " +
155                      str(result.returncode) + " correcting file " +
156                      src_file)
157            return False
158    return True
159
160def fix_style(src_file_list: List[str]) -> int:
161    """
162    Fix the code style. This takes 2 passes of Uncrustify.
163    """
164    if not fix_style_single_pass(src_file_list):
165        return 1
166    if not fix_style_single_pass(src_file_list):
167        return 1
168
169    # Guard against future changes that cause the codebase to require
170    # more passes.
171    if not check_style_is_correct(src_file_list):
172        print_err("Code style still incorrect after second run of Uncrustify.")
173        return 1
174    else:
175        return 0
176
177def main() -> int:
178    """
179    Main with command line arguments.
180    """
181    uncrustify_version = get_uncrustify_version().strip()
182    if UNCRUSTIFY_SUPPORTED_VERSION not in uncrustify_version:
183        print("Warning: Using unsupported Uncrustify version '" +
184              uncrustify_version + "'")
185        print("Note: The only supported version is " +
186              UNCRUSTIFY_SUPPORTED_VERSION)
187
188    parser = argparse.ArgumentParser()
189    parser.add_argument('-f', '--fix', action='store_true',
190                        help=('modify source files to fix the code style '
191                              '(default: print diff, do not modify files)'))
192    parser.add_argument('-s', '--since', metavar='COMMIT', const='development', nargs='?',
193                        help=('only check files modified since the specified commit'
194                              ' (e.g. --since=HEAD~3 or --since=development). If no'
195                              ' commit is specified, default to development.'))
196    # --subset is almost useless: it only matters if there are no files
197    # ('code_style.py' without arguments checks all files known to Git,
198    # 'code_style.py --subset' does nothing). In particular,
199    # 'code_style.py --fix --subset ...' is intended as a stable ("porcelain")
200    # way to restyle a possibly empty set of files.
201    parser.add_argument('--subset', action='store_true',
202                        help='only check the specified files (default with non-option arguments)')
203    parser.add_argument('operands', nargs='*', metavar='FILE',
204                        help='files to check (files MUST be known to git, if none: check all)')
205
206    args = parser.parse_args()
207
208    covered = frozenset(get_src_files(args.since))
209    # We only check files that are known to git
210    if args.subset or args.operands:
211        src_files = [f for f in args.operands if f in covered]
212        skip_src_files = [f for f in args.operands if f not in covered]
213        if skip_src_files:
214            print_skip(skip_src_files)
215    else:
216        src_files = list(covered)
217
218    if args.fix:
219        # Fix mode
220        return fix_style(src_files)
221    else:
222        # Check mode
223        if check_style_is_correct(src_files):
224            print("Checked {} files, style ok.".format(len(src_files)))
225            return 0
226        else:
227            return 1
228
229if __name__ == '__main__':
230    sys.exit(main())
231