summaryrefslogtreecommitdiff
path: root/Biz/Bild.hs
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2021-07-23 14:28:35 -0400
committerBen Sima <ben@bsima.me>2021-11-26 13:47:37 -0500
commit0264f4a5dc37b16f872e6fa92bd8f1fc1e2b1826 (patch)
treedb66845496f21afe845abaa23546b82be9c8adf0 /Biz/Bild.hs
parent7f311fd420e92b6d90007fdd3b2d843e6e1752c3 (diff)
Automatically detect Haskell dependencies
This parses the files contents for imports, then uses ghc-pkg to lookup the package that provides the module. Now I can do that analysis in Haskell instead of nix, which is much easier to code with.
Diffstat (limited to 'Biz/Bild.hs')
-rw-r--r--Biz/Bild.hs322
1 files changed, 210 insertions, 112 deletions
diff --git a/Biz/Bild.hs b/Biz/Bild.hs
index ada7879..08fb208 100644
--- a/Biz/Bild.hs
+++ b/Biz/Bild.hs
@@ -1,19 +1,15 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TupleSections #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | A general purpose build tool.
--
-- : out bild
--- : dep conduit
--- : dep conduit-extra
--- : dep docopt
--- : dep regex-applicative
--- : dep rainbow
--- : dep tasty
--- : dep tasty-hunit
--
-- == Design constraints
--
@@ -75,6 +71,7 @@
-- > bild -s <target>
--
-- Starts a repl/shell for target.
+--
-- - if target.hs, load ghci
-- - if target.scm, load scheme repl
-- - if target.clj, load a clojure repl
@@ -92,10 +89,14 @@
--
-- == Build Metadata
--
--- Metadata is set in the comments with a special syntax. For third-party deps,
+-- Metadata is set in the comments with a special syntax. For system-level deps,
-- we list the deps in comments in the target file, like:
--
--- > -- : dep aeson
+-- > -- : sys cmark
+--
+-- The name is used to lookup the package in `nixpkgs.pkgs.<name>`.
+-- Language-level deps can automatically determined by passing parsed import
+-- statements to a package database, eg `ghc-pkg find-module`.
--
-- The output executable is named with:
--
@@ -125,22 +126,28 @@ import Biz.Namespace (Namespace (..))
import qualified Biz.Namespace as Namespace
import qualified Biz.Test as Test
import qualified Control.Concurrent.Async as Async
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Char8 as Char8
+import qualified Data.ByteString.Lazy as ByteString
import qualified Data.Char as Char
import Data.Conduit ((.|))
import qualified Data.Conduit as Conduit
import qualified Data.Conduit.List as Conduit
import qualified Data.Conduit.Process as Conduit
import qualified Data.List as List
+import qualified Data.Map as Map
import qualified Data.Maybe as Maybe
+import qualified Data.Set as Set
import qualified Data.String as String
import qualified Data.Text as Text
+import qualified Data.Text.IO as Text.IO
import qualified System.Directory as Dir
import qualified System.Environment as Env
import qualified System.Exit as Exit
import System.FilePath (replaceExtension, (</>))
import qualified System.IO as IO
+import qualified System.Process as Process
import qualified Text.Regex.Applicative as Regex
-import qualified Prelude
main :: IO ()
main = Cli.main <| Cli.Plan help move test pure
@@ -149,8 +156,7 @@ main = Cli.main <| Cli.Plan help move test pure
Test.group
"Biz.Bild"
[ Test.unit "can bild bild" <| do
- let ns = Namespace ["Biz", "Bild"] Namespace.Hs
- analyze ns +> build False False +> \case
+ analyze "Biz/Bild.hs" /> Maybe.fromJust +> build False False +> \case
Exit.ExitFailure _ -> Test.assertFailure "can't bild bild"
_ -> pure ()
]
@@ -158,16 +164,28 @@ main = Cli.main <| Cli.Plan help move test pure
move :: Cli.Arguments -> IO ()
move args =
IO.hSetBuffering stdout IO.NoBuffering
- >> traverse getNamespace (Cli.getAllArgs args (Cli.argument "target"))
- /> catMaybes
- /> filter isBuildableNs
- +> traverse analyze
- +> traverse
- ( build
- (args `Cli.has` Cli.longOption "test")
- (args `Cli.has` Cli.longOption "loud")
- )
+ >> pure (Cli.getAllArgs args (Cli.argument "target"))
+ /> filter (not <. ("_" `List.isPrefixOf`))
+ +> filterM Dir.doesFileExist
+ +> traverse (\fn -> analyze fn /> (fn,))
+ /> filter (snd .> isJust)
+ /> Map.fromList
+ /> Map.map Maybe.fromJust
+ /> Map.filter (namespace .> isBuildableNs)
+ +> printOrBuild
+> exitSummary
+ where
+ printOrBuild :: Map FilePath Target -> IO [ExitCode]
+ printOrBuild analyses =
+ if args `Cli.has` Cli.longOption "analyze"
+ then Map.elems analyses |> putJSON >> pure [Exit.ExitSuccess]
+ else Map.toList analyses |> map snd |> traverse (build isTest isLoud)
+ isTest = args `Cli.has` Cli.longOption "test"
+ isLoud = args `Cli.has` Cli.longOption "loud"
+ putJSON = Aeson.encode .> ByteString.toStrict .> Char8.putStrLn
+
+nixStore :: String
+nixStore = "/nix/store/00000000000000000000000000000000-"
help :: Cli.Docopt
help =
@@ -179,9 +197,10 @@ Usage:
bild [options] <target>...
Options:
- --test Run tests on a target after building.
- --loud Show all output from compiler.
- --help Print this info
+ --test Run tests on a target after building
+ --loud Show all output from compiler
+ --analyze Only analyze and print as JSON, don't build
+ -h, --help Print this info
|]
exitSummary :: [Exit.ExitCode] -> IO ()
@@ -204,7 +223,7 @@ data Compiler
| Guile
| NixBuild
| Copy
- deriving (Show)
+ deriving (Show, Generic, Aeson.ToJSON)
data Target = Target
{ -- | Output name
@@ -213,14 +232,16 @@ data Target = Target
namespace :: Namespace,
-- | Absolute path to file
path :: FilePath,
- -- | Parsed/detected dependencies
- deps :: [Dep],
+ -- | Language-specific dependencies
+ langdeps :: Set Dep,
+ -- | System-level dependencies
+ sysdeps :: Set Dep,
-- | Which compiler should we use?
compiler :: Compiler,
-- | Where is this machine being built? Schema: user@location
builder :: Text
}
- deriving (Show)
+ deriving (Show, Generic, Aeson.ToJSON)
-- | We can't build everything yet...
isBuildableNs :: Namespace -> Bool
@@ -246,7 +267,7 @@ vardir = "_/var"
createHier :: String -> IO ()
createHier root =
- mapM_
+ traverse_
(Dir.createDirectoryIfMissing True)
[ root </> bindir,
root </> intdir,
@@ -254,74 +275,135 @@ createHier root =
root </> vardir
]
-getNamespace :: String -> IO (Maybe Namespace)
-getNamespace s = do
+-- >>> removeVersion "array-0.5.4.0-DFLKGIjfsadi"
+-- "array"
+removeVersion :: String -> String
+removeVersion = takeWhile (/= '.') .> butlast2
+ where
+ butlast2 s = take (length s - 2) s
+
+detectImports :: Namespace -> [Text] -> IO (Set Dep)
+detectImports (Namespace _ Namespace.Hs) contentLines = do
+ let imports =
+ contentLines
+ /> Text.unpack
+ /> Regex.match haskellImports
+ |> catMaybes
+ pkgs <- foldM ghcPkgFindModule Set.empty imports
+ transitivePkgs <-
+ imports
+ |> map (Namespace.fromHaskellModule .> Namespace.toPath)
+ |> traverse Dir.makeAbsolute
+ +> filterM Dir.doesFileExist
+ +> traverse analyze -- surely this is a bottleneck ripe for caching
+ /> catMaybes
+ /> map langdeps
+ /> mconcat
+ pure <| pkgs <> transitivePkgs
+detectImports _ _ = Exit.die "can only detectImports for Haskell"
+
+analyze :: FilePath -> IO (Maybe Target)
+analyze path = do
+ content <-
+ withFile path ReadMode <| \h ->
+ IO.hSetEncoding h IO.utf8_bom
+ >> Text.IO.hGetContents h
+ let contentLines = Text.lines content
root <- Env.getEnv "BIZ_ROOT"
- cwd <- Dir.getCurrentDirectory
- return <| Namespace.fromPath root <| cwd </> s
-
-analyze :: Namespace -> IO Target
-analyze namespace@(Namespace.Namespace _ ext) = do
- user <- Env.getEnv "USER" /> Text.pack
- host <- chomp </ readFile "/etc/hostname"
- let path = Namespace.toPath namespace
- case ext of
- Namespace.Hs -> do
- content <- String.lines </ Prelude.readFile path
- let out =
- content
- /> Regex.match (metaOut "--")
- |> catMaybes
- |> head
- return
- Target
- { deps = content /> Regex.match metaDep |> catMaybes,
- builder = user <> "@localhost",
- compiler = detectGhcCompiler out <| String.unlines content,
- ..
- }
- Namespace.Nix ->
- return
- Target
- { deps = [],
- compiler = NixBuild,
- out = Nothing,
- builder =
- if host == "lithium"
- then mempty
- else
- Text.concat
- [ "ssh://",
- user,
- "@dev.simatime.com?ssh-key=/home/",
- user,
- "/.ssh/id_rsa"
- ],
- ..
- }
- Namespace.Scm -> do
- content <- String.lines </ Prelude.readFile path
- return
- Target
- { deps = [],
- compiler = Guile,
- out =
- content
- /> Regex.match (metaOut ";;")
- |> catMaybes
- |> head,
- builder = user <> "@localhost",
- ..
- }
- _ ->
- return
- Target
- { deps = [],
- compiler = Copy,
- out = Nothing,
- builder = user <> "@localhost",
- ..
- }
+ absPath <- Dir.makeAbsolute path
+ Log.info ["bild", "analyze", str path]
+ let ns =
+ if "hs" `List.isSuffixOf` path
+ then Namespace.fromContent <| Text.unpack content
+ else Namespace.fromPath root absPath
+ case ns of
+ Nothing ->
+ Log.warn ["bild", "analyze", str path, "could not find namespace"]
+ >> Log.br
+ >> pure Nothing
+ Just namespace@(Namespace _ ext) ->
+ Just </ do
+ user <- Env.getEnv "USER" /> Text.pack
+ host <- Text.pack </ fromMaybe "interactive" </ Env.lookupEnv "HOSTNAME"
+ case ext of
+ Namespace.Hs -> do
+ langdeps <- detectImports namespace contentLines
+ let out =
+ contentLines
+ /> Text.unpack
+ /> Regex.match (metaOut "--")
+ |> catMaybes
+ |> head
+ pure
+ Target
+ { builder = user <> "@localhost",
+ compiler = detectGhcCompiler out <| Text.unpack content,
+ sysdeps =
+ contentLines
+ /> Text.unpack
+ /> Regex.match (metaSys "--")
+ |> catMaybes
+ |> Set.fromList,
+ ..
+ }
+ Namespace.Nix ->
+ pure
+ Target
+ { langdeps = Set.empty,
+ sysdeps = Set.empty,
+ compiler = NixBuild,
+ out = Nothing,
+ builder =
+ if host == "lithium"
+ then mempty
+ else
+ Text.concat
+ [ "ssh://",
+ user,
+ "@dev.simatime.com?ssh-key=/home/",
+ user,
+ "/.ssh/id_rsa"
+ ],
+ ..
+ }
+ Namespace.Scm -> do
+ pure
+ Target
+ { langdeps = Set.empty,
+ sysdeps = Set.empty,
+ compiler = Guile,
+ out =
+ contentLines
+ /> Text.unpack
+ /> Regex.match (metaOut ";;")
+ |> catMaybes
+ |> head,
+ builder = user <> "@localhost",
+ ..
+ }
+ _ ->
+ pure
+ Target
+ { langdeps = Set.empty,
+ sysdeps = Set.empty,
+ compiler = Copy,
+ out = Nothing,
+ builder = user <> "@localhost",
+ ..
+ }
+
+ghcPkgFindModule :: Set String -> String -> IO (Set String)
+ghcPkgFindModule acc m =
+ Process.readProcess
+ "ghc-pkg"
+ -- instead of relying on global deps declared in ./Bild/Deps/Haskell.nix, I
+ -- could fetch a global package-db from hackage API and pass it here with
+ -- --package-db=FILE
+ ["--names-only", "--simple-output", "find-module", m]
+ ""
+ /> String.lines
+ /> Set.fromList
+ /> Set.union acc
-- | Some rules for detecting the how to compile a ghc module. If there is an
-- out, then we know it's some Exe; if the out ends in .js then it's GhcjsExe,
@@ -332,7 +414,7 @@ analyze namespace@(Namespace.Namespace _ ext) = do
-- import list.
detectGhcCompiler :: Maybe Out -> String -> Compiler
detectGhcCompiler (Just out) _ | jsSuffix out = GhcjsExe
-detectGhcCompiler (Just out) _ | not <| jsSuffix out = GhcExe
+detectGhcCompiler (Just _) _ = GhcExe
detectGhcCompiler Nothing content
| match "import GHCJS" = GhcjsLib
| otherwise = GhcLib
@@ -386,7 +468,7 @@ build andTest loud Target {..} = do
onFailure = Log.fail ["test", nschunk namespace] >> Log.br,
onSuccess = Log.pass ["test", nschunk namespace] >> Log.br
}
- else return exitcode
+ else pure exitcode
GhcLib -> do
Log.info ["bild", "dev", "ghc-lib", nschunk namespace]
proc
@@ -440,16 +522,17 @@ build andTest loud Target {..} = do
-- ]
Guile -> do
Log.info ["bild", "dev", "guile", nschunk namespace]
- proc
- loud
- namespace
- "guild"
- [ "compile",
- "--r7rs",
- "--load-path=" ++ root,
- "--output=" ++ root </> intdir </> replaceExtension path ".scm.go",
- path
- ]
+ _ <-
+ proc
+ loud
+ namespace
+ "guild"
+ [ "compile",
+ "--r7rs",
+ "--load-path=" ++ root,
+ "--output=" ++ root </> intdir </> replaceExtension path ".scm.go",
+ path
+ ]
when (isJust out) <| do
let o = Maybe.fromJust out
writeFile
@@ -467,7 +550,7 @@ build andTest loud Target {..} = do
]
p <- Dir.getPermissions <| root </> bindir </> o
Dir.setPermissions (root </> bindir </> o) (Dir.setOwnerExecutable True p)
- return Exit.ExitSuccess
+ pure Exit.ExitSuccess
NixBuild -> do
Log.info
[ "bild",
@@ -489,7 +572,7 @@ build andTest loud Target {..} = do
]
Copy -> do
Log.warn ["bild", "copy", "TODO", nschunk namespace]
- return Exit.ExitSuccess
+ pure Exit.ExitSuccess
data Proc = Proc
{ loud :: Bool,
@@ -515,8 +598,8 @@ run Proc {..} = do
<| Async.Concurrently
<| Conduit.waitForStreamingProcess cph
if isFailure exitcode
- then puts stderr_ >> onFailure >> return exitcode
- else onSuccess >> return exitcode
+ then puts stderr_ >> onFailure >> pure exitcode
+ else onSuccess >> pure exitcode
-- | Helper for running a standard bild subprocess.
proc :: Bool -> Namespace -> String -> [String] -> IO Exit.ExitCode
@@ -541,5 +624,20 @@ nschunk = Namespace.toPath .> Text.pack
metaDep :: Regex.RE Char Dep
metaDep = Regex.string "-- : dep " *> Regex.many (Regex.psym Char.isAlpha)
+metaSys :: [Char] -> Regex.RE Char Dep
+metaSys comment = Regex.string (comment ++ " : sys ") *> Regex.many (Regex.psym Char.isAlpha)
+
metaOut :: [Char] -> Regex.RE Char Out
metaOut comment = Regex.string (comment ++ " : out ") *> Regex.many (Regex.psym (/= ' '))
+
+haskellImports :: Regex.RE Char String
+haskellImports =
+ Regex.string "import"
+ *> Regex.some (Regex.psym Char.isSpace)
+ *> Regex.many (Regex.psym Char.isLower)
+ *> Regex.many (Regex.psym Char.isSpace)
+ *> Regex.some (Regex.psym isModuleChar)
+ <* Regex.many Regex.anySym
+ where
+ isModuleChar c =
+ elem c <| concat [['A' .. 'Z'], ['a' .. 'z'], ['.', '_'], ['0' .. '9']]