Files
sunnypilot/scripts/lint/check_shell.py
T
2026-09-12 14:14:39 -07:00

133 lines
5.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""A minimal shellcheck-like static analysis tool for shell scripts.
Covers syntax, unquoted expansions, scalar $@ assignments, and read -r.
"""
import re
import argparse
import subprocess
from pathlib import Path
VARIABLE = re.compile(r"\$(?:\{[^}\n]*\}|[A-Za-z_]\w*|[@*0-9])")
ASSIGNMENT = re.compile(r"[A-Za-z_]\w*(?:\[[^]]*\])?\+?=")
OPAQUE = re.compile(r"\$?\(\([^\n]*?\)\)|\[\[.*?\]\]", re.DOTALL)
HEREDOC = re.compile(r"<<(-?)\s*('[^']+'|\"[^\"]+\"|\\?[A-Za-z_]\w*)")
def commands(text):
def scan(i=0, end="", pattern_group=False):
start, quote, words, expansions, documents = i, False, [], [], []
cases = []
while i < len(text):
c = text[i]
if c == "\\":
i += 2
continue
if c == "'" and not quote:
i = text.find("'", i + 1) + 1 or len(text)
continue
if c == '"':
quote = not quote
elif (not quote or text.startswith("$((", i)) and (match := OPAQUE.match(text, i)):
i = match.end()
continue
elif text.startswith("$(", i):
if not quote:
expansions.append(i)
i = yield from scan(i + 2, ")")
continue
elif match := VARIABLE.match(text, i):
if not quote and not match[0].startswith("${#"):
expansions.append(i)
i = match.end()
continue
elif not quote:
if text.startswith("<<<", i):
i += 3
continue
if c == "#" and i == start:
i = text.find("\n", i)
i = len(text) if i < 0 else i
start = i
continue
if not text.startswith("<<<", i) and (match := HEREDOC.match(text, i)):
documents.append((match[2].strip("'\"").lstrip("\\"), bool(match[1])))
i = match.end()
continue
if c in " \t\r\n;|&()":
if start < i:
words.append((text[start:i], start, expansions))
if len(words) >= 3 and words[0][0] == "case" and words[-1][0] == "in":
cases.append(True)
words = []
if words and words[0][0] == "esac" and cases:
cases.pop()
words = []
pattern = bool(cases and cases[-1])
expansions = []
if c in "\n;|&()":
if words and not (pattern or pattern_group) and (c != ")" or end):
yield words
words = []
if c == end and not pattern:
return i + 1
if c == ")" and pattern:
cases[-1] = False
if cases and (terminator := re.match(r";(?:;&|;|&)", text[i:])):
cases[-1] = True
i += len(terminator[0]) - 1
if c == "(":
if pattern and i == start:
cases[-1] = False
i = yield from scan(i + 1, ")", pattern or pattern_group)
start = i
continue
if c == "\n":
for delimiter, strip_tabs in documents:
while i < len(text):
stop = text.find("\n", i + 1)
stop = len(text) if stop < 0 else stop
line, i = text[i + 1:stop], stop
if (line.lstrip("\t") if strip_tabs else line) == delimiter:
break
documents = []
start = i + 1
i += 1
if start < i:
words.append((text[start:i], start, expansions))
if words:
yield words
return i
yield from scan()
def check_text(text):
for words in commands(text):
command = next((w for w, _, _ in words if not ASSIGNMENT.match(w) and w not in {"if", "then", "elif", "while", "until", "do", "!"}), "")
prefix = True
for index, (word, start, expansions) in enumerate(words):
assignment = ASSIGNMENT.match(word) and (prefix or command in {"export", "local", "declare", "readonly", "typeset"})
prefix = prefix and (bool(assignment) or word in {"if", "then", "elif", "while", "until", "do", "!"})
for offset in expansions:
array = word in {"$@", "$*"} or "[@]" in word or "[*]" in word
if not assignment and (command not in {"case", "for", "select"} or array) and not (index and words[index - 1][0] == "<<<"):
yield text.count("\n", 0, offset) + 1, "Quote this expansion to prevent word splitting and globbing"
if assignment and re.fullmatch(r'"[^"\n]*\$@[^"\n]*"', ASSIGNMENT.sub("", word, count=1)):
yield text.count("\n", 0, start) + 1, 'Use an array for "$@", or "$*" to join arguments'
if command == "read" and not any(re.fullmatch(r"-[A-Za-z]*r[A-Za-z0-9]*", w) for w, _, _ in words):
yield text.count("\n", 0, next(start for w, start, _ in words if w == "read")) + 1, "Use read -r to preserve backslashes"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("filenames", nargs="+")
failed = False
for filename in parser.parse_args().filenames:
syntax = subprocess.run(["bash", "-n", "--", filename], check=False)
failed |= syntax.returncode != 0
if syntax.returncode == 0:
for line, message in check_text(Path(filename).read_text()):
print(f"{filename}:{line}: {message}")
failed = True
raise SystemExit(failed)