1#!/usr/bin/env python3 2""" 3# Created: Thu Aug 17 14:06:52 2023 (-0500) 4# @author: Jacob Faibussowitsch 5""" 6from __future__ import annotations 7 8import re 9import os 10import sys 11import abc 12import difflib 13import pathlib 14import argparse 15 16from typing import TypeVar, Union 17from collections.abc import Iterable, Sequence 18 19__version__ = (1, 0, 0) 20__version_str__ = '.'.join(map(str, __version__)) 21 22class Replacer(abc.ABC): 23 __slots__ = 'verbose', 'added', 'path' 24 25 verbose: bool 26 added: bool 27 path: pathlib.Path 28 29 def __init__(self, verbose: bool, path: pathlib.Path) -> None: 30 self.verbose = verbose 31 self.added = False 32 self.path = path 33 return 34 35 def _strip_empty_lines(self, idx: int, ret: list[str]) -> list[str]: 36 r"""Strip empty lines from a list of lines at index `idx` 37 38 Parameters 39 ---------- 40 idx : 41 the index to remove at 42 ret : 43 the list of lines 44 45 Returns 46 ------- 47 ret : 48 the lines with empty lines removed at `idx` 49 """ 50 while ret and not ret[idx].strip(): 51 entry = ret.pop(idx) 52 if self.verbose: 53 print(f'- {entry}') 54 return ret 55 56 @abc.abstractmethod 57 def prologue(self, ret: Sequence[str]) -> list[str]: 58 r"""Common prologue for replacement, strips any leading blank lines 59 60 Parameters 61 ---------- 62 ret : 63 the list of lines for the file 64 65 Returns 66 ------- 67 ret : 68 the list of lines with leading blank spaces removed 69 """ 70 return self._strip_empty_lines(0, ret) 71 72 @abc.abstractmethod 73 def replace(self, last_line: str, line: str, ret: list[str]) -> list[str]: 74 return ret 75 76 @abc.abstractmethod 77 def epilogue(self, last_endif: int, ret: list[str]) -> list[str]: 78 r"""Common epilogue for replacements, strips any trailing blank lines from the header 79 80 Parameters 81 ---------- 82 last_endif : 83 unused 84 ret : 85 the list of lines 86 87 Returns 88 ------- 89 ret : 90 the lines with trailing blank lines pruned 91 """ 92 return self._strip_empty_lines(-1, ret) 93 94class PragmaOnce(Replacer): 95 def prologue(self, ret: list[str]) -> list[str]: 96 return super().prologue(ret) 97 98 def replace(self, prev_line: str, line: str, ret: list[str]) -> list[str]: 99 r"""Replace the selected header-guard line with #pragma once 100 101 Parameters 102 ---------- 103 prev_line : 104 the previous line 105 line : 106 the current line 107 ret : 108 the list previously seen lines to append to 109 110 Returns 111 ------- 112 ret : 113 the list of lines with the new header guard inserted 114 115 Notes 116 ----- 117 This routine is idempotent, i.e. does nothing if it already added it 118 """ 119 ret = super().replace(prev_line, line, ret) 120 if self.added: 121 return ret 122 123 pragma_once = '#pragma once' 124 if line.startswith(pragma_once): 125 # nothing to do, just add the pragma once line back in 126 ret.append(line) 127 return ret 128 129 assert prev_line.startswith('#ifndef') 130 # header-guard to pragma once conversion 131 if self.verbose: 132 print(f'{self.path}:') 133 print(f'- {prev_line.lstrip()}') 134 print(f'- {line.lstrip()}') 135 print(f'+ {pragma_once}') 136 137 ret[-1] = pragma_once 138 self.added = True 139 return ret 140 141 def epilogue(self, last_endif: int, ret: list[str]) -> list[str]: 142 r"""Final function to call after performing replacement 143 144 Parameters 145 ---------- 146 last_endif : 147 the index into `ret` containing the last `#endif` line 148 ret : 149 the list of lines for the file 150 151 Returns 152 ------- 153 ret : 154 If the header guard was replaced with #pragma once, `ret` with the final `#endif` removed, 155 otherwise ret unchanged 156 """ 157 if self.added: 158 endif_line = ret.pop(last_endif - 1) 159 if self.verbose: 160 print(f'- {endif_line}') 161 # # prune empty lines as a result of deleting the header guard 162 # while not ret[-1].strip(): 163 # end = ret.pop() 164 # if self.verbose: 165 # print(f'- {end}') 166 ret = super().epilogue(last_endif, ret) 167 return ret 168 169class VerboseHeaderGuard(Replacer): 170 __slots__ = 'new_ifndef', 'new_guard', 'new_endif', 'append_endif' 171 172 new_ifndef: str 173 new_guard: str 174 new_endif: str 175 append_endif: bool 176 177 def __init__(self, *args, **kwargs) -> None: 178 r"""Construct a `VerboseHeaderGuard` 179 180 Parameters 181 ---------- 182 *args : 183 positional arguments to forward to `Replacer` constructor 184 **kwargs : 185 keyword arguments to forward to `Replacer` constructor 186 """ 187 super().__init__(*args, **kwargs) 188 str_path = str(self.path).casefold() 189 guard_str = str_path[max(str_path.find('petsc'), 0):] 190 guard_str = ''.join('_' if c in {'/', '.', '-', ' '} else c for c in guard_str) 191 self.new_ifndef = f'#ifndef {guard_str}' 192 self.new_guard = f'#define {guard_str}' 193 self.new_endif = f'#endif // {guard_str}' 194 self.append_endif = False 195 return 196 197 def prologue(self, ret: list[str]) -> list[str]: 198 return super().prologue(ret) 199 200 def replace(self, prev_line: str, line: str, ret: list[str]) -> list[str]: 201 r"""Replace the selected header-guard line with a verbose header guard 202 203 Parameters 204 ---------- 205 prev_line : 206 the previous line 207 line : 208 the current line 209 ret : 210 the list previously seen lines to append to 211 212 Returns 213 ------- 214 ret : 215 the list of lines with the new header guard inserted 216 217 Raises 218 ------ 219 ValueError 220 if the line to convert is neither a header-gaurd or #pragma once line 221 222 Notes 223 ----- 224 This routine is idempotent, i.e. does nothing if it already added it 225 """ 226 ret = super().replace(prev_line, line, ret) 227 if self.added: 228 return ret 229 230 self.added = True 231 if prev_line == self.new_ifndef and line == self.new_guard: 232 # nothing to do, add the line back in 233 ret.append(line) 234 return ret 235 236 if self.verbose: 237 print(f'{self.path}:') 238 239 if prev_line.startswith('#ifndef'): 240 # header-guard to header-guard conversion 241 if self.verbose: 242 print(f'- {prev_line.lstrip()}') 243 print(f'- {line.lstrip()}') 244 245 ret[-1] = self.new_ifndef 246 ret.append(self.new_guard) 247 elif line.startswith('#pragma once'): 248 # pragma once to header-guard conversion 249 if self.verbose: 250 print(f'- {line.lstrip()}') 251 self.append_endif = True 252 ret.extend([ 253 self.new_ifndef, 254 self.new_guard 255 ]) 256 else: 257 raise ValueError( 258 f'Line to convert must be either a header-guard or #pragma once, found neither: {line}' 259 ) 260 261 if self.verbose: 262 print(f'+ {self.new_ifndef}') 263 print(f'+ {self.new_guard}') 264 return ret 265 266 def epilogue(self, last_endif: int, ret: list[str]) -> list[str]: 267 r"""Final function to call after replacements 268 269 Parameters 270 ---------- 271 last_endif : 272 the index into `ret` containing the last `#endif` line 273 ret : 274 the list of lines for the file 275 276 Returns 277 ------- 278 ret : 279 `ret` either with an append `#endif` (if converting from `#pragma once` to header-guard) or 280 unchanged 281 """ 282 if self.append_endif: 283 ret.append(self.new_endif) 284 if self.verbose: 285 print(f'+ {ret[-1]}') 286 elif (old := ret[last_endif].lstrip()) != self.new_endif: 287 ret[last_endif] = self.new_endif 288 if self.verbose: 289 print(f'- {old}') 290 print(f'+ {ret[last_endif]}') 291 ret = super().epilogue(last_endif, ret) 292 return ret 293 294_T = TypeVar('_T', bound=Replacer) 295 296def do_replacement(replacer: _T, lines: Iterable[str]) -> list[str]: 297 r"""Replace the header guard using the replacement class 298 299 Parameters 300 ---------- 301 replacer : 302 an instance of a concrete replacement class 303 lines : 304 an iterable of lines of the file 305 306 Returns 307 ------- 308 ret : 309 the file lines with the replaced header guard, if applicable 310 """ 311 header_re = re.compile(r'#ifndef\s+(.*)') 312 define_re = re.compile(r'#define\s+(.*)') 313 314 def is_pragma_once(line: str) -> bool: 315 return line.startswith('#pragma once') 316 317 def is_header_guard(prev_line: str, line: str) -> bool: 318 d_match = define_re.match(line) 319 h_match = header_re.match(prev_line) 320 return d_match is not None and h_match is not None and d_match.group(1) == h_match.group(1) 321 322 def is_match(prev_line: str, line: str) -> bool: 323 return is_pragma_once(line) or is_header_guard(prev_line, line) 324 325 ret: list[str] = [] 326 last_endif = 0 327 328 lines = replacer.prologue(list(lines)) 329 for i, line in enumerate(lines): 330 try: 331 prev_line = ret[-1] 332 except IndexError: 333 prev_line = '' 334 335 if is_match(prev_line, line): 336 ret = replacer.replace(prev_line, line, ret) 337 else: 338 if line.startswith('#endif'): 339 last_endif = i 340 ret.append(line) 341 342 ret = replacer.epilogue(last_endif, ret) 343 return ret 344 345def replace_in_file(path: pathlib.Path, opts: argparse.Namespace, ReplacerCls: type[_T]) -> list[str]: 346 r"""Replace the header guards in a file 347 348 Parameters 349 ---------- 350 path : 351 the path to check 352 opts : 353 the options database to use 354 replacer : 355 the replacement class type to use to make the replacements 356 357 Notes 358 ----- 359 Does nothing if the file isn't a header 360 """ 361 error_diffs: list[str] = [] 362 363 if not path.name.endswith(opts.suffixes): 364 return error_diffs 365 366 if opts.verbose: 367 print('Reading', path) 368 369 lines = path.read_text().splitlines() 370 repl = ReplacerCls(opts.verbose, path) 371 ret = do_replacement(repl, lines) 372 373 if opts.action == 'convert': 374 if not opts.dry_run: 375 path.write_text('\n'.join(ret) + '\n') 376 elif opts.action == 'check': 377 if diffs := list( 378 difflib.unified_diff(lines, ret, fromfile='actual', tofile='expected', lineterm='') 379 ): 380 err_bars = '=' * 95 381 error_diffs.extend([ 382 err_bars, 383 'ERROR: Malformed header guard!', 384 f'ERROR: {path}', 385 *diffs, 386 err_bars 387 ]) 388 return error_diffs 389 390def main(args: argparse.Namespace) -> int: 391 r"""Perform header guard replacement 392 393 Parameters 394 ---------- 395 args : 396 the collected configurations arguments 397 398 Returns 399 ------- 400 ret : 401 a return-code indicating status, 0 for success and nonzero otherwise 402 403 Raises 404 ------ 405 ValueError 406 if `args.kind` is unknown, or `args.action` is unknown 407 """ 408 if args.action not in {'check', 'convert'}: 409 raise ValueError(f'Unknown action {args.action}') 410 411 if args.kind == 'verbose_header_guard': 412 replacer_cls = VerboseHeaderGuard 413 elif args.kind == 'pragma_once': 414 replacer_cls = PragmaOnce 415 else: 416 raise ValueError(f'Unknown replacer kind: {args.kind}') 417 418 args.suffixes = tuple(args.suffixes) 419 exclude_dirs = set(args.exclude_dirs) 420 exclude_files = set(args.exclude_files) 421 errors: list[str] = [] 422 for path in args.paths: 423 path = path.resolve(strict=True) 424 if exclude_dirs.intersection(path.parts) or path.name in exclude_files: 425 # the path itself is in an excluded path 426 continue 427 428 if path.is_file(): 429 errors.extend(replace_in_file(path, args, replacer_cls)) 430 else: 431 for dirname, dirs, files in os.walk(path): 432 dirs[:] = [d for d in dirs if d not in exclude_dirs] 433 dirpath = pathlib.Path(dirname) 434 for f in files: 435 if f not in exclude_files: 436 errors.extend(replace_in_file(dirpath / f, args, replacer_cls)) 437 438 if errors: 439 print(*errors, sep='\n') 440 return 1 441 return 0 442 443def command_line_main() -> int: 444 def str2bool(v: Union[str, bool]) -> bool: 445 if isinstance(v, bool): 446 return v 447 v = v.casefold() 448 if v in {'yes', 'true', 't', 'y', '1'}: 449 return True 450 if v in {'no', 'false', 'f', 'n', '0', ''}: 451 return False 452 raise argparse.ArgumentTypeError(f'Boolean value expected, got \'{v}\'') 453 454 parser = argparse.ArgumentParser( 455 'header guard conversion tool', 456 formatter_class=argparse.ArgumentDefaultsHelpFormatter 457 ) 458 parser.add_argument('paths', nargs='+', type=pathlib.Path, help='paths to check/convert') 459 parser.add_argument( 460 '--verbose', 461 nargs='?', const=True, default=False, metavar='bool', type=str2bool, help='verbose output' 462 ) 463 parser.add_argument( 464 '--dry-run', 465 nargs='?', const=True, default=False, metavar='bool', type=str2bool, 466 help='don\'t actually write results to file, only useful when replacing' 467 ) 468 parser.add_argument( 469 '--kind', required=True, choices=('verbose_header_guard', 'pragma_once'), 470 help='Determine the kind of header guard to enforce' 471 ) 472 parser.add_argument( 473 '--action', required=True, choices=('convert', 'check'), 474 help='whether to replace or check the header guards' 475 ) 476 parser.add_argument( 477 '--suffixes', nargs='+', default=['.h', '.hpp', '.cuh', '.inl', '.H', '.hh'], 478 help='set file suffixes to check, must contain \'.\', e.g. \'.h\'' 479 ) 480 parser.add_argument( 481 '--exclude-dirs', nargs='+', 482 default=[ 483 'binding', 'finclude', 'ftn-mod', 'ftn-auto', 'contrib', 'perfstubs', 'yaml', 'fsrc', 484 'benchmarks', 'valgrind', 'khash', 'mpiuni' 485 ], 486 help=f'set directory names to exclude, must not contain \'{os.path.sep}\'' 487 ) 488 parser.add_argument( 489 '--exclude-files', nargs='+', default=['petscversion.h', 'slepcversion.h'], 490 help=f'set file names to exclude, must not contain \'{os.path.sep}\'' 491 ) 492 parser.add_argument('--version', action='version', version=f'%(prog)s v{__version_str__}') 493 494 args = parser.parse_args() 495 ret = main(args) 496 if ret: 497 err_bar = 'x' + 93 * '*' + 'x' 498 print(err_bar) 499 print('run the following to automatically fix your errors:') 500 print('') 501 print(' '.join('--action=convert' if a.startswith('--action') else a for a in sys.argv)) 502 print(err_bar) 503 # to ensure it prints everything when running in CI 504 sys.stdout.flush() 505 return ret 506 507if __name__ == '__main__': 508 ret = command_line_main() 509 sys.exit(ret) 510