Language ecosystems (part B)

Updated

October 31, 2026

Language ecosystems (part B)

Goal: Package a second language ecosystem tool so the patterns transfer: FODs for language deps, helpers vs raw stdenv, and how overrides differ across builders.

Why it matters

At work you will not stay monoglot. The point is not fluency in five builders—it is recognizing the same skeleton:

src FOD → language-dep FOD → build/install hooks → $out/bin → meta

Part A was depth. This chapter is transfer with a deliberately smaller scope: one binary, clear version, known license.


Theory 1 — Pattern map across ecosystems

Concern Go Rust Python Node
Source pin src.hash src.hash src.hash / fetchPypi src.hash
Dep pin vendorHash cargoHash / lock package set / frozen npmDepsHash
Builder buildGoModule buildRustPackage buildPython* buildNpmPackage
Skip tests doCheck doCheck / cargo flags pytest hooks often build-only
Binary name module main crate bin meta.mainProgram meta.mainProgram
Cross GOOS/GOARCH care target triples often pure often pure JS
Override flavor overrideAttrs overrideAttrs overridePythonAttrs overrideAttrs

When stuck, ask: which hash is wrong—source or language deps?


Theory 2 — Choose B deliberately

If part A was… Good part B pick Why
Go Python CLI or Rust Different dep story
Rust Go small tool Faster vendor cycle
Python Node or Go Leaves interpreter wrapping
Node Python or Go Sees compiled vs interpreted

Avoid starting a second multi-crate workspace. One binary, clear version, known license.

Transfer questions (answer in notes)

  1. What is the second FOD field named?
  2. Where does the binary land under $out?
  3. What is the fastest hash-mismatch workflow for this builder?
  4. Would I pick this ecosystem for internal CLIs at work? Why?

Theory 3 — Wrapping and runtime deps

Interpreted ecosystems often need wrappers:

{ stdenv, lib, python3, makeWrapper }:
stdenv.mkDerivation {
  pname = "tool";
  version = "0.1.0";
  dontUnpack = true;
  nativeBuildInputs = [ makeWrapper ];
  installPhase = ''
    runHook preInstall
    mkdir -p $out/bin
    cat > $out/bin/tool <<'EOF'
    #!/bin/sh
    echo tool
    EOF
    chmod +x $out/bin/tool
    wrapProgram $out/bin/tool \
      --prefix PATH : ${lib.makeBinPath [ python3 ]}
    runHook postInstall
  '';
  meta = {
    mainProgram = "tool";
    license = lib.licenses.mit;
  };
}
Situation Prefer
Official language builder exists Use it (buildGoModule, buildPythonApplication, etc.)
Single script / glue needing runtime deps pkgs.writers.* (writePython3Bin, writeBashBin, …)
Need one env var or PATH sibling on custom binary wrapProgram / makeWrapper
Need whole tree of modules Builder’s dependency graph
Need different interpreter Override package set / python version

Python applications usually wrap PYTHONPATH via the builder—do not reimplement unless you must.

High-level script writers: pkgs.writers

Writing raw stdenv.mkDerivation boilerplate with nativeBuildInputs = [ makeWrapper ] and manual wrapProgram calls is repetitive for standalone tools. nixpkgs provides pkgs.writers (pkgs/build-support/writers/ in nixpkgs source) to author self-contained script binaries with wrapped dependencies and built-in validation.

1. Language-specific writers

Pre-packaged writers exist for common scripting and compiled languages:

  • pkgs.writers.writePython3Bin "name" { libraries = [...]; makeWrapperArgs = [...]; } (builtins.readFile ./script.py): passes Python packages to PYTHONPATH, prefixes PATH for system utilities, and runs a build-time syntax check via pyflakes.
  • pkgs.writers.writeBashBin "name" { makeWrapperArgs = [...]; } ''...'': writes an executable Bash script and runs it through shellcheck during the build, failing fast on syntax or quoting issues.
  • pkgs.writers.writeJSBin, pkgs.writers.writeRustBin, pkgs.writers.writeHaskellBin: corresponding turnkey writers for other languages.

2. Generic interpreted scripts: pkgs.writers.makeScriptWriter

When targeting an interpreted language that lacks a ready-made write<Lang>Bin helper (such as Emacs Lisp, Guile Scheme, Perl, or R), use the lower-level makeScriptWriter. If the interpreter follows the withPackages convention, runtime language dependencies and system binaries compose directly:

