mirror of
https://github.com/koalaman/shellcheck.git
synced 2025-03-12 12:35:25 -07:00
636 lines
21 KiB
Haskell
636 lines
21 KiB
Haskell
{-
|
|
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.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,
|
|
rcfile :: Maybe FilePath
|
|
}
|
|
|
|
defaultOptions = Options {
|
|
checkSpec = emptyCheckSpec,
|
|
externalSources = False,
|
|
sourcePaths = [],
|
|
formatterOptions = newFormatterOptions {
|
|
foColorOption = ColorAuto
|
|
},
|
|
minSeverity = StyleC,
|
|
rcfile = Nothing
|
|
}
|
|
|
|
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 "" ["extended-analysis"]
|
|
(ReqArg (Flag "extended-analysis") "bool") "Perform dataflow analysis (default true)",
|
|
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 "" ["rcfile"]
|
|
(ReqArg (Flag "rcfile") "RCFILE")
|
|
"Prefer the specified configuration file over searching for one",
|
|
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, busybox)",
|
|
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 lookup value list of
|
|
Just value -> return value
|
|
Nothing -> 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 "rcfile" str -> do
|
|
return options {
|
|
rcfile = Just str
|
|
}
|
|
|
|
Flag "enable" value ->
|
|
let cs = checkSpec options in return options {
|
|
checkSpec = cs {
|
|
csOptionalChecks = (csOptionalChecks cs) ++ split ',' value
|
|
}
|
|
}
|
|
|
|
Flag "extended-analysis" str -> do
|
|
value <- parseBool str
|
|
return options {
|
|
checkSpec = (checkSpec options) {
|
|
csExtendedAnalysis = Just 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)
|
|
|
|
parseBool str = do
|
|
case str of
|
|
"true" -> return True
|
|
"false" -> return False
|
|
_ -> do
|
|
printErr $ "Invalid boolean, expected true/false: " ++ str
|
|
throwError SyntaxFailure
|
|
|
|
ioInterface :: Options -> [FilePath] -> IO (SystemInterface IO)
|
|
ioInterface options files = do
|
|
inputs <- mapM normalize files
|
|
cache <- newIORef emptyCache
|
|
configCache <- newIORef ("", Nothing)
|
|
return (newSystemInterface :: SystemInterface IO) {
|
|
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 =
|
|
case rcfile options of
|
|
Just file -> do
|
|
-- We have a specified rcfile. Ignore normal rcfile resolution.
|
|
(path, result) <- readIORef cache
|
|
if path == "/"
|
|
then return result
|
|
else do
|
|
result <- readConfig file
|
|
when (isNothing result) $
|
|
hPutStrLn stderr $ "Warning: unable to read --rcfile " ++ file
|
|
writeIORef cache ("/", result)
|
|
return result
|
|
|
|
Nothing -> 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
|
|
hPutStrLn stderr $ 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 ""
|