From 85cb905e97fe2a9b64f835b323be440288f49108 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 19:48:15 -0400 Subject: [PATCH 1/9] docs(folding): frame Arc 6 folding (draft rev 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft framing for the folding arc, committed to the `folding` branch for review before any implementation. Branch is cut from canonical main @ cac4961 (post Vterm Stage 3 #135). The load-bearing finding: the bundled tree-sitter grammars ship no fold query and no folds.scm — they export HIGHLIGHTS/INJECTIONS/LOCALS/TAGS only. The roadmap's "tree-sitter fold ranges" premise is therefore not free, so the fold source is a real decision (Q#FD1). The draft recommends structural node folding (fold the nearest enclosing block-like node spanning >= 2 rows), which reuses the existing parse trees for every grammar and injection layer with zero per-language authoring; indentation folding (grammarless fallback) and curated per-language queries (quality pass) are deferred. FoldState already exists in the protocol, declared but unproduced, with a test pinning that it is never emitted; no frontend consumes it; gutter markers are frontend-derived like the diagnostic sign bars, so no new wire type is needed. Staged like vterm: Stage 1 engine (instance-side fold model + structural source + Lua commands + FoldState production, headless), Stage 2 TUI collapse+gutter, Stage 3 GPU at parity. Numbered decisions Q#FD1-9, three falsifiable bets, named deferrals, and a Stage 1 acceptance list. Awaiting review rounds; bindings (Q#FD4) and the block-kind heuristic (Bet B1) are the two calls flagged for the user. --- docs/folding-framing.md | 250 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/folding-framing.md diff --git a/docs/folding-framing.md b/docs/folding-framing.md new file mode 100644 index 0000000..eef67c8 --- /dev/null +++ b/docs/folding-framing.md @@ -0,0 +1,250 @@ +# Folding — framing (Arc 6) + +**Draft Revision 1 — 2026-07-22. Status: framing only, committed to the +`folding` branch for review; no implementation yet.** Branch `folding` is cut +from canonical `main` @ `cac4961` (Vterm Stage 3 #135 merged, atop tab-width +parity #137 and locals-query #134), so Arc 5's terminal stage is complete and +this scout reflects that tree. Expect one to three findings rounds on this +document before implementation; revise the doc, then implement Stage 1 on this +same branch. + +## 1. Problem and what ships + +Pmacs cannot fold. The `FoldState` wire family was declared in the M11.1 +semantic-frontend design but has never been produced — the producer says in +so many words that "pmacs has no instance-side fold source yet," and a test +pins that `FoldState` is never emitted. + +Arc 6 gives pmacs a fold engine (instance-side fold model + a fold source + +Lua commands), produces `FoldState`, and renders collapsed regions with a +gutter fold marker in both frontends. It is the roadmap's "keystone gutter +rider": it lights up an already-declared wire family, adds the fold-marker +rider beside the existing diagnostic signs, and is a visible feature. + +**Git gutter markers are a SIBLING rider, not this arc.** They ride the same +gutter but need a diff source, which is unrelated to folding. Named as a +deferral (§11), framed separately. + +## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`) + +- **`FoldState { buffer_id, folds: Vec }`** exists in + `pmacs-protocol/src/message.rs`, gated on `semantic_render`, + DECLARED-BUT-UNPRODUCED. Its doc: *"the instance's authoritative fold set as + document facts. Folding is an instance command-semantics concern (Lua can + fold); the visual collapse is a frontend layout concern — the frontend + renders the placeholder and adjusts its own layout."* So the **fold set is + instance-side state** and the **collapse is frontend layout**. +- `semantic_render.rs::block_adornments_and_fold_state_still_never_emitted` + asserts `FoldState`/`BlockAdornments` are never sent (not even empty). Stage + 1 flips this test. +- **`BlockAdornments`** (also unproduced) is the declared home for + "folded-region placeholders." Arc 6 does **not** produce it — the fold + placeholder is frontend-local (Q#FD7). +- **No fold source exists.** The bundled grammars export + `HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — **no fold query and + no `folds.scm`** in any grammar crate. The roadmap's "tree-sitter fold + ranges" is therefore not free; the fold source is the load-bearing decision + (Q#FD1). +- **No frontend consumes `FoldState`.** The TUI drops it in `frontend.rs`'s + ignored set; the GPU has only a debug label. Both must add consumption. +- **Gutter signs are frontend-derived, not a wire channel.** The GPU's + `collect_gutter_sign_rects` computes diagnostic sign bars locally from the + decorations it already holds. Fold markers follow the same model: derived + frontend-locally from `FoldState`; **no new wire type**. +- **Edit-translation is a solved discipline.** Style spans and decorations are + already translated through `translate_byte_range` on every optimistic edit + and reset on `BufferSnapshot`. Fold ranges reuse it, plus an invalidation + rule when an edit destroys a fold's structure (Q#FD6). +- **Greenfield Lua/commands.** No existing fold surface or commands. + +## 3. Fold source (Q#FD1) — the load-bearing decision + +The grammars ship no fold queries, so "what is foldable" must be defined by +pmacs. Three options: + +- **(A) Curated per-language fold queries** (nvim-treesitter's `@fold`-capture + model). Highest quality, matches the tree-sitter investment — but per + language authoring plus ongoing maintenance, and it is exactly the work the + grammars declined to ship. +- **(B) Structural node folding.** At a point, fold the nearest enclosing + NAMED node that spans ≥2 display rows, biased to block-like kinds by a small + shared heuristic on node-kind names (`block`, `body`, `*_list`, + `declaration_list`, `statement_block`, brace/bracket-delimited nodes). + Reuses the parse trees already present for every bundled grammar AND every + injection layer. Zero per-language authoring. +- **(C) Indentation folding.** Fold the maximal run of lines more-indented + than a header line. Language-agnostic, predictable, works with **no grammar + at all** (plain text, unknown languages), but ignores syntax. + +**Recommendation: (B) structural node folding for grammar-backed buffers as +the v1 engine.** It reuses tree-sitter, needs no per-language work, and folds +the whole set of bundled grammars and injections on day one. (C) indentation +folding is the right **grammarless fallback** but is DEFERRED so Stage 1 stays +scoped to grammar buffers; (A) curated queries are a later **quality pass**, +DEFERRED. This is the honest adjustment to the roadmap's premise: tree-sitter +still drives folding, but via node structure rather than queries that do not +exist. + +Open sub-question for review: the block-kind heuristic in (B) is the part most +likely to feel wrong ("it folded the tiny inner block, not the function"). +The bet (§10) is that "nearest enclosing block-like node spanning ≥2 rows" is +predictable enough for v1; the fallback if it isn't is (A) for the handful of +Tier-1 languages. + +## 4. Where fold state lives (Q#FD2) + +Instance-side, per the wire contract. `EditorCore` (or a sibling store) owns a +**per-buffer set of folded byte ranges**. Commands mutate it; the semantic +producer ships it as `FoldState`; each frontend collapses the union of folded +ranges in its own layout. Nested folds are allowed — the store is a set, and +the frontend collapses the union, so a fold inside a fold is just two ranges. + +## 5. Fold model semantics (Q#FD3) + +- **Head-anchored, byte-range folds.** A fold is `[start, end)` where `start` + is the byte at the fold-head line's content and `end` is one past the last + folded byte. The head line stays visible; the interior collapses. +- **Cursor cannot sit inside a fold.** Folding a range that contains point + moves point to the fold head (Emacs `hs-minor-mode` behavior). Editing + commands that would enter a fold either skip it or unfold it — Q#FD5. +- **Edits translate folds** through the existing `translate_byte_range`, and a + fold whose head or tail is destroyed by an edit (e.g. the head line deleted) + is dropped rather than re-anchored (Q#FD6). Dropping is safe: a fold is a + view convenience, never data. + +## 6. Lua command surface (Q#FD4) + +Greenfield, mirroring the comment/kill-ring command style: + +- `fold.toggle` — fold the enclosing foldable region at point, or unfold if + point's line is a fold head. +- `fold.close` / `fold.open` — explicit fold/unfold at point. +- `fold.close-all` / `fold.open-all` — fold every foldable region in the + buffer / clear the fold set. +- `pmacs.fold` Lua surface: `fold(range?)`, `unfold(range?)`, `folds()`, + `toggle()` — so Lua can drive folding (the wire contract's "Lua can fold"). + +Bindings are deliberately left for the review round — Emacs uses `C-x C-z` / +`hs-*` / outline `C-c @`; pmacs has no precedent, so the binding is a decision +for the user, not a default I pick. + +## 7. Frontend collapse + gutter marker (Q#FD7) + +- **The collapse is frontend-local layout.** The frontend receives the fold + set and removes folded byte ranges from what it lays out: the TUI skips the + folded display rows; the GPU excludes the folded bytes from its shaped code + slice. The head line shows a **placeholder** (e.g. `⋯` or ` ⋯ N lines `) — + frontend-drawn, **not** a `BlockAdornment` (Q#FD7 keeps `BlockAdornments` + unproduced). +- **The gutter marker is frontend-derived from `FoldState`**, exactly like the + diagnostic sign bars: a fold-head line draws an open/closed fold glyph in the + gutter. No new wire type. +- **Caret/hit-test cross folds.** Clicking or arrowing across a fold-head skips + the folded bytes; the existing GPU projected↔source maps gain a fold-aware + step. This is the largest per-frontend cost and is why the frontends are + separate stages. + +## 8. Staging and scope + +Mirrors vterm: one useful, independently testable stage per PR. + +- **Stage 1 — fold engine (instance).** The fold model, the structural fold + source, the Lua/command surface, `FoldState` production (whole-buffer, + diff-suppressed, edit-translated, snapshot-reset), and headless acceptance. + No frontend rendering — `FoldState` is asserted on the wire, not on screen. + This is the approval-critical stage. +- **Stage 2 — TUI collapse + gutter marker.** The TUI consumes `FoldState`, + collapses folded rows, draws the gutter fold glyph and the head placeholder, + and makes cursor motion fold-aware. +- **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`, + excludes folded bytes from its shaped slice, draws the fold glyph + caret/hit + fold-awareness, at TUI parity. + +Stages 2–3 are sketched here and **re-framed in detail after Stage 1 lands**, +exactly as vterm did. This framing asks approval for the overall architecture +and Stage 1's full detail. + +## 9. Numbered decisions + +- **Q#FD1** Fold source: structural tree-sitter node folding (v1); indentation + fallback and curated queries deferred. (§3) +- **Q#FD2** Fold state is instance-side, per-buffer, a set of byte ranges; + nested folds allowed. (§4) +- **Q#FD3** Head-anchored byte-range folds; point cannot sit inside a fold; + edits translate, structure-destroying edits drop. (§5) +- **Q#FD4** Command surface `fold.toggle/close/open/close-all/open-all` + + `pmacs.fold`; bindings decided in review. (§6) +- **Q#FD5** Entering a fold: motion skips it; an edit that targets inside an + existing fold unfolds it first. (Detail deferred to Stage 2 framing.) +- **Q#FD6** A fold whose head/tail an edit destroys is dropped, not + re-anchored — folds are view state, never data. (§5) +- **Q#FD7** Placeholder + gutter marker are frontend-local, derived from + `FoldState`; `BlockAdornments` stays unproduced; no new wire type. (§7) +- **Q#FD8** `FoldState` is whole-buffer (folds are sparse and shift line + numbers above the viewport), diff-suppressed (cached-compare), and reset on + `BufferSnapshot` — the established producer discipline. (§8, Stage 1) +- **Q#FD9** Terminal identity buffers (read-only, #135) never fold; the + producer already suppresses the document family in terminal mode, so no + special case is needed. + +## 10. Bets + +- **B1** "Nearest enclosing block-like node spanning ≥2 rows" is predictable + enough for a v1 fold without curated queries. FALSIFIABLE: if the review + finds the fold target surprising on real Rust/Python, fall back to curated + queries for Tier-1 languages. +- **B2** Whole-buffer `FoldState` is cheap enough (folds are a handful, not + O(lines)); no viewport scoping needed. FALSIFIABLE by a fold-all on a huge + file — but fold-all produces one range per block, still far below the style + span volume the producer already ships. +- **B3** The frontend collapse can reuse each renderer's existing + projected↔source machinery (the GPU already has `translate_byte_range` and a + projected-run map for adornments) rather than a new layout engine. + +## 11. Deferred (named) + +- Indentation folding for grammarless buffers (the Q#FD1 (C) fallback). +- Curated per-language fold queries (the Q#FD1 (A) quality pass). +- Persisted folds across sessions (saveplace-style). +- `fold.hide-level N` / outline-style folding by depth. +- Fold-on-open (auto-fold imports/license headers) — needs a policy. +- `BlockAdornments` production (rich placeholders, diff zones, blame bands). +- **Git gutter markers** — the sibling gutter rider; separate diff source, + separate framing. +- Search revealing folds (a match inside a fold auto-unfolds) — Stage 2+. + +## 12. Acceptance — Stage 1 (engine) + +1. Structural fold source: at a point inside a multi-line block, the fold + source returns the enclosing block-like node's byte range; at top level it + returns the enclosing item; in a grammarless buffer it returns nothing + (fallback deferred). +2. `fold.toggle` folds the enclosing region, and toggling on a fold head + unfolds it; `close-all` folds every block-like region, `open-all` clears. +3. Folding a range containing point moves point to the fold head. +4. `FoldState` is produced (the pinned "never emitted" test is replaced by a + "emitted with the current fold set" test), whole-buffer, diff-suppressed + (an unchanged fold set sends nothing), and reset on `BufferSnapshot`. +5. An edit inside a folded range translates the fold; an edit deleting the + fold head drops the fold; both leave a consistent set. +6. Nested folds: folding an inner then an outer region yields two ranges; + `open-all` clears both. +7. The `pmacs.fold` Lua surface drives all of the above and round-trips + `folds()`. +8. A terminal identity buffer never produces `FoldState` (Q#FD9). + +## 13. Gates (Stage 1) + +The standing suite: `cargo fmt --check`; strict workspace Clippy; `cargo test +--lib` and `--features crdt`; the new `tests/folding_acceptance.rs` (default + +CRDT); `cargo test --test m4_acceptance -- --skip basedpyright`; +`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`; the workspace sweep; `git diff +--check`. New behavioral acceptance is bite-verified with `scripts/bite`. + +## 14. Branch and PR plan + +Branch `folding`, worktree `../pmacs-folding`, cut from canonical `main` @ +`cac4961`. This framing is its first commit. After the framing is approved, +Stage 1 is implemented on this same branch and opened as the first folding PR. +Stages 2 and 3 are separate branches/PRs off the main resulting from the prior +stage, each with its own detailed framing. From 55f5a2e6aaa916149236e7ade22808bab860ebfd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 19:58:12 -0400 Subject: [PATCH 2/9] docs(folding): record the folding lane; retire the merged Vterm Stage 3 lane The ledger inherited from #135's merge still listed Vterm Stage 3 as an open 'never merge without authorization' implementation lane; it is merged (main @ cac4961), so per the update protocol it moves to Closed and Arc 5's terminal stage is marked complete. Adds the folding framing lane (branch folding, off cac4961, framing-only) and points the canonical base at cac4961 / protocol v19. --- docs/active-work.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index ef0ac15..287c5c2 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -52,6 +52,34 @@ git status --short --branch The first command must expose `cac4961` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Folding framing lane (Arc 6) + +- Portable branch: `githubsucks/folding`; worktree `../pmacs-folding`. +- Base: canonical `main` @ `cac4961` (post Vterm Stage 3 #135). +- Framing head: `ee6c77f` (`docs/folding-framing.md` draft rev 1). +- State: **framing only, awaiting review then approval.** No implementation. + Load-bearing decision (Q#FD1): the bundled grammars ship no fold query and + no `folds.scm`, so the roadmap's "tree-sitter fold ranges" is not free; the + draft recommends structural node folding (nearest enclosing block-like node + >= 2 rows), with indentation fallback and curated queries deferred. + `FoldState` already exists in the protocol, declared-but-unproduced (a test + pins it is never emitted); no frontend consumes it; gutter markers are + frontend-derived like the diagnostic sign bars, so no new wire type. Staged + like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU. +- PR: none yet — framing is committed to the branch for review, not opened as + a PR. Stage 1 implements on this same branch after approval. +- Next: user review rounds on `docs/folding-framing.md`; bindings (Q#FD4) and + the block-kind heuristic (Bet B1) are the two calls flagged for the user. + +Recovery worktree: + +```sh +git worktree add --track \ + -b folding \ + ../pmacs-folding \ + githubsucks/folding +``` + ## Parked lane: kill-ring browser + persistence - Portable branch: `githubsucks/kill-ring-browser` @@ -88,6 +116,12 @@ git worktree add --track \ ## Closed since the last snapshot +- **Vterm Stage 3 (protocol v19 + GPU terminal) — MERGED as #135** (`main` + @ `cac4961`, 2026-07-22, after two review rounds). Arc 5's terminal stage + is complete (compile mode #113, Stage 1 #126, Stage 2 #130, Stage 3 #135). + Its lane, worktree (`../pmacs-vterm-gpu`), and branch are done; durable + substrate facts live in `docs/agent-handoff.md` and `docs/vterm-framing.md`. + - **Branches deleted 2026-07-22 (authorized):** `vterm-stage3-framing` (Revision 8 framing; its content is carried on `vterm-gpu`, verified as a superset before deletion — the branch was NOT an ancestor of `vterm-gpu` From b7a865d12045b56dbffbfb2b9c4f1d92ad080e6a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 20:36:01 -0400 Subject: [PATCH 3/9] =?UTF-8?q?docs(folding):=20framing=20rev=202=20?= =?UTF-8?q?=E2=80=94=20address=20review=20round=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings from the first review, all fixed: F1 (architectural): the grid TUI is daemon-rendered and never receives FoldState (advertises semantic_render:false), so its fold collapse is instance-side work in the daemon grid renderer reading the store directly — the vterm Stage 2 shape. FoldState on the wire serves only semantic (GPU) sessions. Staging reworked: Stage 2 = grid/daemon rendering, Stage 3 = wire-fed GPU. F2: stored range pinned to the line-aligned hidden interior (head line visible, closing-delimiter line hidden); one normalized form everywhere, resolving the R1 5/7 contradiction. F3: the store's edit-translation is the instance-side buffer-attached View (BufferStyleSpanTranslator pattern, overlay.rs:235), which sees every real edit — not the frontend-side translate_byte_range. Split the two resets: per-session producer baseline vs per-buffer store lifecycle on content replacement. F4: stale-tree fold creation refuses with a message when ParseViewHandle::current() is None or pending_edit_count() > 0. F5: multi-frontend point + edit-vs-fold pinned — invoking point moves to the head; interactive-point-inside edits unfold, programmatic/remote edits translate; the no-cursor-inside invariant is creation-time-only in Stage 1. F6: Lua data API takes an explicit buffer (no ambient resolution, per #127) with full range validation, which is also what makes terminals never fold. F7: FoldState follows the authoritative-empty discipline; open-all emits exactly one empty frame; the flipped pin test keeps asserting BlockAdornments is never emitted. Minors: source lines not display rows; close-all folds top-level only (hs-hide-all parity); an injected-layer (markdown fence) acceptance added; and an explicit note that FoldState needs no protocol bump. --- docs/folding-framing.md | 467 ++++++++++++++++++++++++++-------------- 1 file changed, 305 insertions(+), 162 deletions(-) diff --git a/docs/folding-framing.md b/docs/folding-framing.md index eef67c8..149819e 100644 --- a/docs/folding-framing.md +++ b/docs/folding-framing.md @@ -1,12 +1,48 @@ # Folding — framing (Arc 6) -**Draft Revision 1 — 2026-07-22. Status: framing only, committed to the -`folding` branch for review; no implementation yet.** Branch `folding` is cut -from canonical `main` @ `cac4961` (Vterm Stage 3 #135 merged, atop tab-width -parity #137 and locals-query #134), so Arc 5's terminal stage is complete and -this scout reflects that tree. Expect one to three findings rounds on this -document before implementation; revise the doc, then implement Stage 1 on this -same branch. +**Revision 2 — 2026-07-22. Status: framing only, on branch `folding` +(off canonical `main` @ `cac4961`); no implementation.** Revision 1 passed +a ground-truth review (every scouted claim verified against the tree) but the +reviewer found one architectural mis-framing and six spec gaps. Revision 2 +fixes all of them; see §0 for the changelog. + +## 0. Revision 2 — review round 1 resolutions + +- **F1 (architectural).** R1's Stage 2 said "the TUI consumes `FoldState`." + It cannot: the grid TUI advertises `semantic_render: false` + (`src/frontend.rs:385`), so the per-session outgoing filter never sends the + `FoldState` family to it, and the TUI has no layout of its own — the daemon + renders its cell grid (`render_states` vs `semantic_states`, + `src/daemon.rs:875`/`881`; the grid path is `render_state.render_frame(...)` + at `:1106`). **The TUI collapse is instance-side rendering in the daemon + grid renderer, reading the fold store directly — no wire.** `FoldState` on + the wire serves ONLY semantic (GPU) sessions. Staging reworked accordingly + (§8): Stage 2 is grid/daemon rendering; Stage 3 is the wire-fed GPU. +- **F2.** Stored range semantics pinned: the store holds **line-aligned + interior** ranges (Q#FD3, §5); the head line stays visible, the interior + including the closing-delimiter line is hidden. §7 rewritten to match. +- **F3.** The fold store's edit-translation is the **instance-side + buffer-attached `View`** (`BufferStyleSpanTranslator` pattern, + `src/overlay.rs:235`), which sees every real edit regardless of source — not + the frontend-side `translate_byte_range`. The two "resets" are split: + per-session producer baseline vs per-buffer store lifecycle (Q#FD8, §5). +- **F4.** Stale-tree fold creation pinned: read `ParseViewHandle::current()` + (`src/syntax.rs:696`); if it is `None` or `pending_edit_count() > 0` + (`:706`), **refuse with a status message** in v1 (Q#FD10, §3). +- **F5.** Multi-frontend point + edit-vs-fold rules pinned (Q#FD3, Q#FD5, §5): + the invoking frontend's point moves to the head; interactive-point-inside + edits unfold, programmatic/remote edits translate; the invariant is + creation-time-only in Stage 1. +- **F6.** Lua surface takes an **explicit buffer** (no ambient resolution, + per #127), with full range **validation** (Q#FD4, Q#FD11, §6) — which is + also what makes Q#FD9 (terminals never fold) hold. +- **F7.** `FoldState` follows the authoritative-empty discipline; the + non-empty→empty transition (open-all) emits exactly one empty frame + (Q#FD8, acceptance 4). +- **Minors.** "≥2 source lines" not display rows; `close-all` folds + top-level only (Emacs `hs-hide-all` parity, feeds B2); an injected-layer + fold acceptance added; and an explicit note that `FoldState` needs **no + protocol bump**. ## 1. Problem and what ships @@ -16,45 +52,60 @@ so many words that "pmacs has no instance-side fold source yet," and a test pins that `FoldState` is never emitted. Arc 6 gives pmacs a fold engine (instance-side fold model + a fold source + -Lua commands), produces `FoldState`, and renders collapsed regions with a -gutter fold marker in both frontends. It is the roadmap's "keystone gutter -rider": it lights up an already-declared wire family, adds the fold-marker -rider beside the existing diagnostic signs, and is a visible feature. +Lua commands), renders collapsed regions with a gutter fold marker in both +frontends, and produces `FoldState` for semantic (GPU) sessions. It is the +roadmap's "keystone gutter rider": it lights up an already-declared wire +family, adds the fold-marker rider beside the existing diagnostic signs, and +is a visible feature. + +**`FoldState` needs no protocol bump.** The variant has been in the wire +encoding since M11.1 and both frontends already decode it (the TUI drops it, +the GPU has a decode arm); Arc 6 only starts *producing* it. No +`PROTOCOL_VERSION` change, no `SUPPORTED` change. **Git gutter markers are a SIBLING rider, not this arc.** They ride the same -gutter but need a diff source, which is unrelated to folding. Named as a -deferral (§11), framed separately. +gutter but need a diff source, unrelated to folding. Named as a deferral +(§11), framed separately. -## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`) +## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified in review) - **`FoldState { buffer_id, folds: Vec }`** exists in - `pmacs-protocol/src/message.rs`, gated on `semantic_render`, - DECLARED-BUT-UNPRODUCED. Its doc: *"the instance's authoritative fold set as + `pmacs-protocol/src/message.rs:886`, gated on `semantic_render`, + DECLARED-BUT-UNPRODUCED. Doc: *"the instance's authoritative fold set as document facts. Folding is an instance command-semantics concern (Lua can - fold); the visual collapse is a frontend layout concern — the frontend - renders the placeholder and adjusts its own layout."* So the **fold set is - instance-side state** and the **collapse is frontend layout**. -- `semantic_render.rs::block_adornments_and_fold_state_still_never_emitted` - asserts `FoldState`/`BlockAdornments` are never sent (not even empty). Stage - 1 flips this test. + fold); the visual collapse is a frontend layout concern."* +- `semantic_render.rs:4002` + (`block_adornments_and_fold_state_still_never_emitted`) asserts + `FoldState`/`BlockAdornments` are never sent. Stage 1 flips this to assert + `FoldState` IS produced while `BlockAdornments` stays unemitted (F7). - **`BlockAdornments`** (also unproduced) is the declared home for "folded-region placeholders." Arc 6 does **not** produce it — the fold placeholder is frontend-local (Q#FD7). - **No fold source exists.** The bundled grammars export - `HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — **no fold query and - no `folds.scm`** in any grammar crate. The roadmap's "tree-sitter fold - ranges" is therefore not free; the fold source is the load-bearing decision - (Q#FD1). -- **No frontend consumes `FoldState`.** The TUI drops it in `frontend.rs`'s - ignored set; the GPU has only a debug label. Both must add consumption. + `HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — no fold query and + no `folds.scm` (`LanguageEntry` has exactly those fields). The roadmap's + "tree-sitter fold ranges" is not free; the fold source is Q#FD1. +- **Two frontend render paths, not one (F1).** The grid TUI is daemon-rendered + (`render_states` → `render_state.render_frame`, `src/daemon.rs:1106`) and + advertises `semantic_render: false` (`src/frontend.rs:385`), so it never + receives `FoldState`. The GPU is a semantic session (`semantic_states` → + `sem.render_frame`, `:1091`) and does receive it. Fold collapse is therefore + daemon-side for the TUI and wire-fed for the GPU. - **Gutter signs are frontend-derived, not a wire channel.** The GPU's - `collect_gutter_sign_rects` computes diagnostic sign bars locally from the - decorations it already holds. Fold markers follow the same model: derived - frontend-locally from `FoldState`; **no new wire type**. -- **Edit-translation is a solved discipline.** Style spans and decorations are - already translated through `translate_byte_range` on every optimistic edit - and reset on `BufferSnapshot`. Fold ranges reuse it, plus an invalidation - rule when an edit destroys a fold's structure (Q#FD6). + `collect_gutter_sign_rects` computes diagnostic sign bars locally; the TUI's + are painted daemon-side. Fold markers follow the same model per path — no new + wire type. +- **Instance-side edit translation is a solved pattern (F3).** Compile-mode's + `BufferStyleSpanTranslator` (`src/overlay.rs:235`) is a buffer-attached + `View` that sees every real edit — commands, CRDT ops, LSP workspace edits, + Lua — once per edit, fragment-preserving. The fold store attaches the same + kind of `View`. The frontend-side `translate_byte_range` + (`pmacs-gpu/src/main.rs`) is a *different* thing (the GPU translating its own + received copies across optimistic edits) and is not the store's mechanism. +- **Staleness is detectable (F4).** `ParseViewHandle::current()` + (`src/syntax.rs:696`) returns `None` before the first settle and the latest + settled bundle otherwise; `pending_edit_count()` (`:706`) is nonzero while + edits await settle. - **Greenfield Lua/commands.** No existing fold surface or commands. ## 3. Fold source (Q#FD1) — the load-bearing decision @@ -63,151 +114,225 @@ The grammars ship no fold queries, so "what is foldable" must be defined by pmacs. Three options: - **(A) Curated per-language fold queries** (nvim-treesitter's `@fold`-capture - model). Highest quality, matches the tree-sitter investment — but per - language authoring plus ongoing maintenance, and it is exactly the work the - grammars declined to ship. + model). Highest quality — but per-language authoring plus ongoing + maintenance, exactly the work the grammars declined to ship. - **(B) Structural node folding.** At a point, fold the nearest enclosing - NAMED node that spans ≥2 display rows, biased to block-like kinds by a small - shared heuristic on node-kind names (`block`, `body`, `*_list`, - `declaration_list`, `statement_block`, brace/bracket-delimited nodes). - Reuses the parse trees already present for every bundled grammar AND every - injection layer. Zero per-language authoring. -- **(C) Indentation folding.** Fold the maximal run of lines more-indented - than a header line. Language-agnostic, predictable, works with **no grammar - at all** (plain text, unknown languages), but ignores syntax. + NAMED node that spans **≥2 source lines** (F-minor: source lines, not + display rows — soft wrap is frontend layout and unknowable instance-side), + biased to block-like kinds by a small shared heuristic on node-kind names + (`block`, `body`, `*_list`, `declaration_list`, `statement_block`, + brace/bracket-delimited nodes). Reuses the parse trees already present for + every bundled grammar AND every injection layer. Zero per-language authoring. +- **(C) Indentation folding.** Language-agnostic, works with no grammar, + predictable, but ignores syntax. **Recommendation: (B) structural node folding for grammar-backed buffers as -the v1 engine.** It reuses tree-sitter, needs no per-language work, and folds -the whole set of bundled grammars and injections on day one. (C) indentation -folding is the right **grammarless fallback** but is DEFERRED so Stage 1 stays -scoped to grammar buffers; (A) curated queries are a later **quality pass**, -DEFERRED. This is the honest adjustment to the roadmap's premise: tree-sitter -still drives folding, but via node structure rather than queries that do not -exist. +the v1 engine.** Reuses tree-sitter, no per-language work, covers all bundled +grammars and injection layers day one. (C) is the grammarless fallback, +DEFERRED so Stage 1 stays scoped to grammar buffers; (A) is a later quality +pass, DEFERRED. -Open sub-question for review: the block-kind heuristic in (B) is the part most -likely to feel wrong ("it folded the tiny inner block, not the function"). -The bet (§10) is that "nearest enclosing block-like node spanning ≥2 rows" is -predictable enough for v1; the fallback if it isn't is (A) for the handful of -Tier-1 languages. +**Stale-tree rule (Q#FD10, F4).** The source reads +`ParseViewHandle::current()`. If it is `None` (no settle yet) or +`pending_edit_count() > 0` (the settled tree's coordinates are stale relative +to the current buffer), a fold command **refuses with a status message and +stores nothing** — a fold is durable state and must not be computed against +stale coordinates. Settle is sub-frame, so the refuse window is tiny. +Translate-the-node-range-through-pending-edits is a named refinement (§11). + +The block-kind heuristic (B) is the part most likely to feel wrong ("it folded +the tiny inner block, not the function"); Bet B1 (§10) states it and names the +fallback (curated Tier-1 queries). ## 4. Where fold state lives (Q#FD2) -Instance-side, per the wire contract. `EditorCore` (or a sibling store) owns a -**per-buffer set of folded byte ranges**. Commands mutate it; the semantic -producer ships it as `FoldState`; each frontend collapses the union of folded -ranges in its own layout. Nested folds are allowed — the store is a set, and -the frontend collapses the union, so a fold inside a fold is just two ranges. +Instance-side, per the wire contract. A **per-buffer fold store** (a set of +byte ranges) lives beside the buffer, attached as a `View` (F3). Commands +mutate it; the daemon grid renderer reads it directly to collapse the TUI +(Stage 2); the semantic producer ships it as `FoldState` to GPU sessions +(Stage 3). Nested folds are allowed — the store is a set, consumers collapse +the union. -## 5. Fold model semantics (Q#FD3) +The store is **shared by every attached frontend** (Emacs parity): folds are a +document-level view fact, not per-window. Per-cursor consequences of that +sharing are pinned in §5 (F5). -- **Head-anchored, byte-range folds.** A fold is `[start, end)` where `start` - is the byte at the fold-head line's content and `end` is one past the last - folded byte. The head line stays visible; the interior collapses. -- **Cursor cannot sit inside a fold.** Folding a range that contains point - moves point to the fold head (Emacs `hs-minor-mode` behavior). Editing - commands that would enter a fold either skip it or unfold it — Q#FD5. -- **Edits translate folds** through the existing `translate_byte_range`, and a - fold whose head or tail is destroyed by an edit (e.g. the head line deleted) - is dropped rather than re-anchored (Q#FD6). Dropping is safe: a fold is a - view convenience, never data. +## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6) -## 6. Lua command surface (Q#FD4) +**Stored range = line-aligned interior (Q#FD3, F2).** A fold is identified by +its **head line** H. The stored/shipped byte range is the **hidden interior**: +from the newline that terminates H through the end of the last source line the +folded region spans. So: -Greenfield, mirroring the comment/kill-ring command style: +- H (with its opener, e.g. `fn foo() {`) **stays visible**, with a + frontend-drawn ellipsis at its end. +- The interior lines **and the closing-delimiter line** (`}`) are **hidden** + — the range ends at the end of the line containing the region's last byte. +- The structural source yields a raw node span `[node.start, node.end)`; the + store **normalizes** it to this line-aligned interior before anything else + (renderer, wire, `folds()`) sees it. One normalized form, one meaning, + everywhere — resolving the R1 §5/§7 contradiction. + +**Point and folds (Q#FD3, F5).** +- Folding a range that contains the **invoking frontend's** point moves that + point to the head line H (Emacs `hs-minor-mode`). +- The store is shared, so **another** frontend's cursor may already sit inside + a newly folded range. "A cursor cannot sit inside a fold" is a **per-cursor, + render-time** invariant: on that frontend's next frame the caret clamps to H + (a Stage 2/3 rendering concern). In **Stage 1** there is no motion-awareness + (deferred to Stage 2/3), so the invariant is **creation-time-only**: folding + moves the invoking point out, but later motion re-entering a fold is not yet + prevented. Acceptance is written to that scope so it cannot self-contradict. + +**Edits and folds (Q#FD5, Q#FD6, F5).** The store's buffer-attached `View` +(F3) sees every edit: +- An **interactive edit at the invoking frontend whose point is inside a + fold** unfolds that fold first — you cannot type into hidden text you cannot + see. +- A **programmatic or remote edit** (a peer CRDT op, an LSP workspace edit, a + Lua buffer edit) **translates** the fold through the `View`, keeping it + folded — it is not a person typing into the hidden region. +- A fold whose head or tail an edit **destroys** (e.g. the head line deleted, + or the range collapses below one hidden line) is **dropped**, not + re-anchored — a fold is view state, never data. + +**Store lifecycle vs producer baseline (Q#FD8, F3).** Two distinct resets, +previously conflated: +- The **per-session producer suppression baseline** resets on `BufferSnapshot` + so the fold set is re-shipped to a (re)joining semantic session — the + established producer discipline. +- The **per-buffer fold store** is dropped or revalidated on buffer **content + replacement** (revert/reload): the ranges describe bytes that no longer + exist, so revert clears the store (revalidation against the new content is a + §11 refinement). + +## 6. Lua command surface and validation (Q#FD4, Q#FD11) + +**Interactive commands** (resolve to the invoking frontend's active-window +buffer — command context, not ambient resolution): - `fold.toggle` — fold the enclosing foldable region at point, or unfold if point's line is a fold head. - `fold.close` / `fold.open` — explicit fold/unfold at point. -- `fold.close-all` / `fold.open-all` — fold every foldable region in the - buffer / clear the fold set. -- `pmacs.fold` Lua surface: `fold(range?)`, `unfold(range?)`, `folds()`, - `toggle()` — so Lua can drive folding (the wire contract's "Lua can fold"). +- `fold.close-all` / `fold.open-all` — fold every **top-level** foldable + region (Emacs `hs-hide-all` parity — nested regions are not auto-folded; + see B2) / clear the fold set. -Bindings are deliberately left for the review round — Emacs uses `C-x C-z` / -`hs-*` / outline `C-c @`; pmacs has no precedent, so the binding is a decision -for the user, not a default I pick. +**Data API (Q#FD4, F6): explicit buffer, no ambient resolution** (matching +#127's deliberate refusal of ambient-buffer lookup): + +- `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`, + `folds(buffer) -> {range,...}`, `toggle(buffer, pos)`. + +**Validation (Q#FD11, F6).** `fold(buffer, range)` validates and rejects +otherwise: the buffer exists and is a normal document buffer; the range is +in-bounds; both endpoints are UTF-8 char boundaries; the range normalizes +(§5) to **at least one hidden line**. This validation is what makes Q#FD9 +hold: a terminal identity buffer is empty, so every range is out-of-bounds and +rejected — no fold can be stored on a terminal even from Lua, with no special +case. + +Bindings are left for this review round — Emacs uses `C-x C-z` / `hs-*` / +outline `C-c @`; pmacs has no precedent, so the binding is the user's call. ## 7. Frontend collapse + gutter marker (Q#FD7) -- **The collapse is frontend-local layout.** The frontend receives the fold - set and removes folded byte ranges from what it lays out: the TUI skips the - folded display rows; the GPU excludes the folded bytes from its shaped code - slice. The head line shows a **placeholder** (e.g. `⋯` or ` ⋯ N lines `) — - frontend-drawn, **not** a `BlockAdornment` (Q#FD7 keeps `BlockAdornments` - unproduced). -- **The gutter marker is frontend-derived from `FoldState`**, exactly like the - diagnostic sign bars: a fold-head line draws an open/closed fold glyph in the - gutter. No new wire type. -- **Caret/hit-test cross folds.** Clicking or arrowing across a fold-head skips - the folded bytes; the existing GPU projected↔source maps gain a fold-aware - step. This is the largest per-frontend cost and is why the frontends are - separate stages. +Two paths (F1): + +- **Grid TUI — daemon-rendered.** The daemon grid renderer reads the fold + store directly and omits each fold's hidden interior from the cells it + paints, showing H with an ellipsis; it draws the gutter fold glyph on H. + No wire, same shape as vterm Stage 2's daemon-painted terminal cells. +- **Semantic GPU — wire-fed.** The GPU receives `FoldState`, excludes the + hidden bytes from its shaped code slice, shows H with an ellipsis, and draws + the fold glyph on H. Caret/hit-test gain a fold-aware step (the largest + per-frontend cost, and why the GPU is its own stage). + +In both paths the placeholder is **frontend-local** (an ellipsis / ` ⋯ N +lines `), **not** a `BlockAdornment` — Q#FD7 keeps `BlockAdornments` +unproduced. The gutter marker is derived from the fold set per path, like the +diagnostic sign bars — no new wire type. ## 8. Staging and scope -Mirrors vterm: one useful, independently testable stage per PR. +Mirrors vterm; reworked for F1 (the TUI path is daemon-side, not a wire +consumer). -- **Stage 1 — fold engine (instance).** The fold model, the structural fold - source, the Lua/command surface, `FoldState` production (whole-buffer, - diff-suppressed, edit-translated, snapshot-reset), and headless acceptance. - No frontend rendering — `FoldState` is asserted on the wire, not on screen. - This is the approval-critical stage. -- **Stage 2 — TUI collapse + gutter marker.** The TUI consumes `FoldState`, - collapses folded rows, draws the gutter fold glyph and the head placeholder, - and makes cursor motion fold-aware. +- **Stage 1 — fold engine (instance), headless.** The per-buffer fold store + + its buffer-attached translating `View`; the structural fold source with the + stale-tree rule; the Lua data API + interactive commands + validation; + `FoldState` production for semantic sessions (authoritative-empty, + diff-suppressed); and headless acceptance. No rendering — folds are asserted + in the store and on the wire, not on screen. **Approval-critical.** +- **Stage 2 — grid (daemon-rendered) collapse + gutter marker.** The daemon + grid renderer collapses folded interiors and draws the TUI gutter fold glyph + + head placeholder; caret handling clamps to H. Instance-side rendering + work; no wire change. - **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`, - excludes folded bytes from its shaped slice, draws the fold glyph + caret/hit - fold-awareness, at TUI parity. + excludes folded bytes from its shaped slice, draws the fold glyph and makes + caret/hit-test fold-aware, at TUI parity. -Stages 2–3 are sketched here and **re-framed in detail after Stage 1 lands**, -exactly as vterm did. This framing asks approval for the overall architecture -and Stage 1's full detail. +Stages 2–3 are sketched here and re-framed in detail after Stage 1 lands. +This framing asks approval for the architecture and Stage 1's full detail. ## 9. Numbered decisions -- **Q#FD1** Fold source: structural tree-sitter node folding (v1); indentation - fallback and curated queries deferred. (§3) -- **Q#FD2** Fold state is instance-side, per-buffer, a set of byte ranges; - nested folds allowed. (§4) -- **Q#FD3** Head-anchored byte-range folds; point cannot sit inside a fold; - edits translate, structure-destroying edits drop. (§5) -- **Q#FD4** Command surface `fold.toggle/close/open/close-all/open-all` + - `pmacs.fold`; bindings decided in review. (§6) -- **Q#FD5** Entering a fold: motion skips it; an edit that targets inside an - existing fold unfolds it first. (Detail deferred to Stage 2 framing.) +- **Q#FD1** Fold source: structural tree-sitter node folding (v1); + indentation fallback and curated queries deferred. (§3) +- **Q#FD2** Fold state is instance-side, per-buffer, a set of ranges, shared + by all frontends; nested folds allowed. (§4) +- **Q#FD3** Stored range is the line-aligned hidden interior (head line + visible, closing-delimiter line hidden); the invoking point moves to the + head; "no cursor inside a fold" is a per-cursor render-time invariant, + creation-time-only in Stage 1. (§5) +- **Q#FD4** Interactive commands `fold.toggle/close/open/close-all/open-all` + (invoking frontend's active buffer); data API `pmacs.fold.*` takes an + explicit buffer, no ambient resolution; bindings decided in review. (§6) +- **Q#FD5** Interactive edit with point inside a fold unfolds it first; + programmatic/remote edits translate the fold. (§5) - **Q#FD6** A fold whose head/tail an edit destroys is dropped, not - re-anchored — folds are view state, never data. (§5) -- **Q#FD7** Placeholder + gutter marker are frontend-local, derived from - `FoldState`; `BlockAdornments` stays unproduced; no new wire type. (§7) -- **Q#FD8** `FoldState` is whole-buffer (folds are sparse and shift line - numbers above the viewport), diff-suppressed (cached-compare), and reset on - `BufferSnapshot` — the established producer discipline. (§8, Stage 1) -- **Q#FD9** Terminal identity buffers (read-only, #135) never fold; the - producer already suppresses the document family in terminal mode, so no - special case is needed. + re-anchored. (§5) +- **Q#FD7** Placeholder + gutter marker are frontend-local per path; the TUI + path is daemon-rendered, the GPU path wire-fed; `BlockAdornments` stays + unproduced; no new wire type. (§7) +- **Q#FD8** `FoldState` (to semantic sessions only) is whole-buffer, + authoritative-empty (initial empty suppressed until a fold exists; unchanged + suppressed; non-empty→empty emits exactly one empty frame), and its + per-session baseline resets on `BufferSnapshot`. The per-buffer STORE is a + separate lifecycle, dropped on buffer content replacement. (§5, §8) +- **Q#FD9** Terminal identity buffers never fold — guaranteed by validation + (empty buffer ⇒ out-of-bounds ⇒ rejected), not a special case. (§6) +- **Q#FD10** Fold creation against a `None` or stale + (`pending_edit_count() > 0`) parse tree refuses with a message; no fold is + stored. (§3) +- **Q#FD11** Explicit-`fold(buffer, range)` validates buffer kind, bounds, + UTF-8 boundaries, and ≥1 hidden line; rejects otherwise. (§6) ## 10. Bets -- **B1** "Nearest enclosing block-like node spanning ≥2 rows" is predictable - enough for a v1 fold without curated queries. FALSIFIABLE: if the review - finds the fold target surprising on real Rust/Python, fall back to curated - queries for Tier-1 languages. -- **B2** Whole-buffer `FoldState` is cheap enough (folds are a handful, not - O(lines)); no viewport scoping needed. FALSIFIABLE by a fold-all on a huge - file — but fold-all produces one range per block, still far below the style - span volume the producer already ships. -- **B3** The frontend collapse can reuse each renderer's existing - projected↔source machinery (the GPU already has `translate_byte_range` and a - projected-run map for adornments) rather than a new layout engine. +- **B1** "Nearest enclosing block-like node spanning ≥2 source lines" is + predictable enough for a v1 fold without curated queries. FALSIFIABLE: if the + review finds the fold target surprising on real Rust/Python, fall back to + curated queries for Tier-1 languages. +- **B2** Whole-buffer `FoldState` is cheap: folds are a handful, and + `close-all` folds **top-level only** (Q#FD4), so the set is O(top-level + blocks), far below the style-span volume the producer already ships. No + viewport scoping. +- **B3** The two collapse paths reuse existing machinery: the daemon grid + renderer already paints cells from instance state (vterm Stage 2), and the + GPU already has a projected↔source map for adornments — neither needs a new + layout engine. ## 11. Deferred (named) -- Indentation folding for grammarless buffers (the Q#FD1 (C) fallback). -- Curated per-language fold queries (the Q#FD1 (A) quality pass). +- Indentation folding for grammarless buffers (Q#FD1 (C)). +- Curated per-language fold queries (Q#FD1 (A)). +- Translate-a-node-range-through-pending-edits so fold creation need not refuse + on a stale tree (Q#FD10 refinement). +- Fold-store revalidation against new content on revert/reload (Q#FD8: v1 + drops the store). - Persisted folds across sessions (saveplace-style). -- `fold.hide-level N` / outline-style folding by depth. -- Fold-on-open (auto-fold imports/license headers) — needs a policy. +- `fold.hide-level N` / outline-style folding by depth; auto-fold-on-open. - `BlockAdornments` production (rich placeholders, diff zones, blame bands). - **Git gutter markers** — the sibling gutter rider; separate diff source, separate framing. @@ -215,23 +340,41 @@ and Stage 1's full detail. ## 12. Acceptance — Stage 1 (engine) -1. Structural fold source: at a point inside a multi-line block, the fold - source returns the enclosing block-like node's byte range; at top level it - returns the enclosing item; in a grammarless buffer it returns nothing - (fallback deferred). -2. `fold.toggle` folds the enclosing region, and toggling on a fold head - unfolds it; `close-all` folds every block-like region, `open-all` clears. -3. Folding a range containing point moves point to the fold head. -4. `FoldState` is produced (the pinned "never emitted" test is replaced by a - "emitted with the current fold set" test), whole-buffer, diff-suppressed - (an unchanged fold set sends nothing), and reset on `BufferSnapshot`. -5. An edit inside a folded range translates the fold; an edit deleting the - fold head drops the fold; both leave a consistent set. -6. Nested folds: folding an inner then an outer region yields two ranges; +1. **Structural source.** At a point inside a multi-line block, the source + returns the enclosing block-like node normalized to its line-aligned + interior; at top level it returns the enclosing item; in a grammarless + buffer it returns nothing (fallback deferred). +2. **Stale/absent tree (Q#FD10).** With `current() == None`, and with + `pending_edit_count() > 0` after an edit before settle, `fold.toggle` + refuses and stores nothing; after settle it succeeds. +3. **Commands.** `fold.toggle` folds the enclosing region and unfolds on a + fold head; `close-all` folds every top-level block-like region (nested not + auto-folded); `open-all` clears. +4. **Range semantics (Q#FD3).** The stored/shipped range is the line-aligned + interior: the head line's bytes are outside it, the closing-delimiter line + is inside it; `folds(buffer)` returns exactly the normalized ranges. +5. **Point (Q#FD3).** Folding a range containing the invoking point moves it + to the head; the creation-time-only scope holds (Stage 1 does not prevent + later motion into a fold). +6. **Edits (Q#FD5/Q#FD6).** An interactive edit at a point inside a fold + unfolds it; a programmatic edit inside a fold translates it; an edit + deleting the head drops it; each leaves a consistent set. +7. **`FoldState` production (Q#FD8, F7).** The flipped pin test asserts all + three transitions to a semantic session — nothing until a fold exists, + nothing when unchanged, exactly one empty frame after `open-all` — while + `BlockAdornments` is still never emitted; the per-session baseline resets on + `BufferSnapshot`. +8. **Store lifecycle.** Buffer content replacement (revert) drops the store. +9. **Nested folds.** Folding an inner then an outer region yields two ranges; `open-all` clears both. -7. The `pmacs.fold` Lua surface drives all of the above and round-trips - `folds()`. -8. A terminal identity buffer never produces `FoldState` (Q#FD9). +10. **Injected layer (§3 injection-coverage claim).** A fold sourced inside an + injected layer — a fenced code block in a markdown buffer — returns the + inner block's range, proving the source walks injection layers, not just + the root tree. +11. **Lua data API (Q#FD4/Q#FD11).** `pmacs.fold.fold/unfold/folds/toggle` + with an explicit buffer drive all of the above and round-trip `folds()`; + an out-of-bounds, non-boundary, or sub-one-line range is rejected; a fold + on a terminal identity buffer is rejected (Q#FD9). ## 13. Gates (Stage 1) @@ -244,7 +387,7 @@ CRDT); `cargo test --test m4_acceptance -- --skip basedpyright`; ## 14. Branch and PR plan Branch `folding`, worktree `../pmacs-folding`, cut from canonical `main` @ -`cac4961`. This framing is its first commit. After the framing is approved, -Stage 1 is implemented on this same branch and opened as the first folding PR. +`cac4961`. This framing (rev 1 → rev 2) is its opening commits. After approval, +Stage 1 implements on this same branch and opens as the first folding PR. Stages 2 and 3 are separate branches/PRs off the main resulting from the prior stage, each with its own detailed framing. From 9d08c663d43c38e786dc2a701e09d2435bb0a536 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 08:55:40 -0400 Subject: [PATCH 4/9] =?UTF-8?q?docs(folding):=20framing=20rev=203=20?= =?UTF-8?q?=E2=80=94=20address=20review=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five majors + four minors from the second review, all fixed: R2-1 (head line on indentation grammars): tree-sitter-python's block starts on the first statement line, so the old heuristic made a body line the fold head and left def foo(): headless. Added a head-selection ascend rule (block-like node -> ascend to the introducer parent while parent.start_line < block.start_line), a no-op for brace languages. Acceptance 1 now tests both Rust and Python. R2-2 (Q#FD5 cannot live in the View): View::on_edit(&Buffer,&Edit) and Edit carry no source frontend and no point-inside signal (only crdt_op), verified at overlay.rs:248 / rope.rs. The store View now does translate + drop only; the interactive unfold is a pre-edit step at the dispatch layer that holds the authenticated frontend and its point. R2-3 (CRDT typing misclassified): the classifier is the authenticated source frontend's point, not the transport — a GPU user's CRDT-op insert inside a fold is interactive. Stage 1 implements the command path; CRDT-origin unfold is a named Stage 3 obligation. R2-4 (#120 stale-mirror trap): revert drops the store + emits BufferSnapshot + resets the baseline, so the empty store is suppressed as 'initial empty' and the GPU keeps stale folds unless its snapshot arm clears the fold mirror. Pinned as a Stage 3 obligation and in acceptance 7. R2-5 (line-aligned tail hid non-member text): } else { / }, [deps]) — now the closing-delimiter line stays visible (closer-aware tail); delimiter-less nodes still hide through the last body line. Decided, not bet. Minors: unfold is plural (nested); shared head lines toggle innermost-first; Q#FD9's reason corrected to the >=1-hidden-line rule (not bounds); and the Stage 2/3 sketch now names fold-aware LineNumbers, visible-line viewport/scroll accounting, and hidden-line sign/presence clamp-or-drop. --- docs/folding-framing.md | 599 ++++++++++++++++++++-------------------- 1 file changed, 296 insertions(+), 303 deletions(-) diff --git a/docs/folding-framing.md b/docs/folding-framing.md index 149819e..7e508ea 100644 --- a/docs/folding-framing.md +++ b/docs/folding-framing.md @@ -1,393 +1,386 @@ # Folding — framing (Arc 6) -**Revision 2 — 2026-07-22. Status: framing only, on branch `folding` -(off canonical `main` @ `cac4961`); no implementation.** Revision 1 passed -a ground-truth review (every scouted claim verified against the tree) but the -reviewer found one architectural mis-framing and six spec gaps. Revision 2 -fixes all of them; see §0 for the changelog. +**Revision 3 — 2026-07-22. Status: framing only, on branch `folding` +(off canonical `main` @ `cac4961`); no implementation.** Rev 1 passed a +ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixes round +2's five majors and four minors. See §0 for the per-round changelog. -## 0. Revision 2 — review round 1 resolutions +## 0. Revision history -- **F1 (architectural).** R1's Stage 2 said "the TUI consumes `FoldState`." - It cannot: the grid TUI advertises `semantic_render: false` - (`src/frontend.rs:385`), so the per-session outgoing filter never sends the - `FoldState` family to it, and the TUI has no layout of its own — the daemon - renders its cell grid (`render_states` vs `semantic_states`, - `src/daemon.rs:875`/`881`; the grid path is `render_state.render_frame(...)` - at `:1106`). **The TUI collapse is instance-side rendering in the daemon - grid renderer, reading the fold store directly — no wire.** `FoldState` on - the wire serves ONLY semantic (GPU) sessions. Staging reworked accordingly - (§8): Stage 2 is grid/daemon rendering; Stage 3 is the wire-fed GPU. -- **F2.** Stored range semantics pinned: the store holds **line-aligned - interior** ranges (Q#FD3, §5); the head line stays visible, the interior - including the closing-delimiter line is hidden. §7 rewritten to match. -- **F3.** The fold store's edit-translation is the **instance-side - buffer-attached `View`** (`BufferStyleSpanTranslator` pattern, - `src/overlay.rs:235`), which sees every real edit regardless of source — not - the frontend-side `translate_byte_range`. The two "resets" are split: - per-session producer baseline vs per-buffer store lifecycle (Q#FD8, §5). -- **F4.** Stale-tree fold creation pinned: read `ParseViewHandle::current()` - (`src/syntax.rs:696`); if it is `None` or `pending_edit_count() > 0` - (`:706`), **refuse with a status message** in v1 (Q#FD10, §3). -- **F5.** Multi-frontend point + edit-vs-fold rules pinned (Q#FD3, Q#FD5, §5): - the invoking frontend's point moves to the head; interactive-point-inside - edits unfold, programmatic/remote edits translate; the invariant is - creation-time-only in Stage 1. -- **F6.** Lua surface takes an **explicit buffer** (no ambient resolution, - per #127), with full range **validation** (Q#FD4, Q#FD11, §6) — which is - also what makes Q#FD9 (terminals never fold) hold. -- **F7.** `FoldState` follows the authoritative-empty discipline; the - non-empty→empty transition (open-all) emits exactly one empty frame - (Q#FD8, acceptance 4). -- **Minors.** "≥2 source lines" not display rows; `close-all` folds - top-level only (Emacs `hs-hide-all` parity, feeds B2); an injected-layer - fold acceptance added; and an explicit note that `FoldState` needs **no - protocol bump**. +### Round 1 (rev 1 → rev 2) + +- **F1** the grid TUI is daemon-rendered and never receives `FoldState`; its + collapse is instance-side in the daemon grid renderer. Staging reworked. +- **F2** stored range pinned to a line-aligned interior. +- **F3** the store's translation is the instance-side buffer-attached `View` + (`BufferStyleSpanTranslator` pattern), not the frontend `translate_byte_range`. +- **F4** stale-tree fold creation refuses. +- **F5** multi-frontend point / edit-vs-fold pinned. +- **F6** explicit-buffer Lua API + validation. +- **F7** authoritative-empty `FoldState`. + +### Round 2 (rev 2 → rev 3) + +- **R2-1 (major).** The block-kind heuristic picked a **body line** as the + fold head on indentation grammars — tree-sitter-python's `block` starts on + the first statement line, so `def foo():` was left above a headless fold + (verified in review; brace languages escaped only because `{` shares the + introducer line). Fixed with a **head-selection ascend rule** (Q#FD1, §3), + a no-op for brace languages. Acceptance 1 now tests both languages. +- **R2-2 (major).** The interactive-vs-programmatic split cannot live in the + store's `View`: `View::on_edit(&Buffer, &Edit)` (`src/overlay.rs:248`) and + `Edit` (`src/rope.rs`) carry no source frontend and no "point was inside" + signal — only optional `crdt_op`. The `View` does **translate + drop only**; + the **unfold is a pre-edit step at the dispatch/command layer** that knows + the authenticated frontend and its point (Q#FD5, §5). This is the + handoff's deferred "origin-pinned `buffer.after-edit` fan-out" gap. +- **R2-3 (major).** "CRDT op = remote = translate" misclassifies GPU typing + (a GPU user types via CRDT ops but is editing at their own point inside a + rendered fold). The classifier is the **authenticated source frontend's + point, not the transport**. Stage 1 implements the unfold for the command + path; **CRDT-origin unfold is a named Stage 3 obligation** (that is when a + GPU user can type into a rendered fold). Q#FD5, §5, §8. +- **R2-4 (major).** Q#FD8 recreated the #120 stale-mirror trap: revert drops + the store, emits `BufferSnapshot`, and resets the producer baseline, so the + now-empty store is suppressed as "initial empty" and a GPU keeps rendering + pre-revert folds unless its snapshot arm **clears the fold mirror**. That + frontend clear is load-bearing; named as a Stage 3 obligation and pinned in + acceptance 7 (Q#FD8, §5). Same class as [[message-gating-on-active-state]]. +- **R2-5 (major).** A line-aligned tail hid non-member text on shared-closer + lines (`} else {`, `}, [deps])`). Fixed by **keeping a closing-delimiter + line visible** (Q#FD3, §5); delimiter-less (indentation) nodes still hide + through their last body line. Decided, not bet. +- **Minors.** (a) unfold is **plural** — every fold containing the point. + (b) a shared head line (`foo(() => {`) toggles **innermost-first**. + (c) Q#FD9's rejection reason corrected: `(0,0)` is in-bounds; the reject + comes from the ≥1-hidden-line rule. (d) Stage 2/3 re-framings must address + three named interactions (§8): fold-aware `LineNumbers`, visible-line + viewport/scroll accounting, and hidden-line signs/presence clamp-or-drop. ## 1. Problem and what ships -Pmacs cannot fold. The `FoldState` wire family was declared in the M11.1 -semantic-frontend design but has never been produced — the producer says in -so many words that "pmacs has no instance-side fold source yet," and a test -pins that `FoldState` is never emitted. +Pmacs cannot fold. `FoldState` was declared in the M11.1 semantic-frontend +design but has never been produced — the producer says "pmacs has no +instance-side fold source yet," and a test pins it is never emitted. -Arc 6 gives pmacs a fold engine (instance-side fold model + a fold source + +Arc 6 gives pmacs a fold engine (instance-side fold store + a fold source + Lua commands), renders collapsed regions with a gutter fold marker in both frontends, and produces `FoldState` for semantic (GPU) sessions. It is the -roadmap's "keystone gutter rider": it lights up an already-declared wire -family, adds the fold-marker rider beside the existing diagnostic signs, and -is a visible feature. +roadmap's "keystone gutter rider." -**`FoldState` needs no protocol bump.** The variant has been in the wire -encoding since M11.1 and both frontends already decode it (the TUI drops it, -the GPU has a decode arm); Arc 6 only starts *producing* it. No -`PROTOCOL_VERSION` change, no `SUPPORTED` change. +**`FoldState` needs no protocol bump** — the variant has been in the encoding +since M11.1 and both frontends already decode it (the TUI drops it, the GPU +has a decode arm); Arc 6 only starts *producing* it. -**Git gutter markers are a SIBLING rider, not this arc.** They ride the same -gutter but need a diff source, unrelated to folding. Named as a deferral -(§11), framed separately. +**Git gutter markers are a SIBLING rider, not this arc** (§11). -## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified in review) +## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified across two review rounds) -- **`FoldState { buffer_id, folds: Vec }`** exists in +- **`FoldState { buffer_id, folds: Vec }`** — `pmacs-protocol/src/message.rs:886`, gated on `semantic_render`, - DECLARED-BUT-UNPRODUCED. Doc: *"the instance's authoritative fold set as - document facts. Folding is an instance command-semantics concern (Lua can - fold); the visual collapse is a frontend layout concern."* -- `semantic_render.rs:4002` - (`block_adornments_and_fold_state_still_never_emitted`) asserts - `FoldState`/`BlockAdornments` are never sent. Stage 1 flips this to assert - `FoldState` IS produced while `BlockAdornments` stays unemitted (F7). + DECLARED-BUT-UNPRODUCED. `semantic_render.rs:4002` pins it is never emitted. - **`BlockAdornments`** (also unproduced) is the declared home for - "folded-region placeholders." Arc 6 does **not** produce it — the fold - placeholder is frontend-local (Q#FD7). -- **No fold source exists.** The bundled grammars export - `HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — no fold query and - no `folds.scm` (`LanguageEntry` has exactly those fields). The roadmap's - "tree-sitter fold ranges" is not free; the fold source is Q#FD1. -- **Two frontend render paths, not one (F1).** The grid TUI is daemon-rendered - (`render_states` → `render_state.render_frame`, `src/daemon.rs:1106`) and - advertises `semantic_render: false` (`src/frontend.rs:385`), so it never - receives `FoldState`. The GPU is a semantic session (`semantic_states` → - `sem.render_frame`, `:1091`) and does receive it. Fold collapse is therefore - daemon-side for the TUI and wire-fed for the GPU. -- **Gutter signs are frontend-derived, not a wire channel.** The GPU's - `collect_gutter_sign_rects` computes diagnostic sign bars locally; the TUI's - are painted daemon-side. Fold markers follow the same model per path — no new - wire type. -- **Instance-side edit translation is a solved pattern (F3).** Compile-mode's - `BufferStyleSpanTranslator` (`src/overlay.rs:235`) is a buffer-attached - `View` that sees every real edit — commands, CRDT ops, LSP workspace edits, - Lua — once per edit, fragment-preserving. The fold store attaches the same - kind of `View`. The frontend-side `translate_byte_range` - (`pmacs-gpu/src/main.rs`) is a *different* thing (the GPU translating its own - received copies across optimistic edits) and is not the store's mechanism. -- **Staleness is detectable (F4).** `ParseViewHandle::current()` - (`src/syntax.rs:696`) returns `None` before the first settle and the latest - settled bundle otherwise; `pending_edit_count()` (`:706`) is nonzero while - edits await settle. -- **Greenfield Lua/commands.** No existing fold surface or commands. + folded-region placeholders. Arc 6 does not produce it (Q#FD7). +- **No fold source exists** — the bundled grammars export + `HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` only, no fold query, no + `folds.scm`. Fold source is Q#FD1. **tree-sitter-python's `block` node + starts on the first statement line, not the `def` line** (R2-1) — the reason + the head-selection rule is required. +- **Two frontend render paths (F1).** Grid TUI: daemon-rendered + (`render_states` → `render_state.render_frame`, `src/daemon.rs:1106`), + advertises `semantic_render: false` (`src/frontend.rs:385`), never receives + `FoldState`. GPU: semantic session (`semantic_states` → `sem.render_frame`, + `:1091`), does. Collapse is daemon-side for the TUI, wire-fed for the GPU. +- **Gutter signs are frontend-derived, not a wire channel** — fold markers + follow suit per path; no new wire type. +- **Instance-side edit translation** is the buffer-attached `View` + (`BufferStyleSpanTranslator`, `src/overlay.rs:235`; hook + `on_edit(&Buffer, &Edit)` at `:248`), which sees every edit once, + provenance-blind (R2-2): `Edit` carries only `crdt_op`, no source frontend. +- **Staleness is detectable** — `ParseViewHandle::current()` + (`src/syntax.rs:696`) is `None` before first settle; `pending_edit_count()` + (`:706`) is nonzero while edits await settle. +- **Greenfield** Lua/commands. -## 3. Fold source (Q#FD1) — the load-bearing decision +## 3. Fold source (Q#FD1) — structural node folding with head selection and closer-aware tail -The grammars ship no fold queries, so "what is foldable" must be defined by -pmacs. Three options: +The grammars ship no fold queries, so pmacs defines "what is foldable." v1 is +**structural node folding for grammar-backed buffers** (reuses the parse trees +for every bundled grammar and injection layer, zero per-language authoring). +Indentation folding (grammarless fallback) and curated per-language queries +(quality pass) are DEFERRED (§11). -- **(A) Curated per-language fold queries** (nvim-treesitter's `@fold`-capture - model). Highest quality — but per-language authoring plus ongoing - maintenance, exactly the work the grammars declined to ship. -- **(B) Structural node folding.** At a point, fold the nearest enclosing - NAMED node that spans **≥2 source lines** (F-minor: source lines, not - display rows — soft wrap is frontend layout and unknowable instance-side), - biased to block-like kinds by a small shared heuristic on node-kind names - (`block`, `body`, `*_list`, `declaration_list`, `statement_block`, - brace/bracket-delimited nodes). Reuses the parse trees already present for - every bundled grammar AND every injection layer. Zero per-language authoring. -- **(C) Indentation folding.** Language-agnostic, works with no grammar, - predictable, but ignores syntax. +The source, at a point: -**Recommendation: (B) structural node folding for grammar-backed buffers as -the v1 engine.** Reuses tree-sitter, no per-language work, covers all bundled -grammars and injection layers day one. (C) is the grammarless fallback, -DEFERRED so Stage 1 stays scoped to grammar buffers; (A) is a later quality -pass, DEFERRED. +1. **Match** the nearest enclosing NAMED node `B` spanning **≥2 source lines** + (source lines, not display rows — soft wrap is frontend-only and unknowable + instance-side), biased to block-like kinds (`block`, `body`, `*_list`, + `declaration_list`, `statement_block`, brace/bracket-delimited nodes). +2. **Head selection (R2-1).** Ascend: while `B`'s parent introduces `B` (a + `function_definition` / `if_statement` / … whose block child is `B`) **and** + `parent.start_line < B.start_line`, take the parent as the head node. This + makes the **introducer line the head** — `def foo():` on Python, where the + `block` starts a line lower. It is a **no-op for brace languages**, where + `{` shares the introducer's line (`parent.start_line == B.start_line`), so + the head node stays `B` and the result is identical. +3. **Tail selection (R2-5).** The hidden interior is a whole-line range. Its + first hidden line is `head_line + 1`. Its last hidden line is: + - if `B`'s last line begins with `B`'s **closing-delimiter token** + (`}`/`)`/`]`, and `end`-style closers later) — a brace/bracket node — + then `B.last_line - 1`, **keeping the closer line visible**. This is what + keeps `} else {` and `}, [deps])` on screen with their trailing siblings. + - else (a delimiter-less node, e.g. a Python `block`) — `B.last_line`, + hiding through the last body line. + +The stored range is the byte range `[end of head_line, end of last-hidden +line]` (§5). A node that yields **zero** hidden lines (e.g. `fn f() {}` on two +lines, empty body) is **not foldable**. **Stale-tree rule (Q#FD10, F4).** The source reads -`ParseViewHandle::current()`. If it is `None` (no settle yet) or -`pending_edit_count() > 0` (the settled tree's coordinates are stale relative -to the current buffer), a fold command **refuses with a status message and -stores nothing** — a fold is durable state and must not be computed against -stale coordinates. Settle is sub-frame, so the refuse window is tiny. -Translate-the-node-range-through-pending-edits is a named refinement (§11). +`ParseViewHandle::current()`; if it is `None` (no settle yet) or +`pending_edit_count() > 0` (settled coordinates are stale), the fold command +**refuses with a status message and stores nothing** — a fold is durable state +and must not be computed against stale coordinates. Settle is a main-thread +pump, so the window is sub-frame. Translate-through-pending is a §11 +refinement. -The block-kind heuristic (B) is the part most likely to feel wrong ("it folded -the tiny inner block, not the function"); Bet B1 (§10) states it and names the -fallback (curated Tier-1 queries). +The block-kind heuristic (step 1) remains a taste bet (Bet B1); step 2 fixed +the *determinable* Python defect, which was not taste. ## 4. Where fold state lives (Q#FD2) Instance-side, per the wire contract. A **per-buffer fold store** (a set of byte ranges) lives beside the buffer, attached as a `View` (F3). Commands -mutate it; the daemon grid renderer reads it directly to collapse the TUI -(Stage 2); the semantic producer ships it as `FoldState` to GPU sessions -(Stage 3). Nested folds are allowed — the store is a set, consumers collapse -the union. +mutate it; the daemon grid renderer reads it directly (Stage 2); the semantic +producer ships it as `FoldState` to GPU sessions (Stage 3). Nested folds are +allowed; the store is **shared by every attached frontend** (Emacs parity). -The store is **shared by every attached frontend** (Emacs parity): folds are a -document-level view fact, not per-window. Per-cursor consequences of that -sharing are pinned in §5 (F5). +## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6, Q#FD8) -## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6) - -**Stored range = line-aligned interior (Q#FD3, F2).** A fold is identified by -its **head line** H. The stored/shipped byte range is the **hidden interior**: -from the newline that terminates H through the end of the last source line the -folded region spans. So: - -- H (with its opener, e.g. `fn foo() {`) **stays visible**, with a - frontend-drawn ellipsis at its end. -- The interior lines **and the closing-delimiter line** (`}`) are **hidden** - — the range ends at the end of the line containing the region's last byte. -- The structural source yields a raw node span `[node.start, node.end)`; the - store **normalizes** it to this line-aligned interior before anything else - (renderer, wire, `folds()`) sees it. One normalized form, one meaning, - everywhere — resolving the R1 §5/§7 contradiction. +**Stored range = line-aligned hidden interior (Q#FD3).** A fold is identified +by its **head line** (the introducer, §3 step 2), which stays visible with a +frontend-drawn ellipsis. The stored byte range is `[end of head line, end of +the last hidden line]`, where the last hidden line is chosen by §3 step 3 — +so a **closing-delimiter line stays visible** (fixing `} else {`), while a +delimiter-less node hides through its last body line. One normalized form is +computed by the source and seen identically by the store, the grid renderer, +the wire, and `folds()`. **Point and folds (Q#FD3, F5).** -- Folding a range that contains the **invoking frontend's** point moves that - point to the head line H (Emacs `hs-minor-mode`). -- The store is shared, so **another** frontend's cursor may already sit inside - a newly folded range. "A cursor cannot sit inside a fold" is a **per-cursor, - render-time** invariant: on that frontend's next frame the caret clamps to H - (a Stage 2/3 rendering concern). In **Stage 1** there is no motion-awareness - (deferred to Stage 2/3), so the invariant is **creation-time-only**: folding - moves the invoking point out, but later motion re-entering a fold is not yet - prevented. Acceptance is written to that scope so it cannot self-contradict. +- Folding a range containing the **invoking frontend's** point moves that + point to the head line. +- The store is shared, so another frontend's cursor may sit inside a newly + folded range. "No cursor inside a fold" is a **per-cursor, render-time** + invariant (its caret clamps to the head on that frontend's next frame — a + Stage 2/3 concern). In **Stage 1** there is no motion-awareness, so the + invariant is **creation-time-only**; acceptance is scoped to that so it + cannot self-contradict. -**Edits and folds (Q#FD5, Q#FD6, F5).** The store's buffer-attached `View` -(F3) sees every edit: -- An **interactive edit at the invoking frontend whose point is inside a - fold** unfolds that fold first — you cannot type into hidden text you cannot - see. -- A **programmatic or remote edit** (a peer CRDT op, an LSP workspace edit, a - Lua buffer edit) **translates** the fold through the `View`, keeping it - folded — it is not a person typing into the hidden region. -- A fold whose head or tail an edit **destroys** (e.g. the head line deleted, - or the range collapses below one hidden line) is **dropped**, not - re-anchored — a fold is view state, never data. +**Edits and folds — two separated mechanisms (Q#FD5, Q#FD6, R2-2, R2-3).** +- The store's buffer-attached `View` does **translation and drop only**, and + is **provenance-blind**: on every edit it translates each fold's range, and + **drops** any fold whose head/tail the edit destroys or whose interior + collapses below one line. It cannot unfold-on-typing because `Edit` carries + no frontend and no point (R2-2). +- **Unfolding on an interactive edit is a pre-edit step at the dispatch layer** + (which holds the authenticated frontend and its point): before applying an + edit that a frontend is making at its point, unfold **every** fold + containing that point (plural — minor a). The classifier is the + **authenticated source frontend's point, not the transport** (R2-3): a GPU + user's CRDT-op insert at a point inside a fold is interactive and must + unfold, even though it arrives as a CRDT op. **Stage 1 implements this for + the command path** (daemon `dispatch_key` self-insert/delete, which has the + frontend + point); **CRDT-origin unfold is a Stage 3 obligation**, wired + when the GPU renders folds and a GPU user can type into one. -**Store lifecycle vs producer baseline (Q#FD8, F3).** Two distinct resets, -previously conflated: +**Store lifecycle vs producer baseline (Q#FD8, F3, R2-4).** Three coupled +resets, kept distinct: - The **per-session producer suppression baseline** resets on `BufferSnapshot` - so the fold set is re-shipped to a (re)joining semantic session — the - established producer discipline. -- The **per-buffer fold store** is dropped or revalidated on buffer **content - replacement** (revert/reload): the ranges describe bytes that no longer - exist, so revert clears the store (revalidation against the new content is a - §11 refinement). + so the fold set is re-shipped to a (re)joining semantic session. +- The **per-buffer store** is dropped on buffer **content replacement** + (revert/reload) — its ranges name bytes that no longer exist (revalidation + is a §11 refinement). +- **The frontend fold mirror must clear on `BufferSnapshot` (R2-4, Stage 3 + obligation).** Revert simultaneously drops the store, emits a snapshot, and + resets the baseline, so the producer sees an empty store with a fresh + baseline and correctly suppresses it as "initial empty" — which means the + GPU keeps rendering pre-revert folds **unless its snapshot arm clears fold + state**, exactly as it already clears spans/decorations. The + empty-after-snapshot suppression is correct **only because** the snapshot + clears the frontend mirror; this pairing is load-bearing and is pinned in + acceptance 7. ## 6. Lua command surface and validation (Q#FD4, Q#FD11) **Interactive commands** (resolve to the invoking frontend's active-window -buffer — command context, not ambient resolution): +buffer — command context, not ambient resolution). On a head line shared by +more than one fold, they act **innermost-first** (minor b): -- `fold.toggle` — fold the enclosing foldable region at point, or unfold if - point's line is a fold head. -- `fold.close` / `fold.open` — explicit fold/unfold at point. -- `fold.close-all` / `fold.open-all` — fold every **top-level** foldable - region (Emacs `hs-hide-all` parity — nested regions are not auto-folded; - see B2) / clear the fold set. +- `fold.toggle`, `fold.close`, `fold.open`, `fold.close-all`, `fold.open-all`. +- `close-all` folds **top-level** foldable regions only (Emacs `hs-hide-all` + parity — nested regions are not auto-folded; feeds B2). `open-all` clears. **Data API (Q#FD4, F6): explicit buffer, no ambient resolution** (matching -#127's deliberate refusal of ambient-buffer lookup): +#127): `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`, +`folds(buffer)`, `toggle(buffer, pos)`. -- `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`, - `folds(buffer) -> {range,...}`, `toggle(buffer, pos)`. +**Validation (Q#FD11, F6).** `fold(buffer, range)` rejects unless: the buffer +is a normal document buffer; both endpoints are UTF-8 boundaries; and the +range normalizes to **≥1 hidden line**. Q#FD9 (terminals never fold) follows +from the last clause (minor c): `(0,0)` on an empty terminal identity buffer +is technically in-bounds, but it normalizes to zero hidden lines and is +rejected — no special case. -**Validation (Q#FD11, F6).** `fold(buffer, range)` validates and rejects -otherwise: the buffer exists and is a normal document buffer; the range is -in-bounds; both endpoints are UTF-8 char boundaries; the range normalizes -(§5) to **at least one hidden line**. This validation is what makes Q#FD9 -hold: a terminal identity buffer is empty, so every range is out-of-bounds and -rejected — no fold can be stored on a terminal even from Lua, with no special -case. - -Bindings are left for this review round — Emacs uses `C-x C-z` / `hs-*` / -outline `C-c @`; pmacs has no precedent, so the binding is the user's call. +Bindings remain the user's call (Emacs has no single convention). ## 7. Frontend collapse + gutter marker (Q#FD7) -Two paths (F1): - -- **Grid TUI — daemon-rendered.** The daemon grid renderer reads the fold - store directly and omits each fold's hidden interior from the cells it - paints, showing H with an ellipsis; it draws the gutter fold glyph on H. - No wire, same shape as vterm Stage 2's daemon-painted terminal cells. +- **Grid TUI — daemon-rendered.** The daemon grid renderer reads the store and + omits each fold's hidden lines, showing the head line with an ellipsis and a + gutter fold glyph. No wire (the vterm Stage 2 shape). - **Semantic GPU — wire-fed.** The GPU receives `FoldState`, excludes the - hidden bytes from its shaped code slice, shows H with an ellipsis, and draws - the fold glyph on H. Caret/hit-test gain a fold-aware step (the largest - per-frontend cost, and why the GPU is its own stage). + hidden bytes from its shaped slice, shows the head + ellipsis + fold glyph, + and makes caret/hit-test fold-aware. It also clears its fold mirror on + `BufferSnapshot` (R2-4). -In both paths the placeholder is **frontend-local** (an ellipsis / ` ⋯ N -lines `), **not** a `BlockAdornment` — Q#FD7 keeps `BlockAdornments` -unproduced. The gutter marker is derived from the fold set per path, like the -diagnostic sign bars — no new wire type. +The placeholder is frontend-local (not a `BlockAdornment`); the gutter marker +is derived per path like the diagnostic sign bars — no new wire type. ## 8. Staging and scope -Mirrors vterm; reworked for F1 (the TUI path is daemon-side, not a wire -consumer). - -- **Stage 1 — fold engine (instance), headless.** The per-buffer fold store + - its buffer-attached translating `View`; the structural fold source with the - stale-tree rule; the Lua data API + interactive commands + validation; - `FoldState` production for semantic sessions (authoritative-empty, - diff-suppressed); and headless acceptance. No rendering — folds are asserted - in the store and on the wire, not on screen. **Approval-critical.** +- **Stage 1 — fold engine (instance), headless.** The per-buffer store + its + translating/dropping `View`; the structural source with head selection, + closer-aware tail, and the stale-tree rule; the Lua data API + interactive + commands + validation; the **command-path pre-edit unfold**; `FoldState` + production (authoritative-empty, diff-suppressed); headless acceptance. No + rendering. **Approval-critical.** - **Stage 2 — grid (daemon-rendered) collapse + gutter marker.** The daemon - grid renderer collapses folded interiors and draws the TUI gutter fold glyph - + head placeholder; caret handling clamps to H. Instance-side rendering - work; no wire change. + grid renderer collapses folded interiors and draws the TUI gutter glyph + + head placeholder; caret clamps to the head. **Must also make the + daemon-computed `LineNumbers` family fold-aware** (skipped lines; relative + distance measured across a fold), **count visible lines in viewport/scroll + accounting**, and **clamp-to-head-or-drop diagnostic signs on hidden lines** + (minor d). - **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`, - excludes folded bytes from its shaped slice, draws the fold glyph and makes - caret/hit-test fold-aware, at TUI parity. + excludes folded bytes, draws the glyph, makes caret/hit-test fold-aware at + TUI parity, **clears the fold mirror on `BufferSnapshot`** (R2-4), wires + **CRDT-origin interactive unfold** (R2-3), and applies the same + hidden-line rules to **peer-presence rects and line numbers** (minor d). -Stages 2–3 are sketched here and re-framed in detail after Stage 1 lands. -This framing asks approval for the architecture and Stage 1's full detail. +Stages 2–3 are sketched; each is re-framed in detail after the prior stage +lands. This framing asks approval for the architecture and Stage 1's detail. ## 9. Numbered decisions -- **Q#FD1** Fold source: structural tree-sitter node folding (v1); - indentation fallback and curated queries deferred. (§3) +- **Q#FD1** Structural node folding: match block-like node ≥2 source lines → + **ascend to the introducer head** → **closer-aware tail** (closing-delimiter + line kept visible; delimiter-less nodes hide through the last body line); + stale/absent tree refuses. Indentation and curated queries deferred. (§3) - **Q#FD2** Fold state is instance-side, per-buffer, a set of ranges, shared - by all frontends; nested folds allowed. (§4) -- **Q#FD3** Stored range is the line-aligned hidden interior (head line - visible, closing-delimiter line hidden); the invoking point moves to the - head; "no cursor inside a fold" is a per-cursor render-time invariant, - creation-time-only in Stage 1. (§5) -- **Q#FD4** Interactive commands `fold.toggle/close/open/close-all/open-all` - (invoking frontend's active buffer); data API `pmacs.fold.*` takes an - explicit buffer, no ambient resolution; bindings decided in review. (§6) -- **Q#FD5** Interactive edit with point inside a fold unfolds it first; - programmatic/remote edits translate the fold. (§5) + by all frontends; nested allowed. (§4) +- **Q#FD3** Stored range = line-aligned hidden interior; head line visible; + closer line visible for closer-terminated nodes; invoking point moves to the + head; no-cursor-inside is per-cursor render-time, creation-time-only in + Stage 1. (§5) +- **Q#FD4** Interactive commands (invoking frontend's buffer, innermost-first + on shared heads); data API takes an explicit buffer, no ambient resolution; + bindings decided by the user. (§6) +- **Q#FD5** The store `View` translates + drops only (provenance-blind); the + **pre-edit interactive unfold** lives at the dispatch layer, keyed on the + authenticated source frontend's point (not transport), unfolding **every** + fold containing it; Stage 1 = command path, CRDT-origin = Stage 3. (§5) - **Q#FD6** A fold whose head/tail an edit destroys is dropped, not re-anchored. (§5) -- **Q#FD7** Placeholder + gutter marker are frontend-local per path; the TUI - path is daemon-rendered, the GPU path wire-fed; `BlockAdornments` stays - unproduced; no new wire type. (§7) -- **Q#FD8** `FoldState` (to semantic sessions only) is whole-buffer, +- **Q#FD7** Placeholder + gutter marker are frontend-local per path (TUI + daemon-rendered, GPU wire-fed); `BlockAdornments` stays unproduced; no new + wire type. (§7) +- **Q#FD8** `FoldState` (semantic sessions only) is whole-buffer, authoritative-empty (initial empty suppressed until a fold exists; unchanged - suppressed; non-empty→empty emits exactly one empty frame), and its - per-session baseline resets on `BufferSnapshot`. The per-buffer STORE is a - separate lifecycle, dropped on buffer content replacement. (§5, §8) -- **Q#FD9** Terminal identity buffers never fold — guaranteed by validation - (empty buffer ⇒ out-of-bounds ⇒ rejected), not a special case. (§6) -- **Q#FD10** Fold creation against a `None` or stale - (`pending_edit_count() > 0`) parse tree refuses with a message; no fold is - stored. (§3) -- **Q#FD11** Explicit-`fold(buffer, range)` validates buffer kind, bounds, - UTF-8 boundaries, and ≥1 hidden line; rejects otherwise. (§6) + suppressed; non-empty→empty emits exactly one empty frame); its per-session + baseline resets on `BufferSnapshot`; the STORE drops on content replacement; + **the GPU fold mirror must clear on `BufferSnapshot`** or empty-after-revert + suppression leaves stale folds (#120 class). (§5, §8) +- **Q#FD9** Terminals never fold — from the ≥1-hidden-line validation, not + from bounds. (§6) +- **Q#FD10** Fold creation against a `None`/stale parse tree refuses. (§3) +- **Q#FD11** `fold(buffer, range)` validates buffer kind, UTF-8 boundaries, + ≥1 hidden line; rejects otherwise. (§6) ## 10. Bets -- **B1** "Nearest enclosing block-like node spanning ≥2 source lines" is - predictable enough for a v1 fold without curated queries. FALSIFIABLE: if the - review finds the fold target surprising on real Rust/Python, fall back to - curated queries for Tier-1 languages. -- **B2** Whole-buffer `FoldState` is cheap: folds are a handful, and - `close-all` folds **top-level only** (Q#FD4), so the set is O(top-level - blocks), far below the style-span volume the producer already ships. No - viewport scoping. -- **B3** The two collapse paths reuse existing machinery: the daemon grid - renderer already paints cells from instance state (vterm Stage 2), and the - GPU already has a projected↔source map for adornments — neither needs a new - layout engine. +- **B1** The block-kind heuristic (§3 step 1) picks a fold *target* users find + natural. FALSIFIABLE on real Rust/Python; fallback is curated Tier-1 + queries. (Head selection and closer-aware tail are now decided, not bet.) +- **B2** Whole-buffer `FoldState` is cheap: folds are a handful, `close-all` + is top-level only, so the set is O(top-level blocks). No viewport scoping. +- **B3** Both collapse paths reuse existing machinery (daemon cell painting; + the GPU's projected↔source map) — no new layout engine. ## 11. Deferred (named) - Indentation folding for grammarless buffers (Q#FD1 (C)). - Curated per-language fold queries (Q#FD1 (A)). -- Translate-a-node-range-through-pending-edits so fold creation need not refuse - on a stale tree (Q#FD10 refinement). -- Fold-store revalidation against new content on revert/reload (Q#FD8: v1 - drops the store). -- Persisted folds across sessions (saveplace-style). -- `fold.hide-level N` / outline-style folding by depth; auto-fold-on-open. +- Translate-a-node-range-through-pending-edits so creation need not refuse on a + stale tree (Q#FD10 refinement). +- Fold-store revalidation against new content on revert/reload (v1 drops it). +- Persisted folds across sessions; `fold.hide-level N`; auto-fold-on-open. - `BlockAdornments` production (rich placeholders, diff zones, blame bands). -- **Git gutter markers** — the sibling gutter rider; separate diff source, - separate framing. +- **Git gutter markers** — the sibling gutter rider; separate diff source. - Search revealing folds (a match inside a fold auto-unfolds) — Stage 2+. ## 12. Acceptance — Stage 1 (engine) -1. **Structural source.** At a point inside a multi-line block, the source - returns the enclosing block-like node normalized to its line-aligned - interior; at top level it returns the enclosing item; in a grammarless - buffer it returns nothing (fallback deferred). +1. **Head selection, both grammar shapes (R2-1).** In Rust `fn foo() { … }`, + a point in the body folds with head line `fn foo() {`. In Python + `def foo(): / body`, a point in the body folds with head line `def foo():` + — **not** a body line. `close-all` on each yields the introducer as head. 2. **Stale/absent tree (Q#FD10).** With `current() == None`, and with `pending_edit_count() > 0` after an edit before settle, `fold.toggle` refuses and stores nothing; after settle it succeeds. 3. **Commands.** `fold.toggle` folds the enclosing region and unfolds on a - fold head; `close-all` folds every top-level block-like region (nested not - auto-folded); `open-all` clears. -4. **Range semantics (Q#FD3).** The stored/shipped range is the line-aligned - interior: the head line's bytes are outside it, the closing-delimiter line - is inside it; `folds(buffer)` returns exactly the normalized ranges. + head; `close-all` folds top-level regions only (a nested inner region is + not auto-folded); `open-all` clears. +4. **Range semantics (Q#FD3, R2-5).** For a brace node the closing-delimiter + line is **outside** the stored range (stays visible) and a `} else {` case + keeps `else {` visible; for a Python node the last body line is **inside** + the range (hidden). `folds(buffer)` returns exactly the normalized ranges. 5. **Point (Q#FD3).** Folding a range containing the invoking point moves it - to the head; the creation-time-only scope holds (Stage 1 does not prevent - later motion into a fold). -6. **Edits (Q#FD5/Q#FD6).** An interactive edit at a point inside a fold - unfolds it; a programmatic edit inside a fold translates it; an edit - deleting the head drops it; each leaves a consistent set. -7. **`FoldState` production (Q#FD8, F7).** The flipped pin test asserts all - three transitions to a semantic session — nothing until a fold exists, + to the head; Stage 1 does not prevent later motion into a fold. +6. **Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3).** The store `View` + translates a fold across a programmatic edit inside it and drops a fold + whose head an edit deletes — with no knowledge of source. A command-path + self-insert at a point inside a fold (or inside **nested** folds) unfolds + **all** of them before the edit applies. (CRDT-origin unfold is asserted in + Stage 3.) +7. **`FoldState` production (Q#FD8, F7, R2-4).** The flipped pin test asserts + all three transitions to a semantic session — nothing until a fold exists, nothing when unchanged, exactly one empty frame after `open-all` — while `BlockAdornments` is still never emitted; the per-session baseline resets on - `BufferSnapshot`. + `BufferSnapshot`. The test documents that empty-after-snapshot suppression + is correct only paired with the Stage 3 frontend-mirror clear. 8. **Store lifecycle.** Buffer content replacement (revert) drops the store. 9. **Nested folds.** Folding an inner then an outer region yields two ranges; - `open-all` clears both. -10. **Injected layer (§3 injection-coverage claim).** A fold sourced inside an - injected layer — a fenced code block in a markdown buffer — returns the - inner block's range, proving the source walks injection layers, not just - the root tree. -11. **Lua data API (Q#FD4/Q#FD11).** `pmacs.fold.fold/unfold/folds/toggle` - with an explicit buffer drive all of the above and round-trip `folds()`; - an out-of-bounds, non-boundary, or sub-one-line range is rejected; a fold - on a terminal identity buffer is rejected (Q#FD9). + `open-all` clears both; a shared head line toggles innermost-first. +10. **Injected layer.** A fold sourced inside an injected layer — a fenced + code block in a markdown buffer — returns the inner block's range, proving + the source walks injection layers, not just the root tree. +11. **Lua data API (Q#FD4/Q#FD11).** `pmacs.fold.*` with an explicit buffer + drives the above and round-trips `folds()`; an out-of-bounds, + non-boundary, or sub-one-line range is rejected; a fold on a terminal + identity buffer is rejected via the ≥1-hidden-line rule (Q#FD9). ## 13. Gates (Stage 1) -The standing suite: `cargo fmt --check`; strict workspace Clippy; `cargo test ---lib` and `--features crdt`; the new `tests/folding_acceptance.rs` (default + -CRDT); `cargo test --test m4_acceptance -- --skip basedpyright`; -`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`; the workspace sweep; `git diff ---check`. New behavioral acceptance is bite-verified with `scripts/bite`. +`cargo fmt --check`; strict workspace Clippy; `cargo test --lib` and +`--features crdt`; `tests/folding_acceptance.rs` (default + CRDT); `cargo test +--test m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test +-p pmacs-gpu`; the workspace sweep; `git diff --check`. New behavioral +acceptance is bite-verified with `scripts/bite`. ## 14. Branch and PR plan -Branch `folding`, worktree `../pmacs-folding`, cut from canonical `main` @ -`cac4961`. This framing (rev 1 → rev 2) is its opening commits. After approval, -Stage 1 implements on this same branch and opens as the first folding PR. -Stages 2 and 3 are separate branches/PRs off the main resulting from the prior -stage, each with its own detailed framing. +Branch `folding`, worktree `../pmacs-folding`, off canonical `main` @ +`cac4961`. This framing (rev 1 → rev 3) is its opening commits. After +approval, Stage 1 implements on this same branch and opens as the first +folding PR. Stages 2 and 3 are separate branches/PRs off the main resulting +from the prior stage, each with its own detailed framing. From 150a6933e35d5da1b9f622f87562c86139fd7dfb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 09:25:08 -0400 Subject: [PATCH 5/9] =?UTF-8?q?docs(folding):=20framing=20rev=204=20?= =?UTF-8?q?=E2=80=94=20address=20review=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One major, three minors, and a nit from the third review, all fixed: R3-1 (major, derived head line): rev 3's head-selection ascend was not a no-op for brace languages — rustfmt wraps long signatures (fn foo( / a: u32, / ) -> bool {) and puts { on its own line under where clauses, so block.start_line > parent.start_line, the ascend fired, and the fold hid the wrapped signature: the R2-5 defect class one level up. Replaced by a derived head line — the interior comes from the body node alone (closer-aware tail unchanged) and the head is the line immediately above the first hidden line (B.start_line - 1 for an introduced delimiter-less body, B.start_line otherwise). Emacs hideshow / LSP foldingRange parity: the fold hides the body, nothing else. The introducer<->body association survives for matching and close-all only. Acceptance 1 gains wrapped-signature cases in both grammar shapes. R3-2: "innermost-first" on a shared head line made the outer fold unreachable via fold.toggle (close inner, reopen inner, forever) and allowed zero-visible-change presses. Replaced by state-aware ordering: close acts on the innermost open fold, open on the outermost closed fold, toggle cycles org-TAB-style (close inward-out, then open all). Acceptance 9 updated. R3-3: Stage 1's "command path" is dispatch_key self-insert/delete only; interactive Lua commands (yank, query-replace, comment-toggle) mutate through the Lua mutator path and classify programmatic, so their edits land inside a fold without unfolding. Stated as the intended Stage 1 line; widening the classifier to interactive Lua command contexts is a named Stage 2 obligation beside Stage 3's CRDT-origin unfold. R3-4: the data API's normalization of an arbitrary range is now defined (head = line containing start; hidden = full lines strictly after it through the line containing end, exclusive of an end at a line start). Nit: stored-range containment pinned start-exclusive/end-inclusive with the matching View boundary bias, so typing at the end of a head line neither unfolds nor lands hidden; acceptance 6 asserts it. Also: Sec 14 records that canonical main has advanced past the cac4961 base (docs + tab-width #137, no Stage 1 overlap; rebase at implementation start), and the active-work folding lane is brought current (head was stale at rev 1). Co-Authored-By: Claude Fable 5 --- docs/active-work.md | 11 +- docs/folding-framing.md | 255 +++++++++++++++++++++++++++++----------- 2 files changed, 192 insertions(+), 74 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 287c5c2..bb0864b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -56,8 +56,10 @@ If it does not, stop and repair the remote/fetch configuration. - Portable branch: `githubsucks/folding`; worktree `../pmacs-folding`. - Base: canonical `main` @ `cac4961` (post Vterm Stage 3 #135). -- Framing head: `ee6c77f` (`docs/folding-framing.md` draft rev 1). -- State: **framing only, awaiting review then approval.** No implementation. +- Framing head: revision 4 of `docs/folding-framing.md` (this commit; rev 1 + `ee6c77f` → rev 2 `7898b8f` → rev 3 `944b42b` → rev 4). +- State: **framing only; three review rounds absorbed (rev 4), awaiting + approval.** No implementation. Load-bearing decision (Q#FD1): the bundled grammars ship no fold query and no `folds.scm`, so the roadmap's "tree-sitter fold ranges" is not free; the draft recommends structural node folding (nearest enclosing block-like node @@ -68,8 +70,9 @@ If it does not, stop and repair the remote/fetch configuration. like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU. - PR: none yet — framing is committed to the branch for review, not opened as a PR. Stage 1 implements on this same branch after approval. -- Next: user review rounds on `docs/folding-framing.md`; bindings (Q#FD4) and - the block-kind heuristic (Bet B1) are the two calls flagged for the user. +- Next: user approval of rev 4 (or a round-4 review). Bindings (Q#FD4) + remain the user's call; the block-kind heuristic stays Bet B1 with + curated Tier-1 queries as fallback. Recovery worktree: diff --git a/docs/folding-framing.md b/docs/folding-framing.md index 7e508ea..aa517ef 100644 --- a/docs/folding-framing.md +++ b/docs/folding-framing.md @@ -1,9 +1,10 @@ # Folding — framing (Arc 6) -**Revision 3 — 2026-07-22. Status: framing only, on branch `folding` +**Revision 4 — 2026-07-23. Status: framing only, on branch `folding` (off canonical `main` @ `cac4961`); no implementation.** Rev 1 passed a -ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixes round -2's five majors and four minors. See §0 for the per-round changelog. +ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixed round +2's five majors and four minors; rev 4 fixes round 3's one major, three +minors, and a nit. See §0 for the per-round changelog. ## 0. Revision history @@ -57,6 +58,36 @@ ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixes round three named interactions (§8): fold-aware `LineNumbers`, visible-line viewport/scroll accounting, and hidden-line signs/presence clamp-or-drop. +### Round 3 (rev 3 → rev 4) + +- **R3-1 (major).** Rev 3's head-selection ascend was **not** a no-op for + brace languages: rustfmt wraps long signatures (`fn foo(` / `a: u32,` / + `) -> bool {`) and puts `{` on its own line under a `where` clause, so + `block.start_line > parent.start_line`, the ascend fired, and the fold hid + the wrapped signature — the R2-5 defect class reintroduced one level up. + Replaced by a **derived head line**: the interior comes from the body node + alone (closer-aware tail unchanged) and the head is **the line immediately + above the first hidden line** (§3) — Emacs hideshow / LSP `foldingRange` + parity; wrapped introducer text now stays visible in both grammar shapes. + The introducer↔body association survives for **matching and `close-all` + only**. Acceptance 1 gains wrapped-signature cases. +- **R3-2 (minor).** "Innermost-first" on a shared head line made the outer + fold unreachable via `fold.toggle` (close inner, reopen inner, forever) + and allowed zero-visible-change presses. Replaced by **state-aware + ordering** with an org-TAB-style toggle cycle (§6); acceptance 9 updated. +- **R3-3 (minor).** Stage 1's "command path" is `dispatch_key` + self-insert/delete only; interactive Lua commands (yank, query-replace, + comment-toggle) mutate through the Lua mutator path and classify + programmatic, so their edits land inside a fold without unfolding. Now + stated as the intended Stage 1 line, and **widening the classifier to + interactive Lua command contexts is a named Stage 2 obligation** (Q#FD5, + §5, §8), beside Stage 3's CRDT-origin unfold. +- **R3-4 (minor).** The data API's normalization of an arbitrary range was + unstated; §6 now defines it (no node, so no introducer or closer + inference). **Nit:** stored-range containment pinned **start-exclusive, + end-inclusive** with the matching `View` boundary bias, so typing at the + end of a head line neither unfolds nor lands hidden (§5; acceptance 6). + ## 1. Problem and what ships Pmacs cannot fold. `FoldState` was declared in the M11.1 semantic-frontend @@ -74,7 +105,7 @@ has a decode arm); Arc 6 only starts *producing* it. **Git gutter markers are a SIBLING rider, not this arc** (§11). -## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified across two review rounds) +## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified across three review rounds) - **`FoldState { buffer_id, folds: Vec }`** — `pmacs-protocol/src/message.rs:886`, gated on `semantic_render`, @@ -84,8 +115,10 @@ has a decode arm); Arc 6 only starts *producing* it. - **No fold source exists** — the bundled grammars export `HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` only, no fold query, no `folds.scm`. Fold source is Q#FD1. **tree-sitter-python's `block` node - starts on the first statement line, not the `def` line** (R2-1) — the reason - the head-selection rule is required. + starts on the first statement line, not the `def` line** (R2-1), and + **tree-sitter-rust's `block` starts at `{`, which rustfmt places below the + `fn` line for wrapped signatures and standalone under `where` clauses** + (R3-1) — the two facts the derived-head rule answers. - **Two frontend render paths (F1).** Grid TUI: daemon-rendered (`render_states` → `render_state.render_frame`, `src/daemon.rs:1106`), advertises `semantic_render: false` (`src/frontend.rs:385`), never receives @@ -102,7 +135,7 @@ has a decode arm); Arc 6 only starts *producing* it. (`:706`) is nonzero while edits await settle. - **Greenfield** Lua/commands. -## 3. Fold source (Q#FD1) — structural node folding with head selection and closer-aware tail +## 3. Fold source (Q#FD1) — structural node folding with derived head line and closer-aware tail The grammars ship no fold queries, so pmacs defines "what is foldable." v1 is **structural node folding for grammar-backed buffers** (reuses the parse trees @@ -112,19 +145,33 @@ Indentation folding (grammarless fallback) and curated per-language queries The source, at a point: -1. **Match** the nearest enclosing NAMED node `B` spanning **≥2 source lines** +1. **Match** the nearest enclosing NAMED node spanning **≥2 source lines** (source lines, not display rows — soft wrap is frontend-only and unknowable instance-side), biased to block-like kinds (`block`, `body`, `*_list`, `declaration_list`, `statement_block`, brace/bracket-delimited nodes). -2. **Head selection (R2-1).** Ascend: while `B`'s parent introduces `B` (a - `function_definition` / `if_statement` / … whose block child is `B`) **and** - `parent.start_line < B.start_line`, take the parent as the head node. This - makes the **introducer line the head** — `def foo():` on Python, where the - `block` starts a line lower. It is a **no-op for brace languages**, where - `{` shares the introducer's line (`parent.start_line == B.start_line`), so - the head node stays `B` and the result is identical. -3. **Tail selection (R2-5).** The hidden interior is a whole-line range. Its - first hidden line is `head_line + 1`. Its last hidden line is: +2. **Resolve introducer↔body (R2-1, R3-1).** If the matched node is an + introducer — a `function_definition` / `if_statement` / … matched from its + header lines, whose block-like body child (grammar field `body` / + `consequence`; feeds B1) starts at or below it — descend to that body + child. The interior-defining node `B` is the body; otherwise it is the + matched node itself. `B` is *introduced* when its parent is such an + introducer. The association exists for **matching and `close-all` + enumeration only** — `fold.toggle` on `def foo():` or on any + wrapped-signature line resolves to the body below; it no longer selects + the head line (rev 3's start-line ascend is removed, R3-1). +3. **Head — the line immediately above the first hidden line (R3-1).** The + head line is `B.start_line - 1` when `B` is an **introduced, + delimiter-less body** (a Python `block`: its introducer's header ends on + the line above — `def foo():`, or the `):` line when the signature + wraps). Otherwise it is `B.start_line` (a brace body's `{` line — + normally the introducer's own line; the `) -> bool {` line when rustfmt + wraps the signature; the standalone `{` under a `where` clause). The + first hidden line is `head_line + 1`. **Wrapped introducer text always + stays visible** — rev 3's ascend took the introducer's *start* line as + the head and so hid wrapped signatures and `where` clauses, the R2-5 + defect class one level up. This is Emacs hideshow / LSP `foldingRange` + parity: the fold hides the body, nothing else. +4. **Tail — closer-aware (R2-5).** The last hidden line is: - if `B`'s last line begins with `B`'s **closing-delimiter token** (`}`/`)`/`]`, and `end`-style closers later) — a brace/bracket node — then `B.last_line - 1`, **keeping the closer line visible**. This is what @@ -133,8 +180,12 @@ The source, at a point: hiding through the last body line. The stored range is the byte range `[end of head_line, end of last-hidden -line]` (§5). A node that yields **zero** hidden lines (e.g. `fn f() {}` on two -lines, empty body) is **not foldable**. +line]` (§5). A fold must have **≥1 hidden line**: the ≥2-source-line gate is +a *match* condition on the matched node, foldability is the ≥1-hidden-line +rule on the *normalized* interior. So `fn f() {` / `}` (empty body — the +closer-aware tail leaves nothing between head and closer) is **not +foldable**, while a two-line `def foo():` / `x = 1` **is**: the matched +`function_definition` spans two lines and its one-line body is the interior. **Stale-tree rule (Q#FD10, F4).** The source reads `ParseViewHandle::current()`; if it is `None` (no settle yet) or @@ -144,8 +195,9 @@ and must not be computed against stale coordinates. Settle is a main-thread pump, so the window is sub-frame. Translate-through-pending is a §11 refinement. -The block-kind heuristic (step 1) remains a taste bet (Bet B1); step 2 fixed -the *determinable* Python defect, which was not taste. +The block-kind heuristic (step 1) and the body-field bias (step 2) remain a +taste bet (Bet B1); steps 3–4 fixed the *determinable* defects (R2-1, R2-5, +R3-1), which were not taste. ## 4. Where fold state lives (Q#FD2) @@ -158,13 +210,23 @@ allowed; the store is **shared by every attached frontend** (Emacs parity). ## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6, Q#FD8) **Stored range = line-aligned hidden interior (Q#FD3).** A fold is identified -by its **head line** (the introducer, §3 step 2), which stays visible with a -frontend-drawn ellipsis. The stored byte range is `[end of head line, end of -the last hidden line]`, where the last hidden line is chosen by §3 step 3 — -so a **closing-delimiter line stays visible** (fixing `} else {`), while a -delimiter-less node hides through its last body line. One normalized form is -computed by the source and seen identically by the store, the grid renderer, -the wire, and `folds()`. +by its **head line** (the line immediately above the hidden interior, §3 +step 3), which stays visible with a frontend-drawn ellipsis. The stored byte +range is `[end of head line, end of the last hidden line]`, where the last +hidden line is chosen by §3 step 4 — so a **closing-delimiter line stays +visible** (fixing `} else {`), while a delimiter-less node hides through its +last body line. One normalized form is computed by the source and seen +identically by the store, the grid renderer, the wire, and `folds()`. + +**Containment and boundary bias (R3-4 nit).** The stored range is +**start-exclusive, end-inclusive** — `(start, end]`. A point at +`range.start` (the end of the head line) is **outside** the fold: typing +there does not trigger the pre-edit unfold, and the store `View` translates +an insert at exactly `range.start` by shifting the fold right (the +`BufferStyleSpanTranslator` at-or-after bias), so the typed character lands +visible on the head line. A point at `range.end` (the end of the last hidden +line) is **inside**: typing there unfolds. One convention covers both the +containment test and the translation bias. **Point and folds (Q#FD3, F5).** - Folding a range containing the **invoking frontend's** point moves that @@ -189,9 +251,19 @@ the wire, and `folds()`. **authenticated source frontend's point, not the transport** (R2-3): a GPU user's CRDT-op insert at a point inside a fold is interactive and must unfold, even though it arrives as a CRDT op. **Stage 1 implements this for - the command path** (daemon `dispatch_key` self-insert/delete, which has the - frontend + point); **CRDT-origin unfold is a Stage 3 obligation**, wired - when the GPU renders folds and a GPU user can type into one. + the command path** — daemon `dispatch_key` self-insert/delete, the only + point-anchored edits the daemon applies directly with the frontend + point + in hand. Two widenings are named, each landing with the rendering that + makes it user-visible (R3-3): + - **Interactive Lua command edits — Stage 2 obligation.** Yank, + query-replace, and comment-toggle mutate through the Lua mutator path, + which this split classifies as programmatic: in Stage 1 such an edit + inside a fold translates without unfolding. Invisible while headless, + but a visible "the yank vanished into the fold" once the TUI collapses — + Stage 2 widens the classifier to interactive Lua command contexts, + keyed on the edit position. + - **CRDT-origin unfold — Stage 3 obligation**, wired when the GPU renders + folds and a GPU user can type into one. **Store lifecycle vs producer baseline (Q#FD8, F3, R2-4).** Three coupled resets, kept distinct: @@ -213,17 +285,35 @@ resets, kept distinct: ## 6. Lua command surface and validation (Q#FD4, Q#FD11) **Interactive commands** (resolve to the invoking frontend's active-window -buffer — command context, not ambient resolution). On a head line shared by -more than one fold, they act **innermost-first** (minor b): +buffer — command context, not ambient resolution): - `fold.toggle`, `fold.close`, `fold.open`, `fold.close-all`, `fold.open-all`. - `close-all` folds **top-level** foldable regions only (Emacs `hs-hide-all` parity — nested regions are not auto-folded; feeds B2). `open-all` clears. +- **Shared head lines — state-aware ordering (R3-2).** On a head line shared + by more than one fold (`foo(() => {`), plain "innermost-first" dead-loops: + toggle closes the inner fold, then acts on it again and *reopens* it, + forever — the outer fold is unreachable, and opening an inner fold while + the outer is closed changes nothing on screen. Ordering is therefore + keyed on fold **state** so every press has a visible effect: `fold.close` + closes the **innermost open** fold (repeated presses walk outward); + `fold.open` opens the **outermost closed** fold (repeated presses walk + inward); `fold.toggle` **cycles org-TAB-style** — it closes the innermost + open fold until every fold on the head is closed, then one more press + opens them all. **Data API (Q#FD4, F6): explicit buffer, no ambient resolution** (matching #127): `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`, `folds(buffer)`, `toggle(buffer, pos)`. +**Arbitrary-range normalization (R3-4).** A data-API `fold(buffer, range)` +carries no node, so none of §3's introducer or closer inference applies — +the caller names exactly what to hide. The head line is the line containing +`range.start`; the hidden lines are the full lines strictly after it, +through the line containing `range.end` — or through the *previous* line +when `range.end` sits at a line start. The stored form is §5's; validation +then applies. + **Validation (Q#FD11, F6).** `fold(buffer, range)` rejects unless: the buffer is a normal document buffer; both endpoints are UTF-8 boundaries; and the range normalizes to **≥1 hidden line**. Q#FD9 (terminals never fold) follows @@ -249,7 +339,7 @@ is derived per path like the diagnostic sign bars — no new wire type. ## 8. Staging and scope - **Stage 1 — fold engine (instance), headless.** The per-buffer store + its - translating/dropping `View`; the structural source with head selection, + translating/dropping `View`; the structural source with derived head line, closer-aware tail, and the stale-tree rule; the Lua data API + interactive commands + validation; the **command-path pre-edit unfold**; `FoldState` production (authoritative-empty, diff-suppressed); headless acceptance. No @@ -259,8 +349,9 @@ is derived per path like the diagnostic sign bars — no new wire type. head placeholder; caret clamps to the head. **Must also make the daemon-computed `LineNumbers` family fold-aware** (skipped lines; relative distance measured across a fold), **count visible lines in viewport/scroll - accounting**, and **clamp-to-head-or-drop diagnostic signs on hidden lines** - (minor d). + accounting**, **clamp-to-head-or-drop diagnostic signs on hidden lines** + (minor d), and **widen the pre-edit interactive unfold to interactive Lua + command edits** (yank / query-replace / comment-toggle — R3-3). - **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`, excludes folded bytes, draws the glyph, makes caret/hit-test fold-aware at TUI parity, **clears the fold mirror on `BufferSnapshot`** (R2-4), wires @@ -273,22 +364,29 @@ lands. This framing asks approval for the architecture and Stage 1's detail. ## 9. Numbered decisions - **Q#FD1** Structural node folding: match block-like node ≥2 source lines → - **ascend to the introducer head** → **closer-aware tail** (closing-delimiter - line kept visible; delimiter-less nodes hide through the last body line); + resolve introducer↔body (matching + `close-all` only) → **head line = the + line immediately above the first hidden line** (wrapped introducer text + always visible) → **closer-aware tail** (closing-delimiter line kept + visible; delimiter-less nodes hide through the last body line); stale/absent tree refuses. Indentation and curated queries deferred. (§3) - **Q#FD2** Fold state is instance-side, per-buffer, a set of ranges, shared by all frontends; nested allowed. (§4) -- **Q#FD3** Stored range = line-aligned hidden interior; head line visible; - closer line visible for closer-terminated nodes; invoking point moves to the - head; no-cursor-inside is per-cursor render-time, creation-time-only in - Stage 1. (§5) -- **Q#FD4** Interactive commands (invoking frontend's buffer, innermost-first - on shared heads); data API takes an explicit buffer, no ambient resolution; - bindings decided by the user. (§6) +- **Q#FD3** Stored range = line-aligned hidden interior, **start-exclusive, + end-inclusive** with the matching `View` boundary bias; head line (the + line above the interior) visible; closer line visible for closer-terminated + nodes; invoking point moves to the head; no-cursor-inside is per-cursor + render-time, creation-time-only in Stage 1. (§5) +- **Q#FD4** Interactive commands (invoking frontend's buffer; shared head + lines use **state-aware ordering** — close innermost-open, open + outermost-closed, toggle cycles); data API takes an explicit buffer, no + ambient resolution, with the §6 arbitrary-range normalization; bindings + decided by the user. (§6) - **Q#FD5** The store `View` translates + drops only (provenance-blind); the **pre-edit interactive unfold** lives at the dispatch layer, keyed on the authenticated source frontend's point (not transport), unfolding **every** - fold containing it; Stage 1 = command path, CRDT-origin = Stage 3. (§5) + fold containing it; Stage 1 = command path (`dispatch_key` + self-insert/delete), interactive-Lua-command widening = Stage 2, + CRDT-origin = Stage 3. (§5) - **Q#FD6** A fold whose head/tail an edit destroys is dropped, not re-anchored. (§5) - **Q#FD7** Placeholder + gutter marker are frontend-local per path (TUI @@ -304,13 +402,14 @@ lands. This framing asks approval for the architecture and Stage 1's detail. from bounds. (§6) - **Q#FD10** Fold creation against a `None`/stale parse tree refuses. (§3) - **Q#FD11** `fold(buffer, range)` validates buffer kind, UTF-8 boundaries, - ≥1 hidden line; rejects otherwise. (§6) + and ≥1 hidden line after the §6 normalization; rejects otherwise. (§6) ## 10. Bets -- **B1** The block-kind heuristic (§3 step 1) picks a fold *target* users find - natural. FALSIFIABLE on real Rust/Python; fallback is curated Tier-1 - queries. (Head selection and closer-aware tail are now decided, not bet.) +- **B1** The block-kind heuristic (§3 step 1) and body-field bias (step 2) + pick a fold *target* users find natural. FALSIFIABLE on real Rust/Python; + fallback is curated Tier-1 queries. (The derived head line and the + closer-aware tail are decided, not bet.) - **B2** Whole-buffer `FoldState` is cheap: folds are a handful, `close-all` is top-level only, so the set is O(top-level blocks). No viewport scoping. - **B3** Both collapse paths reuse existing machinery (daemon cell painting; @@ -330,10 +429,14 @@ lands. This framing asks approval for the architecture and Stage 1's detail. ## 12. Acceptance — Stage 1 (engine) -1. **Head selection, both grammar shapes (R2-1).** In Rust `fn foo() { … }`, - a point in the body folds with head line `fn foo() {`. In Python - `def foo(): / body`, a point in the body folds with head line `def foo():` - — **not** a body line. `close-all` on each yields the introducer as head. +1. **Head line, both grammar shapes, wrapped headers (R2-1, R3-1).** In Rust + `fn foo() { … }`, a point in the body folds with head line `fn foo() {`; + with a rustfmt-wrapped signature (`fn foo(` / `a: u32,` / `) -> bool {`) + the head is the `) -> bool {` line and **every signature line stays + visible**. In Python `def foo(): / body`, a point in the body folds with + head line `def foo():` — **not** a body line; with a wrapped signature + the head is the `):` line and the signature stays visible. `close-all` + on each yields the same heads. 2. **Stale/absent tree (Q#FD10).** With `current() == None`, and with `pending_edit_count() > 0` after an edit before settle, `fold.toggle` refuses and stores nothing; after settle it succeeds. @@ -346,12 +449,16 @@ lands. This framing asks approval for the architecture and Stage 1's detail. the range (hidden). `folds(buffer)` returns exactly the normalized ranges. 5. **Point (Q#FD3).** Folding a range containing the invoking point moves it to the head; Stage 1 does not prevent later motion into a fold. -6. **Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3).** The store `View` - translates a fold across a programmatic edit inside it and drops a fold - whose head an edit deletes — with no knowledge of source. A command-path - self-insert at a point inside a fold (or inside **nested** folds) unfolds - **all** of them before the edit applies. (CRDT-origin unfold is asserted in - Stage 3.) +6. **Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3, R3-3/4).** The store + `View` translates a fold across a programmatic edit inside it and drops a + fold whose head an edit deletes — with no knowledge of source. A + command-path self-insert at a point inside a fold (or inside **nested** + folds) unfolds **all** of them before the edit applies. A self-insert at + the **end of the head line** (`point == range.start`) unfolds **nothing** + and the fold shifts right — the character lands visible on the head line. + An interactive Lua-command edit (e.g. a yank) inside a fold translates + without unfolding in Stage 1; the test documents this as the named + Stage 2 widening. (CRDT-origin unfold is asserted in Stage 3.) 7. **`FoldState` production (Q#FD8, F7, R2-4).** The flipped pin test asserts all three transitions to a semantic session — nothing until a fold exists, nothing when unchanged, exactly one empty frame after `open-all` — while @@ -359,15 +466,20 @@ lands. This framing asks approval for the architecture and Stage 1's detail. `BufferSnapshot`. The test documents that empty-after-snapshot suppression is correct only paired with the Stage 3 frontend-mirror clear. 8. **Store lifecycle.** Buffer content replacement (revert) drops the store. -9. **Nested folds.** Folding an inner then an outer region yields two ranges; - `open-all` clears both; a shared head line toggles innermost-first. +9. **Nested folds (R3-2).** Folding an inner then an outer region yields two + ranges; `open-all` clears both. On a shared head line: repeated + `fold.close` closes innermost-then-outer, repeated `fold.open` opens + outermost-then-inner, and `fold.toggle` cycles close-inner → close-outer + → open-all — the outer fold is reachable by every command. 10. **Injected layer.** A fold sourced inside an injected layer — a fenced code block in a markdown buffer — returns the inner block's range, proving the source walks injection layers, not just the root tree. -11. **Lua data API (Q#FD4/Q#FD11).** `pmacs.fold.*` with an explicit buffer - drives the above and round-trips `folds()`; an out-of-bounds, - non-boundary, or sub-one-line range is rejected; a fold on a terminal - identity buffer is rejected via the ≥1-hidden-line rule (Q#FD9). +11. **Lua data API (Q#FD4/Q#FD11, R3-4).** `pmacs.fold.*` with an explicit + buffer drives the above and round-trips `folds()`; an out-of-bounds, + non-boundary, or sub-one-line range is rejected — including a range + whose `end` sits at the start of the line after its head, which + normalizes to zero hidden lines; a fold on a terminal identity buffer + is rejected via the ≥1-hidden-line rule (Q#FD9). ## 13. Gates (Stage 1) @@ -380,7 +492,10 @@ acceptance is bite-verified with `scripts/bite`. ## 14. Branch and PR plan Branch `folding`, worktree `../pmacs-folding`, off canonical `main` @ -`cac4961`. This framing (rev 1 → rev 3) is its opening commits. After -approval, Stage 1 implements on this same branch and opens as the first -folding PR. Stages 2 and 3 are separate branches/PRs off the main resulting -from the prior stage, each with its own detailed framing. +`cac4961`. This framing (rev 1 → rev 4) is its opening commits. Canonical +`main` has since advanced past the base (documentation + tab-width #137); +Stage 1's instance-side scope does not overlap that work — rebase onto +current `main` when implementation starts. After approval, Stage 1 +implements on this same branch and opens as the first folding PR. Stages 2 +and 3 are separate branches/PRs off the main resulting from the prior +stage, each with its own detailed framing. From 40a820afb8843415d93b2ee275507bdb4486abe7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 09:59:37 -0400 Subject: [PATCH 6/9] =?UTF-8?q?docs(folding):=20framing=20rev=205=20?= =?UTF-8?q?=E2=80=94=20record=20approval=20+=20keybinding=20decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q#FD4 settled: the user chose Emacs hideshow parity, so Stage 1 ships the `C-c @` prefix set (`C-c ` is fully taken by the LSP surface; the hs-minor-mode prefix collides with nothing). §6/§9 now list the five bindings; §0 records the rev 4 -> rev 5 approval note; §14 records the rebase onto canonical `main` @ 96d0bae at implementation start. Bet B1 accepted as framed. active-work.md folding lane flipped to APPROVED / Stage 1 implementing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- docs/active-work.md | 36 ++++++++++++++-------------- docs/folding-framing.md | 52 ++++++++++++++++++++++++++++++----------- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index bb0864b..de60c4f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,24 +55,26 @@ If it does not, stop and repair the remote/fetch configuration. ## Folding framing lane (Arc 6) - Portable branch: `githubsucks/folding`; worktree `../pmacs-folding`. -- Base: canonical `main` @ `cac4961` (post Vterm Stage 3 #135). -- Framing head: revision 4 of `docs/folding-framing.md` (this commit; rev 1 - `ee6c77f` → rev 2 `7898b8f` → rev 3 `944b42b` → rev 4). -- State: **framing only; three review rounds absorbed (rev 4), awaiting - approval.** No implementation. +- Base: **rebased onto canonical `main` @ `96d0bae`** at implementation + start (was `cac4961`; the earlier base fell behind the docs + tab-width + housekeeping). +- Framing head: revision 5 of `docs/folding-framing.md` (rev 1 → … → rev 4 + absorbed three review rounds; rev 5 records approval + the Q#FD4 binding + decision). +- State: **APPROVED; Stage 1 (fold engine, headless) implementing on this + branch.** Bindings decided (Q#FD4 → Emacs hideshow `C-c @` set); Bet B1 + accepted as framed. Load-bearing decision (Q#FD1): the bundled grammars ship no fold query and - no `folds.scm`, so the roadmap's "tree-sitter fold ranges" is not free; the - draft recommends structural node folding (nearest enclosing block-like node - >= 2 rows), with indentation fallback and curated queries deferred. - `FoldState` already exists in the protocol, declared-but-unproduced (a test - pins it is never emitted); no frontend consumes it; gutter markers are - frontend-derived like the diagnostic sign bars, so no new wire type. Staged - like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU. -- PR: none yet — framing is committed to the branch for review, not opened as - a PR. Stage 1 implements on this same branch after approval. -- Next: user approval of rev 4 (or a round-4 review). Bindings (Q#FD4) - remain the user's call; the block-kind heuristic stays Bet B1 with - curated Tier-1 queries as fallback. + no `folds.scm`, so the roadmap's "tree-sitter fold ranges" is not free; v1 + is structural node folding (block-like node ≥2 source lines, derived head + line, closer-aware tail), with indentation fallback and curated queries + deferred. `FoldState` already exists in the protocol, declared-but-unproduced + (a test pins it is never emitted); no frontend consumes it yet; gutter + markers are frontend-derived like the diagnostic sign bars, so no new wire + type. Staged like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU. +- PR: Stage 1 opens as the first folding PR once the gate suite is green. +- Next: land Stage 1; Stages 2/3 are separate branches/PRs, each re-framed + in detail after the prior stage lands. Recovery worktree: diff --git a/docs/folding-framing.md b/docs/folding-framing.md index aa517ef..579b4b8 100644 --- a/docs/folding-framing.md +++ b/docs/folding-framing.md @@ -1,10 +1,11 @@ # Folding — framing (Arc 6) -**Revision 4 — 2026-07-23. Status: framing only, on branch `folding` -(off canonical `main` @ `cac4961`); no implementation.** Rev 1 passed a +**Revision 5 — 2026-07-23. Status: APPROVED; Stage 1 implementing on branch +`folding` (rebased onto canonical `main` @ `96d0bae`).** Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixed round 2's five majors and four minors; rev 4 fixes round 3's one major, three -minors, and a nit. See §0 for the per-round changelog. +minors, and a nit; rev 5 records the settled keybinding decision (Q#FD4) and +approval. See §0 for the per-round changelog. ## 0. Revision history @@ -88,6 +89,17 @@ minors, and a nit. See §0 for the per-round changelog. end-inclusive** with the matching `View` boundary bias, so typing at the end of a head line neither unfolds nor lands hidden (§5; acceptance 6). +### Approval + keybindings (rev 4 → rev 5) + +- **Q#FD4 keybindings decided.** rev 4 deferred the binding to the user; the + user chose **Emacs hideshow parity**, so Stage 1 ships the `C-c @` prefix + set (§6, §9) — `C-c ` is fully taken by the LSP surface, and the + `C-c @` hs-minor-mode prefix collides with nothing. This follows the + M-;/M-% precedent of shipping faithful Emacs-idiom defaults. +- **Bet B1 accepted as framed** (block-kind target heuristic; curated Tier-1 + queries the named fallback). rev 4's architecture is **approved**; Stage 1 + implements on this branch, rebased onto canonical `main` @ `96d0bae`. + ## 1. Problem and what ships Pmacs cannot fold. `FoldState` was declared in the M11.1 semantic-frontend @@ -321,7 +333,19 @@ from the last clause (minor c): `(0,0)` on an empty terminal identity buffer is technically in-bounds, but it normalizes to zero hidden lines and is rejected — no special case. -Bindings remain the user's call (Emacs has no single convention). +**Default bindings (Q#FD4) — Emacs hideshow parity.** Stage 1 ships the +`C-c @` prefix set. `C-c ` is fully taken by the LSP surface, so the +Emacs hs-minor-mode prefix `C-c @` is the one faithful choice that collides +with nothing: + +- `C-c @ C-c` → `fold.toggle` (org-TAB-style cycle) +- `C-c @ C-h` → `fold.close` +- `C-c @ C-s` → `fold.open` +- `C-c @ C-M-h` → `fold.close-all` +- `C-c @ C-M-s` → `fold.open-all` + +These follow the M-;/M-% precedent of shipping faithful Emacs-idiom +defaults; users rebind through `pmacs.keymap` as usual. ## 7. Frontend collapse + gutter marker (Q#FD7) @@ -379,8 +403,8 @@ lands. This framing asks approval for the architecture and Stage 1's detail. - **Q#FD4** Interactive commands (invoking frontend's buffer; shared head lines use **state-aware ordering** — close innermost-open, open outermost-closed, toggle cycles); data API takes an explicit buffer, no - ambient resolution, with the §6 arbitrary-range normalization; bindings - decided by the user. (§6) + ambient resolution, with the §6 arbitrary-range normalization; **default + bindings = the Emacs hideshow `C-c @` prefix set** (§6), rebindable. (§6) - **Q#FD5** The store `View` translates + drops only (provenance-blind); the **pre-edit interactive unfold** lives at the dispatch layer, keyed on the authenticated source frontend's point (not transport), unfolding **every** @@ -491,11 +515,11 @@ acceptance is bite-verified with `scripts/bite`. ## 14. Branch and PR plan -Branch `folding`, worktree `../pmacs-folding`, off canonical `main` @ -`cac4961`. This framing (rev 1 → rev 4) is its opening commits. Canonical -`main` has since advanced past the base (documentation + tab-width #137); -Stage 1's instance-side scope does not overlap that work — rebase onto -current `main` when implementation starts. After approval, Stage 1 -implements on this same branch and opens as the first folding PR. Stages 2 -and 3 are separate branches/PRs off the main resulting from the prior -stage, each with its own detailed framing. +Branch `folding`, worktree `../pmacs-folding`. This framing (rev 1 → rev 5) +is its opening commits; the branch was **rebased onto canonical `main` @ +`96d0bae`** when implementation started (the earlier base `cac4961` was +behind after the documentation + tab-width #137 housekeeping; Stage 1's +instance-side scope does not overlap that work). Stage 1 implements on this +same branch and opens as the first folding PR. Stages 2 and 3 are separate +branches/PRs off the main resulting from the prior stage, each with its own +detailed framing. From 3b411dbb2afc88665c49bc2300ed82a8bc898aa7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 12:14:00 -0400 Subject: [PATCH 7/9] =?UTF-8?q?feat(fold):=20Arc=206=20Stage=201=20?= =?UTF-8?q?=E2=80=94=20instance=20fold=20engine=20(headless)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- builtin/runtime/fold.lua | 52 +++ src/editor.rs | 24 ++ src/editor_core.rs | 29 ++ src/fold.rs | 817 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/lua_bindings/fold.rs | 381 +++++++++++++++++ src/lua_bindings/mod.rs | 2 + src/semantic_render.rs | 175 +++++++- tests/folding_acceptance.rs | 491 ++++++++++++++++++++++ 9 files changed, 1951 insertions(+), 21 deletions(-) create mode 100644 builtin/runtime/fold.lua create mode 100644 src/fold.rs create mode 100644 src/lua_bindings/fold.rs create mode 100644 tests/folding_acceptance.rs diff --git a/builtin/runtime/fold.lua b/builtin/runtime/fold.lua new file mode 100644 index 0000000..60e6f82 --- /dev/null +++ b/builtin/runtime/fold.lua @@ -0,0 +1,52 @@ +-- fold.lua --- interactive fold commands + default bindings (Arc 6). +-- +-- Thin command wrappers over the `pmacs.fold` Rust surface. Each resolves +-- the invoking frontend's active-window buffer and point (command context, +-- not an ambient buffer), then calls the state-aware helper; the Rust side +-- refuses on a stale/absent parse tree, validates, and moves the point to +-- the head line when it folds around it. +-- +-- Default bindings are the Emacs hideshow `C-c @` prefix set (Q#FD4): the +-- LSP surface already owns every `C-c `, so `C-c @` is the one +-- faithful prefix that collides with nothing. Rebind through pmacs.keymap. +-- +-- Framing: docs/folding-framing.md. + +local ed = pmacs.editor +local fold = pmacs.fold + +pmacs.command.define { + name = "fold.toggle", + description = "Toggle the fold at point (org-TAB cycle)", + fn = function() fold.cycle(pmacs.window.buffer(), ed.cursor()) end, +} + +pmacs.command.define { + name = "fold.close", + description = "Close the innermost open fold at point", + fn = function() fold.close(pmacs.window.buffer(), ed.cursor()) end, +} + +pmacs.command.define { + name = "fold.open", + description = "Open the outermost closed fold at point", + fn = function() fold.open(pmacs.window.buffer(), ed.cursor()) end, +} + +pmacs.command.define { + name = "fold.close-all", + description = "Close all top-level folds in the buffer", + fn = function() fold.close_all(pmacs.window.buffer()) end, +} + +pmacs.command.define { + name = "fold.open-all", + description = "Open all folds in the buffer", + fn = function() fold.open_all(pmacs.window.buffer()) end, +} + +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-c", command = "fold.toggle" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-h", command = "fold.close" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-s", command = "fold.open" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-M-h", command = "fold.close-all" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-M-s", command = "fold.open-all" } diff --git a/src/editor.rs b/src/editor.rs index 6ac3509..00f0fd4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -104,6 +104,10 @@ pub struct EditorState { /// default --- M4.2 wires the actual `tree-sitter-rust` and /// `tree-sitter-lua` registrations at startup. pub syntax_registry: crate::syntax::SharedSyntaxRegistry, + /// Per-buffer fold stores (Arc 6). The same `Rc` the core owns (for + /// the pre-edit unfold) and the `pmacs.fold` Lua surface reaches (via + /// Lua app-data); read here by the semantic `FoldState` producer. + pub fold_registry: crate::fold::SharedFoldRegistry, /// Process supervisor (T M4.4). Owns every child process the /// editor has spawned (LSP servers from M4.5; REPLs from M5). /// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor @@ -283,6 +287,15 @@ impl EditorState { // state, but its search overlay resolves wash faces through // this handle. core.borrow_mut().theme = Some(syntax_registry.theme()); + // Arc 6 folding: the core created the fold registry; share that + // same `Rc` into the `pmacs.fold` Lua surface (app-data) so + // commands and the data API mutate the stores the pre-edit unfold + // and the semantic producer read. Installed after + // `make_syntax_registry` so the data API can reach the parse tree + // (also app-data) when it computes a fold target. + let fold_registry = core.borrow().fold_registry.clone(); + crate::lua_bindings::install_fold(lua_host.lua(), &fold_registry) + .expect("install pmacs.fold"); // Arc 4 stage 2 (Q#F2/Q#F3): the GPU font preference and its // `pmacs.gpu` Lua surface. Installed BEFORE load_user_config // below, so an init.lua `set_font` lands in the same handle @@ -459,6 +472,16 @@ impl EditorState { include_str!("../builtin/runtime/comment.lua"), ) .expect("load comment builtin chunk"); + // Arc 6 folding: interactive fold commands + the Emacs hideshow + // `C-c @` bindings. Depends on the `pmacs.fold` Rust surface + // (installed above, after make_syntax_registry) plus pmacs.command + // / pmacs.keymap / pmacs.editor (all pre-runtime). + lua_host + .eval( + Some("@pmacs/builtin/runtime/fold.lua"), + include_str!("../builtin/runtime/fold.lua"), + ) + .expect("load fold builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/indent.lua"), @@ -551,6 +574,7 @@ impl EditorState { interactive_origin, async_runtime, syntax_registry, + fold_registry, process_supervisor, terminal_manager, lsp_manager, diff --git a/src/editor_core.rs b/src/editor_core.rs index ded8143..5afbe90 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -214,6 +214,13 @@ pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner /// of every buffer; windows reference buffers by [`BufferId`]. pub registry: SharedRegistry, + /// Per-buffer fold stores (Arc 6). Shared with `EditorState`, the + /// semantic `FoldState` producer, and the `pmacs.fold` Lua surface + /// — the same `Rc`. The core reaches it so the six point-anchored + /// edit primitives can run the dispatch-layer pre-edit unfold + /// (Q#FD5): a command-path self-insert/delete at a point inside a + /// fold unfolds it before the edit applies. + pub fold_registry: crate::fold::SharedFoldRegistry, /// All windows, keyed by id for stable iteration. `WindowId`s /// are globally unique across all frontends; each /// [`FrontendView`] in `views` references a subset via its @@ -383,6 +390,7 @@ impl EditorCore { ); Self { registry, + fold_registry: crate::fold::make_shared_fold_registry(), windows, views, status: String::new(), @@ -1827,11 +1835,27 @@ impl EditorCore { aw.goal_col = None; } + /// Dispatch-layer pre-edit unfold (Arc 6, Q#FD5). Before a + /// command-path point-anchored edit (the six primitives below), + /// unfold every fold containing the active point so a self-insert or + /// delete inside a collapsed region reveals it rather than landing + /// invisibly. Keyed on the authenticated source frontend's active + /// point (this is `active_window().cursor`), not the transport. A + /// no-op when the buffer has no folds. Interactive Lua-command edits + /// (yank/query-replace/comment) reach the buffer through a different + /// path and are a named Stage 2 widening; CRDT-origin is Stage 3. + fn unfold_before_point_edit(&self) { + let id = self.active_buffer_id(); + let point = self.active_window().cursor; + self.fold_registry.unfold_containing(id, point); + } + /// Insert a single character at the cursor. Returns `true` iff the /// edit landed: a rejecting buffer intercept reports via the status /// line and returns `false`, and callers must not mutate dependent /// state (e.g. selection anchors) on a failed insert (Q#AI9). pub fn insert_char(&mut self, ch: char) -> bool { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let mut buf = [0u8; 4]; let s = ch.encode_utf8(&mut buf); @@ -1867,6 +1891,7 @@ impl EditorCore { /// delegates to [`Self::insert_char`] (a plain insert). The cursor /// lands just past the inserted bytes and any selection is cleared. pub fn insert_char_over_region(&mut self, ch: char) { + self.unfold_before_point_edit(); let Some((lo, hi)) = self.active_region() else { // Q#AI9: an empty selection (anchor == cursor) reports no // region yet stays armed — the insert moves the cursor off @@ -1908,6 +1933,7 @@ impl EditorCore { /// Delete the codepoint immediately before the cursor. pub fn backspace(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; if cursor == 0 { @@ -1929,6 +1955,7 @@ impl EditorCore { /// Delete the codepoint at the cursor (forward delete). pub fn delete_forward(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; let id = self.active_buffer_id(); @@ -1952,6 +1979,7 @@ impl EditorCore { /// between the cursor and where [`Self::move_word_left`] would /// land. pub fn delete_word_backward(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; if cursor == 0 { @@ -1979,6 +2007,7 @@ impl EditorCore { /// [`Self::delete_forward`] over the gap from the cursor to where /// [`Self::move_word_right`] would land. pub fn delete_word_forward(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; let id = self.active_buffer_id(); diff --git a/src/fold.rs b/src/fold.rs new file mode 100644 index 0000000..3772b34 --- /dev/null +++ b/src/fold.rs @@ -0,0 +1,817 @@ +// fold.rs --- Structural code-folding engine (Arc 6, Stage 1). + +//! The instance-side fold engine: a per-buffer fold store, a structural +//! fold source over the tree-sitter parse, and the state-aware fold +//! operations the Lua command/data surface drives. No rendering lives +//! here — Stage 2 (grid) and Stage 3 (GPU) consume the store; the +//! semantic producer ships it as `FoldState`. +//! +//! Design (see `docs/folding-framing.md`, approved rev 5): +//! +//! - **Store = a set of byte ranges** attached to the buffer as a +//! [`View`] so it translates across every edit, provenance-blind +//! (Q#FD2/FD3/FD5). Stored range = `[end of head line, end of the +//! last hidden line]`; containment is **start-exclusive, +//! end-inclusive** `(start, end]` so a point at the end of the head +//! line is *outside* (typing there shifts the fold right, landing the +//! character visible on the head line) while a point at the end of the +//! last hidden line is *inside* (typing there unfolds). +//! - **Source = structural node folding** (Q#FD1): the nearest enclosing +//! block-like node ≥ 2 source lines → resolve introducer↔body → the +//! head line is *the line immediately above the first hidden line* +//! (so a rustfmt-wrapped signature or a `where` clause stays visible — +//! hideshow / LSP `foldingRange` parity) → a **closer-aware tail** +//! keeps a closing-delimiter line visible (`} else {`, `}, [deps])`). +//! - **Translate + drop only** (Q#FD6): an edit strictly inside the +//! interior shifts the fold's end; an edit that crosses the head or +//! tail boundary drops the fold. The *interactive* unfold-on-typing is +//! a dispatch-layer pre-edit step (see `EditorCore`), not here. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use pmacs_protocol::{BufferId, ByteRange}; +use tree_sitter::Node; + +use crate::buffer::{Buffer, BufferError, ViewId}; +use crate::rope::Edit; +use crate::syntax::ParseTreeBundle; +use crate::view::View; + +const OPEN_DELIMS: &[u8] = b"{[("; +const CLOSE_DELIMS: &[u8] = b"}])"; + +// --------------------------------------------------------------------------- +// Line math (a self-contained copy of the `highlight.rs` scan — kept private +// so the fold source has no cross-module coupling). +// --------------------------------------------------------------------------- + +/// `out[n]` = start byte of line `n`; `out` always begins with `0`. The +/// number of lines is `out.len()` (a trailing entry past the final `\n` +/// is included, mirroring `highlight::compute_line_offsets`). +fn compute_line_offsets(source: &[u8]) -> Vec { + let mut out = Vec::with_capacity(source.len() / 32 + 1); + out.push(0); + for (i, b) in source.iter().enumerate() { + if *b == b'\n' { + out.push(i as u32 + 1); + } + } + out +} + +/// Index of the line containing byte `offset`. +fn line_at_offset(line_offsets: &[u32], offset: u32) -> usize { + match line_offsets.binary_search(&offset) { + Ok(i) => i, + Err(i) => i.saturating_sub(1), + } +} + +/// The byte offset just past line `row`'s last *visible* character — i.e. +/// the position of the row's terminating `\n`, or `source.len()` for the +/// final unterminated line. This is the "end of line" the stored range +/// uses for both its head and tail. +fn line_content_end(source: &[u8], line_offsets: &[u32], row: usize) -> u64 { + let start = line_offsets + .get(row) + .copied() + .unwrap_or(source.len() as u32) as usize; + let next = line_offsets + .get(row + 1) + .copied() + .unwrap_or(source.len() as u32) as usize; + let mut end = next.min(source.len()); + if end > start && source[end - 1] == b'\n' { + end -= 1; + } + end as u64 +} + +/// True iff line `row`'s first non-whitespace byte is a closing delimiter +/// (`}`, `)`, `]`) — the closer-aware tail test. +fn line_starts_with_closer(source: &[u8], line_offsets: &[u32], row: usize) -> bool { + let Some(&ls) = line_offsets.get(row) else { + return false; + }; + let mut i = ls as usize; + while i < source.len() && (source[i] == b' ' || source[i] == b'\t') { + i += 1; + } + i < source.len() && CLOSE_DELIMS.contains(&source[i]) +} + +// --------------------------------------------------------------------------- +// FoldStore — the per-buffer set of collapsed ranges. +// --------------------------------------------------------------------------- + +/// A buffer's set of currently-collapsed ranges. Kept sorted by +/// `(start, end)`; nested folds are allowed; exact duplicates are not. +#[derive(Debug, Default)] +pub struct FoldStore { + folds: Vec, +} + +impl FoldStore { + /// An empty store. + #[must_use] + pub fn new() -> Self { + Self { folds: Vec::new() } + } + + /// Whether the store holds no folds. + #[must_use] + pub fn is_empty(&self) -> bool { + self.folds.is_empty() + } + + /// The current folds, sorted and stable — the form the producer diffs + /// and `pmacs.fold.folds` returns. + #[must_use] + pub fn folds(&self) -> Vec { + self.folds.clone() + } + + /// Whether an exactly-equal fold range is already stored. + #[must_use] + pub fn contains_exact(&self, r: ByteRange) -> bool { + self.folds.contains(&r) + } + + /// Add a fold. Rejects an empty/inverted range or an exact duplicate; + /// returns whether it was added. + pub fn insert(&mut self, r: ByteRange) -> bool { + if r.end <= r.start || self.contains_exact(r) { + return false; + } + self.folds.push(r); + self.normalize(); + true + } + + /// Remove an exact fold; returns whether one was removed. + pub fn remove(&mut self, r: ByteRange) -> bool { + let before = self.folds.len(); + self.folds.retain(|f| *f != r); + self.folds.len() != before + } + + /// Drop every fold; returns whether anything was cleared. + pub fn clear(&mut self) -> bool { + let had = !self.folds.is_empty(); + self.folds.clear(); + had + } + + /// Folds whose interior contains `p` under `(start, end]` containment, + /// **innermost first** (a more deeply nested fold has the larger start). + #[must_use] + pub fn containing(&self, p: u64) -> Vec { + let mut v: Vec = self + .folds + .iter() + .copied() + .filter(|f| f.start < p && p <= f.end) + .collect(); + v.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end))); + v + } + + /// Remove every fold containing `p` (the dispatch-layer pre-edit + /// unfold, and the org-TAB "open all" leg). Returns the count removed. + pub fn unfold_containing(&mut self, p: u64) -> usize { + let before = self.folds.len(); + self.folds.retain(|f| !(f.start < p && p <= f.end)); + before - self.folds.len() + } + + fn normalize(&mut self) { + self.folds + .sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + self.folds.dedup(); + } + + /// Translate every fold across `edit`, dropping any whose head or tail + /// the edit crosses (Q#FD6). Provenance-blind: `Edit` carries no source + /// frontend, so this cannot (and must not) unfold-on-typing — that is + /// the dispatch layer's pre-edit job. + /// + /// Boundary handling mirrors `BufferStyleSpanTranslator`'s right-bias: + /// an insert exactly at the start (end of the head line) shifts the + /// whole fold right, so the character lands visible on the head line; + /// an insert exactly at the end is left outside. + pub fn translate(&mut self, edit: &Edit) { + let os = edit.range.start; + let oe = edit.range.end; + let old_len = oe - os; + let new_len = edit.inserted_len; + // Buffers broadcast no-op edits; nothing moved. + if old_len == 0 && new_len == 0 { + return; + } + // Shift a byte offset by the edit's signed length delta, in u64 + // arithmetic (no `as i64` wrap): grow by `new_len - old_len` or + // shrink by `old_len - new_len`, saturating at 0. + let shift = |x: u64| -> u64 { + if new_len >= old_len { + x + (new_len - old_len) + } else { + x.saturating_sub(old_len - new_len) + } + }; + let mut kept = Vec::with_capacity(self.folds.len()); + for f in self.folds.drain(..) { + let (s, e) = (f.start, f.end); + let next = if oe <= s { + // Strictly before the fold (an insert at exactly `s` lands + // here, shifting the fold right — the head-line right-bias). + Some(ByteRange { + start: shift(s), + end: shift(e), + }) + } else if os >= e { + // Strictly after the fold (an insert at exactly `e` too). + Some(ByteRange { start: s, end: e }) + } else if os > s && oe < e { + // Strictly inside the interior — the fold still hides a + // valid interior; shift its end by the delta. + let e2 = shift(e); + if e2 > s { + Some(ByteRange { start: s, end: e2 }) + } else { + None + } + } else { + // The edit crosses the head or tail boundary (or engulfs + // the fold) — the head/tail it named is gone. Drop it. + None + }; + if let Some(r) = next { + kept.push(r); + } + } + self.folds = kept; + self.normalize(); + } +} + +// --------------------------------------------------------------------------- +// FoldStoreTranslator — the buffer-attached View that keeps the store in +// sync with edits. +// --------------------------------------------------------------------------- + +struct FoldStoreTranslator { + store: Arc>, +} + +impl View for FoldStoreTranslator { + fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), BufferError> { + self.store + .lock() + .expect("fold store mutex poisoned") + .translate(edit); + Ok(()) + } + + fn kind(&self) -> &'static str { + "fold_store_translator" + } +} + +// --------------------------------------------------------------------------- +// FoldRegistry — per-buffer stores, keyed by BufferId (the SyntaxRegistry +// model), each paired with a translator View over the same Arc. +// --------------------------------------------------------------------------- + +/// Shared, cloneable handle to the process's fold stores. Held by +/// `EditorCore` (for the pre-edit unfold), by `EditorState` and the +/// semantic producer (to ship `FoldState`), and by the `pmacs.fold` Lua +/// bindings (via Lua app-data) — all the same `Rc`. +pub type SharedFoldRegistry = Rc; + +struct FoldEntry { + store: Arc>, + view: ViewId, +} + +/// One fold store per buffer. Interior-mutable so a `&SharedFoldRegistry` +/// suffices everywhere. +#[derive(Default)] +pub struct FoldRegistry { + stores: RefCell>, +} + +/// Build a fresh, empty fold registry. +#[must_use] +pub fn make_shared_fold_registry() -> SharedFoldRegistry { + Rc::new(FoldRegistry::default()) +} + +impl FoldRegistry { + /// The buffer's store if one exists — the lookup used by read-only + /// callers (the pre-edit unfold and the producer) that must not + /// materialize a store or attach a view. + #[must_use] + pub fn store(&self, buf: BufferId) -> Option>> { + self.stores.borrow().get(&buf).map(|e| Arc::clone(&e.store)) + } + + /// The buffer's folds (sorted; empty when it has no store). + #[must_use] + pub fn folds(&self, buf: BufferId) -> Vec { + self.store(buf) + .map(|s| s.lock().expect("fold store mutex poisoned").folds()) + .unwrap_or_default() + } + + /// Get-or-create the store for `buffer`, attaching the translator view + /// on first materialization so every later edit is tracked. + pub fn store_or_attach(&self, buffer: &mut Buffer) -> Arc> { + let id = buffer.id(); + if let Some(existing) = self.stores.borrow().get(&id) { + return Arc::clone(&existing.store); + } + let store = Arc::new(Mutex::new(FoldStore::new())); + let view = buffer.attach_view(Box::new(FoldStoreTranslator { + store: Arc::clone(&store), + })); + self.stores.borrow_mut().insert( + id, + FoldEntry { + store: Arc::clone(&store), + view, + }, + ); + store + } + + /// Drop the buffer's store and detach its translator view — the + /// content-replacement (revert/reload) and buffer-close reset. Named + /// bytes no longer exist, so revalidation is not attempted (Q#FD8). + pub fn forget(&self, buffer: &mut Buffer) { + if let Some(entry) = self.stores.borrow_mut().remove(&buffer.id()) { + buffer.detach_view(entry.view); + } + } + + /// Unfold every fold in `buf` containing `p`. The pre-edit hook the + /// six `EditorCore` edit primitives call; a no-op when the buffer has + /// no store (hence no folds). Returns the count unfolded. + pub fn unfold_containing(&self, buf: BufferId, p: u64) -> usize { + match self.store(buf) { + Some(s) => s + .lock() + .expect("fold store mutex poisoned") + .unfold_containing(p), + None => 0, + } + } +} + +// --------------------------------------------------------------------------- +// Structural fold source. +// --------------------------------------------------------------------------- + +/// The innermost foldable region at `pos`, or `None` — the fold target the +/// data-API `toggle` and a bare "fold this" use. +#[must_use] +pub fn fold_target_at(bundle: &ParseTreeBundle, pos: u64) -> Option { + candidates_at(bundle, pos).into_iter().next() +} + +/// Every foldable region enclosing `pos`, **innermost first**. The +/// state-aware commands walk this list against the store to decide what to +/// close (innermost open) or open (outermost closed). +#[must_use] +pub fn candidates_at(bundle: &ParseTreeBundle, pos: u64) -> Vec { + let source: &[u8] = &bundle.source; + let line_offsets = compute_line_offsets(source); + let Some(node) = innermost_named_node(bundle, pos) else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut cur = Some(node); + while let Some(n) = cur { + if let Some(r) = fold_from_node(n, source, &line_offsets) + && !out.contains(&r) + { + out.push(r); + } + cur = n.parent(); + } + out +} + +/// The top-level foldable regions in the buffer — what `fold.close-all` +/// collapses (Emacs `hs-hide-all`: top level only, nested not auto-folded). +#[must_use] +pub fn top_level_fold_targets(bundle: &ParseTreeBundle) -> Vec { + let source: &[u8] = &bundle.source; + let line_offsets = compute_line_offsets(source); + let root = bundle.root_tree().root_node(); + let mut out = Vec::new(); + let mut cursor = root.walk(); + for child in root.named_children(&mut cursor) { + if let Some(r) = fold_from_node(child, source, &line_offsets) + && !out.contains(&r) + { + out.push(r); + } + } + out +} + +/// The innermost named node at `pos`, resolved through injection layers: +/// the deepest layer whose root span covers `pos` wins (a fenced code block +/// inside markdown resolves to the inner block, not the markdown node). +fn innermost_named_node(bundle: &ParseTreeBundle, pos: u64) -> Option> { + let p = pos as usize; + let mut best: Option<&crate::syntax::Layer> = None; + for layer in &bundle.layers { + let root = layer.tree.root_node(); + if root.start_byte() <= p && p <= root.end_byte() { + best = match best { + Some(b) if b.depth >= layer.depth => Some(b), + _ => Some(layer), + }; + } + } + best?.tree.root_node().named_descendant_for_byte_range(p, p) +} + +/// Compute the fold range a single node yields, or `None` if it is not a +/// foldable structure (< 2 source lines, no block-like body, or a +/// normalized interior with < 1 hidden line). +fn fold_from_node(n: Node<'_>, source: &[u8], line_offsets: &[u32]) -> Option { + // Match condition: the node spans >= 2 source lines. + if n.end_position().row <= n.start_position().row { + return None; + } + let (b, introduced) = resolve_body(n, source)?; + + let b_start_row = b.start_position().row; + let b_end_row = b.end_position().row; + let b_start_byte = b.start_byte(); + if b_start_byte >= source.len() { + return None; + } + let is_brace = OPEN_DELIMS.contains(&source[b_start_byte]); + + // Head line = the line immediately above the first hidden line. For a + // brace body that is the `{` line (the introducer's own line, or the + // `) -> bool {` line when a signature wraps). For an *introduced* + // delimiter-less body (a Python `block`) the introducer's header ends + // on the line above, so it is `b_start_row - 1`. + let head_row = if is_brace { + b_start_row + } else if introduced && b_start_row > 0 { + b_start_row - 1 + } else { + b_start_row + }; + + // Tail: a closing-delimiter line stays visible (`} else {`); a + // delimiter-less body hides through its last line. + let last_hidden_row = if line_starts_with_closer(source, line_offsets, b_end_row) { + if b_end_row == 0 { + return None; + } + b_end_row - 1 + } else { + b_end_row + }; + + // Foldability = the normalized interior has >= 1 hidden line. + if last_hidden_row < head_row + 1 { + return None; + } + let start = line_content_end(source, line_offsets, head_row); + let end = line_content_end(source, line_offsets, last_hidden_row); + if end <= start { + return None; + } + Some(ByteRange { start, end }) +} + +/// Resolve the interior-defining body `B` and whether it is *introduced* +/// (its parent is an introducer whose body field is `B`). If `n` is itself +/// a body, use it; if it is an introducer with a block-like body child, +/// descend to that child (Q#FD1 step 2 — matching/`close-all` association). +fn resolve_body<'tree>(n: Node<'tree>, source: &[u8]) -> Option<(Node<'tree>, bool)> { + if is_body_kind(n, source) { + return Some((n, is_introduced(n))); + } + if let Some(b) = body_child(n) + && is_body_kind(b, source) + { + return Some((b, true)); + } + None +} + +fn body_child(n: Node) -> Option { + n.child_by_field_name("body") + .or_else(|| n.child_by_field_name("consequence")) +} + +fn is_introduced(n: Node<'_>) -> bool { + if let Some(p) = n.parent() + && let Some(b) = body_child(p) + { + return b.id() == n.id(); + } + false +} + +/// A node is a fold *body* if it opens with a bracket delimiter (a brace +/// body) or is a grammar block node (an indentation body). The delimiter +/// probe generalizes across grammars without a per-language kind list. +fn is_body_kind(n: Node<'_>, source: &[u8]) -> bool { + let sb = n.start_byte(); + if sb < source.len() && OPEN_DELIMS.contains(&source[sb]) { + return true; + } + matches!( + n.kind(), + "block" + | "statement_block" + | "declaration_list" + | "field_declaration_list" + | "enum_variant_list" + | "block_mapping" + | "block_sequence" + ) || n.kind().ends_with("_body") +} + +// --------------------------------------------------------------------------- +// State-aware operations (Q#FD4 shared-head ordering). Pure over a store +// (+ parse bundle); the Lua bindings drive them and move the point. +// --------------------------------------------------------------------------- + +/// Close the innermost still-open foldable region at `p`; returns the newly +/// folded range. Repeated calls walk outward. +pub fn close_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> Option { + for c in candidates_at(bundle, p) { + if !store.contains_exact(c) { + store.insert(c); + return Some(c); + } + } + None +} + +/// Open the outermost currently-closed fold at `p`; returns the removed +/// range. Repeated calls walk inward. +pub fn open_at(store: &mut FoldStore, p: u64) -> Option { + let mut containing = store.containing(p); + // `containing` is innermost-first; the outermost has the smallest start. + containing.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + let outer = containing.into_iter().next()?; + store.remove(outer); + Some(outer) +} + +/// The result of an org-TAB-style toggle cycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CycleOutcome { + /// Closed one more (innermost open) fold on the head. + Closed(ByteRange), + /// Every fold on the head was already closed; opened them all. + OpenedAll(usize), + /// Nothing foldable and nothing folded at the point. + Nothing, +} + +/// `fold.toggle`: org-TAB cycle. While any foldable region at `p` is open, +/// close the innermost open one; once all are closed, one more press opens +/// them all. Every press has a visible effect (Q#FD4/R3-2). +pub fn cycle_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> CycleOutcome { + let candidates = candidates_at(bundle, p); + if candidates.iter().any(|c| !store.contains_exact(*c)) { + for c in &candidates { + if !store.contains_exact(*c) { + store.insert(*c); + return CycleOutcome::Closed(*c); + } + } + } + let n = store.unfold_containing(p); + if n > 0 { + CycleOutcome::OpenedAll(n) + } else { + CycleOutcome::Nothing + } +} + +/// The result of a data-API `toggle(buffer, pos)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToggleOutcome { + /// Folded the innermost tree target at the point. + Folded(ByteRange), + /// Unfolded the stored fold(s) containing the point. + Unfolded(usize), + /// Nothing foldable and nothing folded at the point. + Nothing, +} + +/// Data-API `toggle`: unfold if a stored fold contains `pos`, else fold the +/// innermost tree target at `pos`. +pub fn toggle_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> ToggleOutcome { + if !store.containing(p).is_empty() { + return ToggleOutcome::Unfolded(store.unfold_containing(p)); + } + match fold_target_at(bundle, p) { + Some(t) => { + store.insert(t); + ToggleOutcome::Folded(t) + } + None => ToggleOutcome::Nothing, + } +} + +/// Normalize an arbitrary data-API range (no node, so no introducer/closer +/// inference — the caller names exactly what to hide). Head line = the line +/// containing `range.start`; the hidden lines are the full lines strictly +/// after it through the line containing `range.end` (or the previous line +/// when `range.end` sits at a line start). `None` if that is < 1 hidden +/// line. +#[must_use] +pub fn normalize_arbitrary_range(source: &[u8], range: ByteRange) -> Option { + if range.start > source.len() as u64 || range.end > source.len() as u64 { + return None; + } + let line_offsets = compute_line_offsets(source); + let head_row = line_at_offset(&line_offsets, range.start as u32); + let end_row_raw = line_at_offset(&line_offsets, range.end as u32); + let end_at_line_start = line_offsets.get(end_row_raw).copied() == Some(range.end as u32); + let last_hidden_row = if end_at_line_start && end_row_raw > 0 { + end_row_raw - 1 + } else { + end_row_raw + }; + if last_hidden_row < head_row + 1 { + return None; + } + let start = line_content_end(source, &line_offsets, head_row); + let end = line_content_end(source, &line_offsets, last_hidden_row); + if end <= start { + return None; + } + Some(ByteRange { start, end }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rope::{Edit, Range, Rope}; + + fn edit(range: Range, inserted_len: u64) -> Edit { + Edit { + new_rope: Rope::new(), + range, + inserted_len, + crdt_op: None, + } + } + + fn r(start: u64, end: u64) -> ByteRange { + ByteRange { start, end } + } + + #[test] + fn insert_at_head_boundary_shifts_fold_right() { + // `(start, end]` containment: an insert exactly at the end of the + // head line lands *before* the fold, shifting it right so the + // character stays visible on the head line. + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(10, 10), 1)); + assert_eq!(store.folds(), vec![r(11, 31)]); + } + + #[test] + fn insert_strictly_inside_grows_the_end() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(20, 20), 3)); + assert_eq!(store.folds(), vec![r(10, 33)]); + } + + #[test] + fn insert_at_tail_boundary_leaves_fold_untouched() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(30, 30), 4)); + assert_eq!(store.folds(), vec![r(10, 30)]); + } + + #[test] + fn edit_before_fold_shifts_whole_range() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(2, 5), 0)); // delete 3 bytes before + assert_eq!(store.folds(), vec![r(7, 27)]); + } + + #[test] + fn edit_crossing_head_boundary_drops_fold() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + // A delete starting at the head boundary destroys the head. + store.translate(&edit(Range::new(10, 15), 0)); + assert!(store.is_empty()); + } + + #[test] + fn edit_crossing_tail_boundary_drops_fold() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(25, 40), 0)); + assert!(store.is_empty()); + } + + #[test] + fn containment_is_start_exclusive_end_inclusive() { + let store = { + let mut s = FoldStore::new(); + s.insert(r(10, 30)); + s + }; + assert!(store.containing(10).is_empty(), "start is exclusive"); + assert_eq!(store.containing(11), vec![r(10, 30)]); + assert_eq!(store.containing(30), vec![r(10, 30)], "end is inclusive"); + assert!(store.containing(31).is_empty()); + } + + #[test] + fn containing_is_innermost_first() { + let mut store = FoldStore::new(); + store.insert(r(10, 100)); // outer + store.insert(r(20, 60)); // inner + assert_eq!(store.containing(30), vec![r(20, 60), r(10, 100)]); + } + + #[test] + fn unfold_containing_removes_all_nested() { + let mut store = FoldStore::new(); + store.insert(r(10, 100)); + store.insert(r(20, 60)); + store.insert(r(200, 300)); // unrelated + assert_eq!(store.unfold_containing(30), 2); + assert_eq!(store.folds(), vec![r(200, 300)]); + } + + #[test] + fn insert_rejects_empty_and_duplicate() { + let mut store = FoldStore::new(); + assert!(store.insert(r(10, 30))); + assert!(!store.insert(r(10, 30)), "duplicate rejected"); + assert!(!store.insert(r(5, 5)), "empty rejected"); + assert!(!store.insert(r(9, 8)), "inverted rejected"); + } + + #[test] + fn open_at_takes_outermost_closed() { + let mut store = FoldStore::new(); + store.insert(r(10, 100)); + store.insert(r(20, 60)); + assert_eq!(open_at(&mut store, 30), Some(r(10, 100))); + assert_eq!(open_at(&mut store, 30), Some(r(20, 60))); + assert_eq!(open_at(&mut store, 30), None); + } + + #[test] + fn line_content_end_excludes_newline() { + let src = b"abc\ndef\nghi"; + let off = compute_line_offsets(src); + assert_eq!(line_content_end(src, &off, 0), 3); // "abc" + assert_eq!(line_content_end(src, &off, 1), 7); // "def" + assert_eq!(line_content_end(src, &off, 2), 11); // "ghi" (no newline) + } + + #[test] + fn normalize_arbitrary_range_basic() { + // 0123 4567 89012 + let src = b"aaa\nbbb\nccc\nddd"; + // range covering into line 1 and line 2 -> hidden lines 1..2 + let out = normalize_arbitrary_range(src, r(1, 9)).expect("foldable"); + assert_eq!(out, r(3, 11)); // [end of line0, end of line2] + } + + #[test] + fn normalize_arbitrary_range_end_at_line_start_drops_a_line() { + let src = b"aaa\nbbb\nccc\nddd"; + // end exactly at start of line 2 (byte 8) -> last hidden line is 1. + let out = normalize_arbitrary_range(src, r(1, 8)).expect("foldable"); + assert_eq!(out, r(3, 7)); + } + + #[test] + fn normalize_arbitrary_range_rejects_sub_one_line() { + let src = b"aaa\nbbb\nccc"; + // start and end on the same line -> zero hidden lines. + assert!(normalize_arbitrary_range(src, r(1, 2)).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index bad3e6f..392f7b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,6 +75,7 @@ pub mod document_highlight; pub mod editor; pub mod editor_core; pub mod file_io; +pub mod fold; pub mod font_pref; pub mod formatting; pub mod frontend; diff --git a/src/lua_bindings/fold.rs b/src/lua_bindings/fold.rs new file mode 100644 index 0000000..b79c2c7 --- /dev/null +++ b/src/lua_bindings/fold.rs @@ -0,0 +1,381 @@ +// lua_bindings/fold.rs --- pmacs.fold: the code-folding Lua surface (Arc 6). + +//! `pmacs.fold.*` --- the Lua surface over [`crate::fold`]. Installed +//! entirely from Rust (like `pmacs.config`), after `make_syntax_registry` +//! so the tree-consuming operations can reach the parse tree via app-data. +//! +//! ```lua +//! -- data API (explicit buffer, no ambient resolution): +//! pmacs.fold.fold(buffer, { start = ..., ["end"] = ... }) +//! pmacs.fold.unfold(buffer, { start = ..., ["end"] = ... }) +//! pmacs.fold.folds(buffer) -- -> { {start=,["end"]=}, ... } +//! pmacs.fold.toggle(buffer, pos) +//! +//! -- interactive helpers the fold.lua commands drive (explicit buffer + +//! -- point resolved from the invoking frontend): +//! pmacs.fold.close(buffer, pos) -- close innermost open +//! pmacs.fold.open(buffer, pos) -- open outermost closed +//! pmacs.fold.cycle(buffer, pos) -- org-TAB toggle +//! pmacs.fold.close_all(buffer) -- top-level regions only +//! pmacs.fold.open_all(buffer) +//! ``` +//! +//! Fold *creation* refuses against an absent or stale parse tree (Q#FD10) +//! and validates the buffer kind / UTF-8 boundaries / >= 1-hidden-line +//! rule (Q#FD11); a rejection reports on the status line and returns +//! `false`. Folding a range containing the invoking frontend's point moves +//! that point to the head line (Q#FD3). + +use std::sync::{Arc, Mutex}; + +use mlua::{Lua, Table}; +use pmacs_protocol::{BufferId, ByteRange}; + +use super::{ + BufferIdLua, SharedCore, resolve, resolve_mut, u64_from_lua, with_registry, with_registry_mut, +}; +use crate::buffer::Buffer; +use crate::fold::{self, FoldStore, SharedFoldRegistry}; +use crate::syntax::{ParseTreeBundle, SharedSyntaxRegistry}; + +/// Install `pmacs.fold` over `fold_registry` (the same `Rc` the core owns). +#[allow( + clippy::too_many_lines, + reason = "linear per-function registration of the pmacs.fold surface, \ + mirroring install_config; splitting fragments the wiring" +)] +pub fn install_fold(lua: &Lua, fold_registry: &SharedFoldRegistry) -> mlua::Result<()> { + let fold_mod = lua.create_table()?; + + // ---- data API --------------------------------------------------------- + + { + let reg = fold_registry.clone(); + fold_mod.set( + "fold", + lua.create_function( + move |lua, (buf, range): (BufferIdLua, Table)| -> mlua::Result { + let id = buf.id(); + let requested = range_from_table(&range)?; + let Some(bytes) = document_bytes(lua, id)? else { + set_status(lua, "fold rejected: not a document buffer"); + return Ok(false); + }; + if requested.start > bytes.len() as u64 + || requested.end > bytes.len() as u64 + || !is_char_boundary(&bytes, requested.start) + || !is_char_boundary(&bytes, requested.end) + { + set_status(lua, "fold rejected: out of bounds or not a char boundary"); + return Ok(false); + } + let Some(normalized) = fold::normalize_arbitrary_range(&bytes, requested) + else { + set_status(lua, "fold rejected: range hides no full line"); + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + let added = lock(&store).insert(normalized); + Ok(added) + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "unfold", + lua.create_function( + move |lua, (buf, range): (BufferIdLua, Table)| -> mlua::Result { + let id = buf.id(); + let requested = range_from_table(&range)?; + let Some(store) = reg.store(id) else { + return Ok(false); + }; + // Accept an exact stored range (the `folds()` round-trip) + // or an arbitrary range that normalizes to a stored one. + if lock(&store).remove(requested) { + return Ok(true); + } + if let Ok(Some(bytes)) = document_bytes(lua, id) + && let Some(normalized) = + fold::normalize_arbitrary_range(&bytes, requested) + { + return Ok(lock(&store).remove(normalized)); + } + Ok(false) + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "folds", + lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result { + let out = lua.create_table()?; + for (i, r) in reg.folds(buf.id()).into_iter().enumerate() { + out.set(i + 1, range_to_table(lua, r)?)?; + } + Ok(out) + })?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "toggle", + lua.create_function( + move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + // A stored fold at the point unfolds without needing a tree. + if let Some(store) = reg.store(id) { + let mut s = lock(&store); + if !s.containing(p).is_empty() { + s.unfold_containing(p); + return Ok(true); + } + } + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + match fold::toggle_at(&mut lock(&store), &bundle, p) { + fold::ToggleOutcome::Folded(r) => { + maybe_move_point(lua, id, r); + Ok(true) + } + fold::ToggleOutcome::Unfolded(_) => Ok(true), + fold::ToggleOutcome::Nothing => { + set_status(lua, "nothing foldable here"); + Ok(false) + } + } + }, + )?, + )?; + } + + // ---- interactive helpers (state-aware; driven by fold.lua) ------------ + + { + let reg = fold_registry.clone(); + fold_mod.set( + "close", + lua.create_function( + move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + if let Some(r) = fold::close_at(&mut lock(&store), &bundle, p) { + maybe_move_point(lua, id, r); + Ok(true) + } else { + set_status(lua, "no more folds to close here"); + Ok(false) + } + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "open", + lua.create_function( + move |_, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + let Some(store) = reg.store(id) else { + return Ok(false); + }; + Ok(fold::open_at(&mut lock(&store), p).is_some()) + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "cycle", + lua.create_function( + move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + match fold::cycle_at(&mut lock(&store), &bundle, p) { + fold::CycleOutcome::Closed(r) => { + maybe_move_point(lua, id, r); + Ok(true) + } + fold::CycleOutcome::OpenedAll(_) => Ok(true), + fold::CycleOutcome::Nothing => { + set_status(lua, "nothing foldable here"); + Ok(false) + } + } + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "close_all", + lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result { + let id = buf.id(); + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(0); + }; + let targets = fold::top_level_fold_targets(&bundle); + let store = store_for(lua, ®, id)?; + let mut s = lock(&store); + let mut n = 0i64; + for t in targets { + if s.insert(t) { + n += 1; + } + } + Ok(n) + })?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "open_all", + lua.create_function(move |_, buf: BufferIdLua| -> mlua::Result { + match reg.store(buf.id()) { + Some(store) => Ok(lock(&store).clear()), + None => Ok(false), + } + })?, + )?; + } + + let pmacs: Table = lua.globals().get("pmacs")?; + pmacs.set("fold", fold_mod)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn lock(store: &Arc>) -> std::sync::MutexGuard<'_, FoldStore> { + store.lock().expect("fold store mutex poisoned") +} + +fn range_from_table(t: &Table) -> mlua::Result { + let start = u64_from_lua(t.raw_get::("start")?)?; + let end = u64_from_lua(t.raw_get::("end")?)?; + Ok(ByteRange { start, end }) +} + +fn range_to_table(lua: &Lua, r: ByteRange) -> mlua::Result
{ + let t = lua.create_table()?; + // Byte offsets are always well within `i64` range; the Lua integer + // type is `i64`. + t.set("start", r.start.cast_signed())?; + t.set("end", r.end.cast_signed())?; + Ok(t) +} + +/// The buffer's bytes if it is a normal document buffer, or `None` if it is +/// read-only (a terminal identity buffer or other non-document buffer — +/// the Q#FD11 "normal document buffer" guard). +fn document_bytes(lua: &Lua, buf: BufferId) -> mlua::Result>> { + with_registry(lua, |r| { + let buffer = resolve(r, buf)?; + if buffer.is_read_only() { + return Ok(None); + } + Ok(Some(buffer_bytes(buffer))) + }) +} + +fn buffer_bytes(buf: &Buffer) -> Vec { + let len = buf.len(); + let mut bytes = vec![0u8; len as usize]; + buf.snapshot_rope().slice(0, len, &mut bytes); + bytes +} + +fn is_char_boundary(bytes: &[u8], pos: u64) -> bool { + let p = pos as usize; + p == 0 || p == bytes.len() || (p < bytes.len() && (bytes[p] & 0xC0) != 0x80) +} + +/// The get-or-attach store handle for `buf`, materializing the store and +/// attaching its translator view on first use. +fn store_for( + lua: &Lua, + reg: &SharedFoldRegistry, + buf: BufferId, +) -> mlua::Result>> { + with_registry_mut(lua, |r| { + let buffer = resolve_mut(r, buf)?; + Ok(reg.store_or_attach(buffer)) + }) +} + +/// The settled parse bundle for `buf`, or `None` after reporting the +/// stale/absent-tree rejection on the status line (Q#FD10). +fn bundle_or_status(lua: &Lua, buf: BufferId) -> Option> { + match settled_bundle(lua, buf) { + Ok(bundle) => Some(bundle), + Err(reason) => { + set_status(lua, reason); + None + } + } +} + +fn settled_bundle(lua: &Lua, buf: BufferId) -> Result, &'static str> { + let syntax = lua + .app_data_ref::() + .ok_or("fold: no syntax registry")?; + let handle = syntax.view(buf).ok_or("fold: no parse for this buffer")?; + if handle.pending_edit_count() > 0 { + return Err("fold: parse is stale (edits pending); try again"); + } + handle.current().ok_or("fold: no parse yet; try again") +} + +/// Set the editor status line (rejection reporting). +fn set_status(lua: &Lua, msg: &str) { + if let Some(core) = lua.app_data_ref::() { + core.borrow_mut().status = msg.to_string(); + } +} + +/// Move the invoking frontend's point to the head line when a just-folded +/// range `r` contains it (Q#FD3). No-op if the folded buffer is not the +/// active one or the point is outside the fold. +fn maybe_move_point(lua: &Lua, buf: BufferId, r: ByteRange) { + if let Some(core) = lua.app_data_ref::() { + let mut c = core.borrow_mut(); + if c.active_buffer_id() == buf { + let point = c.active_window().cursor; + // `(start, end]` containment: a point strictly inside the fold + // moves to `start` (the end of the visible head line). + if r.start < point && point <= r.end { + c.set_cursor_byte(r.start); + } + } + } +} diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index ae04b3a..02dd286 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -85,6 +85,7 @@ use crate::workers_buffer; // stable. mod config; mod diag; +mod fold; mod index; mod mcp; // Every `pub` item a moved domain owned is re-exported so its prior @@ -94,6 +95,7 @@ mod mcp; // them), but they were `pub`, so their paths are preserved for // compile-compatibility; any deliberate narrowing is a separate change. pub use diag::install_diag; +pub use fold::install_fold; pub use index::{SharedProjectIndexer, install_project_index, make_project_indexer}; pub use mcp::{McpServerIdLua, install_mcp, make_mcp_manager}; diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 6a06ff9..25e7fea 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -26,9 +26,10 @@ //! and `Decorations` (M11.3), both span-granularity diffed (M11.4); //! `InlineAdornments` (Step 3, from the LSP inlay-hint store, //! M11.2-level suppression); `FileStyleSummary` (resolving Open Q#2 — -//! per-line dominant style for a minimap, generation-keyed). -//! `BlockAdornments` / `FoldState` / `ResourceOffer` remain wire- -//! declared but unproduced. +//! per-line dominant style for a minimap, generation-keyed); `FoldState` +//! (Arc 6 — the instance's authoritative fold set, authoritative-empty). +//! `BlockAdornments` / `ResourceOffer` remain wire-declared but +//! unproduced. use std::collections::HashMap; @@ -163,6 +164,16 @@ pub struct SemanticRenderState { /// byte-identical. `LastFrame::items` reuse keeps the shape /// uniform even though no segment diffing applies. last_adornments: HashMap>, + /// `FoldState` baseline (Arc 6). Whole-buffer, not viewport-clipped, + /// so a plain `Vec` per buffer suffices. Authoritative- + /// empty and diff-suppressed: the first sight of a buffer emits only + /// if a fold exists (no empty-frame spam), an unchanged set emits + /// nothing, and a `non-empty → empty` transition emits exactly one + /// empty frame so the frontend clears its fold mirror. Resets on + /// `BufferSnapshot` — the snapshot's own frontend-side fold-mirror + /// clear is what makes the empty-after-revert suppression correct + /// (Q#FD8, #120 class). + last_folds: HashMap>, /// `FileStyleSummary` baseline (post-M11 minimap producer, /// resolving design-note Open Q#2). The whole-file dominant-style /// summary is expensive to compute on a 100k-line file, so the @@ -417,6 +428,7 @@ impl SemanticRenderState { last_sent: HashMap::new(), last_decorations: HashMap::new(), last_adornments: HashMap::new(), + last_folds: HashMap::new(), last_search_prompt: HashMap::new(), last_menu_prompt: HashMap::new(), last_minibuffer: None, @@ -565,6 +577,7 @@ impl SemanticRenderState { self.last_style_gate.remove(&buffer_id); self.last_decorations.remove(&buffer_id); self.last_adornments.remove(&buffer_id); + self.last_folds.remove(&buffer_id); self.last_summary.remove(&buffer_id); self.last_status.remove(&buffer_id); self.last_search_prompt.remove(&buffer_id); @@ -583,11 +596,12 @@ impl SemanticRenderState { /// send. Returns an empty vec before the frontend declares a /// viewport. /// - /// `BlockAdornments` / `FoldState` are still deliberately *not* - /// produced: pmacs has no instance-side blame / lens / fold / diff - /// source yet. Their wire variants exist (T M11.1); their - /// producers wire in when those features land — the same - /// "declared, not yet wired" discipline. Emitting an empty message + /// `BlockAdornments` is still deliberately *not* produced: pmacs has + /// no instance-side blame / lens / diff source yet. (`FoldState` IS + /// produced now — Arc 6 — authoritative-empty via `fold_state_msg`.) + /// Its wire variant exists (T M11.1); its producer wires in when that + /// feature lands — the same "declared, not yet wired" discipline. + /// Emitting an empty message /// every frame would be waste, not honesty, so `InlineAdornments` is /// suppressed both when unchanged and when there is simply nothing /// to say (no hints, no prior non-empty send). @@ -775,6 +789,8 @@ impl SemanticRenderState { // --- InlineAdornments (Step 3 producer) --- out.extend(self.inline_adornments_msg(state, &vp)); + // --- FoldState (Arc 6 producer; authoritative-empty) --- + out.extend(self.fold_state_msg(state, vp.buffer_id)); // --- FileStyleSummary (minimap producer; Open Q#2) --- out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation)); // --- StatusFacts (status band; Q#S1, protocol v8) --- @@ -1400,6 +1416,35 @@ impl SemanticRenderState { }) } + /// The `FoldState` message for this frame, or `None` (Arc 6). The + /// instance's authoritative fold set for `buffer_id`, whole-buffer + /// (folds are a handful; `close-all` is top-level only, so the set is + /// O(top-level blocks) — no viewport scoping). Authoritative-empty and + /// diff-suppressed exactly like `inline_adornments_msg`: the first + /// sight of a buffer speaks only if a fold exists, an unchanged set is + /// silent, and a `non-empty → empty` transition emits one empty frame + /// so the frontend clears its mirror. Its baseline resets on + /// `BufferSnapshot`; see `on_buffer_snapshot_sent`. + fn fold_state_msg( + &mut self, + state: &EditorState, + buffer_id: BufferId, + ) -> Option { + let folds = state.fold_registry.folds(buffer_id); + let should_emit = match self.last_folds.get(&buffer_id) { + // First sight: speak only if there is a fold to show. + None => !folds.is_empty(), + // A real change; `empty → empty` conveys nothing and is + // suppressed, `non-empty → empty` is a change worth sending. + Some(prev) => *prev != folds && !(folds.is_empty() && prev.is_empty()), + }; + if !should_emit { + return None; + } + self.last_folds.insert(buffer_id, folds.clone()); + Some(InstanceMessage::FoldState { buffer_id, folds }) + } + /// The `FileStyleSummary` message for this frame, or `None`. The /// summary is keyed on CRDT `generation`: a buffer with an /// unchanged generation re-uses the cached summary and emits @@ -3238,11 +3283,11 @@ mod tests { /// All `InstanceMessage` variants the semantic projection may /// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, - /// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt` - /// (Q#SR5), `LineNumbers`, `ThemeFacts` (Q#TH7), `FontFacts` - /// (Q#F5), or `StatuslineSegments` (Q#SL7) — never `CellDelta`, - /// grid `Cursor`, or the still-unwired `BlockAdornments` / - /// `FoldState` families. + /// `FoldState` (Q#FD8), `FileStyleSummary`, `StatusFacts` (Q#S1), + /// `SearchPrompt` (Q#SR5), `LineNumbers`, `ThemeFacts` (Q#TH7), + /// `FontFacts` (Q#F5), or `StatuslineSegments` (Q#SL7) — never + /// `CellDelta`, grid `Cursor`, or the still-unwired `BlockAdornments` + /// family. fn assert_semantic_only(msgs: &[InstanceMessage]) { for m in msgs { assert!( @@ -3251,6 +3296,7 @@ mod tests { InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. } | InstanceMessage::InlineAdornments { .. } + | InstanceMessage::FoldState { .. } | InstanceMessage::FileStyleSummary { .. } | InstanceMessage::StatusFacts { .. } | InstanceMessage::SearchPrompt { .. } @@ -3999,9 +4045,11 @@ mod tests { } #[test] - fn block_adornments_and_fold_state_still_never_emitted() { - // BlockAdornments / FoldState have no instance-side source - // yet, so the projection never produces them (not even empty). + fn block_adornments_still_never_emitted() { + // BlockAdornments has no instance-side source yet, so the + // projection never produces it (not even empty). FoldState is now + // wired (Arc 6) but stays authoritative-empty — see + // `fold_state_not_emitted_without_folds`. let state = empty_state(); let buffer_id = active_buffer(&state); let mut s = local(); @@ -4009,16 +4057,101 @@ mod tests { for _ in 0..3 { for m in s.render_frame(&state) { assert!( - !matches!( - m, - InstanceMessage::BlockAdornments { .. } | InstanceMessage::FoldState { .. } - ), - "a still-unwired block/fold family was emitted: {m:?}" + !matches!(m, InstanceMessage::BlockAdornments { .. }), + "a still-unwired block-adornment family was emitted: {m:?}" ); } } } + #[test] + fn fold_state_not_emitted_without_folds() { + // FoldState IS wired but authoritative-empty: a buffer with no + // folds must never emit an (empty) FoldState frame — no empty- + // frame spam. (Its positive transitions are pinned in the + // folding acceptance suite.) + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + for _ in 0..3 { + assert!( + !s.render_frame(&state) + .iter() + .any(|m| matches!(m, InstanceMessage::FoldState { .. })), + "no folds ⇒ no FoldState message" + ); + } + } + + #[test] + fn fold_state_producer_transitions() { + // Arc 6 Q#FD8 / acceptance 7: the three authoritative-empty + // transitions to a semantic session — nothing until a fold exists, + // nothing when unchanged, exactly one empty frame on + // non-empty→empty — plus the per-session baseline reset on + // BufferSnapshot, while BlockAdornments stays never-emitted. + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + + let fold_frames = |s: &mut SemanticRenderState, st: &EditorState| -> Vec> { + s.render_frame(st) + .into_iter() + .filter_map(|m| match m { + InstanceMessage::FoldState { folds, .. } => Some(folds), + InstanceMessage::BlockAdornments { .. } => { + panic!("BlockAdornments must stay unproduced") + } + _ => None, + }) + .collect() + }; + + // Nothing until a fold exists. + assert!(fold_frames(&mut s, &state).is_empty()); + + // Add a fold → exactly one FoldState frame carrying it. + let range = ByteRange { start: 3, end: 7 }; + { + let core = state.core.borrow(); + let mut reg = core.registry.borrow_mut(); + let buf = reg.get_mut(buffer_id).expect("buffer"); + state + .fold_registry + .store_or_attach(buf) + .lock() + .unwrap() + .insert(range); + } + assert_eq!(fold_frames(&mut s, &state), vec![vec![range]]); + + // Unchanged → nothing. + assert!(fold_frames(&mut s, &state).is_empty()); + + // Clear → exactly one empty frame so the frontend drops its mirror. + { + let store = state.fold_registry.store(buffer_id).expect("store exists"); + store.lock().unwrap().clear(); + } + assert_eq!(fold_frames(&mut s, &state), vec![Vec::::new()]); + // Empty → empty is suppressed. + assert!(fold_frames(&mut s, &state).is_empty()); + + // A snapshot resets the baseline: the still-empty set is again + // suppressed as "initial empty" (the frontend cleared its mirror + // when it applied the snapshot — the Stage 3 pairing). + s.on_buffer_snapshot_sent(buffer_id); + assert!(fold_frames(&mut s, &state).is_empty()); + // …and a fold added after the reset is re-shipped. + { + let store = state.fold_registry.store(buffer_id).expect("store exists"); + store.lock().unwrap().insert(range); + } + assert_eq!(fold_frames(&mut s, &state), vec![vec![range]]); + } + #[test] fn inline_adornments_not_emitted_without_hints() { // Step 3: InlineAdornments IS wired, but a buffer with no LSP diff --git a/tests/folding_acceptance.rs b/tests/folding_acceptance.rs new file mode 100644 index 0000000..1e882c5 --- /dev/null +++ b/tests/folding_acceptance.rs @@ -0,0 +1,491 @@ +//! Folding acceptance (Arc 6, Stage 1 — docs/folding-framing.md). +//! +//! Headless coverage of the fold engine over real grammars: the +//! structural source (derived head line + closer-aware tail across brace +//! and indentation grammars, wrapped signatures, and injection layers), +//! the state-aware operations, the data-API validation, and the +//! dispatch-layer command-path pre-edit unfold. The `FoldState` producer +//! transitions are pinned in `src/semantic_render.rs` +//! (`fold_state_producer_transitions`). + +use std::sync::Arc; + +use pmacs::buffer::{Buffer, BufferId, EditOp}; +use pmacs::editor::EditorState; +use pmacs::fold::{ + self, CycleOutcome, FoldStore, close_at, cycle_at, fold_target_at, open_at, + top_level_fold_targets, +}; +use pmacs::protocol::ByteRange; +use pmacs::syntax::{ParseTreeBundle, ParseView, SyntaxRegistry, run_parse}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// Parse `src` under `lang` synchronously into a resolved bundle (mirrors +/// the crate-internal `parse_layered` with public APIs). +fn parse(reg: &SyntaxRegistry, lang: &str, src: &[u8]) -> Arc { + let language = reg.language(lang).expect("grammar loads"); + let mut buf = Buffer::from_bytes(BufferId::next(), "doc", src); + let view = ParseView::new(&buf, language, lang.to_owned()); + let handle = view.handle(); + let _ = buf.attach_view(Box::new(view)); + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = run_parse(req).expect("parse succeeds"); + reg.resolve_layer_queries(&bundle) +} + +/// The byte offset of `needle`'s first occurrence in `src`. +fn byte_of(src: &str, needle: &str) -> u64 { + src.find(needle).expect("needle present") as u64 +} + +/// The content-end byte (position of the terminating `\n`, or EOF) of the +/// line containing `needle`'s first occurrence. +fn line_content_end_of(src: &str, needle: &str) -> u64 { + let idx = src.find(needle).expect("needle present"); + let bytes = src.as_bytes(); + let mut e = idx; + while e < bytes.len() && bytes[e] != b'\n' { + e += 1; + } + e as u64 +} + +/// The text of the line containing byte `b`. +fn line_text_at(src: &str, b: u64) -> &str { + let bytes = src.as_bytes(); + let b = (b as usize).min(bytes.len()); + let start = bytes[..b] + .iter() + .rposition(|&c| c == b'\n') + .map_or(0, |i| i + 1); + let end = bytes[b..] + .iter() + .position(|&c| c == b'\n') + .map_or(bytes.len(), |i| b + i); + &src[start..end] +} + +// --------------------------------------------------------------------------- +// 1. Head line — both grammar shapes, wrapped headers (R2-1, R3-1). +// --------------------------------------------------------------------------- + +#[test] +fn head_line_rust_single_line_signature() { + let reg = SyntaxRegistry::new(); + let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, "fn foo() {")); + assert_eq!( + line_text_at(src, r.start), + "fn foo() {", + "head line is the fn line" + ); + assert_eq!( + line_text_at(src, r.start + 1), + " let x = 1;", + "first hidden line" + ); +} + +#[test] +fn head_line_rust_wrapped_signature_keeps_signature_visible() { + // R3-1: rustfmt puts `{` on the `) -> bool {` line; the head must be + // that line, NOT `fn foo(` — the wrapped signature stays visible. + let reg = SyntaxRegistry::new(); + let src = "fn foo(\n a: u32,\n) -> bool {\n true\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "true")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, ") -> bool {")); + assert_eq!(line_text_at(src, r.start), ") -> bool {"); + // The two wrapped signature lines are before the fold → visible. + assert!(r.start > line_content_end_of(src, "a: u32,")); +} + +#[test] +fn head_line_python_uses_def_not_a_body_line() { + // R2-1: tree-sitter-python's `block` starts on the first statement + // line, so the head must ascend to `def foo():`, not `x = 1`. + let reg = SyntaxRegistry::new(); + let src = "def foo():\n x = 1\n y = 2\n"; + let bundle = parse(®, "python", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, "def foo():")); + assert_eq!(line_text_at(src, r.start), "def foo():"); + assert_eq!(line_text_at(src, r.start + 1), " x = 1"); +} + +#[test] +fn head_line_python_wrapped_signature_keeps_signature_visible() { + let reg = SyntaxRegistry::new(); + let src = "def foo(\n a,\n):\n x = 1\n"; + let bundle = parse(®, "python", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, "):")); + assert_eq!(line_text_at(src, r.start), "):"); +} + +// --------------------------------------------------------------------------- +// 4. Range semantics — closer-aware tail (R2-5). +// --------------------------------------------------------------------------- + +#[test] +fn brace_closer_line_stays_visible() { + let reg = SyntaxRegistry::new(); + let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable"); + // Last hidden line is the last body line; the `}` line is outside. + assert_eq!(r.end, line_content_end_of(src, "let y = 2;")); + assert_eq!( + line_text_at(src, r.end + 1), + "}", + "closer line stays visible" + ); +} + +#[test] +fn shared_closer_line_else_stays_visible() { + // R2-5: `} else {` keeps its trailing sibling on screen. + let reg = SyntaxRegistry::new(); + let src = "fn f() {\n if a {\n one();\n } else {\n two();\n }\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "one()")).expect("foldable"); + // The consequent block folds; its `} else {` line stays visible. + assert_eq!(line_text_at(src, r.end + 1).trim(), "} else {"); +} + +#[test] +fn python_hides_through_last_body_line() { + let reg = SyntaxRegistry::new(); + let src = "def foo():\n x = 1\n y = 2\n"; + let bundle = parse(®, "python", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable"); + // Delimiter-less: the last body line is hidden (inside the range). + assert_eq!(r.end, line_content_end_of(src, "y = 2")); +} + +// --------------------------------------------------------------------------- +// 3. close-all folds top-level regions only; 9. nested + state-aware order. +// --------------------------------------------------------------------------- + +#[test] +fn close_all_is_top_level_only() { + let reg = SyntaxRegistry::new(); + let src = "fn a() {\n if c {\n work();\n more();\n }\n}\n\nfn b() {\n x();\n y();\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let top = top_level_fold_targets(&bundle); + assert_eq!( + top.len(), + 2, + "two top-level fns, the nested `if` is not auto-folded" + ); + // Neither top-level range is the inner `if` block. + let inner = fold_target_at(&bundle, byte_of(src, "work()")).expect("inner foldable"); + assert!( + !top.contains(&inner), + "close-all does not fold the nested region" + ); +} + +#[test] +fn nested_state_aware_ordering() { + // 9 / R3-2: close walks innermost→outer, open walks outer→inner, and + // toggle cycles close-inner → close-outer → open-all so every command + // reaches the outer fold. + let reg = SyntaxRegistry::new(); + let src = "fn outer() {\n if cond {\n work();\n more();\n }\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let p = byte_of(src, "work()"); + + let mut store = FoldStore::new(); + let inner = close_at(&mut store, &bundle, p).expect("close inner"); + let outer = close_at(&mut store, &bundle, p).expect("close outer"); + assert!( + inner.start > outer.start, + "inner fold is more deeply nested" + ); + assert!( + close_at(&mut store, &bundle, p).is_none(), + "nothing left to close" + ); + assert_eq!(store.folds().len(), 2); + + assert_eq!(open_at(&mut store, p), Some(outer), "open outermost first"); + assert_eq!(open_at(&mut store, p), Some(inner), "then the inner"); + assert!(store.is_empty()); + + assert!(matches!( + cycle_at(&mut store, &bundle, p), + CycleOutcome::Closed(_) + )); + assert!(matches!( + cycle_at(&mut store, &bundle, p), + CycleOutcome::Closed(_) + )); + assert_eq!(store.folds().len(), 2, "cycle closed both"); + assert!(matches!( + cycle_at(&mut store, &bundle, p), + CycleOutcome::OpenedAll(2) + )); + assert!(store.is_empty(), "one more cycle opened them all"); +} + +// --------------------------------------------------------------------------- +// 10. Injected layer (a fenced rust block inside markdown). +// --------------------------------------------------------------------------- + +#[test] +fn fold_sourced_inside_injected_layer() { + let reg = SyntaxRegistry::new(); + let src = "# Title\n\n```rust\nfn demo() {\n let x = 1;\n let y = 2;\n}\n```\n\nText.\n"; + let bundle = parse(®, "markdown", src.as_bytes()); + assert!( + bundle.layers.len() >= 2, + "markdown fence produced an injected rust layer" + ); + let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable inside the fence"); + assert_eq!( + line_text_at(src, r.start), + "fn demo() {", + "resolved the inner block" + ); +} + +// --------------------------------------------------------------------------- +// 2. Stale / absent parse tree refuses (the precondition the binding keys on). +// --------------------------------------------------------------------------- + +#[test] +fn absent_tree_has_no_current_bundle() { + let reg = SyntaxRegistry::new(); + let language = reg.language("rust").expect("grammar"); + let src = b"fn foo() {\n let x = 1;\n}\n"; + let buf = Buffer::from_bytes(BufferId::next(), "doc", src); + let view = ParseView::new(&buf, language, "rust".to_owned()); + let handle = view.handle(); + // Before any parse installs, `current()` is None → the binding refuses. + assert!(handle.current().is_none()); + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = run_parse(req).expect("parse"); + handle.install(reg.resolve_layer_queries(&bundle)); + assert!( + handle.current().is_some(), + "after install, a target is derivable" + ); +} + +// --------------------------------------------------------------------------- +// 6. Command-path pre-edit unfold (Q#FD5). +// --------------------------------------------------------------------------- + +fn active_id(s: &EditorState) -> BufferId { + s.core.borrow().active_buffer_id() +} + +fn insert_into(s: &EditorState, id: BufferId, text: &str) { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + reg.get_mut(id) + .unwrap() + .apply_edit(EditOp::Insert { + pos: 0, + bytes: text.as_bytes(), + }) + .unwrap(); +} + +#[test] +fn command_path_self_insert_unfolds_at_point() { + let s = EditorState::new(); + let id = active_id(&s); + insert_into(&s, id, "line0\nline1\nline2\nline3\n"); + // Fold the interior of lines 1..2: [end of line0, end of line2]. + let store = { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.store_or_attach(reg.get_mut(id).unwrap()) + }; + store + .lock() + .unwrap() + .insert(ByteRange { start: 5, end: 17 }); + // Cursor strictly inside the fold (start of "line2"). + s.core.borrow_mut().set_cursor_byte(12); + + // A command-path self-insert unfolds before the edit lands. + s.core.borrow_mut().insert_char('x'); + assert!( + store.lock().unwrap().is_empty(), + "typing inside a fold unfolds it (Q#FD5)" + ); +} + +#[test] +fn self_insert_at_head_line_end_does_not_unfold() { + // `(start, end]` containment: a self-insert exactly at the end of the + // head line (== range.start) is outside the fold — it must NOT unfold, + // and the translator shifts the fold right so the char lands visible. + let s = EditorState::new(); + let id = active_id(&s); + insert_into(&s, id, "line0\nline1\nline2\nline3\n"); + let store = { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.store_or_attach(reg.get_mut(id).unwrap()) + }; + store + .lock() + .unwrap() + .insert(ByteRange { start: 5, end: 17 }); + s.core.borrow_mut().set_cursor_byte(5); // end of head line "line0" + + s.core.borrow_mut().insert_char('x'); + let folds = store.lock().unwrap().folds(); + assert_eq!( + folds, + vec![ByteRange { start: 6, end: 18 }], + "fold shifts right; the character lands on the head line" + ); +} + +// --------------------------------------------------------------------------- +// 5. Point moves to the head line when a fold is created around it (Q#FD3). +// 11. Data-API validation (Q#FD11) — driven through the Lua surface. +// --------------------------------------------------------------------------- + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Install a settled rust parse over the active scratch buffer so the +/// `pmacs.fold` surface can drive it end to end. +fn install_rust_parse(s: &EditorState, id: BufferId) { + let reg = &s.syntax_registry; + let language = reg.language("rust").expect("grammar"); + let handle = { + let core = s.core.borrow(); + let mut breg = core.registry.borrow_mut(); + let buf = breg.get_mut(id).unwrap(); + let view = ParseView::new(buf, language, "rust".to_owned()); + let handle = view.handle(); + buf.attach_view(Box::new(view)); + handle + }; + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = run_parse(req).expect("parse"); + handle.install(reg.resolve_layer_queries(&bundle)); + reg.attach_view(id, handle); +} + +#[test] +fn folding_moves_point_to_head_line() { + let s = EditorState::new(); + let id = active_id(&s); + let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + insert_into(&s, id, src); + install_rust_parse(&s, id); + // Cursor inside the body; `let x` starts on line 1. + let p = byte_of(src, "let x"); + s.core.borrow_mut().set_cursor_byte(p); + + exec(&s, "pmacs.command.invoke('fold.close')"); + + let head = line_content_end_of(src, "fn foo() {"); + assert_eq!( + s.core.borrow().active_window().cursor, + head, + "the invoking point moved to the head line" + ); + // And the fold exists. + let n: i64 = eval(&s, "return #pmacs.fold.folds(pmacs.window.buffer())"); + assert_eq!(n, 1); +} + +#[test] +fn data_api_validation() { + let s = EditorState::new(); + // A plain document buffer with four lines. + exec( + &s, + "b = pmacs.buffer.from_bytes('doc.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", + ); + + // A valid multi-line range folds. + let ok: bool = eval(&s, "return pmacs.fold.fold(b, { start = 3, ['end'] = 11 })"); + assert!(ok, "a >=1-hidden-line range is accepted"); + let n: i64 = eval(&s, "return #pmacs.fold.folds(b)"); + assert_eq!(n, 1); + + // A sub-one-line range (both endpoints on the same line) is rejected. + let same_line: bool = eval(&s, "return pmacs.fold.fold(b, { start = 0, ['end'] = 2 })"); + assert!( + !same_line, + "a range hiding no full line is rejected (Q#FD11)" + ); + + // An out-of-bounds range is rejected. + let oob: bool = eval( + &s, + "return pmacs.fold.fold(b, { start = 0, ['end'] = 99999 })", + ); + assert!(!oob, "an out-of-bounds range is rejected"); + + // Q#FD9 via the >=1-hidden-line rule: a fold at (0,0) on an empty + // buffer normalizes to zero hidden lines and is rejected. + exec(&s, "e = pmacs.buffer.from_bytes('empty.rs', '')"); + let empty: bool = eval(&s, "return pmacs.fold.fold(e, { start = 0, ['end'] = 0 })"); + assert!( + !empty, + "a zero-length range is rejected (terminals never fold)" + ); + + // Round-trip: unfold the stored range clears it. + let unfolded: bool = eval( + &s, + "local f = pmacs.fold.folds(b)[1]; return pmacs.fold.unfold(b, f)", + ); + assert!(unfolded); + let n2: i64 = eval(&s, "return #pmacs.fold.folds(b)"); + assert_eq!(n2, 0); +} + +// --------------------------------------------------------------------------- +// 8. Buffer content replacement drops the store. +// --------------------------------------------------------------------------- + +#[test] +fn forget_drops_store_and_detaches_view() { + let s = EditorState::new(); + let id = active_id(&s); + insert_into(&s, id, "line0\nline1\nline2\n"); + let store = { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.store_or_attach(reg.get_mut(id).unwrap()) + }; + store + .lock() + .unwrap() + .insert(ByteRange { start: 5, end: 11 }); + assert!(s.fold_registry.store(id).is_some()); + + // Content replacement (revert/reload) drops the store. + { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.forget(reg.get_mut(id).unwrap()); + } + assert!(s.fold_registry.store(id).is_none(), "the store is dropped"); + assert!(fold::make_shared_fold_registry().folds(id).is_empty()); +} From 9691dd4e9f3bd220d18a190448c96295ad06c231 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 13:42:20 -0400 Subject: [PATCH 8/9] fix(fold): address PR #142 review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Finding 1 (bug):** a delete starting exactly at a fold's `end` removed the `\n` that `end` names — the last hidden line's terminator — but the strictly-after arm (`os >= e`) kept the fold, leaving a mid-line end. `translate`'s after-arm is now `os > e || (os == e && old_len == 0)` so a pure insert at `e` still stays outside while a delete at `e` falls to the drop arm, symmetric with the head side. New unit test `delete_starting_at_tail_boundary_drops_fold` (bite-verified). - **Finding 2:** `pmacs.buffer.kill` didn't clean the fold registry. Added `FoldRegistry::forget_buffer(id)` (id-keyed; the view died with the buffer) and wired it into `after_buffer_removed`, mirroring the keymap/config cleanup; the registry is now stashed as Lua app-data. `forget(&mut Buffer)` is clarified as the revert/reload reset. - **Finding 3:** `fold.close-all` now moves the invoking point to the head when it closes a fold around it (Q#FD3); the data-API `fold` exemption (programmatic, no invoking point) is named in the module doc. - Nits: dropped the dead `!(both empty)` conjunct in `fold_state_msg`; replaced the trivial fresh-registry assert; added coverage for the stale-tree refuse via a fold command, the read-only-buffer rejection (Q#FD11), and unfold normalizing an arbitrary range. Gates green: fmt, clippy --workspace --all-targets, --lib (1786), --features crdt (1962), folding_acceptance (24), m4 (skip basedpyright), required-GPU, git diff --check. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- src/fold.rs | 40 +++++++++++++++-- src/lua_bindings/fold.rs | 31 ++++++++----- src/lua_bindings/mod.rs | 3 ++ src/semantic_render.rs | 6 +-- tests/folding_acceptance.rs | 89 +++++++++++++++++++++++++++++++++++-- 5 files changed, 148 insertions(+), 21 deletions(-) diff --git a/src/fold.rs b/src/fold.rs index 3772b34..7e0140e 100644 --- a/src/fold.rs +++ b/src/fold.rs @@ -231,8 +231,12 @@ impl FoldStore { start: shift(s), end: shift(e), }) - } else if os >= e { - // Strictly after the fold (an insert at exactly `e` too). + } else if os > e || (os == e && old_len == 0) { + // Strictly after the fold, OR a pure insert at exactly `e` + // (left outside). A *delete* starting at `e` removes the + // `\n` that `e` names — the terminator of the last hidden + // line — so it destroys the tail and falls through to the + // drop arm below, symmetric with the head side. Some(ByteRange { start: s, end: e }) } else if os > s && oe < e { // Strictly inside the interior — the fold still hides a @@ -348,14 +352,24 @@ impl FoldRegistry { } /// Drop the buffer's store and detach its translator view — the - /// content-replacement (revert/reload) and buffer-close reset. Named - /// bytes no longer exist, so revalidation is not attempted (Q#FD8). + /// content-replacement (revert/reload) reset, where the buffer survives + /// but its bytes are replaced wholesale, so the view must come off too + /// (a later fold re-attaches a fresh one). Named bytes no longer exist, + /// so revalidation is not attempted (Q#FD8, framing acceptance 8). pub fn forget(&self, buffer: &mut Buffer) { if let Some(entry) = self.stores.borrow_mut().remove(&buffer.id()) { buffer.detach_view(entry.view); } } + /// Drop the store for a buffer that has already been removed (the + /// `pmacs.buffer.kill` path). The buffer — and its attached translator + /// view — is gone, so only the map entry needs clearing; there is no + /// view to detach. + pub fn forget_buffer(&self, buf: BufferId) { + self.stores.borrow_mut().remove(&buf); + } + /// Unfold every fold in `buf` containing `p`. The pre-edit hook the /// six `EditorCore` edit primitives call; a no-op when the buffer has /// no store (hence no folds). Returns the count unfolded. @@ -732,6 +746,24 @@ mod tests { assert!(store.is_empty()); } + #[test] + fn delete_starting_at_tail_boundary_drops_fold() { + // A delete beginning exactly at `end` removes the `\n` that `end` + // names (the last hidden line's terminator), destroying the tail — + // it must drop, not survive with a mid-line end. Mirror of + // `insert_at_tail_boundary_leaves_fold_untouched` for a delete. + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(30, 31), 0)); // delete one byte at `end` + assert!(store.is_empty()); + + // Same class: a delete starting at the boundary and extending past. + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(30, 45), 0)); + assert!(store.is_empty()); + } + #[test] fn containment_is_start_exclusive_end_inclusive() { let store = { diff --git a/src/lua_bindings/fold.rs b/src/lua_bindings/fold.rs index b79c2c7..0d4664c 100644 --- a/src/lua_bindings/fold.rs +++ b/src/lua_bindings/fold.rs @@ -23,8 +23,11 @@ //! Fold *creation* refuses against an absent or stale parse tree (Q#FD10) //! and validates the buffer kind / UTF-8 boundaries / >= 1-hidden-line //! rule (Q#FD11); a rejection reports on the status line and returns -//! `false`. Folding a range containing the invoking frontend's point moves -//! that point to the head line (Q#FD3). +//! `false`. The **interactive** commands (`toggle`/`close`/`cycle`/ +//! `close_all`) move the invoking frontend's point to the head line when +//! they fold a range around it (Q#FD3); the **data-API** `fold` is +//! deliberately exempt — a programmatic caller names an explicit buffer +//! and range with no invoking point to relocate. use std::sync::{Arc, Mutex}; @@ -45,6 +48,10 @@ use crate::syntax::{ParseTreeBundle, SharedSyntaxRegistry}; mirroring install_config; splitting fragments the wiring" )] pub fn install_fold(lua: &Lua, fold_registry: &SharedFoldRegistry) -> mlua::Result<()> { + // Also stash the registry as app-data so the buffer-remove cleanup + // (`after_buffer_removed`) can drop a killed buffer's store, mirroring + // the keymap/config registries. + lua.set_app_data(fold_registry.clone()); let fold_mod = lua.create_table()?; // ---- data API --------------------------------------------------------- @@ -99,8 +106,7 @@ pub fn install_fold(lua: &Lua, fold_registry: &SharedFoldRegistry) -> mlua::Resu return Ok(true); } if let Ok(Some(bytes)) = document_bytes(lua, id) - && let Some(normalized) = - fold::normalize_arbitrary_range(&bytes, requested) + && let Some(normalized) = fold::normalize_arbitrary_range(&bytes, requested) { return Ok(lock(&store).remove(normalized)); } @@ -242,14 +248,17 @@ pub fn install_fold(lua: &Lua, fold_registry: &SharedFoldRegistry) -> mlua::Resu }; let targets = fold::top_level_fold_targets(&bundle); let store = store_for(lua, ®, id)?; - let mut s = lock(&store); - let mut n = 0i64; - for t in targets { - if s.insert(t) { - n += 1; - } + let inserted: Vec = { + let mut s = lock(&store); + targets.into_iter().filter(|t| s.insert(*t)).collect() + }; + // close-all is interactive: if a newly-closed top-level + // fold contains the invoking point, move it to the head + // (Q#FD3). At most one top-level fold can contain it. + for r in &inserted { + maybe_move_point(lua, id, *r); } - Ok(n) + Ok(i64::try_from(inserted.len()).unwrap_or(i64::MAX)) })?, )?; } diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 02dd286..7e84899 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1466,6 +1466,9 @@ fn after_buffer_removed(lua: &Lua, id: BufferId) { if let Some(config) = lua.app_data_ref::() { config.borrow_mut().remove_buffer(id); } + if let Some(folds) = lua.app_data_ref::() { + folds.forget_buffer(id); + } let callbacks = match lua.app_data_ref::() { Some(callbacks) => callbacks.take(id), None => Vec::new(), diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 25e7fea..65750d3 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -1434,9 +1434,9 @@ impl SemanticRenderState { let should_emit = match self.last_folds.get(&buffer_id) { // First sight: speak only if there is a fold to show. None => !folds.is_empty(), - // A real change; `empty → empty` conveys nothing and is - // suppressed, `non-empty → empty` is a change worth sending. - Some(prev) => *prev != folds && !(folds.is_empty() && prev.is_empty()), + // Any change: `empty → empty` is byte-identical and suppressed, + // `non-empty → empty` differs and emits one clearing frame. + Some(prev) => *prev != folds, }; if !should_emit { return None; diff --git a/tests/folding_acceptance.rs b/tests/folding_acceptance.rs index 1e882c5..3f9977e 100644 --- a/tests/folding_acceptance.rs +++ b/tests/folding_acceptance.rs @@ -13,8 +13,7 @@ use std::sync::Arc; use pmacs::buffer::{Buffer, BufferId, EditOp}; use pmacs::editor::EditorState; use pmacs::fold::{ - self, CycleOutcome, FoldStore, close_at, cycle_at, fold_target_at, open_at, - top_level_fold_targets, + CycleOutcome, FoldStore, close_at, cycle_at, fold_target_at, open_at, top_level_fold_targets, }; use pmacs::protocol::ByteRange; use pmacs::syntax::{ParseTreeBundle, ParseView, SyntaxRegistry, run_parse}; @@ -487,5 +486,89 @@ fn forget_drops_store_and_detaches_view() { s.fold_registry.forget(reg.get_mut(id).unwrap()); } assert!(s.fold_registry.store(id).is_none(), "the store is dropped"); - assert!(fold::make_shared_fold_registry().folds(id).is_empty()); + assert!( + s.fold_registry.folds(id).is_empty(), + "no folds survive the drop" + ); +} + +#[test] +fn forget_buffer_drops_store_on_kill() { + // The id-keyed reset the pmacs.buffer.kill path uses: the buffer (and + // its attached view) is gone, so only the map entry is cleared. + let s = EditorState::new(); + let id = active_id(&s); + insert_into(&s, id, "line0\nline1\nline2\n"); + { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry + .store_or_attach(reg.get_mut(id).unwrap()) + .lock() + .unwrap() + .insert(ByteRange { start: 5, end: 11 }); + } + assert!(s.fold_registry.store(id).is_some()); + s.fold_registry.forget_buffer(id); + assert!(s.fold_registry.store(id).is_none()); +} + +#[test] +fn stale_parse_tree_refuses_fold() { + // Q#FD10: an edit after the parse leaves `pending_edit_count() > 0`, + // so a fold command refuses (the settled coordinates are stale). + let s = EditorState::new(); + let id = active_id(&s); + let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + insert_into(&s, id, src); + install_rust_parse(&s, id); + // A further edit accumulates a pending edit on the attached parse view. + insert_into(&s, id, "// stale\n"); + s.core.borrow_mut().set_cursor_byte(20); + + exec(&s, "pmacs.command.invoke('fold.close')"); + let n: i64 = eval(&s, "return #pmacs.fold.folds(pmacs.window.buffer())"); + assert_eq!(n, 0, "a stale parse tree refuses fold creation"); +} + +#[test] +fn read_only_buffer_is_rejected() { + // Q#FD11's "normal document buffer" guard: terminals are read-only, so + // a read-only buffer is not foldable. + let s = EditorState::new(); + exec( + &s, + "b = pmacs.buffer.from_bytes('ro.rs', 'aaa\\nbbb\\nccc\\n')", + ); + let id = { + let core = s.core.borrow(); + let reg = core.registry.borrow(); + reg.find_by_name("ro.rs").expect("buffer") + }; + { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + reg.get_mut(id).unwrap().set_read_only(true); + } + let ok: bool = eval(&s, "return pmacs.fold.fold(b, { start = 3, ['end'] = 7 })"); + assert!(!ok, "a read-only buffer is rejected (Q#FD11)"); +} + +#[test] +fn unfold_normalizes_an_arbitrary_range_to_a_stored_fold() { + let s = EditorState::new(); + exec( + &s, + "b = pmacs.buffer.from_bytes('doc.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", + ); + let folded: bool = eval(&s, "return pmacs.fold.fold(b, { start = 3, ['end'] = 11 })"); + assert!(folded); + // A *different* input range that normalizes to the same stored fold. + let unfolded: bool = eval( + &s, + "return pmacs.fold.unfold(b, { start = 1, ['end'] = 10 })", + ); + assert!(unfolded, "unfold normalizes an arbitrary range"); + let n: i64 = eval(&s, "return #pmacs.fold.folds(b)"); + assert_eq!(n, 0); } From 036a994639331adefe55e637032972df8d0c528e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 14:25:28 -0400 Subject: [PATCH 9/9] =?UTF-8?q?test(fold):=20address=20PR=20#142=20review?= =?UTF-8?q?=20round=202=20=E2=80=94=20pin=20the=20round-1=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 correctly found the Finding-2/3 fixes were unpinned (reverting them left the suite green). Both are now bite-verified: - **Kill-path purge (Finding 2).** Replaced the direct `forget_buffer(id)` unit test with `killing_a_buffer_through_the_real_path_purges_its_fold_store`, which drives `pmacs.buffer.remove` — the production route through `after_buffer_removed` — and asserts the store is gone via the dead id (BufferIds never recycle). Mirrors config_registry's real-kill-path test. Bite-verified: reverting the `after_buffer_removed` fold branch turns it red. - **close-all point move (Finding 3).** Added `close_all_command_moves_point_to_enclosing_head`, which invokes the `fold.close-all` command with the point inside the second of two top-level fns and asserts the cursor landed on that fn's head-line content end (and both folds exist). Bite-verified: reverting close_all's `maybe_move_point` loop turns it red. - Ledger: `docs/active-work.md` folding lane now records PR #142 OPEN + the two landed review rounds (was "opens once the gate suite is green"). Correction to the round-1 gate report: the acceptance suite is **21** tests (round 1 was 20, not 24 — a tally slip), green under default and `--features crdt`. Full gate suite otherwise green (fmt, clippy --workspace --all-targets, git diff --check). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- docs/active-work.md | 19 ++++++----- tests/folding_acceptance.rs | 68 ++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index de60c4f..2d364c6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -61,18 +61,21 @@ If it does not, stop and repair the remote/fetch configuration. - Framing head: revision 5 of `docs/folding-framing.md` (rev 1 → … → rev 4 absorbed three review rounds; rev 5 records approval + the Q#FD4 binding decision). -- State: **APPROVED; Stage 1 (fold engine, headless) implementing on this - branch.** Bindings decided (Q#FD4 → Emacs hideshow `C-c @` set); Bet B1 - accepted as framed. +- State: **Stage 1 (fold engine, headless) implemented; PR #142 OPEN**, + two review rounds landed on the branch. Bindings decided (Q#FD4 → Emacs + hideshow `C-c @` set); Bet B1 accepted as framed. Load-bearing decision (Q#FD1): the bundled grammars ship no fold query and no `folds.scm`, so the roadmap's "tree-sitter fold ranges" is not free; v1 is structural node folding (block-like node ≥2 source lines, derived head line, closer-aware tail), with indentation fallback and curated queries - deferred. `FoldState` already exists in the protocol, declared-but-unproduced - (a test pins it is never emitted); no frontend consumes it yet; gutter - markers are frontend-derived like the diagnostic sign bars, so no new wire - type. Staged like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU. -- PR: Stage 1 opens as the first folding PR once the gate suite is green. + deferred. `FoldState` already exists in the protocol; Stage 1 starts + *producing* it (authoritative-empty), no protocol bump; gutter markers are + frontend-derived like the diagnostic sign bars, so no new wire type. Staged + like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU. +- PR: **#142** (`Arc 6 folding — Stage 1: instance fold engine`), open + against `main`. Round 1 (tail-boundary delete bug + buffer-kill cleanup + + close-all point-move) and round 2 (pin the kill-path + close-all through + the real command surface) are landed as fix commits on the branch. - Next: land Stage 1; Stages 2/3 are separate branches/PRs, each re-framed in detail after the prior stage lands. diff --git a/tests/folding_acceptance.rs b/tests/folding_acceptance.rs index 3f9977e..6df069a 100644 --- a/tests/folding_acceptance.rs +++ b/tests/folding_acceptance.rs @@ -493,24 +493,60 @@ fn forget_drops_store_and_detaches_view() { } #[test] -fn forget_buffer_drops_store_on_kill() { - // The id-keyed reset the pmacs.buffer.kill path uses: the buffer (and - // its attached view) is gone, so only the map entry is cleared. +fn killing_a_buffer_through_the_real_path_purges_its_fold_store() { + // Drive the production `pmacs.buffer.remove` route (not `forget_buffer` + // directly), so the app-data stash + the `after_buffer_removed` branch + // are what get exercised — deleting either would leave this red. The + // assertion reads through the DEAD id on purpose: BufferIds are never + // reused, so a stale id cannot alias a later buffer. Mirrors + // config_registry's `killing_a_buffer_through_the_real_path_...`. + let s = EditorState::new(); + exec( + &s, + "b = pmacs.buffer.from_bytes('kill.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", + ); + let id = { + let core = s.core.borrow(); + let reg = core.registry.borrow(); + reg.find_by_name("kill.rs").expect("buffer") + }; + // Fold via the data API (no parse tree needed) so the store exists. + let folded: bool = eval(&s, "return pmacs.fold.fold(b, { start = 3, ['end'] = 11 })"); + assert!(folded); + assert!( + s.fold_registry.store(id).is_some(), + "store exists before kill" + ); + + exec(&s, "pmacs.buffer.remove(b)"); + assert!( + s.fold_registry.store(id).is_none(), + "the real kill path purges the fold store" + ); +} + +#[test] +fn close_all_command_moves_point_to_enclosing_head() { + // Q#FD3 through the command surface: `fold.close-all` is interactive, + // so when it collapses a top-level fold around the invoking point, the + // point moves to that fold's head line (Finding 3, round 1). let s = EditorState::new(); let id = active_id(&s); - insert_into(&s, id, "line0\nline1\nline2\n"); - { - let core = s.core.borrow(); - let mut reg = core.registry.borrow_mut(); - s.fold_registry - .store_or_attach(reg.get_mut(id).unwrap()) - .lock() - .unwrap() - .insert(ByteRange { start: 5, end: 11 }); - } - assert!(s.fold_registry.store(id).is_some()); - s.fold_registry.forget_buffer(id); - assert!(s.fold_registry.store(id).is_none()); + let src = "fn first() {\n a();\n b();\n}\nfn second() {\n c();\n d();\n}\n"; + insert_into(&s, id, src); + install_rust_parse(&s, id); + // Point inside the SECOND function's body. + s.core.borrow_mut().set_cursor_byte(byte_of(src, "c()")); + + exec(&s, "pmacs.command.invoke('fold.close-all')"); + + let n: i64 = eval(&s, "return #pmacs.fold.folds(pmacs.window.buffer())"); + assert_eq!(n, 2, "both top-level functions are folded"); + assert_eq!( + s.core.borrow().active_window().cursor, + line_content_end_of(src, "fn second() {"), + "the invoking point moved to the enclosing fold's head line" + ); } #[test]