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
79    # When this script is called from a git hook, some environment variables
80    # are set by default which force all git commands to use the main repository
81    # (i.e. prevent us from performing commands on the framework repo).
82    # Create an environment without these variables for running commands on the
83    # framework repo.
84    framework_env = os.environ.copy()
85    # Get a list of environment vars that git sets
86    git_env_vars = subprocess.check_output(["git", "rev-parse", "--local-env-vars"],
87                                           universal_newlines=True)
88    # Remove the vars from the environment
89    for var in git_env_vars.split():
90        framework_env.pop(var, None)
91
92    output = subprocess.check_output(["git", "-C", "framework", "ls-files"]
93                                     + file_patterns,
94                                     universal_newlines=True,
95                                     env=framework_env)
96    framework_src_files = output.split()
97
98    if since:
99        # get all files changed in commits since the starting point in ...
100        # ... the main repository
101        cmd = ["git", "log", since + "..HEAD", "--ignore-submodules",
102               "--name-only", "--pretty=", "--"] + src_files
103        output = subprocess.check_output(cmd, universal_newlines=True)
104        committed_changed_files = output.split()
105        # ... the framework submodule
106        cmd = ["git", "-C", "framework", "log", since + "..HEAD",
107               "--name-only", "--pretty=", "--"] + framework_src_files
108        output = subprocess.check_output(cmd, universal_newlines=True,
109                                         env=framework_env)
110        committed_changed_files += ["framework/" + s for s in output.split()]
111
112        # and also get all files with uncommitted changes in ...
113        # ... the main repository
114        cmd = ["git", "diff", "--name-only", "--"] + src_files
115        output = subprocess.check_output(cmd, universal_newlines=True)
116        uncommitted_changed_files = output.split()
117        # ... the framework submodule
118        cmd = ["git", "-C", "framework", "diff", "--name-only", "--"] + \
119              framework_src_files
120        output = subprocess.check_output(cmd, universal_newlines=True,
121                                         env=framework_env)
122        uncommitted_changed_files += ["framework/" + s for s in output.split()]
123
124        src_files = committed_changed_files + uncommitted_changed_files
125    else:
126        src_files += ["framework/" + s for s in framework_src_files]
127
128    generated_files = list_generated_files()
129    # Don't correct style for third-party files (and, for simplicity,
130    # companion files in the same subtree), or for automatically
131    # generated files (we're correcting the templates instead).
132    src_files = [filename for filename in src_files
133                 if not (filename.startswith("3rdparty/") or
134                         filename in generated_files or
135                         is_file_autogenerated(filename))]
136    return src_files
137
138def get_uncrustify_version() -> str:
139    """
140    Get the version string from Uncrustify
141    """
142    result = subprocess.run([UNCRUSTIFY_EXE, "--version"],
143                            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
144                            check=False)
145    if result.returncode != 0:
146        print_err("Could not get Uncrustify version:", str(result.stderr, "utf-8"))
147        return ""
148    else:
149        return str(result.stdout, "utf-8")
150
151def check_style_is_correct(src_file_list: List[str]) -> bool:
152    """
153    Check the code style and output a diff for each file whose style is
154    incorrect.
155    """
156    style_correct = True
157    for src_file in src_file_list:
158        uncrustify_cmd = [UNCRUSTIFY_EXE] + UNCRUSTIFY_ARGS + [src_file]
159        result = subprocess.run(uncrustify_cmd, stdout=subprocess.PIPE,
160                                stderr=subprocess.PIPE, check=False)
161        if result.returncode != 0:
162            print_err("Uncrustify returned " + str(result.returncode) +
163                      " correcting file " + src_file)
164            return False
165
166        # Uncrustify makes changes to the code and places the result in a new
167        # file with the extension ".uncrustify". To get the changes (if any)
168        # simply diff the 2 files.
169        diff_cmd = ["diff", "-u", src_file, src_file + ".uncrustify"]
170        cp = subprocess.run(diff_cmd, check=False)
171
172        if cp.returncode == 1:
173            print(src_file + " changed - code style is incorrect.")
174            style_correct = False
175        elif cp.returncode != 0:
176            raise subprocess.CalledProcessError(cp.returncode, cp.args,
177                                                cp.stdout, cp.stderr)
178
179        # Tidy up artifact
180        os.remove(src_file + ".uncrustify")
181
182    return style_correct
183
184def fix_style_single_pass(src_file_list: List[str]) -> bool:
185    """
186    Run Uncrustify once over the source files.
187    """
188    code_change_args = UNCRUSTIFY_ARGS + ["--no-backup"]
189    for src_file in src_file_list:
190        uncrustify_cmd = [UNCRUSTIFY_EXE] + code_change_args + [src_file]
191        result = subprocess.run(uncrustify_cmd, check=False)
192        if result.returncode != 0:
193            print_err("Uncrustify with file returned: " +
194                      str(result.returncode) + " correcting file " +
195                      src_file)
196            return False
197    return True
198
199def fix_style(src_file_list: List[str]) -> int:
200    """
201    Fix the code style. This takes 2 passes of Uncrustify.
202    """
203    if not fix_style_single_pass(src_file_list):
204        return 1
205    if not fix_style_single_pass(src_file_list):
206        return 1
207
208    # Guard against future changes that cause the codebase to require
209    # more passes.
210    if not check_style_is_correct(src_file_list):
211        print_err("Code style still incorrect after second run of Uncrustify.")
212        return 1
213    else:
214        return 0
215
216def main() -> int:
217    """
218    Main with command line arguments.
219    """
220    uncrustify_version = get_uncrustify_version().strip()
221    if UNCRUSTIFY_SUPPORTED_VERSION not in uncrustify_version:
222        print("Warning: Using unsupported Uncrustify version '" +
223              uncrustify_version + "'")
224        print("Note: The only supported version is " +
225              UNCRUSTIFY_SUPPORTED_VERSION)
226
227    parser = argparse.ArgumentParser()
228    parser.add_argument('-f', '--fix', action='store_true',
229                        help=('modify source files to fix the code style '
230                              '(default: print diff, do not modify files)'))
231    parser.add_argument('-s', '--since', metavar='COMMIT', const='development', nargs='?',
232                        help=('only check files modified since the specified commit'
233                              ' (e.g. --since=HEAD~3 or --since=development). If no'
234                              ' commit is specified, default to development.'))
235    # --subset is almost useless: it only matters if there are no files
236    # ('code_style.py' without arguments checks all files known to Git,
237    # 'code_style.py --subset' does nothing). In particular,
238    # 'code_style.py --fix --subset ...' is intended as a stable ("porcelain")
239    # way to restyle a possibly empty set of files.
240    parser.add_argument('--subset', action='store_true',
241                        help='only check the specified files (default with non-option arguments)')
242    parser.add_argument('operands', nargs='*', metavar='FILE',
243                        help='files to check (files MUST be known to git, if none: check all)')
244
245    args = parser.parse_args()
246
247    covered = frozenset(get_src_files(args.since))
248    # We only check files that are known to git
249    if args.subset or args.operands:
250        src_files = [f for f in args.operands if f in covered]
251        skip_src_files = [f for f in args.operands if f not in covered]
252        if skip_src_files:
253            print_skip(skip_src_files)
254    else:
255        src_files = list(covered)
256
257    if args.fix:
258        # Fix mode
259        return fix_style(src_files)
260    else:
261        # Check mode
262        if check_style_is_correct(src_files):
263            print("Checked {} files, style ok.".format(len(src_files)))
264            return 0
265        else:
266            return 1
267
268if __name__ == '__main__':
269    sys.exit(main())
270