packages feed

vcs-ignore-0.0.1.0: src/Data/VCS/Ignore/Repo/Git.hs

{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData          #-}
{-# LANGUAGE TupleSections       #-}

{-|
Module      : Data.VCS.Ignore.Repo.Git
Description : Implementation of 'Repo' for /GIT/
Copyright   : (c) 2020-2021 Vaclav Svejcar
License     : BSD-3-Clause
Maintainer  : vaclav.svejcar@gmail.com
Stability   : experimental
Portability : POSIX

This module contains implementation of 'Repo' /type class/ for the /GIT/ content
versioning system. Most of the public functions is exported only to make them
visible for tests, end user of this library really shouldn't need to use them.
-}

module Data.VCS.Ignore.Repo.Git
  ( Git(..)
  , Pattern(..)
  , compilePattern
  , matchesPattern
  , parsePatterns
  , loadPatterns
  , findGitIgnores
  , gitIgnorePatterns
  , repoPatterns
  , globalPatterns
  , scanRepo'
  , isIgnored'
  , isGitRepo
  )
where

import           Control.Exception              ( SomeException
                                                , catch
                                                )
import           Control.Monad.Catch            ( MonadThrow
                                                , throwM
                                                )
import           Control.Monad.IO.Class         ( MonadIO
                                                , liftIO
                                                )
import qualified Data.List                     as L
import           Data.Maybe                     ( fromMaybe
                                                , maybeToList
                                                )
import           Data.String                    ( IsString(..) )
import           Data.Text                      ( Text )
import qualified Data.Text                     as T
import qualified Data.Text.IO                  as T
import           Data.VCS.Ignore.FileSystem     ( findPaths
                                                , toPosixPath
                                                )
import           Data.VCS.Ignore.Repo           ( Repo(..)
                                                , Repo(..)
                                                , RepoError(..)
                                                )
import           System.Directory               ( XdgDirectory(XdgConfig)
                                                , canonicalizePath
                                                , doesDirectoryExist
                                                , getXdgDirectory
                                                , makeAbsolute
                                                )
import           System.FilePath                ( makeRelative
                                                , (</>)
                                                )
import qualified System.FilePath.Glob          as G


-- | Data type representing scanned instance of /GIT/ repository.
data Git = Git
  { gitRepoRoot :: FilePath
  -- ^ absolute path to the repository root
  , gitPatterns :: [(FilePath, [Pattern])]
  -- ^ patterns ignored at given repository paths
  }
  deriving (Eq, Show)

instance Repo Git where
  repoName  = const "Git"
  repoRoot  = gitRepoRoot
  scanRepo  = scanRepo' globalPatterns repoPatterns gitIgnorePatterns isGitRepo
  isIgnored = isIgnored'

-- | Represents single pattern to be used as a rule for ignoring paths.
data Pattern = Pattern
  { pPatterns  :: [G.Pattern]
  -- ^ underlying implementation
  , pRaw       :: Text
  -- ^ raw textual representation of the pattern
  , pIsNegated :: Bool
  -- ^ whether the pattern is the negation (starts with @!@)
  }
  deriving (Eq, Show)

instance IsString Pattern where
  fromString = compilePattern . T.pack


------------------------------  PUBLIC FUNCTIONS  ------------------------------

-- | Compiles pattern.
compilePattern :: Text
               -- ^ raw pattern as text
               -> Pattern
               -- ^ compiled pattern
compilePattern raw =
  let woPrefix = fromMaybe raw $ T.stripPrefix "!" raw
      patterns = r2 . r1 $ woPrefix
  in  Pattern { pPatterns  = fmap (G.compile . T.unpack) patterns
              , pRaw       = raw
              , pIsNegated = raw /= woPrefix
              }
 where
  r1 p | any (`T.isPrefixOf` p) ["/", "*"] = p
       | length (filter (not . T.null) . T.splitOn "/" $ p) == 1 = "**/" <> p
       | otherwise                         = "/" <> p
  r2 p | "/" `T.isSuffixOf` p = [p <> "**"]
       | otherwise            = [p, p <> "/**"]


-- | Tests whether given path matches against the pattern.
matchesPattern :: Pattern
               -- ^ pattern to match against
               -> FilePath
               -- ^ path to check
               -> Bool
               -- ^ check result
matchesPattern ptn path = any (`G.match` path) (pPatterns ptn)


-- | Parses /Glob/ patterns from given text source. Each line in input text is
-- considered to be single pattern. Lines starting with @#@ (comments) and blank
-- lines are skipped.
--
-- >>> parsePatterns "*.xml\n.DS_Store"
-- [Pattern {pPatterns = [compile "*.xml",compile "*.xml/*"], pRaw = "*.xml", pIsNegated = False},Pattern {pPatterns = [compile "**/.DS_Store",compile "**/.DS_Store/*"], pRaw = ".DS_Store", pIsNegated = False}]
parsePatterns :: Text
              -- ^ text to parse
              -> [Pattern]
              -- ^ parsed patterns
parsePatterns = fmap compilePattern . filter (not . excluded) . T.lines
 where
  excluded = \line -> or $ fmap ($ T.stripStart line) [comment, T.null]
  comment  = \line -> "#" `T.isPrefixOf` line


-- | Loads /Glob/ patterns from given text file. If the fille cannot be read for
-- any reason, empty list is returned. See 'parsePatterns' for more details
-- about parsing.
loadPatterns :: MonadIO m
             => FilePath
             -- ^ path to text file to parse
             -> m [Pattern]
             -- ^ parsed /Glob/ patterns
loadPatterns path = parsePatterns <$> liftIO content
 where
  content = catch (T.readFile path) (\(_ :: SomeException) -> pure T.empty)


-- | Recursively finds all @.gitignore@ files within the given directory path.
findGitIgnores :: MonadIO m
               => FilePath
               -- ^ path to the directory to search in
               -> m [FilePath]
               -- ^ paths of found @.gitignore@ files
findGitIgnores repoDir = findPaths repoDir isGitIgnore
  where isGitIgnore path = pure $ ".gitignore" `L.isSuffixOf` path


-- | Recursively finds all @.gitignore@ files within the given directory path
-- and parses them into /Glob/ patterns. See 'loadPatterns' and 'findGitIgnores'
-- for more details.
gitIgnorePatterns :: MonadIO m
                  => FilePath
                  -- ^ path to the directory to search @.gitignore@ files in
                  -> m [(FilePath, [Pattern])]
                  -- ^ list of @.gitignore@ paths and parsed /Glob/ patterns
gitIgnorePatterns repoDir = do
  gitIgnores <- findGitIgnores repoDir
  mapM (\p -> (toPosixPath . path $ p, ) <$> loadPatterns p) gitIgnores
  where path p = stripSuffix' ".gitignore" $ stripPrefix' repoDir p


-- | Loads /GIT/ repository specific ignore patterns, present in
-- @REPO_ROOT\/info\/exclude@ file.
repoPatterns :: MonadIO m
             => FilePath
             -- ^ path to the /GIT/ repository root
             -> m [Pattern]
             -- ^ parsed /Glob/ patterns
repoPatterns repoDir = loadPatterns $ repoDir </> "info" </> "exclude"


-- | Loads global /GIT/  ignore patterns, present in
-- @XDG_CONFIG_GOME\/git\/ignore@ file.
globalPatterns :: MonadIO m => m [Pattern]
               -- ^ parsed /Glob/ patterns
globalPatterns =
  (liftIO . getXdgDirectory XdgConfig $ ("git" </> "ignore")) >>= loadPatterns


-- | Internal version of 'scanRepo', where individual functions needs to be
-- explicitly provided, which is useful mainly for testing purposes.
scanRepo' :: (MonadIO m, MonadThrow m)
          => m [Pattern]
          -- ^ reference to 'globalPatterns' function (or similar)
          -> (FilePath -> m [Pattern])
          -- ^ reference to 'repoPatterns' function (or similar)
          -> (FilePath -> m [(FilePath, [Pattern])])
          -- ^ reference to 'gitIgnorePatterns' function (or similar)
          -> (FilePath -> m Bool)
          -- ^ reference to 'isGitRepo' function (or similar)
          -> FilePath
          -- ^ path to /GIT/ repository root
          -> m Git
          -- ^ scanned /Git/ repository
scanRepo' globalPatternsFn repoPatternsFn gitIgnoresFn isGitRepoFn repoDir = do
  absRepoDir <- liftIO $ makeAbsolute repoDir
  gitRepo    <- isGitRepoFn absRepoDir
  (if gitRepo then proceed else abort) absRepoDir
 where
  abort repoDir' = throwM $ InvalidRepo repoDir' "not a valid GIT repository"
  proceed repoDir' = do
    globalPatterns' <- globalPatternsFn
    repoPatterns'   <- repoPatternsFn repoDir'
    gitIgnores      <- gitIgnoresFn repoDir'
    let (r, o)   = sep gitIgnores
        patterns = [("/", globalPatterns' <> repoPatterns' <> r)] <> o
    pure Git { gitRepoRoot = repoDir', gitPatterns = patterns }
  sep xs =
    let predicate = \(p, _) -> p == "/"
        woRoot    = filter (not . predicate) xs
        root      = concat . maybeToList $ snd <$> L.find predicate xs
    in  (root, woRoot)


-- | Internal version of 'isIgnored' function.
isIgnored' :: MonadIO m
           => Git
           -- ^ scanned /GIT/ repository
           -> FilePath
           -- ^ path to check if ignored
           -> m Bool
           -- @True@ if given path is ignored
isIgnored' git@(Git _ patterns) path = do
  np <- toPosixPath <$> normalize (repoRoot git) path
  let ignored = any (check2 np False) (filtered np)
      negated = any (check2 np True) (filtered np)
  pure $ ignored && not negated
 where
  sanitized  = addPrefix "/"
  asRepoPath = \np -> (`stripPrefix'` sanitized np)
  filtered   = \np -> filter (onPath np) patterns
  onPath     = \np (p, _) -> p `L.isPrefixOf` sanitized np
  check2     = \np negated (prefix, ptns) ->
    any (`matchesPattern` asRepoPath np prefix)
      . filter (\p -> pIsNegated p == negated)
      $ ptns


-- | Checks whether given directory path is valid /GIT/ repository.
isGitRepo :: MonadIO m
          => FilePath
          -- ^ path to the directory to check
          -> m Bool
          -- ^ @True@ if the given directory is valid /GIT/ repository
isGitRepo path = liftIO . doesDirectoryExist $ path </> ".git"


------------------------------  PRIVATE FUNCTIONS  -----------------------------

addPrefix :: String -> String -> String
addPrefix prefix str | prefix `L.isPrefixOf` str = str
                     | otherwise                 = prefix <> str


stripPrefix' :: String -> String -> String
stripPrefix' prefix str =
  maybe str T.unpack (T.stripPrefix (T.pack prefix) (T.pack str))


stripSuffix' :: String -> String -> String
stripSuffix' suffix str =
  maybe str T.unpack (T.stripSuffix (T.pack suffix) (T.pack str))


normalize :: MonadIO m => FilePath -> FilePath -> m FilePath
normalize repoDir path = do
  canonicalized <- liftIO . canonicalizePath $ repoDir </> stripPrefix' "/" path
  isDir         <- liftIO $ doesDirectoryExist canonicalized
  let suffix = if isDir || "/" `L.isSuffixOf` path then "/" else ""
  pure $ makeRelative repoDir canonicalized <> suffix