pureshellcheck 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {pureshellcheck-0.2.0/src/pureshellcheck.egg-info → pureshellcheck-0.2.2}/PKG-INFO +12 -11
  2. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/README.md +11 -10
  3. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/pyproject.toml +1 -1
  4. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/__init__.py +1 -1
  5. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/analyzer.py +2 -0
  6. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/cli.py +38 -3
  7. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/parser.py +73 -0
  8. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/shast.py +13 -0
  9. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2/src/pureshellcheck.egg-info}/PKG-INFO +12 -11
  10. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/LICENSE +0 -0
  11. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/MANIFEST.in +0 -0
  12. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/setup.cfg +0 -0
  13. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/astlib.py +0 -0
  14. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/checks/__init__.py +0 -0
  15. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/checks/commands.py +0 -0
  16. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/checks/misc.py +0 -0
  17. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/checks/quoting.py +0 -0
  18. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/checks/variables.py +0 -0
  19. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/varflow.py +0 -0
  20. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck/varscan.py +0 -0
  21. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck.egg-info/SOURCES.txt +0 -0
  22. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck.egg-info/dependency_links.txt +0 -0
  23. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck.egg-info/entry_points.txt +0 -0
  24. {pureshellcheck-0.2.0 → pureshellcheck-0.2.2}/src/pureshellcheck.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pureshellcheck
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A pure Python reimplementation of ShellCheck's most common checks
5
5
  Author: adam2go
6
6
  License: MIT
@@ -157,24 +157,25 @@ no process spawn, no binary. A one-line snippet checks in **~40 µs**
157
157
  (~25,000 checks/second); throughput on large scripts is ~57k lines/s. CLI
158
158
  time is dominated by CPython interpreter startup (~20 ms).
159
159
 
