Expose cabal executables as flake apps (#137)

Also,

Add a corresponding `outputs.apps` option, while the `outputs.localPackages` option is renamed to `outputs.packages` (it now contains package metadata, including packages and its executables).
This commit is contained in:
Shivaraj B H 2023-04-20 00:30:22 +05:30 committed by GitHub
parent fe3d657ffc
commit 4e8e79b9b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 275 additions and 118 deletions

View File

@ -5,6 +5,7 @@
- #134: Add `autoWire` option to control generation of flake outputs
- #138: Add `checks` to `outputs` submodule
- #143: Changed `autoWire` to be an enum type, for granular controlling of which outputs to autowire.
- #137: Expose cabal executables as flake apps. Add a corresponding `outputs.apps` option, while the `outputs.localPackages` option is renamed to `outputs.packages` (it now contains package metadata, including packages and its executables).
## 0.2.0 (Mar 13, 2023)

View File

@ -35,10 +35,11 @@ When nixifying a Haskell project without flake-parts (thus without haskell-flake
In addition, compared to using plain nixpkgs, haskell-flake supports:
- Auto-detection of local packages based on `cabal.project` file (via [find-haskell-paths](https://github.com/srid/haskell-flake/tree/master/nix/find-haskell-paths))
- Auto-detection of local packages based on `cabal.project` file (via [haskell-parsers](https://github.com/srid/haskell-flake/tree/master/nix/haskell-parsers))
- Support for hpack's `package.yaml`
- Parse executables from `.cabal` file
- Composition of dependency overrides, and other project settings, via [[modules]]
## Next steps
Visit [[guide]] for more details, and [[ref]] for module options. If you are new to Nix, see [[basics]]. See [[howto]] for tangential topics.
Visit [[guide]] for more details, and [[ref]] for module options. If you are new to Nix, see [[basics]]. See [[howto]] for tangential topics.

0
doc/test.sh Normal file → Executable file
View File

30
nix/app-type.nix Normal file
View File

@ -0,0 +1,30 @@
# Taken from https://github.com/hercules-ci/flake-parts/blob/dcc36e45d054d7bb554c9cdab69093debd91a0b5/modules/apps.nix#L11-L41
{ pkgs, lib, ... }:
let
derivationType = lib.types.package // {
check = lib.isDerivation;
};
getExe = x:
"${lib.getBin x}/bin/${x.meta.mainProgram or (throw ''Package ${x.name or ""} does not have meta.mainProgram set, so I don't know how to find the main executable. You can set meta.mainProgram, or pass the full path to executable, e.g. program = "''${pkg}/bin/foo"'')}";
programType = lib.types.coercedTo derivationType getExe lib.types.str;
appType = lib.types.submodule {
options = {
type = lib.mkOption {
type = lib.types.enum [ "app" ];
default = "app";
description = ''
A type tag for `apps` consumers.
'';
};
program = lib.mkOption {
type = programType;
description = ''
A path to an executable or a derivation with `meta.mainProgram`.
'';
};
};
};
in
appType

View File

@ -1,11 +0,0 @@
# `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.
- https://github.com/srid/haskell-flake/issues/113

View File

@ -1,79 +0,0 @@
{ 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

@ -1,5 +1,5 @@
# A flake-parts module for Haskell cabal projects.
{ self, config, lib, flake-parts-lib, withSystem, ... }:
{ self, lib, flake-parts-lib, withSystem, ... }:
let
inherit (flake-parts-lib)
@ -16,6 +16,7 @@ in
perSystem = mkPerSystemOption
({ config, self', inputs', pkgs, system, ... }:
let
appType = import ./app-type.nix { inherit pkgs lib; };
hlsCheckSubmodule = types.submodule {
options = {
enable = mkOption {
@ -125,14 +126,21 @@ in
overrides, on top of `basePackages`.
'';
};
localPackages = mkOption {
type = types.attrsOf types.package;
packages = mkOption {
type = types.attrsOf packageInfoSubmodule;
readOnly = true;
description = ''
The local Haskell packages in the project.
Package information for all local packages. Contains the following keys:
This is a subset of `finalPackages` containing only local
packages excluding everything else.
- `package`: The Haskell package derivation
- `executables`: Attrset of executables found in the .cabal file
'';
};
apps = mkOption {
type = types.attrsOf appType;
readOnly = true;
description = ''
Flake apps for each Cabal executable in the project.
'';
};
devShell = mkOption {
@ -152,6 +160,28 @@ in
};
};
packageInfoSubmodule = types.submodule {
options = {
package = mkOption {
type = types.package;
description = ''
The local package derivation.
'';
};
exes = mkOption {
type = types.attrsOf appType;
description = ''
Attrset of executables from `.cabal` file.
The executables are accessed without any reference to the
Haskell library, using `justStaticExecutables`.
NOTE: Evaluating up to this option will involve IFD.
'';
};
};
};
projectSubmodule = types.submoduleWith {
specialArgs = { inherit pkgs self; };
modules = [
@ -232,7 +262,7 @@ in
'';
default =
let
find-haskell-paths = import ./find-haskell-paths {
haskell-parsers = import ./haskell-parsers {
inherit pkgs lib;
throwError = msg: builtins.throw ''
haskell-flake: A default value for `packages` cannot be auto-determined:
@ -244,8 +274,8 @@ in
};
in
lib.mapAttrs
(_: value: { root = value; })
(find-haskell-paths config.projectRoot);
(_: path: { root = path; })
(haskell-parsers.findPackagesInCabalProject config.projectRoot);
defaultText = lib.literalMD "autodiscovered by reading `self` files.";
};
devShell = mkOption {
@ -265,7 +295,7 @@ in
};
autoWire =
let
outputTypes = [ "packages" "checks" "devShells" ];
outputTypes = [ "packages" "checks" "apps" "devShells" ];
in
mkOption {
type = types.listOf (types.enum outputTypes);
@ -295,22 +325,26 @@ in
let
# Like mapAttrs, but merges the values (also attrsets) of the resulting attrset.
mergeMapAttrs = f: attrs: lib.mkMerge (lib.mapAttrsToList f attrs);
mapKeys = f: attrs: lib.mapAttrs' (n: v: { name = f n; value = v; }) attrs;
contains = k: vs: lib.any (x: x == k) vs;
# Prefix value with the project name (unless
# project is named `default`)
prefixUnlessDefault = projectName: value:
if projectName == "default"
then value
else "${projectName}-${value}";
in
{
packages =
mergeMapAttrs
(name: project:
let
mapKeys = f: attrs: lib.mapAttrs' (n: v: { name = f n; value = v; }) attrs;
# Prefix package names with the project name (unless
# project is named `default`)
dropDefaultPrefix = packageName:
if name == "default"
then packageName
else "${name}-${packageName}";
packages = lib.mapAttrs (_: info: info.package) project.outputs.packages;
in
lib.optionalAttrs (contains "packages" project.autoWire) (mapKeys dropDefaultPrefix project.outputs.localPackages))
lib.optionalAttrs (contains "packages" project.autoWire)
(mapKeys (prefixUnlessDefault name) packages))
config.haskellProjects;
devShells =
mergeMapAttrs
@ -322,7 +356,15 @@ in
checks =
mergeMapAttrs
(name: project:
lib.optionalAttrs (contains "checks" project.autoWire) project.outputs.checks
lib.optionalAttrs (contains "checks" project.autoWire)
project.outputs.checks
)
config.haskellProjects;
apps =
mergeMapAttrs
(name: project:
lib.optionalAttrs (contains "apps" project.autoWire)
(mapKeys (prefixUnlessDefault name) project.outputs.apps)
)
config.haskellProjects;
};

View File

@ -0,0 +1,13 @@
# `haskell-parsers`
`haskell-parsers` provides parsers for Haskell associated files: hpack, cabal and cabal.project. It provides:
- **`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).
- It supports `hpack`, thus works with `package.yaml` even if no `.cabal` file exists.
- **`getCabalExecutables`**: a function to extract executables from a `.cabal` file.
## Limitations
- Glob patterns in `cabal.project` are not supported yet. Feel free to open a PR improving the parser to support them.
- https://github.com/srid/haskell-flake/issues/113

View File

@ -0,0 +1,90 @@
{ 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
{
findPackagesInCabalProject = 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);
getCabalExecutables = path:
let
cabalFile = traversal.findSingleCabalFile path;
res = parser.parseCabalExecutableNames (builtins.readFile (lib.concatStrings [ path "/" cabalFile ]));
in
if res.type == "success"
then res.value
else throwError ("Failed to parse ${cabalFile}: ${builtins.toJSON res}");
}

View File

@ -44,4 +44,20 @@ in
val;
in
parsec.runParser parser packageYamlFile;
# 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);
in
parsec.runParser parser cabalFile;
}

View File

@ -32,6 +32,31 @@ let
expected = "foo";
};
};
cabalExecutableTests =
let
eval = s:
let res = parser.parseCabalExecutableNames s; in
if res.type == "success" then res.value else res;
in
{
testSimple = {
expr = eval ''
cabal-version: 3.0
name: test-package
version: 0.1
executable foo-exec
main-is: foo.hs
library test
exposed-modules: Test.Types
executable bar-exec
main-is: bar.hs
'';
expected = [ "foo-exec" "bar-exec" ];
};
};
# Like lib.runTests, but actually fails if any test fails.
runTestsFailing = tests:
let
@ -44,4 +69,5 @@ in
{
"cabal.project" = runTestsFailing cabalProjectTests;
"package.yaml" = runTestsFailing packageYamlTests;
"foo-bar.cabal" = runTestsFailing cabalExecutableTests;
}

View File

@ -23,12 +23,13 @@ let
${command}
touch $out
'';
haskell-parsers = pkgs.callPackage ./haskell-parsers { };
in
{
config =
let
inherit (config.outputs) finalPackages finalOverlay;
inherit (config.outputs) finalPackages finalOverlay packages;
projectKey = name;
@ -62,6 +63,25 @@ in
++ builtins.attrValues (config.devShell.extraLibraries p);
};
});
buildPackageInfo = name: value: {
package = finalPackages.${name};
exes =
let
exeNames = haskell-parsers.getCabalExecutables value.root;
staticPackage = pkgs.haskell.lib.justStaticExecutables finalPackages.${name};
in
lib.listToAttrs
(map
(exe:
lib.nameValuePair exe ({
program = "${staticPackage}/bin/${exe}";
})
)
exeNames
);
};
hlsCheck =
runCommandInSimulatedShell
devShell
@ -85,9 +105,11 @@ in
finalPackages = config.basePackages.extend finalOverlay;
localPackages = lib.mapAttrs
(name: _: finalPackages."${name}")
config.packages;
packages = lib.mapAttrs buildPackageInfo config.packages;
apps =
lib.mkMerge
(lib.mapAttrsToList (_: packageInfo: packageInfo.exes) packages);
checks = lib.filterAttrs (_: v: v != null) {
"${name}-hls" = if (config.devShell.enable && config.devShell.hlsCheck.enable) then hlsCheck else null;

View File

@ -8,7 +8,7 @@ nix --version
# The test directory must contain a 'test.sh' file that will be run in that
# directory.
TESTS=(
./nix/find-haskell-paths
./nix/haskell-parsers
./example
./test/simple
./test/hpack
@ -25,4 +25,4 @@ do
popd
done
logHeader "All tests passed!"
logHeader "All tests passed!"

View File

@ -57,8 +57,11 @@
'';
};
};
# haskell-flake doesn't set the default package, but you can do it here.
packages.default = self'.packages.haskell-flake-test;
# An explicit app to test `nix run .#test` (*without* falling back to
# using self.packages.test)
apps.app1 = self'.apps.haskell-flake-test;
};
};
}

View File

@ -7,6 +7,9 @@ nix build ${OVERRIDE_ALL}
# Run the devshell test script in a nix develop shell.
logHeader "Testing nix devshell"
nix develop ${OVERRIDE_ALL} -c ./test-in-devshell.sh
# Run the cabal executable as flake app
logHeader "Testing nix flake app (cabal exe)"
nix run ${OVERRIDE_ALL} .#app1
# Test non-devshell features:
# Checks
logHeader "Testing nix flake checks"