Rename Collage to Bricoleur
Getty Ritter
7 years ago
| 1 | module Main where | |
| 2 | ||
| 3 | import qualified Bricoleur | |
| 4 | ||
| 5 | main :: IO () | |
| 6 | main = Bricoleur.getOpts >>= Bricoleur.main |
| 1 | cabal-version: 2.2 | |
| 2 | name: bricoleur | |
| 3 | version: 0.1.0.0 | |
| 4 | synopsis: A tool for assembling documents out of working, testable code | |
| 5 | description: | |
| 6 | Bricoleur is a tool for writing documents that include snippets of code | |
| 7 | in such a way that the code can be build and examined independently | |
| 8 | of the source document. | |
| 9 | license: BSD-3-Clause | |
| 10 | author: Getty Ritter <gettylefou@gmail.com> | |
| 11 | maintainer: Getty Ritter <gettylefou@gmail.com> | |
| 12 | copyright: @2018 Getty Ritter | |
| 13 | category: Text | |
| 14 | build-type: Simple | |
| 15 | ||
| 16 | ||
| 17 | library | |
| 18 | exposed-modules: Bricoleur | |
| 19 | , Bricoleur.Config | |
| 20 | , Bricoleur.Opts | |
| 21 | , Bricoleur.Commands.Test | |
| 22 | , Bricoleur.Commands.Splice | |
| 23 | , Bricoleur.Utils | |
| 24 | build-depends: base >=4.7 && <5 | |
| 25 | , adnot | |
| 26 | , bytestring | |
| 27 | , containers | |
| 28 | , directory | |
| 29 | , filepath | |
| 30 | , formatting | |
| 31 | , optparse-applicative | |
| 32 | , process | |
| 33 | , text | |
| 34 | , unix | |
| 35 | , vector | |
| 36 | hs-source-dirs: src | |
| 37 | ghc-options: -Wall | |
| 38 | default-language: Haskell2010 | |
| 39 | ||
| 40 | executable bricoleur | |
| 41 | hs-source-dirs: bricoleur | |
| 42 | main-is: Main.hs | |
| 43 | default-language: Haskell2010 | |
| 44 | ghc-options: -Wall | |
| 45 | build-depends: base >=4.7 && <5 | |
| 46 | , bricoleur |
| 1 | module Main where | |
| 2 | ||
| 3 | import qualified Collage | |
| 4 | ||
| 5 | main :: IO () | |
| 6 | main = Collage.getOpts >>= Collage.main |
| 1 | cabal-version: 2.2 | |
| 2 | name: collage | |
| 3 | version: 0.1.0.0 | |
| 4 | synopsis: A tool for assembling documents out of working, testable code | |
| 5 | description: | |
| 6 | Collage is a tool for writing documents that include snippets of code | |
| 7 | in such a way that the code can be build and examined independently | |
| 8 | of the source document. | |
| 9 | license: BSD-3-Clause | |
| 10 | author: Getty Ritter <gettylefou@gmail.com> | |
| 11 | maintainer: Getty Ritter <gettylefou@gmail.com> | |
| 12 | copyright: @2018 Getty Ritter | |
| 13 | category: Text | |
| 14 | build-type: Simple | |
| 15 | ||
| 16 | ||
| 17 | library | |
| 18 | exposed-modules: Collage | |
| 19 | , Collage.Config | |
| 20 | , Collage.Opts | |
| 21 | , Collage.Commands.Test | |
| 22 | , Collage.Commands.Splice | |
| 23 | , Collage.Utils | |
| 24 | build-depends: base >=4.7 && <5 | |
| 25 | , adnot | |
| 26 | , bytestring | |
| 27 | , containers | |
| 28 | , directory | |
| 29 | , filepath | |
| 30 | , formatting | |
| 31 | , optparse-applicative | |
| 32 | , process | |
| 33 | , text | |
| 34 | , unix | |
| 35 | , vector | |
| 36 | hs-source-dirs: src | |
| 37 | ghc-options: -Wall | |
| 38 | default-language: Haskell2010 | |
| 39 | ||
| 40 | executable collage | |
| 41 | hs-source-dirs: collage | |
| 42 | main-is: Main.hs | |
| 43 | default-language: Haskell2010 | |
| 44 | ghc-options: -Wall | |
| 45 | build-depends: base >=4.7 && <5 | |
| 46 | , collage |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Bricoleur.Commands.Splice(runSplice) where | |
| 4 | ||
| 5 | import Control.Monad (foldM) | |
| 6 | import qualified Data.Map as M | |
| 7 | import qualified Data.Text as T | |
| 8 | import qualified Data.Text.Lazy as TL | |
| 9 | import qualified Data.Text.Lazy.IO as TL | |
| 10 | import System.FilePath ((</>)) | |
| 11 | import qualified System.FilePath as Sys | |
| 12 | ||
| 13 | import Bricoleur.Config | |
| 14 | import Bricoleur.Opts | |
| 15 | import Bricoleur.Utils | |
| 16 | ||
| 17 | findFragmentIdentifiers :: TL.Text -> [T.Text] | |
| 18 | findFragmentIdentifiers t | |
| 19 | | (_, rs) <- TL.break (== '«') t | |
| 20 | , (f, rs') <- TL.break (== '»') (TL.drop 1 rs) | |
| 21 | , not (TL.null f) = TL.toStrict f : findFragmentIdentifiers rs' | |
| 22 | | otherwise = [] | |
| 23 | ||
| 24 | newtype Fragment = Fragment [T.Text] deriving (Eq, Show) | |
| 25 | ||
| 26 | toIdent :: T.Text -> TL.Text | |
| 27 | toIdent = TL.fromStrict . T.cons '«' . flip T.snoc '»' | |
| 28 | ||
| 29 | findFragments :: FilePath -> T.Text -> [Source] -> Either TL.Text (IO TL.Text) | |
| 30 | findFragments root s sources = case (T.splitOn "/" s) of | |
| 31 | [] -> throw "Invalid empty fragment name!" | |
| 32 | (x:rs) -> case [ src | |
| 33 | | src@Source { sourceName = n } <- sources | |
| 34 | , n == x | |
| 35 | ] of | |
| 36 | [src] -> go src rs (sourceExpose src) | |
| 37 | [] -> throw ("Unable to find source named " % stext) x | |
| 38 | _ -> throw ("Ambiguous source name: " % stext) x | |
| 39 | where | |
| 40 | ||
| 41 | go q [] (ExposeFile path) = | |
| 42 | return (cOpenFile "file source" (root </> sourceDir q </> path)) | |
| 43 | go _ [] ExposeSections{} = | |
| 44 | throw ("Splice identifier «" % stext % | |
| 45 | "» matches a file with sections") s | |
| 46 | go _ [] NamedMap{} = | |
| 47 | throw ("Splice identifier «" % stext % | |
| 48 | "» matches a map, but does not specify a key!") s | |
| 49 | ||
| 50 | go q [section] (ExposeSections path) = | |
| 51 | return (readSection (root </> sourceDir q </> path) section) | |
| 52 | ||
| 53 | go _ (_:_) (ExposeSections path) = | |
| 54 | throw ("Splice identifier «" % stext % | |
| 55 | "» indexes too far into the path " % string % "!") | |
| 56 | s path | |
| 57 | ||
| 58 | go _ (_:_) (ExposeFile path) = | |
| 59 | throw ("Splice identifier «" % stext % | |
| 60 | "» indexes too far into the file " % string % "!") | |
| 61 | s path | |
| 62 | ||
| 63 | go q (k:rs) (NamedMap m) | |
| 64 | | Just e <- M.lookup k m = go q rs e | |
| 65 | | otherwise = throw | |
| 66 | ("Splice identifier «" % stext % | |
| 67 | "» references a key " % stext % | |
| 68 | " which cannot be found.") s k | |
| 69 | ||
| 70 | doReplacements :: FilePath -> [Source] -> TL.Text -> IO TL.Text | |
| 71 | doReplacements root e original = | |
| 72 | foldM doReplace original (findFragmentIdentifiers original) | |
| 73 | where | |
| 74 | doReplace file frag = do | |
| 75 | new <- case findFragments root frag e of | |
| 76 | Left err -> cError ("Fragment error: " % text) err | |
| 77 | Right x -> x | |
| 78 | return (TL.replace (toIdent frag) new file) | |
| 79 | ||
| 80 | readSection :: FilePath -> T.Text -> IO TL.Text | |
| 81 | readSection path f = do | |
| 82 | contents <- cOpenFile "section source" path | |
| 83 | case TL.breakOn (toIdent f) contents of | |
| 84 | (_, "") -> cError ("Unable to find section " % shown % | |
| 85 | " in file " % string) f path | |
| 86 | (_, rs) -> | |
| 87 | let (_, skipLine) = TL.breakOn "\n" rs | |
| 88 | (section, _) = TL.breakOn "«end»" (TL.drop 1 skipLine) | |
| 89 | (skipLine', _) = TL.breakOnEnd "\n" section | |
| 90 | in return (TL.init skipLine') | |
| 91 | ||
| 92 | runSplice :: Config -> Options -> IO () | |
| 93 | runSplice conf opts = do | |
| 94 | let root = Sys.takeDirectory (optFile opts) | |
| 95 | f <- cOpenFile "document" (root </> confDocument conf) | |
| 96 | rs <- doReplacements root (confSources conf) f | |
| 97 | TL.putStrLn rs |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Bricoleur.Commands.Test (runTest) where | |
| 4 | ||
| 5 | import Control.Monad (forM_) | |
| 6 | import System.FilePath ((</>)) | |
| 7 | import qualified System.FilePath as Sys | |
| 8 | import qualified System.Process as Sys | |
| 9 | ||
| 10 | import Bricoleur.Opts | |
| 11 | import Bricoleur.Config | |
| 12 | import Bricoleur.Utils | |
| 13 | ||
| 14 | runTest :: Config -> Options -> IO () | |
| 15 | runTest conf opts = do | |
| 16 | let root = Sys.takeDirectory (optFile opts) | |
| 17 | cDebug ("running tests for " % shown) (confDocument conf) | |
| 18 | forM_ (confSources conf) $ \ samp -> do | |
| 19 | cDebug ("- running test for " % stext) (sourceName samp) | |
| 20 | runCommand root samp | |
| 21 | ||
| 22 | runCommand :: FilePath -> Source -> IO () | |
| 23 | runCommand root src = do | |
| 24 | forM_ (sourceCommands src) $ \ln -> do | |
| 25 | let dir = root </> sourceDir src | |
| 26 | cDebug (" - $ " % shown % " in " % string) ln dir | |
| 27 | let process = (Sys.shell ln) { Sys.cwd = Just (root </> sourceDir src) } | |
| 28 | (_, _, _, h) <- Sys.createProcess process | |
| 29 | Sys.waitForProcess h |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Bricoleur.Config | |
| 4 | ( Config(..) | |
| 5 | , Source(..) | |
| 6 | , Expose(..) | |
| 7 | , getConfig | |
| 8 | , example | |
| 9 | ) where | |
| 10 | ||
| 11 | import Control.Applicative ((<|>)) | |
| 12 | import qualified Data.Adnot as A | |
| 13 | import qualified Data.ByteString as B | |
| 14 | import qualified Data.Map.Strict as M | |
| 15 | import qualified Data.Text as T | |
| 16 | import qualified Data.Vector as V | |
| 17 | ||
| 18 | data Config = Config | |
| 19 | { confDocument :: FilePath | |
| 20 | , confSources :: [Source] | |
| 21 | } deriving (Eq, Show) | |
| 22 | ||
| 23 | instance A.FromAdnot Config where | |
| 24 | parseAdnot = A.withSumNamed "config file" "document" go | |
| 25 | where | |
| 26 | go payload | |
| 27 | | Just file <- payload V.!? 0 | |
| 28 | = Config <$> A.parseAdnot file | |
| 29 | <*> mapM A.parseAdnot (V.toList (V.tail payload)) | |
| 30 | | otherwise = Left "expected source file in config" | |
| 31 | ||
| 32 | ||
| 33 | data Source = Source | |
| 34 | { sourceName :: T.Text | |
| 35 | , sourceDir :: FilePath | |
| 36 | , sourceCommands :: [String] | |
| 37 | , sourceExpose :: Expose | |
| 38 | } deriving (Eq, Show) | |
| 39 | ||
| 40 | instance A.FromAdnot Source where | |
| 41 | parseAdnot = A.withProduct "source" $ \p -> do | |
| 42 | name <- p A..: "name" | |
| 43 | dir <- p A..: "dir" | |
| 44 | cmds <- p A..: "cmd" | |
| 45 | expose <- p A..: "expose" | |
| 46 | return (Source name dir cmds expose) | |
| 47 | ||
| 48 | ||
| 49 | data Expose | |
| 50 | = ExposeFile FilePath | |
| 51 | | ExposeSections FilePath | |
| 52 | | NamedMap (M.Map T.Text Expose) | |
| 53 | deriving (Eq, Show) | |
| 54 | ||
| 55 | instance A.FromAdnot Expose where | |
| 56 | parseAdnot v = file v <|> sections v <|> namedMap v | |
| 57 | where | |
| 58 | file = A.withSumNamed "exposed fragments" "file" $ \ ps -> | |
| 59 | case V.toList ps of | |
| 60 | [] -> Left "Expected name for file" | |
| 61 | [f] -> ExposeFile <$> A.parseAdnot f | |
| 62 | _ -> Left "Too many arguments to file" | |
| 63 | ||
| 64 | sections = A.withSumNamed "exposed fragments" "sections" $ \ ps -> | |
| 65 | case V.toList ps of | |
| 66 | [] -> Left "Expected name for sections" | |
| 67 | [f] -> ExposeSections <$> A.parseAdnot f | |
| 68 | _ -> Left "Too many arguments to sections" | |
| 69 | ||
| 70 | namedMap = A.withProduct "exposed fragments" $ \ p -> | |
| 71 | NamedMap <$> mapM A.parseAdnot p | |
| 72 | ||
| 73 | parseConfig :: B.ByteString -> Either String Config | |
| 74 | parseConfig = A.decode | |
| 75 | ||
| 76 | getConfig :: FilePath -> IO (Either String Config) | |
| 77 | getConfig loc = do | |
| 78 | conf <- B.readFile loc | |
| 79 | return (parseConfig conf) | |
| 80 | ||
| 81 | example :: Config | |
| 82 | example = Config | |
| 83 | { confDocument = "main.md" | |
| 84 | , confSources = | |
| 85 | [ Source | |
| 86 | { sourceName = "rust-sample" | |
| 87 | , sourceDir = "s1" | |
| 88 | , sourceCommands = ["cargo clean", "cargo build"] | |
| 89 | , sourceExpose = ExposeFile "src/main.rs" | |
| 90 | } | |
| 91 | , Source | |
| 92 | { sourceName = "haskell-sample" | |
| 93 | , sourceDir = "s2" | |
| 94 | , sourceCommands = ["cabal new-build"] | |
| 95 | , sourceExpose = ExposeSections "Main.hs" | |
| 96 | } | |
| 97 | ] | |
| 98 | } |
| 1 | module Bricoleur.Opts | |
| 2 | ( Command(..) | |
| 3 | , Options(..) | |
| 4 | , getOpts | |
| 5 | ) where | |
| 6 | ||
| 7 | import Control.Applicative ((<|>)) | |
| 8 | import qualified Options.Applicative as Opt | |
| 9 | import qualified System.Directory as Sys | |
| 10 | import qualified System.FilePath as Sys | |
| 11 | ||
| 12 | data Command | |
| 13 | = Test | |
| 14 | | Splice | |
| 15 | deriving (Eq, Show) | |
| 16 | ||
| 17 | data Options = Options | |
| 18 | { optFile :: FilePath | |
| 19 | , optVerbose :: Bool | |
| 20 | , optCommand :: Command | |
| 21 | } deriving (Eq, Show) | |
| 22 | ||
| 23 | desc :: String | |
| 24 | desc = "FINISH ME" | |
| 25 | ||
| 26 | opts :: Opt.ParserInfo Options | |
| 27 | opts = Opt.info (p Opt.<**> Opt.helper) | |
| 28 | (Opt.progDesc desc <> | |
| 29 | Opt.fullDesc <> | |
| 30 | Opt.header "arglbargl") | |
| 31 | where | |
| 32 | p = Options <$> (path <|> pure "") | |
| 33 | <*> verbose | |
| 34 | <*> Opt.subparser (test <> splice) | |
| 35 | ||
| 36 | path = Opt.strOption | |
| 37 | (Opt.short 'f' <> | |
| 38 | Opt.long "file" <> | |
| 39 | Opt.metavar "PATH" <> | |
| 40 | Opt.help "The path to the project file") | |
| 41 | ||
| 42 | verbose = Opt.switch | |
| 43 | (Opt.short 'v' <> | |
| 44 | Opt.long "verbose" <> | |
| 45 | Opt.help "Show debug messages") | |
| 46 | ||
| 47 | test = Opt.command "test" $ Opt.info | |
| 48 | (pure Test Opt.<**> Opt.helper) | |
| 49 | (Opt.progDesc "test the provided sources") | |
| 50 | ||
| 51 | splice = Opt.command "splice" $ Opt.info | |
| 52 | (pure Splice Opt.<**> Opt.helper) | |
| 53 | (Opt.progDesc "splice sources into a final draft") | |
| 54 | ||
| 55 | getOpts :: IO Options | |
| 56 | getOpts = do | |
| 57 | cwd <- Sys.getCurrentDirectory | |
| 58 | options <- Opt.execParser opts | |
| 59 | return $ if null (optFile options) | |
| 60 | then options { optFile = cwd Sys.</> "collage" } | |
| 61 | else options |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Bricoleur.Utils | |
| 4 | ( cOutput | |
| 5 | , cDebug | |
| 6 | , cWarn | |
| 7 | , cError | |
| 8 | ||
| 9 | , cOpenFile | |
| 10 | ||
| 11 | , throw | |
| 12 | ||
| 13 | , F.format | |
| 14 | , (F.%) | |
| 15 | , F.stext | |
| 16 | , F.text | |
| 17 | , F.string | |
| 18 | , F.shown | |
| 19 | ) where | |
| 20 | ||
| 21 | import qualified Formatting as F | |
| 22 | import qualified Data.Text.Lazy as TL | |
| 23 | import qualified Data.Text.Lazy.Builder as TL | |
| 24 | import qualified Data.Text.Lazy.IO as TL | |
| 25 | import qualified System.Directory as Sys | |
| 26 | import qualified System.Exit as Sys | |
| 27 | import qualified System.IO as Sys | |
| 28 | import qualified System.Posix.IO as Posix | |
| 29 | import qualified System.Posix.Terminal as Posix | |
| 30 | ||
| 31 | import Prelude (FilePath, IO, Either(Left), ($)) | |
| 32 | ||
| 33 | throw :: F.Format (Either TL.Text r) a -> a | |
| 34 | throw f = | |
| 35 | F.runFormat f (\ b -> Left (TL.toLazyText b)) | |
| 36 | ||
| 37 | stderr :: TL.Text -> IO () | |
| 38 | stderr = TL.hPutStr Sys.stderr | |
| 39 | ||
| 40 | -- | Write output to stdout | |
| 41 | cOutput :: TL.Text -> IO () | |
| 42 | cOutput = TL.putStrLn | |
| 43 | ||
| 44 | -- | Write a debug message to stderr. | |
| 45 | cDebug :: F.Format (IO ()) a -> a | |
| 46 | cDebug msg = F.runFormat msg $ \ b -> | |
| 47 | TL.hPutStrLn Sys.stderr (TL.toLazyText b) | |
| 48 | ||
| 49 | -- | Write a warning message to stderr. If we are connected to a TTY, | |
| 50 | -- then this will write in an orange color. | |
| 51 | cWarn :: F.Format (IO ()) a -> a | |
| 52 | cWarn msg = F.runFormat msg $ \b -> do | |
| 53 | isTTY <- Posix.queryTerminal Posix.stdOutput | |
| 54 | if isTTY | |
| 55 | then do stderr "\x1b[93m" | |
| 56 | stderr (TL.toLazyText b) | |
| 57 | stderr "\x1b[39m\n" | |
| 58 | else TL.hPutStrLn Sys.stderr (TL.toLazyText b) | |
| 59 | ||
| 60 | -- | Write an error message to stderr and exit. If we are connected to | |
| 61 | -- a TTY, this message will be in red. | |
| 62 | cError :: F.Format (IO r) a -> a | |
| 63 | cError msg = F.runFormat msg $ \b -> do | |
| 64 | isTTY <- Posix.queryTerminal Posix.stdOutput | |
| 65 | if isTTY | |
| 66 | then do stderr "\x1b[91m" | |
| 67 | stderr (TL.toLazyText b) | |
| 68 | stderr "\x1b[39m\n" | |
| 69 | else TL.hPutStrLn Sys.stderr (TL.toLazyText b) | |
| 70 | Sys.exitFailure | |
| 71 | ||
| 72 | ||
| 73 | cOpenFile :: TL.Text -> FilePath -> IO TL.Text | |
| 74 | cOpenFile purpose path = do | |
| 75 | exists <- Sys.doesFileExist path | |
| 76 | if exists | |
| 77 | then TL.readFile path | |
| 78 | else cError ("Unable to open " F.% F.text F.% | |
| 79 | " file at " F.% F.string) purpose path |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Bricoleur | |
| 4 | ( main | |
| 5 | , Opt.getOpts | |
| 6 | ) where | |
| 7 | ||
| 8 | import qualified System.Exit as Sys | |
| 9 | ||
| 10 | import qualified Bricoleur.Config as Conf | |
| 11 | import qualified Bricoleur.Opts as Opt | |
| 12 | ||
| 13 | import qualified Bricoleur.Commands.Splice as Cmd | |
| 14 | import qualified Bricoleur.Commands.Test as Cmd | |
| 15 | ||
| 16 | -- | Run the main @collage@ function with the provided options. | |
| 17 | main :: Opt.Options -> IO () | |
| 18 | main opts = do | |
| 19 | configMb <- Conf.getConfig (Opt.optFile opts) | |
| 20 | config <- case configMb of | |
| 21 | Left err -> Sys.die err | |
| 22 | Right x -> return x | |
| 23 | case Opt.optCommand opts of | |
| 24 | Opt.Test -> Cmd.runTest config opts | |
| 25 | Opt.Splice -> Cmd.runSplice config opts |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Collage.Commands.Splice(runSplice) where | |
| 4 | ||
| 5 | import Control.Monad (foldM) | |
| 6 | import qualified Data.Map as M | |
| 7 | import qualified Data.Text as T | |
| 8 | import qualified Data.Text.Lazy as TL | |
| 9 | import qualified Data.Text.Lazy.IO as TL | |
| 10 | import System.FilePath ((</>)) | |
| 11 | import qualified System.FilePath as Sys | |
| 12 | ||
| 13 | import Collage.Config | |
| 14 | import Collage.Opts | |
| 15 | import Collage.Utils | |
| 16 | ||
| 17 | findFragmentIdentifiers :: TL.Text -> [T.Text] | |
| 18 | findFragmentIdentifiers t | |
| 19 | | (_, rs) <- TL.break (== '«') t | |
| 20 | , (f, rs') <- TL.break (== '»') (TL.drop 1 rs) | |
| 21 | , not (TL.null f) = TL.toStrict f : findFragmentIdentifiers rs' | |
| 22 | | otherwise = [] | |
| 23 | ||
| 24 | newtype Fragment = Fragment [T.Text] deriving (Eq, Show) | |
| 25 | ||
| 26 | toIdent :: T.Text -> TL.Text | |
| 27 | toIdent = TL.fromStrict . T.cons '«' . flip T.snoc '»' | |
| 28 | ||
| 29 | findFragments :: FilePath -> T.Text -> [Source] -> Either TL.Text (IO TL.Text) | |
| 30 | findFragments root s sources = case (T.splitOn "/" s) of | |
| 31 | [] -> throw "Invalid empty fragment name!" | |
| 32 | (x:rs) -> case [ src | |
| 33 | | src@Source { sourceName = n } <- sources | |
| 34 | , n == x | |
| 35 | ] of | |
| 36 | [src] -> go src rs (sourceExpose src) | |
| 37 | [] -> throw ("Unable to find source named " % stext) x | |
| 38 | _ -> throw ("Ambiguous source name: " % stext) x | |
| 39 | where | |
| 40 | ||
| 41 | go q [] (ExposeFile path) = | |
| 42 | return (cOpenFile "file source" (root </> sourceDir q </> path)) | |
| 43 | go _ [] ExposeSections{} = | |
| 44 | throw ("Splice identifier «" % stext % | |
| 45 | "» matches a file with sections") s | |
| 46 | go _ [] NamedMap{} = | |
| 47 | throw ("Splice identifier «" % stext % | |
| 48 | "» matches a map, but does not specify a key!") s | |
| 49 | ||
| 50 | go q [section] (ExposeSections path) = | |
| 51 | return (readSection (root </> sourceDir q </> path) section) | |
| 52 | ||
| 53 | go _ (_:_) (ExposeSections path) = | |
| 54 | throw ("Splice identifier «" % stext % | |
| 55 | "» indexes too far into the path " % string % "!") | |
| 56 | s path | |
| 57 | ||
| 58 | go _ (_:_) (ExposeFile path) = | |
| 59 | throw ("Splice identifier «" % stext % | |
| 60 | "» indexes too far into the file " % string % "!") | |
| 61 | s path | |
| 62 | ||
| 63 | go q (k:rs) (NamedMap m) | |
| 64 | | Just e <- M.lookup k m = go q rs e | |
| 65 | | otherwise = throw | |
| 66 | ("Splice identifier «" % stext % | |
| 67 | "» references a key " % stext % | |
| 68 | " which cannot be found.") s k | |
| 69 | ||
| 70 | doReplacements :: FilePath -> [Source] -> TL.Text -> IO TL.Text | |
| 71 | doReplacements root e original = | |
| 72 | foldM doReplace original (findFragmentIdentifiers original) | |
| 73 | where | |
| 74 | doReplace file frag = do | |
| 75 | new <- case findFragments root frag e of | |
| 76 | Left err -> cError ("Fragment error: " % text) err | |
| 77 | Right x -> x | |
| 78 | return (TL.replace (toIdent frag) new file) | |
| 79 | ||
| 80 | readSection :: FilePath -> T.Text -> IO TL.Text | |
| 81 | readSection path f = do | |
| 82 | contents <- cOpenFile "section source" path | |
| 83 | case TL.breakOn (toIdent f) contents of | |
| 84 | (_, "") -> cError ("Unable to find section " % shown % | |
| 85 | " in file " % string) f path | |
| 86 | (_, rs) -> | |
| 87 | let (_, skipLine) = TL.breakOn "\n" rs | |
| 88 | (section, _) = TL.breakOn "«end»" (TL.drop 1 skipLine) | |
| 89 | (skipLine', _) = TL.breakOnEnd "\n" section | |
| 90 | in return (TL.init skipLine') | |
| 91 | ||
| 92 | runSplice :: Config -> Options -> IO () | |
| 93 | runSplice conf opts = do | |
| 94 | let root = Sys.takeDirectory (optFile opts) | |
| 95 | f <- cOpenFile "document" (root </> confDocument conf) | |
| 96 | rs <- doReplacements root (confSources conf) f | |
| 97 | TL.putStrLn rs |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Collage.Commands.Test (runTest) where | |
| 4 | ||
| 5 | import Control.Monad (forM_) | |
| 6 | import System.FilePath ((</>)) | |
| 7 | import qualified System.FilePath as Sys | |
| 8 | import qualified System.Process as Sys | |
| 9 | ||
| 10 | import Collage.Opts | |
| 11 | import Collage.Config | |
| 12 | import Collage.Utils | |
| 13 | ||
| 14 | runTest :: Config -> Options -> IO () | |
| 15 | runTest conf opts = do | |
| 16 | let root = Sys.takeDirectory (optFile opts) | |
| 17 | cDebug ("running tests for " % shown) (confDocument conf) | |
| 18 | forM_ (confSources conf) $ \ samp -> do | |
| 19 | cDebug ("- running test for " % stext) (sourceName samp) | |
| 20 | runCommand root samp | |
| 21 | ||
| 22 | runCommand :: FilePath -> Source -> IO () | |
| 23 | runCommand root src = do | |
| 24 | forM_ (sourceCommands src) $ \ln -> do | |
| 25 | let dir = root </> sourceDir src | |
| 26 | cDebug (" - $ " % shown % " in " % string) ln dir | |
| 27 | let process = (Sys.shell ln) { Sys.cwd = Just (root </> sourceDir src) } | |
| 28 | (_, _, _, h) <- Sys.createProcess process | |
| 29 | Sys.waitForProcess h |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Collage.Config | |
| 4 | ( Config(..) | |
| 5 | , Source(..) | |
| 6 | , Expose(..) | |
| 7 | , getConfig | |
| 8 | , example | |
| 9 | ) where | |
| 10 | ||
| 11 | import Control.Applicative ((<|>)) | |
| 12 | import qualified Data.Adnot as A | |
| 13 | import qualified Data.ByteString as B | |
| 14 | import qualified Data.Map.Strict as M | |
| 15 | import qualified Data.Text as T | |
| 16 | import qualified Data.Vector as V | |
| 17 | ||
| 18 | data Config = Config | |
| 19 | { confDocument :: FilePath | |
| 20 | , confSources :: [Source] | |
| 21 | } deriving (Eq, Show) | |
| 22 | ||
| 23 | instance A.FromAdnot Config where | |
| 24 | parseAdnot = A.withSumNamed "config file" "document" go | |
| 25 | where | |
| 26 | go payload | |
| 27 | | Just file <- payload V.!? 0 | |
| 28 | = Config <$> A.parseAdnot file | |
| 29 | <*> mapM A.parseAdnot (V.toList (V.tail payload)) | |
| 30 | | otherwise = Left "expected source file in config" | |
| 31 | ||
| 32 | ||
| 33 | data Source = Source | |
| 34 | { sourceName :: T.Text | |
| 35 | , sourceDir :: FilePath | |
| 36 | , sourceCommands :: [String] | |
| 37 | , sourceExpose :: Expose | |
| 38 | } deriving (Eq, Show) | |
| 39 | ||
| 40 | instance A.FromAdnot Source where | |
| 41 | parseAdnot = A.withProduct "source" $ \p -> do | |
| 42 | name <- p A..: "name" | |
| 43 | dir <- p A..: "dir" | |
| 44 | cmds <- p A..: "cmd" | |
| 45 | expose <- p A..: "expose" | |
| 46 | return (Source name dir cmds expose) | |
| 47 | ||
| 48 | ||
| 49 | data Expose | |
| 50 | = ExposeFile FilePath | |
| 51 | | ExposeSections FilePath | |
| 52 | | NamedMap (M.Map T.Text Expose) | |
| 53 | deriving (Eq, Show) | |
| 54 | ||
| 55 | instance A.FromAdnot Expose where | |
| 56 | parseAdnot v = file v <|> sections v <|> namedMap v | |
| 57 | where | |
| 58 | file = A.withSumNamed "exposed fragments" "file" $ \ ps -> | |
| 59 | case V.toList ps of | |
| 60 | [] -> Left "Expected name for file" | |
| 61 | [f] -> ExposeFile <$> A.parseAdnot f | |
| 62 | _ -> Left "Too many arguments to file" | |
| 63 | ||
| 64 | sections = A.withSumNamed "exposed fragments" "sections" $ \ ps -> | |
| 65 | case V.toList ps of | |
| 66 | [] -> Left "Expected name for sections" | |
| 67 | [f] -> ExposeSections <$> A.parseAdnot f | |
| 68 | _ -> Left "Too many arguments to sections" | |
| 69 | ||
| 70 | namedMap = A.withProduct "exposed fragments" $ \ p -> | |
| 71 | NamedMap <$> mapM A.parseAdnot p | |
| 72 | ||
| 73 | parseConfig :: B.ByteString -> Either String Config | |
| 74 | parseConfig = A.decode | |
| 75 | ||
| 76 | getConfig :: FilePath -> IO (Either String Config) | |
| 77 | getConfig loc = do | |
| 78 | conf <- B.readFile loc | |
| 79 | return (parseConfig conf) | |
| 80 | ||
| 81 | example :: Config | |
| 82 | example = Config | |
| 83 | { confDocument = "main.md" | |
| 84 | , confSources = | |
| 85 | [ Source | |
| 86 | { sourceName = "rust-sample" | |
| 87 | , sourceDir = "s1" | |
| 88 | , sourceCommands = ["cargo clean", "cargo build"] | |
| 89 | , sourceExpose = ExposeFile "src/main.rs" | |
| 90 | } | |
| 91 | , Source | |
| 92 | { sourceName = "haskell-sample" | |
| 93 | , sourceDir = "s2" | |
| 94 | , sourceCommands = ["cabal new-build"] | |
| 95 | , sourceExpose = ExposeSections "Main.hs" | |
| 96 | } | |
| 97 | ] | |
| 98 | } |
| 1 | module Collage.Opts | |
| 2 | ( Command(..) | |
| 3 | , Options(..) | |
| 4 | , getOpts | |
| 5 | ) where | |
| 6 | ||
| 7 | import Control.Applicative ((<|>)) | |
| 8 | import qualified Options.Applicative as Opt | |
| 9 | import qualified System.Directory as Sys | |
| 10 | import qualified System.FilePath as Sys | |
| 11 | ||
| 12 | data Command | |
| 13 | = Test | |
| 14 | | Splice | |
| 15 | deriving (Eq, Show) | |
| 16 | ||
| 17 | data Options = Options | |
| 18 | { optFile :: FilePath | |
| 19 | , optVerbose :: Bool | |
| 20 | , optCommand :: Command | |
| 21 | } deriving (Eq, Show) | |
| 22 | ||
| 23 | desc :: String | |
| 24 | desc = "FINISH ME" | |
| 25 | ||
| 26 | opts :: Opt.ParserInfo Options | |
| 27 | opts = Opt.info (p Opt.<**> Opt.helper) | |
| 28 | (Opt.progDesc desc <> | |
| 29 | Opt.fullDesc <> | |
| 30 | Opt.header "arglbargl") | |
| 31 | where | |
| 32 | p = Options <$> (path <|> pure "") | |
| 33 | <*> verbose | |
| 34 | <*> Opt.subparser (test <> splice) | |
| 35 | ||
| 36 | path = Opt.strOption | |
| 37 | (Opt.short 'f' <> | |
| 38 | Opt.long "file" <> | |
| 39 | Opt.metavar "PATH" <> | |
| 40 | Opt.help "The path to the project file") | |
| 41 | ||
| 42 | verbose = Opt.switch | |
| 43 | (Opt.short 'v' <> | |
| 44 | Opt.long "verbose" <> | |
| 45 | Opt.help "Show debug messages") | |
| 46 | ||
| 47 | test = Opt.command "test" $ Opt.info | |
| 48 | (pure Test Opt.<**> Opt.helper) | |
| 49 | (Opt.progDesc "test the provided sources") | |
| 50 | ||
| 51 | splice = Opt.command "splice" $ Opt.info | |
| 52 | (pure Splice Opt.<**> Opt.helper) | |
| 53 | (Opt.progDesc "splice sources into a final draft") | |
| 54 | ||
| 55 | getOpts :: IO Options | |
| 56 | getOpts = do | |
| 57 | cwd <- Sys.getCurrentDirectory | |
| 58 | options <- Opt.execParser opts | |
| 59 | return $ if null (optFile options) | |
| 60 | then options { optFile = cwd Sys.</> "collage" } | |
| 61 | else options |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Collage.Utils | |
| 4 | ( cOutput | |
| 5 | , cDebug | |
| 6 | , cWarn | |
| 7 | , cError | |
| 8 | ||
| 9 | , cOpenFile | |
| 10 | ||
| 11 | , throw | |
| 12 | ||
| 13 | , F.format | |
| 14 | , (F.%) | |
| 15 | , F.stext | |
| 16 | , F.text | |
| 17 | , F.string | |
| 18 | , F.shown | |
| 19 | ) where | |
| 20 | ||
| 21 | import qualified Formatting as F | |
| 22 | import qualified Data.Text.Lazy as TL | |
| 23 | import qualified Data.Text.Lazy.Builder as TL | |
| 24 | import qualified Data.Text.Lazy.IO as TL | |
| 25 | import qualified System.Directory as Sys | |
| 26 | import qualified System.Exit as Sys | |
| 27 | import qualified System.IO as Sys | |
| 28 | import qualified System.Posix.IO as Posix | |
| 29 | import qualified System.Posix.Terminal as Posix | |
| 30 | ||
| 31 | import Prelude (FilePath, IO, Either(Left), ($)) | |
| 32 | ||
| 33 | throw :: F.Format (Either TL.Text r) a -> a | |
| 34 | throw f = | |
| 35 | F.runFormat f (\ b -> Left (TL.toLazyText b)) | |
| 36 | ||
| 37 | stderr :: TL.Text -> IO () | |
| 38 | stderr = TL.hPutStr Sys.stderr | |
| 39 | ||
| 40 | -- | Write output to stdout | |
| 41 | cOutput :: TL.Text -> IO () | |
| 42 | cOutput = TL.putStrLn | |
| 43 | ||
| 44 | -- | Write a debug message to stderr. | |
| 45 | cDebug :: F.Format (IO ()) a -> a | |
| 46 | cDebug msg = F.runFormat msg $ \ b -> | |
| 47 | TL.hPutStrLn Sys.stderr (TL.toLazyText b) | |
| 48 | ||
| 49 | -- | Write a warning message to stderr. If we are connected to a TTY, | |
| 50 | -- then this will write in an orange color. | |
| 51 | cWarn :: F.Format (IO ()) a -> a | |
| 52 | cWarn msg = F.runFormat msg $ \b -> do | |
| 53 | isTTY <- Posix.queryTerminal Posix.stdOutput | |
| 54 | if isTTY | |
| 55 | then do stderr "\x1b[93m" | |
| 56 | stderr (TL.toLazyText b) | |
| 57 | stderr "\x1b[39m\n" | |
| 58 | else TL.hPutStrLn Sys.stderr (TL.toLazyText b) | |
| 59 | ||
| 60 | -- | Write an error message to stderr and exit. If we are connected to | |
| 61 | -- a TTY, this message will be in red. | |
| 62 | cError :: F.Format (IO r) a -> a | |
| 63 | cError msg = F.runFormat msg $ \b -> do | |
| 64 | isTTY <- Posix.queryTerminal Posix.stdOutput | |
| 65 | if isTTY | |
| 66 | then do stderr "\x1b[91m" | |
| 67 | stderr (TL.toLazyText b) | |
| 68 | stderr "\x1b[39m\n" | |
| 69 | else TL.hPutStrLn Sys.stderr (TL.toLazyText b) | |
| 70 | Sys.exitFailure | |
| 71 | ||
| 72 | ||
| 73 | cOpenFile :: TL.Text -> FilePath -> IO TL.Text | |
| 74 | cOpenFile purpose path = do | |
| 75 | exists <- Sys.doesFileExist path | |
| 76 | if exists | |
| 77 | then TL.readFile path | |
| 78 | else cError ("Unable to open " F.% F.text F.% | |
| 79 | " file at " F.% F.string) purpose path |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Collage | |
| 4 | ( main | |
| 5 | , Opt.getOpts | |
| 6 | ) where | |
| 7 | ||
| 8 | import qualified System.Exit as Sys | |
| 9 | ||
| 10 | import qualified Collage.Config as Conf | |
| 11 | import qualified Collage.Opts as Opt | |
| 12 | ||
| 13 | import qualified Collage.Commands.Splice as Cmd | |
| 14 | import qualified Collage.Commands.Test as Cmd | |
| 15 | ||
| 16 | -- | Run the main @collage@ function with the provided options. | |
| 17 | main :: Opt.Options -> IO () | |
| 18 | main opts = do | |
| 19 | configMb <- Conf.getConfig (Opt.optFile opts) | |
| 20 | config <- case configMb of | |
| 21 | Left err -> Sys.die err | |
| 22 | Right x -> return x | |
| 23 | case Opt.optCommand opts of | |
| 24 | Opt.Test -> Cmd.runTest config opts | |
| 25 | Opt.Splice -> Cmd.runSplice config opts |