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