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