850 lines
30 KiB
Rust
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());
|
|
}
|
|
}
|