Commit Graph

138 Commits

Author SHA1 Message Date
Levi Neuwirth 6cd78f0870 test(font): wire acceptance suite + protocol/design docs (items 2-8, 15)
tests/gpu_font_acceptance.rs (items 2-7; the header manifest routes
item 1 to src/protocol.rs pins, item 8 to the src/frontend.rs TUI
drop unit, items 9-14/16-19 to pmacs-gpu's headless suite, and item
15 to the docs):

- 2: a fresh attachment's first frame carries the REAL (None, None)
     default; unchanged ticks are silent; a late joiner receives the
     current preference without post-attach mutation
- 3: a mid-session set_font emits exactly one FontFacts on the next
     frame; an identical re-set advances the epoch without emitting
- 4 (producer half): on_buffer_snapshot_sent re-ships buffer facts
     but never the bufferless FontFacts
- 5: real-daemon probe -- a v17 semantic session receives FontFacts,
     a v16 peer never does (crdt feature)
- 6: the strict Lua contract -- 5.999/72.01/0/-16/NaN/inf/non-number
     sizes error naming `size`; empty/non-string family errors
     naming `family`; unknown keys rejected by name; hostile
     __index/__pairs metatables never invoked and never inject
     values; quantization pins 15.994->1599 / 15.996->1600 and both
     boundaries; set_font {} resets both axes; the getter returns a
     fresh quantized plain table; every rejected shape leaves the
     preference and the wire untouched
- 7: a load_user_config_at fixture's init.lua set_font lands in the
     handle installed before user config, and a pre-attach
     preference ships on the first frame

Bites (mutate, observe the acceptance test fail, restore): the
Option-seeded first-frame send, the payload compare, the snapshot
survival of the font baselines, the v17 gate (BOTH halves widened --
producer for_peer alone leaks nothing because the daemon skip arm
still filters; the wire test only fails when belt AND braces are
cut), the unknown-key rejection, the range-check-original ordering,
and the raw_get metatable isolation.

Docs (item 15): docs/semantic-frontend-protocol.md gains the
FontFacts variant entry (v17 gate, authoritative default, no-pixels
preference relay, fail-closed receiver) and folds FontFacts into the
BufferSnapshot reset-contract paragraph (bufferless facts survive;
the frontend's caret-follow scroll residual is the buffer-scoped
part). docs/pmacs-gpu-design.md's "future customization needs no
wire-protocol changes" claim is corrected in place and points at the
framing as design of record; the Lua-override bullet now names the
landed set_font shape. The framing status bumps to implemented.

Also re-homes the ProcessSupervisor doc comment that the make_font_pref
insertion had orphaned onto the wrong function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-18 11:46:44 +01:00
Levi Neuwirth bc0922bea2 test(m4): keep injection work outside root parse gate 2026-07-15 14:50:45 +01:00
Levi Neuwirth 325dcd553f fix(injections): PR #122 round 2 — sibling precedence, observable cap, docs
Two follow-ups + doc cleanup.

[P2] Same-depth sibling precedence was reversed. The wire priority was
(depth, capture_order), omitting the layer ordinal, so two overlapping
spans from different sibling layers tied — and the active-set insert
then applied the later one first, making the earlier sibling win, the
opposite of the grid's layer-by-layer paint. Priority is now
(layer_index, capture_order): layer_index is the depth-ascending
position in bundle.layers, so a deeper layer AND a later same-depth
sibling both override, matching the grid exactly. New
flatten_same_depth_sibling_later_layer_wins pins it.

[P2] Cap surfacing had no end-to-end test. Added
injection_cap_surfaced_once_and_rearms_via_lua, which drives the real
Lua settle path (syntax.lua tick -> _injection_capped -> pmacs.error)
and asserts surfaced-once, suppressed-on-unchanged-reparse, and
re-armed-after-dropping-below-then-exceeding-the-cap.

Docs:
- Q#IJ6 now states the accurate bound O(n log n + Sum active) for the
  event sweep, not O(boundaries).
- The full-buffer perf test is renamed/narrowed to guard the FLATTENER
  regression; the summary's per-line dominant-style tally is a separate
  pre-existing O(lines x spans) loop, not claimed linear.
- Framing #9 now matches the test: it drives spans_from_segments (the
  extracted replace_style_spans transform) + source_color_at, not a live
  State render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-15 12:07:21 +01:00
Levi Neuwirth 79d75a29e0 fix(injections): PR #122 round 1 — sweep flatten, sync aliases, real multi-range, surfaced cap
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
2026-07-15 12:07:21 +01:00
Levi Neuwirth 4282b1c333 feat(injections): multi-language injection layers
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
2026-07-15 12:07:21 +01:00
Levi Neuwirth 8ce2e9cb0f
Merge pull request #120 from levineuwirth/theme-faces
feat(themes): named UI faces + ThemeFacts channel (protocol v16) — Arc 4 stage 1
2026-07-15 10:30:31 +00:00
Levi Neuwirth b75d45b1d4 perf(themes): PR #120 round 5 -- O(1) frozen counts via store totals
The round-4 freeze read counted the retained for_uri vector inside
status_facts_msg -- correct, but StatusFacts runs at frame cadence
for every semantic session under the shared store mutex, so a long
stale interval cost O(frames x diagnostics x sessions).
DiagnosticStore now maintains per-URI severity totals alongside
by_uri: set replaces them (an empty publication removes them with
the vector), clear removes them, and mark_stale deliberately
preserves both -- entries exist exactly when by_uri entries do, and
set/clear are the store's only by_uri mutators. status_facts_msg
reads the tuple in O(1), and the all-URI severity_totals sum reuses
the cached tuples.

Store unit (acceptance item 34) pins the invariant: all four totals
replace correctly, survive staleness, and clear with the diagnostic
vector; empty_set_clears_uri asserts the totals drop too. The
rounds 3-4 freeze acceptance passes unchanged -- behavior parity,
so the unit pin is the evidence (no runtime bite exists for a
behavior-preserving refactor). Round-5 implementation
user-authored; this commit folds it with framing revision 9, the
protocol doc's O(1) note, and the acceptance manifest pointer to
the item-34 unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-15 11:19:03 +01:00
Levi Neuwirth 3d6336a912 fix(themes): PR #120 round 4 -- freeze sourced from the store
A session first rendering during diagnostic staleness emitted zero
counts: round 3's frozen_diag_counts cache was per-session and only
seeded from fresh computations, so a late joiner attaching mid-edit
(or a buffer first visited between didChange and fresh diagnostics)
had no entry and fell back to (0, 0), contradicting the documented
"frozen counts, never zeros" contract.

mark_stale (T M11.8) keeps the last published diagnostic vector --
only the positions are invalid -- so status_facts_msg now counts
the retained for_uri entries even while stale: the retained entries
ARE the frozen value. The per-session cache is deleted; sourcing
the freeze from the store means there is no session state to lose
to a snapshot reset and no history needed at attach, so the round-3
reset-survival property holds by construction and its round-trip
test passes unchanged against the new mechanism.

