Make command-time attachment healing cancel an armed terminal restart
before replacing the server, while keeping request-only lookup pure and
restart-safe.
Track config-driven server ownership privately, bound every fallback
server per SID, scope no-swap retirement to the failed root, and route
the shipped Lean diagnostics command through the safe resolver while
waiting for initialization.
Add direct acceptance counterexamples for all five review findings and
record the sixth-round verification and vacuity lesson.
Round 5 review: one P1, a frontend scope hole, and three P2s.
**1. A fallback that SPAWNS and then dies retried forever.** The
once-per-buffer guard bounds calls to `_attach_buffer`, not the server
those calls produce. `ensure_server` still never forwards `cfg.restart`,
so the fallback inherits `OnCrash`; an executable that exits before
`initialize` is respawned by the manager with no attempt ceiling —
silently, because `latched` has already disabled the primary's failure
poll. The fallback now gets its own one-shot die-before-initialize
watch, which retires it (ending the respawn loop) and reports.
The prior failing-fallback test used a NONEXISTENT executable, so it
only ever exercised synchronous ENOENT. To reach "spawned, then died"
the fixture has to actually spawn.
**2. Simultaneous frontends.** Both repair triggers read the ambient
`pmacs.window.buffer()`, and the daemon restores `active_frontend` to
the last-dispatched frontend before `tick_processes` — so a Lean buffer
active in ANOTHER frontend receives no `buffer.after-switch` here and
stays stale after its server is globally retired.
Fixed at the seam that is frontend-agnostic: **make consumption safe.**
`attached_for_active` now rebuilds rather than returning a record whose
server is dead, and `attachment_for_request` reports none (it must not
perturb LSP state, so it cannot rebuild). Whichever frontend runs a
command is the active one while it runs, so healing at the point of use
reaches every buffer no eager sweep can. This also closes the half where
a dead attachment was handed to a command and the request vanished.
**3. The retirement sweep stopped user-managed servers.** Selecting on
`language_id == "lean4"` also names servers the user spawned from
`init.lua`, which are not derived from `pmacs.lsp.config.lean4`. It now
keys on the `default-lean4` label `ensure_server` stamps — the
derivation discriminator.
**4. Repair ran even when no swap occurred.** `swap_to_fallback()`
returning false left `latched` true, so the next tick retried the
UNCHANGED configuration and reported it as a fallback failure. Split
into `probe.fallback_installed`: repair exists to apply a swap, so no
swap means nothing to apply.
**5. The once-per-buffer assertion counted table keys**, which cannot
distinguish "once per buffer" from "every tick for one buffer" —
cardinality stays 1 either way. Replaced with a numeric attempt counter;
the bite reports 174 attempts against the expected 1.
Five bites, each against 7c37bdc: no fallback watch -> attempt reaches
4; retire by language_id -> the user's server is stopped; gate repair on
`latched` -> a repair is attempted with no swap; drop the
once-per-buffer guard -> 174 vs 1; hand back a dead attachment -> a
command receives a `stopped` server.
Two more vacuity shapes recorded in the ledger (8 and 9): counting
distinct keys cannot bound repeated work, and a nonexistent executable
cannot reach any post-spawn failure.
Round 4 review: one P1, and it is the same defect for the FOURTH time.
`pmacs.lsp.config.lean4` is a single global entry, so swapping its
command invalidates **every** Lean buffer and **every** Lean server —
Q#LN15 gives one server per project root, so there can be several.
Rounds 1-3 each repaired one buffer and retired one server, and round 3
shipped "repair the armed target, strand the rest": status and config
said fallback while a second open Lean buffer stayed on the retired
command, and a second project root's server stayed live.
The shape that actually holds:
* **Retire ALL `lean4` servers on latch**, not the one the probe
happened to name. `probe.primary` identifies the server the VERDICT
is about; it was never the set of servers the swap invalidates.
* **Repair each buffer lazily and at most once**, when it becomes
active — on `buffer.after-switch` and on the tick. `_attach_buffer`
is an active-buffer-only seam, so a global swap cannot be applied to
every open buffer at once; it has to be applied as they surface.
lsp.lua's own `after-switch` re-pushes views but does not rebuild a
stale attachment, so nothing else covered this.
* The **once-per-buffer bound** is load-bearing: without it a fallback
that also fails to spawn would retry every tick forever — the
round-2 defect, which a naive global repair loop would reintroduce
for every buffer instead of just one.
* `shutting-down` is deliberately not treated as stale. It is still
live by `server_is_live`'s reckoning, so attaching would early-return
the stale record and burn that buffer's single attempt on a no-op.
P2: argument-inclusive attribution was implemented in round 3 but pinned
only by "contains the command name", so a mutation dropping every
argument passed. Now asserted against the exact `<command> <args>`
string.
Also fixed a vacuous assertion this refactor created: a test checked
`_probe.reattach_from == nil` for a field that no longer exists, which
reads as nil and passes for nothing. It now asserts a positive count of
recorded repair attempts.
Three bites, each against 73587b0: repair only the armed buffer -> the
second buffer stays on `lake`; retire only the named server -> one live
stale server remains; drop arguments from attribution -> the exact-string
assertion fails.
The ledger records a second durable lesson beside the vacuity one: **a
scope error repeats until the scope is named.** Four rounds of locally
correct fixes, none of which asked what the config swap invalidates.
When a change edits shared state, enumerate everything derived from it
before repairing anything.
Round 3 review: two P1 asynchronous-correlation defects, with the
focused suite at 25/25 while both were live.
**1. A late version verdict retired nothing and claimed success.**
`probe.watching` is cleared the moment the server initializes — it is
failure-polling state. A slow `lake --version` landing after a
successful initialize therefore reached `fire_latch(nil)`, which retires
nothing: `_attach_buffer` found the still-live primary attachment,
early-returned it, and the retry counted that as done. Status said
"falling back", the config named the fallback, and the buffer stayed on
the old server.
**That is the round-1 silent no-op arriving through a third event
ordering** — first as "no re-attach at all", then as "re-attach cleared
by an unrelated buffer", now as "re-attach satisfied by the server we
were supposed to replace". The fix separates the two facts that were
being carried by one field: `probe.primary` is the server the verdict
applies to and survives initialization; `probe.watching` is the
failure poll and is cleared by it.
The existing fixture could not reach this ordering at all — its `serve`
sleeps, so the primary can never initialize before `--version` returns.
The new one execs the fake LSP for `serve` and delays 0.6s before
reporting 3.0.0.
**2. `buf_key` was the most recently loaded Lean buffer.** Written on
every Lean `buffer.after-load`, so a second Lean file opened before the
verdict became the rebuild target while the latch still watched the
FIRST buffer's server. Target buffer and primary server are one fact and
are now armed together, exactly once. Both files in the new test share a
package, so mis-targeting shows up as a stranded buffer rather than as
two unrelated servers.
**3. The failure message hardcoded `lake serve`** after the latch became
command-agnostic, telling a user whose `my-lean-wrapper` failed to go
debug lake. `configured_command()` names what is actually configured,
arguments included.
**4. The ledger** now records all fifteen bites across the three rounds,
both prior review rounds' findings (the round-2 block was lost when an
earlier edit script aborted before writing), and the durable lesson.
That lesson, recorded for the handoff: **six tests across three rounds
were written, ran green, and pinned nothing** — caught only by biting.
The shapes are enumerated in the ledger; the rule is that a test is not
evidence until the mutation it targets has been shown to fail it. Two
of the six are subtle enough to be worth naming here: a bite that
RAISES is swallowed by the hook's pcall and "passes" for the wrong
reason, and a fixture whose `serve` sleeps cannot reach any ordering
where the primary comes up first.
Three P1 lifecycle defects and two P2s. The focused suite was 20/20 with
every one of them live, which is the part worth keeping.
**1. The crashed primary respawned forever underneath the fallback.**
Round 2 skipped the retire call for terminal servers to avoid corrupting
them — but the crash had already armed `next_restart_at`, and
`maybe_restart` fires on every elapsed backoff with no attempt ceiling.
The broken command kept respawning under the live fallback.
The right call depends on the state, and each is wrong for the other:
`forget` REQUIRES a terminal state and removes the client outright,
which also drops the restart timer; `stop` is for a live one and
corrupts a terminal one (its not-initialized branch parks it in
`ShuttingDown` forever). `retire_server` now dispatches on state.
**2. Re-attachment targeted whatever buffer was active when the
asynchronous verdict landed.** `_attach_buffer` is an active-buffer-only
seam, and "some attachment now names a different server" is satisfied by
an unrelated Rust buffer — clearing the retry and leaving the Lean buffer
stale forever. The initiating buffer is now captured and the retry waits
for it.
**3. A failing fallback retried every tick forever, silently**,
contradicting acceptance 27's promise that a second failure surfaces.
"Waiting for the old server to go" and "attempting the replacement" are
now separate: once the old one is terminal or gone, the replacement is
attempted EXACTLY once, and a spawn failure is reported.
**4. The Lake version parser was being applied to arbitrary wrappers.**
`version_below_3_1` encodes lake's output contract; a working
`my-lean-wrapper` reporting "wrapper 1.0" would have been replaced
despite its server initializing fine. The version probe is now gated on
the command's basename being `lake`. The FAILURE latch stays
command-agnostic — that one keys on the server actually not starting,
which is true of any command.
**5. An unconfigured Lean server was reported as a failure** and latched,
poisoning the session so a later configuration could never take effect.
Absent config or command now means disabled; only a configured command
that produced no attachment is a failure.
**6. The ledger recorded pre-fix counts** after the fixes were pushed.
Now 25/25 and 3,214. That is the #161 fmt-blocker error in a slower
form: verification must describe the pushed tree.
Sign-offs requested in review: `M.fallback` is now `M._fallback`, an
underscored test seam, and its idempotence check compares args as well as
command — the same command with different arguments is not "already
applied". Dropping the `command ~= "lake"` guard stands for the failure
latch only.
Five regression tests added, and **three of them were too weak on first
write; only bite-testing found it**:
* asserting "no live non-fallback server" misses a respawn loop,
because a respawning server sits in `crashed` most of the time —
`attempt` is the observable that counts respawns;
* returning to a buffer with `find_or_open` re-fires
`buffer.after-load`, which repairs the attachment regardless of the
code under test — `switch_buffer` is the honest return;
* a MISSING command fails synchronously inside `after-load` where the
rebuild happens inline, so the async race cannot occur — only the
probe path exercises it.
Each of the five now fails against the exact round-2 mutation it targets.
Round 1 review, four P1s. All real; the first two mean the fallback did
not work at all.
**1. The latch swapped the config but never spawned or re-attached.**
Nothing re-fires an attach on a config change and `attach_buffer`
early-returns for a live attachment, so the buffer stayed bound to the
server that had just been stopped. The user got a config edit and no
language server. `fire_latch` now rebuilds through a new
`pmacs.lsp._attach_buffer` export.
Two mechanics had to be right for that rebuild to happen at all:
* It is **retried on the tick**, because `pmacs.lsp.stop` leaves the
state `shutting-down`, which `server_is_live` counts as LIVE — an
inline re-attach early-returns the stale record and the swap is a
silent no-op.
* The latch **does not stop an already-terminal server**, and this is
a substrate bug worked around rather than a style choice.
`LspManager::stop` on a `Crashed` client takes its not-initialized
branch, terminates the dead process, and sets `ShuttingDown { ..
None }` on the premise that "the next exit observation cleans up" —
but the exit already happened, which is what made it `Crashed`. No
further event arrives, so the client is stuck in `ShuttingDown`
forever: `server_is_live` reads it as live so `attach_buffer` never
rebuilds, and `forget` refuses it for not being terminal. Stopping a
dead server is what makes it un-replaceable. Named in framing §6; the
fix belongs in `stop` and changes behavior for every language.
**2. A missing `lake` bypassed probe and latch entirely** — the single
most likely real failure. `ensure_server` swallows a synchronous ENOENT
and returns nil, so there was no attachment, and the hook keyed on
`active_attachment()` returned before arming anything. The hook now keys
on the buffer's LANGUAGE and treats a Lean buffer with no attachment as
the failure itself.
**3. `waitForDiagnostics` omitted `version`.** Lean's
`WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0,
`src/Lean/Data/Lsp/Extra.lean`); the request is how a client says which
revision it wants. It looked correct only because the fake server echoes
any payload — so the fake server now validates and returns InvalidParams
without it.
**4. The ledger stated the dangerous stacking order** in one sentence
and the correct rule in the next. Fixed to say BEFORE. A safety rule
written twice with opposite senses is worse than not written.
Also (P2): the probe/latch suite now drives the production path —
`buffer.after-load` -> ticks -> probe drain -> latch -> re-attach — with
real executable stubs, and asserts the originally opened buffer ends up
on a LIVE server. Round 1's acceptance 36 asserted every server was
terminal, i.e. pinned the ABSENCE of the fallback it claimed to test.
`M.fallback` is a table so the suite can point it at a working stand-in;
the probe now spawns `cfg.command --version` rather than a hardcoded
`lake`, which is also more correct for a user who configured a wrapper.
`swap_to_fallback`'s `command ~= "lake"` guard is gone: the latch fires
only when the configured server actually failed, one visible fallback
beats no server, and `probe.latched` is what keeps it to exactly one.
Three new bites, all against the committed tree: no re-attach after the
swap -> three latch tests fail; hook keyed on the attachment -> the
missing-`lake` case fails; `waitForDiagnostics` without `version` ->
acc37 fails with the server's InvalidParams.
Framing Q#LN7, Q#LN8, Q#LN16; acceptance 22–28, 24a/24b, 35, 36, 36a, 37.
Stacked on Stage 3a (#167), whose notification/response seams and
`pmacs.fs.canonicalize` this consumes. No protocol change; the only Rust
outside the test helper is one `include_str!` line.
**The Lake-aware root (Q#LN8).** `pmacs.project.detect` cannot express
this rule — it is innermost-wins by construction, and a Lake package's
`lean-toolchain` sits at the outermost level, so a file under
`<pkg>/.lake/packages/dep/` belongs to `<pkg>`'s server rather than
`dep`'s. The resolver walks up collecting markers and returns the
outermost, stopping at `pmacs.project.search_boundary()` so a stray
marker above a fixture cannot leak in.
Two things about the marker test are easy to get wrong in opposite
directions, and both are pinned. `io.open` **succeeds on a directory**,
so a truthiness check accepts a `lean-toolchain` directory; but
requiring a non-nil read rejects an **empty** `lean-toolchain`, which is
a legitimate marker — `locate-dominating-file` semantics are existence,
not content. The discriminator is `read`'s second return: decline only
on a non-nil error. Acceptance 24a and 24b each fail against the
implementation that satisfies only the other; both bites are recorded.
The root is canonicalized once up front, because a configured root
reaches `file_uri_for` verbatim and that URI is the affinity key (#161).
Canonicalizing the starting directory suffices — every ancestor of a
canonical path is canonical, since the walk only strips components.
**`lake serve` with a lazy probe and a one-shot latch (Q#LN7).** Nothing
runs at init: `pmacs.lsp.config` is declarative, and spawning a process
at startup for every user, Lean-using or not, is the cost rev 1 refused.
Both the probe and the server spawn are gated on a real Lean attachment.
The probe cannot gate the first attach — there is no blocking process
run, so its verdict arrives after `ensure_server` has already decided.
Hence the optimistic spawn, with the probe and latch correcting it. A
non-zero probe exit is deliberately NOT a trigger: §2.9's elan-shim case
makes `lake --version` fail on machines where `lake serve` still works,
and the server-failure latch covers that better. The probe answers only
the question failure detection would answer slowly — an old-but-working
lake that starts a useless server.
The latch stops the failing server **before** spawning the fallback, and
that ordering is load-bearing rather than defensive: the spec default is
`OnCrash`, the termination handler never consults the exit code, and
`maybe_restart` has no attempt ceiling, so a broken `lake` respawns
forever underneath the latch. `pmacs.lsp.stop` sets `restart = Never`,
which is what disarms it. Bitten: removing the stop fails acceptance 36.
The swap rewrites `command` and `args` only, so a user's `env`,
`settings`, `init_options` and `root` survive — a wholesale table
replacement would discard their `init.lua` at the moment they are least
likely to notice.
**`waitForDiagnostics` (Q#LN16)** resolves through Stage 3a's response
seam, with `M-x lean.wait-for-diagnostics` on top. `$/lean/fileProgress`
subscribes on the notification seam and is pinned end-to-end through a
new `leanprogress` mode on the fake server rather than by calling the
handler directly — the wiring is the only part that can break.
**Attribution (COHERENCE §9/§1.2).** The probe spawns as
`lean:lake-version-probe`, so a user wondering why their editor touched
`lake` finds an owner in `pmacs.process.list`. The latch reports through
`pmacs.editor.set_status` — the channel that exists — and acceptance 36a
observes that channel, so a report made only through the undefined
`pmacs.error` would fail it.
**Stage 1's acceptance 12 is updated, half superseded.** It asserted
`pmacs.lsp.config.lean4 == nil` to guard against a Stage-3 front-run;
Stage 3b is that stage, so keeping it would pin the opposite of the
intended behavior. The half that survives is the one about restraint,
and it matters more now: constructing an editor spawns nothing even
though the config exists and names `lake`, and opening a Lean buffer
with no server configured spawns nothing either. That is what holds
Q#LN7's "not at init" promise.
Bites recorded, all against the committed tree: bare `io.open` -> 24a
fails, 24b passes; require-non-nil-read -> 24b fails, 24a passes; no
canonicalization -> the symlink case spawns two servers; no stop before
fallback -> acceptance 36 fails.
Six findings from review, one of them a real defect.
**`canonicalize` could emit a path that exists nowhere.**
`p.display().to_string()` substitutes U+FFFD for non-UTF-8 bytes, so a
resolution landing on such a path returned a plausible-looking string
that does not exist on disk — worse than nil, because this value becomes
a server-affinity key through `file_uri_for` and would silently fail to
round-trip, while the doc promised nil for anything unresolvable. Now
`.and_then(|p| p.to_str().map(str::to_owned))`: unrepresentable is a
decline, matching how the fs layer already treats non-UTF-8 symlink
targets.
Pinned by a new acceptance case that reaches a non-UTF-8 target through
an **ASCII** symlink, so the input is representable and only the
resolved output is not — the case a UTF-8 check on the argument would
miss. Bitten: restoring `display()` fails it.
The other five:
- `on_response`'s doc comment now warns that registering against a
server with no attached buffer is fire-on-death, not fire-on-reply,
because the drain visits only attached sids. It looks exactly like a
hung request while debugging, and 3b is the first caller likely to
hit it.
- `deliver_response`'s comment still carried the pre-correction
rationale ("removed BEFORE invocation ... must not be re-entered") —
the claim the bite disproved. It now says what is true: removal is
unconditional, before-vs-after is unobservable without a re-entrant
drain, and the reachable bug is gating removal on a clean return.
- Dropped `server_attempt`'s unused second return.
- Deleted a vacuous assertion in the no-attachment test (counting `_G`
entries to assert "lua globals are readable") — scaffolding that
pinned nothing, the exact shape the project's own lesson flags.
- `#[cfg(unix)]` on the three symlink-dependent tests.
Gates re-run in full. The sweep's first pass tripped
`composition_overhead_under_ten_percent` at 18.8% against a 10% budget;
it passes 3/3 in isolation here, passes in isolation on main, and the
same run reported the realistic-frame overhead as **-4.6%** — a negative
figure is measurement noise, not added work. Nothing in this diff is on
the render path. Rerun of the full sweep: 3,189 across 93 suites, zero
failures.
Two corrections found by bite-testing the suite rather than by reading
it.
**Acceptance 32 was mis-named.** It claimed to pin "the one-shot is
removed BEFORE invocation". Biting that — moving the removal after the
`pcall` — still passes, because `pcall` catches the raise either way and
the removal runs regardless. The before/after ordering is unobservable
unless a handler re-enters the drain, and nothing does. What the test
actually pins is that removal is **unconditional**: the bite that gates
it on `if ok then` fails 2 != 1, because the surviving registration is
invoked a second time by the purge. Renamed and re-commented to say so.
The implementation still removes before invoking, which is the right
defensive order; it is simply not what the assertion proves.
**The purge's generation check is defensive and untested**, now labelled
in place instead of reading as covered. Reaching it needs a crash and
its restart to both land in a gap with no `_async.tick`; the backoff is
500ms, so any tick in that window sees `crashed` and the
absent-or-terminal test fires first. Every attempt to stage it
deterministically exercised the `crashed` path instead. It stays as
insurance for a stalled editor, and says that about itself.
Bites recorded, all against the committed tree:
- removal gated on a clean return -> acc32 fails (2 != 1).
- purge driven by a `crashed`/`stopped` event seen in the drain, the
design the framing originally implied -> the no-attachment case fails
("never called"), while the attached case still passes. That is the
discrimination acceptance 34's second half exists for.
- a resolver without `pmacs.fs.canonicalize` -> two servers, pinned as
34b's own falsification.
Arc 8 Stage 3a (framing Q#LN9, Q#LN20). No Lean content: this changes
the event drain every LSP language runs through, and is split from the
Lean server work for the reason Stage 2 was.
**The seams.** `handle_server_requests` handled five `request` methods
and `initialized`, dropping every `notification` and `response` on the
floor. Dropping responses made `pmacs.lsp.send_request` a write-only API
from Lua — the reply was drained and discarded, so nothing outside
Rust's typed stores could consume one. Two new arms route to
`pmacs.lsp.on_notification(method, fn)` (persistent, method-keyed) and
`pmacs.lsp.on_response(sid, request_id, fn)` (one-shot). Both extend the
existing loop rather than opening a second `events_take` caller, which
would steal events from it.
A one-shot is removed **before** invocation, so a raising handler cannot
be re-entered. Every subscriber is `pcall`ed and a raise reports through
`pmacs.editor.set_status` per COHERENCE §1.2 — not `pmacs.error`, which
is defined nowhere in production. The notification list's length is
captured before the walk so a subscriber registering another cannot
extend the list being iterated.
**The purge is driven off `pmacs.lsp.list()`, not off a death event.**
The framing said acceptance 34's second edge was a killed buffer. That
was wrong, and scouting the implementation is what caught it: pmacs
fires exactly five hooks (`buffer.after-edit`, `buffer.after-load`,
`buffer.after-switch`, `frontend.detached`, `process.after-tick`) and
there is no buffer-kill hook at all, so `lsp.lua` never tears an
attachment down and the drain keeps reaching that server. No leak there.
The real leak is a different path with the same root cause. The drain
builds its sid list from `attachments`, and `attach_buffer` drops a sid
from that table the moment `server_is_live` reports false — rebuilding
against a fresh server. So the `crashed` / `stopped` event that should
trigger the purge is precisely the one most likely to go undrained. A
purge wired to that event leaks exactly when it matters.
`pmacs.lsp.list()` enumerates the manager directly and is unaffected by
attachment bookkeeping, so the purge polls it after each drain: a sid
that is absent, terminal, or running a **new generation** settles its
pending one-shots with an error. The generation check uses the `attempt`
field, because a crash-then-restart reuses the sid — without it a
one-shot would sit waiting on a reply the dead generation owed.
**`pmacs.fs.canonicalize`** (Q#LN20) is the one synchronous function on
`pmacs.fs`, and synchronous is the point: its consumer is a
function-valued `config.root` called from `ensure_server` <-
`attach_buffer` <- `buffer.after-load`, where there is no coroutine and
`pmacs.fs.stat`'s awaitable handle is unusable. It is installed from
`install_async` rather than `install_project` purely for load order —
`make_workspace` runs after `fs.lua` is evaluated, so a canonicalizer
placed there reads nil.
Acceptance: `tests/lsp_dispatch_seams_acceptance.rs`, 14 tests, driven
against `pmacs_fake_lsp` through rust so nothing needs a toolchain.
Dispatch integrity is exercised at real co-occurrence — the fake server
writes `workspace/applyEdit` and the `executeCommand` reply back to
back, so both land in one `events_take` batch. 34b asserts affinity
survives a symlinked open and is paired with its own falsification: the
same resolver minus the canonicalize call spawns two servers, so the
positive case cannot be vacuous.
COHERENCE.md §1.2 makes "a `pcall` around background wiring must log
attributed failure, never discard it" a standing rule, and names
`ensure_server`'s swallowed spawn failure as its canonical case — the
exact function this branch modifies. Round 1 deferred the resolver's
silent `pcall` as a Stage 3 concern. Under that rule it is not a
deferral, it is a fresh instance of the named anti-pattern added by a PR
touching the cited function, made worse by the memo: a raised error is
buried permanently for that directory and never observed again.
A resolver that raises, or returns a non-string non-nil, now leaves an
attributed trace naming the language and the directory. Returning nil
remains the documented decline and stays silent — pinned, so "report
failures" cannot be satisfied by reporting every resolution.
The report goes through `pmacs.editor.set_status`, NOT `pmacs.error`,
and that choice is the finding:
**`pmacs.error` does not exist.** Fifteen call sites across `async.lua`
(5), `syntax.lua` (4), `lsp.lua`, `mcp.lua`, `fs.lua`, `editops.lua`,
`autosave.lua`, and `commands/default.lua` report background failures
through it, each guarded `if pmacs.error then ...`. It is defined
nowhere in production; the only assignment in the tree is a test stub at
`src/editor.rs:9881`, and `type(pmacs.error)` is nil in a fresh
`EditorState` (probed, not inferred). `pmacs.errors` (plural) in
compile.lua is an unrelated namespace. So all fifteen reports are dead,
and the guard makes the silence look deliberate — which is why nobody
noticed. Writing the test is what caught it: the first version of this
fix used `pmacs.error` and its pin failed against a working
implementation.
Both bites recorded: dropping the report entirely fails the pin, and so
does reporting ONLY through `pmacs.error` — the dead-channel variant
this nearly shipped.
Not fixed here, deliberately: defining `pmacs.error`, the fifteen dead
sites, and surfacing the spawn failure itself. That last is Priority 1
work and a user-visible product behavior — what message, where, with
what guidance — so it needs its own framing rather than being smuggled
into an affinity PR.
Three of the five review findings land here; the other two are recorded
as named deferrals in the framing on the dired branch.
Finding 1: the command comment cited "acc4", a name from a draft scheme
that no test carries. It now names the real test, and the comment splits
the shadowing consequence into the two cases that actually exist -- a new
bare name that matches an entry (shadowed) versus one that matches
nothing (creates normally) -- each pointing at its test.
Finding 2: the everyday new-file flow had no test. Typing a bare name
that is not a subsequence of any entry is the path users hit first, and
the only route combining free text with a relative join; every existing
new-file test used a name containing a separator.
find_file_bare_new_name_creates_in_the_root covers it, asserting the
parent is the prompt's root so the join itself is pinned.
Finding 3: the failure arm was never exercised, and as the review noted,
deleting the pcall would have passed the whole suite. Accepting a
directory candidate reaches display_file, whose load fails because
File::open on a directory succeeds and the read returns EISDIR;
find_file_accepting_a_directory_reports_instead_of_raising pins that this
surfaces as the command's status message, leaves the active buffer alone,
and closes the prompt. Verified by manual revert: with the pcall replaced
by a direct call, that test and only that test fails. scripts/bite could
not isolate it, since the guard and its test have no separating commit.
Finding 4 is documented at the command rather than left implicit:
accepting on empty input opens the first-sorted candidate, because
fuzzy_score returns Some(0) for an empty needle and filter_and_sort
breaks the tie lexicographically, so dotfiles lead and a directory can
lead. M-x and switch-buffer share the mechanism, so it is inherited
rather than introduced, and it is listed in the framing beside the
accept-semantics change that would close it.
The blocker was process, not design. The test file was committed before
`cargo fmt` ran, so the reflow of five over-width assertions sat
uncommitted in the working tree while the branch as pushed failed the
first gate in CLAUDE.md. The "fmt clean" reported on the PR described
the worktree, not the branch. Gate results are only meaningful run
against the pushed tree, so this commit lands the formatting first and
the gates are re-run against it.
Two pins review asked for, each covering a branch the nine acceptance
tests left untested:
- A **string** `config.root` as an affinity key. acc17 covers only the
function form, so `return configured, "config"` had no test. The bite
puts both files in their own marked project: drop the config arm and
they key on their own detected roots and spawn two servers, so one
server on the configured root is only reachable if the override wins.
- `root = false` reads as unset. Defended by a truthiness check rather
than `~= nil`, previously by comment alone. Under `~= nil` the config
arm returns `false, "config"` and `file_uri_for(false)` returns nil, so
the file lands on a rootless server instead of its detected project.
Each was falsified against exactly the mutation it targets and neither
against the other.
Also documents an asymmetry review caught: `project_root_for`'s
"detected" arm is canonicalized for free because `pmacs.project.detect`
canonicalizes before walking, but a **configured** root — string or
resolver return — is fed to `file_uri_for` exactly as written, and the
affinity key is that URI. On macOS a resolver returning `/var/…` and a
detected `/private/var/…` are therefore different keys for one
directory, silently yielding two servers for one project. There is no
Lua-side canonicalizer to normalize it, and Stage 3's Lean resolver is
the first real consumer, so the obligation is stated in the
`config.root` doc comment where that resolver's author will read it.
Dired arc Stage 0 (docs/dired-framing.md section 10, Q#DR11). Until now
pmacs had no discoverable way to open a file by path: no find-file
command and no C-x C-f binding, so a file entered a session only from
the CLI, an LSP jump, a project-search visit, or C-x C-r, whose prompt
does pass free text through but completes only over the recent list.
The command prompts with completion rooted at the active buffer's
directory, or the process cwd when the buffer has no backing path, and
opens the result through pmacs.window.display_file. A path that does not
exist yet creates a buffer bound to it with the "[new file]" status,
which is Emacs parity and comes from resolve_target_buffer rather than
anything added here. Nothing is written to disk until the user saves.
Two substrate facts shape the design and are documented at the command
rather than left to be rediscovered.
Completion is flat: the files source lists one directory and yields bare
basenames, and a custom function source could not do better, because
sources are called with no arguments and run synchronously outside any
coroutine, so a callback can neither see the input to re-root on nor
await a directory listing. Hierarchical completion is a named Rust
change in the framing.
A selected candidate shadows typed text: recompute_candidates selects
index 0 whenever the candidate list is non-empty, and
resolve_accepted_value returns the candidate over the typed contents. So
typed text reaches the accept handler exactly when the input filters
every candidate away, which for basename candidates under a subsequence
filter means when it contains a separator. That makes the deeper-path
case work verbatim and leaves one hole: a new bare name that is a
subsequence of an existing entry opens the existing file. The acceptance
pins that as a decision rather than an accident; closing it needs a Rust
change to accept semantics that Stage 0 deliberately does not make.
A leading tilde is expanded before the path reaches the core, because
get_or_load_buffer normalizes the path it stores but loads from the raw
one -- so an unexpanded tilde path deduplicates against an already-open
buffer yet fails to load a file that is not open yet.
The prompt field starts empty and names its root in the prompt string
instead: any prefill would contain a separator and silently disable
completion.
Acceptance is dispatch-driven throughout -- a real C-x C-f, real typing,
a real RET -- so a dead binding cannot pass vacuously and the Lua
lifecycle accept(), which bypasses the path interactive input takes, is
not used.
`ensure_server` reused any live server whose `language_id` matched,
regardless of project root — its own comment documented this as a known
post-v0.1 limitation. For project-model-strict servers that is a
correctness failure, not a rough edge: `lake serve` is bound to one Lake
package, rust-analyzer and gopls to one workspace, so the second project
a user opens gets a server that cannot resolve its imports.
Server affinity is now keyed on the project root, with one rule that
keeps the change from regressing every other language:
The affinity key is the root only when a root was actually FOUND.
`project_root_for` never returns nil for a file that has a path — its
last resort is the file's own directory — so a naive `(language_id,
root)` key would give every directory of loose scratch files its own
server, for every language: two stray .py files in different directories
would spawn two pyrights where today they share one. It now returns
`root, source` with source one of "config" / "detected" / "fallback",
and only the first two become an affinity key.
Matching is on the spawned spec's `root_uri`, nil matching nil, so the
fallback spawn passes `root_uri = nil` for the key and the stored spec to
agree. `cwd` still carries the directory, and `build_initialize`
(src/lsp.rs) derives the identical `rootUri` from `cwd` when the field is
None — using a percent-encoder with the same allowed set as Lua's
`file_uri_for`. The initialize payload for that case is therefore
byte-identical to before; only what the reuse loop matches on changes.
`build_initialize` is the only reader of `spec.root_uri` in the tree.
Two consequences, both deliberate and both asserted rather than
discovered:
- A server hand-spawned from init.lua with only `cwd` set also reads
back nil, so a root-bearing attach will not adopt it. We cannot know
which root it was meant to serve, and guessing wrongly routes a
project's files to the wrong server.
- Opening files across N project roots spawns N servers. rust-analyzer
has the same property and no editor caps it by default; `pmacs.lsp.stop`
is the manual escape and an LRU reaping policy stays deferred.
`config[language].root` may now be a `function(path) -> string|nil` as
well as a string, for languages whose root rule the shared marker walk
cannot express — an innermost-wins walk cannot find an *outermost*
marker. A resolver returning nil declines and falls through to the marker
walk. Results are memoized per directory because hoisting the root
computation above the reuse loop puts it on every attach rather than
every spawn; the memo is keyed weakly by the resolver function itself, so
replacing `config[lang].root` cannot serve a root the old one computed.
`pmacs.lsp.list()` rows gain `root_uri` and `cwd`. `root_uri` is the spec
field verbatim, deliberately not the URI the server was initialized with.
No protocol change. No Lean content: this is the shared affinity function
for every LSP language, so it ships as its own PR and is exercised
through rust, python, go and typescript against `pmacs_fake_lsp`.
tests/lsp_multi_root_acceptance.rs covers acceptance 13-21. Every fixture
sets `pmacs.project.set_search_boundary` at its own tempdir root:
without it the marker walk climbs to the filesystem root, and a stray
`.git` above the temp directory would turn the markerless cases into
detected ones — the assertions would still pass while testing nothing.
Refs docs/lean4-mode-framing.md Q#LN15, acceptance 13-21.
Completes Arc 8 Stage 1: the Lua-side tables that turn a recognized
grammar into a usable mode, plus the acceptance suite for all twelve
framing criteria.
comment.lua -- `lean4 = "--"` (Q#LN5). Line comments only; Lean's block
comment `/- -/` and docstring `/-- -/` belong to the comment arc's own
named deferral and this lane does not front-run it.
pair.lua -- `⟨⟩`, `⦃⦄`, `⟮⟯` alongside the ASCII brackets (Q#LN6). The
anonymous constructor is among the most-typed constructs in Lean;
omitting it would make the pair set feel broken. The other two ride along
because the Stage 4 input method can produce them, and a bracket the pair
set does not understand is worse than one it does. All three sit outside
the nine built-in pair chars, so per Q#AP1 their undo is
cross-peer-degraded -- the documented, pre-existing limitation of
user-extended pairs. No `''`: Lean uses the prime as an identifier suffix
(`h'`, `foo'`), the same reason Rust excludes it.
syntax.lua -- the `lean` -> `lean4` modeline alias (Q#LN2), so an Emacs
`-*- mode: lean -*-` or a Vim `ft=lean` line is not stranded by the entry
being named `lean4`.
syntax.rs -- the `lean` -> `lean4` injection alias (Q#LN17), so both
```lean and ```lean4 fences highlight. The Lean 3 spelling is mapped
forward deliberately: a ```lean fence is overwhelmingly Lean 4 in
practice.
highlight.rs -- `warning` moves from bold red to bold BRIGHT red. Writing
the test found the collision: `number` is plain `fg(1)`, so `sorry` and
the literal `42` beside it were the same colour, differing only in the
bold flag. `sorry` means "admitted, not proved" and is the one token in a
proof file a reader must never skim past, so it now gets the loudest
entry in the table and the test asserts the full style rather than the
colour.
Twelve criteria, seventeen tests. Notes on the ones that could have been
vacuous:
* acc4 uses a `.txt` fixture, not `.lean` -- on a `.lean` path the
extension alone yields `lean4` and the assertion would pass with the
alias table empty. acc4b removes the alias and pins that the raw name
survives, so acc4 cannot silently stop testing anything.
* acc11 goes through the real `_parse_now` injection path and asserts a
`lean4` CHILD LAYER appears. `pmacs.parse.injection_aliases` is a
documented write-only proxy, so an alias-table read would have proven
nothing about the parser; acc11b pins that a misspelled fence still
resolves to nothing.
* acc12 asserts through the process supervisor and the server list that
opening a Lean buffer spawns nothing. This is not decorative: the
machine this arc was scouted on has elan installed with no default
toolchain, where `lake --version` itself fails, and Stage 1 must be
unaffected by that.
Gates: fmt and strict workspace clippy clean; 1,826 default + 2,003 CRDT
library tests; lean4 Stage 1 9, comment toggle 14, auto-pair 45,
injection 4; M4 121; required GPU 152; isolated-config workspace sweep
3,150 across 90 suites; `git diff --check` clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #155 review round 2.
Finding 1 (must fix): Q#BP7 item 1 — "growth reaching the live tail
re-arms follow (top -> None), only when no selection is active" — was
never implemented. `at_bottom` is the instantaneous geometric readout
`scroll_offset == 0`, which a still-anchored view satisfies whenever it
happens to be tall enough to reach the tail, so the round-1 assertion
could not see the gap: the next rows the child printed pushed the
anchored view back into history.
`rearm_follow_on_growth` now clears `top` when a viewport-size
declaration makes the view cover the tail and no selection is frozen,
and every size-declaring path (`snapshot_for_view`, `record_view_size`,
`view_status_for_size`) routes through one `declare_view_size` helper so
grid and semantic declarations cannot disagree. `scroll_view` and
`begin_selection` deliberately stay out: they write `top` themselves,
and `scroll_view` already owns the scroll-driven arm.
New acc32b is the pin the review asked for: scroll into history, grow
past the tail, then release a SECOND burst of child output through a
filesystem gate and assert the view moved with it.
Finding 2: the PTY fixtures emitted LF-only output, which staircases
rightward until every row clips to blanks past the viewport width — so
the round-1 anchor assertions compared "" with "" and could not fail.
Both fixtures now emit CRLF, and each anchor comparison is guarded by
`assert!(!top_before.is_empty())`.
Finding 3: acc33's contrast case asserted nothing, and the behavior it
claimed was false as coded. With the re-arm in place it is true and now
asserted: clearing the selection at the same geometry re-arms follow and
leaves the frozen anchor.
Finding 4: `start_run` gated the panel branch on `display == "panel" or
already_in_panel(..)`, so an explicit `display = "current"` lost to the
inference — and that value is the documented user-facing opt-out from
the Stage 3 default flip. Now gated on OMISSION. acc19b gains the
explicit-"current" case.
Finding 5: `window_drag` is a `HashMap<FrontendId, WindowDragState>`, so
a peer's mode-line press can no longer steal or clear another
frontend's in-flight gesture, and concurrent drags are legal. Cleared on
detach. acc30c gains the mode-line-press case.
Minor: `pmacs.window.buffer()` resolves both arms through the acting
frontend using the shared `lookup_window` / `selected_window` validators
rather than re-implementing them beside an ambient `active_buffer_id()`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #155 review round 1.
Finding 1 (must fix): `try_split_active` had no production caller —
`pmacs.window.split_horizontal` / `split_vertical`, and therefore
`C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a
focused panel made the root wrapper's final child a split rather than
`Leaf(side)`, which both `Layout::compute`'s fixed pass and
`document_subtree` key on: the panel band reverted to 1:1 weight
division and an ordinary window ended up living inside it. Both bindings
now route through the guard, and acc26 asserts through the real Lua
path — a direct core call passes with the guard unwired, which is how it
survived the first round.
Finding 2: the armed-drag early return now checks the arming frontend,
so one frontend's in-flight gesture cannot cancel or swallow another's
mouse events. New acc30c.
Finding 3: `paint_mode_line_graphemes`'s doc block was left heading
`paint_divider_segment`; moved back.
Finding 4: a recompile carries no `display`, so it took the raw switch
and duplicated a panel-placed `*compilation*` into the document window.
`start_run` now detects that the buffer already owns the panel slot.
`pmacs.window.buffer` gained an optional window argument so an adopter
can ask without selecting the panel first. New acc19b.
Stage-2 hazard pins the review asked for, both in `src/daemon.rs`:
a fresh attach while LOCAL is focused in a panel inherits LOCAL's
document buffer, and an initial-target bootstrap whose `after-load`
hook creates and selects a panel still reasserts into a document window.
Minor: dropped listview's dead `p.side`; documented `focus_window`'s
caller-validates contract; `jump_back` restores through `focus_window`
so the "every focus change" contract holds; `params` / `resize` default
to the acting frontend's selected window rather than the ambient one;
widened the flexible-division math to u64 intermediates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `listview.open`, `compile.run`, and `pmacs.terminal.open` all take the
same strict `display = "current" | "panel"`, validated before any
buffer, session, process, or wrapper exists. Omission keeps today's
behavior; Stage 3 flips the default.
- `listview.quit` / `compile.quit` delegate to `window.quit` only when
the buffer really is in a side window, so the presentation is deleted
or restored instead of leaving a source buffer stranded in the slot.
- LSP `visit_location`, LSP go-to-definition, and compile `visit_error`
route through `display_file`, so a visit from a panel lands in the
document target and fires its hook with that window active.
- `window.quit`'s Delete arm focuses the revalidated remembered origin.
- Capability fallback discards an accompanying `height` rather than
rejecting the call.
- `window.min-height` clamps a below-floor value on read instead of
refusing the write.
- `tests/bottom_panel_stage1_acceptance.rs`: 42 tests over the framing's
Stage 1 criteria, including the two production `Layout::compute`
callers, the recursive minima, hide/reappear, the final-focus matrix,
quit chains at the depth cap, per-frontend jump origins, the divider,
and a real-PTY pin of Bet B1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 1 substrate for the bottom-panel arc (docs/bottom-panel-framing.md).
- `WindowParams` (side / fixed_rows / dedicated + implementation-owned
quit action and remembered document origin), `Side`, `QuitAction` with
a bounded replacement history, and the `MIN_WINDOW_OUTER_ROWS` floor.
- `Layout::compute(area, fixed)` allocates fixed rows before dividing the
remainder by weight; both production callers feed the same shared map,
including the peer-presence overlay pass that derives its own rect.
- `subtree_min_rows` / `interactive_min_rows`: the recursive minima, and
`boundary_below` for the shared drag / keyboard resize boundary rule.
- `FrontendView` gains `panel_capable`, `frame_geometry`, and the derived
`panel_hidden`, each spelled explicitly at every construction site.
- `EditorCore`: `primary_document_window`, the non-side target rule,
`display_buffer` + placement policy, `quit_window`, side-window removal
on `kill_buffer`, per-frontend jump entries with origin windows, and the
shared resolve/load-without-switch seam the initial-target bootstrap now
uses too.
- `EditorState`: the panel reconciliation transaction, geometry
declaration, the side-window `dispatch_idle_for` gate, divider paint,
and divider drag.
- `pmacs.window.display / display_file / quit / panel / params /
set_params / resize / display_target`, plus `builtin/runtime/window.lua`
with `window.panel-height`, `window.min-height`, and the resize commands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fold engine behind `docs/folding-framing.md` (approved rev 5): a
per-buffer fold store, a structural tree-sitter fold source, the
state-aware Lua command + data-API surface with the Emacs hideshow
`C-c @` bindings, the dispatch-layer pre-edit unfold, and `FoldState`
production. No rendering — Stages 2 (grid) and 3 (GPU) consume the store.
- `src/fold.rs`: `FoldStore` (a buffer-attached `View` that translates
ranges on every edit and drops any whose head/tail the edit crosses,
provenance-blind — Q#FD6), the structural source (nearest block-like
node >= 2 source lines -> introducer<->body -> **derived head line**,
the line immediately above the first hidden line, so wrapped signatures
and `where` clauses stay visible per R3-1 -> **closer-aware tail**, a
closing-delimiter line stays visible per R2-5), injection-layer walk,
`(start, end]` containment, and the state-aware ops (close innermost
open / open outermost closed / org-TAB cycle). Stale/absent tree
refuses (Q#FD10).
- `src/lua_bindings/fold.rs`: `pmacs.fold.*` — explicit-buffer data API
(`fold`/`unfold`/`folds`/`toggle`) + interactive helpers, validation
(Q#FD11: document buffer, UTF-8 boundaries, >= 1 hidden line — Q#FD9
falls out of the last clause), point-moves-to-head (Q#FD3).
- `builtin/runtime/fold.lua`: `fold.toggle/close/open/close-all/open-all`
commands + the `C-c @` prefix set (Q#FD4).
- `src/editor_core.rs`: the six point-anchored edit primitives run the
pre-edit unfold keyed on the authenticated source's point (Q#FD5,
command path); `EditorCore` owns the shared `FoldRegistry`.
- `src/semantic_render.rs`: the `FoldState` producer —
authoritative-empty, diff-suppressed, baseline resets on
`BufferSnapshot` (Q#FD8); the "never emitted" pin split so
`BlockAdornments` stays unproduced.
- `tests/folding_acceptance.rs` (16) over real Rust/Python/markdown
grammars + `fold_state_producer_transitions` + 15 engine unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
Register a `latex` entry in BUILTIN_LANGUAGES (.tex/.latex/.sty/.cls) backed by
codebook-tree-sitter-latex 0.6.1 — the linkable republish of latex-lsp's grammar
over the tree-sitter-language shim (the squatted `tree-sitter-latex` 0.1.0 ships
no scanner.c and cannot link). The single `extensions` field wires the whole
detection chain ahead of the LSP filetype map, so no Lua edit is needed.
The crate exports no query constants, so highlighting is driven by the in-repo
overlay builtin/queries/latex/highlights.scm — the first such overlay,
include_str!'d as LATEX_HIGHLIGHTS (the audit-rules.scm precedent). This commit
reconciles the vendored nvim-treesitter query (previous commit) onto pmacs'
recognized capture set:
* strip @spell/@nospell — meaningless to pmacs, and clobber-risk on a
multi-capture node;
* remove the 8 #eq?/#any-of?/#lua-match? patterns — pmacs evaluates only
`#is? local`, so unevaluated they would over-match every generic command
(as conditional/emphasis) and every line comment (as a magic directive);
* remap fall-through captures: @module->keyword, @label->type,
@markup.heading*->keyword.control, @markup.link*->constant,
@markup.math->string;
* fix node-name drift: this grammar cut uses curly_group_label(_list) for the
label commands where newer latex-lsp unified them onto curly_group_text.
Tests (framing acceptance): table guard; load-and-parse including a verbatim
environment (exercises the external scanner the broken crate lacked);
highlights-resolve (doubles as the grammar/query node-name compatibility gate);
and extension resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Source: nvim-treesitter/nvim-treesitter, queries/latex/highlights.scm
(branch master, retrieved 2026-07-23). License: Apache-2.0, compatible with
pmacs' MIT OR Apache-2.0.
Committed byte-for-byte before any reconciliation so the follow-up commit's
diff shows exactly which captures/predicates were curated onto pmacs' recognized
set. This is the first in-repo grammar-query overlay (per framing Q#LX2); the
audit-rules.scm include_str! path is the precedent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integrate landed Vterm Stage 2 before the approved modeline merge. Preserve the
active modeline lane in the volatile ledger and record the full integrated gate
results.
Parse bounded Emacs and Vim modelines, normalize common aliases, and give
explicit file metadata precedence over inferred language. Pin one fresh-load
language decision for syntax, LSP, pairing, comments, and initial major mode,
while preserving the LSP path guard and explicit mode overrides.
Cover supported forms, rejection boundaries, precedence, unknown modes,
shebang and modeline pinning, reopen behavior, and pathless buffers.
Store a detected major mode on each buffer and expose it through Lua.
Resolve mode-scoped bindings in dispatch, describe-key, and help links,
including exact encoded mode context after entering the help buffer.
Initialize modes once at buffer load, preserve explicit overrides and
clears across switches, and publish the mode through a per-window
statusline provider. Add daemon acceptance for the complete mode lifecycle.
Install strict owned terminal specification parsing, fresh global state tables,
default-name uniquification, durable view/controller lifecycle, and the builtin
terminal command/statusline surface. Route daemon key and mouse input by the
authenticated source and add non-replaying per-frontend BEL baselines.
Co-Authored-By: Claude <noreply@anthropic.com>
Review round 1, findings 2-4 plus doc notes. Finding 1 landed in fd80bcb.
Finding 3 --- spec fields meaningless for the declared type are now
rejected. DEFINE_SPEC_FIELDS whitelists all nine keys for every type and
the kind parser only reads its own arm's fields, so
`{ type = "string", choices = {...} }` silently defined a string that
accepts anything (the author meant enum) and `min` on a boolean was
dropped. These are typo-shaped bugs the R50 whitelist structurally
cannot see: the key is spelled correctly, it is on the wrong type.
`check_fields_relevant_to_kind` closes it with a pointed error naming
the misplaced field, and a companion test pins that each field is still
accepted where it belongs, including `min`/`max` on number as well as
integer.
Finding 4 --- the after_buffer_removed purge had no end-to-end test.
Every existing test called ConfigRegistry::remove_buffer directly, so
deleting the three lines wired into mod.rs would have left the whole
suite green. The new acceptance test kills a buffer through
pmacs.buffer.remove (the real remove_buffer_and_fire route) and asserts
the locals are gone; bite-verified by removing the hunk and watching it
fail.
Finding 2 (the half with a natural buffer) --- editing.trim-on-save is
now resolved against the buffer being saved rather than the global
chain. Reading globally meant set_local was accepted, stored, and
reported by describe, then never consulted: a pin the user believes in
that does nothing, which is the shape F1 exists to prevent. Two tests,
one for the override and one for the global fallback the change could
have broken; the override test fails against the old global read.
Both new save tests initially passed VACUOUSLY and were rewritten:
pmacs.editor.save() is the raw save, while buffer.before-save fires
inside the buffer.save COMMAND (default.lua:224), and save() no-ops on
an unmodified buffer --- so the original form asserted on a file that
was never rewritten. They now insert content to dirty the buffer and go
through pmacs.command.invoke("buffer.save").
The other half of finding 2 --- a per-buffer autosave.interval-ms is
semantically meaningless yet still accepted --- is recorded as a named
deferral proposing a define-time `scope = "global"` flag, alongside
deferrals for bound-parse field naming and StartupOnly reset symmetry.
Also recorded: interval_ms(1e30) now raises instead of storing a
nonsense float, an improvement but a real divergence from "the wrapper's
shape stays exactly as it was".
Doc: the module header cited framing revision 2; the shipped doc is
revision 3, whose corrections are what the code implements.
Gates: fmt, clippy -D warnings, --lib (1691), --lib --features crdt
(1865), lua54 backend, config_registry_acceptance (16), editops (72),
autosave (29), PMACS_REQUIRE_GPU=1 pmacs-gpu (109), and the full
workspace sweep (2806 tests, exit 0). git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A third registry beside CommandRegistry and HookRegistry, per
docs/config-registry-framing.md. Unblocks the per-buffer auto-pair
toggle, the first of the five backlog items the missing config surface
was gating.
Substrate (src/config_registry.rs):
* ConfigRegistry keyed by name with definition order preserved, R42
mandatory descriptions, R50 typo detection, duplicate rejection,
and SourceLocation provenance -- the command/hook vocabulary.
* Closed scalar kinds: boolean, integer, number, string, enum. Owned
Rust values; Lua tables, functions and userdata are never stored.
Integer exactness is checked by value, never math.type, so the
luajit and lua54 builds agree.
* Two scopes. get(name, buf) resolves buffer-local -> global ->
default; get(name) with no buffer resolves the global chain only
and never consults an ambient buffer. Buffer-locals live in a
registry-owned side table purged at after_buffer_removed, beside
the keymap purge already there.
* An override is ALWAYS stored, even when equal to the value it
shadows; only value_epoch and listener dispatch key on effective
change. Without this a buffer pinned to the current value stores
nothing and a later global set flips it -- the pin silently never
existed. equal_valued_local_override_is_still_stored_and_shields_buffer
fails against the naive reading.
Bindings (src/lua_bindings/config.rs):
* define/get/set/set_local/reset/is_set/describe/list/on_change.
Spec tables are read raw, so neither an unknown key nor a
metatable-provided value can smuggle a field in.
* Listeners commit inside the borrow, snapshot, drop the borrow, and
only then re-enter Lua -- verified by holding the borrow and
watching the test panic with "RefCell already borrowed". A raising
listener is logged without blocking later ones or rolling back, and
a depth bound turns an accidental cycle into a pointed error.
Listeners persist until explicitly disposed; there is no Gc path,
matching the rest of the codebase.
* StartupOnly freezes off the existing InitCompleteFlag at write
time, so this arc adds no editor.rs call at all.
Adopters, each defining its own key so SourceLocation names the owning
module: editing.auto-pair (pair.lua, read per-buffer against the typed
edit's SOURCE buffer), editing.trim-on-save (editops.lua),
autosave.interval-ms (autosave.lua). No public function is removed or
deprecated, and both migration wrappers keep their legacy coercion --
trim_on_save("yes") still enables, interval_ms(1500.7) still floors to
1500 -- coercing before handing the strict registry a conforming value.
M-x describe-setting renders into *help*, modeled on describe-command.
Framing revision 3 records four defects implementation found in the
document itself: acceptance 30 and 31 contradicted each other; the
planned builtin/runtime/config.lua had nothing to hold and would have
broken the source-location contract had it held the one helper it might
have; F5 asked define to police a call it cannot see, moved to
set_local; and list() ordering was underspecified.
No protocol change; SUPPORTED stays [6..18]. No wire surface. Zero
changes to src/editor.rs.
Gates: fmt, clippy -D warnings, --lib (1683), --lib --features crdt
(1857), the new config_registry_acceptance (13) plus auto_pair (45),
editops (72), autosave (29) and m9_6 (25), m4 --skip basedpyright
(114), PMACS_REQUIRE_GPU=1 pmacs-gpu (109), the lua54 backend build,
and the full workspace sweep (2795 tests, exit 0). git diff --check
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the strict pmacs.statusline provider registry, deterministic
borrow-released per-window evaluation, context-scoped failure latches,
and a pure built-in LSP provider.
Preserve the legacy TUI modeline while composing faced custom runs,
and append authoritative complete StatuslineSegments replacements for
semantic frontends. Expand dynamic ThemeFacts, reset producer/frontend
baselines symmetrically, and gate all provider work off protocol v18.
Teach the GPU to atomically validate, resolve, shape, clip, and cache
custom modeline runs without displacing the protected status suffix.
Document the public Lua lifecycle, wire ownership, snapshot semantics,
and the fully gated Arc 4 stage-3 delivery state.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Drive Red Hat yaml-language-server 1.24.0 through the default YAML
auto-attach path. Disable SchemaStore and the Kubernetes CRD catalog for
network-free determinism, require language-specific initialization and a
real syntax diagnostic, and prove the server remains alive afterward.
Update the framing and runtime commentary with the completed live-provider
evidence. The test passes against the pinned provider and fails against the
pre-JSON/YAML runtime under scripts/bite.
Co-Authored-By: OpenAI Codex <noreply@openai.com>
Preserve PR #123's unpushed review fixes on a transfer branch: initial
didChangeConfiguration delivery, explicit JSON validation, the pinned
JSON server provider, corrected YAML configuration sections, and
deterministic plus real-provider acceptance coverage. Record the
observed yaml-language-server 1.24.0 standalone smoke and leave the
real YAML-through-pmacs test, rebase, and full gates explicitly pending
for the destination machine.
Add tree-sitter-json (0.24) and tree-sitter-yaml (0.7) to
BUILTIN_LANGUAGES (both ABI-current via tree-sitter-language, verified
compiling under tree-sitter 0.26), each self-contained highlights, no
injections of their own. Extensions json=.json, yaml=.yaml/.yml; root
kinds json `document`, yaml `stream`.
The payoff from the #122 injection engine is free: the markdown block
injection query already sets injection.language "yaml" for `---`
frontmatter (minus_metadata) and "toml" for `+++` (plus_metadata), so
registering yaml lights up YAML frontmatter highlighting with no extra
wiring, and ```json / ```yaml / ```yml fences resolve through the engine
(yml->yaml alias already present). Two acceptance tests pin this synergy.
LSP (builtin/runtime/lsp.lua): pmacs.lsp.config.json uses the maintained
extracted-bundle binary `vscode-json-language-server --stdio` (NOT the
stale standalone vscode-json-languageserver); MIT, no telemetry, remote
$schema fetch left enabled (no handledSchemaProtocols). pmacs.lsp.config
.yaml uses `yaml-language-server --stdio` with Red Hat telemetry
disabled by default. Both ship the exact workspace/configuration sections
each server pulls (json+http; yaml+http+redhat.telemetry) present-not-null
so the servers get defaults rather than erroring — the CMake #117 lesson.
Sections derived from server source/docs (neither binary installed on
this build machine to observe live; verify where present). Filetype
fallback entries added. JSON is the standing prerequisite for the Jupyter
.ipynb arc; handoff §6 updated.
Nine acceptance tests (grammar ABI, highlights compile, detection,
grammar<->LSP-key alignment, the two frontmatter/fence synergy proofs,
and the pinned LSP-config sections).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Four review findings + a cleanup bundle.
[P1] Wire flattener was O(spans²) and ran over the WHOLE buffer (the
file-style summary uses a whole-buffer viewport, not the visible one).
Replaced the per-interval full scan with an ordered active-set event
sweep (activate on start, expire on end, fold the active set) — linear
in practice. Added full_buffer_summary_scales_on_large_grammar_file
(1500-line rust) as the perf gate.
[P2] _parse_now used the empty alias map from make_request while
_dispatch snapshotted the registry map, so a `py` fence injected async
but not sync. Snapshot aliases on both paths; pinned by
sync_parse_now_resolves_alias.
[P2] The multi-range inline test used a one-line paragraph, whose block
inline node has no named children (link/emphasis are child-grammar
structures) — one range, so it couldn't falsify multi-range. Replaced
with a multi-line blockquote whose inline node carries a named
block_continuation: content_node_ranges now asserts >1 collected range
and emphasis parses on both lines.
[P2] The layer backstop dropped regions silently; the framing requires
a surfaced warning. run_parse now sets ParseTreeBundle::injection_capped;
syntax.lua's settle tick raises it once per buffer via pmacs.error
(_injection_capped). Added injection_layer_cap_surfaces_and_preserves_root
(drives >4096 fences, asserts the flag + bounded count + intact root).
Cleanup:
- The GPU acceptance test now drives the real StyleSpans full-frame
transform (spans_from_segments, extracted from replace_style_spans)
instead of a hand-rolled sort.
- content_node_ranges excludes NAMED children (documented as a round-1
refinement); framing mechanic #3 / Q#IJ5 updated to match.
- parse_duration doc now says root parse; the markdown entry no longer
describes inline as unhighlighted/future.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Teach the syntax engine that one buffer can hold more than one
language. After the root parse, run the grammar's injections.scm, parse
each embedded region with the injected language, and merge every
layer's highlight spans. First consumer: markdown fenced code + inline
(zero new grammars — the block grammar already ships an injection query
and the injected langs already have grammars from #118).
Engine (src/syntax.rs):
- ParseTreeBundle now holds Vec<Layer> (root layer 0 + injected
children, depth-ascending); installed atomically so the existing
Arc::ptr_eq style gate and highlight cache keep working (Q#IJ1).
- run_parse builds layers on the worker: run injections.scm, resolve
the injected language, compute Vec<Range> (exclude NAMED children,
intersect the parent's ranges), set_included_ranges cold-parse,
recurse — bounded by depth (3), a layer backstop (4096), and a
(lang,ranges) visited guard; any child failure drops that child only
(Q#IJ3/IJ5). LanguageEntry gains injections_query; markdown_inline is
registered (retires the M9.7 block-only floor); markdown/rust carry
injection queries.
- Injected languages resolve off the static BUILTIN_LANGUAGES table
(Send loaders + query sources), preserving lazy loading. Dynamic
fence names go through a case-folded alias map seeded with defaults
and Lua-extensible via pmacs.parse.injection_aliases, snapshotted into
ParseRequest at dispatch so the worker never touches the Rc registry
or a Lua table (Q#IJ2/IJ4). Highlight queries are resolved at settle
(resolve_layer_queries), keeping query compilation main-thread/cached.
Producers:
- SyntaxHighlightView (grid) iterates layers shallow-to-deep so a
deeper layer's styling wins within its region (Q#IJ6/IJ7).
- scoped_style_spans (wire) flattens all layers into DISJOINT effective
spans via a boundary sweep, since the GPU re-sorts spans by start
(replace_style_spans / merge_style_spans) and would otherwise destroy
producer order. The GPU source_color_at consumer is fixed to fold all
covering spans (matching semantic_client's effective_style_at) rather
than returning the first.
Named-children exclusion: content ranges exclude only NAMED children
(matching tree-sitter-md's own inline splitter) — excluding a block
inline node's anonymous text tokens would shred the paragraph into
unparseable fragments.
13 acceptance gates (framing docs/multi-language-injections-framing.md):
layer structure, absolute child offsets, alias resolution (static +
case-folded dynamic + unknown-skip + Lua-async override), multi-range
inline, recursion bounds, wire + grid + GPU producers, incremental edit
/ new fence, many-paragraph settle budget with tail coverage, and the
single-layer regression guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
[P2] The shared JavaScript highlights query guards its builtin captures
(console, require, …) with `#is-not? local`, a PROPERTY predicate
(`Query::property_predicates`) that needs a scope map from the grammar's
LOCALS_QUERY — which pmacs does not run. `compute_highlight_spans` took
every capture, so a locally-shadowed `console`/`require` still surfaced
as `@variable.builtin`/`@function.builtin`; a theme distinguishing
`.builtin` would mis-style the shadowed local.
Full locals processing is substrate work; conservatively fail-closed
instead: drop captures whose pattern carries an `#is?`/`#is-not? local`
property predicate (the identifier falls back to its non-builtin
capture). The text predicates (`#eq?`/`#match?`/`#any-of?`, already
applied by the capture iterator) and `#set!` settings are untouched.
This is a general engine fix — it corrects the same latent mis-styling
for any grammar using the locals predicate, not just JS/TS.
- javascript_shadowed_builtin_is_not_mislabeled: a local `const console`
produces no `*.builtin` capture (directly observed to fail — two
`variable.builtin` captures — before the fix).
[P3] Comments this PR invalidated: `lsp.lua` no longer claims Python has
no grammar; `syntax.lua`'s `_has_language` gate comment uses a
still-grammarless example (an init.lua `shebangs.ruby`) instead of
python/javascript; and the rewritten Ruby shebang test's doc no longer
describes it as a Python test.
Gates: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
cmake-language-server does NOT pull a `workspace/configuration` section:
it reads `buildDirectory` from the `initialize` request's
`initializationOptions`, and drives its project model off CMake's File
API under `<buildDirectory>/.cmake/api/` (not `compile_commands.json`).
The `settings = { cmake = {} }` block — and the documented
`settings.cmake.buildDirectory` override — were therefore inert, leaving
conventional out-of-source project data unavailable.
Replace it with `init_options = { buildDirectory = "build" }` (the
conventional out-of-source dir; users override `init_options.buildDirectory`
from init.lua), and correct the comment. The wiring test now asserts
`config.cmake.init_options.buildDirectory == "build"` — bite-verified
against the pre-fix lsp.lua.
Gates: fmt; clippy -D warnings; --features crdt (1720); m4_acceptance
--skip basedpyright (109); GPU (59); full workspace sweep (zero
failures); git diff --check — all green. One `--lib` run flaked on
process::m6_1_pty_mode_lifecycle_started_then_exited (PTY-lifecycle
timing, the m6/m8 daemon-timing family); it passed in the crdt run, the
full sweep, and 4/4 isolated — unrelated to this Lua config change.
Change is Lua config + the acceptance assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Files identified by their whole basename — Dockerfile, Makefile,
CMakeLists.txt, rc dotfiles — had no detection path (extension-, then
shebang-keyed). Add a filename layer and the three grammars behind it.
- **Grammars** (BUILTIN_LANGUAGES): dockerfile via `tree-sitter-containerfile`
(the ABI-current grammar; the old `tree-sitter-dockerfile` pins
`tree-sitter ^0.20` and would fork the graph — containerfile rides
`tree-sitter-language 0.1`, tree-sitter dev-only, like the others),
make via `tree-sitter-make`, cmake via `tree-sitter-cmake`. All ship
self-contained highlights (single fragment). Extensions:
`.dockerfile`/`.containerfile`, `.mk`/`.make`, `.cmake`.
- **Filename layer**: `pmacs.parse.language_from_filename(name)` backed by
an extensible `pmacs.parse.filenames` map, wired into the precedence
chain in both syntax.lua (grammar) and lsp.lua (LSP): grammar-ext →
filetype map → filename → shebang. A recognized extension still wins;
the basename map only fires when the extension misses. Seeds the three
filenames plus shell rc dotfiles (`.bashrc`/`.zshrc`/`PKGBUILD`/… →
bash) — highlighting them against the grammar shipped in #115.
- **LSP**: `config.dockerfile` (docker-langserver --stdio) and
`config.cmake` (cmake-language-server). Make has no server, so no
`config.make` — grammar highlight only. Extension filetype fallbacks
added for id stability.
Bite-verified acceptance:
- filename_grammars_load_and_parse — each grammar's ABI is accepted by
the tree-sitter 0.26 core and parses a representative snippet without
error (dockerfile/cmake root at source_file, make at makefile).
- builtin_languages_include_dockerfile_make_cmake /
language_for_path_resolves_dockerfile_make_cmake_extensions — entry
presence and extension detection.
- m4_filename_map_resolves_special_files — the basename map (incl. path
form and dotfiles→bash), config.dockerfile/cmake commands, and no
config.make. Bite-verified against pre-feature syntax.lua.
- m4_filename_extensionless_dockerfile_highlights — an extensionless
`Dockerfile` resolves to dockerfile for LSP and gets a dockerfile parse
tree; reachable only via the filename map. Bite-verified.
Gates: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
The attached split-string payload (`-Spython3`, `--split-string=...`) can
itself begin with env options or VAR=value assignments before the
interpreter: `-S-i python3`, `-SFOO=bar python3`,
`--split-string=-u FOO python3`. Rather than taking the payload's first
word as the interpreter, re-inject the attached payload into the token
stream so it flows through the same option / operand / assignment state
machine as a separated payload. Adds the three cases as resolver tests.
Two follow-ups from review, both in builtin/runtime/syntax.lua.
1. [P2] Buffer switching bypassed the pinned grammar. after-edit already
reparsed the pinned language, but the after-switch reattach path
(attach_for_active_buffer) re-resolved from scratch — so open an
extensionless `#!/bin/sh` (bash), edit its shebang to lua, switch away
and back, and the grammar flipped to lua while the LSP side kept its
bash attachment (lsp.lua's after-switch reuses the existing record).
attach_for_active_buffer now reuses the language pinned at first attach
whenever a parse view already exists; only a first-seen buffer
resolves. A language change still needs a close/reopen, matching both
the after-edit behavior and how extensions work.
2. [P2] Attached `env -S`/`--split-string` forms failed. The walk skipped
the whole option token, but for split-string the interpreter rides
inside it: `-Spython3`, `-vSpython3` (after no-operand short flags
i/v/0), and `--split-string=python3` all resolved to nil (the last was
also eaten by the earlier `=` branch). The env walk now extracts the
interpreter from the attached value (`^-[iv0]*S(.+)$` /
`^--split-string=(.+)$`); the separated forms (`-S python3`) still work
by walking on to the next token.
Tests (bite-verified against the round-1 syntax.lua — both fail there;
scripts/bite HEAD builtin/runtime/syntax.lua):
- m4_shebang_edit_keeps_pinned_grammar now adds a switch-away/back cycle
(via pmacs.window.switch_buffer, which fires after-switch
synchronously) and asserts the tree stays bash.
- m4_shebang_resolver_maps_interpreters adds the attached split-string
cases (`-Spython3`, `--split-string=python3`, `-vSpython3`).
Gates: fmt; clippy -D warnings; m4_acceptance --skip basedpyright; GPU;
git diff --check green. Only-known-flake caveat as round 1
(editor::composition_overhead_under_ten_percent perf microbenchmark,
unrelated to this Lua change). Change is Lua-only plus the acceptance
tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Three review findings, all in builtin/runtime/syntax.lua.
1. [P1] Syntax bypassed extension precedence and grammar availability.
attach_for_active_buffer resolved `language_for_path or shebang`, but
language_for_path knows only grammar-backed extensions — so a `.py`
file opening with `#!/bin/sh` fell through to the shebang and got a
bash parse tree, and an extensionless `#!/usr/bin/env python3` script
dispatched "python" (no grammar) and raised "unknown language". A new
resolve_active_language walks the full precedence chain — grammar
extension -> LSP filetype map -> shebang — consulting the shebang only
when the extension is unrecognized (a recognized non-grammar extension
like .py is authoritative). Dispatch is then gated on
pmacs.parse._has_language(lang), so grammarless languages are skipped
silently. The extension parts stay keyed on buf:name() (unchanged from
before), so path-less buffers that resolve a grammar by name — e.g.
generated markdown buffers — are unaffected.
2. [P2] Editing an open script's shebang left parsing/highlighting stale.
The after-edit path re-sniffed the mutable shebang: sh -> python
raised "unknown language" while leaving the old bash tree, and
sh -> lua swapped the parse tree under a highlight overlay still
holding the original grammar's query. Reparse now uses the language
pinned at first attach (parse_lang_by_buffer), never re-resolving —
a language change needs a close/reopen, as it does for extensions.
3. [P2] `env` options with operands were mistaken for interpreters.
`#!/usr/bin/env -u FOO python3` skipped `-u` but took `FOO`. The env
walk now skips the operand of the operand-consuming GNU-env options
(-u/--unset, -C/--chdir, -a/--argv0) before selecting the interpreter.
-S/--split-string stays excluded (its string carries the interpreter).
Tests (bite-verified against pre-fix syntax.lua — each fails without its
fix; scripts/bite HEAD builtin/runtime/syntax.lua):
- m4_shebang_does_not_override_extension now also asserts _has_view is
false (no bash grammar tree for a `.py` + `#!/bin/sh`), not only the
LSP language.
- m4_shebang_extensionless_grammarless_language_is_silent — extensionless
python resolves for LSP, gets no grammar view, and records no error.
- m4_shebang_edit_keeps_pinned_grammar — rewriting a `#!/bin/sh` script's
shebang to lua keeps the bash tree and reports no error.
- m4_shebang_resolver_maps_interpreters — added the env-operand cases
(`-u FOO`, `-C /tmp`, combined).
Gates: fmt; clippy -D warnings; m4_acceptance --skip basedpyright; GPU;
git diff --check all green. The only sweep failure is the pre-existing
editor::composition_overhead_under_ten_percent render microbenchmark
(ratio hovers at the 1.10 cutoff; flakes ~1/3 even isolated single-
threaded, already asserted-off on macOS) — a pure-Rust render loop this
Lua-only change cannot touch. Change is Lua-only plus the acceptance
tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Extension detection missed extensionless scripts — `scripts/deploy`, git
hooks, `configure`, and `scripts/bite` itself — so they got neither
highlighting nor an LSP server. Add a first-line shebang fallback.
- New `pmacs.parse.language_from_shebang(buf)` (builtin/runtime/syntax.lua):
sniffs the first line (capped at 256 bytes), maps the interpreter's
basename to a language, and resolves the `#!/usr/bin/env python3`
indirection (skipping env's own `-S`/flags and `VAR=val` assignments).
Backed by `pmacs.parse.shebangs`, a user-extensible map seeded with the
interpreters pmacs can act on: sh-family -> bash, python* -> python,
node -> javascript, lua* -> lua.
- Wired as a strict *fallback* on both resolution paths: syntax.lua's
grammar attach (`language_for_path or language_from_shebang`) and
lsp.lua's `buffer_language` (grammar -> filetypes -> shebang). A
recognized extension always wins, so a `.py`/`.sh` file is never
re-classified by a stray shebang.
- Cross-language, not shell-only: `#!/usr/bin/env python` /`node` /`lua`
resolve too. Special filenames (`.bashrc`, `Dockerfile`, `Makefile`)
are intentionally deferred until there are grammars behind them.
Bite-verified acceptance (tests/m4_acceptance.rs):
- m4_shebang_resolver_maps_interpreters — the mapping incl. env
indirection and `env -S`; non-shebangs and unmapped interpreters
(ruby) resolve to nil.
- m4_shebang_extensionless_script_resolves_bash — opening an
extensionless `#!/bin/sh` script resolves to bash on BOTH paths:
lsp.lua's `active_buffer_language()` and a settled bash parse tree
(grammar attach). Reachable only via the shebang, since the file has
no extension.
- m4_shebang_does_not_override_extension — a `.py` file opening with
`#!/bin/sh` still resolves to python (extension precedence).
Gates green: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check. Change is Lua-only plus the acceptance tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Shell scripts already had LSP (bash-language-server + shellcheck/shfmt,
wired in builtin/runtime/lsp.lua), but no tree-sitter grammar, so their
text rendered without lexical color. Fill in the missing half.
- Bundle tree-sitter-bash (0.25) as a BUILTIN_LANGUAGES entry. Unlike
cuda, bash's highlights.scm is self-contained (no `; inherits:`
delta), so a single fragment suffices. The crate exports
LANGUAGE/HIGHLIGHT_QUERY over tree-sitter-language 0.1 — shared ABI
crate, no second tree-sitter in the graph.
- Extension set is wider than the `.sh`/`.bash` the LSP filetype map
covered: `.zsh`/`.ksh`/`.ash` are close-enough dialects and `.bats`
is bash. The grammar's language name is `bash`, matching the
`pmacs.lsp.config.bash` key, so opening any of these also auto-attaches
bash-language-server (shellcheck declines zsh, so `.zsh` diagnostics
may be sparse; highlighting is unaffected). lsp.lua's filetype map is
extended to the same set as the belt-and-suspenders fallback.
- Extensionless shebang scripts (`#!/bin/sh`) and rc dotfiles
(`.bashrc`) are intentionally NOT covered: detection is extension-keyed
and shebang/filename sniffing is a separate, deferred feature.
Bite-verified acceptance:
- bash_grammar_loads_and_parses_script — the 0.25 grammar's ABI is
accepted by the 0.26 core (set_language succeeds at runtime) and a
representative script (shebang, set, parameter expansion, function,
if) parses without error, rooting at `program`.
- builtin_languages_include_bash / language_for_path_resolves_bash_
extensions — entry presence and detection across the wider set.
- bash_highlights_compile_with_captures — the self-contained query
compiles against the grammar with real capture classes.
- m4_12_default_bundle_wires_bash — through the loaded runtime,
config.bash targets bash-language-server and both grammar detection
and the filetype fallback resolve the new extensions to `bash`.
Gates green: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Two functional gaps from review:
1. Standalone .cuh files got no clangd AST. clangd selects the
compiler language from the file extension, not the LSP languageId:
it knows .cu (-> -x cuda) but not .cuh, so a header with no compile
command fails with fe_expected_compiler_job. config.cuda now sets
init_options.fallbackFlags = { "-xcuda" }, which supplies -x cuda
for any file this server opens that lacks a compile_commands.json
entry (a real compile command still wins). This CUDA server only
ever serves .cu/.cuh, so the fallback cannot mis-flag C/C++.
2. The CUDA highlights query was only a delta. tree-sitter-cuda's
HIGHLIGHTS_QUERY opens with `; inherits: cpp` and defines only the
CUDA-specific captures (launch brackets, __global__/__device__) —
two capture classes. pmacs does not resolve `inherits:`, so ordinary
C/C++ syntax went unhighlighted. LanguageEntry.highlights_query is
now &[&str] (fragments joined base-first); the cuda entry carries
[c, cpp, cuda], compiling to ~16 capture classes. Fragments are
newline-joined, never bare-concatenated — a fragment can end mid
`; comment`, and abutting the next fragment's first token would
corrupt the query. Existing single-query grammars become one-element
slices (byte-identical effective query; no behavior change).
Tests:
- cuda_highlights_resolve_c_and_cpp_captures — asserts the COMPILED
cuda query carries the C base `@variable` capture and >= 8 capture
classes, not merely a non-empty query (the CUDA delta alone has 2 and
no `variable`, so this fails without the base prepend).
- builtin_languages_include_cuda — now asserts the entry composes the
c + cpp + cuda fragments.
- m4_12_default_bundle_wires_cuda — now asserts
config.cuda.init_options.fallbackFlags[1] == "-xcuda".
Gates green: fmt; clippy -D warnings; test --lib (1515); --features crdt
(1689); m4_acceptance --skip basedpyright (101); GPU (59); full
workspace sweep; git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Opening a .cu/.cuh file previously resolved to no language, so no
server attached and there was no highlighting. Wire CUDA end to end,
mirroring the existing C/C++ path:
- Bundle tree-sitter-cuda (0.21) as a new BUILTIN_LANGUAGES entry
claiming .cu/.cuh, with its own HIGHLIGHTS_QUERY. A dedicated grammar
rather than reusing cpp: the C++ grammar errors on the
<<<grid, block>>> kernel-launch syntax. The crate rides
tree-sitter-language 0.1 (its tree-sitter dep is dev-only), so it
shares the ABI crate with the other grammars — no second tree-sitter
in the graph.
- pmacs.lsp.config.cuda targets clangd (the same binary that serves
C/C++; language_id "cuda" so clangd enters its CUDA parse mode), and
.cu/.cuh filetype fallbacks map to "cuda" to keep the LSP id stable
if the grammar is ever dropped. LspStyleView layers clangd's CUDA
semantic tokens on top, exactly as for C/C++.
Bite-verified acceptance:
- cuda_grammar_loads_and_parses_kernel_launch — proves the 0.21
grammar's ABI is accepted by the 0.26 core (set_language succeeds at
runtime, which the compile step cannot confirm) and that the entry
wired the CUDA grammar, not a cpp fallback: the <<<...>>> launch
parses without error, whereas the cpp grammar reports an error on the
same source (verified out of band).
- builtin_languages_include_cuda / language_for_path_resolves_cuda_
extensions — entry presence and .cu/.cuh detection.
- m4_12_default_bundle_wires_cuda — config.cuda targets clangd and the
filetype + grammar detection resolve to "cuda" through the loaded
runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan