diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
 # Changelog
 
+## 0.1.1.0 — 2026-02-24
+
+### Phase 4: nixpkgs Compatibility
+
+- **Thunk memoization** — Per-thunk `IORef` memo cells (matching real Nix in-place mutation). `forceThunk` is a `MonadEval` method: IO evaluators memoize per-allocation, pure evaluators re-evaluate. GC reclaims dead thunks naturally — no unbounded global cache. `unsafePerformIO` CAF float-out prevented via `NOINLINE` + `seq` pattern.
+- **8.4x builtin dispatch speedup** — Case dispatch replacing polymorphic `Map` reconstruction on every call. Direct pattern match on builtin name for zero-allocation dispatch in the hot path.
+- **Regex builtins** — `builtins.match` and `builtins.split` via `regex-tdfa` (pure Haskell POSIX ERE, cross-platform)
+- `ESearchPath !Text` AST constructor — `<nixpkgs>` is now its own node, desugared at eval time to `builtins.findFile builtins.nixPath "nixpkgs"` (matching real Nix semantics)
+- NIX_PATH parsing: `parseNixPath` converts colon-separated `name=path` entries into `{ prefix, path }` attrset thunks
+- `builtinEnv` now accepts search paths: `builtinEnv :: Integer -> [Thunk] -> Env`
+- `EvalState.esSearchPaths` populated from `NIX_PATH` environment variable at startup
+- Directory imports: `import ./dir` automatically resolves to `./dir/default.nix`
+- Dynamic attribute keys: `{ ${expr} = val; }` fully supported in all contexts (non-rec, rec, let, select, hasAttr)
+- Two-phase binding resolution for knot-tying: `resolveBindingKeys` (monadic, evaluates dynamic keys) then `buildResolvedBindingsMap` (pure, enables recursive self-reference)
+- Monadic `resolveKey` replaces pure `resolveStaticKey` — handles both `StaticKey` and `DynamicKey` uniformly
+- CLI `--nix-path NAME=PATH` flag (repeatable, merged with NIX_PATH)
+- CLI `--expr EXPR` for inline expression evaluation
+- CLI deep-force and pretty-printing for eval output
+- `README.md` added to `extra-doc-files` in cabal (shows on Hackage)
+- Parser fix: `TokInterpOpen` in expression context expects `TokRBrace` (not `TokInterpClose`) for closing brace
+- 91 builtins (up from 88)
+- 508 tests (14 new: parseNixPath, search path parsing/eval, dynamic keys, directory import, populated search path resolution)
+
 ## 0.1.0.0 — 2026-02-24
 
 ### Cross-Platform Fixes
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,341 @@
+<div align="center">
+<h1>nova-nix</h1>
+<p><strong>Windows-Native Nix in Pure Haskell</strong></p>
+<p>A from-scratch implementation of the Nix package manager — parser, lazy evaluator, content-addressed store, derivation builder, binary substituter — running natively on Windows, macOS, and Linux. No WSL. No Cygwin. No MSYS2.</p>
+<p><a href="#quick-start">Quick Start</a> · <a href="#cli">CLI</a> · <a href="#modules">Modules</a> · <a href="#architecture">Architecture</a> · <a href="#the-hard-problems">Hard Problems</a> · <a href="#roadmap">Roadmap</a> · <a href="#build--test">Build & Test</a></p>
+<p>
+
+[![CI](https://github.com/Novavero-AI/nova-nix/actions/workflows/ci.yml/badge.svg)](https://github.com/Novavero-AI/nova-nix/actions/workflows/ci.yml)
+[![Hackage](https://img.shields.io/hackage/v/nova-nix.svg)](https://hackage.haskell.org/package/nova-nix)
+![Haskell](https://img.shields.io/badge/haskell-GHC%209.6-purple)
+![License](https://img.shields.io/badge/license-MIT-blue)
+
+</p>
+</div>
+
+---
+
+## What is nova-nix?
+
+A pure Haskell implementation of Nix that treats Windows as a first-class target:
+
+- **Parser** — Hand-rolled recursive descent parser for the full Nix expression language. 13 precedence levels, 17 AST constructors, all syntax forms including search paths (`<nixpkgs>`) and dynamic attribute keys (`{ ${expr} = val; }`). Direct `Text` consumption for maximum throughput.
+- **Lazy Evaluator** — Thunk-based evaluation with environment closures, knot-tying for recursive bindings via Haskell laziness. All 17 AST constructors handled: literals, strings with interpolation, attribute sets (recursive and non-recursive), let bindings, lambdas with formal parameters, if/then/else, with, assert, unary/binary operators, function application, list construction, attribute selection, has-attribute checks, and search path resolution.
+- **91 Built-in Functions** — Type checks, arithmetic, bitwise, strings, lists, attribute sets, higher-order (`map`, `filter`, `foldl'`, `sort`, `genList`, `concatMap`), JSON (`toJSON`/`fromJSON`), hashing (SHA-256/SHA-512/SHA-1/MD5), version parsing, `replaceStrings`, `tryEval`, `deepSeq`, `genericClosure`, string context introspection (`hasContext`, `getContext`, `appendContext`), IO builtins (`import`, `readFile`, `pathExists`, `readDir`, `getEnv`, `toPath`, `toFile`, `findFile`, `scopedImport`, `fetchurl`, `fetchTarball`, `fetchGit`), `derivation`, `placeholder`, `storePath`, and more. 16 builtins available at top level without `builtins.` prefix (`toString`, `map`, `throw`, `import`, `derivation`, `abort`, `baseNameOf`, `dirOf`, `isNull`, `removeAttrs`, `placeholder`, `scopedImport`, `fetchTarball`, `fetchGit`, `fetchurl`, `toFile`) — matching the real Nix language spec.
+- **Search Path Resolution** — `<nixpkgs>` desugars to `builtins.findFile builtins.nixPath "nixpkgs"` — matching real Nix semantics. `NIX_PATH` environment variable is parsed at startup, and `--nix-path` CLI flags merge with it. Directory imports (`import ./dir`) resolve to `dir/default.nix` automatically.
+- **Dynamic Attribute Keys** — `{ ${expr} = val; }` fully supported in all contexts: non-recursive attrs, recursive attrs, let bindings, attribute selection, and has-attribute checks. Key resolution is cleanly separated from value thunk construction to preserve knot-tying in recursive bindings.
+- **String Context Tracking** — Every string carries invisible metadata tracking which store paths it references. Context propagates through interpolation, concatenation, `replaceStrings`, and all string operations. The `derivation` builtin collects contexts into `drvInputDrvs` and `drvInputSrcs` — matching real Nix semantics.
+- **Content-Addressed Store** — `/nix/store` on Unix, `C:\nix\store` on Windows, with real SQLite metadata tracking (ValidPaths + Refs tables, WAL mode)
+- **Derivation Builder** — Full build loop with recursive dependency resolution: topological sort via Kahn's algorithm, binary cache substitution before local builds, input validation, reference scanning, output registration
+- **Binary Substituter** — HTTP binary cache protocol: narinfo fetch + parse, Ed25519 signature verification, NAR download/decompress/unpack, store registration. Priority-ordered multi-cache support. Built on [nova-cache](https://github.com/Novavero-AI/nova-cache).
+- **ATerm Serialization** — Full round-trip `.drv` serialization and parsing with string escape handling
+
+Every module is pure by default. IO lives at the boundaries only.
+
+---
+
+## Try It
+
+```bash
+git clone https://github.com/Novavero-AI/nova-nix.git
+cd nova-nix
+cabal run nova-nix -- --strict eval test.nix
+```
+
+Output:
+
+```
+{ count = 6; greeting = "Hello, nova-nix!"; items = [ 2 4 6 8 10 ]; nested = { a = 1; b = 2; c = 4; }; types = { attrs = "set"; int = "int"; list = "list"; string = "string"; }; }
+```
+
+That's a Nix expression with `let` bindings, `rec` attrs, lambdas, `builtins.map`, `builtins.typeOf`, and arithmetic — parsed, lazily evaluated, and pretty-printed. On Windows, macOS, or Linux.
+
+---
+
+## CLI
+
+```bash
+nova-nix eval FILE.nix                        # Evaluate a .nix file, print result
+nova-nix eval --expr 'EXPR'                   # Evaluate an inline expression
+nova-nix build FILE.nix                       # Build a derivation from a .nix file
+nova-nix --nix-path nixpkgs=/path eval FILE   # Add search paths (repeatable)
+```
+
+### Evaluate
+
+```bash
+$ nova-nix --strict eval test.nix
+{ count = 6; greeting = "Hello, nova-nix!"; items = [ 2 4 6 8 10 ]; nested = { a = 1; b = 2; c = 4; }; types = { attrs = "set"; int = "int"; list = "list"; string = "string"; }; }
+```
+
+Inline expressions:
+
+```bash
+$ nova-nix eval --expr '1 + 2'
+3
+
+$ nova-nix eval --expr 'builtins.map (x: x * x) [1 2 3 4 5]'
+[ 1 4 9 16 25 ]
+
+$ nova-nix eval --expr '{ x = 1; y = 2; }.x + { x = 1; y = 2; }.y'
+3
+```
+
+Search paths:
+
+```bash
+$ nova-nix --nix-path nixpkgs=/path/to/nixpkgs eval --expr 'import <nixpkgs> {}'
+
+$ NIX_PATH=nixpkgs=/path/to/nixpkgs nova-nix eval --expr 'import <nixpkgs> {}'
+```
+
+### Build
+
+```bash
+$ cat > hello.nix <<'EOF'
+derivation {
+  name = "hello";
+  system = builtins.currentSystem;
+  builder = "/bin/sh";
+  args = [ "-c" "mkdir -p $out && echo 'Hello from nova-nix!' > $out/greeting.txt" ];
+}
+EOF
+
+$ nova-nix build hello.nix
+/nix/store/abc...-hello
+```
+
+The `build` command evaluates the `.nix` file, extracts the derivation, builds the full dependency graph, topologically sorts it, checks binary caches for substitutes, builds anything missing locally, and registers all outputs in the store DB.
+
+---
+
+## Quick Start
+
+Add to your `.cabal` file:
+
+```cabal
+build-depends: nova-nix
+```
+
+### Parse a Nix Expression
+
+```haskell
+import Nix.Parser (parseNix)
+import Nix.Expr.Types
+
+main :: IO ()
+main = do
+  case parseNix "<stdin>" "let x = 1 + 2; in x" of
+    Left err   -> print err
+    Right expr -> print expr
+    -- ELet [NamedBinding [StaticKey "x"]
+    --        (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2)))]
+    --      (EVar "x")
+```
+
+### Evaluate an Expression
+
+```haskell
+import Nix.Parser (parseNix)
+import Nix.Eval (eval, PureEval(..), NixValue(..))
+import Nix.Builtins (builtinEnv)
+
+main :: IO ()
+main = do
+  case parseNix "<stdin>" "let x = 5; y = x * 2; in y + 1" of
+    Left err -> print err
+    Right expr -> case runPureEval (eval (builtinEnv 0 []) expr) of
+      Left err  -> putStrLn ("Error: " ++ show err)
+      Right val -> print val  -- VInt 11
+```
+
+The evaluator is polymorphic via `MonadEval` — `PureEval` runs without IO, while `EvalIO` can access the filesystem for `import`, `readFile`, etc.
+
+### Lazy Evaluation in Action
+
+```haskell
+-- Nix is lazy: unused bindings are never evaluated
+-- runPureEval (eval (builtinEnv 0 []) expr) where expr parses:
+--   "let unused = builtins.throw \"boom\"; x = 42; in x"
+-- Right (VInt 42)  —  "boom" is never triggered
+
+-- Recursive attribute sets with self-reference
+--   "rec { a = 1; b = a + 1; c = b * 2; }.c"
+-- Right (VInt 4)
+
+-- Lambda closures, set patterns with defaults
+--   "({ name, greeting ? \"Hello\" }: \"${greeting}, ${name}!\") { name = \"Nix\"; }"
+-- Right (VStr "Hello, Nix!")
+```
+
+---
+
+## Modules
+
+### Parser
+
+| Module | Purpose | Status |
+|--------|---------|--------|
+| `Nix.Expr.Types` | Complete Nix AST — 17 expression constructors (including `ESearchPath`), atoms, formals, operators, string parts, source locations | Done |
+| `Nix.Parser` | Hand-rolled recursive descent parser + lexer. Direct `Text` consumption, source position tracking | Done |
+| `Nix.Parser.Lexer` | Tokenizer — integers, floats, strings with interpolation, paths, URIs, search paths, all operators/keywords | Done |
+| `Nix.Parser.Expr` | Expression parser — 13 precedence levels, left/right/non-associative operators, application, selection, dynamic keys | Done |
+| `Nix.Parser.Internal` | Parser state and combinator internals | Done |
+| `Nix.Parser.ParseError` | Structured parse errors with source positions | Done |
+
+### Evaluator
+
+| Module | Purpose | Status |
+|--------|---------|--------|
+| `Nix.Eval` | Lazy evaluator — all 17 AST constructors, thunk forcing, env operations, 91-builtin dispatch, search path resolution, dynamic attribute keys. Polymorphic via `MonadEval` | Done |
+| `Nix.Eval.Types` | Shared types — `NixValue` (11 constructors), `Thunk` (lazy env for knot-tying), `Env` (lexical + with-scope chain), `StringContext` (store path tracking), `MonadEval` typeclass, `PureEval` runner | Done |
+| `Nix.Eval.Operator` | Binary/unary operators — arithmetic with float promotion, deep structural equality, division-by-zero checks | Done |
+| `Nix.Eval.StringInterp` | String interpolation — value coercion with context propagation, indented string whitespace stripping | Done |
+| `Nix.Eval.Context` | String context construction, queries, extraction — pure helpers for building and inspecting store path references | Done |
+| `Nix.Eval.IO` | IO evaluation monad — real filesystem access, import cache (with directory import), process execution, store writes, NIX_PATH parsing, per-thunk IORef memoization (matching real Nix in-place mutation) | Done |
+| `Nix.Builtins` | Built-in function environment — 91 builtins, search path plumbing (`parseNixPath`), top-level builtin exposure | Done |
+
+### Store + Builder
+
+| Module | Purpose | Status |
+|--------|---------|--------|
+| `Nix.Derivation` | Derivation type, ATerm serialization + parsing (`toATerm`/`fromATerm`), platform detection | Done |
+| `Nix.Hash` | Derivation hashing, store path computation, shared hex/base-32 utilities | Done |
+| `Nix.Store.Path` | Store path types — `StoreDir`, `StorePath`, `parseStorePath`, Windows/Unix support | Done |
+| `Nix.Store.DB` | SQLite store database — `ValidPaths` + `Refs` tables, WAL mode, path registration, reference/deriver queries | Done |
+| `Nix.Store` | High-level store operations — `addToStore`, `scanReferences`, `setReadOnly`, `writeDrv` | Done |
+| `Nix.Builder` | Derivation builder — dependency graph construction, topological sort, binary cache substitution, local build with output registration | Done |
+| `Nix.DependencyGraph` | Dependency graph construction (BFS with `Seq` queue) and topological sort (Kahn's algorithm, O(V+E)), cycle detection | Done |
+| `Nix.Substituter` | Binary cache substituter — HTTP narinfo fetch, signature verification, NAR download/decompress/unpack, store registration. Multi-cache with priority ordering | Done |
+
+---
+
+## Architecture
+
+```
+                     Pure Core (no IO)
+  +-------------------------------------------------+
+  |                                                 |
+  |  Parser --> Expr.Types --> Eval --> Builtins     |
+  |                 |           |                    |
+  |          Parser.Lexer    Eval.Types              |
+  |          Parser.Expr     Eval.Operator           |
+  |          Parser.Internal Eval.StringInterp       |
+  |          ParseError      Eval.Context            |
+  |                             |                    |
+  |                        Derivation --> Hash        |
+  |                             |                    |
+  |                    Store.Path  DependencyGraph    |
+  |                                                 |
+  +-------------------------------------------------+
+                        |
+               IO Boundary (thin)
+  +-------------------------------------------------+
+  |  Eval.IO   Store.DB   Store   Builder   Substituter|
+  +-------------------------------------------------+
+```
+
+**Evaluator design:**
+
+- **MonadEval typeclass** — The evaluator is `eval :: (MonadEval m) => Env -> Expr -> m NixValue`, polymorphic in its effect monad. `PureEval` (newtype over `Either Text`) runs all pure tests with no IO. `EvalIO` provides `readFileText`, `doesPathExist`, `listDirectory`, `getEnvVar`, `getCurrentTime`, `writeToStore`, `scopedImportFile`, `runProcess` for IO builtins.
+- **Thunk-based lazy evaluation with memoization** — List elements and attribute set values are stored as unevaluated thunks (`Thunk Expr Env`). Only forced when a value is demanded. `(x: 1) (throw "boom")` returns `1` because `x` is never referenced. In `EvalIO`, each thunk carries a per-thunk `IORef` memo cell — forced once, then cached in place (matching real Nix's in-place mutation). Dead thunks are reclaimed by GC naturally.
+- **Knot-tying via Haskell laziness** — Recursive `let` and `rec { }` create self-referential environments. The `Thunk` type has a lazy `Env` field so thunks can capture environments that include themselves. Haskell's own laziness resolves the recursion. Dynamic attribute keys are resolved monadi­cally *before* knot-tying — the two-phase design (`resolveBindingKeys` then `buildResolvedBindingsMap`) cleanly separates key evaluation from value thunk construction.
+- **Search path desugaring** — `<nixpkgs>` is its own AST constructor (`ESearchPath`), desugared at eval time to `builtins.findFile builtins.nixPath "nixpkgs"` — exactly how real Nix handles it. `builtins.nixPath` is populated from `NIX_PATH` and `--nix-path` flags.
+- **With-scope chain** — `Env` has lexical bindings (always win) plus a stack of with-scopes walked innermost-first. `let a = 1; in with { a = 2; }; a` correctly returns `1` because lexical scope takes priority.
+- **Short-circuit operators** — `&&`, `||`, and `->` are handled directly in eval (not delegated to Operator) because they must not evaluate both operands.
+- **String context propagation** — Every `VStr` carries a `StringContext` tracking store path references (`SCPlain`, `SCDrvOutput`, `SCAllOutputs`). Context merges through interpolation, concatenation, and string builtins. The `derivation` builtin collects all context into `drvInputDrvs`/`drvInputSrcs`.
+
+**Build pipeline:**
+
+1. Evaluate `.nix` file to extract derivation
+2. Build dependency graph by reading `.drv` files from the store (BFS traversal)
+3. Topologically sort via Kahn's algorithm — leaves first, cycle detection
+4. For each dependency in build order: check store cache, try binary substitution, build locally
+5. Build execution: validate inputs, set up environment, run builder process, scan references, register outputs in SQLite DB
+
+**Key numbers:**
+
+- **22 modules** — all implemented
+- **508 tests** — hand-rolled harness, no framework dependencies
+- **Zero partial functions** — total by construction, `T.uncons` over `T.head`/`T.tail`
+- **Strict by default** — bang patterns on all data fields (except Thunk's Env, which is lazy for knot-tying)
+
+---
+
+## The Hard Problems
+
+Building Nix on Windows means solving problems nobody has fully solved before:
+
+| Problem | Solution |
+|---------|----------|
+| **No `fork`/`exec`** | `System.Process.createProcess` maps to Win32 `CreateProcess` natively |
+| **No symlinks (sometimes)** | Developer Mode enables symlinks; fallback to junction points / copies |
+| **`/nix/store` doesn't exist** | `C:\nix\store` as `StoreDir` — all paths parameterized, never hardcoded |
+| **Case-insensitive filesystem** | Nix store paths are case-sensitive by content hash — collisions impossible |
+| **260-char path limit** | `\\?\` extended-length prefix (32K chars), already used by cargo/node |
+| **No bash** | Ship `bash.exe` from MSYS2 (same as Git for Windows) |
+| **Sandboxing** | Unsandboxed initially (macOS did this for years); future: Win32 Job Objects + App Containers |
+| **stdenv bootstrap** | Cross-compile from Linux, or bootstrap from MSYS2 MinGW toolchain |
+| **Cross-device moves** | `renameDirectory` can fail across devices; fallback to recursive copy + remove |
+
+The biggest challenge isn't any single feature — it's **nixpkgs compatibility**. nixpkgs is 80,000+ packages defined as one massive recursive attrset. It exercises every builtin, every edge case in string context tracking, and every lazy evaluation pattern. The evaluator must handle all of this correctly and fast enough (~2-5 seconds for full nixpkgs eval).
+
+---
+
+## Roadmap
+
+### Done
+
+- [x] **Lexer** — Full Nix tokenization (integers, floats, strings with interpolation, paths, URIs, search paths, operators, keywords)
+- [x] **Parser** — 13 precedence levels, all Nix syntax, structured error reporting
+- [x] **Evaluator** — All 17 AST constructors, lazy thunks, recursive let/rec via knot-tying, with-scope chain, dynamic attribute keys
+- [x] **91 builtins** — Type checks, arithmetic, bitwise, strings, lists, attrsets, higher-order, JSON, hashing, regex (`match`/`split` via `regex-tdfa`), version parsing, tryEval, deepSeq, genericClosure, string context introspection, all IO builtins, derivation
+- [x] **MonadEval refactor** — Evaluator polymorphic in effect monad (`PureEval` for tests, `EvalIO` for real evaluation)
+- [x] **IO builtins** — `import` (with directory import support), `readFile`, `pathExists`, `readDir`, `getEnv`, `toPath`, `toFile`, `findFile`, `scopedImport`, `fetchurl`, `fetchTarball`, `fetchGit`, `currentTime`
+- [x] **`derivation`** — Attrset to `.drv` build recipe with computed `drvPath` and `outPath`, context-aware input population
+- [x] **String context tracking** — `SCPlain`, `SCDrvOutput`, `SCAllOutputs` on every `VStr`, propagated through interpolation, operators, and all string builtins. `hasContext`, `getContext`, `appendContext` introspection builtins.
+- [x] **ATerm serialization + parsing** — Full `.drv` round-trip with `toATerm`/`fromATerm`, string escaping, sorted environments
+- [x] **SQLite store DB** — `ValidPaths` + `Refs` tables, WAL mode, registration, validity checks, reference/deriver queries
+- [x] **Store operations** — `parseStorePath`, `addToStore` (cross-device safe), `scanReferences` (byte-scan), `setReadOnly`, `writeDrv`
+- [x] **Dependency graph** — BFS construction with `Data.Sequence` (O(V+E)), topological sort via Kahn's algorithm, cycle detection
+- [x] **Builder** — Full build loop with recursive dependency resolution: topo sort, cache check, binary substitution, local build, output registration
+- [x] **Binary substituter** — HTTP binary cache protocol: narinfo fetch/parse, Ed25519 signature verification via nova-cache, NAR download/decompress/unpack, store DB registration. Multi-cache with priority ordering.
+- [x] **NIX_PATH / search path resolution** — `<nixpkgs>` desugars to `builtins.findFile builtins.nixPath name`. `NIX_PATH` parsed at startup. `--nix-path` CLI flag for additional entries.
+- [x] **Dynamic attribute keys** — `{ ${expr} = val; }` works in all binding contexts with monadic key resolution preserving knot-tying
+- [x] **Directory imports** — `import ./dir` resolves to `./dir/default.nix` automatically
+- [x] **CLI** — `nova-nix eval FILE.nix`, `nova-nix eval --expr 'EXPR'`, `nova-nix build FILE.nix`, `--nix-path NAME=PATH`
+- [x] **Thunk memoization** — Per-thunk `IORef` memo cells matching real Nix in-place mutation. GC reclaims dead thunks naturally.
+- [x] **Regex builtins** — `builtins.match` and `builtins.split` (POSIX ERE via `regex-tdfa`, pure Haskell, cross-platform)
+- [x] **508 tests** — parser, evaluator, store, builder, substituter, dependency graph, search paths, dynamic keys, directory imports, CLI end-to-end
+
+### Next
+
+- [ ] **nixpkgs evaluation** — The ultimate test: `import <nixpkgs> {}` evaluates correctly and fast
+- [ ] **Missing builtins** — Any others nixpkgs demands (discovered iteratively)
+- [ ] **Performance** — Target ~2-5 seconds for full nixpkgs eval
+
+### Long-Term
+
+- [ ] **`nova-nix shell`** — Enter a development shell (like `nix shell`)
+- [ ] **`nova-nix repl`** — Interactive evaluator
+- [ ] **Nix daemon protocol compatibility**
+- [ ] **XZ decompression** — Enable nova-cache compression flag for real binary cache downloads
+
+---
+
+## Build & Test
+
+```bash
+cabal build                              # Build library + CLI
+cabal test                               # Run all 508 tests
+cabal build --ghc-options="-Werror"      # Warnings as errors (CI default)
+cabal haddock                            # Generate API docs
+```
+
+Requires GHC 9.6 and cabal-install 3.10+.
+
+---
+
+<p align="center">
+  <sub>MIT License · <a href="https://github.com/Novavero-AI">Novavero AI</a></sub>
+</p>
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -3,9 +3,17 @@
 -- Commands:
 --
 -- @
--- nova-nix eval  FILE.nix       Evaluate a .nix file, print result
--- nova-nix build FILE.nix       Build a derivation from a .nix file
+-- nova-nix eval  FILE.nix                  Evaluate a .nix file, print result
+-- nova-nix eval  --expr 'EXPR'             Evaluate an inline expression
+-- nova-nix build FILE.nix                  Build a derivation from a .nix file
 -- @
+--
+-- Flags:
+--
+-- @
+-- --nix-path NAME=PATH   Add a search path entry (repeatable, merged with NIX_PATH)
+-- --expr EXPR            Evaluate an inline expression instead of a file
+-- @
 module Main (main) where
 
 import Control.Monad ((>=>))
@@ -13,37 +21,86 @@
 import qualified Data.Text as T
 import qualified Data.Text.IO as TIO
 import Nix.Builder (BuildConfig (..), BuildResult (..), buildWithDeps, defaultBuildConfig)
-import Nix.Builtins (builtinEnv)
+import Nix.Builtins (builtinEnv, parseNixPath)
 import Nix.Derivation (Derivation (..), DerivationOutput (..))
 import Nix.Eval (MonadEval, NixValue (..), Thunk (..), eval, force)
 import Nix.Eval.IO (EvalState (..), newEvalState, runEvalIO)
 import Nix.Parser (parseNix)
 import Nix.Store (Store, closeStore, openStore, writeDrv)
 import Nix.Store.Path (StorePath, defaultStoreDir, parseStorePath, storePathToFilePath)
-import System.Directory (getTemporaryDirectory)
+import System.Directory (getCurrentDirectory, getTemporaryDirectory)
 import System.Environment (getArgs)
 import System.Exit (exitFailure)
 import System.FilePath (takeDirectory)
 import System.IO (BufferMode (..), hPutStrLn, hSetBuffering, stderr, stdout)
 
+-- ---------------------------------------------------------------------------
+-- Argument parsing
+-- ---------------------------------------------------------------------------
+
+-- | Parsed CLI options.
+data CliOpts = CliOpts
+  { optNixPaths :: ![T.Text],
+    optStrict :: !Bool,
+    optCommand :: !Command
+  }
+
+data Command
+  = CmdEvalFile !FilePath
+  | CmdEvalExpr !T.Text
+  | CmdBuild !FilePath
+  | CmdHelp
+
+parseArgs :: [String] -> CliOpts
+parseArgs = go (CliOpts [] False CmdHelp)
+  where
+    go opts [] = opts
+    go opts ("--nix-path" : val : rest) =
+      go (opts {optNixPaths = optNixPaths opts ++ [T.pack val]}) rest
+    go opts ("--strict" : rest) =
+      go (opts {optStrict = True}) rest
+    go opts ("eval" : "--expr" : expr : rest) =
+      go (opts {optCommand = CmdEvalExpr (T.pack expr)}) rest
+    go opts ("eval" : path : rest) =
+      go (opts {optCommand = CmdEvalFile path}) rest
+    go opts ("build" : path : rest) =
+      go (opts {optCommand = CmdBuild path}) rest
+    go opts _ = opts
+
+-- | Merge --nix-path entries with the search paths from NIX_PATH.
+mergeSearchPaths :: [T.Text] -> [Thunk] -> [Thunk]
+mergeSearchPaths extraPaths envPaths =
+  concatMap parseNixPath extraPaths ++ envPaths
+
+-- ---------------------------------------------------------------------------
+-- Main
+-- ---------------------------------------------------------------------------
+
 main :: IO ()
 main = do
   hSetBuffering stdout LineBuffering
   args <- getArgs
-  case args of
-    ["eval", filePath] -> evalFile filePath
-    ["build", filePath] -> buildFile filePath
-    _ -> do
-      hPutStrLn stderr "Usage: nova-nix <command> FILE.nix"
+  let opts = parseArgs args
+  case optCommand opts of
+    CmdEvalFile filePath -> evalFile (optStrict opts) (optNixPaths opts) filePath
+    CmdEvalExpr expr -> evalExpr (optStrict opts) (optNixPaths opts) expr
+    CmdBuild filePath -> buildFile (optNixPaths opts) filePath
+    CmdHelp -> do
+      hPutStrLn stderr "Usage: nova-nix [--nix-path NAME=PATH] <command>"
       hPutStrLn stderr ""
       hPutStrLn stderr "Commands:"
-      hPutStrLn stderr "  eval   Evaluate a .nix file, print result"
-      hPutStrLn stderr "  build  Build a derivation from a .nix file"
+      hPutStrLn stderr "  eval FILE.nix          Evaluate a .nix file, print result"
+      hPutStrLn stderr "  eval --expr 'EXPR'     Evaluate an inline expression"
+      hPutStrLn stderr "  build FILE.nix         Build a derivation from a .nix file"
+      hPutStrLn stderr ""
+      hPutStrLn stderr "Flags:"
+      hPutStrLn stderr "  --strict               Deep-force all thunks before printing (warning: OOM on large results)"
+      hPutStrLn stderr "  --nix-path NAME=PATH   Add search path (repeatable, merged with NIX_PATH)"
       exitFailure
 
 -- | Evaluate a .nix file and print the result.
-evalFile :: FilePath -> IO ()
-evalFile filePath = do
+evalFile :: Bool -> [T.Text] -> FilePath -> IO ()
+evalFile strict extraPaths filePath = do
   source <- TIO.readFile filePath
   case parseNix (T.pack filePath) source of
     Left err -> do
@@ -51,18 +108,39 @@
       exitFailure
     Right expr -> do
       st <- newEvalState (takeDirectory filePath)
-      result <- runEvalIO st $ do
-        val <- eval (builtinEnv (esTimestamp st)) expr
-        deepForceValue val
+      let searchPaths = mergeSearchPaths extraPaths (esSearchPaths st)
+      result <-
+        runEvalIO st $
+          eval (builtinEnv (esTimestamp st) searchPaths) expr >>= finalize strict
       case result of
         Left err -> do
           TIO.hPutStrLn stderr ("error: " <> err)
           exitFailure
         Right forced -> TIO.putStrLn (prettyValue forced)
 
+-- | Evaluate an inline expression and print the result.
+evalExpr :: Bool -> [T.Text] -> T.Text -> IO ()
+evalExpr strict extraPaths source = do
+  case parseNix "<expr>" source of
+    Left err -> do
+      hPutStrLn stderr ("parse error: " ++ show err)
+      exitFailure
+    Right expr -> do
+      cwd <- getCurrentDirectory
+      st <- newEvalState cwd
+      let searchPaths = mergeSearchPaths extraPaths (esSearchPaths st)
+      result <-
+        runEvalIO st $
+          eval (builtinEnv (esTimestamp st) searchPaths) expr >>= finalize strict
+      case result of
+        Left err -> do
+          TIO.hPutStrLn stderr ("error: " <> err)
+          exitFailure
+        Right forced -> TIO.putStrLn (prettyValue forced)
+
 -- | Parse, evaluate, extract derivation, build, and print result.
-buildFile :: FilePath -> IO ()
-buildFile filePath = do
+buildFile :: [T.Text] -> FilePath -> IO ()
+buildFile extraPaths filePath = do
   source <- TIO.readFile filePath
   case parseNix (T.pack filePath) source of
     Left err -> do
@@ -70,7 +148,8 @@
       exitFailure
     Right expr -> do
       st <- newEvalState (takeDirectory filePath)
-      result <- runEvalIO st (eval (builtinEnv (esTimestamp st)) expr)
+      let searchPaths = mergeSearchPaths extraPaths (esSearchPaths st)
+      result <- runEvalIO st (eval (builtinEnv (esTimestamp st) searchPaths) expr)
       case result of
         Left err -> do
           TIO.hPutStrLn stderr ("eval error: " <> err)
@@ -143,6 +222,17 @@
       -- Write .drv to store if a drvPath is available in the env.
       let envDrvPath = Map.lookup "drvPath" (drvEnv drv)
       mapM_ (writeDrv store drv) (envDrvPath >>= parseStorePath defaultStoreDir)
+
+-- ---------------------------------------------------------------------------
+-- Output formatting
+-- ---------------------------------------------------------------------------
+
+-- | Optionally deep-force a value before printing.
+-- With @--strict@, all thunks are recursively materialized.
+-- Without it, thunks display as @«thunk»@ — safe for large results.
+finalize :: (MonadEval m) => Bool -> NixValue -> m NixValue
+finalize True = deepForceValue
+finalize False = pure
 
 -- ---------------------------------------------------------------------------
 -- Deep-force and pretty-print
diff --git a/nova-nix.cabal b/nova-nix.cabal
--- a/nova-nix.cabal
+++ b/nova-nix.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               nova-nix
-version:            0.1.0.1
+version:            0.1.1.0
 synopsis:           Windows-native Nix implementation in pure Haskell
 description:
   A pure Haskell implementation of the Nix package manager that runs natively
@@ -23,6 +23,7 @@
 tested-with:        GHC == 9.6.7
 extra-doc-files:
     CHANGELOG.md
+    README.md
 
 -- ---------------------------------------------------------------------------
 -- Library: core modules (parser, evaluator, store, builder, substituter)
@@ -54,6 +55,7 @@
 
   build-depends:
       base                >= 4.16 && < 5
+    , array               >= 0.5 && < 0.6
     , bytestring          >= 0.11 && < 0.13
     , containers          >= 0.6 && < 0.8
     , crypton             >= 1.0 && < 2
@@ -66,6 +68,7 @@
     , mtl                 >= 2.2 && < 2.4
     , nova-cache          >= 0.2 && < 0.3
     , process             >= 1.6 && < 1.7
+    , regex-tdfa          >= 1.3 && < 1.4
     , sqlite-simple       >= 0.4 && < 0.5
     , text                >= 2.0 && < 2.2
     , time                >= 1.9 && < 1.15
diff --git a/src/Nix/Builder.hs b/src/Nix/Builder.hs
--- a/src/Nix/Builder.hs
+++ b/src/Nix/Builder.hs
@@ -332,9 +332,10 @@
   IO BuildResult
 registerOutputs config store drv _buildDir outputDirs = do
   let allCandidates = collectAllCandidates drv
-      drvPathText = case drvOutputs drv of
-        [] -> Nothing
-        _ -> Just (T.pack (storePathToFilePath (bcStoreDir config) (StorePath "unknown" "unknown")))
+      -- Deriver path is not available from the Derivation type alone;
+      -- the caller (buildWithDeps) would need to pass it through.
+      -- Register with no deriver for now — queryDeriver will return Nothing.
+      drvPathText = Nothing
   -- Register each output
   results <- mapM (registerSingleOutput config store allCandidates drvPathText) (zip (drvOutputs drv) outputDirs)
   case sequence results of
diff --git a/src/Nix/Builtins.hs b/src/Nix/Builtins.hs
--- a/src/Nix/Builtins.hs
+++ b/src/Nix/Builtins.hs
@@ -9,11 +9,15 @@
   ( -- * Builtin registration
     builtinEnv,
     builtinEnvWithScope,
+
+    -- * NIX_PATH parsing
+    parseNixPath,
   )
 where
 
 import qualified Data.Map.Strict as Map
 import Data.Text (Text)
+import qualified Data.Text as T
 import Nix.Eval (Env (..), NixValue (..), Thunk (..), builtinNames, currentSystemStr, evaluated)
 import Nix.Eval.Types (mkStr)
 import Nix.Store.Path (defaultStoreDirText)
@@ -26,8 +30,11 @@
 --
 -- @currentTime@ is an integer constant (seconds since epoch),
 -- passed in at startup.  In tests, pass @0@.
-builtinEnv :: Integer -> Env
-builtinEnv timestamp =
+--
+-- @searchPaths@ populates @builtins.nixPath@.  Parsed from @NIX_PATH@
+-- by 'parseNixPath'.  In tests, pass @[]@.
+builtinEnv :: Integer -> [Thunk] -> Env
+builtinEnv timestamp searchPaths =
   Env
     { envBindings =
         Map.fromList $
@@ -35,7 +42,7 @@
           [ ("true", evaluated (VBool True)),
             ("false", evaluated (VBool False)),
             ("null", evaluated VNull),
-            ("builtins", evaluated (builtinsAttrSet timestamp))
+            ("builtins", evaluated (builtinsAttrSet timestamp searchPaths))
           ]
             -- Top-level builtin functions (available without builtins. prefix)
             ++ map topLevelBuiltin topLevelBuiltinNames,
@@ -70,22 +77,22 @@
 
 -- | Like 'builtinEnv' but with additional scope bindings overlaid on
 -- the top-level environment.  Used by @scopedImport@.
-builtinEnvWithScope :: Integer -> [(Text, Thunk)] -> Env
-builtinEnvWithScope timestamp scope =
-  let base = builtinEnv timestamp
+builtinEnvWithScope :: Integer -> [Thunk] -> [(Text, Thunk)] -> Env
+builtinEnvWithScope timestamp searchPaths scope =
+  let base = builtinEnv timestamp searchPaths
       scopeMap = Map.fromList scope
    in base {envBindings = Map.union scopeMap (envBindings base)}
 
 -- | The @builtins@ attribute set, derived from the central registry.
-builtinsAttrSet :: Integer -> NixValue
-builtinsAttrSet timestamp =
-  VAttrs $ Map.union builtinEntries (standardEntries timestamp)
+builtinsAttrSet :: Integer -> [Thunk] -> NixValue
+builtinsAttrSet timestamp searchPaths =
+  VAttrs $ Map.union builtinEntries (standardEntries timestamp searchPaths)
   where
     builtinEntries =
       Map.fromList [(name, evaluated (VBuiltin name [])) | name <- builtinNames]
 
-standardEntries :: Integer -> Map.Map Text Thunk
-standardEntries timestamp =
+standardEntries :: Integer -> [Thunk] -> Map.Map Text Thunk
+standardEntries timestamp searchPaths =
   Map.fromList
     [ ("true", evaluated (VBool True)),
       ("false", evaluated (VBool False)),
@@ -93,7 +100,56 @@
       ("storeDir", evaluated (mkStr defaultStoreDirText)),
       ("nixVersion", evaluated (mkStr "2.24.0")),
       ("langVersion", evaluated (VInt 6)),
-      ("nixPath", evaluated (VList [])),
+      ("nixPath", evaluated (VList searchPaths)),
       ("currentTime", evaluated (VInt timestamp)),
       ("currentSystem", evaluated (mkStr currentSystemStr))
     ]
+
+-- ---------------------------------------------------------------------------
+-- NIX_PATH parsing
+-- ---------------------------------------------------------------------------
+
+-- | Parse a @NIX_PATH@-formatted string into a list of search path entry
+-- thunks.  Each entry becomes a @{ prefix, path }@ attrset.
+--
+-- Format: colon-separated entries, each either @name=path@ or plain @path@.
+-- A plain path gets an empty prefix (matching real Nix behaviour).
+--
+-- >>> parseNixPath "nixpkgs=/home/user/nixpkgs:custom=/opt/custom"
+-- [Evaluated (VAttrs {"prefix": "nixpkgs", "path": "/home/user/nixpkgs"}), ...]
+parseNixPath :: Text -> [Thunk]
+parseNixPath raw
+  | T.null raw = []
+  | otherwise = map parseEntry (splitNixPath raw)
+  where
+    parseEntry entry =
+      let (prefix, path) = case T.breakOn "=" entry of
+            (before, after)
+              | T.null after -> ("", before)
+              | otherwise -> (before, T.drop 1 after)
+       in evaluated
+            ( VAttrs
+                ( Map.fromList
+                    [ ("prefix", evaluated (mkStr prefix)),
+                      ("path", evaluated (mkStr path))
+                    ]
+                )
+            )
+
+-- | Split a NIX_PATH string on colon separators, respecting Windows
+-- drive letters.  A colon followed by @\\@ or @/@ (e.g. @C:\\@) is
+-- part of a path, not a separator.
+splitNixPath :: Text -> [Text]
+splitNixPath = go T.empty
+  where
+    go acc remaining = case T.uncons remaining of
+      Nothing -> [acc | not (T.null acc)]
+      Just (':', rest)
+        | isDriveSep rest -> go (acc <> ":" <> T.take 1 rest) (T.drop 1 rest)
+        | otherwise -> acc : go T.empty rest
+      Just (c, rest) -> go (T.snoc acc c) rest
+    -- After a colon, if the next char is \ or /, it's a drive letter
+    isDriveSep t = case T.uncons t of
+      Just ('\\', _) -> True
+      Just ('/', _) -> True
+      _ -> False
diff --git a/src/Nix/Eval.hs b/src/Nix/Eval.hs
--- a/src/Nix/Eval.hs
+++ b/src/Nix/Eval.hs
@@ -49,6 +49,7 @@
 
 import Control.Monad (when, (>=>))
 import qualified Crypto.Hash as CH
+import qualified Data.Array as Array
 import Data.Bits (xor, (.&.), (.|.))
 import qualified Data.ByteArray as BA
 import Data.Char (chr, digitToInt, isAlpha, isDigit, isHexDigit, ord)
@@ -92,10 +93,13 @@
     Formal (..),
     Formals (..),
     NixAtom (..),
+    StringPart (..),
   )
 import Nix.Hash (byteToHex, sha256Hex, truncatedBase32)
 import Nix.Store.Path (StorePath (..), defaultStoreDir, defaultStoreDirText, parseStorePath, storePathToFilePath)
 import qualified System.Info
+import Text.Regex.TDFA (matchAllText)
+import qualified Text.Regex.TDFA as RE
 
 -- | Evaluate a Nix expression in an environment.
 eval :: (MonadEval m) => Env -> Expr -> m NixValue
@@ -117,6 +121,7 @@
   EUnary op operand -> do
     val <- eval env operand
     evalUnary op val
+  ESearchPath name -> evalSearchPath env name
   EBinary OpAnd left right -> evalShortCircuitAnd env left right
   EBinary OpOr left right -> evalShortCircuitOr env left right
   EBinary OpImpl left right -> evalShortCircuitImpl env left right
@@ -126,9 +131,12 @@
     evalBinary force op leftVal rightVal
 
 -- | Force a thunk to a value.
+--
+-- Delegates to 'forceThunk' which is a 'MonadEval' method — this
+-- allows IO evaluators to implement memoization (caching the result
+-- after the first force) while pure evaluators simply re-evaluate.
 force :: (MonadEval m) => Thunk -> m NixValue
-force (Evaluated val) = pure val
-force (Thunk thunkExpr thunkEnv) = eval thunkEnv thunkExpr
+force = forceThunk eval
 
 -- ---------------------------------------------------------------------------
 -- Literal
@@ -141,9 +149,30 @@
   NixBool b -> pure (VBool b)
   NixNull -> pure VNull
   NixUri u -> pure (mkStr u)
-  NixPath p -> pure (VPath p)
+  NixPath p -> VPath <$> resolvePathLiteral p
 
 -- ---------------------------------------------------------------------------
+-- Search paths (<nixpkgs>, <nixpkgs/lib>)
+-- ---------------------------------------------------------------------------
+
+-- | Evaluate a search path expression.
+-- Desugars to @builtins.findFile builtins.nixPath "name"@ — exactly how
+-- real Nix handles @\<name\>@ expressions.
+evalSearchPath :: (MonadEval m) => Env -> Text -> m NixValue
+evalSearchPath env name = do
+  builtinsVal <- evalVar env "builtins"
+  case builtinsVal of
+    VAttrs builtinsAttrs ->
+      case Map.lookup "nixPath" builtinsAttrs of
+        Just nixPathThunk -> do
+          nixPathVal <- force nixPathThunk
+          builtinFindFile nixPathVal (mkStr name)
+        Nothing ->
+          throwEvalError ("file '" <> name <> "' was not found in the Nix search path")
+    _ ->
+      throwEvalError ("file '" <> name <> "' was not found in the Nix search path")
+
+-- ---------------------------------------------------------------------------
 -- Variables
 -- ---------------------------------------------------------------------------
 
@@ -163,74 +192,154 @@
 
 -- | Non-recursive attribute set: thunks capture the outer environment.
 evalNonRecAttrs :: (MonadEval m) => Env -> [Binding] -> m NixValue
-evalNonRecAttrs env bindings =
-  case buildBindingsMap env env bindings of
-    Left err -> throwEvalError err
-    Right attrMap -> pure (VAttrs attrMap)
+evalNonRecAttrs env bindings = do
+  attrMap <- buildBindingsMapM env env bindings
+  pure (VAttrs attrMap)
 
 -- | Recursive attribute set: thunks capture the completed environment
 -- (Haskell's laziness ties the knot — Thunk's Env field is lazy).
+-- Dynamic keys in recursive attrs evaluate against the outer env
+-- (the rec env is not yet available during key resolution).
 evalRecAttrs :: (MonadEval m) => Env -> [Binding] -> m NixValue
-evalRecAttrs env bindings =
+evalRecAttrs env bindings = do
+  -- Resolve dynamic keys eagerly against the outer env.
+  resolvedBindings <- resolveBindingKeys env bindings
+  -- Now knot-tie: thunks capture recEnv, keys are already resolved.
   let recEnv = env {envBindings = Map.union recBindings (envBindings env)}
-      recBindings = case buildBindingsMap recEnv recEnv bindings of
-        Right m -> m
-        Left _ -> Map.empty
-   in pure (VAttrs recBindings)
+      recBindings = buildResolvedBindingsMap recEnv resolvedBindings
+  pure (VAttrs recBindings)
 
--- | Build a flat attribute map from bindings (pure — only creates
--- thunks, never forces them).  Used by let, rec {}, and non-rec {}.
+-- | Build a flat attribute map from bindings (monadic — resolves
+-- dynamic keys via eval, but only creates thunks for values).
+-- Used by non-rec {}.
 --
+-- @thunkEnv@ is the environment captured by value thunks.
+-- @keyEnv@ is used to evaluate dynamic key expressions.
+--
 -- Handles nested attribute paths (@a.b.c = 1@) by building nested
 -- 'VAttrs' maps.  Handles @inherit@ by looking up names in the
 -- environment or a source expression.
-buildBindingsMap :: Env -> Env -> [Binding] -> Either Text (Map Text Thunk)
-buildBindingsMap thunkEnv lookupEnv =
-  foldl' mergeBinding (Right Map.empty)
+buildBindingsMapM :: (MonadEval m) => Env -> Env -> [Binding] -> m (Map Text Thunk)
+buildBindingsMapM thunkEnv keyEnv = foldlM' mergeBinding Map.empty
   where
-    mergeBinding acc binding = do
-      current <- acc
-      new <- processBinding thunkEnv lookupEnv binding
+    mergeBinding current binding = do
+      new <- processBindingM thunkEnv keyEnv binding
       pure (mergeAttrMaps current new)
 
+-- | Strict left fold over a list in a monad.
+foldlM' :: (Monad m) => (b -> a -> m b) -> b -> [a] -> m b
+foldlM' _ acc [] = pure acc
+foldlM' f !acc (x : xs) = do
+  acc2 <- f acc x
+  foldlM' f acc2 xs
+
 -- | Process a single binding into key-value pairs.
-processBinding :: Env -> Env -> Binding -> Either Text (Map Text Thunk)
-processBinding thunkEnv _ (NamedBinding path bodyExpr) =
-  buildNestedAttr thunkEnv path bodyExpr
-processBinding _ lookupEnv (Inherit Nothing names) =
-  Right $ Map.fromList [(n, inheritLookup lookupEnv n) | n <- names]
-processBinding thunkEnv _ (Inherit (Just fromExpr) names) =
-  Right $
+processBindingM :: (MonadEval m) => Env -> Env -> Binding -> m (Map Text Thunk)
+processBindingM thunkEnv keyEnv (NamedBinding path bodyExpr) =
+  buildNestedAttrM thunkEnv keyEnv path bodyExpr
+processBindingM _ lookupEnv (Inherit Nothing names) =
+  pure $ Map.fromList [(n, inheritLookup lookupEnv n) | n <- names]
+processBindingM thunkEnv _ (Inherit (Just fromExpr) names) =
+  pure $
     Map.fromList
       [ (n, mkThunk thunkEnv (ESelect fromExpr [StaticKey n] Nothing))
       | n <- names
       ]
 
+-- ---------------------------------------------------------------------------
+-- Resolved bindings (for knot-tying in rec {} and let)
+-- ---------------------------------------------------------------------------
+
+-- | A binding with all attribute keys pre-resolved to text.
+-- Used by 'evalRecAttrs' and 'evalLet' to separate key resolution
+-- (monadic, may evaluate dynamic keys) from thunk construction
+-- (pure, enables knot-tying).
+data ResolvedBinding
+  = -- | @path = expr@ with all keys resolved to text.
+    ResolvedNamed ![Text] !Expr
+  | -- | @inherit attrs@ from the surrounding scope.
+    ResolvedInherit ![Text]
+  | -- | @inherit (from) attrs@.
+    ResolvedInheritFrom !Expr ![Text]
+
+-- | Resolve all attribute keys in a list of bindings, evaluating
+-- dynamic keys against the given environment.
+resolveBindingKeys :: (MonadEval m) => Env -> [Binding] -> m [ResolvedBinding]
+resolveBindingKeys keyEnv = mapM resolveOne
+  where
+    resolveOne (NamedBinding path bodyExpr) = do
+      resolvedPath <- mapM (resolveKey keyEnv) path
+      pure (ResolvedNamed resolvedPath bodyExpr)
+    resolveOne (Inherit Nothing names) =
+      pure (ResolvedInherit names)
+    resolveOne (Inherit (Just fromExpr) names) =
+      pure (ResolvedInheritFrom fromExpr names)
+
+-- | Build a flat attribute map from pre-resolved bindings (pure).
+-- Thunks capture @thunkEnv@ — suitable for knot-tying.
+buildResolvedBindingsMap :: Env -> [ResolvedBinding] -> Map Text Thunk
+buildResolvedBindingsMap thunkEnv =
+  foldl' mergeBinding Map.empty
+  where
+    mergeBinding current binding =
+      mergeAttrMaps current (processResolved thunkEnv binding)
+
+-- | Process a single resolved binding into key-value pairs (pure).
+processResolved :: Env -> ResolvedBinding -> Map Text Thunk
+processResolved thunkEnv (ResolvedNamed path bodyExpr) =
+  buildResolvedNestedAttr thunkEnv path bodyExpr
+processResolved lookupEnv (ResolvedInherit names) =
+  Map.fromList [(n, inheritLookup lookupEnv n) | n <- names]
+processResolved thunkEnv (ResolvedInheritFrom fromExpr names) =
+  Map.fromList
+    [ (n, mkThunk thunkEnv (ESelect fromExpr [StaticKey n] Nothing))
+    | n <- names
+    ]
+
+-- | Build a nested attribute structure from a resolved dotted path (pure).
+buildResolvedNestedAttr :: Env -> [Text] -> Expr -> Map Text Thunk
+buildResolvedNestedAttr _thunkEnv [] _bodyExpr = Map.empty
+buildResolvedNestedAttr thunkEnv [key] bodyExpr =
+  Map.singleton key (mkThunk thunkEnv bodyExpr)
+buildResolvedNestedAttr thunkEnv (key : rest) bodyExpr =
+  Map.singleton key (evaluated (VAttrs (buildResolvedNestedAttr thunkEnv rest bodyExpr)))
+
 -- | Look up a name for @inherit@.  If not found, create a thunk
--- that will error when forced.
+-- that will error when forced (matching real Nix behaviour).
 inheritLookup :: Env -> Text -> Thunk
 inheritLookup env name =
   case envLookup name env of
     Just thunk -> thunk
-    Nothing -> evaluated (mkStr ("<undefined: " <> name <> ">"))
+    Nothing ->
+      -- Deferred error: only triggers if this binding is actually demanded.
+      -- Constructs: builtins.throw "undefined variable '<name>'"
+      let errMsg = "undefined variable '" <> name <> "'"
+          throwExpr = EApp (EVar "throw") (EStr [StrLit errMsg])
+       in mkThunk env throwExpr
 
 -- | Build a nested attribute structure from a dotted path.
 -- @a.b.c = expr@ becomes @{ a = { b = { c = thunk; }; }; }@.
-buildNestedAttr :: Env -> AttrPath -> Expr -> Either Text (Map Text Thunk)
-buildNestedAttr thunkEnv path bodyExpr = case path of
-  [] -> Left "empty attribute path"
-  [key] -> do
-    keyText <- resolveStaticKey key
-    pure (Map.singleton keyText (mkThunk thunkEnv bodyExpr))
-  (key : rest) -> do
-    keyText <- resolveStaticKey key
-    inner <- buildNestedAttr thunkEnv rest bodyExpr
-    pure (Map.singleton keyText (evaluated (VAttrs inner)))
+buildNestedAttrM :: (MonadEval m) => Env -> Env -> AttrPath -> Expr -> m (Map Text Thunk)
+buildNestedAttrM _thunkEnv _keyEnv [] _bodyExpr =
+  throwEvalError "empty attribute path"
+buildNestedAttrM thunkEnv keyEnv [key] bodyExpr = do
+  keyText <- resolveKey keyEnv key
+  pure (Map.singleton keyText (mkThunk thunkEnv bodyExpr))
+buildNestedAttrM thunkEnv keyEnv (key : rest) bodyExpr = do
+  keyText <- resolveKey keyEnv key
+  inner <- buildNestedAttrM thunkEnv keyEnv rest bodyExpr
+  pure (Map.singleton keyText (evaluated (VAttrs inner)))
 
--- | Resolve a static attribute key to its text name.
-resolveStaticKey :: AttrKey -> Either Text Text
-resolveStaticKey (StaticKey name) = Right name
-resolveStaticKey (DynamicKey _) = Left "dynamic attribute keys not yet supported"
+-- | Resolve an attribute key to its text name.
+-- Static keys return immediately; dynamic keys evaluate the expression
+-- and coerce the result to a string.
+resolveKey :: (MonadEval m) => Env -> AttrKey -> m Text
+resolveKey _env (StaticKey name) = pure name
+resolveKey env (DynamicKey expr) = do
+  val <- eval env expr
+  case val of
+    VStr s _ -> pure s
+    _ -> throwEvalError ("dynamic attribute key must be a string, got " <> typeName val)
 
 -- | Merge two attribute maps.  For overlapping keys where both sides
 -- are attribute sets, merge recursively (for nested attr paths).
@@ -251,7 +360,7 @@
 evalSelect :: (MonadEval m) => Env -> Expr -> AttrPath -> Maybe Expr -> m NixValue
 evalSelect env target path defExpr = do
   targetVal <- eval env target
-  result <- walkAttrPath path targetVal
+  result <- walkAttrPath env path targetVal
   case result of
     Just val -> pure val
     Nothing -> case defExpr of
@@ -261,24 +370,23 @@
 -- | Walk an attribute path through nested attribute sets.
 -- Returns @Just value@ if the full path resolves, @Nothing@ if any
 -- key is missing or a non-set is encountered mid-path.
-walkAttrPath :: (MonadEval m) => AttrPath -> NixValue -> m (Maybe NixValue)
-walkAttrPath [] val = pure (Just val)
-walkAttrPath (key : rest) val = case val of
-  VAttrs attrs ->
-    case resolveStaticKey key of
-      Left err -> throwEvalError err
-      Right keyText ->
-        case Map.lookup keyText attrs of
-          Just thunk -> do
-            inner <- force thunk
-            walkAttrPath rest inner
-          Nothing -> pure Nothing
+-- The @env@ is used only for resolving dynamic keys.
+walkAttrPath :: (MonadEval m) => Env -> AttrPath -> NixValue -> m (Maybe NixValue)
+walkAttrPath _env [] val = pure (Just val)
+walkAttrPath env (key : rest) val = case val of
+  VAttrs attrs -> do
+    keyText <- resolveKey env key
+    case Map.lookup keyText attrs of
+      Just thunk -> do
+        inner <- force thunk
+        walkAttrPath env rest inner
+      Nothing -> pure Nothing
   _ -> pure Nothing
 
 evalHasAttr :: (MonadEval m) => Env -> Expr -> AttrPath -> m NixValue
 evalHasAttr env target path = do
   targetVal <- eval env target
-  result <- walkAttrPath path targetVal
+  result <- walkAttrPath env path targetVal
   case result of
     Just _ -> pure (VBool True)
     Nothing -> pure (VBool False)
@@ -369,13 +477,14 @@
 
 -- | Let is recursive in Nix: all bindings are visible to each other.
 -- Knot-tying via Haskell laziness (Thunk's Env field is lazy).
+-- Dynamic keys in let evaluate against the outer env (the let env
+-- is not yet available during key resolution).
 evalLet :: (MonadEval m) => Env -> [Binding] -> Expr -> m NixValue
-evalLet env bindings body =
+evalLet env bindings body = do
+  resolvedBindings <- resolveBindingKeys env bindings
   let letEnv = env {envBindings = Map.union letBindings (envBindings env)}
-      letBindings = case buildBindingsMap letEnv letEnv bindings of
-        Right m -> m
-        Left _ -> Map.empty
-   in eval letEnv body
+      letBindings = buildResolvedBindingsMap letEnv resolvedBindings
+  eval letEnv body
 
 evalIf :: (MonadEval m) => Env -> Expr -> Expr -> Expr -> m NixValue
 evalIf env cond thenExpr elseExpr = do
@@ -566,6 +675,8 @@
       builtin1 "functionArgs" builtinFunctionArgs,
       builtin2 "zipAttrsWith" builtinZipAttrsWith,
       -- String manipulation
+      builtin2 "match" builtinMatch,
+      builtin2 "split" builtinSplit,
       builtin3 "replaceStrings" builtinReplaceStrings,
       builtin2 "compareVersions" builtinCompareVersions,
       builtin1 "splitVersion" builtinSplitVersion,
@@ -597,7 +708,11 @@
       builtin1 "fetchTarball" builtinFetchTarball,
       builtin1 "fetchGit" builtinFetchGit,
       -- Derivation construction
-      builtin1 "derivation" builtinDerivation
+      builtin1 "derivation" builtinDerivation,
+      -- Error context (pass-through — context only matters on error)
+      builtin2 "addErrorContext" (\_ val -> pure val),
+      -- Attr position (return null — nixpkgs handles this gracefully)
+      builtin2 "unsafeGetAttrPos" (\_ _ -> pure VNull)
     ]
 
 -- | Names of all registered builtins.
@@ -634,10 +749,138 @@
   throwEvalError ("attempt to call " <> typeName other <> ", which is not a function")
 
 -- | Execute a builtin once all arguments are collected.
+--
+-- Direct case dispatch avoids rebuilding the polymorphic 'builtinRegistry'
+-- Map on every call.  'builtinRegistry' is polymorphic in @m@ so GHC
+-- cannot cache it as a CAF — it gets reconstructed on every use.
+-- Pattern matching on the name is zero-allocation.
 executeBuiltin :: (MonadEval m) => Text -> [NixValue] -> m NixValue
-executeBuiltin name args = case Map.lookup name builtinRegistry of
-  Just def -> bdApply def args
-  Nothing -> throwEvalError ("unknown builtin '" <> name <> "'")
+executeBuiltin name args = case name of
+  -- Type checking (arity 1)
+  "typeOf" -> apply1 (pure . mkStr . typeOfValue)
+  "isNull" -> apply1 (pure . VBool . isNullVal)
+  "isInt" -> apply1 (pure . VBool . isIntVal)
+  "isFloat" -> apply1 (pure . VBool . isFloatVal)
+  "isBool" -> apply1 (pure . VBool . isBoolVal)
+  "isString" -> apply1 (pure . VBool . isStringVal)
+  "isList" -> apply1 (pure . VBool . isListVal)
+  "isAttrs" -> apply1 (pure . VBool . isAttrsVal)
+  "isFunction" -> apply1 (pure . VBool . isFunctionVal)
+  -- List operations (arity 1)
+  "length" -> apply1 builtinLength
+  "head" -> apply1 builtinHead
+  "tail" -> apply1 builtinTail
+  -- String operations (arity 1)
+  "toString" -> apply1 (fmap (uncurry VStr) . coerceToString)
+  "stringLength" -> apply1 builtinStringLength
+  -- Control (arity 1)
+  "throw" -> apply1 builtinThrow
+  "abort" -> apply1 builtinThrow
+  -- Attr set operations (arity 1)
+  "attrNames" -> apply1 builtinAttrNames
+  "attrValues" -> apply1 builtinAttrValues
+  "listToAttrs" -> apply1 builtinListToAttrs
+  -- Attr set operations (arity 2)
+  "hasAttr" -> apply2 builtinHasAttr
+  "getAttr" -> apply2 builtinGetAttr
+  "removeAttrs" -> apply2 builtinRemoveAttrs
+  "intersectAttrs" -> apply2 builtinIntersectAttrs
+  "catAttrs" -> apply2 builtinCatAttrs
+  -- List higher-order (arity 2)
+  "map" -> apply2 builtinMap
+  "filter" -> apply2 builtinFilter
+  "genList" -> apply2 builtinGenList
+  "sort" -> apply2 builtinSort
+  "concatMap" -> apply2 builtinConcatMap
+  "any" -> apply2 builtinAny
+  "all" -> apply2 builtinAll
+  "elem" -> apply2 builtinElem
+  "elemAt" -> apply2 builtinElemAt
+  "partition" -> apply2 builtinPartition
+  "groupBy" -> apply2 builtinGroupBy
+  -- String operations (arity 2)
+  "concatStringsSep" -> apply2 builtinConcatStringsSep
+  -- Arity 3
+  "foldl'" -> apply3 builtinFoldl
+  "substring" -> apply3 builtinSubstring
+  -- Numeric
+  "isPath" -> apply1 (pure . VBool . isPathVal)
+  "ceil" -> apply1 builtinCeil
+  "floor" -> apply1 builtinFloor
+  "seq" -> apply2 (\_ b -> pure b)
+  "trace" -> apply2 (\_ b -> pure b)
+  "unsafeDiscardStringContext" -> apply1 builtinDiscardContext
+  "unsafeDiscardOutputDependency" -> apply1 builtinDiscardOutputDep
+  -- String context introspection
+  "hasContext" -> apply1 builtinHasContext
+  "getContext" -> apply1 builtinGetContext
+  "appendContext" -> apply2 builtinAppendContext
+  "baseNameOf" -> apply1 builtinBaseNameOf
+  "dirOf" -> apply1 builtinDirOf
+  "concatLists" -> apply1 builtinConcatLists
+  "lessThan" -> apply2 builtinLessThan
+  -- Arithmetic + bitwise
+  "add" -> apply2 builtinAdd
+  "sub" -> apply2 builtinSub
+  "mul" -> apply2 builtinMul
+  "div" -> apply2 builtinDiv
+  "bitAnd" -> apply2 builtinBitAnd
+  "bitOr" -> apply2 builtinBitOr
+  "bitXor" -> apply2 builtinBitXor
+  -- Attr set higher-order
+  "mapAttrs" -> apply2 builtinMapAttrs
+  "functionArgs" -> apply1 builtinFunctionArgs
+  "zipAttrsWith" -> apply2 builtinZipAttrsWith
+  -- String manipulation
+  "match" -> apply2 builtinMatch
+  "split" -> apply2 builtinSplit
+  "replaceStrings" -> apply3 builtinReplaceStrings
+  "compareVersions" -> apply2 builtinCompareVersions
+  "splitVersion" -> apply1 builtinSplitVersion
+  "parseDrvName" -> apply1 builtinParseDrvName
+  -- Serialization + hashing
+  "toJSON" -> apply1 builtinToJSON
+  "fromJSON" -> apply1 builtinFromJSON
+  "hashString" -> apply2 builtinHashString
+  -- Error handling + sequencing
+  "tryEval" -> apply1 (\_ -> throwEvalError "unreachable: tryEval handled in evalApp")
+  "deepSeq" -> apply2 builtinDeepSeq
+  -- Graph traversal
+  "genericClosure" -> apply1 builtinGenericClosure
+  -- IO builtins (delegate to MonadEval methods)
+  "import" -> apply1 builtinImport
+  "readFile" -> apply1 builtinReadFile
+  "pathExists" -> apply1 builtinPathExists
+  "readDir" -> apply1 builtinReadDir
+  "getEnv" -> apply1 builtinGetEnv
+  "toPath" -> apply1 builtinToPath
+  -- Store path operations
+  "placeholder" -> apply1 builtinPlaceholder
+  "storePath" -> apply1 builtinStorePath
+  "findFile" -> apply2 builtinFindFile
+  "toFile" -> apply2 builtinToFile
+  "scopedImport" -> apply2 builtinScopedImport
+  -- Network fetchers
+  "fetchurl" -> apply1 builtinFetchurl
+  "fetchTarball" -> apply1 builtinFetchTarball
+  "fetchGit" -> apply1 builtinFetchGit
+  -- Derivation construction
+  "derivation" -> apply1 builtinDerivation
+  -- Error context (pass-through — context only matters on error)
+  "addErrorContext" -> apply2 (\_ val -> pure val)
+  -- Attr position (return null — nixpkgs handles this gracefully)
+  "unsafeGetAttrPos" -> apply2 (\_ _ -> pure VNull)
+  _ -> throwEvalError ("unknown builtin '" <> name <> "'")
+  where
+    apply1 f = case args of
+      [a] -> f a
+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
+    apply2 f = case args of
+      [a, b] -> f a b
+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
+    apply3 f = case args of
+      [a, b, c] -> f a b c
+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
 
 -- ---------------------------------------------------------------------------
 -- Builtin implementations — type checking
@@ -1445,6 +1688,74 @@
       | Just suffix <- T.stripPrefix from txt = Just (to, suffix, from)
       | otherwise = findMatch rest txt
 
+-- ---------------------------------------------------------------------------
+-- Builtin implementations — regex (POSIX ERE via regex-tdfa)
+-- ---------------------------------------------------------------------------
+
+-- | @builtins.match regex str@: match a POSIX ERE against a string.
+-- The regex is implicitly anchored (must match the entire string).
+-- Returns @null@ if no match, or a list of capture group strings
+-- (empty string for unmatched optional groups).
+builtinMatch :: (MonadEval m) => NixValue -> NixValue -> m NixValue
+builtinMatch (VStr regex _) (VStr str _) = do
+  let anchored = "^" <> T.unpack regex <> "$"
+  case RE.makeRegexM anchored :: Maybe RE.Regex of
+    Nothing ->
+      throwEvalError ("builtins.match: invalid regex: " <> regex)
+    Just compiled ->
+      let matches = matchAllText compiled (T.unpack str)
+       in case matches of
+            [] -> pure VNull
+            (match : _) ->
+              -- match is an Array of (String, (offset, len)) pairs.
+              -- Index 0 is the full match; indices 1.. are capture groups.
+              let groups = Array.elems match
+                  -- Skip index 0 (full match) — return only capture groups.
+                  captureGroups = drop 1 groups
+                  toThunk (s, _) = evaluated (mkStr (T.pack s))
+               in pure (VList (map toThunk captureGroups))
+builtinMatch (VStr _ _) other =
+  throwEvalError ("builtins.match: expected a string, got " <> typeName other)
+builtinMatch other _ =
+  throwEvalError ("builtins.match: expected a string (regex), got " <> typeName other)
+
+-- | @builtins.split regex str@: split a string by a POSIX ERE.
+-- Returns an alternating list of non-matched strings and match-group lists.
+-- Example: @split "(x)" "axbxc"@ → @["a" ["x"] "b" ["x"] "c"]@
+builtinSplit :: (MonadEval m) => NixValue -> NixValue -> m NixValue
+builtinSplit (VStr regex _) (VStr str _) = do
+  case RE.makeRegexM (T.unpack regex) :: Maybe RE.Regex of
+    Nothing ->
+      throwEvalError ("builtins.split: invalid regex: " <> regex)
+    Just compiled ->
+      let allMatches = matchAllText compiled (T.unpack str)
+          strText = T.unpack str
+          result = buildSplitResult strText 0 allMatches
+       in pure (VList result)
+builtinSplit (VStr _ _) other =
+  throwEvalError ("builtins.split: expected a string, got " <> typeName other)
+builtinSplit other _ =
+  throwEvalError ("builtins.split: expected a string (regex), got " <> typeName other)
+
+-- | Build the alternating list for builtins.split.
+buildSplitResult :: String -> Int -> [Array.Array Int (String, (Int, Int))] -> [Thunk]
+buildSplitResult remaining pos [] =
+  -- No more matches — emit the rest of the string.
+  [evaluated (mkStr (T.pack (drop pos remaining)))]
+buildSplitResult remaining pos (match : rest) =
+  let fullMatch = match Array.! 0
+      (_, (matchStart, matchLen)) = fullMatch
+      -- Text before this match
+      before = T.pack (take (matchStart - pos) (drop pos remaining))
+      -- Capture groups (indices 1..)
+      groups = drop 1 (Array.elems match)
+      groupThunks = map (\(s, _) -> evaluated (mkStr (T.pack s))) groups
+      -- Continue after this match
+      afterPos = matchStart + matchLen
+   in evaluated (mkStr before)
+        : evaluated (VList groupThunks)
+        : buildSplitResult remaining afterPos rest
+
 builtinCompareVersions :: (MonadEval m) => NixValue -> NixValue -> m NixValue
 builtinCompareVersions (VStr a _) (VStr b _) =
   pure (VInt (compareVersionParts (splitVersionStr a) (splitVersionStr b)))
@@ -2006,7 +2317,15 @@
 builtinFetchGit (VStr url _) = do
   -- Use a content-based temp dir to avoid predictable paths
   let urlHash = sha256Hex (TE.encodeUtf8 url)
-      tmpDir = "/tmp/nova-nix-fetchgit-" <> urlHash
+  sysTmpRaw <- getEnvVar "TMPDIR"
+  -- TMPDIR on Unix, TEMP/TMP on Windows; fall back to /tmp
+  sysTmp <-
+    if T.null sysTmpRaw
+      then do
+        winTmp <- getEnvVar "TEMP"
+        pure (if T.null winTmp then "/tmp" else winTmp)
+      else pure sysTmpRaw
+  let tmpDir = sysTmp <> "/nova-nix-fetchgit-" <> urlHash
   (code, _stdout, stderr) <- runProcess "git" ["clone", "--depth", "1", "--", url, tmpDir] ""
   if code /= 0
     then throwEvalError ("builtins.fetchGit: git clone failed: " <> stderr)
diff --git a/src/Nix/Eval/IO.hs b/src/Nix/Eval/IO.hs
--- a/src/Nix/Eval/IO.hs
+++ b/src/Nix/Eval/IO.hs
@@ -9,7 +9,7 @@
 --
 -- @
 -- st <- newEvalState "/path/to/project"
--- result <- runEvalIO st (eval (builtinEnv 0) expr)
+-- result <- runEvalIO st (eval (builtinEnv 0 []) expr)
 -- @
 module Nix.Eval.IO
   ( -- * Evaluator
@@ -29,7 +29,7 @@
 import Control.Monad (when)
 import Control.Monad.IO.Class (liftIO)
 import Control.Monad.Reader (ReaderT (..), asks, local)
-import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
 import Data.Map.Strict (Map)
 import qualified Data.Map.Strict as Map
 import Data.Text (Text)
@@ -37,9 +37,10 @@
 import Data.Text.Encoding (encodeUtf8)
 import qualified Data.Text.IO as TIO
 import Data.Time.Clock.POSIX (getPOSIXTime)
-import Nix.Builtins (builtinEnv, builtinEnvWithScope)
+import Nix.Builtins (builtinEnv, builtinEnvWithScope, parseNixPath)
 import Nix.Eval (eval)
-import Nix.Eval.Types (MonadEval (..), NixValue)
+import Nix.Eval.Types (MonadEval (..), NixValue (..), Thunk (..))
+import Nix.Expr.Types (AttrKey (..), Binding (..), Expr (..), Formal (..), Formals (..), NixAtom (..), StringPart (..))
 import Nix.Hash (sha256Hex, truncatedBase32)
 import Nix.Parser (parseNix)
 import qualified System.Directory as Dir
@@ -70,24 +71,34 @@
 --
 -- 'esBaseDir' is immutable per frame — @import@ uses 'local' to set it
 -- for nested evaluations, so it is exception-safe with no save\/restore.
+--
+-- 'esSearchPaths' holds parsed @NIX_PATH@ entries as thunks, populating
+-- @builtins.nixPath@.
 data EvalState = EvalState
   { esImportCache :: !(IORef (Map FilePath NixValue)),
     esBaseDir :: !FilePath,
     esStoreDir :: !FilePath,
-    esTimestamp :: !Integer
+    esTimestamp :: !Integer,
+    esSearchPaths :: ![Thunk]
   }
 
 -- | Create a fresh evaluation state rooted at the given directory.
+-- Reads @NIX_PATH@ from the environment to populate search paths.
 newEvalState :: FilePath -> IO EvalState
 newEvalState baseDir = do
   cache <- newIORef Map.empty
   now <- fmap (floor . toRational) getPOSIXTime :: IO Integer
+  nixPathStr <- lookupEnvText "NIX_PATH"
+  let searchPaths = case nixPathStr of
+        Just val -> parseNixPath (T.pack val)
+        Nothing -> []
   pure
     EvalState
       { esImportCache = cache,
         esBaseDir = baseDir,
         esStoreDir = "/nix/store",
-        esTimestamp = now
+        esTimestamp = now,
+        esSearchPaths = searchPaths
       }
 
 -- ---------------------------------------------------------------------------
@@ -122,30 +133,40 @@
   importFile rawPath = do
     baseDir <- EvalIO (asks esBaseDir)
     timestamp <- EvalIO (asks esTimestamp)
+    searchPaths <- EvalIO (asks esSearchPaths)
     let raw = T.unpack rawPath
         resolved = if isRelative raw then baseDir </> raw else raw
     canonical <- wrapIO (Dir.canonicalizePath resolved)
+    -- Directory import: append /default.nix if target is a directory
+    target <- wrapIO $ do
+      isDir <- Dir.doesDirectoryExist canonical
+      pure (if isDir then canonical </> "default.nix" else canonical)
     -- Check import cache (readIORef cannot throw, no wrapIO needed)
     cacheRef <- EvalIO (asks esImportCache)
     cache <- EvalIO (liftIO (readIORef cacheRef))
-    case Map.lookup canonical cache of
+    case Map.lookup target cache of
       Just cached -> pure cached
       Nothing -> do
-        source <- wrapIO (TIO.readFile canonical)
-        case parseNix (T.pack canonical) source of
+        source <- wrapIO (TIO.readFile target)
+        case parseNix (T.pack target) source of
           Left err ->
             throwEvalError
-              ("import " <> T.pack canonical <> ": " <> T.pack (show err))
-          Right expr -> do
+              ("import " <> T.pack target <> ": " <> T.pack (show err))
+          Right rawExpr -> do
+            -- Resolve relative paths in the AST to absolute, matching
+            -- real Nix (which resolves at parse time).  This ensures paths
+            -- captured in closures remain valid after the import scope ends.
+            let fileDir = takeDirectory target
+                expr = resolveRelativePaths fileDir rawExpr
             -- local sets new base dir for nested imports — pure, exception-safe
             let nested =
                   EvalIO
                     ( local
-                        (\s -> s {esBaseDir = takeDirectory canonical})
-                        (unEvalIO (eval (builtinEnv timestamp) expr))
+                        (\s -> s {esBaseDir = fileDir})
+                        (unEvalIO (eval (builtinEnv timestamp searchPaths) expr))
                     )
             result <- nested
-            wrapIO (modifyIORef' cacheRef (Map.insert canonical result))
+            wrapIO (modifyIORef' cacheRef (Map.insert target result))
             pure result
 
   getEnvVar name = wrapIO $ do
@@ -176,6 +197,7 @@
   scopedImportFile scope rawPath = do
     baseDir <- EvalIO (asks esBaseDir)
     timestamp <- EvalIO (asks esTimestamp)
+    searchPaths <- EvalIO (asks esSearchPaths)
     let raw = T.unpack rawPath
         resolved = if isRelative raw then baseDir </> raw else raw
     canonical <- wrapIO (Dir.canonicalizePath resolved)
@@ -184,12 +206,14 @@
       Left err ->
         throwEvalError
           ("scopedImport " <> T.pack canonical <> ": " <> T.pack (show err))
-      Right expr -> do
+      Right rawExpr -> do
+        let fileDir = takeDirectory canonical
+            expr = resolveRelativePaths fileDir rawExpr
         -- No import cache for scoped imports (different scopes = different results)
-        let scopedEnv = builtinEnvWithScope timestamp scope
+        let scopedEnv = builtinEnvWithScope timestamp searchPaths scope
         EvalIO
           ( local
-              (\s -> s {esBaseDir = takeDirectory canonical})
+              (\s -> s {esBaseDir = fileDir})
               (unEvalIO (eval scopedEnv expr))
           )
 
@@ -207,6 +231,26 @@
           ExitFailure n -> n
     pure (code, T.pack stdoutStr, T.pack stderrStr)
 
+  resolvePathLiteral path = do
+    baseDir <- EvalIO (asks esBaseDir)
+    let raw = T.unpack path
+    if isRelative raw
+      then pure (T.pack (baseDir </> raw))
+      else pure path
+
+  forceThunk _ (Evaluated val) = pure val
+  forceThunk evalFn (Thunk expr env ref) = do
+    -- Per-thunk IORef memoization: read once, evaluate at most once,
+    -- cache in the thunk's own cell.  Memory is naturally bounded —
+    -- when the thunk is GC'd, so is its cached value.
+    cached <- wrapIO (readIORef ref)
+    case cached of
+      Just val -> pure val
+      Nothing -> do
+        val <- evalFn env expr
+        wrapIO (writeIORef ref (Just val))
+        pure val
+
 -- ---------------------------------------------------------------------------
 -- Helpers
 -- ---------------------------------------------------------------------------
@@ -254,3 +298,57 @@
   case (result :: Either SomeException (Maybe String)) of
     Left _ -> pure Nothing
     Right mval -> pure mval
+
+-- ---------------------------------------------------------------------------
+-- Path resolution (matching real Nix: resolve at parse time)
+-- ---------------------------------------------------------------------------
+
+-- | Resolve all relative 'NixPath' literals in an expression to absolute
+-- paths relative to the given directory.  Real Nix resolves path literals
+-- at parse time based on the source file location.  We do the same right
+-- after parsing in 'importFile' so that paths captured in closures remain
+-- valid after the import scope ends.
+resolveRelativePaths :: FilePath -> Expr -> Expr
+resolveRelativePaths dir = goExpr
+  where
+    goExpr expr = case expr of
+      ELit (NixPath p)
+        | isRelative (T.unpack p) ->
+            ELit (NixPath (T.pack (dir </> T.unpack p)))
+      ELit _ -> expr
+      EStr parts -> EStr (map goPart parts)
+      EIndStr parts -> EIndStr (map goPart parts)
+      EVar _ -> expr
+      EAttrs isRec bindings -> EAttrs isRec (map goBinding bindings)
+      EList elems -> EList (map goExpr elems)
+      ESelect target path mDef ->
+        ESelect (goExpr target) (map goKey path) (fmap goExpr mDef)
+      EHasAttr target path -> EHasAttr (goExpr target) (map goKey path)
+      EApp f x -> EApp (goExpr f) (goExpr x)
+      ELambda formals body -> ELambda (goFormals formals) (goExpr body)
+      ELet bindings body -> ELet (map goBinding bindings) (goExpr body)
+      EIf c t f -> EIf (goExpr c) (goExpr t) (goExpr f)
+      EWith scope body -> EWith (goExpr scope) (goExpr body)
+      EAssert cond body -> EAssert (goExpr cond) (goExpr body)
+      EUnary op e -> EUnary op (goExpr e)
+      EBinary op l r -> EBinary op (goExpr l) (goExpr r)
+      ESearchPath _ -> expr
+
+    goPart part = case part of
+      StrLit _ -> part
+      StrInterp e -> StrInterp (goExpr e)
+
+    goBinding binding = case binding of
+      NamedBinding path e -> NamedBinding (map goKey path) (goExpr e)
+      Inherit from names -> Inherit (fmap goExpr from) names
+
+    goKey key = case key of
+      StaticKey _ -> key
+      DynamicKey e -> DynamicKey (goExpr e)
+
+    goFormals formals = case formals of
+      FormalName _ -> formals
+      FormalSet fs ellipsis -> FormalSet (map goFormal fs) ellipsis
+      FormalNamedSet n fs ellipsis -> FormalNamedSet n (map goFormal fs) ellipsis
+
+    goFormal (Formal n mDef) = Formal n (fmap goExpr mDef)
diff --git a/src/Nix/Eval/Operator.hs b/src/Nix/Eval/Operator.hs
--- a/src/Nix/Eval/Operator.hs
+++ b/src/Nix/Eval/Operator.hs
@@ -28,25 +28,25 @@
 -- | Evaluate a binary operator on two forced values.
 --
 -- The caller must handle short-circuit operators ('OpAnd', 'OpOr',
--- 'OpImpl') before calling this.  The 'Force' function is used only
+-- 'OpImpl') before calling this.  The @Force@ function is used only
 -- for deep structural equality on compound values.
 evalBinary :: (MonadEval m) => Force m -> BinaryOp -> NixValue -> NixValue -> m NixValue
-evalBinary forceThunk op left right = case op of
+evalBinary forceFn op left right = case op of
   OpAdd -> evalAdd left right
   OpSub -> evalArith "subtraction" (-) (-) left right
   OpMul -> evalArith "multiplication" (*) (*) left right
   OpDiv -> evalDiv left right
-  OpEq -> VBool <$> nixEqual forceThunk left right
-  OpNeq -> VBool . not <$> nixEqual forceThunk left right
+  OpEq -> VBool <$> nixEqual forceFn left right
+  OpNeq -> VBool . not <$> nixEqual forceFn left right
   OpLt -> VBool <$> nixCompare left right
   OpLte -> do
     lt <- nixCompare left right
-    eq <- nixEqual forceThunk left right
+    eq <- nixEqual forceFn left right
     pure (VBool (lt || eq))
   OpGt -> VBool <$> nixCompare right left
   OpGte -> do
     gt <- nixCompare right left
-    eq <- nixEqual forceThunk left right
+    eq <- nixEqual forceFn left right
     pure (VBool (gt || eq))
   OpConcat -> evalConcat left right
   OpUpdate -> evalUpdate left right
@@ -153,33 +153,33 @@
 -- String equality ignores context (matching real Nix).
 nixEqual _ (VStr a _) (VStr b _) = pure (a == b)
 nixEqual _ (VPath a) (VPath b) = pure (a == b)
-nixEqual forceThunk (VList as) (VList bs)
+nixEqual forceFn (VList as) (VList bs)
   | length as /= length bs = pure False
-  | otherwise = listEqual forceThunk as bs
-nixEqual forceThunk (VAttrs as) (VAttrs bs)
+  | otherwise = listEqual forceFn as bs
+nixEqual forceFn (VAttrs as) (VAttrs bs)
   | Map.keys as /= Map.keys bs = pure False
   | otherwise = do
       let pairs = zip (Map.elems as) (Map.elems bs)
-      results <- mapM (thunkPairEqual forceThunk) pairs
+      results <- mapM (thunkPairEqual forceFn) pairs
       pure (and results)
 nixEqual _ _ _ = pure False
 
 -- | Pairwise equality of two thunk lists (for list comparison).
 listEqual :: (MonadEval m) => Force m -> [Thunk] -> [Thunk] -> m Bool
 listEqual _ [] [] = pure True
-listEqual forceThunk (a : as) (b : bs) = do
-  va <- forceThunk a
-  vb <- forceThunk b
-  eq <- nixEqual forceThunk va vb
-  if eq then listEqual forceThunk as bs else pure False
+listEqual forceFn (a : as) (b : bs) = do
+  va <- forceFn a
+  vb <- forceFn b
+  eq <- nixEqual forceFn va vb
+  if eq then listEqual forceFn as bs else pure False
 listEqual _ _ _ = pure False
 
 -- | Compare two thunks for equality by forcing both.
 thunkPairEqual :: (MonadEval m) => Force m -> (Thunk, Thunk) -> m Bool
-thunkPairEqual forceThunk (a, b) = do
-  va <- forceThunk a
-  vb <- forceThunk b
-  nixEqual forceThunk va vb
+thunkPairEqual forceFn (a, b) = do
+  va <- forceFn a
+  vb <- forceFn b
+  nixEqual forceFn va vb
 
 -- ---------------------------------------------------------------------------
 -- List / attrset operators
diff --git a/src/Nix/Eval/Types.hs b/src/Nix/Eval/Types.hs
--- a/src/Nix/Eval/Types.hs
+++ b/src/Nix/Eval/Types.hs
@@ -37,6 +37,7 @@
   )
 where
 
+import Data.IORef (IORef, newIORef)
 import Data.Map.Strict (Map)
 import qualified Data.Map.Strict as Map
 import Data.Set (Set)
@@ -44,6 +45,7 @@
 import Nix.Derivation (Derivation)
 import Nix.Expr.Types (Expr, Formals)
 import Nix.Store.Path (StorePath)
+import System.IO.Unsafe (unsafePerformIO)
 
 -- ---------------------------------------------------------------------------
 -- String context
@@ -78,19 +80,32 @@
 
 -- | A thunk: either an unevaluated expression paired with its
 -- capturing environment, or an already-forced value.
+--
+-- Each unevaluated thunk carries an 'IORef' memoization cell that
+-- caches the result after the first force — matching real Nix, which
+-- mutates thunks in-place.  The IORef is allocated lazily via
+-- 'unsafePerformIO' in 'mkThunk' so that knot-tying in recursive
+-- @let@ and @rec {}@ works unchanged (the IORef doesn't participate
+-- in the knot).  Memory is naturally bounded: when a thunk is no
+-- longer reachable, both the thunk and its cached value are GC'd.
 data Thunk
   = -- | Unevaluated: Expr is strict, Env is LAZY to allow
-    -- knot-tying in recursive let and rec { }.
-    Thunk !Expr Env
+    -- knot-tying in recursive let and rec { }.  The IORef cell
+    -- is written once on first force, then read on subsequent forces.
+    Thunk !Expr Env !(IORef (Maybe NixValue))
   | Evaluated !NixValue
-  deriving (Show)
 
+instance Show Thunk where
+  show (Evaluated val) = "Evaluated (" ++ show val ++ ")"
+  show (Thunk expr _ _) = "Thunk (" ++ show expr ++ ") <env> <ref>"
+
 -- | Structural equality — compares expressions for unevaluated thunks,
--- values for evaluated ones.  Will diverge on recursive environments;
--- only used in tests on non-recursive structures.
+-- values for evaluated ones.  Ignores the IORef cell and environment.
+-- Will diverge on recursive environments; only used in tests on
+-- non-recursive structures.
 instance Eq Thunk where
   (Evaluated v1) == (Evaluated v2) = v1 == v2
-  (Thunk e1 _) == (Thunk e2 _) = e1 == e2
+  (Thunk e1 _ _) == (Thunk e2 _ _) = e1 == e2
   _ == _ = False
 
 -- | A Nix value — the result of evaluating an expression.
@@ -166,10 +181,29 @@
 pushWithScope scope env =
   env {envWithScopes = scope : envWithScopes env}
 
--- | Create an unevaluated thunk.
+-- | Create an unevaluated thunk with a fresh memoization cell.
+--
+-- Each thunk gets its own 'IORef' for in-place memoization, matching
+-- real Nix which mutates @Value@ structs on first force.  The cell is
+-- allocated via @newMemoCell@ which uses 'unsafePerformIO' — safe
+-- because 'newIORef' is a pure allocation with no observable side
+-- effects, and the @NOINLINE@ + @seq@ pattern prevents GHC from
+-- floating the allocation to a shared top-level CAF.
 mkThunk :: Env -> Expr -> Thunk
-mkThunk env thunkExpr = Thunk thunkExpr env
+mkThunk env thunkExpr =
+  Thunk thunkExpr env (newMemoCell thunkExpr)
 
+-- | Allocate a fresh memoization cell for a thunk.
+--
+-- @NOINLINE@ prevents inlining so GHC can't see inside or CSE calls.
+-- @seq@ on the argument creates a data dependency that prevents
+-- float-out to a top-level CAF — without this, GHC would hoist
+-- @unsafePerformIO (newIORef Nothing)@ and share ONE cell across
+-- ALL thunks.
+{-# NOINLINE newMemoCell #-}
+newMemoCell :: Expr -> IORef (Maybe NixValue)
+newMemoCell expr = unsafePerformIO (expr `seq` newIORef Nothing)
+
 -- | Wrap an already-computed value as a thunk.
 evaluated :: NixValue -> Thunk
 evaluated = Evaluated
@@ -224,6 +258,20 @@
   -- | Run an external process: @(command, args, stdin) -> (exitCode, stdout, stderr)@.
   runProcess :: Text -> [Text] -> Text -> m (Int, Text, Text)
 
+  -- | Resolve a path literal to an absolute path.
+  -- In IO evaluation, relative paths are resolved against the current
+  -- file's directory (@esBaseDir@).  In pure evaluation, paths are
+  -- returned unchanged.  This ensures that path values captured in
+  -- closures remain valid after the import scope ends.
+  resolvePathLiteral :: Text -> m Text
+
+  -- | Force a thunk to a value, with memoization.
+  -- IO evaluators should cache results (via per-thunk @IORef@).
+  -- Pure evaluators re-evaluate each time.
+  -- The first argument is the evaluation function (to break the
+  -- Eval.Types → Eval circular dependency).
+  forceThunk :: (Env -> Expr -> m NixValue) -> Thunk -> m NixValue
+
 -- | Pure evaluation monad — wraps 'Either Text'.
 -- IO builtins ('readFile', 'import') are unavailable;
 -- everything else evaluates identically to the IO version.
@@ -242,3 +290,6 @@
   writeToStore _ _ = throwEvalError "toFile: not available in pure evaluation"
   scopedImportFile _ _ = throwEvalError "scopedImport: not available in pure evaluation"
   runProcess _ _ _ = throwEvalError "runProcess: not available in pure evaluation"
+  resolvePathLiteral = pure
+  forceThunk _ (Evaluated val) = pure val
+  forceThunk evalFn (Thunk expr env _) = evalFn env expr
diff --git a/src/Nix/Expr/Types.hs b/src/Nix/Expr/Types.hs
--- a/src/Nix/Expr/Types.hs
+++ b/src/Nix/Expr/Types.hs
@@ -168,4 +168,6 @@
     EUnary !UnaryOp !Expr
   | -- | Binary operator application.
     EBinary !BinaryOp !Expr !Expr
+  | -- | Search path lookup: @\<nixpkgs\>@ is @ESearchPath "nixpkgs"@.
+    ESearchPath !Text
   deriving (Eq, Show)
diff --git a/src/Nix/Parser/Expr.hs b/src/Nix/Parser/Expr.hs
--- a/src/Nix/Parser/Expr.hs
+++ b/src/Nix/Parser/Expr.hs
@@ -372,7 +372,7 @@
     TokNull -> advance >> pure (ELit NixNull)
     TokUri u -> advance >> pure (ELit (NixUri u))
     TokPath p -> advance >> pure (ELit (NixPath p))
-    TokSearchPath p -> advance >> pure (ELit (NixPath ("<" <> p <> ">")))
+    TokSearchPath p -> advance >> pure (ESearchPath p)
     TokIdent name -> advance >> pure (EVar name)
     TokStringOpen -> parseString
     TokIndStringOpen -> parseIndString
@@ -510,8 +510,28 @@
         TokIdent name -> do
           _ <- advance
           go (acc ++ [name])
+        -- Quoted strings for keywords used as attribute names: inherit "or";
+        TokStringOpen -> do
+          _ <- advance
+          name <- parseQuotedInheritName
+          go (acc ++ [name])
         _ -> pure acc
 
+-- | Parse a quoted inherit name: a simple string literal (no interpolation).
+-- Used for keywords like @"or"@ in @inherit (self.trivial) "or";@.
+parseQuotedInheritName :: Parser Text
+parseQuotedInheritName = do
+  tok <- peek
+  case tok of
+    TokStringLit s -> do
+      _ <- advance
+      expect TokStringClose
+      pure s
+    TokStringClose -> do
+      _ <- advance
+      pure ""
+    _ -> parseError ("expected string literal in inherit, got " <> showTok tok)
+
 parseAttrPath :: Parser AttrPath
 parseAttrPath = do
   first <- parseAttrKey
@@ -529,7 +549,9 @@
     TokInterpOpen -> do
       _ <- advance
       expr <- parseExpr
-      expect TokInterpClose
+      -- In expression context, the lexer doesn't track interpolation
+      -- mode — } is TokRBrace rather than TokInterpClose.
+      expect TokRBrace
       pure (DynamicKey expr)
     _ -> parseError ("expected attribute key, got " <> showTok tok)
 
diff --git a/src/Nix/Parser/Lexer.hs b/src/Nix/Parser/Lexer.hs
--- a/src/Nix/Parser/Lexer.hs
+++ b/src/Nix/Parser/Lexer.hs
@@ -226,7 +226,17 @@
             '>' | Just '=' <- safeHead rest -> emit2 st TokGte acc
             '>' -> emit1 st TokGt acc
             '/' | Just '/' <- safeHead rest -> emit2 st TokUpdate acc
-            '/' -> emit1 st TokSlash acc
+            '/'
+              -- After an expression token, / is always division
+              | prevCanEndExpr -> emit1 st TokSlash acc
+              -- Not after expression: / followed by path char starts an absolute path
+              | Just c2 <- safeHead rest, isPathChar c2 -> lexPath st acc
+              -- Otherwise, division
+              | otherwise -> emit1 st TokSlash acc
+              where
+                prevCanEndExpr = case acc of
+                  (Located _ _ tok : _) -> canFollowWithDivision tok
+                  [] -> False
             '$'
               | Just '{' <- safeHead rest ->
                   emit2 st TokInterpOpen acc
@@ -609,6 +619,25 @@
 
 isUriChar :: Char -> Bool
 isUriChar c = isAlphaNum c || c `elem` ("%/?:@&=+$,#._~!-" :: [Char])
+
+-- | Whether a token can be the last token of an expression.
+-- Used to decide if @/@ is division or the start of an absolute path.
+-- Real Nix uses the same context-dependent rule in its lexer.
+canFollowWithDivision :: Token -> Bool
+canFollowWithDivision (TokIdent _) = True
+canFollowWithDivision (TokInt _) = True
+canFollowWithDivision (TokFloat _) = True
+canFollowWithDivision (TokPath _) = True
+canFollowWithDivision TokRParen = True
+canFollowWithDivision TokRBracket = True
+canFollowWithDivision TokRBrace = True
+canFollowWithDivision TokInterpClose = True
+canFollowWithDivision TokStringClose = True
+canFollowWithDivision TokIndStringClose = True
+canFollowWithDivision TokTrue = True
+canFollowWithDivision TokFalse = True
+canFollowWithDivision TokNull = True
+canFollowWithDivision _ = False
 
 isIndStringEscape :: Text -> Bool
 isIndStringEscape t = case T.uncons t of
diff --git a/src/Nix/Store.hs b/src/Nix/Store.hs
--- a/src/Nix/Store.hs
+++ b/src/Nix/Store.hs
@@ -128,8 +128,8 @@
     (stDB store)
     PathRegistration
       { prPath = sp,
-        prNarHash = "sha256:placeholder",
-        prNarSize = 0,
+        prNarHash = "sha256:0000000000000000000000000000000000000000000000000000", -- NAR hashing deferred until nova-cache integration
+        prNarSize = 0, -- Computed size deferred until NAR serialization is wired in
         prDeriver = deriver,
         prReferences = refs
       }
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -12,7 +12,7 @@
 import qualified Data.Text.Encoding as TE
 import qualified Data.Text.IO as TIO
 import Nix.Builder (BuildConfig (..), BuildResult (..), buildDerivation, buildWithDeps, defaultBuildConfig)
-import Nix.Builtins (builtinEnv)
+import Nix.Builtins (builtinEnv, parseNixPath)
 import qualified Nix.DependencyGraph as DepGraph
 import Nix.Derivation (Derivation (..), DerivationOutput (..), Platform (..), currentPlatform, fromATerm, platformToText, toATerm)
 import Nix.Eval (Env (..), NixValue (..), StringContext (..), StringContextElement (..), Thunk (..), emptyContext, emptyEnv, eval, mkStr, runPureEval)
@@ -88,7 +88,7 @@
 evalNix :: Text -> Either Text NixValue
 evalNix source = case parseNix "<test>" source of
   Left err -> Left (T.pack (show err))
-  Right expr -> runPureEval (eval (builtinEnv 0) expr)
+  Right expr -> runPureEval (eval (builtinEnv 0 []) expr)
 
 -- | Assert that a Nix expression evaluates to the expected value.
 assertEval :: Text -> Text -> NixValue -> TestResult
@@ -1341,7 +1341,7 @@
   Left err -> pure (Left (T.pack (show err)))
   Right expr -> do
     st <- newEvalState baseDir
-    runEvalIO st (eval (builtinEnv (esTimestamp st)) expr)
+    runEvalIO st (eval (builtinEnv (esTimestamp st) (esSearchPaths st)) expr)
 
 -- | Run a named IO eval test — single label, no double-wrapping.
 runTestIO :: Text -> FilePath -> Text -> NixValue -> IO Bool
@@ -1390,7 +1390,7 @@
           testDir
           "(import ./literal.nix) + (import ./literal.nix)"
           (VInt 84),
-        runTestIOFail "import nonexistent → error" testDir "import ./nonexistent.nix",
+        runTestIOFail "import nonexistent -> error" testDir "import ./nonexistent.nix",
         runTestIO "import attrset + select" testDir "(import ./attrset.nix).x" (VInt 1),
         runTestIO "import let/lambda" testDir "import ./uses-arg.nix" (VInt 15),
         -- import rejects strings (real Nix semantics)
@@ -1413,7 +1413,7 @@
           ("builtins.readFile " <> nixQuotedPath (testDir </> "literal.nix"))
           (mkStr "42"),
         runTestIOFail
-          "readFile missing → error"
+          "readFile missing -> error"
           testDir
           ("builtins.readFile " <> nixQuotedPath (testDir </> "ghost.nix")),
         -- readDir: entries have correct file types
@@ -2047,7 +2047,7 @@
       runTest "single node graph" $ case DepGraph.buildDepGraph readSingle drvA spA of
         Right (DepGraph.DepGraph g) -> assertEqual "single-size" 1 (Map.size g)
         Left err -> Fail ("unexpected error: " <> err),
-      -- Linear chain A→C: topoSort should give [C, A]
+      -- Linear chain A->C: topoSort should give [C, A]
       runTest "linear chain topo" $ case DepGraph.buildDepGraph readChain drvB spB of
         Right graph -> case DepGraph.topoSort graph of
           DepGraph.TopoSorted order ->
@@ -2056,7 +2056,7 @@
               else Fail ("bad order: " <> T.pack (show order))
           DepGraph.TopoCycle cyc -> Fail ("unexpected cycle: " <> T.pack (show cyc))
         Left err -> Fail ("graph build failed: " <> err),
-      -- Diamond D→B,C; B→C: topoSort should have C first, D last
+      -- Diamond D->B,C; B->C: topoSort should have C first, D last
       runTest "diamond topo" $ case DepGraph.buildDepGraph readDiamond drvD spD of
         Right graph -> case DepGraph.topoSort graph of
           DepGraph.TopoSorted order ->
@@ -2079,7 +2079,7 @@
           let deps = DepGraph.directDeps graph spD
            in assertEqual "direct-count" 2 (length deps)
         Left err -> Fail ("graph build failed: " <> err),
-      -- Missing .drv → failure
+      -- Missing .drv -> failure
       runTest "missing drv fails" $ case DepGraph.buildDepGraph readSingle drvB spB of
         Left _ -> Pass
         Right _ -> Fail "expected failure for missing drv",
@@ -2252,7 +2252,7 @@
                 DepGraph.TopoCycle _ -> Pass
                 DepGraph.TopoSorted _ -> Pass -- Partial sort is also acceptable
               Left _ -> Pass, -- Build graph failure also acceptable
-              -- missing .drv → failure in dep graph
+              -- missing .drv -> failure in dep graph
       runTest "missing drv in dep graph" $
         let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "missing.drv"
             drv =
@@ -2954,14 +2954,14 @@
 -- Tests: CLI Integration (Phase 2, Batch 5)
 -- ---------------------------------------------------------------------------
 
--- | End-to-end: eval .nix source → extract derivation → build → verify output.
+-- | End-to-end: eval .nix source -> extract derivation -> build -> verify output.
 evalAndBuild :: StoreDir -> Text -> IO (Either Text (BuildResult, Store))
 evalAndBuild storeDir source = do
   case parseNix "<test>" source of
     Left err -> pure (Left ("parse error: " <> T.pack (show err)))
     Right expr -> do
       st <- newEvalState "."
-      evalResult <- runEvalIO st (eval (builtinEnv (esTimestamp st)) expr)
+      evalResult <- runEvalIO st (eval (builtinEnv (esTimestamp st) (esSearchPaths st)) expr)
       case evalResult of
         Left err -> pure (Left ("eval error: " <> err))
         Right val -> case val of
@@ -2980,8 +2980,8 @@
   putStrLn "cli/e2e"
   shell <- findTestShell
   sequence
-    [ -- End-to-end: eval → build a simple derivation
-      runTestM "e2e eval → build" $ do
+    [ -- End-to-end: eval -> build a simple derivation
+      runTestM "e2e eval -> build" $ do
         tmpBase <- getTemporaryDirectory
         let tmpStore = tmpBase </> "nova-nix-test-e2e1"
         forceRemoveIfExists tmpStore
@@ -3060,6 +3060,90 @@
     ]
 
 -- ---------------------------------------------------------------------------
+-- Tests: Phase 4 — search paths, dynamic keys, directory import
+-- ---------------------------------------------------------------------------
+
+testPhase4 :: IO [Bool]
+testPhase4 = do
+  putStrLn "phase4/search-paths"
+  sequence
+    [ -- parseNixPath tests
+      runTest "parseNixPath empty" $
+        assertEqual "empty" [] (parseNixPath ""),
+      runTest "parseNixPath single" $
+        let result = parseNixPath "nixpkgs=/home/user/nixpkgs"
+         in case result of
+              [Evaluated (VAttrs m)] ->
+                case (Map.lookup "prefix" m, Map.lookup "path" m) of
+                  (Just (Evaluated (VStr "nixpkgs" _)), Just (Evaluated (VStr "/home/user/nixpkgs" _))) -> Pass
+                  _ -> Fail "wrong prefix/path"
+              _ -> Fail ("expected one entry, got " <> T.pack (show (length result))),
+      runTest "parseNixPath multiple" $
+        assertEqual "count" 2 (length (parseNixPath "nixpkgs=/nix:custom=/opt")),
+      runTest "parseNixPath plain path" $
+        let result = parseNixPath "/some/path"
+         in case result of
+              [Evaluated (VAttrs m)] ->
+                case (Map.lookup "prefix" m, Map.lookup "path" m) of
+                  (Just (Evaluated (VStr "" _)), Just (Evaluated (VStr "/some/path" _))) -> Pass
+                  _ -> Fail "wrong prefix/path for plain"
+              _ -> Fail "expected one entry",
+      -- ESearchPath parser test
+      runTest "parse <nixpkgs>" $
+        assertParse "search path" "<nixpkgs>" (ESearchPath "nixpkgs"),
+      runTest "parse <nixpkgs/lib>" $
+        assertParse "search path with subpath" "<nixpkgs/lib>" (ESearchPath "nixpkgs/lib"),
+      -- ESearchPath eval (should fail in pure mode since no search paths)
+      runTest "eval <nixpkgs> fails without path" $
+        assertEvalFail "search path not found" "<nixpkgs>",
+      -- Dynamic attribute keys
+      runTest "dynamic key basic" $
+        assertEval "dynamic key" "{ ${\"hello\"} = 42; }.hello" (VInt 42),
+      runTest "dynamic key from let" $
+        assertEval "dynamic key let" "let name = \"x\"; in { ${name} = 1; }.x" (VInt 1),
+      runTest "dynamic key in select" $
+        assertEval "dynamic key select" "let s = { x = 10; }; in s.${\"x\"}" (VInt 10),
+      runTest "dynamic key in hasAttr" $
+        assertEval "dynamic key hasAttr" "let s = { x = 10; }; in s ? ${\"x\"}" (VBool True),
+      runTest "dynamic key hasAttr missing" $
+        assertEval "dynamic key hasAttr missing" "let s = { x = 10; }; in s ? ${\"y\"}" (VBool False)
+    ]
+
+testPhase4IO :: IO [Bool]
+testPhase4IO = do
+  putStrLn "phase4/directory-import"
+  tmpDir <- getTemporaryDirectory
+  let testDir = tmpDir </> "nova-nix-phase4-test"
+      subDir = testDir </> "mypkg"
+      defaultNix = subDir </> "default.nix"
+  -- Create temp directory structure
+  createDirectoryIfMissing True subDir
+  TIO.writeFile defaultNix "42"
+  results <-
+    sequence
+      [ -- Directory import: import ./dir resolves to ./dir/default.nix
+        runTestM "import directory" $ do
+          result <- evalNixIO testDir ("import " <> T.pack "./mypkg")
+          pure $ case result of
+            Right (VInt 42) -> Pass
+            Right other -> Fail ("expected VInt 42, got " <> T.pack (show other))
+            Left err -> Fail ("eval error: " <> err),
+        -- Search path with --nix-path equivalent (populated nixPath)
+        runTestM "search path with populated nixPath" $ do
+          st <- newEvalState testDir
+          let nixPaths = parseNixPath ("mypkg=" <> T.pack subDir)
+              env = builtinEnv (esTimestamp st) nixPaths
+          result <- runEvalIO st (eval env (ESearchPath "mypkg"))
+          pure $ case result of
+            Right (VPath _) -> Pass
+            Right other -> Fail ("expected VPath, got " <> T.pack (show other))
+            Left err -> Fail ("eval error: " <> err)
+      ]
+  -- Cleanup
+  removeDirectoryRecursive testDir
+  pure results
+
+-- ---------------------------------------------------------------------------
 -- Main
 -- ---------------------------------------------------------------------------
 
@@ -3126,7 +3210,9 @@
           testStoreOps,
           testFromATerm,
           testBuilder,
-          testE2E
+          testE2E,
+          testPhase4,
+          testPhase4IO
         ]
   let total = length results
       passed = length (filter id results)
