Skip to content

Autodetect "packages" based on cabal.project (and package.yaml) #110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Mar 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- New features
- #63: Add `config.haskellProjects.${name}.outputs` containing all flake outputs for that project.
- #102 In addition, `outputs` contains `finalPackages` and `localPackages`.
- #49 & #91: The `packages` option now autodiscovers the top-level `.cabal` file (in addition to looking inside sub-directories) as its default value.
- #49 & #91 & #110: The `packages` option now autodiscovers the top-level `.cabal` file, `package.yaml` or it discovers multiple packages if they are specified in the `cabal.project` file.
- #69: The default flake template creates `flake.nix` only, while the `#example` one creates the full Haskell project template.
- #92: Add `devShell.mkShellArgs` to pass custom arguments to `mkShell`
- #111: Add `devShell.extraLibraries` to add custom Haskell libraries to the devshell.
Expand Down
41 changes: 0 additions & 41 deletions nix/find-cabal-paths.nix

This file was deleted.

10 changes: 10 additions & 0 deletions nix/find-haskell-paths/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# `find-haskell-paths`

`find-haskell-paths` is 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).
- It supports `hpack`, thus works with `package.yaml` even if no `.cabal` file exists.

## Limitations

- Glob patterns in `cabal.project` are not supported yet. Feel free to open a PR improving the parser to support them.
79 changes: 79 additions & 0 deletions nix/find-haskell-paths/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{ pkgs
, lib
, throwError ? msg: builtins.throw msg
, ...
}:

let
parser = import ./parser.nix { inherit pkgs lib; };
traversal = rec {
findSingleCabalFile = path:
let
cabalFiles = lib.filter (lib.strings.hasSuffix ".cabal") (builtins.attrNames (builtins.readDir path));
num = builtins.length cabalFiles;
in
if num == 0
then null
else if num == 1
then builtins.head cabalFiles
else throwError "Expected a single .cabal file, but found multiple: ${builtins.toJSON cabalFiles}";
findSinglePackageYamlFile = path:
let f = path + "/package.yaml";
in if builtins.pathExists f then f else null;
getCabalName = cabalFile:
lib.strings.removeSuffix ".cabal" cabalFile;
getPackageYamlName = fp:
let
name = parser.parsePackageYamlName (builtins.readFile fp);
in
if name.type == "success"
then name.value
else throwError ("Failed to parse ${fp}: ${builtins.toJSON name}");
findHaskellPackageNameOfDirectory = path:
let
cabalFile = findSingleCabalFile path;
packageYamlFile = findSinglePackageYamlFile path;
in
if cabalFile != null
then
getCabalName cabalFile
else if packageYamlFile != null
then
getPackageYamlName packageYamlFile
else
throwError "Neither a .cabal file nor a package.yaml found under ${path}";
};
in
projectRoot:
let
cabalProjectFile = projectRoot + "/cabal.project";
packageDirs =
if builtins.pathExists cabalProjectFile
then
let
res = parser.parseCabalProjectPackages (builtins.readFile cabalProjectFile);
isSelfPath = path:
path == "." || path == "./" || path == "./.";
in
if res.type == "success"
then
map
(path:
if isSelfPath path
then projectRoot
else if lib.strings.hasInfix "*" path
then throwError "Found a path with glob (${path}) in ${cabalProjectFile}, which is not supported"
else if lib.strings.hasSuffix ".cabal" path
then throwError "Expected a directory but ${path} (in ${cabalProjectFile}) is a .cabal filepath"
else "${projectRoot}/${path}"
)
res.value
else throwError ("Failed to parse ${cabalProjectFile}: ${builtins.toJSON res}")
else
[ projectRoot ];
in
lib.listToAttrs
(map
(path:
lib.nameValuePair (traversal.findHaskellPackageNameOfDirectory path) path)
packageDirs)
47 changes: 47 additions & 0 deletions nix/find-haskell-paths/parser.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Sufficiently basic parsers for `cabal.project` and `package.yaml` formats
#
# "sufficiently" because we care only about 'packages' from `cabal.project` and
# 'name' from `package.yaml`.
{ pkgs, lib, ... }:

let
nix-parsec = builtins.fetchGit {
url = "https://github.com/kanwren/nix-parsec.git";
ref = "master";
rev = "1bf25dd9c5de1257a1c67de3c81c96d05e8beb5e";
shallow = true;
};
inherit (import nix-parsec) parsec;
in
{
# Extract the "packages" list from a cabal.project file.
#
# Globs are not supported yet. Values must be refer to a directory, not file.
parseCabalProjectPackages = cabalProjectFile:
let
spaces1 = parsec.skipWhile1 (c: c == " " || c == "\t");
newline = parsec.string "\n";
path = parsec.fmap lib.concatStrings (parsec.many1 (parsec.anyCharBut "\n"));
key = parsec.string "packages:\n";
val =
(parsec.many1
(parsec.between spaces1 newline path)
);
parser = parsec.skipThen
key
val;
in
parsec.runParser parser cabalProjectFile;

# Extract the "name" field from a package.yaml file.
parsePackageYamlName = packageYamlFile:
let
spaces1 = parsec.skipWhile1 (c: c == " " || c == "\t");
key = parsec.string "name:";
val = parsec.fmap lib.concatStrings (parsec.many1 (parsec.anyCharBut "\n"));
parser = parsec.skipThen
(parsec.skipThen key spaces1)
val;
in
parsec.runParser parser packageYamlFile;
}
47 changes: 47 additions & 0 deletions nix/find-haskell-paths/parser_tests.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{ pkgs ? import <nixpkgs> { }, lib ? pkgs.lib, ... }:

