xref: /petsc/lib/petsc/bin/maint/check_header_guard.py (revision b0dcfd164860a975c76f90dabf1036901aab1c4e)
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