packages feed

tilia-0.0.2.0: src/Tilia/Fixity/Cache.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Package-level cache.
module Tilia.Fixity.Cache
  ( Cache,
    PlanToken (..),
    openCache,
    cachedModules,
    storeModules,
    cachedFixities,
    storeFixities,
    cachedExportNames,
    storeExportNames,
    cachedChildren,
    storeChildren,
    cachedInstalled,
    storeInstalled,
    cachedFutileSolve,
    storeFutileSolve,
    cachedFutileFetch,
    storeFutileFetch,
  )
where

import Control.Monad (join)
import Data.Choice (Choice, isFalse)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Text.Read qualified as T
import System.Directory
  ( XdgDirectory (XdgCache),
    createDirectoryIfMissing,
    doesFileExist,
    getModificationTime,
    getXdgDirectory,
    renameFile,
  )
import System.FilePath (takeDirectory, (</>))
import Tilia.Fixity
import Tilia.Fixity.PackageDb (Installed (..), InstalledPackage (..))
import Tilia.Utils (quietly)

-- | Where cached answers are kept together with a token unique to this
-- build plan and environment.
data Cache = Cache FilePath PlanToken

-- | A token that is unique to this plan and environment. It is needed in
-- order to be able to cache the expensive class of lookup failures that are
-- related to chasing module re-export chains. Rather than track what each
-- failure leaned on, all of them are tied to the plan and the environment
-- as a whole.
--
-- The environment belongs in it because availability of a module the plan
-- names depends on where we run the query and that is settled outside the
-- project. See 'Tilia.Fixity.PackageDb.compilerIdentity'.
newtype PlanToken = PlanToken Text
  deriving (Eq, Show)

-- | Bumped whenever cache format changes.
formatVersion :: FilePath
formatVersion = "v1"

-- | Open, creating the directory if need be.
--
-- 'Nothing' if the cache is not to be used or there is nowhere to write,
-- in which case everything still works and is merely slower.
openCache :: Choice "useCache" -> PlanToken -> IO (Maybe Cache)
openCache use token
  | isFalse use = pure Nothing
  | otherwise = quietly Nothing $ do
      root <- (</> formatVersion) <$> getXdgDirectory XdgCache "tilia"
      createDirectoryIfMissing True (root </> "modules")
      createDirectoryIfMissing True (root </> "fixities")
      createDirectoryIfMissing True (root </> "installed")
      pure (Just (Cache root token))

-- | The modules a package exposes, if that was worked out before.
cachedModules :: Cache -> Text -> IO (Maybe [Text])
cachedModules cache package =
  readIfPresent (modulesPath cache package) $
    filter (not . T.null) . T.lines

-- | Remember what a package exposes.
storeModules :: Cache -> Text -> [Text] -> IO ()
storeModules cache package =
  writeAtomically (modulesPath cache package) . T.unlines

-- | What was established about a module before, if anything.
cachedFixities ::
  -- | Where to look.
  Cache ->
  -- | The package the module belongs to. Opaque here.
  Text ->
  -- | The module, by its full dotted name.
  Text ->
  -- | What was established, or 'Nothing' if nothing was.
  IO (Maybe Established)
cachedFixities cache@(Cache _ (PlanToken token)) package modName =
  fmap join . readIfPresent (fixitiesPath cache package modName) $ \contents ->
    case T.lines contents of
      ("read" : entries) -> Declares . Map.fromList <$> traverse parseFixity entries
      [unread] | Just rest <- T.stripPrefix ("unread\t" <> token) unread ->
        case T.uncons rest of
          Nothing -> Just (Unreadable Nothing)
          Just ('\t', below) | not (T.null below) -> Just (Unreadable (Just below))
          _ -> Nothing
      _ -> Nothing

-- | Remember what reading a module established.
storeFixities ::
  -- | Where to write.
  Cache ->
  -- | The package the module belongs to, as 'cachedFixities' takes it.
  Text ->
  -- | The module, by its full dotted name.
  Text ->
  -- | What was established about it.
  Established ->
  IO ()
storeFixities cache package modName answer = do
  quietly () (createDirectoryIfMissing True (fixitiesDir cache package))
  writeAtomically (fixitiesPath cache package modName) $
    case answer of
      Unreadable below ->
        T.unlines ["unread\t" <> token <> foldMap ("\t" <>) below]
      Declares fixities ->
        T.unlines ("read" : fmap renderFixity (Map.toList fixities))
  where
    Cache _ (PlanToken token) = cache

-- | What a module's export list was found to say, if it was ever read.
cachedExportNames ::
  -- | Where to look.
  Cache ->
  -- | The package the module belongs to, as 'cachedFixities' takes it.
  Text ->
  -- | The module, by its full dotted name.
  Text ->
  -- | What its export list said, or 'Nothing' if it was never read.
  IO (Maybe Exported)
