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