diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6f54c..586ede6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Added - Preliminary support for fix suggestions - Files containing Bats tests can now be checked +- Directory wide directives can now be placed in a `.shellcheckrc` - SC2246: Warn if a shebang's interpreter ends with / - SC2245: Warn that Ksh ignores all but the first glob result in `[` - SC2243/SC2244: Suggest using explicit -n for `[ $foo ]` diff --git a/ShellCheck.cabal b/ShellCheck.cabal index ff612ed..8e3ddb5 100644 --- a/ShellCheck.cabal +++ b/ShellCheck.cabal @@ -96,14 +96,15 @@ executable shellcheck array, base >= 4 && < 5, bytestring, - deepseq >= 1.4.0.0, - ShellCheck, containers, + deepseq >= 1.4.0.0, directory, mtl >= 2.2.1, + filepath, parsec >= 3.0, QuickCheck >= 2.7.4, - regex-tdfa + regex-tdfa, + ShellCheck main-is: shellcheck.hs test-suite test-shellcheck @@ -113,13 +114,14 @@ test-suite test-shellcheck array, base >= 4 && < 5, bytestring, - deepseq >= 1.4.0.0, - ShellCheck, containers, + deepseq >= 1.4.0.0, directory, mtl >= 2.2.1, + filepath, parsec, QuickCheck >= 2.7.4, - regex-tdfa + regex-tdfa, + ShellCheck main-is: test/shellcheck.hs diff --git a/shellcheck.1.md b/shellcheck.1.md index 36c341a..739628a 100644 --- a/shellcheck.1.md +++ b/shellcheck.1.md @@ -63,6 +63,10 @@ not warn at all, as `ksh` supports decimals in arithmetic contexts. standard output. Subsequent **-f** options are ignored, see **FORMATS** below for more information. +**--norc** + +: Don't try to look for .shellcheckrc configuration files. + **-S**\ *SEVERITY*,\ **--severity=***severity* : Specify minimum severity of errors to consider. Valid values are *error*, @@ -192,6 +196,31 @@ Valid keys are: files meant to be included (and thus lacking a shebang), or possibly as a more targeted alternative to 'disable=2039'. +# RC FILES +Unless `--norc` is used, ShellCheck will look for a file `.shellcheckrc` or +`shellcheckrc` in the script's directory and each parent directory. If found, +it will read `key=value` pairs from it and treat them as file-wide directives. + +Here is an example `.shellcheckrc`: + + # Don't suggest using -n in [ $var ] + disable=SC2244 + + # Allow using `which` since it gives full paths and is common enough + disable=SC2230 + +If no `.shellcheckrc` is found in any of the parent directories, ShellCheck +will look in `~/.shellcheckrc` followed by the XDG config directory +(usually `~/.config/shellcheckrc`) on Unix, or %APPDATA%/shellcheckrc` on +Windows. Only the first file found will be used. + +Note for Snap users: the Snap sandbox disallows access to hidden files. +Use `shellcheckrc` without the dot instead. + +Note for Docker users: ShellCheck will only be able to look for files that +are mounted in the container, so `~/.shellcheckrc` will not be read. + + # ENVIRONMENT VARIABLES The environment variable `SHELLCHECK_OPTS` can be set with default flags: diff --git a/shellcheck.hs b/shellcheck.hs index a93da2a..8887a85 100644 --- a/shellcheck.hs +++ b/shellcheck.hs @@ -47,6 +47,7 @@ import System.Console.GetOpt import System.Directory import System.Environment import System.Exit +import System.FilePath import System.IO data Flag = Flag String String @@ -95,6 +96,8 @@ options = [ Option "f" ["format"] (ReqArg (Flag "format") "FORMAT") $ "Output format (" ++ formatList ++ ")", + Option "" ["norc"] + (NoArg $ Flag "norc" "true") "Don't look for .shellcheckrc files", Option "s" ["shell"] (ReqArg (Flag "shell") "SHELLNAME") "Specify dialect (sh, bash, dash, ksh)", @@ -330,7 +333,16 @@ parseOption flag options = } } - _ -> return options + Flag "norc" _ -> + return options { + checkSpec = (checkSpec options) { + csIgnoreRC = True + } + } + + Flag str _ -> do + printErr $ "Internal error for --" ++ str ++ ". Please file a bug :(" + return options where die s = do printErr s @@ -345,12 +357,15 @@ parseOption flag options = ioInterface options files = do inputs <- mapM normalize files cache <- newIORef emptyCache + configCache <- newIORef ("", Nothing) return SystemInterface { - siReadFile = get cache inputs + siReadFile = get cache inputs, + siGetConfig = getConfig configCache } where emptyCache :: Map.Map FilePath String emptyCache = Map.empty + get cache inputs file = do map <- readIORef cache case Map.lookup file map of @@ -367,7 +382,6 @@ ioInterface options files = do return $ Right contents ) `catch` handler else return $ Left (file ++ " was not specified as input (see shellcheck -x).") - where handler :: IOException -> IO (Either ErrorMessage String) handler ex = return . Left $ show ex @@ -385,6 +399,58 @@ ioInterface options files = do fallback :: FilePath -> IOException -> IO FilePath fallback path _ = return path + -- Returns the name and contents of .shellcheckrc for the given file + getConfig cache filename = do + path <- normalize filename + let dir = takeDirectory path + (previousPath, result) <- readIORef cache + if dir == previousPath + then return result + else do + paths <- getConfigPaths dir + result <- findConfig paths + writeIORef cache (dir, result) + return result + + findConfig paths = + case paths of + (file:rest) -> do + contents <- readConfig file + if isJust contents + then return contents + else findConfig rest + [] -> return Nothing + + -- Get a list of candidate filenames. This includes .shellcheckrc + -- in all parent directories, plus the user's home dir and xdg dir. + -- The dot is optional for Windows and Snap users. + getConfigPaths dir = do + let next = takeDirectory dir + rest <- if next /= dir + then getConfigPaths next + else defaultPaths `catch` + ((const $ return []) :: IOException -> IO [FilePath]) + return $ (dir </> ".shellcheckrc") : (dir </> "shellcheckrc") : rest + + defaultPaths = do + home <- getAppUserDataDirectory "shellcheckrc" + xdg <- getXdgDirectory XdgConfig "shellcheckrc" + return [home, xdg] + + readConfig file = do + exists <- doesPathExist file + if exists + then do + (contents, _) <- inputFile file `catch` handler file + return $ Just (file, contents) + else + return Nothing + where + handler :: FilePath -> IOException -> IO (String, Bool) + handler file err = do + putStrLn $ file ++ ": " ++ show err + return ("", True) + inputFile file = do (handle, shouldCache) <- if file == "-" diff --git a/src/ShellCheck/Checker.hs b/src/ShellCheck/Checker.hs index f9c1ede..8b3eb76 100644 --- a/src/ShellCheck/Checker.hs +++ b/src/ShellCheck/Checker.hs @@ -72,6 +72,7 @@ checkScript sys spec = do psFilename = csFilename spec, psScript = contents, psCheckSourced = csCheckSourced spec, + psIgnoreRC = csIgnoreRC spec, psShellTypeOverride = csShellTypeOverride spec } let parseMessages = prComments result @@ -146,6 +147,9 @@ checkOptionIncludes includes src = csCheckSourced = True } +checkWithRc rc = getErrors + (mockRcFile rc $ mockedSystemInterface []) + prop_findsParseIssue = check "echo \"$12\"" == [1037] prop_commentDisablesParseIssue1 = @@ -299,5 +303,34 @@ prop_optionIncludes4 = -- expect 2086 & 2154, only 2154 included, so only that's reported [2154] == checkOptionIncludes (Just [2154]) "#!/bin/sh\n var='a b'\n echo $var\n echo $bar" + +prop_readsRcFile = result == [] + where + result = checkWithRc "disable=2086" emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = False + } + +prop_canUseNoRC = result == [2086] + where + result = checkWithRc "disable=2086" emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = True + } + +prop_NoRCWontLookAtFile = result == [2086] + where + result = checkWithRc (error "Fail") emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = True + } + +prop_brokenRcGetsWarning = result == [1134, 2086] + where + result = checkWithRc "rofl" emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = False + } + return [] runTests = $quickCheckAll diff --git a/src/ShellCheck/Interface.hs b/src/ShellCheck/Interface.hs index b492b3f..3a6d685 100644 --- a/src/ShellCheck/Interface.hs +++ b/src/ShellCheck/Interface.hs @@ -21,9 +21,9 @@ module ShellCheck.Interface ( SystemInterface(..) - , CheckSpec(csFilename, csScript, csCheckSourced, csIncludedWarnings, csExcludedWarnings, csShellTypeOverride, csMinSeverity) + , CheckSpec(csFilename, csScript, csCheckSourced, csIncludedWarnings, csExcludedWarnings, csShellTypeOverride, csMinSeverity, csIgnoreRC) , CheckResult(crFilename, crComments) - , ParseSpec(psFilename, psScript, psCheckSourced, psShellTypeOverride) + , ParseSpec(psFilename, psScript, psCheckSourced, psIgnoreRC, psShellTypeOverride) , ParseResult(prComments, prTokenPositions, prRoot) , AnalysisSpec(asScript, asShellType, asFallbackShell, asExecutionMode, asCheckSourced, asTokenPositions) , AnalysisResult(arComments) @@ -46,6 +46,7 @@ module ShellCheck.Interface , newPosition , newTokenComment , mockedSystemInterface + , mockRcFile , newParseSpec , emptyCheckSpec , newPositionedComment @@ -69,9 +70,11 @@ import GHC.Generics (Generic) import qualified Data.Map as Map -newtype SystemInterface m = SystemInterface { +data SystemInterface m = SystemInterface { -- Read a file by filename, or return an error - siReadFile :: String -> m (Either ErrorMessage String) + siReadFile :: String -> m (Either ErrorMessage String), + -- Get the configuration file (name, contents) for a filename + siGetConfig :: String -> m (Maybe (FilePath, String)) } -- ShellCheck input and output @@ -79,6 +82,7 @@ data CheckSpec = CheckSpec { csFilename :: String, csScript :: String, csCheckSourced :: Bool, + csIgnoreRC :: Bool, csExcludedWarnings :: [Integer], csIncludedWarnings :: Maybe [Integer], csShellTypeOverride :: Maybe Shell, @@ -101,6 +105,7 @@ emptyCheckSpec = CheckSpec { csFilename = "", csScript = "", csCheckSourced = False, + csIgnoreRC = False, csExcludedWarnings = [], csIncludedWarnings = Nothing, csShellTypeOverride = Nothing, @@ -112,6 +117,7 @@ newParseSpec = ParseSpec { psFilename = "", psScript = "", psCheckSourced = False, + psIgnoreRC = False, psShellTypeOverride = Nothing } @@ -120,6 +126,7 @@ data ParseSpec = ParseSpec { psFilename :: String, psScript :: String, psCheckSourced :: Bool, + psIgnoreRC :: Bool, psShellTypeOverride :: Maybe Shell } deriving (Show, Eq) @@ -279,7 +286,8 @@ data ColorOption = -- For testing mockedSystemInterface :: [(String, String)] -> SystemInterface Identity mockedSystemInterface files = SystemInterface { - siReadFile = rf + siReadFile = rf, + siGetConfig = const $ return Nothing } where rf file = @@ -287,3 +295,7 @@ mockedSystemInterface files = SystemInterface { [] -> return $ Left "File not included in mock." [(_, contents)] -> return $ Right contents +mockRcFile rcfile mock = mock { + siGetConfig = const . return $ Just (".shellcheckrc", rcfile) +} + diff --git a/src/ShellCheck/Parser.hs b/src/ShellCheck/Parser.hs index 47ea8c9..0289e0d 100644 --- a/src/ShellCheck/Parser.hs +++ b/src/ShellCheck/Parser.hs @@ -113,6 +113,7 @@ allspacing = do allspacingOrFail = do s <- allspacing when (null s) $ fail "Expected whitespace" + return s readUnicodeQuote = do start <- startSpan @@ -306,6 +307,8 @@ initialSystemState = SystemState { data Environment m = Environment { systemInterface :: SystemInterface m, checkSourced :: Bool, + ignoreRC :: Bool, + currentFilename :: String, shellTypeOverride :: Maybe Shell } @@ -949,9 +952,12 @@ prop_readAnnotation6 = isOk readAnnotation "# shellcheck disable=SC1234 # shellc readAnnotation = called "shellcheck directive" $ do try readAnnotationPrefix many1 linewhitespace + readAnnotationWithoutPrefix + +readAnnotationWithoutPrefix = do values <- many1 readKey optional readAnyComment - void linefeed <|> do + void linefeed <|> eof <|> do parseNote ErrorC 1125 "Invalid key=value pair? Ignoring the rest of this directive starting here." many (noneOf "\n") void linefeed <|> eof @@ -2104,7 +2110,7 @@ readSource t@(T_Redirecting _ _ (T_SimpleCommand cmdId _ (cmd:file:_))) = do subRead name script = withContext (ContextSource name) $ inSeparateContext $ - subParse (initialPos name) readScript script + subParse (initialPos name) (readScriptFile True) script readSource t = return t @@ -2980,12 +2986,55 @@ verifyEof = eof <|> choice [ try (lookAhead p) action -prop_readScript1 = isOk readScriptFile "#!/bin/bash\necho hello world\n" -prop_readScript2 = isWarning readScriptFile "#!/bin/bash\r\necho hello world\n" -prop_readScript3 = isWarning readScriptFile "#!/bin/bash\necho hello\xA0world" -prop_readScript4 = isWarning readScriptFile "#!/usr/bin/perl\nfoo=(" -prop_readScript5 = isOk readScriptFile "#!/bin/bash\n#This is an empty script\n\n" -readScriptFile = do + +readConfigFile :: Monad m => FilePath -> SCParser m [Annotation] +readConfigFile filename = do + shouldIgnore <- Mr.asks ignoreRC + if shouldIgnore then return [] else read' filename + where + read' filename = do + sys <- Mr.asks systemInterface + contents <- system $ siGetConfig sys filename + case contents of + Nothing -> return [] + Just (file, str) -> readConfig file str + + readConfig filename contents = do + result <- lift $ runParserT readConfigKVs initialUserState filename contents + case result of + Right result -> + return result + + Left err -> do + parseProblem ErrorC 1134 $ errorFor filename err + return [] + + errorFor filename err = + let line = "line " ++ (show . sourceLine $ errorPos err) + suggestion = getStringFromParsec $ errorMessages err + in + "Failed to process " ++ filename ++ ", " ++ line ++ ": " + ++ suggestion + +prop_readConfigKVs1 = isOk readConfigKVs "disable=1234" +prop_readConfigKVs2 = isOk readConfigKVs "# Comment\ndisable=1234 # Comment\n" +prop_readConfigKVs3 = isOk readConfigKVs "" +prop_readConfigKVs4 = isOk readConfigKVs "\n\n\n\n\t \n" +prop_readConfigKVs5 = isOk readConfigKVs "# shellcheck accepts annotation-like comments in rc files\ndisable=1234" +readConfigKVs = do + anySpacingOrComment + annotations <- many (readAnnotationWithoutPrefix <* anySpacingOrComment) + eof + return $ concat annotations +anySpacingOrComment = + many (void allspacingOrFail <|> void readAnyComment) + +prop_readScript1 = isOk readScript "#!/bin/bash\necho hello world\n" +prop_readScript2 = isWarning readScript "#!/bin/bash\r\necho hello world\n" +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" +readScriptFile sourced = do start <- startSpan pos <- getPosition optional $ do @@ -2995,7 +3044,13 @@ readScriptFile = do sb <- option "" readShebang allspacing annotationStart <- startSpan - annotations <- readAnnotations + fileAnnotations <- readAnnotations + rcAnnotations <- if sourced + then return [] + else do + filename <- Mr.asks currentFilename + readConfigFile filename + let annotations = fileAnnotations ++ rcAnnotations annotationId <- endSpan annotationStart let shellAnnotationSpecified = any (\x -> case x of ShellOverride {} -> True; _ -> False) annotations @@ -3065,7 +3120,7 @@ readScriptFile = do readUtf8Bom = called "Byte Order Mark" $ string "\xFEFF" -readScript = readScriptFile +readScript = readScriptFile False -- Interactively run a specific parser in ghci: -- debugParse readSimpleCommand "echo 'hello world'" @@ -3100,6 +3155,8 @@ testEnvironment = Environment { systemInterface = (mockedSystemInterface []), checkSourced = False, + currentFilename = "myscript", + ignoreRC = False, shellTypeOverride = Nothing } @@ -3275,6 +3332,8 @@ parseScript sys spec = env = Environment { systemInterface = sys, checkSourced = psCheckSourced spec, + currentFilename = psFilename spec, + ignoreRC = psIgnoreRC spec, shellTypeOverride = psShellTypeOverride spec }