From 3b411dbb2afc88665c49bc2300ed82a8bc898aa7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 12:14:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(fold):=20Arc=206=20Stage=201=20=E2=80=94?= =?UTF-8?q?=20instance=20fold=20engine=20(headless)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- builtin/runtime/fold.lua | 52 +++ src/editor.rs | 24 ++ src/editor_core.rs | 29 ++ src/fold.rs | 817 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/lua_bindings/fold.rs | 381 +++++++++++++++++ src/lua_bindings/mod.rs | 2 + src/semantic_render.rs | 175 +++++++- tests/folding_acceptance.rs | 491 ++++++++++++++++++++++ 9 files changed, 1951 insertions(+), 21 deletions(-) create mode 100644 builtin/runtime/fold.lua create mode 100644 src/fold.rs create mode 100644 src/lua_bindings/fold.rs create mode 100644 tests/folding_acceptance.rs diff --git a/builtin/runtime/fold.lua b/builtin/runtime/fold.lua new file mode 100644 index 0000000..60e6f82 --- /dev/null +++ b/builtin/runtime/fold.lua @@ -0,0 +1,52 @@ +-- fold.lua --- interactive fold commands + default bindings (Arc 6). +-- +-- Thin command wrappers over the `pmacs.fold` Rust surface. Each resolves +-- the invoking frontend's active-window buffer and point (command context, +-- not an ambient buffer), then calls the state-aware helper; the Rust side +-- refuses on a stale/absent parse tree, validates, and moves the point to +-- the head line when it folds around it. +-- +-- Default bindings are the Emacs hideshow `C-c @` prefix set (Q#FD4): the +-- LSP surface already owns every `C-c `, so `C-c @` is the one +-- faithful prefix that collides with nothing. Rebind through pmacs.keymap. +-- +-- Framing: docs/folding-framing.md. + +local ed = pmacs.editor +local fold = pmacs.fold + +pmacs.command.define { + name = "fold.toggle", + description = "Toggle the fold at point (org-TAB cycle)", + fn = function() fold.cycle(pmacs.window.buffer(), ed.cursor()) end, +} + +pmacs.command.define { + name = "fold.close", + description = "Close the innermost open fold at point", + fn = function() fold.close(pmacs.window.buffer(), ed.cursor()) end, +} + +pmacs.command.define { + name = "fold.open", + description = "Open the outermost closed fold at point", + fn = function() fold.open(pmacs.window.buffer(), ed.cursor()) end, +} + +pmacs.command.define { + name = "fold.close-all", + description = "Close all top-level folds in the buffer", + fn = function() fold.close_all(pmacs.window.buffer()) end, +} + +pmacs.command.define { + name = "fold.open-all", + description = "Open all folds in the buffer", + fn = function() fold.open_all(pmacs.window.buffer()) end, +} + +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-c", command = "fold.toggle" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-h", command = "fold.close" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-s", command = "fold.open" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-M-h", command = "fold.close-all" } +pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-M-s", command = "fold.open-all" } diff --git a/src/editor.rs b/src/editor.rs index 6ac3509..00f0fd4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -104,6 +104,10 @@ pub struct EditorState { /// default --- M4.2 wires the actual `tree-sitter-rust` and /// `tree-sitter-lua` registrations at startup. pub syntax_registry: crate::syntax::SharedSyntaxRegistry, + /// Per-buffer fold stores (Arc 6). The same `Rc` the core owns (for + /// the pre-edit unfold) and the `pmacs.fold` Lua surface reaches (via + /// Lua app-data); read here by the semantic `FoldState` producer. + pub fold_registry: crate::fold::SharedFoldRegistry, /// Process supervisor (T M4.4). Owns every child process the /// editor has spawned (LSP servers from M4.5; REPLs from M5). /// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor @@ -283,6 +287,15 @@ impl EditorState { // state, but its search overlay resolves wash faces through // this handle. core.borrow_mut().theme = Some(syntax_registry.theme()); + // Arc 6 folding: the core created the fold registry; share that + // same `Rc` into the `pmacs.fold` Lua surface (app-data) so + // commands and the data API mutate the stores the pre-edit unfold + // and the semantic producer read. Installed after + // `make_syntax_registry` so the data API can reach the parse tree + // (also app-data) when it computes a fold target. + let fold_registry = core.borrow().fold_registry.clone(); + crate::lua_bindings::install_fold(lua_host.lua(), &fold_registry) + .expect("install pmacs.fold"); // Arc 4 stage 2 (Q#F2/Q#F3): the GPU font preference and its // `pmacs.gpu` Lua surface. Installed BEFORE load_user_config // below, so an init.lua `set_font` lands in the same handle @@ -459,6 +472,16 @@ impl EditorState { include_str!("../builtin/runtime/comment.lua"), ) .expect("load comment builtin chunk"); + // Arc 6 folding: interactive fold commands + the Emacs hideshow + // `C-c @` bindings. Depends on the `pmacs.fold` Rust surface + // (installed above, after make_syntax_registry) plus pmacs.command + // / pmacs.keymap / pmacs.editor (all pre-runtime). + lua_host + .eval( + Some("@pmacs/builtin/runtime/fold.lua"), + include_str!("../builtin/runtime/fold.lua"), + ) + .expect("load fold builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/indent.lua"), @@ -551,6 +574,7 @@ impl EditorState { interactive_origin, async_runtime, syntax_registry, + fold_registry, process_supervisor, terminal_manager, lsp_manager, diff --git a/src/editor_core.rs b/src/editor_core.rs index ded8143..5afbe90 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -214,6 +214,13 @@ pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner /// of every buffer; windows reference buffers by [`BufferId`]. pub registry: SharedRegistry, + /// Per-buffer fold stores (Arc 6). Shared with `EditorState`, the + /// semantic `FoldState` producer, and the `pmacs.fold` Lua surface + /// — the same `Rc`. The core reaches it so the six point-anchored + /// edit primitives can run the dispatch-layer pre-edit unfold + /// (Q#FD5): a command-path self-insert/delete at a point inside a + /// fold unfolds it before the edit applies. + pub fold_registry: crate::fold::SharedFoldRegistry, /// All windows, keyed by id for stable iteration. `WindowId`s /// are globally unique across all frontends; each /// [`FrontendView`] in `views` references a subset via its @@ -383,6 +390,7 @@ impl EditorCore { ); Self { registry, + fold_registry: crate::fold::make_shared_fold_registry(), windows, views, status: String::new(), @@ -1827,11 +1835,27 @@ impl EditorCore { aw.goal_col = None; } + /// Dispatch-layer pre-edit unfold (Arc 6, Q#FD5). Before a + /// command-path point-anchored edit (the six primitives below), + /// unfold every fold containing the active point so a self-insert or + /// delete inside a collapsed region reveals it rather than landing + /// invisibly. Keyed on the authenticated source frontend's active + /// point (this is `active_window().cursor`), not the transport. A + /// no-op when the buffer has no folds. Interactive Lua-command edits + /// (yank/query-replace/comment) reach the buffer through a different + /// path and are a named Stage 2 widening; CRDT-origin is Stage 3. + fn unfold_before_point_edit(&self) { + let id = self.active_buffer_id(); + let point = self.active_window().cursor; + self.fold_registry.unfold_containing(id, point); + } + /// Insert a single character at the cursor. Returns `true` iff the /// edit landed: a rejecting buffer intercept reports via the status /// line and returns `false`, and callers must not mutate dependent /// state (e.g. selection anchors) on a failed insert (Q#AI9). pub fn insert_char(&mut self, ch: char) -> bool { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let mut buf = [0u8; 4]; let s = ch.encode_utf8(&mut buf); @@ -1867,6 +1891,7 @@ impl EditorCore { /// delegates to [`Self::insert_char`] (a plain insert). The cursor /// lands just past the inserted bytes and any selection is cleared. pub fn insert_char_over_region(&mut self, ch: char) { + self.unfold_before_point_edit(); let Some((lo, hi)) = self.active_region() else { // Q#AI9: an empty selection (anchor == cursor) reports no // region yet stays armed — the insert moves the cursor off @@ -1908,6 +1933,7 @@ impl EditorCore { /// Delete the codepoint immediately before the cursor. pub fn backspace(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; if cursor == 0 { @@ -1929,6 +1955,7 @@ impl EditorCore { /// Delete the codepoint at the cursor (forward delete). pub fn delete_forward(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; let id = self.active_buffer_id(); @@ -1952,6 +1979,7 @@ impl EditorCore { /// between the cursor and where [`Self::move_word_left`] would /// land. pub fn delete_word_backward(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; if cursor == 0 { @@ -1979,6 +2007,7 @@ impl EditorCore { /// [`Self::delete_forward`] over the gap from the cursor to where /// [`Self::move_word_right`] would land. pub fn delete_word_forward(&mut self) { + self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; let id = self.active_buffer_id(); diff --git a/src/fold.rs b/src/fold.rs new file mode 100644 index 0000000..3772b34 --- /dev/null +++ b/src/fold.rs @@ -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 { + let mut out = Vec::with_capacity(source.len() / 32 + 1); + out.push(0); + for (i, b) in source.iter().enumerate() { + if *b == b'\n' { + out.push(i as u32 + 1); + } + } + out +} + +/// Index of the line containing byte `offset`. +fn line_at_offset(line_offsets: &[u32], offset: u32) -> usize { + match line_offsets.binary_search(&offset) { + Ok(i) => i, + Err(i) => i.saturating_sub(1), + } +} + +/// The byte offset just past line `row`'s last *visible* character — i.e. +/// the position of the row's terminating `\n`, or `source.len()` for the +/// final unterminated line. This is the "end of line" the stored range +/// uses for both its head and tail. +fn line_content_end(source: &[u8], line_offsets: &[u32], row: usize) -> u64 { + let start = line_offsets + .get(row) + .copied() + .unwrap_or(source.len() as u32) as usize; + let next = line_offsets + .get(row + 1) + .copied() + .unwrap_or(source.len() as u32) as usize; + let mut end = next.min(source.len()); + if end > start && source[end - 1] == b'\n' { + end -= 1; + } + end as u64 +} + +/// True iff line `row`'s first non-whitespace byte is a closing delimiter +/// (`}`, `)`, `]`) — the closer-aware tail test. +fn line_starts_with_closer(source: &[u8], line_offsets: &[u32], row: usize) -> bool { + let Some(&ls) = line_offsets.get(row) else { + return false; + }; + let mut i = ls as usize; + while i < source.len() && (source[i] == b' ' || source[i] == b'\t') { + i += 1; + } + i < source.len() && CLOSE_DELIMS.contains(&source[i]) +} + +// --------------------------------------------------------------------------- +// FoldStore — the per-buffer set of collapsed ranges. +// --------------------------------------------------------------------------- + +/// A buffer's set of currently-collapsed ranges. Kept sorted by +/// `(start, end)`; nested folds are allowed; exact duplicates are not. +#[derive(Debug, Default)] +pub struct FoldStore { + folds: Vec, +} + +impl FoldStore { + /// An empty store. + #[must_use] + pub fn new() -> Self { + Self { folds: Vec::new() } + } + + /// Whether the store holds no folds. + #[must_use] + pub fn is_empty(&self) -> bool { + self.folds.is_empty() + } + + /// The current folds, sorted and stable — the form the producer diffs + /// and `pmacs.fold.folds` returns. + #[must_use] + pub fn folds(&self) -> Vec { + self.folds.clone() + } + + /// Whether an exactly-equal fold range is already stored. + #[must_use] + pub fn contains_exact(&self, r: ByteRange) -> bool { + self.folds.contains(&r) + } + + /// Add a fold. Rejects an empty/inverted range or an exact duplicate; + /// returns whether it was added. + pub fn insert(&mut self, r: ByteRange) -> bool { + if r.end <= r.start || self.contains_exact(r) { + return false; + } + self.folds.push(r); + self.normalize(); + true + } + + /// Remove an exact fold; returns whether one was removed. + pub fn remove(&mut self, r: ByteRange) -> bool { + let before = self.folds.len(); + self.folds.retain(|f| *f != r); + self.folds.len() != before + } + + /// Drop every fold; returns whether anything was cleared. + pub fn clear(&mut self) -> bool { + let had = !self.folds.is_empty(); + self.folds.clear(); + had + } + + /// Folds whose interior contains `p` under `(start, end]` containment, + /// **innermost first** (a more deeply nested fold has the larger start). + #[must_use] + pub fn containing(&self, p: u64) -> Vec { + let mut v: Vec = self + .folds + .iter() + .copied() + .filter(|f| f.start < p && p <= f.end) + .collect(); + v.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end))); + v + } + + /// Remove every fold containing `p` (the dispatch-layer pre-edit + /// unfold, and the org-TAB "open all" leg). Returns the count removed. + pub fn unfold_containing(&mut self, p: u64) -> usize { + let before = self.folds.len(); + self.folds.retain(|f| !(f.start < p && p <= f.end)); + before - self.folds.len() + } + + fn normalize(&mut self) { + self.folds + .sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + self.folds.dedup(); + } + + /// Translate every fold across `edit`, dropping any whose head or tail + /// the edit crosses (Q#FD6). Provenance-blind: `Edit` carries no source + /// frontend, so this cannot (and must not) unfold-on-typing — that is + /// the dispatch layer's pre-edit job. + /// + /// Boundary handling mirrors `BufferStyleSpanTranslator`'s right-bias: + /// an insert exactly at the start (end of the head line) shifts the + /// whole fold right, so the character lands visible on the head line; + /// an insert exactly at the end is left outside. + pub fn translate(&mut self, edit: &Edit) { + let os = edit.range.start; + let oe = edit.range.end; + let old_len = oe - os; + let new_len = edit.inserted_len; + // Buffers broadcast no-op edits; nothing moved. + if old_len == 0 && new_len == 0 { + return; + } + // Shift a byte offset by the edit's signed length delta, in u64 + // arithmetic (no `as i64` wrap): grow by `new_len - old_len` or + // shrink by `old_len - new_len`, saturating at 0. + let shift = |x: u64| -> u64 { + if new_len >= old_len { + x + (new_len - old_len) + } else { + x.saturating_sub(old_len - new_len) + } + }; + let mut kept = Vec::with_capacity(self.folds.len()); + for f in self.folds.drain(..) { + let (s, e) = (f.start, f.end); + let next = if oe <= s { + // Strictly before the fold (an insert at exactly `s` lands + // here, shifting the fold right — the head-line right-bias). + Some(ByteRange { + start: shift(s), + end: shift(e), + }) + } else if os >= e { + // 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>, +} + +impl View for FoldStoreTranslator { + fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), BufferError> { + self.store + .lock() + .expect("fold store mutex poisoned") + .translate(edit); + Ok(()) + } + + fn kind(&self) -> &'static str { + "fold_store_translator" + } +} + +// --------------------------------------------------------------------------- +// FoldRegistry — per-buffer stores, keyed by BufferId (the SyntaxRegistry +// model), each paired with a translator View over the same Arc. +// --------------------------------------------------------------------------- + +/// Shared, cloneable handle to the process's fold stores. Held by +/// `EditorCore` (for the pre-edit unfold), by `EditorState` and the +/// semantic producer (to ship `FoldState`), and by the `pmacs.fold` Lua +/// bindings (via Lua app-data) — all the same `Rc`. +pub type SharedFoldRegistry = Rc; + +struct FoldEntry { + store: Arc>, + view: ViewId, +} + +/// One fold store per buffer. Interior-mutable so a `&SharedFoldRegistry` +/// suffices everywhere. +#[derive(Default)] +pub struct FoldRegistry { + stores: RefCell>, +} + +/// Build a fresh, empty fold registry. +#[must_use] +pub fn make_shared_fold_registry() -> SharedFoldRegistry { + Rc::new(FoldRegistry::default()) +} + +impl FoldRegistry { + /// The buffer's store if one exists — the lookup used by read-only + /// callers (the pre-edit unfold and the producer) that must not + /// materialize a store or attach a view. + #[must_use] + pub fn store(&self, buf: BufferId) -> Option>> { + self.stores.borrow().get(&buf).map(|e| Arc::clone(&e.store)) + } + + /// The buffer's folds (sorted; empty when it has no store). + #[must_use] + pub fn folds(&self, buf: BufferId) -> Vec { + self.store(buf) + .map(|s| s.lock().expect("fold store mutex poisoned").folds()) + .unwrap_or_default() + } + + /// Get-or-create the store for `buffer`, attaching the translator view + /// on first materialization so every later edit is tracked. + pub fn store_or_attach(&self, buffer: &mut Buffer) -> Arc> { + let id = buffer.id(); + if let Some(existing) = self.stores.borrow().get(&id) { + return Arc::clone(&existing.store); + } + let store = Arc::new(Mutex::new(FoldStore::new())); + let view = buffer.attach_view(Box::new(FoldStoreTranslator { + store: Arc::clone(&store), + })); + self.stores.borrow_mut().insert( + id, + FoldEntry { + store: Arc::clone(&store), + view, + }, + ); + store + } + + /// Drop the buffer's store and detach its translator view — the + /// content-replacement (revert/reload) 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 { + candidates_at(bundle, pos).into_iter().next() +} + +/// Every foldable region enclosing `pos`, **innermost first**. The +/// state-aware commands walk this list against the store to decide what to +/// close (innermost open) or open (outermost closed). +#[must_use] +pub fn candidates_at(bundle: &ParseTreeBundle, pos: u64) -> Vec { + let source: &[u8] = &bundle.source; + let line_offsets = compute_line_offsets(source); + let Some(node) = innermost_named_node(bundle, pos) else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut cur = Some(node); + while let Some(n) = cur { + if let Some(r) = fold_from_node(n, source, &line_offsets) + && !out.contains(&r) + { + out.push(r); + } + cur = n.parent(); + } + out +} + +/// The top-level foldable regions in the buffer — what `fold.close-all` +/// collapses (Emacs `hs-hide-all`: top level only, nested not auto-folded). +#[must_use] +pub fn top_level_fold_targets(bundle: &ParseTreeBundle) -> Vec { + let source: &[u8] = &bundle.source; + let line_offsets = compute_line_offsets(source); + let root = bundle.root_tree().root_node(); + let mut out = Vec::new(); + let mut cursor = root.walk(); + for child in root.named_children(&mut cursor) { + if let Some(r) = fold_from_node(child, source, &line_offsets) + && !out.contains(&r) + { + out.push(r); + } + } + out +} + +/// The innermost named node at `pos`, resolved through injection layers: +/// the deepest layer whose root span covers `pos` wins (a fenced code block +/// inside markdown resolves to the inner block, not the markdown node). +fn innermost_named_node(bundle: &ParseTreeBundle, pos: u64) -> Option> { + let p = pos as usize; + let mut best: Option<&crate::syntax::Layer> = None; + for layer in &bundle.layers { + let root = layer.tree.root_node(); + if root.start_byte() <= p && p <= root.end_byte() { + best = match best { + Some(b) if b.depth >= layer.depth => Some(b), + _ => Some(layer), + }; + } + } + best?.tree.root_node().named_descendant_for_byte_range(p, p) +} + +/// Compute the fold range a single node yields, or `None` if it is not a +/// foldable structure (< 2 source lines, no block-like body, or a +/// normalized interior with < 1 hidden line). +fn fold_from_node(n: Node<'_>, source: &[u8], line_offsets: &[u32]) -> Option { + // Match condition: the node spans >= 2 source lines. + if n.end_position().row <= n.start_position().row { + return None; + } + let (b, introduced) = resolve_body(n, source)?; + + let b_start_row = b.start_position().row; + let b_end_row = b.end_position().row; + let b_start_byte = b.start_byte(); + if b_start_byte >= source.len() { + return None; + } + let is_brace = OPEN_DELIMS.contains(&source[b_start_byte]); + + // Head line = the line immediately above the first hidden line. For a + // brace body that is the `{` line (the introducer's own line, or the + // `) -> bool {` line when a signature wraps). For an *introduced* + // delimiter-less body (a Python `block`) the introducer's header ends + // on the line above, so it is `b_start_row - 1`. + let head_row = if is_brace { + b_start_row + } else if introduced && b_start_row > 0 { + b_start_row - 1 + } else { + b_start_row + }; + + // Tail: a closing-delimiter line stays visible (`} else {`); a + // delimiter-less body hides through its last line. + let last_hidden_row = if line_starts_with_closer(source, line_offsets, b_end_row) { + if b_end_row == 0 { + return None; + } + b_end_row - 1 + } else { + b_end_row + }; + + // Foldability = the normalized interior has >= 1 hidden line. + if last_hidden_row < head_row + 1 { + return None; + } + let start = line_content_end(source, line_offsets, head_row); + let end = line_content_end(source, line_offsets, last_hidden_row); + if end <= start { + return None; + } + Some(ByteRange { start, end }) +} + +/// Resolve the interior-defining body `B` and whether it is *introduced* +/// (its parent is an introducer whose body field is `B`). If `n` is itself +/// a body, use it; if it is an introducer with a block-like body child, +/// descend to that child (Q#FD1 step 2 — matching/`close-all` association). +fn resolve_body<'tree>(n: Node<'tree>, source: &[u8]) -> Option<(Node<'tree>, bool)> { + if is_body_kind(n, source) { + return Some((n, is_introduced(n))); + } + if let Some(b) = body_child(n) + && is_body_kind(b, source) + { + return Some((b, true)); + } + None +} + +fn body_child(n: Node) -> Option { + n.child_by_field_name("body") + .or_else(|| n.child_by_field_name("consequence")) +} + +fn is_introduced(n: Node<'_>) -> bool { + if let Some(p) = n.parent() + && let Some(b) = body_child(p) + { + return b.id() == n.id(); + } + false +} + +/// A node is a fold *body* if it opens with a bracket delimiter (a brace +/// body) or is a grammar block node (an indentation body). The delimiter +/// probe generalizes across grammars without a per-language kind list. +fn is_body_kind(n: Node<'_>, source: &[u8]) -> bool { + let sb = n.start_byte(); + if sb < source.len() && OPEN_DELIMS.contains(&source[sb]) { + return true; + } + matches!( + n.kind(), + "block" + | "statement_block" + | "declaration_list" + | "field_declaration_list" + | "enum_variant_list" + | "block_mapping" + | "block_sequence" + ) || n.kind().ends_with("_body") +} + +// --------------------------------------------------------------------------- +// State-aware operations (Q#FD4 shared-head ordering). Pure over a store +// (+ parse bundle); the Lua bindings drive them and move the point. +// --------------------------------------------------------------------------- + +/// Close the innermost still-open foldable region at `p`; returns the newly +/// folded range. Repeated calls walk outward. +pub fn close_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> Option { + for c in candidates_at(bundle, p) { + if !store.contains_exact(c) { + store.insert(c); + return Some(c); + } + } + None +} + +/// Open the outermost currently-closed fold at `p`; returns the removed +/// range. Repeated calls walk inward. +pub fn open_at(store: &mut FoldStore, p: u64) -> Option { + let mut containing = store.containing(p); + // `containing` is innermost-first; the outermost has the smallest start. + containing.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + let outer = containing.into_iter().next()?; + store.remove(outer); + Some(outer) +} + +/// The result of an org-TAB-style toggle cycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CycleOutcome { + /// Closed one more (innermost open) fold on the head. + Closed(ByteRange), + /// Every fold on the head was already closed; opened them all. + OpenedAll(usize), + /// Nothing foldable and nothing folded at the point. + Nothing, +} + +/// `fold.toggle`: org-TAB cycle. While any foldable region at `p` is open, +/// close the innermost open one; once all are closed, one more press opens +/// them all. Every press has a visible effect (Q#FD4/R3-2). +pub fn cycle_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> CycleOutcome { + let candidates = candidates_at(bundle, p); + if candidates.iter().any(|c| !store.contains_exact(*c)) { + for c in &candidates { + if !store.contains_exact(*c) { + store.insert(*c); + return CycleOutcome::Closed(*c); + } + } + } + let n = store.unfold_containing(p); + if n > 0 { + CycleOutcome::OpenedAll(n) + } else { + CycleOutcome::Nothing + } +} + +/// The result of a data-API `toggle(buffer, pos)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToggleOutcome { + /// Folded the innermost tree target at the point. + Folded(ByteRange), + /// Unfolded the stored fold(s) containing the point. + Unfolded(usize), + /// Nothing foldable and nothing folded at the point. + Nothing, +} + +/// Data-API `toggle`: unfold if a stored fold contains `pos`, else fold the +/// innermost tree target at `pos`. +pub fn toggle_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> ToggleOutcome { + if !store.containing(p).is_empty() { + return ToggleOutcome::Unfolded(store.unfold_containing(p)); + } + match fold_target_at(bundle, p) { + Some(t) => { + store.insert(t); + ToggleOutcome::Folded(t) + } + None => ToggleOutcome::Nothing, + } +} + +/// Normalize an arbitrary data-API range (no node, so no introducer/closer +/// inference — the caller names exactly what to hide). Head line = the line +/// containing `range.start`; the hidden lines are the full lines strictly +/// after it through the line containing `range.end` (or the previous line +/// when `range.end` sits at a line start). `None` if that is < 1 hidden +/// line. +#[must_use] +pub fn normalize_arbitrary_range(source: &[u8], range: ByteRange) -> Option { + if range.start > source.len() as u64 || range.end > source.len() as u64 { + return None; + } + let line_offsets = compute_line_offsets(source); + let head_row = line_at_offset(&line_offsets, range.start as u32); + let end_row_raw = line_at_offset(&line_offsets, range.end as u32); + let end_at_line_start = line_offsets.get(end_row_raw).copied() == Some(range.end as u32); + let last_hidden_row = if end_at_line_start && end_row_raw > 0 { + end_row_raw - 1 + } else { + end_row_raw + }; + if last_hidden_row < head_row + 1 { + return None; + } + let start = line_content_end(source, &line_offsets, head_row); + let end = line_content_end(source, &line_offsets, last_hidden_row); + if end <= start { + return None; + } + Some(ByteRange { start, end }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rope::{Edit, Range, Rope}; + + fn edit(range: Range, inserted_len: u64) -> Edit { + Edit { + new_rope: Rope::new(), + range, + inserted_len, + crdt_op: None, + } + } + + fn r(start: u64, end: u64) -> ByteRange { + ByteRange { start, end } + } + + #[test] + fn insert_at_head_boundary_shifts_fold_right() { + // `(start, end]` containment: an insert exactly at the end of the + // head line lands *before* the fold, shifting it right so the + // character stays visible on the head line. + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(10, 10), 1)); + assert_eq!(store.folds(), vec![r(11, 31)]); + } + + #[test] + fn insert_strictly_inside_grows_the_end() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(20, 20), 3)); + assert_eq!(store.folds(), vec![r(10, 33)]); + } + + #[test] + fn insert_at_tail_boundary_leaves_fold_untouched() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(30, 30), 4)); + assert_eq!(store.folds(), vec![r(10, 30)]); + } + + #[test] + fn edit_before_fold_shifts_whole_range() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(2, 5), 0)); // delete 3 bytes before + assert_eq!(store.folds(), vec![r(7, 27)]); + } + + #[test] + fn edit_crossing_head_boundary_drops_fold() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + // A delete starting at the head boundary destroys the head. + store.translate(&edit(Range::new(10, 15), 0)); + assert!(store.is_empty()); + } + + #[test] + fn edit_crossing_tail_boundary_drops_fold() { + let mut store = FoldStore::new(); + store.insert(r(10, 30)); + store.translate(&edit(Range::new(25, 40), 0)); + assert!(store.is_empty()); + } + + #[test] + fn containment_is_start_exclusive_end_inclusive() { + let store = { + let mut s = FoldStore::new(); + s.insert(r(10, 30)); + s + }; + assert!(store.containing(10).is_empty(), "start is exclusive"); + assert_eq!(store.containing(11), vec![r(10, 30)]); + assert_eq!(store.containing(30), vec![r(10, 30)], "end is inclusive"); + assert!(store.containing(31).is_empty()); + } + + #[test] + fn containing_is_innermost_first() { + let mut store = FoldStore::new(); + store.insert(r(10, 100)); // outer + store.insert(r(20, 60)); // inner + assert_eq!(store.containing(30), vec![r(20, 60), r(10, 100)]); + } + + #[test] + fn unfold_containing_removes_all_nested() { + let mut store = FoldStore::new(); + store.insert(r(10, 100)); + store.insert(r(20, 60)); + store.insert(r(200, 300)); // unrelated + assert_eq!(store.unfold_containing(30), 2); + assert_eq!(store.folds(), vec![r(200, 300)]); + } + + #[test] + fn insert_rejects_empty_and_duplicate() { + let mut store = FoldStore::new(); + assert!(store.insert(r(10, 30))); + assert!(!store.insert(r(10, 30)), "duplicate rejected"); + assert!(!store.insert(r(5, 5)), "empty rejected"); + assert!(!store.insert(r(9, 8)), "inverted rejected"); + } + + #[test] + fn open_at_takes_outermost_closed() { + let mut store = FoldStore::new(); + store.insert(r(10, 100)); + store.insert(r(20, 60)); + assert_eq!(open_at(&mut store, 30), Some(r(10, 100))); + assert_eq!(open_at(&mut store, 30), Some(r(20, 60))); + assert_eq!(open_at(&mut store, 30), None); + } + + #[test] + fn line_content_end_excludes_newline() { + let src = b"abc\ndef\nghi"; + let off = compute_line_offsets(src); + assert_eq!(line_content_end(src, &off, 0), 3); // "abc" + assert_eq!(line_content_end(src, &off, 1), 7); // "def" + assert_eq!(line_content_end(src, &off, 2), 11); // "ghi" (no newline) + } + + #[test] + fn normalize_arbitrary_range_basic() { + // 0123 4567 89012 + let src = b"aaa\nbbb\nccc\nddd"; + // range covering into line 1 and line 2 -> hidden lines 1..2 + let out = normalize_arbitrary_range(src, r(1, 9)).expect("foldable"); + assert_eq!(out, r(3, 11)); // [end of line0, end of line2] + } + + #[test] + fn normalize_arbitrary_range_end_at_line_start_drops_a_line() { + let src = b"aaa\nbbb\nccc\nddd"; + // end exactly at start of line 2 (byte 8) -> last hidden line is 1. + let out = normalize_arbitrary_range(src, r(1, 8)).expect("foldable"); + assert_eq!(out, r(3, 7)); + } + + #[test] + fn normalize_arbitrary_range_rejects_sub_one_line() { + let src = b"aaa\nbbb\nccc"; + // start and end on the same line -> zero hidden lines. + assert!(normalize_arbitrary_range(src, r(1, 2)).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index bad3e6f..392f7b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,6 +75,7 @@ pub mod document_highlight; pub mod editor; pub mod editor_core; pub mod file_io; +pub mod fold; pub mod font_pref; pub mod formatting; pub mod frontend; diff --git a/src/lua_bindings/fold.rs b/src/lua_bindings/fold.rs new file mode 100644 index 0000000..b79c2c7 --- /dev/null +++ b/src/lua_bindings/fold.rs @@ -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 { + let id = buf.id(); + let requested = range_from_table(&range)?; + let Some(bytes) = document_bytes(lua, id)? else { + set_status(lua, "fold rejected: not a document buffer"); + return Ok(false); + }; + if requested.start > bytes.len() as u64 + || requested.end > bytes.len() as u64 + || !is_char_boundary(&bytes, requested.start) + || !is_char_boundary(&bytes, requested.end) + { + set_status(lua, "fold rejected: out of bounds or not a char boundary"); + return Ok(false); + } + let Some(normalized) = fold::normalize_arbitrary_range(&bytes, requested) + else { + set_status(lua, "fold rejected: range hides no full line"); + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + let added = lock(&store).insert(normalized); + Ok(added) + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "unfold", + lua.create_function( + move |lua, (buf, range): (BufferIdLua, Table)| -> mlua::Result { + let id = buf.id(); + let requested = range_from_table(&range)?; + let Some(store) = reg.store(id) else { + return Ok(false); + }; + // Accept an exact stored range (the `folds()` round-trip) + // or an arbitrary range that normalizes to a stored one. + if lock(&store).remove(requested) { + return Ok(true); + } + if let Ok(Some(bytes)) = document_bytes(lua, id) + && let Some(normalized) = + fold::normalize_arbitrary_range(&bytes, requested) + { + return Ok(lock(&store).remove(normalized)); + } + Ok(false) + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "folds", + lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result { + let out = lua.create_table()?; + for (i, r) in reg.folds(buf.id()).into_iter().enumerate() { + out.set(i + 1, range_to_table(lua, r)?)?; + } + Ok(out) + })?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "toggle", + lua.create_function( + move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + // A stored fold at the point unfolds without needing a tree. + if let Some(store) = reg.store(id) { + let mut s = lock(&store); + if !s.containing(p).is_empty() { + s.unfold_containing(p); + return Ok(true); + } + } + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + match fold::toggle_at(&mut lock(&store), &bundle, p) { + fold::ToggleOutcome::Folded(r) => { + maybe_move_point(lua, id, r); + Ok(true) + } + fold::ToggleOutcome::Unfolded(_) => Ok(true), + fold::ToggleOutcome::Nothing => { + set_status(lua, "nothing foldable here"); + Ok(false) + } + } + }, + )?, + )?; + } + + // ---- interactive helpers (state-aware; driven by fold.lua) ------------ + + { + let reg = fold_registry.clone(); + fold_mod.set( + "close", + lua.create_function( + move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + if let Some(r) = fold::close_at(&mut lock(&store), &bundle, p) { + maybe_move_point(lua, id, r); + Ok(true) + } else { + set_status(lua, "no more folds to close here"); + Ok(false) + } + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "open", + lua.create_function( + move |_, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + let Some(store) = reg.store(id) else { + return Ok(false); + }; + Ok(fold::open_at(&mut lock(&store), p).is_some()) + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "cycle", + lua.create_function( + move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { + let id = buf.id(); + let p = u64_from_lua(pos)?; + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(false); + }; + let store = store_for(lua, ®, id)?; + match fold::cycle_at(&mut lock(&store), &bundle, p) { + fold::CycleOutcome::Closed(r) => { + maybe_move_point(lua, id, r); + Ok(true) + } + fold::CycleOutcome::OpenedAll(_) => Ok(true), + fold::CycleOutcome::Nothing => { + set_status(lua, "nothing foldable here"); + Ok(false) + } + } + }, + )?, + )?; + } + + { + let reg = fold_registry.clone(); + fold_mod.set( + "close_all", + lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result { + let id = buf.id(); + let Some(bundle) = bundle_or_status(lua, id) else { + return Ok(0); + }; + let targets = fold::top_level_fold_targets(&bundle); + let store = store_for(lua, ®, id)?; + let 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 { + match reg.store(buf.id()) { + Some(store) => Ok(lock(&store).clear()), + None => Ok(false), + } + })?, + )?; + } + + let pmacs: Table = lua.globals().get("pmacs")?; + pmacs.set("fold", fold_mod)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn lock(store: &Arc>) -> std::sync::MutexGuard<'_, FoldStore> { + store.lock().expect("fold store mutex poisoned") +} + +fn range_from_table(t: &Table) -> mlua::Result { + let start = u64_from_lua(t.raw_get::("start")?)?; + let end = u64_from_lua(t.raw_get::("end")?)?; + Ok(ByteRange { start, end }) +} + +fn range_to_table(lua: &Lua, r: ByteRange) -> mlua::Result
{ + let t = lua.create_table()?; + // Byte offsets are always well within `i64` range; the Lua integer + // type is `i64`. + t.set("start", r.start.cast_signed())?; + t.set("end", r.end.cast_signed())?; + Ok(t) +} + +/// The buffer's bytes if it is a normal document buffer, or `None` if it is +/// read-only (a terminal identity buffer or other non-document buffer — +/// the Q#FD11 "normal document buffer" guard). +fn document_bytes(lua: &Lua, buf: BufferId) -> mlua::Result>> { + with_registry(lua, |r| { + let buffer = resolve(r, buf)?; + if buffer.is_read_only() { + return Ok(None); + } + Ok(Some(buffer_bytes(buffer))) + }) +} + +fn buffer_bytes(buf: &Buffer) -> Vec { + let len = buf.len(); + let mut bytes = vec![0u8; len as usize]; + buf.snapshot_rope().slice(0, len, &mut bytes); + bytes +} + +fn is_char_boundary(bytes: &[u8], pos: u64) -> bool { + let p = pos as usize; + p == 0 || p == bytes.len() || (p < bytes.len() && (bytes[p] & 0xC0) != 0x80) +} + +/// The get-or-attach store handle for `buf`, materializing the store and +/// attaching its translator view on first use. +fn store_for( + lua: &Lua, + reg: &SharedFoldRegistry, + buf: BufferId, +) -> mlua::Result>> { + with_registry_mut(lua, |r| { + let buffer = resolve_mut(r, buf)?; + Ok(reg.store_or_attach(buffer)) + }) +} + +/// The settled parse bundle for `buf`, or `None` after reporting the +/// stale/absent-tree rejection on the status line (Q#FD10). +fn bundle_or_status(lua: &Lua, buf: BufferId) -> Option> { + match settled_bundle(lua, buf) { + Ok(bundle) => Some(bundle), + Err(reason) => { + set_status(lua, reason); + None + } + } +} + +fn settled_bundle(lua: &Lua, buf: BufferId) -> Result, &'static str> { + let syntax = lua + .app_data_ref::() + .ok_or("fold: no syntax registry")?; + let handle = syntax.view(buf).ok_or("fold: no parse for this buffer")?; + if handle.pending_edit_count() > 0 { + return Err("fold: parse is stale (edits pending); try again"); + } + handle.current().ok_or("fold: no parse yet; try again") +} + +/// Set the editor status line (rejection reporting). +fn set_status(lua: &Lua, msg: &str) { + if let Some(core) = lua.app_data_ref::() { + core.borrow_mut().status = msg.to_string(); + } +} + +/// Move the invoking frontend's point to the head line when a just-folded +/// range `r` contains it (Q#FD3). No-op if the folded buffer is not the +/// active one or the point is outside the fold. +fn maybe_move_point(lua: &Lua, buf: BufferId, r: ByteRange) { + if let Some(core) = lua.app_data_ref::() { + let mut c = core.borrow_mut(); + if c.active_buffer_id() == buf { + let point = c.active_window().cursor; + // `(start, end]` containment: a point strictly inside the fold + // moves to `start` (the end of the visible head line). + if r.start < point && point <= r.end { + c.set_cursor_byte(r.start); + } + } + } +} diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index ae04b3a..02dd286 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -85,6 +85,7 @@ use crate::workers_buffer; // stable. mod config; mod diag; +mod fold; mod index; mod mcp; // Every `pub` item a moved domain owned is re-exported so its prior @@ -94,6 +95,7 @@ mod mcp; // them), but they were `pub`, so their paths are preserved for // compile-compatibility; any deliberate narrowing is a separate change. pub use diag::install_diag; +pub use fold::install_fold; pub use index::{SharedProjectIndexer, install_project_index, make_project_indexer}; pub use mcp::{McpServerIdLua, install_mcp, make_mcp_manager}; diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 6a06ff9..25e7fea 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -26,9 +26,10 @@ //! and `Decorations` (M11.3), both span-granularity diffed (M11.4); //! `InlineAdornments` (Step 3, from the LSP inlay-hint store, //! M11.2-level suppression); `FileStyleSummary` (resolving Open Q#2 — -//! per-line dominant style for a minimap, generation-keyed). -//! `BlockAdornments` / `FoldState` / `ResourceOffer` remain wire- -//! declared but unproduced. +//! per-line dominant style for a minimap, generation-keyed); `FoldState` +//! (Arc 6 — the instance's authoritative fold set, authoritative-empty). +//! `BlockAdornments` / `ResourceOffer` remain wire-declared but +//! unproduced. use std::collections::HashMap; @@ -163,6 +164,16 @@ pub struct SemanticRenderState { /// byte-identical. `LastFrame::items` reuse keeps the shape /// uniform even though no segment diffing applies. last_adornments: HashMap>, + /// `FoldState` baseline (Arc 6). Whole-buffer, not viewport-clipped, + /// so a plain `Vec` per buffer suffices. Authoritative- + /// empty and diff-suppressed: the first sight of a buffer emits only + /// if a fold exists (no empty-frame spam), an unchanged set emits + /// nothing, and a `non-empty → empty` transition emits exactly one + /// empty frame so the frontend clears its fold mirror. Resets on + /// `BufferSnapshot` — the snapshot's own frontend-side fold-mirror + /// clear is what makes the empty-after-revert suppression correct + /// (Q#FD8, #120 class). + last_folds: HashMap>, /// `FileStyleSummary` baseline (post-M11 minimap producer, /// resolving design-note Open Q#2). The whole-file dominant-style /// summary is expensive to compute on a 100k-line file, so the @@ -417,6 +428,7 @@ impl SemanticRenderState { last_sent: HashMap::new(), last_decorations: HashMap::new(), last_adornments: HashMap::new(), + last_folds: HashMap::new(), last_search_prompt: HashMap::new(), last_menu_prompt: HashMap::new(), last_minibuffer: None, @@ -565,6 +577,7 @@ impl SemanticRenderState { self.last_style_gate.remove(&buffer_id); self.last_decorations.remove(&buffer_id); self.last_adornments.remove(&buffer_id); + self.last_folds.remove(&buffer_id); self.last_summary.remove(&buffer_id); self.last_status.remove(&buffer_id); self.last_search_prompt.remove(&buffer_id); @@ -583,11 +596,12 @@ impl SemanticRenderState { /// send. Returns an empty vec before the frontend declares a /// viewport. /// - /// `BlockAdornments` / `FoldState` are still deliberately *not* - /// produced: pmacs has no instance-side blame / lens / fold / diff - /// source yet. Their wire variants exist (T M11.1); their - /// producers wire in when those features land — the same - /// "declared, not yet wired" discipline. Emitting an empty message + /// `BlockAdornments` is still deliberately *not* produced: pmacs has + /// no instance-side blame / lens / diff source yet. (`FoldState` IS + /// produced now — Arc 6 — authoritative-empty via `fold_state_msg`.) + /// Its wire variant exists (T M11.1); its producer wires in when that + /// feature lands — the same "declared, not yet wired" discipline. + /// Emitting an empty message /// every frame would be waste, not honesty, so `InlineAdornments` is /// suppressed both when unchanged and when there is simply nothing /// to say (no hints, no prior non-empty send). @@ -775,6 +789,8 @@ impl SemanticRenderState { // --- InlineAdornments (Step 3 producer) --- out.extend(self.inline_adornments_msg(state, &vp)); + // --- FoldState (Arc 6 producer; authoritative-empty) --- + out.extend(self.fold_state_msg(state, vp.buffer_id)); // --- FileStyleSummary (minimap producer; Open Q#2) --- out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation)); // --- StatusFacts (status band; Q#S1, protocol v8) --- @@ -1400,6 +1416,35 @@ impl SemanticRenderState { }) } + /// The `FoldState` message for this frame, or `None` (Arc 6). The + /// instance's authoritative fold set for `buffer_id`, whole-buffer + /// (folds are a handful; `close-all` is top-level only, so the set is + /// O(top-level blocks) — no viewport scoping). Authoritative-empty and + /// diff-suppressed exactly like `inline_adornments_msg`: the first + /// sight of a buffer speaks only if a fold exists, an unchanged set is + /// silent, and a `non-empty → empty` transition emits one empty frame + /// so the frontend clears its mirror. Its baseline resets on + /// `BufferSnapshot`; see `on_buffer_snapshot_sent`. + fn fold_state_msg( + &mut self, + state: &EditorState, + buffer_id: BufferId, + ) -> Option { + let folds = state.fold_registry.folds(buffer_id); + let should_emit = match self.last_folds.get(&buffer_id) { + // First sight: speak only if there is a fold to show. + None => !folds.is_empty(), + // 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> { + s.render_frame(st) + .into_iter() + .filter_map(|m| match m { + InstanceMessage::FoldState { folds, .. } => Some(folds), + InstanceMessage::BlockAdornments { .. } => { + panic!("BlockAdornments must stay unproduced") + } + _ => None, + }) + .collect() + }; + + // Nothing until a fold exists. + assert!(fold_frames(&mut s, &state).is_empty()); + + // Add a fold → exactly one FoldState frame carrying it. + let range = ByteRange { start: 3, end: 7 }; + { + let core = state.core.borrow(); + let mut reg = core.registry.borrow_mut(); + let buf = reg.get_mut(buffer_id).expect("buffer"); + state + .fold_registry + .store_or_attach(buf) + .lock() + .unwrap() + .insert(range); + } + assert_eq!(fold_frames(&mut s, &state), vec![vec![range]]); + + // Unchanged → nothing. + assert!(fold_frames(&mut s, &state).is_empty()); + + // Clear → exactly one empty frame so the frontend drops its mirror. + { + let store = state.fold_registry.store(buffer_id).expect("store exists"); + store.lock().unwrap().clear(); + } + assert_eq!(fold_frames(&mut s, &state), vec![Vec::::new()]); + // Empty → empty is suppressed. + assert!(fold_frames(&mut s, &state).is_empty()); + + // A snapshot resets the baseline: the still-empty set is again + // suppressed as "initial empty" (the frontend cleared its mirror + // when it applied the snapshot — the Stage 3 pairing). + s.on_buffer_snapshot_sent(buffer_id); + assert!(fold_frames(&mut s, &state).is_empty()); + // …and a fold added after the reset is re-shipped. + { + let store = state.fold_registry.store(buffer_id).expect("store exists"); + store.lock().unwrap().insert(range); + } + assert_eq!(fold_frames(&mut s, &state), vec![vec![range]]); + } + #[test] fn inline_adornments_not_emitted_without_hints() { // Step 3: InlineAdornments IS wired, but a buffer with no LSP diff --git a/tests/folding_acceptance.rs b/tests/folding_acceptance.rs new file mode 100644 index 0000000..1e882c5 --- /dev/null +++ b/tests/folding_acceptance.rs @@ -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 { + let language = reg.language(lang).expect("grammar loads"); + let mut buf = Buffer::from_bytes(BufferId::next(), "doc", src); + let view = ParseView::new(&buf, language, lang.to_owned()); + let handle = view.handle(); + let _ = buf.attach_view(Box::new(view)); + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = run_parse(req).expect("parse succeeds"); + reg.resolve_layer_queries(&bundle) +} + +/// The byte offset of `needle`'s first occurrence in `src`. +fn byte_of(src: &str, needle: &str) -> u64 { + src.find(needle).expect("needle present") as u64 +} + +/// The content-end byte (position of the terminating `\n`, or EOF) of the +/// line containing `needle`'s first occurrence. +fn line_content_end_of(src: &str, needle: &str) -> u64 { + let idx = src.find(needle).expect("needle present"); + let bytes = src.as_bytes(); + let mut e = idx; + while e < bytes.len() && bytes[e] != b'\n' { + e += 1; + } + e as u64 +} + +/// The text of the line containing byte `b`. +fn line_text_at(src: &str, b: u64) -> &str { + let bytes = src.as_bytes(); + let b = (b as usize).min(bytes.len()); + let start = bytes[..b] + .iter() + .rposition(|&c| c == b'\n') + .map_or(0, |i| i + 1); + let end = bytes[b..] + .iter() + .position(|&c| c == b'\n') + .map_or(bytes.len(), |i| b + i); + &src[start..end] +} + +// --------------------------------------------------------------------------- +// 1. Head line — both grammar shapes, wrapped headers (R2-1, R3-1). +// --------------------------------------------------------------------------- + +#[test] +fn head_line_rust_single_line_signature() { + let reg = SyntaxRegistry::new(); + let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, "fn foo() {")); + assert_eq!( + line_text_at(src, r.start), + "fn foo() {", + "head line is the fn line" + ); + assert_eq!( + line_text_at(src, r.start + 1), + " let x = 1;", + "first hidden line" + ); +} + +#[test] +fn head_line_rust_wrapped_signature_keeps_signature_visible() { + // R3-1: rustfmt puts `{` on the `) -> bool {` line; the head must be + // that line, NOT `fn foo(` — the wrapped signature stays visible. + let reg = SyntaxRegistry::new(); + let src = "fn foo(\n a: u32,\n) -> bool {\n true\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "true")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, ") -> bool {")); + assert_eq!(line_text_at(src, r.start), ") -> bool {"); + // The two wrapped signature lines are before the fold → visible. + assert!(r.start > line_content_end_of(src, "a: u32,")); +} + +#[test] +fn head_line_python_uses_def_not_a_body_line() { + // R2-1: tree-sitter-python's `block` starts on the first statement + // line, so the head must ascend to `def foo():`, not `x = 1`. + let reg = SyntaxRegistry::new(); + let src = "def foo():\n x = 1\n y = 2\n"; + let bundle = parse(®, "python", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, "def foo():")); + assert_eq!(line_text_at(src, r.start), "def foo():"); + assert_eq!(line_text_at(src, r.start + 1), " x = 1"); +} + +#[test] +fn head_line_python_wrapped_signature_keeps_signature_visible() { + let reg = SyntaxRegistry::new(); + let src = "def foo(\n a,\n):\n x = 1\n"; + let bundle = parse(®, "python", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable"); + assert_eq!(r.start, line_content_end_of(src, "):")); + assert_eq!(line_text_at(src, r.start), "):"); +} + +// --------------------------------------------------------------------------- +// 4. Range semantics — closer-aware tail (R2-5). +// --------------------------------------------------------------------------- + +#[test] +fn brace_closer_line_stays_visible() { + let reg = SyntaxRegistry::new(); + let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable"); + // Last hidden line is the last body line; the `}` line is outside. + assert_eq!(r.end, line_content_end_of(src, "let y = 2;")); + assert_eq!( + line_text_at(src, r.end + 1), + "}", + "closer line stays visible" + ); +} + +#[test] +fn shared_closer_line_else_stays_visible() { + // R2-5: `} else {` keeps its trailing sibling on screen. + let reg = SyntaxRegistry::new(); + let src = "fn f() {\n if a {\n one();\n } else {\n two();\n }\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "one()")).expect("foldable"); + // The consequent block folds; its `} else {` line stays visible. + assert_eq!(line_text_at(src, r.end + 1).trim(), "} else {"); +} + +#[test] +fn python_hides_through_last_body_line() { + let reg = SyntaxRegistry::new(); + let src = "def foo():\n x = 1\n y = 2\n"; + let bundle = parse(®, "python", src.as_bytes()); + let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable"); + // Delimiter-less: the last body line is hidden (inside the range). + assert_eq!(r.end, line_content_end_of(src, "y = 2")); +} + +// --------------------------------------------------------------------------- +// 3. close-all folds top-level regions only; 9. nested + state-aware order. +// --------------------------------------------------------------------------- + +#[test] +fn close_all_is_top_level_only() { + let reg = SyntaxRegistry::new(); + let src = "fn a() {\n if c {\n work();\n more();\n }\n}\n\nfn b() {\n x();\n y();\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let top = top_level_fold_targets(&bundle); + assert_eq!( + top.len(), + 2, + "two top-level fns, the nested `if` is not auto-folded" + ); + // Neither top-level range is the inner `if` block. + let inner = fold_target_at(&bundle, byte_of(src, "work()")).expect("inner foldable"); + assert!( + !top.contains(&inner), + "close-all does not fold the nested region" + ); +} + +#[test] +fn nested_state_aware_ordering() { + // 9 / R3-2: close walks innermost→outer, open walks outer→inner, and + // toggle cycles close-inner → close-outer → open-all so every command + // reaches the outer fold. + let reg = SyntaxRegistry::new(); + let src = "fn outer() {\n if cond {\n work();\n more();\n }\n}\n"; + let bundle = parse(®, "rust", src.as_bytes()); + let p = byte_of(src, "work()"); + + let mut store = FoldStore::new(); + let inner = close_at(&mut store, &bundle, p).expect("close inner"); + let outer = close_at(&mut store, &bundle, p).expect("close outer"); + assert!( + inner.start > outer.start, + "inner fold is more deeply nested" + ); + assert!( + close_at(&mut store, &bundle, p).is_none(), + "nothing left to close" + ); + assert_eq!(store.folds().len(), 2); + + assert_eq!(open_at(&mut store, p), Some(outer), "open outermost first"); + assert_eq!(open_at(&mut store, p), Some(inner), "then the inner"); + assert!(store.is_empty()); + + assert!(matches!( + cycle_at(&mut store, &bundle, p), + CycleOutcome::Closed(_) + )); + assert!(matches!( + cycle_at(&mut store, &bundle, p), + CycleOutcome::Closed(_) + )); + assert_eq!(store.folds().len(), 2, "cycle closed both"); + assert!(matches!( + cycle_at(&mut store, &bundle, p), + CycleOutcome::OpenedAll(2) + )); + assert!(store.is_empty(), "one more cycle opened them all"); +} + +// --------------------------------------------------------------------------- +// 10. Injected layer (a fenced rust block inside markdown). +// --------------------------------------------------------------------------- + +#[test] +fn fold_sourced_inside_injected_layer() { + let reg = SyntaxRegistry::new(); + let src = "# Title\n\n```rust\nfn demo() {\n let x = 1;\n let y = 2;\n}\n```\n\nText.\n"; + let bundle = parse(®, "markdown", src.as_bytes()); + assert!( + bundle.layers.len() >= 2, + "markdown fence produced an injected rust layer" + ); + let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable inside the fence"); + assert_eq!( + line_text_at(src, r.start), + "fn demo() {", + "resolved the inner block" + ); +} + +// --------------------------------------------------------------------------- +// 2. Stale / absent parse tree refuses (the precondition the binding keys on). +// --------------------------------------------------------------------------- + +#[test] +fn absent_tree_has_no_current_bundle() { + let reg = SyntaxRegistry::new(); + let language = reg.language("rust").expect("grammar"); + let src = b"fn foo() {\n let x = 1;\n}\n"; + let buf = Buffer::from_bytes(BufferId::next(), "doc", src); + let view = ParseView::new(&buf, language, "rust".to_owned()); + let handle = view.handle(); + // Before any parse installs, `current()` is None → the binding refuses. + assert!(handle.current().is_none()); + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = run_parse(req).expect("parse"); + handle.install(reg.resolve_layer_queries(&bundle)); + assert!( + handle.current().is_some(), + "after install, a target is derivable" + ); +} + +// --------------------------------------------------------------------------- +// 6. Command-path pre-edit unfold (Q#FD5). +// --------------------------------------------------------------------------- + +fn active_id(s: &EditorState) -> BufferId { + s.core.borrow().active_buffer_id() +} + +fn insert_into(s: &EditorState, id: BufferId, text: &str) { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + reg.get_mut(id) + .unwrap() + .apply_edit(EditOp::Insert { + pos: 0, + bytes: text.as_bytes(), + }) + .unwrap(); +} + +#[test] +fn command_path_self_insert_unfolds_at_point() { + let s = EditorState::new(); + let id = active_id(&s); + insert_into(&s, id, "line0\nline1\nline2\nline3\n"); + // Fold the interior of lines 1..2: [end of line0, end of line2]. + let store = { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.store_or_attach(reg.get_mut(id).unwrap()) + }; + store + .lock() + .unwrap() + .insert(ByteRange { start: 5, end: 17 }); + // Cursor strictly inside the fold (start of "line2"). + s.core.borrow_mut().set_cursor_byte(12); + + // A command-path self-insert unfolds before the edit lands. + s.core.borrow_mut().insert_char('x'); + assert!( + store.lock().unwrap().is_empty(), + "typing inside a fold unfolds it (Q#FD5)" + ); +} + +#[test] +fn self_insert_at_head_line_end_does_not_unfold() { + // `(start, end]` containment: a self-insert exactly at the end of the + // head line (== range.start) is outside the fold — it must NOT unfold, + // and the translator shifts the fold right so the char lands visible. + let s = EditorState::new(); + let id = active_id(&s); + insert_into(&s, id, "line0\nline1\nline2\nline3\n"); + let store = { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.store_or_attach(reg.get_mut(id).unwrap()) + }; + store + .lock() + .unwrap() + .insert(ByteRange { start: 5, end: 17 }); + s.core.borrow_mut().set_cursor_byte(5); // end of head line "line0" + + s.core.borrow_mut().insert_char('x'); + let folds = store.lock().unwrap().folds(); + assert_eq!( + folds, + vec![ByteRange { start: 6, end: 18 }], + "fold shifts right; the character lands on the head line" + ); +} + +// --------------------------------------------------------------------------- +// 5. Point moves to the head line when a fold is created around it (Q#FD3). +// 11. Data-API validation (Q#FD11) — driven through the Lua surface. +// --------------------------------------------------------------------------- + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Install a settled rust parse over the active scratch buffer so the +/// `pmacs.fold` surface can drive it end to end. +fn install_rust_parse(s: &EditorState, id: BufferId) { + let reg = &s.syntax_registry; + let language = reg.language("rust").expect("grammar"); + let handle = { + let core = s.core.borrow(); + let mut breg = core.registry.borrow_mut(); + let buf = breg.get_mut(id).unwrap(); + let view = ParseView::new(buf, language, "rust".to_owned()); + let handle = view.handle(); + buf.attach_view(Box::new(view)); + handle + }; + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = run_parse(req).expect("parse"); + handle.install(reg.resolve_layer_queries(&bundle)); + reg.attach_view(id, handle); +} + +#[test] +fn folding_moves_point_to_head_line() { + let s = EditorState::new(); + let id = active_id(&s); + let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + insert_into(&s, id, src); + install_rust_parse(&s, id); + // Cursor inside the body; `let x` starts on line 1. + let p = byte_of(src, "let x"); + s.core.borrow_mut().set_cursor_byte(p); + + exec(&s, "pmacs.command.invoke('fold.close')"); + + let head = line_content_end_of(src, "fn foo() {"); + assert_eq!( + s.core.borrow().active_window().cursor, + head, + "the invoking point moved to the head line" + ); + // And the fold exists. + let n: i64 = eval(&s, "return #pmacs.fold.folds(pmacs.window.buffer())"); + assert_eq!(n, 1); +} + +#[test] +fn data_api_validation() { + let s = EditorState::new(); + // A plain document buffer with four lines. + exec( + &s, + "b = pmacs.buffer.from_bytes('doc.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", + ); + + // A valid multi-line range folds. + let ok: bool = eval(&s, "return pmacs.fold.fold(b, { start = 3, ['end'] = 11 })"); + assert!(ok, "a >=1-hidden-line range is accepted"); + let n: i64 = eval(&s, "return #pmacs.fold.folds(b)"); + assert_eq!(n, 1); + + // A sub-one-line range (both endpoints on the same line) is rejected. + let same_line: bool = eval(&s, "return pmacs.fold.fold(b, { start = 0, ['end'] = 2 })"); + assert!( + !same_line, + "a range hiding no full line is rejected (Q#FD11)" + ); + + // An out-of-bounds range is rejected. + let oob: bool = eval( + &s, + "return pmacs.fold.fold(b, { start = 0, ['end'] = 99999 })", + ); + assert!(!oob, "an out-of-bounds range is rejected"); + + // Q#FD9 via the >=1-hidden-line rule: a fold at (0,0) on an empty + // buffer normalizes to zero hidden lines and is rejected. + exec(&s, "e = pmacs.buffer.from_bytes('empty.rs', '')"); + let empty: bool = eval(&s, "return pmacs.fold.fold(e, { start = 0, ['end'] = 0 })"); + assert!( + !empty, + "a zero-length range is rejected (terminals never fold)" + ); + + // Round-trip: unfold the stored range clears it. + let unfolded: bool = eval( + &s, + "local f = pmacs.fold.folds(b)[1]; return pmacs.fold.unfold(b, f)", + ); + assert!(unfolded); + let n2: i64 = eval(&s, "return #pmacs.fold.folds(b)"); + assert_eq!(n2, 0); +} + +// --------------------------------------------------------------------------- +// 8. Buffer content replacement drops the store. +// --------------------------------------------------------------------------- + +#[test] +fn forget_drops_store_and_detaches_view() { + let s = EditorState::new(); + let id = active_id(&s); + insert_into(&s, id, "line0\nline1\nline2\n"); + let store = { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.store_or_attach(reg.get_mut(id).unwrap()) + }; + store + .lock() + .unwrap() + .insert(ByteRange { start: 5, end: 11 }); + assert!(s.fold_registry.store(id).is_some()); + + // Content replacement (revert/reload) drops the store. + { + let core = s.core.borrow(); + let mut reg = core.registry.borrow_mut(); + s.fold_registry.forget(reg.get_mut(id).unwrap()); + } + assert!(s.fold_registry.store(id).is_none(), "the store is dropped"); + assert!(fold::make_shared_fold_registry().folds(id).is_empty()); +}