5
from pathlib import Path
6
from typing import Callable, List, Dict, Any # compat
8
# This file checks Bash and Shell scripts for violations not found with
9
# shellcheck or existing methods. You can use it in several ways:
11
# Lint all .bash, .sh, .bats files along with 'bin/asdf' and print out violations:
12
# $ ./scripts/checkstyle.py
14
# The former, but also fix all violations. This must be ran until there
15
# are zero violations since any line can have more than one violation:
16
# $ ./scripts/checkstyle.py --fix
18
# Lint a particular file:
19
# $ ./scripts/checkstyle.py ./lib/functions/installs.bash
21
# Check to ensure all regular expressions are working as intended:
22
# $ ./scripts/checkstyle.py --internal-test-regex
36
LINK: Callable[[str, str], str] = lambda href, text: f'\033]8;;{href}\a{text}\033]8;;\a'
38
def utilGetStrs(line: Any, m: Any):
40
line[0:m.start('match')],
41
line[m.start('match'):m.end('match')],
45
# Before: printf '%s\\n' '^w^'
46
# After: printf '%s\n' '^w^'
47
def noDoubleBackslashFixer(line: str, m: Any) -> str:
48
prestr, midstr, poststr = utilGetStrs(line, m)
50
return f'{prestr}{midstr[1:]}{poststr}'
54
def noPwdCaptureFixer(line: str, m: Any) -> str:
55
prestr, _, poststr = utilGetStrs(line, m)
57
return f'{prestr}$PWD{poststr}'
61
def noTestDoubleEqualsFixer(line: str, m: Any) -> str:
62
prestr, _, poststr = utilGetStrs(line, m)
64
return f'{prestr}={poststr}'
66
# Before: function fn() { ...
69
# Before: function fn { ...
71
def noFunctionKeywordFixer(line: str, m: Any) -> str:
72
prestr, midstr, poststr = utilGetStrs(line, m)
74
midstr = midstr.strip()
75
midstr = midstr[len('function'):]
76
midstr = midstr.strip()
78
parenIdx = midstr.find('(')
79
if parenIdx != -1: midstr = midstr[:parenIdx]
81
return f'{prestr}{midstr}() {poststr}'
83
# Before: >/dev/null 2>&1
86
# Before: 2>/dev/null 1>&2
88
def noVerboseRedirectionFixer(line: str, m: Any) -> str:
89
prestr, _, poststr = utilGetStrs(line, m)
91
return f'{prestr}&>/dev/null{poststr}'
93
def lintfile(file: Path, rules: List[Rule], options: Dict[str, Any]):
94
content_arr = file.read_text().split('\n')
96
for line_i, line in enumerate(content_arr):
97
if 'checkstyle-ignore' in line:
102
if 'sh' in rule['fileTypes']:
103
if file.name.endswith('.sh') or str(file.absolute()).endswith('bin/asdf'):
105
if 'bash' in rule['fileTypes']:
106
if file.name.endswith('.bash') or file.name.endswith('.bats'):
109
if options['verbose']:
110
print(f'{str(file)}: {should_run}')
115
m = re.search(rule['regex'], line)
116
if m is not None and m.group('match') is not None:
117
dir = os.path.relpath(file.resolve(), Path.cwd())
118
prestr = line[0:m.start('match')]
119
midstr = line[m.start('match'):m.end('match')]
120
poststr = line[m.end('match'):]
122
print(f'{c.CYAN}{dir}{c.RESET}:{line_i + 1}')
123
print(f'{c.MAGENTA}{rule["name"]}{c.RESET}: {rule["reason"]}')
124
print(f'{prestr}{c.RED}{midstr}{c.RESET}{poststr}')
128
content_arr[line_i] = rule['fixerFn'](line, m)
133
file.write_text('\n'.join(content_arr))
136
rules: List[Rule] = [
138
'name': 'no-double-backslash',
139
'regex': '".*?(?P<match>\\\\\\\\[abeEfnrtv\'"?xuUc]).*?(?<!\\\\)"',
140
'reason': 'Backslashes are only required if followed by a $, `, ", \\, or <newline>',
141
'fileTypes': ['bash', 'sh'],
142
'fixerFn': noDoubleBackslashFixer,
143
'testPositiveMatches': [
144
'printf "%s\\\\n" "Hai"',
145
'echo -n "Hello\\\\n"'
147
'testNegativeMatches': [
148
'printf "%s\\n" "Hai"',
153
'name': 'no-pwd-capture',
154
'regex': '(?P<match>\\$\\(pwd\\))',
155
'reason': '$PWD is essentially equivalent to $(pwd) without the overhead of a subshell',
156
'fileTypes': ['bash', 'sh'],
157
'fixerFn': noPwdCaptureFixer,
158
'testPositiveMatches': [
161
'testNegativeMatches': [
166
'name': 'no-test-double-equals',
167
'regex': '(?<!\\[)\\[ (?:[^]]|](?=}))*?(?P<match>==).*?]',
168
'reason': 'Disallow double equals in places where they are not necessary for consistency',
169
'fileTypes': ['bash', 'sh'],
170
'fixerFn': noTestDoubleEqualsFixer,
171
'testPositiveMatches': [
173
'[ "${lines[0]}" == blah ]',
175
'testNegativeMatches': [
179
'[ a = b ] || [[ a == b ]]',
180
'[[ a = b ]] || [[ a == b ]]',
181
'[[ "${lines[0]}" == \'usage: \'* ]]',
182
'[ "${lines[0]}" = blah ]',
186
'name': 'no-function-keyword',
187
'regex': '^[ \\t]*(?P<match>function .*?(?:\\([ \\t]*\\))?[ \\t]*){',
188
'reason': 'Only allow functions declared like `fn_name() {{ :; }}` for consistency (see ' + c.LINK('https://www.shellcheck.net/wiki/SC2113', 'ShellCheck SC2113') + ')',
189
'fileTypes': ['bash', 'sh'],
190
'fixerFn': noFunctionKeywordFixer,
191
'testPositiveMatches': [
192
'function fn() { :; }',
193
'function fn { :; }',
195
'testNegativeMatches': [
200
'name': 'no-verbose-redirection',
201
'regex': '(?P<match>(>/dev/null 2>&1|2>/dev/null 1>&2))',
202
'reason': 'Use `&>/dev/null` instead of `>/dev/null 2>&1` or `2>/dev/null 1>&2` for consistency',
203
'fileTypes': ['bash'],
204
'fixerFn': noVerboseRedirectionFixer,
205
'testPositiveMatches': [
206
'echo woof >/dev/null 2>&1',
207
'echo woof 2>/dev/null 1>&2',
209
'testNegativeMatches': [
210
'echo woof &>/dev/null',
211
'echo woof >&/dev/null',
215
[rule.update({ 'found': 0 }) for rule in rules]
217
parser = argparse.ArgumentParser()
218
parser.add_argument('files', metavar='FILES', nargs='*')
219
parser.add_argument('--fix', action='store_true')
220
parser.add_argument('--verbose', action='store_true')
221
parser.add_argument('--internal-test-regex', action='store_true')
222
args = parser.parse_args()
224
if args.internal_test_regex:
226
for positiveMatch in rule['testPositiveMatches']:
227
m: Any = re.search(rule['regex'], positiveMatch)
228
if m is None or m.group('match') is None:
229
print(f'{c.MAGENTA}{rule["name"]}{c.RESET}: Failed {c.CYAN}positive{c.RESET} test:')
230
print(f'=> {positiveMatch}')
233
for negativeMatch in rule['testNegativeMatches']:
234
m: Any = re.search(rule['regex'], negativeMatch)
235
if m is not None and m.group('match') is not None:
236
print(f'{c.MAGENTA}{rule["name"]}{c.RESET}: Failed {c.YELLOW}negative{c.RESET} test:')
237
print(f'=> {negativeMatch}')
244
'verbose': args.verbose,
247
# parse files and print matched lints
248
if len(args.files) > 0:
249
for file in args.files:
252
lintfile(p, rules, options)
254
for file in Path.cwd().glob('**/*'):
255
if '.git' in str(file.absolute()):
259
lintfile(file, rules, options)
261
# print final results
262
print(f'{c.UNDERLINE}TOTAL ISSUES{c.RESET}')
264
print(f'{c.MAGENTA}{rule["name"]}{c.RESET}: {rule["found"]}')
266
grand_total = sum([rule['found'] for rule in rules])
267
print(f'GRAND TOTAL: {grand_total}')