packages feed

ghc-tags-plugin-1.0.0.0: test/Test/ProjectFile.hs

{-# LANGUAGE NamedFieldPuns #-}

module Test.ProjectFile (tests) where

import Control.Exception (bracket)
import Control.Monad (filterM, forM_, when)
import Data.List (find, scanl')
import Data.Maybe (isJust)
import System.Directory
import System.FilePath ((</>), normalise, splitDirectories, takeDirectory, takeExtension)
import System.IO (hClose, openTempFile)

import Test.Tasty (TestTree, testGroup)
import Test.Tasty.QuickCheck

import Plugin.GhcTags.Project


tests :: TestTree
tests = testGroup "ProjectFile"
    [ testProperty "project-relative tags path matches model" resolutionProp ]


data ProjectConfig = CabalProject
                   | CabalProjectLocal
                   | StackConfig
  deriving (Eq, Show)

data DirectoryEntry = DirectoryEntry (Maybe ProjectConfig) Bool
  deriving (Eq, Show)

instance Arbitrary DirectoryEntry where
    arbitrary = DirectoryEntry
      <$> frequency
            [ (6, pure Nothing)
            , (1, pure (Just CabalProject))
            , (1, pure (Just CabalProjectLocal))
            , (1, pure (Just StackConfig))
            ]
      <*> arbitrary
    shrink (DirectoryEntry config package) =
         [ DirectoryEntry Nothing package | isJust config ]
      ++ [ DirectoryEntry config False | package ]


data StackOverride = NoOverride
                   | ExistingOverride
                   | MissingOverride
  deriving (Eq, Show)

instance Arbitrary StackOverride where
    arbitrary = frequency
                  [ (6, pure NoOverride)
                  , (2, pure ExistingOverride)
                  , (1, pure MissingOverride)
                  ]
    shrink NoOverride = []
    shrink _ = [NoOverride]


data ProjectLayout = ProjectLayout {
    levels         :: NonEmptyList DirectoryEntry,
    absoluteOutput :: Bool,
    stackOverride  :: StackOverride,
    relativeOutput :: FilePath
  }
  deriving Show

instance Arbitrary ProjectLayout where
    arbitrary = ProjectLayout
      <$> resize 6 arbitrary
      <*> frequency
            [ (7, pure False)
            , (1, pure True)
            ]
      <*> arbitrary
      <*> elements ["tags", "TAGS", "output/tags", "../tags"]
    shrink layout@ProjectLayout {levels, absoluteOutput, stackOverride} =
         [ layout { levels = levels' }
         | levels' <- shrink levels ]
      ++ [ layout { absoluteOutput = False }
         | absoluteOutput ]
      ++ [ layout { stackOverride = stackOverride' }
         | stackOverride' <- shrink stackOverride ]


resolutionProp :: ProjectLayout -> Property
resolutionProp ProjectLayout {levels = NonEmpty entries, absoluteOutput, stackOverride, relativeOutput} =
    ioProperty $ withTestDirectory $ \root -> do
      let components = ["level" ++ show n | n <- [0 .. length entries - 1]]
          directories = drop 1 $ scanl' (</>) root components
          cwd = foldl (</>) root components
          overrideRoot = root </> "override"
          overrideFile = overrideRoot </> "custom-stack.yaml"
          override = case stackOverride of
            NoOverride -> Nothing
            _ -> Just overrideFile
          path = if absoluteOutput then root </> "absolute.tags" else relativeOutput
      createDirectoryIfMissing True cwd
      forM_ (zip directories entries) $ \(dir, DirectoryEntry config package) -> do
        forM_ config $ \kind -> writeFile (dir </> configName kind) ""
        when package $ writeFile (dir </> "package.cabal") ""
      when (stackOverride == ExistingOverride) $ do
        createDirectory overrideRoot
        writeFile overrideFile ""
      -- The resolver can leave the generated hierarchy. Include ambient
      -- directories in the model when no generated project marker stops it.
      ancestors <-
        if   absoluteOutput
          || stackOverride == ExistingOverride
          || any (\(DirectoryEntry config _) -> isJust config) entries
          then pure []
          else readAncestorEntries root
      actual <- resolveTagsFile cwd override path
      let -- Independent model: select the innermost matching directory from
          -- the generated hierarchy followed by its surrounding directories.
          innerFirst = reverse (zip directories entries) ++ ancestors
          project = fst <$> find (\(_, DirectoryEntry config _) -> isJust config) innerFirst
          package = fst <$> find (\(_, DirectoryEntry _ present) -> present) innerFirst

          fallback = maybe cwd id package

          expected :: TagsAtRoot
          expected
            | absoluteOutput
            = (TagsAtProjectRoot path)

            | ExistingOverride <- stackOverride
            = TagsAtProjectRoot (normalise (overrideRoot </> path))

            | Just dir <- project
            = TagsAtProjectRoot (normalise (dir </> path))

            | otherwise
            = TagsAtPackageRoot (normalise (fallback </> path))

      pure $
        classify absoluteOutput "absolute output" $
        classify (stackOverride == ExistingOverride) "explicit Stack configuration" $
        classify (stackOverride == MissingOverride) "missing Stack configuration" $
        classify (not absoluteOutput && stackOverride /= ExistingOverride && not (isJust project)) "fallback" $
          actual === expected


configName :: ProjectConfig -> FilePath
configName CabalProject      = "cabal.project"
configName CabalProjectLocal = "cabal.project.local"
configName StackConfig       = "stack.yaml"


-- Snapshot filesystem data only; root selection stays in the property model.
readAncestorEntries :: FilePath -> IO [(FilePath, DirectoryEntry)]
readAncestorEntries root = traverse readEntry parents
  where
    parents = reverse . drop 1 . scanl' (</>) "" . splitDirectories . takeDirectory $ root

    readEntry dir = do
      files <- filterM (doesFileExist . (dir </>)) =<< listDirectory dir
      let config = find ((`elem` files) . configName)
                 $ [CabalProject, CabalProjectLocal, StackConfig]
          package = any ((== ".cabal") . takeExtension) files
      pure (dir, DirectoryEntry config package)


withTestDirectory :: (FilePath -> IO a) -> IO a
withTestDirectory action = do
    tmp <- getTemporaryDirectory
    bracket (do (path, h) <- openTempFile tmp "ghc-tags-project-test"
                hClose h
                removeFile path
                createDirectory path
                canonicalizePath path)
            removeDirectoryRecursive
            action