diff --git a/ShellCheck/AST.hs b/ShellCheck/AST.hs index edab3733720da8a78c2faec999a8c9b0f79ebf01..35bbf43a170ef6468cbc22a89c3213227a6f44e7 100644 --- a/ShellCheck/AST.hs +++ b/ShellCheck/AST.hs @@ -125,6 +125,7 @@ data Token = | T_Pipe Id String | T_CoProc Id (Maybe String) Token | T_CoProcBody Id Token + | T_Include Id Token Token -- . & source: SimpleCommand T_Script deriving (Show) data Annotation = DisableComment Integer deriving (Show, Eq) @@ -255,6 +256,7 @@ analyze f g i = delve (T_Annotation id anns t) = d1 t $ T_Annotation id anns delve (T_CoProc id var body) = d1 body $ T_CoProc id var delve (T_CoProcBody id t) = d1 t $ T_CoProcBody id + delve (T_Include id includer script) = d2 includer script $ T_Include id delve t = return t getId t = case t of @@ -350,6 +352,7 @@ getId t = case t of T_Pipe id _ -> id T_CoProc id _ _ -> id T_CoProcBody id _ -> id + T_Include id _ _ -> id blank :: Monad m => Token -> m () blank = const $ return () diff --git a/ShellCheck/Analytics.hs b/ShellCheck/Analytics.hs index db684987298a73a13c541857dfdcc28a9142fdaa..f45e55d79f30195ba9e3932d472cfe058452bac7 100644 --- a/ShellCheck/Analytics.hs +++ b/ShellCheck/Analytics.hs @@ -224,6 +224,7 @@ filterByAnnotation token = any hasNum anns where hasNum (DisableComment ts) = num == ts + shouldIgnoreFor _ (T_Include {}) = True -- Ignore included files shouldIgnoreFor _ _ = False parents = getParentTree token diff --git a/ShellCheck/Checker.hs b/ShellCheck/Checker.hs index 91e61d8af1f418d3f7e6d07eef81b6880ed88241..a000a1346e94f9b015e6eec600f645987e7018d9 100644 --- a/ShellCheck/Checker.hs +++ b/ShellCheck/Checker.hs @@ -81,7 +81,7 @@ checkScript sys spec = do } getErrors sys spec = - map getCode . crComments $ + sort . map getCode . crComments $ runIdentity (checkScript sys spec) where getCode (PositionedComment _ (Comment _ code _)) = code @@ -124,5 +124,45 @@ prop_optionDisablesIssue2 = csExcludedWarnings = [2148, 1037] } +prop_failsWhenNotSourcing = + [1091, 2154] == getErrors + (mockedSystemInterface []) + emptyCheckSpec { + csScript = "source lob; echo \"$bar\"", + csExcludedWarnings = [2148] + } + +prop_worksWhenSourcing = + null $ getErrors + (mockedSystemInterface [("lib", "bar=1")]) + emptyCheckSpec { + csScript = "source lib; echo \"$bar\"", + csExcludedWarnings = [2148] + } + +prop_worksWhenDotting = + null $ getErrors + (mockedSystemInterface [("lib", "bar=1")]) + emptyCheckSpec { + csScript = ". lib; echo \"$bar\"", + csExcludedWarnings = [2148] + } + +prop_noInfiniteSourcing = + [] == getErrors + (mockedSystemInterface [("lib", "source lib")]) + emptyCheckSpec { + csScript = "source lib", + csExcludedWarnings = [2148] + } + +prop_canSourceBadSyntax = + [1094, 2086] == getErrors + (mockedSystemInterface [("lib", "for f; do")]) + emptyCheckSpec { + csScript = "source lib; echo $1", + csExcludedWarnings = [2148] + } + return [] runTests = $quickCheckAll diff --git a/ShellCheck/Parser.hs b/ShellCheck/Parser.hs index d9557737456065eb2c27576259e4385ceab204e1..49232f1ea0a180f21d0fd5fee912e5c1737afab0 100644 --- a/ShellCheck/Parser.hs +++ b/ShellCheck/Parser.hs @@ -21,23 +21,28 @@ module ShellCheck.Parser (parseScript, runTests) where import ShellCheck.AST +import ShellCheck.ASTLib import ShellCheck.Data import ShellCheck.Interface -import Text.Parsec hiding (runParser) -import Debug.Trace + import Control.Monad import Control.Monad.Identity +import Control.Monad.Trans import Data.Char import Data.Functor import Data.List (isPrefixOf, isInfixOf, isSuffixOf, partition, sortBy, intercalate, nub) -import qualified Data.Map as Map -import qualified Control.Monad.State as Ms -import qualified Control.Monad.Reader as Mr import Data.Maybe +import Debug.Trace +import GHC.Exts (sortWith) import Prelude hiding (readList) import System.IO +import Text.Parsec hiding (runParser) import Text.Parsec.Error -import GHC.Exts (sortWith) +import Text.Parsec.Pos +import qualified Control.Monad.Reader as Mr +import qualified Control.Monad.State as Ms +import qualified Data.Map as Map + import Test.QuickCheck.All (quickCheckAll) type SCBase m = Mr.ReaderT (SystemInterface m) (Ms.StateT SystemState m) @@ -125,7 +130,11 @@ almostSpace = --------- Message/position annotation on top of user state data Note = Note Id Severity Code String deriving (Show, Eq) data ParseNote = ParseNote SourcePos Severity Code String deriving (Show, Eq) -data Context = ContextName SourcePos String | ContextAnnotation [Annotation] deriving (Show) +data Context = + ContextName SourcePos String + | ContextAnnotation [Annotation] + | ContextSource String + deriving (Show) data UserState = UserState { lastId :: Id, @@ -179,9 +188,27 @@ shouldIgnoreCode code = do where disabling (ContextAnnotation list) = any disabling' list + disabling (ContextSource _) = True -- Don't add messages for sourced files disabling _ = False disabling' (DisableComment n) = code == n +shouldFollow file = do + context <- getCurrentContexts + if any isThisFile context + then return False + else + if length (filter isSource context) >= 100 + then do + parseProblem ErrorC 1092 "Stopping at 100 'source' frames :O" + return False + else + return True + where + isSource (ContextSource _) = True + isSource _ = False + isThisFile (ContextSource name) | name == file = True + isThisFile _= False + -- Store potential parse problems outside of parsec data SystemState = SystemState { @@ -900,6 +927,18 @@ subParse pos parser input = do setPosition lastPosition return result +inSeparateContext parser = do + context <- Ms.get + success context <|> failure context + where + success c = do + res <- try parser + Ms.put c + return res + failure c = do + Ms.put c + fail "" + prop_readDoubleQuoted = isOk readDoubleQuoted "\"Hello $FOO\"" prop_readDoubleQuoted2 = isOk readDoubleQuoted "\"$'\"" prop_readDoubleQuoted3 = isWarning readDoubleQuoted "\x201Chello\x201D" @@ -1403,7 +1442,6 @@ makeSimpleCommand id1 id2 prefix cmd suffix = redirection (T_FdRedirect {}) = True redirection _ = False - prop_readSimpleCommand = isOk readSimpleCommand "echo test > file" prop_readSimpleCommand2 = isOk readSimpleCommand "cmd &> file" prop_readSimpleCommand3 = isOk readSimpleCommand "export foo=(bar baz)" @@ -1411,6 +1449,7 @@ prop_readSimpleCommand4 = isOk readSimpleCommand "typeset -a foo=(lol)" prop_readSimpleCommand5 = isOk readSimpleCommand "time if true; then echo foo; fi" prop_readSimpleCommand6 = isOk readSimpleCommand "time -p ( ls -l; )" readSimpleCommand = called "simple command" $ do + pos <- getPosition id1 <- getNextId id2 <- getNextId prefix <- option [] readCmdPrefix @@ -1424,7 +1463,11 @@ readSimpleCommand = called "simple command" $ do (["time"], readTimeSuffix), (["let"], readLetSuffix) ] - return $ makeSimpleCommand id1 id2 prefix [cmd] suffix + + let result = makeSimpleCommand id1 id2 prefix [cmd] suffix + if isCommand ["source", "."] cmd + then readSource pos result + else return result where isCommand strings (T_NormalWord _ [T_Literal _ s]) = s `elem` strings isCommand _ _ = False @@ -1434,6 +1477,51 @@ readSimpleCommand = called "simple command" $ do then action else getParser def cmd rest + +readSource :: Monad m => SourcePos -> Token -> SCParser m Token +readSource pos t@(T_Redirecting _ _ (T_SimpleCommand _ _ (cmd:file:_))) = do + let literalFile = getLiteralString file + case literalFile of + Nothing -> do + parseNoteAt pos InfoC 1090 + "This source will be skipped since it's not constant." + return t + Just filename -> do + proceed <- shouldFollow filename + if not proceed + then do + parseNoteAt pos InfoC 1093 + "This file appears to be recursively sourced. Ignoring." + return t + else do + sys <- Mr.ask + input <- system $ siReadFile sys filename + case input of + Left err -> do + parseNoteAt pos InfoC 1091 $ + "Not following: " ++ err + return t + Right script -> do + id <- getNextIdAt pos + + let included = do + src <- subRead filename script + return $ T_Include id t src + + let failed = do + parseNoteAt pos WarningC 1094 + "Parsing of sourced file failed. Ignoring it." + return t + + included <|> failed + where + subRead name script = + withContext (ContextSource name) $ + inSeparateContext $ + subParse (initialPos name) readScript script +readSource _ t = return t + + prop_readPipeline = isOk readPipeline "! cat /etc/issue | grep -i ubuntu" prop_readPipeline2 = isWarning readPipeline "!cat /etc/issue | grep -i ubuntu" prop_readPipeline3 = isOk readPipeline "for f; do :; done|cat" @@ -2222,6 +2310,7 @@ runParser sys p filename contents = (runParserT p initialUserState filename contents) sys) initialSystemState +system = lift . lift . lift parseShell sys name contents = do (result, state) <- runParser sys (parseWithNotes readScript) name contents diff --git a/shellcheck.hs b/shellcheck.hs index 3ce3c3d15021f1e4fcd8996c1ac8e12748c9df16..9370f4a30e5b699108d0b07c717743950acec7b8 100644 --- a/shellcheck.hs +++ b/shellcheck.hs @@ -38,6 +38,7 @@ import Data.Maybe import Data.Monoid import Prelude hiding (catch) import System.Console.GetOpt +import System.Directory import System.Environment import System.Exit import System.IO @@ -56,6 +57,16 @@ instance Monoid Status where mempty = NoProblems mappend = max +data Options = Options { + checkSpec :: CheckSpec, + externalSources :: Bool +} + +defaultOptions = Options { + checkSpec = emptyCheckSpec, + externalSources = False +} + usageHeader = "Usage: shellcheck [OPTIONS...] FILES..." options = [ Option "e" ["exclude"] @@ -64,6 +75,8 @@ options = [ (ReqArg (Flag "format") "FORMAT") "output format", Option "s" ["shell"] (ReqArg (Flag "shell") "SHELLNAME") "Specify dialect (bash,sh,ksh)", + Option "x" ["external-sources"] + (NoArg $ Flag "externals" "true") "Allow 'source' outside of FILES.", Option "V" ["version"] (NoArg $ Flag "version" "true") "Print version information" ] @@ -81,9 +94,9 @@ parseArguments argv = formats :: Map.Map String (IO Formatter) formats = Map.fromList [ ("checkstyle", ShellCheck.Formatter.CheckStyle.format), - ("gcc", ShellCheck.Formatter.GCC.format), + ("gcc", ShellCheck.Formatter.GCC.format), ("json", ShellCheck.Formatter.JSON.format), - ("tty", ShellCheck.Formatter.TTY.format) + ("tty", ShellCheck.Formatter.TTY.format) ] getOption [] _ = Nothing @@ -128,7 +141,7 @@ statusToCode status = process :: [Flag] -> [FilePath] -> ExceptT Status IO Status process flags files = do - options <- foldM (flip parseOption) emptyCheckSpec flags + options <- foldM (flip parseOption) defaultOptions flags verifyFiles files let format = fromMaybe "tty" $ getOption flags "format" formatter <- @@ -140,12 +153,12 @@ process flags files = do throwError SupportFailure where write s = " " ++ s Just f -> ExceptT $ fmap Right f - let sys = ioInterface (const False) + sys <- lift $ ioInterface options files lift $ runFormatter sys formatter options files -runFormatter :: SystemInterface IO -> Formatter -> CheckSpec -> [FilePath] +runFormatter :: SystemInterface IO -> Formatter -> Options -> [FilePath] -> IO Status -runFormatter sys format spec files = do +runFormatter sys format options files = do header format result <- foldM f NoProblems files footer format @@ -163,7 +176,7 @@ runFormatter sys format spec files = do process :: FilePath -> IO Status process filename = do contents <- inputFile filename - let checkspec = spec { + let checkspec = (checkSpec options) { csFilename = filename, csScript = contents } @@ -179,17 +192,30 @@ parseOption flag options = Flag "shell" str -> fromMaybe (die $ "Unknown shell: " ++ str) $ do shell <- shellForExecutable str - return $ return options { csShellTypeOverride = Just shell } + return $ return options { + checkSpec = (checkSpec options) { + csShellTypeOverride = Just shell + } + } Flag "exclude" str -> do new <- mapM parseNum $ split ',' str - let old = csExcludedWarnings options - return options { csExcludedWarnings = new ++ old } + let old = csExcludedWarnings . checkSpec $ options + return options { + checkSpec = (checkSpec options) { + csExcludedWarnings = new ++ old + } + } Flag "version" _ -> do liftIO printVersion throwError NoProblems + Flag "externals" _ -> do + return options { + externalSources = True + } + _ -> return options where die s = do @@ -202,18 +228,34 @@ parseOption flag options = throwError SyntaxFailure return (Prelude.read num :: Integer) -ioInterface filter = - SystemInterface { - siReadFile = get +ioInterface options files = do + inputs <- mapM normalize files + return SystemInterface { + siReadFile = get inputs } where - get file = - if filter file + get inputs file = do + ok <- allowable inputs file + if ok then (Right <$> inputFile file) `catch` handler - else return $ Left (file ++ " was not specified as input.") - - handler :: IOException -> IO (Either ErrorMessage String) - handler ex = return . Left $ show ex + 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 + + allowable inputs x = + if externalSources options + then return True + else do + path <- normalize x + return $ path `elem` inputs + + normalize x = + canonicalizePath x `catch` fallback x + where + fallback :: FilePath -> IOException -> IO FilePath + fallback path _ = return path inputFile file = do contents <-