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:
Levi Neuwirth 2026-07-23 12:14:00 -04:00
parent 40a820afb8
commit 3b411dbb2a
9 changed files with 1951 additions and 21 deletions

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

@ -0,0 +1,52 @@
-- fold.lua --- interactive fold commands + default bindings (Arc 6).
--
-- Thin command wrappers over the `pmacs.fold` Rust surface. Each resolves
-- the invoking frontend's active-window buffer and point (command context,
-- not an ambient buffer), then calls the state-aware helper; the Rust side
-- refuses on a stale/absent parse tree, validates, and moves the point to
-- the head line when it folds around it.
--
-- Default bindings are the Emacs hideshow `C-c @` prefix set (Q#FD4): the
-- LSP surface already owns every `C-c <letter>`, so `C-c @` is the one
-- faithful prefix that collides with nothing. Rebind through pmacs.keymap.
--
-- Framing: docs/folding-framing.md.
local ed = pmacs.editor
local fold = pmacs.fold
pmacs.command.define {
name = "fold.toggle",
description = "Toggle the fold at point (org-TAB cycle)",
fn = function() fold.cycle(pmacs.window.buffer(), ed.cursor()) end,
}
pmacs.command.define {
name = "fold.close",
description = "Close the innermost open fold at point",
fn = function() fold.close(pmacs.window.buffer(), ed.cursor()) end,
}
pmacs.command.define {
name = "fold.open",
description = "Open the outermost closed fold at point",
fn = function() fold.open(pmacs.window.buffer(), ed.cursor()) end,
}
pmacs.command.define {
name = "fold.close-all",
description = "Close all top-level folds in the buffer",
fn = function() fold.close_all(pmacs.window.buffer()) end,
}
pmacs.command.define {
name = "fold.open-all",
description = "Open all folds in the buffer",
fn = function() fold.open_all(pmacs.window.buffer()) end,
}
pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-c", command = "fold.toggle" }
pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-h", command = "fold.close" }
pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-s", command = "fold.open" }
pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-M-h", command = "fold.close-all" }
pmacs.keymap.bind { scope = "global", sequence = "C-c @ C-M-s", command = "fold.open-all" }

View File

@ -104,6 +104,10 @@ pub struct EditorState {
/// default --- M4.2 wires the actual `tree-sitter-rust` and
/// `tree-sitter-lua` registrations at startup.
pub syntax_registry: crate::syntax::SharedSyntaxRegistry,
/// Per-buffer fold stores (Arc 6). The same `Rc` the core owns (for
/// the pre-edit unfold) and the `pmacs.fold` Lua surface reaches (via
/// Lua app-data); read here by the semantic `FoldState` producer.
pub fold_registry: crate::fold::SharedFoldRegistry,
/// Process supervisor (T M4.4). Owns every child process the
/// editor has spawned (LSP servers from M4.5; REPLs from M5).
/// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor
@ -283,6 +287,15 @@ impl EditorState {
// state, but its search overlay resolves wash faces through
// this handle.
core.borrow_mut().theme = Some(syntax_registry.theme());
// Arc 6 folding: the core created the fold registry; share that
// same `Rc` into the `pmacs.fold` Lua surface (app-data) so
// commands and the data API mutate the stores the pre-edit unfold
// and the semantic producer read. Installed after
// `make_syntax_registry` so the data API can reach the parse tree
// (also app-data) when it computes a fold target.
let fold_registry = core.borrow().fold_registry.clone();
crate::lua_bindings::install_fold(lua_host.lua(), &fold_registry)
.expect("install pmacs.fold");
// Arc 4 stage 2 (Q#F2/Q#F3): the GPU font preference and its
// `pmacs.gpu` Lua surface. Installed BEFORE load_user_config
// below, so an init.lua `set_font` lands in the same handle
@ -459,6 +472,16 @@ impl EditorState {
include_str!("../builtin/runtime/comment.lua"),
)
.expect("load comment builtin chunk");
// Arc 6 folding: interactive fold commands + the Emacs hideshow
// `C-c @` bindings. Depends on the `pmacs.fold` Rust surface
// (installed above, after make_syntax_registry) plus pmacs.command
// / pmacs.keymap / pmacs.editor (all pre-runtime).
lua_host
.eval(
Some("@pmacs/builtin/runtime/fold.lua"),
include_str!("../builtin/runtime/fold.lua"),
)
.expect("load fold builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/indent.lua"),
@ -551,6 +574,7 @@ impl EditorState {
interactive_origin,
async_runtime,
syntax_registry,
fold_registry,
process_supervisor,
terminal_manager,
lsp_manager,

View File

@ -214,6 +214,13 @@ pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
/// of every buffer; windows reference buffers by [`BufferId`].
pub registry: SharedRegistry,
/// Per-buffer fold stores (Arc 6). Shared with `EditorState`, the
/// semantic `FoldState` producer, and the `pmacs.fold` Lua surface
/// — the same `Rc`. The core reaches it so the six point-anchored
/// edit primitives can run the dispatch-layer pre-edit unfold
/// (Q#FD5): a command-path self-insert/delete at a point inside a
/// fold unfolds it before the edit applies.
pub fold_registry: crate::fold::SharedFoldRegistry,
/// All windows, keyed by id for stable iteration. `WindowId`s
/// are globally unique across all frontends; each
/// [`FrontendView`] in `views` references a subset via its
@ -383,6 +390,7 @@ impl EditorCore {
);
Self {
registry,
fold_registry: crate::fold::make_shared_fold_registry(),
windows,
views,
status: String::new(),
@ -1827,11 +1835,27 @@ impl EditorCore {
aw.goal_col = None;
}
/// Dispatch-layer pre-edit unfold (Arc 6, Q#FD5). Before a
/// command-path point-anchored edit (the six primitives below),
/// unfold every fold containing the active point so a self-insert or
/// delete inside a collapsed region reveals it rather than landing
/// invisibly. Keyed on the authenticated source frontend's active
/// point (this is `active_window().cursor`), not the transport. A
/// no-op when the buffer has no folds. Interactive Lua-command edits
/// (yank/query-replace/comment) reach the buffer through a different
/// path and are a named Stage 2 widening; CRDT-origin is Stage 3.
fn unfold_before_point_edit(&self) {
let id = self.active_buffer_id();
let point = self.active_window().cursor;
self.fold_registry.unfold_containing(id, point);
}
/// Insert a single character at the cursor. Returns `true` iff the
/// edit landed: a rejecting buffer intercept reports via the status
/// line and returns `false`, and callers must not mutate dependent
/// state (e.g. selection anchors) on a failed insert (Q#AI9).
pub fn insert_char(&mut self, ch: char) -> bool {
self.unfold_before_point_edit();
self.active_window_mut().goal_col = None;
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
@ -1867,6 +1891,7 @@ impl EditorCore {
/// delegates to [`Self::insert_char`] (a plain insert). The cursor
/// lands just past the inserted bytes and any selection is cleared.
pub fn insert_char_over_region(&mut self, ch: char) {
self.unfold_before_point_edit();
let Some((lo, hi)) = self.active_region() else {
// Q#AI9: an empty selection (anchor == cursor) reports no
// region yet stays armed — the insert moves the cursor off
@ -1908,6 +1933,7 @@ impl EditorCore {
/// Delete the codepoint immediately before the cursor.
pub fn backspace(&mut self) {
self.unfold_before_point_edit();
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
if cursor == 0 {
@ -1929,6 +1955,7 @@ impl EditorCore {
/// Delete the codepoint at the cursor (forward delete).
pub fn delete_forward(&mut self) {
self.unfold_before_point_edit();
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
let id = self.active_buffer_id();
@ -1952,6 +1979,7 @@ impl EditorCore {
/// between the cursor and where [`Self::move_word_left`] would
/// land.
pub fn delete_word_backward(&mut self) {
self.unfold_before_point_edit();
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
if cursor == 0 {
@ -1979,6 +2007,7 @@ impl EditorCore {
/// [`Self::delete_forward`] over the gap from the cursor to where
/// [`Self::move_word_right`] would land.
pub fn delete_word_forward(&mut self) {
self.unfold_before_point_edit();
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
let id = self.active_buffer_id();

817
src/fold.rs Normal file
View File

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

View File

@ -75,6 +75,7 @@ pub mod document_highlight;
pub mod editor;
pub mod editor_core;
pub mod file_io;
pub mod fold;
pub mod font_pref;
pub mod formatting;
pub mod frontend;

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

@ -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, &reg, id)?;
let added = lock(&store).insert(normalized);
Ok(added)
},
)?,
)?;
}
{
let reg = fold_registry.clone();
fold_mod.set(
"unfold",
lua.create_function(
move |lua, (buf, range): (BufferIdLua, Table)| -> mlua::Result<bool> {
let id = buf.id();
let requested = range_from_table(&range)?;
let Some(store) = reg.store(id) else {
return Ok(false);
};
// Accept an exact stored range (the `folds()` round-trip)
// or an arbitrary range that normalizes to a stored one.
if lock(&store).remove(requested) {
return Ok(true);
}
if let Ok(Some(bytes)) = document_bytes(lua, id)
&& let Some(normalized) =
fold::normalize_arbitrary_range(&bytes, requested)
{
return Ok(lock(&store).remove(normalized));
}
Ok(false)
},
)?,
)?;
}
{
let reg = fold_registry.clone();
fold_mod.set(
"folds",
lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result<Table> {
let out = lua.create_table()?;
for (i, r) in reg.folds(buf.id()).into_iter().enumerate() {
out.set(i + 1, range_to_table(lua, r)?)?;
}
Ok(out)
})?,
)?;
}
{
let reg = fold_registry.clone();
fold_mod.set(
"toggle",
lua.create_function(
move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result<bool> {
let id = buf.id();
let p = u64_from_lua(pos)?;
// A stored fold at the point unfolds without needing a tree.
if let Some(store) = reg.store(id) {
let mut s = lock(&store);
if !s.containing(p).is_empty() {
s.unfold_containing(p);
return Ok(true);
}
}
let Some(bundle) = bundle_or_status(lua, id) else {
return Ok(false);
};
let store = store_for(lua, &reg, id)?;
match fold::toggle_at(&mut lock(&store), &bundle, p) {
fold::ToggleOutcome::Folded(r) => {
maybe_move_point(lua, id, r);
Ok(true)
}
fold::ToggleOutcome::Unfolded(_) => Ok(true),
fold::ToggleOutcome::Nothing => {
set_status(lua, "nothing foldable here");
Ok(false)
}
}
},
)?,
)?;
}
// ---- interactive helpers (state-aware; driven by fold.lua) ------------
{
let reg = fold_registry.clone();
fold_mod.set(
"close",
lua.create_function(
move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result<bool> {
let id = buf.id();
let p = u64_from_lua(pos)?;
let Some(bundle) = bundle_or_status(lua, id) else {
return Ok(false);
};
let store = store_for(lua, &reg, id)?;
if let Some(r) = fold::close_at(&mut lock(&store), &bundle, p) {
maybe_move_point(lua, id, r);
Ok(true)
} else {
set_status(lua, "no more folds to close here");
Ok(false)
}
},
)?,
)?;
}
{
let reg = fold_registry.clone();
fold_mod.set(
"open",
lua.create_function(
move |_, (buf, pos): (BufferIdLua, i64)| -> mlua::Result<bool> {
let id = buf.id();
let p = u64_from_lua(pos)?;
let Some(store) = reg.store(id) else {
return Ok(false);
};
Ok(fold::open_at(&mut lock(&store), p).is_some())
},
)?,
)?;
}
{
let reg = fold_registry.clone();
fold_mod.set(
"cycle",
lua.create_function(
move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result<bool> {
let id = buf.id();
let p = u64_from_lua(pos)?;
let Some(bundle) = bundle_or_status(lua, id) else {
return Ok(false);
};
let store = store_for(lua, &reg, id)?;
match fold::cycle_at(&mut lock(&store), &bundle, p) {
fold::CycleOutcome::Closed(r) => {
maybe_move_point(lua, id, r);
Ok(true)
}
fold::CycleOutcome::OpenedAll(_) => Ok(true),
fold::CycleOutcome::Nothing => {
set_status(lua, "nothing foldable here");
Ok(false)
}
}
},
)?,
)?;
}
{
let reg = fold_registry.clone();
fold_mod.set(
"close_all",
lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result<i64> {
let id = buf.id();
let Some(bundle) = bundle_or_status(lua, id) else {
return Ok(0);
};
let targets = fold::top_level_fold_targets(&bundle);
let store = store_for(lua, &reg, id)?;
let 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);
}
}
}
}

View File

@ -85,6 +85,7 @@ use crate::workers_buffer;
// stable.
mod config;
mod diag;
mod fold;
mod index;
mod mcp;
// Every `pub` item a moved domain owned is re-exported so its prior
@ -94,6 +95,7 @@ mod mcp;
// them), but they were `pub`, so their paths are preserved for
// compile-compatibility; any deliberate narrowing is a separate change.
pub use diag::install_diag;
pub use fold::install_fold;
pub use index::{SharedProjectIndexer, install_project_index, make_project_indexer};
pub use mcp::{McpServerIdLua, install_mcp, make_mcp_manager};

View File

@ -26,9 +26,10 @@
//! and `Decorations` (M11.3), both span-granularity diffed (M11.4);
//! `InlineAdornments` (Step 3, from the LSP inlay-hint store,
//! M11.2-level suppression); `FileStyleSummary` (resolving Open Q#2 —
//! per-line dominant style for a minimap, generation-keyed).
//! `BlockAdornments` / `FoldState` / `ResourceOffer` remain wire-
//! declared but unproduced.
//! per-line dominant style for a minimap, generation-keyed); `FoldState`
//! (Arc 6 — the instance's authoritative fold set, authoritative-empty).
//! `BlockAdornments` / `ResourceOffer` remain wire-declared but
//! unproduced.
use std::collections::HashMap;
@ -163,6 +164,16 @@ pub struct SemanticRenderState {
/// byte-identical. `LastFrame::items` reuse keeps the shape
/// uniform even though no segment diffing applies.
last_adornments: HashMap<BufferId, LastFrame<InlineAdornment>>,
/// `FoldState` baseline (Arc 6). Whole-buffer, not viewport-clipped,
/// so a plain `Vec<ByteRange>` per buffer suffices. Authoritative-
/// empty and diff-suppressed: the first sight of a buffer emits only
/// if a fold exists (no empty-frame spam), an unchanged set emits
/// nothing, and a `non-empty → empty` transition emits exactly one
/// empty frame so the frontend clears its fold mirror. Resets on
/// `BufferSnapshot` — the snapshot's own frontend-side fold-mirror
/// clear is what makes the empty-after-revert suppression correct
/// (Q#FD8, #120 class).
last_folds: HashMap<BufferId, Vec<ByteRange>>,
/// `FileStyleSummary` baseline (post-M11 minimap producer,
/// resolving design-note Open Q#2). The whole-file dominant-style
/// summary is expensive to compute on a 100k-line file, so the
@ -417,6 +428,7 @@ impl SemanticRenderState {
last_sent: HashMap::new(),
last_decorations: HashMap::new(),
last_adornments: HashMap::new(),
last_folds: HashMap::new(),
last_search_prompt: HashMap::new(),
last_menu_prompt: HashMap::new(),
last_minibuffer: None,
@ -565,6 +577,7 @@ impl SemanticRenderState {
self.last_style_gate.remove(&buffer_id);
self.last_decorations.remove(&buffer_id);
self.last_adornments.remove(&buffer_id);
self.last_folds.remove(&buffer_id);
self.last_summary.remove(&buffer_id);
self.last_status.remove(&buffer_id);
self.last_search_prompt.remove(&buffer_id);
@ -583,11 +596,12 @@ impl SemanticRenderState {
/// send. Returns an empty vec before the frontend declares a
/// viewport.
///
/// `BlockAdornments` / `FoldState` are still deliberately *not*
/// produced: pmacs has no instance-side blame / lens / fold / diff
/// source yet. Their wire variants exist (T M11.1); their
/// producers wire in when those features land — the same
/// "declared, not yet wired" discipline. Emitting an empty message
/// `BlockAdornments` is still deliberately *not* produced: pmacs has
/// no instance-side blame / lens / diff source yet. (`FoldState` IS
/// produced now — Arc 6 — authoritative-empty via `fold_state_msg`.)
/// Its wire variant exists (T M11.1); its producer wires in when that
/// feature lands — the same "declared, not yet wired" discipline.
/// Emitting an empty message
/// every frame would be waste, not honesty, so `InlineAdornments` is
/// suppressed both when unchanged and when there is simply nothing
/// to say (no hints, no prior non-empty send).
@ -775,6 +789,8 @@ impl SemanticRenderState {
// --- InlineAdornments (Step 3 producer) ---
out.extend(self.inline_adornments_msg(state, &vp));
// --- FoldState (Arc 6 producer; authoritative-empty) ---
out.extend(self.fold_state_msg(state, vp.buffer_id));
// --- FileStyleSummary (minimap producer; Open Q#2) ---
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
// --- StatusFacts (status band; Q#S1, protocol v8) ---
@ -1400,6 +1416,35 @@ impl SemanticRenderState {
})
}
/// The `FoldState` message for this frame, or `None` (Arc 6). The
/// instance's authoritative fold set for `buffer_id`, whole-buffer
/// (folds are a handful; `close-all` is top-level only, so the set is
/// O(top-level blocks) — no viewport scoping). Authoritative-empty and
/// diff-suppressed exactly like `inline_adornments_msg`: the first
/// sight of a buffer speaks only if a fold exists, an unchanged set is
/// silent, and a `non-empty → empty` transition emits one empty frame
/// so the frontend clears its mirror. Its baseline resets on
/// `BufferSnapshot`; see `on_buffer_snapshot_sent`.
fn fold_state_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let folds = state.fold_registry.folds(buffer_id);
let should_emit = match self.last_folds.get(&buffer_id) {
// First sight: speak only if there is a fold to show.
None => !folds.is_empty(),
// 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

491
tests/folding_acceptance.rs Normal file
View File

@ -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(&reg, "rust", src.as_bytes());
let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable");
assert_eq!(r.start, line_content_end_of(src, "fn foo() {"));
assert_eq!(
line_text_at(src, r.start),
"fn foo() {",
"head line is the fn line"
);
assert_eq!(
line_text_at(src, r.start + 1),
" let x = 1;",
"first hidden line"
);
}
#[test]
fn head_line_rust_wrapped_signature_keeps_signature_visible() {
// R3-1: rustfmt puts `{` on the `) -> bool {` line; the head must be
// that line, NOT `fn foo(` — the wrapped signature stays visible.
let reg = SyntaxRegistry::new();
let src = "fn foo(\n a: u32,\n) -> bool {\n true\n}\n";
let bundle = parse(&reg, "rust", src.as_bytes());
let r = fold_target_at(&bundle, byte_of(src, "true")).expect("foldable");
assert_eq!(r.start, line_content_end_of(src, ") -> bool {"));
assert_eq!(line_text_at(src, r.start), ") -> bool {");
// The two wrapped signature lines are before the fold → visible.
assert!(r.start > line_content_end_of(src, "a: u32,"));
}
#[test]
fn head_line_python_uses_def_not_a_body_line() {
// R2-1: tree-sitter-python's `block` starts on the first statement
// line, so the head must ascend to `def foo():`, not `x = 1`.
let reg = SyntaxRegistry::new();
let src = "def foo():\n x = 1\n y = 2\n";
let bundle = parse(&reg, "python", src.as_bytes());
let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable");
assert_eq!(r.start, line_content_end_of(src, "def foo():"));
assert_eq!(line_text_at(src, r.start), "def foo():");
assert_eq!(line_text_at(src, r.start + 1), " x = 1");
}
#[test]
fn head_line_python_wrapped_signature_keeps_signature_visible() {
let reg = SyntaxRegistry::new();
let src = "def foo(\n a,\n):\n x = 1\n";
let bundle = parse(&reg, "python", src.as_bytes());
let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable");
assert_eq!(r.start, line_content_end_of(src, "):"));
assert_eq!(line_text_at(src, r.start), "):");
}
// ---------------------------------------------------------------------------
// 4. Range semantics — closer-aware tail (R2-5).
// ---------------------------------------------------------------------------
#[test]
fn brace_closer_line_stays_visible() {
let reg = SyntaxRegistry::new();
let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n";
let bundle = parse(&reg, "rust", src.as_bytes());
let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable");
// Last hidden line is the last body line; the `}` line is outside.
assert_eq!(r.end, line_content_end_of(src, "let y = 2;"));
assert_eq!(
line_text_at(src, r.end + 1),
"}",
"closer line stays visible"
);
}
#[test]
fn shared_closer_line_else_stays_visible() {
// R2-5: `} else {` keeps its trailing sibling on screen.
let reg = SyntaxRegistry::new();
let src = "fn f() {\n if a {\n one();\n } else {\n two();\n }\n}\n";
let bundle = parse(&reg, "rust", src.as_bytes());
let r = fold_target_at(&bundle, byte_of(src, "one()")).expect("foldable");
// The consequent block folds; its `} else {` line stays visible.
assert_eq!(line_text_at(src, r.end + 1).trim(), "} else {");
}
#[test]
fn python_hides_through_last_body_line() {
let reg = SyntaxRegistry::new();
let src = "def foo():\n x = 1\n y = 2\n";
let bundle = parse(&reg, "python", src.as_bytes());
let r = fold_target_at(&bundle, byte_of(src, "x = 1")).expect("foldable");
// Delimiter-less: the last body line is hidden (inside the range).
assert_eq!(r.end, line_content_end_of(src, "y = 2"));
}
// ---------------------------------------------------------------------------
// 3. close-all folds top-level regions only; 9. nested + state-aware order.
// ---------------------------------------------------------------------------
#[test]
fn close_all_is_top_level_only() {
let reg = SyntaxRegistry::new();
let src = "fn a() {\n if c {\n work();\n more();\n }\n}\n\nfn b() {\n x();\n y();\n}\n";
let bundle = parse(&reg, "rust", src.as_bytes());
let top = top_level_fold_targets(&bundle);
assert_eq!(
top.len(),
2,
"two top-level fns, the nested `if` is not auto-folded"
);
// Neither top-level range is the inner `if` block.
let inner = fold_target_at(&bundle, byte_of(src, "work()")).expect("inner foldable");
assert!(
!top.contains(&inner),
"close-all does not fold the nested region"
);
}
#[test]
fn nested_state_aware_ordering() {
// 9 / R3-2: close walks innermost→outer, open walks outer→inner, and
// toggle cycles close-inner → close-outer → open-all so every command
// reaches the outer fold.
let reg = SyntaxRegistry::new();
let src = "fn outer() {\n if cond {\n work();\n more();\n }\n}\n";
let bundle = parse(&reg, "rust", src.as_bytes());
let p = byte_of(src, "work()");
let mut store = FoldStore::new();
let inner = close_at(&mut store, &bundle, p).expect("close inner");
let outer = close_at(&mut store, &bundle, p).expect("close outer");
assert!(
inner.start > outer.start,
"inner fold is more deeply nested"
);
assert!(
close_at(&mut store, &bundle, p).is_none(),
"nothing left to close"
);
assert_eq!(store.folds().len(), 2);
assert_eq!(open_at(&mut store, p), Some(outer), "open outermost first");
assert_eq!(open_at(&mut store, p), Some(inner), "then the inner");
assert!(store.is_empty());
assert!(matches!(
cycle_at(&mut store, &bundle, p),
CycleOutcome::Closed(_)
));
assert!(matches!(
cycle_at(&mut store, &bundle, p),
CycleOutcome::Closed(_)
));
assert_eq!(store.folds().len(), 2, "cycle closed both");
assert!(matches!(
cycle_at(&mut store, &bundle, p),
CycleOutcome::OpenedAll(2)
));
assert!(store.is_empty(), "one more cycle opened them all");
}
// ---------------------------------------------------------------------------
// 10. Injected layer (a fenced rust block inside markdown).
// ---------------------------------------------------------------------------
#[test]
fn fold_sourced_inside_injected_layer() {
let reg = SyntaxRegistry::new();
let src = "# Title\n\n```rust\nfn demo() {\n let x = 1;\n let y = 2;\n}\n```\n\nText.\n";
let bundle = parse(&reg, "markdown", src.as_bytes());
assert!(
bundle.layers.len() >= 2,
"markdown fence produced an injected rust layer"
);
let r = fold_target_at(&bundle, byte_of(src, "let x")).expect("foldable inside the fence");
assert_eq!(
line_text_at(src, r.start),
"fn demo() {",
"resolved the inner block"
);
}
// ---------------------------------------------------------------------------
// 2. Stale / absent parse tree refuses (the precondition the binding keys on).
// ---------------------------------------------------------------------------
#[test]
fn absent_tree_has_no_current_bundle() {
let reg = SyntaxRegistry::new();
let language = reg.language("rust").expect("grammar");
let src = b"fn foo() {\n let x = 1;\n}\n";
let buf = Buffer::from_bytes(BufferId::next(), "doc", src);
let view = ParseView::new(&buf, language, "rust".to_owned());
let handle = view.handle();
// Before any parse installs, `current()` is None → the binding refuses.
assert!(handle.current().is_none());
let mut req = handle.make_request();
req.injection_aliases = reg.injection_alias_snapshot();
let bundle = run_parse(req).expect("parse");
handle.install(reg.resolve_layer_queries(&bundle));
assert!(
handle.current().is_some(),
"after install, a target is derivable"
);
}
// ---------------------------------------------------------------------------
// 6. Command-path pre-edit unfold (Q#FD5).
// ---------------------------------------------------------------------------
fn active_id(s: &EditorState) -> BufferId {
s.core.borrow().active_buffer_id()
}
fn insert_into(s: &EditorState, id: BufferId, text: &str) {
let core = s.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(id)
.unwrap()
.apply_edit(EditOp::Insert {
pos: 0,
bytes: text.as_bytes(),
})
.unwrap();
}
#[test]
fn command_path_self_insert_unfolds_at_point() {
let s = EditorState::new();
let id = active_id(&s);
insert_into(&s, id, "line0\nline1\nline2\nline3\n");
// Fold the interior of lines 1..2: [end of line0, end of line2].
let store = {
let core = s.core.borrow();
let mut reg = core.registry.borrow_mut();
s.fold_registry.store_or_attach(reg.get_mut(id).unwrap())
};
store
.lock()
.unwrap()
.insert(ByteRange { start: 5, end: 17 });
// Cursor strictly inside the fold (start of "line2").
s.core.borrow_mut().set_cursor_byte(12);
// A command-path self-insert unfolds before the edit lands.
s.core.borrow_mut().insert_char('x');
assert!(
store.lock().unwrap().is_empty(),
"typing inside a fold unfolds it (Q#FD5)"
);
}
#[test]
fn self_insert_at_head_line_end_does_not_unfold() {
// `(start, end]` containment: a self-insert exactly at the end of the
// head line (== range.start) is outside the fold — it must NOT unfold,
// and the translator shifts the fold right so the char lands visible.
let s = EditorState::new();
let id = active_id(&s);
insert_into(&s, id, "line0\nline1\nline2\nline3\n");
let store = {
let core = s.core.borrow();
let mut reg = core.registry.borrow_mut();
s.fold_registry.store_or_attach(reg.get_mut(id).unwrap())
};
store
.lock()
.unwrap()
.insert(ByteRange { start: 5, end: 17 });
s.core.borrow_mut().set_cursor_byte(5); // end of head line "line0"
s.core.borrow_mut().insert_char('x');
let folds = store.lock().unwrap().folds();
assert_eq!(
folds,
vec![ByteRange { start: 6, end: 18 }],
"fold shifts right; the character lands on the head line"
);
}
// ---------------------------------------------------------------------------
// 5. Point moves to the head line when a fold is created around it (Q#FD3).
// 11. Data-API validation (Q#FD11) — driven through the Lua surface.
// ---------------------------------------------------------------------------
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
/// Install a settled rust parse over the active scratch buffer so the
/// `pmacs.fold` surface can drive it end to end.
fn install_rust_parse(s: &EditorState, id: BufferId) {
let reg = &s.syntax_registry;
let language = reg.language("rust").expect("grammar");
let handle = {
let core = s.core.borrow();
let mut breg = core.registry.borrow_mut();
let buf = breg.get_mut(id).unwrap();
let view = ParseView::new(buf, language, "rust".to_owned());
let handle = view.handle();
buf.attach_view(Box::new(view));
handle
};
let mut req = handle.make_request();
req.injection_aliases = reg.injection_alias_snapshot();
let bundle = run_parse(req).expect("parse");
handle.install(reg.resolve_layer_queries(&bundle));
reg.attach_view(id, handle);
}
#[test]
fn folding_moves_point_to_head_line() {
let s = EditorState::new();
let id = active_id(&s);
let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n";
insert_into(&s, id, src);
install_rust_parse(&s, id);
// Cursor inside the body; `let x` starts on line 1.
let p = byte_of(src, "let x");
s.core.borrow_mut().set_cursor_byte(p);
exec(&s, "pmacs.command.invoke('fold.close')");
let head = line_content_end_of(src, "fn foo() {");
assert_eq!(
s.core.borrow().active_window().cursor,
head,
"the invoking point moved to the head line"
);
// And the fold exists.
let n: i64 = eval(&s, "return #pmacs.fold.folds(pmacs.window.buffer())");
assert_eq!(n, 1);
}
#[test]
fn data_api_validation() {
let s = EditorState::new();
// A plain document buffer with four lines.
exec(
&s,
"b = pmacs.buffer.from_bytes('doc.rs', 'aaa\\nbbb\\nccc\\nddd\\n')",
);
// A valid multi-line range folds.
let ok: bool = eval(&s, "return pmacs.fold.fold(b, { start = 3, ['end'] = 11 })");
assert!(ok, "a >=1-hidden-line range is accepted");
let n: i64 = eval(&s, "return #pmacs.fold.folds(b)");
assert_eq!(n, 1);
// A sub-one-line range (both endpoints on the same line) is rejected.
let same_line: bool = eval(&s, "return pmacs.fold.fold(b, { start = 0, ['end'] = 2 })");
assert!(
!same_line,
"a range hiding no full line is rejected (Q#FD11)"
);
// An out-of-bounds range is rejected.
let oob: bool = eval(
&s,
"return pmacs.fold.fold(b, { start = 0, ['end'] = 99999 })",
);
assert!(!oob, "an out-of-bounds range is rejected");
// Q#FD9 via the >=1-hidden-line rule: a fold at (0,0) on an empty
// buffer normalizes to zero hidden lines and is rejected.
exec(&s, "e = pmacs.buffer.from_bytes('empty.rs', '')");
let empty: bool = eval(&s, "return pmacs.fold.fold(e, { start = 0, ['end'] = 0 })");
assert!(
!empty,
"a zero-length range is rejected (terminals never fold)"
);
// Round-trip: unfold the stored range clears it.
let unfolded: bool = eval(
&s,
"local f = pmacs.fold.folds(b)[1]; return pmacs.fold.unfold(b, f)",
);
assert!(unfolded);
let n2: i64 = eval(&s, "return #pmacs.fold.folds(b)");
assert_eq!(n2, 0);
}
// ---------------------------------------------------------------------------
// 8. Buffer content replacement drops the store.
// ---------------------------------------------------------------------------
#[test]
fn forget_drops_store_and_detaches_view() {
let s = EditorState::new();
let id = active_id(&s);
insert_into(&s, id, "line0\nline1\nline2\n");
let store = {
let core = s.core.borrow();
let mut reg = core.registry.borrow_mut();
s.fold_registry.store_or_attach(reg.get_mut(id).unwrap())
};
store
.lock()
.unwrap()
.insert(ByteRange { start: 5, end: 11 });
assert!(s.fold_registry.store(id).is_some());
// Content replacement (revert/reload) drops the store.
{
let core = s.core.borrow();
let mut reg = core.registry.borrow_mut();
s.fold_registry.forget(reg.get_mut(id).unwrap());
}
assert!(s.fold_registry.store(id).is_none(), "the store is dropped");
assert!(fold::make_shared_fold_registry().folds(id).is_empty());
}