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,348 @@
1
+ """Variable lifecycle and array checks."""
2
+
3
+ import re
4
+
5
+ from ..analyzer import node_check, tree_check
6
+ from ..astlib import braced_modifier, braced_reference
7
+ from ..parser import literal_text
8
+ from ..shast import ancestors, walk
9
+ from ..varscan import NAME_RE, VarScan, levenshtein
10
+
11
+ INTERNAL_VARIABLES = frozenset({
12
+ "_", "rest", "REST", "CDPATH", "ENV", "FCEDIT", "HISTFILE", "HISTSIZE",
13
+ "HOME", "IFS", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE",
14
+ "LC_MESSAGES", "LC_MONETARY", "LC_NUMERIC", "LC_TIME", "MAIL",
15
+ "MAILCHECK", "MAILPATH", "OLDPWD", "OPTARG", "OPTIND", "PATH", "PWD",
16
+ "BASH", "BASHOPTS", "BASHPID", "BASH_ALIASES", "BASH_ARGC",
17
+ "BASH_ARGV", "BASH_ARGV0", "BASH_CMDS", "BASH_COMMAND",
18
+ "BASH_EXECUTION_STRING", "BASH_LINENO", "BASH_LOADABLES_PATH",
19
+ "BASH_REMATCH", "BASH_SOURCE", "BASH_SUBSHELL", "BASH_VERSINFO",
20
+ "BASH_VERSION", "COMP_CWORD", "COMP_KEY", "COMP_LINE", "COMP_POINT",
21
+ "COMP_TYPE", "COMP_WORDBREAKS", "COMP_WORDS", "COPROC", "DIRSTACK",
22
+ "EPOCHREALTIME", "EPOCHSECONDS", "EUID", "FUNCNAME", "GROUPS",
23
+ "HISTCMD", "HOSTNAME", "HOSTTYPE", "MACHTYPE", "MAPFILE", "OSTYPE",
24
+ "PIPESTATUS", "RANDOM", "READLINE_ARGUMENT", "READLINE_LINE",
25
+ "READLINE_MARK", "READLINE_POINT", "REPLY", "SECONDS", "SHELLOPTS",
26
+ "SHLVL", "SRANDOM", "UID", "BASH_COMPAT", "BASH_ENV", "BASH_XTRACEFD",
27
+ "CHILD_MAX", "COLUMNS", "COMPREPLY", "EMACS", "EXECIGNORE", "FIGNORE",
28
+ "FUNCNEST", "GLOBIGNORE", "HISTCONTROL", "HISTFILESIZE", "HISTIGNORE",
29
+ "HISTTIMEFORMAT", "HOSTFILE", "IGNOREEOF", "INPUTRC", "INSIDE_EMACS",
30
+ "LINES", "OPTERR", "POSIXLY_CORRECT", "PROMPT_COMMAND",
31
+ "PROMPT_DIRTRIM", "PS0", "PS1", "PS2", "PS3", "PS4", "SHELL",
32
+ "TIMEFORMAT", "TMOUT", "BASH_MONOSECONDS", "BASH_TRAPSIG", "GLOBSORT",
33
+ "auto_resume", "histchars", "USER", "TZ", "TERM", "LOGNAME",
34
+ "LD_LIBRARY_PATH", "LANGUAGE", "DISPLAY", "KRB5CCNAME", "LINENO",
35
+ "PPID", "TMPDIR", "XAUTHORITY", "FLAGS_ARGC", "FLAGS_ARGV",
36
+ "FLAGS_ERROR", "FLAGS_FALSE", "FLAGS_HELP", "FLAGS_PARENT",
37
+ "FLAGS_RESERVED", "FLAGS_TRUE", "FLAGS_VERSION", "flags_error",
38
+ "flags_return", "stderr", "stderr_lines", "status", "output", "lines",
39
+ })
40
+
41
+ INTERNAL_ARRAYS = frozenset({
42
+ "BASH_ALIASES", "BASH_ARGC", "BASH_ARGV", "BASH_CMDS", "BASH_LINENO",
43
+ "BASH_REMATCH", "BASH_SOURCE", "BASH_VERSINFO", "COMP_WORDS",
44
+ "COPROC", "DIRSTACK", "FUNCNAME", "GROUPS", "MAPFILE", "PIPESTATUS",
45
+ "COMPREPLY", "lines",
46
+ })
47
+
48
+ COMMON_COMMANDS_HINT = frozenset({
49
+ "cat", "cut", "date", "find", "grep", "head", "ls", "sed", "sort",
50
+ "tail", "wc", "whoami", "pwd", "dirname", "basename",
51
+ })
52
+
53
+
54
+ def get_varscan(ctx):
55
+ scan = ctx.cache.get("varscan")
56
+ if scan is None:
57
+ scan = VarScan(ctx.root, ctx.shell)
58
+ ctx.cache["varscan"] = scan
59
+ return scan
60
+
61
+
62
+ # ----------------------------------------------------------------------
63
+ # SC2034: unused variables
64
+
65
+ @tree_check
66
+ def check_unused_assignments(ctx, root):
67
+ scan = get_varscan(ctx)
68
+ referenced = set()
69
+ prefixes = []
70
+ for ref in scan.refs:
71
+ if ref.kind == "prefix":
72
+ prefixes.append(ref.name)
73
+ else:
74
+ referenced.add(ref.name)
75
+ exported = {a.name for a in scan.assigns if a.exported}
76
+ first_assign = {}
77
+ for a in scan.assigns:
78
+ if a.kind == "checked":
79
+ referenced.add(a.name)
80
+ continue
81
+ if _is_env_prefix_assignment(a.node):
82
+ referenced.add(a.name)
83
+ continue
84
+ if a.name not in first_assign:
85
+ first_assign[a.name] = a
86
+ for name, a in sorted(first_assign.items(),
87
+ key=lambda kv: kv[1].node.pos):
88
+ if name in referenced or name in exported:
89
+ continue
90
+ if name.startswith("_"):
91
+ continue
92
+ if name in INTERNAL_VARIABLES or not NAME_RE.match(name):
93
+ continue
94
+ if any(name.startswith(p) for p in prefixes):
95
+ continue
96
+ ctx.warn(a.node, 2034, "%s appears unused. Verify use (or export"
97
+ " if used externally)." % name)
98
+
99
+
100
+ # ----------------------------------------------------------------------
101
+ # SC2154 / SC2153: referenced but not assigned
102
+
103
+ @tree_check
104
+ def check_unassigned_references(ctx, root):
105
+ scan = get_varscan(ctx)
106
+ assigned = {a.name for a in scan.assigns}
107
+ written = sorted(a.name for a in scan.assigns
108
+ if a.kind != "checked" and NAME_RE.match(a.name))
109
+ seen = set()
110
+ for ref in scan.refs:
111
+ name = ref.name
112
+ if name in seen:
113
+ continue
114
+ if not NAME_RE.match(name):
115
+ continue
116
+ if name in assigned or name in INTERNAL_VARIABLES:
117
+ continue
118
+ if ref.kind in ("guarded", "index-of-other", "prefix"):
119
+ seen.add(name)
120
+ continue
121
+ seen.add(name)
122
+ if name.lower() == name or any(c.islower() for c in name):
123
+ match = _best_match(name, written)
124
+ tip = ""
125
+ if name in COMMON_COMMANDS_HINT:
126
+ tip = ' (for output from commands, use "$(%s ...)" )' % name
127
+ elif match:
128
+ tip = " (did you mean '%s'?)" % match
129
+ ctx.warn(ref.node, 2154,
130
+ "%s is referenced but not assigned%s." % (name, tip))
131
+ else:
132
+ match = _best_match(name, written)
133
+ if match:
134
+ ctx.info(ref.node, 2153, "Possible misspelling: %s may not"
135
+ " be assigned. Did you mean %s?" % (name, match))
136
+
137
+
138
+ def _is_env_prefix_assignment(node):
139
+ """FOO=bar somecommand: the assignment is for the command's env."""
140
+ if node.kind != "T_Assignment":
141
+ return False
142
+ parent = node.parent
143
+ if parent is None or parent.kind != "T_SimpleCommand":
144
+ return False
145
+ if node not in parent.assigns or not parent.words:
146
+ return False
147
+ from ..parser import literal_text
148
+ cmd = literal_text(parent.words[0])
149
+ if cmd:
150
+ cmd = cmd.rsplit("/", 1)[-1]
151
+ return cmd not in ("declare", "typeset", "local", "export", "readonly")
152
+
153
+
154
+ def _best_match(var, candidates):
155
+ best = None
156
+ best_score = 99
157
+ for c in candidates:
158
+ if c == var:
159
+ continue
160
+ if c.lower() == var.lower():
161
+ score = 1
162
+ elif abs(len(c) - len(var)) > 2:
163
+ continue
164
+ else:
165
+ score = levenshtein(var, c)
166
+ if score < best_score:
167
+ best, best_score = c, score
168
+ if best is None:
169
+ return None
170
+ if len(best) > 7 and best_score <= 2:
171
+ return best
172
+ if len(best) > 3 and best_score <= 1:
173
+ return best
174
+ return None
175
+
176
+
177
+ # ----------------------------------------------------------------------
178
+ # SC2004: $ on arithmetic variables
179
+
180
+ @node_check("TA_Expansion")
181
+ def check_arithmetic_deref(ctx, node):
182
+ if len(node.parts) != 1 or node.parts[0].kind != "T_DollarBraced":
183
+ return
184
+ braced = node.parts[0]
185
+ content = braced.content
186
+ name = braced_reference(content)
187
+ if not NAME_RE.match(name):
188
+ return
189
+ if braced_modifier(content) or content.startswith(("#", "!")):
190
+ return
191
+ if "[" in content:
192
+ return
193
+ if _in_let(node):
194
+ return
195
+ ctx.style(braced, 2004, "$/${} is unnecessary on arithmetic"
196
+ " variables.")
197
+
198
+
199
+ @tree_check
200
+ def check_assignment_index_deref(ctx, root):
201
+ # a[$i]=foo for indexed arrays
202
+ scan = get_varscan(ctx)
203
+ for node in walk(root):
204
+ if node.kind != "T_Assignment":
205
+ continue
206
+ if node.name in scan.assoc_arrays:
207
+ continue
208
+ for idx in node.get("indices", ()):
209
+ if isinstance(idx, str) \
210
+ and re.fullmatch(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?",
211
+ idx.strip()):
212
+ ctx.style(node, 2004, "$/${} is unnecessary on arithmetic"
213
+ " variables.")
214
+
215
+
216
+ def _in_let(node):
217
+ for a in ancestors(node):
218
+ if a.kind == "T_SimpleCommand":
219
+ name = literal_text(a.words[0]) if a.words else None
220
+ return name == "let"
221
+ if a.kind in ("T_Arithmetic", "T_DollarArithmetic", "T_Script",
222
+ "T_Condition"):
223
+ return False
224
+ return False
225
+
226
+
227
+ @node_check("T_Arithmetic", "T_DollarArithmetic")
228
+ def check_array_index_deref(ctx, node):
229
+ # (( a[$i] )) -- $ on the index variable
230
+ scan = get_varscan(ctx)
231
+ for n in walk(node):
232
+ if n.kind != "TA_Variable":
233
+ continue
234
+ if n.name in scan.assoc_arrays:
235
+ continue
236
+ for idx in n.get("indices", ()):
237
+ if idx.kind == "TA_Expansion" and len(idx.parts) == 1 \
238
+ and idx.parts[0].kind == "T_DollarBraced":
239
+ content = idx.parts[0].content
240
+ if NAME_RE.match(braced_reference(content)) \
241
+ and not braced_modifier(content) \
242
+ and not content.startswith(("#", "!")):
243
+ ctx.style(idx.parts[0], 2004, "$/${} is unnecessary on"
244
+ " arithmetic variables.")
245
+
246
+
247
+ # ----------------------------------------------------------------------
248
+ # SC2128 / SC2178 / SC2179: arrays used as strings
249
+
250
+ @tree_check
251
+ def check_array_without_index(ctx, root):
252
+ scan = get_varscan(ctx)
253
+ events = [] # (pos, type, name, node)
254
+ for a in scan.assigns:
255
+ if not NAME_RE.match(a.name):
256
+ continue
257
+ node = a.node
258
+ if node.kind == "T_Assignment":
259
+ value = node.get("value")
260
+ is_arr = value is not None and value.kind == "T_Array"
261
+ if node.get("indices"):
262
+ is_arr = True
263
+ events.append((node.end, "array" if is_arr else "string",
264
+ a.name, node, a.append))
265
+ elif a.is_array:
266
+ events.append((node.end, "array", a.name, node, False))
267
+ for ref in scan.refs:
268
+ node = ref.node
269
+ if node.kind != "T_DollarBraced":
270
+ continue
271
+ content = node.content
272
+ if content != ref.name:
273
+ continue # only plain $var / ${var}
274
+ events.append((node.pos, "ref", ref.name, node, False))
275
+ events.sort(key=lambda e: e[0])
276
+ arrays = set(INTERNAL_ARRAYS)
277
+ warned = set()
278
+ for pos, etype, name, node, append in events:
279
+ if etype == "array":
280
+ arrays.add(name)
281
+ elif etype == "string":
282
+ if name in arrays:
283
+ if (name, pos) in warned:
284
+ continue
285
+ warned.add((name, pos))
286
+ if append:
287
+ ctx.warn(node, 2179, 'Use array+=("item") to append'
288
+ " items to an array.")
289
+ else:
290
+ ctx.warn(node, 2178, "Variable was used as an array"
291
+ " but is now assigned a string.")
292
+ arrays.discard(name)
293
+ else:
294
+ if name in arrays:
295
+ if name in warned:
296
+ continue
297
+ warned.add(name)
298
+ ctx.warn(node, 2128, "Expanding an array without an index"
299
+ " only gives the first element.")
300
+
301
+
302
+ # ----------------------------------------------------------------------
303
+ # SC2155: declare and assign separately
304
+
305
+ @node_check("T_SimpleCommand")
306
+ def check_masked_returns(ctx, node):
307
+ if not node.words or not node.assigns:
308
+ return
309
+ name = literal_text(node.words[0])
310
+ if name:
311
+ name = name.rsplit("/", 1)[-1]
312
+ if name not in ("declare", "typeset", "local", "readonly", "export"):
313
+ return
314
+ flags = ""
315
+ for w in node.words[1:]:
316
+ t = literal_text(w)
317
+ if t and t.startswith("-"):
318
+ flags += t[1:]
319
+ is_scoped = _in_scoped_function(ctx, node)
320
+ is_local = (name in ("local", "declare", "typeset")
321
+ and "g" not in flags and is_scoped)
322
+ is_readonly = name == "readonly" or "r" in flags
323
+ if is_local and is_readonly:
324
+ return
325
+ from ..astlib import expanded_parts
326
+ for assign in node.assigns:
327
+ value = assign.get("value")
328
+ if value is None:
329
+ continue
330
+ for p in expanded_parts(value):
331
+ if p.kind in ("T_DollarExpansion", "T_Backticked",
332
+ "T_DollarBraceCommandExpansion"):
333
+ ctx.warn(assign, 2155, "Declare and assign separately to"
334
+ " avoid masking return values.")
335
+ break
336
+ else:
337
+ continue
338
+
339
+
340
+ def _in_scoped_function(ctx, node):
341
+ for a in ancestors(node):
342
+ if a.kind == "T_BatsTest":
343
+ return True
344
+ if a.kind == "T_Function":
345
+ if ctx.shell == "ksh":
346
+ return a.get("keyword_form", False)
347
+ return True
348
+ return False
pureshellcheck/cli.py ADDED
@@ -0,0 +1,162 @@
1
+ """Command line interface, modeled on shellcheck's."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import __version__, run_checks
8
+
9
+ SEVERITY_RANK = {"error": 0, "warning": 1, "info": 2, "style": 3}
10
+
11
+ COLORS = {
12
+ "error": "\x1b[1;31m",
13
+ "warning": "\x1b[1;33m",
14
+ "info": "\x1b[1;32m",
15
+ "style": "\x1b[1;32m",
16
+ "message": "\x1b[1m",
17
+ "source": "",
18
+ "reset": "\x1b[0m",
19
+ }
20
+
21
+
22
+ def parse_args(argv):
23
+ p = argparse.ArgumentParser(
24
+ prog="pureshellcheck",
25
+ description="Lint shell scripts (pure Python port of ShellCheck's"
26
+ " most common checks)")
27
+ p.add_argument("files", nargs="+", metavar="FILE",
28
+ help="script files, or - for stdin")
29
+ p.add_argument("-s", "--shell",
30
+ choices=["sh", "bash", "dash", "ash", "ksh", "busybox"],
31
+ help="specify dialect (default: detect from shebang)")
32
+ p.add_argument("-f", "--format", default="tty",
33
+ choices=["tty", "gcc", "json", "json1"],
34
+ help="output format (default: tty)")
35
+ p.add_argument("-e", "--exclude", action="append", default=[],
36
+ metavar="CODE1,CODE2..", help="exclude these checks")
37
+ p.add_argument("-S", "--severity", default="style",
38
+ choices=["error", "warning", "info", "style"],
39
+ help="minimum severity to report (default: style)")
40
+ p.add_argument("-o", "--enable", action="append", default=[],
41
+ metavar="check1,check2..",
42
+ help="enable optional checks ('all' for every one)")
43
+ p.add_argument("-C", "--color", nargs="?", const="always",
44
+ default="auto", choices=["auto", "always", "never"],
45
+ help="use color (default: auto)")
46
+ p.add_argument("--version", action="version",
47
+ version="pureshellcheck %s" % __version__)
48
+ return p.parse_args(argv)
49
+
50
+
51
+ def parse_excludes(items):
52
+ codes = set()
53
+ for item in items:
54
+ for part in item.split(","):
55
+ part = part.strip()
56
+ if part.upper().startswith("SC"):
57
+ part = part[2:]
58
+ if part.isdigit():
59
+ codes.add(int(part))
60
+ return codes
61
+
62
+
63
+ def check_file(path, args, excluded, min_rank):
64
+ if path == "-":
65
+ source = sys.stdin.read()
66
+ name = "-"
67
+ else:
68
+ with open(path, encoding="utf-8", errors="replace") as f:
69
+ source = f.read()
70
+ name = path
71
+ shell = args.shell if args.shell != "busybox" else "ash"
72
+ include_optional = bool(args.enable)
73
+ findings, _err = run_checks(source, shell=shell,
74
+ include_optional=include_optional,
75
+ filename=name)
76
+ findings = [f for f in findings
77
+ if f.code not in excluded
78
+ and SEVERITY_RANK[f.severity] <= min_rank]
79
+ return name, source, findings
80
+
81
+
82
+ def emit_tty(name, source, findings, out, color):
83
+ lines = source.split("\n")
84
+ c = COLORS if color else dict.fromkeys(COLORS, "")
85
+ for f in findings:
86
+ line_text = lines[f.line - 1] if f.line - 1 < len(lines) else ""
87
+ out.write("\n%sIn %s line %d:%s\n"
88
+ % (c["message"], name, f.line, c["reset"]))
89
+ out.write(line_text + "\n")
90
+ start = f.column - 1
91
+ if f.end_line == f.line and f.end_column > f.column:
92
+ width = f.end_column - f.column
93
+ else:
94
+ width = 1
95
+ marker = "^" if width <= 1 else "^" + "-" * (width - 2) + "^"
96
+ out.write(" " * start + "%s%s SC%d (%s): %s%s\n"
97
+ % (c[f.severity], marker, f.code, f.severity, f.message,
98
+ c["reset"]))
99
+
100
+
101
+ def emit_gcc(name, source, findings, out, color):
102
+ sev = {"error": "error", "warning": "warning", "info": "note",
103
+ "style": "note"}
104
+ for f in findings:
105
+ out.write("%s:%d:%d: %s: %s [SC%d]\n"
106
+ % (name, f.line, f.column, sev[f.severity], f.message,
107
+ f.code))
108
+
109
+
110
+ def finding_json(name, f):
111
+ return {
112
+ "file": name,
113
+ "line": f.line,
114
+ "endLine": f.end_line,
115
+ "column": f.column,
116
+ "endColumn": f.end_column,
117
+ "level": f.severity,
118
+ "code": f.code,
119
+ "message": f.message,
120
+ }
121
+
122
+
123
+ def main(argv=None):
124
+ args = parse_args(sys.argv[1:] if argv is None else argv)
125
+ excluded = parse_excludes(args.exclude)
126
+ min_rank = SEVERITY_RANK[args.severity]
127
+ out = sys.stdout
128
+ color = args.color == "always" or (args.color == "auto"
129
+ and out.isatty())
130
+ any_findings = False
131
+ had_error = False
132
+ json_items = []
133
+ for path in args.files:
134
+ try:
135
+ name, source, findings = check_file(path, args, excluded,
136
+ min_rank)
137
+ except OSError as e:
138
+ sys.stderr.write("pureshellcheck: %s: %s\n"
139
+ % (path, e.strerror or e))
140
+ had_error = True
141
+ continue
142
+ if findings:
143
+ any_findings = True
144
+ if args.format == "tty":
145
+ emit_tty(name, source, findings, out, color)
146
+ elif args.format == "gcc":
147
+ emit_gcc(name, source, findings, out, color)
148
+ else:
149
+ json_items.extend(finding_json(name, f) for f in findings)
150
+ if args.format == "json":
151
+ json.dump(json_items, out)
152
+ out.write("\n")
153
+ elif args.format == "json1":
154
+ json.dump({"comments": json_items}, out)
155
+ out.write("\n")
156
+ if had_error:
157
+ return 2
158
+ return 1 if any_findings else 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ sys.exit(main())