2022-12-23 02:53:22 -07:00
#!/usr/bin/env python3
import re
import os
import argparse
from pathlib import Path
2023-01-28 20:52:22 -07:00
from typing import Callable , List , Dict , Any # compat
2022-12-23 02:53:22 -07:00
# This file checks Bash and Shell scripts for violations not found with
# shellcheck or existing methods. You can use it in several ways:
#
2023-03-26 15:31:06 -07:00
# Lint all .bash, .sh, .bats files along with 'bin/asdf' and print out violations:
2022-12-23 02:53:22 -07:00
# $ ./scripts/checkstyle.py
#
# The former, but also fix all violations. This must be ran until there
2023-03-26 15:31:06 -07:00
# are zero violations since any line can have more than one violation:
2022-12-23 02:53:22 -07:00
# $ ./scripts/checkstyle.py --fix
#
2023-03-26 15:31:06 -07:00
# Lint a particular file:
2022-12-23 02:53:22 -07:00
# $ ./scripts/checkstyle.py ./lib/functions/installs.bash
2023-01-28 06:44:08 -07:00
#
2023-03-26 15:31:06 -07:00
# Check to ensure all regular expressions are working as intended:
2023-01-28 06:44:08 -07:00
# $ ./scripts/checkstyle.py --internal-test-regex
Rule = Dict [ str , Any ]
2022-12-23 02:53:22 -07:00
class c :
RED = ' \033 [91m '
GREEN = ' \033 [92m '
YELLOW = ' \033 [93m '
BLUE = ' \033 [94m '
MAGENTA = ' \033 [95m '
CYAN = ' \033 [96m '
RESET = ' \033 [0m '
BOLD = ' \033 [1m '
UNDERLINE = ' \033 [4m '
2023-01-28 20:52:22 -07:00
LINK : Callable [ [ str , str ] , str ] = lambda href , text : f ' \033 ]8;; { href } \a { text } \033 ]8;; \a '
2022-12-23 02:53:22 -07:00
2023-03-26 15:31:06 -07:00
def utilGetStrs ( line : Any , m : Any ) :
2022-12-27 06:32:12 -07:00
return (
line [ 0 : m . start ( ' match ' ) ] ,
line [ m . start ( ' match ' ) : m . end ( ' match ' ) ] ,
line [ m . end ( ' match ' ) : ]
)
2022-12-23 02:53:22 -07:00
# Before: printf '%s\\n' '^w^'
# After: printf '%s\n' '^w^'
2023-03-26 15:31:06 -07:00
def noDoubleBackslashFixer ( line : str , m : Any ) - > str :
2022-12-27 06:32:12 -07:00
prestr , midstr , poststr = utilGetStrs ( line , m )
return f ' { prestr } { midstr [ 1 : ] } { poststr } '
# Before: $(pwd)
# After: $PWD
2023-03-26 15:31:06 -07:00
def noPwdCaptureFixer ( line : str , m : Any ) - > str :
prestr , _ , poststr = utilGetStrs ( line , m )
2022-12-23 02:53:22 -07:00
2022-12-27 06:32:12 -07:00
return f ' { prestr } $PWD { poststr } '
2022-12-23 02:53:22 -07:00
2023-01-28 06:44:08 -07:00
# Before: [ a == b ]
# After: [ a = b ]
2023-03-26 15:31:06 -07:00
def noTestDoubleEqualsFixer ( line : str , m : Any ) - > str :
prestr , _ , poststr = utilGetStrs ( line , m )
2023-01-14 06:18:44 -07:00
return f ' { prestr } = { poststr } '
2023-01-28 20:52:22 -07:00
# Before: function fn() { ...
# After: fn() { ...
# ---
# Before: function fn { ...
# After fn() { ...
2023-03-26 15:31:06 -07:00
def noFunctionKeywordFixer ( line : str , m : Any ) - > str :
2023-01-28 20:52:22 -07:00
prestr , midstr , poststr = utilGetStrs ( line , m )
midstr = midstr . strip ( )
midstr = midstr [ len ( ' function ' ) : ]
midstr = midstr . strip ( )
parenIdx = midstr . find ( ' ( ' )
if parenIdx != - 1 : midstr = midstr [ : parenIdx ]
return f ' { prestr } { midstr } () { poststr } '
2023-04-10 20:12:08 -07:00
# Before: >/dev/null 2>&1
# After: &>/dev/null
# ---
# Before: 2>/dev/null 1>&2
# After: &>/dev/null
def noVerboseRedirectionFixer ( line : str , m : Any ) - > str :
prestr , _ , poststr = utilGetStrs ( line , m )
return f ' { prestr } &>/dev/null { poststr } '
def lintfile ( file : Path , rules : List [ Rule ] , options : Dict [ str , Any ] ) :
content_arr = file . read_text ( ) . split ( ' \n ' )
2022-12-23 02:53:22 -07:00
for line_i , line in enumerate ( content_arr ) :
2022-12-27 06:32:12 -07:00
if ' checkstyle-ignore ' in line :
continue
2022-12-23 02:53:22 -07:00
for rule in rules :
2023-04-10 20:12:08 -07:00
should_run = False
if ' sh ' in rule [ ' fileTypes ' ] :
if file . name . endswith ( ' .sh ' ) or str ( file . absolute ( ) ) . endswith ( ' bin/asdf ' ) :
should_run = True
if ' bash ' in rule [ ' fileTypes ' ] :
if file . name . endswith ( ' .bash ' ) or file . name . endswith ( ' .bats ' ) :
should_run = True
if options [ ' verbose ' ] :
print ( f ' { str ( file ) } : { should_run } ' )
if not should_run :
continue
2022-12-23 02:53:22 -07:00
m = re . search ( rule [ ' regex ' ] , line )
if m is not None and m . group ( ' match ' ) is not None :
2023-04-10 20:12:08 -07:00
dir = os . path . relpath ( file . resolve ( ) , Path . cwd ( ) )
2022-12-23 02:53:22 -07:00
prestr = line [ 0 : m . start ( ' match ' ) ]
midstr = line [ m . start ( ' match ' ) : m . end ( ' match ' ) ]
poststr = line [ m . end ( ' match ' ) : ]
print ( f ' { c . CYAN } { dir } { c . RESET } : { line_i + 1 } ' )
print ( f ' { c . MAGENTA } { rule [ " name " ] } { c . RESET } : { rule [ " reason " ] } ' )
print ( f ' { prestr } { c . RED } { midstr } { c . RESET } { poststr } ' )
print ( )
if options [ ' fix ' ] :
2023-01-14 06:18:44 -07:00
content_arr [ line_i ] = rule [ ' fixerFn ' ] ( line , m )
2022-12-23 02:53:22 -07:00
rule [ ' found ' ] + = 1
if options [ ' fix ' ] :
2023-04-10 20:12:08 -07:00
file . write_text ( ' \n ' . join ( content_arr ) )
2022-12-23 02:53:22 -07:00
def main ( ) :
2023-01-28 06:44:08 -07:00
rules : List [ Rule ] = [
2022-12-23 02:53:22 -07:00
{
' name ' : ' no-double-backslash ' ,
' regex ' : ' " .*?(?P<match> \\ \\ \\ \\ [abeEfnrtv \' " ?xuUc]).*?(?<! \\ \\ ) " ' ,
' reason ' : ' Backslashes are only required if followed by a $, `, " , \\ , or <newline> ' ,
2023-04-10 20:12:08 -07:00
' fileTypes ' : [ ' bash ' , ' sh ' ] ,
2022-12-27 06:32:12 -07:00
' fixerFn ' : noDoubleBackslashFixer ,
2022-12-27 06:19:30 -07:00
' testPositiveMatches ' : [
' printf " %s \\ \\ n " " Hai " ' ,
' echo -n " Hello \\ \\ n " '
] ,
' testNegativeMatches ' : [
' printf " %s \\ n " " Hai " ' ,
' echo -n " Hello \\ n " '
] ,
2022-12-23 02:53:22 -07:00
} ,
2022-12-27 06:32:12 -07:00
{
' name ' : ' no-pwd-capture ' ,
' regex ' : ' (?P<match> \\ $ \\ (pwd \\ )) ' ,
' reason ' : ' $PWD is essentially equivalent to $(pwd) without the overhead of a subshell ' ,
2023-04-10 20:12:08 -07:00
' fileTypes ' : [ ' bash ' , ' sh ' ] ,
2023-01-14 06:18:44 -07:00
' fixerFn ' : noPwdCaptureFixer ,
2022-12-27 06:32:12 -07:00
' testPositiveMatches ' : [
' $(pwd) '
] ,
' testNegativeMatches ' : [
' $PWD '
] ,
} ,
2023-01-14 06:18:44 -07:00
{
' name ' : ' no-test-double-equals ' ,
2023-01-28 06:44:08 -07:00
' regex ' : ' (?<! \\ [) \\ [ (?:[^]]|](?=}))*?(?P<match>==).*?] ' ,
' reason ' : ' Disallow double equals in places where they are not necessary for consistency ' ,
2023-04-10 20:12:08 -07:00
' fileTypes ' : [ ' bash ' , ' sh ' ] ,
2023-01-14 06:18:44 -07:00
' fixerFn ' : noTestDoubleEqualsFixer ,
' testPositiveMatches ' : [
' [ a == b ] ' ,
2023-01-28 06:44:08 -07:00
' [ " $ {lines[0]} " == blah ] ' ,
2023-01-14 06:18:44 -07:00
] ,
' testNegativeMatches ' : [
' [ a = b ] ' ,
' [[ a = b ]] ' ,
' [[ a == b ]] ' ,
' [ a = b ] || [[ a == b ]] ' ,
2023-01-28 06:44:08 -07:00
' [[ a = b ]] || [[ a == b ]] ' ,
' [[ " $ {lines[0]} " == \' usage: \' * ]] ' ,
' [ " $ {lines[0]} " = blah ] ' ,
2023-01-14 06:18:44 -07:00
] ,
2023-01-28 06:44:08 -07:00
} ,
2023-01-28 20:52:22 -07:00
{
' name ' : ' no-function-keyword ' ,
' regex ' : ' ^[ \\ t]*(?P<match>function .*?(?: \\ ([ \\ t]* \\ ))?[ \\ t]*) { ' ,
' reason ' : ' Only allow functions declared like `fn_name() {{ :; }}` for consistency (see ' + c . LINK ( ' https://www.shellcheck.net/wiki/SC2113 ' , ' ShellCheck SC2113 ' ) + ' ) ' ,
2023-04-10 20:12:08 -07:00
' fileTypes ' : [ ' bash ' , ' sh ' ] ,
2023-01-28 20:52:22 -07:00
' fixerFn ' : noFunctionKeywordFixer ,
' testPositiveMatches ' : [
' function fn() { :; } ' ,
' function fn { :; } ' ,
] ,
' testNegativeMatches ' : [
' fn() { :; } ' ,
] ,
2023-04-10 20:12:08 -07:00
} ,
{
' name ' : ' no-verbose-redirection ' ,
' regex ' : ' (?P<match>(>/dev/null 2>&1|2>/dev/null 1>&2)) ' ,
' reason ' : ' Use `&>/dev/null` instead of `>/dev/null 2>&1` or `2>/dev/null 1>&2` for consistency ' ,
' fileTypes ' : [ ' bash ' ] ,
' fixerFn ' : noVerboseRedirectionFixer ,
' testPositiveMatches ' : [
' echo woof >/dev/null 2>&1 ' ,
' echo woof 2>/dev/null 1>&2 ' ,
] ,
' testNegativeMatches ' : [
' echo woof &>/dev/null ' ,
' echo woof >&/dev/null ' ,
] ,
2023-01-28 20:52:22 -07:00
} ,
2022-12-23 02:53:22 -07:00
]
2023-04-10 20:12:08 -07:00
[ rule . update ( { ' found ' : 0 } ) for rule in rules ]
2022-12-23 02:53:22 -07:00
parser = argparse . ArgumentParser ( )
parser . add_argument ( ' files ' , metavar = ' FILES ' , nargs = ' * ' )
parser . add_argument ( ' --fix ' , action = ' store_true ' )
2023-04-10 20:12:08 -07:00
parser . add_argument ( ' --verbose ' , action = ' store_true ' )
2022-12-27 06:19:30 -07:00
parser . add_argument ( ' --internal-test-regex ' , action = ' store_true ' )
2022-12-23 02:53:22 -07:00
args = parser . parse_args ( )
2022-12-27 06:19:30 -07:00
if args . internal_test_regex :
for rule in rules :
for positiveMatch in rule [ ' testPositiveMatches ' ] :
2023-03-26 15:31:06 -07:00
m : Any = re . search ( rule [ ' regex ' ] , positiveMatch )
2022-12-27 06:19:30 -07:00
if m is None or m . group ( ' match ' ) is None :
print ( f ' { c . MAGENTA } { rule [ " name " ] } { c . RESET } : Failed { c . CYAN } positive { c . RESET } test: ' )
print ( f ' => { positiveMatch } ' )
print ( )
for negativeMatch in rule [ ' testNegativeMatches ' ] :
2023-03-26 15:31:06 -07:00
m : Any = re . search ( rule [ ' regex ' ] , negativeMatch )
2022-12-27 06:19:30 -07:00
if m is not None and m . group ( ' match ' ) is not None :
print ( f ' { c . MAGENTA } { rule [ " name " ] } { c . RESET } : Failed { c . YELLOW } negative { c . RESET } test: ' )
print ( f ' => { negativeMatch } ' )
print ( )
2023-01-14 06:18:44 -07:00
print ( ' Done. ' )
2022-12-27 06:19:30 -07:00
return
2022-12-23 02:53:22 -07:00
options = {
2023-04-10 20:12:08 -07:00
' fix ' : args . fix ,
' verbose ' : args . verbose ,
2022-12-23 02:53:22 -07:00
}
# parse files and print matched lints
if len ( args . files ) > 0 :
for file in args . files :
p = Path ( file )
if p . is_file ( ) :
lintfile ( p , rules , options )
else :
for file in Path . cwd ( ) . glob ( ' **/* ' ) :
2023-04-10 20:12:08 -07:00
if ' .git ' in str ( file . absolute ( ) ) :
continue
if file . is_file ( ) :
lintfile ( file , rules , options )
2022-12-23 02:53:22 -07:00
# print final results
print ( f ' { c . UNDERLINE } TOTAL ISSUES { c . RESET } ' )
for rule in rules :
print ( f ' { c . MAGENTA } { rule [ " name " ] } { c . RESET } : { rule [ " found " ] } ' )
grand_total = sum ( [ rule [ ' found ' ] for rule in rules ] )
print ( f ' GRAND TOTAL: { grand_total } ' )
# exit
if grand_total == 0 :
exit ( 0 )
else :
exit ( 2 )
main ( )