""" Generic linter that greps for a pattern and optionally suggests replacements. """ from __future__ import annotations import argparse import json import logging import os import subprocess import sys import time from enum import Enum from typing import Any, NamedTuple IS_WINDOWS: bool = os.name == "nt" def eprint(*args: Any, **kwargs: Any) -> None: print(*args, file=sys.stderr, flush=True, **kwargs) class LintSeverity(str, Enum): ERROR = "error" WARNING = "warning" ADVICE = "advice" DISABLED = "disabled" class LintMessage(NamedTuple): path: str | None line: int | None char: int | None code: str severity: LintSeverity name: str original: str | None replacement: str | None description: str | None def as_posix(name: str) -> str: return name.replace("\\", "/") if IS_WINDOWS else name def run_command( args: list[str], ) -> subprocess.CompletedProcess[bytes]: logging.debug("$ %s", " ".join(args)) start_time = time.monotonic() try: return subprocess.run( args, capture_output=True, ) finally: end_time = time.monotonic() logging.debug("took %dms", (end_time - start_time) * 1000) def lint_file( matching_line: str, allowlist_pattern: str, replace_pattern: str, linter_name: str, error_name: str, error_description: str, ) -> LintMessage | None: # matching_line looks like: # tools/linter/clangtidy_linter.py:13:import foo.bar.baz split = matching_line.split(":") filename = split[0] if allowlist_pattern: try: proc = run_command(["grep", "-nEHI", allowlist_pattern, filename]) except Exception as err: return LintMessage( path=None, line=None, char=None, code=linter_name, severity=LintSeverity.ERROR, name="command-failed", original=None, replacement=None, description=( f"Failed due to {err.__class__.__name__}:\n{err}" if not isinstance(err, subprocess.CalledProcessError) else ( "COMMAND (exit code {returncode})\n" "{command}\n\n" "STDERR\n{stderr}\n\n" "STDOUT\n{stdout}" ).format( returncode=err.returncode, command=" ".join(as_posix(x) for x in err.cmd), stderr=err.stderr.decode("utf-8").strip() or "(empty)", stdout=err.stdout.decode("utf-8").strip() or "(empty)", ) ), ) # allowlist pattern was found, abort lint if proc.returncode == 0: return None original = None replacement = None if replace_pattern: with open(filename) as f: original = f.read() try: proc = run_command(["sed", "-r", replace_pattern, filename]) replacement = proc.stdout.decode("utf-8") except Exception as err: return LintMessage( path=None, line=None, char=None, code=linter_name, severity=LintSeverity.ERROR, name="command-failed", original=None, replacement=None, description=( f"Failed due to {err.__class__.__name__}:\n{err}" if not isinstance(err, subprocess.CalledProcessError) else ( "COMMAND (exit code {returncode})\n" "{command}\n\n" "STDERR\n{stderr}\n\n" "STDOUT\n{stdout}" ).format( returncode=err.returncode, command=" ".join(as_posix(x) for x in err.cmd), stderr=err.stderr.decode("utf-8").strip() or "(empty)", stdout=err.stdout.decode("utf-8").strip() or "(empty)", ) ), ) return LintMessage( path=split[0], line=int(split[1]) if len(split) > 1 else None, char=None, code=linter_name, severity=LintSeverity.ERROR, name=error_name, original=original, replacement=replacement, description=error_description, ) def main() -> None: parser = argparse.ArgumentParser( description="grep wrapper linter.", fromfile_prefix_chars="@", ) parser.add_argument( "--pattern", required=True, help="pattern to grep for", ) parser.add_argument( "--allowlist-pattern", help="if this pattern is true in the file, we don't grep for pattern", ) parser.add_argument( "--linter-name", required=True, help="name of the linter", ) parser.add_argument( "--match-first-only", action="store_true", help="only match the first hit in the file", ) parser.add_argument( "--error-name", required=True, help="human-readable description of what the error is", ) parser.add_argument( "--error-description", required=True, help="message to display when the pattern is found", ) parser.add_argument( "--replace-pattern", help=( "the form of a pattern passed to `sed -r`. " "If specified, this will become proposed replacement text." ), ) parser.add_argument( "--verbose", action="store_true", help="verbose logging", ) parser.add_argument( "filenames", nargs="+", help="paths to lint", ) args = parser.parse_args() logging.basicConfig( format="<%(threadName)s:%(levelname)s> %(message)s", level=logging.NOTSET if args.verbose else logging.DEBUG if len(args.filenames) < 1000 else logging.INFO, stream=sys.stderr, ) files_with_matches = [] if args.match_first_only: files_with_matches = ["--files-with-matches"] try: proc = run_command( ["grep", "-nEHI", *files_with_matches, args.pattern, *args.filenames] ) except Exception as err: err_msg = LintMessage( path=None, line=None, char=None, code=args.linter_name, severity=LintSeverity.ERROR, name="command-failed", original=None, replacement=None, description=( f"Failed due to {err.__class__.__name__}:\n{err}" if not isinstance(err, subprocess.CalledProcessError) else ( "COMMAND (exit code {returncode})\n" "{command}\n\n" "STDERR\n{stderr}\n\n" "STDOUT\n{stdout}" ).format( returncode=err.returncode, command=" ".join(as_posix(x) for x in err.cmd), stderr=err.stderr.decode("utf-8").strip() or "(empty)", stdout=err.stdout.decode("utf-8").strip() or "(empty)", ) ), ) print(json.dumps(err_msg._asdict()), flush=True) sys.exit(0) lines = proc.stdout.decode().splitlines() for line in lines: lint_message = lint_file( line, args.allowlist_pattern, args.replace_pattern, args.linter_name, args.error_name, args.error_description, ) if lint_message is not None: print(json.dumps(lint_message._asdict()), flush=True) if __name__ == "__main__": main()