From 0ffcc2b8fe4da4ae66e21bf62ae789c646a04746 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:39:25 +0000 Subject: [PATCH 1/3] Add EditorConfig support for shellcheck directives Fix EditorConfig section priority, glob depth matching, and root=true search stop Co-authored-by: Eisuke Kawashima --- ShellCheck.cabal | 1 + shellcheck.1.md | 25 +++ shellcheck.hs | 78 +++++++- src/ShellCheck/EditorConfig.hs | 340 +++++++++++++++++++++++++++++++++ src/ShellCheck/Parser.hs | 5 +- test/shellcheck.hs | 2 + 6 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 src/ShellCheck/EditorConfig.hs 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..9380383b2 100644 --- a/shellcheck.1.md +++ b/shellcheck.1.md @@ -334,6 +334,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..32731a193 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 @@ -110,7 +111,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", @@ -514,8 +515,22 @@ 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 + rcResult <- getRcConfig cache filename + ecResult <- getEditorConfig filename + 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 +556,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..fbaccec0a --- /dev/null +++ b/src/ShellCheck/EditorConfig.hs @@ -0,0 +1,340 @@ +{- + 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) = + case findMatchingBrace rest of + Just (body, rest'') -> + buildAlternation (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]) + + -- Scan past a balanced '{' ... '}' pair, returning the contents of + -- the braces (with any nested braces verbatim) and the remainder. + -- Returns Nothing if the braces are unbalanced. + findMatchingBrace = goBrace 1 "" + where + goBrace _ _ "" = Nothing + goBrace d acc (c:cs) + | c == '}' = + if d == 1 then Just (reverse acc, cs) + else goBrace (d - 1) (c:acc) cs + | c == '{' = goBrace (d + 1) (c:acc) cs + | otherwise = goBrace d (c:acc) cs + + -- Wrap a list of regex alternatives in an anchored group. Empty + -- alternatives are handled so the result is always a valid regex: + -- * all empty -> "" (the whole group matches the empty string) + -- * some empty -> "(a|b)?" (the group is optional) + -- * none empty -> "(a|b|c)" + buildAlternation alts + | all null alts = "" + | any null alts = "(" ++ intercalate "|" (filter (not . null) alts) ++ ")?" + | otherwise = "(" ++ intercalate "|" alts ++ ")" + + -- Expand EditorConfig brace alternatives. Besides the comma + -- separated form, EditorConfig supports numeric ranges like + -- '{1..3}' which match any integer in the range. Commas inside + -- nested braces (e.g. 'ba{r,z}') are not treated as separators. + 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 [] + _ -> splitTopLevelCommas body + + -- Split on commas, but ignore commas that appear inside nested + -- '{...}' pairs so that 'a,b{c,d}' yields ["a", "b{c,d}"]. + splitTopLevelCommas = go 0 "" + where + go _ acc "" = [reverse acc] + go d acc (c:cs) + | c == '{' = go (d + 1) (c:acc) cs + | c == '}' = go (max 0 (d - 1)) (c:acc) cs + | c == ',' && d == 0 = reverse acc : go 0 "" cs + | otherwise = go d (c:acc) cs + + readInt s = + case reads s :: [(Int, String)] of + [(n, "")] -> Just n + _ -> Nothing + +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" +-- Empty brace alternatives (e.g. 'foo{,bar}') make the group optional: +-- 'foo' and 'foobar' both match. +prop_globBraceEmptyAlt = matchesGlob "foo{,bar}" "foo" +prop_globBraceEmptyAlt2 = matchesGlob "foo{,bar}" "foobar" +prop_globBraceEmptyAltNoMatch = not $ matchesGlob "foo{,bar}" "foobaz" +-- Nested braces: '{foo,ba{r,z}}' matches foo, bar and baz. +prop_globBraceNested1 = matchesGlob "{foo,ba{r,z}}" "foo" +prop_globBraceNested2 = matchesGlob "{foo,ba{r,z}}" "bar" +prop_globBraceNested3 = matchesGlob "{foo,ba{r,z}}" "baz" +prop_globBraceNestedNoMatch = not $ matchesGlob "{foo,ba{r,z}}" "baq" + +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" == "" +-- Empty brace alternative makes the glob group optional; 'foo' matches +-- '[foo{,bar}]'. +prop_parseEditorConfigBraceEmpty = + parseEditorConfig "[foo{,bar}]\nshellcheck.shell=sh\n" "foo" == "\nshell=sh\n" +-- Nested braces are expanded correctly; 'baz' matches +-- '[{foo,ba{r,z}}]'. +prop_parseEditorConfigBraceNested = + parseEditorConfig "[{foo,ba{r,z}}]\nshellcheck.shell=sh\n" "baz" == "\nshell=sh\n" +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) From 51df507973a45adc2b064bf55738a496bb20c5e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:02:53 +0000 Subject: [PATCH 2/3] Add --file-name option for stdin EditorConfig resolution Co-authored-by: Freed-Wu <32936898+Freed-Wu@users.noreply.github.com> --- shellcheck.1.md | 6 ++++++ shellcheck.hs | 24 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/shellcheck.1.md b/shellcheck.1.md index 9380383b2..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 diff --git a/shellcheck.hs b/shellcheck.hs index 32731a193..a0f4587d3 100644 --- a/shellcheck.hs +++ b/shellcheck.hs @@ -78,7 +78,8 @@ data Options = Options { sourcePaths :: [FilePath], formatterOptions :: FormatterOptions, minSeverity :: Severity, - rcfile :: Maybe FilePath + rcfile :: Maybe FilePath, + fileNameOverride :: Maybe FilePath } defaultOptions = Options { @@ -89,7 +90,8 @@ defaultOptions = Options { foColorOption = ColorAuto }, minSeverity = StyleC, - rcfile = Nothing + rcfile = Nothing, + fileNameOverride = Nothing } usageHeader = "Usage: shellcheck [OPTIONS...] FILES..." @@ -138,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 @@ -421,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 { @@ -519,8 +529,12 @@ ioInterface options files = do -- merged with any shellcheck.* directives found in applicable -- EditorConfig files. getConfig cache filename = do - rcResult <- getRcConfig cache filename - ecResult <- getEditorConfig filename + 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 = From 0d748c61efeca02536ff02de6b76a86f7989c91a Mon Sep 17 00:00:00 2001 From: "Wu, Zhenyu" Date: Mon, 24 Aug 2026 16:49:36 +0800 Subject: [PATCH 3/3] fix: return 4 for unknown shell --- shellcheck.hs | 45 ++++++++--- src/ShellCheck/Checker.hs | 36 +++++++++ src/ShellCheck/EditorConfig.hs | 141 ++++++++++++++++++++++++++++----- 3 files changed, 191 insertions(+), 31 deletions(-) diff --git a/shellcheck.hs b/shellcheck.hs index a0f4587d3..fce2e6f94 100644 --- a/shellcheck.hs +++ b/shellcheck.hs @@ -300,10 +300,19 @@ runFormatter sys format options files = do } result <- checkScript sys checkspec onResult format result sys + -- A malformed EditorConfig (invalid 'root' or 'shellcheck.*' + -- directive) means shellcheck cannot apply the requested + -- configuration, so it fails rather than silently proceeding. return $ - if null (crComments result) - then NoProblems - else SomeProblems + if any editorConfigError (crComments result) + then SupportFailure + else if null (crComments result) + then NoProblems + else SomeProblems + + editorConfigError pc = + cCode (pcComment pc) == 1134 && + ".editorconfig" `isSuffixOf` posFile (pcStartPos pc) parseEnum name value list = case lookup value list of @@ -535,6 +544,10 @@ ioInterface options files = do else filename rcResult <- getRcConfig cache configFilename ecResult <- getEditorConfig configFilename + -- A rejected EditorConfig blob (one that only contains invalid + -- 'root'/'shellcheck.*' rejections) is surfaced with the + -- EditorConfig source so its SC1134 is attributable to it; this + -- also lets the formatter treat it as a fatal config error. return $ mergeConfigs filename rcResult ecResult mergeConfigs filename rcResult ecResult = @@ -542,7 +555,14 @@ ioInterface options files = do (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) + (Just (rcPath, rc), Just (ecPath, ec)) -> + if isEditorConfigRejection ec + then Just (ecPath, ec) + else Just (rcPath, rc ++ "\n" ++ ec) + + isEditorConfigRejection ec = + all (\l -> null (trim l) || l == "invalid editorconfig value") (lines ec) + getRcConfig cache filename = case rcfile options of @@ -589,17 +609,20 @@ ioInterface options files = do [] -> 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. + -- For each EditorConfig file: report any invalid 'root' + -- declaration (which takes priority) as a rejected config blob, + -- otherwise yield the matching shellcheck.* directives + -- (invalid directives are reported by editorConfigDirectives as a + -- rejected blob so the .shellcheckrc parser emits SC1134). directivesFor path (file, contents) = - case invalidRootLines contents of + let relative = makeRelativeTo (takeDirectory file) path + in 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)] + case editorConfigDirectives contents relative of + Nothing -> [] + Just blob -> [(file, blob)] makeRelativeTo dir path = case stripPrefix (addTrailingSlash dir) path of diff --git a/src/ShellCheck/Checker.hs b/src/ShellCheck/Checker.hs index 8060d05ee..71021d5a9 100644 --- a/src/ShellCheck/Checker.hs +++ b/src/ShellCheck/Checker.hs @@ -22,6 +22,7 @@ module ShellCheck.Checker (checkScript, ShellCheck.Checker.runTests) where import ShellCheck.Analyzer import ShellCheck.ASTLib +import ShellCheck.EditorConfig import ShellCheck.Interface import ShellCheck.Parser @@ -165,6 +166,41 @@ checkWithRcIncludesAndSourcePath rc includes mapper = getErrors siFindSource = mapper } +-- shellcheck.* directives extracted from an EditorConfig file are merged +-- into the same "key=value" blob as .shellcheckrc. We simulate that here +-- by feeding editorConfigDirectives' output through siGetConfig. +checkWithEditorConfig ec name src = + let sys = (mockedSystemInterface [("foo", src)]) { + siGetConfig = const . return $ + Just (".editorconfig", fromMaybe "" $ editorConfigDirectives ec name) + } + in getErrors sys emptyCheckSpec { + csScript = src, + csExcludedWarnings = [2148] + } + +prop_editorConfigAppliesKnownShell = + null $ checkWithEditorConfig "[foo]\nshellcheck.shell=bash\n" "foo" + "#!/bin/sh\necho \"hi\"" +prop_editorConfigAppliesDisable = + null $ checkWithEditorConfig "[foo]\nshellcheck.disable=SC2086\n" "foo" + "#!/bin/sh\necho $1" +prop_editorConfigUnknownShellIsReported = + -- An unknown shell can't be applied silently; it surfaces as a + -- config parse error (SC1134) rather than being dropped. + [1134] == checkWithEditorConfig "[foo]\nshellcheck.shell=zsh\n" "foo" + "#!/bin/sh\necho \"hi\"" +prop_editorConfigCommentValueIsReported = + -- EditorConfig has no inline comments, so a '#'-prefixed value is + -- reported as a config error (SC1134) instead of being eaten. + [1134] == checkWithEditorConfig "[foo]\nshellcheck.disable=#abc\n" "foo" + "#!/bin/sh\necho \"hi\"" +prop_editorConfigNonMatchingSectionIgnored = + -- A directive in a section whose glob does not match the file is + -- not applied (and produces no config error). + [2086] == checkWithEditorConfig "[bar]\nshellcheck.disable=SC2086\n" "foo" + "#!/bin/sh\necho $1" + prop_findsParseIssue = check "echo \"$12\"" == [1037] prop_commentDisablesParseIssue1 = diff --git a/src/ShellCheck/EditorConfig.hs b/src/ShellCheck/EditorConfig.hs index fbaccec0a..d43923810 100644 --- a/src/ShellCheck/EditorConfig.hs +++ b/src/ShellCheck/EditorConfig.hs @@ -24,7 +24,7 @@ -- 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 +module ShellCheck.EditorConfig (parseEditorConfig, isEditorConfigRoot, invalidRootLines, invalidDirectiveLines, editorConfigDirectives, globToRegexString, runTests) where import Data.Char import Data.List @@ -45,7 +45,7 @@ import Test.QuickCheck -- (for the same key), so on conflicts the last matching section wins. parseEditorConfig :: String -> FilePath -> String parseEditorConfig contents name = - renderDirectives . lastWins . concatMap sectionDirectives $ sections + renderDirectives . lastWins . mapMaybe usable $ allDirectives name 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. @@ -59,6 +59,11 @@ parseEditorConfig contents name = where keyOf (_, key, _) = key + usable (line, key, value) = + if isUsableDirective key value + then Just (line, key, value) + else Nothing + ls = lines contents sections = splitSections 1 ls @@ -76,33 +81,103 @@ parseEditorConfig contents name = ('[':cs@(_:_)) | last cs == ']' -> Just (init cs) _ -> Nothing - sectionDirectives (pat, body) = + -- All 'shellcheck.*' directives (as (line, key, value) tuples) found + -- in sections whose glob matches the file being checked. + allDirectives name = concatMap (sectionDirectives name) + + sectionDirectives name (pat, body) = if matchesGlob pat name - then mapMaybe toDirective body + then mapMaybe toDirectivePair body else [] - toDirective (line, l) = - let t = dropLineComment l - in case break (== '=') (trim t) of + toDirectivePair (line, l) = + case break (== '=') (trim (dropLineComment l)) 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 + then Just (line, drop (length "shellcheck.") key', value') 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. + -- dropped here and reported via invalidDirectiveLines. Values that + -- start with '#' or ';' are also dropped and reported: EditorConfig + -- has no inline comments, so such values are otherwise silently + -- eaten by the .shellcheckrc parser's trailing-comment handling + -- (e.g. 'disable=#abc'). Empty values for other keys are dropped + -- too: the rc parser would silently accept them. + isUsableDirective "shell" value = null value || isJust (shellForExecutable value) + isUsableDirective _ value = not (null value) && head value `notElem` "#;" + +-- Returns the 1-based line numbers of invalid 'shellcheck.*' directives, +-- i.e.: +-- * 'shellcheck.shell=' where is a non-empty, unknown shell +-- (e.g. 'zsh'), which would otherwise silently suppress the SC2148 +-- "unknown shell" warning; +-- * any 'shellcheck.=' whose (trimmed) value starts with +-- '#' or ';', since EditorConfig does not allow inline comments and +-- such values are otherwise silently eaten by the .shellcheckrc +-- parser's trailing-comment handling (e.g. 'disable=#abc'). +-- Only directives in sections whose glob matches the file are reported. +invalidDirectiveLines :: String -> FilePath -> [Int] +invalidDirectiveLines contents name = + [ line | (line, key, value) <- allDirectives name sections + , not (isUsableDirective key value) ] + where isUsableDirective "shell" value = null value || isJust (shellForExecutable value) - isUsableDirective _ value = not (null value) + isUsableDirective _ value = not (null value) && head value `notElem` "#;" + + sections = splitSections 1 (lines contents) + + 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 + + allDirectives name = concatMap (sectionDirectives name) + + sectionDirectives name (pat, body) = + if matchesGlob pat name + then mapMaybe toDirectivePair body + else [] + + toDirectivePair (line, l) = + case break (== '=') (trim (dropLineComment l)) of + (key, '=':value) -> + let key' = trim key + value' = trim value + in if "shellcheck." `isPrefixOf` key' + then Just (line, drop (length "shellcheck.") key', value') + else Nothing + _ -> Nothing + +-- Build the directive blob contributed by a single EditorConfig file for +-- the given file being checked. Returns Nothing if the file contributes +-- nothing (no matching section, or only empty values). Returns a blob of +-- "key=value\n" directives when the matching sections are valid, or a +-- single rejected line at the position of any invalid 'shellcheck.*' +-- directive so that the .shellcheckrc parser reports it as SC1134. +editorConfigDirectives :: String -> FilePath -> Maybe String +editorConfigDirectives contents name = + let badLines = invalidDirectiveLines contents name + in if not (null badLines) + then Just . unlines $ map rejectedLine (nub (sort badLines)) + else let result = parseEditorConfig contents name + in if null result then Nothing else Just result + where + rejectedLine n = replicate (n - 1) '\n' ++ "invalid editorconfig value" -- Does the top-level (pre-section) part of an EditorConfig file -- declare "root = true"? Per the spec, this stops the search for @@ -301,17 +376,18 @@ prop_parseEditorConfig5 = 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. +-- An unsupported shell is not emitted as a usable directive; instead its +-- line is reported via invalidDirectiveLines so the caller can reject it. 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. +-- EditorConfig does not allow inline comments, so a trailing '# ...' +-- makes the value invalid (the .shellcheckrc parser's +-- shellForExecutable lookup fails on the embedded text), and the +-- directive is dropped. It is reported via invalidDirectiveLines. prop_parseEditorConfigInlineComment = parseEditorConfig "[*]\nshellcheck.shell=bash # inline\n" "foo" == "" -- Full-line comments starting on first non-ws char are stripped. @@ -335,6 +411,31 @@ prop_invalidRootLinesTrue = invalidRootLines "root = true\n" == [] prop_invalidRootLinesFalse = invalidRootLines "root = false\n" == [] prop_invalidRootLinesInSection = invalidRootLines "[*]\nroot = true\n" == [] +-- An unsupported non-empty shell is reported at its line. +prop_invalidDirectiveLinesUnknownShell = + invalidDirectiveLines "[*]\nshellcheck.shell=zsh\n" "foo" == [2] +-- An empty shell is valid (the rc parser rejects it), so no error. +prop_invalidDirectiveLinesEmptyShell = + invalidDirectiveLines "[*]\nshellcheck.shell=\n" "foo" == [] +-- A known shell is fine. +prop_invalidDirectiveLinesKnownShell = + invalidDirectiveLines "[*]\nshellcheck.shell=bash\n" "foo" == [] +-- A '#'-prefixed value is reported (EditorConfig has no inline comments). +prop_invalidDirectiveLinesHashValue = + invalidDirectiveLines "[foo]\nshellcheck.disable = #abc\n" "foo" == [2] +-- A ';'-prefixed value is reported too. +prop_invalidDirectiveLinesSemicolonValue = + invalidDirectiveLines "[foo]\nshellcheck.disable = ;abc\n" "foo" == [2] +-- A plain invalid value (no comment marker) is not reported here; it is +-- rejected by the .shellcheckrc parser as SC1134 instead. +prop_invalidDirectiveLinesPlainValue = + invalidDirectiveLines "[foo]\nshellcheck.disable = abc\n" "foo" == [] +-- Directives in non-matching sections are ignored. +prop_invalidDirectiveLinesNoMatch = + invalidDirectiveLines "[*.txt]\nshellcheck.shell=zsh\n" "foo" == [] +-- Only the matching section's invalid directive is reported. +prop_invalidDirectiveLinesMatchingSection = + invalidDirectiveLines "[*.txt]\nshellcheck.shell=zsh\n[foo]\nshellcheck.shell=bash\n" "foo" == [] return [] runTests = $quickCheckAll