feat(fold): Arc 6 Stage 1 — instance fold engine (headless)
The fold engine behind `docs/folding-framing.md` (approved rev 5): a per-buffer fold store, a structural tree-sitter fold source, the state-aware Lua command + data-API surface with the Emacs hideshow `C-c @` bindings, the dispatch-layer pre-edit unfold, and `FoldState` production. No rendering — Stages 2 (grid) and 3 (GPU) consume the store. - `src/fold.rs`: `FoldStore` (a buffer-attached `View` that translates ranges on every edit and drops any whose head/tail the edit crosses, provenance-blind — Q#FD6), the structural source (nearest block-like node >= 2 source lines -> introducer<->body -> **derived head line**, the line immediately above the first hidden line, so wrapped signatures and `where` clauses stay visible per R3-1 -> **closer-aware tail**, a closing-delimiter line stays visible per R2-5), injection-layer walk, `(start, end]` containment, and the state-aware ops (close innermost open / open outermost closed / org-TAB cycle). Stale/absent tree refuses (Q#FD10). - `src/lua_bindings/fold.rs`: `pmacs.fold.*` — explicit-buffer data API (`fold`/`unfold`/`folds`/`toggle`) + interactive helpers, validation (Q#FD11: document buffer, UTF-8 boundaries, >= 1 hidden line — Q#FD9 falls out of the last clause), point-moves-to-head (Q#FD3). - `builtin/runtime/fold.lua`: `fold.toggle/close/open/close-all/open-all` commands + the `C-c @` prefix set (Q#FD4). - `src/editor_core.rs`: the six point-anchored edit primitives run the pre-edit unfold keyed on the authenticated source's point (Q#FD5, command path); `EditorCore` owns the shared `FoldRegistry`. - `src/semantic_render.rs`: the `FoldState` producer — authoritative-empty, diff-suppressed, baseline resets on `BufferSnapshot` (Q#FD8); the "never emitted" pin split so `BlockAdornments` stays unproduced. - `tests/folding_acceptance.rs` (16) over real Rust/Python/markdown grammars + `fold_state_producer_transitions` + 15 engine unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
This commit is contained in:
parent
40a820afb8
commit
3b411dbb2a
|
|
@ -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" }
|
||||
|
|
@ -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,817 @@
|
|||
// 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 {
|
||||
// Strictly after the fold (an insert at exactly `e` too).
|
||||
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) and buffer-close reset. Named
|
||||
/// bytes no longer exist, so revalidation is not attempted (Q#FD8).
|
||||
pub fn forget(&self, buffer: &mut Buffer) {
|
||||
if let Some(entry) = self.stores.borrow_mut().remove(&buffer.id()) {
|
||||
buffer.detach_view(entry.view);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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,381 @@
|
|||
// 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`. Folding a range containing the invoking frontend's point moves
|
||||
//! that point to the head line (Q#FD3).
|
||||
|
||||
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<()> {
|
||||
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 mut s = lock(&store);
|
||||
let mut n = 0i64;
|
||||
for t in targets {
|
||||
if s.insert(t) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
// A real change; `empty → empty` conveys nothing and is
|
||||
// suppressed, `non-empty → empty` is a change worth sending.
|
||||
Some(prev) => *prev != folds && !(folds.is_empty() && prev.is_empty()),
|
||||
};
|
||||
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,491 @@
|
|||
//! 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::{
|
||||
self, 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!(fold::make_shared_fold_registry().folds(id).is_empty());
|
||||
}
|
||||
Loading…
Reference in New Issue