From b96b058087da261a12d18a4c44246d1856231746 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/2] Add EditorConfig support for shellcheck directives Fix EditorConfig section priority, glob depth matching, and root=true search stop Co-authored-by: Freed-Wu <32936898+Freed-Wu@users.noreply.github.com> --- ShellCheck.cabal | 1 + shellcheck.1.md | 25 +++++ shellcheck.hs | 63 ++++++++++- src/ShellCheck/EditorConfig.hs | 195 +++++++++++++++++++++++++++++++++ test/shellcheck.hs | 2 + 5 files changed, 283 insertions(+), 3 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..acaeb038e 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 (_, rc), Nothing) -> Just (filename, rc) + (Nothing, Just ec) -> Just (filename, ec) + (Just (_, rc), Just ec) -> Just (filename, 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,48 @@ 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 + path <- normalize filename + dirConfigs <- collectDirConfigs (takeDirectory path) + globalConfig <- readGlobalEditorConfig + let directives = concatMap (directivesFor path) (dirConfigs ++ globalConfig) + return $ if null directives then Nothing else Just (concat directives) + where + directivesFor path (file, contents) = + let relative = makeRelativeTo (takeDirectory file) path + result = parseEditorConfig contents relative + in [result | not (null 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..554126e2e --- /dev/null +++ b/src/ShellCheck/EditorConfig.hs @@ -0,0 +1,195 @@ +{- + 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, globToRegexString, runTests) where + +import Data.Char +import Data.List +import Data.Maybe + +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 = + unlines . map render . lastWins . concatMap sectionDirectives $ sections + where + render (key, value) = key ++ "=" ++ value + + -- Keep only the last occurrence of each key, preserving the + -- relative order of the remaining (first-seen) entries. + lastWins = reverse . nubBy (\a b -> fst a == fst b) . reverse + + ls = lines contents + sections = splitSections ls + + splitSections [] = [] + splitSections (l:rest) = + case parseHeader l of + Just pat -> + let (body, rest') = break (isJust . parseHeader) rest + in (pat, body) : splitSections rest' + Nothing -> splitSections rest + + parseHeader l = + let t = trim (stripComment 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 l = + let t = trim (stripComment l) + in case break (== '=') t of + (key, '=':value) -> + let key' = trim key + value' = trim value + in if "shellcheck." `isPrefixOf` key' + then Just (drop (length "shellcheck.") key', value') + else Nothing + _ -> Nothing + + stripComment = takeWhile (\c -> c /= '#' && c /= ';') + +-- 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 isRootTrue . takeWhile (not . isSectionHeader) $ lines contents + where + isSectionHeader l = + case trim (stripComment l) of + ('[':cs@(_:_)) -> last cs == ']' + _ -> False + + isRootTrue l = + case break (== '=') (trim (stripComment l)) of + (key, '=':value) -> + map toLower (trim key) == "root" && map toLower (trim value) == "true" + _ -> False + + stripComment = takeWhile (\c -> c /= '#' && c /= ';') + +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 (splitCommas 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]) + + 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" + +prop_parseEditorConfig1 = + parseEditorConfig "[*.{ebuild,eclass}]\nshellcheck.shell=bash\nshellcheck.disable=SC2034\n" "foo.ebuild" + == "shell=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" == "disable=SC2034\n" +prop_parseEditorConfig4 = + parseEditorConfig "root = true\n[*.sh]\nindent_style = space\nshellcheck.shell=bash\n" "foo.sh" + == "shell=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" + == "shell=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" + == "shell=sh\ndisable=SC2034\n" + +return [] +runTests = $quickCheckAll 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 5da4a5f9d74401ce8baba925f39123e95a11ccce 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/2] 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 acaeb038e..8108ce7b8 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 =