cachedExportNames cache package modName =
  fmap join . readIfPresent (exportsPath cache package modName) $ \contents ->
    case T.lines contents of
      ("names" : entries) -> Just (Exports (Set.fromList (fmap OpName entries)))
      ["untellable"] -> Just Untellable
      _ -> Nothing

-- | Remember what a module's export list said.
storeExportNames ::
  -- | Where to write.
  Cache ->
  -- | The package the module belongs to, as 'cachedFixities' takes it.
  Text ->
  -- | The module, by its full dotted name.
  Text ->
  -- | What its export list said.
  Exported ->
  IO ()
storeExportNames cache package modName answer = do
  quietly () (createDirectoryIfMissing True (exportsDir cache package))
  writeAtomically (exportsPath cache package modName) $
    case answer of
      Untellable -> T.unlines ["untellable"]
      Exports names -> T.unlines ("names" : [op | OpName op <- Set.toAscList names])

-- | What a module keeps under each of its names, if it was ever read for
-- it.
cachedChildren ::
  -- | Where to look.
  Cache ->
  -- | The package the module belongs to, as 'cachedFixities' takes it.
  Text ->
  -- | The module, by its full dotted name.
  Text ->
  -- | What it keeps under each name, or 'Nothing' if it was never read.
  IO (Maybe (Map OpName (Set OpName)))
cachedChildren cache package modName =
  fmap join . readIfPresent (childrenPath cache package modName) $ \contents ->
    case T.lines contents of
      ("children" : entries) -> Just (Map.fromList (mapMaybe childEntry entries))
      _ -> Nothing
  where
    childEntry line = case T.splitOn "\t" line of
      (parent : kids) -> Just (OpName parent, Set.fromList (fmap OpName kids))
      [] -> Nothing

-- | Remember what a module keeps under each of its names.
storeChildren ::
  -- | Where to write.
  Cache ->
  -- | The package the module belongs to, as 'cachedFixities' takes it.
  Text ->
  -- | The module, by its full dotted name.
  Text ->
  -- | What it keeps under each name.
  Map OpName (Set OpName) ->
  IO ()
storeChildren cache package modName children = do
  quietly () (createDirectoryIfMissing True (childrenDir cache package))
  writeAtomically (childrenPath cache package modName) $
    T.unlines ("children" : fmap entry (Map.toList children))
  where
    entry (OpName parent, kids) =
      T.intercalate "\t" (parent : [kid | OpName kid <- Set.toAscList kids])

-- | What packages the compiler could see when last asked, if it can still
-- see it.
cachedInstalled :: Cache -> IO (Maybe [InstalledPackage])
cachedInstalled cache = quietly Nothing $ do
  readIfPresent (installedPath cache) T.lines >>= \case
    Nothing -> pure Nothing
    Just ls -> do
      let written =
            [(T.unpack path, stamp) | ["db", path, stamp] <- fmap fields ls]
      still <- traverse unchanged written
      pure $
        if not (null written) && and still
          then Just (mapMaybe installedFrom ls)
          else Nothing
  where
    unchanged (path, stamp) =
      quietly False ((== stamp) . T.pack . show <$> getModificationTime path)
    installedFrom l = case fields l of
      ("pkg" : name : version : modules : dirs) ->
        Just
          InstalledPackage
            { ipName = name,
              ipVersion = version,
              ipModules = T.words modules,
              ipImportDirs = fmap T.unpack dirs
            }
      _ -> Nothing
    fields = T.splitOn "\t"

-- | Remember a package the compiler can see, stamped so that a later run
-- can tell whether it still does.
storeInstalled :: Cache -> Installed -> IO ()
storeInstalled cache found
  | null (installedDatabases found) = pure ()
  | otherwise = quietly () $ do
      stamps <- traverse stamped (installedDatabases found)
      writeAtomically (installedPath cache) . T.unlines $
        [T.intercalate "\t" ["db", T.pack path, stamp] | (path, stamp) <- stamps]
          <> [ T.intercalate "\t" $
                 ["pkg", ipName p, ipVersion p, T.unwords (ipModules p)]
                   <> fmap T.pack (ipImportDirs p)
             | p <- installedPackages found
             ]
  where
    stamped path = do
      stamp <- T.pack . show <$> getModificationTime path
      pure (path, stamp)

-- | Whether asking @cabal@ to solve this plan again has already been tried
-- and left the plan exactly as before.
cachedFutileSolve :: Cache -> IO Bool
cachedFutileSolve cache =
  quietly False (doesFileExist (futileSolvePath cache))

