packages feed

hls-test-utils-2.14.0.0: src/Test/Hls/FileSystem.hs

{-# LANGUAGE OverloadedStrings #-}
module Test.Hls.FileSystem
  ( FileSystem(..)
  , VirtualFileTree(..)
  , FileTree
  , Content
  -- * init
  , materialise
  , materialiseVFT
  -- * Interaction
  , readFileFS
  , writeFileFS
  -- * Test helpers
  , mkVirtualFileTree
  , toNfp
  , toAbsFp
  -- * Builders
  , file
  , copy
  , directory
  , text
  , ref
  , copyDir
  -- * Cradle helpers
  , directCradle
  , simpleCabalCradle
  -- * Full project setups
  , directProject
  , directProjectMulti
  , simpleCabalProject
  , simpleCabalProject'
  , atomicFileWriteString
  , atomicFileWriteStringUTF8
  , atomicFileWriteText
  ) where

import           Control.Exception           (onException)
import           Data.Foldable               (traverse_)
import qualified Data.Text                   as T
import qualified Data.Text.IO                as T
import           Development.IDE             (NormalizedFilePath)
import           Language.LSP.Protocol.Types (toNormalizedFilePath)
import           System.Directory
import           System.FilePath             as FP
import           System.IO.Extra             (newTempFileWithin, writeFileUTF8)
import           System.Process.Extra        (readProcess)

-- ----------------------------------------------------------------------------
-- Top Level definitions
-- ----------------------------------------------------------------------------

-- | Representation of a 'VirtualFileTree' that has been 'materialise'd to disk.
--
data FileSystem =
  FileSystem
    { fsRoot         :: FilePath
    , fsTree         :: [FileTree]
    , fsOriginalRoot :: FilePath
    } deriving (Eq, Ord, Show)

-- | Virtual representation of a filesystem tree.
--
-- Operations of 'vftTree' are relative to 'vftOriginalRoot'.
-- In particular, any 'copy' etc. operation looks for the sources in 'vftOriginalRoot'.
--
-- To persist a 'VirtualFileTree', look at 'materialise' and 'materialiseVFT'.
data VirtualFileTree =
  VirtualFileTree
    { vftTree         :: [FileTree]
    , vftOriginalRoot :: FilePath
    } deriving (Eq, Ord, Show)

data FileTree
  = File FilePath Content -- ^ Create a file with the given content.
  | Directory FilePath [FileTree] -- ^ Create a directory with the given files.
  | CopiedDirectory FilePath -- ^ Copy a directory from the test data dir.
  deriving (Show, Eq, Ord)

data Content
  = Inline T.Text
  | Ref FilePath
  deriving (Show, Eq, Ord)

-- ----------------------------------------------------------------------------
-- API with side effects
-- ----------------------------------------------------------------------------

readFileFS :: FileSystem -> FilePath -> IO T.Text
readFileFS fs fp = do
  T.readFile (fsRoot fs </> FP.normalise fp)

writeFileFS :: FileSystem -> FilePath -> Content -> IO ()
writeFileFS fs fp content = do
  contents <- case content of
    Inline txt -> pure txt
    Ref path   -> T.readFile (fsOriginalRoot fs </> FP.normalise path)
  T.writeFile (fsRoot fs </> FP.normalise fp) contents

-- | Materialise a virtual file tree in the 'rootDir' directory.
--
-- Synopsis: @'materialise' rootDir fileTree testDataDir@
--
-- File references in '[FileTree]' are resolved relative to the @testDataDir@.
materialise :: FilePath -> [FileTree] -> FilePath -> IO FileSystem
materialise rootDir' fileTree testDataDir' = do
  let testDataDir = FP.normalise testDataDir'
      rootDir = FP.normalise rootDir'

      persist :: FilePath -> FileTree -> IO ()
      persist root (File name cts) = case cts of
        Inline txt -> T.writeFile (root </> name) txt
        Ref path -> copyFile (testDataDir </> FP.normalise path) (root </> takeFileName name)
      persist root (Directory name nodes) = do
        createDirectory (root </> name)
        mapM_ (persist (root </> name)) nodes
      persist root (CopiedDirectory name) = do
        copyDir' root name

      copyDir' :: FilePath -> FilePath -> IO ()
      copyDir' root dir = do
        files <- fmap FP.normalise . lines <$> withCurrentDirectory (testDataDir </> dir) (readProcess "git" ["ls-files", "--cached", "--modified", "--others"] "")
        mapM_ (createDirectoryIfMissing True . ((root </>) . takeDirectory)) files
        mapM_ (\f -> copyFile (testDataDir </> dir </> f) (root </> f)) files
        return ()

  traverse_ (persist rootDir) fileTree
  pure $ FileSystem rootDir fileTree testDataDir

-- | Materialise a virtual file tree in the 'rootDir' directory.
--
-- Synopsis: @'materialiseVFT' rootDir virtualFileTree@
--
-- File references in 'virtualFileTree' are resolved relative to the @vftOriginalRoot@.
materialiseVFT :: FilePath -> VirtualFileTree -> IO FileSystem
materialiseVFT root fs = materialise root (vftTree fs) (vftOriginalRoot fs)

-- ----------------------------------------------------------------------------
-- Test definition helpers
-- ----------------------------------------------------------------------------

mkVirtualFileTree :: FilePath -> [FileTree] -> VirtualFileTree
mkVirtualFileTree testDataDir tree =
  VirtualFileTree
    { vftTree = tree
    , vftOriginalRoot = testDataDir
    }

toAbsFp :: FileSystem -> FilePath -> FilePath
toAbsFp fs fp = fsRoot fs </> FP.normalise fp

toNfp :: FileSystem -> FilePath -> NormalizedFilePath
toNfp fs fp =
  toNormalizedFilePath $ toAbsFp fs fp

-- ----------------------------------------------------------------------------
-- Builders
-- ----------------------------------------------------------------------------

-- | Create a file in the test project with some content.
--
-- Only the filename will be used, and any directory components are *not*
-- reflected in the test project.
file :: FilePath -> Content -> FileTree
file fp cts = File fp cts

-- | Copy a filepath into a test project. The name of the file is also used
-- in the test project.
--
-- The filepath is always resolved to the root of the test data dir.
copy :: FilePath -> FileTree
copy fp = File fp (Ref fp)

-- | Copy a directory into a test project.
-- The filepath is always resolved to the root of the test data dir.
copyDir :: FilePath -> FileTree
copyDir dir = CopiedDirectory dir

directory :: FilePath -> [FileTree] -> FileTree
directory name nodes = Directory name nodes

-- | Write the given test directly into a file.
text :: T.Text -> Content
text = Inline

-- | Read the contents of the given file
-- The filepath is always resolved to the root of the test data dir.
ref :: FilePath -> Content
ref = Ref

-- ----------------------------------------------------------------------------
-- Cradle Helpers
-- ----------------------------------------------------------------------------

-- | Set up a simple direct cradle.
--
-- All arguments are added to the direct cradle file.
-- Arguments will not be escaped.
directCradle :: [T.Text] -> FileTree
directCradle args =
  file "hie.yaml"
    ( Inline $ T.unlines $
      [ "cradle:"
      , "  direct:"
      , "    arguments:"
      ] <>
      [ "    - " <> arg | arg <- args]
    )

-- | Set up a simple cabal cradle.
--
-- Prefer simple cabal cradle, over custom multi cabal cradles if possible.
simpleCabalCradle :: FileTree
simpleCabalCradle =
  file "hie.yaml"
    (Inline $ T.unlines
      [ "cradle:"
      , "  cabal:"
      ]
    )


-- ----------------------------------------------------------------------------
-- Project setup builders
-- ----------------------------------------------------------------------------

-- | Set up a test project with a single haskell file.
directProject :: FilePath -> [FileTree]
directProject fp =
  [ directCradle [T.pack fp]
  , file fp (Ref fp)
  ]

-- | Set up a test project with multiple haskell files.
--
directProjectMulti :: [FilePath] -> [FileTree]
directProjectMulti fps =
  [ directCradle $ fmap T.pack fps
  ] <> fmap copy fps

-- | Set up a simple cabal cradle  project and copy all the given filepaths
-- into the test directory.
simpleCabalProject :: [FilePath] -> [FileTree]
simpleCabalProject fps =
  [ simpleCabalCradle
  ] <> fmap copy fps

-- | Set up a simple cabal cradle project.
simpleCabalProject' :: [FileTree] -> [FileTree]
simpleCabalProject' fps =
  [ simpleCabalCradle
  ] <> fps


atomicFileWrite :: FilePath -> (FilePath -> IO a) -> IO a
atomicFileWrite targetPath write = do
  let dir = takeDirectory targetPath
  createDirectoryIfMissing True dir
  (tempFilePath, cleanUp) <- newTempFileWithin dir
  (write tempFilePath >>= \x -> renameFile tempFilePath targetPath >> pure x)
    `onException` cleanUp


atomicFileWriteString :: FilePath -> String -> IO ()
atomicFileWriteString targetPath content =
  atomicFileWrite targetPath (flip writeFile content)

atomicFileWriteStringUTF8 :: FilePath -> String -> IO ()
atomicFileWriteStringUTF8 targetPath content =
  atomicFileWrite targetPath (flip writeFileUTF8 content)

atomicFileWriteText :: FilePath -> T.Text -> IO ()
atomicFileWriteText targetPath content =
    atomicFileWrite targetPath (flip T.writeFile content)