Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion nix/haskell-parsers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 9 additions & 8 deletions nix/haskell-parsers/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ let
throwError "No .cabal file found under ${path}";
};
in
{
rec {
findPackagesInCabalProject = projectRoot:
let
cabalProjectFile = projectRoot + "/cabal.project";
Expand Down Expand Up @@ -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}";

}
84 changes: 72 additions & 12 deletions nix/haskell-parsers/parser.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
};
}
67 changes: 67 additions & 0 deletions nix/haskell-parsers/parser_tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -65,4 +131,5 @@ in
{
"cabal.project" = runTestsFailing cabalProjectTests;
"foo-bar.cabal" = runTestsFailing cabalExecutableTests;
"cabal-stanzas" = runTestsFailing cabalStanzasTests;
}
8 changes: 3 additions & 5 deletions nix/modules/project/defaults.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion nix/modules/project/outputs.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 [ ])
);
};

Expand Down
27 changes: 21 additions & 6 deletions nix/modules/project/packages/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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.
'';
Expand All @@ -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}
'';
Expand All @@ -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 = ''
Expand Down