tilia-0.0.1.0: src/Tilia/Fixity/Cache.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Remembering what was read out of a package, between runs.
--
-- Reading a dependency means decompressing an archive and parsing a module,
-- which costs tens of milliseconds. Doing it once is fine. Doing it again
-- for every file of a project, on every save, is not—and that is the shape
-- of the work when a formatter is driven from an editor.
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.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, read in this environment.
data Cache = Cache FilePath PlanToken
-- | A token that is unique to this plan, read in this 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 pair as a
-- whole: anything that could turn a failure into an answer changes one or
-- the other, and failures are few enough that re-deriving them when it does
-- costs little.
--
-- The environment belongs in it because a module the plan names is
-- unreadable where the compiler cannot be asked about its package and
-- readable where it can, and that is settled outside the project. See
-- 'Tilia.Fixity.PackageDb.compilerIdentity'.
newtype PlanToken = PlanToken Text
deriving (Eq, Show)
-- | Bumped whenever what is written changes shape, so that entries from an
-- older Tilia are ignored rather than misread.
formatVersion :: FilePath
formatVersion = "v1"
-- | Open, creating the directory if need be.
--
-- 'Nothing' if there is nowhere to write, in which case everything still
-- works and is merely slower.
--
-- The token is what a failure written through this cache is tied to, and
-- what one read back out of it has to match. See 'PlanToken'.
openCache :: PlanToken -> IO (Maybe Cache)
openCache token = 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 was.
--
-- An 'Unreadable' answer is offered back only under the 'PlanToken' it was
-- written under.
cachedFixities ::
-- | Where to look
Cache ->
-- | The package the module belongs to. Opaque here: whatever the caller
-- uses to tell one package from another is what an answer is filed under,
-- and answers filed under different keys never meet.
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 parseEntry 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 (packageDir 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" : map renderEntry (Map.toList fixities))
where
Cache _ (PlanToken token) = cache
----------------------------------------------------------------------------
-- Export names
-- | What a module's export list was found to say, if it was ever read.
--
-- Wanted for the modules whose fixities could not be established, and asked
-- exactly then: a warm cache answers those from 'cachedFixities' without
-- opening the archive at all, and without this the archive would be opened
-- anyway to ask this instead.
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 (map 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 name carries with it
-- | What a module keeps under each of its names, if it was ever read for
-- it.
--
-- Wanted wherever an import list writes @T(..)@, and got at by reading the
-- module's interface or its source—which on a warm cache is work that
-- would otherwise not be done at all.
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 (map 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" : map entry (Map.toList children))
where
entry (OpName parent, kids) =
T.intercalate "\t" (parent : [kid | OpName kid <- Set.toAscList kids])
----------------------------------------------------------------------------
-- The package database
-- | What the compiler could see when last asked, if it can still see it.
--
-- Whose answer this is, is settled by the path it was found at rather than
-- by anything written inside it. See 'installedPath'.
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] <- map 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 = map T.unpack dirs
}
_ -> Nothing
fields = T.splitOn "\t"
-- | Remember what the compiler can see, stamped so that a later run can
-- tell whether it still does.
--
-- Nothing is written when there is no database to stamp: an answer nothing
-- can invalidate is worse than no answer, because it never stops being
-- given.
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)]
<> map T.pack (ipImportDirs p)
| p <- installedPackages found
]
where
stamped path = do
stamp <- T.pack . show <$> getModificationTime path
pure (path, stamp)
----------------------------------------------------------------------------
-- Solves that came to nothing
-- | Whether asking @cabal@ to solve this plan again has already been tried
-- and left the plan saying exactly what it said 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, and which asking again will therefore not bring in.
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)
----------------------------------------------------------------------------
-- Entries
renderEntry :: ((Namespace, OpName), Fixity) -> Text
renderEntry ((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"
parseEntry :: Text -> Maybe ((Namespace, OpName), Fixity)
parseEntry 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
----------------------------------------------------------------------------
-- Paths and files
packageDir :: Cache -> Text -> FilePath
packageDir (Cache root _) package = root </> "fixities" </> T.unpack package
-- | Under the token, as everything else here is filed under the key that
-- decides it.
--
-- One file shared by every token could only ever carry a check saying whose
-- it was, which answers \"is this mine?\" and never \"where is mine?\": two
-- environments formatting the same project would take turns discarding each
-- other's answer and asking @ghc-pkg@ again.
installedPath :: Cache -> FilePath
installedPath (Cache root (PlanToken token)) =
root </> "installed" </> T.unpack token
futileSolvePath :: Cache -> FilePath
futileSolvePath (Cache root (PlanToken token)) =
root </> "solves" </> T.unpack token
futileFetchPath :: Cache -> FilePath
futileFetchPath (Cache root (PlanToken token)) =
root </> "fetches" </> T.unpack token
modulesPath :: Cache -> Text -> FilePath
modulesPath (Cache root _) package = root </> "modules" </> T.unpack package
fixitiesPath :: Cache -> Text -> Text -> FilePath
fixitiesPath cache package modName =
packageDir cache package </> T.unpack modName
exportsDir :: Cache -> Text -> FilePath
exportsDir (Cache root _) package = root </> "exports" </> T.unpack package
exportsPath :: Cache -> Text -> Text -> FilePath
exportsPath cache package modName =
exportsDir cache package </> T.unpack modName
childrenDir :: Cache -> Text -> FilePath
childrenDir (Cache root _) package = root </> "children" </> T.unpack package
childrenPath :: Cache -> Text -> Text -> FilePath
childrenPath cache package modName =
childrenDir cache package </> T.unpack modName
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.
--
-- Two formatters may run at once—an editor saving while a pre-commit hook
-- runs—and a half-written entry read by the other would be worse than no
-- entry at all. A rename is atomic, so a reader sees the old file or the
-- new one and never a partial one.
--
-- If anything fails the write is abandoned. A stray temporary left in a
-- cache directory costs nothing; a wrong answer would cost a great deal.
writeAtomically :: FilePath -> Text -> IO ()
writeAtomically path contents = quietly () $ do
let temporary = path <> ".tmp"
T.writeFile temporary contents
renameFile temporary path