packages feed

tricorder-0.2.0.0: src/Tricorder/SourceLookup.hs

module Tricorder.SourceLookup
    ( -- * Types
      SourceQuery (..)
    , ModuleSourceResult (..)

      -- * Lookup
    , lookupModuleSource
    ) where

import Atelier.Effects.Cache (Cache, cacheInsert, cacheLookup)
import Atelier.Effects.Env (Env)
import Atelier.Effects.FileSystem (FileSystem)
import Atelier.Effects.Log (Log)
import Data.Aeson (FromJSON, ToJSON)

import Atelier.Effects.Log qualified as Log

import Tricorder.Module (ModuleName (..), PackageId (..))
import Tricorder.SourceLookup.Cabal (Cabal)
import Tricorder.SourceLookup.GhcPkg (GhcPkg)
import Tricorder.SourceLookup.Slice (sliceSymbol)
import Tricorder.SourceLookup.Tarball
    ( TarballOutcome (..)
    , obtainTarball
    , readModuleMember
    )

import Tricorder.SourceLookup.GhcPkg qualified as GhcPkg


-- | The result of a source lookup for a single module.
data ModuleSourceResult
    = -- | Source was found; contains the module (or single-symbol) source text.
      SourceFound SourceQuery Text
    | -- | The module is not provided by any installed package.
      SourceNotFound SourceQuery
    | -- | The package was resolved but no source tarball could be located or
      -- fetched (no index, offline, yanked, or the archive could not be read).
      SourceUnavailable SourceQuery PackageId
    | -- | The module source was found but the requested symbol was not in it.
      FunctionNotFound SourceQuery
    deriving stock (Eq, Generic, Show)
    deriving anyclass (FromJSON, ToJSON)


-- | A query for module source: optionally scoped to a single top-level symbol.
data SourceQuery = SourceQuery
    { moduleName :: ModuleName
    , function :: Maybe Text
    -- ^ The symbol to slice: 'Nothing' is the whole module; @'Just' name@ is a
    -- single top-level declaration — a value binding, or (by initial casing) a
    -- type, class, or constructor.
    }
    deriving stock (Eq, Generic, Show)
    deriving anyclass (FromJSON, Hashable, ToJSON)


-- ── Lookup logic ───────────────────────────────────────────────────────────

-- | Resolve and return the source for a single module.
--
-- Resolves the module to its project-pinned 'PackageId' via @ghc-pkg@, then
-- serves source from that package's sdist tarball in cabal's global cache,
-- fetching it on demand if absent. A symbol query slices the relevant
-- declaration (with its doc comment) from the module source. Both resolution
-- steps are cached, so the fetch + read cost is paid at most once per
-- (package, query).
lookupModuleSource
    :: ( Cabal :> es
       , Cache (PackageId, SourceQuery) ModuleSourceResult :> es
       , Cache ModuleName PackageId :> es
       , Env :> es
       , FileSystem :> es
       , GhcPkg :> es
       , Log :> es
       )
    => SourceQuery
    -> Eff es ModuleSourceResult
lookupModuleSource query = do
    mPkg <- resolvePackage query.moduleName
    case mPkg of
        Nothing -> pure (SourceNotFound query)
        Just p -> do
            mCached <- cacheLookup @(PackageId, SourceQuery) @ModuleSourceResult (p, query)
            case mCached of
                Just result -> do
                    Log.debug $ "Source: " <> unModuleName query.moduleName <> " source hit (cached)"
                    pure result
                Nothing -> serveFromTarball query p


-- | Resolve a module to its package, consulting the module -> package cache first.
resolvePackage
    :: (Cache ModuleName PackageId :> es, GhcPkg :> es, Log :> es)
    => ModuleName
    -> Eff es (Maybe PackageId)
resolvePackage modName = do
    mCachedPkg <- cacheLookup @ModuleName @PackageId modName
    case mCachedPkg of
        Just p -> do
            Log.debug $ "Source: " <> unModuleName modName <> " → " <> unPackageId p <> " (cached)"
            pure (Just p)
        Nothing -> do
            result <- GhcPkg.findModule modName
            Log.debug $ "Source: find-module " <> unModuleName modName <> " → " <> show result
            whenJust result (cacheInsert @ModuleName @PackageId modName)
            pure result


-- | Locate (or fetch) the package's tarball, read the module member, and slice
-- the requested symbol if any. Caches and returns the result.
serveFromTarball
    :: ( Cabal :> es
       , Cache (PackageId, SourceQuery) ModuleSourceResult :> es
       , Env :> es
       , FileSystem :> es
       )
    => SourceQuery
    -> PackageId
    -> Eff es ModuleSourceResult
serveFromTarball query p =
    obtainTarball p >>= \case
        -- A failed `cabal fetch` is transient (offline, stale index), so return
        -- unavailable WITHOUT caching: a later lookup retries once the network or
        -- index recovers, rather than serving the negative for the whole window.
        TarballFetchFailed -> pure (SourceUnavailable query p)
        -- The package is genuinely absent — a deterministic negative, safe to
        -- cache alongside the read/slice outcomes below.
        TarballAbsent -> cacheResult (SourceUnavailable query p)
        TarballAt tarball -> do
            mModuleSrc <- readModuleMember tarball query.moduleName
            cacheResult $ case mModuleSrc of
                Nothing -> SourceUnavailable query p
                Just moduleSrc -> case query.function of
                    Nothing -> SourceFound query moduleSrc
                    Just symbol -> case sliceSymbol symbol moduleSrc of
                        Just slice -> SourceFound query slice
                        Nothing -> FunctionNotFound query
  where
    -- Cache a deterministic outcome so the fetch + read/slice cost is paid at
    -- most once per (package, query) within the cache window.
    cacheResult result = do
        cacheInsert @(PackageId, SourceQuery) @ModuleSourceResult (p, query) result
        pure result