1from __future__ import annotations 2 3import argparse 4import json 5import logging 6import shutil 7import subprocess 8import sys 9import time 10from enum import Enum 11from typing import NamedTuple 12 13 14LINTER_CODE = "SHELLCHECK" 15 16 17class LintSeverity(str, Enum): 18 ERROR = "error" 19 WARNING = "warning" 20 ADVICE = "advice" 21 DISABLED = "disabled" 22 23 24class LintMessage(NamedTuple): 25 path: str | None 26 line: int | None 27 char: int | None 28 code: str 29 severity: LintSeverity 30 name: str 31 original: str | None 32 replacement: str | None 33 description: str | None 34 35 36def run_command( 37 args: list[str], 38) -> subprocess.CompletedProcess[bytes]: 39 logging.debug("$ %s", " ".join(args)) 40 start_time = time.monotonic() 41 try: 42 return subprocess.run( 43 args, 44 capture_output=True, 45 ) 46 finally: 47 end_time = time.monotonic() 48 logging.debug("took %dms", (end_time - start_time) * 1000) 49 50 51def check_files( 52 files: list[str], 53) -> list[LintMessage]: 54 try: 55 proc = run_command( 56 ["shellcheck", "--external-sources", "--format=json1"] + files 57 ) 58 except OSError as err: 59 return [ 60 LintMessage( 61 path=None, 62 line=None, 63 char=None, 64 code=LINTER_CODE, 65 severity=LintSeverity.ERROR, 66 name="command-failed", 67 original=None, 68 replacement=None, 69 description=(f"Failed due to {err.__class__.__name__}:\n{err}"), 70 ) 71 ] 72 stdout = str(proc.stdout, "utf-8").strip() 73 results = json.loads(stdout)["comments"] 74 return [ 75 LintMessage( 76 path=result["file"], 77 name=f"SC{result['code']}", 78 description=result["message"], 79 line=result["line"], 80 char=result["column"], 81 code=LINTER_CODE, 82 severity=LintSeverity.ERROR, 83 original=None, 84 replacement=None, 85 ) 86 for result in results 87 ] 88 89 90if __name__ == "__main__": 91 parser = argparse.ArgumentParser( 92 description="shellcheck runner", 93 fromfile_prefix_chars="@", 94 ) 95 parser.add_argument( 96 "filenames", 97 nargs="+", 98 help="paths to lint", 99 ) 100 101 if shutil.which("shellcheck") is None: 102 err_msg = LintMessage( 103 path="<none>", 104 line=None, 105 char=None, 106 code=LINTER_CODE, 107 severity=LintSeverity.ERROR, 108 name="command-failed", 109 original=None, 110 replacement=None, 111 description="shellcheck is not installed, did you forget to run `lintrunner init`?", 112 ) 113 print(json.dumps(err_msg._asdict()), flush=True) 114 sys.exit(0) 115 116 args = parser.parse_args() 117 118 lint_messages = check_files(args.filenames) 119 for lint_message in lint_messages: 120 print(json.dumps(lint_message._asdict()), flush=True) 121