xref: /llvm-project/llvm/utils/git/code-format-helper.py (revision bd3e8eb6e325081bf7cfbe93652aa825de3170e5)
1#!/usr/bin/env python3
2#
3# ====- code-format-helper, runs code formatters from the ci or in a hook --*- python -*--==#
4#
5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
6# See https://llvm.org/LICENSE.txt for license information.
7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
8#
9# ==--------------------------------------------------------------------------------------==#
10
11import argparse
12import os
13import subprocess
14import sys
15from typing import List, Optional
16
17"""
18This script is run by GitHub actions to ensure that the code in PR's conform to
19the coding style of LLVM. It can also be installed as a pre-commit git hook to
20check the coding style before submitting it. The canonical source of this script
21is in the LLVM source tree under llvm/utils/git.
22
23For C/C++ code it uses clang-format and for Python code it uses darker (which
24in turn invokes black).
25
26You can learn more about the LLVM coding style on llvm.org:
27https://llvm.org/docs/CodingStandards.html
28
29You can install this script as a git hook by symlinking it to the .git/hooks
30directory:
31
32ln -s $(pwd)/llvm/utils/git/code-format-helper.py .git/hooks/pre-commit
33
34You can control the exact path to clang-format or darker with the following
35environment variables: $CLANG_FORMAT_PATH and $DARKER_FORMAT_PATH.
36"""
37
38
39class FormatArgs:
40    start_rev: str = None
41    end_rev: str = None
42    repo: str = None
43    changed_files: List[str] = []
44    token: str = None
45    verbose: bool = True
46
47    def __init__(self, args: argparse.Namespace = None) -> None:
48        if not args is None:
49            self.start_rev = args.start_rev
50            self.end_rev = args.end_rev
51            self.repo = args.repo
52            self.token = args.token
53            self.changed_files = args.changed_files
54
55
56class FormatHelper:
57    COMMENT_TAG = "<!--LLVM CODE FORMAT COMMENT: {fmt}-->"
58    name: str
59    friendly_name: str
60
61    @property
62    def comment_tag(self) -> str:
63        return self.COMMENT_TAG.replace("fmt", self.name)
64
65    @property
66    def instructions(self) -> str:
67        raise NotImplementedError()
68
69    def has_tool(self) -> bool:
70        raise NotImplementedError()
71
72    def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:
73        raise NotImplementedError()
74
75    def pr_comment_text_for_diff(self, diff: str) -> str:
76        return f"""
77:warning: {self.friendly_name}, {self.name} found issues in your code. :warning:
78
79<details>
80<summary>
81You can test this locally with the following command:
82</summary>
83
84``````````bash
85{self.instructions}
86``````````
87
88</details>
89
90<details>
91<summary>
92View the diff from {self.name} here.
93</summary>
94
95``````````diff
96{diff}
97``````````
98
99</details>
100"""
101
102    # TODO: any type should be replaced with the correct github type, but it requires refactoring to
103    # not require the github module to be installed everywhere.
104    def find_comment(self, pr: any) -> any:
105        for comment in pr.as_issue().get_comments():
106            if self.comment_tag in comment.body:
107                return comment
108        return None
109
110    def update_pr(self, comment_text: str, args: FormatArgs, create_new: bool) -> None:
111        import github
112        from github import IssueComment, PullRequest
113
114        repo = github.Github(args.token).get_repo(args.repo)
115        pr = repo.get_issue(args.issue_number).as_pull_request()
116
117        comment_text = self.comment_tag + "\n\n" + comment_text
118
119        existing_comment = self.find_comment(pr)
120        if existing_comment:
121            existing_comment.edit(comment_text)
122        elif create_new:
123            pr.as_issue().create_comment(comment_text)
124
125    def run(self, changed_files: List[str], args: FormatArgs) -> bool:
126        diff = self.format_run(changed_files, args)
127        should_update_gh = args.token is not None and args.repo is not None
128
129        if diff is None:
130            if should_update_gh:
131                comment_text = f"""
132    :white_check_mark: With the latest revision this PR passed the {self.friendly_name}.
133    """
134                self.update_pr(comment_text, args, create_new=False)
135            return True
136        elif len(diff) > 0:
137            if should_update_gh:
138                comment_text = self.pr_comment_text_for_diff(diff)
139                self.update_pr(comment_text, args, create_new=True)
140            else:
141                print(
142                    f"Warning: {self.friendly_name}, {self.name} detected some issues with your code formatting..."
143                )
144            return False
145        else:
146            # The formatter failed but didn't output a diff (e.g. some sort of
147            # infrastructure failure).
148            comment_text = f"""
149:warning: The {self.friendly_name} failed without printing a diff. Check the logs for stderr output. :warning:
150"""
151            self.update_pr(comment_text, args, create_new=False)
152            return False
153
154
155class ClangFormatHelper(FormatHelper):
156    name = "clang-format"
157    friendly_name = "C/C++ code formatter"
158
159    @property
160    def instructions(self) -> str:
161        return " ".join(self.cf_cmd)
162
163    def should_include_extensionless_file(self, path: str) -> bool:
164        return path.startswith("libcxx/include")
165
166    def filter_changed_files(self, changed_files: List[str]) -> List[str]:
167        filtered_files = []
168        for path in changed_files:
169            _, ext = os.path.splitext(path)
170            if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx", ".inc", ".cppm"):
171                filtered_files.append(path)
172            elif ext == "" and self.should_include_extensionless_file(path):
173                filtered_files.append(path)
174        return filtered_files
175
176    @property
177    def clang_fmt_path(self) -> str:
178        if "CLANG_FORMAT_PATH" in os.environ:
179            return os.environ["CLANG_FORMAT_PATH"]
180        return "git-clang-format"
181
182    def has_tool(self) -> bool:
183        cmd = [self.clang_fmt_path, "-h"]
184        proc = None
185        try:
186            proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
187        except:
188            return False
189        return proc.returncode == 0
190
191    def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:
192        cpp_files = self.filter_changed_files(changed_files)
193        if not cpp_files:
194            return None
195
196        cf_cmd = [self.clang_fmt_path, "--diff"]
197
198        if args.start_rev and args.end_rev:
199            cf_cmd.append(args.start_rev)
200            cf_cmd.append(args.end_rev)
201
202        cf_cmd.append("--")
203        cf_cmd += cpp_files
204
205        if args.verbose:
206            print(f"Running: {' '.join(cf_cmd)}")
207        self.cf_cmd = cf_cmd
208        proc = subprocess.run(cf_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
209        sys.stdout.write(proc.stderr.decode("utf-8"))
210
211        if proc.returncode != 0:
212            # formatting needed, or the command otherwise failed
213            if args.verbose:
214                print(f"error: {self.name} exited with code {proc.returncode}")
215                # Print the diff in the log so that it is viewable there
216                print(proc.stdout.decode("utf-8"))
217            return proc.stdout.decode("utf-8")
218        else:
219            return None
220
221
222class DarkerFormatHelper(FormatHelper):
223    name = "darker"
224    friendly_name = "Python code formatter"
225
226    @property
227    def instructions(self) -> str:
228        return " ".join(self.darker_cmd)
229
230    def filter_changed_files(self, changed_files: List[str]) -> List[str]:
231        filtered_files = []
232        for path in changed_files:
233            name, ext = os.path.splitext(path)
234            if ext == ".py":
235                filtered_files.append(path)
236
237        return filtered_files
238
239    @property
240    def darker_fmt_path(self) -> str:
241        if "DARKER_FORMAT_PATH" in os.environ:
242            return os.environ["DARKER_FORMAT_PATH"]
243        return "darker"
244
245    def has_tool(self) -> bool:
246        cmd = [self.darker_fmt_path, "--version"]
247        proc = None
248        try:
249            proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
250        except:
251            return False
252        return proc.returncode == 0
253
254    def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:
255        py_files = self.filter_changed_files(changed_files)
256        if not py_files:
257            return None
258        darker_cmd = [
259            self.darker_fmt_path,
260            "--check",
261            "--diff",
262        ]
263        if args.start_rev and args.end_rev:
264            darker_cmd += ["-r", f"{args.start_rev}...{args.end_rev}"]
265        darker_cmd += py_files
266        if args.verbose:
267            print(f"Running: {' '.join(darker_cmd)}")
268        self.darker_cmd = darker_cmd
269        proc = subprocess.run(
270            darker_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
271        )
272        if args.verbose:
273            sys.stdout.write(proc.stderr.decode("utf-8"))
274
275        if proc.returncode != 0:
276            # formatting needed, or the command otherwise failed
277            if args.verbose:
278                print(f"error: {self.name} exited with code {proc.returncode}")
279                # Print the diff in the log so that it is viewable there
280                print(proc.stdout.decode("utf-8"))
281            return proc.stdout.decode("utf-8")
282        else:
283            sys.stdout.write(proc.stdout.decode("utf-8"))
284            return None
285
286
287ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper())
288
289
290def hook_main():
291    # fill out args
292    args = FormatArgs()
293    args.verbose = False
294
295    # find the changed files
296    cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=d"]
297    proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
298    output = proc.stdout.decode("utf-8")
299    for line in output.splitlines():
300        args.changed_files.append(line)
301
302    failed_fmts = []
303    for fmt in ALL_FORMATTERS:
304        if fmt.has_tool():
305            if not fmt.run(args.changed_files, args):
306                failed_fmts.append(fmt.name)
307        else:
308            print(f"Couldn't find {fmt.name}, can't check " + fmt.friendly_name.lower())
309
310    if len(failed_fmts) > 0:
311        sys.exit(1)
312
313    sys.exit(0)
314
315
316if __name__ == "__main__":
317    script_path = os.path.abspath(__file__)
318    if ".git/hooks" in script_path:
319        hook_main()
320        sys.exit(0)
321
322    parser = argparse.ArgumentParser()
323    parser.add_argument(
324        "--token", type=str, required=True, help="GitHub authentiation token"
325    )
326    parser.add_argument(
327        "--repo",
328        type=str,
329        default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),
330        help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",
331    )
332    parser.add_argument("--issue-number", type=int, required=True)
333    parser.add_argument(
334        "--start-rev",
335        type=str,
336        required=True,
337        help="Compute changes from this revision.",
338    )
339    parser.add_argument(
340        "--end-rev", type=str, required=True, help="Compute changes to this revision"
341    )
342    parser.add_argument(
343        "--changed-files",
344        type=str,
345        help="Comma separated list of files that has been changed",
346    )
347
348    args = FormatArgs(parser.parse_args())
349
350    changed_files = []
351    if args.changed_files:
352        changed_files = args.changed_files.split(",")
353
354    failed_formatters = []
355    for fmt in ALL_FORMATTERS:
356        if not fmt.run(changed_files, args):
357            failed_formatters.append(fmt.name)
358
359    if len(failed_formatters) > 0:
360        print(f"error: some formatters failed: {' '.join(failed_formatters)}")
361        sys.exit(1)
362