Skip to content

Commit 72371d8

Browse files
CopilotFreed-Wu
andauthored
Fix EditorConfig section priority, glob depth matching, and root=true search stop
Co-authored-by: Freed-Wu <32936898+Freed-Wu@users.noreply.github.com>
1 parent ac315e5 commit 72371d8

2 files changed

Lines changed: 59 additions & 8 deletions

File tree

shellcheck.hs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -597,11 +597,12 @@ ioInterface options files = do
597597
| otherwise = dir ++ "/"
598598

599599
collectDirConfigs dir = do
600-
let next = takeDirectory dir
601-
rest <- if next /= dir
600+
current <- readConfig (dir </> ".editorconfig")
601+
let isRoot = maybe False (isEditorConfigRoot . snd) current
602+
next = takeDirectory dir
603+
rest <- if next /= dir && not isRoot
602604
then collectDirConfigs next
603605
else return []
604-
current <- readConfig (dir </> ".editorconfig")
605606
return $ maybeToList current ++ rest
606607

607608
readGlobalEditorConfig = do

src/ShellCheck/EditorConfig.hs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
-- of sections whose glob matches the file being checked are extracted,
2525
-- and turned into the same "key=value" directive syntax that is used in
2626
-- .shellcheckrc files.
27-
module ShellCheck.EditorConfig (parseEditorConfig, globToRegexString, runTests) where
27+
module ShellCheck.EditorConfig (parseEditorConfig, isEditorConfigRoot, globToRegexString, runTests) where
2828

2929
import Data.Char
3030
import Data.List
@@ -38,10 +38,20 @@ import Test.QuickCheck
3838
-- file being checked, return the shellcheck directives (as a
3939
-- "key=value\n" delimited blob, suitable for feeding into the same
4040
-- parser as .shellcheckrc) found in matching sections.
41+
--
42+
-- As per the EditorConfig spec, files are read top to bottom and
43+
-- properties from later sections override those from earlier ones
44+
-- (for the same key), so on conflicts the last matching section wins.
4145
parseEditorConfig :: String -> FilePath -> String
4246
parseEditorConfig contents name =
43-
unlines . concatMap sectionDirectives $ sections
47+
unlines . map render . lastWins . concatMap sectionDirectives $ sections
4448
where
49+
render (key, value) = key ++ "=" ++ value
50+
51+
-- Keep only the last occurrence of each key, preserving the
52+
-- relative order of the remaining (first-seen) entries.
53+
lastWins = reverse . nubBy (\a b -> fst a == fst b) . reverse
54+
4555
ls = lines contents
4656
sections = splitSections ls
4757

@@ -71,24 +81,48 @@ parseEditorConfig contents name =
7181
let key' = trim key
7282
value' = trim value
7383
in if "shellcheck." `isPrefixOf` key'
74-
then Just (drop (length "shellcheck.") key' ++ "=" ++ value')
84+
then Just (drop (length "shellcheck.") key', value')
7585
else Nothing
7686
_ -> Nothing
7787

7888
stripComment = takeWhile (\c -> c /= '#' && c /= ';')
7989

90+
-- Does the top-level (pre-section) part of an EditorConfig file
91+
-- declare "root = true"? Per the spec, this stops the search for
92+
-- further EditorConfig files in parent directories.
93+
isEditorConfigRoot :: String -> Bool
94+
isEditorConfigRoot contents =
95+
any isRootTrue . takeWhile (not . isSectionHeader) $ lines contents
96+
where
97+
isSectionHeader l =
98+
case trim (stripComment l) of
99+
('[':cs@(_:_)) -> last cs == ']'
100+
_ -> False
101+
102+
isRootTrue l =
103+
case break (== '=') (trim (stripComment l)) of
104+
(key, '=':value) ->
105+
map toLower (trim key) == "root" && map toLower (trim value) == "true"
106+
_ -> False
107+
108+
stripComment = takeWhile (\c -> c /= '#' && c /= ';')
109+
80110
trim :: String -> String
81111
trim = dropWhileEnd isSpace . dropWhile isSpace
82112

83-
-- Does the (basename of the) file match the given EditorConfig glob?
113+
-- Does the (relative path of the) file match the given EditorConfig glob?
84114
matchesGlob :: String -> FilePath -> Bool
85115
matchesGlob pattern name =
86116
name `matches` mkRegex (globToRegexString pattern)
87117

88118
-- Translate an EditorConfig glob pattern into an anchored regex string.
119+
-- Per the spec, patterns without a path separator are matched against
120+
-- the file at any depth (as if prefixed with "**/").
89121
globToRegexString :: String -> String
90-
globToRegexString pattern = "^" ++ go pattern ++ "$"
122+
globToRegexString pattern = "^" ++ prefix ++ go pattern ++ "$"
91123
where
124+
prefix = if '/' `elem` pattern then "" else "(.*/)?"
125+
92126
go [] = ""
93127
go ('*':'*':rest) = ".*" ++ go rest
94128
go ('*':rest) = "[^/]*" ++ go rest
@@ -129,6 +163,13 @@ prop_globNoMatch = not $ matchesGlob "*.ebuild" "foo.txt"
129163
prop_globQuestion = matchesGlob "foo?.sh" "food.sh"
130164
prop_globClass = matchesGlob "foo[0-9].sh" "foo1.sh"
131165
prop_globClassNeg = not $ matchesGlob "foo[!0-9].sh" "foo1.sh"
166+
-- Patterns without a path separator should match at any depth.
167+
prop_globAnyDepth = matchesGlob "*.sh" "sub/dir/foo.sh"
168+
prop_globAnyDepthPlain = matchesGlob "foo" "sub/foo"
169+
-- Patterns with a path separator are only matched against the full
170+
-- relative path.
171+
prop_globWithSlashNoMatch = not $ matchesGlob "sub/*.sh" "other/foo.sh"
172+
prop_globWithSlashMatch = matchesGlob "sub/*.sh" "sub/foo.sh"
132173

133174
prop_parseEditorConfig1 =
134175
parseEditorConfig "[*.{ebuild,eclass}]\nshellcheck.shell=bash\nshellcheck.disable=SC2034\n" "foo.ebuild"
@@ -140,6 +181,15 @@ prop_parseEditorConfig3 =
140181
prop_parseEditorConfig4 =
141182
parseEditorConfig "root = true\n[*.sh]\nindent_style = space\nshellcheck.shell=bash\n" "foo.sh"
142183
== "shell=bash\n"
184+
-- A later, more specific section overrides an earlier, more general
185+
-- one for the same key.
186+
prop_parseEditorConfig5 =
187+
parseEditorConfig "[*]\nshellcheck.shell=sh\n\n[foo]\nshellcheck.shell=bash\n" "foo"
188+
== "shell=bash\n"
189+
-- Non-conflicting keys from earlier and later sections are all kept.
190+
prop_parseEditorConfig6 =
191+
parseEditorConfig "[*]\nshellcheck.shell=sh\n\n[foo]\nshellcheck.disable=SC2034\n" "foo"
192+
== "shell=sh\ndisable=SC2034\n"
143193

144194
return []
145195
runTests = $quickCheckAll

0 commit comments

Comments
 (0)