pmacs/src/editor_core.rs

3850 lines
144 KiB
Rust

// editor_core.rs --- Mutable world state shared between Rust and Lua.
//! [`EditorCore`] is the editor's world state: a buffer registry, a
//! window tree, a focused window, file metadata, and the
//! minibuffer. Lives behind a `Rc<RefCell<...>>` so the Lua-bound
//! primitives in [`crate::lua_bindings`] (`pmacs.editor.*`,
//! `pmacs.window.*`) can mutate it from inside command bodies
//! invoked through [`crate::lua::LuaHost::invoke_command`].
//!
//! # Window model (T M2.8)
//!
//! Buffers live in [`BufferRegistry`]. Each [`Window`] points at one
//! by [`BufferId`] and owns its own cursor / view-top / goal-column /
//! [`TextView`]. The [`Layout`] tree maps the cell grid to per-window
//! viewport rectangles. A single [`WindowId`] is "active": every
//! `pmacs.editor.*` primitive operates on it; cursor and edits in
//! the run loop dispatch through it.
//!
//! When the active buffer mutates, [`EditorCore::apply_active_edit`]
//! notifies *every* window whose `buffer_id` matches the active
//! window's --- two windows on the same buffer keep their layout
//! caches synchronized.
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::file_io::{FileMeta, save_atomic};
use crate::lua_bindings::SharedRegistry;
use crate::minibuffer::Minibuffer;
use crate::protocol::FrontendId;
use crate::rope::Edit;
use crate::rope::{Position, Range};
use crate::text_view::TextView;
use crate::view::{DisplayCoord, View};
use crate::window::{FrontendView, Layout, Orientation, Window, WindowId};
/// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op.
///
/// Records **whether the originating frontend already applied the
/// op to its local mirror**, which determines whether the broadcast
/// sweep should exclude that frontend.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum CrdtOpOrigin {
/// A replica frontend's `FrontendEvent::CrdtOp` path applied the
/// op to its local mirror before sending. Broadcast must exclude
/// that frontend (it would double-apply otherwise — see
/// `BufferMirror::apply_local_insert` /
/// `apply_local_delete` and `optimistic::apply_incoming_crdt_op`'s
/// echo-skip rule).
OptimisticReplica(FrontendId),
/// Daemon-side mutation (a `FrontendEvent::Key` round-trip, a
/// Lua-driven edit, a fallback path) generated the op. No
/// frontend has applied it locally; broadcast to every replica
/// frontend, including the one whose `Key` event drove the
/// daemon path (its mirror is otherwise stale).
DaemonKey,
}
/// Live state of an in-progress incremental search (Q#SR5).
///
/// Present only while an isearch is running (`EditorCore::search`);
/// `None` otherwise. Holds the query as typed so far plus the cursor
/// origin to restore on cancel. The *matches* themselves live in
/// [`crate::search::SearchStore`] (shared with the decorations
/// producer and the TUI overlay); this struct is the per-session
/// input state that drives `find_all`.
#[derive(Clone, Debug)]
pub struct SearchSession {
/// The query as typed so far. Each edit re-runs `find_all`.
query: String,
/// Buffer + cursor position when the search began. `C-g` / `Esc`
/// restores this; `RET` keeps the current (match) cursor. The
/// buffer id also anchors `find_all` to the buffer the search
/// started in.
origin: (BufferId, Position),
/// Direction of the most recent step/begin. `true` = forward.
/// Drives the prompt label ("I-search" vs "I-search backward")
/// and the wrap direction of an empty-query repeat.
forward: bool,
/// Whether the query is a regex (Q#RX3). `false` = smart-case
/// substring (`find_all`); `true` = smart-case regex
/// (`find_all_regex`). Toggled live by `M-r`.
regex: bool,
/// `true` when the last recompute's regex pattern failed to compile
/// — the prompt shows `[invalid]` instead of a match count. Always
/// `false` in literal mode (substring never "fails to compile").
invalid: bool,
}
/// Live state of an in-progress query-replace (Arc 2, Q#QR1).
///
/// Present only while a query-replace's interactive phase is running
/// (`EditorCore::query_replace`); `None` otherwise. Unlike
/// [`SearchSession`], the buffer is usually already mutated by the
/// time this ends, so `origin` is used *only* for the nothing-matched
/// restore (Q#QR10); every other exit leaves point at the inspected
/// match. Matching runs forward from `next_from` on the *live* buffer
/// (Q#QR2), so offset shifts and never-re-matching-replacements fall
/// out for free.
#[derive(Clone, Debug)]
pub struct QueryReplaceSession {
/// The literal substring or regex source being replaced.
from: String,
/// The replacement text (may be empty — Q#QR3 deletion).
to: String,
/// Compiled regex engine when in regex mode (Q#QR9), cached for
/// the whole run so `!` stays linear; `None` = smart-case literal.
re: Option<regex::bytes::Regex>,
/// Buffer + cursor when the session began. Restored on cancel
/// *only* when nothing ever matched (Q#QR10).
origin: (BufferId, Position),
/// Byte offset the next forward search starts from — advanced past
/// each replacement so inserted text is never re-matched.
next_from: Position,
/// The match currently being prompted, or `None` before the first
/// advance / after finishing.
current: Option<crate::protocol::ByteRange>,
/// Number of replacements applied so far.
replaced: usize,
/// Whether any match was ever found (distinguishes "nothing
/// matched → restore origin" from "matched, then quit").
found_any: bool,
}
/// The world state mutated by editor commands.
pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
/// of every buffer; windows reference buffers by [`BufferId`].
pub registry: SharedRegistry,
/// 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
/// `Layout`.
pub windows: BTreeMap<WindowId, Window>,
/// T M10.8 — per-frontend views. Each attached frontend has its
/// own `Layout` (split tree) + `active: WindowId`. Buffers are
/// shared via `registry`; cursors / `view_top`s live in the
/// per-frontend `Window` instances.
///
/// Invariant: `FrontendId::LOCAL` always has an entry. The
/// in-process editor uses this view; daemon-attached frontends
/// register additional entries on attach (M10.8 Day 3 wires
/// the per-attach registration via the dispatcher; Day 2 ships
/// a fallback-to-LOCAL accessor so single-frontend tests pass
/// before per-attach registration lands).
pub views: HashMap<FrontendId, FrontendView>,
/// One-line message shown in the status line.
pub status: String,
/// True iff the editor should exit at the next iteration.
pub quit: bool,
/// Universal minibuffer (T M2.7).
pub minibuffer: Minibuffer,
/// The frontend that produced the most recent input event
/// dispatched to this core (T M5.4). v0.1 has a single frontend
/// per instance, so this stays at [`FrontendId::LOCAL`] in
/// practice; the field is load-bearing for v0.3 multi-frontend
/// (multi-window, multi-user) where each input event must be
/// attributable to its source frontend.
pub active_frontend: FrontendId,
/// T M10.8 Day 4 — pending CRDT ops queue.
///
/// Each [`CrdtOpOrigin`] entry records both **what** to broadcast
/// and **who already applied it locally** (the sender-exclusion
/// signal). The dispatcher drains the queue per-tick and
/// broadcasts each op to multi-frontend sessions with
/// `crdt_replica` negotiated.
///
/// # M10.10 post-audit-round-3 F16: origin tagging
///
/// Sender exclusion depends on **whether the originating
/// frontend already applied the op to its local mirror**:
///
/// - [`CrdtOpOrigin::OptimisticReplica`] — a replica frontend's
/// `FrontendEvent::CrdtOp` path applied the op to its mirror
/// before sending. Broadcast must exclude that frontend so it
/// doesn't double-apply.
/// - [`CrdtOpOrigin::DaemonKey`] — daemon-side mutation (a
/// `FrontendEvent::Key` round-trip, a Lua-driven edit, etc.)
/// generated the op. No frontend's mirror has applied it
/// locally; broadcast must include every replica frontend
/// *including* the active one. Without this, the
/// active frontend's mirror would silently drift from daemon
/// state after every fallback / Key-path edit.
pub pending_crdt_ops: Vec<(CrdtOpOrigin, BufferId, crate::rope::CrdtOp)>,
/// T M4.5 L1 — bounded jump ring. Cross-file navigation
/// (`go-to-definition`, references, symbol jumps) pushes the
/// pre-jump `(BufferId, Position)` here before moving the cursor;
/// `M-,` (`jump_back`) pops the most recent entry and restores
/// it. Bounded at [`Self::JUMP_RING_CAP`]: the oldest entry is
/// evicted when full, so a long navigation session can't grow
/// this without limit. Entries naming a now-removed buffer are
/// skipped on pop (stale-handle safe, mirrors the registry's
/// `Missing` contract).
pub jump_ring: Vec<(BufferId, Position)>,
/// In-buffer incremental search store (Q#SR1). Per-buffer query +
/// matches + active index, written by the search session /
/// `search.*` commands and read by the decorations producer
/// ([`crate::semantic_render`]) and the TUI search overlay.
/// Cheaply cloneable (`Arc<Mutex>`); shared with both readers.
pub search_store: crate::search::SharedSearchStore,
/// Live incremental-search session (Q#SR5), or `None` when no
/// search is running. Frontend-agnostic: the TUI run loop and the
/// daemon's `FrontendEvent::Key` path both drive it through the
/// same `search_*` methods, so isearch behaves identically in the
/// terminal and GPU frontends. Only the *prompt surface* differs
/// (TUI bottom row vs GPU status band).
pub search: Option<SearchSession>,
/// In-core clipboard slot (Q#CM6) --- the bytes a paste inserts.
/// Written by copy/cut and by an inbound OS paste; read by paste.
/// The frontend-agnostic source of truth, so paste behaves
/// identically in the terminal and GPU frontends.
clipboard_slot: Vec<u8>,
/// One-shot outbound clipboard publish (Q#CM6). A copy/cut queues
/// `(originating frontend, bytes)`; the dispatcher drains it and
/// sends [`crate::protocol::InstanceSignal::Clipboard`] to that
/// frontend, which writes the OS clipboard (OSC 52 in the TUI,
/// `arboard` in the GPU). Drained per-tick like `pending_crdt_ops`.
pending_clipboard: Option<(FrontendId, Vec<u8>)>,
/// Open context menu (Q#CM1), or `None` when closed. Shared
/// `Arc<Mutex>` so the TUI [`crate::menu::MenuView`] overlay renders
/// from the same state the dispatch path mutates — the menu twin of
/// `search_store`.
pub menu: crate::menu::SharedMenu,
/// Open in-buffer completion popup (Arc 1a, Q#C2), or `None` when
/// closed. Shared `Arc<Mutex>` so the TUI
/// [`crate::completion::CompletionView`] overlay renders from the
/// same state the dispatch path navigates and the Lua driver
/// publishes into — the completion twin of `menu`.
pub completion_popup: crate::completion::SharedCompletionPopup,
/// Buffers whose input must round-trip (Arc 1b, Q#P6). While one
/// of these is the active buffer,
/// [`crate::editor::EditorState::dispatch_idle`] reports `false`,
/// so semantic frontends' optimistic-apply stays off: RET reaches
/// buffer-local bindings (a panel's visit) instead of locally
/// inserting `\n`, and plain typing dispatches into the edit path
/// where a read-only intercept can reject it — a CRDT-import
/// write would bypass the intercept chain entirely. Marked from
/// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill.
round_trip_buffers: std::collections::HashSet<BufferId>,
/// Live query-replace interactive session (Arc 2), or `None`. The
/// query-replace twin of `search`; drives the fifth dispatcher
/// shadow.
query_replace: Option<QueryReplaceSession>,
}
impl EditorCore {
/// A fresh core with one window on a `*scratch*` buffer.
#[must_use]
pub fn new(registry: SharedRegistry) -> Self {
let buffer_id = registry.borrow_mut().create("*scratch*");
let text_view = {
let r = registry.borrow();
let buf = r.get(buffer_id).expect("just-created scratch buffer");
TextView::new(buf)
};
let id = WindowId::next();
let window = Window::new(id, buffer_id, text_view);
let mut windows = BTreeMap::new();
windows.insert(id, window);
let mut views = HashMap::new();
views.insert(
FrontendId::LOCAL,
FrontendView {
layout: Layout::single(id),
active: id,
},
);
Self {
registry,
windows,
views,
status: String::new(),
quit: false,
minibuffer: Minibuffer::new(),
active_frontend: FrontendId::LOCAL,
pending_crdt_ops: Vec::new(),
jump_ring: Vec::new(),
search_store: crate::search::make_shared_store(),
search: None,
clipboard_slot: Vec::new(),
pending_clipboard: None,
menu: crate::menu::make_shared_menu(),
completion_popup: crate::completion::make_shared_popup(),
round_trip_buffers: std::collections::HashSet::new(),
query_replace: None,
}
}
/// Build a core from raw bytes under `name`. Used by tests.
/// Replaces the scratch buffer's content; the active window is
/// retained.
#[must_use]
pub fn from_bytes(registry: SharedRegistry, name: impl Into<String>, bytes: &[u8]) -> Self {
let mut core = Self::new(registry);
let id = core.active_window().buffer_id;
let new_id = {
let mut reg = core.registry.borrow_mut();
let new_id = reg.create_from_bytes(name, bytes);
// Replace the active window's buffer with the new one.
let _ = reg.remove(id);
new_id
};
let text_view = {
let reg = core.registry.borrow();
TextView::new(reg.get(new_id).unwrap())
};
let aw = core.active_window_mut();
aw.buffer_id = new_id;
aw.text_view = text_view;
aw.cursor = 0;
aw.view_top = 0;
aw.goal_col = None;
core
}
// ---- accessors ---------------------------------------------------------
/// T M10.8 — the active frontend's view (layout + active window).
///
/// **Day 2 transitional behavior**: if `active_frontend` has no
/// registered view (the daemon-attached frontend case before Day
/// 3's dispatcher refactor wires `register_frontend_view`), fall
/// back to `FrontendId::LOCAL`'s view. The invariant "LOCAL
/// always has a view" is enforced by the constructor.
#[must_use]
pub fn active_view(&self) -> &FrontendView {
self.views.get(&self.active_frontend).unwrap_or_else(|| {
self.views.get(&FrontendId::LOCAL).expect(
"invariant: FrontendId::LOCAL always has a registered FrontendView; \
populated by EditorCore::new and never removed",
)
})
}
/// Mutable view of the active frontend's [`FrontendView`].
///
/// Same fallback semantics as [`active_view`].
pub fn active_view_mut(&mut self) -> &mut FrontendView {
// Choose the key first to avoid borrowing `self.views`
// twice with overlapping lifetimes (the fallback path).
let key = if self.views.contains_key(&self.active_frontend) {
self.active_frontend
} else {
FrontendId::LOCAL
};
self.views.get_mut(&key).expect(
"invariant: FrontendId::LOCAL always has a registered FrontendView; \
populated by EditorCore::new and never removed",
)
}
/// The active frontend's window-split tree.
#[must_use]
pub fn active_layout(&self) -> &Layout {
&self.active_view().layout
}
/// Mutable access to the active frontend's window-split tree.
pub fn active_layout_mut(&mut self) -> &mut Layout {
&mut self.active_view_mut().layout
}
/// `WindowId` of the active frontend's focused window.
#[must_use]
pub fn active_window_id(&self) -> WindowId {
self.active_view().active
}
/// Set the active frontend's focused window.
pub fn set_active_window_id(&mut self, id: WindowId) {
self.active_view_mut().active = id;
}
/// Reference the active [`Window`] — the window currently
/// focused in the active frontend's view.
#[must_use]
pub fn active_window(&self) -> &Window {
let id = self.active_window_id();
self.windows
.get(&id)
.expect("active window present in core.windows")
}
/// Mutably reference the active [`Window`].
pub fn active_window_mut(&mut self) -> &mut Window {
let id = self.active_window_id();
self.windows
.get_mut(&id)
.expect("active window present in core.windows")
}
/// Reference a specific frontend's active [`Window`].
///
/// Returns `None` if `fid` has no registered view (no fallback —
/// callers explicitly asking about a specific frontend get a
/// truthful answer about whether that frontend has state).
#[must_use]
pub fn active_window_for(&self, fid: FrontendId) -> Option<&Window> {
let view = self.views.get(&fid)?;
self.windows.get(&view.active)
}
/// Mutably reference a specific frontend's active [`Window`].
pub fn active_window_mut_for(&mut self, fid: FrontendId) -> Option<&mut Window> {
let win_id = self.views.get(&fid)?.active;
self.windows.get_mut(&win_id)
}
/// T M10.8 — register a `FrontendView` for `fid`. Called by the
/// daemon on attach (Day 3 dispatcher work). Day 2's fallback
/// path makes this optional; Day 3 makes it required.
pub fn register_frontend_view(&mut self, fid: FrontendId, view: FrontendView) {
self.views.insert(fid, view);
}
/// T M10.8 — drop a frontend's view on detach. The frontend's
/// windows remain in `self.windows` until explicit cleanup (M10.x
/// may add per-detach window pruning); for M10.8 they're
/// orphaned but accessible by id (matches v0.1 behavior where
/// closing a window left others intact).
pub fn unregister_frontend_view(&mut self, fid: FrontendId) {
self.views.remove(&fid);
}
/// [`BufferId`] of the active window's buffer.
#[must_use]
pub fn active_buffer_id(&self) -> BufferId {
self.active_window().buffer_id
}
/// Path bound to the active window's buffer, if any. T M4.5 L1:
/// replaces the old `EditorCore.file_path` field — it now lives
/// per-buffer so cross-file navigation keeps each buffer's
/// identity straight.
#[must_use]
pub fn active_buffer_path(&self) -> Option<PathBuf> {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.ok()
.and_then(|b| b.file_path().map(Path::to_path_buf))
}
/// Filesystem metadata recorded for the active window's buffer.
#[must_use]
pub fn active_file_meta(&self) -> Option<FileMeta> {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.ok()
.and_then(|b| b.file_meta().cloned())
}
/// Bind a path (and clear metadata) on a specific buffer. Used by
/// file open / `pmacs.buffer.from_file`.
///
/// The path is normalized to an absolute, lexically-clean form
/// first ([`normalize_buffer_path`]). This is the single seam
/// every buffer identity flows through (CLI open, Lua find-file,
/// `WorkspaceEdit` rename ops), so doing it here keeps the invariant
/// "a buffer's `file_path` is always absolute" — which the LSP
/// layer relies on to build a resolvable `file:///…` URI (a
/// relative or `~`-prefixed path produced `file://ipc.cpp`, which
/// clangd rejected with `-32602 unresolvable URI`) and which
/// cross-file navigation relies on for buffer-identity matching.
pub fn set_buffer_path(&mut self, id: BufferId, path: Option<PathBuf>) {
let path = path.map(normalize_buffer_path);
if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
b.set_file_path(path);
}
}
/// Record filesystem metadata on a specific buffer.
pub fn set_buffer_meta(&mut self, id: BufferId, meta: Option<FileMeta>) {
if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
b.set_file_meta(meta);
}
}
/// Cursor of the active window (compatibility shim for callers
/// migrated from pre-M2.8 code).
#[must_use]
pub fn cursor(&self) -> Position {
self.active_window().cursor
}
/// `view_top` of the active window.
#[must_use]
pub fn view_top(&self) -> usize {
self.active_window().view_top
}
/// Active buffer's byte length.
#[must_use]
pub fn active_buffer_len(&self) -> u64 {
let id = self.active_buffer_id();
self.registry.borrow().get(id).map_or(0, Buffer::len)
}
/// Active buffer's name. Returns an owned String to release the
/// registry borrow promptly.
#[must_use]
pub fn active_buffer_name(&self) -> String {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.map(|b| b.name().to_owned())
.unwrap_or_default()
}
/// Returns true iff the active buffer has unsaved modifications.
#[must_use]
pub fn active_buffer_is_modified(&self) -> bool {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.is_ok_and(Buffer::is_modified)
}
/// 0-based line index containing the active window's cursor.
#[must_use]
pub fn cursor_line(&self) -> usize {
let aw = self.active_window();
aw.text_view.line_at_offset(aw.cursor)
}
/// Move the active window's cursor to the start of a 0-based line.
/// Out-of-range line numbers clamp to the last line.
pub fn move_to_line(&mut self, line: usize) {
let line_count = self.active_window().text_view.line_count().max(1);
let target_line = line.min(line_count - 1);
let target = self
.active_window()
.text_view
.line_offset(target_line)
.unwrap_or_else(|| self.active_buffer_len());
let aw = self.active_window_mut();
aw.cursor = target;
aw.goal_col = None;
}
// ---- jump ring (T M4.5 L1) ---------------------------------------------
/// Bound on [`Self::jump_ring`]. Large enough for a deep
/// cross-file dig (definition → definition → references …),
/// small enough that a stuck loop can't grow memory unbounded.
pub const JUMP_RING_CAP: usize = 64;
/// Record the active window's current `(buffer, cursor)` as a
/// jump origin. Call this *before* moving the cursor on a
/// navigation action (go-to-definition, references, symbol jump)
/// so `M-,` can return here.
///
/// When the ring is at [`Self::JUMP_RING_CAP`], the oldest
/// origin is evicted (front drop) — the user keeps the most
/// recent trail, which is the one they're likely to unwind.
pub fn push_jump(&mut self) {
let entry = (self.active_buffer_id(), self.cursor());
if self.jump_ring.len() >= Self::JUMP_RING_CAP {
self.jump_ring.remove(0);
}
self.jump_ring.push(entry);
}
/// Pop the most recent jump origin and move there. Returns
/// `true` if a jump was performed.
///
/// Stale entries — a recorded buffer that has since been removed
/// from the registry — are skipped (the loop keeps popping until
/// it finds a live target or the ring empties), so a jump-back
/// never lands on a missing buffer. The restored cursor is
/// clamped to the (possibly now shorter) buffer length.
pub fn jump_back(&mut self) -> bool {
while let Some((bid, pos)) = self.jump_ring.pop() {
if !self.registry.borrow().contains(bid) {
continue;
}
if self.active_buffer_id() != bid && self.switch_active_buffer(bid).is_err() {
continue;
}
let clamped = pos.min(self.active_buffer_len());
let aw = self.active_window_mut();
aw.cursor = clamped;
aw.goal_col = None;
return true;
}
false
}
// ---- incremental search (Q#SR5) ----------------------------------------
//
// Frontend-agnostic isearch driven entirely through these methods.
// The TUI's `dispatch_search_key` and (later) the GPU's round-tripped
// keystrokes both call into here, so search behaves identically in
// both frontends. Matches live in `search_store` (shared with the
// decorations producer and the TUI overlay); `search` holds the
// live query + origin.
/// `true` iff an incremental search is in progress.
#[must_use]
pub fn search_active(&self) -> bool {
self.search.is_some()
}
/// The current isearch query (empty when no search is running).
#[must_use]
pub fn search_query(&self) -> &str {
self.search.as_ref().map_or("", |s| s.query.as_str())
}
/// Direction of the active search (`true` = forward). Defaults to
/// forward when no search is running — callers should gate on
/// [`Self::search_active`] first.
#[must_use]
pub fn search_forward(&self) -> bool {
self.search.as_ref().is_none_or(|s| s.forward)
}
/// `true` iff the active search is in regex mode (Q#RX3). `false`
/// for literal substring, or when no search is running.
#[must_use]
pub fn search_is_regex(&self) -> bool {
self.search.as_ref().is_some_and(|s| s.regex)
}
/// `true` iff the active regex search's pattern failed to compile —
/// the prompt shows `[invalid]` rather than a match count. Always
/// `false` in literal mode / when no search is running.
#[must_use]
pub fn search_is_invalid(&self) -> bool {
self.search.as_ref().is_some_and(|s| s.invalid)
}
/// `(active_index, total)` for the active buffer's matches, for the
/// prompt's "n/m" readout. `active_index` is 0-based and `None`
/// when there are no matches.
#[must_use]
pub fn search_match_summary(&self) -> (Option<usize>, usize) {
let bid = self.active_buffer_id();
let guard = self
.search_store
.lock()
.expect("search store mutex poisoned");
guard
.for_buffer(bid)
.map_or((None, 0), |s| (s.active_index(), s.len()))
}
/// Begin an incremental search anchored at the active buffer +
/// cursor. `forward` sets the initial step direction; `regex`
/// selects regex (`true`) vs literal substring (`false`) matching.
/// A no-op if a search is already running (the entry chord is
/// intercepted while active, so this is only reached from an
/// inactive state — the guard is belt-and-suspenders).
pub fn search_begin(&mut self, forward: bool, regex: bool) {
if self.search.is_some() {
return;
}
let origin = (self.active_buffer_id(), self.cursor());
// Attach the TUI match-wash overlay to the active window (once)
// so matches highlight live as the query grows. It
// self-suppresses when the store has no matches / is stale, so
// leaving it attached across searches is safe. The GPU gets the
// same matches via SearchMatch decorations and never reads this.
self.ensure_search_overlay();
self.search = Some(SearchSession {
query: String::new(),
origin,
forward,
regex,
invalid: false,
});
}
/// Toggle the active search between literal and regex matching
/// (Q#RX3, `M-r`), re-running the current query in the new mode. A
/// no-op when no search is running.
pub fn search_toggle_regex(&mut self) {
let Some(session) = self.search.as_mut() else {
return;
};
session.regex = !session.regex;
self.search_recompute();
}
/// Ensure the active window carries a [`crate::search::SearchView`]
/// overlay, attaching one if absent (deduped by overlay kind). The
/// view reads the per-buffer [`Self::search_store`] keyed on the
/// rendered buffer, so one instance suffices per window.
fn ensure_search_overlay(&mut self) {
let store = self.search_store.clone();
let win = self.active_window_mut();
if !win.overlay_kinds().contains(&"search") {
win.push_overlay(Box::new(crate::search::SearchView::new(store)));
}
}
/// Append a character to the query and re-search.
pub fn search_input_char(&mut self, ch: char) {
let Some(session) = self.search.as_mut() else {
return;
};
session.query.push(ch);
self.search_recompute();
}
/// Drop the last character of the query and re-search. With an
/// empty query this is a no-op (the search stays open, empty).
pub fn search_backspace(&mut self) {
let Some(session) = self.search.as_mut() else {
return;
};
session.query.pop();
self.search_recompute();
}
/// Re-run `find_all` for the current query against the origin
/// buffer, refresh the store, and move the cursor to the match
/// nearest the origin (first match at/after the origin cursor,
/// wrapping). An empty query or no match anchors the cursor back
/// at the origin so a failing search never drifts the view.
fn search_recompute(&mut self) {
let Some(session) = self.search.as_ref() else {
return;
};
let bid = session.origin.0;
let origin_byte = session.origin.1;
let query = session.query.clone();
let regex = session.regex;
let bytes = self.buffer_bytes(bid);
// Regex: `None` ⇒ the pattern won't compile (mark invalid, drop
// matches). Literal substring never fails. An invalid pattern
// clears the store (no stale matches paint) and shows
// `[invalid]` via the prompt.
let (matches, invalid) = if regex {
match crate::search::find_all_regex(&bytes, &query) {
Some(m) => (m, false),
None => (Vec::new(), true),
}
} else {
(crate::search::find_all(&bytes, &query), false)
};
if let Some(session) = self.search.as_mut() {
session.invalid = invalid;
}
let focus = {
let mut guard = self
.search_store
.lock()
.expect("search store mutex poisoned");
guard.set(bid, query, matches);
guard.focus_from(bid, origin_byte)
};
let target = focus.map_or(origin_byte, |range| range.start);
self.search_place_cursor(target);
}
/// Step the active buffer's match focus forward/backward (wrapping)
/// and move the cursor to it. Operates on [`Self::search_store`]
/// directly, so it works both during a live session (C-s / C-r)
/// and after accept (a `search.next` navigation command). A no-op
/// when the active buffer has no matches.
pub fn search_step(&mut self, forward: bool) {
if let Some(session) = self.search.as_mut() {
session.forward = forward;
}
let bid = self.active_buffer_id();
let stepped = {
let mut guard = self
.search_store
.lock()
.expect("search store mutex poisoned");
guard.step(bid, forward)
};
if let Some(range) = stepped {
self.search_place_cursor(range.start);
}
}
/// End the active search. `accept` keeps the cursor at the current
/// match and leaves the matches in the store (so they stay
/// highlighted, and `search.next` can resume, until the next edit
/// marks them stale). Cancel restores the origin cursor and clears
/// the matches. A no-op when no search is running.
pub fn search_finish(&mut self, accept: bool) {
let Some(session) = self.search.take() else {
return;
};
if accept {
return;
}
let (bid, origin_byte) = session.origin;
if self.active_buffer_id() == bid {
self.search_place_cursor(origin_byte);
}
self.search_store
.lock()
.expect("search store mutex poisoned")
.clear(bid);
}
/// Move the active window's cursor to a byte offset (clamped to the
/// buffer extent), resetting the goal column. Shared by the search
/// motions.
fn search_place_cursor(&mut self, byte: u64) {
let clamped = byte.min(self.active_buffer_len());
let aw = self.active_window_mut();
aw.cursor = clamped;
aw.goal_col = None;
}
// ---- query-replace (Arc 2, Q#QR1-10) -----------------------------------
/// True while a query-replace interactive session is running (the
/// fifth dispatcher-shadow predicate; also drives `dispatch_idle`
/// and the modal-close guard).
#[must_use]
pub fn query_replace_active(&self) -> bool {
self.query_replace.is_some()
}
/// The buffer a running query-replace is pinned to, or `None`. The
/// dispatcher reads this so the `buffer.after-edit` revision compare
/// targets the *edited* buffer, not whichever is active.
#[must_use]
pub fn query_replace_origin_buffer(&self) -> Option<BufferId> {
self.query_replace.as_ref().map(|s| s.origin.0)
}
/// Query-replace's wrong-buffer guard. Every edit and cursor move it
/// makes goes through the *active* window/buffer, but the session is
/// pinned to the buffer it started in — and focus can drift
/// mid-session (a click into another split, a key from another
/// frontend). Before touching the buffer, verify the active buffer
/// is still the origin buffer; if not, **abort without editing** so
/// a match found in the origin buffer can never be applied to an
/// unrelated one. Returns `true` when it is safe to proceed.
fn query_replace_on_origin(&mut self) -> bool {
let Some(origin_bid) = self.query_replace.as_ref().map(|s| s.origin.0) else {
return false;
};
if self.active_buffer_id() == origin_bid {
return true;
}
// Focus moved off the origin buffer — abort, don't corrupt.
if let Some(session) = self.query_replace.take() {
self.search_store
.lock()
.expect("search store mutex poisoned")
.clear(session.origin.0);
}
self.status = "query-replace aborted: active buffer changed".into();
false
}
/// Begin a query-replace from the cursor forward (Q#QR8). `regex`
/// selects `query-replace-regexp` (Q#QR9). An invalid regex refuses
/// to start (Q#QR2). Immediately advances to (and prompts on) the
/// first match, or finishes with "No matches" when there are none.
pub fn query_replace_begin(&mut self, from: String, to: String, regex: bool) {
if self.query_replace.is_some() || from.is_empty() {
return;
}
let re = if regex {
let Some(re) = crate::search::compile_search_regex(&from) else {
self.status = format!("Invalid regex: {from}");
return;
};
Some(re)
} else {
None
};
let origin = (self.active_buffer_id(), self.cursor());
// Reuse the isearch match-wash overlay to highlight the current
// match in the TUI; the GPU gets it via SearchMatch decorations.
self.ensure_search_overlay();
self.query_replace = Some(QueryReplaceSession {
from,
to,
re,
origin,
next_from: origin.1,
current: None,
replaced: 0,
found_any: false,
});
self.query_replace_advance();
}
/// Find the next match at/after `next_from` on the live buffer. On
/// a hit: highlight it, reveal it (cursor to its start, Q#QR2), and
/// prompt. On a miss: finish (natural end / nothing-matched).
fn query_replace_advance(&mut self) {
let Some(session) = self.query_replace.as_ref() else {
return;
};
let bid = session.origin.0;
let bytes = self.buffer_bytes(bid);
let start = (session.next_from as usize).min(bytes.len());
let found = match &session.re {
Some(re) => crate::search::find_first_regex_from(&bytes, re, start),
None => crate::search::find_first_from(&bytes, &session.from, start),
};
let Some(range) = found else {
self.query_replace_finish();
return;
};
let from = session.from.clone();
if let Some(session) = self.query_replace.as_mut() {
session.current = Some(range);
session.found_any = true;
}
// Highlight just this match: a single-element store set renders
// it as SearchMatchActive in both frontends (Q#QR5).
{
let mut guard = self
.search_store
.lock()
.expect("search store mutex poisoned");
guard.set(bid, from, vec![range]);
}
self.search_place_cursor(range.start);
self.query_replace_set_prompt();
}
/// Set `core.status` to the per-match prompt (Q#QR4) — shown in
/// both frontends via the v15 `StatusFacts.message` band.
fn query_replace_set_prompt(&mut self) {
if let Some(session) = self.query_replace.as_ref() {
self.status = format!(
"Query replacing '{}' with '{}' (y/n, ! all, . last, q quit)",
session.from, session.to
);
}
}
/// Replace the current match with the to-string as a single edit
/// (Q#QR7), advancing `next_from` past the inserted text so it is
/// never re-matched (Q#QR2). Returns `true` when an edit was
/// applied. Does NOT advance to the next match — callers chain
/// `query_replace_advance` (or finish) as their flow needs.
fn query_replace_apply_current(&mut self) -> bool {
let Some(session) = self.query_replace.as_ref() else {
return false;
};
let Some(range) = session.current else {
return false;
};
let to = session.to.clone();
if let Err(e) = self.apply_active_edit(EditOp::Replace {
range: Range {
start: range.start,
end: range.end,
},
bytes: to.as_bytes(),
}) {
self.status = format!("query-replace: {e}");
return false;
}
let new_next = range.start + to.len() as u64;
if let Some(session) = self.query_replace.as_mut() {
session.next_from = new_next;
session.current = None;
session.replaced += 1;
}
self.search_place_cursor(new_next);
true
}
/// `y` / `SPC` — replace the current match, then advance to the next.
pub fn query_replace_replace(&mut self) {
if self.query_replace_on_origin() && self.query_replace_apply_current() {
self.query_replace_advance();
}
}
/// `n` / `DEL` — leave the current match, advance past it to the next.
pub fn query_replace_skip(&mut self) {
if !self.query_replace_on_origin() {
return;
}
if let Some(session) = self.query_replace.as_mut()
&& let Some(range) = session.current
{
session.next_from = range.end;
session.current = None;
}
self.query_replace_advance();
}
/// `!` — replace the current match and all remaining without
/// prompting, then finish (Q#QR6). One `after-edit` hook fires for
/// the batch (the dispatcher compares revision across the handler).
pub fn query_replace_all(&mut self) {
if !self.query_replace_on_origin() {
return;
}
while self.query_replace_apply_current() {
// Find the next match (mirrors advance's search, without the
// highlight/prompt work — we're not stopping to ask).
let Some(session) = self.query_replace.as_ref() else {
break;
};
let bid = session.origin.0;
let bytes = self.buffer_bytes(bid);
let start = (session.next_from as usize).min(bytes.len());
let found = match &session.re {
Some(re) => crate::search::find_first_regex_from(&bytes, re, start),
None => crate::search::find_first_from(&bytes, &session.from, start),
};
match found {
Some(range) => {
if let Some(session) = self.query_replace.as_mut() {
session.current = Some(range);
}
}
None => break,
}
}
self.query_replace_finish();
}
/// `.` — replace the current match, then finish (Q#QR6).
pub fn query_replace_replace_and_quit(&mut self) {
if !self.query_replace_on_origin() {
return;
}
self.query_replace_apply_current();
self.query_replace_finish();
}
/// End the session (Q#QR10): clear the highlight, restore the origin
/// cursor *only* if nothing ever matched, and set the count status.
/// Every other exit leaves point where the last step put it.
pub fn query_replace_finish(&mut self) {
let Some(session) = self.query_replace.take() else {
return;
};
let bid = session.origin.0;
self.search_store
.lock()
.expect("search store mutex poisoned")
.clear(bid);
if session.found_any {
let n = session.replaced;
self.status = format!("Replaced {n} occurrence{}", if n == 1 { "" } else { "s" });
} else {
if self.active_buffer_id() == bid {
self.search_place_cursor(session.origin.1);
}
self.status = format!("No matches for '{}'", session.from);
}
}
/// Snapshot a buffer's full byte content (empty if the id is
/// stale). O(1) rope snapshot + one copy; used to feed `find_all`.
fn buffer_bytes(&self, buffer_id: BufferId) -> Vec<u8> {
let reg = self.registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
return Vec::new();
};
let len = buf.len();
let mut out = vec![0u8; len as usize];
buf.snapshot_rope().slice(0, len, &mut out);
out
}
// ---- editing primitives ------------------------------------------------
/// Apply `op` to the active buffer; notify every window
/// displaying that buffer. Returns the new buffer length.
///
/// # Errors
///
/// Returns a stringified error on buffer or view failure.
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<u64, String> {
let buffer_id = self.active_buffer_id();
let mut reg = self.registry.borrow_mut();
let buffer = reg.get_mut(buffer_id).map_err(|e| e.to_string())?;
let edit = buffer.apply_edit(op).map_err(|e| e.to_string())?;
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, &edit);
}
}
}
// T M10.8 Day 4 — capture CRDT op (if the buffer was in
// CRDT mode and produced one) for the dispatcher to
// broadcast on the next tick.
//
// M10.10 post-audit-round-3 F16: this is the **daemon-side**
// mutation path (e.g. `FrontendEvent::Key` round-trip,
// Lua-driven edit, fallback). The source frontend's mirror
// has NOT applied this op locally; the queued origin is
// [`CrdtOpOrigin::DaemonKey`] so the broadcast sweep includes
// every replica (no sender exclusion).
if let Some(crdt_op) = edit.crdt_op.as_ref() {
self.pending_crdt_ops
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
// Search matches were computed against the pre-edit text, so
// their byte positions are now wrong. Mark the buffer's matches
// stale (M11.8): the producer / TUI overlay suppress them until
// a fresh search re-runs against the current content. No-op for
// a buffer with no search state. The headline isearch bet —
// "stale-after-edit linger" — is closed here.
self.search_store
.lock()
.expect("search store mutex poisoned")
.mark_stale(buffer_id);
Ok(edit.new_rope.len())
}
/// Notify every window displaying `buffer_id` that the buffer was
/// just edited externally — used by code paths that mutate a buffer
/// without going through [`Self::apply_active_edit`] (the most
/// notable one being [`crate::lua::LuaHost::append_to_errors_buffer`],
/// which writes to `*errors*` from inside Lua callbacks).
///
/// Without this notification, any window currently displaying the
/// edited buffer would keep a stale [`crate::text_view::TextView`]
/// line cache, causing later cursor motions to land at offsets the
/// view cannot map back to display coordinates.
pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, edit);
}
}
}
}
/// Force every window currently showing `buffer_id` to rebuild
/// its [`TextView`] from scratch.
///
/// Used by code paths that rewrite a buffer end-to-end without
/// emitting a useful [`Edit`] (the help renderer issues a
/// delete-all + insert pair on `*help*`; `*buffer-list*` is
/// regenerated from scratch on every C-x C-b). Calling
/// [`Self::notify_buffer_edit`] for each step works but is more
/// fiddly; rebuild is simpler and still O(buffer length) which is
/// what an end-to-end rewrite cost anyway.
///
/// Cursor and `view_top` are clamped to the new buffer extent so
/// they don't dangle past the end after a shrinking rewrite.
pub fn rebuild_views_for(&mut self, buffer_id: BufferId) {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
let len = buffer.len();
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
win.text_view = TextView::new(buffer);
if win.cursor > len {
win.cursor = len;
}
let max_top = win.text_view.line_count().saturating_sub(1);
if win.view_top > max_top {
win.view_top = max_top;
}
}
}
}
/// Save the active buffer to its backing file. Returns `true` on
/// successful write; `false` if no path is associated, the buffer
/// could not be read, or the atomic save failed. Callers (the
/// `buffer.save` Lua command) use the return value to gate
/// `buffer.after-save` firing.
pub fn save(&mut self) -> bool {
let id = self.active_buffer_id();
let Some(path) = self.active_buffer_path() else {
self.status = "no file (M1: open a file from argv)".into();
return false;
};
let len_and_bytes = {
let reg = self.registry.borrow();
let buffer = match reg.get(id) {
Ok(b) => b,
Err(e) => {
self.status = format!("save failed: {e}");
return false;
}
};
let len = buffer.len();
let mut content = vec![0u8; len as usize];
if len > 0 {
buffer.snapshot_rope().slice(0, len, &mut content);
}
(len, content)
};
let (_, content) = len_and_bytes;
match save_atomic(&path, &content) {
Ok(meta) => {
if let Ok(buf) = self.registry.borrow_mut().get_mut(id) {
buf.set_file_meta(Some(meta));
buf.mark_clean();
}
self.status = format!("saved {}", path.display());
true
}
Err(e) => {
self.status = format!("save failed: {e}");
false
}
}
}
/// Move the cursor by one codepoint to the left. No-op at start.
pub fn move_left(&mut self) {
let cursor = self.active_window().cursor;
if cursor == 0 {
self.active_window_mut().goal_col = None;
return;
}
let new = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
prev_codepoint(buffer, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor by one codepoint to the right. No-op at end.
pub fn move_right(&mut self) {
let cursor = self.active_window().cursor;
let new = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
if cursor >= buffer.len() {
cursor
} else {
next_codepoint(buffer, cursor)
}
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor up one line, preserving display column.
pub fn move_up(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let coord = self
.active_window()
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
if coord.row == 0 {
return;
}
let goal = goal_col.unwrap_or(coord.col);
let target = DisplayCoord::new(coord.row - 1, goal);
let new_pos = self
.active_window()
.text_view
.display_to_pos(buffer, target);
(goal, new_pos)
};
let (goal, new_pos) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
}
/// Move the cursor down one line, preserving display column.
pub fn move_down(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let coord = self
.active_window()
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
let next_row = coord.row + 1;
if (next_row as usize) >= self.active_window().text_view.line_count() {
return;
}
let goal = goal_col.unwrap_or(coord.col);
let target = DisplayCoord::new(next_row, goal);
let new_pos = self
.active_window()
.text_view
.display_to_pos(buffer, target);
(goal, new_pos)
};
let (goal, new_pos) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
}
/// Move to the start of the current line.
pub fn move_line_start(&mut self) {
let cursor = self.active_window().cursor;
let new = {
let aw = self.active_window();
let line = aw.text_view.line_at_offset(cursor);
aw.text_view.line_offset(line).unwrap_or(cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor forward by one word.
///
/// Skips runs of non-word characters, then a run of word
/// characters. Word characters are alphanumerics plus `_`, the
/// Emacs default. Multi-byte characters are handled correctly:
/// `is_word` runs after a full UTF-8 codepoint is decoded.
pub fn move_word_right(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
forward_word(buffer, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor backward by one word. Mirror of
/// [`Self::move_word_right`].
pub fn move_word_left(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
backward_word(buffer, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Select the word at the active cursor. Returns `false` when the
/// cursor is not on a word character.
pub fn select_word_at_cursor(&mut self) -> bool {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let range = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else {
return false;
};
word_range_at(buffer, cursor)
};
let Some((start, end)) = range else {
return false;
};
let aw = self.active_window_mut();
aw.selection = Some(crate::window::Selection { anchor: start });
aw.cursor = end;
aw.goal_col = None;
true
}
/// Select the whole line at the active cursor, trailing newline
/// included — the convention that makes consecutive triple-click
/// lines abut (Q#M4). The cursor lands at the selection end (the
/// start of the next line). No-op when the buffer is gone.
pub fn select_line_at_cursor(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let (start, end) = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else {
return;
};
let view = &self.active_window().text_view;
let line = view.line_at_offset(cursor);
let start = view.line_offset(line).unwrap_or(0);
let end = view.line_offset(line + 1).unwrap_or_else(|| buffer.len());
(start, end)
};
let aw = self.active_window_mut();
aw.selection = Some(crate::window::Selection { anchor: start });
aw.cursor = end;
aw.goal_col = None;
}
/// Move the cursor forward to the next paragraph break.
///
/// A paragraph break is a blank line (empty or whitespace-only).
/// If the cursor is currently in a paragraph, the cursor lands at
/// the start of the first blank line after it. If the cursor is
/// already on a blank line, blanks are skipped first, then the
/// next blank line is found. Lands at the end of the buffer when
/// there are no further paragraph breaks. Mirrors GNU Emacs's
/// (and Doom's) `forward-paragraph` semantics.
pub fn move_paragraph_down(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
forward_paragraph(buffer, &aw.text_view, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor backward to the previous paragraph break.
/// Mirror of [`Self::move_paragraph_down`].
pub fn move_paragraph_up(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
backward_paragraph(buffer, &aw.text_view, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor down by approximately one screenful, scrolling
/// `view_top` to match. The step is the active window's last
/// rendered viewport height minus one (so the user keeps a line
/// of context); falls back to a sane default before the first
/// frame has rendered.
pub fn move_page_down(&mut self) {
let step = self.page_step();
let cursor = self.active_window().cursor;
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
let max_line = aw.text_view.line_count().saturating_sub(1) as u32;
let goal_col = aw.goal_col.unwrap_or(coord.col);
let target_row = (coord.row + step).min(max_line);
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
(goal_col, new_pos, view_top.saturating_add(step as usize))
};
let (goal, new_pos, new_top) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
// Also nudge view_top; render's scroll-into-view will clamp
// and align further if needed.
let max_top = aw.text_view.line_count().saturating_sub(1);
aw.view_top = new_top.min(max_top);
}
/// Move the cursor up by approximately one screenful. Mirror of
/// [`Self::move_page_down`].
pub fn move_page_up(&mut self) {
let step = self.page_step();
let cursor = self.active_window().cursor;
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
let goal_col = aw.goal_col.unwrap_or(coord.col);
let target_row = coord.row.saturating_sub(step);
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
(goal_col, new_pos, view_top.saturating_sub(step as usize))
};
let (goal, new_pos, new_top) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
aw.view_top = new_top;
}
/// Number of lines a "page" advances. Uses the active window's
/// last rendered viewport height minus one (one line of context
/// at the seam, like Emacs's `next-screen-context-lines`),
/// clamped to a sensible default for headless tests where no
/// frame has rendered.
fn page_step(&self) -> u32 {
const DEFAULT_PAGE: u32 = 20;
let rows = self.active_window().last_visible_rows;
if rows >= 2 { rows - 1 } else { DEFAULT_PAGE }
}
/// Move to the end of the current line (before any trailing newline).
pub fn move_line_end(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
let line = aw.text_view.line_at_offset(cursor);
let Some(start) = aw.text_view.line_offset(line) else {
return;
};
let len = aw.text_view.line_len(buffer, line).unwrap_or(0);
start + len
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Insert a single character at the cursor.
pub fn insert_char(&mut self, ch: char) {
self.active_window_mut().goal_col = None;
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
let bytes = s.as_bytes();
let pos = self.active_window().cursor;
if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) {
self.status = format!("insert failed: {e}");
return;
}
self.active_window_mut().cursor += bytes.len() as u64;
}
/// CUA type-over: insert `ch`, replacing the active region if one
/// exists. With a region this is a *single* `EditOp::Replace` — one
/// undo step — rather than the former `delete_region()` +
/// `insert_char()` pair, which recorded two. With no region it
/// 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) {
let Some((lo, hi)) = self.active_region() else {
self.insert_char(ch);
return;
};
self.active_window_mut().goal_col = None;
let mut buf = [0u8; 4];
let bytes = ch.encode_utf8(&mut buf).as_bytes();
if let Err(e) = self.apply_active_edit(EditOp::Replace {
range: Range { start: lo, end: hi },
bytes,
}) {
self.status = format!("replace failed: {e}");
return;
}
let aw = self.active_window_mut();
aw.cursor = lo + bytes.len() as u64;
aw.selection = None;
}
/// Delete the codepoint immediately before the cursor.
pub fn backspace(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
if cursor == 0 {
return;
}
let prev = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
prev_codepoint(buffer, cursor)
};
let range = Range::new(prev, cursor);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
return;
}
self.active_window_mut().cursor = prev;
}
/// Delete the codepoint at the cursor (forward delete).
pub fn delete_forward(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
let id = self.active_buffer_id();
let next = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
if cursor >= buffer.len() {
return;
}
next_codepoint(buffer, cursor)
};
let range = Range::new(cursor, next);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
}
}
/// Delete from the cursor backward to the start of the previous
/// word. The CUA-style `Ctrl+Backspace`. No-op at start-of-buffer.
/// Mirrors [`Self::backspace`] but the deleted range is the gap
/// between the cursor and where [`Self::move_word_left`] would
/// land.
pub fn delete_word_backward(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
if cursor == 0 {
return;
}
let new = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
backward_word(buffer, cursor)
};
if new == cursor {
return;
}
let range = Range::new(new, cursor);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
return;
}
self.active_window_mut().cursor = new;
}
/// Delete from the cursor forward to the end of the next word. The
/// CUA-style `Ctrl+Delete`. No-op at end-of-buffer. Mirrors
/// [`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.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
let id = self.active_buffer_id();
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
if cursor >= buffer.len() {
return;
}
forward_word(buffer, cursor)
};
if new == cursor {
return;
}
let range = Range::new(cursor, new);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
}
}
/// Undo the most recent edit on the active buffer; clamp the
/// active window's cursor to the new length and notify all
/// windows on this buffer.
pub fn undo(&mut self) {
self.active_window_mut().goal_col = None;
let buffer_id = self.active_buffer_id();
let edit = {
let mut reg = self.registry.borrow_mut();
let Ok(buffer) = reg.get_mut(buffer_id) else {
return;
};
buffer.undo()
};
match edit {
Ok(edit) => {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
let max = buffer.len();
if win.cursor > max {
win.cursor = max;
}
}
}
drop(reg);
// Post-audit-round-5 F27: undo on a CRDT-backed
// buffer produces a crdt_op that must broadcast to
// every replica frontend (including the one whose
// command triggered the undo — its BufferMirror has
// no other way to converge with the post-undo state).
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
}
Err(_) => self.status = "nothing to undo".into(),
}
}
/// Redo the most recently undone edit on the active buffer.
pub fn redo(&mut self) {
self.active_window_mut().goal_col = None;
let buffer_id = self.active_buffer_id();
let edit = {
let mut reg = self.registry.borrow_mut();
let Ok(buffer) = reg.get_mut(buffer_id) else {
return;
};
buffer.redo()
};
match edit {
Ok(edit) => {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
let max = buffer.len();
if win.cursor > max {
win.cursor = max;
}
}
}
drop(reg);
// Post-audit-round-5 F27 — same as undo above.
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
}
Err(_) => self.status = "nothing to redo".into(),
}
}
/// T M10.10 post-audit-round-5 F27 + F28 — queue a CRDT op
/// produced by a daemon-origin edit (undo/redo via core, Lua
/// bindings, command pipeline) for broadcast.
///
/// Pushes into `pending_crdt_ops` with
/// [`CrdtOpOrigin::DaemonKey`] semantics: the broadcast sweep
/// includes every replica frontend (no sender exclusion). The
/// originating frontend's `BufferMirror` has not applied the op
/// locally — only the daemon's authoritative buffer has — so
/// the source's mirror needs the broadcast just like every
/// other replica.
///
/// No-op when the edit doesn't carry a `crdt_op` (the buffer
/// wasn't CRDT-backed at the time of the edit). Callers can
/// invoke this unconditionally after any daemon-origin
/// `apply_*` that returns an `Edit`; non-CRDT buffers pay no
/// cost beyond the early return.
pub fn queue_daemon_origin_crdt_op(&mut self, buffer_id: BufferId, edit: &Edit) {
if let Some(crdt_op) = edit.crdt_op.as_ref() {
self.pending_crdt_ops
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
}
// ---- window operations -------------------------------------------------
/// Split the active window. Returns the new window's id.
/// `same_buffer` controls whether the new window opens on the
/// active buffer (Emacs default) or a fresh `*scratch*` buffer.
pub fn split_active(&mut self, orientation: Orientation, same_buffer: bool) -> WindowId {
let active_buf = self.active_buffer_id();
let (buffer_id, text_view) = if same_buffer {
let reg = self.registry.borrow();
let buf = reg.get(active_buf).expect("active buffer present");
(active_buf, TextView::new(buf))
} else {
let mut reg = self.registry.borrow_mut();
let new_id = reg.create("*scratch*");
let buf = reg.get(new_id).unwrap();
(new_id, TextView::new(buf))
};
let new_id = WindowId::next();
let new_window = Window::new(new_id, buffer_id, text_view);
self.windows.insert(new_id, new_window);
let active = self.active_window_id();
self.active_layout_mut()
.split_window(active, orientation, new_id);
new_id
}
/// Move focus to the next window in iteration order.
pub fn focus_next(&mut self) {
let active = self.active_window_id();
let next = self.active_layout().focus_next(active);
self.set_active_window_id(next);
}
/// Move focus to the previous window in iteration order.
pub fn focus_prev(&mut self) {
let active = self.active_window_id();
let prev = self.active_layout().focus_prev(active);
self.set_active_window_id(prev);
}
/// Close the active window (unless it's the only one in this
/// frontend). Returns false if the active frontend's layout has a
/// single window.
pub fn close_active(&mut self) -> bool {
// Per-frontend: gate on the *active frontend's* window count, not
// the global `self.windows` set. Every attached frontend keeps its
// own windows in `self.windows`, so a global `<= 1` check let a
// multi-frontend session close a frontend's last window and then
// panic picking a successor from the now-empty layout.
if self.active_layout().iter_ids().len() <= 1 {
return false;
}
let target = self.active_window_id();
self.active_layout_mut().close_window(target);
self.windows.remove(&target);
// Pick an adjacent window as the new focus.
let next = *self
.active_layout()
.iter_ids()
.first()
.expect("at least one window remains");
self.set_active_window_id(next);
true
}
/// Close every window except the active one, *within the active
/// frontend*.
pub fn close_others(&mut self) {
// Per-frontend: only prune the active frontend's own layout. The
// global `self.windows` set holds every frontend's windows, so a
// global `retain(|id| id == keep)` deleted OTHER frontends'
// windows — leaving their `view.active` dangling and panicking the
// next `active_window()` (the multi-frontend close-others crash).
let keep = self.active_window_id();
let doomed: Vec<WindowId> = self
.active_layout()
.iter_ids()
.into_iter()
.filter(|id| *id != keep)
.collect();
self.active_layout_mut().keep_only(keep);
for id in doomed {
self.windows.remove(&id);
}
}
// ---- selection / region (T M2.12) --------------------------------------
/// Active region of the active window, as `(lo, hi)` byte
/// positions, or `None` if no region is set or it is empty.
#[must_use]
pub fn active_region(&self) -> Option<(Position, Position)> {
self.active_window().region()
}
/// Begin a selection at `anchor` on the active window.
pub fn begin_selection(&mut self, anchor: Position) {
self.active_window_mut().selection = Some(crate::window::Selection { anchor });
}
/// Drop any active selection on the active window.
pub fn clear_selection(&mut self) {
self.active_window_mut().selection = None;
}
/// Delete the active region (if any) from the active buffer and
/// move the cursor to the deletion's start. No-op if there is no
/// region. Returns the new buffer length.
///
/// # Errors
///
/// Returns the same stringified error shape as
/// [`Self::apply_active_edit`] if the underlying delete fails.
pub fn delete_region(&mut self) -> Result<u64, String> {
let Some((lo, hi)) = self.active_region() else {
return Ok(self.active_buffer_len());
};
let new_len = self.apply_active_edit(EditOp::Delete {
range: Range { start: lo, end: hi },
})?;
let aw = self.active_window_mut();
aw.cursor = lo;
aw.selection = None;
aw.goal_col = None;
Ok(new_len)
}
// ---- clipboard (Q#CM6) -------------------------------------------------
/// The identifier under (or immediately left of) the cursor, or
/// `None` when the cursor isn't on a word (Q#CM3, the `symbol`
/// context). A word is a run of ASCII alphanumerics / `_`; since
/// those are all single-byte, the slice always lands on UTF-8
/// boundaries.
#[must_use]
pub fn word_at_cursor(&self) -> Option<String> {
let bytes = self.buffer_bytes(self.active_buffer_id());
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let cursor = (self.cursor() as usize).min(bytes.len());
let mut start = cursor;
while start > 0 && is_word(bytes[start - 1]) {
start -= 1;
}
let mut end = cursor;
while end < bytes.len() && is_word(bytes[end]) {
end += 1;
}
if start == end {
return None;
}
String::from_utf8(bytes[start..end].to_vec()).ok()
}
/// Bytes of the active region, or `None` when nothing is selected.
#[must_use]
pub fn region_bytes(&self) -> Option<Vec<u8>> {
let (lo, hi) = self.active_region()?;
let reg = self.registry.borrow();
let buf = reg.get(self.active_buffer_id()).ok()?;
let mut out = vec![0u8; (hi - lo) as usize];
buf.snapshot_rope().slice(lo, hi, &mut out);
Some(out)
}
/// Copy the active region into the clipboard slot and queue an
/// outbound OS-clipboard publish to the originating frontend.
/// Returns `false` (a no-op) when there is no region.
pub fn clipboard_copy(&mut self) -> bool {
let Some(bytes) = self.region_bytes() else {
return false;
};
self.clipboard_slot.clone_from(&bytes);
self.pending_clipboard = Some((self.active_frontend, bytes));
true
}
/// Cut: copy the region, then delete it. Returns `false` (a no-op)
/// when there is no region.
///
/// # Errors
///
/// Propagates [`Self::delete_region`]'s error.
pub fn clipboard_cut(&mut self) -> Result<bool, String> {
if !self.clipboard_copy() {
return Ok(false);
}
self.delete_region()?;
Ok(true)
}
/// Paste the clipboard slot at the cursor, replacing the active
/// region if one exists (one undo step, like CUA type-over).
/// Returns `false` when the slot is empty.
///
/// # Errors
///
/// Propagates the underlying edit error.
pub fn clipboard_paste(&mut self) -> Result<bool, String> {
if self.clipboard_slot.is_empty() {
return Ok(false);
}
let bytes = self.clipboard_slot.clone();
self.insert_bytes_over_region(&bytes)?;
Ok(true)
}
/// Insert externally-pasted bytes at the cursor (inbound OS paste:
/// terminal bracketed paste, or GPU Ctrl-V via `arboard`),
/// refreshing the slot so a later in-app paste repeats them.
/// Replaces the active region if one exists.
///
/// # Errors
///
/// Propagates the underlying edit error.
pub fn paste_inbound(&mut self, data: &[u8]) -> Result<(), String> {
self.clipboard_slot = data.to_vec();
self.insert_bytes_over_region(data)
}
/// Shared insert/replace for paste: `Replace` over the active
/// region, else `Insert` at the cursor. The cursor lands just past
/// the inserted bytes and any selection is cleared. No-op insert for
/// empty `bytes`.
fn insert_bytes_over_region(&mut self, bytes: &[u8]) -> Result<(), String> {
self.active_window_mut().goal_col = None;
let start = if let Some((lo, hi)) = self.active_region() {
self.apply_active_edit(EditOp::Replace {
range: Range { start: lo, end: hi },
bytes,
})?;
lo
} else {
let pos = self.active_window().cursor;
self.apply_active_edit(EditOp::Insert { pos, bytes })?;
pos
};
let aw = self.active_window_mut();
aw.cursor = start + bytes.len() as u64;
aw.selection = None;
Ok(())
}
/// Select the whole active buffer (anchor at 0, cursor at the end).
pub fn select_all(&mut self) {
let len = self.active_buffer_len();
self.begin_selection(0);
let aw = self.active_window_mut();
aw.cursor = len;
aw.goal_col = None;
}
/// Drain the one-shot outbound clipboard publish, if any. Called by
/// the dispatcher each tick (alongside `pending_crdt_ops`).
pub fn take_pending_clipboard(&mut self) -> Option<(FrontendId, Vec<u8>)> {
self.pending_clipboard.take()
}
/// The current clipboard slot bytes (testing / introspection).
#[must_use]
pub fn clipboard_slot(&self) -> &[u8] {
&self.clipboard_slot
}
// ---- context menu (Q#CM1) ----------------------------------------------
/// True while a context menu is open.
#[must_use]
pub fn menu_is_open(&self) -> bool {
self.menu.lock().expect("menu mutex poisoned").is_some()
}
/// Open a menu of resolved `rows` anchored at the absolute `anchor`
/// cell. A no-op (stays closed) when no row is selectable. Attaches
/// the TUI overlay on first open (deduped by kind).
pub fn menu_open(&mut self, rows: Vec<crate::menu::MenuRow>, anchor: (u32, u32)) {
let state = crate::menu::MenuState::new(rows, anchor);
let opened = state.is_some();
*self.menu.lock().expect("menu mutex poisoned") = state;
if opened {
self.ensure_menu_overlay();
}
}
/// Close the menu (the overlay then self-suppresses).
pub fn menu_close(&mut self) {
*self.menu.lock().expect("menu mutex poisoned") = None;
}
/// Move the highlight by `delta` items (wrapping, skipping separators).
pub fn menu_step(&mut self, delta: isize) {
if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut() {
m.step(delta);
}
}
/// Set the highlight to `row` if it names a selectable item (mouse
/// hover / click).
pub fn menu_set_active_row(&mut self, row: usize) {
if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut()
&& matches!(m.rows.get(row), Some(crate::menu::MenuRow::Item { .. }))
{
m.active = row;
}
}
/// The active item's command name, if a menu is open.
#[must_use]
pub fn menu_active_command(&self) -> Option<String> {
self.menu
.lock()
.expect("menu mutex poisoned")
.as_ref()
.and_then(|m| m.active_command().map(str::to_owned))
}
/// Hit-test an absolute cell against the open popup, returning the
/// selectable row it covers (or `None`).
#[must_use]
pub fn menu_hit(&self, row: u32, col: u32) -> Option<usize> {
self.menu
.lock()
.expect("menu mutex poisoned")
.as_ref()
.and_then(|m| m.hit(row, col))
}
/// Ensure the active window carries a [`crate::menu::MenuView`]
/// overlay (deduped by kind). The view reads the shared `menu`, so
/// one instance suffices; it renders nothing while the menu is closed.
fn ensure_menu_overlay(&mut self) {
let menu = self.menu.clone();
let win = self.active_window_mut();
if !win.overlay_kinds().contains(&"context-menu") {
win.push_overlay(Box::new(crate::menu::MenuView::new(menu)));
}
}
// ---- in-buffer completion popup (Arc 1a, Q#C2/Q#C3) --------------------
/// True while the in-buffer completion popup is open.
#[must_use]
pub fn completion_popup_is_open(&self) -> bool {
self.completion_popup
.lock()
.expect("completion popup poisoned")
.is_some()
}
/// Open (or replace) the completion popup session. Attaches the
/// self-suppressing [`crate::completion::CompletionView`] overlay to
/// the active window on first use (deduped by kind, like the menu).
/// Emptiness is enforced upstream:
/// [`crate::completion::CompletionPopupState::new`] refuses to build
/// a candidate-less session.
pub fn completion_popup_open(&mut self, mut state: crate::completion::CompletionPopupState) {
// Stamp the owning window (Lua publishers don't know window
// identity): only that window's overlay paints the popup, and
// a focus change invalidates the session.
state.window_id = Some(self.active_window_id());
*self
.completion_popup
.lock()
.expect("completion popup poisoned") = Some(state);
self.ensure_completion_overlay();
}
/// Close the popup (the overlay then self-suppresses).
pub fn completion_popup_close(&mut self) {
*self
.completion_popup
.lock()
.expect("completion popup poisoned") = None;
}
/// Move the popup highlight by `delta` (wrapping).
pub fn completion_popup_step(&mut self, delta: isize) {
if let Some(p) = self
.completion_popup
.lock()
.expect("completion popup poisoned")
.as_mut()
{
p.step(delta);
}
}
/// Q#C3 session invariant: the popup only survives while the
/// active buffer still matches, the cursor sits at or after the
/// anchor, and every byte between them is a word byte (`[A-Za-z0-9_]`
/// --- the same ASCII word definition the Lua driver uses). A
/// trigger-character session (empty prefix, `cursor == anchor`)
/// holds trivially. Returns the `(anchor, cursor)` pair while the
/// invariant holds.
#[must_use]
fn completion_session_holds(&self) -> Option<(Position, Position)> {
/// Longest byte run still plausibly a completion prefix; past
/// this the session is stale, not a prefix.
const MAX_PREFIX_BYTES: u64 = 512;
let (buffer_id, window_id, anchor) = {
let guard = self
.completion_popup
.lock()
.expect("completion popup poisoned");
let p = guard.as_ref()?;
(p.buffer_id, p.window_id, p.anchor)
};
if window_id != Some(self.active_window_id()) {
return None; // focus moved to another window/split
}
if self.active_buffer_id() != buffer_id {
return None;
}
let cursor = self.active_window().cursor;
if cursor < anchor || cursor - anchor > MAX_PREFIX_BYTES {
return None;
}
let reg = self.registry.borrow();
let buffer = reg.get(buffer_id).ok()?;
if cursor > buffer.len() {
return None;
}
let mut bytes = vec![0u8; (cursor - anchor) as usize];
if !bytes.is_empty() {
buffer.snapshot_rope().slice(anchor, cursor, &mut bytes);
}
bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
.then_some((anchor, cursor))
}
/// Q#C3 post-dispatch validation: close the popup unless the
/// session invariant still holds. Called by the dispatcher after
/// every fallen-through key (motion, edits, buffer switches) and
/// cheap enough to call unconditionally --- a closed popup is a
/// single mutex peek.
pub fn completion_popup_validate(&mut self) {
if self.completion_popup_is_open() && self.completion_session_holds().is_none() {
self.completion_popup_close();
}
}
/// Q#C7 accept: re-validate the session at the moment of accept,
/// close the popup, and --- only when the invariant still holds ---
/// replace `[anchor .. cursor]` with the highlighted candidate's
/// insert text as a **single** edit (one undo step, mirroring
/// [`Self::insert_char_over_region`]). Returns `true` iff the
/// buffer was edited (the dispatcher fires `buffer.after-edit`
/// off that signal).
pub fn completion_popup_accept(&mut self) -> bool {
let holds = self.completion_session_holds();
let snap = {
let guard = self
.completion_popup
.lock()
.expect("completion popup poisoned");
guard
.as_ref()
.and_then(|p| p.selected_candidate().map(|c| c.insert_text.clone()))
};
self.completion_popup_close();
let (Some((anchor, cursor)), Some(text)) = (holds, snap) else {
return false;
};
self.active_window_mut().goal_col = None;
// An empty range degenerates to a plain insert (the
// trigger-character case, where nothing was typed yet).
let result = if cursor > anchor {
self.apply_active_edit(EditOp::Replace {
range: Range {
start: anchor,
end: cursor,
},
bytes: text.as_bytes(),
})
} else {
self.apply_active_edit(EditOp::Insert {
pos: anchor,
bytes: text.as_bytes(),
})
};
if let Err(e) = result {
self.status = format!("completion accept failed: {e}");
return false;
}
let aw = self.active_window_mut();
aw.cursor = anchor + text.len() as u64;
aw.selection = None;
true
}
// ---- round-trip input buffers (Arc 1b, Q#P6) ----------------------------
/// Mark (or unmark) `buffer_id` as requiring round-trip input.
/// See the field doc on `round_trip_buffers` for the semantics.
pub fn set_round_trip_input(&mut self, buffer_id: BufferId, on: bool) {
if on {
self.round_trip_buffers.insert(buffer_id);
} else {
self.round_trip_buffers.remove(&buffer_id);
}
}
/// True while the active buffer requires round-trip input (a
/// panel or other buffer-local-keymap surface is focused).
#[must_use]
pub fn active_buffer_round_trips(&self) -> bool {
self.round_trip_buffers.contains(&self.active_buffer_id())
}
/// Ensure the active window carries a
/// [`crate::completion::CompletionView`] overlay (deduped by kind).
/// The view reads the shared popup, so one instance suffices; it
/// renders nothing while the popup is closed.
fn ensure_completion_overlay(&mut self) {
let popup = self.completion_popup.clone();
let wid = self.active_window_id();
let win = self.active_window_mut();
if !win.overlay_kinds().contains(&"completion-popup") {
win.push_overlay(Box::new(crate::completion::CompletionView::new(popup, wid)));
}
}
/// Safely remove `buffer_id` from the registry. Any window that
/// was displaying it is redirected to a fallback buffer (`*scratch*`,
/// created on demand) so window state never refers to a missing id.
///
/// # Errors
///
/// Returns an error string when `buffer_id` is the only buffer in
/// the registry (the registry must remain non-empty), or when the
/// id doesn't resolve.
pub fn kill_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
{
let reg = self.registry.borrow();
if !reg.contains(buffer_id) {
return Err(format!("buffer {buffer_id:?} not found"));
}
if reg.len() <= 1 {
return Err("cannot kill the last remaining buffer".into());
}
}
self.round_trip_buffers.remove(&buffer_id);
let fallback = {
let mut reg = self.registry.borrow_mut();
match reg.find_by_name("*scratch*") {
Some(id) if id != buffer_id => id,
_ => {
let candidate = reg.ids().iter().copied().find(|id| *id != buffer_id);
match candidate {
Some(id) => id,
None => reg.create("*scratch*"),
}
}
}
};
{
let reg = self.registry.borrow();
let buf = reg.get(fallback).map_err(|e| e.to_string())?;
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
win.buffer_id = fallback;
win.text_view = TextView::new(buf);
win.overlays.clear();
win.cursor = 0;
win.selection = None;
win.view_top = 0;
win.goal_col = None;
}
}
}
self.registry
.borrow_mut()
.remove(buffer_id)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Switch the active window to a different buffer, allocating a
/// fresh [`TextView`] for it.
pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
let text_view = {
let reg = self.registry.borrow();
let buf = reg.get(buffer_id).map_err(|e| e.to_string())?;
TextView::new(buf)
};
let aw = self.active_window_mut();
aw.buffer_id = buffer_id;
aw.text_view = text_view;
// Overlays were keyed to the previous buffer's coordinates;
// dropping them is safer than carrying through coordinates
// that no longer mean anything. Callers that want to preserve
// an overlay across buffer switches re-register after.
aw.overlays.clear();
aw.cursor = 0;
aw.selection = None;
aw.view_top = 0;
aw.goal_col = None;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Codepoint navigation
// ---------------------------------------------------------------------------
/// Return the byte position of the codepoint immediately before `pos`.
fn prev_codepoint(buf: &Buffer, pos: Position) -> Position {
if pos == 0 {
return 0;
}
let rope = buf.snapshot_rope();
let mut p = pos - 1;
while p > 0 {
let b = rope.byte_at(p).unwrap_or(0);
if (b & 0xC0) != 0x80 {
return p;
}
p -= 1;
}
0
}
/// Return the byte position of the codepoint immediately after `pos`.
fn next_codepoint(buf: &Buffer, pos: Position) -> Position {
let len = buf.len();
if pos >= len {
return len;
}
let rope = buf.snapshot_rope();
let lead = rope.byte_at(pos).unwrap_or(0);
let advance = utf8_codepoint_len(lead);
(pos + advance as u64).min(len)
}
fn utf8_codepoint_len(lead: u8) -> usize {
if lead < 0xC0 {
1
} else if lead < 0xE0 {
2
} else if lead < 0xF0 {
3
} else {
4
}
}
/// Decode the codepoint starting at `pos`. Returns `(char, advance)`
/// where `advance` is the number of bytes the codepoint consumed.
/// `None` if `pos` is past the buffer end or the bytes there are not
/// valid UTF-8.
fn char_at(buf: &Buffer, pos: Position) -> Option<(char, u64)> {
let rope = buf.snapshot_rope();
if pos >= rope.len() {
return None;
}
let lead = rope.byte_at(pos)?;
let len = utf8_codepoint_len(lead);
let mut bytes = [0u8; 4];
for (i, slot) in bytes.iter_mut().take(len).enumerate() {
*slot = rope.byte_at(pos + i as u64).unwrap_or(0);
}
let s = std::str::from_utf8(&bytes[..len]).ok()?;
let ch = s.chars().next()?;
Some((ch, len as u64))
}
/// Whether `c` counts as a word character. Matches the Emacs default:
/// alphanumerics plus underscore. Punctuation and whitespace are
/// separators.
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Forward-word semantics: skip non-word characters, then skip word
/// characters, returning the resulting position.
fn forward_word(buf: &Buffer, mut pos: Position) -> Position {
let len = buf.len();
// Skip non-word.
while pos < len {
let Some((ch, advance)) = char_at(buf, pos) else {
break;
};
if is_word_char(ch) {
break;
}
pos += advance;
}
// Skip word.
while pos < len {
let Some((ch, advance)) = char_at(buf, pos) else {
break;
};
if !is_word_char(ch) {
break;
}
pos += advance;
}
pos
}
fn word_range_at(buf: &Buffer, pos: Position) -> Option<(Position, Position)> {
let (ch, ch_len) = char_at(buf, pos)?;
if !is_word_char(ch) {
return None;
}
// Walk back from just *past* the char under the cursor, not from
// `pos` itself: `backward_word(pos)` at a word's FIRST character
// sees the non-word char before it, skips it, and crosses into
// the previous word — double-clicking the 'w' of "llo world"
// would select "llo world". From `pos + ch_len` the char behind
// is this word's own first char, so the walk stops at its start.
let start = backward_word(buf, pos.saturating_add(ch_len));
let end = forward_word(buf, pos);
(start < end).then_some((start, end))
}
/// True iff `line` is empty or contains only ASCII whitespace.
/// Used by paragraph motion: a blank line is a paragraph break.
fn line_is_blank(buf: &Buffer, view: &TextView, line: usize) -> bool {
let Some(start) = view.line_offset(line) else {
return true;
};
let Some(len) = view.line_len(buf, line) else {
return true;
};
if len == 0 {
return true;
}
let rope = buf.snapshot_rope();
for chunk in rope.chunks(start, start + len) {
if chunk.iter().any(|b| !b.is_ascii_whitespace()) {
return false;
}
}
true
}
/// Forward-paragraph: skip blank lines if currently on one, then
/// scan forward until the first blank line; return the position at
/// the start of that line, or the buffer end.
fn forward_paragraph(buf: &Buffer, view: &TextView, pos: Position) -> Position {
let total = view.line_count();
if total == 0 {
return pos;
}
let cur_line = view.line_at_offset(pos);
let starting_blank = line_is_blank(buf, view, cur_line);
let mut line = cur_line.saturating_add(1);
if starting_blank {
while line < total && line_is_blank(buf, view, line) {
line += 1;
}
}
while line < total {
if line_is_blank(buf, view, line) {
return view.line_offset(line).unwrap_or(pos);
}
line += 1;
}
buf.len()
}
/// Backward-paragraph: mirror of [`forward_paragraph`].
fn backward_paragraph(buf: &Buffer, view: &TextView, pos: Position) -> Position {
if pos == 0 {
return 0;
}
let cur_line = view.line_at_offset(pos);
if cur_line == 0 {
return 0;
}
let starting_blank = line_is_blank(buf, view, cur_line);
let mut line = cur_line - 1;
if starting_blank {
loop {
if !line_is_blank(buf, view, line) {
break;
}
if line == 0 {
return view.line_offset(0).unwrap_or(0);
}
line -= 1;
}
}
loop {
if line_is_blank(buf, view, line) {
return view.line_offset(line).unwrap_or(0);
}
if line == 0 {
return 0;
}
line -= 1;
}
}
/// Backward-word semantics: step back over non-word characters, then
/// step back over word characters.
fn backward_word(buf: &Buffer, mut pos: Position) -> Position {
// Step back over non-word characters.
while pos > 0 {
let prev = prev_codepoint(buf, pos);
let Some((ch, _)) = char_at(buf, prev) else {
break;
};
if is_word_char(ch) {
break;
}
pos = prev;
}
// Step back over word characters.
while pos > 0 {
let prev = prev_codepoint(buf, pos);
let Some((ch, _)) = char_at(buf, prev) else {
break;
};
if !is_word_char(ch) {
break;
}
pos = prev;
}
pos
}
/// Normalize a buffer path to an absolute, lexically-clean form:
///
/// 1. expand a leading `~` / `~/…` against `$HOME`,
/// 2. join onto the process cwd if still relative,
/// 3. fold `.` / `..` purely lexically.
///
/// No filesystem access and no symlink resolution (unlike
/// [`std::fs::canonicalize`]): the result is correct for a
/// not-yet-created "[new file]" buffer and never silently rewrites a
/// path's on-disk identity. Every step is best-effort — if `$HOME`
/// or the cwd is unavailable the path is returned as far as it could
/// be resolved rather than panicking.
fn normalize_buffer_path(path: PathBuf) -> PathBuf {
let path = expand_tilde(path);
let abs = if path.is_absolute() {
path
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(path)
} else {
path
};
lexical_normalize(&abs)
}
/// Expand a leading `~` (whole component only) using `$HOME`. A bare
/// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left
/// untouched (no passwd lookup). Returns the input unchanged if it
/// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset.
fn expand_tilde(path: PathBuf) -> PathBuf {
let Some(s) = path.to_str() else {
return path;
};
if s == "~" {
return std::env::var_os("HOME").map_or(path, PathBuf::from);
}
if let Some(rest) = s.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return Path::new(&home).join(rest);
}
path
}
/// Fold `.` and `..` components without touching the filesystem.
/// `..` pops a preceding normal segment; against the root (or a
/// Windows prefix) it is dropped, since you cannot ascend past it.
fn lexical_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut stack: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => match stack.last() {
Some(Component::Normal(_)) => {
stack.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => stack.push(Component::ParentDir),
},
c => stack.push(c),
}
}
let mut out = PathBuf::new();
for c in stack {
out.push(c.as_os_str());
}
if out.as_os_str().is_empty() {
PathBuf::from(".")
} else {
out
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
#[test]
fn lexical_normalize_folds_dot_and_dotdot() {
assert_eq!(
lexical_normalize(Path::new("/a/./b/../c")),
PathBuf::from("/a/c")
);
// `..` cannot ascend past the root.
assert_eq!(
lexical_normalize(Path::new("/../../x")),
PathBuf::from("/x")
);
// Already clean ⇒ unchanged (keeps tempdir paths stable so
// the LSP acceptance tests' exact-path asserts still hold).
assert_eq!(
lexical_normalize(Path::new("/tmp/quickshell/ipc.cpp")),
PathBuf::from("/tmp/quickshell/ipc.cpp")
);
}
#[test]
fn expand_tilde_only_at_leading_component() {
// `~user` (no passwd lookup) and a non-leading `~` are left
// exactly as-is, independent of `$HOME`.
assert_eq!(
expand_tilde(PathBuf::from("~bob/x")),
PathBuf::from("~bob/x")
);
assert_eq!(expand_tilde(PathBuf::from("a/~/b")), PathBuf::from("a/~/b"));
// With `$HOME` set (the case in any normal test environment)
// a leading `~` / `~/…` expands against its real value.
if let Some(home) = std::env::var_os("HOME") {
assert_eq!(expand_tilde(PathBuf::from("~")), PathBuf::from(&home));
assert_eq!(
expand_tilde(PathBuf::from("~/src/ipc.cpp")),
Path::new(&home).join("src/ipc.cpp")
);
}
}
#[test]
fn normalize_buffer_path_yields_absolute() {
// A relative path becomes absolute (joined onto cwd) — this
// is exactly what made clangd reject `file://ipc.cpp`.
let p = normalize_buffer_path(PathBuf::from("ipc.cpp"));
assert!(p.is_absolute(), "expected absolute, got {p:?}");
assert!(p.ends_with("ipc.cpp"));
}
fn fresh() -> EditorCore {
let reg: SharedRegistry =
Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
EditorCore::new(reg)
}
fn from_bytes(bytes: &[u8]) -> EditorCore {
let reg: SharedRegistry =
Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
EditorCore::from_bytes(reg, "test", bytes)
}
/// Attach a second frontend `fid` with its own single-window layout,
/// sharing the active buffer (mirrors `build_fresh_frontend_view`).
fn attach_frontend(s: &mut EditorCore, fid: FrontendId) -> WindowId {
let buffer_id = s.active_buffer_id();
let text_view = {
let reg = s.registry.borrow();
crate::text_view::TextView::new(reg.get(buffer_id).expect("buffer present"))
};
let win_id = WindowId::next();
s.windows
.insert(win_id, Window::new(win_id, buffer_id, text_view));
s.register_frontend_view(
fid,
FrontendView {
layout: Layout::single(win_id),
active: win_id,
},
);
win_id
}
#[test]
fn close_others_does_not_prune_other_frontends_windows() {
// Multi-frontend crash regression: closing others from one
// frontend must not delete another frontend's window (which would
// leave its `view.active` dangling → `active_window()` panic).
let mut s = from_bytes(b"hello\n");
let local_win = s.active_window_id();
let fid2 = FrontendId(42);
let win2 = attach_frontend(&mut s, fid2);
s.active_frontend = fid2;
s.close_others();
assert!(
s.windows.contains_key(&win2),
"close_others keeps the active frontend's own window"
);
assert!(
s.windows.contains_key(&local_win),
"close_others must not remove another frontend's window"
);
// LOCAL's active window is intact — no panic.
s.active_frontend = FrontendId::LOCAL;
assert_eq!(s.active_window_id(), local_win);
let _ = s.active_window();
}
#[test]
fn close_active_refuses_the_frontends_last_window_even_with_others_attached() {
// The "only one left" guard is per-frontend: two windows exist
// globally (LOCAL + fid2), but fid2 has just one, so close must
// refuse rather than empty fid2's layout and panic.
let mut s = from_bytes(b"hello\n");
let local_win = s.active_window_id();
let fid2 = FrontendId(42);
let win2 = attach_frontend(&mut s, fid2);
s.active_frontend = fid2;
assert!(
!s.close_active(),
"close_active refuses the active frontend's only window"
);
assert!(s.windows.contains_key(&win2));
assert!(s.windows.contains_key(&local_win));
}
#[test]
fn insert_advances_cursor() {
let mut s = from_bytes(b"");
s.insert_char('h');
s.insert_char('i');
assert_eq!(s.cursor(), 2);
assert_eq!(s.active_buffer_len(), 2);
}
#[test]
fn backspace_undoes_insertion() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 3;
s.backspace();
assert_eq!(s.cursor(), 2);
assert_eq!(s.active_buffer_len(), 2);
}
#[test]
fn copy_captures_region_and_queues_publish() {
let mut s = from_bytes(b"hello world");
s.begin_selection(0);
s.active_window_mut().cursor = 5; // region [0,5) = "hello"
assert!(s.clipboard_copy());
assert_eq!(s.clipboard_slot(), b"hello");
let (fid, bytes) = s.take_pending_clipboard().expect("publish queued");
assert_eq!(fid, s.active_frontend);
assert_eq!(bytes, b"hello");
// Drained: second take is None.
assert!(s.take_pending_clipboard().is_none());
// Copy does not mutate the buffer.
assert_eq!(s.active_buffer_len(), 11);
}
#[test]
fn copy_without_region_is_a_noop() {
let mut s = from_bytes(b"abc");
assert!(!s.clipboard_copy());
assert!(s.clipboard_slot().is_empty());
assert!(s.take_pending_clipboard().is_none());
}
#[test]
fn cut_copies_then_deletes_region() {
let mut s = from_bytes(b"hello world");
s.begin_selection(6);
s.active_window_mut().cursor = 11; // region [6,11) = "world"
assert!(s.clipboard_cut().unwrap());
assert_eq!(s.clipboard_slot(), b"world");
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello ");
assert_eq!(s.cursor(), 6);
}
#[test]
fn paste_inserts_slot_at_cursor() {
let mut s = from_bytes(b"ac");
// Seed the slot via a copy.
s.begin_selection(0);
s.active_window_mut().cursor = 1; // "a"
s.clipboard_copy();
// Paste "a" between a and c.
s.clear_selection();
s.active_window_mut().cursor = 1;
assert!(s.clipboard_paste().unwrap());
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aac");
assert_eq!(s.cursor(), 2);
}
#[test]
fn paste_replaces_active_region_as_one_step() {
let mut s = from_bytes(b"hello world");
// Copy "hello".
s.begin_selection(0);
s.active_window_mut().cursor = 5;
s.clipboard_copy();
// Select "world" and paste over it.
s.begin_selection(6);
s.active_window_mut().cursor = 11;
assert!(s.clipboard_paste().unwrap());
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello hello");
assert!(s.active_region().is_none()); // selection cleared
}
#[test]
fn paste_with_empty_slot_is_a_noop() {
let mut s = from_bytes(b"abc");
assert!(!s.clipboard_paste().unwrap());
assert_eq!(s.active_buffer_len(), 3);
}
#[test]
fn paste_inbound_inserts_and_refreshes_slot() {
let mut s = from_bytes(b"ab");
s.active_window_mut().cursor = 1;
s.paste_inbound(b"XYZ").unwrap();
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aXYZb");
assert_eq!(s.cursor(), 4);
// Slot refreshed, so an in-app paste repeats the external text.
assert_eq!(s.clipboard_slot(), b"XYZ");
// Inbound paste does NOT queue an outbound publish (no echo loop).
assert!(s.take_pending_clipboard().is_none());
}
#[test]
fn select_all_spans_the_buffer() {
let mut s = from_bytes(b"hello");
s.active_window_mut().cursor = 2;
s.select_all();
assert_eq!(s.active_region(), Some((0, 5)));
assert_eq!(s.region_bytes().unwrap(), b"hello");
}
#[test]
fn word_at_cursor_reads_the_identifier() {
let mut s = from_bytes(b"foo bar_baz qux");
s.active_window_mut().cursor = 6; // inside "bar_baz"
assert_eq!(s.word_at_cursor().as_deref(), Some("bar_baz"));
s.active_window_mut().cursor = 0; // start of "foo"
assert_eq!(s.word_at_cursor().as_deref(), Some("foo"));
s.active_window_mut().cursor = 3; // just past "foo" → scans left
assert_eq!(s.word_at_cursor().as_deref(), Some("foo"));
}
#[test]
fn word_at_cursor_is_none_in_whitespace() {
let mut s = from_bytes(b"a b");
s.active_window_mut().cursor = 2; // a run of spaces, none adjacent left
assert_eq!(s.word_at_cursor(), None);
}
#[test]
fn cursor_navigation_left_right() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 0;
s.move_right();
assert_eq!(s.cursor(), 1);
s.move_right();
s.move_right();
s.move_right();
assert_eq!(s.cursor(), 3);
s.move_left();
s.move_left();
s.move_left();
s.move_left();
assert_eq!(s.cursor(), 0);
}
#[test]
fn cursor_navigation_up_down_preserves_column() {
let mut s = from_bytes(b"abcdef\nghi\njklmno");
s.active_window_mut().cursor = 4;
s.move_down();
assert_eq!(s.cursor(), 10);
s.move_down();
assert_eq!(s.cursor(), 15);
s.move_up();
s.move_up();
assert_eq!(s.cursor(), 4);
}
#[test]
fn line_start_and_end() {
let mut s = from_bytes(b"hello\nworld");
s.active_window_mut().cursor = 8;
s.move_line_start();
assert_eq!(s.cursor(), 6);
s.move_line_end();
assert_eq!(s.cursor(), 11);
}
#[test]
fn undo_clamps_cursor_to_buffer_len() {
let mut s = from_bytes(b"");
s.insert_char('a');
s.insert_char('b');
assert_eq!(s.cursor(), 2);
s.undo();
assert_eq!(s.cursor(), 1);
s.undo();
assert_eq!(s.cursor(), 0);
}
#[test]
fn delete_forward_at_end_is_noop() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 3;
s.delete_forward();
assert_eq!(s.active_buffer_len(), 3);
}
#[test]
fn delete_word_backward_removes_previous_word_to_cursor() {
// Cursor sits at end-of-buffer; deletes back through "world".
let mut s = from_bytes(b"hello world");
s.active_window_mut().cursor = 11;
s.delete_word_backward();
// `backward_word` lands at the start of the word ("world"
// begins at byte 6), so we delete bytes 6..11.
assert_eq!(s.cursor(), 6);
assert_eq!(s.active_buffer_len(), 6);
}
#[test]
fn delete_word_backward_at_start_of_buffer_is_noop() {
let mut s = from_bytes(b"hello");
s.active_window_mut().cursor = 0;
s.delete_word_backward();
assert_eq!(s.cursor(), 0);
assert_eq!(s.active_buffer_len(), 5);
}
#[test]
fn delete_word_forward_removes_next_word_from_cursor() {
let mut s = from_bytes(b"hello world");
s.active_window_mut().cursor = 0;
s.delete_word_forward();
// `forward_word` lands at the end of the first word (byte 5);
// delete bytes 0..5. Cursor stays where it was.
assert_eq!(s.cursor(), 0);
assert_eq!(s.active_buffer_len(), 6);
}
#[test]
fn delete_word_forward_at_end_of_buffer_is_noop() {
let mut s = from_bytes(b"hello");
s.active_window_mut().cursor = 5;
s.delete_word_forward();
assert_eq!(s.cursor(), 5);
assert_eq!(s.active_buffer_len(), 5);
}
#[test]
fn multibyte_navigation() {
let mut s = from_bytes("héllo".as_bytes());
s.active_window_mut().cursor = 0;
s.move_right();
assert_eq!(s.cursor(), 1);
s.move_right();
assert_eq!(s.cursor(), 3);
s.move_right();
assert_eq!(s.cursor(), 4);
s.move_left();
s.move_left();
assert_eq!(s.cursor(), 1);
}
#[test]
fn save_with_no_path_announces_via_status() {
let mut s = fresh();
s.save();
assert!(s.status.contains("no file"));
}
/// T M10.8 — pins the Day 2 transitional fallback behavior in
/// [`EditorCore::active_view`].
///
/// **Day 2 → Day 3 transition contract**: while the dispatcher
/// thread is being wired (Day 3 work), the daemon may set
/// `active_frontend` to a daemon-attached `FrontendId` whose
/// `FrontendView` hasn't been registered yet. The fallback to
/// `FrontendId::LOCAL`'s view keeps single-frontend behavior
/// observable.
///
/// **Day 3 cleanup**: once
/// [`EditorCore::register_frontend_view`] is invariantly called
/// before any event dispatch, this test flips to assert "every
/// `active_frontend` has its own registered view, no fallback
/// ever activates." Until then, the fallback is the bridge.
#[test]
fn active_view_falls_back_to_local_when_active_frontend_unregistered() {
let mut s = fresh();
// Default active_frontend is LOCAL → no fallback yet.
assert_eq!(s.active_frontend, FrontendId::LOCAL);
let local_active_window = s.active_view().active;
// Simulate the Day 2 transitional state: a daemon-attached
// frontend's id is set as active, but no FrontendView is
// registered for it (Day 3 work).
s.active_frontend = FrontendId(42);
assert!(!s.views.contains_key(&FrontendId(42)));
// Fallback activates: active_view() returns LOCAL's view.
let fallback_view = s.active_view();
assert_eq!(
fallback_view.active, local_active_window,
"Day 2 fallback: active_view() returns LOCAL's view when active_frontend has no entry"
);
// Same for active_window().
let win = s.active_window();
assert_eq!(win.id, local_active_window);
}
#[test]
fn active_view_for_explicit_fid_returns_none_when_unregistered() {
// T M10.8 — explicit-fid lookups don't fall back. Callers
// explicitly asking about a specific frontend get a truthful
// None when that frontend has no state, distinguishing
// "active by default" from "actually has its own view."
let s = fresh();
assert!(s.active_window_for(FrontendId(42)).is_none());
assert!(s.active_window_for(FrontendId::LOCAL).is_some());
}
#[test]
fn register_and_unregister_frontend_view() {
// T M10.8 — the lifecycle API the dispatcher uses on attach
// and detach. Wiring lives in `daemon.rs`; this test pins
// the EditorCore-side semantics.
let mut s = fresh();
let fid = FrontendId(7);
assert!(s.active_window_for(fid).is_none());
// Build a view referencing the existing scratch window so
// we don't need a fresh window allocation in this test.
let local_view = s.views[&FrontendId::LOCAL].clone();
s.register_frontend_view(fid, local_view);
assert!(s.active_window_for(fid).is_some());
// Unregister drops the entry; explicit lookup returns None.
s.unregister_frontend_view(fid);
assert!(s.active_window_for(fid).is_none());
// LOCAL invariant survives unrelated register/unregister.
assert!(s.views.contains_key(&FrontendId::LOCAL));
}
#[test]
fn split_active_creates_a_second_window_on_same_buffer() {
let mut s = fresh();
let original = s.active_window_id();
let new_id = s.split_active(Orientation::Vertical, true);
assert_ne!(new_id, original);
assert_eq!(s.windows.len(), 2);
// Same buffer.
assert_eq!(s.windows[&original].buffer_id, s.windows[&new_id].buffer_id);
}
#[test]
fn edit_in_one_window_propagates_through_buffer_to_the_other() {
let mut s = from_bytes(b"abc");
let _new = s.split_active(Orientation::Vertical, true);
// Insert via the active window.
s.active_window_mut().cursor = 3;
s.insert_char('X');
// Buffer length is now 4; the *other* window shares the
// same buffer, so its text view sees the same length.
assert_eq!(s.active_buffer_len(), 4);
// The other window's text_view has the same line count,
// confirming on_edit fired.
let active = s.active_window_id();
let other = s.windows.keys().find(|id| **id != active).copied().unwrap();
assert_eq!(s.windows[&other].text_view.line_count(), 1);
}
#[test]
fn close_active_falls_back_to_remaining_window() {
let mut s = fresh();
s.split_active(Orientation::Horizontal, true);
assert_eq!(s.windows.len(), 2);
assert!(s.close_active());
assert_eq!(s.windows.len(), 1);
}
#[test]
fn close_active_refuses_when_only_one_window() {
let mut s = fresh();
assert!(!s.close_active());
assert_eq!(s.windows.len(), 1);
}
#[test]
fn focus_next_round_robins() {
let mut s = fresh();
let a = s.active_window_id();
let _b = s.split_active(Orientation::Vertical, true);
let _c = s.split_active(Orientation::Horizontal, true);
// Splits don't move focus; `a` is still active.
assert_eq!(s.active_window_id(), a);
let order = s.active_layout().iter_ids();
assert_eq!(order.len(), 3);
// Walking N times wraps back to the original.
for _ in 0..3 {
s.focus_next();
}
assert_eq!(s.active_window_id(), a);
}
// ------------------------------------------------------------------
// F27 / F28 (post-audit-round-5) — daemon-origin CRDT ops are
// queued on `pending_crdt_ops` so they reach all replicas.
// ------------------------------------------------------------------
/// Helper: upgrade the active buffer to CRDT-backed under the
/// LOCAL peer id (mirrors what the daemon does at attach time
/// for replica sessions).
#[cfg(feature = "crdt")]
fn upgrade_active_to_crdt(s: &mut EditorCore) {
let buffer_id = s.active_buffer_id();
let mut reg = s.registry.borrow_mut();
let buf = reg.get_mut(buffer_id).expect("active buffer present");
buf.upgrade_to_crdt(crate::crdt::peer_id_from_frontend(
crate::protocol::FrontendId::LOCAL,
))
.expect("upgrade");
}
/// F27 — undo on a CRDT-backed buffer queues the resulting
/// CRDT op for broadcast.
#[cfg(feature = "crdt")]
#[test]
fn undo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
let mut s = from_bytes(b"abc");
upgrade_active_to_crdt(&mut s);
// Apply an edit so there's something to undo. apply_active_edit
// also pushes a DaemonKey-origin op.
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
let queued_after_edit = s.pending_crdt_ops.len();
assert!(queued_after_edit >= 1, "edit must queue a CRDT op");
// Drain to isolate the undo's queueing.
s.pending_crdt_ops.clear();
s.undo();
assert!(
!s.pending_crdt_ops.is_empty(),
"F27: undo on a CRDT-backed buffer must queue a CRDT op for broadcast"
);
// Origin must be DaemonKey (broadcast-to-all-replicas).
let (origin, _, _) = &s.pending_crdt_ops[0];
assert!(
matches!(origin, CrdtOpOrigin::DaemonKey),
"F27: undo's CRDT op must be queued with DaemonKey origin (broadcast to all replicas including active frontend)"
);
}
/// F27 — redo on a CRDT-backed buffer queues the resulting
/// CRDT op for broadcast.
#[cfg(feature = "crdt")]
#[test]
fn redo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
let mut s = from_bytes(b"abc");
upgrade_active_to_crdt(&mut s);
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
s.undo();
s.pending_crdt_ops.clear();
s.redo();
assert!(
!s.pending_crdt_ops.is_empty(),
"F27: redo on a CRDT-backed buffer must queue a CRDT op for broadcast"
);
let (origin, _, _) = &s.pending_crdt_ops[0];
assert!(matches!(origin, CrdtOpOrigin::DaemonKey));
}
/// F27 — undo on a non-CRDT buffer is a no-op for the broadcast
/// queue (the buffer produced no `crdt_op` on the Edit).
#[test]
fn undo_on_non_crdt_buffer_does_not_queue_crdt_op_f27() {
let mut s = from_bytes(b"abc");
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
// Non-CRDT — apply_active_edit's pending push is a no-op
// (Edit::crdt_op is None). Confirm precondition then undo.
assert!(s.pending_crdt_ops.is_empty());
s.undo();
assert!(
s.pending_crdt_ops.is_empty(),
"F27: undo on a non-CRDT buffer must not produce a phantom queue entry"
);
}
// ---- jump ring (T M4.5 L1) -----------------------------------------
#[test]
fn jump_back_returns_false_on_empty_ring() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 2;
assert!(!s.jump_back(), "empty ring must not move the cursor");
assert_eq!(s.cursor(), 2);
}
#[test]
fn push_then_jump_back_restores_cursor() {
let mut s = from_bytes(b"line one\nline two\nline three");
s.active_window_mut().cursor = 3;
s.push_jump();
s.active_window_mut().cursor = 20;
assert!(s.jump_back());
assert_eq!(s.cursor(), 3);
// Ring is now empty; a second pop is a no-op.
assert!(!s.jump_back());
}
#[test]
fn jump_back_clamps_to_shortened_buffer() {
let mut s = from_bytes(b"abcdefghij");
s.active_window_mut().cursor = 9;
s.push_jump();
// Truncate the buffer so the recorded position is past EOF.
s.apply_active_edit(crate::buffer::EditOp::Delete {
range: Range::new(2, 10),
})
.expect("delete");
assert!(s.jump_back());
assert_eq!(
s.cursor(),
s.active_buffer_len(),
"stale position must clamp to the current buffer length"
);
}
#[test]
fn jump_ring_is_bounded_and_evicts_oldest() {
let mut s = from_bytes(b"0123456789");
for i in 0..(EditorCore::JUMP_RING_CAP + 10) {
s.active_window_mut().cursor = (i % 10) as u64;
s.push_jump();
}
assert_eq!(
s.jump_ring.len(),
EditorCore::JUMP_RING_CAP,
"ring must stay bounded at JUMP_RING_CAP"
);
}
#[test]
fn jump_back_skips_removed_buffer() {
let mut s = from_bytes(b"original");
// Record a jump on a second buffer, then remove that buffer.
let doomed = s.registry.borrow_mut().create_from_bytes("doomed", b"x");
s.switch_active_buffer(doomed).expect("switch");
s.active_window_mut().cursor = 1;
s.push_jump();
// Switch back and record a live origin too.
let original = *s.registry.borrow().ids().first().expect("original id");
s.switch_active_buffer(original).expect("switch back");
s.active_window_mut().cursor = 4;
s.push_jump();
s.active_window_mut().cursor = 0;
// Drop the doomed buffer: its ring entry is now stale.
s.registry.borrow_mut().remove(doomed).expect("remove");
// First pop lands on the live `original` origin.
assert!(s.jump_back());
assert_eq!(s.active_buffer_id(), original);
assert_eq!(s.cursor(), 4);
// Next pop would be the stale `doomed` entry — skipped, ring empties.
assert!(!s.jump_back());
}
// ---- incremental search (Q#SR5) ------------------------------------
fn type_query(s: &mut EditorCore, q: &str) {
for ch in q.chars() {
s.search_input_char(ch);
}
}
#[test]
fn search_begin_then_type_highlights_from_origin() {
let mut s = from_bytes(b"foo bar foo baz foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
assert!(s.search_active());
type_query(&mut s, "foo");
// Three matches: 0..3, 8..11, 16..19; first (at/after origin 0)
// is active and the cursor sits on it.
assert_eq!(s.search_match_summary(), (Some(0), 3));
assert_eq!(s.cursor(), 0);
let guard = s.search_store.lock().expect("store");
assert!(!guard.is_stale(bid));
assert_eq!(guard.for_buffer(bid).expect("entry").len(), 3);
}
#[test]
fn search_step_walks_matches_and_wraps() {
let mut s = from_bytes(b"foo bar foo baz foo");
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
type_query(&mut s, "foo");
assert_eq!(s.cursor(), 0);
s.search_step(true);
assert_eq!((s.search_match_summary(), s.cursor()), ((Some(1), 3), 8));
s.search_step(true);
assert_eq!(s.cursor(), 16);
s.search_step(true); // wraps to the first match
assert_eq!(s.cursor(), 0);
s.search_step(false); // backward wraps to the last
assert_eq!(s.cursor(), 16);
}
#[test]
fn search_focuses_first_match_at_or_after_origin() {
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 5; // inside "bar"
s.search_begin(true, false);
type_query(&mut s, "foo");
// First match with start >= 5 is the one at byte 8.
assert_eq!(s.cursor(), 8);
assert_eq!(s.search_match_summary(), (Some(1), 2));
}
#[test]
fn search_cancel_restores_origin_and_clears_store() {
let mut s = from_bytes(b"foo bar foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 5;
s.search_begin(true, false);
type_query(&mut s, "foo");
assert_eq!(s.cursor(), 8);
s.search_finish(false); // cancel
assert!(!s.search_active());
assert_eq!(s.cursor(), 5, "cancel restores the pre-search cursor");
assert!(
s.search_store
.lock()
.expect("store")
.for_buffer(bid)
.is_none(),
"cancel clears the matches"
);
}
#[test]
fn search_accept_keeps_cursor_and_matches() {
let mut s = from_bytes(b"foo bar foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
type_query(&mut s, "foo");
s.search_step(true); // focus the match at byte 8
assert_eq!(s.cursor(), 8);
s.search_finish(true); // accept
assert!(!s.search_active());
assert_eq!(s.cursor(), 8, "accept keeps the cursor on the match");
assert!(
s.search_store
.lock()
.expect("store")
.for_buffer(bid)
.is_some(),
"accept keeps matches for highlight + navigation"
);
}
#[test]
fn search_backspace_widens_the_match_set() {
let mut s = from_bytes(b"fo foo food");
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10
assert_eq!(s.search_match_summary().1, 2);
s.search_backspace(); // query "fo"
assert_eq!(s.search_query(), "fo");
assert_eq!(s.search_match_summary().1, 3);
}
#[test]
fn search_smart_case_is_case_sensitive_with_uppercase() {
let mut s = from_bytes(b"Foo foo FOO");
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
type_query(&mut s, "Foo"); // uppercase => case-sensitive
assert_eq!(s.search_match_summary().1, 1);
s.search_backspace();
s.search_backspace();
s.search_backspace();
type_query(&mut s, "foo"); // lowercase => smart-case folds all
assert_eq!(s.search_match_summary().1, 3);
}
#[test]
fn edit_marks_accepted_matches_stale() {
let mut s = from_bytes(b"foo foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
type_query(&mut s, "foo");
s.search_finish(true); // matches persist after accept
assert!(!s.search_store.lock().expect("store").is_stale(bid));
s.insert_char('x'); // any edit invalidates the match offsets
assert!(
s.search_store.lock().expect("store").is_stale(bid),
"an edit marks the buffer's matches stale (linger fix)"
);
}
// ---- regex search (Q#RX3) ------------------------------------------
#[test]
fn regex_search_matches_pattern() {
let mut s = from_bytes(b"a1 b2 c3");
s.active_window_mut().cursor = 0;
s.search_begin(true, true);
assert!(s.search_is_regex());
type_query(&mut s, r"\d");
assert_eq!(s.search_match_summary().1, 3, "\\d matches 1, 2, 3");
assert!(!s.search_is_invalid());
}
#[test]
fn regex_invalid_pattern_flags_and_recovers() {
let mut s = from_bytes(b"foo");
s.active_window_mut().cursor = 0;
s.search_begin(true, true);
type_query(&mut s, "fo("); // unbalanced group mid-typing
assert!(s.search_is_invalid(), "incomplete group is invalid");
assert_eq!(s.search_match_summary().1, 0, "invalid ⇒ no matches");
type_query(&mut s, "o)"); // completes the group: regex fo(o) → "foo"
assert!(!s.search_is_invalid(), "valid pattern recovers");
assert_eq!(s.search_match_summary().1, 1);
}
#[test]
fn toggle_regex_reinterprets_the_query() {
let mut s = from_bytes(b"a.b axb");
s.active_window_mut().cursor = 0;
s.search_begin(true, false); // literal
type_query(&mut s, "a.b");
assert!(!s.search_is_regex());
assert_eq!(
s.search_match_summary().1,
1,
"literal '.' matches only a.b"
);
s.search_toggle_regex(); // → regex
assert!(s.search_is_regex());
assert_eq!(s.search_match_summary().1, 2, "regex '.' also matches axb");
s.search_toggle_regex(); // back to literal
assert!(!s.search_is_regex());
assert_eq!(s.search_match_summary().1, 1);
}
// ---- in-buffer completion popup (Arc 1a) --------------------------------
fn text_of(s: &EditorCore) -> String {
let id = s.active_buffer_id();
let reg = s.registry.borrow();
let buf = reg.get(id).expect("active buffer present");
let mut bytes = vec![0u8; buf.len() as usize];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
}
String::from_utf8(bytes).expect("test buffers are UTF-8")
}
fn open_popup(s: &mut EditorCore, anchor: u64, prefix: &str, insert_text: &str) {
let state = crate::completion::CompletionPopupState::new(
s.active_buffer_id(),
anchor,
prefix.to_owned(),
vec![crate::completion::PopupCandidate {
label: insert_text.to_owned(),
kind: crate::completion::CompletionItemKind::Text,
detail: None,
insert_text: insert_text.to_owned(),
}],
1,
)
.expect("non-empty candidate list");
s.completion_popup_open(state);
}
#[test]
fn completion_popup_open_attaches_self_suppressing_overlay() {
let mut s = from_bytes(b"he\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
assert!(s.completion_popup_is_open());
assert!(
s.active_window()
.overlay_kinds()
.contains(&"completion-popup")
);
// Re-opening dedups the overlay by kind.
open_popup(&mut s, 0, "he", "hello");
let kinds = s.active_window().overlay_kinds();
assert_eq!(
kinds.iter().filter(|k| **k == "completion-popup").count(),
1
);
}
#[test]
fn completion_popup_validate_survives_word_growth_and_empty_prefix() {
let mut s = from_bytes(b"he world\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
s.completion_popup_validate();
assert!(s.completion_popup_is_open(), "prefix `he` holds");
// Typing extends the word: still valid.
s.insert_char('l');
s.completion_popup_validate();
assert!(s.completion_popup_is_open(), "prefix `hel` holds");
// Trigger-char shape (cursor == anchor, empty prefix) holds too.
s.completion_popup_close();
s.active_window_mut().cursor = 2;
open_popup(&mut s, 2, "", "llo");
s.completion_popup_validate();
assert!(s.completion_popup_is_open(), "empty prefix at anchor holds");
}
#[test]
fn completion_popup_validate_closes_when_invariant_breaks() {
let mut s = from_bytes(b"he world\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
// Cursor moved past the word: `[anchor..cursor]` spans a space.
s.active_window_mut().cursor = 4;
s.completion_popup_validate();
assert!(!s.completion_popup_is_open(), "non-word bytes close it");
// Cursor moved before the anchor.
s.active_window_mut().cursor = 2;
open_popup(&mut s, 2, "", "x");
s.active_window_mut().cursor = 1;
s.completion_popup_validate();
assert!(!s.completion_popup_is_open(), "cursor < anchor closes it");
// Session bound to a buffer that is not the active one.
let other = s.registry.borrow_mut().create("*other*");
let state = crate::completion::CompletionPopupState::new(
other,
0,
String::new(),
vec![crate::completion::PopupCandidate {
label: "x".into(),
kind: crate::completion::CompletionItemKind::Text,
detail: None,
insert_text: "x".into(),
}],
1,
)
.unwrap();
s.completion_popup_open(state);
s.completion_popup_validate();
assert!(!s.completion_popup_is_open(), "wrong buffer closes it");
}
#[test]
fn completion_popup_accept_replaces_prefix_as_one_undo_step() {
let mut s = from_bytes(b"he and more\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello_world");
assert!(s.completion_popup_accept());
assert_eq!(text_of(&s), "hello_world and more\n");
assert_eq!(s.active_window().cursor, 11);
assert!(!s.completion_popup_is_open(), "accept closes the popup");
// Q#C7: the replace is a single edit — one undo restores the
// original text (not an intermediate delete-then-insert state).
s.undo();
assert_eq!(text_of(&s), "he and more\n");
}
#[test]
fn completion_popup_accept_empty_prefix_inserts_at_anchor() {
let mut s = from_bytes(b"x.\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 2, "", "method");
assert!(s.completion_popup_accept());
assert_eq!(text_of(&s), "x.method\n");
assert_eq!(s.active_window().cursor, 8);
}
#[test]
fn completion_popup_accept_is_noop_when_session_stale() {
let mut s = from_bytes(b"he world\n");
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
// Simulate a race: the cursor left the word before accept ran.
s.active_window_mut().cursor = 5;
assert!(!s.completion_popup_accept());
assert_eq!(text_of(&s), "he world\n", "buffer untouched");
assert!(!s.completion_popup_is_open(), "stale accept still closes");
}
#[test]
fn completion_popup_validate_closes_on_window_focus_change() {
// Two splits on the SAME buffer: the session is window-scoped,
// so moving focus (buffer unchanged!) must invalidate it ---
// this is also what keeps the persistent overlay in the other
// split from painting a popup it doesn't own.
let mut s = from_bytes(b"he world\n");
s.split_active(Orientation::Horizontal, true);
s.active_window_mut().cursor = 2;
open_popup(&mut s, 0, "he", "hello");
s.completion_popup_validate();
assert!(s.completion_popup_is_open(), "session holds in its window");
s.focus_next();
s.completion_popup_validate();
assert!(
!s.completion_popup_is_open(),
"focus change closes the session even with the same buffer"
);
}
// ---- query-replace core (Arc 2) ----------------------------------------
#[test]
fn query_replace_all_replaces_and_counts() {
let mut s = from_bytes(b"foo foo foo\n");
s.query_replace_begin("foo".into(), "bar".into(), false);
assert!(s.query_replace_active(), "session opens on the first match");
s.query_replace_all();
assert_eq!(text_of(&s), "bar bar bar\n");
assert!(!s.query_replace_active(), "! finishes the session");
assert_eq!(s.status, "Replaced 3 occurrences");
}
#[test]
fn query_replace_growing_replacement_does_not_loop() {
// The a→aa shape: replacing must not re-match the inserted text.
let mut s = from_bytes(b"a a a\n");
s.query_replace_begin("a".into(), "aa".into(), false);
s.query_replace_all();
assert_eq!(text_of(&s), "aa aa aa\n", "each 'a' replaced exactly once");
assert_eq!(s.status, "Replaced 3 occurrences");
}
#[test]
fn query_replace_empty_to_deletes() {
let mut s = from_bytes(b"a-b-c\n");
s.query_replace_begin("-".into(), String::new(), false);
s.query_replace_all();
assert_eq!(text_of(&s), "abc\n", "empty replacement deletes matches");
}
#[test]
fn query_replace_skip_then_replace_is_selective() {
let mut s = from_bytes(b"x x x\n");
s.query_replace_begin("x".into(), "y".into(), false);
s.query_replace_skip(); // leave the first x
s.query_replace_replace(); // replace the second x, advance to third
s.query_replace_replace_and_quit(); // replace the third, quit
assert_eq!(text_of(&s), "x y y\n", "first skipped, rest replaced");
assert!(!s.query_replace_active());
}
#[test]
fn query_replace_nothing_matched_restores_origin() {
let mut s = from_bytes(b"hello world\n");
s.active_window_mut().cursor = 6; // on "world"
s.query_replace_begin("zzz".into(), "q".into(), false);
assert!(
!s.query_replace_active(),
"no match → session never stays open"
);
assert_eq!(text_of(&s), "hello world\n", "buffer untouched");
assert_eq!(s.active_window().cursor, 6, "origin cursor restored");
assert_eq!(s.status, "No matches for 'zzz'");
}
#[test]
fn query_replace_starts_from_cursor_forward() {
let mut s = from_bytes(b"k _ k\n");
s.active_window_mut().cursor = 2; // between the two k's
s.query_replace_begin("k".into(), "K".into(), false);
s.query_replace_all();
assert_eq!(text_of(&s), "k _ K\n", "only the match at/after point");
}
#[test]
fn query_replace_aborts_when_active_buffer_changes() {
// The wrong-buffer merge-blocker: a session started in buffer X
// must never apply its match to a buffer that became active
// mid-session. Focus drifts (a click / cross-frontend key), then
// the next replace key aborts safely instead of corrupting.
let mut s = from_bytes(b"foo foo\n");
let x = s.active_buffer_id();
s.query_replace_begin("foo".into(), "bar".into(), false);
assert!(s.query_replace_active());
// Switch the active buffer to an unrelated one (focus drift).
let y = s.registry.borrow_mut().create("*other*");
{
let reg = s.registry.borrow();
let buf = reg.get(y).unwrap();
let tv = crate::text_view::TextView::new(buf);
drop(reg);
let win = s.active_window_mut();
win.buffer_id = y;
win.text_view = tv;
win.cursor = 0;
}
assert_eq!(s.active_buffer_id(), y);
s.query_replace_replace(); // the y/replace key while drifted
assert!(!s.query_replace_active(), "drift aborts the session");
assert_eq!(s.status, "query-replace aborted: active buffer changed");
// Neither buffer was mutated by the aborted replace.
{
let reg = s.registry.borrow();
let bx = reg.get(x).unwrap();
let mut xb = vec![0u8; bx.len() as usize];
bx.snapshot_rope().slice(0, bx.len(), &mut xb);
assert_eq!(&xb, b"foo foo\n", "origin buffer X untouched");
let by = reg.get(y).unwrap();
assert_eq!(by.len(), 0, "unrelated buffer Y untouched");
}
}
#[test]
fn query_replace_regex_replaces_and_invalid_refuses() {
let mut s = from_bytes(b"a1 b2 c3\n");
s.query_replace_begin("[0-9]".into(), "#".into(), true);
s.query_replace_all();
assert_eq!(text_of(&s), "a# b# c#\n", "regex matches digits");
// Invalid regex refuses to start and leaves a status.
let mut s2 = from_bytes(b"abc\n");
s2.query_replace_begin("(unclosed".into(), "x".into(), true);
assert!(
!s2.query_replace_active(),
"invalid regex never opens a session"
);
assert!(s2.status.starts_with("Invalid regex"));
}
}