diff --git a/CHANGELOG.md b/CHANGELOG.md index f254851..f7348a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/doc/start.md b/doc/start.md index 8f8e888..c10369a 100644 --- a/doc/start.md +++ b/doc/start.md @@ -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. \ No newline at end of file +Visit [[guide]] for more details, and [[ref]] for module options. If you are new to Nix, see [[basics]]. See [[howto]] for tangential topics. diff --git a/doc/test.sh b/doc/test.sh old mode 100644 new mode 100755 diff --git a/nix/app-type.nix b/nix/app-type.nix new file mode 100644 index 0000000..b1ebcdd --- /dev/null +++ b/nix/app-type.nix @@ -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 diff --git a/nix/find-haskell-paths/README.md b/nix/find-haskell-paths/README.md deleted file mode 100644 index 3cd7073..0000000 --- a/nix/find-haskell-paths/README.md +++ /dev/null @@ -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 diff --git a/nix/find-haskell-paths/default.nix b/nix/find-haskell-paths/default.nix deleted file mode 100644 index 0714e54..0000000 --- a/nix/find-haskell-paths/default.nix +++ /dev/null @@ -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) diff --git a/nix/flake-module.nix b/nix/flake-module.nix index d533b26..5c786b4 100644 --- a/nix/flake-module.nix +++ b/nix/flake-module.nix @@ -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; }; diff --git a/nix/haskell-parsers/README.md b/nix/haskell-parsers/README.md new file mode 100644 index 0000000..95367ff --- /dev/null +++ b/nix/haskell-parsers/README.md @@ -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 diff --git a/nix/haskell-parsers/default.nix b/nix/haskell-parsers/default.nix new file mode 100644 index 0000000..b1dc6b3 --- /dev/null +++ b/nix/haskell-parsers/default.nix @@ -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}"); +} diff --git a/nix/find-haskell-paths/parser.nix b/nix/haskell-parsers/parser.nix similarity index 73% rename from nix/find-haskell-paths/parser.nix rename to nix/haskell-parsers/parser.nix index 6aba85c..121da28 100644 --- a/nix/find-haskell-paths/parser.nix +++ b/nix/haskell-parsers/parser.nix @@ -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; } diff --git a/nix/find-haskell-paths/parser_tests.nix b/nix/haskell-parsers/parser_tests.nix similarity index 64% rename from nix/find-haskell-paths/parser_tests.nix rename to nix/haskell-parsers/parser_tests.nix index 3998e6f..06c2bd7 100644 --- a/nix/find-haskell-paths/parser_tests.nix +++ b/nix/haskell-parsers/parser_tests.nix @@ -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; } diff --git a/nix/find-haskell-paths/test.sh b/nix/haskell-parsers/test.sh similarity index 100% rename from nix/find-haskell-paths/test.sh rename to nix/haskell-parsers/test.sh diff --git a/nix/haskell-project.nix b/nix/haskell-project.nix index c8c2577..efd6ea6 100644 --- a/nix/haskell-project.nix +++ b/nix/haskell-project.nix @@ -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; diff --git a/runtest.sh b/runtest.sh index 41bad38..47a33d7 100755 --- a/runtest.sh +++ b/runtest.sh @@ -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!" \ No newline at end of file +logHeader "All tests passed!" diff --git a/test/simple/flake.nix b/test/simple/flake.nix index 826d40d..e3426f2 100644 --- a/test/simple/flake.nix +++ b/test/simple/flake.nix @@ -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; }; }; } diff --git a/test/simple/test.sh b/test/simple/test.sh index 5298897..a2d1bc7 100755 --- a/test/simple/test.sh +++ b/test/simple/test.sh @@ -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"