{- Copyright 2012-2019 Vidar Holen This file is part of ShellCheck. https://www.shellcheck.net ShellCheck is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. ShellCheck is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . -} import qualified ShellCheck.Analyzer import ShellCheck.Checker import ShellCheck.Data import ShellCheck.Interface import ShellCheck.Regex import qualified ShellCheck.Formatter.CheckStyle import ShellCheck.Formatter.Format import qualified ShellCheck.Formatter.Diff import qualified ShellCheck.Formatter.GCC import qualified ShellCheck.Formatter.JSON import qualified ShellCheck.Formatter.JSON1 import qualified ShellCheck.Formatter.TTY import qualified ShellCheck.Formatter.Quiet import Control.Exception import Control.Monad import Control.Monad.IO.Class import Control.Monad.Trans.Class import Control.Monad.Except import Data.Bits import Data.Char import Data.Either import Data.Functor import Data.IORef import Data.List import qualified Data.Map as Map import Data.Maybe import Data.Monoid import Data.Semigroup (Semigroup (..)) import Prelude hiding (catch) import System.Console.GetOpt import System.Directory import System.Environment import System.Exit import System.FilePath import System.IO data Flag = Flag String String data Status = NoProblems | SomeProblems | SupportFailure | SyntaxFailure | RuntimeException deriving (Ord, Eq, Show) instance Semigroup Status where (<>) = max instance Monoid Status where mempty = NoProblems mappend = (Data.Semigroup.<>) data Options = Options { checkSpec :: CheckSpec, externalSources :: Bool, sourcePaths :: [FilePath], formatterOptions :: FormatterOptions, minSeverity :: Severity } defaultOptions = Options { checkSpec = emptyCheckSpec, externalSources = False, sourcePaths = [], formatterOptions = newFormatterOptions { foColorOption = ColorAuto }, minSeverity = StyleC } usageHeader = "Usage: shellcheck [OPTIONS...] FILES..." options = [ Option "a" ["check-sourced"] (NoArg $ Flag "sourced" "false") "Include warnings from sourced files", Option "C" ["color"] (OptArg (maybe (Flag "color" "always") (Flag "color")) "WHEN") "Use color (auto, always, never)", Option "i" ["include"] (ReqArg (Flag "include") "CODE1,CODE2..") "Consider only given types of warnings", Option "e" ["exclude"] (ReqArg (Flag "exclude") "CODE1,CODE2..") "Exclude types of warnings", Option "f" ["format"] (ReqArg (Flag "format") "FORMAT") $ "Output format (" ++ formatList ++ ")", Option "" ["list-optional"] (NoArg $ Flag "list-optional" "true") "List checks disabled by default", Option "" ["norc"] (NoArg $ Flag "norc" "true") "Don't look for .shellcheckrc files", Option "o" ["enable"] (ReqArg (Flag "enable") "check1,check2..") "List of optional checks to enable (or 'all')", Option "P" ["source-path"] (ReqArg (Flag "source-path") "SOURCEPATHS") "Specify path when looking for sourced files (\"SCRIPTDIR\" for script's dir)", Option "s" ["shell"] (ReqArg (Flag "shell") "SHELLNAME") "Specify dialect (sh, bash, dash, ksh)", Option "S" ["severity"] (ReqArg (Flag "severity") "SEVERITY") "Minimum severity of errors to consider (error, warning, info, style)", Option "V" ["version"] (NoArg $ Flag "version" "true") "Print version information", Option "W" ["wiki-link-count"] (ReqArg (Flag "wiki-link-count") "NUM") "The number of wiki links to show, when applicable", Option "x" ["external-sources"] (NoArg $ Flag "externals" "true") "Allow 'source' outside of FILES", Option "" ["help"] (NoArg $ Flag "help" "true") "Show this usage summary and exit" ] getUsageInfo = usageInfo usageHeader options printErr = lift . hPutStrLn stderr parseArguments :: [String] -> ExceptT Status IO ([Flag], [FilePath]) parseArguments argv = case getOpt Permute options argv of (opts, files, []) -> return (opts, files) (_, _, errors) -> do printErr $ concat errors ++ "\n" ++ getUsageInfo throwError SyntaxFailure formats :: FormatterOptions -> Map.Map String (IO Formatter) formats options = Map.fromList [ ("checkstyle", ShellCheck.Formatter.CheckStyle.format), ("diff", ShellCheck.Formatter.Diff.format options), ("gcc", ShellCheck.Formatter.GCC.format), ("json", ShellCheck.Formatter.JSON.format), ("json1", ShellCheck.Formatter.JSON1.format), ("tty", ShellCheck.Formatter.TTY.format options), ("quiet", ShellCheck.Formatter.Quiet.format options) ] formatList = intercalate ", " names where names = Map.keys $ formats (formatterOptions defaultOptions) getOption [] _ = Nothing getOption (Flag var val:_) name | name == var = return val getOption (_:rest) flag = getOption rest flag getOptions options name = map (\(Flag _ val) -> val) . filter (\(Flag var _) -> var == name) $ options split char str = split' str [] where split' (a:rest) element = if a == char then reverse element : split' rest [] else split' rest (a:element) split' [] element = [reverse element] toStatus = fmap (either id id) . runExceptT getEnvArgs = do opts <- getEnv "SHELLCHECK_OPTS" `catch` cantWaitForLookupEnv return . filter (not . null) $ opts `splitOn` mkRegex " +" where cantWaitForLookupEnv :: IOException -> IO String cantWaitForLookupEnv = const $ return "" main = do params <- getArgs envOpts <- getEnvArgs let args = envOpts ++ params status <- toStatus $ do (flags, files) <- parseArguments args process flags files exitWith $ statusToCode status statusToCode status = case status of NoProblems -> ExitSuccess SomeProblems -> ExitFailure 1 SyntaxFailure -> ExitFailure 3 SupportFailure -> ExitFailure 4 RuntimeException -> ExitFailure 2 process :: [Flag] -> [FilePath] -> ExceptT Status IO Status process flags files = do options <- foldM (flip parseOption) defaultOptions flags verifyFiles files let format = fromMaybe "tty" $ getOption flags "format" let formatters = formats $ formatterOptions options formatter <- case Map.lookup format formatters of Nothing -> do printErr $ "Unknown format " ++ format printErr "Supported formats:" mapM_ (printErr . write) $ Map.keys formatters throwError SupportFailure where write s = " " ++ s Just f -> ExceptT $ fmap Right f sys <- lift $ ioInterface options files lift $ runFormatter sys formatter options files runFormatter :: SystemInterface IO -> Formatter -> Options -> [FilePath] -> IO Status runFormatter sys format options files = do header format result <- foldM f NoProblems files footer format return result where f :: Status -> FilePath -> IO Status f status file = do newStatus <- process file `catch` handler file return $! status `mappend` newStatus handler :: FilePath -> IOException -> IO Status handler file e = reportFailure file (show e) reportFailure file str = do onFailure format file str return RuntimeException process :: FilePath -> IO Status process filename = do input <- siReadFile sys Nothing filename either (reportFailure filename) check input where check contents = do let checkspec = (checkSpec options) { csFilename = filename, csScript = contents } result <- checkScript sys checkspec onResult format result sys return $ if null (crComments result) then NoProblems else SomeProblems parseEnum name value list = case filter ((== value) . fst) list of [(name, value)] -> return value [] -> do printErr $ "Unknown value for --" ++ name ++ ". " ++ "Valid options are: " ++ (intercalate ", " $ map fst list) throwError SupportFailure parseColorOption value = parseEnum "color" value [ ("auto", ColorAuto), ("always", ColorAlways), ("never", ColorNever) ] parseSeverityOption value = parseEnum "severity" value [ ("error", ErrorC), ("warning", WarningC), ("info", InfoC), ("style", StyleC) ] parseOption flag options = case flag of Flag "shell" str -> fromMaybe (die $ "Unknown shell: " ++ str) $ do shell <- shellForExecutable str return $ return options { checkSpec = (checkSpec options) { csShellTypeOverride = Just shell } } Flag "exclude" str -> do new <- mapM parseNum $ filter (not . null) $ split ',' str let old = csExcludedWarnings . checkSpec $ options return options { checkSpec = (checkSpec options) { csExcludedWarnings = new ++ old } } Flag "include" str -> do new <- mapM parseNum $ filter (not . null) $ split ',' str let old = csIncludedWarnings . checkSpec $ options return options { checkSpec = (checkSpec options) { csIncludedWarnings = if null new then old else Just new `mappend` old } } Flag "version" _ -> do liftIO printVersion throwError NoProblems Flag "list-optional" _ -> do liftIO printOptional throwError NoProblems Flag "help" _ -> do liftIO $ putStrLn getUsageInfo throwError NoProblems Flag "externals" _ -> return options { externalSources = True } Flag "color" color -> do option <- parseColorOption color return options { formatterOptions = (formatterOptions options) { foColorOption = option } } Flag "source-path" str -> do let paths = splitSearchPath str return options { sourcePaths = (sourcePaths options) ++ paths } Flag "sourced" _ -> return options { checkSpec = (checkSpec options) { csCheckSourced = True } } Flag "severity" severity -> do option <- parseSeverityOption severity return options { checkSpec = (checkSpec options) { csMinSeverity = option } } Flag "wiki-link-count" countString -> do count <- parseNum countString return options { formatterOptions = (formatterOptions options) { foWikiLinkCount = count } } Flag "norc" _ -> return options { checkSpec = (checkSpec options) { csIgnoreRC = True } } Flag "enable" value -> let cs = checkSpec options in return options { checkSpec = cs { csOptionalChecks = (csOptionalChecks cs) ++ split ',' value } } -- This flag is handled specially in 'process' Flag "format" _ -> return options Flag str _ -> do printErr $ "Internal error for --" ++ str ++ ". Please file a bug :(" return options where die s = do printErr s throwError SupportFailure parseNum ('S':'C':str) = parseNum str parseNum num = do unless (all isDigit num) $ do printErr $ "Invalid number: " ++ num throwError SyntaxFailure return (Prelude.read num :: Integer) ioInterface :: Options -> [FilePath] -> IO (SystemInterface IO) ioInterface options files = do inputs <- mapM normalize files cache <- newIORef emptyCache configCache <- newIORef ("", Nothing) return SystemInterface { siReadFile = get cache inputs, siFindSource = findSourceFile inputs (sourcePaths options), siGetConfig = getConfig configCache } where emptyCache :: Map.Map FilePath String emptyCache = Map.empty get cache inputs rcSuggestsExternal file = do map <- readIORef cache case Map.lookup file map of Just x -> return $ Right x Nothing -> fetch cache inputs rcSuggestsExternal file fetch cache inputs rcSuggestsExternal file = do ok <- allowable rcSuggestsExternal inputs file if ok then (do (contents, shouldCache) <- inputFile file when shouldCache $ modifyIORef cache $ Map.insert file contents return $ Right contents ) `catch` handler else if rcSuggestsExternal == Just False then return $ Left (file ++ " was not specified as input, and external files were disabled via directive.") 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 rcSuggestsExternal inputs x = if fromMaybe (externalSources options) rcSuggestsExternal 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 -- 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 <- doesFileExist 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) andM a b arg = do first <- a arg if not first then return False else b arg findM p = foldr go (pure Nothing) where go x acc = do b <- p x if b then pure (Just x) else acc findSourceFile inputs sourcePathFlag currentScript rcSuggestsExternal sourcePathAnnotation original = if isAbsolute original then let (_, relative) = splitDrive original in find relative original else find original original where find filename deflt = do sources <- findM ((allowable rcSuggestsExternal inputs) `andM` doesFileExist) $ (adjustPath filename):(map (( filename) . adjustPath) $ sourcePathFlag ++ sourcePathAnnotation) case sources of Nothing -> return deflt Just first -> return first scriptdir = dropFileName currentScript adjustPath str = case (splitDirectories str) of ("SCRIPTDIR":rest) -> joinPath (scriptdir:rest) _ -> str inputFile file = do (handle, shouldCache) <- if file == "-" then return (stdin, True) else do h <- openBinaryFile file ReadMode reopenable <- hIsSeekable h return (h, not reopenable) hSetBinaryMode handle True contents <- decodeString <$> hGetContents handle -- closes handle seq (length contents) $ return (contents, shouldCache) -- Decode a char8 string into a utf8 string, with fallback on -- ISO-8859-1. This avoids depending on additional libraries. decodeString = decode where decode [] = [] decode (c:rest) | isAscii c = c : decode rest decode (c:rest) = let num = (fromIntegral $ ord c) :: Int next = case num of _ | num >= 0xF8 -> Nothing | num >= 0xF0 -> construct (num .&. 0x07) 3 rest | num >= 0xE0 -> construct (num .&. 0x0F) 2 rest | num >= 0xC0 -> construct (num .&. 0x1F) 1 rest | True -> Nothing in case next of Just (n, remainder) -> chr n : decode remainder Nothing -> c : decode rest construct x 0 rest = do guard $ x <= 0x10FFFF return (x, rest) construct x n (c:rest) = let num = (fromIntegral $ ord c) :: Int in if num >= 0x80 && num <= 0xBF then construct ((x `shiftL` 6) .|. (num .&. 0x3f)) (n-1) rest else Nothing construct _ _ _ = Nothing verifyFiles files = when (null files) $ do printErr "No files specified.\n" printErr $ usageInfo usageHeader options throwError SyntaxFailure printVersion = do putStrLn "ShellCheck - shell script analysis tool" putStrLn $ "version: " ++ shellcheckVersion putStrLn "license: GNU General Public License, version 3" putStrLn "website: https://www.shellcheck.net" printOptional = do mapM f list where list = sortOn cdName ShellCheck.Analyzer.optionalChecks f item = do putStrLn $ "name: " ++ cdName item putStrLn $ "desc: " ++ cdDescription item putStrLn $ "example: " ++ cdPositive item putStrLn $ "fix: " ++ cdNegative item putStrLn ""