diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39d8893 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +* +!LICENSE +!Setup.hs +!ShellCheck.cabal +!shellcheck.hs +!src diff --git a/CHANGELOG.md b/CHANGELOG.md index 262849f..45c9a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Since previous release ### Added - Preliminary support for fix suggestions +- New `-f diff` unified diff format for auto-fixes - Files containing Bats tests can now be checked - Directory wide directives can now be placed in a `.shellcheckrc` - Optional checks: Use `--list-optional` to show a list of tests, @@ -8,6 +9,10 @@ - Source paths: Use `-P dir1:dir2` or a `source-path=dir1` directive to specify search paths for sourced files. - json1 format like --format=json but treats tabs as single characters +- Recognize FLAGS variables created by the shflags library. +- Site-specific changes can now be made in Custom.hs for ease of patching +- SC2154: Also warn about unassigned uppercase variables (optional) +- SC2252: Warn about `[ $a != x ] || [ $a != y ]`, similar to SC2055 - SC2251: Inform about ineffectual ! in front of commands - SC2250: Warn about variable references without braces (optional) - SC2249: Warn about `case` with missing default case (optional) @@ -16,12 +21,16 @@ - SC2246: Warn if a shebang's interpreter ends with / - SC2245: Warn that Ksh ignores all but the first glob result in `[` - SC2243/SC2244: Suggest using explicit -n for `[ $foo ]` (optional) +- SC1135: Suggest not ending double quotes just to make $ literal ### Changed - If a directive or shebang is not specified, a `.bash/.bats/.dash/.ksh` extension will be used to infer the shell type when present. - Disabling SC2120 on a function now disables SC2119 on call sites +### Fixed +- SC2183 no longer warns about missing printf args for `%()T` + ## v0.6.0 - 2018-12-02 ### Added - Command line option --severity/-S for filtering by minimum severity diff --git a/ShellCheck.cabal b/ShellCheck.cabal index 099052e..4658dd0 100644 --- a/ShellCheck.cabal +++ b/ShellCheck.cabal @@ -57,6 +57,7 @@ library bytestring, containers >= 0.5, deepseq >= 1.4.0.0, + Diff >= 0.2.0, directory >= 1.2.3.0, mtl >= 2.2.1, filepath, @@ -73,13 +74,16 @@ library ShellCheck.AnalyzerLib ShellCheck.Checker ShellCheck.Checks.Commands + ShellCheck.Checks.Custom ShellCheck.Checks.ShellSupport ShellCheck.Data ShellCheck.Fixer ShellCheck.Formatter.Format ShellCheck.Formatter.CheckStyle + ShellCheck.Formatter.Diff ShellCheck.Formatter.GCC ShellCheck.Formatter.JSON + ShellCheck.Formatter.JSON1 ShellCheck.Formatter.TTY ShellCheck.Formatter.Quiet ShellCheck.Interface @@ -99,6 +103,7 @@ executable shellcheck bytestring, containers, deepseq >= 1.4.0.0, + Diff >= 0.2.0, directory >= 1.2.3.0, mtl >= 2.2.1, filepath, @@ -117,6 +122,7 @@ test-suite test-shellcheck bytestring, containers, deepseq >= 1.4.0.0, + Diff >= 0.2.0, directory >= 1.2.3.0, mtl >= 2.2.1, filepath, diff --git a/shellcheck.1.md b/shellcheck.1.md index 3b3498a..77fa79a 100644 --- a/shellcheck.1.md +++ b/shellcheck.1.md @@ -152,28 +152,47 @@ not warn at all, as `ksh` supports decimals in arithmetic contexts. ... +**diff** + +: Auto-fixes in unified diff format. Can be piped to `git apply` or `patch -p1` + to automatically apply fixes. + + --- a/test.sh + +++ b/test.sh + @@ -2,6 +2,6 @@ + ## Example of a broken script. + for f in $(ls *.m3u) + do + - grep -qi hq.*mp3 $f \ + + grep -qi hq.*mp3 "$f" \ + && echo -e 'Playlist $f contains a HQ file in mp3 format' + done + + **json1** : Json is a popular serialization format that is more suitable for web applications. ShellCheck's json is compact and contains only the bare minimum. Tabs are counted as 1 character. - [ - { - "file": "filename", - "line": lineNumber, - "column": columnNumber, - "level": "severitylevel", - "code": errorCode, - "message": "warning message" - }, - ... - ] + { + comments: [ + { + "file": "filename", + "line": lineNumber, + "column": columnNumber, + "level": "severitylevel", + "code": errorCode, + "message": "warning message" + }, + ... + ] + } **json** -: This is a legacy version of the **json1** format, with a tab stop - of 8 instead of 1. +: This is a legacy version of the **json1** format. It's a raw array of + comments, and all offsets have a tab stop of 8. **quiet** @@ -251,6 +270,9 @@ Here is an example `.shellcheckrc`: # Turn on warnings for unquoted variables with safe values enable=quote-safe-variables + # Turn on warnings for unassigned uppercase variables + enable=check-unassigned-uppercase + # Allow using `which` since it gives full paths and is common enough disable=SC2230 diff --git a/shellcheck.hs b/shellcheck.hs index 02ed88a..20fb4b6 100644 --- a/shellcheck.hs +++ b/shellcheck.hs @@ -25,8 +25,10 @@ import ShellCheck.Regex import qualified ShellCheck.Formatter.CheckStyle import ShellCheck.Formatter.Format +import qualified ShellCheck.Formatter.Diff import qualified ShellCheck.Formatter.GCC import qualified ShellCheck.Formatter.JSON +import qualified ShellCheck.Formatter.JSON1 import qualified ShellCheck.Formatter.TTY import qualified ShellCheck.Formatter.Quiet @@ -140,9 +142,10 @@ parseArguments argv = formats :: FormatterOptions -> Map.Map String (IO Formatter) formats options = Map.fromList [ ("checkstyle", ShellCheck.Formatter.CheckStyle.format), + ("diff", ShellCheck.Formatter.Diff.format options), ("gcc", ShellCheck.Formatter.GCC.format), - ("json", ShellCheck.Formatter.JSON.format False), -- JSON with 8-char tabs - ("json1", ShellCheck.Formatter.JSON.format True), -- JSON with 1-char tabs + ("json", ShellCheck.Formatter.JSON.format), + ("json1", ShellCheck.Formatter.JSON1.format), ("tty", ShellCheck.Formatter.TTY.format options), ("quiet", ShellCheck.Formatter.Quiet.format options) ] @@ -497,8 +500,8 @@ ioInterface options files = do find original original where find filename deflt = do - sources <- filterM ((allowable inputs) `andM` doesFileExist) - (map ( filename) $ map adjustPath $ sourcePathFlag ++ sourcePathAnnotation) + sources <- filterM ((allowable inputs) `andM` doesFileExist) $ + (adjustPath filename):(map ( filename) $ map adjustPath $ sourcePathFlag ++ sourcePathAnnotation) case sources of [] -> return deflt (first:_) -> return first diff --git a/src/ShellCheck/AST.hs b/src/ShellCheck/AST.hs index eb236ca..d8faec6 100644 --- a/src/ShellCheck/AST.hs +++ b/src/ShellCheck/AST.hs @@ -121,7 +121,7 @@ data Token = | T_Rbrace Id | T_Redirecting Id [Token] Token | T_Rparen Id - | T_Script Id String [Token] + | T_Script Id Token [Token] -- Shebang T_Literal, followed by script. | T_Select Id | T_SelectIn Id String [Token] [Token] | T_Semi Id diff --git a/src/ShellCheck/Analytics.hs b/src/ShellCheck/Analytics.hs index 36b941e..067a53f 100644 --- a/src/ShellCheck/Analytics.hs +++ b/src/ShellCheck/Analytics.hs @@ -231,6 +231,13 @@ optionalTreeChecks = [ cdPositive = "var=hello; echo $var", cdNegative = "var=hello; echo ${var}" }, nodeChecksToTreeCheck [checkVariableBraces]) + + ,(newCheckDescription { + cdName = "check-unassigned-uppercase", + cdDescription = "Warn when uppercase variables are unassigned", + cdPositive = "echo $VAR", + cdNegative = "VAR=hello; echo $VAR" + }, checkUnassignedReferences' True) ] optionalCheckMap :: Map.Map String (Parameters -> Token -> [TokenComment]) @@ -266,7 +273,11 @@ producesComments :: (Parameters -> Token -> [TokenComment]) -> String -> Maybe B producesComments f s = do let pr = pScript s prRoot pr - return . not . null $ runList (defaultSpec pr) [f] + let spec = defaultSpec pr + let params = makeParameters spec + return . not . null $ + filterByAnnotation spec params $ + runList spec [f] -- Copied from https://wiki.haskell.org/Edit_distance dist :: Eq a => [a] -> [a] -> Int @@ -527,7 +538,7 @@ indexOfSublists sub = f 0 prop_checkShebangParameters1 = verifyTree checkShebangParameters "#!/usr/bin/env bash -x\necho cow" prop_checkShebangParameters2 = verifyNotTree checkShebangParameters "#! /bin/sh -l " checkShebangParameters p (T_Annotation _ _ t) = checkShebangParameters p t -checkShebangParameters _ (T_Script id sb _) = +checkShebangParameters _ (T_Script _ (T_Literal id sb) _) = [makeComment ErrorC id 2096 "On most OS, shebangs can only specify a single parameter." | length (words sb) > 2] prop_checkShebang1 = verifyNotTree checkShebang "#!/usr/bin/env bash -x\necho cow" @@ -547,7 +558,7 @@ checkShebang params (T_Annotation _ list t) = where isOverride (ShellOverride _) = True isOverride _ = False -checkShebang params (T_Script id sb _) = execWriter $ do +checkShebang params (T_Script _ (T_Literal id sb) _) = execWriter $ do unless (shellTypeSpecified params) $ do when (sb == "") $ err id 2148 "Tips depend on target shell and yours is unknown. Add a shebang." @@ -829,6 +840,7 @@ prop_checkArrayWithoutIndex6 = verifyTree checkArrayWithoutIndex "echo $PIPESTAT prop_checkArrayWithoutIndex7 = verifyTree checkArrayWithoutIndex "a=(a b); a+=c" prop_checkArrayWithoutIndex8 = verifyTree checkArrayWithoutIndex "declare -a foo; foo=bar;" prop_checkArrayWithoutIndex9 = verifyTree checkArrayWithoutIndex "read -r -a arr <<< 'foo bar'; echo \"$arr\"" +prop_checkArrayWithoutIndex10= verifyTree checkArrayWithoutIndex "read -ra arr <<< 'foo bar'; echo \"$arr\"" checkArrayWithoutIndex params _ = doVariableFlowAnalysis readF writeF defaultMap (variableFlow params) where @@ -917,6 +929,8 @@ prop_checkSingleQuotedVariables14= verifyNot checkSingleQuotedVariables "[ -v 'b prop_checkSingleQuotedVariables15= verifyNot checkSingleQuotedVariables "git filter-branch 'test $GIT_COMMIT'" prop_checkSingleQuotedVariables16= verify checkSingleQuotedVariables "git '$a'" prop_checkSingleQuotedVariables17= verifyNot checkSingleQuotedVariables "rename 's/(.)a/$1/g' *" +prop_checkSingleQuotedVariables18= verifyNot checkSingleQuotedVariables "echo '``'" +prop_checkSingleQuotedVariables19= verifyNot checkSingleQuotedVariables "echo '```'" checkSingleQuotedVariables params t@(T_SingleQuoted id s) = when (s `matches` re) $ @@ -962,7 +976,7 @@ checkSingleQuotedVariables params t@(T_SingleQuoted id s) = TC_Unary _ _ "-v" _ -> True _ -> False - re = mkRegex "\\$[{(0-9a-zA-Z_]|`.*`" + re = mkRegex "\\$[{(0-9a-zA-Z_]|`[^`]+`" sedContra = mkRegex "\\$[{dpsaic]($|[^a-zA-Z])" getFindCommand (T_SimpleCommand _ _ words) = @@ -1348,14 +1362,39 @@ prop_checkOrNeq2 = verify checkOrNeq "(( a!=lol || a!=foo ))" prop_checkOrNeq3 = verify checkOrNeq "[ \"$a\" != lol || \"$a\" != foo ]" prop_checkOrNeq4 = verifyNot checkOrNeq "[ a != $cow || b != $foo ]" prop_checkOrNeq5 = verifyNot checkOrNeq "[[ $a != /home || $a != */public_html/* ]]" +prop_checkOrNeq6 = verify checkOrNeq "[ $a != a ] || [ $a != b ]" +prop_checkOrNeq7 = verify checkOrNeq "[ $a != a ] || [ $a != b ] || true" +prop_checkOrNeq8 = verifyNot checkOrNeq "[[ $a != x || $a != x ]]" -- This only catches the most idiomatic cases. Fixme? -checkOrNeq _ (TC_Or id typ op (TC_Binary _ _ op1 lhs1 rhs1 ) (TC_Binary _ _ op2 lhs2 rhs2)) - | lhs1 == lhs2 && (op1 == op2 && (op1 == "-ne" || op1 == "!=")) && not (any isGlob [rhs1,rhs2]) = - warn id 2055 $ "You probably wanted " ++ (if typ == SingleBracket then "-a" else "&&") ++ " here." +-- For test-level "or": [ x != y -o x != z ] +checkOrNeq _ (TC_Or id typ op (TC_Binary _ _ op1 lhs1 rhs1 ) (TC_Binary _ _ op2 lhs2 rhs2)) + | (op1 == op2 && (op1 == "-ne" || op1 == "!=")) && lhs1 == lhs2 && rhs1 /= rhs2 && not (any isGlob [rhs1,rhs2]) = + warn id 2055 $ "You probably wanted " ++ (if typ == SingleBracket then "-a" else "&&") ++ " here, otherwise it's always true." + +-- For arithmetic context "or" checkOrNeq _ (TA_Binary id "||" (TA_Binary _ "!=" word1 _) (TA_Binary _ "!=" word2 _)) | word1 == word2 = - warn id 2056 "You probably wanted && here." + warn id 2056 "You probably wanted && here, otherwise it's always true." + +-- For command level "or": [ x != y ] || [ x != z ] +checkOrNeq _ (T_OrIf id lhs rhs) = potentially $ do + (lhs1, op1, rhs1) <- getExpr lhs + (lhs2, op2, rhs2) <- getExpr rhs + guard $ op1 == op2 && op1 `elem` ["-ne", "!="] + guard $ lhs1 == lhs2 && rhs1 /= rhs2 + guard . not $ any isGlob [rhs1, rhs2] + return $ warn id 2252 "You probably wanted && here, otherwise it's always true." + where + getExpr x = + case x of + T_OrIf _ lhs _ -> getExpr lhs -- Fetches x and y in `T_OrIf x (T_OrIf y z)` + T_Pipeline _ _ [x] -> getExpr x + T_Redirecting _ _ c -> getExpr c + T_Condition _ _ c -> getExpr c + TC_Binary _ _ op lhs rhs -> return (lhs, op, rhs) + _ -> fail "" + checkOrNeq _ _ = return () @@ -2047,6 +2086,11 @@ prop_checkUnused38= verifyTree checkUnusedAssignments "(( a=42 ))" prop_checkUnused39= verifyNotTree checkUnusedAssignments "declare -x -f foo" prop_checkUnused40= verifyNotTree checkUnusedAssignments "arr=(1 2); num=2; echo \"${arr[@]:num}\"" prop_checkUnused41= verifyNotTree checkUnusedAssignments "@test 'foo' {\ntrue\n}\n" +prop_checkUnused42= verifyNotTree checkUnusedAssignments "DEFINE_string foo '' ''; echo \"${FLAGS_foo}\"" +prop_checkUnused43= verifyTree checkUnusedAssignments "DEFINE_string foo '' ''" +prop_checkUnused44= verifyNotTree checkUnusedAssignments "DEFINE_string \"foo$ibar\" x y" +prop_checkUnused45= verifyTree checkUnusedAssignments "readonly foo=bar" +prop_checkUnused46= verifyTree checkUnusedAssignments "readonly foo=(bar)" checkUnusedAssignments params t = execWriter (mapM_ warnFor unused) where flow = variableFlow params @@ -2106,7 +2150,10 @@ prop_checkUnassignedReferences34= verifyNotTree checkUnassignedReferences "decla prop_checkUnassignedReferences35= verifyNotTree checkUnassignedReferences "echo ${arr[foo-bar]:?fail}" prop_checkUnassignedReferences36= verifyNotTree checkUnassignedReferences "read -a foo -r <<<\"foo bar\"; echo \"$foo\"" prop_checkUnassignedReferences37= verifyNotTree checkUnassignedReferences "var=howdy; printf -v 'array[0]' %s \"$var\"; printf %s \"${array[0]}\";" -checkUnassignedReferences params t = warnings +prop_checkUnassignedReferences38= verifyTree (checkUnassignedReferences' True) "echo $VAR" + +checkUnassignedReferences = checkUnassignedReferences' False +checkUnassignedReferences' includeGlobals params t = warnings where (readMap, writeMap) = execState (mapM tally $ variableFlow params) (Map.empty, Map.empty) defaultAssigned = Map.fromList $ map (\a -> (a, ())) $ filter (not . null) internalVariables @@ -2151,8 +2198,11 @@ checkUnassignedReferences params t = warnings return $ " (did you mean '" ++ match ++ "'?)" warningFor var place = do + guard $ isVariableName var guard . not $ isInArray var place || isGuarded place - (if isLocal var then warningForLocals else warningForGlobals) var place + (if includeGlobals || isLocal var + then warningForLocals + else warningForGlobals) var place warnings = execWriter . sequence $ mapMaybe (uncurry warningFor) unassigned @@ -2299,27 +2349,18 @@ prop_checkCdAndBack4 = verify checkCdAndBack "cd $tmp; foo; cd -" prop_checkCdAndBack5 = verifyNot checkCdAndBack "cd ..; foo; cd .." prop_checkCdAndBack6 = verify checkCdAndBack "for dir in */; do cd \"$dir\"; some_cmd; cd ..; done" prop_checkCdAndBack7 = verifyNot checkCdAndBack "set -e; for dir in */; do cd \"$dir\"; some_cmd; cd ..; done" -checkCdAndBack params = doLists +prop_checkCdAndBack8 = verifyNot checkCdAndBack "cd tmp\nfoo\n# shellcheck disable=SC2103\ncd ..\n" +checkCdAndBack params t = + unless (hasSetE params) $ mapM_ doList $ getCommandSequences t where - shell = shellType params - doLists (T_ForIn _ _ _ cmds) = doList cmds - doLists (T_ForArithmetic _ _ _ _ cmds) = doList cmds - doLists (T_WhileExpression _ _ cmds) = doList cmds - doLists (T_UntilExpression _ _ cmds) = doList cmds - doLists (T_Script _ _ cmds) = doList cmds - doLists (T_IfExpression _ thens elses) = do - mapM_ (\(_, l) -> doList l) thens - doList elses - doLists _ = return () - isCdRevert t = case oversimplify t of - ["cd", p] -> p `elem` ["..", "-"] + [_, p] -> p `elem` ["..", "-"] _ -> False - getCmd (T_Annotation id _ x) = getCmd x - getCmd (T_Pipeline id _ [x]) = getCommandName x - getCmd _ = Nothing + getCandidate (T_Annotation _ _ x) = getCandidate x + getCandidate (T_Pipeline id _ [x]) | x `isCommand` "cd" = return x + getCandidate _ = Nothing findCdPair list = case list of @@ -2329,13 +2370,9 @@ checkCdAndBack params = doLists else findCdPair (b:rest) _ -> Nothing - doList list = - if hasSetE params - then return () - else let cds = filter ((== Just "cd") . getCmd) list - in potentially $ do - cd <- findCdPair cds - return $ info cd 2103 "Use a ( subshell ) to avoid having to cd back." + doList list = potentially $ do + cd <- findCdPair $ mapMaybe getCandidate list + return $ info cd 2103 "Use a ( subshell ) to avoid having to cd back." prop_checkLoopKeywordScope1 = verify checkLoopKeywordScope "continue 2" prop_checkLoopKeywordScope2 = verify checkLoopKeywordScope "for f; do ( break; ); done" @@ -3058,8 +3095,8 @@ checkSplittingInArrays params t = && not (getBracedReference (bracedString part) `elem` variablesWithoutSpaces) -> warn id 2206 $ if shellType params == Ksh - then "Quote to prevent word splitting, or split robustly with read -A or while read." - else "Quote to prevent word splitting, or split robustly with mapfile or read -a." + then "Quote to prevent word splitting/globbing, or split robustly with read -A or while read." + else "Quote to prevent word splitting/globbing, or split robustly with mapfile or read -a." _ -> return () forCommand id = @@ -3358,18 +3395,23 @@ checkDefaultCase _ t = pg <- wordToExactPseudoGlob pat return $ pseudoGlobIsSuperSetof pg [PGMany] -prop_checkUselessBang1 = verify checkUselessBang "! true; rest" -prop_checkUselessBang2 = verify checkUselessBang "while true; do ! true; done" -prop_checkUselessBang3 = verifyNot checkUselessBang "if ! true; then true; fi" -prop_checkUselessBang4 = verifyNot checkUselessBang "( ! true )" -prop_checkUselessBang5 = verifyNot checkUselessBang "{ ! true; }" -prop_checkUselessBang6 = verifyNot checkUselessBang "x() { ! [ x ]; }" -checkUselessBang params t = mapM_ check (getNonReturningCommands t) +prop_checkUselessBang1 = verify checkUselessBang "set -e; ! true; rest" +prop_checkUselessBang2 = verifyNot checkUselessBang "! true; rest" +prop_checkUselessBang3 = verify checkUselessBang "set -e; while true; do ! true; done" +prop_checkUselessBang4 = verifyNot checkUselessBang "set -e; if ! true; then true; fi" +prop_checkUselessBang5 = verifyNot checkUselessBang "set -e; ( ! true )" +prop_checkUselessBang6 = verify checkUselessBang "set -e; { ! true; }" +prop_checkUselessBang7 = verifyNot checkUselessBang "set -e; x() { ! [ x ]; }" +prop_checkUselessBang8 = verifyNot checkUselessBang "set -e; if { ! true; }; then true; fi" +prop_checkUselessBang9 = verifyNot checkUselessBang "set -e; while ! true; do true; done" +checkUselessBang params t = when (hasSetE params) $ mapM_ check (getNonReturningCommands t) where check t = case t of - T_Banged id _ -> - info id 2251 "This ! is not on a condition and skips errexit. Use { ! ...; } to errexit, or verify usage." + T_Banged id cmd | not $ isCondition (getPath (parentMap params) t) -> + addComment $ makeCommentWithFix InfoC id 2251 + "This ! is not on a condition and skips errexit. Use `&& exit 1` instead, or make sure $? is checked." + (fixWith [replaceStart id params 1 "", replaceEnd (getId cmd) params 0 " && exit 1"]) _ -> return () -- Get all the subcommands that aren't likely to be the return value @@ -3377,7 +3419,7 @@ checkUselessBang params t = mapM_ check (getNonReturningCommands t) getNonReturningCommands t = case t of T_Script _ _ list -> dropLast list - T_BraceGroup _ list -> dropLast list + T_BraceGroup _ list -> if isFunctionBody t then dropLast list else list T_Subshell _ list -> dropLast list T_WhileExpression _ conds cmds -> dropLast conds ++ cmds T_UntilExpression _ conds cmds -> dropLast conds ++ cmds @@ -3388,6 +3430,11 @@ checkUselessBang params t = mapM_ check (getNonReturningCommands t) concatMap (dropLast . fst) conds ++ concatMap snd conds ++ elses _ -> [] + isFunctionBody t = + case getPath (parentMap params) t of + _:T_Function {}:_-> True + _ -> False + dropLast t = case t of [_] -> [] diff --git a/src/ShellCheck/Analyzer.hs b/src/ShellCheck/Analyzer.hs index 01440d8..33d2ae0 100644 --- a/src/ShellCheck/Analyzer.hs +++ b/src/ShellCheck/Analyzer.hs @@ -25,6 +25,7 @@ import ShellCheck.Interface import Data.List import Data.Monoid import qualified ShellCheck.Checks.Commands +import qualified ShellCheck.Checks.Custom import qualified ShellCheck.Checks.ShellSupport @@ -41,6 +42,7 @@ analyzeScript spec = newAnalysisResult { checkers params = mconcat $ map ($ params) [ ShellCheck.Checks.Commands.checker, + ShellCheck.Checks.Custom.checker, ShellCheck.Checks.ShellSupport.checker ] diff --git a/src/ShellCheck/AnalyzerLib.hs b/src/ShellCheck/AnalyzerLib.hs index d99ea98..70b781e 100644 --- a/src/ShellCheck/AnalyzerLib.hs +++ b/src/ShellCheck/AnalyzerLib.hs @@ -206,7 +206,7 @@ containsSetE root = isNothing $ doAnalysis (guard . not . isSetE) root where isSetE t = case t of - T_Script _ str _ -> str `matches` re + T_Script _ (T_Literal _ str) _ -> str `matches` re T_SimpleCommand {} -> t `isUnqualifiedCommand` "set" && ("errexit" `elem` oversimplify t || @@ -252,7 +252,7 @@ determineShell fallbackShell t = fromMaybe Bash $ do getCandidates (T_Annotation _ annotations s) = map forAnnotation annotations ++ [Just $ fromShebang s] - fromShebang (T_Script _ s t) = executableFromShebang s + fromShebang (T_Script _ (T_Literal _ s) _) = executableFromShebang s -- Given a string like "/bin/bash" or "/usr/bin/env dash", -- return the shell basename like "bash" or "dash" @@ -546,10 +546,6 @@ getReferencedVariableCommand base@(T_SimpleCommand _ _ (T_NormalWord _ (T_Litera (not $ any (`elem` flags) ["f", "F"]) then concatMap getReference rest else [] - "readonly" -> - if any (`elem` flags) ["f", "p"] - then [] - else concatMap getReference rest "trap" -> case rest of head:_ -> map (\x -> (head, head, x)) $ getVariablesFromLiteralToken head @@ -606,6 +602,11 @@ getModifiedVariableCommand base@(T_SimpleCommand _ _ (T_NormalWord _ (T_Literal "mapfile" -> maybeToList $ getMapfileArray base rest "readarray" -> maybeToList $ getMapfileArray base rest + "DEFINE_boolean" -> maybeToList $ getFlagVariable rest + "DEFINE_float" -> maybeToList $ getFlagVariable rest + "DEFINE_integer" -> maybeToList $ getFlagVariable rest + "DEFINE_string" -> maybeToList $ getFlagVariable rest + _ -> [] where flags = map snd $ getAllFlags base @@ -675,9 +676,22 @@ getModifiedVariableCommand base@(T_SimpleCommand _ _ (T_NormalWord _ (T_Literal return (base, lastArg, name, DataArray SourceExternal) -- get all the array variables used in read, e.g. read -a arr - getReadArrayVariables args = do + getReadArrayVariables args = map (getLiteralArray . snd) - (filter (\(x,_) -> getLiteralString x == Just "-a") (zip (args) (tail args))) + (filter (isArrayFlag . fst) (zip args (tail args))) + + isArrayFlag x = fromMaybe False $ do + str <- getLiteralString x + return $ case str of + '-':'-':_ -> False + '-':str -> 'a' `elem` str + _ -> False + + -- get the FLAGS_ variable created by a shflags DEFINE_ call + getFlagVariable (n:v:_) = do + name <- getLiteralString n + return (base, n, "FLAGS_" ++ name, DataString $ SourceExternal) + getFlagVariable _ = Nothing getModifiedVariableCommand _ = [] @@ -777,7 +791,7 @@ isCommandMatch token matcher = fromMaybe False $ -- False: .*foo.* isConfusedGlobRegex :: String -> Bool isConfusedGlobRegex ('*':_) = True -isConfusedGlobRegex [x,'*'] | x /= '\\' = True +isConfusedGlobRegex [x,'*'] | x `notElem` "\\." = True isConfusedGlobRegex _ = False isVariableStartChar x = x == '_' || isAsciiLower x || isAsciiUpper x diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs index 851d7f2..c6346a9 100644 --- a/src/ShellCheck/Checks/Commands.hs +++ b/src/ShellCheck/Checks/Commands.hs @@ -94,6 +94,7 @@ commandChecks = [ ,checkSudoRedirect ,checkSudoArgs ,checkSourceArgs + ,checkChmodDashr ] buildCommandMap :: [CommandCheck] -> Map.Map CommandName (Token -> Analysis) @@ -213,6 +214,9 @@ prop_checkGrepRe17= verifyNot checkGrepRe "grep --exclude 'Foo*' file" prop_checkGrepRe18= verifyNot checkGrepRe "grep --exclude-dir 'Foo*' file" prop_checkGrepRe19= verify checkGrepRe "grep -- 'Foo*' file" prop_checkGrepRe20= verifyNot checkGrepRe "grep --fixed-strings 'Foo*' file" +prop_checkGrepRe21= verifyNot checkGrepRe "grep -o 'x*' file" +prop_checkGrepRe22= verifyNot checkGrepRe "grep --only-matching 'x*' file" +prop_checkGrepRe23= verifyNot checkGrepRe "grep '.*' file" checkGrepRe = CommandCheck (Basename "grep") check where check cmd = f cmd (arguments cmd) @@ -245,7 +249,7 @@ checkGrepRe = CommandCheck (Basename "grep") check where "Note that unlike globs, " ++ [char] ++ "* here matches '" ++ [char, char, char] ++ "' but not '" ++ wordStartingWith char ++ "'." where flags = map snd $ getAllFlags cmd - grepGlobFlags = ["fixed-strings", "F", "include", "exclude", "exclude-dir"] + grepGlobFlags = ["fixed-strings", "F", "include", "exclude", "exclude-dir", "o", "only-matching"] wordStartingWith c = head . filter ([c] `isPrefixOf`) $ candidates @@ -534,52 +538,83 @@ prop_checkPrintfVar15= verifyNot checkPrintfVar "printf '%*s\\n' 1 2" prop_checkPrintfVar16= verifyNot checkPrintfVar "printf $'string'" prop_checkPrintfVar17= verify checkPrintfVar "printf '%-*s\\n' 1" prop_checkPrintfVar18= verifyNot checkPrintfVar "printf '%-*s\\n' 1 2" +prop_checkPrintfVar19= verifyNot checkPrintfVar "printf '%(%s)T'" +prop_checkPrintfVar20= verifyNot checkPrintfVar "printf '%d %(%s)T' 42" +prop_checkPrintfVar21= verify checkPrintfVar "printf '%d %(%s)T'" checkPrintfVar = CommandCheck (Exactly "printf") (f . arguments) where f (doubledash:rest) | getLiteralString doubledash == Just "--" = f rest f (dashv:var:rest) | getLiteralString dashv == Just "-v" = f rest f (format:params) = check format params f _ = return () - countFormats string = - case string of - '%':'%':rest -> countFormats rest - '%':'(':rest -> 1 + countFormats (dropWhile (/= ')') rest) - '%':rest -> regexBasedCountFormats rest + countFormats (dropWhile (/= '%') rest) - _:rest -> countFormats rest - [] -> 0 - - regexBasedCountFormats rest = - maybe 1 (foldl (\acc group -> acc + (if group == "*" then 1 else 0)) 1) (matchRegex re rest) - where - -- constructed based on specifications in "man printf" - re = mkRegex "#?-?\\+? ?0?(\\*|\\d*).?(\\d*|\\*)[diouxXfFeEgGaAcsb]" - -- \____ _____/\___ ____/ \____ ____/\________ ________/ - -- V V V V - -- flags field width precision format character - -- field width and precision can be specified with a '*' instead of a digit, - -- in which case printf will accept one more argument for each '*' used check format more = do fromMaybe (return ()) $ do string <- getLiteralString format - let vars = countFormats string - - return $ do - when (vars == 0 && more /= []) $ - err (getId format) 2182 - "This printf format string has no variables. Other arguments are ignored." - - when (vars > 0 - && ((length more) `mod` vars /= 0 || null more) - && all (not . mayBecomeMultipleArgs) more) $ - warn (getId format) 2183 $ - "This format string has " ++ show vars ++ " variables, but is passed " ++ show (length more) ++ " arguments." + let formats = getPrintfFormats string + let formatCount = length formats + let argCount = length more + return $ + case () of + () | argCount == 0 && formatCount == 0 -> + return () -- This is fine + () | formatCount == 0 && argCount > 0 -> + err (getId format) 2182 + "This printf format string has no variables. Other arguments are ignored." + () | any mayBecomeMultipleArgs more -> + return () -- We don't know so trust the user + () | argCount < formatCount && onlyTrailingTs formats argCount -> + return () -- Allow trailing %()Ts since they use the current time + () | argCount > 0 && argCount `mod` formatCount == 0 -> + return () -- Great: a suitable number of arguments + () -> + warn (getId format) 2183 $ + "This format string has " ++ show formatCount ++ " variables, but is passed " ++ show argCount ++ " arguments." unless ('%' `elem` concat (oversimplify format) || isLiteral format) $ info (getId format) 2059 "Don't use variables in the printf format string. Use printf \"..%s..\" \"$foo\"." + where + onlyTrailingTs format argCount = + all (== 'T') $ drop argCount format +prop_checkGetPrintfFormats1 = getPrintfFormats "%s" == "s" +prop_checkGetPrintfFormats2 = getPrintfFormats "%0*s" == "*s" +prop_checkGetPrintfFormats3 = getPrintfFormats "%(%s)T" == "T" +prop_checkGetPrintfFormats4 = getPrintfFormats "%d%%%(%s)T" == "dT" +prop_checkGetPrintfFormats5 = getPrintfFormats "%bPassed: %d, %bFailed: %d%b, Skipped: %d, %bErrored: %d%b\\n" == "bdbdbdbdb" +getPrintfFormats = getFormats + where + -- Get the arguments in the string as a string of type characters, + -- e.g. "Hello %s" -> "s" and "%(%s)T %0*d\n" -> "T*d" + getFormats :: String -> String + getFormats string = + case string of + '%':'%':rest -> getFormats rest + '%':'(':rest -> + case dropWhile (/= ')') rest of + ')':c:trailing -> c : getFormats trailing + _ -> "" + '%':rest -> regexBasedGetFormats rest + _:rest -> getFormats rest + [] -> "" + + regexBasedGetFormats rest = + case matchRegex re rest of + Just [width, precision, typ, rest] -> + (if width == "*" then "*" else "") ++ + (if precision == "*" then "*" else "") ++ + typ ++ getFormats rest + Nothing -> take 1 rest ++ getFormats rest + where + -- constructed based on specifications in "man printf" + re = mkRegex "#?-?\\+? ?0?(\\*|\\d*)\\.?(\\d*|\\*)([diouxXfFeEgGaAcsbq])(.*)" + -- \____ _____/\___ ____/ \____ ____/\_________ _________/ \ / + -- V V V V V + -- flags field width precision format character rest + -- field width and precision can be specified with a '*' instead of a digit, + -- in which case printf will accept one more argument for each '*' used prop_checkUuoeCmd1 = verify checkUuoeCmd "echo $(date)" @@ -1042,5 +1077,16 @@ checkSourceArgs = CommandCheck (Exactly ".") f "The dot command does not support arguments in sh/dash. Set them as variables." _ -> return () +prop_checkChmodDashr1 = verify checkChmodDashr "chmod -r 0755 dir" +prop_checkChmodDashr2 = verifyNot checkChmodDashr "chmod -R 0755 dir" +prop_checkChmodDashr3 = verifyNot checkChmodDashr "chmod a-r dir" +checkChmodDashr = CommandCheck (Basename "chmod") f + where + f t = mapM_ check $ arguments t + check t = potentially $ do + flag <- getLiteralString t + guard $ flag == "-r" + return $ warn (getId t) 2253 "Use -R to recurse, or explicitly a-r to remove read permissions." + return [] runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |]) diff --git a/src/ShellCheck/Checks/Custom.hs b/src/ShellCheck/Checks/Custom.hs new file mode 100644 index 0000000..76ac83c --- /dev/null +++ b/src/ShellCheck/Checks/Custom.hs @@ -0,0 +1,21 @@ +{- + This empty file is provided for ease of patching in site specific checks. + However, there are no guarantees regarding compatibility between versions. +-} + +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.Checks.Custom (checker, ShellCheck.Checks.Custom.runTests) where + +import ShellCheck.AnalyzerLib +import Test.QuickCheck + +checker :: Parameters -> Checker +checker params = Checker { + perScript = const $ return (), + perToken = const $ return () + } + +prop_CustomTestsWork = True + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Checks/ShellSupport.hs b/src/ShellCheck/Checks/ShellSupport.hs index 5ca44a1..83d23fb 100644 --- a/src/ShellCheck/Checks/ShellSupport.hs +++ b/src/ShellCheck/Checks/ShellSupport.hs @@ -174,6 +174,9 @@ prop_checkBashisms90 = verifyNot checkBashisms "#!/bin/sh\nset -o \"$opt\"" prop_checkBashisms91 = verify checkBashisms "#!/bin/sh\nwait -n" prop_checkBashisms92 = verify checkBashisms "#!/bin/sh\necho $((16#FF))" prop_checkBashisms93 = verify checkBashisms "#!/bin/sh\necho $(( 10#$(date +%m) ))" +prop_checkBashisms94 = verify checkBashisms "#!/bin/sh\n[ -v var ]" +prop_checkBashisms95 = verify checkBashisms "#!/bin/sh\necho $_" +prop_checkBashisms96 = verifyNot checkBashisms "#!/bin/dash\necho $_" checkBashisms = ForShell [Sh, Dash] $ \t -> do params <- ask kludge params t @@ -208,6 +211,8 @@ checkBashisms = ForShell [Sh, Dash] $ \t -> do warnMsg id "== in place of = is" bashism (TC_Binary id SingleBracket "=~" _ _) = warnMsg id "=~ regex matching is" + bashism (TC_Unary id SingleBracket "-v" _) = + warnMsg id "unary -v (in place of [ -n \"${var+x}\" ]) is" bashism (TC_Unary id _ "-a" _) = warnMsg id "unary -a in place of -e is" bashism (TA_Unary id op _) @@ -405,10 +410,11 @@ checkBashisms = ForShell [Sh, Dash] $ \t -> do ] bashVars = [ "OSTYPE", "MACHTYPE", "HOSTTYPE", "HOSTNAME", - "DIRSTACK", "EUID", "UID", "SHLVL", "PIPESTATUS", "SHELLOPTS" + "DIRSTACK", "EUID", "UID", "SHLVL", "PIPESTATUS", "SHELLOPTS", + "_" ] bashDynamicVars = [ "RANDOM", "SECONDS" ] - dashVars = [ ] + dashVars = [ "_" ] isBashVariable var = (var `elem` bashDynamicVars || var `elem` bashVars && not (isAssigned var)) diff --git a/src/ShellCheck/Data.hs b/src/ShellCheck/Data.hs index 2eedeeb..1394c04 100644 --- a/src/ShellCheck/Data.hs +++ b/src/ShellCheck/Data.hs @@ -36,6 +36,11 @@ internalVariables = [ -- Ksh , ".sh.version" + + -- shflags + , "FLAGS_ARGC", "FLAGS_ARGV", "FLAGS_ERROR", "FLAGS_FALSE", "FLAGS_HELP", + "FLAGS_PARENT", "FLAGS_RESERVED", "FLAGS_TRUE", "FLAGS_VERSION", + "flags_error", "flags_return" ] specialVariablesWithoutSpaces = [ @@ -45,6 +50,9 @@ variablesWithoutSpaces = specialVariablesWithoutSpaces ++ [ "BASHPID", "BASH_ARGC", "BASH_LINENO", "BASH_SUBSHELL", "EUID", "LINENO", "OPTIND", "PPID", "RANDOM", "SECONDS", "SHELLOPTS", "SHLVL", "UID", "COLUMNS", "HISTFILESIZE", "HISTSIZE", "LINES" + + -- shflags + , "FLAGS_ERROR", "FLAGS_FALSE", "FLAGS_TRUE" ] specialVariables = specialVariablesWithoutSpaces ++ ["@", "*"] diff --git a/src/ShellCheck/Formatter/Diff.hs b/src/ShellCheck/Formatter/Diff.hs new file mode 100644 index 0000000..445d9de --- /dev/null +++ b/src/ShellCheck/Formatter/Diff.hs @@ -0,0 +1,255 @@ +{- + Copyright 2019 Vidar 'koala_man' Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.Formatter.Diff (format, ShellCheck.Formatter.Diff.runTests) where + +import ShellCheck.Interface +import ShellCheck.Fixer +import ShellCheck.Formatter.Format + +import Control.Monad +import Data.Algorithm.Diff +import Data.Array +import Data.IORef +import Data.List +import qualified Data.Monoid as Monoid +import Data.Maybe +import qualified Data.Map as M +import GHC.Exts (sortWith) +import System.IO +import System.FilePath + +import Test.QuickCheck + +import Debug.Trace +ltt x = trace (show x) x + +format :: FormatterOptions -> IO Formatter +format options = do + didOutput <- newIORef False + shouldColor <- shouldOutputColor (foColorOption options) + let color = if shouldColor then colorize else nocolor + return Formatter { + header = return (), + footer = checkFooter didOutput color, + onFailure = reportFailure color, + onResult = reportResult didOutput color + } + + +contextSize = 3 +red = 31 +green = 32 +yellow = 33 +cyan = 36 +bold = 1 + +nocolor n = id +colorize n s = (ansi n) ++ s ++ (ansi 0) +ansi n = "\x1B[" ++ show n ++ "m" + +printErr :: ColorFunc -> String -> IO () +printErr color = hPutStrLn stderr . color bold . color red +reportFailure color file msg = printErr color $ file ++ ": " ++ msg + +checkFooter didOutput color = do + output <- readIORef didOutput + unless output $ + printErr color "Issues were detected, but none were auto-fixable. Use another format to see them." + +type ColorFunc = (Int -> String -> String) +data LFStatus = LinefeedMissing | LinefeedOk +data DiffDoc a = DiffDoc String LFStatus [DiffRegion a] +data DiffRegion a = DiffRegion (Int, Int) (Int, Int) [Diff a] + +reportResult :: (IORef Bool) -> ColorFunc -> CheckResult -> SystemInterface IO -> IO () +reportResult didOutput color result sys = do + let comments = crComments result + let suggestedFixes = mapMaybe pcFix comments + let fixmap = buildFixMap suggestedFixes + mapM_ output $ M.toList fixmap + where + output (name, fix) = do + file <- (siReadFile sys) name + case file of + Right contents -> do + putStrLn $ formatDoc color $ makeDiff name contents fix + writeIORef didOutput True + Left msg -> reportFailure color name msg + +hasTrailingLinefeed str = + case str of + [] -> True + _ -> last str == '\n' + +coversLastLine regions = + case regions of + [] -> False + _ -> (fst $ last regions) + +-- TODO: Factor this out into a unified diff library because we're doing a lot +-- of the heavy lifting anyways. +makeDiff :: String -> String -> Fix -> DiffDoc String +makeDiff name contents fix = do + let hunks = groupDiff $ computeDiff contents fix + let lf = if coversLastLine hunks && not (hasTrailingLinefeed contents) + then LinefeedMissing + else LinefeedOk + DiffDoc name lf $ findRegions hunks + +computeDiff :: String -> Fix -> [Diff String] +computeDiff contents fix = + let old = lines contents + array = listArray (1, fromIntegral $ (length old)) old + new = applyFix fix array + in getDiff old new + +-- Group changes into hunks +groupDiff :: [Diff a] -> [(Bool, [Diff a])] +groupDiff = filter (\(_, l) -> not (null l)) . hunt [] + where + -- Churn through 'Both's until we find a difference + hunt current [] = [(False, reverse current)] + hunt current (x@Both {}:rest) = hunt (x:current) rest + hunt current list = + let (context, previous) = splitAt contextSize current + in (False, reverse previous) : gather context 0 list + + -- Pick out differences until we find a run of Both's + gather current n [] = + let (extras, patch) = splitAt (max 0 $ n - contextSize) current + in [(True, reverse patch), (False, reverse extras)] + + gather current n list@(Both {}:_) | n == contextSize*2 = + let (context, previous) = splitAt contextSize current + in (True, reverse previous) : hunt context list + + gather current n (x@Both {}:rest) = gather (x:current) (n+1) rest + gather current n (x:rest) = gather (x:current) 0 rest + +-- Get line numbers for hunks +findRegions :: [(Bool, [Diff String])] -> [DiffRegion String] +findRegions = find' 1 1 + where + find' _ _ [] = [] + find' left right ((output, run):rest) = + let (dl, dr) = countDelta run + remainder = find' (left+dl) (right+dr) rest + in + if output + then DiffRegion (left, dl) (right, dr) run : remainder + else remainder + +-- Get left/right line counts for a hunk +countDelta :: [Diff a] -> (Int, Int) +countDelta = count' 0 0 + where + count' left right [] = (left, right) + count' left right (x:rest) = + case x of + Both {} -> count' (left+1) (right+1) rest + First {} -> count' (left+1) right rest + Second {} -> count' left (right+1) rest + +formatRegion :: ColorFunc -> LFStatus -> DiffRegion String -> String +formatRegion color lf (DiffRegion left right diffs) = + let header = color cyan ("@@ -" ++ (tup left) ++ " +" ++ (tup right) ++" @@") + in + unlines $ header : reverse (getStrings lf (reverse diffs)) + where + noLF = "\\ No newline at end of file" + + getStrings LinefeedOk list = map format list + getStrings LinefeedMissing list@((Both _ _):_) = noLF : map format list + getStrings LinefeedMissing list@((First _):_) = noLF : map format list + getStrings LinefeedMissing (last:rest) = format last : getStrings LinefeedMissing rest + + tup (a,b) = (show a) ++ "," ++ (show b) + format (Both x _) = ' ':x + format (First x) = color red $ '-':x + format (Second x) = color green $ '+':x + +splitLast [] = ([], []) +splitLast x = + let (last, rest) = splitAt 1 $ reverse x + in (reverse rest, last) + +formatDoc color (DiffDoc name lf regions) = + let (most, last) = splitLast regions + in + (color bold $ "--- " ++ ("a" name)) ++ "\n" ++ + (color bold $ "+++ " ++ ("b" name)) ++ "\n" ++ + concatMap (formatRegion color LinefeedOk) most ++ + concatMap (formatRegion color lf) last + +-- Create a Map from filename to Fix +buildFixMap :: [Fix] -> M.Map String Fix +buildFixMap fixes = perFile + where + splitFixes = concatMap splitFixByFile fixes + perFile = groupByMap (posFile . repStartPos . head . fixReplacements) splitFixes + +-- There are currently no multi-file fixes, but let's handle it anyways +splitFixByFile :: Fix -> [Fix] +splitFixByFile fix = map makeFix $ groupBy sameFile (fixReplacements fix) + where + sameFile rep1 rep2 = (posFile $ repStartPos rep1) == (posFile $ repStartPos rep2) + makeFix reps = newFix { fixReplacements = reps } + +groupByMap :: (Ord k, Monoid v) => (v -> k) -> [v] -> M.Map k v +groupByMap f = M.fromListWith Monoid.mappend . map (\x -> (f x, x)) + +-- For building unit tests +b n = Both n n +l = First +r = Second + +prop_identifiesProperContext = groupDiff [b 1, b 2, b 3, b 4, l 5, b 6, b 7, b 8, b 9] == + [(False, [b 1]), -- Omitted + (True, [b 2, b 3, b 4, l 5, b 6, b 7, b 8]), -- A change with three lines of context + (False, [b 9])] -- Omitted + +prop_includesContextFromStartIfNecessary = groupDiff [b 4, l 5, b 6, b 7, b 8, b 9] == + [ -- Nothing omitted + (True, [b 4, l 5, b 6, b 7, b 8]), -- A change with three lines of context + (False, [b 9])] -- Omitted + +prop_includesContextUntilEndIfNecessary = groupDiff [b 4, l 5] == + [ -- Nothing omitted + (True, [b 4, l 5]) + ] -- Nothing Omitted + +prop_splitsIntoMultipleHunks = groupDiff [l 1, b 1, b 2, b 3, b 4, b 5, b 6, b 7, r 8] == + [ -- Nothing omitted + (True, [l 1, b 1, b 2, b 3]), + (False, [b 4]), + (True, [b 5, b 6, b 7, r 8]) + ] -- Nothing Omitted + +prop_splitsIntoMultipleHunksUnlessTouching = groupDiff [l 1, b 1, b 2, b 3, b 4, b 5, b 6, r 7] == + [ + (True, [l 1, b 1, b 2, b 3, b 4, b 5, b 6, r 7]) + ] + +prop_countDeltasWorks = countDelta [b 1, l 2, r 3, r 4, b 5] == (3,4) +prop_countDeltasWorks2 = countDelta [] == (0,0) + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Formatter/Format.hs b/src/ShellCheck/Formatter/Format.hs index 11dfd17..cb7dfe6 100644 --- a/src/ShellCheck/Formatter/Format.hs +++ b/src/ShellCheck/Formatter/Format.hs @@ -22,8 +22,12 @@ module ShellCheck.Formatter.Format where import ShellCheck.Data import ShellCheck.Interface import ShellCheck.Fixer + import Control.Monad import Data.Array +import Data.List +import System.IO +import System.Info -- A formatter that carries along an arbitrary piece of data data Formatter = Formatter { @@ -59,6 +63,17 @@ makeNonVirtual comments contents = fixReplacements = map (\r -> removeTabStops r arr) (fixReplacements f) } fix c = (removeTabStops c arr) { - pcFix = liftM untabbedFix (pcFix c) + pcFix = fmap untabbedFix (pcFix c) } + +shouldOutputColor :: ColorOption -> IO Bool +shouldOutputColor colorOption = do + term <- hIsTerminalDevice stdout + let windows = "mingw" `isPrefixOf` os + let isUsableTty = term && not windows + let useColor = case colorOption of + ColorAlways -> True + ColorNever -> False + ColorAuto -> isUsableTty + return useColor diff --git a/src/ShellCheck/Formatter/JSON.hs b/src/ShellCheck/Formatter/JSON.hs index c3f3219..7c26421 100644 --- a/src/ShellCheck/Formatter/JSON.hs +++ b/src/ShellCheck/Formatter/JSON.hs @@ -30,12 +30,12 @@ import GHC.Exts import System.IO import qualified Data.ByteString.Lazy.Char8 as BL -format :: Bool -> IO Formatter -format removeTabs = do +format :: IO Formatter +format = do ref <- newIORef [] return Formatter { header = return (), - onResult = collectResult removeTabs ref, + onResult = collectResult ref, onFailure = outputError, footer = finish ref } @@ -98,19 +98,12 @@ instance ToJSON Fix where outputError file msg = hPutStrLn stderr $ file ++ ": " ++ msg -collectResult removeTabs ref cr sys = mapM_ f groups +collectResult ref cr sys = mapM_ f groups where comments = crComments cr groups = groupWith sourceFile comments f :: [PositionedComment] -> IO () - f group = do - let filename = sourceFile (head group) - result <- siReadFile sys filename - let contents = either (const "") id result - let comments' = if removeTabs - then makeNonVirtual comments contents - else comments - modifyIORef ref (\x -> comments' ++ x) + f group = modifyIORef ref (\x -> comments ++ x) finish ref = do list <- readIORef ref diff --git a/src/ShellCheck/Formatter/JSON1.hs b/src/ShellCheck/Formatter/JSON1.hs new file mode 100644 index 0000000..7335d8c --- /dev/null +++ b/src/ShellCheck/Formatter/JSON1.hs @@ -0,0 +1,127 @@ +{-# LANGUAGE OverloadedStrings #-} +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.JSON1 (format) where + +import ShellCheck.Interface +import ShellCheck.Formatter.Format + +import Data.Aeson +import Data.IORef +import Data.Monoid +import GHC.Exts +import System.IO +import qualified Data.ByteString.Lazy.Char8 as BL + +format :: IO Formatter +format = do + ref <- newIORef [] + return Formatter { + header = return (), + onResult = collectResult ref, + onFailure = outputError, + footer = finish ref + } + +data Json1Output = Json1Output { + comments :: [PositionedComment] + } + +instance ToJSON Json1Output where + toJSON result = object [ + "comments" .= comments result + ] + toEncoding result = pairs ( + "comments" .= comments result + ) + +instance ToJSON Replacement where + toJSON replacement = + let start = repStartPos replacement + end = repEndPos replacement + str = repString replacement in + object [ + "precedence" .= repPrecedence replacement, + "insertionPoint" .= + case repInsertionPoint replacement of + InsertBefore -> "beforeStart" :: String + InsertAfter -> "afterEnd", + "line" .= posLine start, + "column" .= posColumn start, + "endLine" .= posLine end, + "endColumn" .= posColumn end, + "replacement" .= str + ] + +instance ToJSON PositionedComment where + toJSON comment = + let start = pcStartPos comment + end = pcEndPos comment + c = pcComment comment in + object [ + "file" .= posFile start, + "line" .= posLine start, + "endLine" .= posLine end, + "column" .= posColumn start, + "endColumn" .= posColumn end, + "level" .= severityText comment, + "code" .= cCode c, + "message" .= cMessage c, + "fix" .= pcFix comment + ] + + toEncoding comment = + let start = pcStartPos comment + end = pcEndPos comment + c = pcComment comment in + pairs ( + "file" .= posFile start + <> "line" .= posLine start + <> "endLine" .= posLine end + <> "column" .= posColumn start + <> "endColumn" .= posColumn end + <> "level" .= severityText comment + <> "code" .= cCode c + <> "message" .= cMessage c + <> "fix" .= pcFix comment + ) + +instance ToJSON Fix where + toJSON fix = object [ + "replacements" .= fixReplacements fix + ] + +outputError file msg = hPutStrLn stderr $ file ++ ": " ++ msg + +collectResult ref cr sys = mapM_ f groups + where + comments = crComments cr + groups = groupWith sourceFile comments + f :: [PositionedComment] -> IO () + f group = do + let filename = sourceFile (head group) + result <- siReadFile sys filename + let contents = either (const "") id result + let comments' = makeNonVirtual comments contents + modifyIORef ref (\x -> comments' ++ x) + +finish ref = do + list <- readIORef ref + BL.putStrLn $ encode $ Json1Output { comments = list } diff --git a/src/ShellCheck/Formatter/Quiet.hs b/src/ShellCheck/Formatter/Quiet.hs index 9ad8b97..b7e0ee9 100644 --- a/src/ShellCheck/Formatter/Quiet.hs +++ b/src/ShellCheck/Formatter/Quiet.hs @@ -27,8 +27,7 @@ import Data.IORef import System.Exit format :: FormatterOptions -> IO Formatter -format options = do - topErrorRef <- newIORef [] +format options = return Formatter { header = return (), footer = return (), diff --git a/src/ShellCheck/Formatter/TTY.hs b/src/ShellCheck/Formatter/TTY.hs index 845feeb..4dabf45 100644 --- a/src/ShellCheck/Formatter/TTY.hs +++ b/src/ShellCheck/Formatter/TTY.hs @@ -188,13 +188,7 @@ code num = "SC" ++ show num getColorFunc :: ColorOption -> IO ColorFunc getColorFunc colorOption = do - term <- hIsTerminalDevice stdout - let windows = "mingw" `isPrefixOf` os - let isUsableTty = term && not windows - let useColor = case colorOption of - ColorAlways -> True - ColorNever -> False - ColorAuto -> isUsableTty + useColor <- shouldOutputColor colorOption return $ if useColor then colorComment else const id where colorComment level comment = diff --git a/src/ShellCheck/Parser.hs b/src/ShellCheck/Parser.hs index c09d64c..b2935fd 100644 --- a/src/ShellCheck/Parser.hs +++ b/src/ShellCheck/Parser.hs @@ -138,7 +138,6 @@ almostSpace = return ' ' --------- Message/position annotation on top of user state -data Note = Note Id Severity Code String deriving (Show, Eq) data ParseNote = ParseNote SourcePos SourcePos Severity Code String deriving (Show, Eq) data Context = ContextName SourcePos String @@ -166,10 +165,6 @@ initialUserState = UserState { } codeForParseNote (ParseNote _ _ _ code _) = code -noteToParseNote map (Note id severity code message) = - ParseNote pos pos severity code message - where - pos = fromJust $ Map.lookup id map getLastId = lastId <$> getState @@ -1529,10 +1524,10 @@ ensureDollar = readNormalDollar = do ensureDollar - readDollarExp <|> readDollarDoubleQuote <|> readDollarSingleQuote <|> readDollarLonely + readDollarExp <|> readDollarDoubleQuote <|> readDollarSingleQuote <|> readDollarLonely False readDoubleQuotedDollar = do ensureDollar - readDollarExp <|> readDollarLonely + readDollarExp <|> readDollarLonely True prop_readDollarExpression1 = isOk readDollarExpression "$(((1) && 3))" @@ -1694,12 +1689,32 @@ readVariableName = do rest <- many variableChars return (f:rest) -readDollarLonely = do + +prop_readDollarLonely1 = isWarning readNormalWord "\"$\"var" +prop_readDollarLonely2 = isWarning readNormalWord "\"$\"\"var\"" +prop_readDollarLonely3 = isOk readNormalWord "\"$\"$var" +prop_readDollarLonely4 = isOk readNormalWord "\"$\"*" +prop_readDollarLonely5 = isOk readNormalWord "$\"str\"" +readDollarLonely quoted = do start <- startSpan char '$' id <- endSpan start - n <- lookAhead (anyChar <|> (eof >> return '_')) + when quoted $ do + isHack <- quoteForEscape + when isHack $ + parseProblemAtId id StyleC 1135 + "Prefer escape over ending quote to make $ literal. Instead of \"It costs $\"5, use \"It costs \\$5\"." return $ T_Literal id "$" + where + quoteForEscape = option False $ try . lookAhead $ do + char '"' + -- Check for "foo $""bar" + optional $ char '"' + c <- anyVar + -- Don't trigger on [[ x == "$"* ]] or "$"$pattern + return $ c `notElem` "*$" + anyVar = variableStart <|> digit <|> specialVariable + prop_readHereDoc = isOk readScript "cat << foo\nlol\ncow\nfoo" prop_readHereDoc2 = isNotOk readScript "cat <<- EOF\n cow\n EOF" @@ -2750,7 +2765,7 @@ readAssignmentWordExt lenient = try $ do variable <- readVariableName when lenient $ optional (readNormalDollar >> parseNoteAt pos ErrorC - 1067 "For indirection, use (associative) arrays or 'read \"var$n\" <<< \"value\"'") + 1067 "For indirection, use arrays, declare \"var$n=value\", or (for sh) read/eval.") indices <- many readArrayIndex hasLeftSpace <- fmap (not . null) spacing pos <- getPosition @@ -2790,10 +2805,11 @@ readAssignmentWordExt lenient = try $ do string "=" >> return Assign ] - readEmptyLiteral = do - start <- startSpan - id <- endSpan start - return $ T_Literal id "" + +readEmptyLiteral = do + start <- startSpan + id <- endSpan start + return $ T_Literal id "" readArrayIndex = do start <- startSpan @@ -2941,12 +2957,14 @@ prop_readShebang5 = isWarning readShebang "\n#!/bin/sh" prop_readShebang6 = isWarning readShebang " # Copyright \n!#/bin/bash" prop_readShebang7 = isNotOk readShebang "# Copyright \nfoo\n#!/bin/bash" readShebang = do + start <- startSpan anyShebang <|> try readMissingBang <|> withHeader many linewhitespace str <- many $ noneOf "\r\n" + id <- endSpan start optional carriageReturn optional linefeed - return str + return $ T_Literal id str where anyShebang = choice $ map try [ readCorrect, @@ -3077,7 +3095,8 @@ readScriptFile sourced = do readUtf8Bom parseProblem ErrorC 1082 "This file has a UTF-8 BOM. Remove it with: LC_CTYPE=C sed '1s/^...//' < yourscript ." - sb <- option "" readShebang + shebang <- readShebang <|> readEmptyLiteral + let (T_Literal _ shebangString) = shebang allspacing annotationStart <- startSpan fileAnnotations <- readAnnotations @@ -3094,19 +3113,19 @@ readScriptFile sourced = do let ignoreShebang = shellAnnotationSpecified || shellFlagSpecified unless ignoreShebang $ - verifyShebang pos (getShell sb) - if ignoreShebang || isValidShell (getShell sb) /= Just False + verifyShebang pos (getShell shebangString) + if ignoreShebang || isValidShell (getShell shebangString) /= Just False then do commands <- withAnnotations annotations readCompoundListOrEmpty id <- endSpan start verifyEof let script = T_Annotation annotationId annotations $ - T_Script id sb commands + T_Script id shebang commands reparseIndices script else do many anyChar id <- endSpan start - return $ T_Script id sb [] + return $ T_Script id shebang [] where basename s = reverse . takeWhile (/= '/') . reverse $ s diff --git a/stack.yaml b/stack.yaml index d39cada..6dee632 100644 --- a/stack.yaml +++ b/stack.yaml @@ -2,7 +2,7 @@ # For more information, see: https://docs.haskellstack.org/en/stable/yaml_configuration/ # Specifies the GHC version and set of packages available (e.g., lts-3.5, nightly-2015-09-21, ghc-7.10.2) -resolver: lts-8.5 +resolver: lts-13.26 # Local packages, usually specified by relative directory name packages: diff --git a/test/shellcheck.hs b/test/shellcheck.hs index 8f858d6..ac84116 100644 --- a/test/shellcheck.hs +++ b/test/shellcheck.hs @@ -6,8 +6,10 @@ import qualified ShellCheck.Analytics import qualified ShellCheck.AnalyzerLib import qualified ShellCheck.Checker import qualified ShellCheck.Checks.Commands +import qualified ShellCheck.Checks.Custom import qualified ShellCheck.Checks.ShellSupport import qualified ShellCheck.Fixer +import qualified ShellCheck.Formatter.Diff import qualified ShellCheck.Parser main = do @@ -17,8 +19,10 @@ main = do ,ShellCheck.AnalyzerLib.runTests ,ShellCheck.Checker.runTests ,ShellCheck.Checks.Commands.runTests + ,ShellCheck.Checks.Custom.runTests ,ShellCheck.Checks.ShellSupport.runTests ,ShellCheck.Fixer.runTests + ,ShellCheck.Formatter.Diff.runTests ,ShellCheck.Parser.runTests ] if and results