build-1.1: src/Build/Task/Opaque.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ConstraintKinds, RankNTypes, FlexibleInstances, GADTs #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Opaque monadic tasks, whose inputs and outputs can be dynamic.
module Build.Task.Opaque where
import Control.Monad
import Control.Monad.State
import Control.Monad.Writer
import Data.List (isInfixOf)
import Debug.Trace
import System.FilePath
-- | Environment variables are identified by names.
type Variable = String
-- | A collection of keys for accessing files, environment variables, and
-- contents of directories. Directories are somewhat magic, because their values
-- are derived from 'File' keys, i.e. creating a new file in a directory
-- requires updating the the corresponding 'Dir' key.
data Key a where
File :: FilePath -> Key String -- ^ File contents.
Env :: Variable -> Key String -- ^ Environment variable.
Dir :: FilePath -> Key [FilePath] -- ^ Directory contents.
-- | Read a key's value in a computation context @f@.
type Get k f = forall a. k a -> f a
-- | Write a key's value in a computation context @f@. Note: the type can be
-- changed to @forall a. k a -> f a -> f a@ to allow for static analysis of
-- applicative and selective build tasks, since we cannot have @a@ in a static
-- context @f@, e.g. in @Const@. See more details in Section 5.3 of this paper:
-- https://www.staff.ncl.ac.uk/andrey.mokhov/selective-functors.pdf.
type Put k f = forall a. k a -> a -> f ()
-- | A build task is a stateful computation in a monadic context @f@ that is
-- given two callbacks: for reading and writing values to a key/value store.
type Task k a = forall f. Monad f => Get k f -> Put k f -> f a
-- | A unique task identifier, e.g. the path to the corresponding build script.
type TaskName = String
-- | A task along with its unique identifier.
data NamedTask k = NamedTask { taskName :: TaskName, task :: Task k () }
-- | A collection of build tasks using the same read and write interface.
type Tasks k = [NamedTask k]
-- | An example type of "black box" build tasks: we can only find out what they
-- read and write by executing them in a monadic context.
type BlackBox = Task Key ()
-- | Multiple black boxes, e.g. a collection of build scripts lying around.
type BlackBoxes = Tasks Key
-- | An example collection of black boxes.
tasks :: BlackBoxes
tasks = [NamedTask "release" release, NamedTask "build" build]
-- Placing "release" after "build" avoids restarting the "release" task:
-- [NamedTask "build" build, NamedTask "release" release]
-- | A typical build script that compiles a couple of C files, possibly
-- depending on some header files, and then links the resulting objects into an
-- executable.
build :: BlackBox
build get put = do
compile "src/a.c" "obj/a.o" get put
compile "src/b.c" "obj/b.o" get put
link "obj" "out/exe" get put
-- | A script for packaging the contents of the directory @out@ in an archive.
-- Note that if called prematurely, it will miss some of the release files and
-- will /succeed/, yielding an incomplete archive. The task will therefore need
-- to be rerun whenever the key @Dir "out"@ is updated.
release :: BlackBox
release get put = do
files <- map ("out/" ++) <$> get (Dir "out")
archive <- concat <$> mapM (get . File) files
put (File "release.tar") archive
-- Note: this task doesn't need to be monadic, a selective interface is enough!
-- | Compile a C source file, possibly including the @lib.h@ header.
compile :: FilePath -> FilePath -> BlackBox
compile src obj get put = do
source <- get (File src)
header <- if "#include <lib.h>" `isInfixOf` source -- find an #include
then do
path <- get (Env "LIBDIR")
get (File $ path ++ "/lib.h")
else return ""
put (File obj) (header ++ source)
-- | Link object files in a given directory, producing an executable. Note that
-- this task can /fail/ if run prematurely i.e. when some object files have not
-- yet been placed in the @obj@ directory, since some symbols will be undefined.
link :: FilePath -> FilePath -> BlackBox
link dir exe get put = do
objs <- map ("obj/" ++) <$> get (Dir dir)
binary <- concat <$> mapM (get . File) objs
put (File exe) binary
-- | A task execution log entry, recording either a read from a key and the
-- obtained value, or a write to a key, along with the written value.
data LogEntry k where
GetEntry :: k a -> a -> LogEntry k
PutEntry :: k a -> a -> LogEntry k
-- | A log is a sequence of log entries, in the execution order.
type Log k = [LogEntry k]
-- TODO: Can we simplify the implementation?
-- | Check if a log contains a 'GetEntry' for a given 'Key'. Useful to detect if
-- a task has a certain input dependency.
hasWrongGet :: Log Key -> Key a -> a -> Bool
hasWrongGet log k a = case k of
File x -> any (matchesFile x a) log
Env x -> any (matchesEnv x a) log
Dir x -> any (matchesDir x a) log
where
matchesFile :: FilePath -> String -> LogEntry Key -> Bool
matchesFile x a (GetEntry (File y) b) = (x == y) && (a /= b)
matchesFile _ _ _ = False
matchesEnv :: Variable -> String -> LogEntry Key -> Bool
matchesEnv x a (GetEntry (Env y) b) = (x == y) && (a /= b)
matchesEnv _ _ _ = False
matchesDir :: FilePath -> [FilePath] -> LogEntry Key -> Bool
matchesDir x a (GetEntry (Dir y) b) = (x == y) && (a /= b)
matchesDir _ _ _ = False
{- Example execution of task 'build' in GHCi:
> log <- execute getIO putIO build
Get : File "src/a.c" = a
Put : File "obj/a.o" = a
Get : File "src/b.c" = b...#include <lib.h>...
Get : Env "LIBDIR" = libs
Get : File "libs/lib.h" = lib...
Put : File "obj/b.o" = lib...b...#include <lib.h>...
Get : Dir "obj" = ["a.o", "b.o", "c.o"]
Get : File "a.o" = 123
Get : File "b.o" = 456
Get : File "c.o" = 789
Put : File "out/exe" = 123456789
> log
[ Get (File "src/a.c", "a")
, Put (File "obj/a.o", "a")
, Get (File "src/b.c", "b...#include <lib.h>...")
, Get (Env "LIBDIR", "libs")
, Get (File "libs/lib.h", "lib...")
, Put (File "obj/b.o", "lib...b...#include <lib.h>...")
, Get (Dir "obj", ["a.o","b.o","c.o"])
, Get (File "a.o", "123")
, Get (File "b.o", "456")
, Get (File "c.o", "789")
, Put (File "out/exe", "123456789") ]
-}
-- TODO: Note that at the moment logging does not account for modifying
-- directories when creating new files.
-- | Execute a monadic task using given callbacks 'Get' and 'Put', logging all
-- reads and writes.
execute :: forall m k. Monad m => Get k m -> Put k m -> Task k () -> m (Log k)
execute get put task = execWriterT $ task loggingGet loggingPut
where
loggingGet :: k a -> WriterT (Log k) m a
loggingGet k = do
a <- lift $ get k
tell [GetEntry k a]
return a
loggingPut :: k a -> a -> WriterT (Log k) m ()
loggingPut k a = do
lift $ put k a
tell [PutEntry k a]
-- | An association of keys to values.
newtype Store = Store { getValue :: forall a. Key a -> a }
putValue :: Key a -> a -> Store -> Store
putValue (File x) a (Store f) = Store $ \k -> case k of
File y | x == y -> a
_ -> f k
putValue (Env x) a (Store f) = Store $ \k -> case k of
Env y | x == y -> a
_ -> f k
putValue (Dir x) a (Store f) = Store $ \k -> case k of
Dir y | x == y -> a
_ -> f k
-- | An example store with the following contents:
--
-- File "src/a.c" -> "a"
-- File "src/b.c" -> "b...#include <lib.h>..."
-- File "obj/main.o" -> "...main..."
-- File "lib/lib.h" -> "lib..."
-- File "out/README" -> "This is a README..."
-- Env "LIBDIR" -> "lib"
-- Dir "obj" -> ["main.o"]
-- Dir "out" -> ["README"]
exampleStore :: Store
exampleStore = Store $ \case
File "src/a.c" -> "a"
File "src/b.c" -> "b...#include <lib.h>..."
File "obj/main.o" -> "...main..."
File "lib/lib.h" -> "lib..."
File "out/README" -> "This is a README..."
Env "LIBDIR" -> "lib"
Dir "obj" -> ["main.o"]
Dir "out" -> ["README"]
File _ -> "<empty file>"
Env _ -> "<empty variable>"
Dir _ -> [] -- empty directory
-- | Known information about build task dependencies.
type Graph = TaskName -> Maybe (Log Key)
-- | A build system that builds a collection of black box tasks by executing
-- them blindly, and recording the resulting dependencies.
blindBuild :: BlackBoxes -> Store -> Graph -> (Store, Graph)
blindBuild tasks store graph = fst $ execState build ((store, graph), tasks)
where
build :: State ((Store, Graph), BlackBoxes) ()
build = do
queue <- gets snd
case queue of
[] -> return ()
(NamedTask id task : tasks) -> do
-- Remove the first task from the queue
modify $ \((s, g), _) -> ((s, g), tasks)
-- Execute the task, possibly restaring some previously executed tasks
log <- trace ("Execute " ++ id) $ execute get put task
modify $ \((s, g), ts) -> let ng t = if t == id then Just log else g t
in ((s, ng), ts)
-- Build the rest of the queue
build
-- Simply return whatever is in the store.
get :: Key a -> State ((Store, Graph), BlackBoxes) a
get k = do
store <- gets (fst . fst)
let a = getValue store k
trace ("Get (" ++ showKey k ++ ", " ++ showValue k a ++ ")") $ return a
-- Update the value, and restart any tasks which depended on it but have
-- been executed too early.
put :: Key a -> a -> State ((Store, Graph), BlackBoxes) ()
put k a = do
-- Update the store
trace ("Put (" ++ showKey k ++ ", " ++ showValue k a ++ ")") $
modify $ \((s, g), bs) -> ((putValue k a s, g), bs)
-- Restart any tasks which depended on this key
graph <- gets (snd . fst)
queue <- gets snd
forM_ tasks $ \task@(NamedTask id _) -> case graph id of
Nothing -> return ()
Just log -> when (hasWrongGet log k a && id `notInQueue` queue) $
trace ("Restart " ++ id) $
modify $ \((s, g), bs) -> ((s, g), bs ++ [task])
-- Make sure to update the corresponding directory key if a new file has
-- been created
case k of
File path -> do
let dir = takeDirectory path
file = takeFileName path
store <- gets (fst . fst)
let files = getValue store (Dir dir)
when (file `notElem` files) $ put (Dir dir) (files ++ [file])
_ -> return ()
-- Check that a task does not appear in a queue
notInQueue :: TaskName -> BlackBoxes -> Bool
notInQueue _ [] = True
notInQueue id (t:ts) | id == taskName t = False
| otherwise = notInQueue id ts
{- Example blind build
> res = blindBuild tasks exampleStore (const Nothing)
> res `seq` ()
Execute release
Get (Dir "out", ["README"])
Get (File "out/README", "This is a README...")
Put (File "release.tar", "This is a README...")
Put (Dir ".", ["release.tar"])
Execute build
Get (File "src/a.c", "a")
Put (File "obj/a.o", "a")
Put (Dir "obj", ["main.o","a.o"])
Get (File "src/b.c", "b...#include <lib.h>...")
Get (Env "LIBDIR", "lib")
Get (File "lib/lib.h", "lib...")
Put (File "obj/b.o", "lib...b...#include <lib.h>...")
Put (Dir "obj", ["main.o","a.o","b.o"])
Get (Dir "obj", ["main.o","a.o","b.o"])
Get (File "obj/main.o", "...main...")
Get (File "obj/a.o", "a")
Get (File "obj/b.o", "lib...b...#include <lib.h>...")
Put (File "out/exe", "...main...alib...b...#include <lib.h>...")
Put (Dir "out", ["README","exe"])
Restart release
Execute release
Get (Dir "out", ["README","exe"])
Get (File "out/README", "This is a README...")
Get (File "out/exe", "...main...alib...b...#include <lib.h>...")
Put (File "release.tar", "This is a README......main...alib...b...#include <lib.h>...")
> snd res "release"
Just [ Get (Dir "out", ["README","exe"])
, Get (File "out/README", "This is a README...")
, Get (File "out/exe", "alib...b...#include <lib.h>...<empty file>")
, Put (File "release.tar", "This is a README...alib...b...#include <lib.h>...<empty file>")]
> snd res "build"
Just [ Get (File "src/a.c", "a")
, Put (File "obj/a.o", "a")
, Get (File "src/b.c", "b...#include <lib.h>...")
, Get (Env "LIBDIR", "lib")
, Get (File "lib/lib.h", "lib...")
, Put (File "obj/b.o", "lib...b...#include <lib.h>...")
, Get (Dir "obj", ["main.o","a.o","b.o"])
, Get (File "obj/main.o", "...main...")
, Get (File "obj/a.o", "a")
, Get (File "obj/b.o", "lib...b...#include <lib.h>...")
, Put (File "out/exe", "...main...alib...b...#include <lib.h>...")]
-}
---------------------------- Some boilerplate code -----------------------------
-- | A way to show the name of a key.
type ShowKey k = forall a. k a -> String
-- | A simple pretty-printer for the data type 'Key'.
showKey :: ShowKey Key
showKey (File f) = "File " ++ show f
showKey (Env v) = "Env " ++ show v
showKey (Dir d) = "Dir " ++ show d
-- | Show a value corresponding to a key, extracting an appropriate 'Show'
-- instance from it.
showValue :: Key a -> a -> String
showValue (File _) f = show f
showValue (Env _) v = show v
showValue (Dir _) d = show d
instance Show (LogEntry Key) where
show (GetEntry k@(File _) a) = "Get (" ++ showKey k ++ ", " ++ show a ++ ")"
show (GetEntry k@(Env _) a) = "Get (" ++ showKey k ++ ", " ++ show a ++ ")"
show (GetEntry k@(Dir _) a) = "Get (" ++ showKey k ++ ", " ++ show a ++ ")"
show (PutEntry k@(File _) a) = "Put (" ++ showKey k ++ ", " ++ show a ++ ")"
show (PutEntry k@(Env _) a) = "Put (" ++ showKey k ++ ", " ++ show a ++ ")"
show (PutEntry k@(Dir _) a) = "Put (" ++ showKey k ++ ", " ++ show a ++ ")"
----------------------------- Auxiliary functions ------------------------------
-- | A 'Get' in 'IO' for GHCi experiments.
getIO :: Get Key IO
getIO (File f) = putStr ("Get : File " ++ show f ++ " = ") >> getLine
getIO (Env v) = putStr ("Get : Env " ++ show v ++ " = ") >> getLine
getIO (Dir d) = putStr ("Get : Dir " ++ show d ++ " = ") >> (read <$> getLine)
-- | A 'Put' in 'IO' for GHCi experiments.
putIO :: Put Key IO
putIO (File f) x = putStr ("Put : File " ++ show f ++ " = ") >> putStrLn x
putIO (Env v) x = putStr ("Put : Env " ++ show v ++ " = ") >> putStrLn x
putIO (Dir d) x = putStr ("Put : Dir " ++ show d ++ " = ") >> print x