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..fce2e6f94 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
@@ -294,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
@@ -420,6 +435,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 +534,37 @@ 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
+ -- 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 =
+ 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 (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
Just file -> do
-- We have a specified rcfile. Ignore normal rcfile resolution.
@@ -541,6 +590,66 @@ 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: 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) =
+ let relative = makeRelativeTo (takeDirectory file) path
+ in case invalidRootLines contents of
+ (badLine:_) ->
+ [(file, replicate (badLine - 1) '\n' ++ "invalid editorconfig value\n")]
+ [] ->
+ case editorConfigDirectives contents relative of
+ Nothing -> []
+ Just blob -> [(file, blob)]
+
+ 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/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
new file mode 100644
index 000000000..8a8ae29be
--- /dev/null
+++ b/src/ShellCheck/EditorConfig.hs
@@ -0,0 +1,468 @@
+{-
+ 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, invalidDirectiveLines, editorConfigDirectives, 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 . 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.
+ 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
+
+ usable (line, key, value) =
+ if isUsableDirective key value
+ then Just (line, key, value)
+ else Nothing
+
+ 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
+
+ -- 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 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
+
+ -- Only emit directives that the .shellcheckrc parser can handle
+ -- unambiguously. Unknown shells (e.g. 'shellcheck.shell=zsh') would
+ -- otherwise silently suppress the SC2148 "unknown shell" warning, so
+ -- they are dropped here and reported via invalidDirectiveLines.
+ -- Values containing '#' or ';' anywhere (not just at the start) are
+ -- also dropped and reported: EditorConfig has no inline comments, so
+ -- such a value is otherwise silently truncated by the .shellcheckrc
+ -- parser's trailing-comment handling (e.g. both 'disable=#abc' and
+ -- 'disable=SC2148 #abc' would silently lose the part from '#'
+ -- onwards). Empty values for other keys are simply skipped (no-op):
+ -- the rc parser would silently accept them anyway.
+ isUsableDirective "shell" value = null value || isJust (shellForExecutable value)
+ isUsableDirective _ value = not (null value) && not (hasCommentMarker value)
+
+ hasCommentMarker = any (`elem` "#;")
+
+-- 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 contains '#'
+-- or ';' anywhere, since EditorConfig does not allow inline comments
+-- and such a value is otherwise silently truncated by the
+-- .shellcheckrc parser's trailing-comment handling (e.g. both
+-- 'disable=#abc' and 'disable=SC2148 #abc' would silently lose the
+-- part from '#' onwards). Plain empty values are not reported here:
+-- they are simply no-ops.
+-- 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
+ , isInvalidDirective key value ]
+ where
+ isInvalidDirective "shell" value = not (null value) && isNothing (shellForExecutable value)
+ isInvalidDirective _ value = hasCommentMarker value
+
+ hasCommentMarker = any (`elem` "#;")
+
+ 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
+-- 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 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" == ""
+-- A '#' embedded anywhere in the value (not just at the start) makes it
+-- invalid too, since EditorConfig has no inline comments and the
+-- .shellcheckrc parser would otherwise silently truncate it at the '#'.
+prop_parseEditorConfigEmbeddedComment =
+ parseEditorConfig "[*]\nshellcheck.disable=SC2148 #abc\n" "foo" == ""
+-- 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.
+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" == []
+-- 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 '#' embedded anywhere in the value (not just at the start) is
+-- reported too: previously this was silently accepted, since the
+-- .shellcheckrc parser would treat everything from the '#' onwards as a
+-- trailing comment (e.g. this used to disable SC2148 without warning).
+prop_invalidDirectiveLinesEmbeddedHashValue =
+ invalidDirectiveLines "[foo]\nshellcheck.disable = SC2148 #abc\n" "foo" == [2]
+prop_invalidDirectiveLinesEmbeddedSemicolonValue =
+ invalidDirectiveLines "[foo]\nshellcheck.disable = SC2148 ;abc\n" "foo" == [2]
+-- A plain empty value (no comment marker) is not reported: it is simply
+-- a no-op, unlike a value that was truncated down to empty by a leading
+-- comment marker.
+prop_invalidDirectiveLinesEmptyValueNotInvalid =
+ invalidDirectiveLines "[foo]\nshellcheck.disable =\n" "foo" == []
+-- 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
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)