packages feed

moonlight-pale-0.1.0.0: src-ghc-surface/Moonlight/Pale/Ghc/Hie/Read.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE StandaloneKindSignatures #-}

{-| Reading HIE files into module-name oracle indexes. -}
module Moonlight.Pale.Ghc.Hie.Read
  ( HieReadError (..),
    readModuleOracle,
    hieFileOracle,
    indexHieRoots,
  )
where

import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try)
import Data.Array (Array)
import Data.Bifunctor (first)
import Data.Either (partitionEithers)
import Data.Foldable (foldlM)
import Data.Kind (Type)
import Data.List (isPrefixOf, sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import GHC.Iface.Ext.Binary (HieFileResult (..), readHieFile)
import GHC.Iface.Ext.Types
  ( ContextInfo (..),
    HieAST (..),
    HieASTs (..),
    HieFile (..),
    HieTypeFlat,
    Identifier,
    IdentifierDetails (..),
    NodeInfo (..),
    SourcedNodeInfo (..),
    TypeIndex,
  )
import GHC.Types.Name (Name, isExternalName, nameModule, nameOccName)
import GHC.Types.Name.Cache (NameCache, newNameCache)
import GHC.Types.Name.Occurrence (occNameString)
import GHC.Unit.Module (moduleName, moduleNameString, moduleUnit, unitString)
import Moonlight.Pale.Ghc.Hie.Oracle (ModuleNameOracle (..), ResolvedOrigin (..), mkPackageUnit)
import Moonlight.Pale.Ghc.Hie.SourceKey
  ( HieOracleArtifact (..),
    HieOracleIndex,
    buildHieOracleIndex,
  )
import Moonlight.Pale.Ghc.Hie.TypeWords
  ( TypeGraphObstruction (..),
    TypeWords,
    hieTypeRootsTypeWords,
  )
import Moonlight.Pale.Ghc.Expr (SourceRegion, sourceRegionFromRealSrcSpan)
import System.Directory
  ( canonicalizePath,
    doesDirectoryExist,
    doesFileExist,
    listDirectory,
    pathIsSymbolicLink,
  )
import System.FilePath (normalise, takeExtension, (</>))

type HieReadError :: Type
data HieReadError
  = HieReadError !FilePath !String
  | HieRootError !FilePath !String
  | HieTraversalError !FilePath !String
  | HieTypeGraphError !FilePath !(Map SourceRegion (Set.Set TypeGraphObstruction))
  deriving stock (Eq, Show)

readModuleOracle :: NameCache -> FilePath -> IO (Either HieReadError ModuleNameOracle)
readModuleOracle nameCache hiePath = do
  readResult <- tryReadHieFile nameCache hiePath
  pure
    ( first (HieReadError hiePath . show) readResult
        >>= hieFileOracle hiePath
    )

indexHieRoots :: [FilePath] -> IO ([HieReadError], HieOracleIndex)
indexHieRoots [] =
  pure ([], buildHieOracleIndex [])
indexHieRoots roots = do
  nameCache <- newNameCache
  collection <- collectHieRoots roots
  readResults <-
    traverse
      ( \hiePath ->
          fmap (HieOracleArtifact hiePath)
            <$> readModuleOracle nameCache hiePath
      )
      (Set.toAscList (hcFiles collection))
  let (readErrors, artifacts) =
        partitionEithers readResults
  pure
    ( reverse (hcErrorsReversed collection) <> readErrors,
      buildHieOracleIndex artifacts
    )

tryReadHieFile :: NameCache -> FilePath -> IO (Either SomeException HieFileResult)
tryReadHieFile nameCache hiePath =
  trySynchronousException (readHieFile nameCache hiePath)

hieFileOracle :: FilePath -> HieFileResult -> Either HieReadError ModuleNameOracle
hieFileOracle hiePath result =
  let hieFile = hie_file_result result
      oracleBuild = foldHieAsts (hie_asts hieFile)
      typeProjection = projectTypeRoots (hie_types hieFile) (obTypeRoots oracleBuild)
   in if Map.null (tpObstructions typeProjection)
        then
          Right
            ModuleNameOracle
              { mnoSourcePath = normalise (hie_hs_file hieFile),
                mnoGlobalUsesAtSpan = obGlobalUsesAtSpan oracleBuild,
                mnoGlobalUses = obGlobals oracleBuild,
                mnoEvidenceAtSpan = obEvidence oracleBuild,
                mnoTypeAtSpan = tpWords typeProjection
              }
        else
          Left (HieTypeGraphError hiePath (tpObstructions typeProjection))

data OracleBuild = OracleBuild
  { obGlobals :: !(Map String (Set.Set ResolvedOrigin)),
    obGlobalUsesAtSpan :: !(Map SourceRegion (Map String (Set.Set ResolvedOrigin))),
    obEvidence :: !(Map SourceRegion (Set.Set ResolvedOrigin)),
    obTypeRoots :: !(Map SourceRegion (Set.Set TypeIndex))
  }

emptyOracleBuild :: OracleBuild
emptyOracleBuild =
  OracleBuild
    { obGlobals = Map.empty,
      obGlobalUsesAtSpan = Map.empty,
      obEvidence = Map.empty,
      obTypeRoots = Map.empty
    }

foldHieAsts :: HieASTs TypeIndex -> OracleBuild
foldHieAsts (HieASTs astsByPath) =
  Map.foldl' foldHieAst emptyOracleBuild astsByPath

foldHieAst :: OracleBuild -> HieAST TypeIndex -> OracleBuild
foldHieAst oracleBuild ast =
  foldl'
    foldHieAst
    ( Map.foldl'
        (foldNodeInfo (sourceRegionFromRealSrcSpan (nodeSpan ast)))
        oracleBuild
        (getSourcedNodeInfo (sourcedNodeInfo ast))
    )
    (nodeChildren ast)

foldNodeInfo :: SourceRegion -> OracleBuild -> NodeInfo TypeIndex -> OracleBuild
foldNodeInfo region oracleBuild nodeInfo =
  Map.foldlWithKey'
    (foldIdentifierDetails region)
    ( foldl'
        (\buildValue typeIndex -> buildValue {obTypeRoots = insertAt region typeIndex (obTypeRoots buildValue)})
        oracleBuild
        (nodeType nodeInfo)
    )
    (nodeIdentifiers nodeInfo)

foldIdentifierDetails :: SourceRegion -> OracleBuild -> Identifier -> IdentifierDetails TypeIndex -> OracleBuild
foldIdentifierDetails region oracleBuild identifier details =
  maybe
    oracleBuild
    ( \origin ->
        OracleBuild
          { obGlobals =
              if Set.member Use (identInfo details)
                then insertAt (roOcc origin) origin (obGlobals oracleBuild)
                else obGlobals oracleBuild,
            obGlobalUsesAtSpan =
              if Set.member Use (identInfo details)
                then insertGlobalUseAtSpan region origin (obGlobalUsesAtSpan oracleBuild)
                else obGlobalUsesAtSpan oracleBuild,
            obEvidence =
              if any evidenceContext (identInfo details)
                then insertAt region origin (obEvidence oracleBuild)
                else obEvidence oracleBuild,
            obTypeRoots = obTypeRoots oracleBuild
          }
    )
    (identifierOrigin identifier)

insertAt :: (Ord key, Ord value) => key -> value -> Map key (Set.Set value) -> Map key (Set.Set value)
insertAt key value =
  Map.insertWith Set.union key (Set.singleton value)

insertGlobalUseAtSpan :: SourceRegion -> ResolvedOrigin -> Map SourceRegion (Map String (Set.Set ResolvedOrigin)) -> Map SourceRegion (Map String (Set.Set ResolvedOrigin))
insertGlobalUseAtSpan region origin =
  Map.insertWith
    (Map.unionWith Set.union)
    region
    (Map.singleton (roOcc origin) (Set.singleton origin))

evidenceContext :: ContextInfo -> Bool
evidenceContext = \case
  EvidenceVarBind {} ->
    True
  EvidenceVarUse ->
    True
  _ ->
    False

data TypeProjection = TypeProjection
  { tpWords :: !(Map SourceRegion (Set.Set TypeWords)),
    tpObstructions :: !(Map SourceRegion (Set.Set TypeGraphObstruction))
  }

projectTypeRoots ::
  Array TypeIndex HieTypeFlat ->
  Map SourceRegion (Set.Set TypeIndex) ->
  TypeProjection
projectTypeRoots typeTable rootsByRegion =
  let regionsByRoot = regionsByTypeRoot rootsByRegion
      compiledRoots = hieTypeRootsTypeWords typeTable (Map.keysSet regionsByRoot)
   in Map.foldlWithKey'
        (projectRoot compiledRoots)
        TypeProjection {tpWords = Map.empty, tpObstructions = Map.empty}
        regionsByRoot
  where
    projectRoot compiledRoots projection typeIndex regions =
      case Map.findWithDefault (Left (MissingTypeIndex typeIndex)) typeIndex compiledRoots of
        Left obstruction ->
          projection
            { tpObstructions =
                insertAcrossRegions obstruction regions (tpObstructions projection)
            }
        Right wordsValue ->
          projection
            { tpWords =
                insertAcrossRegions wordsValue regions (tpWords projection)
            }

regionsByTypeRoot :: Map SourceRegion (Set.Set TypeIndex) -> Map TypeIndex (Set.Set SourceRegion)
regionsByTypeRoot =
  Map.foldlWithKey'
    ( \rootsByIndex region typeIndices ->
        Set.foldl'
          (\nextRoots typeIndex -> insertAt typeIndex region nextRoots)
          rootsByIndex
          typeIndices
    )
    Map.empty

insertAcrossRegions ::
  (Ord value) =>
  value ->
  Set.Set SourceRegion ->
  Map SourceRegion (Set.Set value) ->
  Map SourceRegion (Set.Set value)
insertAcrossRegions value regions valuesByRegion =
  Set.foldl'
    (\nextValues region -> insertAt region value nextValues)
    valuesByRegion
    regions

identifierOrigin :: Identifier -> Maybe ResolvedOrigin
identifierOrigin = \case
  Left _ ->
    Nothing
  Right name ->
    nameOrigin name

nameOrigin :: Name -> Maybe ResolvedOrigin
nameOrigin name =
  if isExternalName name
    then
      let nameModuleValue = nameModule name
          unitText = unitString (moduleUnit nameModuleValue)
       in case mkPackageUnit unitText of
            Left _ ->
              Nothing
            Right unitValue ->
              Just
                ResolvedOrigin
                  { roUnit = unitValue,
                    roModule = moduleNameString (moduleName nameModuleValue),
                    roOcc = occNameString (nameOccName name)
                  }
    else Nothing

data HieCollection = HieCollection
  { hcVisitedDirectories :: !(Set.Set FilePath),
    hcFiles :: !(Set.Set FilePath),
    hcErrorsReversed :: ![HieReadError]
  }

emptyHieCollection :: HieCollection
emptyHieCollection =
  HieCollection
    { hcVisitedDirectories = Set.empty,
      hcFiles = Set.empty,
      hcErrorsReversed = []
    }

data TraversalContext
  = RootContext
  | DescendantContext

data PathKind
  = DirectoryPath
  | DirectorySymlinkPath
  | FilePathKind
  | MissingPath

collectHieRoots :: [FilePath] -> IO HieCollection
collectHieRoots =
  foldlM
    (\collection root -> collectPath RootContext root collection)
    emptyHieCollection
    . sort

collectPath ::
  TraversalContext ->
  FilePath ->
  HieCollection ->
  IO HieCollection
collectPath context path collection = do
  pathKindResult <- classifyPath path
  case pathKindResult of
    Left message ->
      pure (recordTraversalFailure context path message collection)
    Right MissingPath ->
      pure (recordTraversalFailure context path "no such file or directory" collection)
    Right DirectorySymlinkPath ->
      pure
        ( case context of
            RootContext ->
              recordTraversalFailure
                RootContext
                path
                "directory symlink roots are not traversed"
                collection
            DescendantContext ->
              collection
        )
    Right DirectoryPath ->
      collectDirectory context path collection
    Right FilePathKind ->
      collectFile context path collection

classifyPath :: FilePath -> IO (Either String PathKind)
classifyPath path = do
  symbolicLinkResult <- tryFilesystem (pathIsSymbolicLink path)
  case symbolicLinkResult of
    Left message ->
      pure (Left message)
    Right symbolicLink -> do
      directoryResult <- tryFilesystem (doesDirectoryExist path)
      fileResult <- tryFilesystem (doesFileExist path)
      pure
        ( classifyObservedPath symbolicLink
            <$> directoryResult
            <*> fileResult
        )

classifyObservedPath :: Bool -> Bool -> Bool -> PathKind
classifyObservedPath symbolicLink directoryExists fileExists
  | directoryExists && symbolicLink =
      DirectorySymlinkPath
  | directoryExists =
      DirectoryPath
  | fileExists =
      FilePathKind
  | otherwise =
      MissingPath

collectDirectory ::
  TraversalContext ->
  FilePath ->
  HieCollection ->
  IO HieCollection
collectDirectory context directory collection = do
  canonicalResult <- canonicalPath directory
  case canonicalResult of
    Left message ->
      pure (recordTraversalFailure context directory message collection)
    Right canonicalDirectory
      | Set.member canonicalDirectory (hcVisitedDirectories collection) ->
          pure collection
      | otherwise -> do
          entriesResult <- tryFilesystem (listDirectory canonicalDirectory)
          case entriesResult of
            Left message ->
              pure (recordTraversalFailure context directory message collection)
            Right entries ->
              foldlM
                (\nextCollection entry -> collectPath DescendantContext (canonicalDirectory </> entry) nextCollection)
                collection
                  { hcVisitedDirectories =
                      Set.insert canonicalDirectory (hcVisitedDirectories collection)
                  }
                (sort (filter (not . isPrefixOf ".") entries))

collectFile ::
  TraversalContext ->
  FilePath ->
  HieCollection ->
  IO HieCollection
collectFile context path collection
  | not (hieFilePath path) =
      pure collection
  | otherwise = do
      canonicalResult <- canonicalPath path
      pure
        ( either
            (\message -> recordTraversalFailure context path message collection)
            (\canonicalFile -> collection {hcFiles = Set.insert canonicalFile (hcFiles collection)})
            canonicalResult
        )

canonicalPath :: FilePath -> IO (Either String FilePath)
canonicalPath path =
  fmap normalise <$> tryFilesystem (canonicalizePath path)

tryFilesystem :: IO value -> IO (Either String value)
tryFilesystem action =
  first show <$> trySynchronousException action

trySynchronousException :: IO value -> IO (Either SomeException value)
trySynchronousException action = do
  result <- try action
  case result of
    Left exceptionValue ->
      case fromException exceptionValue :: Maybe SomeAsyncException of
        Just asynchronousException ->
          throwIO asynchronousException
        Nothing ->
          pure (Left exceptionValue)
    Right value ->
      pure (Right value)

recordTraversalFailure ::
  TraversalContext ->
  FilePath ->
  String ->
  HieCollection ->
  HieCollection
recordTraversalFailure context path message collection =
  collection
    { hcErrorsReversed =
        traversalFailure context path message : hcErrorsReversed collection
    }

traversalFailure :: TraversalContext -> FilePath -> String -> HieReadError
traversalFailure RootContext =
  HieRootError
traversalFailure DescendantContext =
  HieTraversalError

hieFilePath :: FilePath -> Bool
hieFilePath =
  (== ".hie") . takeExtension