Acceptance item 33 marks a populated store stale BEFORE the
SemanticRenderState exists and asserts the first frame reports the
preserved counts; runtime bite vs pre-fix semantic_render.rs.
Framing revision 8; the protocol doc's freeze sentence now says
store knowledge, including the late-joiner case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-15 11:07:19 +01:00
Levi Neuwirth 2fe6738d68 fix(themes): PR #120 round 3 -- GPU snapshot symmetry, count freeze
Finding 1: the round-2 reset contract was asymmetric. The producer
resets search/menu/status baselines on every snapshot send, but the
GPU's BufferSnapshot arm only cleared spans, decorations,
adornments, summary, and the completion popup -- a menu or search
open at switch time survived the snapshot with no close message
ever coming (the new buffer's first CLOSED state is suppressed
daemon-side), leaving a stale popup that also held
daemon_intercepts_keys true and swallowed pointer events
indefinitely. The arm now clears search_prompt, menu, and
status_facts; the minibuffer is deliberately exempt on both sides
(one global core instance, matching the producer's surviving
last_minibuffer baseline). GPU test opens search + menu + status
via the real wire arms, applies a snapshot, and asserts all three
clear, the intercept gate releases, and the popup pixels vanish --
hand-bitten by disabling the three clears (fix and test share
main.rs).

Finding 2: the round-2 reset broke the diagnostic-count freeze.
last_status was both the peer emission baseline and the
stale-store freeze source, so a snapshot between didChange and
fresh diagnostics re-shipped StatusFacts with zeroed counts. The
freeze source now lives apart: frozen_diag_counts advances on every
fresh count, is read when the store is stale, and survives
on_buffer_snapshot_sent -- which keeps killing the emission
baseline to force the re-send. Acceptance renders (1,1), marks the
store stale, applies the reset, and asserts the re-sent StatusFacts
still carries (1,1); runtime bite vs pre-fix semantic_render.rs
fails exactly as predicted (Some((0,0)) vs Some((1,1))).

Framing revision 7; acceptance items 31-32; the protocol doc's
snapshot-reset paragraph now lists the full frontend drop set and
names the count freeze as daemon knowledge, not peer state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-15 10:49:10 +01:00
Levi Neuwirth d91ff1a9e2 fix(themes): PR #120 round 2 -- snapshot/baseline reset contract
Finding 1: a BufferSnapshot wipes the frontend's buffer-scoped
render state (spans, decorations, adornments, minimap summary,
completion popup), but the producer's per-buffer emission baselines
survived the switch -- on an unchanged A -> B -> A round trip,
last_summary[A]'s key still matched and the daemon emitted nothing,
so the frontend never regained A's themed minimap (or A's
StatusFacts: the band kept B's name) until an edit, republish, or
theme mutation happened to move the key.

The fix is the general contract, not a minimap special case:
SemanticRenderState::on_buffer_snapshot_sent(buffer_id) kills every
buffer-scoped baseline for that buffer (spans + style gate,
decorations, adornments, summary, status, search/menu prompts,
completion popup), called wherever the daemon writes a snapshot --
the active-buffer-follow path and the F29 upgrade broadcast; the
attach bootstrap constructs its session state fresh. Deliberately
surviving: the bufferless ThemeFacts pair, the global minibuffer
baseline, the per-frontend gutter mode, the revision-keyed diag
line cache, and other buffers' baselines.

Evidence: a producer round-trip acceptance test (themed summary and
StatusFacts return at the SAME generation; identical payload), a
real-daemon wire test driving A -> B -> A via dispatched keys
(runtime bite: times out against pre-fix daemon.rs), a Rust unit
pinning the reset's scope, and a GPU test where the re-shipped
summary restores the first visit's pixels exactly (frontend half --
no GPU code change, coverage only). The semantic_render.rs bite is
compile-fail (the hook is absent pre-fix), disclosed as weaker.
The protocol doc's composition section now states the snapshot
reset contract on both sides of the wire.

Finding 2: the acceptance-suite manifest header now lists the true
item split (1-19, 24-26, 28-29 here; 20-23, 27, 30 in the GPU
suite). Framing revision 6 folds the round; acceptance items 28-30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-15 10:13:20 +01:00
Levi Neuwirth 3083458cb0 fix(themes): PR #120 round 1 -- minimap cache, __index holes, v15 face leak
Finding 1: accepting a FileStyleSummary drops the GPU minimap vertex
cache -- theme recolors and diagnostic republishes arrive at an
unchanged generation, and the cache keys only on (generation, dims,
scroll), so stale strokes survived until an edit/resize/scroll. The
daemon payload-suppresses identical summaries, so the invalidation
is precise. GPU test drives two same-generation summaries;
hand-bitten by reverting the single invalidation line (script-bite
is vacuous here: fix and test share main.rs).

Finding 2: lua_to_style propagates every Table::get error -- the
lookups run __index, so a raising metatable previously parsed as an
all-default style and the merge SUCCEEDED, committing valid siblings
against the Q#TH6 all-or-nothing contract. Boolean fields keep Lua
truthiness by design (mlua bool), so only raising lookups fail the
transaction. Acceptance reproduces the reviewer's trap shape;
runtime bite vs pre-fix mod.rs.

Finding 3: SemanticRenderState::for_peer records the negotiated
version; below v16 no ThemeFacts is produced and no ui.diag.* face
folds into the FileStyleSummary marks -- the summary is an ungated
pre-v16 channel, and a v15 peer must not get face-derived minimap
colors while its other severity surfaces stay unthemed. The summary
cache key zeroes its face-epoch component for such peers. Acceptance
drives v15/v16 producers side by side; compile-fail bite disclosed
(the test needs for_peer, absent pre-fix).

