From 3e64d8d344fff6c59970b5d0ec85c7baf839fbfe Mon Sep 17 00:00:00 2001 From: dmjio Date: Thu, 22 Oct 2026 17:10:20 -0700 Subject: [PATCH] A miso project with a library can be a dependency of another A dependency from git: or a path: had to be a Cabal package: one with a miso.yml and a library: section failed with "has no .cabal file". So a library made with miso could be used only in its own project. miso cabal now prints the .cabal file of the library of the miso.yml here: name, version, synopsis, author; the exposed modules (or every module under source-dirs), its dependencies by name (the top-level list and the library's), mhs-options, and the C and JavaScript files, include dirs and headers, cpp/cc/ld options and extra-libraries. MicroCabal builds and installs a library from those fields. A dependency with a miso.yml and no .cabal file is copied to $MISO_HOME/src/cabalized/NAME-KEY (without .git/, .miso/, dist/, public/, vendor/), with that .cabal file next to it, and the copy is what is read for its dependencies and built; the checkout itself is not changed. The copy is made again each time, so a change to a path: dependency is built (its key in the store still comes from its own files). The dependency's name in miso.yml has to be the project's. Its own git: or path: dependencies are an error that says to list them in the project that uses it. Checked: - miso cabal on a library with two modules, cbits/, include/, cpp-options and extra-libraries; MicroCabal parses the result; a project with no library: section, and one whose library needs a git dependency, give errors that say why - with a test program: the copy has the library's files and the .cabal file, not .git/ or dist/, and the original folder is unchanged; a dependency named otherwise than its project is an error; a Cabal package is used as it is; a change to the source is in the next copy - the smoke test now builds and runs a project that depends on a miso library (which needs split) by path:, then changes the library and checks the change is picked up, and that no .cabal file was written into the library's folder --- README.md | 9 +++ scripts/smoke-test.sh | 18 ++++++ src/Miso/CLI/Build.hs | 17 +++++- src/Miso/CLI/Cabalize.hs | 117 +++++++++++++++++++++++++++++++++++++++ src/Miso/CLI/Deps.hs | 14 ++++- src/Miso/CLI/Main.hs | 3 + src/Miso/CLI/Resolve.hs | 5 +- 7 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 src/Miso/CLI/Cabalize.hs diff --git a/README.md b/README.md index c623eda..d4f37f5 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ miso build # public/index.js (target: browser) | `miso lock` | write `miso.lock`: the dependencies of `miso.yml` and the packages they need; lock what is not locked yet, keep what is | | `miso update` | a new package list for mcabal, and `miso.lock` again at its versions (git dependencies at their current commits); then install | | `miso vendor` | lock as `miso lock` does, and copy the source of each locked dependency to `vendor/NAME`; builds then install from there, and fail rather than download | +| `miso cabal` | print the `.cabal` file of the project's library: what another project that depends on it builds it with | | `miso doctor` | check the installation and the tools miso needs, and say how to fix what is missing | | `miso upgrade [VERSION]` | install the latest release (or `VERSION`) in place | | `miso uninstall [--yes]` | remove miso: `$MISO_HOME` (packages, caches) and the tool where `install.sh` put it. It shows what it removes and asks first; it lists the lines of your shell files that add miso to `PATH` (it does not edit them) | @@ -132,6 +133,14 @@ executables: - The top-level `main`, `source-dirs` and `target` (and the fields below) cannot be used with `executables:`. A project with just `library:` is a library: `miso build` checks it, and there is nothing to run. +- Another project can depend on a project with a library, from `git:` or a + `path:`, as on any package. miso builds it from a copy (in + `$MISO_HOME/src/cabalized`) with the `.cabal` file of its library, which + `miso cabal` prints: its `name`, `version`, modules, dependencies and the + fields below. The checkout itself is not changed. The `name:` of the + dependency is the project's. Its dependencies are by name (from Hackage, + or the tool's); one it takes from `git:` or a `path:` has to be listed in + the project that uses it instead. Tests are programs that run on this machine, and pass when they exit with 0 (as Cabal's `exitcode-stdio` tests): diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 52ac70d..0a23ec8 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -157,4 +157,22 @@ grep -q 'Installing miso .*(built already, ' miso.log || { echo "smoke test: mis ls .miso/mcabal/mhs-*/packages/miso-*.pkg >/dev/null || { echo "smoke test: miso is not in .miso/" >&2; exit 1; } miso lock > /dev/null grep -q '^ git: https://github.com/dmjio/miso$' miso.lock || { echo "smoke test: miso lock did not lock miso" >&2; cat miso.lock >&2; exit 1; } +# a miso project with a library: section, as a path: dependency of another +# project (built from the .cabal file miso cabal makes of it), with a +# package of its own; and a change to it, picked up +cd "$tmp" +mkdir -p greet/src +printf 'name: greet\nversion: 0.3.0\ndependencies: [split]\nlibrary:\n source-dirs: [src]\n' > greet/miso.yml +printf 'module Greet (greet) where\nimport Data.List.Split (splitOn)\ngreet :: String\ngreet = concat (splitOn "," "he,llo")\n' > greet/src/Greet.hs +(cd greet && miso cabal) | grep -q '^ exposed-modules: Greet$' || { echo "smoke test: miso cabal did not list the library's modules" >&2; (cd greet && miso cabal) >&2; exit 1; } +miso new usegreet > /dev/null +cd usegreet +printf 'name: usegreet\nversion: 0.1.0\nmain: Main\nsource-dirs: [app]\ntarget: native\ndependencies:\n - name: greet\n path: ../greet\n' > miso.yml +printf 'import Greet (greet)\nmain :: IO ()\nmain = putStrLn greet\n' > app/Main.hs +miso run > greet.log 2>&1 || { echo "smoke test: a miso project as a path dependency did not build" >&2; cat greet.log >&2; exit 1; } +[ "$(tail -1 greet.log | tr -d '\r')" = hello ] || { echo "smoke test: expected hello from the greet library" >&2; cat greet.log >&2; exit 1; } +sed -e 's/he,llo/hi,there/' ../greet/src/Greet.hs > ../greet/src/Greet.hs.new && mv ../greet/src/Greet.hs.new ../greet/src/Greet.hs +miso run > greet2.log 2>&1 || { echo "smoke test: the changed greet library did not build" >&2; cat greet2.log >&2; exit 1; } +[ "$(tail -1 greet2.log | tr -d '\r')" = hithere ] || { echo "smoke test: the change to the greet library was not picked up" >&2; cat greet2.log >&2; exit 1; } +[ ! -e ../greet/greet.cabal ] || { echo "smoke test: miso wrote a .cabal file into the dependency's folder" >&2; exit 1; } echo "smoke test: ok" diff --git a/src/Miso/CLI/Build.hs b/src/Miso/CLI/Build.hs index efda06b..9efca91 100644 --- a/src/Miso/CLI/Build.hs +++ b/src/Miso/CLI/Build.hs @@ -7,6 +7,7 @@ module Miso.CLI.Build , libraryArgs , programArgs , nativeFiles + , libraryFiles , modulesIn ) where @@ -30,11 +31,21 @@ nativeFiles cfg exe = do let lib = fmap libNative (cfgLibrary cfg) own = exeNative exe dflt = maybe True (const False) lib -- the program has the defaults - (lc, lj, li) <- maybe (pure ([], [], [])) (files True) lib - (oc, oj, oi) <- files dflt own + (lc, lj, li) <- maybe (pure ([], [], [])) (nativeFilesOf True) lib + (oc, oj, oi) <- nativeFilesOf dflt own pure (lc ++ oc, lj ++ oj, li ++ oi) + +-- | The C, JavaScript and include files of the library (with the defaults +-- cbits/*.c, jsbits/*.js and include/, where they are there). +libraryFiles :: Library -> IO ([FilePath], [FilePath], [FilePath]) +libraryFiles lib = nativeFilesOf True (libNative lib) + +-- | The C and JavaScript files and include dirs of one component; with dflt, +-- the defaults apply to what miso.yml does not list. +nativeFilesOf :: Bool -> Native -> IO ([FilePath], [FilePath], [FilePath]) +nativeFilesOf dflt n = files where - files dflt n = do + files = do c <- globs (pick dflt ["cbits/*.c"] (cSources n)) j <- globs (pick dflt ["jsbits/*.js"] (jsSources n)) i <- existing (pick dflt ["include"] (includeDirs n)) diff --git a/src/Miso/CLI/Cabalize.hs b/src/Miso/CLI/Cabalize.hs new file mode 100644 index 0000000..d37db76 --- /dev/null +++ b/src/Miso/CLI/Cabalize.hs @@ -0,0 +1,117 @@ +-- | A miso project with a library, as a dependency of another project. +-- +-- mcabal builds packages from a .cabal file. A dependency (from git:, or a +-- path:) that is a miso project has a miso.yml and a library: section +-- instead: 'cabalized' makes a copy of it with the .cabal file of its +-- library ('cabalFile', which miso cabal prints), and the copy is what is +-- read for its dependencies and built. The checkout itself is not changed. +module Miso.CLI.Cabalize + ( cabalFile + , cabalized + ) where + +import Control.Monad (unless, when) +import Data.List (intercalate, isPrefixOf, nub) +import System.Directory (createDirectoryIfMissing, doesFileExist, getCurrentDirectory, listDirectory) + +import Miso.CLI.Build (libraryFiles, modulesIn) +import Miso.CLI.Color +import Miso.CLI.Config +import Miso.CLI.Util + +-- | The .cabal file of the library of the miso.yml here (miso cabal). +cabalFile :: Config -> IO String +cabalFile cfg = do + lib <- maybe (failWith (cfgName cfg ++ " has no library: section in " ++ configFile + ++ ", so it has nothing another project can import (a library: section lists its modules' source-dirs)")) pure + (cfgLibrary cfg) + -- What it needs, by name: the packages of the top-level list and of the + -- library's. One from git: or a path: of its own would need the project + -- that uses this one to fetch it too, which it does not do yet. + let deps = nub (cfgDeps cfg ++ libDeps lib) + case [ d | d <- deps, isGit d || isLocal d ] of + [] -> pure () + ds -> failWith (cfgName cfg ++ " needs " ++ intercalate ", " (map depName ds) + ++ " from git: or a path:, which a project that uses " ++ cfgName cfg + ++ " cannot fetch for it yet; list them in that project's " ++ configFile ++ " instead") + mods <- maybe (concat <$> mapM modulesIn (libSourceDirs lib)) pure (libExposed lib) + when (null mods) $ failWith ("the library has no modules in " ++ unwords (libSourceDirs lib)) + (cs, js, incs) <- libraryFiles lib + headers <- case incs of + d : _ -> filter (".h" `isSuffixOf'`) <$> listDirectory d + [] -> pure [] + let n = libNative lib + field k vs = [ " " ++ k ++ ": " ++ intercalate ", " vs | not (null vs) ] + opts k vs = [ " " ++ k ++ ": " ++ unwords vs | not (null vs) ] + pure $ unlines $ + [ "-- written by miso " ++ version ++ " from " ++ configFile ++ " (miso cabal)" + , "cabal-version: 2.2" + , "name: " ++ cfgName cfg + , "version: " ++ cfgVersion cfg + ] ++ [ "synopsis: " ++ s | Just s <- [cfgSynopsis cfg] ] + ++ [ "author: " ++ a | Just a <- [cfgAuthor cfg] ] ++ + [ "build-type: Simple" + , "" + , "library" + , " default-language: Haskell2010" + ] ++ field "hs-source-dirs" (libSourceDirs lib) + ++ field "exposed-modules" mods + ++ field "build-depends" ("base" : [ depName d ++ maybe "" (" == " ++) (depVersion d) | d <- deps ]) + ++ opts "mhs-options" (cfgMhsOptions cfg ++ libMhsOptions lib) + ++ field "c-sources" cs + ++ field "js-sources" js + ++ field "include-dirs" incs + ++ field "install-includes" headers + ++ opts "cpp-options" (cppOptions n) + ++ opts "cc-options" (ccOptions n) + ++ opts "ld-options" (ldOptions n) + ++ field "extra-libraries" (extraLibraries n) + +-- | The directory to read and build a dependency from: dir itself when it +-- has a .cabal file (or nothing to make one from), otherwise a copy with the +-- .cabal file of its miso.yml, in $MISO_HOME/src/cabalized. The copy is +-- made again each time, as the source (a path:) may have changed. With +-- the name the dependency has in miso.yml, it is checked to be the +-- project's. +cabalized :: Maybe String -> FilePath -> IO FilePath +cabalized mname dir = do + es <- listDirectory dir + let hasCabal = any (".cabal" `isSuffixOf'`) es + hasYml <- doesFileExist (dir ++ "/" ++ configFile) + if hasCabal || not hasYml then pure dir else do + name <- maybe (either failWith (pure . cfgName) . parseConfig =<< readFile' (dir ++ "/" ++ configFile)) pure mname + home <- misoHome + here <- getCurrentDirectory + let abs' = if "/" `isPrefixOf` dir then dir else here ++ "/" ++ dir + key <- maybe "" (takeWhile (/= ' ')) <$> readCommand ("printf '%s' " ++ quote abs' ++ " | cksum") + let base = home ++ "/src/cabalized" + stage = base ++ "/" ++ name ++ "-" ++ key + tmp = stage ++ ".tmp" + createDirectoryIfMissing True base + -- its files, without what builds and tools keep there + run ("rm -rf " ++ quote tmp ++ " && mkdir " ++ quote tmp + ++ " && cd " ++ quote dir + ++ " && tar -cf - --exclude=./.git --exclude=./.miso --exclude=./dist --exclude=./dist-mcabal" + ++ " --exclude=./public --exclude=./vendor --exclude=./.mhscache . | tar -xf - -C " ++ quote tmp) + me <- self + (ok, out) <- captureIn tmp (quote me ++ " cabal") + unless ok $ do + _ <- tryRun ("rm -rf " ++ quote tmp) + failWith ("dependency " ++ name ++ " (" ++ dir ++ ") is a miso project, but miso cannot use it as a package:\n" ++ out) + pkg <- either failWith (pure . cfgName) . parseConfig =<< readFile' (tmp ++ "/" ++ configFile) + when (pkg /= name) $ do + _ <- tryRun ("rm -rf " ++ quote tmp) + failWith ("dependency " ++ name ++ " is " ++ dir ++ ", whose " ++ configFile ++ " names it " ++ pkg + ++ "; use name: " ++ pkg ++ " for it") + writeFile (tmp ++ "/" ++ name ++ ".cabal") out + run ("rm -rf " ++ quote stage ++ " && mv " ++ quote tmp ++ " " ++ quote stage) + pure stage + where + -- run a command in a directory: did it work, and what it printed (stdout) + captureIn d c = do + out <- readCommand ("cd " ++ quote d ++ " && MISO_PROJECT= " ++ c) + case out of + Just s -> pure (True, s) + Nothing -> do + err <- maybe "" id <$> readCommand ("cd " ++ quote d ++ " && MISO_PROJECT= " ++ c ++ " 2>&1; true") + pure (False, err) diff --git a/src/Miso/CLI/Deps.hs b/src/Miso/CLI/Deps.hs index 13eaa65..166904c 100644 --- a/src/Miso/CLI/Deps.hs +++ b/src/Miso/CLI/Deps.hs @@ -21,6 +21,7 @@ import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileE import qualified MicroCabal.Main as MC import MicroHs.Compile (mhsVersion) +import Miso.CLI.Cabalize (cabalized) import Miso.CLI.Color import Miso.CLI.Config import Miso.CLI.Lock @@ -332,7 +333,18 @@ unitKey planned toolIds u ds = do -- | Where the source of a package is, fetched if needed: Nothing when mcabal -- installs it by name (mcabal -r). sourceOf :: Maybe [(String, (String, String))] -> Dep -> Maybe Locked -> IO (Maybe FilePath, Maybe String) -sourceOf vend d ml = +sourceOf vend d ml = do + (mdir, from) <- sourceOf' vend d ml + -- a miso project (miso.yml, no .cabal file): built from a copy with the + -- .cabal file of its library + case mdir of + Just dir -> do + dir' <- cabalized (Just (maybe (depName d) lockedName ml)) dir + pure (Just dir', from) + Nothing -> pure (Nothing, from) + +sourceOf' :: Maybe [(String, (String, String))] -> Dep -> Maybe Locked -> IO (Maybe FilePath, Maybe String) +sourceOf' vend d ml = case (vend, ml) of (Just vs, _) | not (isLocal d) -> do dir <- vendoredDir vs ml d diff --git a/src/Miso/CLI/Main.hs b/src/Miso/CLI/Main.hs index d67f0ca..08c43e7 100644 --- a/src/Miso/CLI/Main.hs +++ b/src/Miso/CLI/Main.hs @@ -20,6 +20,7 @@ import qualified MicroHs.Main as MHS import MicroHs.Compile (mhsVersion) import Miso.CLI.Build +import Miso.CLI.Cabalize (cabalFile) import Miso.CLI.Color import Miso.CLI.Config import Miso.CLI.Deps @@ -56,6 +57,7 @@ main' = do "new" : _ -> failWith "usage: miso new NAME [--template T] (miso templates lists the templates)" "init" : as -> initProject as ["templates"] -> listTemplates + ["cabal"] -> withConfig (\cfg -> cabalFile cfg >>= putStr) "build" : as -> withConfig (build ("--dev" `elem` as) (filter (/= "--dev") as)) "run" : as -> withConfig (runProgram as) "test" : as -> withConfig (test as) @@ -117,6 +119,7 @@ usage = do , cmd "lock" "write miso.lock, keeping the versions locked already" , cmd "update" "lock Hackage and git dependencies in miso.lock, and install them" , cmd "vendor" "copy the locked dependencies' sources to vendor/, to build from" + , cmd "cabal" "print the .cabal file of the library (other projects build it from that)" ] ts <- sequence [ cmd "doctor" "check the installation and the tools miso needs" diff --git a/src/Miso/CLI/Resolve.hs b/src/Miso/CLI/Resolve.hs index 5f4d829..8c5f970 100644 --- a/src/Miso/CLI/Resolve.hs +++ b/src/Miso/CLI/Resolve.hs @@ -38,6 +38,7 @@ import qualified MicroCabal.StackageList as MC import MicroHs.Compile (mhsVersion) +import Miso.CLI.Cabalize (cabalized) import Miso.CLI.Color import Miso.CLI.Config import Miso.CLI.Lock @@ -142,7 +143,9 @@ importsMiso cfg = do -- and the cabal flags given, with MicroHs's names, as mcabal reads them; -- not the package's own (internal) libraries. packageDeps :: MC.Env -> [(String, Bool)] -> String -> FilePath -> IO [String] -packageDeps env fl why dir = do +packageDeps env fl why dir0 = do + -- a miso project: its library, as the .cabal file miso makes of it + dir <- cabalized Nothing dir0 cabals <- filter (".cabal" `isSuffixOf'`) <$> listDirectory dir f <- case cabals of [f] -> pure f