diff --git a/ShellCheck.cabal b/ShellCheck.cabal index f6070a701..4a55545f5 100644 --- a/ShellCheck.cabal +++ b/ShellCheck.cabal @@ -84,6 +84,7 @@ library ShellCheck.Checks.Custom ShellCheck.Checks.ShellSupport ShellCheck.Data + ShellCheck.EditorConfig ShellCheck.Fixer ShellCheck.Formatter.Format ShellCheck.Formatter.CheckStyle diff --git a/shellcheck.1.md b/shellcheck.1.md index 2fdc5f4d8..5b344d24e 100644 --- a/shellcheck.1.md +++ b/shellcheck.1.md @@ -139,6 +139,12 @@ not warn at all, as `ksh` supports decimals in arithmetic contexts. read the list from standard input. This option is processed in addition to any files specified on the command line. +**--file-name** *FILE* + +: When checking standard input (`-`), use *FILE* as the filename to resolve + `.shellcheckrc` and EditorConfig configuration, instead of `-`. This has no + effect when checking regular files. + # FORMATS @@ -334,6 +340,31 @@ 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. +# EDITORCONFIG + +Unless `--norc` is used, ShellCheck will also look for a file `.editorconfig` +in the script's directory and each parent directory. Any section whose glob +pattern matches the checked file will have its `shellcheck.*` keys read as +directives, with the `shellcheck.` prefix stripped. This uses the same +`key=value` syntax as `.shellcheckrc`. + +For example: + + [*.{ebuild,eclass}] + shellcheck.shell=bash + shellcheck.disable=SC2034 + + [{PKGBUILD,APKBUILD}] + shellcheck.shell=bash + shellcheck.disable=SC2034 + +If no matching directives are found in any `.editorconfig` in the parent +directories, ShellCheck will look in the global default +`$XDG_CONFIG_HOME/editorconfig.ini` (usually `~/.config/editorconfig.ini`). + +Directives from `.shellcheckrc`/`shellcheckrc` and from `.editorconfig` are +both applied, with `.shellcheckrc` taking precedence in case of conflicts. + # ENVIRONMENT VARIABLES diff --git a/shellcheck.hs b/shellcheck.hs index 9378b78f0..a0f4587d3 100644 --- a/shellcheck.hs +++ b/shellcheck.hs @@ -20,6 +20,7 @@ import qualified ShellCheck.Analyzer import ShellCheck.Checker import ShellCheck.Data +import ShellCheck.EditorConfig import ShellCheck.Interface import ShellCheck.Regex @@ -77,7 +78,8 @@ data Options = Options { sourcePaths :: [FilePath], formatterOptions :: FormatterOptions, minSeverity :: Severity, - rcfile :: Maybe FilePath + rcfile :: Maybe FilePath, + fileNameOverride :: Maybe FilePath } defaultOptions = Options { @@ -88,7 +90,8 @@ defaultOptions = Options { foColorOption = ColorAuto }, minSeverity = StyleC, - rcfile = Nothing + rcfile = Nothing, + fileNameOverride = Nothing } usageHeader = "Usage: shellcheck [OPTIONS...] FILES..." @@ -110,7 +113,7 @@ options = [ 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", + (NoArg $ Flag "norc" "true") "Don't look for .shellcheckrc and .editorconfig files", Option "" ["rcfile"] (ReqArg (Flag "rcfile") "RCFILE") "Prefer the specified configuration file over searching for one", @@ -137,7 +140,10 @@ options = [ (NoArg $ Flag "help" "true") "Show this usage summary and exit", Option "" ["files-from"] (ReqArg (Flag "files-from") "FILE") - "Read input files from FILE (one per line, or '-' for stdin)" + "Read input files from FILE (one per line, or '-' for stdin)", + Option "" ["file-name"] + (ReqArg (Flag "file-name") "FILE") + "Use FILE as the filename for parsing EditorConfig configuration when input is stdin" ] getUsageInfo = usageInfo usageHeader options @@ -420,6 +426,11 @@ parseOption flag options = rcfile = Just str } + Flag "file-name" str -> do + return options { + fileNameOverride = Just str + } + Flag "enable" value -> let cs = checkSpec options in return options { checkSpec = cs { @@ -514,8 +525,26 @@ ioInterface options files = do fallback path _ = return path - -- Returns the name and contents of .shellcheckrc for the given file - getConfig cache filename = + -- Returns the name and contents of .shellcheckrc for the given file, + -- merged with any shellcheck.* directives found in applicable + -- EditorConfig files. + getConfig cache filename = do + let configFilename = + if filename == "-" + then fromMaybe filename (fileNameOverride options) + else filename + rcResult <- getRcConfig cache configFilename + ecResult <- getEditorConfig configFilename + return $ mergeConfigs filename rcResult ecResult + + mergeConfigs filename rcResult ecResult = + case (rcResult, ecResult) of + (Nothing, Nothing) -> Nothing + (Just (rcPath, rc), Nothing) -> Just (rcPath, rc) + (Nothing, Just (ecPath, ec)) -> Just (ecPath, ec) + (Just (rcPath, rc), Just (_, ec)) -> Just (rcPath, rc ++ "\n" ++ ec) + + getRcConfig cache filename = case rcfile options of Just file -> do -- We have a specified rcfile. Ignore normal rcfile resolution. @@ -541,6 +570,63 @@ ioInterface options files = do writeIORef cache (dir, result) return result + -- Look for .editorconfig files in the target file's directory and + -- all its parents (as per the EditorConfig spec), plus the global + -- ${XDG_CONFIG_HOME}/editorconfig.ini default. shellcheck.* keys in + -- matching sections are turned into directives. + getEditorConfig filename = do + -- Resolve the directory (to find .editorconfig files) but keep + -- the leaf filename as-is so that globs match the symlink name + -- rather than the resolved target. + let name = takeFileName filename + dir <- normalize (takeDirectory filename) + let path = dir name + dirConfigs <- collectDirConfigs dir + globalConfig <- readGlobalEditorConfig + let allConfigs = dirConfigs ++ globalConfig + contributions = concatMap (directivesFor path) allConfigs + return $ case contributions of + [] -> Nothing + ((sourceFile, _):_) -> Just (sourceFile, concatMap snd contributions) + where + -- For each EditorConfig file: either report an invalid 'root' + -- declaration at its line, or yield the matching shellcheck.* + -- directives. Invalid roots take priority over directives. + directivesFor path (file, contents) = + case invalidRootLines contents of + (badLine:_) -> + [(file, replicate (badLine - 1) '\n' ++ "invalid editorconfig value\n")] + [] -> + let relative = makeRelativeTo (takeDirectory file) path + result = parseEditorConfig contents relative + in if null result then [] else [(file, result)] + + makeRelativeTo dir path = + case stripPrefix (addTrailingSlash dir) path of + Just rest -> rest + Nothing -> takeFileName path + + addTrailingSlash dir + | null dir = dir + | last dir == '/' = dir + | otherwise = dir ++ "/" + + collectDirConfigs dir = do + current <- readConfig (dir ".editorconfig") + let isRoot = maybe False (isEditorConfigRoot . snd) current + next = takeDirectory dir + rest <- if next /= dir && not isRoot + then collectDirConfigs next + else return [] + return $ maybeToList current ++ rest + + readGlobalEditorConfig = do + path <- (getXdgDirectory XdgConfig "editorconfig.ini") + `catch` ((const $ return "") :: IOException -> IO FilePath) + if null path + then return [] + else maybeToList <$> readConfig path + findConfig paths = case paths of (file:rest) -> do diff --git a/src/ShellCheck/EditorConfig.hs b/src/ShellCheck/EditorConfig.hs new file mode 100644 index 000000000..b11918105 --- /dev/null +++ b/src/ShellCheck/EditorConfig.hs @@ -0,0 +1,294 @@ +{- + Copyright 2012-2024 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 . +-} + +{-# LANGUAGE TemplateHaskell #-} +-- Minimal support for reading shellcheck directives from EditorConfig +-- style files (https://editorconfig.org/). Only the `shellcheck.*` keys +-- of sections whose glob matches the file being checked are extracted, +-- and turned into the same "key=value" directive syntax that is used in +-- .shellcheckrc files. +module ShellCheck.EditorConfig (parseEditorConfig, isEditorConfigRoot, invalidRootLines, globToRegexString, runTests) where + +import Data.Char +import Data.List +import Data.Maybe + +import ShellCheck.Data (shellForExecutable) +import ShellCheck.Regex + +import Test.QuickCheck + +-- Given the contents of an EditorConfig style file and the name of the +-- file being checked, return the shellcheck directives (as a +-- "key=value\n" delimited blob, suitable for feeding into the same +-- parser as .shellcheckrc) found in matching sections. +-- +-- As per the EditorConfig spec, files are read top to bottom and +-- properties from later sections override those from earlier ones +-- (for the same key), so on conflicts the last matching section wins. +parseEditorConfig :: String -> FilePath -> String +parseEditorConfig contents name = + renderDirectives . lastWins . concatMap sectionDirectives $ sections + where + -- Render each directive at its original line in the config file, so + -- that any parse error (SC1134) is reported at the correct line. + renderDirectives = snd . foldl step (0, "") + step (prevLine, out) (line, key, value) = + (line, out ++ replicate (line - prevLine - 1) '\n' ++ key ++ "=" ++ value ++ "\n") + + -- Keep only the last occurrence of each key, preserving the + -- relative order of the remaining (first-seen) entries. + lastWins = reverse . nubBy (\a b -> keyOf a == keyOf b) . reverse + where + keyOf (_, key, _) = key + + ls = lines contents + sections = splitSections 1 ls + + splitSections _ [] = [] + splitSections n (l:rest) = + case parseHeader l of + Just pat -> + let (body, rest') = break (isJust . parseHeader) rest + in (pat, zip [n+1..] body) : splitSections (n + 1 + length body) rest' + Nothing -> splitSections (n+1) rest + + parseHeader l = + let t = trim (dropLineComment l) + in case t of + ('[':cs@(_:_)) | last cs == ']' -> Just (init cs) + _ -> Nothing + + sectionDirectives (pat, body) = + if matchesGlob pat name + then mapMaybe toDirective body + else [] + + toDirective (line, l) = + let t = dropLineComment l + in case break (== '=') (trim t) of + (key, '=':value) -> + let key' = trim key + value' = trim value + in if "shellcheck." `isPrefixOf` key' + then + let directive = drop (length "shellcheck.") key' + in if isUsableDirective directive value' + then Just (line, directive, value') + else Nothing + else Nothing + _ -> Nothing + + -- Only emit directives that the .shellcheckrc parser can handle. + -- Unknown shells (e.g. 'shellcheck.shell=zsh') would otherwise + -- silently suppress the SC2148 "unknown shell" warning, so they are + -- dropped here. Empty values are kept: the rc parser rejects them, + -- producing an SC1134 error at the right file and line. + isUsableDirective "shell" value = null value || isJust (shellForExecutable value) + isUsableDirective _ value = not (null value) + +-- Does the top-level (pre-section) part of an EditorConfig file +-- declare "root = true"? Per the spec, this stops the search for +-- further EditorConfig files in parent directories. +isEditorConfigRoot :: String -> Bool +isEditorConfigRoot contents = + any (== Just "true") . map rootValue $ preSectionLines contents + where + isSectionHeader l = + case trim (dropLineComment l) of + ('[':cs@(_:_)) -> last cs == ']' + _ -> False + preSectionLines = takeWhile (not . isSectionHeader) . lines + + rootValue l = + case break (== '=') (trim (dropLineComment l)) of + (key, '=':value) | map toLower (trim key) == "root" -> + Just (map toLower (trim value)) + _ -> Nothing + +-- Returns the 1-based line numbers of invalid 'root' declarations, +-- i.e. root values other than true/false (such as 'root ='). +invalidRootLines :: String -> [Int] +invalidRootLines contents = + [ n | (n, l) <- zip [1..] (preSectionLines contents) + , case rootValue l of + Just value -> value `notElem` ["true", "false"] + Nothing -> False ] + where + isSectionHeader l = + case trim (dropLineComment l) of + ('[':cs@(_:_)) -> last cs == ']' + _ -> False + preSectionLines = takeWhile (not . isSectionHeader) . lines + + rootValue l = + case break (== '=') (trim (dropLineComment l)) of + (key, '=':value) | map toLower (trim key) == "root" -> + Just (map toLower (trim value)) + _ -> Nothing + +-- EditorConfig does not allow inline comments; '#' and ';' starting +-- on the first non-whitespace character denote a full-line comment. +-- Lines not starting with '#' or ';' must be returned verbatim. +dropLineComment l = + case dropWhile isSpace l of + ('#':_) -> "" + (';':_) -> "" + _ -> l + +trim :: String -> String +trim = dropWhileEnd isSpace . dropWhile isSpace + +-- Does the (relative path of the) file match the given EditorConfig glob? +matchesGlob :: String -> FilePath -> Bool +matchesGlob pattern name = + name `matches` mkRegex (globToRegexString pattern) + +-- Translate an EditorConfig glob pattern into an anchored regex string. +-- Per the spec, patterns without a path separator are matched against +-- the file at any depth (as if prefixed with "**/"). +globToRegexString :: String -> String +globToRegexString pattern = "^" ++ prefix ++ go pattern ++ "$" + where + prefix = if '/' `elem` pattern then "" else "(.*/)?" + + go [] = "" + go ('*':'*':rest) = ".*" ++ go rest + go ('*':rest) = "[^/]*" ++ go rest + go ('?':rest) = "[^/]" ++ go rest + go ('[':rest) = + let (cls, rest') = break (== ']') rest + in case rest' of + (']':rest'') -> "[" ++ translateClass cls ++ "]" ++ go rest'' + _ -> "\\[" ++ go rest + go ('{':rest) = + let (body, rest') = break (== '}') rest + in case rest' of + ('}':rest'') -> + "(" ++ intercalate "|" (map go (braceAlternatives body)) ++ ")" ++ go rest'' + _ -> "\\{" ++ go rest + go (c:rest) + | c `elem` regexSpecials = ['\\', c] ++ go rest + | otherwise = c : go rest + + regexSpecials = ".\\+()^$|" + + translateClass ('!':cs) = '^' : escapeClass cs + translateClass cs = escapeClass cs + escapeClass = concatMap (\c -> if c == '\\' then "\\\\" else [c]) + + -- Expand EditorConfig brace alternatives. Besides the comma + -- separated form, EditorConfig supports numeric ranges like + -- '{1..3}' which match any integer in the range. + braceAlternatives body = + case break (== '.') body of + (fromStr, '.':'.':toStr) + | Just from <- readInt fromStr + , Just to <- readInt toStr -> + map show $ + if from < to + then [from..to] + else [] + _ -> splitCommas body + + readInt s = + case reads s :: [(Int, String)] of + [(n, "")] -> Just n + _ -> Nothing + + splitCommas s = + case break (== ',') s of + (before, ',':after) -> before : splitCommas after + (before, "") -> [before] + (before, after) -> [before ++ after] + +prop_globStar = matchesGlob "*.ebuild" "foo.ebuild" +prop_globBraceExt = matchesGlob "*.{ebuild,eclass}" "foo.eclass" +prop_globBraceExt2 = matchesGlob "*.{ebuild,eclass}" "foo.ebuild" +prop_globBraceName = matchesGlob "{PKGBUILD,APKBUILD}" "PKGBUILD" +prop_globBraceName2 = matchesGlob "{PKGBUILD,APKBUILD}" "APKBUILD" +prop_globNoMatch = not $ matchesGlob "*.ebuild" "foo.txt" +prop_globQuestion = matchesGlob "foo?.sh" "food.sh" +prop_globClass = matchesGlob "foo[0-9].sh" "foo1.sh" +prop_globClassNeg = not $ matchesGlob "foo[!0-9].sh" "foo1.sh" +-- Patterns without a path separator should match at any depth. +prop_globAnyDepth = matchesGlob "*.sh" "sub/dir/foo.sh" +prop_globAnyDepthPlain = matchesGlob "foo" "sub/foo" +-- Patterns with a path separator are only matched against the full +-- relative path. +prop_globWithSlashNoMatch = not $ matchesGlob "sub/*.sh" "other/foo.sh" +prop_globWithSlashMatch = matchesGlob "sub/*.sh" "sub/foo.sh" +-- Numeric range expansion +prop_globRange = matchesGlob "file{1..3}.sh" "file2.sh" +prop_globRangeStart = matchesGlob "file{1..3}.sh" "file1.sh" +prop_globRangeEnd = matchesGlob "file{1..3}.sh" "file3.sh" +prop_globRangeNoMatch = not $ matchesGlob "file{1..3}.sh" "file4.sh" +prop_globRangeNegative = matchesGlob "file{-2..0}.sh" "file-1.sh" +prop_globRangeDescending = not $ matchesGlob "file{3..1}.sh" "file2.sh" +prop_globLiteralDots = not $ matchesGlob "file{1..3}.sh" "file1..3.sh" + +prop_parseEditorConfig1 = + parseEditorConfig "[*.{ebuild,eclass}]\nshellcheck.shell=bash\nshellcheck.disable=SC2034\n" "foo.ebuild" + == "\nshell=bash\ndisable=SC2034\n" +prop_parseEditorConfig2 = + parseEditorConfig "[*.{ebuild,eclass}]\nshellcheck.shell=bash\n" "foo.txt" == "" +prop_parseEditorConfig3 = + parseEditorConfig "[{PKGBUILD,APKBUILD}]\nshellcheck.disable=SC2034\n" "PKGBUILD" == "\ndisable=SC2034\n" +prop_parseEditorConfig4 = + parseEditorConfig "root = true\n[*.sh]\nindent_style = space\nshellcheck.shell=bash\n" "foo.sh" + == "\n\n\nshell=bash\n" +-- A later, more specific section overrides an earlier, more general +-- one for the same key. +prop_parseEditorConfig5 = + parseEditorConfig "[*]\nshellcheck.shell=sh\n\n[foo]\nshellcheck.shell=bash\n" "foo" + == "\n\n\n\nshell=bash\n" +-- Non-conflicting keys from earlier and later sections are all kept. +prop_parseEditorConfig6 = + parseEditorConfig "[*]\nshellcheck.shell=sh\n\n[foo]\nshellcheck.disable=SC2034\n" "foo" + == "\nshell=sh\n\n\ndisable=SC2034\n" +-- An unsupported shell must not suppress the SC2148 warning, so the +-- directive is not emitted. +prop_parseEditorConfigUnknownShell = + parseEditorConfig "[*]\nshellcheck.shell=zsh\n" "foo" == "" +prop_parseEditorConfigEmptyShell = + parseEditorConfig "[*]\nshellcheck.shell=\n" "foo" == "\nshell=\n" +prop_parseEditorConfigEmptyDisable = + parseEditorConfig "[*]\nshellcheck.disable=\n" "foo" == "" +-- EditorConfig does not allow inline comments; the whole line after +-- the value is kept (including '# ...'), making the shell value +-- invalid, so no usable directive is emitted. +prop_parseEditorConfigInlineComment = + parseEditorConfig "[*]\nshellcheck.shell=bash # inline\n" "foo" == "" +-- Full-line comments starting on first non-ws char are stripped. +prop_parseEditorConfigLineComment = + parseEditorConfig "[*]\n# shellcheck.shell=bash\n" "foo" == "" +prop_parseEditorConfigSemicolonComment = + parseEditorConfig "[*]\n; shellcheck.shell=bash\n" "foo" == "" +prop_isEditorConfigRootEmpty = not $ isEditorConfigRoot "root =\n" +prop_isEditorConfigRootFalse = not $ isEditorConfigRoot "root = false\n" +prop_isEditorConfigRootTrue = isEditorConfigRoot "root = TRUE\n" +prop_invalidRootLinesEmpty = invalidRootLines "root =\n" == [1] +prop_invalidRootLinesTrue = invalidRootLines "root = true\n" == [] +prop_invalidRootLinesFalse = invalidRootLines "root = false\n" == [] +prop_invalidRootLinesInSection = + invalidRootLines "[*]\nroot = true\n" == [] + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Parser.hs b/src/ShellCheck/Parser.hs index 2902f9b99..c0530fa5c 100644 --- a/src/ShellCheck/Parser.hs +++ b/src/ShellCheck/Parser.hs @@ -3297,7 +3297,10 @@ readConfigFile filename = do return result Left err -> do - parseProblem ErrorC 1134 $ errorFor filename err + -- Report the error at its location in the config file + -- (e.g. .shellcheckrc or .editorconfig), not at the + -- current position in the script being checked. + parseProblemAt (errorPos err) ErrorC 1134 $ errorFor filename err return [] errorFor filename err = diff --git a/test/shellcheck.hs b/test/shellcheck.hs index d5e056d58..8bad78a60 100644 --- a/test/shellcheck.hs +++ b/test/shellcheck.hs @@ -12,6 +12,7 @@ import qualified ShellCheck.Checks.Commands import qualified ShellCheck.Checks.ControlFlow import qualified ShellCheck.Checks.Custom import qualified ShellCheck.Checks.ShellSupport +import qualified ShellCheck.EditorConfig import qualified ShellCheck.Fixer import qualified ShellCheck.Formatter.Diff import qualified ShellCheck.Parser @@ -35,6 +36,7 @@ main = do , ("Checks.ControlFlow" , ShellCheck.Checks.ControlFlow.runTests) , ("Checks.Custom" , ShellCheck.Checks.Custom.runTests) , ("Checks.ShellSupport", ShellCheck.Checks.ShellSupport.runTests) + , ("EditorConfig" , ShellCheck.EditorConfig.runTests) , ("Fixer" , ShellCheck.Fixer.runTests) , ("Formatter.Diff" , ShellCheck.Formatter.Diff.runTests) , ("Parser" , ShellCheck.Parser.runTests)