Merge pull request #142 from levineuwirth/folding

Arc 6 folding — Stage 1: instance fold engine
This commit is contained in:
Levi Neuwirth 2026-07-23 18:50:02 +00:00 committed by GitHub
commit c49a8c71be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2681 additions and 21 deletions

52
builtin/runtime/fold.lua Normal file
View File

@ -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 <letter>`, 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" }

View File

@ -52,6 +52,42 @@ git status --short --branch
The first command must expose `63fbc66` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Folding framing lane (Arc 6)
- Portable branch: `githubsucks/folding`; worktree `../pmacs-folding`.
- Base: **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 folding \
../pmacs-folding \
githubsucks/folding
```
## Parked lane: kill-ring browser + persistence
- Portable branch: `githubsucks/kill-ring-browser`
@ -88,6 +124,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`

525
docs/folding-framing.md Normal file
View File

@ -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 <letter>` 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<ByteRange> }`** —
`pmacs-protocol/src/message.rs:886`, gated on `semantic_render`,
DECLARED-BUT-UNPRODUCED. `semantic_render.rs:4002` pins it is never emitted.
- **`BlockAdornments`** (also unproduced) is the declared home for
folded-region placeholders. Arc 6 does not produce it (Q#FD7).
- **No fold source exists** — the bundled grammars export
`HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` only, no fold query, no
`folds.scm`. Fold source is Q#FD1. **tree-sitter-python's `block` node
starts on the first statement line, not the `def` line** (R2-1), 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 34 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 <letter>` 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 23 are sketched; each is re-framed in detail after the prior stage
lands. This framing asks approval for the architecture and Stage 1's detail.
## 9. Numbered decisions
- **Q#FD1** Structural node folding: match block-like node ≥2 source lines →
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.

View File

@ -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,

View File

@ -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();

849
src/fold.rs Normal file
View File

@ -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<u32> {
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<ByteRange>,
}
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<ByteRange> {
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<ByteRange> {
let mut v: Vec<ByteRange> = 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<Mutex<FoldStore>>,
}
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<FoldRegistry>;
struct FoldEntry {
store: Arc<Mutex<FoldStore>>,
view: ViewId,
}
/// One fold store per buffer. Interior-mutable so a `&SharedFoldRegistry`
/// suffices everywhere.
#[derive(Default)]
pub struct FoldRegistry {
stores: RefCell<HashMap<BufferId, FoldEntry>>,
}
/// 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<Arc<Mutex<FoldStore>>> {
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<ByteRange> {
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<Mutex<FoldStore>> {
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<ByteRange> {
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<ByteRange> {
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<ByteRange> {
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<Node<'_>> {
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<ByteRange> {
// 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<Node> {
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<ByteRange> {
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<ByteRange> {
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<ByteRange> {
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());
}
}

View File

@ -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;

390
src/lua_bindings/fold.rs Normal file
View File

@ -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<bool> {
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, &reg, 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<bool> {
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<Table> {
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<bool> {
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, &reg, 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<bool> {
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, &reg, 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<bool> {
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<bool> {
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, &reg, 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<i64> {
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, &reg, id)?;
let inserted: Vec<ByteRange> = {
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<bool> {
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<Mutex<FoldStore>>) -> std::sync::MutexGuard<'_, FoldStore> {
store.lock().expect("fold store mutex poisoned")
}
fn range_from_table(t: &Table) -> mlua::Result<ByteRange> {
let start = u64_from_lua(t.raw_get::<i64>("start")?)?;
let end = u64_from_lua(t.raw_get::<i64>("end")?)?;
Ok(ByteRange { start, end })
}
fn range_to_table(lua: &Lua, r: ByteRange) -> mlua::Result<Table> {
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<Option<Vec<u8>>> {
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<u8> {
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<Arc<Mutex<FoldStore>>> {
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<Arc<ParseTreeBundle>> {
match settled_bundle(lua, buf) {
Ok(bundle) => Some(bundle),
Err(reason) => {
set_status(lua, reason);
None
}
}
}
fn settled_bundle(lua: &Lua, buf: BufferId) -> Result<Arc<ParseTreeBundle>, &'static str> {
let syntax = lua
.app_data_ref::<SharedSyntaxRegistry>()
.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::<SharedCore>() {
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::<SharedCore>() {
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);
}
}
}
}

View File

@ -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::SharedConfigRegistry>() {
config.borrow_mut().remove_buffer(id);
}
if let Some(folds) = lua.app_data_ref::<crate::fold::SharedFoldRegistry>() {
folds.forget_buffer(id);
}
let callbacks = match lua.app_data_ref::<BufferRemoveCallbacks>() {
Some(callbacks) => callbacks.take(id),
None => Vec::new(),

View File

@ -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<BufferId, LastFrame<InlineAdornment>>,
/// `FoldState` baseline (Arc 6). Whole-buffer, not viewport-clipped,
/// so a plain `Vec<ByteRange>` 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<BufferId, Vec<ByteRange>>,
/// `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<InstanceMessage> {
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<Vec<ByteRange>> {
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::<ByteRange>::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

610
tests/folding_acceptance.rs Normal file
View File

@ -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<ParseTreeBundle> {
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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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<T: mlua::FromLuaMulti>(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);
}