|
| 1 | +#!/usr/bin/env python |
| 2 | + |
| 3 | +importargparse |
| 4 | +importos |
| 5 | +importsys |
| 6 | +importpathlib |
| 7 | +importsubprocess |
| 8 | +importcontextlib |
| 9 | + |
| 10 | +fromdataclassesimportdataclass |
| 11 | + |
| 12 | + |
| 13 | +GIT_ROOT=pathlib.Path( |
| 14 | +subprocess.check_output( |
| 15 | + ["git","rev-parse","--show-toplevel"],universal_newlines=True |
| 16 | + ).strip() |
| 17 | +) |
| 18 | + |
| 19 | + |
| 20 | +defclang_format(clang_format,config,files): |
| 21 | +ifnotfiles: |
| 22 | +raiseValueError("Files list cannot be empty") |
| 23 | + |
| 24 | +cmd= [clang_format,"--verbose",f"--style=file:{config.as_posix()}","-i"] |
| 25 | +cmd.extend(files) |
| 26 | + |
| 27 | +subprocess.run(cmd,check=True) |
| 28 | + |
| 29 | + |
| 30 | +defls_files(patterns): |
| 31 | +"""Git-only search, but rather poor at matching complex patterns (at least w/ <=py3.12)""" |
| 32 | +proc=subprocess.run( |
| 33 | + ["git","--no-pager","ls-files"], |
| 34 | +capture_output=True, |
| 35 | +check=True, |
| 36 | +universal_newlines=True, |
| 37 | + ) |
| 38 | + |
| 39 | +out= [] |
| 40 | +forlineinproc.stdout.split("\n"): |
| 41 | +path=pathlib.Path(line.strip()) |
| 42 | +ifany(path.match(pattern)forpatterninpatterns): |
| 43 | +out.append(path) |
| 44 | + |
| 45 | +returnout |
| 46 | + |
| 47 | + |
| 48 | +defdiff_lines(): |
| 49 | +proc=subprocess.run( |
| 50 | + ["git","--no-pager","diff","--ignore-submodules"], |
| 51 | +capture_output=True, |
| 52 | +check=True, |
| 53 | +universal_newlines=True, |
| 54 | + ) |
| 55 | + |
| 56 | +returnproc.stdout.split("\n") |
| 57 | + |
| 58 | + |
| 59 | +deffind_files(patterns): |
| 60 | +"""Filesystem search, matches both git and non-git files""" |
| 61 | +return [ |
| 62 | +file |
| 63 | +forpatterninpatterns |
| 64 | +forfilein [foundforfoundinGIT_ROOT.rglob(pattern)] |
| 65 | + ] |
| 66 | + |
| 67 | + |
| 68 | +deffind_core_files(): |
| 69 | +"""Returns a subset of Core files that should be formatted""" |
| 70 | +return [ |
| 71 | +file |
| 72 | +forfileinfind_files( |
| 73 | + ( |
| 74 | +"cores/esp8266/Lwip*", |
| 75 | +"libraries/ESP8266mDNS/**/*", |
| 76 | +"libraries/Wire/**/*", |
| 77 | +"libraries/lwIP*/**/*", |
| 78 | +"cores/esp8266/debug*", |
| 79 | +"cores/esp8266/core_esp8266_si2c*", |
| 80 | +"cores/esp8266/StreamString*", |
| 81 | +"cores/esp8266/StreamSend*", |
| 82 | +"libraries/Netdump/**/*", |
| 83 | +"tests/**/*", |
| 84 | + ) |
| 85 | + ) |
| 86 | +iffile.is_file() |
| 87 | +andfile.suffixin (".c",".cpp",".h",".hpp") |
| 88 | +andnotGIT_ROOT/"tests/host/bin"infile.parents |
| 89 | +andnotGIT_ROOT/"tests/host/common/catch.hpp"==file |
| 90 | + ] |
| 91 | + |
| 92 | + |
| 93 | +deffind_arduino_files(): |
| 94 | +"""Returns every .ino file available in the repository, excluding submodule ones""" |
| 95 | +return [ |
| 96 | +ino |
| 97 | +forlibraryinfind_files(("libraries/*",)) |
| 98 | +iflibrary.is_dir()andnot (library/".git").exists() |
| 99 | +forinoinlibrary.rglob("**/*.ino") |
| 100 | + ] |
| 101 | + |
| 102 | + |
| 103 | +FILES_PRESETS= { |
| 104 | +"core":find_core_files, |
| 105 | +"arduino":find_arduino_files, |
| 106 | +} |
| 107 | + |
| 108 | + |
| 109 | +@dataclass |
| 110 | +classChanged: |
| 111 | +file:str |
| 112 | +hunk:str |
| 113 | +lines:list[int] |
| 114 | + |
| 115 | + |
| 116 | +classContext: |
| 117 | +def__init__(self): |
| 118 | +self.append_hunk=False |
| 119 | +self.deleted=False |
| 120 | +self.file="" |
| 121 | +self.hunk= [] |
| 122 | +self.markers= [] |
| 123 | + |
| 124 | +defreset(self): |
| 125 | +self.__init__() |
| 126 | + |
| 127 | +defreset_with_line(self,line): |
| 128 | +self.reset() |
| 129 | +self.hunk.append(line) |
| 130 | + |
| 131 | +defpop(self,out,line): |
| 132 | +ifself.fileandself.hunkandself.markers: |
| 133 | +out.append( |
| 134 | +Changed(file=self.file,hunk="\n".join(self.hunk),lines=self.markers) |
| 135 | + ) |
| 136 | + |
| 137 | +self.reset_with_line(line) |
| 138 | + |
| 139 | + |
| 140 | +defchanged_files_for_diff(lines:list[str]|str)->list[Changed]: |
| 141 | +""" |
| 142 | + Naive git-diff output parser. Generates list of objects for every file changed after clang-format. |
| 143 | + """ |
| 144 | +matchlines: |
| 145 | +casestr(): |
| 146 | +lines=lines.split("\n") |
| 147 | +caselist(): |
| 148 | +pass |
| 149 | +case _: |
| 150 | +raiseValueError("Unknown 'lines' type, can be either list[str] or str") |
| 151 | + |
| 152 | +ctx=Context() |
| 153 | +out= [] |
| 154 | + |
| 155 | +# TODO: pygit2? |
| 156 | +# ref. https://github.com/cpp-linter/cpp-linter/blob/main/cpp_linter/git/__init__.py ::parse_diff |
| 157 | +# ref. https://github.com/libgit2/pygit2/blob/master/src/diff.c ::parse_diff |
| 158 | +forlineinlines: |
| 159 | +# '--- a/path/to/changed/file' most likely |
| 160 | +# '--- /dev/null' aka created file. should be ignored, same as removed ones |
| 161 | +ifline.startswith("---"): |
| 162 | +ctx.pop(out,line) |
| 163 | + |
| 164 | +_,file=line.split(" ") |
| 165 | +ctx.deleted="/dev/null"infile |
| 166 | + |
| 167 | +# '+++ b/path/to/changed/file' most likely |
| 168 | +# '+++ /dev/null' aka removed file |
| 169 | +elifnotctx.deletedandline.startswith("+++"): |
| 170 | +ctx.hunk.append(line) |
| 171 | + |
| 172 | +_,file=line.split(" ") |
| 173 | +ctx.deleted="/dev/null"infile |
| 174 | +ifnotctx.deleted: |
| 175 | +ctx.file=file[2:] |
| 176 | + |
| 177 | +# @@ from-file-line-numbers to-file-line-numbers @@ |
| 178 | +elifnotctx.deletedandline.startswith("@@"): |
| 179 | +ctx.hunk.append(line) |
| 180 | + |
| 181 | +_,_,numbers,_=line.split(" ",3) |
| 182 | +if","innumbers: |
| 183 | +numbers,_=numbers.split(",")# drop count |
| 184 | + |
| 185 | +numbers=numbers.replace("+","") |
| 186 | +numbers=numbers.replace("-","") |
| 187 | + |
| 188 | +ctx.markers.append(int(numbers)) |
| 189 | +ctx.append_hunk=True |
| 190 | + |
| 191 | +# capture diff for the summary |
| 192 | +elifctx.append_hunkandline.startswith(("+","-"," ")): |
| 193 | +ctx.hunk.append(line) |
| 194 | + |
| 195 | +ctx.pop(out,line) |
| 196 | + |
| 197 | +returnout |
| 198 | + |
| 199 | + |
| 200 | +defchanged_files()->list[Changed]: |
| 201 | +returnchanged_files_for_diff(diff_lines()) |
| 202 | + |
| 203 | + |
| 204 | +deferrors_changed(changed:Changed): |
| 205 | +all_lines=", ".join(str(x)forxinchanged.lines) |
| 206 | +forlineinchanged.lines: |
| 207 | +print( |
| 208 | +f"::error file={changed.file},title=Run tests/restyle.sh and re-commit{changed.file},line={line}::File{changed.file} failed clang-format style check. (lines{all_lines})" |
| 209 | + ) |
| 210 | + |
| 211 | + |
| 212 | +SUMMARY_PATH=pathlib.Path(os.environ.get("GITHUB_STEP_SUMMARY",os.devnull)) |
| 213 | +SUMMARY_OUTPUT=SUMMARY_PATH.open("a") |
| 214 | + |
| 215 | + |
| 216 | +defsummary_diff(changed:Changed): |
| 217 | +withcontextlib.redirect_stdout(SUMMARY_OUTPUT): |
| 218 | +print(f"#{changed.file} (suggested change)") |
| 219 | +print("```diff") |
| 220 | +print(changed.hunk) |
| 221 | +print("```") |
| 222 | + |
| 223 | + |
| 224 | +defstdout_diff(): |
| 225 | +subprocess.run(["git","--no-pager","diff","--ignore-submodules"]) |
| 226 | + |
| 227 | + |
| 228 | +defassert_unchanged(): |
| 229 | +subprocess.run( |
| 230 | + ["git","diff","--ignore-submodules","--exit-code"], |
| 231 | +check=True, |
| 232 | +stdout=subprocess.DEVNULL, |
| 233 | + ) |
| 234 | + |
| 235 | + |
| 236 | +defrun_format(args): |
| 237 | +targets= [] |
| 238 | + |
| 239 | +forincludeinargs.include: |
| 240 | +targets.append( |
| 241 | + (GIT_ROOT/f"tests/clang-format-{include}.yaml",FILES_PRESETS[include]()) |
| 242 | + ) |
| 243 | + |
| 244 | +ifnottargets: |
| 245 | +targets.append((args.config,args.files)) |
| 246 | + |
| 247 | +fortargetintargets: |
| 248 | +clang_format(args.clang_format,*target) |
| 249 | + |
| 250 | + |
| 251 | +defrun_assert(args): |
| 252 | +forchangedinchanged_files(): |
| 253 | +ifargs.with_errors: |
| 254 | +errors_changed(changed) |
| 255 | +ifargs.with_summary: |
| 256 | +summary_diff(changed) |
| 257 | + |
| 258 | +ifargs.with_diff: |
| 259 | +stdout_diff() |
| 260 | + |
| 261 | +assert_unchanged() |
| 262 | + |
| 263 | + |
| 264 | +if__name__=="__main__": |
| 265 | +parser=argparse.ArgumentParser() |
| 266 | + |
| 267 | +cmd=parser.add_subparsers(required=True) |
| 268 | +format_=cmd.add_parser("format") |
| 269 | +format_.set_defaults(func=run_format) |
| 270 | +format_.add_argument("--clang-format",default="clang-format") |
| 271 | + |
| 272 | +fmt=format_.add_subparsers(required=True) |
| 273 | + |
| 274 | +preset=fmt.add_parser("preset") |
| 275 | +preset.add_argument( |
| 276 | +"--include",action="append",required=True,choices=tuple(FILES_PRESETS.keys()) |
| 277 | + ) |
| 278 | + |
| 279 | +files=fmt.add_parser("files") |
| 280 | +files.add_argument("--config",type=pathlib.Path,required=True) |
| 281 | +files.add_argument("files",type=pathlib.Path,nargs="+") |
| 282 | + |
| 283 | +assert_=cmd.add_parser("assert") |
| 284 | +assert_.set_defaults(func=run_assert) |
| 285 | +assert_.add_argument("--with-diff",action="store_true") |
| 286 | +assert_.add_argument("--with-errors",action="store_true") |
| 287 | +assert_.add_argument("--with-summary",action="store_true") |
| 288 | + |
| 289 | +args=parser.parse_args() |
| 290 | +args.func(args) |