| {- |
| 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 <https://www.gnu.org/licenses/>. |
| -} |
| 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.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 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 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 file = do |
| map <- readIORef cache |
| case Map.lookup file map of |
| Just x -> return $ Right x |
| Nothing -> fetch cache inputs file |
| |
| fetch cache inputs file = do |
| ok <- allowable inputs file |
| if ok |
| then (do |
| (contents, shouldCache) <- inputFile file |
| when shouldCache $ |
| modifyIORef cache $ Map.insert file contents |
| 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 |
| |
| 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 |
| |
| -- 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 |
| |
| findSourceFile inputs sourcePathFlag currentScript sourcePathAnnotation original = |
| if isAbsolute original |
| then |
| let (_, relative) = splitDrive original |
| in find relative original |
| else |
| find original original |
| where |
| find filename deflt = do |
| sources <- filterM ((allowable inputs) `andM` doesFileExist) $ |
| (adjustPath filename):(map (</> filename) $ map adjustPath $ sourcePathFlag ++ sourcePathAnnotation) |
| case sources of |
| [] -> return deflt |
| (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 "" |