Version 2.2.0 · Nix · Pure functional · Zero side-effects
- Overview
- Architecture
- Quick Start
- File Layout
- API Reference — mkDevShell
- Hook Lifecycle
- Pipeline Stages
- State Machine
- Protocol Definitions
- Strategy Pattern
- Plugin / combinFrom
- Directory Loader — pdshells
- Naming Convention
- Extension Guide
- Configuration Reference
- Troubleshooting
pdshell is a pure-Nix library for composing reproducible development shells.
| Module | Purpose |
|---|---|
mk-pdshell.nix |
Low-level shell constructor — 7-stage typed pipeline → pkgs.mkShell derivation |
pdshells.nix |
High-level directory loader — recursively discovers .nix files → flat { shellName = derivation; } |
Design goals
| Principle | Realisation |
|---|---|
| Dependency Inversion | _resolveStrategy, _mergeStrategy, _hookComposeStrategy; FileProcessStrategy |
| Pipeline Dataflow | |> operators throughout; every stage is Context → Context |
| Layered Architecture | mk-pdshell: 7 explicit stages; pdshells: subdirs → common → default |
| Incremental Mode | Context accumulates fields per stage; never reset mid-pipeline |
| Strategy Management | All three strategies first-class and swappable without touching core |
| State Machine | ContextPhase / layer phase strings; validate.fn-assertPhase at each stage |
| Lifecycle Management | 5 named hook phases with guaranteed ordering and labelled sections |
| Boundary Clarity | Public: mkDevShell, loader output; Internal: all other bindings |
| Data-Driven | All behaviour parameterised; no hard-coded shell logic |
| Communication Protocol | Context is the typed message envelope; LayerResult/FileResult are typed outputs |
| Plugin / Hot-swap | combinFrom = composable plugins; strategies injectable without touching core |
┌──────────────────────────────────────────────────────────────────────┐
│ PUBLIC API │
│ mkDevShell { ... } pdshells { devDir; ... } │
└──────────────────┬───────────────────────────┬───────────────────────┘
│ │
┌──────────────▼───────────┐ ┌───────────▼────────────────────┐
│ mk-pdshell.nix │ │ pdshells.nix │
│ 7-Stage Pipeline │ │ Recursive Dir Loader │
│ │◄──│ (delegates to mkDevShell) │
│ INIT │ │ │
│ RESOLVE ◄─resolveStrat │ │ SCAN → SUBDIRS (depth-first) │
│ EXTRACT │ │ → COMMON (non-default) │
│ MERGE ◄─mergeStrat │ │ → DEFAULT (default.nix) │
│ COMPOSE ◄─hookCompose │ │ → DONE │
│ BUILD │ │ │
│ EXEC → pkgs.mkShell │ └────────────────────────────────┘
└──────────────────────────┘
# flake.nix
{
inputs.pdshell.url = "github:redskaber/pdshell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.nix-types.url = "github:redskaber/nix-types";
outputs = { self, nixpkgs, pdshell, ... }@inputs:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
inherit (pdshell.lib) mkDevShell pdshells;
in {
# Single shell via mkDevShell
devShells.x86_64-linux.default = mkDevShell {
inherit pkgs;
buildInputs = [ pkgs.git pkgs.curl ];
shellHook = "echo hello";
};
# All shells from a directory tree via pdshells
devShells.x86_64-linux = pdshells {
inherit pkgs inputs;
devDir = ./dev;
};
};
}lib/dev/
├── mk-pdshell.nix # Shell constructor
└── pdshells.nix # Directory loader
dev/ # Your shell definitions live here
├── default.nix # → shell name: "default"
├── rust.nix # → shell name: "rust"
└── backend/
├── default.nix # → shell name: "backend"
└── db.nix # → shell name: "backend-db"
mkDevShell {
# ── Identity ────────────────────────────────────────────────────
name ? "dev-shell",
# ── Inputs ──────────────────────────────────────────────────────
buildInputs ? [],
nativeBuildInputs ? [],
# ── Plugin composition ──────────────────────────────────────────
combinFrom ? [], # list of shell-config attrsets to compose
# ── Hook lifecycle (strings) ────────────────────────────────────
preInputsHook ? "", # before buildInputs activated
postInputsHook ? "", # after buildInputs activated
preShellHook ? "", # just before interactive shell
postShellHook ? "", # at shell exit / cleanup
shellHook ? "", # raw mkShell-compatible hook (legacy / inherited)
# ── Hook lifecycle (functions) ──────────────────────────────────
# Each *Fn receives { pkgs } and must return string | null.
preInputsHookFn ? null,
postInputsHookFn ? null,
preShellHookFn ? null,
postShellHookFn ? null,
# ── Shell override ───────────────────────────────────────────────
# exec'd AFTER all hooks; replaces the current process.
# Must exist in PATH or be an absolute path.
# Honoured at the variant level (pdshells passes variant attrsets to mkDevShell).
shell ? null,
# ── Extension points (dependency inversion) ──────────────────────
_resolveStrategy ? resolveStrategy.default,
_mergeStrategy ? mergeStrategy.unique,
_hookComposeStrategy ? hookComposer.fn-defaultComposeStrategy,
# Any extra attrs are forwarded verbatim to pkgs.mkShell
...
}shell enter
│
▼ preInputsHook / preInputsHookFn ← PRE-INPUTS HOOK section
│
│ (buildInputs / nativeBuildInputs activated by Nix)
│
▼ postInputsHook / postInputsHookFn ← POST-INPUTS HOOK section
│
▼ INHERITED SHELLHOOK ← shellHook from combinFrom entries
│ + top-level shellHook
│
▼ preShellHook / preShellHookFn ← PRE-SHELL HOOK section
│
▼ postShellHook / postShellHookFn ← POST-SHELL HOOK section
│
▼ [if shell != null] exec <shell> ← FINAL SHELL OVERRIDE
Generated shellHook sections are labelled for grep-ability:
# === PRE-INPUTS HOOK ===
export CC=gcc
# === POST-INPUTS HOOK ===
export NODE_ENV=development
# === INHERITED SHELLHOOK ===
echo "from combinFrom"
# === PRE-SHELL HOOK ===
source .envrc
# === POST-SHELL HOOK ===
unset CC
# === FINAL SHELL OVERRIDE ===
exec zshEmpty sections are omitted entirely.
| # | FSM phase | Responsibility |
|---|---|---|
| 1 | INIT |
Wrap raw args in a typed Context |
| 2 | RESOLVE |
Unwrap combinFrom entries via the resolve strategy |
| 3 | EXTRACT |
Normalise hook structures using HookPhase constants |
| 4 | MERGE |
Merge buildInputs / nativeBuildInputs via merge strategy |
| 5 | COMPOSE |
Build all hook lifecycle phases via the hook compose strategy |
| 6 | BUILD |
Assemble final pkgs.mkShell parameter set; apply shell override |
| 7 | EXEC |
String validation + pkgs.mkShell |
Stage 5 is delegated to _hookComposeStrategy (default:
hookComposer.fn-defaultComposeStrategy), which composes each phase with
fully-named, role-distinct parameters (HookPhase.* and HookFnField.*).
INIT ──▶ RESOLVE ──▶ EXTRACT ──▶ MERGE ──▶ COMPOSE ──▶ BUILD ──▶ EXEC
validate.fn-assertPhase is called at every stage entry.
Out-of-order execution → PHASE VIOLATION error.
SCAN ──▶ SUBDIRS ──▶ COMMON ──▶ DEFAULT ──▶ DONE
Each directory gets a fresh LayerContext — no state leaks between sibling directories.
All string constants are defined as typed attrsets. No bare string literals appear inside the pipeline stages.
HookPhase.PRE_INPUTS = "preInputsHook"
HookPhase.POST_INPUTS = "postInputsHook"
HookPhase.PRE_SHELL = "preShellHook"
HookPhase.POST_SHELL = "postShellHook"
HookPhase.SHELL = "shellHook"HookFnField.PRE_INPUTS = "preInputsHookFn"
HookFnField.POST_INPUTS = "postInputsHookFn"
HookFnField.PRE_SHELL = "preShellHookFn"
HookFnField.POST_SHELL = "postShellHookFn"Single source of truth for every field name in shell-config attrsets and args.
Covers _resolveStrategy, _mergeStrategy, and _hookComposeStrategy so all
extension overrides are stripped before forwarding to pkgs.mkShell.
Protocol: entry:attrset → config:attrset
| Name | Behaviour |
|---|---|
resolveStrategy.default |
Unwrap { default = {...}; } or use entry directly |
Protocol: base:[pkg] → extracted:[pkg] → merged:[pkg]
| Name | Behaviour |
|---|---|
mergeStrategy.unique |
Concatenate + deduplicate (default) |
mergeStrategy.prepend |
combinFrom items first, then base; deduplicate |
mergeStrategy.baseOnly |
Ignore combinFrom inputs entirely |
Protocol: args → in:[hookAttrset] → resolvedCombin:[attrset] → composedPhases
| Name | Behaviour |
|---|---|
hookComposer.fn-defaultComposeStrategy |
Explicit per-phase composition (default) |
# base plugin (e.g. dev/base.nix)
{ pkgs, ... }:
{
default = {
buildInputs = [ pkgs.git pkgs.curl ];
preShellHook = ''export EDITOR=nvim'';
};
}
# composite shell (e.g. dev/default.nix)
{ pkgs, dev, ... }:
{
frontend = {
combinFrom = [ dev.base ]; # dev = variantsTree from pdshells
shellHook = "echo 'frontend'";
};
}- Sub-directories — depth-first recursive.
- Common
.nixfiles — each sees only the sub-directoryvariantsTree. default.nix— sees sub-directory + commonvariantsTrees.
pdshells {
inherit pkgs inputs;
devDir = ./dev; # required
shared = {}; # optional — forwarded to every imported file
suffix = ".nix"; # optional — file extension filter (default: ".nix")
}
# → { shellName = <derivation>; ... }Shell names are checked for uniqueness globally at evaluation time.
Duplicate names cause a build-time NAMING CONFLICT error.
The check is strictly evaluated via assert lib.seq — it cannot be silently skipped.
| Source | Variant | Result |
|---|---|---|
dev/default.nix |
default |
default |
dev/default.nix |
rust |
rust |
dev/backend/default.nix |
default |
backend |
dev/backend/default.nix |
api |
backend-api |
dev/backend/db.nix |
default |
backend-db |
dev/backend/db.nix |
pg |
backend-db-pg |
Full details in docs/extension-guide.md.
mkDevShell {
_resolveStrategy = entry:
if builtins.hasAttr "cfg" entry then entry.cfg
else resolveStrategy.default entry;
}mkDevShell {
_mergeStrategy = mergeStrategy.prepend; # combinFrom overrides base
}mkDevShell {
_hookComposeStrategy = myCompose; # myCompose : args → in → resolvedCombin → phases
}mkDevShell {
preShellHookFn = { pkgs }: ''
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
'';
}# dev/python/machine.nix
{ pkgs, ... }:
{
default = {
buildInputs = [ pkgs.python3 pkgs.zsh ];
shell = "zsh"; # exec'd after all hooks
};
}args: mkDevShell ({
buildInputs = [ pkgs.git pkgs.gnupg ];
_mergeStrategy = mergeStrategy.prepend;
} // args);let
subject = import ./lib/mk-pdshell.nix { inherit pkgs; };
in {
test-resolve = subject.resolveStrategy.default
{ default = { buildInputs = [ pkgs.git ]; }; };
test-merge = subject.mergeStrategy.unique [ pkgs.git ] [ pkgs.git pkgs.curl ];
test-hookCompose = subject.hookComposer.fn-defaultComposeStrategy
{ preInputsHook = "echo pre"; } [] [];
test-shell = subject.mkDevShell { buildInputs = [ pkgs.git ]; };
}| Parameter | Type | Default | Description |
|---|---|---|---|
name |
string | "dev-shell" |
Derivation name prefix |
buildInputs |
[pkg] |
[] |
Runtime packages |
nativeBuildInputs |
[pkg] |
[] |
Build-time packages |
combinFrom |
[attrset] |
[] |
Plugins to compose |
preInputsHook |
string | "" |
Hook before inputs |
postInputsHook |
string | "" |
Hook after inputs |
preShellHook |
string | "" |
Hook before shell |
postShellHook |
string | "" |
Hook after shell |
shellHook |
string | "" |
Raw mkShell-compat / legacy hook |
*HookFn |
{pkgs}→str|null |
null |
Dynamic hook functions |
shell |
string|null | null |
Final shell override |
_resolveStrategy |
function | resolveStrategy.default |
combinFrom resolver |
_mergeStrategy |
function | mergeStrategy.unique |
Input merge algorithm |
_hookComposeStrategy |
function | hookComposer.fn-defaultComposeStrategy |
Hook composition algorithm |
| Parameter | Type | Required | Description |
|---|---|---|---|
pkgs |
nixpkgs | yes | nixpkgs instance |
inputs |
attrset | yes | Flake inputs |
devDir |
path | yes | Root of shell definitions |
shared |
attrset | no | Forwarded to every imported file as shared |
suffix |
string | no (".nix") |
File extension filter |
| Error | Cause | Fix |
|---|---|---|
PHASE VIOLATION |
Stage called out of order | Use pipeline in documented order |
HOOK CONTRACT VIOLATION |
*HookFn returned wrong type |
Return string | null only |
INVALID COMBIN ENTRY |
No shell keys in combinFrom entry | Add buildInputs, shellHook, etc. |
NAMING CONFLICT |
Two shells resolve to the same name | Rename variants or files |
KEY COLLISION |
defaultAttrs conflicts with existing tree |
Rename variants in default.nix |
STRUCTURAL AMBIGUITY |
foo.nix and foo/ coexist |
Keep only one source per base name |
EMPTY DIRECTORY |
No .nix files and no sub-dirs |
Add a shell or remove the directory |
PATH NOT FOUND |
devDir doesn't exist |
Verify devDir path |
Shell not found after exec |
Binary not in $PATH at hook time |
Add shell package to buildInputs |