// lua_bindings/fold.rs --- pmacs.fold: the code-folding Lua surface (Arc 6). //! `pmacs.fold.*` --- the Lua surface over [`crate::fold`]. Installed //! entirely from Rust (like `pmacs.config`), after `make_syntax_registry` //! so the tree-consuming operations can reach the parse tree via app-data. //! //! ```lua //! -- data API (explicit buffer, no ambient resolution): //! pmacs.fold.fold(buffer, { start = ..., ["end"] = ... }) //! pmacs.fold.unfold(buffer, { start = ..., ["end"] = ... }) //! pmacs.fold.folds(buffer) -- -> { {start=,["end"]=}, ... } //! pmacs.fold.toggle(buffer, pos) //! //! -- interactive helpers the fold.lua commands drive (explicit buffer + //! -- point resolved from the invoking frontend): //! pmacs.fold.close(buffer, pos) -- close innermost open //! pmacs.fold.open(buffer, pos) -- open outermost closed //! pmacs.fold.cycle(buffer, pos) -- org-TAB toggle //! pmacs.fold.close_all(buffer) -- top-level regions only //! pmacs.fold.open_all(buffer) //! ``` //! //! Fold *creation* refuses against an absent or stale parse tree (Q#FD10) //! and validates the buffer kind / UTF-8 boundaries / >= 1-hidden-line //! rule (Q#FD11); a rejection reports on the status line and returns //! `false`. Folding a range containing the invoking frontend's point moves //! that point to the head line (Q#FD3). use std::sync::{Arc, Mutex}; use mlua::{Lua, Table}; use pmacs_protocol::{BufferId, ByteRange}; use super::{ BufferIdLua, SharedCore, resolve, resolve_mut, u64_from_lua, with_registry, with_registry_mut, }; use crate::buffer::Buffer; use crate::fold::{self, FoldStore, SharedFoldRegistry}; use crate::syntax::{ParseTreeBundle, SharedSyntaxRegistry}; /// Install `pmacs.fold` over `fold_registry` (the same `Rc` the core owns). #[allow( clippy::too_many_lines, reason = "linear per-function registration of the pmacs.fold surface, \ mirroring install_config; splitting fragments the wiring" )] pub fn install_fold(lua: &Lua, fold_registry: &SharedFoldRegistry) -> mlua::Result<()> { let fold_mod = lua.create_table()?; // ---- data API --------------------------------------------------------- { let reg = fold_registry.clone(); fold_mod.set( "fold", lua.create_function( move |lua, (buf, range): (BufferIdLua, Table)| -> mlua::Result { let id = buf.id(); let requested = range_from_table(&range)?; let Some(bytes) = document_bytes(lua, id)? else { set_status(lua, "fold rejected: not a document buffer"); return Ok(false); }; if requested.start > bytes.len() as u64 || requested.end > bytes.len() as u64 || !is_char_boundary(&bytes, requested.start) || !is_char_boundary(&bytes, requested.end) { set_status(lua, "fold rejected: out of bounds or not a char boundary"); return Ok(false); } let Some(normalized) = fold::normalize_arbitrary_range(&bytes, requested) else { set_status(lua, "fold rejected: range hides no full line"); return Ok(false); }; let store = store_for(lua, ®, id)?; let added = lock(&store).insert(normalized); Ok(added) }, )?, )?; } { let reg = fold_registry.clone(); fold_mod.set( "unfold", lua.create_function( move |lua, (buf, range): (BufferIdLua, Table)| -> mlua::Result { let id = buf.id(); let requested = range_from_table(&range)?; let Some(store) = reg.store(id) else { return Ok(false); }; // Accept an exact stored range (the `folds()` round-trip) // or an arbitrary range that normalizes to a stored one. if lock(&store).remove(requested) { return Ok(true); } if let Ok(Some(bytes)) = document_bytes(lua, id) && let Some(normalized) = fold::normalize_arbitrary_range(&bytes, requested) { return Ok(lock(&store).remove(normalized)); } Ok(false) }, )?, )?; } { let reg = fold_registry.clone(); fold_mod.set( "folds", lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result { let out = lua.create_table()?; for (i, r) in reg.folds(buf.id()).into_iter().enumerate() { out.set(i + 1, range_to_table(lua, r)?)?; } Ok(out) })?, )?; } { let reg = fold_registry.clone(); fold_mod.set( "toggle", lua.create_function( move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { let id = buf.id(); let p = u64_from_lua(pos)?; // A stored fold at the point unfolds without needing a tree. if let Some(store) = reg.store(id) { let mut s = lock(&store); if !s.containing(p).is_empty() { s.unfold_containing(p); return Ok(true); } } let Some(bundle) = bundle_or_status(lua, id) else { return Ok(false); }; let store = store_for(lua, ®, id)?; match fold::toggle_at(&mut lock(&store), &bundle, p) { fold::ToggleOutcome::Folded(r) => { maybe_move_point(lua, id, r); Ok(true) } fold::ToggleOutcome::Unfolded(_) => Ok(true), fold::ToggleOutcome::Nothing => { set_status(lua, "nothing foldable here"); Ok(false) } } }, )?, )?; } // ---- interactive helpers (state-aware; driven by fold.lua) ------------ { let reg = fold_registry.clone(); fold_mod.set( "close", lua.create_function( move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { let id = buf.id(); let p = u64_from_lua(pos)?; let Some(bundle) = bundle_or_status(lua, id) else { return Ok(false); }; let store = store_for(lua, ®, id)?; if let Some(r) = fold::close_at(&mut lock(&store), &bundle, p) { maybe_move_point(lua, id, r); Ok(true) } else { set_status(lua, "no more folds to close here"); Ok(false) } }, )?, )?; } { let reg = fold_registry.clone(); fold_mod.set( "open", lua.create_function( move |_, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { let id = buf.id(); let p = u64_from_lua(pos)?; let Some(store) = reg.store(id) else { return Ok(false); }; Ok(fold::open_at(&mut lock(&store), p).is_some()) }, )?, )?; } { let reg = fold_registry.clone(); fold_mod.set( "cycle", lua.create_function( move |lua, (buf, pos): (BufferIdLua, i64)| -> mlua::Result { let id = buf.id(); let p = u64_from_lua(pos)?; let Some(bundle) = bundle_or_status(lua, id) else { return Ok(false); }; let store = store_for(lua, ®, id)?; match fold::cycle_at(&mut lock(&store), &bundle, p) { fold::CycleOutcome::Closed(r) => { maybe_move_point(lua, id, r); Ok(true) } fold::CycleOutcome::OpenedAll(_) => Ok(true), fold::CycleOutcome::Nothing => { set_status(lua, "nothing foldable here"); Ok(false) } } }, )?, )?; } { let reg = fold_registry.clone(); fold_mod.set( "close_all", lua.create_function(move |lua, buf: BufferIdLua| -> mlua::Result { let id = buf.id(); let Some(bundle) = bundle_or_status(lua, id) else { return Ok(0); }; let targets = fold::top_level_fold_targets(&bundle); let store = store_for(lua, ®, id)?; let mut s = lock(&store); let mut n = 0i64; for t in targets { if s.insert(t) { n += 1; } } Ok(n) })?, )?; } { let reg = fold_registry.clone(); fold_mod.set( "open_all", lua.create_function(move |_, buf: BufferIdLua| -> mlua::Result { match reg.store(buf.id()) { Some(store) => Ok(lock(&store).clear()), None => Ok(false), } })?, )?; } let pmacs: Table = lua.globals().get("pmacs")?; pmacs.set("fold", fold_mod)?; Ok(()) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn lock(store: &Arc>) -> std::sync::MutexGuard<'_, FoldStore> { store.lock().expect("fold store mutex poisoned") } fn range_from_table(t: &Table) -> mlua::Result { let start = u64_from_lua(t.raw_get::("start")?)?; let end = u64_from_lua(t.raw_get::("end")?)?; Ok(ByteRange { start, end }) } fn range_to_table(lua: &Lua, r: ByteRange) -> mlua::Result
{ let t = lua.create_table()?; // Byte offsets are always well within `i64` range; the Lua integer // type is `i64`. t.set("start", r.start.cast_signed())?; t.set("end", r.end.cast_signed())?; Ok(t) } /// The buffer's bytes if it is a normal document buffer, or `None` if it is /// read-only (a terminal identity buffer or other non-document buffer — /// the Q#FD11 "normal document buffer" guard). fn document_bytes(lua: &Lua, buf: BufferId) -> mlua::Result>> { with_registry(lua, |r| { let buffer = resolve(r, buf)?; if buffer.is_read_only() { return Ok(None); } Ok(Some(buffer_bytes(buffer))) }) } fn buffer_bytes(buf: &Buffer) -> Vec { let len = buf.len(); let mut bytes = vec![0u8; len as usize]; buf.snapshot_rope().slice(0, len, &mut bytes); bytes } fn is_char_boundary(bytes: &[u8], pos: u64) -> bool { let p = pos as usize; p == 0 || p == bytes.len() || (p < bytes.len() && (bytes[p] & 0xC0) != 0x80) } /// The get-or-attach store handle for `buf`, materializing the store and /// attaching its translator view on first use. fn store_for( lua: &Lua, reg: &SharedFoldRegistry, buf: BufferId, ) -> mlua::Result>> { with_registry_mut(lua, |r| { let buffer = resolve_mut(r, buf)?; Ok(reg.store_or_attach(buffer)) }) } /// The settled parse bundle for `buf`, or `None` after reporting the /// stale/absent-tree rejection on the status line (Q#FD10). fn bundle_or_status(lua: &Lua, buf: BufferId) -> Option> { match settled_bundle(lua, buf) { Ok(bundle) => Some(bundle), Err(reason) => { set_status(lua, reason); None } } } fn settled_bundle(lua: &Lua, buf: BufferId) -> Result, &'static str> { let syntax = lua .app_data_ref::() .ok_or("fold: no syntax registry")?; let handle = syntax.view(buf).ok_or("fold: no parse for this buffer")?; if handle.pending_edit_count() > 0 { return Err("fold: parse is stale (edits pending); try again"); } handle.current().ok_or("fold: no parse yet; try again") } /// Set the editor status line (rejection reporting). fn set_status(lua: &Lua, msg: &str) { if let Some(core) = lua.app_data_ref::() { core.borrow_mut().status = msg.to_string(); } } /// Move the invoking frontend's point to the head line when a just-folded /// range `r` contains it (Q#FD3). No-op if the folded buffer is not the /// active one or the point is outside the fold. fn maybe_move_point(lua: &Lua, buf: BufferId, r: ByteRange) { if let Some(core) = lua.app_data_ref::() { let mut c = core.borrow_mut(); if c.active_buffer_id() == buf { let point = c.active_window().cursor; // `(start, end]` containment: a point strictly inside the fold // moves to `start` (the end of the visible head line). if r.start < point && point <= r.end { c.set_cursor_byte(r.start); } } } }