packages feed

tilia-0.1.0.0: src/Tilia/Ignore.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Ignore files written the way @.gitignore@ files are.
module Tilia.Ignore
  ( IgnoreFile,
    parseIgnoreFile,
    isIgnored,
    isIgnoredInProject,
    ignoreFilesAbove,
    belowRoot,
  )
where

import Data.Char
  ( isAlpha,
    isAlphaNum,
    isControl,
    isDigit,
    isHexDigit,
    isLower,
    isPrint,
    isPunctuation,
    isSpace,
    isSymbol,
    isUpper,
  )
import Data.List (inits, isPrefixOf, isSuffixOf, stripPrefix, tails)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import System.Directory (canonicalizePath, doesFileExist)
import System.FilePath (joinPath, normalise, splitDirectories, (</>))
import Tilia.Utils (quietly)

-- | The patterns of one ignore file, in the order they are written.
newtype IgnoreFile = IgnoreFile [Pattern]

-- | One line of an ignore file.
data Pattern = Pattern
  { -- | Whether it begins with @!@ and so re-includes what it matches.
    patNegated :: Bool,
    -- | Whether it ends with @/@ and so matches directories alone.
    patDirectoryOnly :: Bool,
    -- | Whether it holds a @/@ other than a trailing one, and so matches
    -- the path from the ignore file's directory rather than the last name.
    patAnchored :: Bool,
    -- | What it matches.
    patTokens :: [Token]
  }

