xref: /llvm-project/llvm/utils/git/code-format-helper.py (revision 428660cfb986dd0a59cd2a16972c5f7109080522)
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        diff = self.format_run(changed_files, args)
129        should_update_gh = args.token is not None and args.repo is not None
130
131        if diff is None:
132            if should_update_gh:
133                comment_text = f"""
134:white_check_mark: With the latest revision this PR passed the {self.friendly_name}.
135"""
136                self.update_pr(comment_text, args, create_new=False)
137            return True
138        elif len(diff) > 0:
139            if should_update_gh:
140                comment_text = self.pr_comment_text_for_diff(diff)
141                self.update_pr(comment_text, args, create_new=True)
142            else:
143                print(
144                    f"Warning: {self.friendly_name}, {self.name} detected some issues with your code formatting..."
145                )
146            return False
147        else:
148            # The formatter failed but didn't output a diff (e.g. some sort of
149            # infrastructure failure).
150            comment_text = f"""
151:warning: The {self.friendly_name} failed without printing a diff. Check the logs for stderr output. :warning:
152"""
153            self.update_pr(comment_text, args, create_new=False)
154            return False
155
156
157class ClangFormatHelper(FormatHelper):
158    name = "clang-format"
159    friendly_name = "C/C++ code formatter"
160
161    @property
162    def instructions(self) -> str:
163        return " ".join(self.cf_cmd)
164
165    def should_include_extensionless_file(self, path: str) -> bool:
166        return path.startswith("libcxx/include")
167
168    def filter_changed_files(self, changed_files: List[str]) -> List[str]:
169        filtered_files = []
170        for path in changed_files:
171            _, ext = os.path.splitext(path)
172            if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx", ".inc", ".cppm"):
173                filtered_files.append(path)
174            elif ext == "" and self.should_include_extensionless_file(path):
175                filtered_files.append(path)
176        return filtered_files
177
178    @property
179    def clang_fmt_path(self) -> str:
180        if "CLANG_FORMAT_PATH" in os.environ:
181            return os.environ["CLANG_FORMAT_PATH"]
182        return "git-clang-format"
183
184    def has_tool(self) -> bool:
185        cmd = [self.clang_fmt_path, "-h"]
186        proc = None
187        try:
188            proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
189        except:
190            return False
191        return proc.returncode == 0
192
193    def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:
194        cpp_files = self.filter_changed_files(changed_files)
195        if not cpp_files:
196            return None
197
198        cf_cmd = [self.clang_fmt_path, "--diff"]
199
200        if args.start_rev and args.end_rev:
201            cf_cmd.append(args.start_rev)
202            cf_cmd.append(args.end_rev)
203
204        cf_cmd.append("--")
205        cf_cmd += cpp_files
206
207        if args.verbose:
208            print(f"Running: {' '.join(cf_cmd)}")
209        self.cf_cmd = cf_cmd
210        proc = subprocess.run(cf_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
211        sys.stdout.write(proc.stderr.decode("utf-8"))
212
213        if proc.returncode != 0:
214            # formatting needed, or the command otherwise failed
215            if args.verbose:
216                print(f"error: {self.name} exited with code {proc.returncode}")
217                # Print the diff in the log so that it is viewable there
218                print(proc.stdout.decode("utf-8"))
219            return proc.stdout.decode("utf-8")
220        else:
221            return None
222
223
224class DarkerFormatHelper(FormatHelper):
225    name = "darker"
226    friendly_name = "Python code formatter"
227
228    @property
229    def instructions(self) -> str:
230        return " ".join(self.darker_cmd)
231
232    def filter_changed_files(self, changed_files: List[str]) -> List[str]:
233        filtered_files = []
234        for path in changed_files:
235            name, ext = os.path.splitext(path)
236            if ext == ".py":
237                filtered_files.append(path)
238
239        return filtered_files
240
241    @property
242    def darker_fmt_path(self) -> str:
243        if "DARKER_FORMAT_PATH" in os.environ:
244            return os.environ["DARKER_FORMAT_PATH"]
245        return "darker"
246
247    def has_tool(self) -> bool:
248        cmd = [self.darker_fmt_path, "--version"]
249        proc = None
250        try:
251            proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
252        except:
253            return False
254        return proc.returncode == 0
255
256    def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:
257        py_files = self.filter_changed_files(changed_files)
258        if not py_files:
259            return None
260        darker_cmd = [
261            self.darker_fmt_path,
262            "--check",
263            "--diff",
264        ]
265        if args.start_rev and args.end_rev:
266            darker_cmd += ["-r", f"{args.start_rev}...{args.end_rev}"]
267        darker_cmd += py_files
268        if args.verbose:
269            print(f"Running: {' '.join(darker_cmd)}")
270        self.darker_cmd = darker_cmd
271        proc = subprocess.run(
272            darker_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
273        )
274        if args.verbose:
275            sys.stdout.write(proc.stderr.decode("utf-8"))
276
277        if proc.returncode != 0:
278            # formatting needed, or the command otherwise failed
279            if args.verbose:
280                print(f"error: {self.name} exited with code {proc.returncode}")
281                # Print the diff in the log so that it is viewable there
282                print(proc.stdout.decode("utf-8"))
283            return proc.stdout.decode("utf-8")
284        else:
285            sys.stdout.write(proc.stdout.decode("utf-8"))
286            return None
287
288
289ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper())
290
291
292def hook_main():
293    # fill out args
294    args = FormatArgs()
295    args.verbose = False
296
297    # find the changed files
298    cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=d"]
299    proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
300    output = proc.stdout.decode("utf-8")
301    for line in output.splitlines():
302        args.changed_files.append(line)
303
304    failed_fmts = []
305    for fmt in ALL_FORMATTERS:
306        if fmt.has_tool():
307            if not fmt.run(args.changed_files, args):
308                failed_fmts.append(fmt.name)
309        else:
310            print(f"Couldn't find {fmt.name}, can't check " + fmt.friendly_name.lower())
311
312    if len(failed_fmts) > 0:
313        sys.exit(1)
314
315    sys.exit(0)
316
317
318if __name__ == "__main__":
319    script_path = os.path.abspath(__file__)
320    if ".git/hooks" in script_path:
321        hook_main()
322        sys.exit(0)
323
324    parser = argparse.ArgumentParser()
325    parser.add_argument(
326        "--token", type=str, required=True, help="GitHub authentiation token"
327    )
328    parser.add_argument(
329        "--repo",
330        type=str,
331        default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),
332        help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",
333    )
334    parser.add_argument("--issue-number", type=int, required=True)
335    parser.add_argument(
336        "--start-rev",
337        type=str,
338        required=True,
339        help="Compute changes from this revision.",
340    )
341    parser.add_argument(
342        "--end-rev", type=str, required=True, help="Compute changes to this revision"
343    )
344    parser.add_argument(
345        "--changed-files",
346        type=str,
347        help="Comma separated list of files that has been changed",
348    )
349
350    args = FormatArgs(parser.parse_args())
351
352    changed_files = []
353    if args.changed_files:
354        changed_files = args.changed_files.split(",")
355
356    failed_formatters = []
357    for fmt in ALL_FORMATTERS:
358        if not fmt.run(changed_files, args):
359            failed_formatters.append(fmt.name)
360
361    if len(failed_formatters) > 0:
362        print(f"error: some formatters failed: {' '.join(failed_formatters)}")
363        sys.exit(1)
364