pkgs.writers.makeScriptWriter {
  # System binary dependencies made available on PATH
  makeWrapperArgs = [
    "--prefix" "PATH" ":" (pkgs.lib.makeBinPath [ pkgs.alsa-utils ])
  ];

  # The interpreter with its language libraries
  interpreter = "${(pkgs.emacs.withPackages (epkgs: [ epkgs.org-mode ]))}/bin/emacs --script";
} "/bin/render-notes" (builtins.readFile ./render.el);

The output path (/bin/render-notes) is package-relative, placed safely under $out/bin/render-notes.

3. Generic compiled scripts: pkgs.writers.makeBinWriter

For compiled languages without a turnkey helper (e.g., Ada, Pascal, or specialized C tools), makeBinWriter takes a compilation script and input file path:

pkgs.writers.makeBinWriter {
  makeWrapperArgs = [
    "--prefix" "PATH" ":" (pkgs.lib.makeBinPath [ pkgs.coreutils ])
  ];

  # Nix runs compileScript in the build sandbox
  # $contentPath contains the source code, $out is the destination binary
  compileScript = ''
    ${pkgs.gnat}/bin/gnatmake -o $out $contentPath
  '';
} "/bin/ada-tool" (builtins.readFile ./main.adb);

Where to find them

Because pkgs.writers documentation in the Nixpkgs manual is minimal, the authoritative reference is the nixpkgs source code itself at pkgs/build-support/writers/default.nix. Reading that file reveals every supported argument (makeWrapperArgs, libraries, linter toggles) and how wrappers are assembled.


Theory 4 — Overrides preview

Deep dive lives in the patching chapter; know these exist:

# Change flags without forking the whole expression
helloGo.overrideAttrs (old: {
  ldflags = (old.ldflags or []) ++ [ "-X main.extra=eco-b" ];
})
# Python package override inside python3.pkgs
python3.pkgs.requests.overridePythonAttrs (old: {
  doCheck = false;
})
# Node / generic
helloNode.overrideAttrs (old: {
  postPatch = (old.postPatch or "") + ''
    substituteInPlace package.json --replace-fail '"private": true' '"private": false'
  '';
})

Composition rules matter later—for now, confirm your ecosystem B package can be lightly tweaked without a full fork.


Theory 5 — Cross-ecosystem FOD discipline

Whatever language B you chose, fetchers obey the same rules as the fetchers chapter:

Rule Why
Pin URL + hash Reproducible
Vendor offline where possible CI without network flakiness
Prefer language-native lock → Nix conversion carefully Don’t invent hashes by hand when tooling exists
Never commit fakeHash CI will fail or worse, someone “fixes” blindly
# Always capture both hashes when documenting
echo "src.hash=…"
echo "langDep.hash=…"   # vendorHash / cargoHash / npmDepsHash

Theory 6 — Runtime vs build-time closure

nativeBuildInputs  → tools for build (compilers, make)
buildInputs        → linked/used at build; may propagate
propagated*        → infect dependents (use sparingly)

A package that “works in nix develop” but fails as nix build often mixed shell conveniences with missing buildInputs.

Ecosystem Common closure weight
Pure Go (CGO_ENABLED=0) Small
Rust release bin Small–medium
Python app Interpreter + site-packages
Node CLI Node runtime + modules (or bundled)
nix path-info -Sh ./result
nix path-info -rS ./result | sort -k2 -n | tail -n 20

Theory 7 — Dual interface pattern

Make both A and B print something comparable so checks share structure:

eco-a: hello from ecosystem A
eco-b: hello from ecosystem B

Or both support --version with Nix-injected versions. Unified checks then look like:

both-run = pkgs.runCommand "both-run" {
  nativeBuildInputs = [ a b ];
} ''
  ${a}/bin/${a.meta.mainProgram} --version
  ${b}/bin/${b.meta.mainProgram} --version
  touch $out
'';

Worked example — dual packages in one flake

{
  description = "language ecosystems A+B lab";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { self, nixpkgs }:
    let
      systems = [ "x86_64-linux" "aarch64-linux" ];
      forAllSystems = nixpkgs.lib.genAttrs systems;
    in {
      packages = forAllSystems (system:
        let pkgs = import nixpkgs { inherit system; }; in {
          lang-a = pkgs.callPackage ./pkgs/lang-a.nix { };
          lang-b = pkgs.callPackage ./pkgs/lang-b.nix { };
          default = self.packages.${system}.lang-b;
        });

      checks = forAllSystems (system:
        let
          pkgs = import nixpkgs { inherit system; };
          a = self.packages.${system}.lang-a;
          b = self.packages.${system}.lang-b;
        in {
          both-run = pkgs.runCommand "both-run" {
            nativeBuildInputs = [ a b ];
          } ''
            "${a}/bin/${a.meta.mainProgram}" --help >/dev/null 2>&1 \
              || "${a}/bin/${a.meta.mainProgram}" >/dev/null
            "${b}/bin/${b.meta.mainProgram}" --help >/dev/null 2>&1 \
              || "${b}/bin/${b.meta.mainProgram}" >/dev/null
            touch $out
          '';
        });
    };
}

