diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..93c8e83
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,19 @@
+---
+name: Bug report
+about: A gem fails to build or load
+---
+
+**What happened?**
+(Paste the error message)
+
+**Gemfile.lock** (attach or paste the relevant section)
+
+**System:** (e.g., aarch64-darwin, x86_64-linux)
+
+**Bundler version:** (output of `bundle --version`)
+
+**PLATFORMS section of your Gemfile.lock:**
+(paste it -- this is often the key to diagnosing platform issues)
+
+**Did you run `bundle lock --add-platform`?**
+(yes/no, and which platforms)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6cfdace
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,22 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ flake-check:
+ name: nix flake check (x86_64-linux)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Nix
+ uses: DeterminateSystems/nix-installer-action@main
+
+ - name: Enable Magic Nix Cache
+ uses: DeterminateSystems/magic-nix-cache-action@main
+
+ - name: nix flake check
+ run: nix flake check --print-build-logs
diff --git a/.gitignore b/.gitignore
index 6c80ffb..8ea880f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
.direnv
result
result-*
+tmp
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..dbaf5c9
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,60 @@
+# Architecture
+
+## Pipeline
+
+```mermaid
+flowchart TD
+ input([Gemfile + Gemfile.lock])
+ parse["parse.nix — pure Nix
reads lockfile text
emits { gemName, version, platform, source, sha256, groups }"]
+ resolve["resolve.nix — pure Nix
filters by group and platform
expands transitive deps
picks one variant per name"]
+ build["default.nix — nixpkgs
applies gemConfig
calls buildRubyGem
combines into buildEnv"]
+ output([Derivation with all gems on GEM_PATH])
+
+ input --> parse --> resolve --> build --> output
+```
+
+## Key Design Decisions
+
+1. Parse the lockfile in pure Nix (no bundix, no gemset.nix). Group extraction
+ uses a Ruby IFD because Gemfile semantics are Bundler's domain.
+2. Prefer precompiled native gems over source compilation.
+3. Only apply gemConfig overrides to ruby-platform gems (precompiled gems
+ should not need source build patches).
+4. Delegate to nixpkgs' `buildRubyGem` and `defaultGemConfig` rather than
+ maintaining custom builders.
+
+## What We Use from Nixpkgs
+
+- `buildRubyGem` -- builds individual gem derivations
+- `defaultGemConfig` -- per-gem build overrides (nokogiri, grpc, etc.)
+- `buildEnv` -- combines gem derivations into a single environment path
+
+We reimplement lockfile parsing, group filtering, and platform resolution
+because upstream assumes a `gemset.nix` generated by bundix. We parse the
+lockfile directly.
+
+## File Map
+
+```
+lib/gemfile-env/
+ default.nix Orchestrator. Chains parse -> resolve -> build.
+ parse.nix Lockfile parsing. Pure Nix, takes { lib }.
+ resolve.nix Filtering and resolution. Pure Nix, takes { lib }.
+ parse-gemfile-and-lockfile.nix IO shell: readFile, runCommand, calls parse.nix.
+ gem-configs.nix Local per-gem build overrides.
+ gem-groups.rb Ruby IFD script for Gemfile group extraction.
+
+test/
+ helpers.nix Shared assertEq, assertThrows, fixtures.
+ unit/test-parse-logic.nix Unit tests for parse.nix.
+ unit/test-resolve-logic.nix Unit tests for resolve.nix.
+ unit/test-pipeline-logic.nix End-to-end parse + resolve tests.
+ unit/test-{parse,resolve,pipeline}.nix Wrappers that import logic + lib.
+ fixtures/lockfiles/ Lockfile corpus for testing.
+ integration/ Integration tests with real gem builds.
+
+examples/
+ simple/ Pure-ruby gems (rack, rake).
+ medium/ Native gems (nokogiri, puma, ethon).
+ complex/ Rails 8, git/path sources.
+```
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..ef811f2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,116 @@
+# Contributing to gems4nix
+
+## Architecture Overview
+
+gems4nix is a three-stage pipeline: **parse** the lockfile, **resolve** gems
+for the target system, and **build** derivations via nixpkgs' `buildRubyGem`.
+All parsing and resolution logic is pure Nix (no IO, no nixpkgs deps), making
+it directly testable with `nix eval`. See [ARCHITECTURE.md](ARCHITECTURE.md)
+for the full pipeline diagram and file map.
+
+## How to Run Tests
+
+```sh
+# All checks (unit + integration) via flake
+nix flake check
+
+# Unit tests individually (returns true or throws on failure)
+nix eval --file test/unit/test-parse.nix --json
+nix eval --file test/unit/test-resolve.nix --json
+nix eval --file test/unit/test-pipeline.nix --json
+
+# Integration examples
+cd examples/simple && nix flake check --no-write-lock-file
+cd examples/medium && nix flake check --no-write-lock-file
+cd examples/complex && nix flake check --no-write-lock-file
+
+# Everything at once
+nix eval --file test/unit/test-parse.nix --json && \
+nix eval --file test/unit/test-resolve.nix --json && \
+nix eval --file test/unit/test-pipeline.nix --json && \
+echo "unit tests passed" && \
+for ex in simple medium complex; do
+ (cd examples/$ex && nix flake check --no-write-lock-file) || exit 1
+done && \
+echo "all tests passed"
+```
+
+## How to Add a Test
+
+1. Identify which layer the test belongs to:
+ - **Parsing** (lockfile text to gem attrs) -- `test/unit/test-parse-logic.nix`
+ - **Resolution** (filtering, platform matching) -- `test/unit/test-resolve-logic.nix`
+ - **Pipeline** (end-to-end parse + resolve) -- `test/unit/test-pipeline-logic.nix`
+
+2. Add a test case. Each test is an `assertEq` call with a descriptive name:
+ ```nix
+ (assertEq "test_my_new_case"
+ (someFunction someInput)
+ expectedOutput)
+ ```
+
+3. Add it to the `allTests` list at the bottom of the file.
+
+4. Run the test:
+ ```sh
+ nix eval --file test/unit/test-parse.nix --json
+ ```
+ It returns `true` on success or throws with the test name and diff on failure.
+
+## How to Contribute a Lockfile Fixture (Bug Reports)
+
+If you have a `Gemfile.lock` that causes a build failure or incorrect behavior:
+
+1. Add your `Gemfile.lock` to `test/fixtures/lockfiles/` with a descriptive
+ name (e.g., `nokogiri-ruby-only.lock`).
+2. Add a unit test in the appropriate `test-*-logic.nix` file that parses or
+ resolves against your lockfile content and demonstrates the bug.
+3. Open a PR or issue with both files.
+
+Minimal lockfile excerpts are preferred over full lockfiles. Include at least
+the `GEM`, `PLATFORMS`, and `CHECKSUMS` sections relevant to the bug.
+
+## How to Add a Gem Config Override
+
+Gem config overrides live in `lib/gemfile-env/gem-configs.nix`. Each entry is a
+function that receives the gem's attributes and returns an attrset to merge:
+
+```nix
+{
+ my-gem = attrs: {
+ buildInputs = [ some-package ];
+ NIX_CFLAGS_COMPILE = "-I${some-package}/include";
+ };
+}
+```
+
+Notes:
+- Gem configs are only applied to ruby-platform gems (precompiled native gems
+ skip config, since they do not need source build overrides).
+- Check `nixpkgs.defaultGemConfig` first -- it may already handle your gem.
+- Our local overrides in `gem-configs.nix` layer on top of `defaultGemConfig`.
+
+## Test Philosophy
+
+Tests follow a **red-green-refactor** loop:
+
+1. **Red:** Write a test that captures expected behavior. Run it. Watch it fail
+ (or verify it passes if testing existing correct behavior).
+2. **Green:** Make the minimal change to pass the test.
+3. **Refactor:** With passing tests as a safety net, clean up the code. Re-run
+ tests to confirm nothing broke.
+
+Key principles:
+- Unit tests are pure Nix evaluations -- no network, no builds, no IO.
+- Unit tests use synthetic lockfile content (inline strings), not real files.
+- Integration tests (`examples/`) use real `Gemfile.lock` files from rubygems.org
+ and build actual gem derivations.
+- Shared assertion helpers (`assertEq`, `assertThrows`) live in `test/helpers.nix`.
+
+## Code Style
+
+- Format with `nixfmt`.
+- Pure logic files (`parse.nix`, `resolve.nix`) take only `{ lib }:` as input
+ for testability. No nixpkgs build dependencies.
+- IO (file reads, IFD) is isolated in `parse-gemfile-and-lockfile.nix`.
+- Comments explain "why", not "what".
diff --git a/Readme.md b/Readme.md
index d3eab49..ec7ab34 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,111 +1,193 @@
-Bundler 2.6 shipped with the ability to write checksums into its lockfile. That means for apps using Bundler >= 2.6 we no longer need a standalone tool to fetch gems and hash them. Instead we can parse the Gemfile and Gemfile.lock directly from Nix, which is what you're looking at here.
+# gems4nix
-Along the way we're paying special attention to multi-platform support for Ruby gems. This had been problematic in Bundix, and solutions seem to be scattered across PRs in various states of languished. We may as well get that sorted out here as well, because I want to use Sorbet, and stop worrying about cross platform gems in general.
+Bundle Ruby gems into a Nix environment using Bundler checksums from
+`Gemfile.lock` -- no `bundix` or `gemset.nix` needed.
-This project does make use of existing Nixpkgs abstractions as much as possible to avoid reimplementing work that doesn't need to be reimplemented. Notably, `buildRubyGem`. That lets us focus the scope, avoid rabbit holes, and generally derisk things.
+## Quick Start
-Quick reference:
+Add gems4nix to your flake inputs, apply the overlay, and call `gemfileEnv`:
```nix
-gemEnv {
- name = "test-gem-env";
- gemfile = ./Gemfile;
- gemfileLock = ./Gemfile.lock;
-};
+{
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs?ref=24.11";
+ gems4nix = {
+ url = "github:omc/gems4nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs = { nixpkgs, gems4nix, ... }:
+ let
+ pkgs = import nixpkgs {
+ system = "aarch64-darwin"; # or your system
+ overlays = [ gems4nix.overlays.default ];
+ };
+ gems = pkgs.gemfileEnv {
+ name = "my-app-gems";
+ gemfile = ./Gemfile;
+ gemfileLock = ./Gemfile.lock;
+ };
+ in {
+ # Use `gems` in buildInputs, set GEM_PATH, etc.
+ };
+}
+```
+
+What it does:
+
+- Parses `Gemfile.lock` (including checksums) in pure Nix
+- Resolves platform-specific gem variants for your system (prefers precompiled
+ native gems over source compilation)
+- Builds each gem with nixpkgs' `buildRubyGem` and combines them into a
+ `buildEnv`
+
+## Prerequisites
+
+- **Bundler >= 2.5** -- older versions do not write `CHECKSUMS` into
+ `Gemfile.lock`, which gems4nix requires.
+- **Platform entries in your lockfile** -- run `bundle lock --add-platform` to
+ add precompiled native gem variants for your target systems.
+
+Which platforms to add for which Nix system:
+
+| Nix system | `bundle lock --add-platform ...` |
+|---|---|
+| `aarch64-darwin` | `arm64-darwin` |
+| `x86_64-darwin` | `x86_64-darwin` |
+| `aarch64-linux` | `aarch64-linux aarch64-linux-gnu aarch64-linux-musl` |
+| `x86_64-linux` | `x86_64-linux x86_64-linux-gnu x86_64-linux-musl` |
+
+To cover all four systems at once:
+
+```sh
+bundle lock \
+ --add-platform arm64-darwin x86_64-darwin \
+ aarch64-linux aarch64-linux-gnu aarch64-linux-musl \
+ x86_64-linux x86_64-linux-gnu x86_64-linux-musl
```
-Platforms are auto-detected from `stdenv.hostPlatform.system`. The mapping covers `aarch64-darwin`, `x86_64-darwin`, `aarch64-linux`, and `x86_64-linux`, including musl and universal-darwin variants. You can override with an explicit `platforms` list if needed.
+## Common Errors and Solutions
+
+**"gems4nix: cannot find CHECKSUMS section in lockfile"**
+Your lockfile was generated by Bundler < 2.6 and lacks checksums. Upgrade
+Bundler and regenerate:
+```sh
+gem install bundler
+bundle lock
+```
-You can also provide `groups` to filter gems:
+**"Could not find 'mini_portile2'" (or similar build-time dep)**
+Your lockfile only has the `ruby` platform, so nokogiri (or similar) is being
+compiled from source and needs build-time dependencies that were filtered out.
+Add platform entries so the precompiled variant is used instead:
+```sh
+bundle lock --add-platform arm64-darwin # (or your platform)
+```
+**"gems4nix: unsupported system '...'"**
+The automatic platform detection does not recognize your
+`stdenv.hostPlatform.system`. Pass an explicit `platforms` list:
```nix
-gemEnv {
- name = "gems-prod";
- gemfile = ./Gemfile;
- gemfileLock = ./Gemfile.lock;
- groups = [ "default" "production" ];
- # platforms auto-detected; override if needed:
- # platforms = [ "ruby" "arm64-darwin" "universal-darwin" ];
-}
+gemfileEnv {
+ # ...
+ platforms = [ "ruby" "arm64-darwin" "universal-darwin" ];
+};
```
+**Build fails for a specific gem**
+Some gems need extra build inputs or patches. Check whether
+`nixpkgs.defaultGemConfig` already has an override for that gem. If not, supply
+one via `gemConfig`:
+```nix
+gemfileEnv {
+ # ...
+ gemConfig = pkgs.defaultGemConfig // {
+ my-gem = attrs: {
+ buildInputs = [ pkgs.openssl ];
+ };
+ };
+};
+```
+
+## How It Works
+
+The pipeline has three stages:
+
+1. **Parse** (`parse.nix`) -- reads `Gemfile.lock` in pure Nix and produces a
+ list of gem attribute sets with name, version, platform, source URL, and
+ SHA256 from the CHECKSUMS section.
-## How platform resolution works
+2. **Resolve** (`resolve.nix`) -- filters gems by requested groups and target
+ platforms, expands transitive dependencies, and resolves each gem name to
+ exactly one variant (preferring precompiled native over ruby-platform).
+
+3. **Build** (`default.nix`) -- applies `gemConfig` overrides (only to
+ ruby-platform gems), calls `buildRubyGem` for each resolved gem, and
+ combines them into a `buildEnv`.
+
+### Platform resolution
Many gems ship precompiled native variants alongside a pure-ruby fallback.
-The lockfile's CHECKSUMS section lists all of them:
+The lockfile CHECKSUMS section lists all of them:
```
-nokogiri (1.18.8) sha256=8c7464... # pure ruby, compiles libxml2 from source
+nokogiri (1.18.8) sha256=8c7464... # pure ruby
nokogiri (1.18.8-arm64-darwin) sha256=483b... # precompiled for Apple Silicon
nokogiri (1.18.8-x86_64-linux-gnu) sha256=4a7... # precompiled for x86 Linux
```
gems4nix narrows this down in three steps, matching what `bundle install` does:
-1. **Filter by platform.** Keep only variants whose platform string is in the
- accepted set for this system. On `aarch64-darwin` that's
- `["ruby" "arm64-darwin" "universal-darwin"]`. This discards
- `x86_64-linux-gnu` etc.
+1. **Filter by platform.** Keep only variants whose platform is in the accepted
+ set for this system. On `aarch64-darwin` that is
+ `["ruby" "arm64-darwin" "universal-darwin"]`.
2. **Prefer native over ruby.** If both `arm64-darwin` and `ruby` variants
- survive the filter, pick the native one. Native gems are precompiled, which
- means less compile time and complexity, and maybe better cache behavior.
+ survive, pick the native one. This avoids source compilation.
3. **One gem per name.** After resolution each gem name maps to exactly one
derivation.
-`ffi` works the same way, its native variants avoid compiling libffi:
-
-```
-ffi (1.17.2) # needs libffi headers + C compiler
-ffi (1.17.2-arm64-darwin) # precompiled, no build deps
-```
-
-The `PLATFORMS` section of the lockfile tells Bundler which platforms to
-resolve for. It may list platforms like `universal-darwin` that no gem
-actually ships a variant for. That's fine, those simply match nothing and
-the `ruby` fallback is used.
+## Configuration
-### Preference ranking
+`gemfileEnv` accepts these parameters:
-`resolvePlatforms` accepts a preference-ordered platform list (from
-`platformsForSystem`) and ranks candidates by position. On `aarch64-darwin`
-the order is `["ruby" "arm64-darwin" "universal-darwin"]`, so an exact
-arch match always beats a compatible one, and any native variant beats
-pure ruby.
+| Parameter | Default | Description |
+|---|---|---|
+| `name` | (required) | Name for the resulting derivation |
+| `gemfile` | (required) | Path to `Gemfile` |
+| `gemfileLock` | (required) | Path to `Gemfile.lock` |
+| `groups` | `["default" "development" "production" "test"]` | Which Bundler groups to include |
+| `platforms` | auto-detected from `stdenv` | List of Bundler platform strings |
+| `gemGroups` | auto-detected via `gem-groups.rb` | Attrset of `{ gemName = [ "group1" ... ]; }` to override group detection |
+| `gemConfig` | `nixpkgs.defaultGemConfig` | Per-gem build overrides |
+| `ruby` | `nixpkgs.ruby` | Ruby derivation to build against |
-## Testing
+### Group filtering example
-Unit tests for the parser and filter helpers:
-
-```sh
-nix eval --file test/unit/test-parser.nix --json
-nix eval --file test/unit/test-filter.nix --json
-```
-
-Integration tests via `examples/`:
-
-```sh
-cd examples/simple && nix flake check --no-write-lock-file
-cd examples/medium && nix flake check --no-write-lock-file
-cd examples/complex && nix flake check --no-write-lock-file
+```nix
+gemfileEnv {
+ name = "prod-gems";
+ gemfile = ./Gemfile;
+ gemfileLock = ./Gemfile.lock;
+ groups = [ "default" "production" ];
+};
```
-| Example | Gems | What it tests |
-|-----------|------|---------------|
-| `simple` | rack, rake | Pure-ruby gems load and report correct versions |
-| `medium` | nokogiri, puma, ethon, rack, minitest | Native platform variants, group filtering, `defaultGemConfig` overrides |
-| `complex` | Rails 8, ffi, nokogiri, bootsnap, errgonomic (git), hello_gem (path) | Full Rails env, git/path sources (SKIP until TODO #13) |
-
-See `TESTING.md` for the full test strategy, structure, and red-green-refactor workflow.
+Group extraction uses a Ruby IFD (`gem-groups.rb`) by default. To avoid IFD,
+pass `gemGroups` explicitly.
-## WIP
+## Contributing
-This is a few days of coding. It's being used in prod but for a specific Rails app and its gems that gets daily attention from a team. There is probably more generalized usage to take into account and collect into unit tests. Still, in general, the hard parts are already solved in nixpkgs, this is just an alternate route to collecting the relevant attributes for each gem.
+See [CONTRIBUTING.md](CONTRIBUTING.md) for how to run tests, add fixtures, and
+contribute code. See [ARCHITECTURE.md](ARCHITECTURE.md) for a one-page project
+map.
-- Git and path gem sources. The parser gracefully skips these (no crash), but the gems aren't included in the environment. `buildRubyGem` already handles both source types; we just need to parse the `GIT` and `PATH` lockfile sections.
-- `bundlerEnv` has a much more capable `buildEnv` with Bundler-aware binstubs. Need to study the differences and decide what to adopt.
-- See `TODO.md` for the full list of critiques and upstream alignment opportunities.
+## Known Limitations
-Once these are in a good place, I'm also thinking about pre Bundler 2.6 backwards compatibility. Maybe this is worth its own standalone tool to generate the hashes, if we have created compelling solutions to the other quirks present in Bundix.
+- **Git and path gem sources are not yet supported.** The parser skips them
+ gracefully (no crash), but they are not included in the environment.
+- **Bundler >= 2.5 is required** for the CHECKSUMS section in `Gemfile.lock`.
+- **Group extraction uses Ruby IFD** by default. This is an impurity at Nix
+ evaluation time. Pass `gemGroups` to avoid it.
diff --git a/TESTING.md b/TESTING.md
deleted file mode 100644
index 13000cb..0000000
--- a/TESTING.md
+++ /dev/null
@@ -1,157 +0,0 @@
-# Testing Strategy
-
-## Philosophy
-
-Tests follow a **red-green-refactor** loop:
-
-1. **Red:** Write a test that captures expected behavior. Run it. Watch it
- fail (or verify it passes if testing existing correct behavior). For new
- bug-fix tests, the test should fail against the current code to confirm the
- bug is real before fixing.
-
-2. **Green:** Make the minimal change to pass the test. For tests that
- validate existing correct behavior, this step is already done: the test
- passes on first run, confirming the implementation is correct.
-
-3. **Refactor:** With passing tests as a safety net, improve the code. Rerun
- tests to confirm nothing broke.
-
-## Test Structure
-
-```
-lib/gemfile-env/
-├── default.nix # orchestrator: imports helpers, builds gems
-├── parse-gemfile-and-lockfile.nix # IO shell: readFile, runCommand, delegates to helpers
-├── parser-helpers.nix # pure: all parsing (line, section, lockfile assembly)
-├── filter-helpers.nix # pure: filterGroup, filterPlatform, resolvePlatforms
-├── gem-groups.rb # Ruby script for group extraction
-└── gem-dependencies.rb # (placeholder)
-
-test/
-├── test-helpers.nix # shared assertEq, assertThrows
-├── test.nix # integration test (full Rails build)
-└── unit/
- ├── test-parser.nix # unit tests for parser-helpers.nix
- └── test-filter.nix # unit tests for filter-helpers.nix
-
-examples/
-├── simple/ # pure-ruby gems (rack, rake)
-│ ├── flake.nix
-│ ├── Gemfile
-│ ├── Gemfile.lock
-│ └── validate.rb
-├── medium/ # native gems (nokogiri, puma, ethon)
-│ ├── flake.nix
-│ ├── Gemfile
-│ ├── Gemfile.lock
-│ └── validate.rb
-└── complex/ # Rails 8, git source, path source
- ├── flake.nix
- ├── Gemfile
- ├── Gemfile.lock
- ├── validate.rb
- └── vendor/hello_gem/ # local path gem for testing
-```
-
-### Architecture for testability
-
-All pure logic lives in `*-helpers.nix` files that take only `{ lib }` as
-input. The production modules (`parse-gemfile-and-lockfile.nix`, `default.nix`)
-import these helpers and add IO / nixpkgs build concerns on top. Tests import
-the helpers directly to avoid needing `callPackage`, `runCommand`, or Ruby.
-
-Shared assertion functions (`assertEq`, `assertThrows`) live in
-`test/test-helpers.nix` and are imported by all unit test files.
-
-### Unit tests
-
-Fast, pure Nix, no network or build. Test individual functions with
-synthetic inputs.
-
-- **`test-parser.nix`** -- `findIndices`, `takeLines`, `parseChecksumLine`,
- `parseGemSection`, `parseLockfileContent`, `buildGemRemotes`,
- `mergeGemMetadata`. Includes tests for malformed input (missing hash,
- extra whitespace, missing sections) and git/path gem handling (hashless
- checksum lines return null).
-
-- **`test-filter.nix`** -- `filterGroup`, `filterPlatform`,
- `resolvePlatforms`, `applyGemConfigs`, `platformsForSystem`. Includes
- preference ranking tests (exact arch > compatible > ruby), shadowing bug
- regression, and system-to-platform mapping for all four supported systems.
-
-### Integration tests (`examples/`)
-
-Each example is a self-contained flake with a real Gemfile.lock (with
-checksums from rubygems.org), a Ruby validation script, and a `checks`
-output. The validation script requires each gem, calls a method to prove the
-native extension works, and exits nonzero on failure.
-
-| Example | Gems | What it exercises |
-|-----------|------|-------------------|
-| `simple` | 2 | Basic pipeline: parse, filter, build, load |
-| `medium` | 5 | Native platform variants, group filtering, `defaultGemConfig` |
-| `complex` | 60+ | Full Rails, git/path sources (SKIP until implemented), transitive deps |
-
-The complex example's `validate.rb` uses `rescue LoadError` to SKIP
-git/path source gems rather than failing. When TODO #13 is implemented,
-those lines will start printing `OK` instead of `SKIP`, no test changes
-needed.
-
-### Integration test (`test/test.nix`)
-
-The original test that evaluates a full `gemfileEnv` against the
-`test/rails/` fixture. Validates the end-to-end pipeline including
-`gem-groups.rb` group extraction.
-
-## Running Tests
-
-### Unit tests
-
-```sh
-nix eval --file test/unit/test-parser.nix --json
-nix eval --file test/unit/test-filter.nix --json
-```
-
-### Integration tests
-
-```sh
-# Individual example
-cd examples/simple && nix flake check --no-write-lock-file
-
-# All examples
-for ex in simple medium complex; do
- (cd examples/$ex && nix flake check --no-write-lock-file)
-done
-```
-
-### Everything
-
-```sh
-nix eval --file test/unit/test-parser.nix --json && \
-nix eval --file test/unit/test-filter.nix --json && \
-echo "unit tests passed" && \
-for ex in simple medium complex; do
- (cd examples/$ex && nix flake check --no-write-lock-file) || exit 1
-done && \
-echo "all tests passed"
-```
-
-### Red-green-refactor example
-
-```sh
-# 1. RED: write a new test case in test/unit/test-parser.nix, then:
-nix eval --file test/unit/test-parser.nix --json
-# => error: ... (test fails; good, the bug is confirmed)
-
-# 2. GREEN: fix the code in lib/gemfile-env/parser-helpers.nix
-nix eval --file test/unit/test-parser.nix --json
-# => true (test passes; fix is correct)
-
-# 3. REFACTOR: clean up, then re-run to confirm nothing broke
-nix eval --file test/unit/test-parser.nix --json && \
-nix eval --file test/unit/test-filter.nix --json && \
-echo "all tests still pass"
-```
-
-Each `nix eval` returns `true` on success or throws an assertion error with a
-descriptive message on failure. No external test harness needed.
diff --git a/TODO.md b/TODO.md
index 8d9b03e..a1a2a8e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,5 +1,7 @@
# TODO
+> For the latest status, see [GitHub Issues](https://github.com/omc/gems4nix/issues).
+
## Critiques and Recommendations
### Parser (`parse-gemfile-and-lockfile.nix`)
@@ -23,7 +25,7 @@
`builtins.listToAttrs` on a flattened list means if the same gem name
appears in multiple GEM sections (e.g., `faraday` from both rubygems.org
and a private registry), only the first section's remote survives. The TODO
- in `parser-helpers.nix` acknowledges this, but the current behavior is
+ in `parse.nix` acknowledges this, but the current behavior is
silently arbitrary rather than loudly wrong.
4. **No support for git or path sources.**
@@ -45,7 +47,7 @@
Error: `Could not find 'mini_portile2' (~> 2.8.2)` during nokogiri build.
- **Regression test:** `test/unit/test-filter.nix` →
+ **Regression test:** `test/unit/test-resolve-logic.nix` →
`test_ruby_only_nokogiri_keeps_build_deps` (currently fails, documenting
the bug).
@@ -83,7 +85,7 @@ the less we maintain and the more we benefit from upstream fixes.
nixpkgs already has `filterGemset`, `platformMatches`, `groupMatches`,
`applyGemConfigs`, and `composeGemAttrs` in
`pkgs/development/ruby-modules/bundled-common/functions.nix`. Our
- `filter-helpers.nix` reimplements some of these. The upstream versions
+ `resolve.nix` reimplements some of these. The upstream versions
handle edge cases we don't yet (e.g., `platformMatches` checks
`ruby.rubyEngine` and `version.majMin`, not raw platform strings;
`groupMatches` always includes `"default"`; `filterGemset` recursively
@@ -132,7 +134,7 @@ the less we maintain and the more we benefit from upstream fixes.
- Reorder the pipeline: resolve platforms *before* applying gem configs,
so only the winning variant gets configured.
- **Regression test:** `test/unit/test-filter.nix` →
+ **Regression test:** `test/unit/test-resolve-logic.nix` →
`test_applyGemConfigs_should_respect_platform` (currently fails,
documenting the bug).
@@ -163,7 +165,7 @@ the less we maintain and the more we benefit from upstream fixes.
- Use `bundlerEnv` directly with our parsed output as the `gemset`
- Incrementally adopt gems4nix without a hard cutover
- **Action:** Add a `toGemset` function to `parser-helpers.nix` that
+ **Action:** Add a `toGemset` function to `parse.nix` that
converts our internal representation to the `gemset.nix` format. This
also serves as a migration path and compatibility layer.
diff --git a/flake.nix b/flake.nix
index 37c4112..215efaa 100644
--- a/flake.nix
+++ b/flake.nix
@@ -34,6 +34,51 @@
};
};
+ checks = forAllSystems (
+ { pkgs, ... }:
+ let
+ # Helper: wire a { lib }: unit test into flake checks.
+ # The -logic.nix file is imported with pkgs.lib, fully evaluated via
+ # builtins.deepSeq (which forces assertEq throws to surface), and
+ # wrapped in writeText to produce a derivation for the checks contract.
+ # Test failures abort nix flake check at eval time with the assertEq
+ # error message visible in the trace.
+ nixEvalCheck =
+ name: testFile:
+ let
+ result = import testFile { lib = pkgs.lib; };
+ in
+ pkgs.writeText "unit-${name}" (builtins.deepSeq result "PASS");
+
+ gemfileEnv = pkgs.callPackage ./lib/gemfile-env { };
+
+ gems = gemfileEnv {
+ name = "platform-gems-test";
+ gemfile = ./test/integration/platform-gems/Gemfile;
+ gemfileLock = ./test/integration/platform-gems/Gemfile.lock;
+ groups = [ "default" ];
+ };
+ in
+ {
+ unit-resolve = nixEvalCheck "resolve" ./test/unit/test-resolve-logic.nix;
+ unit-parse = nixEvalCheck "parse" ./test/unit/test-parse-logic.nix;
+ unit-pipeline = nixEvalCheck "pipeline" ./test/unit/test-pipeline-logic.nix;
+
+ integration-platform-gems =
+ pkgs.runCommand "integration-platform-gems"
+ {
+ buildInputs = [
+ pkgs.ruby
+ gems
+ ];
+ }
+ ''
+ ruby ${./test/integration/platform-gems/validate.rb}
+ touch $out
+ '';
+ }
+ );
+
devShells = forAllSystems (
{ pkgs, ... }:
{
diff --git a/lib/gemfile-env/default.nix b/lib/gemfile-env/default.nix
index b2ee119..e07c71e 100644
--- a/lib/gemfile-env/default.nix
+++ b/lib/gemfile-env/default.nix
@@ -9,16 +9,14 @@
defaultGemConfig,
buildEnv,
- # nokogiri
- zlib,
- libxml2,
- libxslt,
- libiconv,
-
# debug
...
}:
+let
+ defaultRuby = ruby;
+in
+
# function arguments:
{
name,
@@ -31,72 +29,58 @@
"production"
"test"
],
+ gemGroups ? null, # null = auto-detect via gem-groups.rb IFD; attrset = override
gemConfig ? defaultGemConfig,
- ruby ? ruby,
+ ruby ? defaultRuby,
+ debug ? false, # when true, builtins.trace each gem being built
...
}:
let
# ── parsing ──────────────────────────────────────────────────
parseGemfileAndLockfile = callPackage ./parse-gemfile-and-lockfile.nix { };
- gemMetadata = parseGemfileAndLockfile { inherit gemfile gemfileLock; };
+ parsed = parseGemfileAndLockfile { inherit gemfile gemfileLock gemGroups; };
+ gemMetadata = parsed.gems;
+ depGraph = parsed.depGraph;
- # ── filtering (pure logic lives in filter-helpers.nix) ───────
- filterHelpers = import ./filter-helpers.nix { inherit lib; };
+ # ── filtering (pure logic lives in resolve.nix) ─────────────
+ filterHelpers = import ./resolve.nix { inherit lib; };
inherit (filterHelpers)
filterGroup
filterPlatform
resolvePlatforms
applyGemConfigs
platformsForSystem
+ expandTransitiveDeps
+ warnIfNoPlatformGems
;
# Resolve platforms: user-supplied list, or auto-detect from stdenv
resolvedPlatforms =
- if platforms != null then platforms else platformsForSystem stdenv.hostPlatform.system;
+ let
+ raw = if platforms != null then platforms else platformsForSystem stdenv.hostPlatform.system;
+ in
+ warnIfNoPlatformGems gemMetadata raw;
+ # Step 1: filter by groups
gemsForGroups = builtins.filter (filterGroup groups) gemMetadata;
- gemsForGroupsAndPlatforms = builtins.filter (filterPlatform resolvedPlatforms) gemsForGroups;
+
+ # Step 2: expand transitive deps so build-time dependencies survive
+ # (e.g., mini_portile2 needed by ruby-platform nokogiri)
+ afterGroupNames = map (g: g.gemName) gemsForGroups;
+ expandedNames = expandTransitiveDeps depGraph afterGroupNames;
+ expandedGems = builtins.filter (g: builtins.elem g.gemName expandedNames) gemMetadata;
+
+ # Step 3: filter by platform
+ gemsForGroupsAndPlatforms = builtins.filter (filterPlatform resolvedPlatforms) expandedGems;
# Merge user-supplied gemConfig with our local overrides (e.g., nokogiri).
# The user's config takes precedence: if they supply a nokogiri entry, it
# replaces ours. To layer on top of ours, they can import and extend it.
- nokogiriConfig = {
- nokogiri =
- attrs:
- (
- {
- buildFlags = [
- "--use-system-libraries"
- "--with-zlib-lib=${zlib.out}/lib"
- "--with-zlib-include=${zlib.dev}/include"
- "--with-xml2-lib=${libxml2.out}/lib"
- "--with-xml2-include=${libxml2.dev}/include/libxml2"
- "--with-xslt-lib=${libxslt.out}/lib"
- "--with-xslt-include=${libxslt.dev}/include"
- "--with-exslt-lib=${libxslt.out}/lib"
- "--with-exslt-include=${libxslt.dev}/include"
- "--gumbo-dev"
- ]
- ++ lib.optionals stdenv.hostPlatform.isDarwin [
- "--with-iconv-dir=${libiconv}"
- "--with-opt-include=${libiconv}/include"
- ];
- }
- // lib.optionalAttrs stdenv.hostPlatform.isDarwin {
- buildInputs = [ libxml2 ];
-
- # libxml 2.12 upgrade requires these fixes
- # https://github.com/sparklemotion/nokogiri/pull/3032
- # which don't trivially apply to older versions
- meta.broken =
- (lib.versionOlder attrs.version "1.16.0") && (lib.versionAtLeast libxml2.version "2.12");
- }
- );
- };
+ gemConfigs = callPackage ./gem-configs.nix { };
- # Layer: defaultGemConfig < nokogiriConfig < user gemConfig
- mergedGemConfig = defaultGemConfig // nokogiriConfig // gemConfig;
+ # Layer: defaultGemConfig < gemConfigs < user gemConfig
+ mergedGemConfig = defaultGemConfig // gemConfigs // gemConfig;
# Resolve platform duplicates FIRST: prefer exact arch match > compatible > ruby.
# This must happen before applyGemConfigs so that defaultGemConfig entries
@@ -108,11 +92,22 @@ let
let
configured =
if gemAttrs.platform == "ruby" then applyGemConfigs mergedGemConfig gemAttrs else gemAttrs;
+ traced =
+ if debug then
+ builtins.trace "gems4nix [debug]: building ${configured.gemName} ${configured.version} (${configured.platform})" configured
+ else
+ configured;
in
- buildRubyGem configured
+ buildRubyGem traced
) platformResolvedGemsByName;
in
buildEnv {
name = "${name}-${lib.strings.concatStringsSep "-" groups}-${lib.strings.concatStringsSep "-" resolvedPlatforms}";
paths = finalGems;
+ postBuild = ''
+ mkdir -p $out/nix-support
+ cat > $out/nix-support/setup-hook < before.length
+ groups[child_name] = merged
+ changed = true
+ end
+ end
end
+ break unless changed
end
puts JSON.generate(groups)
diff --git a/lib/gemfile-env/parse-gemfile-and-lockfile.nix b/lib/gemfile-env/parse-gemfile-and-lockfile.nix
index 45a5dc1..29328f8 100644
--- a/lib/gemfile-env/parse-gemfile-and-lockfile.nix
+++ b/lib/gemfile-env/parse-gemfile-and-lockfile.nix
@@ -1,7 +1,27 @@
# TODO: git source
#
# IO shell: reads files, runs Ruby for group info, delegates pure logic to
-# parser-helpers.nix. All testable logic lives there.
+# parse.nix. All testable logic lives there.
+#
+# ── Impurity note: gem group extraction ─────────────────────
+# Group extraction (mapping gems to Gemfile groups like :default, :test, etc.)
+# uses Ruby IFD (import-from-derivation) via gem-groups.rb. This spawns a
+# Bundler process at eval time to inspect the Gemfile's group declarations.
+#
+# This is acceptable because:
+# - Gemfile semantics are tightly coupled to Bundler: groups can be defined
+# with arbitrary Ruby (conditionals, eval, method calls) that only Bundler
+# can reliably interpret.
+# - The IFD is hermetic: it reads only the Gemfile and Gemfile.lock, runs in
+# a sandboxed derivation, and produces deterministic JSON output.
+#
+# Users who want to avoid the IFD can supply the `gemGroups` parameter to
+# gemfileEnv with an explicit { gemName = [ "group1" "group2" ]; ... }
+# mapping. When gemGroups is non-null, gem-groups.rb is skipped entirely.
+#
+# A pure Nix Gemfile parser is a long-term aspiration but impractical for
+# general use given the arbitrary Ruby that real Gemfiles contain.
+# ─────────────────────────────────────────────────────────────
#
# callPackage definitions:
{
@@ -21,16 +41,23 @@
{
gemfile,
gemfileLock,
+ gemGroups ? null, # null = auto-detect via gem-groups.rb; attrset = override
}:
let
- helpers = import ./parser-helpers.nix { inherit lib; };
- inherit (helpers) parseLockfileContent buildGemRemotes mergeGemMetadata;
+ helpers = import ./parse.nix { inherit lib; };
+ inherit (helpers)
+ parseLockfile
+ indexRemotes
+ mergeGemMetadata
+ parseDependencies
+ ;
# ── IO ───────────────────────────────────────────────────────
# use the Gemfile to produce group information for each gem
+ # (skipped when the caller supplies an explicit gemGroups override)
gemGroupsJson =
runCommand "gem-groups-json"
{
@@ -48,12 +75,29 @@ let
# ── pure assembly (delegated to helpers) ─────────────────────
content = builtins.readFile gemfileLock;
- parsed = parseLockfileContent content;
- gemRemotes = buildGemRemotes parsed.gemSections;
- gemGroups = builtins.fromJSON (builtins.readFile gemGroupsJson);
+ lines = lib.splitString "\n" content;
+ parsed = parseLockfile content;
+ gemRemotes = indexRemotes parsed.gemSections;
+ resolvedGemGroups =
+ if gemGroups != null then gemGroups else builtins.fromJSON (builtins.readFile gemGroupsJson);
+
+ # Build the dependency graph from all GEM sections.
+ # parseDependencies operates on raw lines (preserving indentation).
+ # Multiple GEM sections (multiple remotes) are merged; later entries
+ # for the same gem name merge their dep lists.
+ gemSectionIndices = helpers.findIndices (l: l == "GEM") lines;
+ gemSectionRawLines = lib.lists.map (i: helpers.takeLines i lines) gemSectionIndices;
+ depGraphs = lib.lists.map parseDependencies gemSectionRawLines;
+ depGraph = builtins.foldl' (
+ acc: g: lib.attrsets.zipAttrsWith (name: vals: lib.unique (lib.flatten vals)) ([ acc ] ++ [ g ])
+ ) { } depGraphs;
in
-mergeGemMetadata {
- inherit (parsed) checksumSection;
- inherit gemRemotes gemGroups;
+{
+ gems = mergeGemMetadata {
+ inherit (parsed) checksumSection;
+ inherit gemRemotes;
+ gemGroups = resolvedGemGroups;
+ };
+ inherit depGraph;
}
diff --git a/lib/gemfile-env/parse.nix b/lib/gemfile-env/parse.nix
new file mode 100644
index 0000000..d847869
--- /dev/null
+++ b/lib/gemfile-env/parse.nix
@@ -0,0 +1,400 @@
+# Pure helper functions for parsing Gemfile.lock
+# Extracted for testability: no runCommand, no IO.
+{ lib }:
+
+let
+
+ # when a list has many matching elements, return them all
+ findIndices =
+ pred: list:
+ let
+ loop =
+ idx: xs:
+ if xs == [ ] then
+ [ ]
+ else
+ let
+ head = builtins.head xs;
+ tail = builtins.tail xs;
+ rest = loop (idx + 1) tail;
+ in
+ if pred head then [ idx ] ++ rest else rest;
+ in
+ loop 0 list;
+
+ # given a bunch of lines, and a starting point offset, return a list of lines
+ # until the first blank line
+ takeLines =
+ start: lines:
+ let
+ tail = builtins.tail (lib.drop start lines);
+ takeUntilEmpty =
+ builtins.foldl'
+ (
+ acc: line:
+ if acc.done || line == "" then
+ acc
+ // {
+ done = true;
+ lines = acc.lines;
+ }
+ else
+ acc // { lines = acc.lines ++ [ line ]; }
+ )
+ {
+ lines = [ ];
+ done = false;
+ }
+ tail;
+ in
+ takeUntilEmpty.lines;
+
+ # Known Ruby gem platform strings. Used by parseChecksumLine to parse
+ # version-platform from the right, avoiding mis-parses on pre-release
+ # versions containing `-` (e.g., `1.0.0-beta.1-arm64-darwin`).
+ knownPlatforms = [
+ # macOS
+ "arm64-darwin"
+ "x86_64-darwin"
+ "universal-darwin"
+ # Linux GNU
+ "aarch64-linux-gnu"
+ "arm-linux-gnu"
+ "x86_64-linux-gnu"
+ # Linux musl
+ "aarch64-linux-musl"
+ "arm-linux-musl"
+ "x86_64-linux-musl"
+ # Generic linux (no libc suffix)
+ "aarch64-linux"
+ "x86_64-linux"
+ "arm-linux"
+ # Java / JRuby
+ "java"
+ # Windows
+ "x86-mingw32"
+ "x64-mingw32"
+ "x64-mingw-ucrt"
+ # MSWIN
+ "x86-mswin32"
+ "x64-mswin64"
+ ];
+
+ # Check if a version-platform string ends with a known platform.
+ # Returns { version, platform } where platform is "ruby" if no known
+ # platform suffix is found.
+ splitVersionPlatform =
+ raw:
+ let
+ # Try each known platform: check if raw ends with "-"
+ matchPlatform = builtins.foldl' (
+ acc: plat:
+ if acc != null then
+ acc
+ else
+ let
+ suffix = "-${plat}";
+ in
+ if lib.strings.hasSuffix suffix raw then
+ {
+ version = lib.strings.removeSuffix suffix raw;
+ platform = plat;
+ }
+ else
+ null
+ ) null knownPlatforms;
+ in
+ if matchPlatform != null then
+ matchPlatform
+ else
+ {
+ version = raw;
+ platform = "ruby";
+ };
+
+ # parse a gem checksum line
+ # " zeitwerk (2.6.18) sha256=bd2d213996ff7b3b364cd342a585fbee9797dbc1c0c6d868dc4150cc75739781"
+ parseChecksumLine =
+ line:
+ let
+ stripped = lib.strings.removePrefix " " line;
+ parts = lib.splitString " " stripped;
+ numParts = builtins.length parts;
+
+ # Git and path source gems appear in CHECKSUMS without a hash:
+ # errgonomic (0.5.1)
+ # hello_gem (0.1.0)
+ # Return null for these; the caller filters them out.
+ # GEM-sourced gems always have 3+ parts: NAME (VERSION) sha256=DIGEST
+ isHashless = numParts < 3;
+
+ _ =
+ if !isHashless && builtins.elemAt parts 0 == "" then
+ throw "gems4nix (internal): parseChecksumLine: unexpected leading whitespace in line: ${line}"
+ else
+ true;
+
+ gemName = builtins.elemAt parts 0;
+ rawVersion = builtins.elemAt parts 1;
+
+ __ =
+ if !isHashless && !(lib.strings.hasPrefix "(" rawVersion) then
+ throw "gems4nix (internal): parseChecksumLine: expected version in parens, e.g. '(1.0.0)', but got '${rawVersion}' in line: ${line}"
+ else
+ true;
+
+ # Parse version-platform from the right to handle pre-release versions
+ # containing `-` (e.g., `1.0.0-beta.1-arm64-darwin`).
+ versionPlatformRaw = lib.strings.removeSuffix ")" (lib.strings.removePrefix "(" rawVersion);
+ vp = splitVersionPlatform versionPlatformRaw;
+ inherit (vp) version platform;
+
+ rawHash = builtins.elemAt parts 2;
+ hashParts = lib.splitString "=" rawHash;
+
+ ___ =
+ if !isHashless && builtins.length hashParts < 2 then
+ throw "gems4nix (internal): parseChecksumLine: expected 'sha256=DIGEST' but got '${rawHash}' in line: ${line}"
+ else
+ true;
+
+ sha256 = builtins.elemAt hashParts 1;
+ in
+ # Git/path gems without a hash: return null (skipped by caller)
+ if isHashless then
+ null
+ else
+ assert _ == true;
+ assert __ == true;
+ assert ___ == true;
+ {
+ inherit
+ version
+ platform
+ gemName
+ ;
+ source = {
+ inherit sha256;
+ };
+ };
+
+ # given a bunch of lines that represent a GEM section, return the remote and the list of gems.
+ # we're not concerned with the version specs here, since we'll get that later from the checksum.
+ # this is just to reconstruct a url to the gem file later.
+ parseGemSection =
+ lines:
+ let
+ remoteStr = builtins.elemAt (lib.strings.splitString ": " (builtins.elemAt lines 0)) 1;
+ remote =
+ if (lib.strings.hasSuffix "/" remoteStr) then lib.strings.removeSuffix "/" remoteStr else remoteStr;
+ gems = lib.lists.map (
+ line:
+ let
+ parts = builtins.filter (s: s != "") (lib.strings.splitString " " line);
+ name = builtins.elemAt parts 0;
+ in
+ name
+ ) (lib.lists.drop 2 lines);
+ in
+ {
+ inherit remote gems;
+ };
+
+ # ── dependency graph parsing ─────────────────────────────────
+
+ # Parse the dependency graph from raw GEM section lines.
+ # Operates on the raw lines (preserving indentation) rather than
+ # parseGemSection's output (which discards indentation).
+ #
+ # In a Gemfile.lock GEM section, after "specs:", gems are at 4-space
+ # indent and their dependencies are at 6-space indent:
+ #
+ # nokogiri (1.19.2)
+ # mini_portile2 (~> 2.8.2)
+ # racc (~> 1.4)
+ # racc (1.8.1)
+ #
+ # Returns: { gemName = [ "dep1" "dep2" ... ]; ... }
+ # Gem names from the 4-space lines have their version-platform stripped.
+ # Dependency names from the 6-space lines have version constraints stripped.
+ # Multiple platform variants of the same gem are merged (union of deps).
+ parseDependencies =
+ lines:
+ let
+ # Process only lines after "specs:" header
+ specsIdx = lib.lists.findFirstIndex (l: lib.strings.hasInfix "specs:" l) null lines;
+ specLines = if specsIdx != null then lib.lists.drop (specsIdx + 1) lines else [ ];
+
+ # Walk through spec lines, tracking current gem name and collecting deps.
+ # 4-space indent = gem line, 6-space indent = dependency line.
+ parsed =
+ builtins.foldl'
+ (
+ acc: line:
+ let
+ is4space = lib.strings.hasPrefix " " line && !(lib.strings.hasPrefix " " line);
+ is6space = lib.strings.hasPrefix " " line;
+ in
+ if is4space then
+ let
+ # Extract gem name (first token after stripping whitespace)
+ stripped = lib.strings.removePrefix " " line;
+ nameParts = builtins.filter (s: s != "") (lib.strings.splitString " " stripped);
+ name = builtins.elemAt nameParts 0;
+ in
+ acc
+ // {
+ currentGem = name;
+ result = acc.result // {
+ ${name} = (acc.result.${name} or [ ]);
+ };
+ }
+ else if is6space && acc.currentGem != null then
+ let
+ # Extract dependency name (first token, ignoring version constraint)
+ stripped = lib.strings.removePrefix " " line;
+ nameParts = builtins.filter (s: s != "") (lib.strings.splitString " " stripped);
+ depName = builtins.elemAt nameParts 0;
+ existing = acc.result.${acc.currentGem} or [ ];
+ # Merge: avoid duplicates (multiple platform variants of same gem)
+ newDeps = if builtins.elem depName existing then existing else existing ++ [ depName ];
+ in
+ acc
+ // {
+ result = acc.result // {
+ ${acc.currentGem} = newDeps;
+ };
+ }
+ else
+ acc
+ )
+ {
+ currentGem = null;
+ result = { };
+ }
+ specLines;
+ in
+ parsed.result;
+
+ # Parse the DEPENDENCIES section from a lockfile to get the list of
+ # top-level gems (what the user explicitly depends on).
+ # Each line is " gemName" or " gemName (~> 1.0)" -- extract just the name.
+ # Returns: [ "gem1" "gem2" ... ]
+ parseDependenciesSection =
+ lines:
+ builtins.map (
+ line:
+ let
+ stripped = lib.strings.removePrefix " " line;
+ nameParts = builtins.filter (s: s != "") (lib.strings.splitString " " stripped);
+ in
+ builtins.elemAt nameParts 0
+ ) lines;
+
+ # Extract the DEPENDENCIES section lines from a lockfile's raw lines.
+ # Returns: list of lines between DEPENDENCIES header and next blank line.
+ takeDependenciesSection =
+ lines:
+ let
+ idx = lib.lists.findFirstIndex (l: l == "DEPENDENCIES") null lines;
+ in
+ if idx != null then takeLines idx lines else [ ];
+
+ # ── lockfile-level assembly (pure, no IO) ────────────────────
+
+ # Parse the full content of a Gemfile.lock into its checksum and GEM sections.
+ # Returns: { checksumSection, gemSections }
+ parseLockfile =
+ content:
+ let
+ lines = lib.splitString "\n" content;
+
+ # CHECKSUMS
+ checksumSectionIndex = lib.lists.findFirstIndex (line: line == "CHECKSUMS") null lines;
+ checksumSectionLines =
+ if checksumSectionIndex == null then
+ throw "gems4nix: cannot find CHECKSUMS in Gemfile.lock - run 'bundle lock --add-checksums'"
+ else
+ takeLines checksumSectionIndex lines;
+ # parseChecksumLine returns null for git/path gems (no hash); filter them out.
+ checksumSection = builtins.filter (x: x != null) (
+ lib.lists.map parseChecksumLine checksumSectionLines
+ );
+
+ # GEM sections (may have more than one remote)
+ gemSectionIndices = findIndices (l: l == "GEM") lines;
+ _ =
+ if gemSectionIndices == [ ] then
+ throw "gems4nix: cannot find GEM section in Gemfile.lock - is this a valid Bundler lockfile?"
+ else
+ true;
+ gemSectionLines = lib.lists.map (i: takeLines i lines) gemSectionIndices;
+ gemSections = lib.lists.map parseGemSection gemSectionLines;
+ in
+ assert _ == true;
+ {
+ inherit checksumSection gemSections;
+ };
+
+ # Invert gem sections into a flat { gemName = remote; ... } lookup.
+ # First-writer-wins when a gem appears in multiple sections (builtins.listToAttrs
+ # keeps the first entry for duplicate keys).
+ # TODO: group by gem name for multiple remotes; e.g., depot depends on faraday
+ # which shows up in both but we prefer rubygems.org.
+ indexRemotes =
+ gemSections:
+ builtins.listToAttrs (
+ lib.lists.flatten (
+ lib.lists.map (
+ section:
+ lib.lists.map (gem: {
+ name = gem;
+ value = section.remote;
+ }) section.gems
+ ) gemSections
+ )
+ );
+
+ # Merge parsed checksums with group info and remote URLs into the final
+ # gem metadata list that the rest of the pipeline expects.
+ mergeGemMetadata =
+ {
+ checksumSection,
+ gemRemotes,
+ gemGroups,
+ }:
+ lib.lists.map (gemAttrs: {
+ inherit (gemAttrs)
+ gemName
+ platform
+ version
+ ;
+
+ # Build-time deps (e.g., mini_portile2) may appear in the lock but not
+ # in the group parser output. Default to empty groups so they get
+ # filtered out rather than crashing.
+ groups = gemGroups.${gemAttrs.gemName} or [ ];
+
+ source = gemAttrs.source // {
+ remotes = [ gemRemotes.${gemAttrs.gemName} ];
+ type = "gem"; # todo: git, path sources
+ };
+ }) checksumSection;
+
+in
+{
+ inherit
+ findIndices
+ takeLines
+ knownPlatforms
+ splitVersionPlatform
+ parseChecksumLine
+ parseGemSection
+ parseDependencies
+ parseDependenciesSection
+ takeDependenciesSection
+ parseLockfile
+ indexRemotes
+ mergeGemMetadata
+ ;
+}
diff --git a/lib/gemfile-env/parser-helpers.nix b/lib/gemfile-env/parser-helpers.nix
deleted file mode 100644
index 5fae566..0000000
--- a/lib/gemfile-env/parser-helpers.nix
+++ /dev/null
@@ -1,235 +0,0 @@
-# Pure helper functions for parsing Gemfile.lock
-# Extracted for testability: no runCommand, no IO.
-{ lib }:
-
-let
-
- # when a list has many matching elements, return them all
- findIndices =
- pred: list:
- let
- loop =
- idx: xs:
- if xs == [ ] then
- [ ]
- else
- let
- head = builtins.head xs;
- tail = builtins.tail xs;
- rest = loop (idx + 1) tail;
- in
- if pred head then [ idx ] ++ rest else rest;
- in
- loop 0 list;
-
- # given a bunch of lines, and a starting point offset, return a list of lines
- # until the first blank line
- takeLines =
- start: lines:
- let
- tail = builtins.tail (lib.drop start lines);
- takeUntilEmpty =
- builtins.foldl'
- (
- acc: line:
- if acc.done || line == "" then
- acc
- // {
- done = true;
- lines = acc.lines;
- }
- else
- acc // { lines = acc.lines ++ [ line ]; }
- )
- {
- lines = [ ];
- done = false;
- }
- tail;
- in
- takeUntilEmpty.lines;
-
- # parse a gem checksum line
- # " zeitwerk (2.6.18) sha256=bd2d213996ff7b3b364cd342a585fbee9797dbc1c0c6d868dc4150cc75739781"
- parseChecksumLine =
- line:
- let
- stripped = lib.strings.removePrefix " " line;
- parts = lib.splitString " " stripped;
- numParts = builtins.length parts;
-
- # Git and path source gems appear in CHECKSUMS without a hash:
- # errgonomic (0.5.1)
- # hello_gem (0.1.0)
- # Return null for these; the caller filters them out.
- # GEM-sourced gems always have 3+ parts: NAME (VERSION) sha256=DIGEST
- isHashless = numParts < 3;
-
- _ =
- if !isHashless && builtins.elemAt parts 0 == "" then
- throw "parseChecksumLine: unexpected leading whitespace in line: ${line}"
- else
- true;
-
- gemName = builtins.elemAt parts 0;
- rawVersion = builtins.elemAt parts 1;
-
- __ =
- if !isHashless && !(lib.strings.hasPrefix "(" rawVersion) then
- throw "parseChecksumLine: expected version in parens, e.g. '(1.0.0)', but got '${rawVersion}' in line: ${line}"
- else
- true;
-
- versionParts = lib.splitString "-" (
- lib.strings.removeSuffix ")" (lib.strings.removePrefix "(" rawVersion)
- );
- version = builtins.elemAt versionParts 0;
- platform =
- if builtins.length versionParts > 1 then
- (lib.strings.concatStringsSep "-" (lib.lists.drop 1 versionParts))
- else
- "ruby";
-
- rawHash = builtins.elemAt parts 2;
- hashParts = lib.splitString "=" rawHash;
-
- ___ =
- if !isHashless && builtins.length hashParts < 2 then
- throw "parseChecksumLine: expected 'sha256=DIGEST' but got '${rawHash}' in line: ${line}"
- else
- true;
-
- sha256 = builtins.elemAt hashParts 1;
- in
- # Git/path gems without a hash: return null (skipped by caller)
- if isHashless then
- null
- else
- assert _ == true;
- assert __ == true;
- assert ___ == true;
- {
- inherit
- version
- platform
- gemName
- ;
- source = {
- inherit sha256;
- };
- };
-
- # given a bunch of lines that represent a GEM section, return the remote and the list of gems.
- # we're not concerned with the version specs here, since we'll get that later from the checksum.
- # this is just to reconstruct a url to the gem file later.
- parseGemSection =
- lines:
- let
- remoteStr = builtins.elemAt (lib.strings.splitString ": " (builtins.elemAt lines 0)) 1;
- remote =
- if (lib.strings.hasSuffix "/" remoteStr) then lib.strings.removeSuffix "/" remoteStr else remoteStr;
- gems = lib.lists.map (
- line:
- let
- parts = builtins.filter (s: s != "") (lib.strings.splitString " " line);
- name = builtins.elemAt parts 0;
- in
- name
- ) (lib.lists.drop 2 lines);
- in
- {
- inherit remote gems;
- };
-
- # ── lockfile-level assembly (pure, no IO) ────────────────────
-
- # Parse the full content of a Gemfile.lock into its checksum and GEM sections.
- # Returns: { checksumSection, gemSections }
- parseLockfileContent =
- content:
- let
- lines = lib.splitString "\n" content;
-
- # CHECKSUMS
- checksumSectionIndex = lib.lists.findFirstIndex (line: line == "CHECKSUMS") null lines;
- checksumSectionLines =
- if checksumSectionIndex == null then
- throw "cannot find CHECKSUMS in Gemfile.lock - run 'bundle lock --add-checksums'"
- else
- takeLines checksumSectionIndex lines;
- # parseChecksumLine returns null for git/path gems (no hash); filter them out.
- checksumSection = builtins.filter (x: x != null) (
- lib.lists.map parseChecksumLine checksumSectionLines
- );
-
- # GEM sections (may have more than one remote)
- gemSectionIndices = findIndices (l: l == "GEM") lines;
- _ =
- if gemSectionIndices == [ ] then
- throw "cannot find GEM section in Gemfile.lock - is this a valid Bundler lockfile?"
- else
- true;
- gemSectionLines = lib.lists.map (i: takeLines i lines) gemSectionIndices;
- gemSections = lib.lists.map parseGemSection gemSectionLines;
- in
- assert _ == true;
- {
- inherit checksumSection gemSections;
- };
-
- # Invert gem sections into a flat { gemName = remote; ... } lookup.
- # Last-writer-wins when a gem appears in multiple sections.
- # TODO: group by gem name for multiple remotes; e.g., depot depends on faraday
- # which shows up in both but we prefer rubygems.org.
- buildGemRemotes =
- gemSections:
- builtins.listToAttrs (
- lib.lists.flatten (
- lib.lists.map (
- section:
- lib.lists.map (gem: {
- name = gem;
- value = section.remote;
- }) section.gems
- ) gemSections
- )
- );
-
- # Merge parsed checksums with group info and remote URLs into the final
- # gem metadata list that the rest of the pipeline expects.
- mergeGemMetadata =
- {
- checksumSection,
- gemRemotes,
- gemGroups,
- }:
- lib.lists.map (gemAttrs: {
- inherit (gemAttrs)
- gemName
- platform
- version
- ;
-
- # Build-time deps (e.g., mini_portile2) may appear in the lock but not
- # in the group parser output. Default to empty groups so they get
- # filtered out rather than crashing.
- groups = gemGroups.${gemAttrs.gemName} or [ ];
-
- source = gemAttrs.source // {
- remotes = [ gemRemotes.${gemAttrs.gemName} ];
- type = "gem"; # todo: git, path sources
- };
- }) checksumSection;
-
-in
-{
- inherit
- findIndices
- takeLines
- parseChecksumLine
- parseGemSection
- parseLockfileContent
- buildGemRemotes
- mergeGemMetadata
- ;
-}
diff --git a/lib/gemfile-env/filter-helpers.nix b/lib/gemfile-env/resolve.nix
similarity index 62%
rename from lib/gemfile-env/filter-helpers.nix
rename to lib/gemfile-env/resolve.nix
index 18698c2..5eb066d 100644
--- a/lib/gemfile-env/filter-helpers.nix
+++ b/lib/gemfile-env/resolve.nix
@@ -19,6 +19,29 @@
gemConfig: attrs:
if gemConfig ? ${attrs.gemName} then attrs // gemConfig.${attrs.gemName} attrs else attrs;
+ # Expand a set of gem names to include all transitive dependencies.
+ # depGraph: { gemName = [ "dep1" "dep2" ... ]; ... } from parseDependencies
+ # initialNames: list of gem names that survived group+platform filtering
+ # Returns: expanded list of gem names including all transitive deps.
+ # Uses a convergent fixpoint: iterates until no new deps are added.
+ expandTransitiveDeps =
+ depGraph: initialNames:
+ let
+ step =
+ names:
+ let
+ newDeps = lib.unique (lib.flatten (map (n: depGraph.${n} or [ ]) names));
+ in
+ lib.unique (names ++ newDeps);
+ converge =
+ prev:
+ let
+ next = step prev;
+ in
+ if next == prev then prev else converge next;
+ in
+ converge initialNames;
+
# Map a nixpkgs system string to the Ruby platform strings that should be
# accepted from a Gemfile.lock. Always includes "ruby" (pure-Ruby gems).
#
@@ -56,7 +79,31 @@
if mapping ? ${system} then
mapping.${system}
else
- throw "platformsForSystem: unsupported system '${system}'. Pass an explicit `platforms` list or extend platformsForSystem.";
+ throw "gems4nix: unsupported system '${system}'. Supported: aarch64-darwin, x86_64-darwin, aarch64-linux, x86_64-linux. Or pass an explicit `platforms` list.";
+
+ # Check whether a lockfile has precompiled native gem variants.
+ # If the target platforms include non-ruby platforms but NO gem in the
+ # parsed lockfile has a non-ruby platform, emit a lib.warn advising the
+ # user to add platform variants to their lockfile.
+ #
+ # gems: flat list of gem attrsets (the full parsed lockfile, before filtering)
+ # platforms: resolved platform list (from platformsForSystem or user)
+ # Returns: platforms (pass-through), but with a warning attached if needed.
+ warnIfNoPlatformGems =
+ gems: platforms:
+ let
+ wantNative = builtins.any (p: p != "ruby") platforms;
+ hasNative = builtins.any (g: g.platform != "ruby") gems;
+ platformsStr = lib.concatStringsSep " " (builtins.filter (p: p != "ruby") platforms);
+ in
+ if wantNative && !hasNative then
+ lib.warn ''
+ gems4nix: Your Gemfile.lock contains no precompiled native gem variants.
+ All native gems will be compiled from source, which may be slow or fail.
+ To add precompiled variants, run:
+ bundle lock --add-platform ${platformsStr}'' platforms
+ else
+ platforms;
# Given a preference-ordered platform list and a list of gems (possibly
# containing duplicates for different platforms), resolve to one gem per
diff --git a/test/fixtures/lockfiles/README.md b/test/fixtures/lockfiles/README.md
new file mode 100644
index 0000000..118dc13
--- /dev/null
+++ b/test/fixtures/lockfiles/README.md
@@ -0,0 +1,20 @@
+# Lockfile Fixtures
+
+Real and synthetic `Gemfile.lock` files used by unit tests.
+
+## Contributing a fixture
+
+1. Copy your `Gemfile.lock` into this directory with a descriptive name
+ (e.g., `grpc-old-style.lock`, `private-remote.lock`).
+2. Ensure the lockfile includes a `CHECKSUMS` section (`bundle lock --add-checksums`).
+3. Add platforms with `bundle lock --add-platform ` if testing
+ platform resolution.
+4. Reference the fixture from a test in `test/unit/`.
+
+## Existing fixtures
+
+| File | Description |
+|------|-------------|
+| `minimal.lock` | Two pure-Ruby gems (rack, rake). Copied from `examples/simple/`. |
+| `medium.lock` | Nokogiri, ffi, puma with multi-platform variants. Copied from `examples/medium/`. |
+| `ruby-only.lock` | Only `ruby` in PLATFORMS -- no precompiled native variants. |
diff --git a/test/fixtures/lockfiles/medium.lock b/test/fixtures/lockfiles/medium.lock
new file mode 100644
index 0000000..591a96b
--- /dev/null
+++ b/test/fixtures/lockfiles/medium.lock
@@ -0,0 +1,93 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ ethon (0.18.0)
+ ffi (>= 1.15.0)
+ logger
+ ffi (1.17.3)
+ ffi (1.17.3-aarch64-linux-gnu)
+ ffi (1.17.3-aarch64-linux-musl)
+ ffi (1.17.3-arm-linux-gnu)
+ ffi (1.17.3-arm-linux-musl)
+ ffi (1.17.3-arm64-darwin)
+ ffi (1.17.3-x86_64-darwin)
+ ffi (1.17.3-x86_64-linux-gnu)
+ ffi (1.17.3-x86_64-linux-musl)
+ logger (1.7.0)
+ mini_portile2 (2.8.9)
+ minitest (5.27.0)
+ nio4r (2.7.5)
+ nokogiri (1.19.2)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ nokogiri (1.19.2-aarch64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-aarch64-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-musl)
+ racc (~> 1.4)
+ puma (6.6.1)
+ nio4r (~> 2.0)
+ racc (1.8.1)
+ rack (3.2.5)
+
+PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ arm64-darwin-24
+ ruby
+ universal-darwin
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ ethon
+ minitest (~> 5.0)
+ nokogiri (~> 1.18)
+ puma (~> 6.0)
+ rack (~> 3.0)
+
+CHECKSUMS
+ ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2
+ ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
+ ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068
+ ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2
+ ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668
+ ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053
+ ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f
+ ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5
+ ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
+ ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
+ minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
+ nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+ nokogiri (1.19.2) sha256=38fdd8b59db3d5ea9e7dfb14702e882b9bf819198d5bf976f17ebce12c481756
+ nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19
+ nokogiri (1.19.2-aarch64-linux-musl) sha256=7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515
+ nokogiri (1.19.2-arm-linux-gnu) sha256=b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081
+ nokogiri (1.19.2-arm-linux-musl) sha256=61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c
+ nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205
+ nokogiri (1.19.2-x86_64-darwin) sha256=7d9af11fda72dfaa2961d8c4d5380ca0b51bc389dc5f8d4b859b9644f195e7a4
+ nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
+ nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
+ puma (6.6.1) sha256=b9b56e4a4ea75d1bfa6d9e1972ee2c9f43d0883f011826d914e8e37b3694ea1e
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
+ rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
+
+BUNDLED WITH
+ 2.5.22
diff --git a/test/fixtures/lockfiles/minimal.lock b/test/fixtures/lockfiles/minimal.lock
new file mode 100644
index 0000000..70a14c4
--- /dev/null
+++ b/test/fixtures/lockfiles/minimal.lock
@@ -0,0 +1,27 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ rack (3.2.5)
+ rake (13.3.1)
+
+PLATFORMS
+ aarch64-linux
+ aarch64-linux-gnu
+ arm64-darwin
+ arm64-darwin-24
+ ruby
+ universal-darwin
+ x86_64-darwin
+ x86_64-linux
+ x86_64-linux-gnu
+
+DEPENDENCIES
+ rack (~> 3.0)
+ rake (~> 13.0)
+
+CHECKSUMS
+ rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+
+BUNDLED WITH
+ 2.7.2
diff --git a/test/fixtures/lockfiles/ruby-only.lock b/test/fixtures/lockfiles/ruby-only.lock
new file mode 100644
index 0000000..efead0a
--- /dev/null
+++ b/test/fixtures/lockfiles/ruby-only.lock
@@ -0,0 +1,19 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ rack (3.2.5)
+ rake (13.3.1)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ rack (~> 3.0)
+ rake (~> 13.0)
+
+CHECKSUMS
+ rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+
+BUNDLED WITH
+ 2.7.2
diff --git a/test/helpers.nix b/test/helpers.nix
new file mode 100644
index 0000000..ae20bcb
--- /dev/null
+++ b/test/helpers.nix
@@ -0,0 +1,64 @@
+# Shared test helpers for gems4nix unit tests.
+#
+# Usage:
+# inherit (import ../helpers.nix) assertEq assertThrows expectedFailure mkGem;
+
+{
+ # Assert two values are equal, with a descriptive name on failure.
+ assertEq =
+ name: actual: expected:
+ if actual == expected then
+ true
+ else
+ throw "FAIL: ${name}\n expected: ${builtins.toJSON expected}\n actual: ${builtins.toJSON actual}";
+
+ # Assert that evaluating an expression throws (any error).
+ # Uses builtins.tryEval + deepSeq so it catches `throw` but NOT `abort`
+ # (e.g., raw builtins.elemAt out-of-bounds). If you need to test that an
+ # abort becomes a throw, fix the code first (that's the point).
+ assertThrows =
+ name: expr:
+ let
+ result = builtins.tryEval (builtins.deepSeq expr expr);
+ in
+ if result.success then
+ throw "FAIL: ${name}\n expected an error but got: ${builtins.toJSON result.value}"
+ else
+ true;
+
+ # Document a known bug. Returns true when the bug still exists (assertion
+ # fails as expected). When the bug is fixed, this will throw, reminding you
+ # to move the test to allTests as a positive assertion.
+ expectedFailure =
+ name: expr:
+ let
+ result = builtins.tryEval (builtins.deepSeq expr expr);
+ in
+ if !result.success then
+ true # bug still present, expected
+ else
+ throw "expectedFailure: ${name} -- this bug appears to be FIXED! Move this test to allTests.";
+
+ # Minimal gem fixture for testing. Produces a gem attrset with sensible
+ # defaults that can be overridden per-test.
+ mkGem =
+ {
+ gemName,
+ platform ? "ruby",
+ groups ? [ "default" ],
+ version ? "1.0.0",
+ }:
+ {
+ inherit
+ gemName
+ platform
+ groups
+ version
+ ;
+ source = {
+ sha256 = "fake";
+ remotes = [ "https://rubygems.org" ];
+ type = "gem";
+ };
+ };
+}
diff --git a/test/integration/platform-gems/Gemfile b/test/integration/platform-gems/Gemfile
new file mode 100644
index 0000000..6ad5fca
--- /dev/null
+++ b/test/integration/platform-gems/Gemfile
@@ -0,0 +1,10 @@
+source 'https://rubygems.org'
+
+gem 'ethon'
+gem 'nokogiri', '~> 1.18'
+gem 'puma', '~> 6.0'
+gem 'rack', '~> 3.0'
+
+group :test do
+ gem 'minitest', '~> 5.0'
+end
diff --git a/test/integration/platform-gems/Gemfile.lock b/test/integration/platform-gems/Gemfile.lock
new file mode 100644
index 0000000..591a96b
--- /dev/null
+++ b/test/integration/platform-gems/Gemfile.lock
@@ -0,0 +1,93 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ ethon (0.18.0)
+ ffi (>= 1.15.0)
+ logger
+ ffi (1.17.3)
+ ffi (1.17.3-aarch64-linux-gnu)
+ ffi (1.17.3-aarch64-linux-musl)
+ ffi (1.17.3-arm-linux-gnu)
+ ffi (1.17.3-arm-linux-musl)
+ ffi (1.17.3-arm64-darwin)
+ ffi (1.17.3-x86_64-darwin)
+ ffi (1.17.3-x86_64-linux-gnu)
+ ffi (1.17.3-x86_64-linux-musl)
+ logger (1.7.0)
+ mini_portile2 (2.8.9)
+ minitest (5.27.0)
+ nio4r (2.7.5)
+ nokogiri (1.19.2)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ nokogiri (1.19.2-aarch64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-aarch64-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-musl)
+ racc (~> 1.4)
+ puma (6.6.1)
+ nio4r (~> 2.0)
+ racc (1.8.1)
+ rack (3.2.5)
+
+PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ arm64-darwin-24
+ ruby
+ universal-darwin
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ ethon
+ minitest (~> 5.0)
+ nokogiri (~> 1.18)
+ puma (~> 6.0)
+ rack (~> 3.0)
+
+CHECKSUMS
+ ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2
+ ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
+ ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068
+ ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2
+ ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668
+ ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053
+ ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f
+ ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5
+ ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
+ ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
+ minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
+ nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+ nokogiri (1.19.2) sha256=38fdd8b59db3d5ea9e7dfb14702e882b9bf819198d5bf976f17ebce12c481756
+ nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19
+ nokogiri (1.19.2-aarch64-linux-musl) sha256=7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515
+ nokogiri (1.19.2-arm-linux-gnu) sha256=b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081
+ nokogiri (1.19.2-arm-linux-musl) sha256=61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c
+ nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205
+ nokogiri (1.19.2-x86_64-darwin) sha256=7d9af11fda72dfaa2961d8c4d5380ca0b51bc389dc5f8d4b859b9644f195e7a4
+ nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
+ nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
+ puma (6.6.1) sha256=b9b56e4a4ea75d1bfa6d9e1972ee2c9f43d0883f011826d914e8e37b3694ea1e
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
+ rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
+
+BUNDLED WITH
+ 2.5.22
diff --git a/test/integration/platform-gems/validate.rb b/test/integration/platform-gems/validate.rb
new file mode 100644
index 0000000..2749271
--- /dev/null
+++ b/test/integration/platform-gems/validate.rb
@@ -0,0 +1,41 @@
+# Integration test: validates platform-dependent gems are loadable and functional.
+# Focuses on nokogiri (native XML extension) and ffi (foreign function interface)
+# since these are the gems whose platform inference is being tested.
+# Exit 1 on any failure so `nix build` treats it as a build error.
+
+failures = []
+
+# nokogiri: native gem with platform-specific precompiled variants.
+# If platform resolution is wrong, this will either fail to load or
+# fall back to the ruby variant (which requires compiling libxml2).
+begin
+ require "nokogiri"
+ v = Nokogiri::VERSION
+ # Actually parse XML to prove the native extension works
+ doc = Nokogiri::XML("- hello
")
+ text = doc.at_xpath("//item").text
+ raise "XML parse failed: got #{text.inspect}" unless text == "hello"
+ puts "OK nokogiri #{v} (XML parsing works)"
+rescue => e
+ failures << "nokogiri: #{e.message}"
+end
+
+# ffi: native gem with platform-specific precompiled variants.
+# Included transitively via ethon (not a direct Gemfile dependency).
+# Tests that the correct platform variant was selected.
+begin
+ require "ffi"
+ v = FFI::VERSION
+
+ # Exercise basic FFI functionality
+ puts "OK ffi #{v}"
+rescue => e
+ failures << "ffi: #{e.message}"
+end
+
+if failures.any?
+ failures.each { |f| $stderr.puts "FAIL #{f}" }
+ exit 1
+else
+ puts "All #{2} platform-dependent gems validated."
+end
diff --git a/test/test-helpers.nix b/test/test-helpers.nix
deleted file mode 100644
index 6375372..0000000
--- a/test/test-helpers.nix
+++ /dev/null
@@ -1,28 +0,0 @@
-# Shared test assertion helpers for gems4nix unit tests.
-#
-# Usage:
-# inherit (import ../test-helpers.nix) assertEq assertThrows;
-
-{
- # Assert two values are equal, with a descriptive name on failure.
- assertEq =
- name: actual: expected:
- if actual == expected then
- true
- else
- throw "FAIL: ${name}\n expected: ${builtins.toJSON expected}\n actual: ${builtins.toJSON actual}";
-
- # Assert that evaluating an expression throws (any error).
- # Uses builtins.tryEval + deepSeq so it catches `throw` but NOT `abort`
- # (e.g., raw builtins.elemAt out-of-bounds). If you need to test that an
- # abort becomes a throw, fix the code first (that's the point).
- assertThrows =
- name: expr:
- let
- result = builtins.tryEval (builtins.deepSeq expr expr);
- in
- if result.success then
- throw "FAIL: ${name}\n expected an error but got: ${builtins.toJSON result.value}"
- else
- true;
-}
diff --git a/test/unit/test-parse-logic.nix b/test/unit/test-parse-logic.nix
new file mode 100644
index 0000000..4a5b294
--- /dev/null
+++ b/test/unit/test-parse-logic.nix
@@ -0,0 +1,772 @@
+# Unit tests for parse.nix (logic only, no fetchTarball)
+#
+# Accepts { lib }: so it can be imported by both:
+# - The standalone wrapper (test-parse.nix) for `nix eval --file` usage
+# - The root flake.nix checks via `import ./test-parse-logic.nix { lib = pkgs.lib; }`
+#
+# Returns: true (all assertions pass) or throws with a descriptive message.
+
+{ lib }:
+
+let
+ helpers = import ../../lib/gemfile-env/parse.nix { inherit lib; };
+ inherit (helpers)
+ findIndices
+ takeLines
+ knownPlatforms
+ splitVersionPlatform
+ parseChecksumLine
+ parseGemSection
+ parseDependencies
+ parseDependenciesSection
+ takeDependenciesSection
+ parseLockfile
+ indexRemotes
+ mergeGemMetadata
+ ;
+ inherit (import ../helpers.nix) assertEq assertThrows;
+
+ # ── findIndices ──────────────────────────────────────────────
+
+ test_findIndices_multiple =
+ assertEq "findIndices: multiple matches"
+ (findIndices (x: x == "GEM") [
+ "GEM"
+ "foo"
+ "bar"
+ "GEM"
+ "baz"
+ ])
+ [
+ 0
+ 3
+ ];
+
+ test_findIndices_none = assertEq "findIndices: no matches" (findIndices (x: x == "NOPE") [
+ "GEM"
+ "foo"
+ "bar"
+ ]) [ ];
+
+ test_findIndices_single = assertEq "findIndices: single match" (findIndices (x: x == "bar") [
+ "foo"
+ "bar"
+ "baz"
+ ]) [ 1 ];
+
+ # ── takeLines ────────────────────────────────────────────────
+
+ test_takeLines_basic =
+ assertEq "takeLines: lines until blank"
+ (takeLines 0 [
+ "HEADER"
+ " line1"
+ " line2"
+ ""
+ " line3"
+ ])
+ [
+ " line1"
+ " line2"
+ ];
+
+ test_takeLines_no_blank =
+ assertEq "takeLines: no blank line (runs to end)"
+ (takeLines 0 [
+ "HEADER"
+ "a"
+ "b"
+ "c"
+ ])
+ [
+ "a"
+ "b"
+ "c"
+ ];
+
+ test_takeLines_immediate_blank = assertEq "takeLines: blank immediately after header" (takeLines 0 [
+ "HEADER"
+ ""
+ "stuff"
+ ]) [ ];
+
+ test_takeLines_offset =
+ assertEq "takeLines: with offset"
+ (takeLines 2 [
+ "skip"
+ "skip"
+ "HEADER"
+ " a"
+ " b"
+ ""
+ " c"
+ ])
+ [
+ " a"
+ " b"
+ ];
+
+ # ── parseChecksumLine: happy path ────────────────────────────
+
+ test_parseChecksum_simple =
+ let
+ result = parseChecksumLine " zeitwerk (2.7.2) sha256=842e067cb11eb923d747249badfb5fcdc9652d6f20a1f06453317920fdcd4673";
+ in
+ assertEq "parseChecksumLine: simple gem - gemName" result.gemName "zeitwerk"
+ && assertEq "parseChecksumLine: simple gem - version" result.version "2.7.2"
+ && assertEq "parseChecksumLine: simple gem - platform" result.platform "ruby"
+ &&
+ assertEq "parseChecksumLine: simple gem - sha256" result.source.sha256
+ "842e067cb11eb923d747249badfb5fcdc9652d6f20a1f06453317920fdcd4673";
+
+ test_parseChecksum_platform =
+ let
+ result = parseChecksumLine " nokogiri (1.18.8-arm64-darwin) sha256=483b5b9fb33653f6f05cbe00d09ea315f268f0e707cfc809aa39b62993008212";
+ in
+ assertEq "parseChecksumLine: platform gem - gemName" result.gemName "nokogiri"
+ && assertEq "parseChecksumLine: platform gem - version" result.version "1.18.8"
+ && assertEq "parseChecksumLine: platform gem - platform" result.platform "arm64-darwin"
+ &&
+ assertEq "parseChecksumLine: platform gem - sha256" result.source.sha256
+ "483b5b9fb33653f6f05cbe00d09ea315f268f0e707cfc809aa39b62993008212";
+
+ test_parseChecksum_multi_segment_platform =
+ let
+ result = parseChecksumLine " ffi (1.17.2-aarch64-linux-gnu) sha256=c910bd3cae70b76690418cce4572b7f6c208d271f323d692a067d59116211a1a";
+ in
+ assertEq "parseChecksumLine: multi-segment platform - gemName" result.gemName "ffi"
+ && assertEq "parseChecksumLine: multi-segment platform - version" result.version "1.17.2"
+ &&
+ assertEq "parseChecksumLine: multi-segment platform - platform" result.platform
+ "aarch64-linux-gnu"
+ &&
+ assertEq "parseChecksumLine: multi-segment platform - sha256" result.source.sha256
+ "c910bd3cae70b76690418cce4572b7f6c208d271f323d692a067d59116211a1a";
+
+ # ── parseChecksumLine: malformed input (recommendation #1) ──
+
+ # Git/path gems appear in CHECKSUMS without a hash; parseChecksumLine
+ # returns null for these so the caller can filter them out.
+ test_parseChecksum_missing_hash_returns_null =
+ assertEq "parseChecksumLine: missing hash returns null (git/path gem)"
+ (parseChecksumLine " errgonomic (0.5.1)")
+ null;
+
+ test_parseChecksum_extra_leading_spaces = assertThrows "parseChecksumLine: extra leading spaces should throw a helpful error" (
+ parseChecksumLine " zeitwerk (2.6.18) sha256=abc123"
+ );
+
+ test_parseChecksum_empty_line_returns_null =
+ assertEq "parseChecksumLine: empty line returns null" (parseChecksumLine "")
+ null;
+
+ # ── parseGemSection ──────────────────────────────────────────
+
+ test_parseGemSection_basic =
+ let
+ result = parseGemSection [
+ " remote: https://rubygems.org/"
+ " specs:"
+ " abbrev (0.1.2)"
+ " zeitwerk (2.7.2)"
+ ];
+ in
+ assertEq "parseGemSection: remote (trailing slash stripped)" result.remote "https://rubygems.org"
+ && assertEq "parseGemSection: gems list" result.gems [
+ "abbrev"
+ "zeitwerk"
+ ];
+
+ test_parseGemSection_no_trailing_slash =
+ let
+ result = parseGemSection [
+ " remote: https://rubygems.pkg.github.com/omc"
+ " specs:"
+ " depot (1.4.0)"
+ ];
+ in
+ assertEq "parseGemSection: remote without trailing slash" result.remote
+ "https://rubygems.pkg.github.com/omc"
+ && assertEq "parseGemSection: gems from private remote" result.gems [ "depot" ];
+
+ test_parseGemSection_deps_included =
+ let
+ result = parseGemSection [
+ " remote: https://rubygems.org/"
+ " specs:"
+ " actioncable (8.0.2)"
+ " actionpack (= 8.0.2)"
+ " activesupport (= 8.0.2)"
+ " zeitwerk (2.7.2)"
+ ];
+ in
+ # dependency lines are included; parseGemSection does not distinguish indent levels.
+ assertEq "parseGemSection: dependency lines included (current behavior)" result.gems [
+ "actioncable"
+ "actionpack"
+ "activesupport"
+ "zeitwerk"
+ ];
+
+ # ── parseLockfile ─────────────────────────────────────
+
+ minimalLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ rake (13.0.6)
+ zeitwerk (2.7.2)
+
+ PLATFORMS
+ ruby
+
+ CHECKSUMS
+ rake (13.0.6) sha256=aaaa
+ zeitwerk (2.7.2) sha256=bbbb
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+
+ test_parseLockfile =
+ let
+ result = parseLockfile minimalLockfile;
+ in
+ assertEq "parseLockfile: checksumSection length" (builtins.length result.checksumSection) 2
+ &&
+ assertEq "parseLockfile: first checksum gemName" (builtins.elemAt result.checksumSection 0).gemName
+ "rake"
+ && assertEq "parseLockfile: gemSections length" (builtins.length result.gemSections) 1
+ &&
+ assertEq "parseLockfile: first section remote" (builtins.elemAt result.gemSections 0).remote
+ "https://rubygems.org";
+
+ test_parseLockfile_missing_checksums = assertThrows "parseLockfile: missing CHECKSUMS throws" (parseLockfile ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ rake (13.0.6)
+
+ PLATFORMS
+ ruby
+ '');
+
+ multiRemoteLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ rake (13.0.6)
+
+ GEM
+ remote: https://private.example.com/
+ specs:
+ mygem (1.0.0)
+
+ CHECKSUMS
+ rake (13.0.6) sha256=aaaa
+ mygem (1.0.0) sha256=bbbb
+ '';
+
+ test_parseLockfile_multi_remote =
+ let
+ result = parseLockfile multiRemoteLockfile;
+ in
+ assertEq "parseLockfile: multi-remote gemSections count" (builtins.length result.gemSections) 2;
+
+ # ── parseLockfile: missing GEM section (critique #5) ──
+
+ test_parseLockfile_missing_gem_section = assertThrows "parseLockfile: missing GEM section throws" (parseLockfile ''
+ CHECKSUMS
+ rake (13.0.6) sha256=aaaa
+
+ BUNDLED WITH
+ 2.5.22
+ '');
+
+ # A lockfile with CHECKSUMS but a completely empty GEM section (no specs)
+ test_parseLockfile_empty_gem_specs =
+ let
+ result = parseLockfile ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+
+ CHECKSUMS
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+ in
+ # empty CHECKSUMS = no gems parsed, and an empty GEM section is valid
+ assertEq "parseLockfile: empty gem specs returns empty checksumSection"
+ (builtins.length result.checksumSection)
+ 0
+ &&
+ assertEq "parseLockfile: empty gem specs still has one gemSection"
+ (builtins.length result.gemSections)
+ 1;
+
+ # ── parseLockfile: git/path gems skipped ─────────────
+
+ gitPathLockfile = ''
+ GIT
+ remote: https://github.com/omc/errgonomic.git
+ revision: abc123
+ branch: main
+ specs:
+ errgonomic (0.5.1)
+
+ PATH
+ remote: vendor/hello_gem
+ specs:
+ hello_gem (0.1.0)
+
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ rake (13.0.6)
+
+ CHECKSUMS
+ errgonomic (0.5.1)
+ hello_gem (0.1.0)
+ rake (13.0.6) sha256=aaaa
+ '';
+
+ test_parseLockfile_skips_hashless =
+ let
+ result = parseLockfile gitPathLockfile;
+ in
+ # Only rake (with a hash) survives; errgonomic and hello_gem are filtered out
+ assertEq "parseLockfile: git/path gems filtered from checksumSection"
+ (builtins.length result.checksumSection)
+ 1
+ &&
+ assertEq "parseLockfile: surviving gem is rake" (builtins.elemAt result.checksumSection 0).gemName
+ "rake";
+
+ # ── indexRemotes ──────────────────────────────────────────
+
+ test_indexRemotes =
+ let
+ sections = [
+ {
+ remote = "https://rubygems.org";
+ gems = [
+ "rake"
+ "zeitwerk"
+ ];
+ }
+ {
+ remote = "https://private.example.com";
+ gems = [ "mygem" ];
+ }
+ ];
+ result = indexRemotes sections;
+ in
+ assertEq "indexRemotes: rake" result.rake "https://rubygems.org"
+ && assertEq "indexRemotes: zeitwerk" result.zeitwerk "https://rubygems.org"
+ && assertEq "indexRemotes: mygem" result.mygem "https://private.example.com";
+
+ test_indexRemotes_first_writer_wins =
+ let
+ sections = [
+ {
+ remote = "https://rubygems.org";
+ gems = [ "faraday" ];
+ }
+ {
+ remote = "https://private.example.com";
+ gems = [ "faraday" ];
+ }
+ ];
+ result = indexRemotes sections;
+ in
+ # builtins.listToAttrs keeps the first occurrence when names collide
+ assertEq "indexRemotes: duplicate gem uses first remote" result.faraday "https://rubygems.org";
+
+ # ── mergeGemMetadata ─────────────────────────────────────────
+
+ test_mergeGemMetadata =
+ let
+ result = mergeGemMetadata {
+ checksumSection = [
+ {
+ gemName = "rake";
+ version = "13.0.6";
+ platform = "ruby";
+ source = {
+ sha256 = "aaaa";
+ };
+ }
+ {
+ gemName = "ffi";
+ version = "1.17.2";
+ platform = "arm64-darwin";
+ source = {
+ sha256 = "bbbb";
+ };
+ }
+ ];
+ gemRemotes = {
+ rake = "https://rubygems.org";
+ ffi = "https://rubygems.org";
+ };
+ gemGroups = {
+ rake = [ "default" ];
+ ffi = [
+ "default"
+ "development"
+ ];
+ };
+ };
+ rake = builtins.elemAt result 0;
+ ffi = builtins.elemAt result 1;
+ in
+ assertEq "mergeGemMetadata: rake groups" rake.groups [ "default" ]
+ && assertEq "mergeGemMetadata: rake remote" rake.source.remotes [ "https://rubygems.org" ]
+ && assertEq "mergeGemMetadata: rake type" rake.source.type "gem"
+ && assertEq "mergeGemMetadata: ffi platform" ffi.platform "arm64-darwin"
+ && assertEq "mergeGemMetadata: ffi groups" ffi.groups [
+ "default"
+ "development"
+ ];
+
+ test_mergeGemMetadata_missing_group_defaults_empty =
+ let
+ result = mergeGemMetadata {
+ checksumSection = [
+ {
+ gemName = "mini_portile2";
+ version = "2.8.0";
+ platform = "ruby";
+ source = {
+ sha256 = "cccc";
+ };
+ }
+ ];
+ gemRemotes = {
+ mini_portile2 = "https://rubygems.org";
+ };
+ gemGroups = { }; # mini_portile2 not in groups (build-time dep)
+ };
+ gem = builtins.elemAt result 0;
+ in
+ assertEq "mergeGemMetadata: missing group defaults to []" gem.groups [ ];
+
+ # ── parseChecksumLine: right-to-left platform parsing ────────
+
+ # Beta version with platform: version contains `-`, must not be confused
+ # with the platform separator.
+ test_parseChecksum_beta_version_with_platform =
+ let
+ result = parseChecksumLine " nokogiri (1.16.0.beta.1-arm64-darwin) sha256=deadbeef";
+ in
+ assertEq "parseChecksumLine: beta version with platform - gemName" result.gemName "nokogiri"
+ && assertEq "parseChecksumLine: beta version with platform - version" result.version "1.16.0.beta.1"
+ &&
+ assertEq "parseChecksumLine: beta version with platform - platform" result.platform
+ "arm64-darwin";
+
+ # Beta version without platform: entire string is the version, platform = ruby.
+ test_parseChecksum_beta_version_no_platform =
+ let
+ result = parseChecksumLine " mygem (2.0.0-rc1) sha256=abcd1234";
+ in
+ assertEq "parseChecksumLine: beta version no platform - gemName" result.gemName "mygem"
+ && assertEq "parseChecksumLine: beta version no platform - version" result.version "2.0.0-rc1"
+ && assertEq "parseChecksumLine: beta version no platform - platform" result.platform "ruby";
+
+ # Pre-release with multi-segment platform
+ test_parseChecksum_prerelease_multi_segment_platform =
+ let
+ result = parseChecksumLine " ffi (2.0.0-beta.2-aarch64-linux-gnu) sha256=face0000";
+ in
+ assertEq "parseChecksumLine: prerelease multi-segment platform - version" result.version
+ "2.0.0-beta.2"
+ &&
+ assertEq "parseChecksumLine: prerelease multi-segment platform - platform" result.platform
+ "aarch64-linux-gnu";
+
+ # splitVersionPlatform: known platform
+ test_splitVersionPlatform_known =
+ let
+ result = splitVersionPlatform "1.18.8-arm64-darwin";
+ in
+ assertEq "splitVersionPlatform: known platform - version" result.version "1.18.8"
+ && assertEq "splitVersionPlatform: known platform - platform" result.platform "arm64-darwin";
+
+ # splitVersionPlatform: no platform (pure version)
+ test_splitVersionPlatform_ruby =
+ let
+ result = splitVersionPlatform "2.7.2";
+ in
+ assertEq "splitVersionPlatform: no platform - version" result.version "2.7.2"
+ && assertEq "splitVersionPlatform: no platform - platform" result.platform "ruby";
+
+ # splitVersionPlatform: unknown suffix treated as version
+ test_splitVersionPlatform_unknown =
+ let
+ result = splitVersionPlatform "1.0.0-beta.1";
+ in
+ assertEq "splitVersionPlatform: unknown suffix is version" result.version "1.0.0-beta.1"
+ && assertEq "splitVersionPlatform: unknown suffix platform is ruby" result.platform "ruby";
+
+ # ── parseDependencies ───────────────────────────────────────
+
+ test_parseDependencies_nokogiri =
+ let
+ result = parseDependencies [
+ " remote: https://rubygems.org/"
+ " specs:"
+ " nokogiri (1.19.2)"
+ " mini_portile2 (~> 2.8.2)"
+ " racc (~> 1.4)"
+ " racc (1.8.1)"
+ ];
+ in
+ assertEq "parseDependencies: nokogiri deps" result.nokogiri [
+ "mini_portile2"
+ "racc"
+ ]
+ && assertEq "parseDependencies: racc has no deps" result.racc [ ];
+
+ test_parseDependencies_multiple_gems =
+ let
+ result = parseDependencies [
+ " remote: https://rubygems.org/"
+ " specs:"
+ " ethon (0.18.0)"
+ " ffi (>= 1.15.0)"
+ " logger"
+ " ffi (1.17.3)"
+ " logger (1.7.0)"
+ " puma (6.6.1)"
+ " nio4r (~> 2.0)"
+ " nio4r (2.7.5)"
+ ];
+ in
+ assertEq "parseDependencies: ethon deps" result.ethon [
+ "ffi"
+ "logger"
+ ]
+ && assertEq "parseDependencies: ffi no deps" result.ffi [ ]
+ && assertEq "parseDependencies: logger no deps" result.logger [ ]
+ && assertEq "parseDependencies: puma deps" result.puma [ "nio4r" ]
+ && assertEq "parseDependencies: nio4r no deps" result.nio4r [ ];
+
+ # Platform variants of the same gem should merge dependencies (union)
+ test_parseDependencies_platform_variants_merge =
+ let
+ result = parseDependencies [
+ " remote: https://rubygems.org/"
+ " specs:"
+ " nokogiri (1.19.2)"
+ " mini_portile2 (~> 2.8.2)"
+ " racc (~> 1.4)"
+ " nokogiri (1.19.2-arm64-darwin)"
+ " racc (~> 1.4)"
+ ];
+ in
+ # Both variants share the name "nokogiri"; the ruby variant has
+ # mini_portile2 + racc, the native variant only has racc.
+ # Since they share a key, deps get merged.
+ assertEq "parseDependencies: platform variants merge deps" result.nokogiri [
+ "mini_portile2"
+ "racc"
+ ];
+
+ # Gems with no dependencies at all
+ test_parseDependencies_no_deps =
+ let
+ result = parseDependencies [
+ " remote: https://rubygems.org/"
+ " specs:"
+ " rack (3.2.5)"
+ " minitest (5.27.0)"
+ ];
+ in
+ assertEq "parseDependencies: rack no deps" result.rack [ ]
+ && assertEq "parseDependencies: minitest no deps" result.minitest [ ];
+
+ # Multi-segment platform gems in specs (e.g., ffi with aarch64-linux-gnu)
+ test_parseDependencies_multi_segment_platform =
+ let
+ result = parseDependencies [
+ " remote: https://rubygems.org/"
+ " specs:"
+ " ffi (1.17.3)"
+ " ffi (1.17.3-aarch64-linux-gnu)"
+ " ffi (1.17.3-x86_64-linux-musl)"
+ ];
+ in
+ # All ffi variants should parse as "ffi" with empty deps
+ assertEq "parseDependencies: multi-segment platform ffi" result.ffi [ ];
+
+ # ── parseDependenciesSection ────────────────────────────────
+
+ test_parseDependenciesSection_basic =
+ let
+ result = parseDependenciesSection [
+ " ethon"
+ " minitest (~> 5.0)"
+ " nokogiri (~> 1.18)"
+ " puma (~> 6.0)"
+ " rack (~> 3.0)"
+ ];
+ in
+ assertEq "parseDependenciesSection: extracts gem names" result [
+ "ethon"
+ "minitest"
+ "nokogiri"
+ "puma"
+ "rack"
+ ];
+
+ # DEPENDENCIES section with no version constraints
+ test_parseDependenciesSection_no_constraints =
+ let
+ result = parseDependenciesSection [
+ " rake"
+ " bundler"
+ ];
+ in
+ assertEq "parseDependenciesSection: no constraints" result [
+ "rake"
+ "bundler"
+ ];
+
+ # ── takeDependenciesSection ─────────────────────────────────
+
+ test_takeDependenciesSection =
+ let
+ lines = [
+ "GEM"
+ " remote: https://rubygems.org/"
+ " specs:"
+ " rake (13.0.6)"
+ ""
+ "PLATFORMS"
+ " ruby"
+ ""
+ "DEPENDENCIES"
+ " rake"
+ " bundler (~> 2.0)"
+ ""
+ "BUNDLED WITH"
+ " 2.5.22"
+ ];
+ result = takeDependenciesSection lines;
+ in
+ assertEq "takeDependenciesSection: extracts DEPENDENCIES lines" result [
+ " rake"
+ " bundler (~> 2.0)"
+ ];
+
+ test_takeDependenciesSection_missing =
+ let
+ lines = [
+ "GEM"
+ " remote: https://rubygems.org/"
+ " specs:"
+ " rake (13.0.6)"
+ ""
+ "CHECKSUMS"
+ " rake (13.0.6) sha256=aaaa"
+ ];
+ result = takeDependenciesSection lines;
+ in
+ assertEq "takeDependenciesSection: missing section returns []" result [ ];
+
+ # ── error message prefixes (Phase 4) ─────────────────────────
+
+ test_parseLockfile_missing_checksums_prefix = assertThrows "parseLockfile: missing CHECKSUMS throws with gems4nix prefix" (parseLockfile ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ rake (13.0.6)
+
+ PLATFORMS
+ ruby
+ '');
+
+ test_parseLockfile_missing_gem_section_prefix = assertThrows "parseLockfile: missing GEM section throws with gems4nix prefix" (parseLockfile ''
+ CHECKSUMS
+ rake (13.0.6) sha256=aaaa
+
+ BUNDLED WITH
+ 2.5.22
+ '');
+
+ test_parseChecksum_bad_version_format = assertThrows "parseChecksumLine: bad version format throws with gems4nix (internal) prefix" (
+ parseChecksumLine " zeitwerk 2.6.18 sha256=abc123"
+ );
+
+ test_parseChecksum_bad_hash_format = assertThrows "parseChecksumLine: bad hash format throws with gems4nix (internal) prefix" (
+ parseChecksumLine " zeitwerk (2.6.18) nohash"
+ );
+
+ # ── all tests ────────────────────────────────────────────────
+
+ allTests =
+ # findIndices
+ test_findIndices_multiple
+ && test_findIndices_none
+ && test_findIndices_single
+ # takeLines
+ && test_takeLines_basic
+ && test_takeLines_no_blank
+ && test_takeLines_immediate_blank
+ && test_takeLines_offset
+ # parseChecksumLine
+ && test_parseChecksum_simple
+ && test_parseChecksum_platform
+ && test_parseChecksum_multi_segment_platform
+ && test_parseChecksum_missing_hash_returns_null
+ && test_parseChecksum_extra_leading_spaces
+ && test_parseChecksum_empty_line_returns_null
+ # parseGemSection
+ && test_parseGemSection_basic
+ && test_parseGemSection_no_trailing_slash
+ && test_parseGemSection_deps_included
+ # parseLockfile
+ && test_parseLockfile
+ && test_parseLockfile_missing_checksums
+ && test_parseLockfile_multi_remote
+ # parseLockfile: missing GEM section
+ && test_parseLockfile_missing_gem_section
+ && test_parseLockfile_empty_gem_specs
+ # parseLockfile: git/path gems
+ && test_parseLockfile_skips_hashless
+ # indexRemotes
+ && test_indexRemotes
+ && test_indexRemotes_first_writer_wins
+ # mergeGemMetadata
+ && test_mergeGemMetadata
+ && test_mergeGemMetadata_missing_group_defaults_empty
+ # parseChecksumLine: right-to-left platform parsing
+ && test_parseChecksum_beta_version_with_platform
+ && test_parseChecksum_beta_version_no_platform
+ && test_parseChecksum_prerelease_multi_segment_platform
+ && test_splitVersionPlatform_known
+ && test_splitVersionPlatform_ruby
+ && test_splitVersionPlatform_unknown
+ # parseDependencies
+ && test_parseDependencies_nokogiri
+ && test_parseDependencies_multiple_gems
+ && test_parseDependencies_platform_variants_merge
+ && test_parseDependencies_no_deps
+ && test_parseDependencies_multi_segment_platform
+ # parseDependenciesSection
+ && test_parseDependenciesSection_basic
+ && test_parseDependenciesSection_no_constraints
+ # takeDependenciesSection
+ && test_takeDependenciesSection
+ && test_takeDependenciesSection_missing
+ # error message prefixes (Phase 4)
+ && test_parseLockfile_missing_checksums_prefix
+ && test_parseLockfile_missing_gem_section_prefix
+ && test_parseChecksum_bad_version_format
+ && test_parseChecksum_bad_hash_format;
+
+in
+allTests
diff --git a/test/unit/test-parse.nix b/test/unit/test-parse.nix
new file mode 100644
index 0000000..42190cd
--- /dev/null
+++ b/test/unit/test-parse.nix
@@ -0,0 +1,15 @@
+# Unit tests for parse.nix (standalone wrapper)
+#
+# Run: nix eval --file test/unit/test-parse.nix --json
+# Returns: true (all assertions pass) or throws with a descriptive message.
+#
+# This is a thin wrapper around test-parse-logic.nix that bootstraps nixpkgs
+# via fetchTarball. The logic file accepts { lib }: and is also imported
+# directly by the root flake.nix checks.
+
+let
+ nixpkgs = import (fetchTarball {
+ url = "https://github.com/NixOS/nixpkgs/archive/nixos-24.11.tar.gz";
+ }) { };
+in
+import ./test-parse-logic.nix { lib = nixpkgs.lib; }
diff --git a/test/unit/test-parser.nix b/test/unit/test-parser.nix
deleted file mode 100644
index bf7106a..0000000
--- a/test/unit/test-parser.nix
+++ /dev/null
@@ -1,493 +0,0 @@
-# Unit tests for parser-helpers.nix
-#
-# Run: nix eval --file test/unit/test-parser.nix --json
-# Returns: true (all assertions pass) or throws with a descriptive message.
-
-let
- nixpkgs = import (fetchTarball {
- url = "https://github.com/NixOS/nixpkgs/archive/nixos-24.11.tar.gz";
- }) { };
- lib = nixpkgs.lib;
- helpers = import ../../lib/gemfile-env/parser-helpers.nix { inherit lib; };
- inherit (helpers)
- findIndices
- takeLines
- parseChecksumLine
- parseGemSection
- parseLockfileContent
- buildGemRemotes
- mergeGemMetadata
- ;
- inherit (import ../test-helpers.nix) assertEq assertThrows;
-
- # ── findIndices ──────────────────────────────────────────────
-
- test_findIndices_multiple =
- assertEq "findIndices: multiple matches"
- (findIndices (x: x == "GEM") [
- "GEM"
- "foo"
- "bar"
- "GEM"
- "baz"
- ])
- [
- 0
- 3
- ];
-
- test_findIndices_none = assertEq "findIndices: no matches" (findIndices (x: x == "NOPE") [
- "GEM"
- "foo"
- "bar"
- ]) [ ];
-
- test_findIndices_single = assertEq "findIndices: single match" (findIndices (x: x == "bar") [
- "foo"
- "bar"
- "baz"
- ]) [ 1 ];
-
- # ── takeLines ────────────────────────────────────────────────
-
- test_takeLines_basic =
- assertEq "takeLines: lines until blank"
- (takeLines 0 [
- "HEADER"
- " line1"
- " line2"
- ""
- " line3"
- ])
- [
- " line1"
- " line2"
- ];
-
- test_takeLines_no_blank =
- assertEq "takeLines: no blank line (runs to end)"
- (takeLines 0 [
- "HEADER"
- "a"
- "b"
- "c"
- ])
- [
- "a"
- "b"
- "c"
- ];
-
- test_takeLines_immediate_blank = assertEq "takeLines: blank immediately after header" (takeLines 0 [
- "HEADER"
- ""
- "stuff"
- ]) [ ];
-
- test_takeLines_offset =
- assertEq "takeLines: with offset"
- (takeLines 2 [
- "skip"
- "skip"
- "HEADER"
- " a"
- " b"
- ""
- " c"
- ])
- [
- " a"
- " b"
- ];
-
- # ── parseChecksumLine: happy path ────────────────────────────
-
- test_parseChecksum_simple =
- let
- result = parseChecksumLine " zeitwerk (2.7.2) sha256=842e067cb11eb923d747249badfb5fcdc9652d6f20a1f06453317920fdcd4673";
- in
- assertEq "parseChecksumLine: simple gem - gemName" result.gemName "zeitwerk"
- && assertEq "parseChecksumLine: simple gem - version" result.version "2.7.2"
- && assertEq "parseChecksumLine: simple gem - platform" result.platform "ruby"
- &&
- assertEq "parseChecksumLine: simple gem - sha256" result.source.sha256
- "842e067cb11eb923d747249badfb5fcdc9652d6f20a1f06453317920fdcd4673";
-
- test_parseChecksum_platform =
- let
- result = parseChecksumLine " nokogiri (1.18.8-arm64-darwin) sha256=483b5b9fb33653f6f05cbe00d09ea315f268f0e707cfc809aa39b62993008212";
- in
- assertEq "parseChecksumLine: platform gem - gemName" result.gemName "nokogiri"
- && assertEq "parseChecksumLine: platform gem - version" result.version "1.18.8"
- && assertEq "parseChecksumLine: platform gem - platform" result.platform "arm64-darwin"
- &&
- assertEq "parseChecksumLine: platform gem - sha256" result.source.sha256
- "483b5b9fb33653f6f05cbe00d09ea315f268f0e707cfc809aa39b62993008212";
-
- test_parseChecksum_multi_segment_platform =
- let
- result = parseChecksumLine " ffi (1.17.2-aarch64-linux-gnu) sha256=c910bd3cae70b76690418cce4572b7f6c208d271f323d692a067d59116211a1a";
- in
- assertEq "parseChecksumLine: multi-segment platform - gemName" result.gemName "ffi"
- && assertEq "parseChecksumLine: multi-segment platform - version" result.version "1.17.2"
- &&
- assertEq "parseChecksumLine: multi-segment platform - platform" result.platform
- "aarch64-linux-gnu"
- &&
- assertEq "parseChecksumLine: multi-segment platform - sha256" result.source.sha256
- "c910bd3cae70b76690418cce4572b7f6c208d271f323d692a067d59116211a1a";
-
- # ── parseChecksumLine: malformed input (recommendation #1) ──
-
- # Git/path gems appear in CHECKSUMS without a hash; parseChecksumLine
- # returns null for these so the caller can filter them out.
- test_parseChecksum_missing_hash_returns_null =
- assertEq "parseChecksumLine: missing hash returns null (git/path gem)"
- (parseChecksumLine " errgonomic (0.5.1)")
- null;
-
- test_parseChecksum_extra_leading_spaces = assertThrows "parseChecksumLine: extra leading spaces should throw a helpful error" (
- parseChecksumLine " zeitwerk (2.6.18) sha256=abc123"
- );
-
- test_parseChecksum_empty_line_returns_null =
- assertEq "parseChecksumLine: empty line returns null" (parseChecksumLine "")
- null;
-
- # ── parseGemSection ──────────────────────────────────────────
-
- test_parseGemSection_basic =
- let
- result = parseGemSection [
- " remote: https://rubygems.org/"
- " specs:"
- " abbrev (0.1.2)"
- " zeitwerk (2.7.2)"
- ];
- in
- assertEq "parseGemSection: remote (trailing slash stripped)" result.remote "https://rubygems.org"
- && assertEq "parseGemSection: gems list" result.gems [
- "abbrev"
- "zeitwerk"
- ];
-
- test_parseGemSection_no_trailing_slash =
- let
- result = parseGemSection [
- " remote: https://rubygems.pkg.github.com/omc"
- " specs:"
- " depot (1.4.0)"
- ];
- in
- assertEq "parseGemSection: remote without trailing slash" result.remote
- "https://rubygems.pkg.github.com/omc"
- && assertEq "parseGemSection: gems from private remote" result.gems [ "depot" ];
-
- test_parseGemSection_deps_included =
- let
- result = parseGemSection [
- " remote: https://rubygems.org/"
- " specs:"
- " actioncable (8.0.2)"
- " actionpack (= 8.0.2)"
- " activesupport (= 8.0.2)"
- " zeitwerk (2.7.2)"
- ];
- in
- # dependency lines are included; parseGemSection does not distinguish indent levels.
- assertEq "parseGemSection: dependency lines included (current behavior)" result.gems [
- "actioncable"
- "actionpack"
- "activesupport"
- "zeitwerk"
- ];
-
- # ── parseLockfileContent ─────────────────────────────────────
-
- minimalLockfile = ''
- GEM
- remote: https://rubygems.org/
- specs:
- rake (13.0.6)
- zeitwerk (2.7.2)
-
- PLATFORMS
- ruby
-
- CHECKSUMS
- rake (13.0.6) sha256=aaaa
- zeitwerk (2.7.2) sha256=bbbb
-
- BUNDLED WITH
- 2.5.22
- '';
-
- test_parseLockfileContent =
- let
- result = parseLockfileContent minimalLockfile;
- in
- assertEq "parseLockfileContent: checksumSection length" (builtins.length result.checksumSection) 2
- &&
- assertEq "parseLockfileContent: first checksum gemName"
- (builtins.elemAt result.checksumSection 0).gemName
- "rake"
- && assertEq "parseLockfileContent: gemSections length" (builtins.length result.gemSections) 1
- &&
- assertEq "parseLockfileContent: first section remote" (builtins.elemAt result.gemSections 0).remote
- "https://rubygems.org";
-
- test_parseLockfileContent_missing_checksums = assertThrows "parseLockfileContent: missing CHECKSUMS throws" (parseLockfileContent ''
- GEM
- remote: https://rubygems.org/
- specs:
- rake (13.0.6)
-
- PLATFORMS
- ruby
- '');
-
- multiRemoteLockfile = ''
- GEM
- remote: https://rubygems.org/
- specs:
- rake (13.0.6)
-
- GEM
- remote: https://private.example.com/
- specs:
- mygem (1.0.0)
-
- CHECKSUMS
- rake (13.0.6) sha256=aaaa
- mygem (1.0.0) sha256=bbbb
- '';
-
- test_parseLockfileContent_multi_remote =
- let
- result = parseLockfileContent multiRemoteLockfile;
- in
- assertEq "parseLockfileContent: multi-remote gemSections count" (builtins.length result.gemSections)
- 2;
-
- # ── parseLockfileContent: missing GEM section (critique #5) ──
-
- test_parseLockfileContent_missing_gem_section = assertThrows "parseLockfileContent: missing GEM section throws" (parseLockfileContent ''
- CHECKSUMS
- rake (13.0.6) sha256=aaaa
-
- BUNDLED WITH
- 2.5.22
- '');
-
- # A lockfile with CHECKSUMS but a completely empty GEM section (no specs)
- test_parseLockfileContent_empty_gem_specs =
- let
- result = parseLockfileContent ''
- GEM
- remote: https://rubygems.org/
- specs:
-
- CHECKSUMS
-
- BUNDLED WITH
- 2.5.22
- '';
- in
- # empty CHECKSUMS = no gems parsed, and an empty GEM section is valid
- assertEq "parseLockfileContent: empty gem specs returns empty checksumSection"
- (builtins.length result.checksumSection)
- 0
- &&
- assertEq "parseLockfileContent: empty gem specs still has one gemSection"
- (builtins.length result.gemSections)
- 1;
-
- # ── parseLockfileContent: git/path gems skipped ─────────────
-
- gitPathLockfile = ''
- GIT
- remote: https://github.com/omc/errgonomic.git
- revision: abc123
- branch: main
- specs:
- errgonomic (0.5.1)
-
- PATH
- remote: vendor/hello_gem
- specs:
- hello_gem (0.1.0)
-
- GEM
- remote: https://rubygems.org/
- specs:
- rake (13.0.6)
-
- CHECKSUMS
- errgonomic (0.5.1)
- hello_gem (0.1.0)
- rake (13.0.6) sha256=aaaa
- '';
-
- test_parseLockfileContent_skips_hashless =
- let
- result = parseLockfileContent gitPathLockfile;
- in
- # Only rake (with a hash) survives; errgonomic and hello_gem are filtered out
- assertEq "parseLockfileContent: git/path gems filtered from checksumSection"
- (builtins.length result.checksumSection)
- 1
- &&
- assertEq "parseLockfileContent: surviving gem is rake"
- (builtins.elemAt result.checksumSection 0).gemName
- "rake";
-
- # ── buildGemRemotes ──────────────────────────────────────────
-
- test_buildGemRemotes =
- let
- sections = [
- {
- remote = "https://rubygems.org";
- gems = [
- "rake"
- "zeitwerk"
- ];
- }
- {
- remote = "https://private.example.com";
- gems = [ "mygem" ];
- }
- ];
- result = buildGemRemotes sections;
- in
- assertEq "buildGemRemotes: rake" result.rake "https://rubygems.org"
- && assertEq "buildGemRemotes: zeitwerk" result.zeitwerk "https://rubygems.org"
- && assertEq "buildGemRemotes: mygem" result.mygem "https://private.example.com";
-
- test_buildGemRemotes_first_writer_wins =
- let
- sections = [
- {
- remote = "https://rubygems.org";
- gems = [ "faraday" ];
- }
- {
- remote = "https://private.example.com";
- gems = [ "faraday" ];
- }
- ];
- result = buildGemRemotes sections;
- in
- # builtins.listToAttrs keeps the first occurrence when names collide
- assertEq "buildGemRemotes: duplicate gem uses first remote" result.faraday "https://rubygems.org";
-
- # ── mergeGemMetadata ─────────────────────────────────────────
-
- test_mergeGemMetadata =
- let
- result = mergeGemMetadata {
- checksumSection = [
- {
- gemName = "rake";
- version = "13.0.6";
- platform = "ruby";
- source = {
- sha256 = "aaaa";
- };
- }
- {
- gemName = "ffi";
- version = "1.17.2";
- platform = "arm64-darwin";
- source = {
- sha256 = "bbbb";
- };
- }
- ];
- gemRemotes = {
- rake = "https://rubygems.org";
- ffi = "https://rubygems.org";
- };
- gemGroups = {
- rake = [ "default" ];
- ffi = [
- "default"
- "development"
- ];
- };
- };
- rake = builtins.elemAt result 0;
- ffi = builtins.elemAt result 1;
- in
- assertEq "mergeGemMetadata: rake groups" rake.groups [ "default" ]
- && assertEq "mergeGemMetadata: rake remote" rake.source.remotes [ "https://rubygems.org" ]
- && assertEq "mergeGemMetadata: rake type" rake.source.type "gem"
- && assertEq "mergeGemMetadata: ffi platform" ffi.platform "arm64-darwin"
- && assertEq "mergeGemMetadata: ffi groups" ffi.groups [
- "default"
- "development"
- ];
-
- test_mergeGemMetadata_missing_group_defaults_empty =
- let
- result = mergeGemMetadata {
- checksumSection = [
- {
- gemName = "mini_portile2";
- version = "2.8.0";
- platform = "ruby";
- source = {
- sha256 = "cccc";
- };
- }
- ];
- gemRemotes = {
- mini_portile2 = "https://rubygems.org";
- };
- gemGroups = { }; # mini_portile2 not in groups (build-time dep)
- };
- gem = builtins.elemAt result 0;
- in
- assertEq "mergeGemMetadata: missing group defaults to []" gem.groups [ ];
-
- # ── all tests ────────────────────────────────────────────────
-
- allTests =
- # findIndices
- test_findIndices_multiple
- && test_findIndices_none
- && test_findIndices_single
- # takeLines
- && test_takeLines_basic
- && test_takeLines_no_blank
- && test_takeLines_immediate_blank
- && test_takeLines_offset
- # parseChecksumLine
- && test_parseChecksum_simple
- && test_parseChecksum_platform
- && test_parseChecksum_multi_segment_platform
- && test_parseChecksum_missing_hash_returns_null
- && test_parseChecksum_extra_leading_spaces
- && test_parseChecksum_empty_line_returns_null
- # parseGemSection
- && test_parseGemSection_basic
- && test_parseGemSection_no_trailing_slash
- && test_parseGemSection_deps_included
- # parseLockfileContent
- && test_parseLockfileContent
- && test_parseLockfileContent_missing_checksums
- && test_parseLockfileContent_multi_remote
- # parseLockfileContent: missing GEM section
- && test_parseLockfileContent_missing_gem_section
- && test_parseLockfileContent_empty_gem_specs
- # parseLockfileContent: git/path gems
- && test_parseLockfileContent_skips_hashless
- # buildGemRemotes
- && test_buildGemRemotes
- && test_buildGemRemotes_first_writer_wins
- # mergeGemMetadata
- && test_mergeGemMetadata
- && test_mergeGemMetadata_missing_group_defaults_empty;
-
-in
-allTests
diff --git a/test/unit/test-pipeline-logic.nix b/test/unit/test-pipeline-logic.nix
new file mode 100644
index 0000000..6cf3922
--- /dev/null
+++ b/test/unit/test-pipeline-logic.nix
@@ -0,0 +1,614 @@
+# Unit tests for pipeline: full pipeline from lockfile content to
+# resolved gems per system.
+#
+# Accepts { lib }: so it can be imported by both:
+# - The standalone wrapper (test-pipeline.nix) for `nix eval --file` usage
+# - The root flake.nix checks via `import ./test-pipeline-logic.nix { lib = pkgs.lib; }`
+#
+# Tests the FULL pipeline: parseLockfile -> filterPlatform -> resolvePlatforms
+# across all 4 systems with realistic synthetic lockfile data.
+#
+# Returns: true (all assertions pass) or throws with a descriptive message.
+
+{ lib }:
+
+let
+ parserHelpers = import ../../lib/gemfile-env/parse.nix { inherit lib; };
+ filterHelpers = import ../../lib/gemfile-env/resolve.nix { inherit lib; };
+ inherit (parserHelpers)
+ parseLockfile
+ indexRemotes
+ mergeGemMetadata
+ parseDependencies
+ ;
+ inherit (filterHelpers)
+ filterGroup
+ filterPlatform
+ resolvePlatforms
+ platformsForSystem
+ expandTransitiveDeps
+ ;
+ inherit (import ../helpers.nix) assertEq assertThrows;
+
+ # ── synthetic lockfile: nokogiri + ffi + grpc (old-style bare platforms) ──
+ #
+ # This simulates a lockfile with:
+ # - nokogiri: all standard platform variants
+ # - ffi: all standard platform variants
+ # - grpc (v1.73): OLD-STYLE bare platform names (aarch64-linux, x86_64-linux)
+ # - rake: ruby-only (control gem)
+
+ syntheticLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ ffi (1.17.3)
+ ffi (1.17.3-aarch64-linux-gnu)
+ ffi (1.17.3-aarch64-linux-musl)
+ ffi (1.17.3-arm64-darwin)
+ ffi (1.17.3-x86_64-darwin)
+ ffi (1.17.3-x86_64-linux-gnu)
+ ffi (1.17.3-x86_64-linux-musl)
+ grpc (1.73.0)
+ grpc (1.73.0-aarch64-linux)
+ grpc (1.73.0-x86_64-linux)
+ grpc (1.73.0-arm64-darwin)
+ grpc (1.73.0-x86_64-darwin)
+ nokogiri (1.19.2)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ nokogiri (1.19.2-aarch64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-aarch64-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.2-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.2-x86_64-linux-musl)
+ racc (~> 1.4)
+ rake (13.2.1)
+
+ PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm64-darwin
+ ruby
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+ CHECKSUMS
+ ffi (1.17.3) sha256=aaa0000000000000000000000000000000000000000000000000000000000001
+ ffi (1.17.3-aarch64-linux-gnu) sha256=aaa0000000000000000000000000000000000000000000000000000000000002
+ ffi (1.17.3-aarch64-linux-musl) sha256=aaa0000000000000000000000000000000000000000000000000000000000003
+ ffi (1.17.3-arm64-darwin) sha256=aaa0000000000000000000000000000000000000000000000000000000000004
+ ffi (1.17.3-x86_64-darwin) sha256=aaa0000000000000000000000000000000000000000000000000000000000005
+ ffi (1.17.3-x86_64-linux-gnu) sha256=aaa0000000000000000000000000000000000000000000000000000000000006
+ ffi (1.17.3-x86_64-linux-musl) sha256=aaa0000000000000000000000000000000000000000000000000000000000007
+ grpc (1.73.0) sha256=bbb0000000000000000000000000000000000000000000000000000000000001
+ grpc (1.73.0-aarch64-linux) sha256=bbb0000000000000000000000000000000000000000000000000000000000002
+ grpc (1.73.0-x86_64-linux) sha256=bbb0000000000000000000000000000000000000000000000000000000000003
+ grpc (1.73.0-arm64-darwin) sha256=bbb0000000000000000000000000000000000000000000000000000000000004
+ grpc (1.73.0-x86_64-darwin) sha256=bbb0000000000000000000000000000000000000000000000000000000000005
+ nokogiri (1.19.2) sha256=ccc0000000000000000000000000000000000000000000000000000000000001
+ nokogiri (1.19.2-aarch64-linux-gnu) sha256=ccc0000000000000000000000000000000000000000000000000000000000002
+ nokogiri (1.19.2-aarch64-linux-musl) sha256=ccc0000000000000000000000000000000000000000000000000000000000003
+ nokogiri (1.19.2-arm64-darwin) sha256=ccc0000000000000000000000000000000000000000000000000000000000004
+ nokogiri (1.19.2-x86_64-darwin) sha256=ccc0000000000000000000000000000000000000000000000000000000000005
+ nokogiri (1.19.2-x86_64-linux-gnu) sha256=ccc0000000000000000000000000000000000000000000000000000000000006
+ nokogiri (1.19.2-x86_64-linux-musl) sha256=ccc0000000000000000000000000000000000000000000000000000000000007
+ rake (13.2.1) sha256=ddd0000000000000000000000000000000000000000000000000000000000001
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+
+ # ── synthetic lockfile: grpc new-style (aarch64-linux-gnu) ──
+ #
+ # Simulates grpc >= 1.74 which uses -gnu/-musl suffixed platform names.
+
+ grpcNewStyleLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ grpc (1.74.0)
+ grpc (1.74.0-aarch64-linux-gnu)
+ grpc (1.74.0-aarch64-linux-musl)
+ grpc (1.74.0-x86_64-linux-gnu)
+ grpc (1.74.0-x86_64-linux-musl)
+ grpc (1.74.0-arm64-darwin)
+ grpc (1.74.0-x86_64-darwin)
+
+ PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm64-darwin
+ ruby
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+ CHECKSUMS
+ grpc (1.74.0) sha256=eee0000000000000000000000000000000000000000000000000000000000001
+ grpc (1.74.0-aarch64-linux-gnu) sha256=eee0000000000000000000000000000000000000000000000000000000000002
+ grpc (1.74.0-aarch64-linux-musl) sha256=eee0000000000000000000000000000000000000000000000000000000000003
+ grpc (1.74.0-x86_64-linux-gnu) sha256=eee0000000000000000000000000000000000000000000000000000000000004
+ grpc (1.74.0-x86_64-linux-musl) sha256=eee0000000000000000000000000000000000000000000000000000000000005
+ grpc (1.74.0-arm64-darwin) sha256=eee0000000000000000000000000000000000000000000000000000000000006
+ grpc (1.74.0-x86_64-darwin) sha256=eee0000000000000000000000000000000000000000000000000000000000007
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+
+ # ── synthetic lockfile: grpc with BOTH bare and -gnu (preference test) ──
+ #
+ # This is a hypothetical lockfile containing both aarch64-linux AND
+ # aarch64-linux-gnu for the same gem. Tests that bare wins because it
+ # appears earlier in platformsForSystem.
+
+ grpcBothStylesLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ grpc (1.73.99)
+ grpc (1.73.99-aarch64-linux)
+ grpc (1.73.99-aarch64-linux-gnu)
+ grpc (1.73.99-x86_64-linux)
+ grpc (1.73.99-x86_64-linux-gnu)
+ grpc (1.73.99-arm64-darwin)
+ grpc (1.73.99-x86_64-darwin)
+
+ PLATFORMS
+ aarch64-linux
+ aarch64-linux-gnu
+ arm64-darwin
+ ruby
+ x86_64-darwin
+ x86_64-linux
+ x86_64-linux-gnu
+
+ CHECKSUMS
+ grpc (1.73.99) sha256=fff0000000000000000000000000000000000000000000000000000000000001
+ grpc (1.73.99-aarch64-linux) sha256=fff0000000000000000000000000000000000000000000000000000000000002
+ grpc (1.73.99-aarch64-linux-gnu) sha256=fff0000000000000000000000000000000000000000000000000000000000003
+ grpc (1.73.99-x86_64-linux) sha256=fff0000000000000000000000000000000000000000000000000000000000004
+ grpc (1.73.99-x86_64-linux-gnu) sha256=fff0000000000000000000000000000000000000000000000000000000000005
+ grpc (1.73.99-arm64-darwin) sha256=fff0000000000000000000000000000000000000000000000000000000000006
+ grpc (1.73.99-x86_64-darwin) sha256=fff0000000000000000000000000000000000000000000000000000000000007
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+
+ # ── synthetic lockfile: ruby-only gem (no platform variants) ──
+ #
+ # Tests that a gem with only the ruby variant is correctly selected.
+
+ rubyOnlyLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ httparty (0.22.0)
+
+ PLATFORMS
+ ruby
+
+ CHECKSUMS
+ httparty (0.22.0) sha256=ggg0000000000000000000000000000000000000000000000000000000000001
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+
+ # ── helpers: run the full pipeline for a given lockfile and system ──
+
+ # Simulate gemGroups: all gems in "default" group.
+ # Note: real group assignment comes from gem-groups.rb (a Ruby script run via
+ # runCommand in parse-gemfile-and-lockfile.nix). This pure-Nix test cannot
+ # exercise that IO boundary; it synthesizes groups to test the platform
+ # resolution pipeline in isolation. Group parsing coverage is provided by
+ # the integration test (which runs the full gemfileEnv pipeline with real
+ # Gemfile/Gemfile.lock) and by the filterGroup unit tests in test-resolve-logic.nix.
+ defaultGroups =
+ checksumSection:
+ builtins.listToAttrs (
+ map (gem: {
+ name = gem.gemName;
+ value = [ "default" ];
+ }) checksumSection
+ );
+
+ runPipeline =
+ { lockfileContent, system }:
+ let
+ parsed = parseLockfile lockfileContent;
+ gemRemotes = indexRemotes parsed.gemSections;
+ gemGroups = defaultGroups parsed.checksumSection;
+ gems = mergeGemMetadata {
+ inherit (parsed) checksumSection;
+ inherit gemRemotes gemGroups;
+ };
+ platforms = platformsForSystem system;
+ afterGroup = builtins.filter (filterGroup [ "default" ]) gems;
+ afterPlatform = builtins.filter (filterPlatform platforms) afterGroup;
+ resolved = resolvePlatforms platforms afterPlatform;
+ in
+ resolved;
+
+ # ── main lockfile tests: nokogiri, ffi, grpc (old-style) ────
+
+ # aarch64-darwin
+ resultAarch64Darwin = runPipeline {
+ lockfileContent = syntheticLockfile;
+ system = "aarch64-darwin";
+ };
+
+ test_aarch64_darwin_nokogiri =
+ assertEq "aarch64-darwin: nokogiri resolves to arm64-darwin" resultAarch64Darwin.nokogiri.platform
+ "arm64-darwin";
+
+ test_aarch64_darwin_ffi =
+ assertEq "aarch64-darwin: ffi resolves to arm64-darwin" resultAarch64Darwin.ffi.platform
+ "arm64-darwin";
+
+ test_aarch64_darwin_grpc =
+ assertEq "aarch64-darwin: grpc (old-style) resolves to arm64-darwin"
+ resultAarch64Darwin.grpc.platform
+ "arm64-darwin";
+
+ test_aarch64_darwin_rake =
+ assertEq "aarch64-darwin: rake stays ruby" resultAarch64Darwin.rake.platform
+ "ruby";
+
+ # aarch64-linux
+ resultAarch64Linux = runPipeline {
+ lockfileContent = syntheticLockfile;
+ system = "aarch64-linux";
+ };
+
+ test_aarch64_linux_nokogiri =
+ assertEq "aarch64-linux: nokogiri resolves to aarch64-linux-gnu"
+ resultAarch64Linux.nokogiri.platform
+ "aarch64-linux-gnu";
+
+ test_aarch64_linux_ffi =
+ assertEq "aarch64-linux: ffi resolves to aarch64-linux-gnu" resultAarch64Linux.ffi.platform
+ "aarch64-linux-gnu";
+
+ test_aarch64_linux_grpc_old_style =
+ assertEq "aarch64-linux: grpc (old-style bare) resolves to aarch64-linux"
+ resultAarch64Linux.grpc.platform
+ "aarch64-linux";
+
+ test_aarch64_linux_rake =
+ assertEq "aarch64-linux: rake stays ruby" resultAarch64Linux.rake.platform
+ "ruby";
+
+ # x86_64-darwin
+ resultX8664Darwin = runPipeline {
+ lockfileContent = syntheticLockfile;
+ system = "x86_64-darwin";
+ };
+
+ test_x86_64_darwin_nokogiri =
+ assertEq "x86_64-darwin: nokogiri resolves to x86_64-darwin" resultX8664Darwin.nokogiri.platform
+ "x86_64-darwin";
+
+ test_x86_64_darwin_ffi =
+ assertEq "x86_64-darwin: ffi resolves to x86_64-darwin" resultX8664Darwin.ffi.platform
+ "x86_64-darwin";
+
+ test_x86_64_darwin_grpc =
+ assertEq "x86_64-darwin: grpc (old-style) resolves to x86_64-darwin" resultX8664Darwin.grpc.platform
+ "x86_64-darwin";
+
+ test_x86_64_darwin_rake =
+ assertEq "x86_64-darwin: rake stays ruby" resultX8664Darwin.rake.platform
+ "ruby";
+
+ # x86_64-linux
+ resultX8664Linux = runPipeline {
+ lockfileContent = syntheticLockfile;
+ system = "x86_64-linux";
+ };
+
+ test_x86_64_linux_nokogiri =
+ assertEq "x86_64-linux: nokogiri resolves to x86_64-linux-gnu" resultX8664Linux.nokogiri.platform
+ "x86_64-linux-gnu";
+
+ test_x86_64_linux_ffi =
+ assertEq "x86_64-linux: ffi resolves to x86_64-linux-gnu" resultX8664Linux.ffi.platform
+ "x86_64-linux-gnu";
+
+ test_x86_64_linux_grpc_old_style =
+ assertEq "x86_64-linux: grpc (old-style bare) resolves to x86_64-linux"
+ resultX8664Linux.grpc.platform
+ "x86_64-linux";
+
+ test_x86_64_linux_rake =
+ assertEq "x86_64-linux: rake stays ruby" resultX8664Linux.rake.platform
+ "ruby";
+
+ # ── grpc new-style tests (>= 1.74, -gnu suffixed) ──────────
+
+ resultGrpcNewAarch64Linux = runPipeline {
+ lockfileContent = grpcNewStyleLockfile;
+ system = "aarch64-linux";
+ };
+
+ test_grpc_new_style_aarch64_linux =
+ assertEq "aarch64-linux: grpc (new-style -gnu) resolves to aarch64-linux-gnu"
+ resultGrpcNewAarch64Linux.grpc.platform
+ "aarch64-linux-gnu";
+
+ resultGrpcNewX8664Linux = runPipeline {
+ lockfileContent = grpcNewStyleLockfile;
+ system = "x86_64-linux";
+ };
+
+ test_grpc_new_style_x86_64_linux =
+ assertEq "x86_64-linux: grpc (new-style -gnu) resolves to x86_64-linux-gnu"
+ resultGrpcNewX8664Linux.grpc.platform
+ "x86_64-linux-gnu";
+
+ resultGrpcNewAarch64Darwin = runPipeline {
+ lockfileContent = grpcNewStyleLockfile;
+ system = "aarch64-darwin";
+ };
+
+ test_grpc_new_style_aarch64_darwin =
+ assertEq "aarch64-darwin: grpc (new-style) resolves to arm64-darwin"
+ resultGrpcNewAarch64Darwin.grpc.platform
+ "arm64-darwin";
+
+ resultGrpcNewX8664Darwin = runPipeline {
+ lockfileContent = grpcNewStyleLockfile;
+ system = "x86_64-darwin";
+ };
+
+ test_grpc_new_style_x86_64_darwin =
+ assertEq "x86_64-darwin: grpc (new-style) resolves to x86_64-darwin"
+ resultGrpcNewX8664Darwin.grpc.platform
+ "x86_64-darwin";
+
+ # ── grpc preference test: bare vs -gnu (when both present) ──
+
+ resultGrpcBothAarch64Linux = runPipeline {
+ lockfileContent = grpcBothStylesLockfile;
+ system = "aarch64-linux";
+ };
+
+ test_grpc_both_aarch64_linux_prefers_bare =
+ assertEq "aarch64-linux: grpc prefers bare aarch64-linux over aarch64-linux-gnu"
+ resultGrpcBothAarch64Linux.grpc.platform
+ "aarch64-linux";
+
+ resultGrpcBothX8664Linux = runPipeline {
+ lockfileContent = grpcBothStylesLockfile;
+ system = "x86_64-linux";
+ };
+
+ test_grpc_both_x86_64_linux_prefers_bare =
+ assertEq "x86_64-linux: grpc prefers bare x86_64-linux over x86_64-linux-gnu"
+ resultGrpcBothX8664Linux.grpc.platform
+ "x86_64-linux";
+
+ # ── ruby fallback: gem with no platform-specific variants ───
+
+ resultRubyOnlyAarch64Darwin = runPipeline {
+ lockfileContent = rubyOnlyLockfile;
+ system = "aarch64-darwin";
+ };
+
+ test_ruby_fallback_aarch64_darwin =
+ assertEq "aarch64-darwin: ruby-only gem correctly selects ruby"
+ resultRubyOnlyAarch64Darwin.httparty.platform
+ "ruby";
+
+ resultRubyOnlyAarch64Linux = runPipeline {
+ lockfileContent = rubyOnlyLockfile;
+ system = "aarch64-linux";
+ };
+
+ test_ruby_fallback_aarch64_linux =
+ assertEq "aarch64-linux: ruby-only gem correctly selects ruby"
+ resultRubyOnlyAarch64Linux.httparty.platform
+ "ruby";
+
+ # ── ruby NOT selected when platform-specific exists ─────────
+
+ test_ruby_not_selected_when_specific_exists_darwin =
+ assertEq "aarch64-darwin: ruby variant NOT selected for nokogiri (platform-specific exists)"
+ (resultAarch64Darwin.nokogiri.platform != "ruby")
+ true;
+
+ test_ruby_not_selected_when_specific_exists_linux =
+ assertEq "aarch64-linux: ruby variant NOT selected for ffi (platform-specific exists)"
+ (resultAarch64Linux.ffi.platform != "ruby")
+ true;
+
+ # ── gemConfig-skipping pipeline ─────────────────────────────
+ #
+ # Precompiled gems should NOT receive defaultGemConfig (which contains
+ # source-compilation instructions). Ruby-only gems SHOULD receive it.
+
+ grpcConfig = {
+ grpc = attrs: {
+ postPatch = "substituteInPlace Makefile --replace foo bar";
+ buildFlags = [ "--with-system-certs" ];
+ };
+ };
+
+ test_pipeline_precompiled_skips_gemConfig =
+ let
+ resolved = resultAarch64Darwin;
+ # Simulate the pipeline: only apply gemConfig to ruby variants
+ configured = builtins.mapAttrs (
+ name: gem: if gem.platform == "ruby" then filterHelpers.applyGemConfigs grpcConfig gem else gem
+ ) resolved;
+ in
+ # grpc on aarch64-darwin resolves to arm64-darwin (precompiled), so no gemConfig
+ assertEq "pipeline: precompiled grpc does NOT get postPatch" (configured.grpc ? postPatch) false
+ # rake is ruby, it would get gemConfig if there was a rake entry, but there isn't
+ && assertEq "pipeline: rake stays ruby and unchanged" configured.rake.platform "ruby";
+
+ test_pipeline_ruby_only_gets_gemConfig =
+ let
+ # Use the ruby-only lockfile scenario: no platform-specific variants
+ rubyOnlyGrpcLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ grpc (1.73.0)
+
+ PLATFORMS
+ ruby
+
+ CHECKSUMS
+ grpc (1.73.0) sha256=bbb0000000000000000000000000000000000000000000000000000000000001
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+ resolved = runPipeline {
+ lockfileContent = rubyOnlyGrpcLockfile;
+ system = "aarch64-darwin";
+ };
+ configured = builtins.mapAttrs (
+ name: gem: if gem.platform == "ruby" then filterHelpers.applyGemConfigs grpcConfig gem else gem
+ ) resolved;
+ in
+ assertEq "pipeline: ruby grpc gets postPatch" (configured.grpc ? postPatch) true
+ && assertEq "pipeline: ruby grpc platform is ruby" configured.grpc.platform "ruby";
+
+ # ── dependency expansion pipeline test ──────────────────────
+ #
+ # Exercises parseDependencies + expandTransitiveDeps in the pipeline.
+ # mini_portile2 has groups=[] (build-only dep), gets filtered by groups,
+ # but dep expansion recovers it because nokogiri depends on it.
+
+ depExpansionLockfile = ''
+ GEM
+ remote: https://rubygems.org/
+ specs:
+ nokogiri (1.19.2)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ mini_portile2 (2.8.9)
+ racc (1.8.1)
+
+ PLATFORMS
+ ruby
+
+ CHECKSUMS
+ mini_portile2 (2.8.9) sha256=aaa0000000000000000000000000000000000000000000000000000000000001
+ nokogiri (1.19.2) sha256=aaa0000000000000000000000000000000000000000000000000000000000002
+ racc (1.8.1) sha256=aaa0000000000000000000000000000000000000000000000000000000000003
+
+ DEPENDENCIES
+ nokogiri (~> 1.19)
+
+ BUNDLED WITH
+ 2.5.22
+ '';
+
+ test_pipeline_dep_expansion =
+ let
+ parsed = parseLockfile depExpansionLockfile;
+ lines = lib.splitString "\n" depExpansionLockfile;
+ depGraph = parseDependencies lines;
+ gemRemotes = indexRemotes parsed.gemSections;
+ # Only nokogiri is in the "default" group (it's in DEPENDENCIES).
+ # mini_portile2 and racc are build deps with no group.
+ gemGroups = {
+ nokogiri = [ "default" ];
+ };
+ gems = mergeGemMetadata {
+ inherit (parsed) checksumSection;
+ inherit gemRemotes gemGroups;
+ };
+ platforms = platformsForSystem "aarch64-darwin";
+
+ # Step 1: group filter removes mini_portile2 and racc (groups=[])
+ afterGroup = builtins.filter (filterGroup [ "default" ]) gems;
+ afterGroupNames = map (g: g.gemName) afterGroup;
+
+ # Step 2: expand transitive deps — recovers mini_portile2 and racc
+ survivingNames = map (g: g.gemName) afterGroup;
+ expandedNames = expandTransitiveDeps depGraph survivingNames;
+ # Re-include gems that were filtered out but are needed as deps
+ allGemsByName = builtins.listToAttrs (
+ map (g: {
+ name = g.gemName;
+ value = g;
+ }) gems
+ );
+ afterExpansion = map (n: allGemsByName.${n}) (
+ builtins.filter (n: allGemsByName ? ${n}) expandedNames
+ );
+
+ afterPlatform = builtins.filter (filterPlatform platforms) afterExpansion;
+ resolved = resolvePlatforms platforms afterPlatform;
+ names = builtins.attrNames resolved;
+ in
+ # mini_portile2 was NOT in any group, but dep expansion recovered it
+ assertEq "dep expansion: mini_portile2 recovered after group filtering"
+ (builtins.elem "mini_portile2" names)
+ true
+ && assertEq "dep expansion: racc recovered after group filtering" (builtins.elem "racc" names) true
+ && assertEq "dep expansion: nokogiri present" (builtins.elem "nokogiri" names) true
+ # Verify group filtering alone would have dropped them
+ &&
+ assertEq "dep expansion: group filter alone drops mini_portile2"
+ (builtins.elem "mini_portile2" afterGroupNames)
+ false;
+
+ # ── all tests ────────────────────────────────────────────────
+
+ allTests =
+ # main lockfile: aarch64-darwin
+ test_aarch64_darwin_nokogiri
+ && test_aarch64_darwin_ffi
+ && test_aarch64_darwin_grpc
+ && test_aarch64_darwin_rake
+ # main lockfile: aarch64-linux
+ && test_aarch64_linux_nokogiri
+ && test_aarch64_linux_ffi
+ && test_aarch64_linux_grpc_old_style
+ && test_aarch64_linux_rake
+ # main lockfile: x86_64-darwin
+ && test_x86_64_darwin_nokogiri
+ && test_x86_64_darwin_ffi
+ && test_x86_64_darwin_grpc
+ && test_x86_64_darwin_rake
+ # main lockfile: x86_64-linux
+ && test_x86_64_linux_nokogiri
+ && test_x86_64_linux_ffi
+ && test_x86_64_linux_grpc_old_style
+ && test_x86_64_linux_rake
+ # grpc new-style (-gnu suffixed)
+ && test_grpc_new_style_aarch64_linux
+ && test_grpc_new_style_x86_64_linux
+ && test_grpc_new_style_aarch64_darwin
+ && test_grpc_new_style_x86_64_darwin
+ # grpc preference: bare vs -gnu
+ && test_grpc_both_aarch64_linux_prefers_bare
+ && test_grpc_both_x86_64_linux_prefers_bare
+ # ruby fallback
+ && test_ruby_fallback_aarch64_darwin
+ && test_ruby_fallback_aarch64_linux
+ # ruby NOT selected when specific exists
+ && test_ruby_not_selected_when_specific_exists_darwin
+ && test_ruby_not_selected_when_specific_exists_linux
+ # gemConfig pipeline
+ && test_pipeline_precompiled_skips_gemConfig
+ && test_pipeline_ruby_only_gets_gemConfig
+ # dependency expansion pipeline
+ && test_pipeline_dep_expansion;
+
+in
+allTests
diff --git a/test/unit/test-pipeline.nix b/test/unit/test-pipeline.nix
new file mode 100644
index 0000000..035a09b
--- /dev/null
+++ b/test/unit/test-pipeline.nix
@@ -0,0 +1,15 @@
+# Unit tests for pipeline (standalone wrapper)
+#
+# Run: nix eval --file test/unit/test-pipeline.nix --json
+# Returns: true (all assertions pass) or throws with a descriptive message.
+#
+# This is a thin wrapper around test-pipeline-logic.nix that
+# bootstraps nixpkgs via fetchTarball. The logic file accepts { lib }: and
+# is also imported directly by the root flake.nix checks.
+
+let
+ nixpkgs = import (fetchTarball {
+ url = "https://github.com/NixOS/nixpkgs/archive/nixos-24.11.tar.gz";
+ }) { };
+in
+import ./test-pipeline-logic.nix { lib = nixpkgs.lib; }
diff --git a/test/unit/test-filter.nix b/test/unit/test-resolve-logic.nix
similarity index 75%
rename from test/unit/test-filter.nix
rename to test/unit/test-resolve-logic.nix
index 6709e34..2cfa935 100644
--- a/test/unit/test-filter.nix
+++ b/test/unit/test-resolve-logic.nix
@@ -1,45 +1,24 @@
-# Unit tests for filter-helpers.nix
+# Unit tests for resolve.nix (logic only, no fetchTarball)
+#
+# Accepts { lib }: so it can be imported by both:
+# - The standalone wrapper (test-resolve.nix) for `nix eval --file` usage
+# - The root flake.nix checks via `import ./test-resolve-logic.nix { lib = pkgs.lib; }`
#
-# Run: nix eval --file test/unit/test-filter.nix --json
# Returns: true (all assertions pass) or throws with a descriptive message.
+{ lib }:
+
let
- nixpkgs = import (fetchTarball {
- url = "https://github.com/NixOS/nixpkgs/archive/nixos-24.11.tar.gz";
- }) { };
- lib = nixpkgs.lib;
- filterHelpers = import ../../lib/gemfile-env/filter-helpers.nix { inherit lib; };
+ filterHelpers = import ../../lib/gemfile-env/resolve.nix { inherit lib; };
inherit (filterHelpers)
filterGroup
filterPlatform
resolvePlatforms
applyGemConfigs
platformsForSystem
+ expandTransitiveDeps
;
- inherit (import ../test-helpers.nix) assertEq assertThrows;
-
- # ── test fixtures ────────────────────────────────────────────
-
- mkGem =
- {
- gemName,
- platform ? "ruby",
- groups ? [ "default" ],
- version ? "1.0.0",
- }:
- {
- inherit
- gemName
- platform
- groups
- version
- ;
- source = {
- sha256 = "fake";
- remotes = [ "https://rubygems.org" ];
- type = "gem";
- };
- };
+ inherit (import ../helpers.nix) assertEq assertThrows mkGem;
gemRake = mkGem {
gemName = "rake";
@@ -536,20 +515,101 @@ let
result.nokogiri.platform
"x86_64-darwin";
+ # ── expandTransitiveDeps ──────────────────────────────────────
+
+ test_expandTransitiveDeps_basic =
+ let
+ depGraph = {
+ nokogiri = [
+ "mini_portile2"
+ "racc"
+ ];
+ racc = [ ];
+ mini_portile2 = [ ];
+ };
+ result = expandTransitiveDeps depGraph [ "nokogiri" ];
+ in
+ assertEq "expandTransitiveDeps: nokogiri pulls in mini_portile2"
+ (builtins.elem "mini_portile2" result)
+ true
+ && assertEq "expandTransitiveDeps: nokogiri pulls in racc" (builtins.elem "racc" result) true
+ && assertEq "expandTransitiveDeps: nokogiri itself included" (builtins.elem "nokogiri" result) true;
+
+ test_expandTransitiveDeps_transitive_chain =
+ let
+ # A depends on B, B depends on C
+ depGraph = {
+ a = [ "b" ];
+ b = [ "c" ];
+ c = [ ];
+ };
+ result = expandTransitiveDeps depGraph [ "a" ];
+ in
+ assertEq "expandTransitiveDeps: transitive chain A->B->C includes C" (builtins.elem "c" result) true
+ && assertEq "expandTransitiveDeps: transitive chain includes B" (builtins.elem "b" result) true
+ && assertEq "expandTransitiveDeps: transitive chain includes A" (builtins.elem "a" result) true;
+
+ test_expandTransitiveDeps_no_deps =
+ let
+ depGraph = {
+ rake = [ ];
+ };
+ result = expandTransitiveDeps depGraph [ "rake" ];
+ in
+ assertEq "expandTransitiveDeps: no deps is identity" result [ "rake" ];
+
+ test_expandTransitiveDeps_circular =
+ let
+ # A depends on B, B depends on A
+ depGraph = {
+ a = [ "b" ];
+ b = [ "a" ];
+ };
+ result = expandTransitiveDeps depGraph [ "a" ];
+ sorted = builtins.sort builtins.lessThan result;
+ in
+ assertEq "expandTransitiveDeps: circular deps converge" sorted [
+ "a"
+ "b"
+ ];
+
+ test_expandTransitiveDeps_unknown_dep_not_added =
+ let
+ # depGraph only knows about nokogiri; "unknown_gem" not in graph
+ depGraph = {
+ nokogiri = [ "mini_portile2" ];
+ };
+ result = expandTransitiveDeps depGraph [ "nokogiri" ];
+ in
+ # mini_portile2 IS added (it's a dep of nokogiri)
+ assertEq "expandTransitiveDeps: dep of known gem is added" (builtins.elem "mini_portile2" result)
+ true
+ # but only gems reachable from the initial set appear
+ && assertEq "expandTransitiveDeps: only reachable gems in result" (builtins.length result) 2;
+
+ test_expandTransitiveDeps_empty_initial =
+ let
+ depGraph = {
+ a = [ "b" ];
+ };
+ result = expandTransitiveDeps depGraph [ ];
+ in
+ assertEq "expandTransitiveDeps: empty initial set stays empty" result [ ];
+
# ── regression: ruby-only nokogiri drops mini_portile2 ───────
#
# When a lockfile has only the `ruby` variant of nokogiri (no precompiled
# platform gems), platform resolution selects it. The ruby variant needs
# mini_portile2 to compile, but mini_portile2 gets groups=[] (transitive
# build dep not in any Gemfile group) and filterGroup drops it.
- # This causes: "Could not find 'mini_portile2' (~> 2.8.2)"
#
- # This test documents the bug. When fixed, it should pass.
+ # Fixed by expandTransitiveDeps: after group filtering, we expand
+ # transitive deps so build-time dependencies like mini_portile2 survive.
test_ruby_only_nokogiri_keeps_build_deps =
let
# Lockfile has only the ruby variant of nokogiri (no arm64-darwin etc.)
- gems = [
+ allGems = [
(mkGem {
gemName = "nokogiri";
platform = "ruby";
@@ -566,10 +626,29 @@ let
groups = [ "default" ];
})
];
+ depGraph = {
+ nokogiri = [
+ "mini_portile2"
+ "racc"
+ ];
+ racc = [ ];
+ mini_portile2 = [ ];
+ };
requestedGroups = [ "default" ];
platforms = platformsForSystem "aarch64-darwin";
- afterGroup = builtins.filter (filterGroup requestedGroups) gems;
- afterPlatform = builtins.filter (filterPlatform platforms) afterGroup;
+
+ # Step 1: filter by groups
+ afterGroup = builtins.filter (filterGroup requestedGroups) allGems;
+ afterGroupNames = map (g: g.gemName) afterGroup;
+
+ # Step 2: expand transitive deps to recover filtered-out build deps
+ expandedNames = expandTransitiveDeps depGraph afterGroupNames;
+
+ # Step 3: select gems whose names are in the expanded set from the full list
+ expandedGems = builtins.filter (g: builtins.elem g.gemName expandedNames) allGems;
+
+ # Step 4: filter by platform and resolve
+ afterPlatform = builtins.filter (filterPlatform platforms) expandedGems;
resolved = resolvePlatforms platforms afterPlatform;
names = builtins.attrNames resolved;
in
@@ -581,6 +660,74 @@ let
(builtins.elem "mini_portile2" names)
true;
+ # ── error message prefixes (Phase 4) ─────────────────────────
+
+ test_platformsForSystem_error_lists_supported = assertThrows "platformsForSystem: unsupported system throws with gems4nix prefix and supported list" (
+ platformsForSystem "riscv64-linux"
+ );
+
+ test_platformsForSystem_error_mips = assertThrows "platformsForSystem: mips throws with clear message" (
+ platformsForSystem "mips-linux"
+ );
+
+ # ── warnIfNoPlatformGems ────────────────────────────────────
+
+ test_warnIfNoPlatformGems_no_native =
+ let
+ gems = [
+ (mkGem {
+ gemName = "rake";
+ platform = "ruby";
+ })
+ (mkGem {
+ gemName = "nokogiri";
+ platform = "ruby";
+ })
+ ];
+ platforms = [
+ "ruby"
+ "arm64-darwin"
+ "universal-darwin"
+ ];
+ # warnIfNoPlatformGems should pass through platforms unchanged (warning is a side effect)
+ result = filterHelpers.warnIfNoPlatformGems gems platforms;
+ in
+ assertEq "warnIfNoPlatformGems: returns platforms unchanged when warning fires" result platforms;
+
+ test_warnIfNoPlatformGems_has_native =
+ let
+ gems = [
+ (mkGem {
+ gemName = "rake";
+ platform = "ruby";
+ })
+ (mkGem {
+ gemName = "nokogiri";
+ platform = "arm64-darwin";
+ })
+ ];
+ platforms = [
+ "ruby"
+ "arm64-darwin"
+ "universal-darwin"
+ ];
+ result = filterHelpers.warnIfNoPlatformGems gems platforms;
+ in
+ assertEq "warnIfNoPlatformGems: returns platforms when native gems present" result platforms;
+
+ test_warnIfNoPlatformGems_ruby_only_platforms =
+ let
+ gems = [
+ (mkGem {
+ gemName = "rake";
+ platform = "ruby";
+ })
+ ];
+ platforms = [ "ruby" ];
+ result = filterHelpers.warnIfNoPlatformGems gems platforms;
+ in
+ assertEq "warnIfNoPlatformGems: no warning when only ruby platforms requested" result platforms;
+
# ── all tests ────────────────────────────────────────────────
allTests =
@@ -625,8 +772,22 @@ let
&& test_platformsForSystem_x86_64_linux
&& test_platformsForSystem_unknown_throws
&& test_platformsForSystem_filter_integration
- # regression: ruby-only nokogiri must keep build deps
- && test_ruby_only_nokogiri_keeps_build_deps;
+ # expandTransitiveDeps
+ && test_expandTransitiveDeps_basic
+ && test_expandTransitiveDeps_transitive_chain
+ && test_expandTransitiveDeps_no_deps
+ && test_expandTransitiveDeps_circular
+ && test_expandTransitiveDeps_unknown_dep_not_added
+ && test_expandTransitiveDeps_empty_initial
+ # regression: ruby-only nokogiri keeps build deps (was quarantined)
+ && test_ruby_only_nokogiri_keeps_build_deps
+ # error message prefixes (Phase 4)
+ && test_platformsForSystem_error_lists_supported
+ && test_platformsForSystem_error_mips
+ # warnIfNoPlatformGems
+ && test_warnIfNoPlatformGems_no_native
+ && test_warnIfNoPlatformGems_has_native
+ && test_warnIfNoPlatformGems_ruby_only_platforms;
in
allTests
diff --git a/test/unit/test-resolve.nix b/test/unit/test-resolve.nix
new file mode 100644
index 0000000..3b4a09c
--- /dev/null
+++ b/test/unit/test-resolve.nix
@@ -0,0 +1,15 @@
+# Unit tests for resolve.nix (standalone wrapper)
+#
+# Run: nix eval --file test/unit/test-resolve.nix --json
+# Returns: true (all assertions pass) or throws with a descriptive message.
+#
+# This is a thin wrapper around test-resolve-logic.nix that bootstraps nixpkgs
+# via fetchTarball. The logic file accepts { lib }: and is also imported
+# directly by the root flake.nix checks.
+
+let
+ nixpkgs = import (fetchTarball {
+ url = "https://github.com/NixOS/nixpkgs/archive/nixos-24.11.tar.gz";
+ }) { };
+in
+import ./test-resolve-logic.nix { lib = nixpkgs.lib; }