pmacs/src/fold.rs

850 lines
30 KiB
Rust

// fold.rs --- Structural code-folding engine (Arc 6, Stage 1).
//! The instance-side fold engine: a per-buffer fold store, a structural
//! fold source over the tree-sitter parse, and the state-aware fold
//! operations the Lua command/data surface drives. No rendering lives
//! here — Stage 2 (grid) and Stage 3 (GPU) consume the store; the
//! semantic producer ships it as `FoldState`.
//!
//! Design (see `docs/folding-framing.md`, approved rev 5):
//!
//! - **Store = a set of byte ranges** attached to the buffer as a
//! [`View`] so it translates across every edit, provenance-blind
//! (Q#FD2/FD3/FD5). Stored range = `[end of head line, end of the
//! last hidden line]`; containment is **start-exclusive,
//! end-inclusive** `(start, end]` so a point at the end of the head
//! line is *outside* (typing there shifts the fold right, landing the
//! character visible on the head line) while a point at the end of the
//! last hidden line is *inside* (typing there unfolds).
//! - **Source = structural node folding** (Q#FD1): the nearest enclosing
//! block-like node ≥ 2 source lines → resolve introducer↔body → the
//! head line is *the line immediately above the first hidden line*
//! (so a rustfmt-wrapped signature or a `where` clause stays visible —
//! hideshow / LSP `foldingRange` parity) → a **closer-aware tail**
//! keeps a closing-delimiter line visible (`} else {`, `}, [deps])`).
//! - **Translate + drop only** (Q#FD6): an edit strictly inside the
//! interior shifts the fold's end; an edit that crosses the head or
//! tail boundary drops the fold. The *interactive* unfold-on-typing is
//! a dispatch-layer pre-edit step (see `EditorCore`), not here.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use pmacs_protocol::{BufferId, ByteRange};
use tree_sitter::Node;
use crate::buffer::{Buffer, BufferError, ViewId};
use crate::rope::Edit;
use crate::syntax::ParseTreeBundle;
use crate::view::View;
const OPEN_DELIMS: &[u8] = b"{[(";
const CLOSE_DELIMS: &[u8] = b"}])";
// ---------------------------------------------------------------------------
// Line math (a self-contained copy of the `highlight.rs` scan — kept private
// so the fold source has no cross-module coupling).
// ---------------------------------------------------------------------------
/// `out[n]` = start byte of line `n`; `out` always begins with `0`. The
/// number of lines is `out.len()` (a trailing entry past the final `\n`
/// is included, mirroring `highlight::compute_line_offsets`).
fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
let mut out = Vec::with_capacity(source.len() / 32 + 1);
out.push(0);
for (i, b) in source.iter().enumerate() {
if *b == b'\n' {
out.push(i as u32 + 1);
}
}
out
}
/// Index of the line containing byte `offset`.
fn line_at_offset(line_offsets: &[u32], offset: u32) -> usize {
match line_offsets.binary_search(&offset) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
}
}
/// The byte offset just past line `row`'s last *visible* character — i.e.
/// the position of the row's terminating `\n`, or `source.len()` for the
/// final unterminated line. This is the "end of line" the stored range
/// uses for both its head and tail.
fn line_content_end(source: &[u8], line_offsets: &[u32], row: usize) -> u64 {
let start = line_offsets
.get(row)
.copied()
.unwrap_or(source.len() as u32) as usize;
let next = line_offsets
.get(row + 1)
.copied()
.unwrap_or(source.len() as u32) as usize;
let mut end = next.min(source.len());
if end > start && source[end - 1] == b'\n' {
end -= 1;
}
end as u64
}
/// True iff line `row`'s first non-whitespace byte is a closing delimiter
/// (`}`, `)`, `]`) — the closer-aware tail test.
fn line_starts_with_closer(source: &[u8], line_offsets: &[u32], row: usize) -> bool {
let Some(&ls) = line_offsets.get(row) else {
return false;
};
let mut i = ls as usize;
while i < source.len() && (source[i] == b' ' || source[i] == b'\t') {
i += 1;
}
i < source.len() && CLOSE_DELIMS.contains(&source[i])
}
// ---------------------------------------------------------------------------
// FoldStore — the per-buffer set of collapsed ranges.
// ---------------------------------------------------------------------------
/// A buffer's set of currently-collapsed ranges. Kept sorted by
/// `(start, end)`; nested folds are allowed; exact duplicates are not.
#[derive(Debug, Default)]
pub struct FoldStore {
folds: Vec<ByteRange>,
}
impl FoldStore {
/// An empty store.
#[must_use]
pub fn new() -> Self {
Self { folds: Vec::new() }
}
/// Whether the store holds no folds.
#[must_use]
pub fn is_empty(&self) -> bool {
self.folds.is_empty()
}
/// The current folds, sorted and stable — the form the producer diffs
/// and `pmacs.fold.folds` returns.
#[must_use]
pub fn folds(&self) -> Vec<ByteRange> {
self.folds.clone()
}
/// Whether an exactly-equal fold range is already stored.
#[must_use]
pub fn contains_exact(&self, r: ByteRange) -> bool {
self.folds.contains(&r)
}
/// Add a fold. Rejects an empty/inverted range or an exact duplicate;
/// returns whether it was added.
pub fn insert(&mut self, r: ByteRange) -> bool {
if r.end <= r.start || self.contains_exact(r) {
return false;
}
self.folds.push(r);
self.normalize();
true
}
/// Remove an exact fold; returns whether one was removed.
pub fn remove(&mut self, r: ByteRange) -> bool {
let before = self.folds.len();
self.folds.retain(|f| *f != r);
self.folds.len() != before
}
/// Drop every fold; returns whether anything was cleared.
pub fn clear(&mut self) -> bool {
let had = !self.folds.is_empty();
self.folds.clear();
had
}
/// Folds whose interior contains `p` under `(start, end]` containment,
/// **innermost first** (a more deeply nested fold has the larger start).
#[must_use]
pub fn containing(&self, p: u64) -> Vec<ByteRange> {
let mut v: Vec<ByteRange> = self
.folds
.iter()
.copied()
.filter(|f| f.start < p && p <= f.end)
.collect();
v.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
v
}
/// Remove every fold containing `p` (the dispatch-layer pre-edit
/// unfold, and the org-TAB "open all" leg). Returns the count removed.
pub fn unfold_containing(&mut self, p: u64) -> usize {
let before = self.folds.len();
self.folds.retain(|f| !(f.start < p && p <= f.end));
before - self.folds.len()
}
fn normalize(&mut self) {
self.folds
.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end)));
self.folds.dedup();
}
/// Translate every fold across `edit`, dropping any whose head or tail
/// the edit crosses (Q#FD6). Provenance-blind: `Edit` carries no source
/// frontend, so this cannot (and must not) unfold-on-typing — that is
/// the dispatch layer's pre-edit job.
///
/// Boundary handling mirrors `BufferStyleSpanTranslator`'s right-bias:
/// an insert exactly at the start (end of the head line) shifts the
/// whole fold right, so the character lands visible on the head line;
/// an insert exactly at the end is left outside.
pub fn translate(&mut self, edit: &Edit) {
let os = edit.range.start;
let oe = edit.range.end;
let old_len = oe - os;
let new_len = edit.inserted_len;
// Buffers broadcast no-op edits; nothing moved.
if old_len == 0 && new_len == 0 {
return;
}
// Shift a byte offset by the edit's signed length delta, in u64
// arithmetic (no `as i64` wrap): grow by `new_len - old_len` or
// shrink by `old_len - new_len`, saturating at 0.
let shift = |x: u64| -> u64 {
if new_len >= old_len {
x + (new_len - old_len)
} else {
x.saturating_sub(old_len - new_len)
}
};
let mut kept = Vec::with_capacity(self.folds.len());
for f in self.folds.drain(..) {
let (s, e) = (f.start, f.end);
let next = if oe <= s {
// Strictly before the fold (an insert at exactly `s` lands
// here, shifting the fold right — the head-line right-bias).
Some(ByteRange {
start: shift(s),
end: shift(e),
})
} else if os > e || (os == e && old_len == 0) {
// Strictly after the fold, OR a pure insert at exactly `e`
// (left outside). A *delete* starting at `e` removes the
// `\n` that `e` names — the terminator of the last hidden
// line — so it destroys the tail and falls through to the
// drop arm below, symmetric with the head side.
Some(ByteRange { start: s, end: e })
} else if os > s && oe < e {
// Strictly inside the interior — the fold still hides a
// valid interior; shift its end by the delta.
let e2 = shift(e);
if e2 > s {
Some(ByteRange { start: s, end: e2 })
} else {
None
}
} else {
// The edit crosses the head or tail boundary (or engulfs
// the fold) — the head/tail it named is gone. Drop it.
None
};
if let Some(r) = next {
kept.push(r);
}
}
self.folds = kept;
self.normalize();
}
}
// ---------------------------------------------------------------------------
// FoldStoreTranslator — the buffer-attached View that keeps the store in
// sync with edits.
// ---------------------------------------------------------------------------
struct FoldStoreTranslator {
store: Arc<Mutex<FoldStore>>,
}
impl View for FoldStoreTranslator {
fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), BufferError> {
self.store
.lock()
.expect("fold store mutex poisoned")
.translate(edit);
Ok(())
}
fn kind(&self) -> &'static str {
"fold_store_translator"
}
}
// ---------------------------------------------------------------------------
// FoldRegistry — per-buffer stores, keyed by BufferId (the SyntaxRegistry
// model), each paired with a translator View over the same Arc.
// ---------------------------------------------------------------------------
/// Shared, cloneable handle to the process's fold stores. Held by
/// `EditorCore` (for the pre-edit unfold), by `EditorState` and the
/// semantic producer (to ship `FoldState`), and by the `pmacs.fold` Lua
/// bindings (via Lua app-data) — all the same `Rc`.
pub type SharedFoldRegistry = Rc<FoldRegistry>;
struct FoldEntry {
store: Arc<Mutex<FoldStore>>,
view: ViewId,
}
/// One fold store per buffer. Interior-mutable so a `&SharedFoldRegistry`
/// suffices everywhere.
#[derive(Default)]
pub struct FoldRegistry {
stores: RefCell<HashMap<BufferId, FoldEntry>>,
}
/// Build a fresh, empty fold registry.
#[must_use]
pub fn make_shared_fold_registry() -> SharedFoldRegistry {
Rc::new(FoldRegistry::default())
}
impl FoldRegistry {
/// The buffer's store if one exists — the lookup used by read-only
/// callers (the pre-edit unfold and the producer) that must not
/// materialize a store or attach a view.
#[must_use]
pub fn store(&self, buf: BufferId) -> Option<Arc<Mutex<FoldStore>>> {
self.stores.borrow().get(&buf).map(|e| Arc::clone(&e.store))
}
/// The buffer's folds (sorted; empty when it has no store).
#[must_use]
pub fn folds(&self, buf: BufferId) -> Vec<ByteRange> {
self.store(buf)
.map(|s| s.lock().expect("fold store mutex poisoned").folds())
.unwrap_or_default()
}
/// Get-or-create the store for `buffer`, attaching the translator view
/// on first materialization so every later edit is tracked.
pub fn store_or_attach(&self, buffer: &mut Buffer) -> Arc<Mutex<FoldStore>> {
let id = buffer.id();
if let Some(existing) = self.stores.borrow().get(&id) {
return Arc::clone(&existing.store);
}
let store = Arc::new(Mutex::new(FoldStore::new()));
let view = buffer.attach_view(Box::new(FoldStoreTranslator {
store: Arc::clone(&store),
}));
self.stores.borrow_mut().insert(
id,
FoldEntry {
store: Arc::clone(&store),
view,
},
);
store
}
/// Drop the buffer's store and detach its translator view — the
/// content-replacement (revert/reload) reset, where the buffer survives
/// but its bytes are replaced wholesale, so the view must come off too
/// (a later fold re-attaches a fresh one). Named bytes no longer exist,
/// so revalidation is not attempted (Q#FD8, framing acceptance 8).
pub fn forget(&self, buffer: &mut Buffer) {
if let Some(entry) = self.stores.borrow_mut().remove(&buffer.id()) {
buffer.detach_view(entry.view);
}
}
/// Drop the store for a buffer that has already been removed (the
/// `pmacs.buffer.kill` path). The buffer — and its attached translator
/// view — is gone, so only the map entry needs clearing; there is no
/// view to detach.
pub fn forget_buffer(&self, buf: BufferId) {
self.stores.borrow_mut().remove(&buf);
}
/// Unfold every fold in `buf` containing `p`. The pre-edit hook the
/// six `EditorCore` edit primitives call; a no-op when the buffer has
/// no store (hence no folds). Returns the count unfolded.
pub fn unfold_containing(&self, buf: BufferId, p: u64) -> usize {
match self.store(buf) {
Some(s) => s
.lock()
.expect("fold store mutex poisoned")
.unfold_containing(p),
None => 0,
}
}
}
// ---------------------------------------------------------------------------
// Structural fold source.
// ---------------------------------------------------------------------------
/// The innermost foldable region at `pos`, or `None` — the fold target the
/// data-API `toggle` and a bare "fold this" use.
#[must_use]
pub fn fold_target_at(bundle: &ParseTreeBundle, pos: u64) -> Option<ByteRange> {
candidates_at(bundle, pos).into_iter().next()
}
/// Every foldable region enclosing `pos`, **innermost first**. The
/// state-aware commands walk this list against the store to decide what to
/// close (innermost open) or open (outermost closed).
#[must_use]
pub fn candidates_at(bundle: &ParseTreeBundle, pos: u64) -> Vec<ByteRange> {
let source: &[u8] = &bundle.source;
let line_offsets = compute_line_offsets(source);
let Some(node) = innermost_named_node(bundle, pos) else {
return Vec::new();
};
let mut out = Vec::new();
let mut cur = Some(node);
while let Some(n) = cur {
if let Some(r) = fold_from_node(n, source, &line_offsets)
&& !out.contains(&r)
{
out.push(r);
}
cur = n.parent();
}
out
}
/// The top-level foldable regions in the buffer — what `fold.close-all`
/// collapses (Emacs `hs-hide-all`: top level only, nested not auto-folded).
#[must_use]
pub fn top_level_fold_targets(bundle: &ParseTreeBundle) -> Vec<ByteRange> {
let source: &[u8] = &bundle.source;
let line_offsets = compute_line_offsets(source);
let root = bundle.root_tree().root_node();
let mut out = Vec::new();
let mut cursor = root.walk();
for child in root.named_children(&mut cursor) {
if let Some(r) = fold_from_node(child, source, &line_offsets)
&& !out.contains(&r)
{
out.push(r);
}
}
out
}
/// The innermost named node at `pos`, resolved through injection layers:
/// the deepest layer whose root span covers `pos` wins (a fenced code block
/// inside markdown resolves to the inner block, not the markdown node).
fn innermost_named_node(bundle: &ParseTreeBundle, pos: u64) -> Option<Node<'_>> {
let p = pos as usize;
let mut best: Option<&crate::syntax::Layer> = None;
for layer in &bundle.layers {
let root = layer.tree.root_node();
if root.start_byte() <= p && p <= root.end_byte() {
best = match best {
Some(b) if b.depth >= layer.depth => Some(b),
_ => Some(layer),
};
}
}
best?.tree.root_node().named_descendant_for_byte_range(p, p)
}
/// Compute the fold range a single node yields, or `None` if it is not a
/// foldable structure (< 2 source lines, no block-like body, or a
/// normalized interior with < 1 hidden line).
fn fold_from_node(n: Node<'_>, source: &[u8], line_offsets: &[u32]) -> Option<ByteRange> {
// Match condition: the node spans >= 2 source lines.
if n.end_position().row <= n.start_position().row {
return None;
}
let (b, introduced) = resolve_body(n, source)?;
let b_start_row = b.start_position().row;
let b_end_row = b.end_position().row;
let b_start_byte = b.start_byte();
if b_start_byte >= source.len() {
return None;
}
let is_brace = OPEN_DELIMS.contains(&source[b_start_byte]);
// Head line = the line immediately above the first hidden line. For a
// brace body that is the `{` line (the introducer's own line, or the
// `) -> bool {` line when a signature wraps). For an *introduced*
// delimiter-less body (a Python `block`) the introducer's header ends
// on the line above, so it is `b_start_row - 1`.
let head_row = if is_brace {
b_start_row
} else if introduced && b_start_row > 0 {
b_start_row - 1
} else {
b_start_row
};
// Tail: a closing-delimiter line stays visible (`} else {`); a
// delimiter-less body hides through its last line.
let last_hidden_row = if line_starts_with_closer(source, line_offsets, b_end_row) {
if b_end_row == 0 {
return None;
}
b_end_row - 1
} else {
b_end_row
};
// Foldability = the normalized interior has >= 1 hidden line.
if last_hidden_row < head_row + 1 {
return None;
}
let start = line_content_end(source, line_offsets, head_row);
let end = line_content_end(source, line_offsets, last_hidden_row);
if end <= start {
return None;
}
Some(ByteRange { start, end })
}
/// Resolve the interior-defining body `B` and whether it is *introduced*
/// (its parent is an introducer whose body field is `B`). If `n` is itself
/// a body, use it; if it is an introducer with a block-like body child,
/// descend to that child (Q#FD1 step 2 — matching/`close-all` association).
fn resolve_body<'tree>(n: Node<'tree>, source: &[u8]) -> Option<(Node<'tree>, bool)> {
if is_body_kind(n, source) {
return Some((n, is_introduced(n)));
}
if let Some(b) = body_child(n)
&& is_body_kind(b, source)
{
return Some((b, true));
}
None
}
fn body_child(n: Node) -> Option<Node> {
n.child_by_field_name("body")
.or_else(|| n.child_by_field_name("consequence"))
}
fn is_introduced(n: Node<'_>) -> bool {
if let Some(p) = n.parent()
&& let Some(b) = body_child(p)
{
return b.id() == n.id();
}
false
}
/// A node is a fold *body* if it opens with a bracket delimiter (a brace
/// body) or is a grammar block node (an indentation body). The delimiter
/// probe generalizes across grammars without a per-language kind list.
fn is_body_kind(n: Node<'_>, source: &[u8]) -> bool {
let sb = n.start_byte();
if sb < source.len() && OPEN_DELIMS.contains(&source[sb]) {
return true;
}
matches!(
n.kind(),
"block"
| "statement_block"
| "declaration_list"
| "field_declaration_list"
| "enum_variant_list"
| "block_mapping"
| "block_sequence"
) || n.kind().ends_with("_body")
}
// ---------------------------------------------------------------------------
// State-aware operations (Q#FD4 shared-head ordering). Pure over a store
// (+ parse bundle); the Lua bindings drive them and move the point.
// ---------------------------------------------------------------------------
/// Close the innermost still-open foldable region at `p`; returns the newly
/// folded range. Repeated calls walk outward.
pub fn close_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> Option<ByteRange> {
for c in candidates_at(bundle, p) {
if !store.contains_exact(c) {
store.insert(c);
return Some(c);
}
}
None
}
/// Open the outermost currently-closed fold at `p`; returns the removed
/// range. Repeated calls walk inward.
pub fn open_at(store: &mut FoldStore, p: u64) -> Option<ByteRange> {
let mut containing = store.containing(p);
// `containing` is innermost-first; the outermost has the smallest start.
containing.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
let outer = containing.into_iter().next()?;
store.remove(outer);
Some(outer)
}
/// The result of an org-TAB-style toggle cycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CycleOutcome {
/// Closed one more (innermost open) fold on the head.
Closed(ByteRange),
/// Every fold on the head was already closed; opened them all.
OpenedAll(usize),
/// Nothing foldable and nothing folded at the point.
Nothing,
}
/// `fold.toggle`: org-TAB cycle. While any foldable region at `p` is open,
/// close the innermost open one; once all are closed, one more press opens
/// them all. Every press has a visible effect (Q#FD4/R3-2).
pub fn cycle_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> CycleOutcome {
let candidates = candidates_at(bundle, p);
if candidates.iter().any(|c| !store.contains_exact(*c)) {
for c in &candidates {
if !store.contains_exact(*c) {
store.insert(*c);
return CycleOutcome::Closed(*c);
}
}
}
let n = store.unfold_containing(p);
if n > 0 {
CycleOutcome::OpenedAll(n)
} else {
CycleOutcome::Nothing
}
}
/// The result of a data-API `toggle(buffer, pos)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToggleOutcome {
/// Folded the innermost tree target at the point.
Folded(ByteRange),
/// Unfolded the stored fold(s) containing the point.
Unfolded(usize),
/// Nothing foldable and nothing folded at the point.
Nothing,
}
/// Data-API `toggle`: unfold if a stored fold contains `pos`, else fold the
/// innermost tree target at `pos`.
pub fn toggle_at(store: &mut FoldStore, bundle: &ParseTreeBundle, p: u64) -> ToggleOutcome {
if !store.containing(p).is_empty() {
return ToggleOutcome::Unfolded(store.unfold_containing(p));
}
match fold_target_at(bundle, p) {
Some(t) => {
store.insert(t);
ToggleOutcome::Folded(t)
}
None => ToggleOutcome::Nothing,
}
}
/// Normalize an arbitrary data-API range (no node, so no introducer/closer
/// inference — the caller names exactly what to hide). Head line = the line
/// containing `range.start`; the hidden lines are the full lines strictly
/// after it through the line containing `range.end` (or the previous line
/// when `range.end` sits at a line start). `None` if that is < 1 hidden
/// line.
#[must_use]
pub fn normalize_arbitrary_range(source: &[u8], range: ByteRange) -> Option<ByteRange> {
if range.start > source.len() as u64 || range.end > source.len() as u64 {
return None;
}
let line_offsets = compute_line_offsets(source);
let head_row = line_at_offset(&line_offsets, range.start as u32);
let end_row_raw = line_at_offset(&line_offsets, range.end as u32);
let end_at_line_start = line_offsets.get(end_row_raw).copied() == Some(range.end as u32);
let last_hidden_row = if end_at_line_start && end_row_raw > 0 {
end_row_raw - 1
} else {
end_row_raw
};
if last_hidden_row < head_row + 1 {
return None;
}
let start = line_content_end(source, &line_offsets, head_row);
let end = line_content_end(source, &line_offsets, last_hidden_row);
if end <= start {
return None;
}
Some(ByteRange { start, end })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rope::{Edit, Range, Rope};
fn edit(range: Range, inserted_len: u64) -> Edit {
Edit {
new_rope: Rope::new(),
range,
inserted_len,
crdt_op: None,
}
}
fn r(start: u64, end: u64) -> ByteRange {
ByteRange { start, end }
}
#[test]
fn insert_at_head_boundary_shifts_fold_right() {
// `(start, end]` containment: an insert exactly at the end of the
// head line lands *before* the fold, shifting it right so the
// character stays visible on the head line.
let mut store = FoldStore::new();
store.insert(r(10, 30));
store.translate(&edit(Range::new(10, 10), 1));
assert_eq!(store.folds(), vec![r(11, 31)]);
}
#[test]
fn insert_strictly_inside_grows_the_end() {
let mut store = FoldStore::new();
store.insert(r(10, 30));
store.translate(&edit(Range::new(20, 20), 3));
assert_eq!(store.folds(), vec![r(10, 33)]);
}
#[test]
fn insert_at_tail_boundary_leaves_fold_untouched() {
let mut store = FoldStore::new();
store.insert(r(10, 30));
store.translate(&edit(Range::new(30, 30), 4));
assert_eq!(store.folds(), vec![r(10, 30)]);
}
#[test]
fn edit_before_fold_shifts_whole_range() {
let mut store = FoldStore::new();
store.insert(r(10, 30));
store.translate(&edit(Range::new(2, 5), 0)); // delete 3 bytes before
assert_eq!(store.folds(), vec![r(7, 27)]);
}
#[test]
fn edit_crossing_head_boundary_drops_fold() {
let mut store = FoldStore::new();
store.insert(r(10, 30));
// A delete starting at the head boundary destroys the head.
store.translate(&edit(Range::new(10, 15), 0));
assert!(store.is_empty());
}
#[test]
fn edit_crossing_tail_boundary_drops_fold() {
let mut store = FoldStore::new();
store.insert(r(10, 30));
store.translate(&edit(Range::new(25, 40), 0));
assert!(store.is_empty());
}
#[test]
fn delete_starting_at_tail_boundary_drops_fold() {
// A delete beginning exactly at `end` removes the `\n` that `end`
// names (the last hidden line's terminator), destroying the tail —
// it must drop, not survive with a mid-line end. Mirror of
// `insert_at_tail_boundary_leaves_fold_untouched` for a delete.
let mut store = FoldStore::new();
store.insert(r(10, 30));
store.translate(&edit(Range::new(30, 31), 0)); // delete one byte at `end`
assert!(store.is_empty());
// Same class: a delete starting at the boundary and extending past.
let mut store = FoldStore::new();
store.insert(r(10, 30));
store.translate(&edit(Range::new(30, 45), 0));
assert!(store.is_empty());
}
#[test]
fn containment_is_start_exclusive_end_inclusive() {
let store = {
let mut s = FoldStore::new();
s.insert(r(10, 30));
s
};
assert!(store.containing(10).is_empty(), "start is exclusive");
assert_eq!(store.containing(11), vec![r(10, 30)]);
assert_eq!(store.containing(30), vec![r(10, 30)], "end is inclusive");
assert!(store.containing(31).is_empty());
}
#[test]
fn containing_is_innermost_first() {
let mut store = FoldStore::new();
store.insert(r(10, 100)); // outer
store.insert(r(20, 60)); // inner
assert_eq!(store.containing(30), vec![r(20, 60), r(10, 100)]);
}
#[test]
fn unfold_containing_removes_all_nested() {
let mut store = FoldStore::new();
store.insert(r(10, 100));
store.insert(r(20, 60));
store.insert(r(200, 300)); // unrelated
assert_eq!(store.unfold_containing(30), 2);
assert_eq!(store.folds(), vec![r(200, 300)]);
}
#[test]
fn insert_rejects_empty_and_duplicate() {
let mut store = FoldStore::new();
assert!(store.insert(r(10, 30)));
assert!(!store.insert(r(10, 30)), "duplicate rejected");
assert!(!store.insert(r(5, 5)), "empty rejected");
assert!(!store.insert(r(9, 8)), "inverted rejected");
}
#[test]
fn open_at_takes_outermost_closed() {
let mut store = FoldStore::new();
store.insert(r(10, 100));
store.insert(r(20, 60));
assert_eq!(open_at(&mut store, 30), Some(r(10, 100)));
assert_eq!(open_at(&mut store, 30), Some(r(20, 60)));
assert_eq!(open_at(&mut store, 30), None);
}
#[test]
fn line_content_end_excludes_newline() {
let src = b"abc\ndef\nghi";
let off = compute_line_offsets(src);
assert_eq!(line_content_end(src, &off, 0), 3); // "abc"
assert_eq!(line_content_end(src, &off, 1), 7); // "def"
assert_eq!(line_content_end(src, &off, 2), 11); // "ghi" (no newline)
}
#[test]
fn normalize_arbitrary_range_basic() {
// 0123 4567 89012
let src = b"aaa\nbbb\nccc\nddd";
// range covering into line 1 and line 2 -> hidden lines 1..2
let out = normalize_arbitrary_range(src, r(1, 9)).expect("foldable");
assert_eq!(out, r(3, 11)); // [end of line0, end of line2]
}
#[test]
fn normalize_arbitrary_range_end_at_line_start_drops_a_line() {
let src = b"aaa\nbbb\nccc\nddd";
// end exactly at start of line 2 (byte 8) -> last hidden line is 1.
let out = normalize_arbitrary_range(src, r(1, 8)).expect("foldable");
assert_eq!(out, r(3, 7));
}
#[test]
fn normalize_arbitrary_range_rejects_sub_one_line() {
let src = b"aaa\nbbb\nccc";
// start and end on the same line -> zero hidden lines.
assert!(normalize_arbitrary_range(src, r(1, 2)).is_none());
}
}