extern crate self as escriba_runtime;
pub mod breakpoint;
mod courier;
mod plugin_host;
pub mod scan;
pub use breakpoint::Breakpoints;
pub use plugin_host::{LazyTrigger, PluginHost};
pub mod status;
pub use escriba_mode::{OpState, OperatorPending};
enum ObjectKey {
Consumed,
Compose(Action),
}
pub use status::{PromptKind, StatusModel};
use std::collections::HashMap;
use awase::KeyRepeatGate;
use escriba_buffer::BufferSet;
use escriba_buffer::TextRev;
use escriba_command::CommandRegistry;
use escriba_core::{
Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, InsertAt,
JumpList, Mode, Motion, Operator, Position, Range, TextEffect, WindowId,
};
use escriba_input::{InputOutcome, translate_app_event};
use escriba_keymap::{Key, Keymap};
use escriba_madoguchi::{Negai, Outcome};
use escriba_mode::ModalState;
use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
use escriba_ui::chrome::{ChromePalette, FleetTheme};
use escriba_ui::splash::Splash;
use escriba_ui::{Layout, Viewport, Window};
use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, VmError};
use madori::AppEvent;
use std::time::Instant;
pub struct EditorState {
pub buffers: BufferSet,
pub modal: ModalState,
pub search: SearchState,
pub keymap: Keymap,
pub commands: CommandRegistry,
pub layout: Layout,
pub active: BufferId,
cursors: Cursors,
pub quit_requested: bool,
pub messages: Vec<String>,
search_at: Option<Anchored<usize, TextRev>>,
last_change: Option<LastChange>,
recording_insert: bool,
pub jumps: JumpList,
pub options: HashMap<String, String>,
lisp_vm: Option<EscribaVm>,
pub pending_keys: Vec<Key>,
repeat_gate: KeyRepeatGate<Key>,
pub plugin_host: PluginHost,
register: Option<String>,
op_pending: zenmai::Stateful<OperatorPending>,
pending_object: Option<bool>,
pending_find: Option<FindSpec>,
last_find: Option<FindSpec>,
pending_mark: Option<MarkKey>,
marks: HashMap<char, Position>,
edit_gen: EditGen,
damage: Damage,
theme: FleetTheme,
chrome: ChromePalette,
dispatch_depth: u8,
pub results: escriba_shirube::ListRegistry,
picker: Option<escriba_ui::picker::Picker>,
index_rev: escriba_shirube::IndexRev,
lsp_gen: escriba_shirube::SessionGen,
scan_gen: escriba_shirube::SessionGen,
courier: courier::Courier,
picker_projects: Option<(bool, Option<String>)>,
pub filetypes: escriba_core::FiletypeTable,
splash: Option<Splash>,
semantic: Option<SemanticPaint>,
breakpoints: Breakpoints,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticPaint {
buffer: BufferId,
spans: Vec<escriba_madoguchi::SemanticSpan>,
anchor: escriba_shirube::Anchor,
}
impl SemanticPaint {
#[must_use]
pub const fn new(
buffer: BufferId,
spans: Vec<escriba_madoguchi::SemanticSpan>,
anchor: escriba_shirube::Anchor,
) -> Self {
Self {
buffer,
spans,
anchor,
}
}
#[must_use]
pub fn fresh(
&self,
world: &escriba_shirube::Anchor,
buffer: BufferId,
) -> &[escriba_madoguchi::SemanticSpan] {
if self.buffer == buffer && self.anchor.is_fresh(world) {
&self.spans
} else {
&[]
}
}
}
enum SplashKey {
NotShowing,
Ran(Action),
Dismissed,
}
pub struct EditorWindow<'a> {
state: &'a EditorState,
}
impl escriba_madoguchi::CursorView for EditorWindow<'_> {
fn position(&self) -> Position {
self.state.cursor()
}
fn mode(&self) -> Mode {
self.state.modal.mode()
}
}
impl escriba_madoguchi::SyntaxView for EditorWindow<'_> {
fn filetype(&self) -> Option<&escriba_core::Filetype> {
let path = self.state.buffers.get(self.state.active)?.path.as_deref()?;
self.state.filetypes.resolve(path)
}
}
impl escriba_madoguchi::SearchView for EditorWindow<'_> {
fn pattern(&self) -> Option<&str> {
self.state.search.committed_pattern()
}
fn match_count(&self) -> Option<usize> {
self.state
.search
.committed_pattern()
.map(|_| self.state.search.match_count())
}
fn is_prompting(&self) -> bool {
self.state.search.is_prompting()
}
}
impl escriba_madoguchi::Snapshot for EditorWindow<'_> {
fn active(&self) -> Option<&dyn escriba_madoguchi::BufferView> {
self.buffer(self.state.active)
}
fn buffer(&self, id: BufferId) -> Option<&dyn escriba_madoguchi::BufferView> {
self.state
.buffers
.get(id)
.map(|b| b as &dyn escriba_madoguchi::BufferView)
}
fn buffer_ids(&self) -> Vec<BufferId> {
self.state.buffers.ids()
}
fn cursor(&self) -> &dyn escriba_madoguchi::CursorView {
self
}
fn option(&self, name: &str) -> Option<&str> {
self.state.options.get(name).map(String::as_str)
}
fn search(&self) -> &dyn escriba_madoguchi::SearchView {
self
}
fn syntax(&self) -> &dyn escriba_madoguchi::SyntaxView {
self
}
}
impl EditorState {
#[must_use]
pub fn window(&self) -> EditorWindow<'_> {
EditorWindow { state: self }
}
pub fn interpret(&mut self, outcome: Outcome) {
if let Some(m) = outcome.verdict.message() {
self.messages.push(m.to_string());
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
}
if outcome.verdict.is_failure() {
return;
}
for slip in outcome.slips {
self.honour(slip);
}
}
fn lower(action: &Action, active: BufferId) -> Option<Vec<Negai>> {
Some(match action {
Action::Quit => vec![Negai::Quit],
Action::ClearSearchHighlight => vec![Negai::ClearSearchHighlight],
Action::Save => vec![Negai::Save { buffer: active }],
Action::Undo => vec![Negai::Undo { buffer: active }],
Action::Redo => vec![Negai::Redo { buffer: active }],
Action::Edit(edit) => vec![Negai::Edit {
buffer: active,
edit: edit.clone(),
}],
_ => return None,
})
}
#[must_use]
pub fn world(&self) -> escriba_shirube::Anchor {
let mut a = escriba_shirube::Anchor::new();
for id in self.buffers.ids() {
if let Some(b) = self.buffers.get(id) {
a = a.on(escriba_shirube::Axis::Text(id, b.text_rev()));
}
}
a = a.on(escriba_shirube::Axis::Index(self.index_rev));
a = a.on(escriba_shirube::Axis::Session(
escriba_shirube::SessionKind::Lsp,
self.lsp_gen,
));
a.on(escriba_shirube::Axis::Session(
escriba_shirube::SessionKind::Scan,
self.scan_gen,
))
}
#[must_use]
pub fn spot(&self) -> escriba_core::Spot {
escriba_core::Spot::new(self.active, self.cursor())
}
fn goto_spot(&mut self, s: escriba_core::Spot) {
if s.buffer != self.active && self.buffers.get(s.buffer).is_some() {
self.active = s.buffer;
}
let clamped = self
.buffers
.get(self.active)
.map_or(s.pos, |b| b.clamp(s.pos));
self.set_cursor(clamped);
}
pub fn bump_index_rev(&mut self) {
self.index_rev = escriba_shirube::IndexRev(self.index_rev.0.wrapping_add(1));
}
pub fn bump_lsp_gen(&mut self) {
self.lsp_gen = escriba_shirube::SessionGen(self.lsp_gen.0.wrapping_add(1));
}
pub fn bump_scan_gen(&mut self) {
self.scan_gen = escriba_shirube::SessionGen(self.scan_gen.0.wrapping_add(1));
}
pub fn hire(&mut self, crew: escriba_madoguchi::errand::Crew) {
self.courier.hire(crew);
}
pub fn diagnose_open_buffers(&mut self) {
for id in self.buffers.ids() {
self.ask_for_diagnostics(id);
}
}
fn seal(
&self,
freight: &escriba_madoguchi::errand::Freight,
) -> escriba_shirube::NonEmptyAnchor {
use escriba_madoguchi::errand::Freight;
use escriba_shirube::{Axis, NonEmptyAnchor, SessionKind};
match freight {
Freight::Scan { .. } => {
NonEmptyAnchor::on(Axis::Session(SessionKind::Scan, self.scan_gen))
}
Freight::Diagnostics { buffer, .. } => {
let rev = self.buffers.get(*buffer).map_or_else(
escriba_buffer::TextRev::default,
escriba_buffer::Buffer::text_rev,
);
NonEmptyAnchor::on(Axis::Text(*buffer, rev))
.and(Axis::Session(SessionKind::Lsp, self.lsp_gen))
}
Freight::Format { path, .. } => match self.buffers.find_by_path(path) {
Some(id) => {
let rev = self.buffers.get(id).map_or_else(
escriba_buffer::TextRev::default,
escriba_buffer::Buffer::text_rev,
);
NonEmptyAnchor::on(Axis::Text(id, rev))
}
None => NonEmptyAnchor::on(Axis::Session(SessionKind::Lsp, self.lsp_gen)),
},
}
}
const DELIVER_BUDGET: usize = 64;
pub fn deliver(&mut self) {
let slips = self.courier.drain(Self::DELIVER_BUDGET);
if slips.is_empty() {
return;
}
for slip in slips {
self.honour_one(slip);
}
self.bump_gen();
}
fn walk_list(&mut self, list: &str, forward: bool) {
let world = self.world();
let Some(result) = self.results.get(list) else {
let mut m = String::from("no list named ");
m.push_str(list);
self.messages.push(m);
return;
};
if result.is_stale(&world) {
self.messages
.push("that list is out of date — run it again".to_string());
return;
}
let here = (Some(self.active), self.cursor().line);
let Some(found) = result.step(&world, here, forward, escriba_shirube::Bound::Exclusive)
else {
let mut m = String::from("no entries in ");
m.push_str(list);
self.messages.push(m);
return;
};
let site = found.site.clone();
let msg = found.message.clone();
self.jump_to_site(&site);
self.messages.push(msg);
}
pub fn jump_to_site(&mut self, site: &escriba_shirube::Site) {
self.jumps.push(self.spot());
if let Some(target) = site.buffer {
if target != self.active && self.buffers.get(target).is_some() {
self.active = target;
self.refollow_cursor();
}
}
let to = site.range.start;
let clamped = self.buffers.get(self.active).map_or(to, |b| b.clamp(to));
self.set_cursor(clamped);
}
fn close_buffer(&mut self, id: BufferId) {
if self.buffers.close(id).is_none() {
self.messages.push("no such buffer".to_string());
return;
}
if self.active != id {
return;
}
let next = self.buffers.ids().into_iter().find(|b| *b > id);
self.active = match next.or_else(|| self.buffers.ids().into_iter().next_back()) {
Some(b) => b,
None => self.buffers.scratch(""),
};
self.set_cursor(Position::ZERO);
if let Some(w) = self.layout.active_window_mut() {
w.buffer_id = self.active;
}
}
fn cycle_buffer(&mut self, forward: bool) {
let ids = self.buffers.ids();
if ids.len() < 2 {
self.messages.push("only one buffer".to_string());
return;
}
let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
let next = if forward {
(at + 1) % ids.len()
} else {
(at + ids.len() - 1) % ids.len()
};
self.active = ids[next];
self.set_cursor(Position::ZERO);
if let Some(w) = self.layout.active_window_mut() {
w.buffer_id = self.active;
}
}
fn refollow(&mut self) {
self.set_cursor(self.cursor());
}
fn honour(&mut self, slip: Negai) {
let touches_text = slip.touches_text();
self.honour_one(slip);
self.damage = self.damage.join(if touches_text {
Damage::Full
} else {
Damage::Viewport
});
self.bump_gen();
}
fn honour_one(&mut self, slip: Negai) {
match slip {
Negai::Edit { buffer, edit } => {
if let Some(b) = self.buffers.get_mut(buffer) {
let _ = b.apply(&edit);
}
self.refollow();
}
Negai::SetCursor { buffer, to } => {
let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
self.set_cursor(clamped);
}
Negai::EnterMode(m) => self.modal.enter(m),
Negai::OpenPicker(source) => self.open_picker(source),
Negai::SplitWindow { stacked } => {
let axis = if stacked {
escriba_ui::shikiri::Axis::Stacked
} else {
escriba_ui::shikiri::Axis::SideBySide
};
self.layout.split_active(axis);
self.refollow_cursor();
self.damage = self.damage.join(Damage::Viewport);
}
Negai::CloseWindow => {
let id = self.layout.active();
if self.layout.close(id) {
self.refollow_cursor();
self.damage = self.damage.join(Damage::Viewport);
} else {
self.messages
.push("E444: Cannot close last window".to_string());
}
}
Negai::FocusDir { dx, dy } => {
use escriba_ui::Dir;
let dir = match (dx, dy) {
(d, _) if d < 0 => Dir::Left,
(d, _) if d > 0 => Dir::Right,
(_, d) if d < 0 => Dir::Up,
_ => Dir::Down,
};
if let Some(id) = self.layout.neighbour(dir) {
self.layout.focus(id);
if let Some(w) = self.layout.active_window() {
self.active = w.buffer_id;
}
self.refollow_cursor();
self.damage = self.damage.join(Damage::Viewport);
}
}
Negai::GrepProject { pattern } => self.grep_project(&pattern),
Negai::FormatBuffer => self.ask_for_format(self.active),
Negai::ToggleBreakpoint => self.toggle_breakpoint(),
Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
Negai::FocusBuffer(id) => {
if self.buffers.get(id).is_some() {
self.active = id;
}
}
Negai::OpenPath(path) => match self.buffers.open(&path) {
Ok(id) => {
self.active = id;
self.ask_for_diagnostics(id);
}
Err(e) => self.messages.push(e.to_string()),
},
Negai::CloseBuffer(id) => self.close_buffer(id),
Negai::Save { buffer } => {
if let Some(b) = self.buffers.get_mut(buffer) {
if let Err(e) = b.save() {
self.messages.push(e.to_string());
}
}
self.refollow();
}
Negai::Undo { buffer } => {
if let Some(b) = self.buffers.get_mut(buffer) {
let _ = b.undo();
}
self.refollow();
}
Negai::Redo { buffer } => {
if let Some(b) = self.buffers.get_mut(buffer) {
let _ = b.redo();
}
self.refollow();
}
Negai::Yank { text, .. } => self.register = Some(text),
Negai::ClearSearchHighlight => self.search.clear_highlight(),
Negai::SetOption { name, value } => {
self.options.insert(name, value);
}
Negai::InsertText(text) => self.insert_text(&text),
Negai::RunCommand { name, args } => self.run_command(&name, &args),
Negai::PublishFindings { list, findings } => {
let world = self.world();
self.results
.publish(list, escriba_shirube::ResultList::new(findings, world));
}
Negai::PublishSemanticTokens { buffer, tokens } => {
let world = self.world();
self.semantic = Some(SemanticPaint::new(buffer, tokens, world));
}
Negai::ErrandReply { anchor, then } => {
if anchor.is_fresh(&self.world()) {
match *then {
Negai::PublishFindings { list, findings } => {
self.results.publish(
list.clone(),
escriba_shirube::ResultList::new(findings, anchor),
);
self.refresh_projected_picker(&list);
}
Negai::PublishSemanticTokens { buffer, tokens } => {
self.semantic = Some(SemanticPaint::new(buffer, tokens, anchor));
}
other => self.honour_one(other),
}
}
}
Negai::WalkList { list, forward } => self.walk_list(&list, forward),
Negai::Message(m) => self.messages.push(m),
Negai::Quit => self.quit_requested = true,
Negai::Errand(freight) => {
let anchor = self.seal(&freight);
self.courier.send(*freight, anchor);
}
Negai::AwaitKey { .. } => {
self.messages
.push("deferred work is not wired yet".to_string());
}
}
}
}
enum SeqStep {
Pending,
Resolved(Action),
Passthrough,
}
const fn is_repeat_storm_candidate(key: &Key) -> bool {
matches!(
key,
Key::Char('h')
| Key::Char('j')
| Key::Char('k')
| Key::Char('l')
| Key::Left
| Key::Right
| Key::Up
| Key::Down
)
}
fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
use escriba_command::CommandError as E;
match e {
E::Unhandled(_) => e.to_string(),
E::NotFound(n) if n.contains('.') => {
let mut m = String::with_capacity(n.len() + 48);
m.push('`');
m.push_str(n);
m.push_str("` is declared but not implemented yet");
m
}
_ => {
let _ = name;
e.to_string()
}
}
}
enum CommitOutcome {
Landed {
origin: usize,
step: escriba_search::Step,
},
NotFound,
NoPrevious,
NoPrompt,
}
#[derive(Debug, Clone)]
struct LastChange {
action: Action,
count: u32,
inserted: String,
}
impl EditorState {
pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
let window = Window {
id: WindowId(1),
buffer_id: active,
viewport: Viewport {
top_line: 0,
left_column: 0,
visible_lines: 40,
visible_columns: 160,
},
};
Self {
buffers: initial,
modal: ModalState::new(),
search: SearchState::new(escriba_search::CaseMode::Smart),
search_at: None,
last_change: None,
recording_insert: false,
jumps: JumpList::new(),
keymap: Keymap::default_vim(),
commands: CommandRegistry::default_set(),
layout: Layout::single(window),
active,
cursors: Cursors::single(Position::ZERO),
quit_requested: false,
register: None,
op_pending: zenmai::Stateful::new(OpState::Resting),
pending_object: None,
pending_find: None,
last_find: None,
pending_mark: None,
marks: HashMap::new(),
messages: Vec::new(),
options: HashMap::new(),
lisp_vm: None,
pending_keys: Vec::new(),
repeat_gate: KeyRepeatGate::new(),
plugin_host: PluginHost::default(),
edit_gen: EditGen::default(),
damage: Damage::None,
dispatch_depth: 0,
filetypes: escriba_core::FiletypeTable::new(),
results: escriba_shirube::ListRegistry::new(),
picker: None,
index_rev: escriba_shirube::IndexRev::default(),
lsp_gen: escriba_shirube::SessionGen::default(),
scan_gen: escriba_shirube::SessionGen::default(),
courier: courier::Courier::inert(),
picker_projects: None,
theme: FleetTheme::prescribed_default(),
chrome: ChromePalette::prescribed(),
splash: None,
semantic: None,
breakpoints: Breakpoints::default(),
}
}
#[must_use]
pub fn semantic_spans(&self, buffer: BufferId) -> &[escriba_madoguchi::SemanticSpan] {
self.semantic
.as_ref()
.map_or(&[][..], |p| p.fresh(&self.world(), buffer))
}
#[must_use]
pub fn gutter_marks(
&self,
world: &escriba_shirube::Anchor,
buffer: BufferId,
line: u32,
) -> escriba_ui::gutter::GutterMarks {
escriba_ui::gutter::GutterMarks::new(
self.results.worst_on_line(world, buffer, line),
self.breakpoints.is_set(buffer, line),
)
}
#[must_use]
pub const fn breakpoints(&self) -> &Breakpoints {
&self.breakpoints
}
#[must_use]
pub const fn theme(&self) -> FleetTheme {
self.theme
}
#[must_use]
pub const fn chrome(&self) -> ChromePalette {
self.chrome
}
pub fn set_theme(&mut self, theme: FleetTheme) {
if self.theme == theme {
return;
}
self.theme = theme;
self.chrome = ChromePalette::for_theme(theme);
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
}
#[must_use]
pub fn splash(&self) -> Option<&Splash> {
self.splash.as_ref()
}
pub fn set_splash(&mut self, splash: Splash) {
if splash.is_empty() {
return;
}
self.splash = Some(splash);
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
}
pub fn dismiss_splash(&mut self) {
if self.splash.take().is_some() {
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
}
}
#[must_use]
pub fn picker(&self) -> Option<&escriba_ui::picker::Picker> {
self.picker.as_ref()
}
fn ask_for_diagnostics(&mut self, buffer: escriba_core::BufferId) {
let Some(b) = self.buffers.get(buffer) else {
return;
};
let Some(path) = b.path.clone() else {
return;
};
let text = b.to_string();
let language = self.language_of(&path);
let freight = escriba_madoguchi::errand::Freight::Diagnostics {
buffer,
path,
language,
text,
};
let anchor = self.seal(&freight);
self.courier.send(freight, anchor);
}
fn language_of(&self, path: &std::path::Path) -> Option<String> {
self.filetypes.resolve(path).map(|f| f.name.clone())
}
fn ask_for_format(&mut self, buffer: escriba_core::BufferId) {
let Some(b) = self.buffers.get(buffer) else {
return;
};
let Some(path) = b.path.clone() else {
self.messages
.push("format: this buffer has no path".to_string());
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
return;
};
let text = b.to_string();
let language = self.language_of(&path);
let freight = escriba_madoguchi::errand::Freight::Format {
buffer,
path,
language,
text,
};
let anchor = self.seal(&freight);
self.courier.send(freight, anchor);
}
fn toggle_breakpoint(&mut self) {
let buffer = self.active;
let line = self.cursor().line;
if self.buffers.get(buffer).is_none() {
return;
}
let set = self.breakpoints.toggle(buffer, line);
let mut msg = String::from(if set {
"breakpoint set at line "
} else {
"breakpoint cleared at line "
});
msg.push_str(&(line + 1).to_string());
self.messages.push(msg);
}
fn close_picker(&mut self) {
self.picker = None;
self.picker_projects = None;
self.bump_scan_gen();
self.courier.cancel_all();
self.bump_gen();
}
fn consume_picker_key(&mut self, key: &Key) -> escriba_ui::picker::Consumed {
use escriba_ui::picker::Consumed;
let Some(p) = self.picker.as_mut() else {
return Consumed::NotShowing;
};
let outcome = p.on_key(key);
match &outcome {
Consumed::Dismissed | Consumed::Chose(_) => self.close_picker(),
Consumed::Held => self.bump_gen(),
Consumed::NotShowing => {}
}
outcome
}
fn honour_choice(&mut self, choice: escriba_ui::picker::Choice) {
use escriba_ui::picker::Choice;
let slip = match choice {
Choice::Buffer(id) => Negai::FocusBuffer(id),
Choice::Command(name) => Negai::RunCommand {
name,
args: Vec::new(),
},
Choice::OpenFile(path) => Negai::OpenPath(path),
Choice::Location { path, line } => {
self.interpret(Outcome::did(vec![Negai::OpenPath(path)]));
let site = escriba_shirube::Site::in_buffer(
self.active,
escriba_core::Range::new(
escriba_core::Position::new(line, 0),
escriba_core::Position::new(line, 1),
),
);
self.jump_to_site(&site);
return;
}
};
self.interpret(Outcome::did(vec![slip]));
}
const GREP_FILE_LIMIT: usize = 2_000;
fn walk_project(limit: usize) -> (Vec<std::path::PathBuf>, bool) {
Self::walk_from(std::path::Path::new("."), limit)
}
fn walk_from(root: &std::path::Path, limit: usize) -> (Vec<std::path::PathBuf>, bool) {
let mut out = Vec::new();
let mut truncated = false;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || name == "target" || name == "node_modules" {
continue;
}
let path = entry.path();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
stack.push(path);
continue;
}
if out.len() >= limit {
truncated = true;
return (out, truncated);
}
out.push(path);
}
}
(out, truncated)
}
fn report_truncation(&mut self, truncated: bool) {
if truncated {
self.messages
.push("scan stopped at the limit — results are INCOMPLETE".to_string());
}
}
fn grep_project(&mut self, pattern: &str) {
use escriba_ui::picker::{Picker, Source};
if pattern.is_empty() {
self.messages.push("grep: empty pattern".to_string());
return;
}
self.bump_scan_gen();
self.courier.cancel_all();
self.results.clear(crate::scan::LIST);
let freight = escriba_madoguchi::errand::Freight::Scan {
raw: pattern.to_string(),
case: escriba_search::CaseMode::Smart,
root: std::path::PathBuf::from("."),
};
let anchor = self.seal(&freight);
self.courier.send(freight, anchor);
self.picker = Some(Picker::open(Source::Grep, Vec::new()));
self.picker_projects = Some((true, Some(crate::scan::LIST.to_string())));
self.bump_gen();
}
fn refresh_projected_picker(&mut self, published: &str) {
let Some((workspace, ref list)) = self.picker_projects else {
return;
};
if list.as_deref().is_some_and(|l| l != published) {
return;
}
let items = self.finding_items(workspace, list.as_deref());
if let Some(p) = self.picker.as_mut() {
if p.refresh_items(items) {
self.bump_gen();
}
}
}
fn finding_choice(&self, f: &escriba_shirube::Finding) -> Option<escriba_ui::picker::Choice> {
use escriba_ui::picker::Choice;
let line = f.site.range.start.line;
if let Some(p) = &f.site.path {
return Some(Choice::Location {
path: p.clone(),
line,
});
}
let id = f.site.buffer?;
let b = self.buffers.get(id)?;
b.path.as_ref().map_or(Some(Choice::Buffer(id)), |p| {
Some(Choice::Location {
path: p.clone(),
line,
})
})
}
fn finding_label(&self, f: &escriba_shirube::Finding) -> String {
if let Some(p) = &f.site.path {
return p.to_string_lossy().into_owned();
}
f.site
.buffer
.and_then(|id| self.buffers.get(id))
.and_then(|b| b.path.as_ref())
.map_or_else(
|| String::from("[scratch]"),
|p| p.to_string_lossy().into_owned(),
)
}
fn file_items(
&mut self,
root: &std::path::Path,
) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
use escriba_ui::picker::{Choice, PickerItem};
let (files, truncated) = Self::walk_from(root, Self::GREP_FILE_LIMIT);
self.report_truncation(truncated);
files
.into_iter()
.map(|p| {
let label = p.to_string_lossy().into_owned();
PickerItem::new(Choice::OpenFile(p), label)
})
.collect()
}
fn finding_items(
&self,
workspace: bool,
only: Option<&str>,
) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
use escriba_ui::picker::PickerItem;
let world = self.world();
let active = Some(self.active);
let mut items = Vec::new();
let names: Vec<&str> = only.map_or_else(|| self.results.names(), |n| vec![n]);
for name in names {
let Some(list) = self.results.get(name) else {
continue;
};
for f in list.fresh(&world) {
if !workspace && f.site.buffer != active {
continue;
}
let Some(choice) = self.finding_choice(f) else {
continue;
};
let line = f.site.range.start.line;
let mut label = String::with_capacity(64);
label.push_str(f.severity.label());
label.push_str(" ");
label.push_str(&self.finding_label(f));
label.push(':');
label.push_str(&(line + 1).to_string());
label.push_str(" ");
label.push_str(&f.message);
items.push(PickerItem::new(choice, label));
}
}
items
}
fn open_picker(&mut self, source: escriba_madoguchi::PickerSource) {
use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
let (src, items) = match source {
escriba_madoguchi::PickerSource::Buffers => (
Source::Buffers,
self.buffers
.ids()
.into_iter()
.filter_map(|id| {
let b = self.buffers.get(id)?;
let label = b.path.as_ref().map_or_else(
|| String::from("[scratch]"),
|p| p.to_string_lossy().into_owned(),
);
Some(PickerItem::new(Choice::Buffer(id), label))
})
.collect::<Vec<_>>(),
),
escriba_madoguchi::PickerSource::Help => (
Source::Help,
self.keymap
.entries_sorted()
.into_iter()
.map(|(mode, key, b)| {
let mut label = String::with_capacity(48);
label.push_str(mode.as_str());
label.push_str(" ");
label.push_str(&format!("{key:?}"));
label.push_str(" ");
label.push_str(&b.description);
let choice = match &b.action {
escriba_core::Action::Command { name, .. } => {
Choice::Command(name.clone())
}
other => Choice::Command(format!("{other:?}")),
};
PickerItem::new(choice, label)
})
.collect::<Vec<_>>(),
),
escriba_madoguchi::PickerSource::Files => {
(Source::Files, self.file_items(std::path::Path::new(".")))
}
escriba_madoguchi::PickerSource::Project => {
const MARKERS: &[&str] = &[
"Cargo.toml",
"flake.nix",
"package.json",
"go.mod",
"pyproject.toml",
];
let (files, truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
self.report_truncation(truncated);
let mut roots: Vec<std::path::PathBuf> = files
.into_iter()
.filter(|p| {
p.file_name()
.is_some_and(|n| MARKERS.contains(&n.to_string_lossy().as_ref()))
})
.filter_map(|p| p.parent().map(std::path::Path::to_path_buf))
.collect();
roots.sort();
roots.dedup();
(
Source::Project,
roots
.into_iter()
.map(|p| {
let label = p.to_string_lossy().into_owned();
PickerItem::new(Choice::OpenFile(p), label)
})
.collect::<Vec<_>>(),
)
}
escriba_madoguchi::PickerSource::Commands => (
Source::Commands,
self.commands
.names()
.into_iter()
.map(|n| PickerItem::new(Choice::Command(n.to_string()), n.to_string()))
.collect::<Vec<_>>(),
),
escriba_madoguchi::PickerSource::FilesUnder(root) => {
(Source::Files, self.file_items(&root))
}
escriba_madoguchi::PickerSource::Findings { workspace } => {
(Source::Findings, self.finding_items(workspace, None))
}
};
if items.is_empty() {
self.messages.push("nothing to pick from".to_string());
return;
}
self.picker = Some(Picker::open(src, items));
self.bump_gen();
}
fn consume_object_key(&mut self, key: Key) -> Option<ObjectKey> {
use escriba_core::TextObject as O;
let Key::Char(c) = key else {
if self.pending_object.take().is_some() {
self.op_pending
.dispatch((Action::ChangeMode(Mode::Normal), 1));
return Some(ObjectKey::Consumed);
}
return None;
};
if let Some(around) = self.pending_object.take() {
let object = match c {
'w' => Some(O::Word { around }),
'(' | ')' | 'b' => Some(O::Delimited {
open: '(',
close: ')',
around,
}),
'{' | '}' | 'B' => Some(O::Delimited {
open: '{',
close: '}',
around,
}),
'[' | ']' => Some(O::Delimited {
open: '[',
close: ']',
around,
}),
'<' | '>' => Some(O::Delimited {
open: '<',
close: '>',
around,
}),
'"' => Some(O::Delimited {
open: '"',
close: '"',
around,
}),
'\'' => Some(O::Delimited {
open: '\'',
close: '\'',
around,
}),
'`' => Some(O::Delimited {
open: '`',
close: '`',
around,
}),
_ => None,
};
let OpState::Awaiting { op, count } = *self.op_pending.state() else {
return Some(ObjectKey::Consumed);
};
self.op_pending
.dispatch((Action::ChangeMode(Mode::Normal), 1));
let Some(object) = object else {
return Some(ObjectKey::Consumed);
};
let composed = Action::ApplyOperatorObject { op, object };
for _ in 1..count {
self.apply(&composed);
}
return Some(ObjectKey::Compose(composed));
}
if matches!(c, 'i' | 'a') && matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
self.pending_object = Some(c == 'a');
return Some(ObjectKey::Consumed);
}
None
}
fn consume_find_key(&mut self, key: Key) -> Option<ObjectKey> {
if let Some(spec) = self.pending_find.take() {
let Key::Char(ch) = key else {
if matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
self.op_pending
.dispatch((Action::ChangeMode(Mode::Normal), 1));
}
return Some(ObjectKey::Consumed);
};
let spec = FindSpec { ch, ..spec };
self.last_find = Some(spec);
return Some(ObjectKey::Compose(Action::Move(Motion::FindChar {
ch,
backward: spec.backward,
till: spec.till,
})));
}
if self.modal.mode() != Mode::Normal && self.modal.mode() != Mode::Visual {
return None;
}
if !self.pending_keys.is_empty() {
return None;
}
let Key::Char(c) = key else { return None };
let (backward, till) = match c {
'f' => (false, false),
'F' => (true, false),
't' => (false, true),
'T' => (true, true),
_ => return None,
};
self.pending_find = Some(FindSpec {
ch: '\0',
backward,
till,
});
Some(ObjectKey::Consumed)
}
fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
let Some(splash) = self.splash.as_ref() else {
return SplashKey::NotShowing;
};
let chosen = match key {
Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
_ => None,
};
self.dismiss_splash();
chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
}
#[must_use]
pub fn edit_gen(&self) -> EditGen {
self.edit_gen
}
fn bump_gen(&mut self) {
self.edit_gen = self.edit_gen.next();
}
#[must_use]
pub fn damage(&self) -> Damage {
self.damage
}
pub fn take_damage(&mut self) -> Damage {
std::mem::replace(&mut self.damage, Damage::None)
}
fn active_line_count(&self) -> u32 {
self.buffers
.get(self.active)
.map_or(0, escriba_buffer::Buffer::line_count)
}
pub fn register_lazy_plugin(
&mut self,
name: impl Into<String>,
triggers: Vec<LazyTrigger>,
entry_src: impl Into<String>,
) {
self.plugin_host.register(name, triggers, entry_src);
}
fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
return 0;
};
let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
if let Some(value) = self.options.get("mapleader") {
if let Some(key) = escriba_lisp::parse_leader_key(value) {
self.keymap.set_leader(key);
}
}
let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
(cmd.registered + km.keybinds_applied) as usize
}
pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
let pending = self.plugin_host.pending_for_filetype(filetype);
let n = pending.len();
for src in pending {
self.apply_plugin_entry(&src);
}
n
}
pub fn activate_event_plugins(&mut self, event: &str) -> usize {
let pending = self.plugin_host.pending_for_event(event);
let n = pending.len();
for src in pending {
self.apply_plugin_entry(&src);
}
n
}
pub fn tick(&mut self, event: &AppEvent) {
self.tick_at(event, Instant::now());
}
pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
match translate_app_event(event) {
InputOutcome::Key(k) => {
if self.gate_key(&k, now) {
self.on_key(&k);
}
}
InputOutcome::Resized { .. } => {
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
}
InputOutcome::Quit => self.quit_requested = true,
InputOutcome::Focus(_) | InputOutcome::None => {}
}
}
fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
match self.modal.mode() {
Mode::Normal | Mode::Visual | Mode::VisualLine => {
if is_repeat_storm_candidate(key) {
return self.repeat_gate.try_pass_at(*key, now);
}
true
}
Mode::Insert | Mode::Command => true,
}
}
pub fn on_key(&mut self, key: &Key) {
match self.consume_picker_key(key) {
escriba_ui::picker::Consumed::NotShowing => {}
escriba_ui::picker::Consumed::Held | escriba_ui::picker::Consumed::Dismissed => return,
escriba_ui::picker::Consumed::Chose(c) => {
self.honour_choice(c);
return;
}
}
match self.consume_splash_key(key) {
SplashKey::NotShowing | SplashKey::Dismissed => {}
SplashKey::Ran(action) => {
self.apply(&action);
return;
}
}
if let Some(outcome) = self.consume_mark_key(key.clone()) {
match outcome {
ObjectKey::Consumed => return,
ObjectKey::Compose(a) => {
let count = self.modal.pending_count().unwrap_or(1);
self.modal.clear_count();
self.apply_counted(&a, count);
return;
}
}
}
if let Some(action) = self.consume_object_key(*key) {
match action {
ObjectKey::Consumed => return,
ObjectKey::Compose(a) => {
self.apply(&a);
return;
}
}
}
if let Some(outcome) = self.consume_find_key(key.clone()) {
match outcome {
ObjectKey::Consumed => return,
ObjectKey::Compose(a) => {
let count = self.modal.pending_count().unwrap_or(1);
self.modal.clear_count();
self.apply_counted(&a, count);
return;
}
}
}
match self.step_sequence(key) {
SeqStep::Pending => return,
SeqStep::Resolved(action) => {
let count = self.modal.pending_count().unwrap_or(1);
self.modal.clear_count();
for _ in 0..count {
self.apply(&action);
if self.quit_requested {
return;
}
}
return;
}
SeqStep::Passthrough => {}
}
let counted = self.keymap.dispatch(&self.modal, key);
if matches!(counted.action, Action::Pending) {
if let Key::Char(c) = key {
if c.is_ascii_digit() {
let d = u32::from(*c as u8 - b'0');
self.modal.append_count(d);
}
}
return;
}
self.apply_counted(&counted.action, counted.count);
self.modal.clear_count();
}
fn step_sequence(&mut self, key: &Key) -> SeqStep {
let mode = self.modal.mode();
if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
return SeqStep::Passthrough;
}
if !self.pending_keys.is_empty() {
let mut seq = self.pending_keys.clone();
seq.push(key.clone());
if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
let action = b.action.clone();
self.pending_keys.clear();
return SeqStep::Resolved(action);
}
if self.keymap.is_sequence_prefix(mode, &seq) {
self.pending_keys = seq;
return SeqStep::Pending;
}
self.pending_keys.clear();
}
let start = [key.clone()];
if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
self.pending_keys = start.to_vec();
return SeqStep::Pending;
}
SeqStep::Passthrough
}
#[must_use]
pub fn cursor(&self) -> Position {
self.cursors.primary()
}
pub fn refollow_cursor(&mut self) {
self.set_cursor(self.cursors.primary());
}
fn set_cursor(&mut self, pos: Position) {
self.place_cursor(pos, CursorRest::OnCharacter);
}
fn place_cursor(&mut self, pos: Position, rest: CursorRest) {
let clamped = if let Some(buf) = self.buffers.get(self.active) {
let on_buffer = buf.clamp(pos);
if rest == CursorRest::OnCharacter && self.modal.mode() == Mode::Normal {
Position::new(
on_buffer.line,
on_buffer
.column
.min(buf.line_len_chars(on_buffer.line).saturating_sub(1)),
)
} else {
on_buffer
}
} else {
pos
};
self.cursors.set_primary(clamped);
if let Some(w) = self.layout.active_window_mut() {
w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
}
}
fn apply(&mut self, action: &Action) {
self.apply_counted(action, 1);
}
fn apply_counted(&mut self, action: &Action, count: u32) {
if matches!(action, Action::SubmitCommand) {
if let Some(e) = self.search.prompt_error() {
let mut m = String::from("E383: Invalid search string: ");
m.push_str(&e.to_string());
self.messages.push(m);
return;
}
}
let action = &match action {
Action::Move(Motion::Column(_)) => Action::Move(Motion::Column(count)),
a => a.clone(),
};
let count = match action {
Action::Move(Motion::Column(_)) => 1,
_ => count,
};
for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
if let Action::ApplyOperator { op, motion } = resolved {
self.apply_operator_n(op, motion, times);
if self.quit_requested {
return;
}
continue;
}
for _ in 0..times {
self.apply_resolved(&resolved);
if self.quit_requested {
return;
}
}
}
}
#[must_use]
fn text_rev(&self) -> TextRev {
self.buffers
.get(self.active)
.map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
}
fn active_text(&self) -> String {
self.buffers
.get(self.active)
.map(escriba_buffer::Buffer::to_string)
.unwrap_or_default()
}
fn cursor_char(&self) -> usize {
self.buffers
.get(self.active)
.and_then(|b| b.position_to_char(self.cursor()).ok())
.unwrap_or(0)
}
#[must_use]
pub fn status_model(&self) -> StatusModel<'_> {
let cursor = self.cursor();
let prompt = self.search.prompt();
let kind = match prompt.map(|p| p.direction) {
Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
None if self.modal.mode() == Mode::Command => PromptKind::Ex,
None => PromptKind::None,
};
StatusModel {
mode: self.modal.mode(),
line: cursor.line.saturating_add(1) as usize,
column: cursor.column.saturating_add(1) as usize,
prompt: kind,
prompt_text: prompt
.map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
prompt_caret: prompt.map_or_else(
|| self.modal.minibuffer_caret(),
escriba_search::Prompt::caret,
),
count: self.match_count(),
message: self.messages.last().map(String::as_str),
}
}
#[must_use]
fn match_count(&self) -> MatchCount {
if self.search.is_prompting() {
let text = self.active_text();
return match self.search.preview(&text) {
escriba_search::Preview::Landed { step, total } => {
MatchCount::new(step.index, total)
}
escriba_search::Preview::NoMatch => MatchCount::None,
escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
MatchCount::Idle
}
};
}
if self.search.pattern().is_none() {
return MatchCount::Idle;
}
let total = self.search.matches().len();
let rev = self.text_rev();
self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
if total == 0 {
MatchCount::None
} else {
MatchCount::Idle
},
|&i| MatchCount::new(i, total),
)
}
fn repeat_last_change(&mut self) {
let Some(change) = self.last_change.clone() else {
self.messages
.push("E32: No previous change to repeat".to_string());
return;
};
for _ in 0..change.count.max(1) {
self.apply_resolved(&change.action);
}
for c in change.inserted.chars() {
self.apply_resolved(&Action::InsertChar(c));
}
if self.modal.mode() == Mode::Insert {
self.apply_resolved(&Action::ChangeMode(Mode::Normal));
}
self.last_change = Some(change);
self.recording_insert = false;
}
fn object_line(&self) -> Option<Range> {
let buf = self.buffers.get(self.active)?;
let line = self.cursor().line;
let last = buf.line_count().saturating_sub(1);
if line < last {
Some(Range::new(
Position::new(line, 0),
Position::new(line + 1, 0),
))
} else if line > 0 {
Some(Range::new(
Position::new(line - 1, buf.line_len_chars(line - 1)),
Position::new(line, buf.line_len_chars(line)),
))
} else {
Some(Range::new(
Position::new(0, 0),
Position::new(0, buf.line_len_chars(0)),
))
}
}
fn object_word(&self, around: bool) -> Option<Range> {
let buf = self.buffers.get(self.active)?;
let pos = self.cursor();
let text: Vec<char> = buf.line(pos.line)?.chars().collect();
if text.is_empty() {
return None;
}
let col = (pos.column as usize).min(text.len().saturating_sub(1));
#[derive(PartialEq, Clone, Copy)]
enum Class {
Word,
Punct,
Space,
}
let class = |c: char| {
if c.is_alphanumeric() || c == '_' {
Class::Word
} else if c.is_whitespace() {
Class::Space
} else {
Class::Punct
}
};
let here = class(text[col]);
let mut start = col;
while start > 0 && class(text[start - 1]) == here {
start -= 1;
}
let mut end = col + 1;
while end < text.len() && class(text[end]) == here {
end += 1;
}
if around {
let after = end;
while end < text.len() && class(text[end]) == Class::Space {
end += 1;
}
if end == after {
while start > 0 && class(text[start - 1]) == Class::Space {
start -= 1;
}
}
}
Some(Range::new(
Position::new(pos.line, start as u32),
Position::new(pos.line, end as u32),
))
}
fn object_delimited(&self, open: char, close: char, around: bool) -> Option<Range> {
let buf = self.buffers.get(self.active)?;
let pos = self.cursor();
let text: Vec<char> = buf.line(pos.line)?.chars().collect();
if text.is_empty() {
return None;
}
let col = (pos.column as usize).min(text.len().saturating_sub(1));
let (l, r) = if open == close {
let l = (0..=col).rev().find(|&i| text[i] == open)?;
let r = ((col.max(l) + 1)..text.len()).find(|&i| text[i] == close)?;
(l, r)
} else {
let mut depth = 0i32;
let l = (0..=col).rev().find(|&i| {
if text[i] == close && i != col {
depth += 1;
false
} else if text[i] == open {
if depth == 0 {
true
} else {
depth -= 1;
false
}
} else {
false
}
})?;
depth = 0;
let r = ((l + 1)..text.len()).find(|&i| {
if text[i] == open {
depth += 1;
false
} else if text[i] == close {
if depth == 0 {
true
} else {
depth -= 1;
false
}
} else {
false
}
})?;
(l, r)
};
let (s, e) = if around { (l, r + 1) } else { (l + 1, r) };
Some(Range::new(
Position::new(pos.line, s as u32),
Position::new(pos.line, e as u32),
))
}
fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
use escriba_core::TextObject as O;
match object {
O::Line => return self.object_line(),
O::Word { around } => return self.object_word(around),
O::Delimited {
open,
close,
around,
} => return self.object_delimited(open, close, around),
O::NextMatch | O::PrevMatch => {}
}
let at = self.cursor_char();
let matches = self.search.matches();
let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
match object {
O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
_ => Bound::Inclusive.first_matching(&starts, at, true),
}
})?;
let m = matches.get(idx)?;
let buf = self.buffers.get(self.active)?;
Some(Range {
start: buf.char_to_position(m.start),
end: buf.char_to_position(m.end),
})
}
fn land_on(&mut self, step: escriba_search::Step) {
if let Some(buf) = self.buffers.get(self.active) {
let pos = buf.char_to_position(step.target.start);
self.set_cursor(pos);
}
self.search_at = Some(Anchored::new(step.index, self.text_rev()));
}
fn report_wrap(&mut self, step: &escriba_search::Step) {
if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
self.messages.push(msg.to_string());
}
}
fn jump_search(&mut self, reverse: bool) {
self.search.relight();
self.jumps.push(self.spot());
let at = self.cursor_char();
match self.search.repeat(at, reverse) {
Some(step) => {
self.report_wrap(&step);
self.land_on(step);
}
None => {
let msg = self.search.pattern().map_or_else(
|| "E35: No previous regular expression".to_string(),
|p| {
let mut m = String::from("E486: Pattern not found: ");
m.push_str(p.raw());
m
},
);
self.messages.push(msg);
}
}
}
fn preview_search(&mut self) {
let text = self.active_text();
let Some(origin) = self.search.prompt().map(|p| p.origin) else {
return;
};
let target = match self.search.preview(&text) {
escriba_search::Preview::Landed { step, .. } => step.target.start,
escriba_search::Preview::Idle
| escriba_search::Preview::Incomplete
| escriba_search::Preview::NoMatch => origin,
};
if let Some(buf) = self.buffers.get(self.active) {
let pos = buf.char_to_position(target);
self.set_cursor(pos);
}
}
fn commit_search_prompt(&mut self) -> CommitOutcome {
let text = self.active_text();
let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
else {
return CommitOutcome::NoPrompt;
};
match self.search.accept(&text) {
escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
self.modal.clear_minibuffer();
self.modal.enter(Mode::Normal);
match self.search.commit_step_skipping(origin, skip) {
Some(step) => {
self.report_wrap(&step);
CommitOutcome::Landed { origin, step }
}
None => {
self.report_pattern_not_found();
CommitOutcome::NotFound
}
}
}
escriba_search::Accepted::NothingToRepeat => {
self.modal.clear_minibuffer();
self.modal.enter(Mode::Normal);
self.messages
.push("E35: No previous regular expression".to_string());
CommitOutcome::NoPrevious
}
escriba_search::Accepted::Invalid(e) => {
let mut m = String::from("E383: Invalid search string: ");
m.push_str(&e.to_string());
self.messages.push(m);
CommitOutcome::NoPrompt
}
}
}
fn report_pattern_not_found(&mut self) {
let mut m = String::from("E486: Pattern not found");
if let Some(p) = self.search.pattern() {
m.push_str(": ");
m.push_str(p.raw());
}
self.messages.push(m);
}
fn submit_search(&mut self) {
match self.commit_search_prompt() {
CommitOutcome::Landed { origin, step } => {
if let Some(buf) = self.buffers.get(self.active) {
let from = buf.char_to_position(origin);
self.jumps.push(escriba_core::Spot::new(self.active, from));
}
self.land_on(step);
}
CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
}
}
fn submit_search_operated(&mut self, op: Operator) {
match self.commit_search_prompt() {
CommitOutcome::Landed { origin, step } => {
if let Some(buf) = self.buffers.get(self.active) {
let from = buf.char_to_position(origin);
let target = buf.char_to_position(step.target.start);
self.jumps.push(escriba_core::Spot::new(self.active, from));
self.set_cursor(from);
self.apply_operator_to(op, target);
}
}
CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
}
}
fn apply_resolved(&mut self, action: &Action) {
let lines_before = self.active_line_count();
let rev_before = self.text_rev();
let cline_before = self.cursor().line;
match action {
Action::Quit
| Action::ClearSearchHighlight
| Action::Save
| Action::Undo
| Action::Redo
| Action::Edit(_) => {
for slip in Self::lower(action, self.active).unwrap_or_default() {
self.honour_one(slip);
}
}
Action::Move(m) => self.apply_motion(*m),
Action::SearchOpen(dir) => {
let origin = self.cursor_char();
self.search.open(*dir, origin);
self.modal.enter(Mode::Command);
}
Action::SearchRepeat { reverse } => self.jump_search(*reverse),
Action::SearchWord { reverse } => {
let dir = if *reverse {
SearchDirection::Backward
} else {
SearchDirection::Forward
};
let (text, at) = (self.active_text(), self.cursor_char());
self.jumps.push(self.spot());
match self.search.search_word(&text, at, dir) {
Some(step) => self.land_on(step),
None => self
.messages
.push("E348: No string under cursor".to_string()),
}
}
Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
Action::TextObject(object) => {
if let Some(range) = self.resolve_object(*object) {
self.jumps.push(self.spot());
self.set_cursor(range.start);
} else {
self.report_pattern_not_found();
}
}
Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
Some(range) => self.apply_operator_over(*op, range),
None => self.report_pattern_not_found(),
},
Action::RepeatLastChange => self.repeat_last_change(),
Action::JumpBack => {
let here = self.spot();
if let Some(spot) = self.jumps.back(here) {
self.goto_spot(spot);
} else {
self.messages
.push("E662: At start of changelist".to_string());
}
}
Action::JumpForward => {
if let Some(spot) = self.jumps.forward() {
self.goto_spot(spot);
} else {
self.messages.push("E663: At end of changelist".to_string());
}
}
Action::ChangeMode(m) => {
if *m == Mode::Normal && self.search.is_prompting() {
if let Some(origin) = self.search.cancel() {
if let Some(buf) = self.buffers.get(self.active) {
let pos = buf.char_to_position(origin);
self.set_cursor(pos);
}
}
}
self.modal.enter(*m);
}
Action::EnterInsert(at) => self.enter_insert_at(*at),
Action::InsertChar(c) => self.insert_char(*c),
Action::SubmitCommand => {
if self.search.is_prompting() {
self.submit_search();
} else {
self.submit_command();
}
}
Action::Command { name, args } => self.run_command(name, args),
Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
Action::Operator(_) => {}
Action::PromptCaret { to } => {
if self.search.is_prompting() {
self.search.move_caret(*to);
} else {
self.modal.move_minibuffer_caret(*to);
}
}
Action::SearchPreviewStep { forward } => {
if self.search.is_prompting() {
self.search.preview_step(*forward);
self.preview_search();
}
}
Action::DeleteForward => {
if self.modal.mode() == Mode::Command {
if self.search.is_prompting() {
self.search.delete_at_caret();
self.preview_search();
} else {
self.modal.delete_minibuffer_at_caret();
}
} else {
self.delete_after_cursor();
}
}
Action::DeleteWordBefore => {
if self.modal.mode() == Mode::Command {
if self.search.is_prompting() {
self.search.delete_word_before_caret();
self.preview_search();
}
} else {
self.delete_word_before_cursor();
}
}
Action::DeleteToLineStart => {
if self.modal.mode() == Mode::Command {
if self.search.is_prompting() {
self.search.clear_before_caret();
self.preview_search();
}
} else {
self.delete_to_line_start();
}
}
Action::Backspace => {
if self.modal.mode() == Mode::Command {
self.prompt_backspace();
if self.search.is_prompting() {
self.preview_search();
}
} else {
self.delete_before_cursor();
}
}
Action::PromptHistory { back } => {
if self.search.is_prompting() {
self.search.history_step(*back);
self.preview_search();
}
}
Action::SetMark(name) => {
if name.is_ascii_lowercase() {
let at = self.cursor();
self.marks.insert(*name, at);
} else {
self.messages
.push(format!("E191: mark `{name}` is not a-z"));
}
}
Action::ScrollView(align) => self.scroll_view(*align),
Action::Pending => {}
}
let lines_after = self.active_line_count();
let cline_after = self.cursor().line;
let d = match action {
Action::SearchOpen(_)
| Action::PromptHistory { .. }
| Action::Backspace
| Action::PromptCaret { .. }
| Action::SearchPreviewStep { .. }
| Action::DeleteForward
| Action::DeleteWordBefore
| Action::DeleteToLineStart
| Action::SearchRepeat { .. }
| Action::SearchWord { .. }
| Action::ClearSearchHighlight
| Action::SearchSubmitOperated { .. }
| Action::RepeatLastChange
| Action::TextObject(_)
| Action::ApplyOperatorObject { .. }
| Action::JumpBack
| Action::JumpForward
| Action::ScrollView(_) => Damage::Full,
Action::InsertChar(_)
| Action::Edit(_)
| Action::Undo
| Action::Redo
| Action::EnterInsert(_)
| Action::ApplyOperator { .. } => {
if lines_after == lines_before {
Damage::span(cline_before, cline_after)
} else {
Damage::Lines {
from: cline_before.min(cline_after),
to: u32::MAX,
}
}
}
Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
Action::Save => Damage::Viewport,
Action::Command { .. } | Action::SubmitCommand => Damage::Full,
Action::Quit | Action::Operator(_) | Action::SetMark(_) | Action::Pending => {
Damage::None
}
};
self.damage = self.damage.join(d);
if self.recording_insert {
match action {
Action::InsertChar(c) => {
if let Some(lc) = self.last_change.as_mut() {
lc.inserted.push(*c);
}
}
Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
_ => {}
}
} else if self.text_rev() != rev_before
&& !matches!(
action,
Action::RepeatLastChange | Action::Undo | Action::Redo
)
{
self.last_change = Some(LastChange {
action: action.clone(),
count: 1,
inserted: String::new(),
});
self.recording_insert = self.modal.mode() == Mode::Insert;
}
if action.highlight_effect() == HighlightEffect::Clear {
self.search.clear_highlight();
}
if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
let text = self.active_text();
self.search.refresh(&text);
}
self.bump_gen();
}
fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
let buf = self.buffers.get(self.active)?;
let pos = from;
Some(match motion {
Motion::SearchNext | Motion::SearchPrev => {
let at = buf.position_to_char(pos).ok()?;
let step = self
.search
.repeat(at, matches!(motion, Motion::SearchPrev))?;
buf.char_to_position(step.target.start)
}
Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
Motion::LineStart => Position::new(pos.line, 0),
Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
Motion::LineLastNonBlank => {
let chars = line_chars(buf, pos.line);
let col = chars
.iter()
.rposition(|c| !c.is_whitespace())
.and_then(|i| u32::try_from(i).ok())
.unwrap_or(0);
Position::new(pos.line, col)
}
Motion::Column(n) => Position::new(
pos.line,
n.saturating_sub(1).min(buf.line_len_chars(pos.line)),
),
Motion::LineDownFirstNonBlank => {
first_non_blank(buf, pos.line.saturating_add(1).min(last_text_line(buf)))
}
Motion::LineUpFirstNonBlank => first_non_blank(buf, pos.line.saturating_sub(1)),
Motion::DocStart => Position::ZERO,
Motion::DocEnd => Position::new(
buf.line_count().saturating_sub(1),
buf.line_len_chars(buf.line_count().saturating_sub(1)),
),
Motion::WordStartNext => word_next(buf, pos, Width::Small),
Motion::WordEndNext => word_end(buf, pos, Width::Small),
Motion::WordStartPrev => word_prev(buf, pos, Width::Small),
Motion::WordEndPrev => word_end_prev(buf, pos, Width::Small),
Motion::BigWordStartNext => word_next(buf, pos, Width::Big),
Motion::BigWordEndNext => word_end(buf, pos, Width::Big),
Motion::BigWordStartPrev => word_prev(buf, pos, Width::Big),
Motion::BigWordEndPrev => word_end_prev(buf, pos, Width::Big),
Motion::FindChar { ch, backward, till } => find_char(buf, pos, ch, backward, till)?,
Motion::RepeatFind { reverse } => {
let last = self.last_find?;
let backward = last.backward != reverse;
find_char(buf, pos, last.ch, backward, last.till)?
}
Motion::MatchPair => self.resolve_match(buf, pos)?,
Motion::MarkExact(name) => {
let at = *self.marks.get(&name)?;
Position::new(
at.line.min(last_text_line(buf)),
at.column
.min(buf.line_len_chars(at.line.min(last_text_line(buf)))),
)
}
Motion::MarkLine(name) => {
let at = *self.marks.get(&name)?;
first_non_blank(buf, at.line.min(last_text_line(buf)))
}
Motion::ParagraphNext => paragraph(buf, pos, true),
Motion::ParagraphPrev => paragraph(buf, pos, false),
Motion::SentenceNext => sentence(buf, pos, true),
Motion::SentencePrev => sentence(buf, pos, false),
Motion::ScreenTop | Motion::ScreenMiddle | Motion::ScreenBottom => {
let vp = self.layout.active_window().map_or(
Viewport {
top_line: 0,
left_column: 0,
visible_lines: 1,
visible_columns: 1,
},
|w| w.viewport,
);
let last = last_text_line(buf);
let bottom = vp
.top_line
.saturating_add(vp.visible_lines.saturating_sub(1))
.min(last);
let line = match motion {
Motion::ScreenTop => vp.top_line.min(last),
Motion::ScreenBottom => bottom,
_ => vp.top_line.min(last) + (bottom - vp.top_line.min(last)) / 2,
};
first_non_blank(buf, line)
}
Motion::PageDown | Motion::HalfPageDown => {
Position::new(pos.line.saturating_add(10), pos.column)
}
Motion::PageUp | Motion::HalfPageUp => {
Position::new(pos.line.saturating_sub(10), pos.column)
}
Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
Motion::ForwardSexp
| Motion::BackwardSexp
| Motion::UpList
| Motion::DownList
| Motion::BeginningOfDefun
| Motion::EndOfDefun
| Motion::BeginningOfSexp
| Motion::EndOfSexp => pos,
})
}
fn scroll_view(&mut self, align: escriba_core::ViewAlign) {
use escriba_core::ViewAlign;
let line = self.cursor().line;
let Some(w) = self.layout.active_window_mut() else {
return;
};
let h = w.viewport.visible_lines.max(1);
w.viewport.top_line = match align {
ViewAlign::Top => line,
ViewAlign::Center => line.saturating_sub(h / 2),
ViewAlign::Bottom => line.saturating_sub(h.saturating_sub(1)),
};
self.damage = self.damage.join(Damage::Full);
self.bump_gen();
}
fn consume_mark_key(&mut self, key: Key) -> Option<ObjectKey> {
if let Some(kind) = self.pending_mark.take() {
let Key::Char(name) = key else {
if matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
self.op_pending
.dispatch((Action::ChangeMode(Mode::Normal), 1));
}
return Some(ObjectKey::Consumed);
};
return Some(ObjectKey::Compose(match kind {
MarkKey::Set => Action::SetMark(name),
MarkKey::GotoExact => Action::Move(Motion::MarkExact(name)),
MarkKey::GotoLine => Action::Move(Motion::MarkLine(name)),
}));
}
if !matches!(self.modal.mode(), Mode::Normal | Mode::Visual) {
return None;
}
if self.pending_object.is_some() || !self.pending_keys.is_empty() {
return None;
}
let Key::Char(c) = key else { return None };
let kind = match c {
'm' => MarkKey::Set,
'`' => MarkKey::GotoExact,
'\'' => MarkKey::GotoLine,
_ => return None,
};
self.pending_mark = Some(kind);
Some(ObjectKey::Consumed)
}
fn resolve_match(&self, buf: &escriba_buffer::Buffer, pos: Position) -> Option<Position> {
let Some(pairs) = self.word_pairs_for_active() else {
return match_pair(buf, pos);
};
let bracket_col = line_chars(buf, pos.line)
.into_iter()
.enumerate()
.skip(pos.column as usize)
.find(|(_, c)| MATCH_PAIRS.iter().any(|&(o, cl)| *c == o || *c == cl))
.and_then(|(i, _)| u32::try_from(i).ok());
let word_col = word_hits(buf, pos.line, pairs)
.into_iter()
.find(|h| h.end > pos.column)
.map(|h| h.col);
match (bracket_col, word_col) {
(Some(b), Some(w)) if w < b => match_word_pair(buf, pos, pairs),
(Some(_), _) => match_pair(buf, pos),
(None, Some(_)) => match_word_pair(buf, pos, pairs),
(None, None) => None,
}
}
fn word_pairs_for_active(&self) -> Option<WordPairs> {
let path = self.buffers.get(self.active)?.path.as_deref()?;
let name = &self.filetypes.resolve(path)?.name;
WORD_PAIRS
.iter()
.find(|(ft, _)| ft == name)
.map(|(_, pairs)| *pairs)
}
fn apply_motion(&mut self, motion: Motion) {
if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
self.jump_search(matches!(motion, Motion::SearchPrev));
return;
}
let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
return;
};
self.set_cursor(pos);
}
fn apply_operator_n(&mut self, op: Operator, motion: Motion, n: u32) {
if n <= 1 {
self.apply_operator(op, motion);
return;
}
let from = self.cursor();
let mut to = from;
for _ in 0..n {
match self.resolve_motion(to, motion) {
Some(next) if next != to => to = next,
_ => break,
}
}
if to == from {
self.apply_operator(op, motion);
return;
}
self.apply_operator_to(op, self.operated_end(motion, to));
}
fn operated_end(&self, motion: Motion, to: Position) -> Position {
let motion = match motion {
Motion::RepeatFind { reverse } => match self.last_find {
Some(f) => Motion::FindChar {
ch: f.ch,
backward: f.backward != reverse,
till: f.till,
},
None => return to,
},
m => m,
};
if !motion.is_inclusive() {
return to;
}
let line_len = self
.buffers
.get(self.active)
.map_or(to.column, |b| b.line_len_chars(to.line));
Position::new(to.line, to.column.saturating_add(1).min(line_len))
}
fn apply_operator(&mut self, op: Operator, motion: Motion) {
let from = self.cursor();
let Some(to) = self.resolve_motion(from, motion) else {
if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
if self.search.pattern().is_none() {
self.messages
.push("E35: No previous regular expression".to_string());
} else {
self.report_pattern_not_found();
}
}
return;
};
self.apply_operator_to(op, self.operated_end(motion, to));
}
fn apply_operator_to(&mut self, op: Operator, to: Position) {
let from = self.cursor();
self.apply_operator_over(
op,
Range {
start: from,
end: to,
},
);
}
fn apply_operator_over(&mut self, op: Operator, range: Range) {
let range = range.normalized();
if range.is_empty() {
return;
}
let text = self
.buffers
.get(self.active)
.and_then(|buf| buf.slice(range).ok());
if op.leaves_register() {
if let Some(t) = &text {
self.register = Some(t.clone());
}
}
match op {
Operator::Delete | Operator::Change => {
if let Some(buf) = self.buffers.get_mut(self.active) {
let _ = buf.apply(&Edit::delete(range));
}
self.set_cursor(range.start);
if op == Operator::Change {
self.modal.enter(Mode::Insert);
}
}
Operator::Yank => {
self.set_cursor(range.start);
}
_ => {
self.messages
.push("operator not yet implemented".to_owned());
}
}
}
#[must_use]
pub fn register(&self) -> Option<&str> {
self.register.as_deref()
}
fn insert_char(&mut self, c: char) {
if self.modal.mode() == Mode::Command {
if self.search.is_prompting() {
self.search.push(c);
self.preview_search();
} else {
self.modal.push_minibuffer(c);
}
return;
}
let cursor = self.cursor();
let Some(buf) = self.buffers.get_mut(self.active) else {
return;
};
let edit = Edit::insert(cursor, c.to_string());
if buf.apply(&edit).is_ok() {
let next = if c == '\n' {
Position::new(cursor.line.saturating_add(1), 0)
} else {
cursor.shift_right(1)
};
self.place_cursor(next, CursorRest::AtInsertPoint);
}
}
fn enter_insert_at(&mut self, at: InsertAt) {
self.modal.enter_insert();
let cursor = self.cursor();
let Some(buf) = self.buffers.get(self.active) else {
return;
};
let line_len = buf.line_len_chars(cursor.line);
let target = match at {
InsertAt::Caret => Some(cursor),
InsertAt::AfterCaret => Some(Position::new(
cursor.line,
cursor.column.saturating_add(1).min(line_len),
)),
InsertAt::LineEnd => Some(Position::new(cursor.line, line_len)),
InsertAt::FirstNonBlank => Some(first_non_blank(buf, cursor.line)),
InsertAt::OpenBelow | InsertAt::OpenAbove => None,
};
if let Some(pos) = target {
self.place_cursor(pos, CursorRest::AtInsertPoint);
return;
}
let (at_pos, land_on) = match at {
InsertAt::OpenBelow => (
Position::new(cursor.line, line_len),
Position::new(cursor.line.saturating_add(1), 0),
),
_ => (Position::new(cursor.line, 0), Position::new(cursor.line, 0)),
};
let Some(buf) = self.buffers.get_mut(self.active) else {
return;
};
if buf.apply(&Edit::insert(at_pos, "\n")).is_ok() {
self.place_cursor(land_on, CursorRest::AtInsertPoint);
}
}
fn delete_before_cursor(&mut self) {
let cursor = self.cursor();
let Some(buf) = self.buffers.get(self.active) else {
return;
};
let target = if cursor.column > 0 {
Position::new(cursor.line, cursor.column.saturating_sub(1))
} else if cursor.line > 0 {
let above = cursor.line.saturating_sub(1);
Position::new(above, buf.line_len_chars(above))
} else {
return;
};
self.erase_back_to(target);
}
fn erase_back_to(&mut self, target: Position) {
let cursor = self.cursor();
if (target.line, target.column) >= (cursor.line, cursor.column) {
return;
}
let edit = Edit::delete(Range {
start: target,
end: cursor,
});
if let Some(buf) = self.buffers.get_mut(self.active) {
if buf.apply(&edit).is_ok() {
self.set_cursor(target);
}
}
}
fn delete_word_before_cursor(&mut self) {
let cursor = self.cursor();
let Some(target) = self.resolve_motion(cursor, Motion::WordStartPrev) else {
return;
};
if (target.line, target.column) >= (cursor.line, cursor.column) {
self.delete_before_cursor();
return;
}
self.erase_back_to(target);
}
fn delete_to_line_start(&mut self) {
let cursor = self.cursor();
let Some(indent) = self.resolve_motion(cursor, Motion::LineFirstNonBlank) else {
return;
};
let target = if (indent.line, indent.column) < (cursor.line, cursor.column) {
indent
} else {
Position::new(cursor.line, 0)
};
self.erase_back_to(target);
}
fn delete_after_cursor(&mut self) {
let cursor = self.cursor();
let Some(buf) = self.buffers.get(self.active) else {
return;
};
let target = if cursor.column < buf.line_len_chars(cursor.line) {
Position::new(cursor.line, cursor.column.saturating_add(1))
} else if cursor.line.saturating_add(1) < buf.line_count() {
Position::new(cursor.line.saturating_add(1), 0)
} else {
return;
};
let edit = Edit::delete(Range {
start: cursor,
end: target,
});
if let Some(buf) = self.buffers.get_mut(self.active) {
let _ = buf.apply(&edit);
}
}
fn prompt_backspace(&mut self) -> bool {
if self.modal.mode() != Mode::Command {
return false;
}
if self.search.is_prompting() {
if self.search.backspace() {
self.modal.clear_minibuffer();
self.modal.enter(Mode::Normal);
}
return true;
}
self.modal.pop_minibuffer();
true
}
fn submit_command(&mut self) {
let line = self.modal.minibuffer().to_string();
self.modal.escape();
let Some(inv) = escriba_command::ex::parse(&line) else {
return;
};
self.run_command(&inv.command, &inv.args);
}
fn run_command(&mut self, name: &str, args: &[String]) {
if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
let mut m = String::from("command recursion too deep at `");
m.push_str(name);
m.push_str("` — refusing");
self.messages.push(m);
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
return;
}
self.dispatch_depth += 1;
self.run_command_inner(name, args);
self.dispatch_depth -= 1;
}
const MAX_DISPATCH_DEPTH: u8 = 8;
fn run_command_inner(&mut self, name: &str, args: &[String]) {
if self.plugin_host.pending() > 0 {
let pending = self.plugin_host.pending_for_command(name);
for src in pending {
self.apply_plugin_entry(&src);
}
}
let outcome = {
let window = self.window();
self.commands.run(name, &window, args)
};
match outcome {
Ok(o) => self.interpret(o),
Err(e) => {
self.messages.push(describe_command_failure(name, &e));
self.damage = self.damage.join(Damage::Viewport);
self.bump_gen();
}
}
}
#[must_use]
pub fn snapshot(&self) -> EditorSnapshot {
let current_line = self
.buffers
.get(self.active)
.and_then(|b| b.line(self.cursor().line))
.map(|s| s.trim_end_matches('\n').to_string())
.unwrap_or_default();
let buffer_name = self
.buffers
.get(self.active)
.and_then(|b| b.path.as_ref())
.map(|p| p.display().to_string())
.unwrap_or_else(|| "[scratch]".to_string());
EditorSnapshot {
cursor_line: i64::from(self.cursor().line),
cursor_column: i64::from(self.cursor().column),
current_line,
mode: self.modal.mode().as_str().to_string(),
buffer_name,
}
}
pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
let mut host = EscribaHost::with_snapshot(self.snapshot());
let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
vm.eval(src, &mut host)?;
let effects = host.take_effects();
self.apply_host_effects(effects);
Ok(())
}
pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
self.interpret(Outcome::did(effects));
}
fn insert_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
let cursor = self.cursor();
let Some(buf) = self.buffers.get_mut(self.active) else {
return;
};
let edit = Edit::insert(cursor, text.to_string());
if buf.apply(&edit).is_ok() {
let next = if let Some(nl) = text.rfind('\n') {
let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
Position::new(cursor.line + added_lines, last_line_len)
} else {
let n = u32::try_from(text.chars().count()).unwrap_or(0);
cursor.shift_right(n)
};
self.place_cursor(next, CursorRest::AtInsertPoint);
}
}
}
fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
let Some(text) = buf.line(line) else {
return Position::new(line, 0);
};
let col = text
.chars()
.take_while(|c| c.is_whitespace() && *c != '\n')
.count();
Position::new(line, u32::try_from(col).unwrap_or(0))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FindSpec {
ch: char,
backward: bool,
till: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkKey {
Set,
GotoExact,
GotoLine,
}
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
enum CursorRest {
OnCharacter,
AtInsertPoint,
}
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
enum WordClass {
Word,
Punct,
Space,
}
fn word_class(c: char) -> WordClass {
if c.is_alphanumeric() || c == '_' {
WordClass::Word
} else if c.is_whitespace() {
WordClass::Space
} else {
WordClass::Punct
}
}
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
enum Width {
Small,
Big,
}
fn class_at(c: char, width: Width) -> WordClass {
match (width, word_class(c)) {
(Width::Big, WordClass::Punct) => WordClass::Word,
(_, k) => k,
}
}
fn line_chars(buf: &escriba_buffer::Buffer, line: u32) -> Vec<char> {
let Some(text) = buf.line(line) else {
return Vec::new();
};
let len = buf.line_len_chars(line) as usize;
text.chars().take(len).collect()
}
fn last_text_line(buf: &escriba_buffer::Buffer) -> u32 {
let last = buf.line_count().saturating_sub(1);
if last > 0 && buf.line_len_chars(last) == 0 {
last - 1
} else {
last
}
}
fn buffer_end(buf: &escriba_buffer::Buffer) -> Position {
let line = last_text_line(buf);
Position::new(line, buf.line_len_chars(line))
}
fn word_next(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
let mut line = pos.line;
let mut chars = line_chars(buf, line);
let mut col = (pos.column as usize).min(chars.len());
if col < chars.len() {
let start = class_at(chars[col], width);
if start != WordClass::Space {
while col < chars.len() && class_at(chars[col], width) == start {
col += 1;
}
}
}
loop {
while col < chars.len() && class_at(chars[col], width) == WordClass::Space {
col += 1;
}
if col < chars.len() {
return Position::new(line, u32::try_from(col).unwrap_or(pos.column));
}
if line >= last_text_line(buf) {
return Position::new(line, u32::try_from(chars.len()).unwrap_or(pos.column));
}
line += 1;
col = 0;
chars = line_chars(buf, line);
if chars.is_empty() {
return Position::new(line, 0);
}
}
}
fn word_prev(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
let chars = line_chars(buf, pos.line);
let mut i = (pos.column as usize).min(chars.len());
while i > 0 && class_at(chars[i - 1], width) == WordClass::Space {
i -= 1;
}
if i > 0 {
let run = class_at(chars[i - 1], width);
while i > 0 && class_at(chars[i - 1], width) == run {
i -= 1;
}
}
Position::new(pos.line, u32::try_from(i).unwrap_or(0))
}
fn word_end_prev(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
let chars = line_chars(buf, pos.line);
let start = (pos.column as usize).min(chars.len());
let Some(mut i) = start.checked_sub(1) else {
return pos;
};
if let Some(&here) = chars.get(start) {
let run = class_at(here, width);
if run != WordClass::Space {
while i > 0 && class_at(chars[i], width) == run {
i -= 1;
}
}
}
while i > 0 && class_at(chars[i], width) == WordClass::Space {
i -= 1;
}
Position::new(pos.line, u32::try_from(i).unwrap_or(0))
}
fn word_end(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
let mut line = pos.line;
let mut chars = line_chars(buf, line);
let mut col = (pos.column as usize).saturating_add(1);
loop {
while col < chars.len() && class_at(chars[col], width) == WordClass::Space {
col += 1;
}
if col < chars.len() {
break;
}
if line >= last_text_line(buf) {
return buffer_end(buf);
}
line += 1;
col = 0;
chars = line_chars(buf, line);
}
let run = class_at(chars[col], width);
while col + 1 < chars.len() && class_at(chars[col + 1], width) == run {
col += 1;
}
Position::new(line, u32::try_from(col).unwrap_or(pos.column))
}
fn find_char(
buf: &escriba_buffer::Buffer,
pos: Position,
ch: char,
backward: bool,
till: bool,
) -> Option<Position> {
let chars = line_chars(buf, pos.line);
let cur = (pos.column as usize).min(chars.len());
let hit = if backward {
let from = if till { cur.checked_sub(1)? } else { cur };
(0..from).rev().find(|&i| chars[i] == ch)?
} else {
let from = if till { cur.saturating_add(2) } else { cur + 1 };
(from.min(chars.len())..chars.len()).find(|&i| chars[i] == ch)?
};
let col = match (backward, till) {
(false, true) => hit - 1,
(true, true) => hit + 1,
_ => hit,
};
Some(Position::new(pos.line, u32::try_from(col).ok()?))
}
const MATCH_PAIRS: [(char, char); 4] = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')];
type WordPairs = &'static [(&'static str, &'static [&'static str], &'static str)];
const WORD_PAIRS: &[(&str, WordPairs)] = &[
(
"lua",
&[
("if", &["elseif", "else"], "end"),
("for", &[], "end"),
("while", &[], "end"),
("function", &[], "end"),
("do", &[], "end"),
("repeat", &[], "until"),
],
),
(
"ruby",
&[
("if", &["elsif", "else"], "end"),
("unless", &["else"], "end"),
("case", &["when", "else"], "end"),
("begin", &["rescue", "ensure", "else"], "end"),
("def", &[], "end"),
("class", &[], "end"),
("module", &[], "end"),
("do", &[], "end"),
("while", &[], "end"),
],
),
(
"sh",
&[
("if", &["elif", "else"], "fi"),
("case", &[], "esac"),
("do", &[], "done"),
],
),
(
"bash",
&[
("if", &["elif", "else"], "fi"),
("case", &[], "esac"),
("do", &[], "done"),
],
),
(
"elixir",
&[
("do", &["else", "rescue", "after", "catch"], "end"),
("fn", &[], "end"),
],
),
(
"vim",
&[
("if", &["elseif", "else"], "endif"),
("function", &[], "endfunction"),
("while", &[], "endwhile"),
("for", &[], "endfor"),
("try", &["catch", "finally"], "endtry"),
],
),
];
#[derive(Clone, Copy)]
struct WordHit {
line: u32,
col: u32,
end: u32,
group: usize,
role: u8,
}
fn match_pair(buf: &escriba_buffer::Buffer, pos: Position) -> Option<Position> {
let chars = line_chars(buf, pos.line);
let start = (pos.column as usize).min(chars.len());
let (col, open, close, forward) = (start..chars.len()).find_map(|i| {
MATCH_PAIRS.iter().find_map(|&(o, c)| {
if chars[i] == o {
Some((i, o, c, true))
} else if chars[i] == c {
Some((i, o, c, false))
} else {
None
}
})
})?;
let last = buf.line_count().saturating_sub(1);
let mut depth = 0i32;
let (mut line, mut i) = (pos.line, col);
let mut text = chars;
loop {
let c = text[i];
if c == open {
depth += if forward { 1 } else { -1 };
} else if c == close {
depth += if forward { -1 } else { 1 };
}
if depth == 0 {
return Some(Position::new(line, u32::try_from(i).ok()?));
}
if forward {
i += 1;
while i >= text.len() {
if line >= last {
return None;
}
line += 1;
text = line_chars(buf, line);
i = 0;
}
} else {
while i == 0 {
if line == 0 {
return None;
}
line -= 1;
text = line_chars(buf, line);
i = text.len();
}
i -= 1;
}
}
}
fn word_hits(buf: &escriba_buffer::Buffer, line: u32, pairs: WordPairs) -> Vec<WordHit> {
let chars = line_chars(buf, line);
let mut out = Vec::new();
let mut i = 0usize;
while i < chars.len() {
if word_class(chars[i]) != WordClass::Word {
i += 1;
continue;
}
let start = i;
while i < chars.len() && word_class(chars[i]) == WordClass::Word {
i += 1;
}
let word: String = chars[start..i].iter().collect();
for (group, (open, middles, close)) in pairs.iter().enumerate() {
let role = if word == *open {
0
} else if word == *close {
2
} else if middles.contains(&word.as_str()) {
1
} else {
continue;
};
out.push(WordHit {
line,
col: u32::try_from(start).unwrap_or(0),
end: u32::try_from(i).unwrap_or(0),
group,
role,
});
break;
}
}
out
}
fn match_word_pair(
buf: &escriba_buffer::Buffer,
pos: Position,
pairs: WordPairs,
) -> Option<Position> {
let here = word_hits(buf, pos.line, pairs)
.into_iter()
.find(|h| h.end > pos.column)?;
let last = last_text_line(buf);
let forward = here.role != 2;
let mut depth = 0i32;
let mut line = here.line;
loop {
let hits = word_hits(buf, line, pairs);
let scan: Vec<WordHit> = if line == here.line {
let mut v: Vec<WordHit> = hits
.into_iter()
.filter(|h| {
if forward {
h.col > here.col
} else {
h.col < here.col
}
})
.collect();
if !forward {
v.reverse();
}
v
} else {
let mut v = hits;
if !forward {
v.reverse();
}
v
};
for h in scan {
if h.group != here.group {
continue;
}
match (h.role, forward) {
(0, true) | (2, false) => depth += 1,
(2, true) | (0, false) => {
if depth == 0 {
return Some(Position::new(h.line, h.col));
}
depth -= 1;
}
(1, _) if depth == 0 => return Some(Position::new(h.line, h.col)),
_ => {}
}
}
if forward {
if line >= last {
return None;
}
line += 1;
} else {
if line == 0 {
return None;
}
line -= 1;
}
}
}
fn paragraph(buf: &escriba_buffer::Buffer, pos: Position, forward: bool) -> Position {
let last = last_text_line(buf);
let mut line = pos.line;
loop {
if forward {
if line >= last {
return buffer_end(buf);
}
line += 1;
} else {
if line == 0 {
return Position::ZERO;
}
line -= 1;
}
if buf.line_len_chars(line) == 0 {
return Position::new(line, 0);
}
}
}
fn sentence(buf: &escriba_buffer::Buffer, pos: Position, forward: bool) -> Position {
let starts = sentence_starts(buf);
let here = (pos.line, pos.column);
if forward {
starts
.iter()
.find(|&&(l, c)| (l, c) > here)
.map_or_else(|| buffer_end(buf), |&(l, c)| Position::new(l, c))
} else {
starts
.iter()
.rev()
.find(|&&(l, c)| (l, c) < here)
.map_or(Position::ZERO, |&(l, c)| Position::new(l, c))
}
}
fn sentence_starts(buf: &escriba_buffer::Buffer) -> Vec<(u32, u32)> {
let mut out = vec![(0u32, 0u32)];
let mut ended = false;
for line in 0..=last_text_line(buf) {
let chars = line_chars(buf, line);
if chars.is_empty() {
out.push((line, 0));
ended = false;
continue;
}
for (i, &c) in chars.iter().enumerate() {
if ended && !c.is_whitespace() {
out.push((line, u32::try_from(i).unwrap_or(0)));
ended = false;
}
if matches!(c, '.' | '!' | '?') {
ended = true;
} else if !matches!(c, ')' | ']' | '"' | '\'') && !c.is_whitespace() {
ended = false;
}
}
}
out.sort_unstable();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
use madori::event::{KeyCode, KeyEvent, Modifiers};
fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
st.apply(&Action::SearchOpen(dir));
for c in pat.chars() {
st.apply(&Action::InsertChar(c));
}
st.apply(&Action::SubmitCommand);
}
#[test]
fn slash_search_moves_the_cursor_to_the_match() {
let mut st = new_state_with("alpha\nbravo\ncharlie\n");
type_search(&mut st, SearchDirection::Forward, "charlie");
assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
assert_eq!(st.search.matches().len(), 1);
}
#[test]
#[allow(non_snake_case)]
fn n_and_N_walk_matches_in_both_directions() {
let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
type_search(&mut st, SearchDirection::Forward, "foo");
let first = st.cursor().line;
st.apply(&Action::SearchRepeat { reverse: false });
let second = st.cursor().line;
assert!(second > first, "n advances ({first} -> {second})");
st.apply(&Action::SearchRepeat { reverse: true });
assert_eq!(st.cursor().line, first, "N comes back");
}
#[test]
fn star_searches_the_word_under_the_cursor() {
let mut st = new_state_with("needle\nhaystack\nneedle\n");
st.apply(&Action::SearchWord { reverse: false });
assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
}
#[test]
fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
let mut st = new_state_with("foo\nbar\nfoo\n");
type_search(&mut st, SearchDirection::Forward, "foo");
let matches_before = st.search.matches().len();
st.apply(&Action::SearchOpen(SearchDirection::Forward));
st.apply(&Action::InsertChar('z'));
st.apply(&Action::ChangeMode(Mode::Normal));
assert!(!st.search.is_prompting(), "prompt gone");
assert_eq!(
st.search.pattern().unwrap().raw(),
"foo",
"old pattern survives"
);
assert_eq!(
st.search.matches().len(),
matches_before,
"old highlights survive"
);
}
#[test]
fn a_search_prompt_and_an_ex_command_are_not_confused() {
let mut st = new_state_with("foo\n");
st.apply(&Action::ChangeMode(Mode::Command));
assert!(!st.search.is_prompting(), "`:` must not open a search");
st.apply(&Action::InsertChar('w'));
assert!(
st.search.prompt().is_none(),
"typed char went to the ex line"
);
}
#[test]
fn a_missing_pattern_reports_instead_of_failing_silently() {
let mut st = new_state_with("alpha\nbravo\n");
type_search(&mut st, SearchDirection::Forward, "zzz");
assert!(
st.messages.iter().any(|m| m.contains("E486")),
"must report not-found, got {:?}",
st.messages
);
}
#[test]
fn n_without_any_search_reports_rather_than_moving() {
let mut st = new_state_with("alpha\nbravo\n");
let before = st.cursor();
st.apply(&Action::SearchRepeat { reverse: false });
assert_eq!(st.cursor(), before, "cursor must not move");
assert!(
st.messages.iter().any(|m| m.contains("E35")),
"got {:?}",
st.messages
);
}
#[test]
fn search_as_a_motion_composes_with_an_operator() {
let mut st = new_state_with("alpha bravo charlie\n");
type_search(&mut st, SearchDirection::Forward, "charlie");
st.set_cursor(Position::new(0, 0));
let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
assert!(target.is_some(), "search must resolve as a motion");
assert_eq!(target.unwrap().column, 12, "at `charlie`");
}
#[test]
fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
let st = new_state_with("alpha bravo\n");
assert!(
st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
.is_none()
);
}
#[test]
fn clear_highlight_keeps_the_pattern_usable() {
let mut st = new_state_with("foo\nbar\nfoo\n");
type_search(&mut st, SearchDirection::Forward, "foo");
st.apply(&Action::ClearSearchHighlight);
assert!(st.search.highlights().is_empty(), "nothing lit");
st.apply(&Action::SearchRepeat { reverse: false });
assert!(st.search.pattern().is_some(), "but n still works");
}
#[test]
fn typing_previews_incrementally_before_commit() {
let mut st = new_state_with("alpha\nbravo\ncharlie\n");
st.apply(&Action::SearchOpen(SearchDirection::Forward));
for c in "charlie".chars() {
st.apply(&Action::InsertChar(c));
}
assert_eq!(st.cursor().line, 2, "preview moved the cursor");
assert!(st.search.pattern().is_none(), "but nothing is committed");
}
#[test]
fn backspace_corrects_the_prompt_and_reruns_the_preview() {
let mut st = new_state_with("alpha\nbravo\n");
st.apply(&Action::SearchOpen(SearchDirection::Forward));
for c in "bravox".chars() {
st.apply(&Action::InsertChar(c));
}
assert_eq!(st.search.prompt().unwrap().text(), "bravox");
st.apply(&Action::Backspace);
assert_eq!(
st.search.prompt().unwrap().text(),
"bravo",
"typo corrected"
);
assert_eq!(
st.status_model().prompt_text,
"bravo",
"the model reads the PROMPT — the minibuffer is the ex-line's store",
);
assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
}
#[test]
fn backspacing_past_the_slash_closes_the_prompt() {
let mut st = new_state_with("alpha\n");
st.apply(&Action::SearchOpen(SearchDirection::Forward));
st.apply(&Action::InsertChar('a'));
st.apply(&Action::Backspace);
st.apply(&Action::Backspace);
assert!(!st.search.is_prompting(), "prompt closed");
assert_eq!(st.modal.mode(), Mode::Normal);
}
#[test]
fn noh_clears_highlights_and_keeps_the_pattern() {
let mut st = new_state_with("foo\nbar\nfoo\n");
type_search(&mut st, SearchDirection::Forward, "foo");
assert!(!st.search.highlights().is_empty());
st.run_command("noh", &[]);
assert!(st.search.highlights().is_empty(), ":noh turns them off");
assert!(st.search.pattern().is_some(), "but n still works");
}
#[test]
fn noh_accepts_the_vim_aliases() {
for name in ["noh", "nohl", "nohlsearch"] {
let mut st = new_state_with("foo\nfoo\n");
type_search(&mut st, SearchDirection::Forward, "foo");
st.run_command(name, &[]);
assert!(st.search.highlights().is_empty(), "{name} must clear");
}
}
#[test]
fn backspace_on_the_ex_line_does_not_touch_search_state() {
let mut st = new_state_with("foo\n");
st.apply(&Action::ChangeMode(Mode::Command));
st.apply(&Action::InsertChar('w'));
st.apply(&Action::InsertChar('q'));
st.apply(&Action::Backspace);
assert_eq!(st.status_model().prompt_text, "w");
assert!(st.search.prompt().is_none(), "no search was involved");
}
#[test]
fn up_arrow_recalls_the_previous_search() {
let mut st = new_state_with("alpha\nbravo\n");
type_search(&mut st, SearchDirection::Forward, "bravo");
st.apply(&Action::SearchOpen(SearchDirection::Forward));
st.apply(&Action::PromptHistory { back: true });
assert_eq!(st.search.prompt().unwrap().text(), "bravo");
assert_eq!(
st.status_model().prompt_text,
"bravo",
"display follows the prompt"
);
}
#[test]
fn arrowing_back_down_restores_the_half_typed_pattern() {
let mut st = new_state_with("alpha\nbravo\n");
type_search(&mut st, SearchDirection::Forward, "bravo");
st.apply(&Action::SearchOpen(SearchDirection::Forward));
st.apply(&Action::InsertChar('a'));
st.apply(&Action::PromptHistory { back: true });
assert_eq!(st.search.prompt().unwrap().text(), "bravo");
st.apply(&Action::PromptHistory { back: false });
assert_eq!(
st.search.prompt().unwrap().text(),
"a",
"the draft comes back"
);
assert_eq!(st.status_model().prompt_text, "a");
}
#[test]
fn history_arrows_do_nothing_on_the_ex_line() {
let mut st = new_state_with("alpha\n");
st.apply(&Action::ChangeMode(Mode::Command));
st.apply(&Action::InsertChar('w'));
st.apply(&Action::PromptHistory { back: true });
assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
}
fn finding_at(buffer: BufferId, line: u32, msg: &str) -> escriba_shirube::Finding {
use escriba_core::{Position, Range};
escriba_shirube::Finding::new(
escriba_shirube::Site::in_buffer(
buffer,
Range::new(Position::new(line, 0), Position::new(line, 1)),
),
escriba_shirube::Severity::Error,
msg.to_string(),
escriba_shirube::Origin::Text("test"),
)
}
#[test]
fn published_findings_become_picker_rows() {
let mut st = new_state_with("a\nb\nc\n");
let world = st.world();
st.results.publish(
"test",
escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
);
let rows = st.finding_items(true, None);
assert_eq!(rows.len(), 1, "the published finding produces a row");
let label = &rows[0].label;
assert!(label.contains("ERROR"), "{label}");
assert!(label.contains(":2"), "lines are 1-based on screen: {label}");
assert!(label.contains("boom"), "{label}");
}
#[test]
fn a_stale_list_contributes_no_rows() {
let mut st = new_state_with("a\nb\nc\n");
let world = st.world();
st.results.publish(
"test",
escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
);
assert_eq!(st.finding_items(true, None).len(), 1, "fresh to begin with");
st.apply(&Action::InsertChar('x'));
assert!(
st.finding_items(true, None).is_empty(),
"an edit moved the text on; the list is stale and must not be shown"
);
}
#[test]
fn document_scope_excludes_another_buffer() {
let mut st = new_state_with("a\nb\n");
let other = st.buffers.scratch("z\n");
let world = st.world();
st.results.publish(
"test",
escriba_shirube::ResultList::new(
vec![
finding_at(st.active, 0, "mine"),
finding_at(other, 0, "theirs"),
],
world,
),
);
let ws = st.finding_items(true, None);
assert_eq!(ws.len(), 2, "workspace scope shows both");
let doc = st.finding_items(false, None);
assert_eq!(doc.len(), 1, "document scope shows only the active buffer");
assert!(doc[0].label.contains("mine"), "{}", doc[0].label);
}
#[test]
fn files_under_a_root_produces_rows() {
let mut st = new_state_with("");
let rows = st.file_items(std::path::Path::new("."));
assert!(!rows.is_empty(), "the working directory has files");
}
fn after(text: &str, line: u32, col: u32, act: Action) -> String {
let mut st = new_state_with(text);
st.set_cursor(Position::new(line, col));
st.apply(&act);
st.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default()
}
fn del_obj(o: escriba_core::TextObject) -> Action {
Action::ApplyOperatorObject {
op: escriba_core::Operator::Delete,
object: o,
}
}
#[test]
fn dd_removes_the_line_not_just_its_contents() {
let got = after("a\nb\nc\n", 1, 0, del_obj(escriba_core::TextObject::Line));
assert_eq!(got, "a\nc\n");
}
#[test]
fn dd_on_the_last_line_leaves_no_blank_behind() {
let got = after("a\nb\nc\n", 2, 0, del_obj(escriba_core::TextObject::Line));
assert_eq!(got, "a\nb\n", "no trailing empty line: {got:?}");
}
#[test]
fn dd_on_the_only_line_clears_it_but_keeps_the_line() {
let got = after("solo\n", 0, 2, del_obj(escriba_core::TextObject::Line));
assert!(got.starts_with('\n') || got.is_empty(), "{got:?}");
}
#[test]
fn diw_takes_the_word_and_daw_takes_its_trailing_space() {
let inner = after(
"one two three\n",
0,
5,
del_obj(escriba_core::TextObject::Word { around: false }),
);
assert_eq!(inner, "one three\n", "iw leaves both spaces");
let around = after(
"one two three\n",
0,
5,
del_obj(escriba_core::TextObject::Word { around: true }),
);
assert_eq!(around, "one three\n", "aw takes the trailing space");
}
#[test]
fn iw_from_any_column_inside_the_word_takes_the_whole_word() {
for col in 4..=6 {
let got = after(
"one two three\n",
0,
col,
del_obj(escriba_core::TextObject::Word { around: false }),
);
assert_eq!(got, "one three\n", "from column {col}");
}
}
#[test]
fn iw_on_punctuation_takes_the_punctuation_run() {
let got = after(
"foo::bar\n",
0,
3,
del_obj(escriba_core::TextObject::Word { around: false }),
);
assert_eq!(got, "foobar\n");
}
#[test]
fn i_paren_takes_the_inside_and_a_paren_takes_the_brackets_too() {
let inner = after(
"f(a, b)\n",
0,
3,
del_obj(escriba_core::TextObject::Delimited {
open: '(',
close: ')',
around: false,
}),
);
assert_eq!(inner, "f()\n");
let around = after(
"f(a, b)\n",
0,
3,
del_obj(escriba_core::TextObject::Delimited {
open: '(',
close: ')',
around: true,
}),
);
assert_eq!(around, "f\n");
}
#[test]
fn nested_brackets_resolve_to_the_enclosing_pair() {
let got = after(
"f(g(x), y)\n",
0,
8,
del_obj(escriba_core::TextObject::Delimited {
open: '(',
close: ')',
around: false,
}),
);
assert_eq!(got, "f()\n", "took the outer pair");
}
#[test]
fn quotes_do_not_nest_so_the_nearest_pair_wins() {
let got = after(
r#"say "hi there" ok"#,
0,
7,
del_obj(escriba_core::TextObject::Delimited {
open: '"',
close: '"',
around: false,
}),
);
assert_eq!(got, "say \"\" ok");
}
#[test]
fn an_unmatched_delimiter_resolves_to_nothing_rather_than_guessing() {
let mut st = new_state_with("f(a, b\n");
st.set_cursor(Position::new(0, 3));
let before = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
st.apply(&del_obj(escriba_core::TextObject::Delimited {
open: '(',
close: ')',
around: false,
}));
let got_after = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
assert_eq!(got_after, before, "no closing bracket: change nothing");
}
fn new_state_with(text: &str) -> EditorState {
let mut bufs = BufferSet::new();
let id = bufs.scratch(text);
EditorState::new_with_buffer(bufs, id)
}
#[test]
fn setting_a_breakpoint_repaints() {
let mut s = new_state_with("alpha\nbravo\ncharlie\n");
let before = s.edit_gen();
s.run_command("dap.toggle-breakpoint", &[]);
assert!(
s.breakpoints().is_set(s.active, 0),
"precondition: the toggle ran",
);
assert_ne!(
s.edit_gen(),
before,
"the GPU face rebuilds its cached gutter ONLY on a generation \
change — without this the mark never reaches that screen",
);
assert!(
!s.damage().is_none(),
"and a scoped-repaint face has to be told the viewport moved",
);
}
#[test]
fn a_breakpoint_toggle_with_no_open_buffer_marks_nothing() {
let mut s = new_state_with("alpha\n");
s.active = BufferId(9_999);
s.run_command("dap.toggle-breakpoint", &[]);
assert!(!s.breakpoints().is_set(s.active, 0), "nothing was marked");
assert!(
!s.messages.iter().any(|m| m.contains("breakpoint")),
"and nothing was claimed: {:?}",
s.messages,
);
}
#[test]
fn edit_gen_advances_on_applied_action_not_on_read() {
let mut s = new_state_with("hello\nworld\n");
let g0 = s.edit_gen();
s.apply(&Action::InsertChar('X'));
assert_ne!(
s.edit_gen(),
g0,
"an applied action must advance the refresh generation",
);
let g1 = s.edit_gen();
assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
}
#[test]
fn damage_tracks_edit_scope_and_drains() {
let mut s = new_state_with("hello\nworld\n");
assert!(s.damage().is_none(), "a fresh state has no damage");
s.apply(&Action::InsertChar('X')); assert_eq!(
s.damage(),
Damage::Lines { from: 0, to: 0 },
"a local edit damages just its line",
);
let drained = s.take_damage();
assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
assert!(s.damage().is_none(), "take_damage drains to None");
s.apply(&Action::InsertChar('\n')); assert_eq!(
s.damage(),
Damage::Lines {
from: 0,
to: u32::MAX,
},
"a line-count change damages to end-of-document",
);
}
fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
let mut s = new_state_with(text);
for w in s.layout.windows_mut() {
w.viewport.visible_lines = vis_lines;
w.viewport.visible_columns = vis_cols;
}
s
}
fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
let w = s.layout.active_window().expect("active window");
let v = w.viewport;
let c = s.cursor();
assert!(
v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
"[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
c.line,
v.top_line,
v.top_line + v.visible_lines,
);
assert!(
v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
"[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
c.column,
v.left_column,
v.left_column + v.visible_columns,
);
}
#[test]
fn pressing_d_twice_deletes_the_line() {
let mut st = new_state_with("alpha\nbeta\ngamma\n");
st.set_cursor(Position::new(1, 0));
st.tick(&press(KeyCode::Char('d')));
st.tick(&press(KeyCode::Char('d')));
let got = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
assert_eq!(got, "alpha\ngamma\n", "dd from the keyboard");
}
#[test]
fn pressing_2_d_d_deletes_two_lines() {
let mut st = new_state_with("a\nb\nc\nd\n");
st.set_cursor(Position::new(0, 0));
for k in ['2', 'd', 'd'] {
st.tick(&press(KeyCode::Char(k)));
}
let got = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
assert_eq!(got, "c\nd\n", "count applies to the doubled operator");
}
fn entry(text: &str, at: Position, key: char) -> (Mode, Position, String) {
let mut st = new_state_with(text);
st.set_cursor(at);
st.tick(&press(KeyCode::Char(key)));
(
st.modal.mode(),
st.cursor(),
st.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default(),
)
}
#[test]
fn the_insert_entry_family_places_the_caret_like_vim() {
const TEXT: &str = " hello\nworld\n";
let from = Position::new(0, 4); for (key, want_cursor, want_text, why) in [
(
'i',
Position::new(0, 4),
TEXT,
"`i` inserts before the caret",
),
(
'I',
Position::new(0, 2),
TEXT,
"`I` goes to the first NON-BLANK, not to column 0",
),
(
'a',
Position::new(0, 5),
TEXT,
"`a` appends after the caret",
),
(
'A',
Position::new(0, 7),
TEXT,
"`A` parks one PAST the last char — the whole point of the key",
),
(
'o',
Position::new(1, 0),
" hello\n\nworld\n",
"`o` opens below and lands on the new line",
),
(
'O',
Position::new(0, 0),
"\n hello\nworld\n",
"`O` opens above; the fresh line takes the caret's line number",
),
] {
let (mode, cursor, text) = entry(TEXT, from, key);
assert_eq!(mode, Mode::Insert, "`{key}` must enter Insert");
assert_eq!(cursor, want_cursor, "{why}");
assert_eq!(text, want_text, "`{key}`: {why}");
}
}
#[test]
fn every_insert_entry_has_a_key() {
let km = escriba_keymap::Keymap::default_vim();
let bound: Vec<InsertAt> = km
.entries_sorted()
.into_iter()
.filter_map(|(mode, _, b)| match (mode, &b.action) {
(Mode::Normal, Action::EnterInsert(at)) => Some(*at),
_ => None,
})
.collect();
for at in InsertAt::ALL {
assert!(
bound.contains(&at),
"InsertAt::{at:?} ({}) has no Normal-mode key",
at.as_str()
);
}
assert_eq!(
bound.len(),
InsertAt::ALL.len(),
"one key per entry, no duplicates: {bound:?}"
);
}
#[test]
fn shift_a_then_typing_appends_at_the_end_of_the_line() {
let mut st = new_state_with("hello\nworld\n");
st.set_cursor(Position::new(0, 0));
st.tick(&press(KeyCode::Char('A')));
for c in "!!".chars() {
st.tick(&press(KeyCode::Char(c)));
}
assert_eq!(
st.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default(),
"hello!!\nworld\n"
);
}
#[test]
fn a_on_the_last_character_appends_after_it() {
let mut st = new_state_with("hello\n");
st.set_cursor(Position::new(0, 4)); st.tick(&press(KeyCode::Char('a')));
assert_eq!(st.cursor(), Position::new(0, 5), "one past the `o`");
st.tick(&press(KeyCode::Char('?')));
assert_eq!(
st.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default(),
"hello?\n"
);
}
#[test]
fn entering_insert_types_nothing() {
const TEXT: &str = " hello\nworld\n";
for key in ['i', 'I', 'a', 'A'] {
let (_, _, text) = entry(TEXT, Position::new(0, 4), key);
assert_eq!(text, TEXT, "`{key}` must not write a character");
}
for key in ['o', 'O'] {
let (_, _, text) = entry(TEXT, Position::new(0, 4), key);
assert_eq!(
text.chars().filter(|c| *c == '\n').count(),
3,
"`{key}` adds exactly one line terminator and no other char"
);
assert!(
text.contains(" hello") && text.contains("world"),
"`{key}` must not disturb the existing lines: {text:?}"
);
}
}
#[test]
fn the_insert_entry_keys_do_not_shadow_text_objects() {
assert_eq!(keys("one two three\n", 0, 5, "daw"), "one three\n");
assert_eq!(keys("one two three\n", 0, 5, "diw"), "one three\n");
assert_eq!(keys("f(a, b)\n", 0, 3, "di("), "f()\n");
let (mode, cursor, _) = entry("one two\n", Position::new(0, 0), 'a');
assert_eq!(mode, Mode::Insert);
assert_eq!(cursor, Position::new(0, 1), "no operator ⇒ `a` appends");
}
fn keys(text: &str, line: u32, col: u32, seq: &str) -> String {
let mut st = new_state_with(text);
st.set_cursor(Position::new(line, col));
for c in seq.chars() {
st.tick(&press(KeyCode::Char(c)));
}
st.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default()
}
#[test]
fn diw_from_the_keyboard() {
assert_eq!(keys("one two three\n", 0, 5, "diw"), "one three\n");
}
#[test]
fn daw_from_the_keyboard_takes_the_space() {
assert_eq!(keys("one two three\n", 0, 5, "daw"), "one three\n");
}
#[test]
fn ciw_deletes_and_enters_insert() {
let mut st = new_state_with("one two\n");
st.set_cursor(Position::new(0, 5));
for c in "ciw".chars() {
st.tick(&press(KeyCode::Char(c)));
}
assert_eq!(st.modal.mode(), Mode::Insert, "change leaves you inserting");
let got = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
assert_eq!(got, "one \n");
}
#[test]
fn di_paren_and_da_paren_from_the_keyboard() {
assert_eq!(keys("f(a, b)\n", 0, 3, "di("), "f()\n");
assert_eq!(keys("f(a, b)\n", 0, 3, "da("), "f\n");
}
#[test]
fn the_closing_bracket_and_b_are_aliases() {
for sel in ["di(", "di)", "dib"] {
assert_eq!(keys("f(a, b)\n", 0, 3, sel), "f()\n", "{sel}");
}
}
#[test]
fn di_quote_from_the_keyboard() {
assert_eq!(keys("say \"hi\" ok\n", 0, 6, "di\""), "say \"\" ok\n");
}
#[test]
fn i_alone_still_enters_insert_when_no_operator_is_pending() {
let mut st = new_state_with("abc\n");
st.tick(&press(KeyCode::Char('i')));
assert_eq!(st.modal.mode(), Mode::Insert);
}
#[test]
fn an_unknown_object_key_cancels_rather_than_staying_armed() {
let mut st = new_state_with("one two\n");
st.set_cursor(Position::new(0, 5));
for c in "diz".chars() {
st.tick(&press(KeyCode::Char(c)));
}
let got = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
assert_eq!(got, "one two\n", "nothing was deleted");
assert_eq!(*st.op_pending.state(), OpState::Resting, "and it disarmed");
}
#[test]
fn a_counted_delete_puts_ALL_of_it_in_the_register() {
let mut st = new_state_with("one two three four\n");
st.set_cursor(Position::new(0, 0));
for c in "3dw".chars() {
st.tick(&press(KeyCode::Char(c)));
}
assert_eq!(
st.register.as_deref(),
Some("one two three "),
"all three words, in the order they were deleted"
);
}
#[test]
fn an_uncounted_delete_still_replaces_the_register() {
let mut st = new_state_with("alpha beta\n");
st.set_cursor(Position::new(0, 0));
for c in "3dw".chars() {
st.tick(&press(KeyCode::Char(c)));
}
let mut st2 = new_state_with("gamma delta\n");
st2.set_cursor(Position::new(0, 0));
for c in "dw".chars() {
st2.tick(&press(KeyCode::Char(c)));
}
assert_eq!(st2.register.as_deref(), Some("gamma "));
}
#[test]
fn two_separate_counted_deletes_do_not_accumulate_into_each_other() {
let mut st = new_state_with("a b c d e f\n");
st.set_cursor(Position::new(0, 0));
for c in "2dw".chars() {
st.tick(&press(KeyCode::Char(c)));
}
let first = st.register.clone();
for c in "2dw".chars() {
st.tick(&press(KeyCode::Char(c)));
}
assert_eq!(first.as_deref(), Some("a b "));
assert_eq!(st.register.as_deref(), Some("c d "), "not \"a b c d \"");
}
#[test]
fn a_counted_yank_accumulates_without_changing_the_buffer() {
let mut st = new_state_with("one two three\n");
st.set_cursor(Position::new(0, 0));
let before = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
for c in "2yw".chars() {
st.tick(&press(KeyCode::Char(c)));
}
assert_eq!(st.register.as_deref(), Some("one two "));
let after = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
assert_eq!(after, before, "yank does not edit");
}
fn a_finding(buffer: BufferId, line: u32) -> escriba_shirube::Finding {
use escriba_core::{Position, Range};
escriba_shirube::Finding::new(
escriba_shirube::Site::in_buffer(
buffer,
Range::new(Position::new(line, 0), Position::new(line, 1)),
),
escriba_shirube::Severity::Error,
"computed off the tick".to_string(),
escriba_shirube::Origin::Text("test"),
)
}
#[test]
fn a_fresh_errand_reply_is_honoured() {
let mut st = new_state_with("a\nb\nc\n");
let anchor = st.world();
st.honour_one(escriba_madoguchi::Negai::ErrandReply {
anchor,
then: Box::new(escriba_madoguchi::Negai::PublishFindings {
list: "lsp".to_string(),
findings: vec![a_finding(st.active, 1)],
}),
});
assert_eq!(
st.finding_items(true, None).len(),
1,
"the world had not moved"
);
}
#[test]
fn a_stale_errand_reply_is_dropped_not_resealed() {
let mut st = new_state_with("a\nb\nc\n");
let anchor = st.world();
st.apply(&Action::InsertChar('x'));
st.honour_one(escriba_madoguchi::Negai::ErrandReply {
anchor,
then: Box::new(escriba_madoguchi::Negai::PublishFindings {
list: "lsp".to_string(),
findings: vec![a_finding(st.active, 1)],
}),
});
assert!(
st.finding_items(true, None).is_empty(),
"a reply computed against an older text revision must be DROPPED, \
not resealed against the current one"
);
}
#[test]
fn a_stale_errand_reply_cannot_edit_the_buffer() {
let mut st = new_state_with("hello\n");
let anchor = st.world();
st.apply(&Action::InsertChar('!'));
let before = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
st.honour_one(escriba_madoguchi::Negai::ErrandReply {
anchor,
then: Box::new(escriba_madoguchi::Negai::Edit {
buffer: st.active,
edit: escriba_core::Edit {
range: Range::new(Position::new(0, 0), Position::new(0, 0)),
kind: escriba_core::EditKind::Insert {
text: "FORMATTED".to_string(),
},
},
}),
});
let after = st
.buffers
.get(st.active)
.map(|b| b.to_string())
.unwrap_or_default();
assert_eq!(
after, before,
"a stale formatter reply must not touch the text"
);
}
fn press(kc: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
key: kc,
pressed: true,
modifiers: Modifiers::default(),
text: None,
})
}
fn line0_len(s: &EditorState) -> u32 {
s.buffers.get(s.active).unwrap().line_len_chars(0)
}
#[test]
fn delete_to_line_end_clears_line_and_fills_register() {
let mut s = new_state_with("hello world");
s.apply(&Action::ApplyOperator {
op: Operator::Delete,
motion: Motion::LineEnd,
});
assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
assert_eq!(
s.register(),
Some("hello world"),
"delete fills the register"
);
assert_eq!(
s.cursor(),
Position::ZERO,
"cursor lands at the range start"
);
}
#[test]
fn delete_over_right_motion_removes_one_char() {
let mut s = new_state_with("abc");
s.apply(&Action::ApplyOperator {
op: Operator::Delete,
motion: Motion::Right,
});
assert_eq!(
s.buffers.get(s.active).unwrap().line(0).as_deref(),
Some("bc")
);
assert_eq!(s.register(), Some("a"));
}
#[test]
fn change_to_line_end_deletes_and_enters_insert() {
let mut s = new_state_with("hello world");
assert_eq!(s.modal.mode(), Mode::Normal);
s.apply(&Action::ApplyOperator {
op: Operator::Change,
motion: Motion::LineEnd,
});
assert_eq!(line0_len(&s), 0, "c$ deletes the range");
assert_eq!(
s.modal.mode(),
Mode::Insert,
"change enters Insert to type the replacement"
);
assert_eq!(
s.register(),
Some("hello world"),
"change fills the register"
);
}
#[test]
fn yank_to_line_end_fills_register_without_mutating() {
let mut s = new_state_with("hello world");
s.apply(&Action::ApplyOperator {
op: Operator::Yank,
motion: Motion::LineEnd,
});
assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
assert_eq!(s.register(), Some("hello world"), "yank fills the register");
assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
}
#[test]
fn resolve_motion_is_the_shared_target_for_move_and_operator() {
let mut s = new_state_with("hello world");
let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
assert_eq!(target, Position::new(0, 11), "the exclusive range end");
s.apply_motion(Motion::LineEnd);
assert_eq!(
s.cursor(),
Position::new(0, 10),
"`$` rests on the last character, not past it",
);
let mut d = new_state_with("hello world");
d.apply(&Action::ApplyOperator {
op: Operator::Delete,
motion: Motion::LineEnd,
});
assert_eq!(
line0_len(&d),
0,
"`d$` deletes through the last character — the range ends where \
resolve_motion said, not where the cursor may rest",
);
}
#[test]
fn empty_motion_range_is_a_no_op() {
let mut s = new_state_with("abc");
s.apply(&Action::ApplyOperator {
op: Operator::Delete,
motion: Motion::LineStart,
});
assert_eq!(
s.buffers.get(s.active).unwrap().line(0).as_deref(),
Some("abc")
);
assert_eq!(s.register(), None);
}
#[test]
fn operator_then_motion_composes_through_the_pending_fsm() {
let mut s = new_state_with("hello world");
s.apply(&Action::Operator(Operator::Delete));
assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
s.apply(&Action::Move(Motion::LineEnd));
assert_eq!(
line0_len(&s),
0,
"d then $ composes d$ and deletes the line"
);
assert_eq!(s.register(), Some("hello world"));
}
#[test]
fn change_operator_through_fsm_enters_insert() {
let mut s = new_state_with("hello world");
s.apply(&Action::Operator(Operator::Change));
s.apply(&Action::Move(Motion::LineEnd));
assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
}
#[test]
fn lone_motion_after_no_operator_just_moves() {
let mut s = new_state_with("hello world");
s.apply(&Action::Move(Motion::LineEnd));
assert_eq!(s.cursor(), Position::new(0, 10));
assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
}
#[test]
fn counted_operator_deletes_count_times() {
let mut s = new_state_with("abcdef");
s.apply_counted(&Action::Operator(Operator::Delete), 3);
assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
s.apply(&Action::Move(Motion::Right));
assert_eq!(
s.buffers.get(s.active).unwrap().line(0).as_deref(),
Some("def")
);
}
#[test]
fn operator_and_motion_counts_multiply_end_to_end() {
let mut s = new_state_with("abcdefgh");
s.apply_counted(&Action::Operator(Operator::Delete), 2);
s.apply_counted(&Action::Move(Motion::Right), 3);
assert_eq!(
s.buffers.get(s.active).unwrap().line(0).as_deref(),
Some("gh")
);
}
#[test]
fn bare_counted_motion_still_repeats_no_regression() {
let mut s = new_state_with("a\nb\nc\nd\ne");
s.apply_counted(&Action::Move(Motion::Down), 3);
assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
}
struct SpacedClock(std::time::Instant);
impl SpacedClock {
fn new() -> Self {
Self(std::time::Instant::now())
}
fn next(&mut self) -> std::time::Instant {
self.0 += std::time::Duration::from_secs(1);
self.0
}
}
#[test]
fn hjkl_moves_cursor() {
let mut s = new_state_with("hello\nworld");
s.tick(&press(KeyCode::Char('l')));
assert_eq!(s.cursor().column, 1);
s.tick(&press(KeyCode::Char('j')));
assert_eq!(s.cursor().line, 1);
s.tick(&press(KeyCode::Char('h')));
assert_eq!(s.cursor().column, 0);
}
#[test]
fn insert_mode_inserts_chars() {
let mut s = new_state_with("");
s.tick(&press(KeyCode::Char('i')));
assert_eq!(s.modal.mode(), Mode::Insert);
s.tick(&press(KeyCode::Char('h')));
s.tick(&press(KeyCode::Char('i')));
assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
assert_eq!(s.cursor().column, 2);
}
#[test]
fn esc_returns_to_normal() {
let mut s = new_state_with("");
s.tick(&press(KeyCode::Char('i')));
s.tick(&press(KeyCode::Escape));
assert_eq!(s.modal.mode(), Mode::Normal);
}
#[test]
fn count_prefix_repeats_motion() {
let mut s = new_state_with("abcdefghij");
s.tick(&press(KeyCode::Char('5')));
s.tick(&press(KeyCode::Char('l')));
assert_eq!(s.cursor().column, 5);
}
#[test]
fn close_event_requests_quit() {
let mut s = new_state_with("");
s.tick(&AppEvent::CloseRequested);
assert!(s.quit_requested);
}
#[test]
fn word_next_jumps_past_whitespace() {
let mut s = new_state_with("foo bar baz");
let mut clk = SpacedClock::new();
s.tick_at(&press(KeyCode::Char('w')), clk.next());
assert_eq!(s.cursor().column, 4);
s.tick_at(&press(KeyCode::Char('w')), clk.next());
assert_eq!(s.cursor().column, 8);
}
#[test]
fn leader_sequence_holds_then_resolves() {
let mut s = new_state_with("a\nbb\nccc");
s.keymap.bind_sequence(
Mode::Normal,
vec![Key::Char(','), Key::Char('g')],
Action::Move(Motion::DocEnd),
"doc end",
);
s.on_key(&Key::Char(','));
assert_eq!(s.pending_keys, vec![Key::Char(',')]);
assert_eq!(s.cursor(), Position::ZERO);
s.on_key(&Key::Char('g'));
assert!(s.pending_keys.is_empty());
assert_eq!(s.cursor().line, 2);
}
#[test]
fn two_key_gg_jumps_doc_start() {
let mut s = new_state_with("a\nbb\nccc");
s.keymap.bind_sequence(
Mode::Normal,
vec![Key::Char('g'), Key::Char('g')],
Action::Move(Motion::DocStart),
"doc start",
);
let mut clk = SpacedClock::new();
s.tick_at(&press(KeyCode::Char('j')), clk.next());
s.tick_at(&press(KeyCode::Char('j')), clk.next());
assert_eq!(s.cursor().line, 2);
s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
}
#[test]
fn broken_sequence_aborts_and_clears_pending() {
let mut s = new_state_with("hello");
s.keymap.bind_sequence(
Mode::Normal,
vec![Key::Char('g'), Key::Char('g')],
Action::Move(Motion::DocEnd),
"doc end",
);
s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
assert_eq!(s.cursor(), Position::ZERO);
}
#[test]
fn single_binding_wins_over_sequence_prefix() {
let mut s = new_state_with("abcde");
let mut clk = SpacedClock::new();
s.tick_at(&press(KeyCode::Char('l')), clk.next());
s.tick_at(&press(KeyCode::Char('l')), clk.next());
assert_eq!(s.cursor().column, 2);
s.keymap.bind_sequence(
Mode::Normal,
vec![Key::Char('h'), Key::Char('z')],
Action::Move(Motion::DocEnd),
"shadowed",
);
s.on_key(&Key::Char('h'));
assert!(s.pending_keys.is_empty(), "single binding should not pend");
assert_eq!(s.cursor().column, 1, "h moved left immediately");
}
#[test]
fn lisp_set_option_writes_live_options() {
let mut s = new_state_with("");
s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
}
#[test]
fn lisp_insert_modifies_buffer_and_advances_cursor() {
let mut s = new_state_with("");
s.run_lisp(r#"(insert "abc")"#).unwrap();
assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
assert_eq!(s.cursor(), Position::new(0, 3));
}
#[test]
fn lisp_message_appends_to_messages() {
let mut s = new_state_with("");
s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
}
#[test]
fn lisp_reads_snapshot_and_branches_to_effect() {
let mut s = new_state_with("one\ntwo\nthree");
s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
.unwrap();
assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
}
#[test]
fn lisp_run_command_effect_drives_registry() {
let mut s = new_state_with("");
s.run_lisp(r#"(insert "abc")"#).unwrap();
assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
s.run_lisp(r#"(run-command "undo")"#).unwrap();
assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
}
#[test]
fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
let mut s = new_state_with("");
s.run_lisp(r#"(run-command "quit")"#).unwrap();
assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
assert_eq!(
s.modal.minibuffer(),
"",
"quit must not pollute any command line — Normal mode has no minibuffer",
);
}
#[test]
fn lazy_plugin_activates_on_command_trigger() {
let mut s = new_state_with("");
s.register_lazy_plugin(
"user-lazy",
vec![LazyTrigger::Command("LazyGo".into())],
r#"(defoption :name "lazy-loaded" :value "yes")
(defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
);
assert_eq!(s.plugin_host.pending(), 1);
assert!(
s.options.get("lazy-loaded").is_none(),
"entry not applied yet"
);
s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
assert_eq!(
s.options.get("lazy-loaded").map(String::as_str),
Some("yes"),
"the command trigger applied the plugin's entry",
);
assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
}
#[test]
fn lazy_plugin_activates_on_filetype() {
let mut s = new_state_with("");
s.register_lazy_plugin(
"user-rust",
vec![LazyTrigger::FileType("rust".into())],
r#"(defoption :name "rust-plugin" :value "on")"#,
);
let n = s.activate_filetype_plugins("rust");
assert_eq!(n, 1);
assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
assert_eq!(s.activate_filetype_plugins("rust"), 0);
}
#[test]
fn cached_vm_serves_multiple_run_lisp_calls() {
let mut s = new_state_with("");
s.run_lisp(r#"(message "one")"#).unwrap();
assert!(
s.lisp_vm.is_some(),
"VM should be cached after first run_lisp"
);
s.run_lisp(r#"(message "two")"#).unwrap();
assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
}
#[test]
fn lisp_define_persists_across_run_lisp_calls() {
let mut s = new_state_with("");
s.run_lisp(r#"(define greeting "hi")"#).unwrap();
s.run_lisp(r#"(message greeting)"#).unwrap();
assert_eq!(s.messages, vec!["hi".to_string()]);
}
#[test]
fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
let mut s = new_state_with("");
s.run_lisp(
r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
)
.unwrap();
assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
assert_eq!(
s.options.get("col").map(String::as_str),
Some("stale-zero"),
"cursor-column within the same call reads the pre-eval snapshot",
);
s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
.unwrap();
assert_eq!(
s.options.get("col2").map(String::as_str),
Some("live-two"),
"a later call sees the refreshed snapshot",
);
}
#[test]
fn insert_text_effect_multiline_lands_cursor_on_last_line() {
let mut s = new_state_with("");
s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
assert_eq!(s.cursor(), Position::new(1, 3));
}
#[test]
fn visual_mode_sequence_resolves() {
let mut s = new_state_with("abc");
s.modal.enter(Mode::Visual);
s.keymap.bind_sequence(
Mode::Visual,
vec![Key::Char('g'), Key::Char('e')],
Action::Move(Motion::DocEnd),
"ge",
);
s.on_key(&Key::Char('g'));
assert_eq!(s.pending_keys, vec![Key::Char('g')]);
s.on_key(&Key::Char('e'));
assert!(s.pending_keys.is_empty());
assert_eq!(
s.cursor().column,
3,
"ge resolved to doc-end in visual mode"
);
}
#[test]
fn sequence_abort_with_bound_breaking_key_redispatches() {
let mut s = new_state_with("abcde");
s.keymap.bind_sequence(
Mode::Normal,
vec![Key::Char('g'), Key::Char('g')],
Action::Move(Motion::DocEnd),
"gg",
);
s.on_key(&Key::Char('g'));
assert_eq!(s.pending_keys, vec![Key::Char('g')]);
s.on_key(&Key::Char('l'));
assert!(s.pending_keys.is_empty());
assert_eq!(
s.cursor().column,
1,
"the breaking key l should re-dispatch as move-right",
);
}
#[test]
fn viewport_contains_cursor_after_every_op() {
let mut s = new_state_small_viewport("", 5, 10);
assert_cursor_in_viewport(&s, "initial");
s.tick(&press(KeyCode::Char('i')));
assert_eq!(s.modal.mode(), Mode::Insert);
for line in 0..30u32 {
for c in "line".chars() {
s.tick(&press(KeyCode::Char(c)));
assert_cursor_in_viewport(&s, "typing chars");
}
s.tick(&press(KeyCode::Enter));
assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
}
for i in 0..200u32 {
s.tick(&press(KeyCode::Char('x')));
assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
}
s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
assert_cursor_in_viewport(&s, "insert_text multiline");
s.tick(&press(KeyCode::Escape));
assert_eq!(s.modal.mode(), Mode::Normal);
for m in [
Motion::DocStart,
Motion::DocEnd,
Motion::Down,
Motion::Down,
Motion::Up,
Motion::Right,
Motion::Right,
Motion::Left,
Motion::LineEnd,
Motion::LineStart,
Motion::GotoLine(1),
Motion::GotoLine(40),
Motion::PageDown,
Motion::PageUp,
] {
s.apply_motion(m);
assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
}
for i in 0..50u32 {
s.apply(&Action::Undo);
assert_cursor_in_viewport(&s, &format!("undo {i}"));
}
for i in 0..50u32 {
s.apply(&Action::Redo);
assert_cursor_in_viewport(&s, &format!("redo {i}"));
}
}
#[test]
fn insert_at_eof_keeps_cursor_in_bounds() {
let mut s = new_state_small_viewport("abc", 5, 10);
s.apply_motion(Motion::DocEnd);
s.tick(&press(KeyCode::Char('i')));
s.tick(&press(KeyCode::Char('d')));
let buf = s.buffers.get(s.active).unwrap();
let clamped = buf.clamp(s.cursor());
assert_eq!(
s.cursor(),
clamped,
"cursor must be clamped in-bounds at EOF"
);
assert_cursor_in_viewport(&s, "insert at eof");
}
#[test]
fn count_prefix_then_sequence_repeats() {
let mut s = new_state_with("a\nb\nc\nd\ne");
s.keymap.bind_sequence(
Mode::Normal,
vec![Key::Char('g'), Key::Char('j')],
Action::Move(Motion::Down),
"gj",
);
s.on_key(&Key::Char('2'));
s.on_key(&Key::Char('g'));
s.on_key(&Key::Char('j'));
assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
}
#[test]
fn held_key_repeat_storm_is_debounced_in_normal_mode() {
let mut s = new_state_with(&"x\n".repeat(40));
let t0 = std::time::Instant::now();
let mut delivered = 0u32;
for i in 0..20u32 {
let before = s.cursor().line;
s.tick_at(
&press(KeyCode::Char('j')),
t0 + std::time::Duration::from_millis(u64::from(i) * 50),
);
if s.cursor().line != before {
delivered += 1;
}
}
assert!(
(10..=14).contains(&delivered),
"expected the storm debounced to ~13 moves, got {delivered}",
);
assert!(
delivered < 20,
"the gate must drop SOME storm ticks, not pass all 20",
);
}
#[test]
fn spaced_intentional_taps_all_pass() {
let mut s = new_state_with(&"x\n".repeat(10));
let t0 = std::time::Instant::now();
for i in 0..5u32 {
s.tick_at(
&press(KeyCode::Char('j')),
t0 + std::time::Duration::from_millis(u64::from(i) * 100),
);
}
assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
}
#[test]
fn distinct_keys_have_independent_clocks() {
let mut s = new_state_with("abc\ndef\nghi");
let t = std::time::Instant::now();
s.tick_at(&press(KeyCode::Char('j')), t);
s.tick_at(
&press(KeyCode::Char('j')),
t + std::time::Duration::from_millis(10),
);
assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
s.tick_at(
&press(KeyCode::Char('l')),
t + std::time::Duration::from_millis(10),
);
assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
}
#[test]
fn cursor_home_preserves_single_cursor_behavior() {
let mut s = new_state_with("hello\nworld\nthere");
assert_eq!(s.cursor(), Position::ZERO);
assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
s.apply_motion(Motion::Down);
s.apply_motion(Motion::Right);
s.apply_motion(Motion::Right);
assert_eq!(s.cursor(), Position::new(1, 2));
assert_eq!(s.cursors.count(), 1);
let w = s.layout.active_window().unwrap();
assert!(w.viewport.top_line <= s.cursor().line);
}
#[test]
fn insert_mode_is_ungated_so_repeat_typing_works() {
let mut s = new_state_with("");
s.tick(&press(KeyCode::Char('i')));
assert_eq!(s.modal.mode(), Mode::Insert);
let t = std::time::Instant::now();
for _ in 0..10 {
s.tick_at(&press(KeyCode::Char('x')), t);
}
assert_eq!(
s.buffers.get(s.active).unwrap().to_string(),
"xxxxxxxxxx",
"insert-mode repeat typing is ungated",
);
}
mod courier_seam {
use super::new_state_with;
use escriba_madoguchi::Negai;
use escriba_madoguchi::errand::{Crew, Errand, Freight, Parcel, Runner};
use escriba_shirube::{Anchor, Axis, ResultList, SessionKind};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::Sender;
fn a_scan() -> Freight {
Freight::Scan {
raw: "needle".into(),
case: escriba_search::CaseMode::Smart,
root: ".".into(),
}
}
struct Says(Negai);
impl Runner for Says {
fn start(&self, e: Errand, _c: Arc<AtomicBool>, reply: Sender<Parcel>) {
let _ = reply.send(Parcel {
id: e.id,
slip: self.0.clone(),
});
}
}
struct EchoesSeal(Negai);
impl Runner for EchoesSeal {
fn start(&self, e: Errand, _c: Arc<AtomicBool>, reply: Sender<Parcel>) {
let _ = reply.send(Parcel {
id: e.id,
slip: Negai::ErrandReply {
anchor: e.anchor.into_anchor(),
then: Box::new(self.0.clone()),
},
});
}
}
fn crew_with_scan(r: impl Runner + 'static) -> Crew {
Crew {
scan: Box::new(r),
diagnostics: Box::new(escriba_madoguchi::errand::Idle("t")),
format: Box::new(escriba_madoguchi::errand::Idle("t")),
}
}
#[test]
fn an_errand_is_dispatched_sealed_and_its_reply_applied_at_the_drain() {
let mut st = new_state_with("x\n");
st.hire(crew_with_scan(EchoesSeal(Negai::Message("done".into()))));
st.honour_one(Negai::Errand(Box::new(a_scan())));
assert!(
!st.messages.iter().any(|m| m == "done"),
"nothing is applied before the drain"
);
st.deliver();
assert!(
st.messages.iter().any(|m| m == "done"),
"the reply lands at the drain: {:?}",
st.messages
);
}
#[test]
fn a_reply_whose_world_moved_is_dropped() {
let mut st = new_state_with("x\n");
st.hire(crew_with_scan(EchoesSeal(Negai::Message("late".into()))));
st.honour_one(Negai::Errand(Box::new(a_scan())));
st.bump_scan_gen();
st.deliver();
assert!(
!st.messages.iter().any(|m| m == "late"),
"a superseded reply must not be applied: {:?}",
st.messages
);
}
#[test]
fn a_reply_whose_world_held_is_applied() {
let mut st = new_state_with("x\n");
st.hire(crew_with_scan(EchoesSeal(Negai::Message("ok".into()))));
st.honour_one(Negai::Errand(Box::new(a_scan())));
st.deliver();
assert!(st.messages.iter().any(|m| m == "ok"));
}
#[test]
fn typing_does_not_stale_a_scan_reply() {
let mut st = new_state_with("x\n");
st.hire(crew_with_scan(EchoesSeal(Negai::Message("rows".into()))));
st.honour_one(Negai::Errand(Box::new(a_scan())));
st.insert_text("hello");
st.deliver();
assert!(
st.messages.iter().any(|m| m == "rows"),
"a scan does not depend on buffer text: {:?}",
st.messages
);
}
#[test]
fn findings_from_an_errand_keep_the_narrow_seal_they_were_computed_with() {
let mut st = new_state_with("x\n");
st.hire(crew_with_scan(EchoesSeal(Negai::PublishFindings {
list: "grep".into(),
findings: vec![],
})));
st.honour_one(Negai::Errand(Box::new(a_scan())));
st.deliver();
let sealed_with = st.results.get("grep").expect("published").anchor().clone();
let axes = sealed_with.axes();
assert_eq!(axes.len(), 1, "narrow, not the whole world: {axes:?}");
assert!(
matches!(axes[0], Axis::Session(SessionKind::Scan, _)),
"sealed on the scan session: {axes:?}"
);
st.insert_text("more");
assert!(
!st.results
.get("grep")
.expect("still there")
.is_stale(&st.world()),
"an unrelated edit must not stale a scan list"
);
}
#[test]
fn a_direct_publish_still_seals_at_the_world() {
let mut st = new_state_with("x\n");
st.honour_one(Negai::PublishFindings {
list: "todo".into(),
findings: vec![],
});
let axes = st.results.get("todo").expect("published").anchor().axes();
assert!(
axes.len() > 1,
"the on-tick path anchors on the whole world: {axes:?}"
);
}
#[test]
fn an_empty_anchor_would_bypass_the_gate_which_is_why_seal_cannot_mint_one() {
let mut st = new_state_with("x\n");
st.bump_scan_gen();
st.bump_lsp_gen();
st.insert_text("moved a long way");
st.honour_one(Negai::ErrandReply {
anchor: Anchor::new(),
then: Box::new(Negai::Message("forged".into())),
});
assert!(
st.messages.iter().any(|m| m == "forged"),
"an empty anchor passes any world — the hazard NonEmptyAnchor removes"
);
}
#[test]
fn closing_the_picker_supersedes_the_scan_it_was_feeding() {
let mut st = new_state_with("x\n");
st.hire(crew_with_scan(EchoesSeal(Negai::Message("rows".into()))));
st.honour_one(Negai::Errand(Box::new(a_scan())));
st.close_picker();
st.deliver();
assert!(
!st.messages.iter().any(|m| m == "rows"),
"rows must not reopen a picker the operator closed: {:?}",
st.messages
);
}
#[test]
fn an_errand_with_no_crew_hired_says_so() {
let mut st = new_state_with("x\n");
st.honour_one(Negai::Errand(Box::new(a_scan())));
st.deliver();
assert!(
st.messages.iter().any(|m| m.contains("scan")),
"the inert crew announces: {:?}",
st.messages
);
}
#[test]
fn delivering_nothing_does_not_repaint() {
let mut st = new_state_with("x\n");
let before = st.edit_gen();
st.deliver();
assert_eq!(st.edit_gen(), before, "an empty drain is not a change");
}
#[test]
fn delivering_something_repaints() {
let mut st = new_state_with("x\n");
st.hire(crew_with_scan(Says(Negai::Message("hi".into()))));
st.honour_one(Negai::Errand(Box::new(a_scan())));
let before = st.edit_gen();
st.deliver();
assert_ne!(st.edit_gen(), before, "a delivered reply repaints");
}
#[test]
fn the_two_session_generations_are_independent() {
let mut st = new_state_with("x\n");
let scan_sealed = ResultList::new(
vec![],
Anchor::new().on(Axis::Session(SessionKind::Scan, st.scan_gen)),
);
st.bump_lsp_gen();
assert!(
!scan_sealed.is_stale(&st.world()),
"an LSP restart must not discard scan results"
);
st.bump_scan_gen();
assert!(scan_sealed.is_stale(&st.world()), "…but a scan bump does");
}
#[test]
fn every_freight_class_seals_on_something() {
let mut st = new_state_with("x\n");
let active = st.active;
for freight in [
a_scan(),
Freight::Diagnostics {
buffer: active,
path: "a.nix".into(),
language: None,
text: String::new(),
},
Freight::Format {
buffer: active,
path: "a.nix".into(),
language: None,
text: String::new(),
},
] {
let sealed = st.seal(&freight);
assert!(
!sealed.as_anchor().is_empty(),
"{} sealed on nothing",
freight.label()
);
}
let _ = &mut st;
}
}
}