diff --git a/nix/haskell-parsers/README.md b/nix/haskell-parsers/README.md index c11f1896..03901ef0 100644 --- a/nix/haskell-parsers/README.md +++ b/nix/haskell-parsers/README.md @@ -4,7 +4,7 @@ - **`findPackagesInCabalProject`**: a superior alternative to nixpkgs' [`haskellPathsInDir`](https://github.com/NixOS/nixpkgs/blob/f991762ea1345d850c06cd9947700f3b08a12616/lib/filesystem.nix#L18). - It locates packages based on the "packages" field of `cabal.project` file if it exists (otherwise it returns the top-level package). -- **`getCabalExecutables`**: a function to extract executables from a `.cabal` file. +- **`getCabalStanzas`**: a function to extract all stanzas from a `.cabal` file. ## Limitations diff --git a/nix/haskell-parsers/default.nix b/nix/haskell-parsers/default.nix index 95ada340..7de8d973 100644 --- a/nix/haskell-parsers/default.nix +++ b/nix/haskell-parsers/default.nix @@ -31,7 +31,7 @@ let throwError "No .cabal file found under ${path}"; }; in -{ +rec { findPackagesInCabalProject = projectRoot: let cabalProjectFile = projectRoot + "/cabal.project"; @@ -61,22 +61,23 @@ in lib.optional (traversal.findSingleCabalFile projectRoot != null) projectRoot; in - lib.listToAttrs - (map - (path: - lib.nameValuePair (traversal.findHaskellPackageNameOfDirectory path) path) - packageDirs); + map + (path: lib.nameValuePair (traversal.findHaskellPackageNameOfDirectory path) path) + packageDirs; - getCabalExecutables = path: + getCabalStanzas = path: let cabalFile = traversal.findSingleCabalFile path; in if cabalFile != null then - let res = parser.parseCabalExecutableNames (builtins.readFile (lib.concatStrings [ path "/" cabalFile ])); + let + cabalContent = builtins.readFile (lib.concatStrings [ path "/" cabalFile ]); + res = parser.parseCabalStanzas cabalContent; in if res.type == "success" then res.value else throwError "Failed to parse ${cabalFile}: ${builtins.toJSON res}" else throwError "No .cabal file found under ${path}"; + } diff --git a/nix/haskell-parsers/parser.nix b/nix/haskell-parsers/parser.nix index 4d3655e0..5b95c13f 100644 --- a/nix/haskell-parsers/parser.nix +++ b/nix/haskell-parsers/parser.nix @@ -13,7 +13,7 @@ let }; inherit (import nix-parsec) parsec; in -{ +rec { # Extract the "packages" list from a cabal.project file. # # Globs are not supported yet. Values must be refer to a directory, not file. @@ -36,19 +36,79 @@ in in parsec.runParser parser cabalProjectFile; + # Extract all stanzas from a .cabal file in a single pass using choice + # Returns an attribute set mapping stanza type to list of names + parseCabalStanzas = cabalFile: + with parsec; + let + # Parse either a stanza line or skip irrelevant line + parseLineOrSkip = parsec.choice [ + # Try to parse a stanza line + (parsec.fmap + (result: { + type = lib.head result; + name = lib.removePrefix " " (lib.elemAt result 1); + }) + (parsec.sequence [ + (parsec.choice [ + (parsec.string "executable") + (parsec.string "test-suite") + (parsec.string "benchmark") + (parsec.string "foreign-library") + (parsec.string "custom-setup") + (parsec.string "library") + ]) + (parsec.fmap lib.concatStrings (parsec.many (parsec.anyCharBut "\n"))) + ])) + # Or skip this line + (parsec.fmap + (_: null) + (parsec.sequence [ (parsec.many (parsec.anyCharBut "\n")) anyChar ])) + ]; + + # Parse many lines, keeping only stanzas (non-null results) + parser = parsec.fmap + (lib.filter (x: x != null)) + (parsec.many parseLineOrSkip); + + result = parsec.runParser parser cabalFile; + in + if result.type == "success" then + let + # Group stanzas by type, filtering out empty names + groupedStanzas = lib.foldl + (acc: stanza: + let + shouldInclude = stanza.name != ""; + in + if shouldInclude then + acc // { + ${stanza.type} = (acc.${stanza.type} or [ ]) ++ [ stanza.name ]; + } + else + acc) + { } + result.value; + in + { + type = "success"; + value = groupedStanzas; + } + else + result; + # Extract all the executables from a .cabal file parseCabalExecutableNames = cabalFile: - with parsec; let - # Skip empty lines and lines that don't start with 'executable' - skipLines = - skipTill - (sequence [ (skipWhile (x: x != "\n")) anyChar ]) - (parsec.string "executable "); - val = parsec.fmap lib.concatStrings (parsec.many1 (parsec.anyCharBut "\n")); - parser = parsec.many (parsec.skipThen - skipLines - val); + result = parseCabalStanzas cabalFile; in - parsec.runParser parser cabalFile; + if result.type == "success" + then { + type = "success"; + value = result.value.executable or [ ]; + } + else { + type = "error"; + value = result.value; + }; } diff --git a/nix/haskell-parsers/parser_tests.nix b/nix/haskell-parsers/parser_tests.nix index 507fbddf..a400a7f1 100644 --- a/nix/haskell-parsers/parser_tests.nix +++ b/nix/haskell-parsers/parser_tests.nix @@ -53,6 +53,72 @@ let expected = [ "foo-exec" "bar-exec" ]; }; }; + cabalStanzasTests = + let + eval = s: + let + res = parser.parseCabalStanzas s; + in + if res.type == "success" then res.value else res; + in + { + testMultipleStanzas = { + expr = eval '' + cabal-version: 3.0 + name: test-package + version: 0.1 + + library + exposed-modules: Test.Types + + executable foo-exec + main-is: foo.hs + + library extra-lib + exposed-modules: Extra.Types + + test-suite unit-tests + type: exitcode-stdio-1.0 + main-is: test.hs + + executable bar-exec + main-is: bar.hs + + test-suite integration-tests + type: exitcode-stdio-1.0 + main-is: integration.hs + ''; + expected = { + executable = [ "foo-exec" "bar-exec" ]; + library = [ "extra-lib" ]; + test-suite = [ "unit-tests" "integration-tests" ]; + }; + }; + testSingleStanza = { + expr = eval '' + cabal-version: 3.0 + name: test-package + version: 0.1 + + executable my-exe + main-is: main.hs + ''; + expected = { + executable = [ "my-exe" ]; + }; + }; + testEmptyResults = { + expr = eval '' + cabal-version: 3.0 + name: test-package + version: 0.1 + + library + exposed-modules: Test.Types + ''; + expected = { }; + }; + }; # Like lib.runTests, but actually fails if any test fails. runTestsFailing = tests: let @@ -65,4 +131,5 @@ in { "cabal.project" = runTestsFailing cabalProjectTests; "foo-bar.cabal" = runTestsFailing cabalExecutableTests; + "cabal-stanzas" = runTestsFailing cabalStanzasTests; } diff --git a/nix/modules/project/defaults.nix b/nix/modules/project/defaults.nix index 8957873a..c4a9d0ef 100644 --- a/nix/modules/project/defaults.nix +++ b/nix/modules/project/defaults.nix @@ -56,11 +56,9 @@ in localPackages = lib.pipe config.projectRoot [ haskell-parsers.findPackagesInCabalProject (x: config.log.traceDebug "${name}.findPackagesInCabalProject = ${builtins.toJSON x}" x) - (lib.mapAttrs (_: path: { - # The rest of the module options are not defined, because we'll use - # the submodule defaults. - source = path; - })) + (pkgs: lib.listToAttrs (map + (pkg: pkg // { value = { source = pkg.value; }; }) + pkgs)) ]; in lib.optionalAttrs config.defaults.enable localPackages; diff --git a/nix/modules/project/outputs.nix b/nix/modules/project/outputs.nix index 16c70419..ae5ba4cc 100644 --- a/nix/modules/project/outputs.nix +++ b/nix/modules/project/outputs.nix @@ -104,7 +104,7 @@ in else "Executable ${if exe != name then "${exe} from " else "for "}package ${name}"; }) ) - value.cabal.executables + (value.cabal.stanzas.executable or [ ]) ); }; diff --git a/nix/modules/project/packages/package.nix b/nix/modules/project/packages/package.nix index 3046a1f7..38321f12 100644 --- a/nix/modules/project/packages/package.nix +++ b/nix/modules/project/packages/package.nix @@ -33,10 +33,12 @@ in default = "cabal.nix"; }; - cabal.executables = mkOption { - type = types.nullOr (types.listOf types.str); + cabal.stanzas = mkOption { + type = types.nullOr (types.attrsOf (types.listOf types.str)); description = '' - List of executable names found in the cabal file of the package. + Attribute set mapping stanza types to lists of stanza names found in the cabal file. + + For example: { executable = ["foo" "bar"]; library = ["mylib"]; test-suite = ["tests"]; } The value is null if 'source' option is Hackage version. ''; @@ -45,7 +47,7 @@ in haskell-parsers = import ../../../haskell-parsers { inherit pkgs lib; throwError = msg: project.config.log.throwError '' - Unable to determine executable names for package ${name}: + Unable to determine stanzas for package ${name}: ${msg} ''; @@ -54,12 +56,25 @@ in if lib.types.path.check config.source then lib.pipe config.source [ - haskell-parsers.getCabalExecutables - (x: project.config.log.traceDebug "${name}.getCabalExecutables = ${builtins.toString x}" x) + haskell-parsers.getCabalStanzas + (x: project.config.log.traceDebug "${name}.getCabalStanzas = ${builtins.toJSON x}" x) ] else null; # cfg.source is Hackage version; nothing to do. }; + cabal.executables = mkOption { + type = types.nullOr (types.listOf types.str); + description = '' + List of executable names found in the cabal file of the package. + + This is automatically derived from cabal.stanzas.executable. + ''; + default = + if config.cabal.stanzas != null + then config.cabal.stanzas.executable or [ ] + else [ ]; + }; + local.toCurrentProject = mkOption { type = types.bool; description = ''