Finding 4: framing revision 5 weakens the canonical-severity claim
to what is true -- the daemon-RESOLVED color is canonical, while the
GPU's built-in squiggle/sign/counter defaults are historical bright
RGBs that differ from the minimap's converted Indexed marks, a
pre-existing divergence kept because unset faces must render
byte-identically to before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 19:36:31 +01:00
Levi Neuwirth 665fd82860 fix(highlight): PR #118 round 1 — drop locals-predicate captures; stale comments
[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
2026-07-14 17:45:09 +01:00
Levi Neuwirth bcec61e020 feat(highlight): grammars for python/go/typescript/javascript/toml/zig
These five languages already had LSP configs (basedpyright, gopls,
tsserver, taplo, zls) but shipped no tree-sitter grammar, so they
rendered with no lexical color. Fill the gap — 8 BUILTIN_LANGUAGES
entries across 6 crates:

- python (`tree-sitter-python`, root `module`), go (`tree-sitter-go`),
  toml (`tree-sitter-toml-ng`), zig (`tree-sitter-zig`, +`.zon`) — each a
  single self-contained highlights query.
- JavaScript/TypeScript family: `tree-sitter-javascript` parses both
  `.js` and `.jsx`; `tree-sitter-typescript` ships two grammars
  (LANGUAGE_TYPESCRIPT, LANGUAGE_TSX). The four entries — javascript,
  javascriptreact, typescript, typescriptreact — mirror the LSP filetype
  map so tsserver enables the JSX parser. Highlights inherit: the TS
  query is a ~5-capture delta over JavaScript and JSX is a further
  delta, so the entries compose base-first (js → jsx → ts), the same
  pattern as `cuda` over C/C++ (typescript resolves ~22 capture classes,
  typescriptreact ~24).

Each grammar's name equals its existing `pmacs.lsp.config.<name>` key,
so grammar detection (which wins over the filetype map) resolves the id
the server keys off — the file now gets BOTH highlighting and the right
server. No lsp.lua change needed. All crates ride `tree-sitter-language
0.1` with tree-sitter dev-only — no second core in the graph.

Bite-verified acceptance:
- gap_grammars_load_and_parse — each grammar's ABI accepted by the 0.26
  core; a snippet parses without error at its root (covers both TS
  grammars, incl. JSX).
- typescript_highlights_compose_the_javascript_base — the compiled
  typescript/typescriptreact queries resolve >= 15 captures, not just the
  ~5-capture TS delta (the JS base is really composed in).
- builtin_languages_include_gap_grammars /
  gap_grammar_extensions_resolve — entry presence + extension detection
  across all 8 ids.
- m4_gap_grammars_align_with_lsp_configs — through the loaded runtime,
  each path's grammar id matches an existing LSP config. Bite-verified
  against pre-feature src/syntax.rs.

Ripple: two #116 shebang tests used python as their "has-LSP-but-no-
grammar" example, which this PR invalidates. Updated both — the .py +
`#!/bin/sh` precedence test now asserts a python grammar tree (not "no
tree"), and the grammarless-language-is-silent gate test switches to
`ruby` (genuinely grammarless) via a test-local shebang mapping.

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
2026-07-14 17:29:54 +01:00
Levi Neuwirth ff2ce0f197 fix(lsp): PR #117 round 1 — CMake config via initializationOptions
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
2026-07-14 17:03:05 +01:00
Levi Neuwirth 7646dda583 feat(highlight): filename detection + Dockerfile/Make/CMake grammars
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
2026-07-14 16:40:50 +01:00
Levi Neuwirth 7975eeda87 feat(themes): named UI faces + ThemeFacts channel (protocol v16)
Arc 4 stage 1 (docs/theme-faces-framing.md, revision 4). Faces are
theme entries under the reserved ui/ui.* namespace -- zero new Lua
API. Theme::face() resolves with the dotted-prefix walk but never
falls back to default_style; each face applies owns-surface within
its stage-1 component mask, identical on both frontends.

Substrate: two monotonic theme mutation counters (syntax/face) with
transactional set/merge/clear/default (parse before locking, commit
all-or-nothing, bump from the prior value); the StyleGate and the
minimap summary key on the counters -- fixing the pre-existing bug
where a mid-session pmacs.theme.set never re-shipped StyleSpans --
with the summary gaining payload-equality suppression that still
advances its key on computation.

Wire: InstanceMessage::ThemeFacts appended after CompletionPopup
(postcard discriminants are ordinal; a byte pin guards placement),
PROTOCOL_VERSION 15 -> 16, daemon-gated >= 16, one authoritative
table per attachment (None-seeded baselines), TUI silent-drop arm.

Grid: paint_frame resolves ui.modeline / ui.statusline /
ui.minibuffer(.candidate) / ui.gutter / ui.selection faces;
SearchView and DiagnosticView take the theme handle through the real
attachment paths (EditorCore injection, install_diag threading); the
canonical severity color resolves ui.diag.* with the Default ->
built-in policy that keeps the minimap presence encoding sound.

GPU: exact-name face table applied per draw with the Q#TH5 Default
mapping (plain text / window bg, reverse swap), local/peer wash
split, candidate-dropdown glyph site, and the status-band
shaping-cache invalidation without which a diag-face recolor with
constant counts kept stale counter colors.

Tests: 18-test acceptance suite (grid, wire, daemon gate, atomicity,
monotonicity, late join), 7 GPU headless tests incl. decoded vertex
colors, units for the face walk / transactional commits / producer
caches; protocol pins for v16 + the CompletionPopup byte pin.
Bites vs 3cbb9de (scripts/bite): semantic_render.rs (8 runtime test
failures), editor.rs (5 runtime), daemon.rs (v15 gate, runtime);
lua_bindings/mod.rs, pmacs-gpu/main.rs, search.rs, diag.rs, and
highlight.rs bite as compile failures (weaker evidence, disclosed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 16:11:09 +01:00
Levi Neuwirth 7479213c3f fix(highlight): env -S payload is a full arg list, not a bare interpreter
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.
2026-07-14 16:00:47 +01:00
Levi Neuwirth 558d00020f fix(highlight): PR #116 round 2 — pin grammar across switch, attached env -S
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
2026-07-14 15:20:22 +01:00
Levi Neuwirth f300b77533 fix(highlight): PR #116 round 1 — shebang precedence, pinned grammar, env operands
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
2026-07-14 15:02:59 +01:00
Levi Neuwirth 4a60e4c858 feat(highlight): shebang-based language detection for extensionless scripts
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
2026-07-14 14:33:06 +01:00
Levi Neuwirth 6fd7db81fe feat(highlight): shell/bash tree-sitter grammar for the shell family
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
2026-07-14 13:50:21 +01:00
Levi Neuwirth 3cbb9dedd0
Merge pull request #114 from levineuwirth/cuda-lsp
feat(lsp): CUDA support — clangd + bundled tree-sitter grammar
2026-07-14 11:04:09 +00:00
Levi Neuwirth ea3641bba2 fix(lsp): PR #114 round 1 — .cuh AST via fallbackFlags, real C/C++ highlights
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
2026-07-14 11:55:58 +01:00
Levi Neuwirth 11075914f3 feat(lsp): CUDA support — clangd + bundled tree-sitter grammar
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
2026-07-14 10:58:36 +01:00
Levi Neuwirth 7562d83198 fix(compile): make overlay teardown atomic 2026-07-14 10:57:14 +01:00
Levi Neuwirth b6e44f21d6 fix(compile): PR #113 round 7 — validated overlay attachment, registry-only dispose
Finding-by-finding (framing revision 13; bites via scripts/bite
against fe04aa4):

1. attach_style_overlay validates the handle. A handle's translator
   follows edits to ITS buffer only, so attaching it to another
   buffer created a render view showing spans nobody maintains —
   rejected now, with the message naming the recorded owner and
   pointing at add_style_overlay for the target buffer. A disposed
   handle's translator is gone, so re-attachment resurrected
   rendering with frozen coordinates — the disposed state is shared
   across handle clones (FromLua clones) via Arc<AtomicBool> and
   attachment after dispose() fails, pointing at add_style_overlay
   for a fresh handle. Bite: r7f1 pins cross-buffer rejection,
   same-buffer acceptance, dispose-then-attach rejection, and both
   message shapes.
2. dispose() detaches the translator through the always-registered
   SharedRegistry; only the window cleanup rides the optional
   SharedCore. Pre-fix all cleanup lived inside the SharedCore
   branch, so an install-only/headless host got success with the
   translator left attached — paying on every edit for the buffer's
   lifetime. Registry-only unit asserts the buffer's view count
   returns to baseline (and stays there on double dispose); the
   acceptance-crate twin r7f2 builds the same install-only host and
   bites via the mod.rs swap (the in-crate unit vanishes with it).

Gates: fmt; clippy workspace all-targets; lib 1535; crdt lib 1709;
compile acceptance 65; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2526/0; git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 10:48:13 +01:00
Levi Neuwirth fe04aa481b fix(compile): PR #113 round 6 — idempotent split-complete attachment, no-op edit guard, handle disposal
Finding-by-finding (framing revision 12; bites via scripts/bite
against a49adc2):

1. Render-view attachment is idempotent and split-complete. Overlays
   expose overlay_identity (the span store's allocation address);
   Window::ensure_overlay attaches a store-backed render view AT
   MOST once per window — pre-fix every switch into the buffer
   blindly pushed another copy onto EVERY matching window, so
   passive panes accumulated duplicates, each cloning all spans and
   rescanning the buffer per frame. A same-buffer split copies
   clonable overlays to the new pane via clone_for_split (splits
   fire no switch hook and started with an empty overlay list — the
   new compilation pane rendered unstyled). Bites: the acceptance
   test asserts both panes styled with exactly one attachment
   IMMEDIATELY post-split (before any switch could heal the pane
   through the attach-to-all path — the first draft asserted only
   after bouncing and was vacuous against the split fix), then
   re-asserts after three bounce cycles; fails against pre-fix
   editor_core.rs (split half) and pre-fix mod.rs (accumulation
   half) independently. Units pin ensure-once and split-copy/no-copy.
2. The translator ignores pure no-op edits (buffers deliberately
   broadcast empty inserts/deletes for callers that count calls):
   pre-fix each interior no-op split the containing span into two
   adjacent fragments — unbounded list growth for repeated no-ops at
   distinct positions, and a no-op at a UTF-8 continuation byte
   minted a mid-codepoint span boundary. Units now cover genuine
   EditOp::Insert (the round-5 "insertion" unit only replaced) and
   no-ops at five interior positions including the continuation
   byte; the Lua twin (r6f2) bites via the overlay.rs swap — as a
   compile failure, since that file also carries the round-6
   identity machinery (weaker evidence, per the bite script's
   caveat; the in-crate unit pins the behavior directly).
3. StyleOverlayHandleLua retains the buffer and translator ViewId
   and exposes idempotent dispose(): detaches the buffer-attached
   translator (later edits stop paying for it) and removes every
   window render view over the store. Documented lifetime contract:
   one handle per buffer incarnation (the compile/REPL discipline)
   needs no disposal — the buffer's death frees it; repeated
   creation on a long-lived buffer must dispose retired handles.
   Bite: r6f3 (translate → dispose → edit must NOT move the span,
   render views gone, double-dispose safe) fails against pre-fix
   mod.rs.

Gates: fmt; clippy workspace all-targets; lib 1534; crdt lib 1708;
compile acceptance 63; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2523/0 (one m8-class flake, clean on
rerun); git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 10:17:56 +01:00
Levi Neuwirth a49adc2589 fix(compile): PR #113 round 5 — buffer-level span translation, fragment preservation, tracked line start
Finding-by-finding (framing revision 11; bites via scripts/bite
against 6793edc):

1. Style-span coordinate translation belongs to the BUFFER. A new
   BufferStyleSpanTranslator is attached by
   pmacs.buffer.add_style_overlay and sees every edit exactly once —
   bypass writes, undo/redo, remote CRDT ops — independent of window
   count or visibility; the window-attached BufferStyleOverlay
   copies are render-only (on_edit removed). Pre-fix each attached
   view translated the shared store: start_run's explicit attach
   duplicated the after-switch hook's (switch_buffer fires it
   synchronously), so the normal path shifted later spans TWICE per
   byte-delta rewrite, splits multiplied further, and a hidden
   buffer shifted ZERO times. The redundant attach is removed;
   correctness no longer depends on attachment discipline. Bites:
   per-cell rendered assertions active (red a, blue bc, CR, red é →
   é red, b/c blue) and hidden (run finishes with the buffer in no
   window; switch back renders true colors); three direct units pin
   exactly-once with extra render views attached.
2. Translation preserves the untouched fragments of a partially
   overlapped span: left of the replaced range keeps its styling,
   right of it shifts by the length delta, only the rewritten bytes
   lose theirs (the writer styles what it writes; inserted bytes
   inherit nothing). Pre-fix any overlap dropped the WHOLE span —
   red abc, SGR reset, CR, X left bc unstyled; zero translation
   painted the default X red instead. Bite: exact (glyph, fg) cells
   X=default, b/c=red — any_styled_cell cannot see either failure.
3. The per-CR/BS/erase-line whole-prefix scan is gone:
   slot.line_start is tracked — advanced at every \n (append helper
   + the mid-line newline branch), read O(1) by the rewind paths,
   reset on run start/resync/raw marker appends. Measured on 2 MB of
   output + 3000 CR updates (release): 2.52s pre-fix → 0.67s
   post-fix (remainder is fixture-bound; pre-fix cost grows with
   buffer size). No correctness bite is possible for a pure perf fix
   — the committed test pins the tracked value's behavior across
   multi-line appends, batch-boundary CR, repeated CR, erase-line,
   and recovery paths, and passes on both implementations by design.

Gates: fmt; clippy workspace all-targets; lib 1531; crdt lib 1705;
compile acceptance 60; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2517/0; git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 22:07:01 +01:00
Levi Neuwirth 6793edcfc7 fix(compile): PR #113 round 4 — column-counted CR rewrites, alt-screen style resync
Finding-by-finding (framing revision 10; bites via scripts/bite
against b5bbce8):

1. CR rewrites are COLUMN-counted and newline-segmented, not
   byte-counted. Each newline-free segment of a text event consumes
   one existing codepoint per incoming codepoint (codepoints
   approximate columns; double-width and combining characters count
   as one — the documented stance), and LF is not an overwrite
   column: a newline arriving mid-line drops the cursor to a fresh
   line and the stale remainder survives in place (terminal
   semantics). Pre-fix, abcdef\rX\n wrote "X\n" over "ab" — splitting
   the line and leaving "cdef" as a ghost line the parser saw again
   at EOF — and abc\ré ate two ASCII columns because é is two bytes.
   Round-3's UTF-8 invariant holds per-segment: every edit's range
   ends sit on codepoint boundaries, so the rope is valid after each
   step and byte-native CRDT edits never reject. Bites: single-batch
   (shorter rewrite, multibyte-over-ASCII, CRLF), split-feed with the
   é split across batches, and a CRDT twin covering the segmented
   multi-edit replication.
2. Alternate-screen exits resynchronize the effective style. The
   parser now tracks the style the consumer LAST RECEIVED
   (emitted_style; outside alt-screen it always equals
   current_style). An ordinary ?1049l exit emits the resync SetStyle
   whenever suppressed SGR changes drifted the two apart, and
   finish() balances against emitted_style rather than
   current_style — a suppressed SGR reset inside the alt screen left
   the internal style default, so the old comparison saw nothing to
   balance while the consumer stayed red. Consumer-mirror units for
   both drift directions plus the no-drift no-event case; Lua twin
   (r4f2) bites via the ansi.rs swap.

Gates: fmt; clippy workspace all-targets; lib 1528; crdt lib 1702;
compile acceptance 56; crdt acceptance 3; m4 101; m6.4 15; m6.8 8;
GPU 59; workspace sweep 2510/0; git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 17:55:08 +01:00
Levi Neuwirth b5bbce899a test(compile): portable r3f3 fixture — macOS has no /bin/true
The hostile-metatable half of r3f3 spawned /bin/true, which exists on
Linux but not macOS (true lives at /usr/bin/true there), so the spawn
failed with NotFound and the pcall absorbed it — failing the "raw
reads must not trip a raising __index" assert on both macOS flavors.
Use the suite's /bin/sh -c idiom instead. The r1f6 /bin/true specs
stay: their type errors fire in spec parsing before any exec, and the
asserts pin the message text, so the binary there is inert on every
platform.

Bite re-verified: against pre-fix src/lua_bindings/mod.rs the test
still fails at the pgid assert (metatable-provided group=true
honored), so the fixture change keeps its teeth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 17:31:12 +01:00
Levi Neuwirth b76c46603a fix(compile): PR #113 round 3 — UTF-8-safe renderer, observable parser reset, raw spec reads
Finding-by-finding (framing revision 9; bites via scripts/bite):

1. The CR/backspace renderer is UTF-8-safe: overwrite ranges consume
   WHOLE existing codepoints (range end aligned forward past
   continuation bytes) in ONE atomic replace of the complete text
   event — never a split of either side — and backspace steps to the
   previous codepoint boundary; out_pos stays on boundaries by
   induction. Pre-fix, byte-counted splits left malformed bytes on
   the plain rope, and under CRDT the byte-native edit rejected the
   mid-codepoint range, aborting the pump after events_take had
   consumed the batch (terminal event lost, record leaked). Bites:
   default acceptance (é\rX, X\ré, é\bX with exact-content, marker,
   clean-*errors*, baseline asserts) and a CRDT twin that pre-fix
   times out never reaching its exit marker.
2. parser:finish()'s reset is observable: balancing events —
   AlternateScreenExit for an unclosed enter, a default SetStyle for
   a non-default running style (now also cleared; reset() preserved
   it) — let consumers unwind mirrored state from the event stream
   alone. New Rust unit applies events to consumer state; Lua twin
   (r3f2) bites via the ansi.rs swap.
3. stdin/group spec fields are RAW reads: spec tables are plain
   data, metatable-provided fields are deliberately not honored (the
   compile.lua rawget posture), and a raising __index can no longer
   be silently absorbed as group=false, quietly disabling
   process-group isolation. Regression test pins both shapes:
   metatable-provided group=true is ignored (pgid != pid), and a
   hostile raising metatable spawns cleanly.

Gates: fmt, clippy workspace all-targets, lib 1526, crdt lib 1700,
compile acceptance 53, crdt acceptance 2, m4 101, GPU 59, workspace
sweep 2505/0, git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 17:17:02 +01:00
Levi Neuwirth 6902959b09 test(compile): readiness-gate the survivor fixtures — macOS CI race
macOS/luajit failed shutdown_force_kills_outstanding_ledger_groups
with "survivor alive pre-shutdown": on a slow scheduler the leader
(`( trap '' TERM; ... ) & echo $! > pidfile`) can exit before the
backgrounded subshell installs its trap, so the leader-exit
group-TERM kills the "survivor". Linux wins that race consistently;
macOS runners don't. The same race made three sibling tests
vacuously green when it fired (a dead survivor trivially satisfies
"survivor dies" and trivially bounds the drain).

Fix: a shared fixture (survivor_script / survivor_cmdline) writes a
readiness file immediately after `trap` and the leader busy-waits on
it before exiting — the trap is provably installed before any
group-TERM can be sent. Applied to the three process.rs unit
fixtures and the acc08/acc09 acceptance twins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:46:45 +01:00
Levi Neuwirth 50abf91c15 test(compile): portable process probes — macOS CI
Two supervisor unit tests failed on the macOS CI matrix (both Lua
flavors; Linux green):

- pgid_of read /proc/<pid>/stat, which has no macOS equivalent — now
  probes via `ps -o pgid=` (portable, still avoids widening the nix
  feature set with `process` for getpgid).
- the setsid escape-hatch test requires util-linux's setsid(1),
  absent on macOS — now skips per-test when setsid isn't on PATH
  (the m6_5 selective-skip precedent); the escape hatch is a
  Linux-production behavior and the other group-lifecycle tests
  still run everywhere.

Also fixed while here: the acceptance suite's pid_alive was a /proc
existence check, which on macOS made every "descendant is dead"
assertion vacuously TRUE (passing, but toothless) — now a portable
`kill -0` probe, so the group-kill assertions bite on both OSes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:35:32 +01:00
Levi Neuwirth 37fac4324a fix(compile): PR #113 round 2 — rule snapshots, finite indexes, shell isolation, parser reset
Finding-by-finding (framing revision 8; bites via scripts/bite):

1. Rule validation is a stable, total snapshot: validated scalar
   fields are copied into per-run plain tables via raw reads
   (rawget; metatable-provided fields deliberately not honored), so
   post-run mutation of the user's rule objects cannot alter an
   in-flight run and a hostile __index is a counted skip, not an
   error thrown through the pump mid-batch. The container traversal
   is itself pcall-protected; traversal-raise semantics are
   Lua-flavor-dependent (5.2+ ipairs consults __index, LuaJIT reads
   raw) and the test pins both flavors.
2. Capture indexes must be FINITE (floor(math.huge) == math.huge, so
   integrality alone passed it); math.huge is now a counted
   malformed entry.
3. Shell-command never touches the rule table: no spurious
   compile-rule warnings on M-!, and no rule-container state can
   block a run that performs no parsing.
4. AnsiParser::finish() (and parser:finish()) now fully resets the
   parser — in-flight CSI/OSC/escape state and alt-screen
   suppression included — so a post-finish feed parses a fresh
   stream. Three direct unit tests in ansi.rs plus a Lua-driven twin
   in the acceptance suite (the twin exists because a scripts/bite
   file swap replaces the in-file units along with the fix).
5. Comment corrections: fractional capture indexes read a distinct
   absent key (not a neighboring capture); the group-coercion
   comment describes truthiness, not false; the AnsiParserLua
   rustdoc lists finish().

Bites: r2f1 (both shapes), r2f2, r2f3 fail against pre-fix
compile.lua; r2f4 fails against pre-fix ansi.rs. Gates: fmt, clippy
workspace all-targets, lib 1525, crdt lib 1699, compile acceptance
50, crdt acceptance 1, m4 101, GPU 59, workspace sweep 2501/0 (one
flaky-suite rerun per the standing m8 rule), git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:30:41 +01:00
Levi Neuwirth d67d30bb64 fix(compile): PR #113 round 1 — coordinates, recovery, rules, types, EOF
Finding-by-finding (framing revision 7; every fix bite-verified via
scripts/bite against the pre-fix tree):

1. Stored coordinates must be finite integers, and both cursor walks
   are movement-bounded — they clamp at EOF, and the column walk
   clamps at the target row's EOL instead of marching onto later
   rows. An astronomical %d+ capture can no longer hang the editor.
2. The grep panel gains the same immediate buffer.after-edit
   recovery trigger as the compile slots: M-x buffer.undo after a
   COMPLETED search is marked synchronously.
3. The rustc arrow rule uses the framing's ([^:]+) spelling — paths
   with spaces capture whole.
4. All pattern captures are collected (index 4+ reads the real
   capture, not nil-as-column-0); capture indexes must be positive
   integers; a rule naming a column its match didn't produce rejects
   the match.
5. emit_text_raw is module-local — a user global could shadow the
   helper the terminal-event path depends on, and its error consumed
   the terminal event before pump cleanup/forget ran.
6. stdin/group spec fields reject wrong Lua types as hard errors;
   group is matched as a raw Value because mlua's bool conversion
   applies Lua truthiness ("true" would silently coerce).
7. resync also nils the public line_start_byte — total pre-marker
   anchor invalidation includes the byte anchor.
8. The inherited cwd resolves through
   pmacs.instance.identity().working_directory; the header always
   names a real path and relative error files get an explicit base.
9. New AnsiParser::finish() + parser:finish() (additions #5): a
   truncated multibyte sequence at process EOF surfaces as U+FFFD
   before the exit marker instead of vanishing.
10. The built-in default rules are a private deep copy — in-place
    mutations of the public table no longer survive the "using
    built-in defaults" degradation.

Eleven new tests (r1f1a/b–r1f10); bites: 9 fail against pre-fix
compile.lua, r1f2 against pre-fix default.lua, r1f6 against pre-fix
lua_bindings/mod.rs — all clean assertion failures. Gates: fmt,
clippy workspace all-targets, lib 1522, crdt lib 1696, compile
acceptance 45, crdt acceptance 1, m4 101, GPU 59, workspace sweep
2493/0, git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:03:09 +01:00
Levi Neuwirth a2b12dc9d6 docs+test: gate fixes and handoff snapshot (compile-mode in flight)
cargo fmt over the new files; doc-markdown backticks; is_ok_and in
the recompile counter wait; m4_6's M-g n/p pin updated to the Q#CM5
takeover contract (error.next/error.previous with the diag commands
as the dispatchers' fallback — the test's no-attachment status
behavior is unchanged). Handoff §1: main @ 0efb5cd, compile-mode
branch in flight at framing revision 6, themes named as the
standing runner-up.

Gate results on this machine (laptop, basedpyright live): fmt,
clippy --workspace --all-targets, lib 1522, crdt lib 1696,
compile_mode_acceptance 34, compile_mode_crdt_acceptance 1,
m4_acceptance 101 (no skip), PMACS_REQUIRE_GPU gpu 59, workspace
sweep 2482/0, git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 15:23:20 +01:00
Levi Neuwirth 53854ed803 test(compile): acceptance suites — framing items 1-33 dispatch-driven, 35 two-replica
tests/compile_mode_acceptance.rs (34 tests): spawn shape + header +
exit markers; read-only under dispatch; child-boundary stderr merge
in emission order; stdin EOF; group kill/leader-exit/escalation/
ledger bites incl. the redirected TERM-ignoring survivor and the
pipe-holding-descendant tick-latency bound; starter-rule parsing
with 0-based normalization and severity posture; sub-1 fail-closed;
severity override + malformed-rule containers; unterminated final
line; RET/n-p/M-g n/M-g p/C-x ` navigation pins with the diag
fallback; recompile + q-target discipline; supersede baseline; all
seven undo/redo chords table-driven; M-x undo after a completed run
recovering via buffer.after-edit; no-hook shrink and same-length
newline-moving replace with anchor epochs; ANSI SGR/CR with
rendered-cell attachment proof surviving RET-then-M-,; killed-buffer
teardown; grep locations panel, kill-mid-search + masking
prevention, root retention; shell-command M-!; round-trip pins.

tests/compile_mode_crdt_acceptance.rs: a chord-triggered full run
converges byte-identically on two replicas (mid-session generated-
buffer snapshot adoption), and a synthetic accepted replica edit
triggers the immediate recovery marker, converging across the
causal-reorder seam.

Fixes found by the suite: compile.lua's CR handling now scans the
current line start from the buffer (the REPL discipline) instead of
using the per-batch parse position — a same-batch CR previously let
a progress line overwrite earlier output; malformed Lua patterns
are rejected (and counted) at validation time via a probe match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 15:11:43 +01:00
Levi Neuwirth 87e88da024 fix(edit): PR #111 round 1 — scalar-valid UTF-8, per-word capitalize, trim error reporting
Finding 1: codepoint recognition is now full UTF-8 scalar validation
(shared second-byte constraint table: overlongs, surrogates, and
beyond-U+10FFFF all fail), and transpose validates the scalar AT the
cursor trailing-bytes-included — a valid lead with non-continuation
trailing bytes fails closed, as does a length-consistent overlong or
out-of-range span behind the cursor. Zap's single-codepoint check
uses the same validator as defense-in-depth (minibuffer contents
arrive as Rust-side UTF-8; the buffer-facing checks are the
load-bearing ones).

Finding 2: capitalize is per-word across the span — Emacs
capitalize-region parity, verified against Emacs 30.2 ("hello WORLD"
-> "Hello World", "9abc a9bc" -> "9abc A9bc"); the one remaining
deviation is named and pinned: `_` is a word constituent in this
pack's ASCII class, so "foo_bar" -> "Foo_bar" versus Emacs's
"Foo_Bar".

Finding 3: an unexpected error caught by the trim-on-save outer
pcall is no longer discarded — it reports on the status line AND the
*errors* buffer via pmacs.error (the autosave sweep convention),
both pcall'd, still never vetoing the save.

All three fixes bite-verified: the five new/updated acceptance cases
fail against the pre-fix editops.lua (72 total now). Framing at
revision 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ
2026-07-12 16:34:18 +01:00
Levi Neuwirth f0a07f41c5 Merge remote-tracking branch 'githubsucks/main' into editops
# Conflicts:
#	docs/agent-handoff.md
2026-07-12 16:33:51 +01:00
Levi Neuwirth 3085195794 test(edit): PR #110 round 3 — pin raw-byte predicate posture and top-level sets guard
Coverage pins only, no code changes.

Finding 1 (low): the predicate's raw-byte posture is now pinned from
the buffer side — `(` typed immediately before a lone 0xFF inserts no
closer. Verified non-vacuous: reverting char_at to nil-on-malformed
makes the test fail (nil reads as end-of-buffer and pairs before the
junk).

Finding 2 (low): the top-level container guard is pinned alongside
the per-entry cases — `pmacs.pair.sets = 42` pairs nothing and leaves
*errors* clean.

Framing synced to revision 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-12 16:20:01 +01:00
Levi Neuwirth ceaeb81386 fix(edit): PR #110 round 2 — UTF-8 well-formedness, source-buffer relevance, non-table sets
Finding 1 (medium): pair entries validate full UTF-8 well-formedness
(Unicode Table 3-7), not just lead-byte length — continuation-byte
shape on every trailing byte, overlong encodings (C0/C1, E0 80-9F,
F0 80-8F), UTF-16 surrogates (ED A0-BF), and beyond-U+10FFFF (F5+,
F4 90+) all disqualify, so "(\xC2x" can no longer inject invalid
bytes as a closer. char_at shares the validator and returns the raw
byte for malformed buffer content: the predicate treats junk as
word-like (no pairing before it), never as EOL. Bite:
malformed_utf8_pair_entries_are_rejected (four ill-formed shapes).

Finding 2 (low): relevance and reporting resolve against the SOURCE
buffer the record names, not whatever buffer a context-switching
command left active. New pmacs.lsp.buffer_language(buf) is the
parameterized primitive (active_buffer_language delegates), backed by
a new buf:path() query on buffer handles. Bites: rust→python `'` now
stays silent; python→rust `'` now reports "source context changed".

Finding 3 (low): non-table set containers degrade
language→default→empty instead of throwing from the after-edit
callback on every keystroke. Bites: a string default pairs nothing
with a clean *errors* buffer; a junk language entry falls back to the
default set.

Framing synced to revision 5 (Q#AP2 well-formedness + container
degradation + source-buffer resolution, Q#AP3 predicate junk-byte
posture, acceptance list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-12 15:54:16 +01:00
Levi Neuwirth 781cd95fe2 feat(edit): editing-conveniences pack (editops)
builtin/runtime/editops.lua: goto-line (M-g g / M-g M-g), case ops
(M-u/M-l/M-c), transpose chars/words (C-t/M-t), zap-to-char (M-z) +
zap-up-to-char, line move/duplicate/join (M-up/M-down/M-^), region
sort/reverse/dedupe, delete-trailing-whitespace + opt-in
trim_on_save. All edits ride the Q#EC2 guarded single-replace
discipline (snapshot, exact effective-triple check, context guard,
right-gravity transformed-cursor repair, unconditional selection
clear); word/case ops are explicit-byte-range ASCII (locale-proof);
transpose-words matches the empirical Emacs 30.2 boundary table.

killring.lua: zap commands join KILL_CHAIN; new exports kill_range
(validated, chain-aware, typed failure returns), break_chain([fid]),
and the Q#EC6 pending-prompt marker (arm/commit; arm-time
abandoned-marker break; kill_push force-fresh on an uncommitted
marker; detach cleanup) closing the silent-session-replacement hole.
Zap guards its origin frontend and re-verifies this_command at
accept time; commit_kill_prompt() reports armament so a consumed
marker fails closed.

editor.rs: editops.lua loader entry before saveplace.lua (the Q#EC9
before-save registration-order contract).

tests/editops_acceptance.rs: 68 dispatch-driven cases — RET/C-g
completed minibuffer sessions, the boundary-state pin, origin-guard
and silent-replacement matrices, the nine-position transpose table,
intercept discipline (reject/transform/context-switch/zero-length
anchor), trim sweep semantics, and trim-on-save veto interactions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ
2026-07-12 15:33:17 +01:00
Levi Neuwirth b0bbc86792 fix(edit): PR #110 round 1 — revision postcondition, relevance gate, strict pair parsing
Finding 1 (medium): the typed-edit record now pins the edited
buffer's revision after the completing edit; typed_edit_finish
re-reads it at dispatch end and drops the record if the command
edited again — a redefined buffer.self-insert that replaces the typed
char (cursor unmoved) no longer leaves a stale-but-clean record, so
`(`-then-replace-with-`[` yields `[`, not `[)`. Bite:
post_insert_mutation_by_the_command_kills_the_record.

Finding 2 (medium): pair-set relevance is established before the
clean/context gates, so a transformed or relocated character outside
the active set stays silent instead of drawing an auto-pair report.
Bite: transformed_non_pair_char_stays_silent.

Finding 3 (medium): split_pair parses EXACTLY two codepoints and
rejects trailing bytes — a "()x" (or "«»x") entry is skipped
entirely, never honored as `(` → `)x`; valid multibyte pairs ("«»")
pair and skip at byte-correct cursors. Bites:
malformed_pair_entries_are_skipped_not_partially_honored,
multibyte_pair_entries_pair_and_skip.

Finding 4 (low): the record-capture seam is gated behind the opt-in
pmacs.pair._capture_records test facility, off by default — no
consumed record is retained in production, restoring the Q#AP9
ephemerality the seam had defeated. Seam-reading tests opt in;
record_capture_is_off_by_default pins the default.

Finding 5 (low): the equal-revision source-context-switch twin is
covered — the fan-out is skipped by the active-buffer revision
compare, pairing fails closed silently, and no report is possible;
the framing scopes the context-change report as best-effort until the
buffer-aware edit epoch lands.

Framing synced to revision 4 (Q#AP2 entry rule, Q#AP3 relevance-first
+ best-effort report scope, Q#AP9 revision postcondition + capture
facility + the dispatch-path intercept borrow note, acceptance list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-12 15:17:33 +01:00
Levi Neuwirth 223e26420b feat(edit): auto-pairing (Arc 2)
Typing an opener inserts the closer with the cursor between; typing a
closer over its twin steps over it. Q#AP1: the nine built-in pair
chars leave both optimistic classifiers (shared charset in
pmacs-protocol) and round-trip through dispatch, so the opener and the
hook's closer are adjacent daemon-peer undo units, dispatch CUA
type-over applies, and skip never paints a transient duplicate.

Q#AP9: exact one-shot typed-edit provenance. EditorCore's
apply_active_edit now returns the effective Edit; the dispatch
fallback arms a per-frontend record (codepoint + requested vs
effective ranges + post-cursor + clean verdict) that insert primitives
complete and the daemon's optimistic CRDT arm builds directly. The
record is takeable exactly once via pmacs.editor.take_typed_edit()
during the one after-edit fan-out, then cleared — paste, programmatic
edits, manual hook runs, nested re-runs, rejected edits, and stale
this_command all observe nil, and transformed / relocated /
context-switched source self-inserts fail closed with a status.

pair.lua (loaded BEFORE lsp.lua — ordering contract in editor.rs):
per-language pmacs.pair.sets with a conservative default (no ' or `),
EOL/whitespace/closer insertion predicate, reactive skip-over-close,
rejected/transformed intercept outcomes with context-guarded
translate-and-clamp cursor repair.

Acceptance: 32 dispatch-driven cases (predicate, skip, per-language
sets, non-typed provenance incl. production-shaped paste, type-over,
undo/redo grain, intercept outcomes on both the source and reaction
edits, context-switch probe, record lifecycle, frontend isolation) +
first-didChange ordering against the fake LSP's sighelp mode via a
new PMACS_FAKE_LSP_CHANGE_SINK replay file. Six two-replica CRDT
cases pin dispatch-route convergence with cursor-between, undo/redo
walking the pair on both replicas, both mixed-history undo models as
named substrate limits, and the optimistic custom-char route
(closer-broadcast-before-opener convergence, degraded cross-peer
undo). TestDaemon gains spawn_with_config for init.lua-extended pair
sets.

Framing: docs/auto-pairing-framing.md (revision 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-11 17:11:56 +01:00
Levi Neuwirth 180343e6e3 fix(edit): PR #109 round 1 — shared search invalidation, daemon anchor clear, bounded indent scan
Finding 1: the empty-anchor optimistic residual was never GPU-only
(the TUI mirror tracks no selection state; its gate checks cursor
freshness/EOL only). The fix moves daemon-side: handle_remote_crdt_op
clears a selection whose anchor equals the pre-edit cursor (= empty)
before applying the source cursor update; nonempty selections stand.
Covers both frontends. The TUI gate's missing type-over check
(nonempty selection at EOL) is a named deferral.

Finding 2: Q#AI8 invalidation is one helper
(search_invalidate_for_edit) invoked from all four edit paths --
apply_active_edit, notify_buffer_edit, and now undo/redo, which
received precise Edits but invalidated nothing. rebuild_views_for is
named as a lower-frequency bypass (deferral).

Finding 3: acceptance matrix trued up -- added active-search
fail-closed + retype recovery, delete translation on both paths,
undo/redo staleness + origin tests; modal contexts narrowed to what
this suite pins (query-replace/menu/completion ride their own
suites).

Finding 4: indent extraction is a forward-chunked scan stopping at
the first non-whitespace byte -- Enter at the end of a giant
minified line no longer materializes the line. Functional pin at
64 KiB.

Both medium fixes are bite-verified (tests fail with the fix
disabled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
2026-07-10 15:46:36 -04:00
Levi Neuwirth 7b5365cfbf feat(edit): auto-indent on newline (Arc 2)
RET now runs edit.newline-and-indent (builtin/runtime/indent.lua):
one insert/replace of "\n" plus the current line's leading whitespace,
copied verbatim and clipped at the split point (Q#AI3). Region RET
stays a single Replace (CUA type-over, one undo step, one CRDT op);
the selection clears after every successful edit (Q#AI4). Fix-up is
snapshot-guarded against context-switching intercepts and repairs the
cursor by right-gravity translation through the effective edit
(Q#AI5). buffer.newline remains the plain-newline escape hatch.

GPU (Q#AI1/Q#AI6): plain Enter is no longer optimistic-eligible --
its classifier arm's premise (byte-identical to a self-insert) died
with the new binding. Enter round-trips like the TUI, which also
makes global and buffer-local RET rebindings (buffer-list visit)
reachable from the GPU frontend.

Substrate fixes that RET would otherwise ship on top of:

- Q#AI8 search staleness: notify_buffer_edit now marks matches stale
  and right-gravity-translates the live session origin, matching
  apply_active_edit; SearchStore::step and search_match_summary fail
  closed while stale (a live search un-sticks on the next pattern
  keystroke, since set() clears staleness).
- Q#AI9 empty selections: insert_char reports success and the
  no-region arm of insert_char_over_region clears a lingering anchor
  only on Ok -- ordinary typing no longer type-overs its own previous
  keystroke after S-Left at BOF, and a rejected insert mutates no
  state.

Acceptance: tests/auto_indent_acceptance.rs (20 dispatch-driven
cases), tests/auto_indent_crdt_acceptance.rs (pending optimistic
input then round-tripped Enter converges on the source replica),
flipped GPU classifier test, and lib tests for the store, core, and
dispatch seams.

Framing: docs/auto-indent-framing.md (five review rounds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
2026-07-10 12:11:05 -04:00
Levi Neuwirth c32eadba8d feat(edit): comment/uncomment toggle on M-; (Arc 2)
New builtin/runtime/comment.lua: `edit.toggle-comment` comments or
uncomments the current line — or every line the region touches — using
the language's line prefix from the public, user-extensible
`pmacs.comment.strings` table (Q#CT3; block comments deferred).
Language detection reuses lsp.lua's grammar+filetypes chain, now
exported as `pmacs.lsp.active_buffer_language()` (the only lsp.lua
touch — one assignment).

Semantics (Q#CT4): uncomment iff every non-blank line already starts
(after its indentation) with the prefix, stripping the prefix plus one
padding space; otherwise comment, inserting `prefix .. " "` at the
minimum indentation of the span's non-blank lines (Emacs comment-region
alignment). Blank lines are skipped in both directions and don't feed
the min-indent; an all-blank span is a status no-op. Mixed spans
comment — the double prefix round-trips, preserving inner
commented-out code.

The whole toggle is ONE buf:replace (Q#CT5): one undo step (no undo
grouping exists — N per-line edits would need N undos), one CRDT op,
and one effective-edit verification with the killring intercept
discipline (pcall'd; a rejection reports rather than throws; any
post-intercept deviation reports and skips the cursor fix-up).

No-region M-; is Emacs `comment-line`, not `comment-dwim`: toggle,
then move to the next line so repeated M-; walks a block (named
deviation; DWIM's append-at-EOL can come later under its own name).
Region toggles clear the selection and land at the span start. The
command boundary substrate provides chain-break and after-edit for
free (Q#CT6) — asserted anyway.

Tests (comment_toggle_acceptance, 14): rust/lua/python prefixes and
exact round-trips; cursor-next-line incl. the no-trailing-newline
clamp; region min-indent alignment + blank-line skip + selection
clear; mixed-span round-trip; region ending at column 0 excludes that
line; unknown-language and pathless-scratch no-ops; ONE undo restores
a multi-line toggle; rejecting/transforming intercepts (cursor fix-up
skipped); after-edit exactly once on both keybound and M-x paths;
C-k, M-;, C-k breaks the kill chain. Fixture editors empty
pmacs.lsp.config so .rs/.py files never spawn real servers.

Gates: fmt; workspace clippy -D warnings; lib 1500; crdt 1672;
comment 14; killring 30; cua 5; completion 9; autosave 29; m4 100
(--skip basedpyright); GPU 58 (PMACS_REQUIRE_GPU=1); full workspace
sweep clean; git diff --check clean.

Framing: docs/comment-toggle-framing.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtRqijWecEzTjPt1B4Nrt5
2026-07-09 22:56:44 -04:00
Levi Neuwirth dd6ec68762 fix(lsp): convert rename/prepareRename positions per position encoding
request_rename and request_prepare_rename sent raw byte columns instead
of routing through outbound_position — the same bug class as the
semantic-range and code-action fixes that just merged (#105). On a
UTF-16 server, a rename at a position past non-ASCII text resolves the
wrong character (or an invalid one) and renames the wrong symbol.

Both single-Position builders now convert. The posecho fake validates
request positions on its rename/prepareRename arms in UTF-16 units, and
the new test drives both requests at byte offset 3 of "éx" (UTF-16
character 2) — both stores filling proves both builders converted.

(Fix authored locally by Levi during the round-5 review; recovered from
the working tree after the #105 merge and landed verbatim, plus a
cargo fmt pass.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:16:06 -04:00
Levi Neuwirth 685547f2a7 fix(lsp): convert semantic-range (and code-action) bounds per position encoding
Addresses the round-5 finding: the whole-document range that serves a
RANGE-ONLY semantic-token provider derived its columns from UTF-8 byte
counts and sent them unchanged — unlike the inlay path, it skipped
outbound_position. A UTF-16 server receives an invalid end character
for non-ASCII text ("é" is two bytes, one UTF-16 unit) and may reject
the request; since /range is a range-only provider's ONLY pull path,
that means no semantic styling at all.

Both bounds of request_semantic_tokens_range now go through
outbound_position. request_code_action had the identical bug (byte
columns, no conversion) and is fixed in the same stroke — same class,
same one-line shape, commented as such.

Fixture: `rangeonly16` fake mode = rangeonly + negotiated UTF-16 +
STRICT UTF-16 bounds validation on /range (fail-closed: a missing
didOpen record or absent uri also rejects, so the fixture can never
pass vacuously). An env-gated PMACS_FAKE_RANGE_SINK records the
received range for debugging. Test opens a file whose last line ends
in non-ASCII and asserts tokens arrive; verified it bites — with the
conversion removed the wire carries the byte column (13 vs the valid
11), the fake rejects, and the test fails.

Honest note: an earlier bite-check in this session produced a vacuous
pass because short, non-unique edit patterns hit the WRONG json! block
(temporarily regressing the inlay conversion and accidentally
converting code-action). The final diff is anchored uniquely and
verified: inlay unchanged (whitespace only), semantic + code-action
converted, bite-check red/green confirmed against the exact lines.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 99;
killring 30; completion 9; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:17:50 -04:00
Levi Neuwirth 625128c139 fix(lsp): range-only providers, completion-accept boundary, exact codepoint classify
Addresses the round-4 findings against the stack (PR #104 portion).

- HIGH range-only semantic-token servers: LSP defines
  semanticTokensProvider.full and .range as optional, INDEPENDENT
  capabilities, but the old any-provider gate sent /full regardless — a
  range-only server rejects it and the swallowed error means no styling,
  ever. Both the auto-pull and the manual command now gate each request
  kind on its own capability: /full (delta under full.delta) when
  negotiated; a range-only provider gets a WHOLE-DOCUMENT /range request.
  New `rangeonly` fake mode (advertises range without full, rejects
  /full) + test proving tokens arrive via the range path.

- MEDIUM completion acceptance left this_command stale: the popup accept
  applies its edit and fires after-edit outside command dispatch, so
  this_command could still read "buffer.self-insert" from the typing that
  raised the popup — a candidate ending in "(" would spuriously
  auto-trigger signature help. Accept now stamps its own boundary
  ("completion.accept"); asserted in the popup acceptance suite.

- MEDIUM GPU shape inference tightened: the 1-4-byte predicate accepted
  a 2-byte "a(" insert (two ASCII codepoints). The classifier now decodes
  the inserted bytes from the post-edit rope and requires the leading
  byte's UTF-8 sequence length to equal inserted_len — exactly one
  codepoint. The daemon unit test now drives an "a(" op and asserts it
  breaks the chain instead of classifying as typing. Exact wire
  provenance on the CRDT op remains the named deferred general fix.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 98;
completion 9; killring 30; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:48:08 -04:00