pureshellcheck 0.1.0__py3-none-any.whl
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.
- pureshellcheck/__init__.py +58 -0
- pureshellcheck/analyzer.py +403 -0
- pureshellcheck/astlib.py +302 -0
- pureshellcheck/checks/__init__.py +6 -0
- pureshellcheck/checks/commands.py +1003 -0
- pureshellcheck/checks/misc.py +233 -0
- pureshellcheck/checks/quoting.py +440 -0
- pureshellcheck/checks/variables.py +348 -0
- pureshellcheck/cli.py +162 -0
- pureshellcheck/parser.py +2050 -0
- pureshellcheck/shast.py +106 -0
- pureshellcheck/varflow.py +584 -0
- pureshellcheck/varscan.py +511 -0
- pureshellcheck-0.1.0.dist-info/METADATA +184 -0
- pureshellcheck-0.1.0.dist-info/RECORD +19 -0
- pureshellcheck-0.1.0.dist-info/WHEEL +5 -0
- pureshellcheck-0.1.0.dist-info/entry_points.txt +2 -0
- pureshellcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- pureshellcheck-0.1.0.dist-info/top_level.txt +1 -0
pureshellcheck/astlib.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Shared AST analysis helpers, ported from ShellCheck's ASTLib semantics."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .shast import ancestors, walk
|
|
6
|
+
from .parser import literal_text, quoted_literal_text
|
|
7
|
+
|
|
8
|
+
SPECIAL_INTEGER_VARIABLES = frozenset({"$", "?", "!", "#"})
|
|
9
|
+
SPECIAL_VARIABLES_WITHOUT_SPACES = SPECIAL_INTEGER_VARIABLES | {"-"}
|
|
10
|
+
VARIABLES_WITHOUT_SPACES = SPECIAL_VARIABLES_WITHOUT_SPACES | {
|
|
11
|
+
"BASHPID", "BASH_ARGC", "BASH_LINENO", "BASH_SUBSHELL", "EUID",
|
|
12
|
+
"EPOCHREALTIME", "EPOCHSECONDS", "LINENO", "OPTIND", "PPID", "RANDOM",
|
|
13
|
+
"READLINE_ARGUMENT", "READLINE_MARK", "READLINE_POINT", "SECONDS",
|
|
14
|
+
"SHELLOPTS", "SHLVL", "SRANDOM", "UID", "COLUMNS", "HISTFILESIZE",
|
|
15
|
+
"HISTSIZE", "LINES", "FLAGS_ERROR", "FLAGS_FALSE", "FLAGS_TRUE",
|
|
16
|
+
"status", # bats
|
|
17
|
+
}
|
|
18
|
+
SPECIAL_VARIABLES = SPECIAL_VARIABLES_WITHOUT_SPACES | {"@", "*"}
|
|
19
|
+
UNBRACED_VARIABLES = SPECIAL_VARIABLES | set("0123456789")
|
|
20
|
+
|
|
21
|
+
EXPANSION_KINDS = frozenset({
|
|
22
|
+
"T_DollarBraced", "T_DollarExpansion", "T_Backticked",
|
|
23
|
+
"T_DollarArithmetic", "T_DollarBraceCommandExpansion",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
QUOTE_KINDS = frozenset({
|
|
27
|
+
"T_SingleQuoted", "T_DoubleQuoted", "T_DollarSingleQuoted",
|
|
28
|
+
"T_DollarDoubleQuoted",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def word_parts(word):
|
|
33
|
+
if word is None:
|
|
34
|
+
return []
|
|
35
|
+
if word.kind == "T_NormalWord":
|
|
36
|
+
return word.parts
|
|
37
|
+
return [word]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def expanded_parts(word):
|
|
41
|
+
"""Word parts with double-quote layers flattened."""
|
|
42
|
+
out = []
|
|
43
|
+
for p in word_parts(word):
|
|
44
|
+
if p.kind in ("T_DoubleQuoted", "T_DollarDoubleQuoted"):
|
|
45
|
+
out.extend(p.parts)
|
|
46
|
+
else:
|
|
47
|
+
out.append(p)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_constant(word):
|
|
52
|
+
"""True if the word contains no expansions at all (quotes ok)."""
|
|
53
|
+
if word.kind == "T_NormalWord" and word.parts:
|
|
54
|
+
first = word.parts[0]
|
|
55
|
+
if first.kind == "T_Literal" and first.text.startswith("~") \
|
|
56
|
+
and not first.get("escaped"):
|
|
57
|
+
return False # tilde expansion
|
|
58
|
+
for n in walk(word):
|
|
59
|
+
if n.kind in EXPANSION_KINDS:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def has_expansions(word):
|
|
65
|
+
return not is_constant(word)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def onlyLiteralString(word):
|
|
69
|
+
return literal_text(word)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def word_text_approx(word, glob_marker="\0"):
|
|
73
|
+
"""Approximate the word's expanded text; expansions become markers."""
|
|
74
|
+
out = []
|
|
75
|
+
for p in word_parts(word):
|
|
76
|
+
k = p.kind
|
|
77
|
+
if k == "T_Literal":
|
|
78
|
+
out.append(p.text)
|
|
79
|
+
elif k in ("T_SingleQuoted", "T_DollarSingleQuoted"):
|
|
80
|
+
out.append(p.text)
|
|
81
|
+
elif k in ("T_DoubleQuoted", "T_DollarDoubleQuoted"):
|
|
82
|
+
for q in p.parts:
|
|
83
|
+
if q.kind == "T_Literal":
|
|
84
|
+
out.append(q.text)
|
|
85
|
+
else:
|
|
86
|
+
out.append(glob_marker)
|
|
87
|
+
elif k == "T_Glob":
|
|
88
|
+
out.append(p.text)
|
|
89
|
+
else:
|
|
90
|
+
out.append(glob_marker)
|
|
91
|
+
return "".join(out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
GLOB_CHARS = "*?["
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def has_glob(word):
|
|
98
|
+
for n in walk(word):
|
|
99
|
+
if n.kind in ("T_Glob", "T_Extglob"):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_glob_free_literal(text):
|
|
105
|
+
return not any(c in text for c in GLOB_CHARS)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ----------------------------------------------------------------------
|
|
109
|
+
# ${...} decomposition
|
|
110
|
+
|
|
111
|
+
def braced_reference(content):
|
|
112
|
+
"""The variable name referenced by ${content}."""
|
|
113
|
+
s = content
|
|
114
|
+
if s.startswith("#") and len(s) > 1 and s != "##":
|
|
115
|
+
s = s[1:]
|
|
116
|
+
elif s.startswith("!") and len(s) > 1:
|
|
117
|
+
s = s[1:]
|
|
118
|
+
m = re.match(r"[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*#?$!_-]", s)
|
|
119
|
+
return m.group(0) if m else s
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def braced_modifier(content):
|
|
123
|
+
"""Everything after the name/indices in ${content}."""
|
|
124
|
+
s = content
|
|
125
|
+
if s.startswith("#") and len(s) > 1 and s != "##":
|
|
126
|
+
s = s[1:]
|
|
127
|
+
elif s.startswith("!") and len(s) > 1:
|
|
128
|
+
s = s[1:]
|
|
129
|
+
m = re.match(r"[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*#?$!_-]", s)
|
|
130
|
+
if not m:
|
|
131
|
+
return ""
|
|
132
|
+
i = m.end()
|
|
133
|
+
while i < len(s) and s[i] == "[":
|
|
134
|
+
depth = 0
|
|
135
|
+
j = i
|
|
136
|
+
while j < len(s):
|
|
137
|
+
if s[j] == "[":
|
|
138
|
+
depth += 1
|
|
139
|
+
elif s[j] == "]":
|
|
140
|
+
depth -= 1
|
|
141
|
+
if depth == 0:
|
|
142
|
+
break
|
|
143
|
+
j += 1
|
|
144
|
+
if j >= len(s):
|
|
145
|
+
break
|
|
146
|
+
i = j + 1
|
|
147
|
+
return s[i:]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def braced_index(content):
|
|
151
|
+
"""The text inside [..] following the name, or None."""
|
|
152
|
+
m = re.match(r"[!#]?(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+)\[(.*?)\]", content)
|
|
153
|
+
return m.group(1) if m else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_array_expansion(part):
|
|
157
|
+
"""$@, ${a[@]} and friends ($* joins to a string, so it's excluded)."""
|
|
158
|
+
if part.kind != "T_DollarBraced":
|
|
159
|
+
return False
|
|
160
|
+
content = part.content
|
|
161
|
+
if content.startswith("#"):
|
|
162
|
+
return False
|
|
163
|
+
if content.startswith("@"):
|
|
164
|
+
return True
|
|
165
|
+
return "[@]" in content
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_counting_reference(part):
|
|
169
|
+
"""${#var} or ${#arr[@]}."""
|
|
170
|
+
return (part.kind == "T_DollarBraced" and part.content.startswith("#")
|
|
171
|
+
and len(part.content) > 1)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def is_quoted_alternative_reference(part):
|
|
175
|
+
"""${v:+"$v"} style: alternative value where the user quotes inside."""
|
|
176
|
+
if part.kind != "T_DollarBraced":
|
|
177
|
+
return False
|
|
178
|
+
mod = braced_modifier(part.content)
|
|
179
|
+
return mod.startswith(":+") or mod.startswith("+")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def part_is_quoted(part, stop_at):
|
|
183
|
+
"""Is `part` enclosed in quotes between itself and `stop_at` ancestor?"""
|
|
184
|
+
for a in ancestors(part):
|
|
185
|
+
if a is stop_at:
|
|
186
|
+
return False
|
|
187
|
+
if a.kind in ("T_DoubleQuoted", "T_DollarDoubleQuoted",
|
|
188
|
+
"T_SingleQuoted"):
|
|
189
|
+
return True
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ----------------------------------------------------------------------
|
|
194
|
+
# Quoting context (port of isQuoteFreeNode)
|
|
195
|
+
|
|
196
|
+
def is_quote_free(node, shell="bash", strict=False):
|
|
197
|
+
"""True if expansion of `node` would not be subject to word splitting."""
|
|
198
|
+
prev = node
|
|
199
|
+
for a in ancestors(node):
|
|
200
|
+
k = a.kind
|
|
201
|
+
if k == "TC_Nullary" or k == "TC_Unary" or k == "TC_Binary":
|
|
202
|
+
cond = _enclosing_condition(a)
|
|
203
|
+
if cond is not None and not cond.single:
|
|
204
|
+
return True
|
|
205
|
+
# single bracket: keep walking; T_SimpleCommand-ish below
|
|
206
|
+
elif k in ("TA_Sequence", "T_Arithmetic", "TA_Expansion",
|
|
207
|
+
"TA_Assignment", "TA_Binary", "TA_Unary", "TA_Trinary",
|
|
208
|
+
"TA_Variable", "TA_Parenthesis", "T_DollarArithmetic"):
|
|
209
|
+
return True
|
|
210
|
+
elif k == "T_Assignment":
|
|
211
|
+
return _assignment_is_quoting(a, shell)
|
|
212
|
+
elif k == "T_IndexedElement":
|
|
213
|
+
return True
|
|
214
|
+
elif k in ("T_DoubleQuoted", "T_DollarDoubleQuoted"):
|
|
215
|
+
return True
|
|
216
|
+
elif k == "T_CaseExpression":
|
|
217
|
+
return True
|
|
218
|
+
elif k == "T_HereDoc":
|
|
219
|
+
return True
|
|
220
|
+
elif k == "T_DollarBraced":
|
|
221
|
+
return True
|
|
222
|
+
elif k in ("T_ForIn", "T_SelectIn"):
|
|
223
|
+
if prev in a.get("words", ()):
|
|
224
|
+
return not strict
|
|
225
|
+
elif k in ("T_SimpleCommand", "T_Condition", "T_Script",
|
|
226
|
+
"T_DollarExpansion", "T_Backticked", "T_ProcSub",
|
|
227
|
+
"T_DollarBraceCommandExpansion"):
|
|
228
|
+
return False
|
|
229
|
+
prev = a
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _enclosing_condition(node):
|
|
234
|
+
for a in ancestors(node):
|
|
235
|
+
if a.kind == "T_Condition":
|
|
236
|
+
return a
|
|
237
|
+
if a.kind in ("T_SimpleCommand", "T_Script"):
|
|
238
|
+
return None
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _assignment_is_quoting(assignment, shell):
|
|
243
|
+
"""Assignments don't split, except sh's `export foo=$bar` arguments."""
|
|
244
|
+
if shell != "sh" and shell != "dash" and shell != "ash":
|
|
245
|
+
return True
|
|
246
|
+
parent = assignment.parent
|
|
247
|
+
if parent is not None and parent.kind == "T_SimpleCommand":
|
|
248
|
+
return assignment not in parent.assigns or not parent.words
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def closest_command(node):
|
|
253
|
+
for a in ancestors(node):
|
|
254
|
+
if a.kind == "T_SimpleCommand":
|
|
255
|
+
return a
|
|
256
|
+
if a.kind in ("T_Script", "T_DollarExpansion", "T_Backticked"):
|
|
257
|
+
return None
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def used_as_command_name(node):
|
|
262
|
+
"""Is node (part of) the command-name word of a simple command?"""
|
|
263
|
+
prev = node
|
|
264
|
+
for a in ancestors(node):
|
|
265
|
+
if a.kind == "T_NormalWord":
|
|
266
|
+
prev = a
|
|
267
|
+
continue
|
|
268
|
+
if a.kind == "T_SimpleCommand":
|
|
269
|
+
words = a.words
|
|
270
|
+
return bool(words) and prev is words[0]
|
|
271
|
+
return False
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ----------------------------------------------------------------------
|
|
276
|
+
# Space status of values (port of CFG SpaceStatus, simplified)
|
|
277
|
+
|
|
278
|
+
EMPTY, CLEAN, DIRTY = 0, 1, 2
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def join_status(a, b):
|
|
282
|
+
"""Concatenation algebra: empty+clean=clean, dirty wins."""
|
|
283
|
+
if a == DIRTY or b == DIRTY:
|
|
284
|
+
return DIRTY
|
|
285
|
+
if a == CLEAN or b == CLEAN:
|
|
286
|
+
return CLEAN
|
|
287
|
+
return EMPTY
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def merge_status(a, b):
|
|
291
|
+
"""Control-flow merge: worst case wins; empty merges to dirty."""
|
|
292
|
+
if a == b:
|
|
293
|
+
return a
|
|
294
|
+
return DIRTY
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def literal_space_status(text):
|
|
298
|
+
if text == "":
|
|
299
|
+
return EMPTY
|
|
300
|
+
if re.search(r"[\s*?\[\]]", text):
|
|
301
|
+
return DIRTY
|
|
302
|
+
return CLEAN
|