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
2929import Data.Char
3030import 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.
4145parseEditorConfig :: String -> FilePath -> String
4246parseEditorConfig 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+
80110trim :: String -> String
81111trim = 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?
84114matchesGlob :: String -> FilePath -> Bool
85115matchesGlob 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 "**/").
89121globToRegexString :: 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"
129163prop_globQuestion = matchesGlob " foo?.sh" " food.sh"
130164prop_globClass = matchesGlob " foo[0-9].sh" " foo1.sh"
131165prop_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
133174prop_parseEditorConfig1 =
134175 parseEditorConfig " [*.{ebuild,eclass}]\n shellcheck.shell=bash\n shellcheck.disable=SC2034\n " " foo.ebuild"
@@ -140,6 +181,15 @@ prop_parseEditorConfig3 =
140181prop_parseEditorConfig4 =
141182 parseEditorConfig " root = true\n [*.sh]\n indent_style = space\n shellcheck.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 " [*]\n shellcheck.shell=sh\n\n [foo]\n shellcheck.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 " [*]\n shellcheck.shell=sh\n\n [foo]\n shellcheck.disable=SC2034\n " " foo"
192+ == " shell=sh\n disable=SC2034\n "
143193
144194return []
145195runTests = $ quickCheckAll
0 commit comments