pmacs/docs/folding-framing.md

394 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 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
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), 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, unrelated to folding. Named as a deferral
(§11), framed separately.
## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified in review)
- **`FoldState { buffer_id, folds: Vec<ByteRange> }`** exists in
`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).
- **`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.
## 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 — 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.
**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.
**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. 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.
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)
**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.
**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 **top-level** foldable
region (Emacs `hs-hide-all` parity — nested regions are not auto-folded;
see B2) / clear the fold set.
**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)
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; 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 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 and makes
caret/hit-test fold-aware, at TUI parity.
Stages 23 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 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. (§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 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 (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.
- `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 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.
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)
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 (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.