pmacs/docs/folding-framing.md

387 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Folding — framing (Arc 6)
**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 history
### 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. `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 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."
**`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** (§11).
## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified across two review rounds)
- **`FoldState { buffer_id, folds: Vec<ByteRange> }`** —
`pmacs-protocol/src/message.rs:886`, gated on `semantic_render`,
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 (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) — structural node folding with head selection 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
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).
The source, at a point:
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` (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 (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 (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).
## 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()`.
**Point and folds (Q#FD3, F5).**
- 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 — 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, 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 **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). On a head line shared by
more than one fold, they act **innermost-first** (minor b):
- `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): `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`,
`folds(buffer)`, `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.
Bindings remain the user's call (Emacs has no single convention).
## 7. Frontend collapse + gutter marker (Q#FD7)
- **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 slice, shows the head + ellipsis + fold glyph,
and makes caret/hit-test fold-aware. It also clears its fold mirror on
`BufferSnapshot` (R2-4).
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
- **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 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, 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 23 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** 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 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 (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); 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** 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 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.
- Search revealing folds (a match inside a fold auto-unfolds) — Stage 2+.
## 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.
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
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; 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`. 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.
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)
`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`, 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.