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

This commit is contained in:
Sridhar Ratnakumar 2023-03-10 09:41:21 -05:00 committed by GitHub
parent da1d8bbda4
commit 56d8d9787a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 243 additions and 62 deletions

View File

@ -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.

View File

@ -1,41 +0,0 @@
{ lib, ... }:
self:
# We look for a single *.cabal in project root as well as
# multiple */*.cabal. Otherwise, error out.
#
# In future, we could just read `cabal.project`. See #76.
let
# Like pkgs.haskell.lib.haskellPathsInDir' but with a few differences
# - Allows top-level .cabal files
haskellPathsInDir' = path:
lib.filterAttrs (k: v: v != null) (lib.mapAttrs'
(k: v:
if v == "regular" && lib.strings.hasSuffix ".cabal" k
then lib.nameValuePair (lib.strings.removeSuffix ".cabal" k) path
else
if v == "directory" && builtins.pathExists (path + "/${k}/${k}.cabal")
then lib.nameValuePair k (path + "/${k}")
else lib.nameValuePair k null
)
(builtins.readDir path));
errorNoDefault = msg:
builtins.throw ''
haskell-flake: A default value for `packages` cannot be auto-detected:
${msg}
You must manually specify the `packages` option.
'';
cabalPaths =
let
cabalPaths = haskellPathsInDir' self;
in
if cabalPaths == { }
then
errorNoDefault ''
No .cabal file found in project root or its sub-directories.
''
else cabalPaths;
in
cabalPaths

View File

@ -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.

View File

@ -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)

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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.
'';
};
};
};
@ -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 = ''
@ -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.
Autodiscovered by default by looking for `.cabal` files in
top-level or sub-directories.
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.
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 {
@ -230,7 +260,7 @@ in
};
}
})
];
};
in

View File

@ -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!"