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/docs/active-work.md b/docs/active-work.md index 9c14a25..70df3e5 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -52,28 +52,67 @@ git status --short --branch The `git log` command must expose `4daa1b8` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Active lane: GPU initial target framing +## Active lane: GPU initial target -- Portable branch: `githubsucks/gpu-initial-target-framing` -- Framing checkpoint: `71039d1` (Revision 2; 2026-07-23). -- Base: `githubsucks/main` @ `4daa1b8`; protocol v19. -- State: framing only; user review completed and all four non-structural - findings resolved; no implementation and no PR. +- Portable implementation branch: `gpu-initial-target` (publish to + `githubsucks/gpu-initial-target` at the first verified checkpoint); worktree + `../pmacs-gpu-initial-target`. +- Approved framing branch: `githubsucks/gpu-initial-target-framing`; + Revision 2 checkpoint `71039d1`. +- Implementation base: canonical `githubsucks/main` @ `c49a8c7` (folding + Stage 1 #142 merged after the framing base); protocol v19 before this work. +- State: framing approved 2026-07-23; implementation started; no PR. - Scope: one session-scoped `pmacs --gpu [--socket …] FILE` target, protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact Unix path transport, pre-window target readiness, replica coherence, and - behavioral acceptance. -- Next: explicit user approval. Only then create the implementation branch - from this checkpoint; automatic GUI selection, multiple files, general - live-open commands, packaging, and remote GPU paths remain deferred. + the approved behavioral acceptance matrix. +- Next: implement and smoke-test the approved contract. Automatic GUI + selection, multiple files, general live-open commands, packaging, and + remote GPU paths remain deferred. + +Recovery worktree after the first push: + +```sh +git worktree add --track \ + -b gpu-initial-target \ + ../pmacs-gpu-initial-target \ + githubsucks/gpu-initial-target +``` + +## Folding framing lane (Arc 6) + +- Portable branch: `githubsucks/folding`; worktree `../pmacs-folding`. +- 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: **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; 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. Recovery worktree: ```sh git worktree add --track \ - -b gpu-initial-target-framing \ - ../pmacs-gpu-initial-target-framing \ - githubsucks/gpu-initial-target-framing + -b folding \ + ../pmacs-folding \ + githubsucks/folding ``` ## Parked lane: kill-ring browser + persistence @@ -112,6 +151,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` diff --git a/docs/folding-framing.md b/docs/folding-framing.md new file mode 100644 index 0000000..579b4b8 --- /dev/null +++ b/docs/folding-framing.md @@ -0,0 +1,525 @@ +# Folding — framing (Arc 6) + +**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; rev 5 records the settled keybinding decision (Q#FD4) and +approval. 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. + +### 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). + +### 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 +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 three review rounds) + +- **`FoldState { buffer_id, folds: Vec }`** — + `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), 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 + `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 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 +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 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. **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 + 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 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 +`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) 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) + +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 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 + 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, 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: +- 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): + +- `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 +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. + +**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) + +- **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 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 + 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**, **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 + **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; 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 → + 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, **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; **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** + 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 + 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, + and ≥1 hidden line after the §6 normalization; rejects otherwise. (§6) + +## 10. Bets + +- **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; + 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 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. +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, 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 + `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 (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, 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) + +`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`. 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. 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..7e0140e --- /dev/null +++ b/src/fold.rs @@ -0,0 +1,849 @@ +// 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 || (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 + // 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) 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. + 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 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 = { + 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..0d4664c --- /dev/null +++ b/src/lua_bindings/fold.rs @@ -0,0 +1,390 @@ +// 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`. 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}; + +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<()> { + // 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 --------------------------------------------------------- + + { + 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 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(i64::try_from(inserted.len()).unwrap_or(i64::MAX)) + })?, + )?; + } + + { + 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..7e84899 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}; @@ -1464,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 6a06ff9..65750d3 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(), + // 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; + } + 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..6df069a --- /dev/null +++ b/tests/folding_acceptance.rs @@ -0,0 +1,610 @@ +//! 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::{ + 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!( + s.fold_registry.folds(id).is_empty(), + "no folds survive the drop" + ); +} + +#[test] +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); + 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] +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); +}