mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-30 17:03:41 +08:00
add shellcheck-like static analysis (#38877)
This commit is contained in:
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user