packages feed

tilia-0.1.0.0: tests/Tilia/IgnoreSpec.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Ignore files written the way @.gitignore@ files are.
module Tilia.IgnoreSpec (spec) where

import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text.IO qualified as T
import System.Directory (canonicalizePath, createDirectoryIfMissing)
import System.FilePath (splitDirectories, takeDirectory, (</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Tilia.Ignore

spec :: Spec
spec = do
  describe "a pattern" $ do
    it "without a slash matches a name at any depth" $
      "*.hs" `ignores` ["A.hs", "src/A.hs", "src/x/B.hs"] `butNot` ["A.hsc"]

    it "with a slash at the start or in the middle matches from the file's directory" $ do
      "/foo" `ignores` ["foo/a.hs"] `butNot` ["a/foo/b.hs"]
      "doc/*" `ignores` ["doc/x", "doc/y/x"] `butNot` ["a/doc/x"]

    it "with a slash at the end matches directories alone" $
      "foo/" `ignores` ["foo/a.hs", "a/foo/b.hs"] `butNot` ["foo", "a/foo"]

    it "with * and ? does not match a slash" $ do
      "a/*/b" `ignores` ["a/x/b"] `butNot` ["a/b", "a/x/y/b"]
      "?.hs" `ignores` ["a.hs"] `butNot` ["ab.hs"]

    it "with **/ at the start matches in every directory" $
      "**/gen/" `ignores` ["gen/G.hs", "q/gen/G.hs"] `butNot` ["gen"]

    it "with /**/ matches any number of directories, none included" $
      "a/**/b" `ignores` ["a/b", "a/x/b", "a/x/y/b"] `butNot` ["b"]

    it "with /** at the end matches everything inside" $
      "deep/**" `ignores` ["deep/1/2/3/f.hs"] `butNot` ["deep"]

    it "with a bracket matches the characters it lists, or all others" $ do
      "[ab].hs" `ignores` ["a.hs", "b.hs"] `butNot` ["c.hs"]
      "[!a].hs" `ignores` ["b.hs"] `butNot` ["a.hs"]
      "[^a].hs" `ignores` ["b.hs"] `butNot` ["a.hs"]
      "[a-c]x" `ignores` ["bx"] `butNot` ["dx"]
      "x[[:digit:]]" `ignores` ["x1"] `butNot` ["xa"]

    it "with an unclosed bracket matches nothing" $
      "[ab.hs" `ignores` [] `butNot` ["[ab.hs", "a.hs"]

    it "can escape what is special with a backslash" $ do
      "\\#x" `ignores` ["#x"] `butNot` []
      "\\!x" `ignores` ["!x"] `butNot` []
      "t/a\\*b" `ignores` ["t/a*b"] `butNot` ["t/axb"]
      "foo\\ " `ignores` ["foo "] `butNot` ["foo"]

    it "loses the spaces it ends in but not the ones it begins with" $ do
      "foo  " `ignores` ["foo"] `butNot` []
      " foo" `ignores` [" foo"] `butNot` ["foo"]

  describe "an ignore file" $ do
    it "says nothing on a blank line or a comment" $
      "# *.hs\n\n" `ignores` [] `butNot` ["A.hs"]

    it "lets a later pattern win, and ! re-include" $ do
      "*.hs\n!keep.hs\n" `ignores` ["a.hs"] `butNot` ["keep.hs"]
      "!keep.hs\n*.hs\n" `ignores` ["a.hs", "keep.hs"] `butNot` []

    it "cannot re-include a file whose directory it excluded" $ do
      "lib/\n!lib/keep.hs\n" `ignores` ["lib/keep.hs", "lib/drop.hs"] `butNot` []
      "lib/*\n!lib/keep.hs\n" `ignores` ["lib/drop.hs"] `butNot` ["lib/keep.hs"]

    it "reads lines ended with a carriage return" $
      "*.hs\r\n!keep.hs\r\n" `ignores` ["a.hs"] `butNot` ["keep.hs"]

  describe "ignore files in several directories" $ do
    it "match from their own directories" $
      nested [([], ""), (["src"], "/x/\n")] ["src/x/B.hs"] ["x/B.hs", "src/A.hs"]

    it "let the deeper file win" $
      nested [([], "*.hs\n"), (["src"], "!A.hs\n")] ["B.hs", "src/x/B.hs"] ["src/A.hs"]

    it "cannot re-include what a file above excluded the directory of" $
      nested [([], "q/\n"), (["q"], "!gen/\n")] ["q/gen/G.hs"] []

  describe "a file of a project" $ do
    it "is ignored by the .tiliaignore files above it, whether it exists or not" $
      withProject $ \root -> do
        isIgnoredInProject root (root </> "src" </> "Generated.hs") `shouldReturn` True
        isIgnoredInProject root (root </> "src" </> "inner" </> "A.hs-boot") `shouldReturn` True
        isIgnoredInProject root (root </> "src" </> "inner" </> ".." </> "Generated.hs") `shouldReturn` True
        isIgnoredInProject root (root </> "src" </> "New.hs") `shouldReturn` False

    it "is not ignored when it is outside of the project" $
      withProject $ \root ->
        isIgnoredInProject root (takeDirectory root </> "Generated.hs") `shouldReturn` False

-- | A project with @src/Generated.hs@, which its root's @.tiliaignore@
-- ignores, and a @.tiliaignore@ in @src/inner@ ignoring boot files.
withProject :: (FilePath -> Expectation) -> Expectation
withProject act =
  withSystemTempDirectory "tilia-ignore" $ \dir -> do
    root <- canonicalizePath dir
    let write path text = do
          createDirectoryIfMissing True (takeDirectory (root </> path))
          T.writeFile (root </> path) text
    write ".tiliaignore" "src/Generated.hs\n"
    write ("src" </> "Generated.hs") "module Generated where\n"
    write ("src" </> "inner" </> ".tiliaignore") "*.hs-boot\n"
    act root

-- | Check which of the paths a single ignore file at the root ignores.
ignores :: Text -> [FilePath] -> [FilePath] -> Expectation
ignores content = nested [([], content)]

-- | Read the paths that follow as the ones the pattern should not ignore.
butNot :: ([FilePath] -> Expectation) -> [FilePath] -> Expectation
butNot = ($)

-- | Check which paths ignore files in several directories ignore.
nested :: [([FilePath], Text)] -> [FilePath] -> [FilePath] -> Expectation
nested files ignored kept =
  fmap (isIgnored rules . splitDirectories) (ignored <> kept)
    `shouldBe` fmap (const True) ignored <> fmap (const False) kept
  where
    rules = Map.fromList [(directory, parseIgnoreFile t) | (directory, t) <- files]