packages feed

moonlight-pale-0.1.0.0: src-test/Moonlight/Pale/Test/Resources.hs

{-# LANGUAGE DerivingStrategies #-}

{-| Validated discovery of compiler, package, and resource paths. -}
module Moonlight.Pale.Test.Resources
  ( ResourcePathError (..),
    renderResourcePathError,
    resolveCompilerRoot,
    findActiveCabalBuildDirectory,
    resolvePackageRoot,
    resolveCompilerFile,
    resolveCompilerDirectory,
    resolvePackageFile,
    resolvePackageDirectory,
  )
where

import Control.Exception
  ( SomeAsyncException,
    SomeException,
    displayException,
    fromException,
    throwIO,
    try,
  )
import Control.Monad (join)
import Data.Kind (Type)
import Data.List (unfoldr)
import Data.Maybe (catMaybes)
import Data.Set qualified as Set
import System.Directory (canonicalizePath, doesDirectoryExist, doesFileExist, getCurrentDirectory)
import System.Environment (getExecutablePath, lookupEnv)
import System.FilePath (isAbsolute, makeRelative, normalise, splitDirectories, takeDirectory, (</>))

type ResourcePathError :: Type
data ResourcePathError
  = CompilerRootNotFound FilePath
  | MissingResourceFile FilePath
  | MissingResourceDirectory FilePath
  | ResourcePathNotRelativeToRoot FilePath
  | ResourcePathEscapesRoot FilePath FilePath
  | ResourceFilesystemFailure FilePath String
  deriving stock (Eq, Show)

renderResourcePathError :: ResourcePathError -> String
renderResourcePathError resourcePathError =
  case resourcePathError of
    CompilerRootNotFound packageMarker ->
      "unable to locate compiler root with cabal.project and marker: " <> packageMarker
    MissingResourceFile filePath ->
      "missing resource file: " <> filePath
    MissingResourceDirectory directoryPath ->
      "missing resource directory: " <> directoryPath
    ResourcePathNotRelativeToRoot resourcePath ->
      "resource path is not relative to its root: " <> resourcePath
    ResourcePathEscapesRoot rootPath escapedPath ->
      "resource path escapes root " <> rootPath <> ": " <> escapedPath
    ResourceFilesystemFailure contextPath exceptionText ->
      "filesystem failure while resolving " <> contextPath <> ": " <> exceptionText

resolveCompilerRoot :: FilePath -> IO (Either ResourcePathError FilePath)
resolveCompilerRoot packageMarker =
  fmap join $
    trySynchronous packageMarker $ do
      currentDirectory <- getCurrentDirectory
      executableDirectory <- takeDirectory <$> getExecutablePath
      maybeCompilerRoot <-
        findAnyCompilerRoot
          packageMarker
          [currentDirectory, executableDirectory]
      pure
        ( case maybeCompilerRoot of
            Nothing -> Left (CompilerRootNotFound packageMarker)
            Just compilerRoot -> Right compilerRoot
        )

resolvePackageRoot :: FilePath -> IO (Either ResourcePathError FilePath)
resolvePackageRoot packageMarker =
  fmap
    (fmap (\compilerRoot -> normalise (compilerRoot </> takeDirectory packageMarker)))
    (resolveCompilerRoot packageMarker)

resolveCompilerFile :: FilePath -> FilePath -> IO (Either ResourcePathError FilePath)
resolveCompilerFile =
  resolveExistingPath resolveCompilerRoot doesFileExist MissingResourceFile

resolveCompilerDirectory :: FilePath -> FilePath -> IO (Either ResourcePathError FilePath)
resolveCompilerDirectory =
  resolveExistingPath resolveCompilerRoot doesDirectoryExist MissingResourceDirectory

resolvePackageFile :: FilePath -> FilePath -> IO (Either ResourcePathError FilePath)
resolvePackageFile =
  resolveExistingPath resolvePackageRoot doesFileExist MissingResourceFile

resolvePackageDirectory :: FilePath -> FilePath -> IO (Either ResourcePathError FilePath)
resolvePackageDirectory =
  resolveExistingPath resolvePackageRoot doesDirectoryExist MissingResourceDirectory

resolveExistingPath ::
  (FilePath -> IO (Either ResourcePathError FilePath)) ->
  (FilePath -> IO Bool) ->
  (FilePath -> ResourcePathError) ->
  FilePath ->
  FilePath ->
  IO (Either ResourcePathError FilePath)
resolveExistingPath resolveRoot pathExists toMissingError packageMarker relativePath
  | not (pathRelativeToRoot relativePath) =
      pure (Left (ResourcePathNotRelativeToRoot relativePath))
  | otherwise =
      fmap join $
        trySynchronous (packageMarker </> relativePath) $ do
          rootResult <- resolveRoot packageMarker
          case rootResult of
            Left rootError -> pure (Left rootError)
            Right rootPath -> do
              canonicalRoot <- canonicalizePath rootPath
              let resolvedPath = normalise (canonicalRoot </> relativePath)
              if not (pathWithinRoot canonicalRoot resolvedPath)
                then
                  pure (Left (ResourcePathEscapesRoot canonicalRoot resolvedPath))
                else do
                  pathPresent <- pathExists resolvedPath
                  if pathPresent
                    then do
                      canonicalResolvedPath <- canonicalizePath resolvedPath
                      pure
                        ( if pathWithinRoot canonicalRoot canonicalResolvedPath
                            then Right canonicalResolvedPath
                            else Left (ResourcePathEscapesRoot canonicalRoot canonicalResolvedPath)
                        )
                    else
                      pure (Left (toMissingError resolvedPath))

findActiveCabalBuildDirectory :: IO (Either ResourcePathError (Maybe FilePath))
findActiveCabalBuildDirectory =
  trySynchronous "cache/plan.json" $ do
    maybeComponentBuildDirectory <- lookupEnv "HASKELL_DIST_DIR"
    executableDirectory <- takeDirectory <$> getExecutablePath
    findAnyAncestorDirectory
      hasCabalBuildPlan
      (catMaybes [maybeComponentBuildDirectory, Just executableDirectory])

trySynchronous ::
  FilePath ->
  IO value ->
  IO (Either ResourcePathError value)
trySynchronous contextPath action = do
  result <- try action
  case result of
    Left exceptionValue
      | Just asyncException <-
          (fromException exceptionValue :: Maybe SomeAsyncException) ->
          throwIO asyncException
      | otherwise ->
          pure
            ( Left
                (ResourceFilesystemFailure contextPath (displayException (exceptionValue :: SomeException)))
            )
    Right value ->
      pure (Right value)

findAncestorDirectory :: (FilePath -> IO Bool) -> FilePath -> IO (Maybe FilePath)
findAncestorDirectory hasMarker directoryPath =
  canonicalizePath directoryPath
    >>= firstJustM matchingDirectory . ancestorDirectories
  where
    matchingDirectory candidateDirectory =
      hasMarker candidateDirectory
        >>= \markerPresent ->
          pure
            ( if markerPresent
                then Just candidateDirectory
                else Nothing
            )

findAnyCompilerRoot :: FilePath -> [FilePath] -> IO (Maybe FilePath)
findAnyCompilerRoot packageMarker =
  findAnyAncestorDirectory (hasCompilerRootMarkers packageMarker)

findAnyAncestorDirectory :: (FilePath -> IO Bool) -> [FilePath] -> IO (Maybe FilePath)
findAnyAncestorDirectory hasMarker seedDirectories =
  traverse canonicalizePath seedDirectories
    >>= firstJustM (findAncestorDirectory hasMarker) . Set.toAscList . Set.fromList

ancestorDirectories :: FilePath -> [FilePath]
ancestorDirectories initialDirectory =
  initialDirectory : unfoldr parentDirectory initialDirectory
  where
    parentDirectory childDirectory =
      let parent = takeDirectory childDirectory
       in if parent == childDirectory
            then Nothing
            else Just (parent, parent)

firstJustM :: Monad effect => (candidate -> effect (Maybe result)) -> [candidate] -> effect (Maybe result)
firstJustM inspectCandidate =
  foldr
    ( \candidate laterResult ->
        inspectCandidate candidate
          >>= maybe laterResult (pure . Just)
    )
    (pure Nothing)

pathWithinRoot :: FilePath -> FilePath -> Bool
pathWithinRoot rootPath childPath =
  let relativePath = makeRelative rootPath childPath
   in not (isAbsolute relativePath)
        && case splitDirectories relativePath of
          ".." : _ -> False
          _ -> True

pathRelativeToRoot :: FilePath -> Bool
pathRelativeToRoot resourcePath =
  not (isAbsolute resourcePath)
    && case splitDirectories (normalise resourcePath) of
      ".." : _ -> False
      _ -> True

hasCompilerRootMarkers :: FilePath -> FilePath -> IO Bool
hasCompilerRootMarkers packageMarker directoryPath = do
  hasProject <- doesFileExist (directoryPath </> "cabal.project")
  hasPackage <- doesFileExist (directoryPath </> packageMarker)
  pure (hasProject && hasPackage)

hasCabalBuildPlan :: FilePath -> IO Bool
hasCabalBuildPlan directoryPath =
  doesFileExist (directoryPath </> "cache" </> "plan.json")