From 56d8d9787ad8914bf54c2580a4dbb80adbe38f35 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <3998+srid@users.noreply.github.com> Date: Fri, 10 Mar 2023 09:41:21 -0500 Subject: [PATCH] Autodetect "packages" based on `cabal.project` (and `package.yaml`) (#110) --- CHANGELOG.md | 2 +- nix/find-cabal-paths.nix | 41 ------------- nix/find-haskell-paths/README.md | 10 ++++ nix/find-haskell-paths/default.nix | 79 +++++++++++++++++++++++++ nix/find-haskell-paths/parser.nix | 47 +++++++++++++++ nix/find-haskell-paths/parser_tests.nix | 47 +++++++++++++++ nix/flake-module.nix | 46 +++++++++++--- runtest.sh | 33 +++++++---- 8 files changed, 243 insertions(+), 62 deletions(-) delete mode 100644 nix/find-cabal-paths.nix create mode 100644 nix/find-haskell-paths/README.md create mode 100644 nix/find-haskell-paths/default.nix create mode 100644 nix/find-haskell-paths/parser.nix create mode 100644 nix/find-haskell-paths/parser_tests.nix diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ea492..267b8a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/nix/find-cabal-paths.nix b/nix/find-cabal-paths.nix deleted file mode 100644 index 9c520fe..0000000 --- a/nix/find-cabal-paths.nix +++ /dev/null @@ -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 - diff --git a/nix/find-haskell-paths/README.md b/nix/find-haskell-paths/README.md new file mode 100644 index 0000000..7d342f2 --- /dev/null +++ b/nix/find-haskell-paths/README.md @@ -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. diff --git a/nix/find-haskell-paths/default.nix b/nix/find-haskell-paths/default.nix new file mode 100644 index 0000000..0714e54 --- /dev/null +++ b/nix/find-haskell-paths/default.nix @@ -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) diff --git a/nix/find-haskell-paths/parser.nix b/nix/find-haskell-paths/parser.nix new file mode 100644 index 0000000..6aba85c --- /dev/null +++ b/nix/find-haskell-paths/parser.nix @@ -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; +} diff --git a/nix/find-haskell-paths/parser_tests.nix b/nix/find-haskell-paths/parser_tests.nix new file mode 100644 index 0000000..3998e6f --- /dev/null +++ b/nix/find-haskell-paths/parser_tests.nix @@ -0,0 +1,47 @@ +{ pkgs ? import { }, 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; +} diff --git a/nix/flake-module.nix b/nix/flake-module.nix index 79bc557..be3204b 100644 --- a/nix/flake-module.nix +++ b/nix/flake-module.nix @@ -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 diff --git a/runtest.sh b/runtest.sh index 8bc651c..86d64e9 100755 --- a/runtest.sh +++ b/runtest.sh @@ -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!" \ No newline at end of file