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,233 @@
1
+ """Assorted smaller checks."""
2
+
3
+ import re
4
+
5
+ from ..analyzer import node_check
6
+ from ..astlib import word_parts
7
+ from ..parser import literal_text, quoted_literal_text
8
+ from ..shast import ancestors, walk
9
+ from .commands import first_word_basename, is_condition, word_approx
10
+
11
+
12
+ # SC2007: deprecated $[..]
13
+
14
+ @node_check("T_DollarArithmetic")
15
+ def check_dollar_brackets(ctx, node):
16
+ if node.get("deprecated"):
17
+ ctx.style(node, 2007, "Use $((..)) instead of deprecated $[..].")
18
+
19
+
20
+ # SC2035: glob that may become an option
21
+
22
+ @node_check("T_SimpleCommand")
23
+ def check_globs_as_options(ctx, node):
24
+ if len(node.words) < 2:
25
+ return
26
+ name = first_word_basename(node)
27
+ if name in ("echo", "printf"):
28
+ return
29
+ for w in node.words[1:]:
30
+ approx = word_approx(w)
31
+ if approx in ("--", ":::", "::::"):
32
+ break
33
+ parts = word_parts(w)
34
+ if parts and parts[0].kind == "T_Glob" \
35
+ and parts[0].text in ("*", "?"):
36
+ ctx.info(parts[0], 2035, "Use ./*glob* or -- *glob* so names"
37
+ " with dashes won't become options.")
38
+
39
+
40
+ # SC2103: cd there and back
41
+
42
+ def command_sequences(node):
43
+ """Statement lists where consecutive-command checks apply."""
44
+ f = node.fields
45
+ if node.kind in ("T_Script", "T_BraceGroup", "T_Subshell"):
46
+ yield f.get("commands", [])
47
+ elif node.kind in ("T_WhileExpression", "T_UntilExpression",
48
+ "T_ForIn", "T_ForArithmetic", "T_SelectIn"):
49
+ yield f.get("body", [])
50
+ elif node.kind == "T_IfExpression":
51
+ for _cond, body in node.branches:
52
+ yield body
53
+ yield node.else_body
54
+
55
+
56
+ SEQUENCE_KINDS = ("T_Script", "T_BraceGroup", "T_Subshell",
57
+ "T_WhileExpression", "T_UntilExpression", "T_ForIn",
58
+ "T_ForArithmetic", "T_SelectIn", "T_IfExpression")
59
+
60
+
61
+ @node_check(*SEQUENCE_KINDS)
62
+ def check_cd_and_back(ctx, node):
63
+ from .commands import has_set_e
64
+ if has_set_e(ctx):
65
+ return
66
+ for commands in command_sequences(node):
67
+ candidates = []
68
+ for cmd in commands:
69
+ if cmd.kind == "T_SimpleCommand" \
70
+ and first_word_basename(cmd) == "cd":
71
+ candidates.append(cmd)
72
+ for a, b in zip(candidates, candidates[1:]):
73
+ if _is_cd_revert(b) and not _is_cd_revert(a):
74
+ ctx.info(b, 2103, "Use a ( subshell ) to avoid having to"
75
+ " cd back.")
76
+ return
77
+
78
+
79
+ def _is_cd_revert(cmd):
80
+ if len(cmd.words) != 2:
81
+ return False
82
+ return word_approx(cmd.words[1]) in ("..", "-")
83
+
84
+
85
+ # SC2093: exec that won't be the last command
86
+
87
+ CLEANUP_COMMANDS = frozenset({":", "echo", "exit", "printf", "return"})
88
+
89
+
90
+ def _has_execfail(ctx):
91
+ cached = ctx.cache.get("execfail")
92
+ if cached is None:
93
+ cached = False
94
+ if ctx.shell in ("sh", "dash", "ash"):
95
+ ctx.cache["execfail"] = False
96
+ return False
97
+ for n in walk(ctx.root):
98
+ if n.kind == "T_SimpleCommand" and \
99
+ first_word_basename(n) == "shopt":
100
+ if any(word_approx(w) == "execfail" for w in n.words[1:]):
101
+ cached = True
102
+ break
103
+ ctx.cache["execfail"] = cached
104
+ return cached
105
+
106
+
107
+ @node_check(*SEQUENCE_KINDS)
108
+ def check_spurious_exec(ctx, node):
109
+ if _has_execfail(ctx):
110
+ return
111
+ in_loop = node.kind in ("T_WhileExpression", "T_UntilExpression",
112
+ "T_ForIn", "T_ForArithmetic", "T_SelectIn")
113
+ for commands in command_sequences(node):
114
+ cmds = list(commands)
115
+ while cmds and _is_cleanup(cmds[-1]):
116
+ cmds.pop()
117
+ check_until = len(cmds) if in_loop else len(cmds) - 1
118
+ for cmd in cmds[:max(check_until, 0)]:
119
+ if cmd.kind == "T_SimpleCommand" and len(cmd.words) >= 2 \
120
+ and literal_text(cmd.words[0]) == "exec":
121
+ ctx.warn(cmd, 2093, 'Remove "exec " if script should'
122
+ " continue after this command.")
123
+
124
+
125
+ def _is_cleanup(cmd):
126
+ if cmd.kind != "T_SimpleCommand":
127
+ return False
128
+ if not cmd.words:
129
+ return bool(cmd.assigns)
130
+ return first_word_basename(cmd) in CLEANUP_COMMANDS
131
+
132
+
133
+ # SC2094: reading and writing the same file in a pipeline
134
+
135
+ @node_check("T_Pipeline", "T_SimpleCommand")
136
+ def check_redirect_to_same(ctx, node):
137
+ if node.kind == "T_SimpleCommand":
138
+ if node.parent is not None and node.parent.kind == "T_Pipeline":
139
+ return
140
+ commands = [node]
141
+ else:
142
+ commands = node.commands
143
+ reads = {}
144
+ writes = {}
145
+ for cmd in commands:
146
+ for r, target, is_write in _redirect_files(ctx, cmd):
147
+ d = writes if is_write else reads
148
+ d.setdefault(target, r)
149
+ if cmd.kind == "T_SimpleCommand" \
150
+ and first_word_basename(cmd) not in ("echo", "printf"):
151
+ for w in cmd.words[1:]:
152
+ text = _file_word_text(w)
153
+ if text:
154
+ reads.setdefault(text, w)
155
+ for target, r in writes.items():
156
+ if target in reads and target not in ("/dev/null", "/dev/stdin",
157
+ "/dev/stdout", "/dev/tty"):
158
+ ctx.info(r, 2094, "Make sure not to read and write the same"
159
+ " file in the same pipeline.")
160
+ other = reads[target]
161
+ ctx.info(other, 2094, "Make sure not to read and write the"
162
+ " same file in the same pipeline.")
163
+ return
164
+
165
+
166
+ def _redirect_files(ctx, cmd):
167
+ for r in cmd.get("redirects", ()) or ():
168
+ op = r.op
169
+ if isinstance(op, str) or op.kind != "T_IoFile":
170
+ continue
171
+ text = _file_word_text(op.file)
172
+ if not text:
173
+ continue
174
+ yield r, text, op.op in (">", ">>", "&>", "&>>", ">|")
175
+
176
+
177
+ def _file_word_text(word):
178
+ text = word_approx(word)
179
+ if not text or text.startswith("-"):
180
+ return None
181
+ if "\0" in text:
182
+ return None
183
+ from ..astlib import has_expansions
184
+ if has_expansions(word):
185
+ # render expansions textually so $file == $file matches
186
+ out = []
187
+ for p in word_parts(word):
188
+ if p.kind == "T_DollarBraced":
189
+ out.append("${%s}" % p.content)
190
+ elif p.kind == "T_Literal":
191
+ out.append(p.text)
192
+ elif p.kind in ("T_SingleQuoted", "T_DollarSingleQuoted"):
193
+ out.append(p.text)
194
+ elif p.kind in ("T_DoubleQuoted",):
195
+ for q in p.parts:
196
+ if q.kind == "T_Literal":
197
+ out.append(q.text)
198
+ elif q.kind == "T_DollarBraced":
199
+ out.append("${%s}" % q.content)
200
+ else:
201
+ return None
202
+ else:
203
+ return None
204
+ return "".join(out)
205
+ return text
206
+
207
+
208
+ # SC2028: echo with escape sequences
209
+
210
+ ECHO_ESCAPE_RE = re.compile(r"\\[ntrabceEfv]|\\x[0-9a-fA-F]|\\[0-7]")
211
+
212
+
213
+ @node_check("T_SimpleCommand")
214
+ def check_unused_echo_escapes(ctx, node):
215
+ if first_word_basename(node) != "echo":
216
+ return
217
+ args = node.words[1:]
218
+ if args:
219
+ lit = literal_text(args[0])
220
+ if lit and lit.startswith("-") and "e" in lit:
221
+ return
222
+ for w in args:
223
+ for p in word_parts(w):
224
+ text = None
225
+ if p.kind == "T_SingleQuoted":
226
+ text = p.text
227
+ elif p.kind == "T_DoubleQuoted":
228
+ text = "".join(q.text for q in p.parts
229
+ if q.kind == "T_Literal")
230
+ if text and ECHO_ESCAPE_RE.search(text):
231
+ ctx.info(p, 2028, "echo may not expand escape sequences."
232
+ " Use printf.")
233
+ return
@@ -0,0 +1,440 @@
1
+ """Quoting and word-splitting checks (SC2086 family and friends)."""
2
+
3
+ import re
4
+
5
+ from ..analyzer import node_check, tree_check
6
+ from ..astlib import (
7
+ CLEAN, SPECIAL_VARIABLES_WITHOUT_SPACES, UNBRACED_VARIABLES,
8
+ braced_modifier, braced_reference, closest_command, expanded_parts,
9
+ is_array_expansion, is_counting_reference, is_quote_free,
10
+ is_quoted_alternative_reference, word_parts,
11
+ )
12
+ from ..parser import literal_text, quoted_literal_text
13
+ from ..shast import ancestors
14
+ from ..varflow import VarFlow
15
+
16
+ WILL_SPLIT_KINDS = frozenset({
17
+ "T_DollarBraced", "T_DollarExpansion", "T_Backticked",
18
+ "T_BraceExpansion", "T_Glob", "T_Extglob",
19
+ "T_DollarBraceCommandExpansion",
20
+ })
21
+
22
+
23
+ def will_split(part):
24
+ if part.kind == "T_NormalWord":
25
+ return any(will_split(p) for p in part.parts)
26
+ return part.kind in WILL_SPLIT_KINDS
27
+
28
+
29
+ def may_become_multiple_args(word):
30
+ for p in expanded_parts(word):
31
+ if is_array_expansion(p):
32
+ return True
33
+ if p.kind == "T_DollarBraced" and p.content.startswith("!"):
34
+ return True
35
+ return False
36
+
37
+
38
+ def command_word_of(ctx, node):
39
+ """True if node is (part of) the effective command name word."""
40
+ word = ctx.parent_word(node)
41
+ if word is None:
42
+ return False
43
+ cmd = closest_command(word)
44
+ if cmd is None or not cmd.words:
45
+ return False
46
+ if word is cmd.words[0]:
47
+ return True
48
+ return word is ctx.command_name_word(cmd)
49
+
50
+
51
+ # ----------------------------------------------------------------------
52
+ # SC2086 / SC2223 / SC2248 spacefulness, and SC2089/SC2090 quotes-in-vars
53
+
54
+ QUOTE_CHARS_RE = re.compile(r'"|([/= ]|^)\'|\'( |$)|\\ ')
55
+
56
+
57
+ @tree_check
58
+ def check_spacefulness(ctx, root):
59
+ reported = set()
60
+ quote_holders = {} # var name -> assignment node that embedded quotes
61
+
62
+ def word_has_quotes(word):
63
+ """Does this value contain shell quotes? Returns witness node."""
64
+ if word is None:
65
+ return None
66
+ for p in word_parts(word):
67
+ k = p.kind
68
+ if k in ("T_DoubleQuoted", "T_DollarDoubleQuoted"):
69
+ for q in p.parts:
70
+ w = word_has_quotes(q)
71
+ if w is not None:
72
+ return w
73
+ elif k == "T_DollarBraced":
74
+ if p.content in quote_holders:
75
+ return quote_holders[p.content]
76
+ elif k in ("T_Literal", "T_SingleQuoted",
77
+ "T_DollarSingleQuoted"):
78
+ text = p.text
79
+ if k == "T_Literal" and p.get("escaped"):
80
+ text = "\\" + text
81
+ if QUOTE_CHARS_RE.search(text):
82
+ return p
83
+ return None
84
+
85
+ def on_assign(name, value, node):
86
+ if value is not None and value.kind == "T_Array":
87
+ return
88
+ witness = word_has_quotes(value)
89
+ if witness is None:
90
+ quote_holders.pop(name, None)
91
+ else:
92
+ quote_holders[name] = witness if witness.kind == "T_Literal" \
93
+ or "Quoted" in witness.kind else witness
94
+
95
+ def on_reference(node, name, status, integer):
96
+ if node.pos in reported:
97
+ return
98
+ reported.add(node.pos)
99
+ if is_array_expansion(node):
100
+ return
101
+ if is_counting_reference(node):
102
+ return
103
+ if is_quoted_alternative_reference(node):
104
+ return
105
+ if is_quote_free(node, ctx.shell):
106
+ return
107
+ check_quotes_in_literals(node, name)
108
+ if command_word_of(ctx, node):
109
+ return
110
+ if name in SPECIAL_VARIABLES_WITHOUT_SPACES:
111
+ return
112
+ if status == CLEAN or integer:
113
+ ctx.style(node, 2248, "Prefer double quoting even when variables"
114
+ " don't contain special characters.")
115
+ return
116
+ if is_default_assignment(node):
117
+ ctx.info(node, 2223, "This default assignment may cause DoS due"
118
+ " to globbing. Quote it.")
119
+ else:
120
+ ctx.info(node, 2086, "Double quote to prevent globbing and word"
121
+ " splitting.")
122
+
123
+ def check_quotes_in_literals(node, name):
124
+ witness = quote_holders.get(node.content)
125
+ if witness is None:
126
+ return
127
+ cmd = closest_command(node)
128
+ if cmd is not None and cmd.words:
129
+ first = literal_text(cmd.words[0])
130
+ if first and first.rsplit("/", 1)[-1] == "eval":
131
+ return
132
+ ctx.warn(witness, 2089, "Quotes/backslashes will be treated "
133
+ "literally. Use an array.")
134
+ ctx.warn(node, 2090, "Quotes/backslashes in this variable will "
135
+ "not be respected.")
136
+
137
+ def is_default_assignment(node):
138
+ mod = braced_modifier(node.content)
139
+ if not (mod.startswith("=") or mod.startswith(":=")):
140
+ return False
141
+ cmd = closest_command(node)
142
+ return cmd is not None and bool(cmd.words) \
143
+ and literal_text(cmd.words[0]) == ":"
144
+
145
+ VarFlow(on_reference, ctx.shell, on_assign=on_assign).run(root)
146
+
147
+
148
+ # ----------------------------------------------------------------------
149
+ # SC2046: unquoted command substitution
150
+
151
+ @node_check("T_DollarExpansion", "T_Backticked",
152
+ "T_DollarBraceCommandExpansion")
153
+ def check_unquoted_expansions(ctx, node):
154
+ if not node.commands:
155
+ return
156
+ if expansion_command_name(node) in ("seq", "pgrep"):
157
+ return
158
+ if is_quote_free(node, ctx.shell):
159
+ return
160
+ if command_word_of(ctx, node):
161
+ return
162
+ ctx.warn(node, 2046, "Quote this to prevent word splitting.")
163
+
164
+
165
+ def expansion_command_name(node):
166
+ cmds = node.commands
167
+ if len(cmds) != 1 or cmds[0].kind != "T_SimpleCommand":
168
+ return None
169
+ if not cmds[0].words:
170
+ return None
171
+ name = literal_text(cmds[0].words[0])
172
+ return name.rsplit("/", 1)[-1] if name else None
173
+
174
+
175
+ # ----------------------------------------------------------------------
176
+ # SC2068: unquoted array expansions; SC2145: string/array concatenation
177
+
178
+ @node_check("T_NormalWord")
179
+ def check_unquoted_dollar_at(ctx, word):
180
+ if is_quote_free(word, ctx.shell, strict=True):
181
+ return
182
+ for p in word.parts:
183
+ if is_array_expansion(p):
184
+ if not is_quoted_alternative_reference(p):
185
+ ctx.err(p, 2068, "Double quote array expansions to avoid"
186
+ " re-splitting elements.")
187
+ return
188
+
189
+
190
+ @node_check("T_NormalWord")
191
+ def check_concatenated_dollar_at(ctx, word):
192
+ parts = expanded_parts(word)
193
+ if len(parts) <= 1:
194
+ return
195
+ if is_quote_free(word, ctx.shell):
196
+ return
197
+ for p in parts:
198
+ if is_array_expansion(p):
199
+ ctx.err(p, 2145, "Argument mixes string and array. Use * or"
200
+ " separate argument.")
201
+ return
202
+
203
+
204
+ # ----------------------------------------------------------------------
205
+ # SC2048: $* and ${a[*]}
206
+
207
+ @node_check("T_DollarBraced")
208
+ def check_dollar_star(ctx, node):
209
+ content = node.content
210
+ if content.startswith("#"):
211
+ return
212
+ name = braced_reference(content)
213
+ is_star = name == "*"
214
+ if not is_star:
215
+ m = re.match(r"[A-Za-z_][A-Za-z0-9_]*\[\*\]", content)
216
+ is_star = bool(m)
217
+ if not is_star:
218
+ return
219
+ if is_quote_free(node, ctx.shell, strict=True):
220
+ return
221
+ ctx.warn(node, 2048, 'Use "$@" (with quotes) to prevent whitespace'
222
+ ' problems.')
223
+
224
+
225
+ # ----------------------------------------------------------------------
226
+ # SC2006: legacy backticks
227
+
228
+ @node_check("T_Backticked")
229
+ def check_backticks(ctx, node):
230
+ if not node.commands:
231
+ return
232
+ ctx.style(node, 2006, "Use $(...) notation instead of legacy"
233
+ " backticks `...`.")
234
+
235
+
236
+ # ----------------------------------------------------------------------
237
+ # SC2016: expressions in single quotes
238
+
239
+ SQ_DOLLAR_RE = re.compile(r"\$[{(0-9a-zA-Z_]|`[^`]+`")
240
+ SED_CONTRA_RE = re.compile(r"\$[{dpsaic]($|[^a-zA-Z])")
241
+
242
+ SQ_OK_COMMANDS = frozenset({
243
+ "trap", "sh", "bash", "ksh", "zsh", "ssh", "eval", "xprop", "alias",
244
+ "sudo", "doas", "run0", "docker", "podman", "oc", "dpkg-query", "jq",
245
+ "rename", "rg", "unset", "crontab", "watch", "git filter-branch",
246
+ })
247
+
248
+ PROMPT_VARS = frozenset({"PS1", "PS2", "PS3", "PS4", "PROMPT_COMMAND"})
249
+
250
+
251
+ @node_check("T_SingleQuoted")
252
+ def check_single_quoted_variables(ctx, node):
253
+ if not SQ_DOLLAR_RE.search(node.text):
254
+ return
255
+ names = _sq_command_chain(ctx, node)
256
+ if "sed" in names:
257
+ if not SED_CONTRA_RE.search(node.text):
258
+ ctx.info(node, 2016, "Expressions don't expand in single quotes,"
259
+ " use double quotes for that.")
260
+ return
261
+ for name in names:
262
+ if name in SQ_OK_COMMANDS or name.endswith("awk") \
263
+ or name.startswith("perl") or name.startswith("mumps"):
264
+ return
265
+ chain = [node] + list(ancestors(node))[:3]
266
+ for a in chain[1:4]:
267
+ if a.kind == "T_Assignment" and a.name in PROMPT_VARS:
268
+ return
269
+ if a.kind == "TC_Unary" and a.op == "-v":
270
+ return
271
+ ctx.info(node, 2016, "Expressions don't expand in single quotes, use"
272
+ " double quotes for that.")
273
+
274
+
275
+ def _sq_command_chain(ctx, node):
276
+ """All command names involved: wrappers plus the effective command."""
277
+ cmd = closest_command(node)
278
+ if cmd is None:
279
+ return []
280
+ raw = literal_text(cmd.words[0]) if cmd.words else None
281
+ names = [raw.rsplit("/", 1)[-1]] if raw else []
282
+ word, idx, wrappers = ctx.command_resolution(cmd)
283
+ names.extend(wrappers)
284
+ name = literal_text(word) if word is not None else None
285
+ if name:
286
+ name = name.rsplit("/", 1)[-1]
287
+ names.append(name)
288
+ args = [literal_text(w) for w in cmd.words]
289
+ if name == "find":
290
+ for i, a in enumerate(args):
291
+ if a in ("-exec", "-execdir", "-ok", "-okdir") \
292
+ and i + 1 < len(args) and args[i + 1]:
293
+ names.append(args[i + 1].rsplit("/", 1)[-1])
294
+ elif name == "git" and len(args) > idx + 1 \
295
+ and args[idx + 1] == "filter-branch":
296
+ names.append("git filter-branch")
297
+ return names
298
+
299
+
300
+ # ----------------------------------------------------------------------
301
+ # SC2066 / SC2041 / SC2042 / SC2043 / SC2258: for-in words
302
+
303
+ @node_check("T_ForIn")
304
+ def check_for_in_quoted(ctx, node):
305
+ words = node.words
306
+ if not node.has_in:
307
+ return
308
+ if len(words) == 1:
309
+ word = words[0]
310
+ parts = word_parts(word)
311
+ if len(parts) == 1 and parts[0].kind == "T_DoubleQuoted":
312
+ dq = parts[0]
313
+ lit = quoted_literal_text(word)
314
+ if (any(will_split(p) for p in dq.parts)
315
+ and not may_become_multiple_args(word)) \
316
+ or (lit is not None
317
+ and any(c in lit for c in "*?[")):
318
+ ctx.err(dq, 2066, "Since you double quoted this, it will"
319
+ " not word split, and the loop will only run once.")
320
+ return
321
+ if len(parts) == 1 and parts[0].kind == "T_SingleQuoted":
322
+ ctx.warn(parts[0], 2041, "This is a literal string. To run as"
323
+ " a command, use $(..) instead of '..'.")
324
+ return
325
+ unquoted = _unquoted_literal(word)
326
+ if unquoted is not None and "," in unquoted:
327
+ ctx.warn(word, 2042, "Use spaces, not commas, to separate loop"
328
+ " elements.")
329
+ return
330
+ if not will_split(word) and not may_become_multiple_args(word):
331
+ ctx.warn(word, 2043, "This loop will only ever run once. Bad"
332
+ " quoting or missing glob/expansion?")
333
+ return
334
+ for word in words:
335
+ suffix = _trailing_unquoted_literal(word)
336
+ if suffix is not None and suffix.text.endswith(","):
337
+ ctx.warn(word, 2258, "The trailing comma is part of the value,"
338
+ " not a separator. Delete or quote it.")
339
+
340
+
341
+ def _unquoted_literal(word):
342
+ out = []
343
+ for p in word_parts(word):
344
+ if p.kind != "T_Literal":
345
+ return None
346
+ out.append(p.text)
347
+ return "".join(out)
348
+
349
+
350
+ def _trailing_unquoted_literal(word):
351
+ parts = word_parts(word)
352
+ if parts and parts[-1].kind == "T_Literal" \
353
+ and not parts[-1].get("escaped"):
354
+ return parts[-1]
355
+ return None
356
+
357
+
358
+ # ----------------------------------------------------------------------
359
+ # SC2206 / SC2207: word splitting in array assignments
360
+
361
+ @node_check("T_Array")
362
+ def check_splitting_in_arrays(ctx, node):
363
+ for element in node.elements:
364
+ value = element.value if element.kind == "T_IndexedElement" \
365
+ else element
366
+ for p in word_parts(value):
367
+ k = p.kind
368
+ if k in ("T_DollarExpansion", "T_Backticked",
369
+ "T_DollarBraceCommandExpansion"):
370
+ ctx.warn(p, 2207, "Prefer mapfile or read -a to split"
371
+ " command output (or quote to avoid splitting).")
372
+ elif k == "T_DollarBraced":
373
+ name = braced_reference(p.content)
374
+ if name in SPECIAL_VARIABLES_WITHOUT_SPACES:
375
+ continue
376
+ if is_counting_reference(p):
377
+ continue
378
+ ctx.warn(p, 2206, "Quote to prevent word splitting/globbing,"
379
+ " or split robustly with mapfile or read -a.")
380
+
381
+
382
+ # ----------------------------------------------------------------------
383
+ # SC2026 / SC2027 / SC2140: inexplicably unquoted words
384
+
385
+ @node_check("T_NormalWord")
386
+ def check_inexplicably_unquoted(ctx, word):
387
+ parts = word.parts
388
+ for i in range(len(parts) - 1):
389
+ a, b = parts[i], parts[i + 1]
390
+ if a.kind == "T_SingleQuoted" and b.kind == "T_Literal" \
391
+ and b.text and b.text.isalnum():
392
+ ctx.info(b, 2026, "This word is outside of quotes. Did you"
393
+ " intend to 'nest '\"'single quotes'\"' instead'?")
394
+ if i + 2 < len(parts) and a.kind == "T_DoubleQuoted" \
395
+ and parts[i + 2].kind == "T_DoubleQuoted":
396
+ trapped = b
397
+ if trapped.kind in ("T_DollarExpansion", "T_DollarBraced"):
398
+ ctx.warn(trapped, 2027, "The surrounding quotes actually"
399
+ " unquote this. Remove or escape them.")
400
+ elif trapped.kind == "T_Literal":
401
+ if trapped.text in ("=", ":", "/"):
402
+ continue
403
+ if _quotes_single_thing(a) and \
404
+ _quotes_single_thing(parts[i + 2]):
405
+ continue
406
+ if _is_regex_context(word):
407
+ continue
408
+ ctx.warn(trapped, 2140, 'Word is of the form "A"B"C"'
409
+ ' (B indicated). Did you mean "ABC" or'
410
+ ' "A\\"B\\"C"?')
411
+
412
+
413
+ def _quotes_single_thing(dq):
414
+ return len(dq.parts) == 1 and dq.parts[0].kind in (
415
+ "T_DollarExpansion", "T_DollarBraced", "T_Backticked")
416
+
417
+
418
+ def _is_regex_context(word):
419
+ for a in ancestors(word):
420
+ if a.kind == "TC_Binary" and a.op == "=~" and a.rhs is word:
421
+ return True
422
+ if a.kind in ("T_SimpleCommand", "T_Script"):
423
+ return False
424
+ return False
425
+
426
+
427
+ # ----------------------------------------------------------------------
428
+ # SC2250 (optional): prefer braces around variable references
429
+
430
+ @node_check("T_DollarBraced")
431
+ def check_variable_braces(ctx, node):
432
+ if node.get("braced"):
433
+ return
434
+ name = braced_reference(node.content)
435
+ if name in UNBRACED_VARIABLES:
436
+ return
437
+ if command_word_of(ctx, node):
438
+ return
439
+ ctx.style(node, 2250, "Prefer putting braces around variable references"
440
+ " even when not strictly required.")