vcs-ignore-0.1.0.0: src/Data/VCS/Ignore/Git/Internal/Pattern.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
-- |
-- Module : Data.VCS.Ignore.Git.Internal.Pattern
-- Description : Parsing and evaluation of Git ignore patterns
-- Copyright : (c) 2020-2026 Vaclav Svejcar
-- License : BSD-3-Clause
-- Maintainer : vaclav.svejcar@gmail.com
-- Stability : experimental
-- Portability : portable
module Data.VCS.Ignore.Git.Internal.Pattern (
Pattern,
PatternGroup (..),
parsePatterns,
loadPatternsFile,
evaluatePatternGroups,
) where
import Control.Exception (IOException, catch, throwIO)
import qualified Data.List as L
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Data.VCS.Ignore.Types (PathKind (..))
import System.Directory (
doesDirectoryExist,
doesFileExist,
pathIsSymbolicLink,
)
import System.FilePath (pathSeparator)
import qualified System.FilePath.Glob as G
import System.IO.Error (
isDoesNotExistError,
tryIOError,
)
-- | A compiled Git ignore rule. Its representation is deliberately private so
-- callers cannot construct rules that bypass Git's line parsing semantics.
data Pattern = Pattern
{ patternMatchers :: [G.Pattern]
, patternNegated :: Bool
, patternDirectoryOnly :: Bool
}
deriving (Eq, Show)
-- | Patterns from one ignore source, scoped to a repository-relative prefix.
-- Prefixes use POSIX separators and are conventionally written as @/@ for the
-- repository root or @/directory/@ for a nested ignore file.
data PatternGroup = PatternGroup
{ patternGroupPrefix :: FilePath
, patternGroupPatterns :: [Pattern]
}
deriving (Eq, Show)
-- | Parses the contents of an ignore file. Blank lines and comments are
-- discarded. CRLF, escaped leading hash/bang characters and Git's trailing
-- space rules are handled before compiling each pattern.
parsePatterns :: T.Text -> [Pattern]
parsePatterns = foldr parseLine [] . T.lines
where
parseLine raw patterns =
case prepareLine raw of
Nothing -> patterns
Just line -> compilePattern line : patterns
-- | Loads and parses an ignore file. The flag controls whether a symbolic link
-- may be followed: global and repository exclude files use 'True', while a
-- working-tree @.gitignore@ uses 'False' to match Git's behaviour.
--
-- A missing file is an empty source. Other I/O failures are propagated.
loadPatternsFile :: Bool -> FilePath -> IO [Pattern]
loadPatternsFile followSymbolicLink path = do
symbolicLinkResult <- tryIOError $ pathIsSymbolicLink path
case symbolicLinkResult of
Left error'
| isDoesNotExistError error' -> pure []
| otherwise -> ioError error'
Right True
| not followSymbolicLink -> pure []
| otherwise -> do
isRegularFile <- doesFileExist path
if isRegularFile then parsePatterns <$> readPatterns else pure []
Right False -> do
isDirectory <- doesDirectoryExist path
if isDirectory
then pure []
else do
isRegularFile <- doesFileExist path
if isRegularFile then parsePatterns <$> readPatterns else pure []
where
readPatterns = T.readFile path `catch` handleMissing
handleMissing error'
| isDoesNotExistError error' = pure T.empty
| otherwise = throwIO (error' :: IOException)
-- | Evaluates groups ordered from lowest to highest precedence. Within each
-- group, patterns retain file order. Consequently, the last matching rule is
-- authoritative. The result is 'True' when the path is ignored.
evaluatePatternGroups :: PathKind -> [PatternGroup] -> FilePath -> Bool
evaluatePatternGroups kind groups path =
fromMaybe False $ L.foldl' applyGroup Nothing groups
where
candidate = addLeadingSlash . toPosix $ path
applyGroup result group
| groupApplies prefix candidate =
L.foldl' (applyPattern prefix) result $ patternGroupPatterns group
| otherwise = result
where
prefix = normalizePrefix $ patternGroupPrefix group
applyPattern prefix result pattern'
| matchesPattern kind pattern' (pathForGroup prefix candidate) =
Just . not $ patternNegated pattern'
| otherwise = result
prepareLine :: T.Text -> Maybe T.Text
prepareLine raw
| T.null line = Nothing
| "#" `T.isPrefixOf` line = Nothing
| otherwise = Just line
where
line = stripUnescapedTrailingSpaces . T.dropWhileEnd (== '\r') $ raw
compilePattern :: T.Text -> Pattern
compilePattern line =
Pattern
{ patternMatchers =
if hasDanglingEscape body
then []
else compileGlob . T.unpack <$> matcherSources patternBody directoryOnly
, patternNegated = negated
, patternDirectoryOnly = directoryOnly
}
where
(negated, body) = case T.uncons line of
Just ('!', rest) -> (True, rest)
_ -> (False, line)
directoryOnly = "/" `T.isSuffixOf` body
withoutDirectoryMarker = fromMaybe body $ T.stripSuffix "/" body
patternBody = unescapePattern withoutDirectoryMarker
matcherSources :: T.Text -> Bool -> [T.Text]
matcherSources raw directoryOnly
| T.null raw = []
| directoryOnly = [expandTrailingRecursive scoped]
| "/**" `T.isSuffixOf` scoped = [expandTrailingRecursive scoped]
| otherwise = [scoped]
where
scoped = case T.stripPrefix "/" raw of
Just anchored -> "/" <> anchored
Nothing
| "**/" `T.isPrefixOf` raw -> raw
| "/" `T.isInfixOf` raw -> "/" <> raw
| otherwise -> "**/" <> raw
-- Glob recognizes recursive wildcards in the @**/@ form. Git also gives a
-- trailing @/**@ recursive meaning, so append a final wildcard component to
-- preserve that behaviour at arbitrary depth.
expandTrailingRecursive :: T.Text -> T.Text
expandTrailingRecursive source
| "/**" `T.isSuffixOf` source = source <> "/*"
| otherwise = source
compileGlob :: String -> G.Pattern
compileGlob =
G.compileWith
G.compDefault
{ G.numberRanges = False
, G.pathSepInRanges = False
}
matchesPattern :: PathKind -> Pattern -> FilePath -> Bool
matchesPattern kind pattern' candidate =
(not (patternDirectoryOnly pattern') || kind == Directory)
&& any (`G.match` candidate) (patternMatchers pattern')
pathForGroup :: FilePath -> FilePath -> FilePath
pathForGroup "/" candidate = candidate
pathForGroup prefix candidate =
addLeadingSlash . fromMaybe candidate $ L.stripPrefix prefix candidate
groupApplies :: FilePath -> FilePath -> Bool
groupApplies "/" _ = True
groupApplies prefix candidate =
prefix `L.isPrefixOf` addTrailingSlash candidate
&& prefix /= addTrailingSlash candidate
normalizePrefix :: FilePath -> FilePath
normalizePrefix prefix
| stripped == "" = "/"
| otherwise = addTrailingSlash . addLeadingSlash $ stripped
where
stripped = dropWhile (== '/') . L.dropWhileEnd (== '/') . toPosix $ prefix
stripUnescapedTrailingSpaces :: T.Text -> T.Text
stripUnescapedTrailingSpaces text = case T.unsnoc text of
Just (prefix, ' ') ->
let slashes = T.takeWhileEnd (== '\\') prefix
beforeSlashes = T.dropEnd (T.length slashes) prefix
in if odd (T.length slashes)
then beforeSlashes <> T.dropEnd 1 slashes <> " "
else stripUnescapedTrailingSpaces prefix
_ -> text
hasDanglingEscape :: T.Text -> Bool
hasDanglingEscape = odd . T.length . T.takeWhileEnd (== '\\')
unescapePattern :: T.Text -> T.Text
unescapePattern = T.pack . go . T.unpack
where
go [] = []
go ['\\'] = ['\\']
go ('[' : rest) =
case takeCharacterClass [] rest of
Nothing -> "[[]" <> go rest
Just (body, remaining) ->
'[' : (unescapeClass body <> (']' : go remaining))
go ('\\' : char : rest) = escapeGlobLiteral char <> go rest
go (char : rest) = char : go rest
takeCharacterClass _ [] = Nothing
takeCharacterClass prefix ('\\' : char : rest) =
takeCharacterClass (char : '\\' : prefix) rest
takeCharacterClass prefix (']' : rest) = Just (reverse prefix, rest)
takeCharacterClass prefix (char : rest) =
takeCharacterClass (char : prefix) rest
unescapeClass body =
case unescapeClassBody body of
(False, characters) -> characters
(True, '!' : characters) -> '!' : '-' : characters
(True, '^' : characters) -> '^' : '-' : characters
(True, characters) -> '-' : characters
unescapeClassBody [] = (False, [])
unescapeClassBody ('\\' : '-' : rest) =
let (_, characters) = unescapeClassBody rest
in (True, characters)
unescapeClassBody ('\\' : char : rest) =
let (hasLiteralHyphen, characters) = unescapeClassBody rest
in (hasLiteralHyphen, escapeGlobLiteral char <> characters)
unescapeClassBody (char : rest) =
let (hasLiteralHyphen, characters) = unescapeClassBody rest
in (hasLiteralHyphen, char : characters)
escapeGlobLiteral '*' = "[*]"
escapeGlobLiteral '?' = "[?]"
escapeGlobLiteral '[' = "[[]"
escapeGlobLiteral char = [char]
addLeadingSlash :: FilePath -> FilePath
addLeadingSlash path
| "/" `L.isPrefixOf` path = path
| otherwise = '/' : path
addTrailingSlash :: FilePath -> FilePath
addTrailingSlash path
| "/" `L.isSuffixOf` path = path
| otherwise = path <> "/"
toPosix :: FilePath -> FilePath
toPosix = fmap replaceSeparator
where
replaceSeparator char
| char == pathSeparator = '/'
| otherwise = char