Tiny Python app (if A was compiled)

{ lib, python3Packages }:
python3Packages.buildPythonApplication {
  pname = "eco-b-greet";
  version = "0.1.0";
  pyproject = true;
  src = ../src/eco-b-greet;
  build-system = with python3Packages; [ setuptools ];
  meta = {
    description = "Ecosystem B greet";
    license = lib.licenses.mit;
    mainProgram = "eco-b-greet";
  };
}
# src/eco-b-greet/pyproject.toml
[project]
name = "eco-b-greet"
version = "0.1.0"
requires-python = ">=3.11"
[project.scripts]
eco-b-greet = "eco_b_greet:main"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
# src/eco-b-greet/eco_b_greet.py
def main():
    print("hello from ecosystem B")

if __name__ == "__main__":
    main()

Tiny Node CLI (alternative)

{ lib, buildNpmPackage }:
buildNpmPackage {
  pname = "eco-b-node";
  version = "0.1.0";
  src = ../src/eco-b-node;
  npmDepsHash = "sha256-…";
  meta = {
    mainProgram = "eco-b-node";
    license = lib.licenses.mit;
  };
}

Tiny Go (if A was Python)

{ lib, buildGoModule }:
buildGoModule {
  pname = "eco-b-go";
  version = "0.1.0";
  src = ../src/eco-b-go;
  vendorHash = null;
  meta = {
    mainProgram = "eco-b-go";
    license = lib.licenses.mit;
  };
}

Lab — multi-step

Suggested workspace: ~/lab/nixos/packaging/eco-b
(or extend the ecosystem A flake)

Step 1 — Import or copy the part A package

Keep lang-a buildable so you compare logs side by side.

Step 2 — Scaffold smaller B project

Budget: under ~50 lines of application code. No microservices.

Step 3 — Package with the B builder

nix build .#lang-b -L
nix run .#lang-b

Record every hash you had to set (src + language deps).

Step 4 — Comparison table (required)

In NOTES.md:

Question Lang A Lang B
Builder name
Source hash field
Dep hash field
Time to first success
Hardest error
Where binary lands
Closure (nar size)
Would I use this at work for CLIs?

Step 5 — Unified check

Add a flake checks.* that smoke-tests both tools. nix flake check.

Step 6 — Optional same interface

Make both print a comparable line so a shell script can assert either.

Step 7 — Closure glance

nix path-info -rS ./result | sort -k2 -n | tail
nix path-info -Sh ./result

Note which ecosystem pulled a heavier closure and why (interpreter? compiler runtime? libc?).

Step 8 — Hash bump drill

  1. Intentionally change a src rev or use lib.fakeHash
  2. Run build; capture the got: hash
  3. Paste correct hash; rebuild green
  4. Write the exact error class name in notes

Step 9 — Light override

Apply overrideAttrs or overridePythonAttrs to flip doCheck or add an ldflags entry. Rebuild once.


Exercises

  1. Draw the FOD graph for A and B (boxes and arrows).
  2. Time cold build vs warm cache for each (rough wall clock).
  3. Document one override API difference between the two builders.
  4. Try nix run without mainProgram set—observe failure mode—then fix.
  5. Write a recommendation: “default internal CLI language for our team” with two bullets of rationale from your measurements.

Common gotchas

Symptom / mistake What to do
Trying to “finish” a second huge app Shrink scope; transfer patterns, not product
Mixing package sets (python311 vs python312) Align interpreters
Assuming vendorHash = null always works Only pure module-free trees
npmDepsHash churn from lock format Commit lock; pin npm
Comparing wall times unfairly Same machine, warm vs cold cache noted
Forgetting meta.license Set it; habit for nixpkgs-shaped work
Unified check hardcodes wrong bin names Use meta.mainProgram
Porting Go ldflags habits to Python Use each ecosystem’s idioms
Skipping comparison table Required—this chapter is about transfer

Checkpoint

  • Second ecosystem package builds and runs
  • Comparison table filled honestly
  • Both A and B reachable from one flake
  • At least one combined check
  • Closure notes captured
  • Hash bump drill completed
  • Light override demonstrated

Commit

git add .
git commit -m "packaging: language ecosystem B + comparison"

Write three personal gotchas before patching and overrides.