Merge pull request #142 from levineuwirth/folding
Arc 6 folding — Stage 1: instance fold engine
This commit is contained in:
commit
c49a8c71be
|
|
@ -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" }
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 3–4 fixed the *determinable* defects (R2-1, R2-5,
|
||||
R3-1), which were not taste.
|
||||
|
||||
## 4. Where fold state lives (Q#FD2)
|
||||
|
||||
Instance-side, per the wire contract. A **per-buffer fold store** (a set of
|
||||
byte ranges) lives beside the buffer, attached as a `View` (F3). Commands
|
||||
mutate it; the daemon grid renderer reads it directly (Stage 2); the semantic
|
||||
producer ships it as `FoldState` to GPU sessions (Stage 3). Nested folds are
|
||||
allowed; the store is **shared by every attached frontend** (Emacs parity).
|
||||
|
||||
## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6, Q#FD8)
|
||||
|
||||
**Stored range = line-aligned hidden interior (Q#FD3).** A fold is identified
|
||||
by its **head line** (the line immediately above the hidden interior, §3
|
||||
step 3), which stays visible with a frontend-drawn ellipsis. The stored byte
|
||||
range is `[end of head line, end of the last hidden line]`, where the last
|
||||
hidden line is chosen by §3 step 4 — so a **closing-delimiter line stays
|
||||
visible** (fixing `} else {`), while a delimiter-less node hides through its
|
||||
last body line. One normalized form is computed by the source and seen
|
||||
identically by the store, the grid renderer, the wire, and `folds()`.
|
||||
|
||||
**Containment and boundary bias (R3-4 nit).** The stored range is
|
||||
**start-exclusive, end-inclusive** — `(start, end]`. A point at
|
||||
`range.start` (the end of the head line) is **outside** the fold: typing
|
||||
there does not trigger the pre-edit unfold, and the store `View` translates
|
||||
an insert at exactly `range.start` by shifting the fold right (the
|
||||
`BufferStyleSpanTranslator` at-or-after bias), so the typed character lands
|
||||
visible on the head line. A point at `range.end` (the end of the last hidden
|
||||
line) is **inside**: typing there unfolds. One convention covers both the
|
||||
containment test and the translation bias.
|
||||
|
||||
**Point and folds (Q#FD3, F5).**
|
||||
- Folding a range containing the **invoking frontend's** point moves that
|
||||
point to the head line.
|
||||
- The store is shared, so another frontend's cursor may sit inside a newly
|
||||
folded range. "No cursor inside a fold" is a **per-cursor, render-time**
|
||||
invariant (its caret clamps to the head on that frontend's next frame — a
|
||||
Stage 2/3 concern). In **Stage 1** there is no motion-awareness, so the
|
||||
invariant is **creation-time-only**; acceptance is scoped to that so it
|
||||
cannot self-contradict.
|
||||
|
||||
**Edits and folds — two separated mechanisms (Q#FD5, Q#FD6, R2-2, R2-3).**
|
||||
- The store's buffer-attached `View` does **translation and drop only**, and
|
||||
is **provenance-blind**: on every edit it translates each fold's range, and
|
||||
**drops** any fold whose head/tail the edit destroys or whose interior
|
||||
collapses below one line. It cannot unfold-on-typing because `Edit` carries
|
||||
no frontend and no point (R2-2).
|
||||
- **Unfolding on an interactive edit is a pre-edit step at the dispatch layer**
|
||||
(which holds the authenticated frontend and its point): before applying an
|
||||
edit that a frontend is making at its point, unfold **every** fold
|
||||
containing that point (plural — minor a). The classifier is the
|
||||
**authenticated source frontend's point, not the transport** (R2-3): a GPU
|
||||
user's CRDT-op insert at a point inside a fold is interactive and must
|
||||
unfold, even though it arrives as a CRDT op. **Stage 1 implements this for
|
||||
the command path** — daemon `dispatch_key` self-insert/delete, the only
|
||||
point-anchored edits the daemon applies directly with the frontend + point
|
||||
in hand. Two widenings are named, each landing with the rendering that
|
||||
makes it user-visible (R3-3):
|
||||
- **Interactive Lua command edits — Stage 2 obligation.** Yank,
|
||||
query-replace, and comment-toggle mutate through the Lua mutator path,
|
||||
which this split classifies as programmatic: in Stage 1 such an edit
|
||||
inside a fold translates without unfolding. Invisible while headless,
|
||||
but a visible "the yank vanished into the fold" once the TUI collapses —
|
||||
Stage 2 widens the classifier to interactive Lua command contexts,
|
||||
keyed on the edit position.
|
||||
- **CRDT-origin unfold — Stage 3 obligation**, wired when the GPU renders
|
||||
folds and a GPU user can type into one.
|
||||
|
||||
**Store lifecycle vs producer baseline (Q#FD8, F3, R2-4).** Three coupled
|
||||
resets, kept distinct:
|
||||
- The **per-session producer suppression baseline** resets on `BufferSnapshot`
|
||||
so the fold set is re-shipped to a (re)joining semantic session.
|
||||
- The **per-buffer store** is dropped on buffer **content replacement**
|
||||
(revert/reload) — its ranges name bytes that no longer exist (revalidation
|
||||
is a §11 refinement).
|
||||
- **The frontend fold mirror must clear on `BufferSnapshot` (R2-4, Stage 3
|
||||
obligation).** Revert simultaneously drops the store, emits a snapshot, and
|
||||
resets the baseline, so the producer sees an empty store with a fresh
|
||||
baseline and correctly suppresses it as "initial empty" — which means the
|
||||
GPU keeps rendering pre-revert folds **unless its snapshot arm clears fold
|
||||
state**, exactly as it already clears spans/decorations. The
|
||||
empty-after-snapshot suppression is correct **only because** the snapshot
|
||||
clears the frontend mirror; this pairing is load-bearing and is pinned in
|
||||
acceptance 7.
|
||||
|
||||
## 6. Lua command surface and validation (Q#FD4, Q#FD11)
|
||||
|
||||
**Interactive commands** (resolve to the invoking frontend's active-window
|
||||
buffer — command context, not ambient resolution):
|
||||
|
||||
- `fold.toggle`, `fold.close`, `fold.open`, `fold.close-all`, `fold.open-all`.
|
||||
- `close-all` folds **top-level** foldable regions only (Emacs `hs-hide-all`
|
||||
parity — nested regions are not auto-folded; feeds B2). `open-all` clears.
|
||||
- **Shared head lines — state-aware ordering (R3-2).** On a head line shared
|
||||
by more than one fold (`foo(() => {`), plain "innermost-first" dead-loops:
|
||||
toggle closes the inner fold, then acts on it again and *reopens* it,
|
||||
forever — the outer fold is unreachable, and opening an inner fold while
|
||||
the outer is closed changes nothing on screen. Ordering is therefore
|
||||
keyed on fold **state** so every press has a visible effect: `fold.close`
|
||||
closes the **innermost open** fold (repeated presses walk outward);
|
||||
`fold.open` opens the **outermost closed** fold (repeated presses walk
|
||||
inward); `fold.toggle` **cycles org-TAB-style** — it closes the innermost
|
||||
open fold until every fold on the head is closed, then one more press
|
||||
opens them all.
|
||||
|
||||
**Data API (Q#FD4, F6): explicit buffer, no ambient resolution** (matching
|
||||
#127): `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`,
|
||||
`folds(buffer)`, `toggle(buffer, pos)`.
|
||||
|
||||
**Arbitrary-range normalization (R3-4).** A data-API `fold(buffer, range)`
|
||||
carries no node, so none of §3's introducer or closer inference applies —
|
||||
the caller names exactly what to hide. The head line is the line containing
|
||||
`range.start`; the hidden lines are the full lines strictly after it,
|
||||
through the line containing `range.end` — or through the *previous* line
|
||||
when `range.end` sits at a line start. The stored form is §5's; validation
|
||||
then applies.
|
||||
|
||||
**Validation (Q#FD11, F6).** `fold(buffer, range)` rejects unless: the buffer
|
||||
is a normal document buffer; both endpoints are UTF-8 boundaries; and the
|
||||
range normalizes to **≥1 hidden line**. Q#FD9 (terminals never fold) follows
|
||||
from the last clause (minor c): `(0,0)` on an empty terminal identity buffer
|
||||
is technically in-bounds, but it normalizes to zero hidden lines and is
|
||||
rejected — no special case.
|
||||
|
||||
**Default bindings (Q#FD4) — Emacs hideshow parity.** Stage 1 ships the
|
||||
`C-c @` prefix set. `C-c <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 2–3 are sketched; each is re-framed in detail after the prior stage
|
||||
lands. This framing asks approval for the architecture and Stage 1's detail.
|
||||
|
||||
## 9. Numbered decisions
|
||||
|
||||
- **Q#FD1** Structural node folding: match block-like node ≥2 source lines →
|
||||
resolve introducer↔body (matching + `close-all` only) → **head line = the
|
||||
line immediately above the first hidden line** (wrapped introducer text
|
||||
always visible) → **closer-aware tail** (closing-delimiter line kept
|
||||
visible; delimiter-less nodes hide through the last body line);
|
||||
stale/absent tree refuses. Indentation and curated queries deferred. (§3)
|
||||
- **Q#FD2** Fold state is instance-side, per-buffer, a set of ranges, shared
|
||||
by all frontends; nested allowed. (§4)
|
||||
- **Q#FD3** Stored range = line-aligned hidden interior, **start-exclusive,
|
||||
end-inclusive** with the matching `View` boundary bias; head line (the
|
||||
line above the interior) visible; closer line visible for closer-terminated
|
||||
nodes; invoking point moves to the head; no-cursor-inside is per-cursor
|
||||
render-time, creation-time-only in Stage 1. (§5)
|
||||
- **Q#FD4** Interactive commands (invoking frontend's buffer; shared head
|
||||
lines use **state-aware ordering** — close innermost-open, open
|
||||
outermost-closed, toggle cycles); data API takes an explicit buffer, no
|
||||
ambient resolution, with the §6 arbitrary-range normalization; **default
|
||||
bindings = the Emacs hideshow `C-c @` prefix set** (§6), rebindable. (§6)
|
||||
- **Q#FD5** The store `View` translates + drops only (provenance-blind); the
|
||||
**pre-edit interactive unfold** lives at the dispatch layer, keyed on the
|
||||
authenticated source frontend's point (not transport), unfolding **every**
|
||||
fold containing it; Stage 1 = command path (`dispatch_key`
|
||||
self-insert/delete), interactive-Lua-command widening = Stage 2,
|
||||
CRDT-origin = Stage 3. (§5)
|
||||
- **Q#FD6** A fold whose head/tail an edit destroys is dropped, not
|
||||
re-anchored. (§5)
|
||||
- **Q#FD7** Placeholder + gutter marker are frontend-local per path (TUI
|
||||
daemon-rendered, GPU wire-fed); `BlockAdornments` stays unproduced; no new
|
||||
wire type. (§7)
|
||||
- **Q#FD8** `FoldState` (semantic sessions only) is whole-buffer,
|
||||
authoritative-empty (initial empty suppressed until a fold exists; unchanged
|
||||
suppressed; non-empty→empty emits exactly one empty frame); its per-session
|
||||
baseline resets on `BufferSnapshot`; the STORE drops on content replacement;
|
||||
**the GPU fold mirror must clear on `BufferSnapshot`** or empty-after-revert
|
||||
suppression leaves stale folds (#120 class). (§5, §8)
|
||||
- **Q#FD9** Terminals never fold — from the ≥1-hidden-line validation, not
|
||||
from bounds. (§6)
|
||||
- **Q#FD10** Fold creation against a `None`/stale parse tree refuses. (§3)
|
||||
- **Q#FD11** `fold(buffer, range)` validates buffer kind, UTF-8 boundaries,
|
||||
and ≥1 hidden line after the §6 normalization; rejects otherwise. (§6)
|
||||
|
||||
## 10. Bets
|
||||
|
||||
- **B1** The block-kind heuristic (§3 step 1) and body-field bias (step 2)
|
||||
pick a fold *target* users find natural. FALSIFIABLE on real Rust/Python;
|
||||
fallback is curated Tier-1 queries. (The derived head line and the
|
||||
closer-aware tail are decided, not bet.)
|
||||
- **B2** Whole-buffer `FoldState` is cheap: folds are a handful, `close-all`
|
||||
is top-level only, so the set is O(top-level blocks). No viewport scoping.
|
||||
- **B3** Both collapse paths reuse existing machinery (daemon cell painting;
|
||||
the GPU's projected↔source map) — no new layout engine.
|
||||
|
||||
## 11. Deferred (named)
|
||||
|
||||
- Indentation folding for grammarless buffers (Q#FD1 (C)).
|
||||
- Curated per-language fold queries (Q#FD1 (A)).
|
||||
- Translate-a-node-range-through-pending-edits so creation need not refuse on a
|
||||
stale tree (Q#FD10 refinement).
|
||||
- Fold-store revalidation against new content on revert/reload (v1 drops it).
|
||||
- Persisted folds across sessions; `fold.hide-level N`; auto-fold-on-open.
|
||||
- `BlockAdornments` production (rich placeholders, diff zones, blame bands).
|
||||
- **Git gutter markers** — the sibling gutter rider; separate diff source.
|
||||
- Search revealing folds (a match inside a fold auto-unfolds) — Stage 2+.
|
||||
|
||||
## 12. Acceptance — Stage 1 (engine)
|
||||
|
||||
1. **Head line, both grammar shapes, wrapped headers (R2-1, R3-1).** In Rust
|
||||
`fn foo() { … }`, a point in the body folds with head line `fn foo() {`;
|
||||
with a rustfmt-wrapped signature (`fn foo(` / `a: u32,` / `) -> bool {`)
|
||||
the head is the `) -> bool {` line and **every signature line stays
|
||||
visible**. In Python `def foo(): / body`, a point in the body folds with
|
||||
head line `def foo():` — **not** a body line; with a wrapped signature
|
||||
the head is the `):` line and the signature stays visible. `close-all`
|
||||
on each yields the same heads.
|
||||
2. **Stale/absent tree (Q#FD10).** With `current() == None`, and with
|
||||
`pending_edit_count() > 0` after an edit before settle, `fold.toggle`
|
||||
refuses and stores nothing; after settle it succeeds.
|
||||
3. **Commands.** `fold.toggle` folds the enclosing region and unfolds on a
|
||||
head; `close-all` folds top-level regions only (a nested inner region is
|
||||
not auto-folded); `open-all` clears.
|
||||
4. **Range semantics (Q#FD3, R2-5).** For a brace node the closing-delimiter
|
||||
line is **outside** the stored range (stays visible) and a `} else {` case
|
||||
keeps `else {` visible; for a Python node the last body line is **inside**
|
||||
the range (hidden). `folds(buffer)` returns exactly the normalized ranges.
|
||||
5. **Point (Q#FD3).** Folding a range containing the invoking point moves it
|
||||
to the head; Stage 1 does not prevent later motion into a fold.
|
||||
6. **Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3, R3-3/4).** The store
|
||||
`View` translates a fold across a programmatic edit inside it and drops a
|
||||
fold whose head an edit deletes — with no knowledge of source. A
|
||||
command-path self-insert at a point inside a fold (or inside **nested**
|
||||
folds) unfolds **all** of them before the edit applies. A self-insert at
|
||||
the **end of the head line** (`point == range.start`) unfolds **nothing**
|
||||
and the fold shifts right — the character lands visible on the head line.
|
||||
An interactive Lua-command edit (e.g. a yank) inside a fold translates
|
||||
without unfolding in Stage 1; the test documents this as the named
|
||||
Stage 2 widening. (CRDT-origin unfold is asserted in Stage 3.)
|
||||
7. **`FoldState` production (Q#FD8, F7, R2-4).** The flipped pin test asserts
|
||||
all three transitions to a semantic session — nothing until a fold exists,
|
||||
nothing when unchanged, exactly one empty frame after `open-all` — while
|
||||
`BlockAdornments` is still never emitted; the per-session baseline resets on
|
||||
`BufferSnapshot`. The test documents that empty-after-snapshot suppression
|
||||
is correct only paired with the Stage 3 frontend-mirror clear.
|
||||
8. **Store lifecycle.** Buffer content replacement (revert) drops the store.
|
||||
9. **Nested folds (R3-2).** Folding an inner then an outer region yields two
|
||||
ranges; `open-all` clears both. On a shared head line: repeated
|
||||
`fold.close` closes innermost-then-outer, repeated `fold.open` opens
|
||||
outermost-then-inner, and `fold.toggle` cycles close-inner → close-outer
|
||||
→ open-all — the outer fold is reachable by every command.
|
||||
10. **Injected layer.** A fold sourced inside an injected layer — a fenced
|
||||
code block in a markdown buffer — returns the inner block's range, proving
|
||||
the source walks injection layers, not just the root tree.
|
||||
11. **Lua data API (Q#FD4/Q#FD11, R3-4).** `pmacs.fold.*` with an explicit
|
||||
buffer drives the above and round-trips `folds()`; an out-of-bounds,
|
||||
non-boundary, or sub-one-line range is rejected — including a range
|
||||
whose `end` sits at the start of the line after its head, which
|
||||
normalizes to zero hidden lines; a fold on a terminal identity buffer
|
||||
is rejected via the ≥1-hidden-line rule (Q#FD9).
|
||||
|
||||
## 13. Gates (Stage 1)
|
||||
|
||||
`cargo fmt --check`; strict workspace Clippy; `cargo test --lib` and
|
||||
`--features crdt`; `tests/folding_acceptance.rs` (default + CRDT); `cargo test
|
||||
--test m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test
|
||||
-p pmacs-gpu`; the workspace sweep; `git diff --check`. New behavioral
|
||||
acceptance is bite-verified with `scripts/bite`.
|
||||
|
||||
## 14. Branch and PR plan
|
||||
|
||||
Branch `folding`, worktree `../pmacs-folding`. This framing (rev 1 → rev 5)
|
||||
is its opening commits; the branch was **rebased onto canonical `main` @
|
||||
`96d0bae`** when implementation started (the earlier base `cac4961` was
|
||||
behind after the documentation + tab-width #137 housekeeping; Stage 1's
|
||||
instance-side scope does not overlap that work). Stage 1 implements on this
|
||||
same branch and opens as the first folding PR. Stages 2 and 3 are separate
|
||||
branches/PRs off the main resulting from the prior stage, each with its own
|
||||
detailed framing.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, ®, 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, ®, 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, ®, 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, ®, 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, ®, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(®, "rust", src.as_bytes());
|
||||
let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable");
|
||||
assert_eq!(r.start, line_content_end_of(src, "fn foo() {"));
|
||||
assert_eq!(
|
||||
line_text_at(src, r.start),
|
||||
"fn foo() {",
|
||||
"head line is the fn line"
|
||||
);
|
||||
assert_eq!(
|
||||
line_text_at(src, r.start + 1),
|
||||
" let x = 1;",
|
||||
"first hidden line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_line_rust_wrapped_signature_keeps_signature_visible() {
|
||||
// R3-1: rustfmt puts `{` on the `) -> bool {` line; the head must be
|
||||
// that line, NOT `fn foo(` — the wrapped signature stays visible.
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "fn foo(\n a: u32,\n) -> bool {\n true\n}\n";
|
||||
let bundle = parse(®, "rust", src.as_bytes());
|
||||
let r = fold_target_at(&bundle, byte_of(src, "true")).expect("foldable");
|
||||
assert_eq!(r.start, line_content_end_of(src, ") -> bool {"));
|
||||
assert_eq!(line_text_at(src, r.start), ") -> bool {");
|
||||
// The two wrapped signature lines are before the fold → visible.
|
||||
assert!(r.start > line_content_end_of(src, "a: u32,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_line_python_uses_def_not_a_body_line() {
|
||||
// R2-1: tree-sitter-python's `block` starts on the first statement
|
||||
// line, so the head must ascend to `def foo():`, not `x = 1`.
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "def foo():\n x = 1\n y = 2\n";
|
||||
let bundle = parse(®, "python", src.as_bytes());
|
||||
let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable");
|
||||
assert_eq!(r.start, line_content_end_of(src, "def foo():"));
|
||||
assert_eq!(line_text_at(src, r.start), "def foo():");
|
||||
assert_eq!(line_text_at(src, r.start + 1), " x = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_line_python_wrapped_signature_keeps_signature_visible() {
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "def foo(\n a,\n):\n x = 1\n";
|
||||
let bundle = parse(®, "python", src.as_bytes());
|
||||
let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable");
|
||||
assert_eq!(r.start, line_content_end_of(src, "):"));
|
||||
assert_eq!(line_text_at(src, r.start), "):");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Range semantics — closer-aware tail (R2-5).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn brace_closer_line_stays_visible() {
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n";
|
||||
let bundle = parse(®, "rust", src.as_bytes());
|
||||
let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable");
|
||||
// Last hidden line is the last body line; the `}` line is outside.
|
||||
assert_eq!(r.end, line_content_end_of(src, "let y = 2;"));
|
||||
assert_eq!(
|
||||
line_text_at(src, r.end + 1),
|
||||
"}",
|
||||
"closer line stays visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_closer_line_else_stays_visible() {
|
||||
// R2-5: `} else {` keeps its trailing sibling on screen.
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "fn f() {\n if a {\n one();\n } else {\n two();\n }\n}\n";
|
||||
let bundle = parse(®, "rust", src.as_bytes());
|
||||
let r = fold_target_at(&bundle, byte_of(src, "one()")).expect("foldable");
|
||||
// The consequent block folds; its `} else {` line stays visible.
|
||||
assert_eq!(line_text_at(src, r.end + 1).trim(), "} else {");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_hides_through_last_body_line() {
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "def foo():\n x = 1\n y = 2\n";
|
||||
let bundle = parse(®, "python", src.as_bytes());
|
||||
let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable");
|
||||
// Delimiter-less: the last body line is hidden (inside the range).
|
||||
assert_eq!(r.end, line_content_end_of(src, "y = 2"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. close-all folds top-level regions only; 9. nested + state-aware order.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn close_all_is_top_level_only() {
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "fn a() {\n if c {\n work();\n more();\n }\n}\n\nfn b() {\n x();\n y();\n}\n";
|
||||
let bundle = parse(®, "rust", src.as_bytes());
|
||||
let top = top_level_fold_targets(&bundle);
|
||||
assert_eq!(
|
||||
top.len(),
|
||||
2,
|
||||
"two top-level fns, the nested `if` is not auto-folded"
|
||||
);
|
||||
// Neither top-level range is the inner `if` block.
|
||||
let inner = fold_target_at(&bundle, byte_of(src, "work()")).expect("inner foldable");
|
||||
assert!(
|
||||
!top.contains(&inner),
|
||||
"close-all does not fold the nested region"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_state_aware_ordering() {
|
||||
// 9 / R3-2: close walks innermost→outer, open walks outer→inner, and
|
||||
// toggle cycles close-inner → close-outer → open-all so every command
|
||||
// reaches the outer fold.
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "fn outer() {\n if cond {\n work();\n more();\n }\n}\n";
|
||||
let bundle = parse(®, "rust", src.as_bytes());
|
||||
let p = byte_of(src, "work()");
|
||||
|
||||
let mut store = FoldStore::new();
|
||||
let inner = close_at(&mut store, &bundle, p).expect("close inner");
|
||||
let outer = close_at(&mut store, &bundle, p).expect("close outer");
|
||||
assert!(
|
||||
inner.start > outer.start,
|
||||
"inner fold is more deeply nested"
|
||||
);
|
||||
assert!(
|
||||
close_at(&mut store, &bundle, p).is_none(),
|
||||
"nothing left to close"
|
||||
);
|
||||
assert_eq!(store.folds().len(), 2);
|
||||
|
||||
assert_eq!(open_at(&mut store, p), Some(outer), "open outermost first");
|
||||
assert_eq!(open_at(&mut store, p), Some(inner), "then the inner");
|
||||
assert!(store.is_empty());
|
||||
|
||||
assert!(matches!(
|
||||
cycle_at(&mut store, &bundle, p),
|
||||
CycleOutcome::Closed(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
cycle_at(&mut store, &bundle, p),
|
||||
CycleOutcome::Closed(_)
|
||||
));
|
||||
assert_eq!(store.folds().len(), 2, "cycle closed both");
|
||||
assert!(matches!(
|
||||
cycle_at(&mut store, &bundle, p),
|
||||
CycleOutcome::OpenedAll(2)
|
||||
));
|
||||
assert!(store.is_empty(), "one more cycle opened them all");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Injected layer (a fenced rust block inside markdown).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fold_sourced_inside_injected_layer() {
|
||||
let reg = SyntaxRegistry::new();
|
||||
let src = "# Title\n\n```rust\nfn demo() {\n let x = 1;\n let y = 2;\n}\n```\n\nText.\n";
|
||||
let bundle = parse(®, "markdown", src.as_bytes());
|
||||
assert!(
|
||||
bundle.layers.len() >= 2,
|
||||
"markdown fence produced an injected rust layer"
|
||||
);
|
||||
let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable inside the fence");
|
||||
assert_eq!(
|
||||
line_text_at(src, r.start),
|
||||
"fn demo() {",
|
||||
"resolved the inner block"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Stale / absent parse tree refuses (the precondition the binding keys on).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn absent_tree_has_no_current_bundle() {
|
||||
let reg = SyntaxRegistry::new();
|
||||
let language = reg.language("rust").expect("grammar");
|
||||
let src = b"fn foo() {\n let x = 1;\n}\n";
|
||||
let buf = Buffer::from_bytes(BufferId::next(), "doc", src);
|
||||
let view = ParseView::new(&buf, language, "rust".to_owned());
|
||||
let handle = view.handle();
|
||||
// Before any parse installs, `current()` is None → the binding refuses.
|
||||
assert!(handle.current().is_none());
|
||||
let mut req = handle.make_request();
|
||||
req.injection_aliases = reg.injection_alias_snapshot();
|
||||
let bundle = run_parse(req).expect("parse");
|
||||
handle.install(reg.resolve_layer_queries(&bundle));
|
||||
assert!(
|
||||
handle.current().is_some(),
|
||||
"after install, a target is derivable"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Command-path pre-edit unfold (Q#FD5).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn active_id(s: &EditorState) -> BufferId {
|
||||
s.core.borrow().active_buffer_id()
|
||||
}
|
||||
|
||||
fn insert_into(s: &EditorState, id: BufferId, text: &str) {
|
||||
let core = s.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.get_mut(id)
|
||||
.unwrap()
|
||||
.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: text.as_bytes(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_path_self_insert_unfolds_at_point() {
|
||||
let s = EditorState::new();
|
||||
let id = active_id(&s);
|
||||
insert_into(&s, id, "line0\nline1\nline2\nline3\n");
|
||||
// Fold the interior of lines 1..2: [end of line0, end of line2].
|
||||
let store = {
|
||||
let core = s.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
s.fold_registry.store_or_attach(reg.get_mut(id).unwrap())
|
||||
};
|
||||
store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(ByteRange { start: 5, end: 17 });
|
||||
// Cursor strictly inside the fold (start of "line2").
|
||||
s.core.borrow_mut().set_cursor_byte(12);
|
||||
|
||||
// A command-path self-insert unfolds before the edit lands.
|
||||
s.core.borrow_mut().insert_char('x');
|
||||
assert!(
|
||||
store.lock().unwrap().is_empty(),
|
||||
"typing inside a fold unfolds it (Q#FD5)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_insert_at_head_line_end_does_not_unfold() {
|
||||
// `(start, end]` containment: a self-insert exactly at the end of the
|
||||
// head line (== range.start) is outside the fold — it must NOT unfold,
|
||||
// and the translator shifts the fold right so the char lands visible.
|
||||
let s = EditorState::new();
|
||||
let id = active_id(&s);
|
||||
insert_into(&s, id, "line0\nline1\nline2\nline3\n");
|
||||
let store = {
|
||||
let core = s.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
s.fold_registry.store_or_attach(reg.get_mut(id).unwrap())
|
||||
};
|
||||
store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(ByteRange { start: 5, end: 17 });
|
||||
s.core.borrow_mut().set_cursor_byte(5); // end of head line "line0"
|
||||
|
||||
s.core.borrow_mut().insert_char('x');
|
||||
let folds = store.lock().unwrap().folds();
|
||||
assert_eq!(
|
||||
folds,
|
||||
vec![ByteRange { start: 6, end: 18 }],
|
||||
"fold shifts right; the character lands on the head line"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Point moves to the head line when a fold is created around it (Q#FD3).
|
||||
// 11. Data-API validation (Q#FD11) — driven through the Lua surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<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);
|
||||
}
|
||||
Loading…
Reference in New Issue