ShellCheck 0.7.1 → 0.7.2
raw patch · 16 files changed
+1510/−557 lines, 16 files
Files
- README.md +14/−5
- ShellCheck.cabal +5/−2
- shellcheck.1.md +3/−2
- shellcheck.hs +1/−1
- src/ShellCheck/AST.hs +1/−1
- src/ShellCheck/ASTLib.hs +244/−54
- src/ShellCheck/Analytics.hs +721/−93
- src/ShellCheck/AnalyzerLib.hs +104/−126
- src/ShellCheck/Checker.hs +14/−1
- src/ShellCheck/Checks/Commands.hs +109/−79
- src/ShellCheck/Checks/ShellSupport.hs +91/−78
- src/ShellCheck/Fixer.hs +8/−8
- src/ShellCheck/Formatter/TTY.hs +2/−3
- src/ShellCheck/Interface.hs +3/−3
- src/ShellCheck/Parser.hs +188/−101
- test/shellcheck.hs +2/−0
README.md view
@@ -109,7 +109,8 @@ * [Codacy](https://www.codacy.com/) * [Code Climate](https://codeclimate.com/) * [Code Factor](https://www.codefactor.io/)-* [Github](https://github.com/features/actions)(only Linux)+* [CircleCI](https://circleci.com) via the [ShellCheck Orb](https://circleci.com/orbs/registry/orb/circleci/shellcheck)+* [Github](https://github.com/features/actions) (only Linux) Services and platforms with third party plugins: @@ -148,7 +149,7 @@ pacman -S shellcheck -or get the dependency free [shellcheck-static](https://aur.archlinux.org/packages/shellcheck-static/) from the AUR.+or get the dependency free [shellcheck-bin](https://aur.archlinux.org/packages/shellcheck-bin/) from the AUR. On Gentoo based distros: @@ -167,10 +168,14 @@ pkg install hs-ShellCheck -On OS X with homebrew:+On macOS (OS X) with Homebrew: brew install shellcheck +Or with MacPorts:++ sudo port install shellcheck+ On OpenBSD: pkg_add shellcheck@@ -197,6 +202,10 @@ C:\> scoop install shellcheck ``` +From [conda-forge](https://anaconda.org/conda-forge/shellcheck):++ conda install -c conda-forge shellcheck+ From Snap Store: snap install --channel=edge shellcheck@@ -220,7 +229,7 @@ * [Linux, x86_64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.linux.x86_64.tar.xz) (statically linked) * [Linux, armv6hf](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.linux.armv6hf.tar.xz), i.e. Raspberry Pi (statically linked) * [Linux, aarch64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.linux.aarch64.tar.xz) aka ARM64 (statically linked)-* [MacOS, x86_64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.darwin.x86_64.tar.xz)+* [macOS, x86_64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.darwin.x86_64.tar.xz) * [Windows, x86](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.zip) or see the [GitHub Releases](https://github.com/koalaman/shellcheck/releases) for other releases@@ -264,7 +273,7 @@ ShellCheck is built and packaged using Cabal. Install the package `cabal-install` from your system's package manager (with e.g. `apt-get`, `brew`, `emerge`, `yum`, or `zypper`). -On MacOS (OS X), you can do a fast install of Cabal using brew, which takes a couple of minutes instead of more than 30 minutes if you try to compile it from source.+On macOS (OS X), you can do a fast install of Cabal using brew, which takes a couple of minutes instead of more than 30 minutes if you try to compile it from source. $ brew install cabal-install
ShellCheck.cabal view
@@ -1,5 +1,5 @@ Name: ShellCheck-Version: 0.7.1+Version: 0.7.2 Synopsis: Shell script analysis tool License: GPL-3 License-file: LICENSE@@ -8,7 +8,7 @@ Maintainer: vidar@vidarholen.net Homepage: https://www.shellcheck.net/ Build-Type: Simple-Cabal-Version: >= 1.8+Cabal-Version: >= 1.10 Bug-reports: https://github.com/koalaman/shellcheck/issues Description: The goals of ShellCheck are:@@ -83,6 +83,7 @@ ShellCheck.Regex other-modules: Paths_ShellCheck+ default-language: Haskell98 executable shellcheck if impl(ghc < 8.0)@@ -103,6 +104,7 @@ QuickCheck >= 2.7.4, regex-tdfa, ShellCheck+ default-language: Haskell98 main-is: shellcheck.hs test-suite test-shellcheck@@ -122,5 +124,6 @@ QuickCheck >= 2.7.4, regex-tdfa, ShellCheck+ default-language: Haskell98 main-is: test/shellcheck.hs
shellcheck.1.md view
@@ -232,7 +232,8 @@ **disable** : Disables a comma separated list of error codes for the following command. The command can be a simple command like `echo foo`, or a compound command- like a function definition, subshell block or loop.+ like a function definition, subshell block or loop. A range can be+ be specified with a dash, e.g. `disable=SC3000-SC4000` to exclude 3xxx. **enable** : Enable an optional check by name, as listed with **--list-optional**.@@ -254,7 +255,7 @@ **shell** : Overrides the shell detected from the shebang. This is useful for files meant to be included (and thus lacking a shebang), or possibly- as a more targeted alternative to 'disable=2039'.+ as a more targeted alternative to 'disable=SC2039'. # RC FILES
shellcheck.hs view
@@ -507,7 +507,7 @@ where find filename deflt = do sources <- findM ((allowable inputs) `andM` doesFileExist) $- (adjustPath filename):(map (</> filename) $ map adjustPath $ sourcePathFlag ++ sourcePathAnnotation)+ (adjustPath filename):(map ((</> filename) . adjustPath) $ sourcePathFlag ++ sourcePathAnnotation) case sources of Nothing -> return deflt Just first -> return first
src/ShellCheck/AST.hs view
@@ -145,7 +145,7 @@ deriving (Show, Eq, Functor, Foldable, Traversable) data Annotation =- DisableComment Integer+ DisableComment Integer Integer -- [from, to) | EnableComment String | SourceOverride String | ShellOverride String
src/ShellCheck/ASTLib.hs view
@@ -17,9 +17,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. -}+{-# LANGUAGE TemplateHaskell #-} module ShellCheck.ASTLib where import ShellCheck.AST+import ShellCheck.Regex import Control.Monad.Writer import Control.Monad@@ -28,7 +30,13 @@ import Data.Functor.Identity import Data.List import Data.Maybe+import qualified Data.Map as Map+import Numeric (showHex) +import Test.QuickCheck++arguments (T_SimpleCommand _ _ (cmd:args)) = args+ -- Is this a type of loop? isLoop t = case t of T_WhileExpression {} -> True@@ -134,13 +142,95 @@ str <- getLeadingUnquotedString token return $ "-" `isPrefixOf` str --- Given a T_DollarBraced, return a simplified version of the string contents.-bracedString (T_DollarBraced _ _ l) = concat $ oversimplify l-bracedString _ = error "Internal shellcheck error, please report! (bracedString on non-variable)"+-- getGnuOpts "erd:u:" will parse a list of arguments tokens like `read`+-- -re -d : -u 3 bar+-- into+-- Just [("r", (-re, -re)), ("e", (-re, -re)), ("d", (-d,:)), ("u", (-u,3)), ("", (bar,bar))]+--+-- Each string flag maps to a tuple of (flag, argument), where argument=flag if it+-- doesn't take a specific one.+--+-- Any unrecognized flag will result in Nothing. The exception is if arbitraryLongOpts+-- is set, in which case --anything will map to "anything".+getGnuOpts :: String -> [Token] -> Maybe [(String, (Token, Token))]+getGnuOpts str args = getOpts (True, False) str [] args +-- As above, except the first non-arg string will treat the rest as arguments+getBsdOpts :: String -> [Token] -> Maybe [(String, (Token, Token))]+getBsdOpts str args = getOpts (False, False) str [] args++-- Tests for this are in Commands.hs where it's more frequently used+getOpts ::+ -- Behavioral config: gnu style, allow arbitrary long options+ (Bool, Bool)+ -- A getopts style string+ -> String+ -- List of long options and whether they take arguments+ -> [(String, Bool)]+ -- List of arguments (excluding command)+ -> [Token]+ -- List of flags to tuple of (optionToken, valueToken)+ -> Maybe [(String, (Token, Token))]++getOpts (gnu, arbitraryLongOpts) string longopts args = process args+ where+ flagList (c:':':rest) = ([c], True) : flagList rest+ flagList (c:rest) = ([c], False) : flagList rest+ flagList [] = longopts+ flagMap = Map.fromList $ ("", False) : flagList string++ process [] = return []+ process (token:rest) = do+ case getLiteralStringDef "\0" token of+ "--" -> return $ listToArgs rest+ '-':'-':word -> do+ let (name, arg) = span (/= '=') word+ needsArg <-+ if arbitraryLongOpts+ then return $ Map.findWithDefault False name flagMap+ else Map.lookup name flagMap++ if needsArg && null arg+ then+ case rest of+ (arg:rest2) -> do+ more <- process rest2+ return $ (name, (token, arg)) : more+ _ -> fail "Missing arg"+ else do+ more <- process rest+ -- Consider splitting up token to get arg+ return $ (name, (token, token)) : more+ '-':opts -> shortToOpts opts token rest+ arg ->+ if gnu+ then do+ more <- process rest+ return $ ("", (token, token)):more+ else return $ listToArgs (token:rest)++ shortToOpts opts token args =+ case opts of+ c:rest -> do+ needsArg <- Map.lookup [c] flagMap+ case () of+ _ | needsArg && null rest -> do+ (next:restArgs) <- return args+ more <- process restArgs+ return $ ([c], (token, next)):more+ _ | needsArg -> do+ more <- process args+ return $ ([c], (token, token)):more+ _ -> do+ more <- shortToOpts rest token args+ return $ ([c], (token, token)):more+ [] -> process args++ listToArgs = map (\x -> ("", (x, x)))+ -- Is this an expansion of multiple items of an array?-isArrayExpansion t@(T_DollarBraced _ _ _) =- let string = bracedString t in+isArrayExpansion (T_DollarBraced _ _ l) =+ let string = concat $ oversimplify l in "@" `isPrefixOf` string || not ("#" `isPrefixOf` string) && "[@]" `isInfixOf` string isArrayExpansion _ = False@@ -148,8 +238,8 @@ -- Is it possible that this arg becomes multiple args? mayBecomeMultipleArgs t = willBecomeMultipleArgs t || f t where- f t@(T_DollarBraced _ _ _) =- let string = bracedString t in+ f (T_DollarBraced _ _ l) =+ let string = concat $ oversimplify l in "!" `isPrefixOf` string f (T_DoubleQuoted _ parts) = any f parts f (T_NormalWord _ parts) = any f parts@@ -193,6 +283,12 @@ str _ = Nothing getUnquotedLiteral _ = Nothing +isQuotes t =+ case t of+ T_DoubleQuoted {} -> True+ T_SingleQuoted {} -> True+ _ -> False+ -- Get the last unquoted T_Literal in a word like "${var}foo"THIS -- or nothing if the word does not end in an unquoted literal. getTrailingUnquotedLiteral :: Token -> Maybe Token@@ -211,8 +307,11 @@ getLeadingUnquotedString :: Token -> Maybe String getLeadingUnquotedString t = case t of- T_NormalWord _ ((T_Literal _ s) : _) -> return s+ T_NormalWord _ ((T_Literal _ s) : rest) -> return $ s ++ from rest _ -> Nothing+ where+ from ((T_Literal _ s):rest) = s ++ from rest+ from _ = "" -- Maybe get the literal string of this token and any globs in it. getGlobOrLiteralString = getLiteralStringExt f@@ -273,7 +372,38 @@ -- Is this token a string literal? isLiteral t = isJust $ getLiteralString t +-- Escape user data for messages.+-- Messages generally avoid repeating user data, but sometimes it's helpful.+e4m = escapeForMessage+escapeForMessage :: String -> String+escapeForMessage str = concatMap f str+ where+ f '\\' = "\\\\"+ f '\n' = "\\n"+ f '\r' = "\\r"+ f '\t' = "\\t"+ f '\x1B' = "\\e"+ f c =+ if shouldEscape c+ then+ if ord c < 256+ then "\\x" ++ (pad0 2 $ toHex c)+ else "\\U" ++ (pad0 4 $ toHex c)+ else [c] + shouldEscape c =+ (not $ isPrint c)+ || (not (isAscii c) && not (isLetter c))++ pad0 :: Int -> String -> String+ pad0 n s =+ let l = length s in+ if l < n+ then (replicate (n-l) '0') ++ s+ else s+ toHex :: Char -> String+ toHex c = map toUpper $ showHex (ord c) ""+ -- Turn a NormalWord like foo="bar $baz" into a series of constituent elements like [foo=,bar ,$baz] getWordParts (T_NormalWord _ l) = concatMap getWordParts l getWordParts (T_DoubleQuoted _ l) = l@@ -301,7 +431,7 @@ -- Maybe get the command name string of a token representing a command getCommandName :: Token -> Maybe String-getCommandName = fst . getCommandNameAndToken+getCommandName = fst . getCommandNameAndToken False -- Maybe get the name+arguments of a command. getCommandArgv t = do@@ -311,20 +441,38 @@ -- Get the command name token from a command, i.e. -- the token representing 'ls' in 'ls -la 2> foo'. -- If it can't be determined, return the original token.-getCommandTokenOrThis = snd . getCommandNameAndToken+getCommandTokenOrThis = snd . getCommandNameAndToken False -getCommandNameAndToken :: Token -> (Maybe String, Token)-getCommandNameAndToken t = fromMaybe (Nothing, t) $ do- (T_SimpleCommand _ _ (w:rest)) <- getCommand t- s <- getLiteralString w- if "busybox" `isSuffixOf` s || "builtin" == s- then- case rest of- (applet:_) -> return (getLiteralString applet, applet)- _ -> return (Just s, w)- else- return (Just s, w)+-- Given a command, get the string and token that represents the command name.+-- If direct, return the actual command (e.g. exec in 'exec ls')+-- If not, return the logical command (e.g. 'ls' in 'exec ls') +getCommandNameAndToken :: Bool -> Token -> (Maybe String, Token)+getCommandNameAndToken direct t = fromMaybe (Nothing, t) $ do+ cmd@(T_SimpleCommand _ _ (w:rest)) <- getCommand t+ s <- getLiteralString w+ return $ fromMaybe (Just s, w) $ do+ guard $ not direct+ actual <- getEffectiveCommandToken s cmd rest+ return (getLiteralString actual, actual)+ where+ getEffectiveCommandToken str cmd args =+ let+ firstArg = do+ arg <- listToMaybe args+ guard . not $ isFlag arg+ return arg+ in+ case str of+ "busybox" -> firstArg+ "builtin" -> firstArg+ "command" -> firstArg+ "run" -> firstArg -- Used by bats+ "exec" -> do+ opts <- getBsdOpts "cla:" args+ (_, (t, _)) <- find (null . fst) opts+ return t+ _ -> fail "" -- If a command substitution is a single command, get its name. -- $(date +%s) = Just "date"@@ -341,9 +489,9 @@ -- Get the basename of a token representing a command getCommandBasename = fmap basename . getCommandName- where- basename = reverse . takeWhile (/= '/') . reverse +basename = reverse . takeWhile (/= '/') . reverse+ isAssignment t = case t of T_Redirecting _ _ w -> isAssignment w@@ -400,10 +548,10 @@ f t@T_SimpleCommand {} = sequence_ $ do name <- getCommandName t let assocNames = ["declare","local","typeset"]- guard $ elem name assocNames+ guard $ name `elem` assocNames let flags = getAllFlags t- guard $ elem "A" $ map snd flags- let args = map fst . filter ((==) "" . snd) $ flags+ guard $ "A" `elem` map snd flags+ let args = [arg | (arg, "") <- flags] let names = mapMaybe (getLiteralStringExt nameAssignments) args return $ tell names f _ = return ()@@ -421,39 +569,37 @@ -- Turn a word into a PG pattern, replacing all unknown/runtime values with -- PGMany.-wordToPseudoGlob :: Token -> Maybe [PseudoGlob]-wordToPseudoGlob word =- simplifyPseudoGlob . concat <$> mapM f (getWordParts word)- where- f x = case x of- T_Literal _ s -> return $ map PGChar s- T_SingleQuoted _ s -> return $ map PGChar s-- T_DollarBraced {} -> return [PGMany]- T_DollarExpansion {} -> return [PGMany]- T_Backticked {} -> return [PGMany]-- T_Glob _ "?" -> return [PGAny]- T_Glob _ ('[':_) -> return [PGAny]- T_Glob {} -> return [PGMany]-- T_Extglob {} -> return [PGMany]-- _ -> return [PGMany]+wordToPseudoGlob :: Token -> [PseudoGlob]+wordToPseudoGlob = fromMaybe [PGMany] . wordToPseudoGlob' False -- Turn a word into a PG pattern, but only if we can preserve -- exact semantics. wordToExactPseudoGlob :: Token -> Maybe [PseudoGlob]-wordToExactPseudoGlob word =- simplifyPseudoGlob . concat <$> mapM f (getWordParts word)+wordToExactPseudoGlob = wordToPseudoGlob' True++wordToPseudoGlob' :: Bool -> Token -> Maybe [PseudoGlob]+wordToPseudoGlob' exact word =+ simplifyPseudoGlob <$> toGlob word where+ toGlob :: Token -> Maybe [PseudoGlob]+ toGlob word =+ case word of+ T_NormalWord _ (T_Literal _ ('~':str):rest) -> do+ guard $ not exact+ let this = (PGMany : (map PGChar $ dropWhile (/= '/') str))+ tail <- concat <$> (mapM f $ concatMap getWordParts rest)+ return $ this ++ tail+ _ -> concat <$> (mapM f $ getWordParts word)+ f x = case x of- T_Literal _ s -> return $ map PGChar s+ T_Literal _ s -> return $ map PGChar s T_SingleQuoted _ s -> return $ map PGChar s- T_Glob _ "?" -> return [PGAny]- T_Glob _ "*" -> return [PGMany]- _ -> fail "Unknown token type"+ T_Glob _ "?" -> return [PGAny]+ T_Glob _ "*" -> return [PGMany]+ T_Glob _ ('[':_) | not exact -> return [PGAny]+ _ -> if exact then fail "" else return [PGMany] + -- Reorder a PseudoGlob for more efficient matching, e.g. -- f?*?**g -> f??*g simplifyPseudoGlob :: [PseudoGlob] -> [PseudoGlob]@@ -502,8 +648,7 @@ matchable (PGMany : rest) [] = matchable rest [] matchable _ _ = False -wordsCanBeEqual x y = fromMaybe True $- liftM2 pseudoGlobsCanOverlap (wordToPseudoGlob x) (wordToPseudoGlob y)+wordsCanBeEqual x y = pseudoGlobsCanOverlap (wordToPseudoGlob x) (wordToPseudoGlob y) -- Is this an expansion that can be quoted, -- e.g. $(foo) `foo` $foo (but not {foo,})?@@ -517,6 +662,11 @@ T_Backticked {} -> True _ -> False +-- Is this an expansion that results in a simple string?+isStringExpansion t = isCommandSubstitution t || case t of+ T_DollarArithmetic {} -> True+ T_DollarBraced {} -> not (isArrayExpansion t)+ _ -> False -- Is this a T_Annotation that ignores a specific code? isAnnotationIgnoringCode code t =@@ -524,5 +674,45 @@ T_Annotation _ anns _ -> any hasNum anns _ -> False where- hasNum (DisableComment ts) = code == ts+ hasNum (DisableComment from to) = code >= from && code < to hasNum _ = False++prop_executableFromShebang1 = executableFromShebang "/bin/sh" == "sh"+prop_executableFromShebang2 = executableFromShebang "/bin/bash" == "bash"+prop_executableFromShebang3 = executableFromShebang "/usr/bin/env ksh" == "ksh"+prop_executableFromShebang4 = executableFromShebang "/usr/bin/env -S foo=bar bash -x" == "bash"+prop_executableFromShebang5 = executableFromShebang "/usr/bin/env --split-string=bash -x" == "bash"+prop_executableFromShebang6 = executableFromShebang "/usr/bin/env --split-string=foo=bar bash -x" == "bash"+prop_executableFromShebang7 = executableFromShebang "/usr/bin/env --split-string bash -x" == "bash"+prop_executableFromShebang8 = executableFromShebang "/usr/bin/env --split-string foo=bar bash -x" == "bash"+prop_executableFromShebang9 = executableFromShebang "/usr/bin/env foo=bar dash" == "dash"+prop_executableFromShebang10 = executableFromShebang "/bin/busybox sh" == "ash"+prop_executableFromShebang11 = executableFromShebang "/bin/busybox ash" == "ash"++-- Get the shell executable from a string like '/usr/bin/env bash'+executableFromShebang :: String -> String+executableFromShebang = shellFor+ where+ re = mkRegex "/env +(-S|--split-string=?)? *(.*)"+ shellFor s | s `matches` re =+ case matchRegex re s of+ Just [flag, shell] -> fromEnvArgs (words shell)+ _ -> ""+ shellFor sb =+ case words sb of+ [] -> ""+ [x] -> basename x+ (first:second:args) | basename first == "busybox" ->+ case basename second of+ "sh" -> "ash" -- busybox sh is ash+ x -> x+ (first:args) | basename first == "env" ->+ fromEnvArgs args+ (first:_) -> basename first++ fromEnvArgs args = fromMaybe "" $ find (notElem '=') $ skipFlags args+ basename s = reverse . takeWhile (/= '/') . reverse $ s+ skipFlags = dropWhile ("-" `isPrefixOf`)++return []+runTests = $quickCheckAll
src/ShellCheck/Analytics.hs view
@@ -64,6 +64,7 @@ ,checkUncheckedCdPushdPopd ,checkArrayAssignmentIndices ,checkUseBeforeDefinition+ ,checkAliasUsedInSameParsingUnit ] runAnalytics :: AnalysisSpec -> [TokenComment]@@ -190,6 +191,12 @@ ,checkUselessBang ,checkTranslatedStringVariable ,checkModifiedArithmeticInRedirection+ ,checkBlatantRecursion+ ,checkBadTestAndOr+ ,checkAssignToSelf+ ,checkEqualsInCommand+ ,checkSecondArgIsComparison+ ,checkComparisonWithLeadingX ] optionalChecks = map fst optionalTreeChecks@@ -266,15 +273,23 @@ | t `isUnqualifiedCommand` str = f cmd rest checkUnqualifiedCommand _ _ _ = return () +verifyCodes :: (Parameters -> Token -> Writer [TokenComment] ()) -> [Code] -> String -> Bool+verifyCodes f l s = codes == Just l+ where+ treeCheck = runNodeAnalysis f+ comments = runAndGetComments treeCheck s+ codes = map (cCode . tcComment) <$> comments checkNode f = producesComments (runNodeAnalysis f) producesComments :: (Parameters -> Token -> [TokenComment]) -> String -> Maybe Bool-producesComments f s = do+producesComments f s = not . null <$> runAndGetComments f s++runAndGetComments f s = do let pr = pScript s prRoot pr let spec = defaultSpec pr let params = makeParameters spec- return . not . null $+ return $ filterByAnnotation spec params $ runList spec [f] @@ -353,6 +368,19 @@ repPrecedence = depth, repInsertionPoint = InsertBefore }+replaceToken id params r =+ let tp = tokenPositions params+ (start, end) = tp Map.! id+ depth = length $ getPath (parentMap params) (T_EOF id)+ in+ newReplacement {+ repStartPos = start,+ repEndPos = end,+ repString = r,+ repPrecedence = depth,+ repInsertionPoint = InsertBefore+ }+ surroundWidth id params s = fixWith [replaceStart id params 0 s, replaceEnd id params 0 s] fixWith fixes = newFix { fixReplacements = fixes } @@ -383,7 +411,7 @@ prop_checkAssignAteCommand5 = verify checkAssignAteCommand "PAGER=cat grep bar" prop_checkAssignAteCommand6 = verifyNot checkAssignAteCommand "PAGER=\"cat\" grep bar" prop_checkAssignAteCommand7 = verify checkAssignAteCommand "here=pwd"-checkAssignAteCommand _ (T_SimpleCommand id (T_Assignment _ _ _ _ assignmentTerm:[]) list) =+checkAssignAteCommand _ (T_SimpleCommand id [T_Assignment _ _ _ _ assignmentTerm] list) = -- Check if first word is intended as an argument (flag or glob). if firstWordIsArg list then@@ -415,7 +443,7 @@ prop_checkWrongArit = verify checkWrongArithmeticAssignment "i=i+1" prop_checkWrongArit2 = verify checkWrongArithmeticAssignment "n=2; i=n*2"-checkWrongArithmeticAssignment params (T_SimpleCommand id (T_Assignment _ _ _ _ val:[]) []) =+checkWrongArithmeticAssignment params (T_SimpleCommand id [T_Assignment _ _ _ _ val] []) = sequence_ $ do str <- getNormalString val match <- matchRegex regex str@@ -535,9 +563,14 @@ prop_checkShebangParameters1 = verifyTree checkShebangParameters "#!/usr/bin/env bash -x\necho cow" prop_checkShebangParameters2 = verifyNotTree checkShebangParameters "#! /bin/sh -l "+prop_checkShebangParameters3 = verifyNotTree checkShebangParameters "#!/usr/bin/env -S bash -x\necho cow"+prop_checkShebangParameters4 = verifyNotTree checkShebangParameters "#!/usr/bin/env --split-string bash -x\necho cow" checkShebangParameters p (T_Annotation _ _ t) = checkShebangParameters p t 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]+ [makeComment ErrorC id 2096 "On most OS, shebangs can only specify a single parameter." | isMultiWord]+ where+ isMultiWord = length (words sb) > 2 && not (sb `matches` re)+ re = mkRegex "env +(-S|--split-string)" prop_checkShebang1 = verifyNotTree checkShebang "#!/usr/bin/env bash -x\necho cow" prop_checkShebang2 = verifyNotTree checkShebang "#! /bin/sh -l "@@ -551,6 +584,12 @@ prop_checkShebang10= verifyNotTree checkShebang "#!foo\n# shellcheck shell=sh ignore=SC2239\ntrue" prop_checkShebang11= verifyTree checkShebang "#!/bin/sh/\ntrue" prop_checkShebang12= verifyTree checkShebang "#!/bin/sh/ -xe\ntrue"+prop_checkShebang13= verifyTree checkShebang "#!/bin/busybox sh"+prop_checkShebang14= verifyNotTree checkShebang "#!/bin/busybox sh\n# shellcheck shell=sh\n"+prop_checkShebang15= verifyNotTree checkShebang "#!/bin/busybox sh\n# shellcheck shell=dash\n"+prop_checkShebang16= verifyTree checkShebang "#!/bin/busybox ash"+prop_checkShebang17= verifyNotTree checkShebang "#!/bin/busybox ash\n# shellcheck shell=dash\n"+prop_checkShebang18= verifyNotTree checkShebang "#!/bin/busybox ash\n# shellcheck shell=sh\n" checkShebang params (T_Annotation _ list t) = if any isOverride list then [] else checkShebang params t where@@ -781,8 +820,8 @@ prop_checkDollarStar = verify checkDollarStar "for f in $*; do ..; done" prop_checkDollarStar2 = verifyNot checkDollarStar "a=$*" prop_checkDollarStar3 = verifyNot checkDollarStar "[[ $* = 'a b' ]]"-checkDollarStar p t@(T_NormalWord _ [b@(T_DollarBraced id _ _)])- | bracedString b == "*" &&+checkDollarStar p t@(T_NormalWord _ [T_DollarBraced id _ l])+ | concat (oversimplify l) == "*" && not (isStrictlyQuoteFree (parentMap p) t) = warn id 2048 "Use \"$@\" (with quotes) to prevent whitespace problems." checkDollarStar _ _ = return ()@@ -848,7 +887,8 @@ 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\""+prop_checkArrayWithoutIndex10 = verifyTree checkArrayWithoutIndex "read -ra arr <<< 'foo bar'; echo \"$arr\""+prop_checkArrayWithoutIndex11 = verifyNotTree checkArrayWithoutIndex "read -rpfoobar r; r=42" checkArrayWithoutIndex params _ = doVariableFlowAnalysis readF writeF defaultMap (variableFlow params) where@@ -941,8 +981,10 @@ prop_checkSingleQuotedVariables19= verifyNot checkSingleQuotedVariables "echo '```'" prop_checkSingleQuotedVariables20= verifyNot checkSingleQuotedVariables "mumps -run %XCMD 'W $O(^GLOBAL(5))'" prop_checkSingleQuotedVariables21= verifyNot checkSingleQuotedVariables "mumps -run LOOP%XCMD --xec 'W $O(^GLOBAL(6))'"--+prop_checkSingleQuotedVariables22= verifyNot checkSingleQuotedVariables "jq '$__loc__'"+prop_checkSingleQuotedVariables23= verifyNot checkSingleQuotedVariables "command jq '$__loc__'"+prop_checkSingleQuotedVariables24= verifyNot checkSingleQuotedVariables "exec jq '$__loc__'"+prop_checkSingleQuotedVariables25= verifyNot checkSingleQuotedVariables "exec -c -a foo jq '$__loc__'" checkSingleQuotedVariables params t@(T_SingleQuoted id s) =@@ -973,6 +1015,7 @@ ,"alias" ,"sudo" -- covering "sudo sh" and such ,"docker" -- like above+ ,"podman" ,"dpkg-query" ,"jq" -- could also check that user provides --arg ,"rename"@@ -1174,8 +1217,8 @@ where error t = unless (isConstantNonRe t) $- err (getId t) 2076- "Don't quote right-hand side of =~, it'll match literally rather than as a regex."+ warn (getId t) 2076+ "Remove quotes from right-hand side of =~ to match as a regex rather than literally." re = mkRegex "[][*.+()|]" hasMetachars s = s `matches` re isConstantNonRe t = fromMaybe False $ do@@ -1208,6 +1251,9 @@ prop_checkConstantIfs7 = verifyNot checkConstantIfs "[ a -nt b ]" prop_checkConstantIfs8 = verifyNot checkConstantIfs "[[ ~foo == '~foo' ]]" prop_checkConstantIfs9 = verify checkConstantIfs "[[ *.png == [a-z] ]]"+prop_checkConstantIfs10 = verifyNot checkConstantIfs "[[ ~me == ~+ ]]"+prop_checkConstantIfs11 = verifyNot checkConstantIfs "[[ ~ == ~+ ]]"+prop_checkConstantIfs12 = verify checkConstantIfs "[[ '~' == x ]]" checkConstantIfs _ (TC_Binary id typ op lhs rhs) | not isDynamic = if isConstant lhs && isConstant rhs then warn id 2050 "This expression is constant. Did you forget the $ on a variable?"@@ -1309,8 +1355,8 @@ prop_checkArithmeticDeref14= verifyNot checkArithmeticDeref "(( $! ))" prop_checkArithmeticDeref15= verifyNot checkArithmeticDeref "(( ${!var} ))" prop_checkArithmeticDeref16= verifyNot checkArithmeticDeref "(( ${x+1} + ${x=42} ))"-checkArithmeticDeref params t@(TA_Expansion _ [b@(T_DollarBraced id _ _)]) =- unless (isException $ bracedString b) getWarning+checkArithmeticDeref params t@(TA_Expansion _ [T_DollarBraced id _ l]) =+ unless (isException $ concat $ oversimplify l) getWarning where isException [] = True isException s@(h:_) = any (`elem` "/.:#%?*@$-!+=^,") s || isDigit h@@ -1676,30 +1722,25 @@ doList tail True doList' _ _ = return () - commentIfExec (T_Pipeline id _ list) =- mapM_ commentIfExec $ take 1 list- commentIfExec (T_Redirecting _ _ f@(- T_SimpleCommand id _ (cmd:arg:_)))- | f `isUnqualifiedCommand` "exec" =- warn id 2093- "Remove \"exec \" if script should continue after this command."+ commentIfExec (T_Pipeline id _ [c]) = commentIfExec c+ commentIfExec (T_Redirecting _ _ (T_SimpleCommand id _ (cmd:additionalArg:_))) |+ getLiteralString cmd == Just "exec" =+ warn id 2093 "Remove \"exec \" if script should continue after this command." commentIfExec _ = return () prop_checkSpuriousExpansion1 = verify checkSpuriousExpansion "if $(true); then true; fi"-prop_checkSpuriousExpansion2 = verify checkSpuriousExpansion "while \"$(cmd)\"; do :; done" prop_checkSpuriousExpansion3 = verifyNot checkSpuriousExpansion "$(cmd) --flag1 --flag2" prop_checkSpuriousExpansion4 = verify checkSpuriousExpansion "$((i++))" checkSpuriousExpansion _ (T_SimpleCommand _ _ [T_NormalWord _ [word]]) = check word where check word = case word of T_DollarExpansion id _ ->- warn id 2091 "Remove surrounding $() to avoid executing output."+ warn id 2091 "Remove surrounding $() to avoid executing output (or use eval if intentional)." T_Backticked id _ ->- warn id 2092 "Remove backticks to avoid executing output."+ warn id 2092 "Remove backticks to avoid executing output (or use eval if intentional)." T_DollarArithmetic id _ -> err id 2084 "Remove '$' or use '_=$((expr))' to avoid executing output."- T_DoubleQuoted id [subword] -> check subword _ -> return () checkSpuriousExpansion _ _ = return () @@ -1719,7 +1760,7 @@ hasVariables = mkRegex "[`$]" checkHereDoc (T_FdRedirect _ _ (T_HereDoc id _ Unquoted token tokens)) | not (all isConstant tokens) =- warn id 2087 $ "Quote '" ++ token ++ "' to make here document expansions happen on the server side rather than on the client."+ warn id 2087 $ "Quote '" ++ (e4m token) ++ "' to make here document expansions happen on the server side rather than on the client." checkHereDoc _ = return () checkSshHereDoc _ _ = return () @@ -1836,6 +1877,9 @@ prop_checkSpacefulness38= verifyTree checkSpacefulness "a=; echo $a" prop_checkSpacefulness39= verifyNotTree checkSpacefulness "a=''\"\"''; b=x$a; echo $b" prop_checkSpacefulness40= verifyNotTree checkSpacefulness "a=$((x+1)); echo $a"+prop_checkSpacefulness41= verifyNotTree checkSpacefulness "exec $1 --flags"+prop_checkSpacefulness42= verifyNotTree checkSpacefulness "run $1 --flags"+prop_checkSpacefulness43= verifyNotTree checkSpacefulness "$foo=42" data SpaceStatus = SpaceSome | SpaceNone | SpaceEmpty deriving (Eq) instance Semigroup SpaceStatus where@@ -1860,27 +1904,43 @@ emit $ makeComment InfoC (getId token) 2223 "This default assignment may cause DoS due to globbing. Quote it." else- emit $ makeCommentWithFix InfoC (getId token) 2086- "Double quote to prevent globbing and word splitting."- (addDoubleQuotesAround params token)+ unless (quotesMayConflictWithSC2281 params token) $+ emit $ makeCommentWithFix InfoC (getId token) 2086+ "Double quote to prevent globbing and word splitting."+ (addDoubleQuotesAround params token) isDefaultAssignment parents token = let modifier = getBracedModifier $ bracedString token in any (`isPrefixOf` modifier) ["=", ":="] && isParamTo parents ":" token + -- Given a T_DollarBraced, return a simplified version of the string contents.+ bracedString (T_DollarBraced _ _ l) = concat $ oversimplify l+ bracedString _ = error "Internal shellcheck error, please report! (bracedString on non-variable)"+ prop_checkSpacefulness4v= verifyTree checkVerboseSpacefulness "foo=3; foo=$(echo $foo)" prop_checkSpacefulness8v= verifyTree checkVerboseSpacefulness "a=foo\\ bar; a=foo; rm $a" prop_checkSpacefulness28v = verifyTree checkVerboseSpacefulness "exec {n}>&1; echo $n" prop_checkSpacefulness36v = verifyTree checkVerboseSpacefulness "arg=$#; echo $arg"+prop_checkSpacefulness44v = verifyNotTree checkVerboseSpacefulness "foo=3; $foo=4" checkVerboseSpacefulness params = checkSpacefulness' onFind params where onFind spaces token name =- when (spaces == SpaceNone && name `notElem` specialVariablesWithoutSpaces) $+ when (spaces == SpaceNone+ && name `notElem` specialVariablesWithoutSpaces+ && not (quotesMayConflictWithSC2281 params token)) $ tell [makeCommentWithFix StyleC (getId token) 2248 "Prefer double quoting even when variables don't contain special characters." (addDoubleQuotesAround params token)] +-- Don't suggest quotes if this will instead be autocorrected+-- from $foo=bar to foo=bar. This is not pretty but ok.+quotesMayConflictWithSC2281 params t =+ case getPath (parentMap params) t of+ _ : T_NormalWord parentId (me:T_Literal _ ('=':_):_) : T_SimpleCommand _ _ (cmd:_) : _ ->+ (getId t) == (getId me) && (parentId == getId cmd)+ _ -> False+ addDoubleQuotesAround params token = (surroundWidth (getId token) params "\"") checkSpacefulness' :: (SpaceStatus -> Token -> String -> Writer [TokenComment] ()) ->@@ -1940,7 +2000,7 @@ T_DollarArithmetic _ _ -> SpaceNone T_Literal _ s -> fromLiteral s T_SingleQuoted _ s -> fromLiteral s- T_DollarBraced _ _ _ -> spacefulF $ getBracedReference $ bracedString x+ T_DollarBraced _ _ l -> spacefulF $ getBracedReference $ concat $ oversimplify l T_NormalWord _ w -> isSpacefulWord spacefulF w T_DoubleQuoted _ w -> isSpacefulWord spacefulF w _ -> SpaceEmpty@@ -1955,19 +2015,17 @@ prop_CheckVariableBraces2 = verifyNot checkVariableBraces "a='123'; echo ${a}" prop_CheckVariableBraces3 = verifyNot checkVariableBraces "#shellcheck disable=SC2016\necho '$a'" prop_CheckVariableBraces4 = verifyNot checkVariableBraces "echo $* $1"-checkVariableBraces params t =- case t of- T_DollarBraced id False _- | name `notElem` unbracedVariables ->- styleWithFix id 2250- "Prefer putting braces around variable references even when not strictly required."- (fixFor t)-- _ -> return ()+prop_CheckVariableBraces5 = verifyNot checkVariableBraces "$foo=42"+checkVariableBraces params t@(T_DollarBraced id False l)+ | name `notElem` unbracedVariables && not (quotesMayConflictWithSC2281 params t) =+ styleWithFix id 2250+ "Prefer putting braces around variable references even when not strictly required."+ (fixFor t) where- name = getBracedReference $ bracedString t+ name = getBracedReference $ concat $ oversimplify l fixFor token = fixWith [replaceStart (getId token) params 1 "${" ,replaceEnd (getId token) params 0 "}"]+checkVariableBraces _ _ = return () prop_checkQuotesInLiterals1 = verifyTree checkQuotesInLiterals "param='--foo=\"bar\"'; app $param" prop_checkQuotesInLiterals1a= verifyTree checkQuotesInLiterals "param=\"--foo='lolbar'\"; app $param"@@ -2013,7 +2071,7 @@ squashesQuotes t = case t of- T_DollarBraced id _ _ -> "#" `isPrefixOf` bracedString t+ T_DollarBraced id _ l -> "#" `isPrefixOf` concat (oversimplify l) _ -> False readF _ expr name = do@@ -2054,18 +2112,27 @@ verifyNotTree checkFunctionsUsedExternally "foo() { :; }; ssh host echo foo" prop_checkFunctionsUsedExternally7 = verifyNotTree checkFunctionsUsedExternally "install() { :; }; sudo apt-get install foo"+prop_checkFunctionsUsedExternally8 =+ verifyTree checkFunctionsUsedExternally "foo() { :; }; command sudo foo"+prop_checkFunctionsUsedExternally9 =+ verifyTree checkFunctionsUsedExternally "foo() { :; }; exec -c sudo foo" checkFunctionsUsedExternally params t = runNodeAnalysis checkCommand params t where- checkCommand _ t@(T_SimpleCommand _ _ (cmd:args)) =- case getCommandBasename t of- Just name -> do+ checkCommand _ t@(T_SimpleCommand _ _ argv) =+ case getCommandNameAndToken False t of+ (Just str, t) -> do+ let name = basename str+ let args = skipOver t argv let argStrings = map (\x -> (fromMaybe "" $ getLiteralString x, x)) args let candidates = getPotentialCommands name argStrings mapM_ (checkArg name) candidates _ -> return () checkCommand _ _ = return () + skipOver t list = drop 1 $ dropWhile (\c -> getId c /= id) $ list+ where id = getId t+ -- Try to pick out the argument[s] that may be commands getPotentialCommands name argAndString = case name of@@ -2121,6 +2188,7 @@ prop_checkUnused14= verifyNotTree checkUnusedAssignments "x=(1); n=0; echo ${x[n]}" prop_checkUnused15= verifyNotTree checkUnusedAssignments "x=(1); n=0; (( x[n] ))" prop_checkUnused16= verifyNotTree checkUnusedAssignments "foo=5; declare -x foo"+prop_checkUnused16b= verifyNotTree checkUnusedAssignments "f() { local -x foo; foo=42; bar; }; f" prop_checkUnused17= verifyNotTree checkUnusedAssignments "read -i 'foo' -e -p 'Input: ' bar; $bar;" prop_checkUnused18= verifyNotTree checkUnusedAssignments "a=1; arr=( [$a]=42 ); echo \"${arr[@]}\"" prop_checkUnused19= verifyNotTree checkUnusedAssignments "a=1; let b=a+1; echo $b"@@ -2214,6 +2282,15 @@ prop_checkUnassignedReferences38= verifyTree (checkUnassignedReferences' True) "echo $VAR" prop_checkUnassignedReferences39= verifyNotTree checkUnassignedReferences "builtin export var=4; echo $var" prop_checkUnassignedReferences40= verifyNotTree checkUnassignedReferences ": ${foo=bar}"+prop_checkUnassignedReferences41= verifyNotTree checkUnassignedReferences "mapfile -t files 123; echo \"${files[@]}\""+prop_checkUnassignedReferences42= verifyNotTree checkUnassignedReferences "mapfile files -t; echo \"${files[@]}\""+prop_checkUnassignedReferences43= verifyNotTree checkUnassignedReferences "mapfile --future files; echo \"${files[@]}\""+prop_checkUnassignedReferences_minusNPlain = verifyNotTree checkUnassignedReferences "if [ -n \"$x\" ]; then echo $x; fi"+prop_checkUnassignedReferences_minusZPlain = verifyNotTree checkUnassignedReferences "if [ -z \"$x\" ]; then echo \"\"; fi"+prop_checkUnassignedReferences_minusNBraced = verifyNotTree checkUnassignedReferences "if [ -n \"${x}\" ]; then echo $x; fi"+prop_checkUnassignedReferences_minusZBraced = verifyNotTree checkUnassignedReferences "if [ -z \"${x}\" ]; then echo \"\"; fi"+prop_checkUnassignedReferences_minusNDefault = verifyNotTree checkUnassignedReferences "if [ -n \"${x:-}\" ]; then echo $x; fi"+prop_checkUnassignedReferences_minusZDefault = verifyNotTree checkUnassignedReferences "if [ -z \"${x:-}\" ]; then echo \"\"; fi" checkUnassignedReferences = checkUnassignedReferences' False checkUnassignedReferences' includeGlobals params t = warnings@@ -2274,7 +2351,7 @@ isInArray var t = any isArray $ getPath (parentMap params) t where isArray T_Array {} = True- isArray b@(T_DollarBraced _ _ _) | var /= getBracedReference (bracedString b) = True+ isArray (T_DollarBraced _ _ l) | var /= getBracedReference (concat $ oversimplify l) = True isArray _ = False isGuarded (T_DollarBraced _ _ v) =@@ -2296,8 +2373,11 @@ prop_checkGlobsAsOptions2 = verify checkGlobsAsOptions "ls ??.*" prop_checkGlobsAsOptions3 = verifyNot checkGlobsAsOptions "rm -- *.txt" prop_checkGlobsAsOptions4 = verifyNot checkGlobsAsOptions "*.txt"-checkGlobsAsOptions _ (T_SimpleCommand _ _ args) =- mapM_ check $ takeWhile (not . isEndOfArgs) (drop 1 args)+prop_checkGlobsAsOptions5 = verifyNot checkGlobsAsOptions "echo 'Files:' *.txt"+prop_checkGlobsAsOptions6 = verifyNot checkGlobsAsOptions "printf '%s\\n' *"+checkGlobsAsOptions _ cmd@(T_SimpleCommand _ _ args) =+ unless ((fromMaybe "" $ getCommandBasename cmd) `elem` ["echo", "printf"]) $+ mapM_ check $ takeWhile (not . isEndOfArgs) (drop 1 args) where check v@(T_NormalWord _ (T_Glob id s:_)) | s == "*" || s == "?" = info id 2035 "Use ./*glob* or -- *glob* so names with dashes won't become options."@@ -2327,6 +2407,7 @@ prop_checkWhileReadPitfalls12 = verifyNot checkWhileReadPitfalls "while read foo\ndo\nmplayer foo.ogv << EOF\nq\nEOF\ndone" prop_checkWhileReadPitfalls13 = verify checkWhileReadPitfalls "while read foo; do x=$(ssh host cmd); done" prop_checkWhileReadPitfalls14 = verify checkWhileReadPitfalls "while read foo; do echo $(ssh host cmd) < /dev/null; done"+prop_checkWhileReadPitfalls15 = verify checkWhileReadPitfalls "while read foo; do ssh $foo cmd & done" checkWhileReadPitfalls params (T_WhileExpression id [command] contents) | isStdinReadCommand command =@@ -2377,6 +2458,7 @@ warnWithFix (getId cmd) 2095 ("Use " ++ name ++ " " ++ flag ++ " to prevent " ++ name ++ " from swallowing stdin.") (fix flag cmd)+ checkMuncher (T_Backgrounded _ t) = checkMuncher t checkMuncher _ = return () stdinRedirect (T_FdRedirect _ fd op)@@ -2402,7 +2484,7 @@ checkPrefixAssignmentReference params t@(T_DollarBraced id _ value) = check path where- name = getBracedReference $ bracedString t+ name = getBracedReference $ concat $ oversimplify value path = getPath (parentMap params) t idPath = map getId path @@ -2437,7 +2519,7 @@ where isCharClass str = "[" `isPrefixOf` str && "]" `isSuffixOf` str contents = dropNegation . drop 1 . take (length str - 1) $ str- hasDupes = any (>1) . map length . group . sort . filter (/= '-') $ contents+ hasDupes = any ((>1) . length) . group . sort . filter (/= '-') $ contents dropNegation s = case s of '!':rest -> rest@@ -2554,6 +2636,7 @@ prop_checkUnpassedInFunctions11= verifyNotTree checkUnpassedInFunctions "foo() { bar() { echo $1; }; bar baz; }; foo;" prop_checkUnpassedInFunctions12= verifyNotTree checkUnpassedInFunctions "foo() { echo ${!var*}; }; foo;" prop_checkUnpassedInFunctions13= verifyNotTree checkUnpassedInFunctions "# shellcheck disable=SC2120\nfoo() { echo $1; }\nfoo\n"+prop_checkUnpassedInFunctions14= verifyTree checkUnpassedInFunctions "foo() { echo $#; }; foo" checkUnpassedInFunctions params root = execWriter $ mapM_ warnForGroup referenceGroups where@@ -2595,7 +2678,7 @@ return $ tell [(str, null args, t)] checkCommand _ = Nothing - isPositional str = str == "*" || str == "@"+ isPositional str = str == "*" || str == "@" || str == "#" || (all isDigit str && str /= "0" && str /= "") isArgumentless (_, b, _) = b@@ -2612,7 +2695,7 @@ suggestParams (name, _, thing) = info (getId thing) 2119 $- "Use " ++ name ++ " \"$@\" if function's $1 should mean script's $1."+ "Use " ++ (e4m name) ++ " \"$@\" if function's $1 should mean script's $1." warnForDeclaration func name = warn (getId func) 2120 $ name ++ " references arguments, but none are ever passed."@@ -2799,7 +2882,7 @@ then -- Ksh appears to stop processing after unrecognized tokens, so operators -- will effectively work with globs, but only the first match.- when (op `elem` ['-':c:[] | c <- "bcdfgkprsuwxLhNOGRS" ]) $+ when (op `elem` [['-', c] | c <- "bcdfgkprsuwxLhNOGRS" ]) $ warn (getId token) 2245 $ op ++ " only applies to the first expansion of this glob. Use a loop to check any/all." else@@ -2867,11 +2950,14 @@ prop_checkMaskedReturns1 = verify checkMaskedReturns "f() { local a=$(false); }" prop_checkMaskedReturns2 = verify checkMaskedReturns "declare a=$(false)" prop_checkMaskedReturns3 = verify checkMaskedReturns "declare a=\"`false`\""-prop_checkMaskedReturns4 = verifyNot checkMaskedReturns "declare a; a=$(false)"-prop_checkMaskedReturns5 = verifyNot checkMaskedReturns "f() { local -r a=$(false); }"+prop_checkMaskedReturns4 = verify checkMaskedReturns "readonly a=$(false)"+prop_checkMaskedReturns5 = verify checkMaskedReturns "readonly a=\"`false`\""+prop_checkMaskedReturns6 = verifyNot checkMaskedReturns "declare a; a=$(false)"+prop_checkMaskedReturns7 = verifyNot checkMaskedReturns "f() { local -r a=$(false); }"+prop_checkMaskedReturns8 = verifyNot checkMaskedReturns "a=$(false); readonly a" checkMaskedReturns _ t@(T_SimpleCommand id _ (cmd:rest)) = sequence_ $ do name <- getCommandName t- guard $ name `elem` ["declare", "export"]+ guard $ name `elem` ["declare", "export", "readonly"] || name == "local" && "r" `notElem` map snd (getAllFlags t) return $ mapM_ checkArgs rest where@@ -2898,8 +2984,8 @@ where flags = getAllFlags t has_t0 = Just "0" == do- parsed <- getOpts flagsForRead flags- t <- lookup "t" parsed+ parsed <- getGnuOpts flagsForRead $ arguments t+ (_, t) <- lookup "t" parsed getLiteralString t checkReadWithoutR _ _ = return ()@@ -3035,7 +3121,7 @@ isZero t = getLiteralString t == Just "0" isExitCode t = case getWordParts t of- [exp@T_DollarBraced {}] -> bracedString exp == "?"+ [T_DollarBraced _ _ l] -> concat (oversimplify l) == "?" _ -> False message id = style id 2181 "Check exit code directly with e.g. 'if mycmd;', not indirectly with $?." @@ -3137,9 +3223,7 @@ if isConstant word then warn (getId word) 2194 "This word is constant. Did you forget the $ on a variable?"- else sequence_ $ do- pg <- wordToPseudoGlob word- return $ mapM_ (check pg) allpatterns+ else mapM_ (check $ wordToPseudoGlob word) allpatterns let exactGlobs = tupMap wordToExactPseudoGlob breakpatterns let fuzzyGlobs = tupMap wordToPseudoGlob breakpatterns@@ -3152,15 +3236,13 @@ fst3 (x,_,_) = x snd3 (_,x,_) = x tp = tokenPositions params- check target candidate = sequence_ $ do- candidateGlob <- wordToPseudoGlob candidate- guard . not $ pseudoGlobsCanOverlap target candidateGlob- return $ warn (getId candidate) 2195- "This pattern will never match the case statement's word. Double check them."+ check target candidate = unless (pseudoGlobsCanOverlap target $ wordToPseudoGlob candidate) $+ warn (getId candidate) 2195+ "This pattern will never match the case statement's word. Double check them." tupMap f l = map (\x -> (x, f x)) l checkDoms ((glob, Just x), rest) =- forM_ (find (\(_, p) -> x `pseudoGlobIsSuperSetof` p) valids) $+ forM_ (find (\(_, p) -> x `pseudoGlobIsSuperSetof` p) rest) $ \(first,_) -> do warn (getId glob) 2221 $ "This pattern always overrides a later one" <> patternContext (getId first) warn (getId first) 2222 $ "This pattern never matches because of a previous pattern" <> patternContext (getId glob)@@ -3170,8 +3252,6 @@ case posLine . fst <$> Map.lookup id tp of Just l -> " on line " <> show l <> "." _ -> "."-- valids = [(x,y) | (x, Just y) <- rest] checkDoms _ = return () @@ -3226,7 +3306,7 @@ T_DollarBraced id _ str | not (isCountingReference part) && not (isQuotedAlternativeReference part)- && getBracedReference (bracedString part) `notElem` variablesWithoutSpaces+ && getBracedReference (concat $ oversimplify str) `notElem` variablesWithoutSpaces -> warn id 2206 $ if shellType params == Ksh then "Quote to prevent word splitting/globbing, or split robustly with read -A or while read."@@ -3287,26 +3367,101 @@ prop_checkPipeToNowhere7 = verifyNot checkPipeToNowhere "echo foo | var=$(cat) ls" prop_checkPipeToNowhere8 = verify checkPipeToNowhere "foo | true" prop_checkPipeToNowhere9 = verifyNot checkPipeToNowhere "mv -i f . < /dev/stdin"+prop_checkPipeToNowhere10 = verify checkPipeToNowhere "ls > file | grep foo"+prop_checkPipeToNowhere11 = verify checkPipeToNowhere "ls | grep foo < file"+prop_checkPipeToNowhere12 = verify checkPipeToNowhere "ls > foo > bar"+prop_checkPipeToNowhere13 = verify checkPipeToNowhere "ls > foo 2> bar > baz"+prop_checkPipeToNowhere14 = verify checkPipeToNowhere "ls > foo &> bar"+prop_checkPipeToNowhere15 = verifyNot checkPipeToNowhere "ls > foo 2> bar |& grep 'No space left'"+prop_checkPipeToNowhere16 = verifyNot checkPipeToNowhere "echo World | cat << EOF\nhello $(cat)\nEOF\n"+prop_checkPipeToNowhere17 = verify checkPipeToNowhere "echo World | cat << 'EOF'\nhello $(cat)\nEOF\n"+prop_checkPipeToNowhere18 = verifyNot checkPipeToNowhere "ls 1>&3 3>&1 3>&- | wc -l"+prop_checkPipeToNowhere19 = verifyNot checkPipeToNowhere "find . -print0 | du --files0-from=/dev/stdin"+prop_checkPipeToNowhere20 = verifyNot checkPipeToNowhere "find . | du --exclude-from=/dev/fd/0"++data PipeType = StdoutPipe | StdoutStderrPipe | NoPipe deriving (Eq) checkPipeToNowhere :: Parameters -> Token -> WriterT [TokenComment] Identity ()-checkPipeToNowhere _ t =+checkPipeToNowhere params t = case t of- T_Pipeline _ _ (first:rest) -> mapM_ checkPipe rest+ T_Pipeline _ pipes cmds ->+ mapM_ checkPipe $ commandsWithContext pipes cmds T_Redirecting _ redirects cmd | any redirectsStdin redirects -> checkRedir cmd _ -> return () where- checkPipe redir = sequence_ $ do- cmd <- getCommand redir- name <- getCommandBasename cmd- guard $ name `elem` nonReadingCommands- guard . not $ hasAdditionalConsumers cmd- -- Confusing echo for cat is so common that it's worth a special case- let suggestion =- if name == "echo"- then "Did you want 'cat' instead?"- else "Wrong command or missing xargs?"- return $ warn (getId cmd) 2216 $- "Piping to '" ++ name ++ "', a command that doesn't read stdin. " ++ suggestion+ checkPipe (input, stage, output) = do+ let hasConsumers = hasAdditionalConsumers stage+ let hasProducers = hasAdditionalProducers stage + sequence_ $ do+ cmd <- getCommand stage+ name <- getCommandBasename cmd+ guard $ name `elem` nonReadingCommands+ guard $ not hasConsumers && input /= NoPipe+ guard . not $ commandSpecificException name cmd++ -- Confusing echo for cat is so common that it's worth a special case+ let suggestion =+ if name == "echo"+ then "Did you want 'cat' instead?"+ else "Wrong command or missing xargs?"+ return $ warn (getId cmd) 2216 $+ "Piping to '" ++ name ++ "', a command that doesn't read stdin. " ++ suggestion++ sequence_ $ do+ T_Redirecting _ redirs cmd <- return stage+ fds <- mapM getRedirectionFds redirs++ let fdAndToken :: [(Integer, Token)]+ fdAndToken =+ concatMap (\(list, redir) -> map (\n -> (n, redir)) list) $+ zip fds redirs++ let fdMap =+ Map.fromListWith (++) $+ map (\(a,b) -> (a,[b])) fdAndToken++ let inputWarning = sequence_ $ do+ guard $ input /= NoPipe && not hasConsumers+ (override:_) <- Map.lookup 0 fdMap+ return $ err (getOpId override) 2259 $+ "This redirection overrides piped input. To use both, merge or pass filenames."++ -- Only produce output warnings for regular pipes, since these are+ -- way more common, and `foo > out 2> err |& foo` can still write+ -- to stderr if the files fail to open+ let outputWarning = sequence_ $ do+ guard $ output == StdoutPipe && not hasProducers+ (override:_) <- Map.lookup 1 fdMap+ return $ err (getOpId override) 2260 $+ "This redirection overrides the output pipe. Use 'tee' to output to both."++ return $ do+ inputWarning+ outputWarning+ mapM_ warnAboutDupes $ Map.assocs fdMap++ commandSpecificException name cmd =+ case name of+ "du" -> any ((`elem` ["exclude-from", "files0-from"]) . snd) $ getAllFlags cmd+ _ -> False++ warnAboutDupes (n, list@(_:_:_)) =+ forM_ list $ \c -> err (getOpId c) 2261 $+ "Multiple redirections compete for " ++ str n ++ ". Use cat, tee, or pass filenames instead."+ warnAboutDupes _ = return ()++ alternative =+ if shellType params `elem` [Bash, Ksh]+ then "process substitutions or temp files"+ else "temporary files"++ str n =+ case n of+ 0 -> "stdin"+ 1 -> "stdout"+ 2 -> "stderr"+ _ -> "FD " ++ show n+ checkRedir cmd = sequence_ $ do name <- getCommandBasename cmd guard $ name `elem` nonReadingCommands@@ -3320,23 +3475,74 @@ "Redirecting to '" ++ name ++ "', a command that doesn't read stdin. " ++ suggestion -- Could any words in a SimpleCommand consume stdin (e.g. echo "$(cat)")?- hasAdditionalConsumers t = isNothing $- doAnalysis (guard . not . mayConsume) t+ hasAdditionalConsumers = treeContains mayConsume+ -- Could any words in a SimpleCommand produce stdout? E.g. >(tee foo)+ hasAdditionalProducers = treeContains mayProduce+ treeContains pred t = isNothing $+ doAnalysis (guard . not . pred) t mayConsume t = case t of- T_ProcSub {} -> True+ T_ProcSub _ "<" _ -> True T_Backticked {} -> True T_DollarExpansion {} -> True _ -> False - redirectsStdin t =+ mayProduce t = case t of- T_FdRedirect _ _ (T_IoFile _ T_Less {} _) -> True- T_FdRedirect _ _ T_HereDoc {} -> True- T_FdRedirect _ _ T_HereString {} -> True+ T_ProcSub _ ">" _ -> True _ -> False + getOpId t =+ case t of+ T_FdRedirect _ _ x -> getOpId x+ T_IoFile _ op _ -> getId op+ _ -> getId t++ getRedirectionFds t =+ case t of+ T_FdRedirect _ "" x -> getDefaultFds x+ T_FdRedirect _ "&" _ -> return [1, 2]+ T_FdRedirect _ num x | all isDigit num ->+ -- Don't report the number unless we know what it is.+ -- This avoids triggering on 3>&1 1>&3+ getDefaultFds x *> return [read num]+ -- Don't bother with {fd}>42 and such+ _ -> Nothing++ getDefaultFds redir =+ case redir of+ T_HereDoc {} -> return [0]+ T_HereString {} -> return [0]+ T_IoFile _ op _ ->+ case op of+ T_Less {} -> return [0]+ T_Greater {} -> return [1]+ T_DGREAT {} -> return [1]+ T_GREATAND {} -> return [1, 2]+ T_CLOBBER {} -> return [1]+ T_IoDuplicate _ op "-" -> getDefaultFds op+ _ -> Nothing+ _ -> Nothing++ redirectsStdin t =+ fromMaybe False $ do+ fds <- getRedirectionFds t+ return $ 0 `elem` fds++ pipeType t =+ case t of+ T_Pipe _ "|" -> StdoutPipe+ T_Pipe _ "|&" -> StdoutStderrPipe+ _ -> NoPipe++ commandsWithContext pipes cmds =+ let pipeTypes = map pipeType pipes+ inputs = NoPipe : pipeTypes+ outputs = pipeTypes ++ [NoPipe]+ in+ zip3 inputs cmds outputs+ prop_checkUseBeforeDefinition1 = verifyTree checkUseBeforeDefinition "f; f() { true; }" prop_checkUseBeforeDefinition2 = verifyNotTree checkUseBeforeDefinition "f() { true; }; f" prop_checkUseBeforeDefinition3 = verifyNotTree checkUseBeforeDefinition "if ! mycmd --version; then mycmd() { true; }; fi"@@ -3596,7 +3802,8 @@ prop_checkModifiedArithmeticInRedirection3 = verifyNot checkModifiedArithmeticInRedirection "while true; do true; done > $((i++))" prop_checkModifiedArithmeticInRedirection4 = verify checkModifiedArithmeticInRedirection "cat <<< $((i++))" prop_checkModifiedArithmeticInRedirection5 = verify checkModifiedArithmeticInRedirection "cat << foo\n$((i++))\nfoo\n"-checkModifiedArithmeticInRedirection _ t =+prop_checkModifiedArithmeticInRedirection6 = verifyNot checkModifiedArithmeticInRedirection "#!/bin/dash\nls > $((i=i+1))"+checkModifiedArithmeticInRedirection params t = unless (shellType params == Dash) $ case t of T_Redirecting _ redirs (T_SimpleCommand _ _ (_:_)) -> mapM_ checkRedirs redirs _ -> return ()@@ -3625,6 +3832,427 @@ warnFor id = warn id 2257 "Arithmetic modifications in command redirections may be discarded. Do them separately." +prop_checkAliasUsedInSameParsingUnit1 = verifyTree checkAliasUsedInSameParsingUnit "alias x=y; x"+prop_checkAliasUsedInSameParsingUnit2 = verifyNotTree checkAliasUsedInSameParsingUnit "alias x=y\nx"+prop_checkAliasUsedInSameParsingUnit3 = verifyTree checkAliasUsedInSameParsingUnit "{ alias x=y\nx\n}"+prop_checkAliasUsedInSameParsingUnit4 = verifyNotTree checkAliasUsedInSameParsingUnit "alias x=y; 'x';"+prop_checkAliasUsedInSameParsingUnit5 = verifyNotTree checkAliasUsedInSameParsingUnit ":\n{\n#shellcheck disable=SC2262\nalias x=y\nx\n}"+prop_checkAliasUsedInSameParsingUnit6 = verifyNotTree checkAliasUsedInSameParsingUnit ":\n{\n#shellcheck disable=SC2262\nalias x=y\nalias x=z\nx\n}" -- Only consider the first instance+checkAliasUsedInSameParsingUnit :: Parameters -> Token -> [TokenComment]+checkAliasUsedInSameParsingUnit params root =+ let+ -- Get all root commands+ commands = concat $ getCommandSequences root+ -- Group them by whether they start on the same line where the previous one ended+ units = groupByLink followsOnLine commands+ in+ execWriter $ mapM_ checkUnit units+ where+ lineSpan t =+ let m = tokenPositions params in do+ (start, end) <- Map.lookup t m+ return $ (posLine start, posLine end)++ followsOnLine a b = fromMaybe False $ do+ (_, end) <- lineSpan (getId a)+ (start, _) <- lineSpan (getId b)+ return $ end == start++ checkUnit :: [Token] -> Writer [TokenComment] ()+ checkUnit unit = evalStateT (mapM_ (doAnalysis findCommands) unit) (Map.empty)++ isSourced t =+ let+ f (T_SourceCommand {}) = True+ f _ = False+ in+ any f $ getPath (parentMap params) t++ findCommands :: Token -> StateT (Map.Map String Token) (Writer [TokenComment]) ()+ findCommands t = case t of+ T_SimpleCommand _ _ (cmd:args) ->+ case getUnquotedLiteral cmd of+ Just "alias" ->+ mapM_ addAlias args+ Just name | '/' `notElem` name -> do+ cmd <- gets (Map.lookup name)+ case cmd of+ Just alias ->+ unless (isSourced t || shouldIgnoreCode params 2262 alias) $ do+ warn (getId alias) 2262 "This alias can't be defined and used in the same parsing unit. Use a function instead."+ info (getId t) 2263 "Since they're in the same parsing unit, this command will not refer to the previously mentioned alias."+ _ -> return ()+ _ -> return ()+ _ -> return ()+ addAlias arg = do+ let (name, value) = break (== '=') $ getLiteralStringDef "-" arg+ when (isVariableName name && not (null value)) $+ modify (Map.insertWith (\new old -> old) name arg)++-- Like groupBy, but compares pairs of adjacent elements, rather than against the first of the span+prop_groupByLink1 = groupByLink (\a b -> a+1 == b) [1,2,3,2,3,7,8,9] == [[1,2,3], [2,3], [7,8,9]]+prop_groupByLink2 = groupByLink (==) ([] :: [()]) == []+groupByLink :: (a -> a -> Bool) -> [a] -> [[a]]+groupByLink f list =+ case list of+ [] -> []+ (x:xs) -> foldr c n xs x []+ where+ c next rest current span =+ if f current next+ then rest next (current:span)+ else (reverse $ current:span) : rest next []+ n current span = [reverse (current:span)]+++prop_checkBlatantRecursion1 = verify checkBlatantRecursion ":(){ :|:& };:"+prop_checkBlatantRecursion2 = verify checkBlatantRecursion "f() { f; }"+prop_checkBlatantRecursion3 = verifyNot checkBlatantRecursion "f() { command f; }"+prop_checkBlatantRecursion4 = verify checkBlatantRecursion "cd() { cd \"$lol/$1\" || exit; }"+prop_checkBlatantRecursion5 = verifyNot checkBlatantRecursion "cd() { [ -z \"$1\" ] || cd \"$1\"; }"+prop_checkBlatantRecursion6 = verifyNot checkBlatantRecursion "cd() { something; cd $1; }"+prop_checkBlatantRecursion7 = verifyNot checkBlatantRecursion "cd() { builtin cd $1; }"+checkBlatantRecursion :: Parameters -> Token -> Writer [TokenComment] ()+checkBlatantRecursion params t =+ case t of+ T_Function _ _ _ name body ->+ case getCommandSequences body of+ [first : _] -> checkList name first+ _ -> return ()+ _ -> return ()+ where+ checkList :: String -> Token -> Writer [TokenComment] ()+ checkList name t =+ case t of+ T_Backgrounded _ t -> checkList name t+ T_AndIf _ lhs _ -> checkList name lhs+ T_OrIf _ lhs _ -> checkList name lhs+ T_Pipeline _ _ cmds -> mapM_ (checkCommand name) cmds+ _ -> return ()++ checkCommand :: String -> Token -> Writer [TokenComment] ()+ checkCommand name cmd = sequence_ $ do+ let (invokedM, t) = getCommandNameAndToken True cmd+ invoked <- invokedM+ guard $ name == invoked+ return $+ errWithFix (getId t) 2264+ ("This function unconditionally re-invokes itself. Missing 'command'?")+ (fixWith [replaceStart (getId t) params 0 $ "command "])+++prop_checkBadTestAndOr1 = verify checkBadTestAndOr "[ x ] & [ y ]"+prop_checkBadTestAndOr2 = verify checkBadTestAndOr "test -e foo & [ y ]"+prop_checkBadTestAndOr3 = verify checkBadTestAndOr "[ x ] | [ y ]"+checkBadTestAndOr params t =+ case t of+ T_Pipeline _ seps cmds@(_:_:_) -> checkOrs seps cmds+ T_Backgrounded id cmd -> checkAnds id cmd+ _ -> return ()+ where+ checkOrs seps cmds =+ let maybeSeps = map Just seps+ commandWithSeps = zip3 (Nothing:maybeSeps) cmds (maybeSeps ++ [Nothing])+ in+ mapM_ checkTest commandWithSeps+ checkTest (before, cmd, after) =+ when (isTest cmd) $ do+ checkPipe before+ checkPipe after++ checkPipe t =+ case t of+ Just (T_Pipe id "|") ->+ warnWithFix id 2266 "Use || for logical OR. Single | will pipe." $+ fixWith [replaceEnd id params 0 "|"]+ _ -> return ()++ checkAnds id t =+ case t of+ T_AndIf _ _ rhs -> checkAnds id rhs+ T_OrIf _ _ rhs -> checkAnds id rhs+ T_Pipeline _ _ list | not (null list) -> checkAnds id (last list)+ cmd -> when (isTest cmd) $+ errWithFix id 2265 "Use && for logical AND. Single & will background and return true." $+ (fixWith [replaceEnd id params 0 "&"])++ isTest t =+ case t of+ T_Condition {} -> True+ T_SimpleCommand {} -> t `isCommand` "test"+ T_Redirecting _ _ t -> isTest t+ T_Annotation _ _ t -> isTest t+ _ -> False++prop_checkComparisonWithLeadingX1 = verify checkComparisonWithLeadingX "[ x$foo = xlol ]"+prop_checkComparisonWithLeadingX2 = verify checkComparisonWithLeadingX "test x$foo = xlol"+prop_checkComparisonWithLeadingX3 = verifyNot checkComparisonWithLeadingX "[ $foo = xbar ]"+prop_checkComparisonWithLeadingX4 = verifyNot checkComparisonWithLeadingX "test $foo = xbar"+prop_checkComparisonWithLeadingX5 = verify checkComparisonWithLeadingX "[ \"x$foo\" = 'xlol' ]"+prop_checkComparisonWithLeadingX6 = verify checkComparisonWithLeadingX "[ x\"$foo\" = x'lol' ]"+checkComparisonWithLeadingX params t =+ case t of+ TC_Binary id typ op lhs rhs | op == "=" || op == "==" ->+ check lhs rhs+ T_SimpleCommand _ _ [cmd, lhs, op, rhs] |+ getLiteralString cmd == Just "test" &&+ getLiteralString op `elem` [Just "=", Just "=="] ->+ check lhs rhs+ _ -> return ()+ where+ msg = "Avoid x-prefix in comparisons as it no longer serves a purpose."+ check lhs rhs = sequence_ $ do+ l <- fixLeadingX lhs+ r <- fixLeadingX rhs+ return $ styleWithFix (getId lhs) 2268 msg $ fixWith [l, r]++ fixLeadingX token =+ case getWordParts token of+ T_Literal id ('x':_):_ ->+ case token of+ -- The side is a single, unquoted x, so we have to quote+ T_NormalWord _ [T_Literal id "x"] ->+ return $ replaceStart id params 1 "\"\""+ -- Otherwise we can just delete it+ _ -> return $ replaceStart id params 1 ""+ T_SingleQuoted id ('x':_):_ ->+ -- Replace the single quote and x+ return $ replaceStart id params 2 "'"+ _ -> Nothing++prop_checkAssignToSelf1 = verify checkAssignToSelf "x=$x"+prop_checkAssignToSelf2 = verify checkAssignToSelf "x=${x}"+prop_checkAssignToSelf3 = verify checkAssignToSelf "x=\"$x\""+prop_checkAssignToSelf4 = verifyNot checkAssignToSelf "x=$x mycmd"+checkAssignToSelf _ t =+ case t of+ T_SimpleCommand _ vars [] -> mapM_ check vars+ _ -> return ()+ where+ check t =+ case t of+ T_Assignment id Assign name [] t ->+ case getWordParts t of+ [T_DollarBraced _ _ b] -> do+ when (Just name == getLiteralString b) $+ msg id+ _ -> return ()+ _ -> return ()+ msg id = info id 2269 "This variable is assigned to itself, so the assignment does nothing."+++prop_checkEqualsInCommand1a = verifyCodes checkEqualsInCommand [2277] "#!/bin/bash\n0='foo'"+prop_checkEqualsInCommand2a = verifyCodes checkEqualsInCommand [2278] "#!/bin/ksh \n$0='foo'"+prop_checkEqualsInCommand3a = verifyCodes checkEqualsInCommand [2279] "#!/bin/dash\n${0}='foo'"+prop_checkEqualsInCommand4a = verifyCodes checkEqualsInCommand [2280] "#!/bin/sh \n0='foo'"++prop_checkEqualsInCommand1b = verifyCodes checkEqualsInCommand [2270] "1='foo'"+prop_checkEqualsInCommand2b = verifyCodes checkEqualsInCommand [2270] "${2}='foo'"++prop_checkEqualsInCommand1c = verifyCodes checkEqualsInCommand [2271] "var$((n+1))=value"+prop_checkEqualsInCommand2c = verifyCodes checkEqualsInCommand [2271] "var${x}=value"+prop_checkEqualsInCommand3c = verifyCodes checkEqualsInCommand [2271] "var$((cmd))x='foo'"+prop_checkEqualsInCommand4c = verifyCodes checkEqualsInCommand [2271] "$(cmd)='foo'"++prop_checkEqualsInCommand1d = verifyCodes checkEqualsInCommand [2273] "======="+prop_checkEqualsInCommand2d = verifyCodes checkEqualsInCommand [2274] "======= Here ======="+prop_checkEqualsInCommand3d = verifyCodes checkEqualsInCommand [2275] "foo\n=42"++prop_checkEqualsInCommand1e = verifyCodes checkEqualsInCommand [] "--foo=bar"+prop_checkEqualsInCommand2e = verifyCodes checkEqualsInCommand [] "$(cmd)'=foo'"+prop_checkEqualsInCommand3e = verifyCodes checkEqualsInCommand [2276] "var${x}/=value"+prop_checkEqualsInCommand4e = verifyCodes checkEqualsInCommand [2276] "${}=value"+prop_checkEqualsInCommand5e = verifyCodes checkEqualsInCommand [2276] "${#x}=value"++prop_checkEqualsInCommand1f = verifyCodes checkEqualsInCommand [2281] "$var=foo"+prop_checkEqualsInCommand2f = verifyCodes checkEqualsInCommand [2281] "$a=$b"+prop_checkEqualsInCommand3f = verifyCodes checkEqualsInCommand [2281] "${var}=foo"+prop_checkEqualsInCommand4f = verifyCodes checkEqualsInCommand [2281] "${var[42]}=foo"+prop_checkEqualsInCommand5f = verifyCodes checkEqualsInCommand [2281] "$var+=foo"++prop_checkEqualsInCommand1g = verifyCodes checkEqualsInCommand [2282] "411toppm=true"++checkEqualsInCommand params originalToken =+ case originalToken of+ T_SimpleCommand _ _ (word:_) -> check word+ _ -> return ()+ where+ hasEquals t =+ case t of+ T_Literal _ s -> '=' `elem` s+ _ -> False++ check t@(T_NormalWord _ list) | any hasEquals list =+ case break hasEquals list of+ (leading, (eq:_)) -> msg t (stripSinglePlus leading) eq+ _ -> return ()+ check _ = return ()++ -- This is a workaround for the parser adding + and = as separate literals+ stripSinglePlus l =+ case reverse l of+ (T_Literal _ "+"):rest -> reverse rest+ _ -> l++ positionalAssignmentRe = mkRegex "^[0-9][0-9]?="+ positionalMsg id =+ err id 2270 "To assign positional parameters, use 'set -- first second ..' (or use [ ] to compare)."+ indirectionMsg id =+ err id 2271 "For indirection, use arrays, declare \"var$n=value\", or (for sh) read/eval."+ badComparisonMsg id =+ err id 2272 "Command name contains ==. For comparison, use [ \"$var\" = value ]."+ conflictMarkerMsg id =+ err id 2273 "Sequence of ===s found. Merge conflict or intended as a commented border?"+ borderMsg id =+ err id 2274 "Command name starts with ===. Intended as a commented border?"+ prefixMsg id =+ err id 2275 "Command name starts with =. Bad line break?"+ genericMsg id =+ err id 2276 "This is interpreted as a command name containing '='. Bad assignment or comparison?"+ assign0Msg id bashfix =+ case shellType params of+ Bash -> errWithFix id 2277 "Use BASH_ARGV0 to assign to $0 in bash (or use [ ] to compare)." bashfix+ Ksh -> err id 2278 "$0 can't be assigned in Ksh (but it does reflect the current function)."+ Dash -> err id 2279 "$0 can't be assigned in Dash. This becomes a command name."+ _ -> err id 2280 "$0 can't be assigned this way, and there is no portable alternative."+ leadingNumberMsg id =+ err id 2282 "Variable names can't start with numbers, so this is interpreted as a command."++ isExpansion t =+ case t of+ T_Arithmetic {} -> True+ _ -> isQuoteableExpansion t++ isConflictMarker cmd = fromMaybe False $ do+ str <- getUnquotedLiteral cmd+ guard $ all (== '=') str+ guard $ length str >= 4 && length str <= 12 -- Git uses 7 but who knows+ return True++ mayBeVariableName l = fromMaybe False $ do+ guard . not $ any isQuotes l+ guard . not $ any willBecomeMultipleArgs l+ str <- getLiteralStringExt (\_ -> Just "x") (T_NormalWord (Id 0) l)+ return $ isVariableName str++ isLeadingNumberVar s =+ let lead = takeWhile (/= '=') s+ in not (null lead) && isDigit (head lead)+ && all isVariableChar lead && not (all isDigit lead)++ msg cmd leading (T_Literal litId s) = do+ -- There are many different cases, and the order of the branches matter.+ case leading of+ -- --foo=42+ [] | "-" `isPrefixOf` s -> -- There's SC2215 for these+ return ()++ -- ======Hello======+ [] | "=" `isPrefixOf` s ->+ case originalToken of+ T_SimpleCommand _ [] [word] | isConflictMarker word ->+ conflictMarkerMsg (getId originalToken)+ _ | "===" `isPrefixOf` s -> borderMsg (getId originalToken)+ _ -> prefixMsg (getId cmd)++ -- $var==42+ _ | "==" `isInfixOf` s ->+ badComparisonMsg (getId cmd)++ -- ${foo[x]}=42 and $foo=42+ [T_DollarBraced id braced l] | "=" `isPrefixOf` s -> do+ let variableStr = concat $ oversimplify l+ let variableReference = getBracedReference variableStr+ let variableModifier = getBracedModifier variableStr+ let isPlain = isVariableName variableStr+ let isPositional = all isDigit variableStr++ let isArray = variableReference /= ""+ && "[" `isPrefixOf` variableModifier+ && "]" `isSuffixOf` variableModifier++ case () of+ -- $foo=bar should already have caused a parse-time SC1066+ -- _ | not braced && isPlain ->+ -- return ()++ _ | variableStr == "" -> -- Don't try to fix ${}=foo+ genericMsg (getId cmd)++ -- $#=42 or ${#var}=42+ _ | "#" `isPrefixOf` variableStr ->+ genericMsg (getId cmd)++ -- ${0}=42+ _ | variableStr == "0" ->+ assign0Msg id $ fixWith [replaceToken id params "BASH_ARGV0"]++ -- $2=2+ _ | isPositional ->+ positionalMsg id++ _ | isArray || isPlain ->+ errWithFix id 2281+ ("Don't use " ++ (if braced then "${}" else "$") ++ " on the left side of assignments.") $+ fixWith $+ if braced+ then [ replaceStart id params 2 "", replaceEnd id params 1 "" ]+ else [ replaceStart id params 1 "" ]++ _ -> indirectionMsg id++ -- 2=42+ [] | s `matches` positionalAssignmentRe ->+ if "0=" `isPrefixOf` s+ then+ assign0Msg litId $ fixWith [replaceStart litId params 1 "BASH_ARGV0"]+ else+ positionalMsg litId++ -- 9foo=42+ [] | isLeadingNumberVar s ->+ leadingNumberMsg (getId cmd)++ -- var${foo}x=42+ (_:_) | mayBeVariableName leading && (all isVariableChar $ takeWhile (/= '=') s) ->+ indirectionMsg (getId cmd)++ _ -> genericMsg (getId cmd)+++prop_checkSecondArgIsComparison1 = verify checkSecondArgIsComparison "foo = $bar"+prop_checkSecondArgIsComparison2 = verify checkSecondArgIsComparison "$foo = $bar"+prop_checkSecondArgIsComparison3 = verify checkSecondArgIsComparison "2f == $bar"+prop_checkSecondArgIsComparison4 = verify checkSecondArgIsComparison "'var' =$bar"+prop_checkSecondArgIsComparison5 = verify checkSecondArgIsComparison "foo ='$bar'"+prop_checkSecondArgIsComparison6 = verify checkSecondArgIsComparison "$foo =$bar"+prop_checkSecondArgIsComparison7 = verify checkSecondArgIsComparison "2f ==$bar"+prop_checkSecondArgIsComparison8 = verify checkSecondArgIsComparison "'var' =$bar"+prop_checkSecondArgIsComparison9 = verify checkSecondArgIsComparison "var += $(foo)"+prop_checkSecondArgIsComparison10 = verify checkSecondArgIsComparison "var +=$(foo)"+checkSecondArgIsComparison _ t =+ case t of+ T_SimpleCommand _ _ (lhs:arg:_) -> sequence_ $ do+ argString <- getLeadingUnquotedString arg+ case argString of+ '=':'=':'=':'=':_ -> Nothing -- Don't warn about `echo ======` and such+ '+':'=':_ ->+ return $ err (headId t) 2285 $+ "Remove spaces around += to assign (or quote '+=' if literal)."+ '=':'=':_ ->+ return $ err (getId t) 2284 $+ "Use [ x = y ] to compare values (or quote '==' if literal)."+ '=':_ ->+ return $ err (headId arg) 2283 $+ "Remove spaces around = to assign (or use [ ] to compare, or quote '=' if literal)."+ _ -> Nothing+ _ -> return ()+ where+ -- We don't pinpoint exactly, but this helps cases like foo =$bar+ headId t =+ case t of+ T_NormalWord _ (x:_) -> getId x+ _ -> getId t return [] runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |])
src/ShellCheck/AnalyzerLib.hs view
@@ -163,6 +163,8 @@ info id code str = addComment $ makeComment InfoC id code str style id code str = addComment $ makeComment StyleC id code str +errWithFix :: MonadWriter [TokenComment] m => Id -> Code -> String -> Fix -> m ()+errWithFix = addCommentWithFix ErrorC warnWithFix :: MonadWriter [TokenComment] m => Id -> Code -> String -> Fix -> m () warnWithFix = addCommentWithFix WarningC styleWithFix :: MonadWriter [TokenComment] m => Id -> Code -> String -> Fix -> m ()@@ -178,7 +180,7 @@ withFix = comment { tcFix = Just fix }- in withFix `deepseq` withFix+ in force withFix makeParameters spec = let params = Parameters {@@ -236,6 +238,10 @@ prop_determineShell6 = determineShellTest "#! /bin/sh" == Sh prop_determineShell7 = determineShellTest "#! /bin/ash" == Dash prop_determineShell8 = determineShellTest' (Just Ksh) "#!/bin/sh" == Sh+prop_determineShell9 = determineShellTest "#!/bin/env -S dash -x" == Dash+prop_determineShell10 = determineShellTest "#!/bin/env --split-string= dash -x" == Dash+prop_determineShell11 = determineShellTest "#!/bin/busybox sh" == Dash -- busybox sh is a specific shell, not posix sh+prop_determineShell12 = determineShellTest "#!/bin/busybox ash" == Dash determineShellTest = determineShellTest' Nothing determineShellTest' fallbackShell = determineShell fallbackShell . fromJust . prRoot . pScript@@ -249,22 +255,11 @@ headOrDefault (fromShebang s) [s | ShellOverride s <- annotations] 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"-executableFromShebang :: String -> String-executableFromShebang = shellFor- where- shellFor s | "/env " `isInfixOf` s = headOrDefault "" (drop 1 $ words s)- shellFor s | ' ' `elem` s = shellFor $ takeWhile (/= ' ') s- shellFor s = reverse . takeWhile (/= '/') . reverse $ s--- -- Given a root node, make a map from Id to parent Token. -- This is used to populate parentMap in Parameters getParentTree :: Token -> Map.Map Id Token getParentTree t =- snd . snd $ runState (doStackAnalysis pre post t) ([], Map.empty)+ snd $ execState (doStackAnalysis pre post t) ([], Map.empty) where pre t = modify (first ((:) t)) post t = do@@ -293,15 +288,15 @@ isQuoteFreeNode strict tree t =- (isQuoteFreeElement t == Just True) ||+ isQuoteFreeElement t || headOrDefault False (mapMaybe isQuoteFreeContext (drop 1 $ getPath tree t)) where -- Is this node self-quoting in itself? isQuoteFreeElement t = case t of- T_Assignment {} -> return True- T_FdRedirect {} -> return True- _ -> Nothing+ T_Assignment {} -> True+ T_FdRedirect {} -> True+ _ -> False -- Are any subnodes inherently self-quoting? isQuoteFreeContext t =@@ -354,8 +349,8 @@ -- Like above, if koala_man knew Haskell when starting this project. getClosestCommandM t = do- tree <- asks parentMap- return $ getClosestCommand tree t+ params <- ask+ return $ getClosestCommand (parentMap params) t -- Is the token used as a command name (the first word in a T_SimpleCommand)? usedAsCommandName tree token = go (getId token) (tail $ getPath tree token)@@ -364,8 +359,8 @@ | currentId == getId word = go id rest go currentId (T_DoubleQuoted id [word]:rest) | currentId == getId word = go id rest- go currentId (T_SimpleCommand _ _ (word:_):_)- | currentId == getId word = True+ go currentId (t@(T_SimpleCommand _ _ (word:_)):_) =+ getId word == currentId || getId (getCommandTokenOrThis t) == currentId go _ _ = False -- A list of the element and all its parents up to the root node.@@ -377,8 +372,8 @@ -- Version of the above taking the map from the current context -- Todo: give this the name "getPath" getPathM t = do- map <- asks parentMap- return $ getPath map t+ params <- ask+ return $ getPath (parentMap params) t isParentOf tree parent child = elem (getId parent) . map getId $ getPath tree child@@ -388,14 +383,13 @@ -- Find the first match in a list where the predicate is Just True. -- Stops if it's Just False and ignores Nothing. findFirst :: (a -> Maybe Bool) -> [a] -> Maybe a-findFirst p l =- case l of- [] -> Nothing- (x:xs) ->- case p x of- Just True -> return x- Just False -> Nothing- Nothing -> findFirst p xs+findFirst p = foldr go Nothing+ where+ go x acc =+ case p x of+ Just True -> return x+ Just False -> Nothing+ Nothing -> acc -- Check whether a word is entirely output from a single command tokenIsJustCommandOutput t = case t of@@ -410,8 +404,7 @@ -- TODO: Replace this with a proper Control Flow Graph getVariableFlow params t =- let (_, stack) = runState (doStackAnalysis startScope endScope t) []- in reverse stack+ reverse $ execState (doStackAnalysis startScope endScope t) [] where startScope t = let scopeType = leadType params t@@ -462,28 +455,22 @@ causesSubshell = do (T_Pipeline _ _ list) <- parentPipeline- if length list <= 1- then return False- else if not $ hasLastpipe params- then return True- else return . not $ (getId . head $ reverse list) == getId t+ return $ case list of+ _:_:_ -> not (hasLastpipe params) || getId (last list) /= getId t+ _ -> False getModifiedVariables t = case t of T_SimpleCommand _ vars [] ->- concatMap (\x -> case x of- T_Assignment id _ name _ w ->- [(x, x, name, dataTypeFrom DataString w)]- _ -> []- ) vars- c@T_SimpleCommand {} ->- getModifiedVariableCommand c+ [(x, x, name, dataTypeFrom DataString w) | x@(T_Assignment id _ name _ w) <- vars]+ T_SimpleCommand {} ->+ getModifiedVariableCommand t TA_Unary _ "++|" v@(TA_Variable _ name _) -> [(t, v, name, DataString $ SourceFrom [v])] TA_Unary _ "|++" v@(TA_Variable _ name _) -> [(t, v, name, DataString $ SourceFrom [v])]- TA_Assignment _ op (TA_Variable _ name _) rhs -> maybeToList $ do+ TA_Assignment _ op (TA_Variable _ name _) rhs -> do guard $ op `elem` ["=", "*=", "/=", "%=", "+=", "-=", "<<=", ">>=", "&=", "^=", "|="] return (t, t, name, DataString $ SourceFrom [rhs]) @@ -495,26 +482,30 @@ -- Count [[ -v foo ]] as an "assignment". -- This is to prevent [ -v foo ] being unassigned or unused.- TC_Unary id _ "-v" token -> maybeToList $ do+ TC_Unary id _ "-v" token -> do str <- fmap (takeWhile (/= '[')) $ -- Quoted index flip getLiteralStringExt token $ \x -> case x of T_Glob _ s -> return s -- Unquoted index- _ -> Nothing+ _ -> [] guard . not . null $ str return (t, token, str, DataString SourceChecked) + TC_Unary _ _ "-n" token -> markAsChecked t token+ TC_Unary _ _ "-z" token -> markAsChecked t token+ TC_Nullary _ _ token -> markAsChecked t token+ T_DollarBraced _ _ l -> maybeToList $ do- let string = bracedString t+ let string = concat $ oversimplify l let modifier = getBracedModifier string guard $ any (`isPrefixOf` modifier) ["=", ":="] return (t, t, getBracedReference string, DataString $ SourceFrom [l]) - t@(T_FdRedirect _ ('{':var) op) -> -- {foo}>&2 modifies foo+ T_FdRedirect _ ('{':var) op -> -- {foo}>&2 modifies foo [(t, t, takeWhile (/= '}') var, DataString SourceInteger) | not $ isClosingFileOp op] - t@(T_CoProc _ name _) ->+ T_CoProc _ name _ -> [(t, t, fromMaybe "COPROC" name, DataArray SourceInteger)] --Points to 'for' rather than variable@@ -522,6 +513,14 @@ T_ForIn id str words _ -> [(t, t, str, DataString $ SourceFrom words)] T_SelectIn id str words _ -> [(t, t, str, DataString $ SourceFrom words)] _ -> []+ where+ markAsChecked place token = mapMaybe (f place) $ getWordParts token+ f place t = case t of+ T_DollarBraced _ _ l ->+ let str = getBracedReference $ concat $ oversimplify l in do+ guard $ isVariableName str+ return (place, t, str, DataString SourceChecked)+ _ -> Nothing isClosingFileOp op = case op of@@ -541,6 +540,9 @@ (not $ any (`elem` flags) ["f", "F"]) then concatMap getReference rest else []+ "local" -> if "x" `elem` flags+ then concatMap getReference rest+ else [] "trap" -> case rest of head:_ -> map (\x -> (base, head, x)) $ getVariablesFromLiteralToken head@@ -569,10 +571,14 @@ "builtin" -> getModifiedVariableCommand $ T_SimpleCommand id cmdPrefix rest "read" ->- let params = map getLiteral rest- readArrayVars = getReadArrayVariables rest- in- catMaybes . (++ readArrayVars) . takeWhile isJust . reverse $ params+ let fallback = catMaybes $ takeWhile isJust (reverse $ map getLiteral rest)+ in fromMaybe fallback $ do+ parsed <- getGnuOpts flagsForRead rest+ case lookup "a" parsed of+ Just (_, var) -> (:[]) <$> getLiteralArray var+ Nothing -> return $ catMaybes $+ map (getLiteral . snd . snd) $ filter (null . fst) parsed+ "getopts" -> case rest of opts:var:_ -> maybeToList $ getLiteral var@@ -664,25 +670,28 @@ f [] = fail "not found" -- mapfile has some curious syntax allowing flags plus 0..n variable names- -- where only the first non-option one is used if any. Here we cheat and- -- just get the last one, if it's a variable name.- getMapfileArray base arguments = do- lastArg <- listToMaybe (reverse arguments)- name <- getLiteralString lastArg- guard $ isVariableName name- return (base, lastArg, name, DataArray SourceExternal)-- -- get all the array variables used in read, e.g. read -a arr- getReadArrayVariables args =- map (getLiteralArray . snd)- (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+ -- where only the first non-option one is used if any.+ getMapfileArray base rest = parseArgs `mplus` fallback+ where+ parseArgs :: Maybe (Token, Token, String, DataType)+ parseArgs = do+ args <- getGnuOpts "d:n:O:s:u:C:c:t" rest+ case [y | ("",(_,y)) <- args] of+ [] ->+ return (base, base, "MAPFILE", DataArray SourceExternal)+ first:_ -> do+ name <- getLiteralString first+ guard $ isVariableName name+ return (base, first, name, DataArray SourceExternal)+ -- If arg parsing fails (due to bad or new flags), get the last variable name+ fallback :: Maybe (Token, Token, String, DataType)+ fallback = do+ (name, token) <- listToMaybe . mapMaybe f $ reverse rest+ return (base, token, name, DataArray SourceExternal)+ f arg = do+ name <- getLiteralString arg+ guard $ isVariableName name+ return (name, arg) -- get the FLAGS_ variable created by a shflags DEFINE_ call getFlagVariable (n:v:_) = do@@ -713,7 +722,7 @@ getReferencedVariables parents t = case t of- T_DollarBraced id _ l -> let str = bracedString t in+ T_DollarBraced id _ l -> let str = concat $ oversimplify l in (t, t, getBracedReference str) : map (\x -> (l, l, x)) ( getIndexReferences str@@ -738,7 +747,7 @@ (t, t, "output") ] - t@(T_FdRedirect _ ('{':var) op) -> -- {foo}>&- references and closes foo+ T_FdRedirect _ ('{':var) op -> -- {foo}>&- references and closes foo [(t, t, takeWhile (/= '}') var) | isClosingFileOp op] x -> getReferencedVariableCommand x where@@ -755,9 +764,9 @@ literalizer t = case t of T_Glob _ s -> return s -- Also when parsed as globs- _ -> Nothing+ _ -> [] - getIfReference context token = maybeToList $ do+ getIfReference context token = do str@(h:_) <- getLiteralStringExt literalizer token when (isDigit h) $ fail "is a number" return (context, token, getBracedReference str)@@ -808,7 +817,7 @@ prop_getVariablesFromLiteral1 = getVariablesFromLiteral "$foo${bar//a/b}$BAZ" == ["foo", "bar", "BAZ"] getVariablesFromLiteral string =- map (!! 0) $ matchAllSubgroups variableRegex string+ map head $ matchAllSubgroups variableRegex string where variableRegex = mkRegex "\\$\\{?([A-Za-z0-9_]+)" @@ -824,27 +833,26 @@ prop_getBracedReference9 = getBracedReference "foo:-bar" == "foo" prop_getBracedReference10= getBracedReference "foo: -1" == "foo" prop_getBracedReference11= getBracedReference "!os*" == ""+prop_getBracedReference11b= getBracedReference "!os@" == "" prop_getBracedReference12= getBracedReference "!os?bar**" == "" prop_getBracedReference13= getBracedReference "foo[bar]" == "foo" getBracedReference s = fromMaybe s $ nameExpansion s `mplus` takeName noPrefix `mplus` getSpecial noPrefix `mplus` getSpecial s where noPrefix = dropPrefix s- dropPrefix (c:rest) = if c `elem` "!#" then rest else c:rest- dropPrefix "" = ""+ dropPrefix (c:rest) | c `elem` "!#" = rest+ dropPrefix cs = cs takeName s = do let name = takeWhile isVariableChar s guard . not $ null name return name- getSpecial (c:_) =- if c `elem` "*@#?-$!" then return [c] else fail "not special"- getSpecial _ = fail "empty"+ getSpecial (c:_) | c `elem` "*@#?-$!" = return [c]+ getSpecial _ = fail "empty or not special" - nameExpansion ('!':rest) = do -- e.g. ${!foo*bar*}- let suffix = dropWhile isVariableChar rest- guard $ suffix /= rest -- e.g. ${!@}- first <- suffix !!! 0- guard $ first `elem` "*?"+ nameExpansion ('!':next:rest) = do -- e.g. ${!foo*bar*}+ guard $ isVariableChar next -- e.g. ${!@}+ first <- find (not . isVariableChar) rest+ guard $ first `elem` "*?@" return "" nameExpansion _ = Nothing @@ -877,8 +885,8 @@ -- Run a command if the shell is in the given list whenShell l c = do- shell <- asks shellType- when (shell `elem` l ) c+ params <- ask+ when (shellType params `elem` l ) c filterByAnnotation asSpec params =@@ -907,45 +915,15 @@ -- FIXME: doesn't handle ${a:+$var} vs ${a:+"$var"} isQuotedAlternativeReference t = case t of- T_DollarBraced _ _ _ ->- getBracedModifier (bracedString t) `matches` re+ T_DollarBraced _ _ l ->+ getBracedModifier (concat $ oversimplify l) `matches` re _ -> False where re = mkRegex "(^|\\]):?\\+" --- getGnuOpts "erd:u:" will parse a SimpleCommand like--- read -re -d : -u 3 bar--- into--- Just [("r", -re), ("e", -re), ("d", :), ("u", 3), ("", bar)]--- where flags with arguments map to arguments, while others map to themselves.--- Any unrecognized flag will result in Nothing.-getGnuOpts str t = getOpts str $ getAllFlags t-getBsdOpts str t = getOpts str $ getLeadingFlags t-getOpts :: String -> [(Token, String)] -> Maybe [(String, Token)]-getOpts string flags = process flags- where- flagList (c:':':rest) = ([c], True) : flagList rest- flagList (c:rest) = ([c], False) : flagList rest- flagList [] = []- flagMap = Map.fromList $ ("", False) : flagList string-- process [] = return []- process [(token, flag)] = do- takesArg <- Map.lookup flag flagMap- guard $ not takesArg- return [(flag, token)]- process ((token1, flag1):rest2@((token2, flag2):rest)) = do- takesArg <- Map.lookup flag1 flagMap- if takesArg- then do- guard $ null flag2- more <- process rest- return $ (flag1, token2) : more- else do- more <- process rest2- return $ (flag1, token1) : more--supportsArrays shell = shell == Bash || shell == Ksh+supportsArrays Bash = True+supportsArrays Ksh = True+supportsArrays _ = False -- Returns true if the shell is Bash or Ksh (sorry for the name, Ksh) isBashLike :: Parameters -> Bool
src/ShellCheck/Checker.hs view
@@ -229,6 +229,12 @@ prop_cantSourceDynamic2 = [1090] == checkWithIncludes [("lib", "")] "source ~/foo" +prop_canStripPrefixAndSource =+ null $ checkWithIncludes [("./lib", "")] "source \"$MYDIR/lib\""++prop_canStripPrefixAndSource2 =+ null $ checkWithIncludes [("./utils.sh", "")] "source \"$(dirname \"${BASH_SOURCE[0]}\")/utils.sh\""+ prop_canSourceDynamicWhenRedirected = null $ checkWithIncludes [("lib", "")] "#shellcheck source=lib\n. \"$1\"" @@ -270,7 +276,7 @@ check "# Disable $? warning\n#shellcheck disable=SC2181\n# Disable quoting warning\n#shellcheck disable=2086\ntrue\n[ $? == 0 ] && echo $1" prop_sourcePartOfOriginalScript = -- #1181: -x disabled posix warning for 'source'- 2039 `elem` checkWithIncludes [("./saywhat.sh", "echo foo")] "#!/bin/sh\nsource ./saywhat.sh"+ 3046 `elem` checkWithIncludes [("./saywhat.sh", "echo foo")] "#!/bin/sh\nsource ./saywhat.sh" prop_spinBug1413 = null $ check "fun() {\n# shellcheck disable=SC2188\n> /dev/null\n}\n" @@ -293,6 +299,13 @@ result = checkWithSpec [] emptyCheckSpec { csFilename = "file.sh", csScript = "#shellcheck disable=SC2148\nfoo"+ }++prop_canDisableParseErrors = null $ result+ where+ result = checkWithSpec [] emptyCheckSpec {+ csFilename = "file.sh",+ csScript = "#shellcheck disable=SC1073,SC1072,SC2148\n()" } prop_shExtensionDoesntMatter = result == [2148]
src/ShellCheck/Checks/Commands.hs view
@@ -19,6 +19,7 @@ -} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE MultiWayIf #-} -- This module contains checks that examine specific commands by name. module ShellCheck.Checks.Commands (checker, optionalChecks, ShellCheck.Checks.Commands.runTests) where@@ -52,8 +53,6 @@ verify f s = producesComments (getChecker [f]) s == Just True verifyNot f s = producesComments (getChecker [f]) s == Just False -arguments (T_SimpleCommand _ _ (cmd:args)) = args- commandChecks :: [CommandCheck] commandChecks = [ checkTr@@ -95,6 +94,7 @@ ,checkSudoArgs ,checkSourceArgs ,checkChmodDashr+ ,checkXargsDashi ] optionalChecks = map fst optionalCommandChecks@@ -115,6 +115,35 @@ verify check (cdPositive desc) && verifyNot check (cdNegative desc) +-- Run a check against the getopt parser. If it fails, the lists are empty.+checkGetOpts str flags args f =+ flags == actualFlags && args == actualArgs+ where+ toTokens = map (T_Literal (Id 0)) . words+ opts = fromMaybe [] $ f (toTokens str)+ actualFlags = filter (not . null) $ map fst opts+ actualArgs = [onlyLiteralString x | ("", (_, x)) <- opts]++-- Short options+prop_checkGetOptsS1 = checkGetOpts "-f x" ["f"] [] $ getOpts (True, True) "f:" []+prop_checkGetOptsS2 = checkGetOpts "-fx" ["f"] [] $ getOpts (True, True) "f:" []+prop_checkGetOptsS3 = checkGetOpts "-f -x" ["f", "x"] [] $ getOpts (True, True) "fx" []+prop_checkGetOptsS4 = checkGetOpts "-f -x" ["f"] [] $ getOpts (True, True) "f:" []+prop_checkGetOptsS5 = checkGetOpts "-fx" [] [] $ getOpts (True, True) "fx:" []++-- Long options+prop_checkGetOptsL1 = checkGetOpts "--foo=bar baz" ["foo"] ["baz"] $ getOpts (True, False) "" [("foo", True)]+prop_checkGetOptsL2 = checkGetOpts "--foo bar baz" ["foo"] ["baz"] $ getOpts (True, False) "" [("foo", True)]+prop_checkGetOptsL3 = checkGetOpts "--foo baz" ["foo"] ["baz"] $ getOpts (True, True) "" []+prop_checkGetOptsL4 = checkGetOpts "--foo baz" [] [] $ getOpts (True, False) "" []++-- Know when to terminate+prop_checkGetOptsT1 = checkGetOpts "-a x -b" ["a", "b"] ["x"] $ getOpts (True, True) "ab" []+prop_checkGetOptsT2 = checkGetOpts "-a x -b" ["a"] ["x","-b"] $ getOpts (False, True) "ab" []+prop_checkGetOptsT3 = checkGetOpts "-a -- -b" ["a"] ["-b"] $ getOpts (True, True) "ab" []+prop_checkGetOptsT4 = checkGetOpts "-a -- -b" ["a", "b"] [] $ getOpts (True, True) "a:b" []++ buildCommandMap :: [CommandCheck] -> Map.Map CommandName (Token -> Analysis) buildCommandMap = foldl' addCheck Map.empty where@@ -199,12 +228,11 @@ checkFindNameGlob = CommandCheck (Basename "find") (f . arguments) where acceptsGlob s = s `elem` [ "-ilname", "-iname", "-ipath", "-iregex", "-iwholename", "-lname", "-name", "-path", "-regex", "-wholename" ] f [] = return ()- f (x:xs) = g x xs- g _ [] = return ()- g a (b:r) = do+ f (x:xs) = foldr g (const $ return ()) xs x+ g b acc a = do forM_ (getLiteralString a) $ \s -> when (acceptsGlob s && isGlob b) $ warn (getId b) 2061 $ "Quote the parameter to " ++ s ++ " so the shell won't interpret it."- g b r+ acc b prop_checkNeedlessExpr = verify checkNeedlessExpr "foo=$(expr 3 + 2)"@@ -283,17 +311,11 @@ candidates = sampleWords ++ map (\(x:r) -> toUpper x : r) sampleWords - getSuspiciousRegexWildcard str =- if not $ str `matches` contra- then do- match <- matchRegex suspicious str- str <- match !!! 0- str !!! 0- else- fail "looks good"- where- suspicious = mkRegex "([A-Za-z1-9])\\*"- contra = mkRegex "[^a-zA-Z1-9]\\*|[][^$+\\\\]"+ getSuspiciousRegexWildcard str = case matchRegex suspicious str of+ Just [[c]] | not (str `matches` contra) -> Just c+ _ -> fail "looks good"+ suspicious = mkRegex "([A-Za-z1-9])\\*"+ contra = mkRegex "[^a-zA-Z1-9]\\*|[][^$+\\\\]" prop_checkTrapQuotes1 = verify checkTrapQuotes "trap \"echo $num\" INT"@@ -462,8 +484,8 @@ where check t = sequence_ $ do let flags = getAllFlags t- dashP <- find ((\f -> f == "p" || f == "parents") . snd) flags- dashM <- find ((\f -> f == "m" || f == "mode") . snd) flags+ dashP <- find (\(_,f) -> f == "p" || f == "parents") flags+ dashM <- find (\(_,f) -> f == "m" || f == "mode") flags -- mkdir -pm 0700 dir is fine, so is ../dir, but dir/subdir is not. guard $ any couldHaveSubdirs (drop 1 $ arguments t) return $ warn (getId $ fst dashM) 2174 "When used with -p, -m only applies to the deepest directory."@@ -483,7 +505,7 @@ checkNonportableSignals = CommandCheck (Exactly "trap") (f . arguments) where f args = case args of- first:rest -> unless (isFlag first) $ mapM_ check rest+ first:rest | not $ isFlag first -> mapM_ check rest _ -> return () check param = sequence_ $ do@@ -520,9 +542,9 @@ info (getId cmd) 2117 "To run commands as another user, use su -c or sudo." - undirected (T_Pipeline _ _ l) = length l <= 1+ undirected (T_Pipeline _ _ (_:_:_)) = False -- This should really just be modifications to stdin, but meh- undirected (T_Redirecting _ list _) = null list+ undirected (T_Redirecting _ (_:_) _) = False undirected _ = True @@ -539,9 +561,8 @@ ([], hostport:r@(_:_)) -> checkArg $ last r _ -> return () checkArg (T_NormalWord _ [T_DoubleQuoted id parts]) =- case filter (not . isConstant) parts of- [] -> return ()- (x:_) -> info (getId x) 2029+ forM_ (find (not . isConstant) parts) $+ \x -> info (getId x) 2029 "Note that, unescaped, this expands on the client side." checkArg _ = return () @@ -567,6 +588,8 @@ prop_checkPrintfVar19= verifyNot checkPrintfVar "printf '%(%s)T'" prop_checkPrintfVar20= verifyNot checkPrintfVar "printf '%d %(%s)T' 42" prop_checkPrintfVar21= verify checkPrintfVar "printf '%d %(%s)T'"+prop_checkPrintfVar22= verify checkPrintfVar "printf '%s\n%s' foo"+ 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@@ -580,22 +603,21 @@ 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."+ return $ if+ | 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+ | otherwise ->+ 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@@ -610,6 +632,8 @@ 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"+prop_checkGetPrintfFormats6 = getPrintfFormats "%s%s" == "ss"+prop_checkGetPrintfFormats7 = getPrintfFormats "%s\n%s" == "ss" getPrintfFormats = getFormats where -- Get the arguments in the string as a string of type characters,@@ -628,17 +652,17 @@ regexBasedGetFormats rest = case matchRegex re rest of- Just [width, precision, typ, rest] ->+ 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+ re = mkRegex "#?-?\\+? ?0?(\\*|\\d*)\\.?(\\d*|\\*)([diouxXfFeEgGaAcsbq])((\n|.)*)"+ -- \____ _____/\___ ____/ \____ ____/\_________ _________/ \______ /+ -- 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 @@ -663,17 +687,12 @@ prop_checkSetAssignment6 = verifyNot checkSetAssignment "set" checkSetAssignment = CommandCheck (Exactly "set") (f . arguments) where- f (var:value:rest) =- let str = literal var in- when (isVariableName str || isAssignment str) $- msg (getId var)- f (var:_) =- when (isAssignment $ literal var) $- msg (getId var)+ f (var:rest)+ | (not (null rest) && isVariableName str) || isAssignment str =+ warn (getId var) 2121 "To assign a variable, use just 'var=value', no 'set ..'."+ where str = literal var f _ = return () - msg id = warn id 2121 "To assign a variable, use just 'var=value', no 'set ..'."- isAssignment str = '=' `elem` str literal (T_NormalWord _ l) = concatMap literal l literal (T_Literal _ str) = str@@ -687,8 +706,7 @@ checkExportedExpansions = CommandCheck (Exactly "export") (mapM_ check . arguments) where check t = sequence_ $ do- var <- getSingleUnmodifiedVariable t- let name = bracedString var+ name <- getSingleUnmodifiedBracedString t return . warn (getId t) 2163 $ "This does not export '" ++ name ++ "'. Remove $/${} for that, or use ${var?} to quiet." @@ -704,26 +722,25 @@ where options = getGnuOpts flagsForRead getVars cmd = fromMaybe [] $ do- opts <- options cmd- return [y | (x,y) <- opts, null x || x == "a"]+ opts <- options $ arguments cmd+ return [y | (x,(_, y)) <- opts, null x || x == "a"] check cmd = mapM_ warning $ getVars cmd warning t = sequence_ $ do- var <- getSingleUnmodifiedVariable t- let name = bracedString var+ name <- getSingleUnmodifiedBracedString t guard $ isVariableName name -- e.g. not $1 return . warn (getId t) 2229 $ "This does not read '" ++ name ++ "'. Remove $/${} for that, or use ${var?} to quiet." -- Return the single variable expansion that makes up this word, if any. -- e.g. $foo -> $foo, "$foo"'' -> $foo , "hello $name" -> Nothing-getSingleUnmodifiedVariable :: Token -> Maybe Token-getSingleUnmodifiedVariable word =+getSingleUnmodifiedBracedString :: Token -> Maybe String+getSingleUnmodifiedBracedString word = case getWordParts word of- [t@(T_DollarBraced {})] ->- let contents = bracedString t+ [T_DollarBraced _ _ l] ->+ let contents = concat $ oversimplify l name = getBracedReference contents- in guard (contents == name) >> return t+ in guard (contents == name) >> return contents _ -> Nothing prop_checkAliasesUsesArgs1 = verify checkAliasesUsesArgs "alias a='cp $1 /a'"@@ -883,12 +900,12 @@ notRequested = Map.difference handledMap requestedMap warnUnhandled optId caseId str =- warn caseId 2213 $ "getopts specified -" ++ str ++ ", but it's not handled by this 'case'."+ warn caseId 2213 $ "getopts specified -" ++ (e4m str) ++ ", but it's not handled by this 'case'." - warnRedundant (key, expr) = sequence_ $ do- str <- key- guard $ str `notElem` ["*", ":", "?"]- return $ warn (getId expr) 2214 "This case is not specified by getopts."+ warnRedundant (Just str, expr)+ | str `notElem` ["*", ":", "?"] =+ warn (getId expr) 2214 "This case is not specified by getopts."+ warnRedundant _ = return () getHandledStrings (_, globs, _) = map (\x -> (literal x, x)) globs@@ -899,7 +916,7 @@ fromGlob t = case t of- T_Glob _ ('[':c:']':[]) -> return [c]+ T_Glob _ ['[', c, ']'] -> return [c] T_Glob _ "*" -> return "*" T_Glob _ "?" -> return "?" _ -> Nothing@@ -934,7 +951,7 @@ when (isRecursive t) $ mapM_ (mapM_ checkWord . braceExpand) $ arguments t where- isRecursive = any (`elem` ["r", "R", "recursive"]) . map snd . getAllFlags+ isRecursive = any ((`elem` ["r", "R", "recursive"]) . snd) . getAllFlags checkWord token = case getLiteralString token of@@ -966,9 +983,9 @@ f _ = return "" stripTrailing c = reverse . dropWhile (== c) . reverse- skipRepeating c (a:b:rest) | a == b && b == c = skipRepeating c (b:rest)- skipRepeating c (a:r) = a:skipRepeating c r- skipRepeating _ [] = []+ skipRepeating c = foldr go []+ where+ go a r = a : case r of b:rest | b == c && a == b -> rest; _ -> r paths = [ "", "/bin", "/etc", "/home", "/mnt", "/usr", "/usr/share", "/usr/local",@@ -1039,7 +1056,7 @@ prop_checkWhich = verify checkWhich "which '.+'" checkWhich = CommandCheck (Basename "which") $- \t -> info (getId $ getCommandTokenOrThis t) 2230 "which is non-standard. Use builtin 'command -v' instead."+ \t -> info (getId $ getCommandTokenOrThis t) 2230 "'which' is non-standard. Use builtin 'command -v' instead." prop_checkSudoRedirect1 = verify checkSudoRedirect "sudo echo 3 > /proc/file" prop_checkSudoRedirect2 = verify checkSudoRedirect "sudo cmd < input"@@ -1081,8 +1098,8 @@ checkSudoArgs = CommandCheck (Basename "sudo") f where f t = sequence_ $ do- opts <- parseOpts t- let nonFlags = [x | ("",x) <- opts]+ opts <- parseOpts $ arguments t+ let nonFlags = [x | ("",(x, _)) <- opts] commandArg <- nonFlags !!! 0 command <- getLiteralString commandArg guard $ command `elem` builtins@@ -1112,6 +1129,19 @@ flag <- getLiteralString t guard $ flag == "-r" return $ warn (getId t) 2253 "Use -R to recurse, or explicitly a-r to remove read permissions."++prop_checkXargsDashi1 = verify checkXargsDashi "xargs -i{} echo {}"+prop_checkXargsDashi2 = verifyNot checkXargsDashi "xargs -I{} echo {}"+prop_checkXargsDashi3 = verifyNot checkXargsDashi "xargs sed -i -e foo"+prop_checkXargsDashi4 = verify checkXargsDashi "xargs -e sed -i foo"+prop_checkXargsDashi5 = verifyNot checkXargsDashi "xargs -x sed -i foo"+checkXargsDashi = CommandCheck (Basename "xargs") f+ where+ f t = sequence_ $ do+ opts <- parseOpts $ arguments t+ (option, value) <- lookup "i" opts+ return $ info (getId option) 2267 "GNU xargs -i is deprecated in favor of -I{}"+ parseOpts = getBsdOpts "0oprtxadR:S:J:L:l:n:P:s:e:E:i:I:" return [] runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |])
src/ShellCheck/Checks/ShellSupport.hs view
@@ -178,6 +178,9 @@ prop_checkBashisms94 = verify checkBashisms "#!/bin/sh\n[ -v var ]" prop_checkBashisms95 = verify checkBashisms "#!/bin/sh\necho $_" prop_checkBashisms96 = verifyNot checkBashisms "#!/bin/dash\necho $_"+prop_checkBashisms97 = verify checkBashisms "#!/bin/sh\necho ${var,}"+prop_checkBashisms98 = verify checkBashisms "#!/bin/sh\necho ${var^^}"+prop_checkBashisms99 = verifyNot checkBashisms "#!/bin/dash\necho [^f]oo" checkBashisms = ForShell [Sh, Dash] $ \t -> do params <- ask kludge params t@@ -186,102 +189,102 @@ kludge params = bashism where isDash = shellType params == Dash- warnMsg id s =+ warnMsg id code s = if isDash- then warn id 2169 $ "In dash, " ++ s ++ " not supported."- else warn id 2039 $ "In POSIX sh, " ++ s ++ " undefined."+ then err id code $ "In dash, " ++ s ++ " not supported."+ else warn id code $ "In POSIX sh, " ++ s ++ " undefined." - bashism (T_ProcSub id _ _) = warnMsg id "process substitution is"- bashism (T_Extglob id _ _) = warnMsg id "extglob is"- bashism (T_DollarSingleQuoted id _) = warnMsg id "$'..' is"- bashism (T_DollarDoubleQuoted id _) = warnMsg id "$\"..\" is"- bashism (T_ForArithmetic id _ _ _ _) = warnMsg id "arithmetic for loops are"- bashism (T_Arithmetic id _) = warnMsg id "standalone ((..)) is"- bashism (T_DollarBracket id _) = warnMsg id "$[..] in place of $((..)) is"- bashism (T_SelectIn id _ _ _) = warnMsg id "select loops are"- bashism (T_BraceExpansion id _) = warnMsg id "brace expansion is"- bashism (T_Condition id DoubleBracket _) = warnMsg id "[[ ]] is"- bashism (T_HereString id _) = warnMsg id "here-strings are"+ bashism (T_ProcSub id _ _) = warnMsg id 3001 "process substitution is"+ bashism (T_Extglob id _ _) = warnMsg id 3002 "extglob is"+ bashism (T_DollarSingleQuoted id _) = warnMsg id 3003 "$'..' is"+ bashism (T_DollarDoubleQuoted id _) = warnMsg id 3004 "$\"..\" is"+ bashism (T_ForArithmetic id _ _ _ _) = warnMsg id 3005 "arithmetic for loops are"+ bashism (T_Arithmetic id _) = warnMsg id 3006 "standalone ((..)) is"+ bashism (T_DollarBracket id _) = warnMsg id 3007 "$[..] in place of $((..)) is"+ bashism (T_SelectIn id _ _ _) = warnMsg id 3008 "select loops are"+ bashism (T_BraceExpansion id _) = warnMsg id 3009 "brace expansion is"+ bashism (T_Condition id DoubleBracket _) = warnMsg id 3010 "[[ ]] is"+ bashism (T_HereString id _) = warnMsg id 3011 "here-strings are" bashism (TC_Binary id SingleBracket op _ _) | op `elem` [ "<", ">", "\\<", "\\>", "<=", ">=", "\\<=", "\\>="] =- unless isDash $ warnMsg id $ "lexicographical " ++ op ++ " is"+ unless isDash $ warnMsg id 3012 $ "lexicographical " ++ op ++ " is" bashism (TC_Binary id SingleBracket op _ _) | op `elem` [ "-ot", "-nt", "-ef" ] =- unless isDash $ warnMsg id $ op ++ " is"+ unless isDash $ warnMsg id 3013 $ op ++ " is" bashism (TC_Binary id SingleBracket "==" _ _) =- warnMsg id "== in place of = is"+ warnMsg id 3014 "== in place of = is" bashism (TC_Binary id SingleBracket "=~" _ _) =- warnMsg id "=~ regex matching is"+ warnMsg id 3015 "=~ regex matching is" bashism (TC_Unary id SingleBracket "-v" _) =- warnMsg id "unary -v (in place of [ -n \"${var+x}\" ]) is"+ warnMsg id 3016 "unary -v (in place of [ -n \"${var+x}\" ]) is" bashism (TC_Unary id _ "-a" _) =- warnMsg id "unary -a in place of -e is"+ warnMsg id 3017 "unary -a in place of -e is" bashism (TA_Unary id op _) | op `elem` [ "|++", "|--", "++|", "--|"] =- warnMsg id $ filter (/= '|') op ++ " is"- bashism (TA_Binary id "**" _ _) = warnMsg id "exponentials are"- bashism (T_FdRedirect id "&" (T_IoFile _ (T_Greater _) _)) = warnMsg id "&> is"- bashism (T_FdRedirect id "" (T_IoFile _ (T_GREATAND _) _)) = warnMsg id ">& is"- bashism (T_FdRedirect id ('{':_) _) = warnMsg id "named file descriptors are"+ warnMsg id 3018 $ filter (/= '|') op ++ " is"+ bashism (TA_Binary id "**" _ _) = warnMsg id 3019 "exponentials are"+ bashism (T_FdRedirect id "&" (T_IoFile _ (T_Greater _) _)) = warnMsg id 3020 "&> is"+ bashism (T_FdRedirect id "" (T_IoFile _ (T_GREATAND _) _)) = warnMsg id 3021 ">& is"+ bashism (T_FdRedirect id ('{':_) _) = warnMsg id 3022 "named file descriptors are" bashism (T_FdRedirect id num _)- | all isDigit num && length num > 1 = warnMsg id "FDs outside 0-9 are"+ | all isDigit num && length num > 1 = warnMsg id 3023 "FDs outside 0-9 are" bashism (T_Assignment id Append _ _ _) =- warnMsg id "+= is"+ warnMsg id 3024 "+= is" bashism (T_IoFile id _ word) | isNetworked =- warnMsg id "/dev/{tcp,udp} is"+ warnMsg id 3025 "/dev/{tcp,udp} is" where file = onlyLiteralString word isNetworked = any (`isPrefixOf` file) ["/dev/tcp", "/dev/udp"]- bashism (T_Glob id str) | "[^" `isInfixOf` str =- warnMsg id "^ in place of ! in glob bracket expressions is"+ bashism (T_Glob id str) | not isDash && "[^" `isInfixOf` str =+ warnMsg id 3026 "^ in place of ! in glob bracket expressions is" bashism t@(TA_Variable id str _) | isBashVariable str =- warnMsg id $ str ++ " is"+ warnMsg id 3028 $ str ++ " is" bashism t@(T_DollarBraced id _ token) = do mapM_ check expansion when (isBashVariable var) $- warnMsg id $ var ++ " is"+ warnMsg id 3028 $ var ++ " is" where- str = bracedString t+ str = concat $ oversimplify token var = getBracedReference str- check (regex, feature) =- when (isJust $ matchRegex regex str) $ warnMsg id feature+ check (regex, code, feature) =+ when (isJust $ matchRegex regex str) $ warnMsg id code feature bashism t@(T_Pipe id "|&") =- warnMsg id "|& in place of 2>&1 | is"+ warnMsg id 3029 "|& in place of 2>&1 | is" bashism (T_Array id _) =- warnMsg id "arrays are"+ warnMsg id 3030 "arrays are" bashism (T_IoFile id _ t) | isGlob t =- warnMsg id "redirecting to/from globs is"+ warnMsg id 3031 "redirecting to/from globs is" bashism (T_CoProc id _ _) =- warnMsg id "coproc is"+ warnMsg id 3032 "coproc is" bashism (T_Function id _ _ str _) | not (isVariableName str) =- warnMsg id "naming functions outside [a-zA-Z_][a-zA-Z0-9_]* is"+ warnMsg id 3033 "naming functions outside [a-zA-Z_][a-zA-Z0-9_]* is" bashism (T_DollarExpansion id [x]) | isOnlyRedirection x =- warnMsg id "$(<file) to read files is"+ warnMsg id 3034 "$(<file) to read files is" bashism (T_Backticked id [x]) | isOnlyRedirection x =- warnMsg id "`<file` to read files is"+ warnMsg id 3035 "`<file` to read files is" bashism t@(T_SimpleCommand _ _ (cmd:arg:_)) | t `isCommand` "echo" && argString `matches` flagRegex = if isDash then when (argString /= "-n") $- warnMsg (getId arg) "echo flags besides -n"+ warnMsg (getId arg) 3036 "echo flags besides -n" else- warnMsg (getId arg) "echo flags are"+ warnMsg (getId arg) 3037 "echo flags are" where argString = concat $ oversimplify arg flagRegex = mkRegex "^-[eEsn]+$" bashism t@(T_SimpleCommand _ _ (cmd:arg:_))- | t `isCommand` "exec" && "-" `isPrefixOf` concat (oversimplify arg) =- warnMsg (getId arg) "exec flags are"+ | getLiteralString cmd == Just "exec" && "-" `isPrefixOf` concat (oversimplify arg) =+ warnMsg (getId arg) 3038 "exec flags are" bashism t@(T_SimpleCommand id _ _)- | t `isCommand` "let" = warnMsg id "'let' is"+ | t `isCommand` "let" = warnMsg id 3039 "'let' is" bashism t@(T_SimpleCommand _ _ (cmd:args)) | t `isCommand` "set" = unless isDash $ checkOptions $ getLiteralArgs args@@ -289,16 +292,17 @@ -- Get the literal options from a list of arguments, -- up until the first non-literal one getLiteralArgs :: [Token] -> [(Id, String)]- getLiteralArgs (first:rest) = fromMaybe [] $ do- str <- getLiteralString first- return $ (getId first, str) : getLiteralArgs rest- getLiteralArgs [] = []+ getLiteralArgs = foldr go []+ where+ go first rest = case getLiteralString first of+ Just str -> (getId first, str) : rest+ Nothing -> [] -- Check a flag-option pair (such as -o errexit) checkOptions (flag@(fid,flag') : opt@(oid,opt') : rest) | flag' `matches` oFlagRegex = do when (opt' `notElem` longOptions) $- warnMsg oid $ "set option " <> opt' <> " is"+ warnMsg oid 3040 $ "set option " <> opt' <> " is" checkFlags (flag:rest) | otherwise = checkFlags (flag:opt:rest) checkOptions (flag:rest) = checkFlags (flag:rest)@@ -311,10 +315,10 @@ unless (flag' `matches` validFlagsRegex) $ forM_ (tail flag') $ \letter -> when (letter `notElem` optionsSet) $- warnMsg fid $ "set flag " <> ('-':letter:" is")+ warnMsg fid 3041 $ "set flag " <> ('-':letter:" is") checkOptions rest | beginsWithDoubleDash flag' = do- warnMsg fid $ "set flag " <> flag' <> " is"+ warnMsg fid 3042 $ "set flag " <> flag' <> " is" checkOptions rest -- Either a word that doesn't start with a dash, or simply '--', -- so stop checking.@@ -336,16 +340,19 @@ let name = fromMaybe "" $ getCommandName t flags = getLeadingFlags t in do+ when (name == "local" && not isDash) $+ -- This is so commonly accepted that we'll make it a special case+ warnMsg id 3043 $ "'local' is" when (name `elem` unsupportedCommands) $- warnMsg id $ "'" ++ name ++ "' is"+ warnMsg id 3044 $ "'" ++ name ++ "' is" sequence_ $ do allowed' <- Map.lookup name allowedFlags allowed <- allowed' (word, flag) <- find (\x -> (not . null . snd $ x) && snd x `notElem` allowed) flags- return . warnMsg (getId word) $ name ++ " -" ++ flag ++ " is"+ return . warnMsg (getId word) 3045 $ name ++ " -" ++ flag ++ " is" - when (name == "source") $ warnMsg id "'source' in place of '.' is"+ when (name == "source") $ warnMsg id 3046 "'source' in place of '.' is" when (name == "trap") $ let check token = sequence_ $ do@@ -353,12 +360,12 @@ let upper = map toUpper str return $ do when (upper `elem` ["ERR", "DEBUG", "RETURN"]) $- warnMsg (getId token) $ "trapping " ++ str ++ " is"+ warnMsg (getId token) 3047 $ "trapping " ++ str ++ " is" when ("SIG" `isPrefixOf` upper) $- warnMsg (getId token)+ warnMsg (getId token) 3048 "prefixing signal names with 'SIG' is" when (not isDash && upper /= str) $- warnMsg (getId token)+ warnMsg (getId token) 3049 "using lower/mixed case for signal names is" in mapM_ check (drop 1 rest)@@ -367,13 +374,13 @@ format <- rest !!! 0 -- flags are covered by allowedFlags let literal = onlyLiteralString format guard $ "%q" `isInfixOf` literal- return $ warnMsg (getId format) "printf %q is"+ return $ warnMsg (getId format) 3050 "printf %q is" where unsupportedCommands = [ "let", "caller", "builtin", "complete", "compgen", "declare", "dirs", "disown", "enable", "mapfile", "readarray", "pushd", "popd", "shopt", "suspend", "typeset"- ] ++ if not isDash then ["local"] else []+ ] allowedFlags = Map.fromList [ ("cd", Just ["L", "P"]), ("exec", Just []),@@ -390,29 +397,35 @@ ("unset", Just ["f", "v"]), ("wait", Just []) ]- bashism t@(T_SourceCommand id src _) =- let name = fromMaybe "" $ getCommandName src- in when (name == "source") $ warnMsg id "'source' in place of '.' is"- bashism (TA_Expansion _ (T_Literal id str : _)) | str `matches` radix =- when (str `matches` radix) $ warnMsg id "arithmetic base conversion is"+ bashism t@(T_SourceCommand id src _)+ | getCommandName src == Just "source" = warnMsg id 3051 "'source' in place of '.' is"+ bashism (TA_Expansion _ (T_Literal id str : _))+ | str `matches` radix = warnMsg id 3052 "arithmetic base conversion is" where radix = mkRegex "^[0-9]+#" bashism _ = return () varChars="_0-9a-zA-Z" expansion = let re = mkRegex in [- (re $ "^![" ++ varChars ++ "]", "indirect expansion is"),- (re $ "^[" ++ varChars ++ "]+\\[.*\\]$", "array references are"),- (re $ "^![" ++ varChars ++ "]+\\[[*@]]$", "array key expansion is"),- (re $ "^![" ++ varChars ++ "]+[*@]$", "name matching prefixes are"),- (re $ "^[" ++ varChars ++ "*@]+:[^-=?+]", "string indexing is"),- (re $ "^([*@][%#]|#[@*])", "string operations on $@/$* are"),- (re $ "^[" ++ varChars ++ "*@]+(\\[.*\\])?/", "string replacement is")+ (re $ "^![" ++ varChars ++ "]", 3053, "indirect expansion is"),+ (re $ "^[" ++ varChars ++ "]+\\[.*\\]$", 3054, "array references are"),+ (re $ "^![" ++ varChars ++ "]+\\[[*@]]$", 3055, "array key expansion is"),+ (re $ "^![" ++ varChars ++ "]+[*@]$", 3056, "name matching prefixes are"),+ (re $ "^[" ++ varChars ++ "*@]+:[^-=?+]", 3057, "string indexing is"),+ (re $ "^([*@][%#]|#[@*])", 3058, "string operations on $@/$* are"),+ (re $ "^[" ++ varChars ++ "*@]+(\\[.*\\])?[,^]", 3059, "case modification is"),+ (re $ "^[" ++ varChars ++ "*@]+(\\[.*\\])?/", 3060, "string replacement is") ] bashVars = [+ -- This list deliberately excludes $BASH_VERSION as it's often used+ -- for shell identification. "OSTYPE", "MACHTYPE", "HOSTTYPE", "HOSTNAME", "DIRSTACK", "EUID", "UID", "SHLVL", "PIPESTATUS", "SHELLOPTS",- "_"+ "_", "BASHOPTS", "BASHPID", "BASH_ALIASES", "BASH_ARGC",+ "BASH_ARGV", "BASH_ARGV0", "BASH_CMDS", "BASH_COMMAND",+ "BASH_EXECUTION_STRING", "BASH_LINENO", "BASH_REMATCH", "BASH_SOURCE",+ "BASH_SUBSHELL", "BASH_VERSINFO", "EPOCHREALTIME", "EPOCHSECONDS",+ "FUNCNAME", "GROUPS", "MACHTYPE", "MAPFILE" ] bashDynamicVars = [ "RANDOM", "SECONDS" ] dashVars = [ "_" ]@@ -506,13 +519,13 @@ case token of T_Assignment _ _ name (first:second:_) _ -> about second T_IndexedElement _ (first:second:_) _ -> about second- T_DollarBraced {} ->- when (isMultiDim token) $ about token+ T_DollarBraced _ _ l ->+ when (isMultiDim l) $ about token _ -> return () about t = warn (getId t) 2180 "Bash does not support multidimensional arrays. Use 1D or associative arrays." re = mkRegex "^\\[.*\\]\\[.*\\]" -- Fixme, this matches ${foo:- [][]} and such as well- isMultiDim t = getBracedModifier (bracedString t) `matches` re+ isMultiDim l = getBracedModifier (concat $ oversimplify l) `matches` re prop_checkPS11 = verify checkPS1Assignments "PS1='\\033[1;35m\\$ '" prop_checkPS11a= verify checkPS1Assignments "export PS1='\\033[1;35m\\$ '"
src/ShellCheck/Fixer.hs view
@@ -273,10 +273,10 @@ where f sum _ PSLeaf = sum f sum target (PSBranch pivot left right cumulative) =- case () of- _ | target < pivot -> f sum target left- _ | target > pivot -> f (sum+cumulative) target right- _ -> sum+cumulative+ case target `compare` pivot of+ LT -> f sum target left+ GT -> f (sum+cumulative) target right+ EQ -> sum+cumulative -- Add a value to the Prefix Sum tree at the given index. -- Values accumulate: addPSValue 42 2 . addPSValue 42 3 == addPSValue 42 5@@ -285,10 +285,10 @@ where f PSLeaf = PSBranch key PSLeaf PSLeaf value f (PSBranch pivot left right sum) =- case () of- _ | key < pivot -> PSBranch pivot (f left) right (sum + value)- _ | key > pivot -> PSBranch pivot left (f right) sum- _ -> PSBranch pivot left right (sum + value)+ case key `compare` pivot of+ LT -> PSBranch pivot (f left) right (sum + value)+ GT -> PSBranch pivot left (f right) sum+ EQ -> PSBranch pivot left right (sum + value) prop_pstreeSumsCorrectly kvs targets = let
src/ShellCheck/Formatter/TTY.hs view
@@ -127,7 +127,7 @@ let lineCount = length fileLinesList let fileLines = listArray (1, lineCount) fileLinesList let groups = groupWith lineNo comments- mapM_ (\commentsForLine -> do+ forM_ groups $ \commentsForLine -> do let lineNum = fromIntegral $ lineNo (head commentsForLine) let line = if lineNum < 1 || lineNum > lineCount then ""@@ -136,10 +136,9 @@ putStrLn $ color "message" $ "In " ++ fileName ++" line " ++ show lineNum ++ ":" putStrLn (color "source" line)- mapM_ (\c -> putStrLn (color (severityText c) $ cuteIndent c)) commentsForLine+ forM_ commentsForLine $ \c -> putStrLn $ color (severityText c) $ cuteIndent c putStrLn "" showFixedString color commentsForLine (fromIntegral lineNum) fileLines- ) groups -- Pick out only the lines necessary to show a fix in action sliceFile :: Fix -> Array Int String -> (Fix, Array Int String)
src/ShellCheck/Interface.hs view
@@ -73,15 +73,15 @@ data SystemInterface m = SystemInterface {- -- Read a file by filename, or return an error+ -- | Read a file by filename, or return an error siReadFile :: String -> m (Either ErrorMessage String),- -- Given:+ -- | Given: -- the current script, -- a list of source-path annotations in effect, -- and a sourced file, -- find the sourced file siFindSource :: String -> [String] -> String -> m FilePath,- -- Get the configuration file (name, contents) for a filename+ -- | Get the configuration file (name, contents) for a filename siGetConfig :: String -> m (Maybe (FilePath, String)) }
src/ShellCheck/Parser.hs view
@@ -24,7 +24,7 @@ module ShellCheck.Parser (parseScript, runTests) where import ShellCheck.AST-import ShellCheck.ASTLib+import ShellCheck.ASTLib hiding (runTests) import ShellCheck.Data import ShellCheck.Interface @@ -87,11 +87,23 @@ unicodeDoubleQuotes = "\x201C\x201D\x2033\x2036" unicodeSingleQuotes = "\x2018\x2019" -prop_spacing = isOk spacing " \\\n # Comment"+prop_spacing1 = isOk spacing " \\\n # Comment"+prop_spacing2 = isOk spacing "# We can continue lines with \\"+prop_spacing3 = isWarning spacing " \\\n # --verbose=true \\" spacing = do- x <- many (many1 linewhitespace <|> try (string "\\\n" >> return ""))+ x <- many (many1 linewhitespace <|> continuation) optional readComment return $ concat x+ where+ continuation = do+ try (string "\\\n")+ -- The line was continued. Warn if this next line is a comment with a trailing \+ whitespace <- many linewhitespace+ optional $ do+ x <- readComment+ when ("\\" `isSuffixOf` x) $+ parseProblem ErrorC 1143 "This backslash is part of a comment and does not continue the line."+ return whitespace spacing1 = do spacing <- spacing@@ -123,8 +135,10 @@ return $ T_Literal id [c] carriageReturn = do- parseNote ErrorC 1017 "Literal carriage return. Run script through tr -d '\\r' ."+ pos <- getPosition char '\r'+ parseProblemAt pos ErrorC 1017 "Literal carriage return. Run script through tr -d '\\r' ."+ return '\r' almostSpace = choice [@@ -209,9 +223,14 @@ endSpan (IncompleteInterval start) = do endPos <- getPosition- id <- getNextIdBetween start endPos- return id+ getNextIdBetween start endPos +getSpanPositionsFor m = do+ start <- getPosition+ m+ end <- getPosition+ return (start, end)+ addToHereDocMap id list = do state <- getState let map = hereDocMap state@@ -253,16 +272,22 @@ shouldIgnoreCode code = do context <- getCurrentContexts checkSourced <- Mr.asks checkSourced- return $ any (disabling checkSourced) context+ return $ any (contextItemDisablesCode checkSourced code) context++-- Does this item on the context stack disable warnings for 'code'?+contextItemDisablesCode :: Bool -> Integer -> Context -> Bool+contextItemDisablesCode alsoCheckSourced code = disabling alsoCheckSourced where disabling checkSourced item = case item of ContextAnnotation list -> any disabling' list ContextSource _ -> not $ checkSourced _ -> False- disabling' (DisableComment n) = code == n+ disabling' (DisableComment n m) = code >= n && code < m disabling' _ = False ++ getCurrentAnnotations includeSource = concatMap get . takeWhile (not . isBoundary) <$> getCurrentContexts where@@ -373,16 +398,15 @@ parseNoteAtWithEnd start end c l a = addParseNote $ ParseNote start end c l a --------- Convenient combinators-thenSkip main follow = do- r <- main- optional follow- return r+thenSkip main follow = main <* optional follow unexpecting s p = try $ (try p >> fail ("Unexpected " ++ s)) <|> return () notFollowedBy2 = unexpecting "" +isFollowedBy p = (lookAhead . try $ p $> True) <|> return False+ reluctantlyTill p end = (lookAhead (void (try end) <|> eof) >> return []) <|> do x <- p@@ -420,7 +444,7 @@ parsecBracket before after op = do val <- before- (op val <* optional (after val)) <|> (after val *> fail "")+ op val `thenSkip` after val <|> (after val *> fail "") swapContext contexts p = parsecBracket (getCurrentContexts <* setCurrentContexts contexts)@@ -914,8 +938,9 @@ prop_readCondition21 = isOk readCondition "[[ $1 =~ ^(a\\ b)$ ]]" prop_readCondition22 = isOk readCondition "[[ $1 =~ \\.a\\.(\\.b\\.)\\.c\\. ]]" prop_readCondition23 = isOk readCondition "[[ -v arr[$var] ]]"-prop_readCondition24 = isWarning readCondition "[[ 1 == 2 ]]]" prop_readCondition25 = isOk readCondition "[[ lex.yy.c -ot program.l ]]"+prop_readCondition26 = isOk readScript "[[ foo ]]\\\n && bar"+prop_readCondition27 = not $ isOk readConditionCommand "[[ x ]] foo" readCondition = called "test expression" $ do opos <- getPosition start <- startSpan@@ -942,15 +967,9 @@ cpos <- getPosition close <- try (string "]]") <|> string "]" <|> fail "Expected test to end here (don't wrap commands in []/[[]])" id <- endSpan start- when (open == "[[" && close /= "]]") $ parseProblemAt cpos ErrorC 1033 "Did you mean ]] ?"- when (open == "[" && close /= "]" ) $ parseProblemAt opos ErrorC 1034 "Did you mean [[ ?"- optional $ lookAhead $ do- pos <- getPosition- notFollowedBy2 readCmdWord <|>- parseProblemAt pos ErrorC 1136- ("Unexpected characters after terminating " ++ close ++ ". Missing semicolon/linefeed?")+ when (open == "[[" && close /= "]]") $ parseProblemAt cpos ErrorC 1033 "Test expression was opened with double [[ but closed with single ]. Make sure they match."+ when (open == "[" && close /= "]" ) $ parseProblemAt opos ErrorC 1034 "Test expression was opened with single [ but closed with double ]]. Make sure they match." spacing- many readCmdWord -- Read and throw away remainders to get then/do warnings. Fixme? return $ T_Condition id typ condition readAnnotationPrefix = do@@ -964,6 +983,7 @@ prop_readAnnotation4 = isWarning readAnnotation "# shellcheck cats=dogs disable=SC1234\n" prop_readAnnotation5 = isOk readAnnotation "# shellcheck disable=SC2002 # All cats are precious\n" prop_readAnnotation6 = isOk readAnnotation "# shellcheck disable=SC1234 # shellcheck foo=bar\n"+prop_readAnnotation7 = isOk readAnnotation "# shellcheck disable=SC1000,SC2000-SC3000,SC1001\n" readAnnotation = called "shellcheck directive" $ do try readAnnotationPrefix many1 linewhitespace@@ -984,12 +1004,16 @@ key <- many1 (letter <|> char '-') char '=' <|> fail "Expected '=' after directive key" annotations <- case key of- "disable" -> readCode `sepBy` char ','+ "disable" -> readRange `sepBy` char ',' where+ readRange = do+ from <- readCode+ to <- choice [ char '-' *> readCode, return $ from+1 ]+ return $ DisableComment from to readCode = do optional $ string "SC" int <- many1 digit- return $ DisableComment (read int)+ return $ read int "enable" -> readName `sepBy` char ',' where@@ -1027,6 +1051,7 @@ unexpecting "shellcheck annotation" readAnnotationPrefix readAnyComment +prop_readAnyComment = isOk readAnyComment "# Comment" readAnyComment = do char '#' many $ noneOf "\r\n"@@ -1392,6 +1417,8 @@ do next <- quotable <|> oneOf "?*@!+[]{}.,~#" when (next == ' ') $ checkTrailingSpaces pos <|> return ()+ -- Check if this line is followed by a commented line with a trailing backslash+ when (next == '\n') $ try . lookAhead $ void spacing return $ if next == '\n' then "" else [next] <|> do@@ -1554,7 +1581,7 @@ readDollarExp = arithmetic <|> readDollarExpansion <|> readDollarBracket <|> readDollarBraceCommandExpansion <|> readDollarBraced <|> readDollarVariable where arithmetic = readAmbiguous "$((" readDollarArithmetic readDollarExpansion (\pos ->- parseNoteAt pos ErrorC 1102 "Shells disambiguate $(( differently or not at all. For $(command substition), add space after $( . For $((arithmetics)), fix parsing errors.")+ parseNoteAt pos ErrorC 1102 "Shells disambiguate $(( differently or not at all. For $(command substitution), add space after $( . For $((arithmetics)), fix parsing errors.") prop_readDollarSingleQuote = isOk readDollarSingleQuote "$'foo\\\'lol'" readDollarSingleQuote = called "$'..' expression" $ do@@ -1603,6 +1630,7 @@ c <- readArithmeticContents string "))" id <- endSpan start+ spacing return (T_Arithmetic id c) -- If the next characters match prefix, try two different parsers and warn if the alternate parser had to be used@@ -1749,6 +1777,8 @@ prop_readHereDoc18= isOk readScript "cat <<'\"foo'\nbar\n\"foo\n" prop_readHereDoc20= isWarning readScript "cat << foo\n foo\n()\nfoo\n" prop_readHereDoc21= isOk readScript "# shellcheck disable=SC1039\ncat << foo\n foo\n()\nfoo\n"+prop_readHereDoc22 = isWarning readScript "cat << foo\r\ncow\r\nfoo\r\n"+prop_readHereDoc23 = isNotOk readScript "cat << foo \r\ncow\r\nfoo\r\n" readHereDoc = called "here document" $ do pos <- getPosition try $ string "<<"@@ -1777,7 +1807,9 @@ -- Fun fact: bash considers << foo"" quoted, but not << <("foo"). readToken = do str <- readStringForParser readNormalWord- return $ unquote str+ -- A here doc actually works with \r\n because the \r becomes part of the token+ crstr <- (carriageReturn >> (return $ str ++ "\r")) <|> return str+ return $ unquote crstr readPendingHereDocs = do docs <- popPendingHereDocs@@ -1892,14 +1924,14 @@ debugHereDoc tokenId endToken doc | endToken `isInfixOf` doc = let lookAt line = when (endToken `isInfixOf` line) $- parseProblemAtId tokenId ErrorC 1042 ("Close matches include '" ++ line ++ "' (!= '" ++ endToken ++ "').")+ parseProblemAtId tokenId ErrorC 1042 ("Close matches include '" ++ (e4m line) ++ "' (!= '" ++ (e4m endToken) ++ "').") in do- parseProblemAtId tokenId ErrorC 1041 ("Found '" ++ endToken ++ "' further down, but not on a separate line.")+ parseProblemAtId tokenId ErrorC 1041 ("Found '" ++ (e4m endToken) ++ "' further down, but not on a separate line.") mapM_ lookAt (lines doc) | map toLower endToken `isInfixOf` map toLower doc =- parseProblemAtId tokenId ErrorC 1043 ("Found " ++ endToken ++ " further down, but with wrong casing.")+ parseProblemAtId tokenId ErrorC 1043 ("Found " ++ (e4m endToken) ++ " further down, but with wrong casing.") | otherwise =- parseProblemAtId tokenId ErrorC 1044 ("Couldn't find end token `" ++ endToken ++ "' in the here document.")+ parseProblemAtId tokenId ErrorC 1044 ("Couldn't find end token `" ++ (e4m endToken) ++ "' in the here document.") readFilename = readNormalWord@@ -1955,8 +1987,6 @@ spacing return $ T_FdRedirect id n redir -readRedirectList = many1 readIoRedirect- prop_readHereString = isOk readHereString "<<< \"Hello $world\"" readHereString = called "here string" $ do start <- startSpan@@ -2079,10 +2109,6 @@ then action else getParser def cmd rest - cStyleComment cmd =- case cmd of- _ -> False- validateCommand cmd = case cmd of (T_NormalWord _ [T_Literal _ "//"]) -> commentWarning (getId cmd)@@ -2119,14 +2145,14 @@ let file = getFile file' rest' override <- getSourceOverride let literalFile = do- name <- override `mplus` getLiteralString file+ name <- override `mplus` getLiteralString file `mplus` stripDynamicPrefix file -- Hack to avoid 'source ~/foo' trying to read from literal tilde guard . not $ "~/" `isPrefixOf` name return name case literalFile of Nothing -> do parseNoteAtId (getId file) WarningC 1090- "Can't follow non-constant source. Use a directive to specify location."+ "ShellCheck can't follow non-constant source. Use a directive to specify location." return t Just filename -> do proceed <- shouldFollow filename@@ -2179,6 +2205,16 @@ SourcePath x -> Just x _ -> Nothing + -- If the word has a single expansion as the directory, try stripping it+ -- This affects `$foo/bar` but not `${foo}-dir/bar` or `/foo/$file`+ stripDynamicPrefix word =+ case getWordParts word of+ exp : rest | isStringExpansion exp -> do+ str <- getLiteralString (T_NormalWord (Id 0) rest)+ guard $ "/" `isPrefixOf` str+ return $ "." ++ str+ _ -> Nothing+ subRead name script = withContext (ContextSource name) $ inSeparateContext $@@ -2276,6 +2312,7 @@ readCommand = choice [ readCompoundCommand,+ readConditionCommand, readCoProc, readSimpleCommand ]@@ -2388,6 +2425,7 @@ allspacing char ')' <|> fail "Expected ) closing the subshell" id <- endSpan start+ spacing return $ T_Subshell id list prop_readBraceGroup = isOk readBraceGroup "{ a; b | c | d; e; }"@@ -2408,6 +2446,7 @@ parseProblem ErrorC 1056 "Expected a '}'. If you have one, try a ; or \\n in front of it." fail "Missing '}'" id <- endSpan start+ spacing return $ T_BraceGroup id list prop_readBatsTest = isOk readBatsTest "@test 'can parse' {\n true\n}"@@ -2460,6 +2499,11 @@ parseProblemAtId (getId doKw) ErrorC 1061 "Couldn't find 'done' for this 'do'." parseProblem ErrorC 1062 "Expected 'done' matching previously mentioned 'do'." return "Expected 'done'"++ optional . lookAhead $ do+ pos <- getPosition+ try $ string "<("+ parseProblemAt pos ErrorC 1142 "Use 'done < <(cmd)' to redirect from process substitution (currently missing one '<')." return commands @@ -2482,19 +2526,31 @@ readArithmetic id <|> readRegular id where readArithmetic id = called "arithmetic for condition" $ do- try $ string "(("+ readArithmeticDelimiter '(' "Missing second '(' to start arithmetic for ((;;)) loop" x <- readArithmeticContents char ';' >> spacing y <- readArithmeticContents char ';' >> spacing z <- readArithmeticContents spacing- string "))"+ readArithmeticDelimiter ')' "Missing second ')' to terminate 'for ((;;))' loop condition" spacing optional $ readSequentialSep >> spacing group <- readBraced <|> readDoGroup id return $ T_ForArithmetic id x y z group + -- For c='(' read "((" and be lenient about spaces+ readArithmeticDelimiter c msg = do+ char c+ startPos <- getPosition+ sp <- spacing+ endPos <- getPosition+ char c <|> do+ parseProblemAt startPos ErrorC 1137 msg+ fail ""+ unless (null sp) $+ parseProblemAtWithEnd startPos endPos ErrorC 1138 $ "Remove spaces between " ++ [c,c] ++ " in arithmetic for loop."+ readBraced = do (T_BraceGroup _ list) <- readBraceGroup return list@@ -2625,7 +2681,7 @@ readWithoutFunction = try $ do name <- many1 functionChars- guard $ name /= "time" -- Interfers with time ( foo )+ guard $ name /= "time" -- Interferes with time ( foo ) spacing readParens return $ \id -> T_Function id (FunctionKeyword False) (FunctionParentheses True) name@@ -2665,9 +2721,38 @@ id <- endSpan start return $ T_CoProcBody id body - readPattern = (readPatternWord `thenSkip` spacing) `sepBy1` (char '|' `thenSkip` spacing) +prop_readConditionCommand = isOk readConditionCommand "[[ x ]] > foo 2>&1"+readConditionCommand = do+ cmd <- readCondition+ redirs <- many readIoRedirect+ id <- getNextIdSpanningTokenList (cmd:redirs)++ pos <- getPosition+ hasDashAo <- isFollowedBy $ do+ c <- choice $ try . string <$> ["-o", "-a", "or", "and"]+ posEnd <- getPosition+ parseProblemAtWithEnd pos posEnd ErrorC 1139 $+ "Use " ++ alt c ++ " instead of '" ++ c ++ "' between test commands."++ -- If the next word is a keyword, readNormalWord will trigger a warning+ hasKeyword <- isFollowedBy readKeyword+ hasWord <- isFollowedBy readNormalWord++ when (hasWord && not (hasKeyword || hasDashAo)) $ do+ -- We have other words following, and no error has been emitted.+ posEnd <- getPosition+ parseProblemAtWithEnd pos posEnd ErrorC 1140 "Unexpected parameters after condition. Missing &&/||, or bad expression?"++ return $ T_Redirecting id redirs cmd+ where+ alt "or" = "||"+ alt "-o" = "||"+ alt "and" = "&&"+ alt "-a" = "&&"+ alt _ = "|| or &&"+ prop_readCompoundCommand = isOk readCompoundCommand "{ echo foo; }>/dev/null" readCompoundCommand = do cmd <- choice [@@ -2675,7 +2760,6 @@ readAmbiguous "((" readArithmeticExpression readSubshell (\pos -> parseNoteAt pos ErrorC 1105 "Shells disambiguate (( differently or not at all. For subshell, add spaces around ( . For ((, fix parsing errors."), readSubshell,- readCondition, readWhileClause, readUntilClause, readIfClause,@@ -2685,15 +2769,15 @@ readBatsTest, readFunctionDefinition ]- spacing redirs <- many readIoRedirect id <- getNextIdSpanningTokenList (cmd:redirs)- unless (null redirs) $ optional $ do- lookAhead $ try (spacing >> needsSeparator)- parseProblem WarningC 1013 "Bash requires ; or \\n here, after redirecting nested compound commands."+ optional . lookAhead $ do+ notFollowedBy2 $ choice [readKeyword, g_Lbrace]+ pos <- getPosition+ many1 readNormalWord+ posEnd <- getPosition+ parseProblemAtWithEnd pos posEnd ErrorC 1141 "Unexpected tokens after compound command. Bad redirection or missing ;/&&/||/|?" return $ T_Redirecting id redirs cmd- where- needsSeparator = choice [ g_Then, g_Else, g_Elif, g_Fi, g_Do, g_Done, g_Esac, g_Rbrace ] readCompoundList = readTerm@@ -2763,17 +2847,13 @@ prop_readAssignmentWord = isOk readAssignmentWord "a=42" prop_readAssignmentWord2 = isOk readAssignmentWord "b=(1 2 3)"-prop_readAssignmentWord3 = isWarning readAssignmentWord "$b = 13"-prop_readAssignmentWord4 = isWarning readAssignmentWord "b = $(lol)" prop_readAssignmentWord5 = isOk readAssignmentWord "b+=lol"-prop_readAssignmentWord6 = isWarning readAssignmentWord "b += (1 2 3)" prop_readAssignmentWord7 = isOk readAssignmentWord "a[3$n'']=42" prop_readAssignmentWord8 = isOk readAssignmentWord "a[4''$(cat foo)]=42" prop_readAssignmentWord9 = isOk readAssignmentWord "IFS= " prop_readAssignmentWord9a= isOk readAssignmentWord "foo=" prop_readAssignmentWord9b= isOk readAssignmentWord "foo= " prop_readAssignmentWord9c= isOk readAssignmentWord "foo= #bar"-prop_readAssignmentWord10= isWarning readAssignmentWord "foo$n=42" prop_readAssignmentWord11= isOk readAssignmentWord "foo=([a]=b [c] [d]= [e f )" prop_readAssignmentWord12= isOk readAssignmentWord "a[b <<= 3 + c]='thing'" prop_readAssignmentWord13= isOk readAssignmentWord "var=( (1 2) (3 4) )"@@ -2781,52 +2861,63 @@ prop_readAssignmentWord15= isOk readAssignmentWord "var=(1 [2]=(3 4))" readAssignmentWord = readAssignmentWordExt True readWellFormedAssignment = readAssignmentWordExt False-readAssignmentWordExt lenient = try $ do- start <- startSpan- pos <- getPosition- when lenient $- optional (char '$' >> parseNote ErrorC 1066 "Don't use $ on the left side of assignments.")- variable <- readVariableName- when lenient $- optional (readNormalDollar >> parseNoteAt pos ErrorC- 1067 "For indirection, use arrays, declare \"var$n=value\", or (for sh) read/eval.")- indices <- many readArrayIndex- hasLeftSpace <- fmap (not . null) spacing- pos <- getPosition- id <- endSpan start- op <- readAssignmentOp+readAssignmentWordExt lenient = called "variable assignment" $ do+ -- Parse up to and including the = in a 'try'+ (id, variable, op, indices) <- try $ do+ start <- startSpan+ pos <- getPosition+ -- Check for a leading $ at parse time, to warn for $foo=(bar) which+ -- would otherwise cause a parse failure so it can't be checked later.+ leadingDollarPos <-+ if lenient+ then optionMaybe $ getSpanPositionsFor (char '$')+ else return Nothing+ variable <- readVariableName+ indices <- many readArrayIndex+ hasLeftSpace <- fmap (not . null) spacing+ opStart <- getPosition+ id <- endSpan start+ op <- readAssignmentOp+ opEnd <- getPosition++ when (isJust leadingDollarPos || hasLeftSpace) $ do+ hasParen <- isFollowedBy (spacing >> char '(')+ when hasParen $+ sequence_ $ do+ (l, r) <- leadingDollarPos+ return $ parseProblemAtWithEnd l r ErrorC 1066 "Don't use $ on the left side of assignments."++ -- Fail so that this is not parsed as an assignment.+ fail ""+ -- At this point we know for sure.+ return (id, variable, op, indices)++ rightPosStart <- getPosition hasRightSpace <- fmap (not . null) spacing+ rightPosEnd <- getPosition isEndOfCommand <- fmap isJust $ optionMaybe (try . lookAhead $ (void (oneOf "\r\n;&|)") <|> eof))- if not hasLeftSpace && (hasRightSpace || isEndOfCommand)++ if hasRightSpace || isEndOfCommand then do- when (variable /= "IFS" && hasRightSpace && not isEndOfCommand) $- parseNoteAt pos WarningC 1007+ when (variable /= "IFS" && hasRightSpace && not isEndOfCommand) $ do+ parseProblemAtWithEnd rightPosStart rightPosEnd WarningC 1007 "Remove space after = if trying to assign a value (for empty string, use var='' ... )." value <- readEmptyLiteral return $ T_Assignment id op variable indices value else do- when (hasLeftSpace || hasRightSpace) $- parseNoteAt pos ErrorC 1068 $- "Don't put spaces around the "- ++ (if op == Append- then "+= when appending"- else "= in assignments")- ++ " (or quote to make it literal)."+ optional $ do+ lookAhead $ char '='+ parseProblem ErrorC 1097 "Unexpected ==. For assignment, use =. For comparison, use [/[[. Or quote for literal string."+ value <- readArray <|> readNormalWord spacing return $ T_Assignment id op variable indices value where readAssignmentOp = do- pos <- getPosition- unexpecting "" $ string "==="+ -- This is probably some kind of ascii art border+ unexpecting "===" (string "===") choice [ string "+=" >> return Append,- do- try (string "==")- parseProblemAt pos ErrorC 1097- "Unexpected ==. For assignment, use =. For comparison, use [/[[."- return Assign,- string "=" >> return Assign ] @@ -3093,7 +3184,7 @@ let line = "line " ++ (show . sourceLine $ errorPos err) suggestion = getStringFromParsec $ errorMessages err in- "Failed to process " ++ filename ++ ", " ++ line ++ ": "+ "Failed to process " ++ (e4m filename) ++ ", " ++ line ++ ": " ++ suggestion prop_readConfigKVs1 = isOk readConfigKVs "disable=1234"@@ -3114,6 +3205,7 @@ prop_readScript3 = isWarning readScript "#!/bin/bash\necho hello\xA0world" prop_readScript4 = isWarning readScript "#!/usr/bin/perl\nfoo=(" prop_readScript5 = isOk readScript "#!/bin/bash\n#This is an empty script\n\n"+prop_readScript6 = isOk readScript "#!/usr/bin/env -S X=FOO bash\n#This is an empty script\n\n" readScriptFile sourced = do start <- startSpan pos <- getPosition@@ -3139,8 +3231,8 @@ let ignoreShebang = shellAnnotationSpecified || shellFlagSpecified unless ignoreShebang $- verifyShebang pos (getShell shebangString)- if ignoreShebang || isValidShell (getShell shebangString) /= Just False+ verifyShebang pos (executableFromShebang shebangString)+ if ignoreShebang || isValidShell (executableFromShebang shebangString) /= Just False then do commands <- withAnnotations annotations readCompoundListOrEmpty id <- endSpan start@@ -3154,16 +3246,6 @@ return $ T_Script id shebang [] where- basename s = reverse . takeWhile (/= '/') . reverse $ s- getShell sb =- case words sb of- [] -> ""- [x] -> basename x- (first:second:_) ->- if basename first == "env"- then second- else basename first- verifyShebang pos s = do case isValidShell s of Just True -> return ()@@ -3316,16 +3398,21 @@ prRoot = Just $ reattachHereDocs script (hereDocMap userstate) }- Left err ->+ Left err -> do+ let context = contextStack state return newParseResult { prComments = map toPositionedComment $- notesForContext (contextStack state)- ++ [makeErrorFor err]+ (filter (not . isIgnored context) $+ notesForContext context+ ++ [makeErrorFor err]) ++ parseProblems state, prTokenPositions = Map.empty, prRoot = Nothing }+ where+ -- A final pass for ignoring parse errors after failed parsing+ isIgnored stack note = any (contextItemDisablesCode False (codeForParseNote note)) stack notesForContext list = zipWith ($) [first, second] $ filter isName list where
test/shellcheck.hs view
@@ -4,6 +4,7 @@ import System.Exit import qualified ShellCheck.Analytics import qualified ShellCheck.AnalyzerLib+import qualified ShellCheck.ASTLib import qualified ShellCheck.Checker import qualified ShellCheck.Checks.Commands import qualified ShellCheck.Checks.Custom@@ -17,6 +18,7 @@ results <- sequence [ ShellCheck.Analytics.runTests ,ShellCheck.AnalyzerLib.runTests+ ,ShellCheck.ASTLib.runTests ,ShellCheck.Checker.runTests ,ShellCheck.Checks.Commands.runTests ,ShellCheck.Checks.Custom.runTests