// 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>` 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, } /// 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, /// 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, /// 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)>, } 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(), } } /// 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, 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 { 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 { 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) { 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) { 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 } // ---- 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 { 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())); } 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). Returns /// false if there's only one window left. pub fn close_active(&mut self) -> bool { if self.windows.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. pub fn close_others(&mut self) { let keep = self.active_window_id(); self.active_layout_mut().keep_only(keep); self.windows.retain(|id, _| *id == keep); } // ---- 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 { 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) } /// 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()); } } 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 = 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) } #[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 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()); } }