let
parser = pkgs.callPackage ./parser.nix { };
cabalProjectTests =
let
eval = s:
let res = parser.parseCabalProjectPackages s; in
if res.type == "success" then res.value else res;
in
{
testSimple = {
expr = eval ''
packages:
foo
bar
'';
expected = [ "foo" "bar" ];
};
};
packageYamlTests =
let
eval = s:
let res = parser.parsePackageYamlName s; in
if res.type == "success" then res.value else res;
in
{
testSimple = {
expr = eval ''
name: foo
'';
expected = "foo";
};
};
# Like lib.runTests, but actually fails if any test fails.
runTestsFailing = tests:
let
res = lib.runTests tests;
in
if res == builtins.trace "All tests passed" [ ]
then res
else builtins.throw "Some tests failed: ${builtins.toJSON res}" res;
in
{
"cabal.project" = runTestsFailing cabalProjectTests;
"package.yaml" = runTestsFailing packageYamlTests;
}
46 changes: 38 additions & 8 deletions nix/flake-module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ in
options = {
root = mkOption {
type = path;
description = "Path to Haskell package where the `.cabal` file lives";
description = ''
The directory path under which the Haskell package's `.cabal`
file or `package.yaml` resides.
'';
};
};
};
Expand Down Expand Up @@ -147,8 +150,19 @@ in
specialArgs = { inherit pkgs self; };
modules = [
./haskell-project.nix
{
({ config, ... }: {
options = {
projectRoot = mkOption {
type = types.path;
description = ''
Path to the root of the project directory.

Chaning this affects certain functionality, like where to
look for the 'cabal.project' file.
'';
default = self;
defaultText = "Top-level directory of the flake";
};
basePackages = mkOption {
type = types.attrsOf raw;
description = ''
Expand Down Expand Up @@ -199,17 +213,33 @@ in
packages = mkOption {
type = types.lazyAttrsOf packageSubmodule;
description = ''
Attrset of local packages in the project repository.
Set of local packages in the project repository.

If you have a `cabal.project` file (under `projectRoot`),
those packages are automatically discovered. Otherwise, a
top-level .cabal or package.yaml file is used to discover
the single local project.

Autodiscovered by default by looking for `.cabal` files in
top-level or sub-directories.
haskell-flake currently supports a limited range of syntax
for `cabal.project`. Specifically it requires an explicit
list of package directories under the "packages" option.
'';
default =
let find-cabal-paths = import ./find-cabal-paths.nix { inherit lib; };
let
find-haskell-paths = import ./find-haskell-paths {
inherit pkgs lib;
throwError = msg: builtins.throw ''
haskell-flake: A default value for `packages` cannot be auto-determined:

${msg}

Please specify the `packages` option manually or change your project configuration.
'';
};
in
lib.mapAttrs
(_: value: { root = value; })
(find-cabal-paths self);
(find-haskell-paths config.projectRoot);
defaultText = lib.literalMD "autodiscovered by reading `self` files.";
};
devShell = mkOption {
Expand All @@ -230,7 +260,7 @@ in


};
}
})
];
};
in
Expand Down
33 changes: 21 additions & 12 deletions runtest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,41 @@ else
}
fi


# Waiting on github.com/nixbuild/nix-quick-install-action to support 2.13+
# We use newer Nix for:
# - https://github.com/NixOS/nix/issues/7263
# - https://github.com/NixOS/nix/issues/7026
NIX="nix run github:nixos/nix/2.14.1 --"
${NIX} --version

# Before anything, run the main haskell-flake tests
logHeader "Testing find-haskell-paths' parser"
${NIX} eval -I nixpkgs=flake:github:nixos/nixpkgs/bb31220cca6d044baa6dc2715b07497a2a7c4bc7 \
--impure --expr 'import ./nix/find-haskell-paths/parser_tests.nix {}'


FLAKE=$(pwd)

# A Nix bug causes incorrect self when in a sub-flake.
# https://github.com/NixOS/nix/issues/7263
# Workaround: copy ./test somewhere outside of this Git repo.
TESTDIR=$(mktemp -d)
trap 'rm -fr "$TESTDIR"' EXIT
cp -r ./test/* "$TESTDIR"
cd "$TESTDIR"
pwd
pushd ./test

# First, build the flake
logHeader "Testing nix build"
nix build --override-input haskell-flake path:${FLAKE}
${NIX} build --override-input haskell-flake path:${FLAKE}
# Run the devshell test script in a nix develop shell.
logHeader "Testing nix devshell"
nix develop --override-input haskell-flake path:${FLAKE} -c ./test.sh
${NIX} develop --override-input haskell-flake path:${FLAKE} -c ./test.sh
# Test non-devshell features:
# Checks
logHeader "Testing nix flake checks"
nix --option sandbox false \
${NIX} --option sandbox false \
build --override-input haskell-flake path:${FLAKE} -L .#check

popd

logHeader "Testing docs"
nix build --override-input haskell-flake path:${FLAKE} \
--option log-lines 1000 --show-trace \
github:hercules-ci/flake.parts-website#checks.${SYSTEM}.linkcheck
"github:hercules-ci/flake.parts-website#checks.${SYSTEM}.linkcheck"

logHeader "All tests passed!"