Repository navigation
refactor(config): migrate template variables through MiniJinja's own parser - #4141
Conversation
…parser
The retired-variable rewrite found its identifiers with a hand-rolled scanner
that re-derived MiniJinja's delimiters, whitespace control, string quoting,
`{% raw %}` handling and assignment-target grammar. Every place the two
readings disagreed was a bug in the file worktrunk writes (#4117), and closing
each one grew the scanner further.
Enable MiniJinja's `unstable_machinery` and parse instead. `TemplateVars` walks
the AST once: the reads are the `Expr::Var` nodes, which is by construction the
set the renderer resolves against the context, and the bindings are the
statements' own target expressions. Rewriting splices at the byte ranges the
parser gave those nodes.
That deletes the tag walker, the target-list reader, the quote and identifier
scanners and the `{% raw %}` state — seventeen functions for nine short
visitor methods — and it collapses detection and migration into a single call
rather than two functions sharing a predicate. Behavior is unchanged except
where the scanner was wrong: a tag opened inside a `{% raw %}` block no longer
leaves the whole template unmigrated, since the parser reads that content as
the literal it is.
`unstable_machinery` carries no semver guarantee, so the coupling is to the
AST's shape alone: the `Stmt` and `Expr` matches are exhaustive, so a MiniJinja
that moves a node fails the build rather than quietly mis-migrating a config.
It pulls in `internal_debug`, which costs ~116KB of release binary (~67KB
stripped).
for more information, see https://pre-commit.ci
The visitor has one arm per AST node, and a missed arm loses a migration silently rather than failing. One case per shape — slices, unary and binary ops, tests, filters, call splats, autoescape and filter blocks, a for-else and an if-else body — keeps every arm exercised.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Self-review. Three findings; the first two I'll push as a follow-up commit.
test_normalize_rewrites_reads_in_every_expression_shape doesn't reach Expr::Compare or IfExpr.false_expr, which is what codecov/patch is failing on. The test's stated contract is "one case per AST node the walk descends, since a missed visitor arm loses a migration silently rather than failing", and those two arms are the gap — codecov reports exactly six missed lines, the Compare arm and the self.expr(false_expr) line. The Compare miss is not obvious from the tests that exist: {% set a = repo_root == "x" %} looks like it covers it, but MiniJinja's parse_compare lowers a single comparison to Expr::BinOp and only emits Expr::Compare for a chained one (ops.len() >= 2). So {{ 1 < repo_root < 3 }} and {{ a if b else repo_root }} are the two cases that both meet the contract and close the check; I verified both migrate correctly against the current code.
The comment on TemplateVars::of justifying Default::default() says the whitespace default is "what the environments that render these templates use", and that isn't true. expand_template_with in src/config/expansion.rs calls env.set_keep_trailing_newline(true) for every ShellEscapeMode other than Literal, so the parse here and the render disagree. The disagreement is harmless, but for a reason worth stating instead: WhitespaceConfig only shapes literal text — keep_trailing_newline decides whether the tokenizer drops a trailing \n before lexing, trim_blocks/lstrip_blocks only move the boundaries of EmitRaw — so it can never move an Expr::Var span, and the rewrite copies everything outside those spans from the original string. As written the comment invites a reader to "fix" the mismatch by threading the render environment's config through, which would add a coupling the rewrite doesn't need.
The cargo install exposure is larger than the PR body allows, and the stated reason for accepting it doesn't hold. The body argues "a version bound wouldn't reliably prevent a break" because MiniJinja last moved this API in a patch release. That's true of the caret bound minijinja = "2.20" carries today, but not of an upper bound: ">=2.20, <2.25" pins out exactly the release that could break. It matters because cargo install worktrunk — the install path in README.md, docs/src/content/docs/worktrunk.md, and the release-notes template in .github/workflows/release.yaml — ignores Cargo.lock and resolves the newest matching 2.x, so a future machinery change breaks source installs of every already-published release, not just main. Nothing on our side would see it first: minimal-versions in the nightly workflow resolves minimum versions, and every other job builds from the lock. Against that, no dependency in Cargo.toml carries an upper bound today, and one would need bumping on each MiniJinja release — so this is a maintainer call on whether the ongoing pin maintenance is worth removing the failure mode, not something I should decide in a refactor PR.
The rest holds up. I checked the two matches against the 2.24.0 AST: Stmt and Expr are exhaustive over the variants this feature set compiles, and CallArg is too, so a reshaped node is a compile error rather than a dropped migration. Span offsets are byte offsets (Tokenizer::advance accumulates current_offset in bytes), which I confirmed by running the splice over templates with multi-byte text on both sides of a tag.
`codecov/patch` missed `Expr::Compare` and `IfExpr.false_expr`, the two arms `test_normalize_rewrites_reads_in_every_expression_shape` claims to pin one case each of. `Compare` is the non-obvious one: MiniJinja's `parse_compare` lowers a single comparison to `Expr::BinOp` and only emits `Expr::Compare` for a chained one, so the existing `repo_root == "x"` case never reached it. Also correct the comment justifying `Default::default()` on the parse. `expand_template_with` renders with `keep_trailing_newline(true)`, so the configs don't match; what makes the mismatch safe is that a `WhitespaceConfig` only shapes literal text, never an `Expr::Var` span.
Version bump to 0.78.0 plus the changelog section for this release, a comment-preservation fix the release review turned up, a PTY test-filter fix, and merges of `main` (commits that landed during the review, including #4124, #4122, #4141, #4140 and #4145). **Bump level**: minor. `cargo semver-checks` reports 2 breaking library changes — a new `leftover_branch` field on the `GitError::WorktreeCreationFailed` struct variant, and `DEPRECATED_TEMPLATE_VARS` removed. Pre-1.0, so a breaking change takes a minor bump. ## Comment-preservation fix A config save — declining the commit-generation offer, say — dropped the trailing comment on every value it rewrote. That was already true in v0.77.0 for a line the save itself changed (`skip-commit-generation-prompt = false # note` → `true`, reproduced on the v0.77.0 binary). #4080 widened it to lines the command never touched: retired template variables now migrate on every load, so the next unrelated save rewrote any line naming one. #4080's own PR said this "needs a call before merge" and merged without one. - `replace_keeping_decor` keeps the value's decor (spacing after `=`, trailing comment) when `merge_tables` replaces a scalar. Comments *above* a line sit on the key and were never at risk. - `main`'s #4120 independently reworked the inline-table arm and moved a key's leading comment onto the rewritten header, but documented that a trailing comment after the closing brace "is still dropped". `replace_inline_with_table` now carries it after the header's `]`, so both of its callers keep it. - Tests: the existing comment-preservation test goes back to a retired name (the only fixture that exercises the replacement) and adds a comment on the mutated line; a new test snapshots and re-parses an inline `[projects]` entry. Each fails without its half of the fix. Not fixed, and identical on v0.77.0: a save deletes an explicitly written *default* value (e.g. `skip-commit-generation-prompt = false # note`) as a stale key, comment included. ## `--help` correction #4104 moved the OpenCode plugin path to etcetera's `Xdg` strategy, which uses `$XDG_CONFIG_HOME` only when absolute — a deliberate, documented choice — but `wt config plugins opencode --help` still said install mirrors OpenCode's precedence. OpenCode reads a relative value as-is, so the help now says an empty `$OPENCODE_CONFIG_DIR` and an empty or relative `$XDG_CONFIG_HOME` are ignored. ## Data-loss surface review Independent finders swept the full diff, including the five commits merged from `main`. Nothing holds the tag; candidates were adjudicated against v0.77.0 binaries: - **Fixed in this release, reproduced on v0.77.0**: #4104 stops `wt config shell install` (with Nushell) deleting a `nushell/vendor/autoload/wt.nu` under the current directory when `$XDG_CONFIG_HOME` is empty or relative; #4120 stops a save or migration from writing an inline section's comment inside `[commit]` brackets, which made the config unparseable, and from dropping unknown keys. - **`wt step prune --min-age` doesn't apply where no reflog exists** — pre-existing, proven by building the pre-#4077 binary. `git clone --bare` and `git fetch` into `refs/heads/*` write no reflog, so in the bare layout `tips-patterns.md` recommends every branch starts unprotected. Docstring corrected here; behavioral fix is follow-up. - **`wt config update --output <path>` replaces any named file whole**, no existence check or prompt — pre-existing, follow-up. - **`wt config shell install fish --yes` replaces a hand-written wrapper at the XDG path** — accepted ("ownership is the path", predates #3602), newly reachable only for users with `$XDG_CONFIG_HOME` set. The FAQ's delete inventory now cross-references it. - **`--var <retired-name>` binds a name nothing references** after #4080 — inert, no shipped usage; follow-up. - **#4135 migrates nothing**: an existing oh-my-pi hook stays put, `pi uninstall` points at `omp uninstall`. `PI_CODING_AGENT_DIR='~/x'` is used literally (Pi expands `~`) — creates a stray directory, deletes nothing; follow-up. ## Changelog 37 entries; each new or edited entry was re-verified against v0.77.0 until a pass came back clean (six passes in all). Passes caught, among others: the OpenCode `-x` recipe was wrong since 0.76.0, not 0.75.0; #4080's JSON reaches every hook, not just `pre-start`/`post-start`; a quoted git error string git never prints; a copy-button overlap that existed only inside this release; #4104's relative-`XDG_CONFIG_HOME` OpenCode change described as a fix when OpenCode reads that path as-is; and which commands a broken inline section stopped. **Fixed** leads with the data-loss entries. ## Open PRs checked against v0.77.0 - **#4124** fixed a bug new in this release, caused by #4080, and has landed and been merged in here. The hand-rolled template-variable scanner it extends is slated for removal; see its commit message. - **#4112** — no data-loss regression vs v0.77.0; two small non-safety regressions from #4111 (an uncapped auto-staging file list, a narrow `Nothing to commit` refusal under `status.showUntrackedFiles = no`). Not merged, by decision. - **#4122** (merged in) and **#4123** — pre-existing bugs, identical on v0.77.0. ## PTY filter fix `test (macos)` failed once on a shell's failed-`setpgid` diagnostic that landed straight after the capture's trailing `\x1b[0m`, with no newline between, so #4136's line-anchored filter missed it. The filter now also matches right after an SGR escape and keeps the escape; its test gains that shape and fails on the old pattern. ## Validation - `cargo run -- hook pre-merge --yes` — 4930 tests passed, 1 skipped; clippy, fmt, doc-sync, snapshot and lockfile checks green - Nightly on the original cut-from tip (https://github.com/max-sixty/worktrunk/actions/runs/35060617020): green across `full-tests` on linux/macos/windows, `feature-powerset`, all `release-target`s, `nix-flake`, `minimal-versions`, `link-check` > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Problem
Follow-up to @max-sixty's question on #4124: "Do we need our own minijinja hand-rolled scanner?"
No.
src/config/deprecation.rslocated the identifiers it migrates with a scanner that re-derived MiniJinja's grammar from the raw string — where a tag ends, how whitespace control is spelled, what a quoted string is, what{% raw %}makes literal, and howset/for/withtarget lists are shaped. Every place that second reading disagreed with the parser was visible in the file worktrunk writes: #4117 was a}}inside a string ending a tag early, and the two review rounds on #4124 added parenthesized tuple targets,+whitespace control, and tags inside{% raw %}. Each fix made the scanner longer and the next disagreement harder to rule out, because nothing structurally tied it to what MiniJinja actually does.Solution
Enable MiniJinja's
unstable_machineryand parse the template instead of scanning it.TemplateVarswalks the AST once and collects two things: the reads, which are theExpr::Varnodes — by construction the set the renderer resolves against the context — and the bound names, which are the statements' own target expressions. The rewrite then splices at the byte ranges the parser gave those nodes. Everything the scanner had to decide for itself is now the parser's answer: quoted delimiters, whitespace control, comments,{% raw %}contents, attribute names, keyword-argument names, nested tuple targets.That deletes seventeen functions — the tag walker, the target-list reader with its
TargetsEndAtmodes, the quote and identifier scanners, the{% raw %}state machine — for nine short visitor methods. It also collapses detection and migration into one call (migrate_retired_vars) instead of two functions sharing a predicate, so the framework invariant is now structural rather than maintained.The shadowing policy is unchanged: a retired or canonical name bound anywhere still drops the whole pair rather than renaming per scope. The AST would now support real scope tracking, but that is a behavior change, not a simplification, so it stays out of this PR.
What this costs
Two things worth weighing against the deletion, both stated up front:
unstable_machineryhas no semver guarantee. MiniJinja last changed it in a patch release (1.0.16 movedtokenize/parsesignatures), so a version bound wouldn't reliably prevent a break. The coupling here is to the AST's shape alone and both matches are exhaustive, so the failure mode is a compile error naming the node to handle — never a silent mis-migration. CI catches it on a bump; acargo installagainst a future MiniJinja would fail to build rather than misbehave.unstable_machinerypulls ininternal_debug, which derivesDebugacross the AST and instruction types. TheCargo.tomlcomment now records this against the ~200KB the trimmed feature set saves.Behavior change
One, and it is a fix:
{{ repo_root }}{% raw %}{{ " {% endraw %}now migrates. The scanner had to find that tag's end itself, couldn't, and left the whole template alone; MiniJinja reads the raw block as the literal it is.test_normalize_bails_on_a_tag_it_cannot_terminatepinned the old behavior and is replaced bytest_normalize_migrates_past_a_tag_opened_inside_a_raw_blockplustest_normalize_leaves_an_unparsable_template_untouched, which pins what the bail-out is actually for.Testing
Every existing test in the module passes unchanged apart from that one, including the whole
test_warning_fires_iff_update_changesbattery and the edge cases #4124 added. New pins for what the parser now decides:test_normalize_skips_identifiers_that_are_not_reads— a keyword-argument name and a map key.test_normalize_rewrites_reads_out_of_visit_order— the AST hands reads back test-first for a conditional and keys-before-values for a map, so two migrations in one tag must still splice coherently. Removing the sort makes this panic.test_normalize_rewrites_inside_a_do_statement—{% do %}is its own statement, not an emitted expression.test_normalize_rewrites_reads_in_every_expression_shape— one case per AST node the walk descends, since a missed visitor arm loses a migration silently rather than failing.test_warning_fires_iff_update_changesgains an unparseable template tountouched.cargo test --lib --binspasses exceptcopy::tests::test_copy_dir_recursive_does_not_count_a_non_regular_file, which fails onmainin this sandbox too (socket(AF_UNIX, …)is blocked — verified by stashing the change and re-running).cargo test --test integration configis 582/582.cargo clippy --all-targetsandcargo fmt --checkare clean.