Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ShellCheck.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ library
ShellCheck.Checks.Custom
ShellCheck.Checks.ShellSupport
ShellCheck.Data
ShellCheck.EditorConfig
ShellCheck.Fixer
ShellCheck.Formatter.Format
ShellCheck.Formatter.CheckStyle
Expand Down
31 changes: 31 additions & 0 deletions shellcheck.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
83 changes: 77 additions & 6 deletions shellcheck.hs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import qualified ShellCheck.Analyzer
import ShellCheck.Checker
import ShellCheck.Data
import ShellCheck.EditorConfig
import ShellCheck.Interface
import ShellCheck.Regex

Expand Down Expand Up @@ -77,7 +78,8 @@ data Options = Options {
sourcePaths :: [FilePath],
formatterOptions :: FormatterOptions,
minSeverity :: Severity,
rcfile :: Maybe FilePath
rcfile :: Maybe FilePath,
fileNameOverride :: Maybe FilePath
}

defaultOptions = Options {
Expand All @@ -88,7 +90,8 @@ defaultOptions = Options {
foColorOption = ColorAuto
},
minSeverity = StyleC,
rcfile = Nothing
rcfile = Nothing,
fileNameOverride = Nothing
}

usageHeader = "Usage: shellcheck [OPTIONS...] FILES..."
Expand All @@ -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",
Expand All @@ -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

Expand Down Expand Up @@ -420,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 {
Expand Down Expand Up @@ -514,8 +525,26 @@ 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
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.
Expand All @@ -541,6 +570,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
Expand Down
195 changes: 195 additions & 0 deletions src/ShellCheck/EditorConfig.hs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
-}

{-# 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not support {num1..num2} pattern.

| 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
Loading
Loading