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.
@@ -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
@@ -0,0 +1,6 @@
1
+ """Check modules; importing this package registers all checks."""
2
+
3
+ from . import quoting # noqa: F401
4
+ from . import commands # noqa: F401
5
+ from . import variables # noqa: F401
6
+ from . import misc # noqa: F401