-- | One piece of a pattern.
data Token
  = -- | A character that stands for itself.
    Literal Char
  | -- | @?@, any character but @/@.
    AnyChar
  | -- | @*@, any run of characters without @/@.
    AnyRun
  | -- | @**/@ at the start, any number of leading directories.
    LeadingDirectories
  | -- | @/**/@, a @/@ with any number of directories inside it.
    InnerDirectories
  | -- | @/**@ at the end, everything below the directory before it.
    Everything
  | -- | A bracket expression, whether it is negated, and what it holds.
    Bracket Bool [Char -> Bool]

-- | Read an ignore file.
parseIgnoreFile :: Text -> IgnoreFile
parseIgnoreFile =
  IgnoreFile
    . mapMaybe (parsePattern . T.unpack . withoutReturn)
    . T.lines
  where
    withoutReturn = T.dropWhileEnd (== '\r')

-- | Read one line of an ignore file.
parsePattern :: String -> Maybe Pattern
parsePattern line0 = case trimmed line0 of
  "" -> Nothing
  '#' : _ -> Nothing
  '!' : rest -> pattern True rest
  line -> pattern False line
  where
    pattern negated body = do
      let directoryOnly = "/" `isSuffixOf` body
          stripped = if directoryOnly then init body else body
          anchored = '/' `elem` stripped
          relative = case stripped of
            '/' : rest -> rest
            _ -> stripped
      tokens <- tokenize relative
      if null tokens
        then Nothing
        else
          Just
            Pattern
              { patNegated = negated,
                patDirectoryOnly = directoryOnly,
                patAnchored = anchored,
                patTokens = tokens
              }

-- | A line without the spaces it ends in, except one escaped with @\\@.
trimmed :: String -> String
trimmed = reverse . go . reverse
  where
    go = \case
      ' ' : '\\' : rest -> ' ' : '\\' : rest
      ' ' : rest -> go rest
      other -> other

-- | Split a pattern into its pieces, or 'Nothing' where a bracket is left
-- open.
tokenize :: String -> Maybe [Token]
tokenize = go True
  where
    go atStart = \case
      [] -> Just []
      '*' : '*' : '/' : rest
        | atStart -> (LeadingDirectories :) <$> go True rest
      '/' : '*' : '*' : '/' : rest -> (InnerDirectories :) <$> go True rest
      "/**" -> Just [Everything]
      '*' : rest -> (AnyRun :) <$> go False (dropWhile (== '*') rest)
      '?' : rest -> (AnyChar :) <$> go False rest
      '[' : rest -> do
        (token, rest') <- bracket rest
        (token :) <$> go False rest'
      '\\' : c : rest -> (Literal c :) <$> go False rest
      c : rest -> (Literal c :) <$> go (c == '/') rest

-- | Read a bracket expression after its @[@.
bracket :: String -> Maybe (Token, String)
bracket = \case
  c : rest | c `elem` ['!', '^'] -> members True [] True rest
  rest -> members False [] True rest
  where
    members negated acc first = \case
      ']' : rest | not first -> Just (Bracket negated acc, rest)
      '[' : ':' : rest
        | (name, ':' : ']' : rest') <- break (== ':') rest,
          Just p <- lookup name posixClasses ->
            members negated (p : acc) False rest'
      '\\' : c : rest -> range negated acc c rest
      c : rest -> range negated acc c rest
      [] -> Nothing
    range negated acc lo = \case
      '-' : hi : rest
        | hi /= ']' ->
            let hi' = case (hi, rest) of
                  ('\\', h : _) -> h
                  _ -> hi
                rest' = if hi == '\\' then drop 1 rest else rest
             in members negated ((\c -> lo <= c && c <= hi') : acc) False rest'
      rest -> members negated ((== lo) : acc) False rest

-- | The character classes a bracket expression can name.
posixClasses :: [(String, Char -> Bool)]
posixClasses =
  [ ("alnum", isAlphaNum),
    ("alpha", isAlpha),
    ("blank", (`elem` [' ', '\t'])),
    ("cntrl", isControl),
    ("digit", isDigit),
    ("graph", \c -> isPrint c && not (isSpace c)),
    ("lower", isLower),
    ("print", isPrint),
    ("punct", \c -> isPunctuation c || isSymbol c),
    ("space", isSpace),
    ("upper", isUpper),
    ("xdigit", isHexDigit)
  ]

-- | Whether a pattern matches a path, given relative to the directory of
-- the ignore file the pattern is in, and whether that path is a directory.
matches :: Pattern -> [String] -> Bool -> Bool
matches p segments isDirectory
  | patDirectoryOnly p && not isDirectory = False
  | patAnchored p = wildmatch (patTokens p) (joinSegments segments)
  | otherwise = case reverse segments of
      name : _ -> wildmatch (patTokens p) name
      [] -> False

-- | Whether the pieces of a pattern match a path, @/@ and all.
wildmatch :: [Token] -> String -> Bool
wildmatch = \case
  [] -> null
  Literal c : ts -> \case
    x : xs | x == c -> wildmatch ts xs
    _ -> False
  AnyChar : ts -> \case
    x : xs | x /= '/' -> wildmatch ts xs
    _ -> False
  Bracket negated ps : ts -> \case
    x : xs | x /= '/', any ($ x) ps /= negated -> wildmatch ts xs
    _ -> False
  AnyRun : ts -> \s ->
    any
      (wildmatch ts)
      [rest | (skipped, rest) <- splits s, '/' `notElem` skipped]
  LeadingDirectories : ts -> \s ->
    any
      (wildmatch ts)
      (s : [rest | (skipped, rest) <- splits s, "/" `isSuffixOf` skipped])
  InnerDirectories : ts -> \s ->
    any
      (wildmatch ts)
      [ rest
      | (skipped, rest) <- splits s,
        "/" `isPrefixOf` skipped,
        "/" `isSuffixOf` skipped
      ]
  Everything : _ -> \s -> "/" `isPrefixOf` s && length s > 1
  where
    splits s = zip (inits s) (tails s)

-- | A path's segments, joined with @/@.
joinSegments :: [String] -> String
joinSegments = \case
  [] -> ""
  s : ss -> s <> concatMap ('/' :) ss

-- | Whether a file is ignored, given its path as segments relative to the
-- project root and the ignore files found in the directories above it, each
-- by its directory's segments.
isIgnored :: Map [String] IgnoreFile -> [String] -> Bool
isIgnored files path =
  any (\n -> excluded (take n path) True) [1 .. length path - 1]
    || excluded path False
  where
    excluded entry isDirectory =
      case [ not (patNegated p)
           | (n, IgnoreFile ps) <- applicable entry,
             p <- ps,
             matches p (drop n entry) isDirectory
           ] of
        [] -> False
        verdicts -> last verdicts
    applicable entry =
      [ (n, file)
      | n <- [0 .. length entry - 1],
        Just file <- [Map.lookup (take n entry) files]
      ]

-- | Whether the @.tiliaignore@ files of a project ignore a file, which need
-- not exist.
isIgnoredInProject ::
  -- | The project root, canonical.
  FilePath ->
  -- | The file.
  FilePath ->
  IO Bool
isIgnoredInProject root path = quietly False $ do
  file <- canonicalizePath path
  case belowRoot root file of
    Nothing -> pure False
    Just below -> do
      ignoreFiles <- ignoreFilesAbove root [below]
      pure (isIgnored ignoreFiles below)

-- | The @.tiliaignore@ files in the project root and in every directory
-- between it and the given files, each by its directory's segments relative
-- to the root.
ignoreFilesAbove :: FilePath -> [[FilePath]] -> IO (Map [FilePath] IgnoreFile)
ignoreFilesAbove root paths =
  Map.fromList . concat <$> traverse read' (Set.toList directories)
  where
    directories =
      Set.fromList [take n path | path <- paths, n <- [0 .. length path - 1]]
    read' directory = do
      let file = joinPath (root : directory) </> ".tiliaignore"
      exists <- doesFileExist file
      if exists
        then (\t -> [(directory, parseIgnoreFile t)]) <$> T.readFile file
        else pure []

-- | A path's segments relative to the project root, if it is under it.
belowRoot ::
  -- | The project root.
  FilePath ->
  -- | The path.
  FilePath ->
  Maybe [FilePath]
belowRoot root path =
  stripPrefix (splitDirectories root) (splitDirectories (normalise path))