160
- **v0.2.0 vs v0.1.0** (controlled before/after,
160
+ **v0.2.x vs v0.1.0** (controlled before/after,
161
161
  `python tools/bench_compare.py`: baseline wheel from PyPI vs this tree in
162
162
  the same interpreter, 25–200 in-process repeats, outputs verified
163
163
  identical on every workload):
164
164
 
165
- | workload | v0.1.0 | v0.2.0 | improvement |
165
+ | workload | v0.1.0 | v0.2.1 | improvement |
166
166
  |---|---|---|---|
167
- | tiny (1 line) | 0.061 ms | 0.037 ms | 1.6× |
168
- | small (75 lines) | 2.62 ms | 1.30 ms | 2.0× |
169
- | medium (263 lines) | 9.46 ms | 4.81 ms | 2.0× |
170
- | large (1216 lines) | 48.6 ms | 21.3 ms | **2.3×** |
167
+ | tiny (1 line) | 0.058 ms | 0.036 ms | 1.6× |
168
+ | small (75 lines) | 2.44 ms | 1.21 ms | 2.0× |
169
+ | medium (263 lines) | 8.76 ms | 4.48 ms | 2.0× |
170
+ | large (1216 lines) | 46.2 ms | 20.0 ms | **2.3×** |
171
171
 
172
- The v0.2.0 speedups came from caching the AST child/parent structure and a
172
+ The speedups came from caching the AST child/parent structure and a
173
173
  document-order node table (one traversal instead of dozens), making
174
174
  variable states immutable tuples so branch snapshots are plain dict
175
- copies, a banded Levenshtein for SC2153 (fuzz-tested against the
176
- reference implementation on 20,000 random pairs), and memoizing repeated
177
- word/command resolution.
175
+ copies, a leaf-node fast path, a banded Levenshtein for SC2153
176
+ (fuzz-tested against the reference implementation on 20,000 random
177
+ pairs), and memoizing repeated word/command resolution. Package import is
178
+ 3.6 ms; remaining CLI latency is CPython interpreter startup.
178
179
 
179
180
  ## Compatibility notes
180
181
 
@@ -130,24 +130,25 @@ no process spawn, no binary. A one-line snippet checks in **~40 µs**
130
130
  (~25,000 checks/second); throughput on large scripts is ~57k lines/s. CLI
131
131
  time is dominated by CPython interpreter startup (~20 ms).
132
132
 
133
- **v0.2.0 vs v0.1.0** (controlled before/after,
133
+ **v0.2.x vs v0.1.0** (controlled before/after,
134
134
  `python tools/bench_compare.py`: baseline wheel from PyPI vs this tree in
135
135
  the same interpreter, 25–200 in-process repeats, outputs verified
136
136
  identical on every workload):
137
137
 
138
- | workload | v0.1.0 | v0.2.0 | improvement |
138
+ | workload | v0.1.0 | v0.2.1 | improvement |
139
139
  |---|---|---|---|
140
- | tiny (1 line) | 0.061 ms | 0.037 ms | 1.6× |
141
- | small (75 lines) | 2.62 ms | 1.30 ms | 2.0× |
142
- | medium (263 lines) | 9.46 ms | 4.81 ms | 2.0× |
143
- | large (1216 lines) | 48.6 ms | 21.3 ms | **2.3×** |
140
+ | tiny (1 line) | 0.058 ms | 0.036 ms | 1.6× |
141
+ | small (75 lines) | 2.44 ms | 1.21 ms | 2.0× |
142
+ | medium (263 lines) | 8.76 ms | 4.48 ms | 2.0× |
143
+ | large (1216 lines) | 46.2 ms | 20.0 ms | **2.3×** |
144
144
 
145
- The v0.2.0 speedups came from caching the AST child/parent structure and a
145
+ The speedups came from caching the AST child/parent structure and a
146
146
  document-order node table (one traversal instead of dozens), making
147
147
  variable states immutable tuples so branch snapshots are plain dict
148
- copies, a banded Levenshtein for SC2153 (fuzz-tested against the
149
- reference implementation on 20,000 random pairs), and memoizing repeated
150
- word/command resolution.
148
+ copies, a leaf-node fast path, a banded Levenshtein for SC2153
149
+ (fuzz-tested against the reference implementation on 20,000 random
150
+ pairs), and memoizing repeated word/command resolution. Package import is
151
+ 3.6 ms; remaining CLI latency is CPython interpreter startup.
151
152
 
152
153
  ## Compatibility notes
153
154
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pureshellcheck"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "A pure Python reimplementation of ShellCheck's most common checks"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -6,7 +6,7 @@ common checks.
6
6
  ... print(finding.line, finding.column, finding.code, finding.message)
7
7
  """
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.2.2"
10
10
 
11
11
  from .analyzer import Finding, run_checks # noqa: F401
12
12
  from .parser import ParseError, parse # noqa: F401
@@ -382,6 +382,8 @@ def apply_directives(findings, directives, nodes, source, positions):
382
382
  def run_checks(source, shell=None, include_optional=False,
383
383
  filename="<stdin>"):
384
384
  """Parse and analyze a script. Returns (findings, parse_error|None)."""
385
+ if source and source[0] == chr(0xFEFF):
386
+ source = source[1:] # tolerate a UTF-8 BOM
385
387
  parser = Parser(source)
386
388
  try:
387
389
  root = parser.parse()
@@ -1,7 +1,6 @@
1
1
  """Command line interface, modeled on shellcheck's."""
2
2
 
3
3
  import argparse
4
- import json
5
4
  import sys
6
5
 
7
6
  from . import __version__, run_checks
@@ -19,6 +18,38 @@ COLORS = {
19
18
  }
20
19
 
21
20
 
21
+ _TAKES_ONE = {"-s", "--shell", "-f", "--format", "-e", "--exclude",
22
+ "-S", "--severity", "-o", "--enable"}
23
+ _COLOR_VALUES = {"auto", "always", "never"}
24
+
25
+
26
+ def _reorder(argv):
27
+ """Let options appear after files, like shellcheck: before Python 3.12,
28
+ argparse stops recognizing options once positionals begin, so
29
+ `pureshellcheck foo.sh -f gcc` would fail there. Deterministically sort
30
+ options to the front."""
31
+ opts, pos = [], []
32
+ i, n = 0, len(argv)
33
+ while i < n:
34
+ a = argv[i]
35
+ if a == "--":
36
+ pos.extend(argv[i + 1:])
37
+ break
38
+ if a.startswith("-") and a != "-":
39
+ take = 2 if a in _TAKES_ONE and i + 1 < n else 1
40
+ if a in ("-C", "--color") and i + 1 < n \
41
+ and argv[i + 1] in _COLOR_VALUES:
42
+ take = 2
43
+ opts.extend(argv[i:i + take])
44
+ i += take
45
+ else:
46
+ pos.append(a)
47
+ i += 1
48
+ if any(p.startswith("-") and p != "-" for p in pos):
49
+ pos.insert(0, "--")
50
+ return opts + pos
51
+
52
+
22
53
  def parse_args(argv):
23
54
  p = argparse.ArgumentParser(
24
55
  prog="pureshellcheck",
@@ -45,7 +76,7 @@ def parse_args(argv):
45
76
  help="use color (default: auto)")
46
77
  p.add_argument("--version", action="version",
47
78
  version="pureshellcheck %s" % __version__)
48
- return p.parse_args(argv)
79
+ return p.parse_args(_reorder(argv))
49
80
 
50
81
 
51
82
  def parse_excludes(items):
@@ -65,7 +96,9 @@ def check_file(path, args, excluded, min_rank):
65
96
  source = sys.stdin.read()
66
97
  name = "-"
67
98
  else:
68
- with open(path, encoding="utf-8", errors="replace") as f:
99
+ # utf-8-sig: tolerate a UTF-8 BOM, which would otherwise break
100
+ # shebang detection
101
+ with open(path, encoding="utf-8-sig", errors="replace") as f:
69
102
  source = f.read()
70
103
  name = path
71
104
  shell = args.shell if args.shell != "busybox" else "ash"
@@ -148,9 +181,11 @@ def main(argv=None):
148
181
  else:
149
182
  json_items.extend(finding_json(name, f) for f in findings)
150
183
  if args.format == "json":
184
+ import json
151
185
  json.dump(json_items, out)
152
186
  out.write("\n")
153
187
  elif args.format == "json1":
188
+ import json
154
189
  json.dump({"comments": json_items}, out)
155
190
  out.write("\n")
156
191
  if had_error:
@@ -22,6 +22,15 @@ END_KEYWORDS = {
22
22
  # Characters that terminate an unquoted word.
23
23
  METACHARS = " \t\n;&|<>()"
24
24
 
25
+ # Cap on nested constructs - subshells "(", command substitutions "$(",
26
+ # brace/arithmetic groups - which re-enter read_command or read_arith_primary.
27
+ # Arithmetic nesting is the most frame-hungry (its precedence chain adds ~15
28
+ # interpreter frames per level), so 40 trips at ~600 frames: comfortably under
29
+ # CPython's default 1000 limit on every platform (including Windows' ~1 MB
30
+ # stack) even from a deep call site, yet far above any real script. Deeply
31
+ # nested input like "$( $( $(" or "(((((" raises a clean ParseError.
32
+ MAX_NESTING = 40
33
+
25
34
  UNQUOTED_RUN = re.compile(r"""[^\\'"$`*?\[\](){}<>|&;\s!#~]+""")
26
35
  DQUOTE_RUN = re.compile(r'[^"\\$`]+')
27
36
  SQUOTE_END = re.compile(r"'")
@@ -81,6 +90,17 @@ class ParseError(Exception):
81
90
  self.code = code
82
91
 
83
92
 
93
+ class _NestingLimit(Exception):
94
+ """Raised when input nests deeper than MAX_NESTING. Deliberately NOT a
95
+ ParseError so the parser's backtracking (which catches ParseError to try
96
+ alternatives) cannot swallow it; parse() turns it into a ParseError."""
97
+
98
+ def __init__(self, message, pos):
99
+ super().__init__(message)
100
+ self.message = message
101
+ self.pos = pos
102
+
103
+
84
104
  class Directive:
85
105
  """A `# shellcheck key=value` comment."""
86
106
 
@@ -104,6 +124,11 @@ class Parser:
104
124
  self.pending_heredocs = []
105
125
  self.directives = []
106
126
  self.line_had_command = False
127
+ # Smallest index from which a glob-class scan reached EOF with no
128
+ # closing ']' or metachar; from there on every '[' is a literal.
129
+ # Caps the otherwise O(n^2) rescan on input like "[1,[1,[1,...".
130
+ self._glob_eof_from = self.n + 1
131
+ self._nesting = 0 # current compound-command nesting depth
107
132
 
108
133
  # ------------------------------------------------------------------
109
134
  # Low-level helpers
@@ -192,6 +217,13 @@ class Parser:
192
217
  # Script / lists
193
218
 
194
219
  def parse(self):
220
+ try:
221
+ return self._parse()
222
+ except _NestingLimit as e:
223
+ # Surface the internal depth-limit signal as the public error type.
224
+ raise ParseError(e.message, e.pos) from None
225
+
226
+ def _parse(self):
195
227
  shebang = None
196
228
  if self.src.startswith("#!"):
197
229
  end = self.src.find("\n")
@@ -340,6 +372,20 @@ class Parser:
340
372
  # Commands
341
373
 
342
374
  def read_command(self):
375
+ # Every nested compound command - subshell, command substitution,
376
+ # brace/arithmetic group - re-enters here, so this bounds parser
377
+ # recursion and keeps adversarial input from overflowing the stack.
378
+ self._nesting += 1
379
+ if self._nesting > MAX_NESTING:
380
+ self._nesting -= 1
381
+ raise _NestingLimit("commands nested too deeply (over %d levels)"
382
+ % MAX_NESTING, self.i)
383
+ try:
384
+ return self._read_command()
385
+ finally:
386
+ self._nesting -= 1
387
+
388
+ def _read_command(self):
343
389
  self.skip_inline_ws()
344
390
  if self.at_end():
345
391
  return None
@@ -1366,11 +1412,17 @@ class Parser:
1366
1412
  """Parse [...] as a glob character class, else literal '['."""
1367
1413
  src, n = self.src, self.n
1368
1414
  start = self.i
1415
+ # We already proved the rest of the input has no closing ']' (nor a
1416
+ # metachar) from an earlier '['; this one can't open a class either.
1417
+ if start >= self._glob_eof_from:
1418
+ self.i = start + 1
1419
+ return Node("T_Literal", start, self.i, text="[")
1369
1420
  j = self.i + 1
1370
1421
  if j < n and src[j] in "!^":
1371
1422
  j += 1
1372
1423
  if j < n and src[j] == "]":
1373
1424
  j += 1
1425
+ jumped = False
1374
1426
  while j < n:
1375
1427
  c = src[j]
1376
1428
  if c == "]":
@@ -1384,8 +1436,15 @@ class Parser:
1384
1436
  if end == -1:
1385
1437
  break
1386
1438
  j = end + 2
1439
+ jumped = True
1387
1440
  continue
1388
1441
  j += 1
1442
+ else:
1443
+ # Reached EOF with no ']' or metachar. Unless a [:class:] jump
1444
+ # skipped over text, no later '[' can find one either - remember
1445
+ # it so the next '[' bails in O(1).
1446
+ if not jumped:
1447
+ self._glob_eof_from = start
1389
1448
  self.i = start + 1
1390
1449
  return Node("T_Literal", start, self.i, text="[")
1391
1450
 
@@ -1842,6 +1901,20 @@ class Parser:
1842
1901
  return operand
1843
1902
 
1844
1903
  def read_arith_primary(self):
1904
+ # Nested "(...)" in arithmetic re-enters here, a separate recursion
1905
+ # from read_command, so it shares the same nesting guard to keep
1906
+ # "(((((..." / "$((((..." from overflowing the stack.
1907
+ self._nesting += 1
1908
+ if self._nesting > MAX_NESTING:
1909
+ self._nesting -= 1
1910
+ raise _NestingLimit("arithmetic nested too deeply (over %d levels)"
1911
+ % MAX_NESTING, self.i)
1912
+ try:
1913
+ return self._read_arith_primary()
1914
+ finally:
1915
+ self._nesting -= 1
1916
+
1917
+ def _read_arith_primary(self):
1845
1918
  self.skip_arith_ws()
1846
1919
  start = self.i
1847
1920
  src, n = self.src, self.n
@@ -82,15 +82,28 @@ def walk(node):
82
82
  stack.extend(reversed(children))
83
83
 
84
84
 
85
+ # Node kinds whose fields can never contain child nodes.
86
+ LEAF_KINDS = frozenset({
87
+ "T_Literal", "T_SingleQuoted", "T_DollarSingleQuoted", "T_Glob",
88
+ "TA_Literal", "TA_Empty", "T_IoDuplicate", "TC_Empty",
89
+ })
90
+
91
+ _EMPTY = ()
92
+
93
+
85
94
  def set_parents(root):
86
95
  """Link parents, cache children, and return all nodes in doc order."""
87
96
  nodes = []
88
97
  append = nodes.append
89
98
  stack = [root]
90
99
  pop = stack.pop
100
+ leaf = LEAF_KINDS
91
101
  while stack:
92
102
  n = pop()
93
103
  append(n)
104
+ if n.kind in leaf:
105
+ n.children = _EMPTY
106
+ continue
94
107
  children = []
95
108
  add = children.append
96
109
  for value in n.fields.values():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pureshellcheck
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A pure Python reimplementation of ShellCheck's most common checks
5
5
  Author: adam2go
6
6
  License: MIT
@@ -157,24 +157,25 @@ no process spawn, no binary. A one-line snippet checks in **~40 µs**
157
157
  (~25,000 checks/second); throughput on large scripts is ~57k lines/s. CLI
158
158
  time is dominated by CPython interpreter startup (~20 ms).
159
159
 
160
- **v0.2.0 vs v0.1.0** (controlled before/after,
160
+ **v0.2.x vs v0.1.0** (controlled before/after,
161
161
  `python tools/bench_compare.py`: baseline wheel from PyPI vs this tree in
162
162
  the same interpreter, 25–200 in-process repeats, outputs verified
163
163
  identical on every workload):
164
164
 
165
- | workload | v0.1.0 | v0.2.0 | improvement |
165
+ | workload | v0.1.0 | v0.2.1 | improvement |
166
166
  |---|---|---|---|
167
- | tiny (1 line) | 0.061 ms | 0.037 ms | 1.6× |
168
- | small (75 lines) | 2.62 ms | 1.30 ms | 2.0× |
169
- | medium (263 lines) | 9.46 ms | 4.81 ms | 2.0× |
170
- | large (1216 lines) | 48.6 ms | 21.3 ms | **2.3×** |
167
+ | tiny (1 line) | 0.058 ms | 0.036 ms | 1.6× |
168
+ | small (75 lines) | 2.44 ms | 1.21 ms | 2.0× |
169
+ | medium (263 lines) | 8.76 ms | 4.48 ms | 2.0× |
170
+ | large (1216 lines) | 46.2 ms | 20.0 ms | **2.3×** |
171
171
 
172
- The v0.2.0 speedups came from caching the AST child/parent structure and a
172
+ The speedups came from caching the AST child/parent structure and a
173
173
  document-order node table (one traversal instead of dozens), making
174
174
  variable states immutable tuples so branch snapshots are plain dict
175
- copies, a banded Levenshtein for SC2153 (fuzz-tested against the
176
- reference implementation on 20,000 random pairs), and memoizing repeated
177
- word/command resolution.
175
+ copies, a leaf-node fast path, a banded Levenshtein for SC2153
176
+ (fuzz-tested against the reference implementation on 20,000 random
177
+ pairs), and memoizing repeated word/command resolution. Package import is
178
+ 3.6 ms; remaining CLI latency is CPython interpreter startup.
178
179
 
179
180
  ## Compatibility notes
180
181
 
File without changes
File without changes