tilia-0.1.0.0: src/Tilia/Fixity/Cache.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Package-level cache.
module Tilia.Fixity.Cache
( Cache,
PlanToken (..),
openCache,
recalled,
cachedModules,
storeModules,
cachedEstablished,
storeEstablished,
cachedSummaries,
storeSummaries,
cachedInstalled,
storeInstalled,
cachedFutileSolve,
storeFutileSolve,
cachedFutileFetch,
storeFutileFetch,
)
where
import Control.Monad (join)
import Data.Choice (Choice, isFalse)
import Data.Foldable (toList, traverse_)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, isJust)
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 Data.Time.Format (defaultTimeLocale, formatTime)
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, or nowhere, in which case nothing is
-- remembered.
data Cache = Cache FilePath PlanToken | NoCache
-- | 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 = "v2"
-- | Open, creating the directory if need be.
--
-- 'NoCache' 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 Cache
openCache use token
| isFalse use = pure NoCache
| otherwise = quietly NoCache $ do
root <- (</> formatVersion) <$> getXdgDirectory XdgCache "tilia"
createDirectoryIfMissing True root
pure (Cache root token)
-- | What was remembered, or else what reading finds, remembered in turn.
recalled ::
-- | Recall the answer.
IO (Maybe a) ->
-- | Remember one.
(a -> IO ()) ->
-- | Work the answer out, if there is one to be had.
IO (Maybe a) ->
IO (Maybe a)
recalled recall remember work =
recall >>= \case
Just answer -> pure (Just answer)
Nothing -> do
found <- work
traverse_ remember found
pure found
-- | The modules a package exposes, if that was worked out before.
cachedModules :: Cache -> Text -> IO (Maybe [Text])
cachedModules cache package =
readIfPresent (at cache ["modules", package]) $
filter (not . T.null) . T.lines
-- | Remember what a package exposes.
storeModules :: Cache -> Text -> [Text] -> IO ()
storeModules cache package =
writeAtomically (at cache ["modules", package]) . T.unlines
-- | What was established about a module before, if anything.
cachedEstablished ::
-- | 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, or if what it
-- left unsettled was left so under another plan.
IO (Maybe Established)
cachedEstablished cache package modName =
fmap join . readIfPresent (at cache ["established", package, modName]) $
assembled . fmap T.words . T.lines
where
assembled entries = do
let fieldsOf kind = [fields | k : fields <- entries, k == kind]
fixities <- traverse parseFixity (fieldsOf "fixity")
unsettled <- traverse unsettledEntry (fieldsOf "unsettled")
untold <- traverse untoldEntry (fieldsOf "untold")
names <- concat <$> traverse parseByNamespace (fieldsOf "names")
certain <- traverse (memberEntry parseByNamespace) (fieldsOf "member")
members <-
traverse (memberEntry (Just . fmap OpName)) (fieldsOf "members")
let byParent = Map.fromListWith Set.union certain
pure
Established
{ establishedFixities = Map.fromList fixities,
establishedUnsettled = Map.fromListWith Set.union unsettled,
establishedUntold = Set.fromList untold,
establishedCertain = Certain (Set.fromList names) byParent,
establishedMembers =
Map.union
(Map.fromListWith Set.union members)
(Map.map (Set.map snd) byParent)
}
unsettledEntry = \case
token : fields
| token == tokenOf cache ->
let (chain, names) = break (isJust . parseNamespace) fields
in (chain,) . Set.fromList <$> parseNamespaced names
_ -> Nothing
untoldEntry = \case
token : chain | token == tokenOf cache -> Just chain
_ -> Nothing
memberEntry kidsOf = \case
parent : kids -> (OpName parent,) . Set.fromList <$> kidsOf kids
[] -> Nothing
-- | Remember what reading a module established.
storeEstablished ::
-- | Where to write.
Cache ->
-- | The package the module belongs to, as 'cachedEstablished' takes it.
Text ->
-- | The module, by its full dotted name.
Text ->
-- | What was established about it.
Established ->
IO ()
storeEstablished cache package modName established =
writeAtomically path . renderLines $
fmap (("fixity" :) . renderFixity) (Map.toList fixities)
<> [ ["unsettled", tokenOf cache] <> chain <> renderNamespaced names
| (chain, names) <- Map.toList (establishedUnsettled established)
]
<> [ "untold" : tokenOf cache : chain
| chain <- Set.toList (establishedUntold established)
]
<> ["names" : fields | fields <- renderByNamespace (certainNames certain)]
<> [ "member" : parent : fields
| (OpName parent, kids) <- Map.toList (certainMembers certain),
fields <- case renderByNamespace kids of
[] -> [[]]
grouped -> grouped
]
-- The members of a name are mostly its certain members, which the
-- lines above already say.
<> [ "members" : parent : [kid | OpName kid <- Set.toAscList kids]
| (OpName parent, kids) <- Map.toList (establishedMembers established),
Just kids /= Map.lookup (OpName parent) written
]
where
path = at cache ["established", package, modName]
fixities = establishedFixities established
certain = establishedCertain established
written = Map.map (Set.map snd) (certainMembers certain)
-- | What each configuration of one of the project's own modules says, if it
-- was last read from what the stamp stands for.
cachedSummaries ::
-- | Where to look.
Cache ->
-- | Which module, by a name for its file.
Text ->
-- | What it was read from: its text and the settings it was read with.
Text ->
-- | What it said, or 'Nothing' if nothing is remembered for that stamp.
IO (Maybe (Maybe (NonEmpty ModuleSummary)))
cachedSummaries cache key stamp =
fmap join . readIfPresent (at cache ["summaries", key]) $ \contents ->
case T.lines contents of
(header : rest) | header == readFrom stamp -> parseSummaries rest
_ -> Nothing
-- | Remember what each configuration of one of the project's own modules
-- says, in place of what an earlier text of it said.
storeSummaries ::
-- | Where to write.
Cache ->
-- | Which module, as 'cachedSummaries' takes it.
Text ->
-- | What it was read from, as 'cachedSummaries' takes it.
Text ->
-- | What it said, or 'Nothing' where none of its configurations parses.
Maybe (NonEmpty ModuleSummary) ->
IO ()
storeSummaries cache key stamp summaries =
writeAtomically (at cache ["summaries", key]) . T.unlines $
readFrom stamp : renderSummaries summaries
-- | The first line of a remembered summary: what it was read from, and the
-- versions of Tilia and of the parser that read it.
readFrom :: Text -> Text
readFrom stamp =
T.unwords ["for", stamp, VERSION_tilia, VERSION_ghc_lib_parser]
-- | Render what a module's configurations say, a line for each thing.
renderSummaries :: Maybe (NonEmpty ModuleSummary) -> [Text]
renderSummaries = \case
Nothing -> ["unparsed"]
Just summaries ->
concatMap
(("configuration" :) . fmap T.unwords . renderSummary)
(toList summaries)
where
renderSummary s =
[["name", name] | Just name <- [summaryName s]]
<> foldMap
(\items -> ["exports"] : fmap renderExport items)
(summaryExports s)
<> concatMap renderImport (summaryImports s)
<> fmap (("fixity" :) . renderFixity) (Map.toList (summaryFixities s))
<> [ ["defines", renderNamespace namespace, op]
| (namespace, OpName op) <- Set.toAscList (summaryNames s)
]
<> fmap declares (Map.toList (summaryDeclaredMembers s))
<> fmap offers (Map.toList (summaryListedMembers s))
declares (OpName parent, kids) = "declares" : parent : renderNamespaced kids
offers (OpName parent, kids) =
"offers" : parent : [kid | OpName kid <- Set.toAscList kids]
renderExport = \case
ExportName namespace qualifier op ->
["export", "name", renderNamespace namespace] <> under qualifier [op]
ExportAll qualifier op -> ["export", "all"] <> under qualifier [op]
ExportSome qualifier op kids ->
["export", "some"] <> under qualifier (op : kids)
ExportModule m -> ["export", "module", m]
under qualifier ops = fromMaybe "-" qualifier : [op | OpName op <- ops]
renderImport i =
["import", importModule i, qualification i, importAlias i]
: case importNames i of
Nothing -> []
Just (hiding, items) ->
["list", if hiding then "hiding" else "only"]
: fmap renderItem items
qualification i = if importQualified i then "qualified" else "open"
renderItem = \case
ImportedName (OpName op) -> ["item", "name", op]
ImportedAll (OpName op) -> ["item", "all", op]
ImportedSome op kids -> "item" : "some" : [k | OpName k <- op : kids]
-- | Parse what 'renderSummaries' rendered.
parseSummaries :: [Text] -> Maybe (Maybe (NonEmpty ModuleSummary))
parseSummaries = \case
["unparsed"] -> Just Nothing
ls -> Just <$> (NE.nonEmpty =<< traverse parseSummary =<< configurations ls)
where
configurations = \case
[] -> Just []
"configuration" : rest ->
let (these, more) = break (== "configuration") rest
in (these :) <$> configurations more
_ -> Nothing
parseSummary = go empty . fmap T.words
where
empty = ModuleSummary Nothing Nothing [] Map.empty Set.empty Map.empty Map.empty
go s = \case
[] ->
Just
s
{ summaryExports = reverse <$> summaryExports s,
summaryImports = reverse (summaryImports s)
}
["name", name] : rest -> go s{summaryName = Just name} rest
["exports"] : rest -> go s{summaryExports = Just []} rest
("export" : fields) : rest -> do
item <- exportItem fields
items <- summaryExports s
go s{summaryExports = Just (item : items)} rest
["import", m, how, alias] : rest -> do
qualified <- case how of
"qualified" -> Just True
"open" -> Just False
_ -> Nothing
let (listed, rest') = span isListed rest
list <- importList listed
go s{summaryImports = Import m qualified alias list : summaryImports s} rest'
("fixity" : fields) : rest -> do
(key, fixity) <- parseFixity fields
go s{summaryFixities = Map.insert key fixity (summaryFixities s)} rest
["defines", namespace, op] : rest -> do
n <- parseNamespace namespace
go s{summaryNames = Set.insert (n, OpName op) (summaryNames s)} rest
("declares" : parent : kids) : rest -> do
namespaced <- parseNamespaced kids
go s{summaryDeclaredMembers = Map.insert (OpName parent) (Set.fromList namespaced) (summaryDeclaredMembers s)} rest
("offers" : parent : kids) : rest ->
go s{summaryListedMembers = Map.insert (OpName parent) (names kids) (summaryListedMembers s)} rest
_ -> Nothing
names = Set.fromList . fmap OpName
qualifier q = if q == "-" then Nothing else Just q
exportItem = \case
["name", namespace, q, op] ->
(\n -> ExportName n (qualifier q) (OpName op)) <$> parseNamespace namespace
["all", q, op] -> Just (ExportAll (qualifier q) (OpName op))
"some" : q : op : kids -> Just (ExportSome (qualifier q) (OpName op) (fmap OpName kids))
["module", m] -> Just (ExportModule m)
_ -> Nothing
isListed = \case
"list" : _ -> True
"item" : _ -> True
_ -> False
importList = \case
[] -> Just Nothing
["list", way] : items -> do
hiding <- case way of
"hiding" -> Just True
"only" -> Just False
_ -> Nothing
Just . (,) hiding <$> traverse importItem items
_ -> Nothing
importItem = \case
["item", "name", op] -> Just (ImportedName (OpName op))
["item", "all", op] -> Just (ImportedAll (OpName op))
"item" : "some" : op : kids -> Just (ImportedSome (OpName op) (fmap OpName kids))
_ -> Nothing
-- | 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 (at cache ["installed", tokenOf cache]) T.lines >>= \case
Nothing -> pure Nothing
Just ls -> do
let (databases, packages) = span (T.isPrefixOf "db ") ls
written =
[ (T.unpack (T.drop 1 path), stamp)
| Just database <- fmap (T.stripPrefix "db ") databases,
let (stamp, path) = T.breakOn " " database
]
still <- traverse unchanged written
pure $
if not (null written) && and still
then installedFrom packages
else Nothing
where
unchanged (path, stamp) = quietly False ((== stamp) <$> stampOf path)
installedFrom = \case
[] -> Just []
l : rest -> case T.words l of
"package" : name : version : modules ->
let (owned, more) = break (T.isPrefixOf "package ") rest
reexports = [(v, o) | ["reexport", v, o] <- fmap T.words owned]
dirs = [T.unpack d | Just d <- fmap (T.stripPrefix "dir ") owned]
in (InstalledPackage name version modules reexports dirs :)
<$> installedFrom more
_ -> Nothing
-- | 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 databases = pure ()
| otherwise = quietly () $ do
stamps <- traverse stampOf databases
writeAtomically (at cache ["installed", tokenOf cache]) . renderLines $
[["db", stamp, T.pack path] | (path, stamp) <- zip databases stamps]
<> concatMap packageLines (installedPackages found)
where
databases = installedDatabases found
packageLines p =
("package" : ipName p : ipVersion p : ipModules p)
: [["reexport", v, o] | (v, o) <- ipReexports p]
<> [["dir", T.pack dir] | dir <- ipImportDirs p]
-- | When a package database last changed, as one field.
stampOf :: FilePath -> IO Text
stampOf path =
T.pack . formatTime defaultTimeLocale "%s%Q" <$> getModificationTime path
-- | 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 =
isJust <$> readIfPresent (at cache ["solves", tokenOf cache]) (const ())
-- | Remember that solving again did not widen the plan.
storeFutileSolve :: Cache -> IO ()
storeFutileSolve cache =
writeAtomically (at cache ["solves", tokenOf cache]) ""
-- | The packages an earlier run was still short of after asking @cabal@ to
-- fetch them.
cachedFutileFetch :: Cache -> IO [Text]
cachedFutileFetch cache =
fromMaybe []
<$> readIfPresent
(at cache ["fetches", tokenOf cache])
(filter (not . T.null) . T.lines)
-- | Remember what fetching left missing.
storeFutileFetch :: Cache -> [Text] -> IO ()
storeFutileFetch cache =
writeAtomically (at cache ["fetches", tokenOf cache]) . T.unlines
-- | Render a fixity declaration as fields.
renderFixity :: ((Namespace, OpName), Fixity) -> [Text]
renderFixity ((namespace, OpName op), Fixity direction precedence) =
[ op,
renderNamespace namespace,
renderDirection direction,
T.pack (show precedence)
]
where
renderDirection = \case
LeftAssoc -> "l"
RightAssoc -> "r"
NoAssoc -> "n"
-- | Parse a fixity declaration from fields.
parseFixity :: [Text] -> Maybe ((Namespace, OpName), Fixity)
parseFixity = \case
[op, namespace, direction, precedence] -> do
n <- parseNamespace namespace
d <- parseDirection direction
p <- readPrecedence precedence
pure ((n, OpName op), Fixity d p)
_ -> Nothing
where
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
-- | Render a namespace as 'Text'.
renderNamespace :: Namespace -> Text
renderNamespace = \case
InTypes -> "t"
InTerms -> "v"
-- | Parse a namespace from 'Text'.
parseNamespace :: Text -> Maybe Namespace
parseNamespace = \case
"t" -> Just InTypes
"v" -> Just InTerms
_ -> Nothing
-- | Render names in their namespaces as fields, each namespace before its
-- name.
renderNamespaced :: Set (Namespace, OpName) -> [Text]
renderNamespaced names =
concat [[renderNamespace namespace, op] | (namespace, OpName op) <- Set.toAscList names]
-- | Render names as one list of fields for each namespace they are in, the
-- namespace first.
renderByNamespace :: Set (Namespace, OpName) -> [[Text]]
renderByNamespace names =
[ renderNamespace namespace : ops
| namespace <- [InTypes, InTerms],
let ops = [op | (n, OpName op) <- Set.toAscList names, n == namespace],
not (null ops)
]
-- | Parse one list of fields 'renderByNamespace' rendered.
parseByNamespace :: [Text] -> Maybe [(Namespace, OpName)]
parseByNamespace = \case
[] -> Just []
namespace : ops -> (\n -> fmap ((n,) . OpName) ops) <$> parseNamespace namespace
-- | Parse what 'renderNamespaced' rendered.
parseNamespaced :: [Text] -> Maybe [(Namespace, OpName)]
parseNamespaced = \case
[] -> Just []
namespace : op : rest ->
(:) <$> ((,OpName op) <$> parseNamespace namespace) <*> parseNamespaced rest
_ -> Nothing
-- | Where the cache keeps what the path names, or 'Nothing' if it keeps
-- nothing.
at :: Cache -> [Text] -> Maybe FilePath
at cache path = case cache of
Cache root _ -> Just (foldl (</>) root (fmap T.unpack path))
NoCache -> Nothing
-- | The token a cache is kept under.
tokenOf :: Cache -> Text
tokenOf = \case
Cache _ (PlanToken token) -> token
NoCache -> ""
-- | Render lines of fields, the fields separated by spaces.
renderLines :: [[Text]] -> Text
renderLines = T.unlines . fmap T.unwords
-- | Read and parse a file, or 'Nothing' where there is none to read.
readIfPresent :: Maybe FilePath -> (Text -> a) -> IO (Maybe a)
readIfPresent place parse = case place of
Nothing -> pure Nothing
Just path -> 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, making the directory if need
-- be.
writeAtomically :: Maybe FilePath -> Text -> IO ()
writeAtomically place contents = case place of
Nothing -> pure ()
Just path -> quietly () $ do
createDirectoryIfMissing True (takeDirectory path)
let temporary = path <> ".tmp"
T.writeFile temporary contents
renameFile temporary path