-- | Remember that solving again did not widen the plan.
storeFutileSolve :: Cache -> IO ()
storeFutileSolve cache = quietly () $ do
  createDirectoryIfMissing True (takeDirectory (futileSolvePath cache))
  writeAtomically (futileSolvePath cache) ""

-- | The packages an earlier run was still short of after asking @cabal@ to
-- fetch them.
cachedFutileFetch :: Cache -> IO [Text]
cachedFutileFetch cache =
  fromMaybe []
    <$> readIfPresent
      (futileFetchPath cache)
      (filter (not . T.null) . T.lines)

-- | Remember what fetching left missing.
storeFutileFetch :: Cache -> [Text] -> IO ()
storeFutileFetch cache packages = quietly () $ do
  createDirectoryIfMissing True (takeDirectory (futileFetchPath cache))
  writeAtomically (futileFetchPath cache) (T.unlines packages)

-- | Render a fixity declaration as 'Text'.
renderFixity :: ((Namespace, OpName), Fixity) -> Text
renderFixity ((namespace, OpName op), Fixity direction precedence) =
  T.intercalate
    "\t"
    [op, renderNamespace namespace, renderDirection direction, T.pack (show precedence)]
  where
    renderNamespace = \case
      InTypes -> "t"
      InTerms -> "v"
    renderDirection = \case
      LeftAssoc -> "l"
      RightAssoc -> "r"
      NoAssoc -> "n"

-- | Parse a fixity declaration from 'Text'.
parseFixity :: Text -> Maybe ((Namespace, OpName), Fixity)
parseFixity line = case T.splitOn "\t" line of
  [op, namespace, direction, precedence] -> do
    n <- parseNamespace namespace
    d <- parseDirection direction
    p <- readPrecedence precedence
    pure ((n, OpName op), Fixity d p)
  _ -> Nothing
  where
    parseNamespace = \case
      "t" -> Just InTypes
      "v" -> Just InTerms
      _ -> Nothing
    parseDirection = \case
      "l" -> Just LeftAssoc
      "r" -> Just RightAssoc
      "n" -> Just NoAssoc
      _ -> Nothing
    readPrecedence t = case T.signed T.decimal t of
      Right (p, rest) | T.null rest -> Just p
      _ -> Nothing

-- | The directory where fixities are stored.
fixitiesDir :: Cache -> Text -> FilePath
fixitiesDir (Cache root _) package = root </> "fixities" </> T.unpack package

-- | Where @ghc-pkg dump@ is remembered.
installedPath :: Cache -> FilePath
installedPath (Cache root (PlanToken token)) =
  root </> "installed" </> T.unpack token

-- | An empty file whose mere presence says solving this plan again would
-- not widen it.
futileSolvePath :: Cache -> FilePath
futileSolvePath (Cache root (PlanToken token)) =
  root </> "solves" </> T.unpack token

-- | The packages fetching left missing, one per line.
futileFetchPath :: Cache -> FilePath
futileFetchPath (Cache root (PlanToken token)) =
  root </> "fetches" </> T.unpack token

-- | Every module a package holds, one per line.
modulesPath :: Cache -> Text -> FilePath
modulesPath (Cache root _) package = root </> "modules" </> T.unpack package

-- | What reading one module established about fixities.
fixitiesPath :: Cache -> Text -> Text -> FilePath
fixitiesPath cache package modName =
  fixitiesDir cache package </> T.unpack modName

-- | The directory where export lists are stored.
exportsDir :: Cache -> Text -> FilePath
exportsDir (Cache root _) package = root </> "exports" </> T.unpack package

-- | What one module's export list was found to say.
exportsPath :: Cache -> Text -> Text -> FilePath
exportsPath cache package modName =
  exportsDir cache package </> T.unpack modName

-- | The directory where the names kept under a name are stored.
childrenDir :: Cache -> Text -> FilePath
childrenDir (Cache root _) package = root </> "children" </> T.unpack package

-- | What one module keeps under each of its names.
childrenPath :: Cache -> Text -> Text -> FilePath
childrenPath cache package modName =
  childrenDir cache package </> T.unpack modName

-- | Read and parse a file, or 'Nothing' where there is none to read.
readIfPresent :: FilePath -> (Text -> a) -> IO (Maybe a)
readIfPresent path parse = quietly Nothing $ do
  there <- doesFileExist path
  if there then Just . parse <$> T.readFile path else pure Nothing

-- | Write via a temporary file and a rename.
writeAtomically :: FilePath -> Text -> IO ()
writeAtomically path contents = quietly () $ do
  let temporary = path <> ".tmp"
  T.writeFile temporary contents
  renameFile temporary path