mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-18 14:43:44 +08:00
133 lines
5.1 KiB
Python
Executable File
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)
|