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