1extern crate self as escriba_runtime;
9
10mod plugin_host;
11pub use plugin_host::{LazyTrigger, PluginHost};
12
13mod operator_pending;
14pub mod status;
15
16pub use operator_pending::{OpState, OperatorPending};
17pub use status::{PromptKind, StatusModel};
18
19use std::collections::HashMap;
20
21use awase::KeyRepeatGate;
22use escriba_buffer::BufferSet;
23use escriba_command::{CommandRegistry, EditContext};
24use escriba_core::{
25 Action, BufferId, Cursors, Damage, Edit, EditGen, Mode, Motion, Operator, Position, Range,
26 TextEffect, WindowId,
27};
28use escriba_input::{InputOutcome, translate_app_event};
29use escriba_keymap::{Key, Keymap};
30use escriba_mode::ModalState;
31use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
32use escriba_ui::{Layout, Rect, Viewport, Window};
33use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, HostEffect, VmError};
34use madori::AppEvent;
35use std::time::Instant;
36
37pub struct EditorState {
40 pub buffers: BufferSet,
41 pub modal: ModalState,
42 pub search: SearchState,
46 pub keymap: Keymap,
47 pub commands: CommandRegistry,
48 pub layout: Layout,
49 pub active: BufferId,
50 cursors: Cursors,
55 pub quit_requested: bool,
56 pub messages: Vec<String>,
59 search_at: Option<usize>,
63 pub options: HashMap<String, String>,
67 lisp_vm: Option<EscribaVm>,
73 pub pending_keys: Vec<Key>,
79 repeat_gate: KeyRepeatGate<Key>,
87 pub plugin_host: PluginHost,
92 register: Option<String>,
97 op_pending: zenmai::Stateful<OperatorPending>,
102 edit_gen: EditGen,
107 damage: Damage,
112}
113
114enum SeqStep {
116 Pending,
118 Resolved(Action),
120 Passthrough,
122}
123
124impl EditorState {
125 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
127 let window = Window {
128 id: WindowId(1),
129 buffer_id: active,
130 viewport: Viewport {
131 top_line: 0,
132 left_column: 0,
133 visible_lines: 40,
134 visible_columns: 160,
135 },
136 rect: Rect {
137 x: 0,
138 y: 0,
139 width: 1200,
140 height: 800,
141 },
142 };
143 Self {
144 buffers: initial,
145 modal: ModalState::new(),
146 search: SearchState::new(escriba_search::CaseMode::Smart),
147 search_at: None,
148 keymap: Keymap::default_vim(),
149 commands: CommandRegistry::default_set(),
150 layout: Layout::single(window),
151 active,
152 cursors: Cursors::single(Position::ZERO),
153 quit_requested: false,
154 register: None,
155 op_pending: zenmai::Stateful::new(OpState::Resting),
156 messages: Vec::new(),
157 options: HashMap::new(),
158 lisp_vm: None,
159 pending_keys: Vec::new(),
160 repeat_gate: KeyRepeatGate::new(),
161 plugin_host: PluginHost::default(),
162 edit_gen: EditGen::default(),
163 damage: Damage::None,
164 }
165 }
166
167 #[must_use]
171 pub fn edit_gen(&self) -> EditGen {
172 self.edit_gen
173 }
174
175 fn bump_gen(&mut self) {
177 self.edit_gen = self.edit_gen.next();
178 }
179
180 #[must_use]
182 pub fn damage(&self) -> Damage {
183 self.damage
184 }
185
186 pub fn take_damage(&mut self) -> Damage {
190 std::mem::replace(&mut self.damage, Damage::None)
191 }
192
193 fn active_line_count(&self) -> u32 {
196 self.buffers
197 .get(self.active)
198 .map_or(0, escriba_buffer::Buffer::line_count)
199 }
200
201 pub fn register_lazy_plugin(
207 &mut self,
208 name: impl Into<String>,
209 triggers: Vec<LazyTrigger>,
210 entry_src: impl Into<String>,
211 ) {
212 self.plugin_host.register(name, triggers, entry_src);
213 }
214
215 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
221 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
222 return 0;
223 };
224 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
225 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
226 if let Some(value) = self.options.get("mapleader") {
227 if let Some(key) = escriba_lisp::parse_leader_key(value) {
228 self.keymap.set_leader(key);
229 }
230 }
231 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
232 (cmd.registered + km.keybinds_applied) as usize
233 }
234
235 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
239 let pending = self.plugin_host.pending_for_filetype(filetype);
240 let n = pending.len();
241 for src in pending {
242 self.apply_plugin_entry(&src);
243 }
244 n
245 }
246
247 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
250 let pending = self.plugin_host.pending_for_event(event);
251 let n = pending.len();
252 for src in pending {
253 self.apply_plugin_entry(&src);
254 }
255 n
256 }
257
258 pub fn tick(&mut self, event: &AppEvent) {
263 self.tick_at(event, Instant::now());
264 }
265
266 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
270 match translate_app_event(event) {
271 InputOutcome::Key(k) => {
272 if self.gate_key(&k, now) {
273 self.on_key(&k);
274 }
275 }
276 InputOutcome::Resized { width, height } => {
277 if let Some(w) = self
278 .layout
279 .windows
280 .iter_mut()
281 .find(|w| w.id == self.layout.active)
282 {
283 w.rect.width = width;
284 w.rect.height = height;
285 }
286 self.damage = self.damage.join(Damage::Viewport);
287 self.bump_gen();
288 }
289 InputOutcome::Quit => self.quit_requested = true,
290 InputOutcome::Focus(_) | InputOutcome::None => {}
291 }
292 }
293
294 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
304 match self.modal.mode() {
305 Mode::Normal | Mode::Visual | Mode::VisualLine => {
306 self.repeat_gate.try_pass_at(*key, now)
307 }
308 Mode::Insert | Mode::Command => true,
309 }
310 }
311
312 pub fn on_key(&mut self, key: &Key) {
314 match self.step_sequence(key) {
318 SeqStep::Pending => return,
319 SeqStep::Resolved(action) => {
320 let count = self.modal.pending_count().unwrap_or(1);
321 self.modal.clear_count();
322 for _ in 0..count {
323 self.apply(&action);
324 if self.quit_requested {
325 return;
326 }
327 }
328 return;
329 }
330 SeqStep::Passthrough => {}
331 }
332 let counted = self.keymap.dispatch(&self.modal, key);
333 if matches!(counted.action, Action::Pending) {
335 if let Key::Char(c) = key {
336 if c.is_ascii_digit() {
337 let d = u32::from(*c as u8 - b'0');
338 self.modal.append_count(d);
339 }
340 }
341 return;
342 }
343 self.apply_counted(&counted.action, counted.count);
347 self.modal.clear_count();
349 }
350
351 fn step_sequence(&mut self, key: &Key) -> SeqStep {
363 let mode = self.modal.mode();
364 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
365 return SeqStep::Passthrough;
366 }
367 if !self.pending_keys.is_empty() {
368 let mut seq = self.pending_keys.clone();
369 seq.push(key.clone());
370 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
371 let action = b.action.clone();
372 self.pending_keys.clear();
373 return SeqStep::Resolved(action);
374 }
375 if self.keymap.is_sequence_prefix(mode, &seq) {
376 self.pending_keys = seq;
377 return SeqStep::Pending;
378 }
379 self.pending_keys.clear();
382 }
383 let start = [key.clone()];
384 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
385 self.pending_keys = start.to_vec();
386 return SeqStep::Pending;
387 }
388 SeqStep::Passthrough
389 }
390
391 #[must_use]
396 pub fn cursor(&self) -> Position {
397 self.cursors.primary()
398 }
399
400 fn set_cursor(&mut self, pos: Position) {
409 let clamped = if let Some(buf) = self.buffers.get(self.active) {
410 buf.clamp(pos)
411 } else {
412 pos
413 };
414 self.cursors.set_primary(clamped);
415 if let Some(w) = self
416 .layout
417 .windows
418 .iter_mut()
419 .find(|w| w.id == self.layout.active)
420 {
421 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
422 }
423 }
424
425 fn apply(&mut self, action: &Action) {
427 self.apply_counted(action, 1);
428 }
429
430 fn apply_counted(&mut self, action: &Action, count: u32) {
438 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
439 for _ in 0..times {
440 self.apply_resolved(&resolved);
441 if self.quit_requested {
442 return;
443 }
444 }
445 }
446 }
447
448 fn active_text(&self) -> String {
450 self.buffers
451 .get(self.active)
452 .map(escriba_buffer::Buffer::to_string)
453 .unwrap_or_default()
454 }
455
456 fn cursor_char(&self) -> usize {
458 self.buffers
459 .get(self.active)
460 .and_then(|b| b.position_to_char(self.cursor()).ok())
461 .unwrap_or(0)
462 }
463
464 #[must_use]
472 pub fn status_model(&self) -> StatusModel<'_> {
473 let cursor = self.cursor();
474 let prompt = self.search.prompt();
475
476 let kind = match prompt.map(|p| p.direction) {
477 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
478 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
479 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
482 None => PromptKind::None,
483 };
484
485 StatusModel {
486 mode: self.modal.mode(),
487 line: cursor.line.saturating_add(1) as usize,
488 column: cursor.column.saturating_add(1) as usize,
489 prompt: kind,
490 prompt_text: prompt.map_or_else(|| self.modal.minibuffer(), |p| p.text.as_str()),
491 count: self.match_count(),
492 message: self.messages.last().map(String::as_str),
493 }
494 }
495
496 #[must_use]
502 fn match_count(&self) -> MatchCount {
503 if self.search.is_prompting() {
504 let text = self.active_text();
505 let total = self.search.preview_total(&text);
506 return match self.search.preview(&text) {
507 Some(step) => MatchCount::new(step.index, total),
508 None if total == 0 && !self.search.prompt_is_empty() => MatchCount::None,
512 None => MatchCount::Idle,
513 };
514 }
515 if self.search.pattern().is_none() {
516 return MatchCount::Idle;
517 }
518 let total = self.search.matches().len();
519 self.search_at.map_or(
520 if total == 0 {
521 MatchCount::None
522 } else {
523 MatchCount::Idle
524 },
525 |i| MatchCount::new(i, total),
526 )
527 }
528
529 fn land_on(&mut self, step: escriba_search::Step) {
530 if let Some(buf) = self.buffers.get(self.active) {
531 let pos = buf.char_to_position(step.target.start);
532 self.set_cursor(pos);
533 }
534 self.search_at = Some(step.index);
538 if let Some(msg) = step.wrapped.message() {
539 self.messages.push(msg.to_string());
540 }
541 }
542
543 fn jump_search(&mut self, reverse: bool) {
547 let at = self.cursor_char();
548 match self.search.repeat(at, reverse) {
549 Some(step) => self.land_on(step),
550 None => {
551 let msg = self.search.pattern().map_or_else(
552 || "E35: No previous regular expression".to_string(),
553 |p| {
554 let mut m = String::from("E486: Pattern not found: ");
555 m.push_str(p.raw());
556 m
557 },
558 );
559 self.messages.push(msg);
560 }
561 }
562 }
563
564 fn preview_search(&mut self) {
571 let text = self.active_text();
572 if let Some(step) = self.search.preview(&text) {
573 if let Some(buf) = self.buffers.get(self.active) {
574 let pos = buf.char_to_position(step.target.start);
575 self.set_cursor(pos);
576 }
577 }
578 }
579
580 fn submit_search(&mut self) {
582 let text = self.active_text();
583 let at = self
598 .search
599 .prompt()
600 .map_or_else(|| self.cursor_char(), |p| p.origin);
601 let outcome = self.search.accept(&text);
602 match outcome {
603 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
604 self.modal.clear_minibuffer();
605 self.modal.enter(Mode::Normal);
606 match self.search.commit_step(at) {
607 Some(step) => self.land_on(step),
608 None => {
609 let mut m = String::from("E486: Pattern not found");
610 if let Some(p) = self.search.pattern() {
611 m.push_str(": ");
612 m.push_str(p.raw());
613 }
614 self.messages.push(m);
615 }
616 }
617 }
618 escriba_search::Accepted::NothingToRepeat => {
619 self.modal.clear_minibuffer();
620 self.modal.enter(Mode::Normal);
621 self.messages
622 .push("E35: No previous regular expression".to_string());
623 }
624 escriba_search::Accepted::Invalid(e) => {
627 let mut m = String::from("E383: Invalid search string: ");
628 m.push_str(&e.to_string());
629 self.messages.push(m);
630 }
631 }
632 }
633
634 fn apply_resolved(&mut self, action: &Action) {
635 let lines_before = self.active_line_count();
638 let cline_before = self.cursor().line;
639 match action {
640 Action::Move(m) => self.apply_motion(*m),
641 Action::SearchOpen(dir) => {
642 let origin = self.cursor_char();
646 self.search.open(*dir, origin);
647 self.modal.enter(Mode::Command);
648 }
649 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
650 Action::SearchWord { reverse } => {
651 let dir = if *reverse {
652 SearchDirection::Backward
653 } else {
654 SearchDirection::Forward
655 };
656 let (text, at) = (self.active_text(), self.cursor_char());
657 match self.search.search_word(&text, at, dir) {
658 Some(step) => self.land_on(step),
659 None => self
662 .messages
663 .push("E348: No string under cursor".to_string()),
664 }
665 }
666 Action::ClearSearchHighlight => self.search.clear_highlight(),
667 Action::ChangeMode(m) => {
668 if *m == Mode::Normal && self.search.is_prompting() {
672 if let Some(origin) = self.search.cancel() {
673 if let Some(buf) = self.buffers.get(self.active) {
674 let pos = buf.char_to_position(origin);
675 self.set_cursor(pos);
676 }
677 }
678 }
679 self.modal.enter(*m);
680 }
681 Action::InsertChar(c) => self.insert_char(*c),
682 Action::Edit(edit) => self.apply_edit(edit),
683 Action::Undo => {
684 if let Some(buf) = self.buffers.get_mut(self.active) {
685 let _ = buf.undo();
686 }
687 self.set_cursor(self.cursor());
690 }
691 Action::Redo => {
692 if let Some(buf) = self.buffers.get_mut(self.active) {
693 let _ = buf.redo();
694 }
695 self.set_cursor(self.cursor());
696 }
697 Action::Save => {
698 if let Some(buf) = self.buffers.get_mut(self.active) {
699 let _ = buf.save();
700 }
701 self.set_cursor(self.cursor());
702 }
703 Action::Quit => self.quit_requested = true,
704 Action::SubmitCommand => {
705 if self.search.is_prompting() {
706 self.submit_search();
707 } else {
708 self.submit_command();
709 }
710 }
711 Action::Command { name, args } => self.run_command(name, args),
712 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
713 Action::Operator(_) => {}
716 Action::PromptBackspace => {
717 self.prompt_backspace();
718 if self.search.is_prompting() {
722 self.preview_search();
723 }
724 }
725 Action::PromptHistory { back } => {
726 if self.search.is_prompting() {
727 self.search.history_step(*back);
728 self.modal.clear_minibuffer();
732 if let Some(text) = self.search.prompt().map(|p| p.text.clone()) {
733 self.modal.push_minibuffer_str(&text);
734 }
735 self.preview_search();
736 }
737 }
738 Action::Pending => {}
739 }
740 let lines_after = self.active_line_count();
745 let cline_after = self.cursor().line;
746 let d = match action {
747 Action::SearchOpen(_)
751 | Action::PromptHistory { .. }
752 | Action::PromptBackspace
753 | Action::SearchRepeat { .. }
754 | Action::SearchWord { .. }
755 | Action::ClearSearchHighlight => Damage::Full,
756 Action::InsertChar(_)
757 | Action::Edit(_)
758 | Action::Undo
759 | Action::Redo
760 | Action::ApplyOperator { .. } => {
761 if lines_after == lines_before {
762 Damage::span(cline_before, cline_after)
763 } else {
764 Damage::Lines {
765 from: cline_before.min(cline_after),
766 to: u32::MAX,
767 }
768 }
769 }
770 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
771 Action::Save => Damage::Viewport,
772 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
773 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
774 };
775 self.damage = self.damage.join(d);
776 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
785 let text = self.active_text();
786 self.search.refresh(&text);
787 let at = self.cursor_char();
793 self.search_at = self.search.matches().iter().position(|m| m.contains(at));
794 }
795 self.bump_gen();
800 }
801
802 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
812 let buf = self.buffers.get(self.active)?;
813 let pos = from;
814 Some(match motion {
815 Motion::SearchNext | Motion::SearchPrev => {
820 let at = buf.position_to_char(pos).ok()?;
821 let step = self
822 .search
823 .repeat(at, matches!(motion, Motion::SearchPrev))?;
824 buf.char_to_position(step.target.start)
825 }
826 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
827 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
828 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
829 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
830 Motion::LineStart => Position::new(pos.line, 0),
831 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
832 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
833 Motion::DocStart => Position::ZERO,
834 Motion::DocEnd => Position::new(
835 buf.line_count().saturating_sub(1),
836 buf.line_len_chars(buf.line_count().saturating_sub(1)),
837 ),
838 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
839 Motion::WordStartPrev => word_prev(buf, pos),
840 Motion::PageDown | Motion::HalfPageDown => {
841 Position::new(pos.line.saturating_add(10), pos.column)
842 }
843 Motion::PageUp | Motion::HalfPageUp => {
844 Position::new(pos.line.saturating_sub(10), pos.column)
845 }
846 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
847 Motion::ForwardSexp
850 | Motion::BackwardSexp
851 | Motion::UpList
852 | Motion::DownList
853 | Motion::BeginningOfDefun
854 | Motion::EndOfDefun
855 | Motion::BeginningOfSexp
856 | Motion::EndOfSexp => pos,
857 })
858 }
859
860 fn apply_motion(&mut self, motion: Motion) {
861 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
862 return;
863 };
864 self.set_cursor(pos);
867 }
868
869 fn apply_operator(&mut self, op: Operator, motion: Motion) {
876 let from = self.cursor();
877 let Some(to) = self.resolve_motion(from, motion) else {
878 return;
879 };
880 let range = Range {
881 start: from,
882 end: to,
883 }
884 .normalized();
885 if range.is_empty() {
886 return;
887 }
888 let text = self
890 .buffers
891 .get(self.active)
892 .and_then(|buf| buf.slice(range).ok());
893 if op.leaves_register() {
894 if let Some(t) = &text {
895 self.register = Some(t.clone());
896 }
897 }
898 match op {
899 Operator::Delete | Operator::Change => {
902 if let Some(buf) = self.buffers.get_mut(self.active) {
903 let _ = buf.apply(&Edit::delete(range));
904 }
905 self.set_cursor(range.start);
906 if op == Operator::Change {
907 self.modal.enter(Mode::Insert);
908 }
909 }
910 Operator::Yank => {
913 self.set_cursor(range.start);
914 }
915 _ => {
919 self.messages
920 .push("operator not yet implemented".to_owned());
921 }
922 }
923 }
924
925 #[must_use]
928 pub fn register(&self) -> Option<&str> {
929 self.register.as_deref()
930 }
931
932 fn insert_char(&mut self, c: char) {
933 if self.modal.mode() == Mode::Command {
934 if self.search.is_prompting() {
938 self.search.push(c);
939 self.modal.push_minibuffer(c);
940 self.preview_search();
941 } else {
942 self.modal.push_minibuffer(c);
943 }
944 return;
945 }
946 let cursor = self.cursor();
947 let Some(buf) = self.buffers.get_mut(self.active) else {
948 return;
949 };
950 let edit = Edit::insert(cursor, c.to_string());
951 if buf.apply(&edit).is_ok() {
952 let next = if c == '\n' {
953 Position::new(cursor.line.saturating_add(1), 0)
954 } else {
955 cursor.shift_right(1)
956 };
957 self.set_cursor(next);
960 }
961 }
962
963 fn prompt_backspace(&mut self) -> bool {
967 if self.modal.mode() != Mode::Command {
968 return false;
969 }
970 if self.search.is_prompting() {
971 if self.search.backspace() {
973 self.modal.clear_minibuffer();
974 self.modal.enter(Mode::Normal);
975 return true;
976 }
977 }
978 self.modal.pop_minibuffer();
979 true
980 }
981
982 fn apply_edit(&mut self, _edit: &Edit) {
983 }
987
988 fn submit_command(&mut self) {
989 let line = self.modal.minibuffer().to_string();
993 self.modal.escape();
994 let (name, args) = parse_command_line(&line);
995 if name.is_empty() {
996 return;
997 }
998 self.run_command(&name, &args);
999 }
1000
1001 fn run_command(&mut self, name: &str, args: &[String]) {
1002 if matches!(name, "noh" | "nohl" | "nohlsearch") {
1008 self.search.clear_highlight();
1009 return;
1010 }
1011 if self.plugin_host.pending() > 0 {
1017 let pending = self.plugin_host.pending_for_command(name);
1018 for src in pending {
1019 self.apply_plugin_entry(&src);
1020 }
1021 }
1022 let active = Some(self.active);
1023 let mut quit = false;
1024 {
1025 let mut ctx = EditContext {
1026 buffers: &mut self.buffers,
1027 active,
1028 state: &mut self.modal,
1029 quit_requested: &mut quit,
1030 };
1031 let _ = self.commands.run(name, &mut ctx, args);
1032 }
1033 if quit {
1036 self.quit_requested = true;
1037 }
1038 }
1039
1040 #[must_use]
1045 pub fn snapshot(&self) -> EditorSnapshot {
1046 let current_line = self
1047 .buffers
1048 .get(self.active)
1049 .and_then(|b| b.line(self.cursor().line))
1050 .map(|s| s.trim_end_matches('\n').to_string())
1051 .unwrap_or_default();
1052 let buffer_name = self
1053 .buffers
1054 .get(self.active)
1055 .and_then(|b| b.path.as_ref())
1056 .map(|p| p.display().to_string())
1057 .unwrap_or_else(|| "[scratch]".to_string());
1058 EditorSnapshot {
1059 cursor_line: i64::from(self.cursor().line),
1060 cursor_column: i64::from(self.cursor().column),
1061 current_line,
1062 mode: self.modal.mode().as_str().to_string(),
1063 buffer_name,
1064 }
1065 }
1066
1067 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
1083 let mut host = EscribaHost::with_snapshot(self.snapshot());
1084 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
1085 vm.eval(src, &mut host)?;
1086 let effects = host.take_effects();
1087 self.apply_host_effects(effects);
1088 Ok(())
1089 }
1090
1091 pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1095 for eff in effects {
1096 match eff {
1097 HostEffect::Message(m) => self.messages.push(m),
1098 HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
1099 HostEffect::SetOption { name, value } => {
1100 self.options.insert(name, value);
1101 }
1102 HostEffect::InsertText(text) => self.insert_text(&text),
1103 }
1104 }
1105 }
1106
1107 fn insert_text(&mut self, text: &str) {
1110 if text.is_empty() {
1111 return;
1112 }
1113 let cursor = self.cursor();
1114 let Some(buf) = self.buffers.get_mut(self.active) else {
1115 return;
1116 };
1117 let edit = Edit::insert(cursor, text.to_string());
1118 if buf.apply(&edit).is_ok() {
1119 let next = if let Some(nl) = text.rfind('\n') {
1120 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
1121 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
1122 Position::new(cursor.line + added_lines, last_line_len)
1123 } else {
1124 let n = u32::try_from(text.chars().count()).unwrap_or(0);
1125 cursor.shift_right(n)
1126 };
1127 self.set_cursor(next);
1130 }
1131 }
1132}
1133
1134fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1135 let Some(text) = buf.line(line) else {
1136 return Position::new(line, 0);
1137 };
1138 let col = text
1139 .chars()
1140 .take_while(|c| c.is_whitespace() && *c != '\n')
1141 .count();
1142 Position::new(line, u32::try_from(col).unwrap_or(0))
1143}
1144
1145fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1146 let Some(text) = buf.line(pos.line) else {
1147 return pos;
1148 };
1149 let chars: Vec<char> = text.chars().collect();
1150 let start = pos.column as usize;
1151 let mut i = start;
1152 while i < chars.len() && !chars[i].is_whitespace() {
1153 i += 1;
1154 }
1155 while i < chars.len() && chars[i].is_whitespace() {
1156 i += 1;
1157 }
1158 if i >= chars.len() {
1159 if pos.line + 1 < buf.line_count() {
1161 return Position::new(pos.line + 1, 0);
1162 }
1163 }
1164 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1165}
1166
1167fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1168 let Some(text) = buf.line(pos.line) else {
1169 return pos;
1170 };
1171 let chars: Vec<char> = text.chars().collect();
1172 let mut i = (pos.column as usize).min(chars.len());
1173 while i > 0 && chars[i - 1].is_whitespace() {
1174 i -= 1;
1175 }
1176 while i > 0 && !chars[i - 1].is_whitespace() {
1177 i -= 1;
1178 }
1179 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1180}
1181
1182fn parse_command_line(line: &str) -> (String, Vec<String>) {
1183 let mut parts = line.split_whitespace();
1184 let Some(first) = parts.next() else {
1185 return (String::new(), Vec::new());
1186 };
1187 let head = first.strip_prefix(':').unwrap_or(first);
1188 let name = match head {
1189 "w" => "save",
1190 "q" => "quit",
1191 "u" => "undo",
1192 other => other,
1193 };
1194 (name.to_string(), parts.map(str::to_string).collect())
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200 use madori::event::{KeyCode, KeyEvent, Modifiers};
1201
1202 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1210 st.apply(&Action::SearchOpen(dir));
1211 for c in pat.chars() {
1212 st.apply(&Action::InsertChar(c));
1213 }
1214 st.apply(&Action::SubmitCommand);
1215 }
1216
1217 #[test]
1218 fn slash_search_moves_the_cursor_to_the_match() {
1219 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1220 type_search(&mut st, SearchDirection::Forward, "charlie");
1221 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1222 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1223 assert_eq!(st.search.matches().len(), 1);
1224 }
1225
1226 #[test]
1227 fn n_and_N_walk_matches_in_both_directions() {
1228 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1229 type_search(&mut st, SearchDirection::Forward, "foo");
1230 let first = st.cursor().line;
1231 st.apply(&Action::SearchRepeat { reverse: false });
1232 let second = st.cursor().line;
1233 assert!(second > first, "n advances ({first} -> {second})");
1234 st.apply(&Action::SearchRepeat { reverse: true });
1235 assert_eq!(st.cursor().line, first, "N comes back");
1236 }
1237
1238 #[test]
1239 fn star_searches_the_word_under_the_cursor() {
1240 let mut st = new_state_with("needle\nhaystack\nneedle\n");
1241 st.apply(&Action::SearchWord { reverse: false });
1242 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1243 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1244 }
1245
1246 #[test]
1247 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1248 let mut st = new_state_with("foo\nbar\nfoo\n");
1249 type_search(&mut st, SearchDirection::Forward, "foo");
1250 let matches_before = st.search.matches().len();
1251
1252 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1253 st.apply(&Action::InsertChar('z'));
1254 st.apply(&Action::ChangeMode(Mode::Normal));
1255
1256 assert!(!st.search.is_prompting(), "prompt gone");
1257 assert_eq!(
1258 st.search.pattern().unwrap().raw(),
1259 "foo",
1260 "old pattern survives"
1261 );
1262 assert_eq!(
1263 st.search.matches().len(),
1264 matches_before,
1265 "old highlights survive"
1266 );
1267 }
1268
1269 #[test]
1270 fn a_search_prompt_and_an_ex_command_are_not_confused() {
1271 let mut st = new_state_with("foo\n");
1272 st.apply(&Action::ChangeMode(Mode::Command));
1274 assert!(!st.search.is_prompting(), "`:` must not open a search");
1275 st.apply(&Action::InsertChar('w'));
1276 assert!(
1277 st.search.prompt().is_none(),
1278 "typed char went to the ex line"
1279 );
1280 }
1281
1282 #[test]
1283 fn a_missing_pattern_reports_instead_of_failing_silently() {
1284 let mut st = new_state_with("alpha\nbravo\n");
1285 type_search(&mut st, SearchDirection::Forward, "zzz");
1286 assert!(
1287 st.messages.iter().any(|m| m.contains("E486")),
1288 "must report not-found, got {:?}",
1289 st.messages
1290 );
1291 }
1292
1293 #[test]
1294 fn n_without_any_search_reports_rather_than_moving() {
1295 let mut st = new_state_with("alpha\nbravo\n");
1296 let before = st.cursor();
1297 st.apply(&Action::SearchRepeat { reverse: false });
1298 assert_eq!(st.cursor(), before, "cursor must not move");
1299 assert!(
1300 st.messages.iter().any(|m| m.contains("E35")),
1301 "got {:?}",
1302 st.messages
1303 );
1304 }
1305
1306 #[test]
1307 fn search_as_a_motion_composes_with_an_operator() {
1308 let mut st = new_state_with("alpha bravo charlie\n");
1310 type_search(&mut st, SearchDirection::Forward, "charlie");
1311 st.set_cursor(Position::new(0, 0));
1312 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1313 assert!(target.is_some(), "search must resolve as a motion");
1314 assert_eq!(target.unwrap().column, 12, "at `charlie`");
1315 }
1316
1317 #[test]
1318 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1319 let st = new_state_with("alpha bravo\n");
1322 assert!(
1323 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
1324 .is_none()
1325 );
1326 }
1327
1328 #[test]
1329 fn clear_highlight_keeps_the_pattern_usable() {
1330 let mut st = new_state_with("foo\nbar\nfoo\n");
1331 type_search(&mut st, SearchDirection::Forward, "foo");
1332 st.apply(&Action::ClearSearchHighlight);
1333 assert!(st.search.highlights().is_empty(), "nothing lit");
1334 st.apply(&Action::SearchRepeat { reverse: false });
1335 assert!(st.search.pattern().is_some(), "but n still works");
1336 }
1337
1338 #[test]
1339 fn typing_previews_incrementally_before_commit() {
1340 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1341 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1342 for c in "charlie".chars() {
1343 st.apply(&Action::InsertChar(c));
1344 }
1345 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1347 assert!(st.search.pattern().is_none(), "but nothing is committed");
1348 }
1349
1350 #[test]
1351 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1352 let mut st = new_state_with("alpha\nbravo\n");
1353 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1354 for c in "bravox".chars() {
1355 st.apply(&Action::InsertChar(c));
1356 }
1357 assert_eq!(st.search.prompt().unwrap().text, "bravox");
1358 st.apply(&Action::PromptBackspace);
1359 assert_eq!(st.search.prompt().unwrap().text, "bravo", "typo corrected");
1360 assert_eq!(st.modal.minibuffer(), "bravo", "display stays in lockstep");
1361 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1362 }
1363
1364 #[test]
1365 fn backspacing_past_the_slash_closes_the_prompt() {
1366 let mut st = new_state_with("alpha\n");
1367 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1368 st.apply(&Action::InsertChar('a'));
1369 st.apply(&Action::PromptBackspace);
1370 st.apply(&Action::PromptBackspace);
1371 assert!(!st.search.is_prompting(), "prompt closed");
1372 assert_eq!(st.modal.mode(), Mode::Normal);
1373 }
1374
1375 #[test]
1376 fn noh_clears_highlights_and_keeps_the_pattern() {
1377 let mut st = new_state_with("foo\nbar\nfoo\n");
1378 type_search(&mut st, SearchDirection::Forward, "foo");
1379 assert!(!st.search.highlights().is_empty());
1380 st.run_command("noh", &[]);
1381 assert!(st.search.highlights().is_empty(), ":noh turns them off");
1382 assert!(st.search.pattern().is_some(), "but n still works");
1383 }
1384
1385 #[test]
1386 fn noh_accepts_the_vim_aliases() {
1387 for name in ["noh", "nohl", "nohlsearch"] {
1388 let mut st = new_state_with("foo\nfoo\n");
1389 type_search(&mut st, SearchDirection::Forward, "foo");
1390 st.run_command(name, &[]);
1391 assert!(st.search.highlights().is_empty(), "{name} must clear");
1392 }
1393 }
1394
1395 #[test]
1396 fn backspace_on_the_ex_line_does_not_touch_search_state() {
1397 let mut st = new_state_with("foo\n");
1398 st.apply(&Action::ChangeMode(Mode::Command));
1399 st.apply(&Action::InsertChar('w'));
1400 st.apply(&Action::InsertChar('q'));
1401 st.apply(&Action::PromptBackspace);
1402 assert_eq!(st.modal.minibuffer(), "w");
1403 assert!(st.search.prompt().is_none(), "no search was involved");
1404 }
1405
1406 #[test]
1407 fn up_arrow_recalls_the_previous_search() {
1408 let mut st = new_state_with("alpha\nbravo\n");
1409 type_search(&mut st, SearchDirection::Forward, "bravo");
1410 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1411 st.apply(&Action::PromptHistory { back: true });
1412 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1413 assert_eq!(st.modal.minibuffer(), "bravo", "display follows the prompt");
1414 }
1415
1416 #[test]
1417 fn arrowing_back_down_restores_the_half_typed_pattern() {
1418 let mut st = new_state_with("alpha\nbravo\n");
1419 type_search(&mut st, SearchDirection::Forward, "bravo");
1420 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1421 st.apply(&Action::InsertChar('a'));
1422 st.apply(&Action::PromptHistory { back: true });
1423 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1424 st.apply(&Action::PromptHistory { back: false });
1425 assert_eq!(
1426 st.search.prompt().unwrap().text,
1427 "a",
1428 "the draft comes back"
1429 );
1430 assert_eq!(st.modal.minibuffer(), "a");
1431 }
1432
1433 #[test]
1434 fn history_arrows_do_nothing_on_the_ex_line() {
1435 let mut st = new_state_with("alpha\n");
1436 st.apply(&Action::ChangeMode(Mode::Command));
1437 st.apply(&Action::InsertChar('w'));
1438 st.apply(&Action::PromptHistory { back: true });
1439 assert_eq!(st.modal.minibuffer(), "w", "ex line untouched");
1440 }
1441
1442 fn new_state_with(text: &str) -> EditorState {
1443 let mut bufs = BufferSet::new();
1444 let id = bufs.scratch(text);
1445 EditorState::new_with_buffer(bufs, id)
1446 }
1447
1448 #[test]
1454 fn edit_gen_advances_on_applied_action_not_on_read() {
1455 let mut s = new_state_with("hello\nworld\n");
1456 let g0 = s.edit_gen();
1457 s.apply(&Action::InsertChar('X'));
1458 assert_ne!(
1459 s.edit_gen(),
1460 g0,
1461 "an applied action must advance the refresh generation",
1462 );
1463 let g1 = s.edit_gen();
1465 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1466 }
1467
1468 #[test]
1473 fn damage_tracks_edit_scope_and_drains() {
1474 let mut s = new_state_with("hello\nworld\n");
1475 assert!(s.damage().is_none(), "a fresh state has no damage");
1476
1477 s.apply(&Action::InsertChar('X')); assert_eq!(
1479 s.damage(),
1480 Damage::Lines { from: 0, to: 0 },
1481 "a local edit damages just its line",
1482 );
1483
1484 let drained = s.take_damage();
1485 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
1486 assert!(s.damage().is_none(), "take_damage drains to None");
1487
1488 s.apply(&Action::InsertChar('\n')); assert_eq!(
1490 s.damage(),
1491 Damage::Lines {
1492 from: 0,
1493 to: u32::MAX,
1494 },
1495 "a line-count change damages to end-of-document",
1496 );
1497 }
1498
1499 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
1503 let mut s = new_state_with(text);
1504 for w in &mut s.layout.windows {
1505 w.viewport.visible_lines = vis_lines;
1506 w.viewport.visible_columns = vis_cols;
1507 }
1508 s
1509 }
1510
1511 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
1516 let w = s.layout.active_window().expect("active window");
1517 let v = w.viewport;
1518 let c = s.cursor();
1519 assert!(
1520 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
1521 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
1522 c.line,
1523 v.top_line,
1524 v.top_line + v.visible_lines,
1525 );
1526 assert!(
1527 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
1528 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
1529 c.column,
1530 v.left_column,
1531 v.left_column + v.visible_columns,
1532 );
1533 }
1534
1535 fn press(kc: KeyCode) -> AppEvent {
1536 AppEvent::Key(KeyEvent {
1537 key: kc,
1538 pressed: true,
1539 modifiers: Modifiers::default(),
1540 text: None,
1541 })
1542 }
1543
1544 fn line0_len(s: &EditorState) -> u32 {
1547 s.buffers.get(s.active).unwrap().line_len_chars(0)
1548 }
1549
1550 #[test]
1551 fn delete_to_line_end_clears_line_and_fills_register() {
1552 let mut s = new_state_with("hello world");
1553 s.apply(&Action::ApplyOperator {
1554 op: Operator::Delete,
1555 motion: Motion::LineEnd,
1556 });
1557 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
1558 assert_eq!(
1559 s.register(),
1560 Some("hello world"),
1561 "delete fills the register"
1562 );
1563 assert_eq!(
1564 s.cursor(),
1565 Position::ZERO,
1566 "cursor lands at the range start"
1567 );
1568 }
1569
1570 #[test]
1571 fn delete_over_right_motion_removes_one_char() {
1572 let mut s = new_state_with("abc");
1573 s.apply(&Action::ApplyOperator {
1574 op: Operator::Delete,
1575 motion: Motion::Right,
1576 });
1577 assert_eq!(
1578 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1579 Some("bc")
1580 );
1581 assert_eq!(s.register(), Some("a"));
1582 }
1583
1584 #[test]
1585 fn change_to_line_end_deletes_and_enters_insert() {
1586 let mut s = new_state_with("hello world");
1587 assert_eq!(s.modal.mode(), Mode::Normal);
1588 s.apply(&Action::ApplyOperator {
1589 op: Operator::Change,
1590 motion: Motion::LineEnd,
1591 });
1592 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
1593 assert_eq!(
1594 s.modal.mode(),
1595 Mode::Insert,
1596 "change enters Insert to type the replacement"
1597 );
1598 assert_eq!(
1599 s.register(),
1600 Some("hello world"),
1601 "change fills the register"
1602 );
1603 }
1604
1605 #[test]
1606 fn yank_to_line_end_fills_register_without_mutating() {
1607 let mut s = new_state_with("hello world");
1608 s.apply(&Action::ApplyOperator {
1609 op: Operator::Yank,
1610 motion: Motion::LineEnd,
1611 });
1612 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
1613 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
1614 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
1615 }
1616
1617 #[test]
1618 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
1619 let mut s = new_state_with("hello world");
1623 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
1624 assert_eq!(target, Position::new(0, 11));
1625 s.apply_motion(Motion::LineEnd);
1626 assert_eq!(
1627 s.cursor(),
1628 target,
1629 "the move path resolves the same target the operator uses"
1630 );
1631 }
1632
1633 #[test]
1634 fn empty_motion_range_is_a_no_op() {
1635 let mut s = new_state_with("abc");
1638 s.apply(&Action::ApplyOperator {
1639 op: Operator::Delete,
1640 motion: Motion::LineStart,
1641 });
1642 assert_eq!(
1643 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1644 Some("abc")
1645 );
1646 assert_eq!(s.register(), None);
1647 }
1648
1649 #[test]
1650 fn operator_then_motion_composes_through_the_pending_fsm() {
1651 let mut s = new_state_with("hello world");
1655 s.apply(&Action::Operator(Operator::Delete));
1656 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
1657 s.apply(&Action::Move(Motion::LineEnd));
1658 assert_eq!(
1659 line0_len(&s),
1660 0,
1661 "d then $ composes d$ and deletes the line"
1662 );
1663 assert_eq!(s.register(), Some("hello world"));
1664 }
1665
1666 #[test]
1667 fn change_operator_through_fsm_enters_insert() {
1668 let mut s = new_state_with("hello world");
1669 s.apply(&Action::Operator(Operator::Change));
1670 s.apply(&Action::Move(Motion::LineEnd));
1671 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
1672 }
1673
1674 #[test]
1675 fn lone_motion_after_no_operator_just_moves() {
1676 let mut s = new_state_with("hello world");
1678 s.apply(&Action::Move(Motion::LineEnd));
1679 assert_eq!(s.cursor(), Position::new(0, 11));
1680 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
1681 }
1682
1683 #[test]
1684 fn counted_operator_deletes_count_times() {
1685 let mut s = new_state_with("abcdef");
1689 s.apply_counted(&Action::Operator(Operator::Delete), 3);
1690 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
1691 s.apply(&Action::Move(Motion::Right));
1692 assert_eq!(
1693 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1694 Some("def")
1695 );
1696 }
1697
1698 #[test]
1699 fn operator_and_motion_counts_multiply_end_to_end() {
1700 let mut s = new_state_with("abcdefgh");
1702 s.apply_counted(&Action::Operator(Operator::Delete), 2);
1703 s.apply_counted(&Action::Move(Motion::Right), 3);
1704 assert_eq!(
1705 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1706 Some("gh")
1707 );
1708 }
1709
1710 #[test]
1711 fn bare_counted_motion_still_repeats_no_regression() {
1712 let mut s = new_state_with("a\nb\nc\nd\ne");
1715 s.apply_counted(&Action::Move(Motion::Down), 3);
1716 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
1717 }
1718
1719 struct SpacedClock(std::time::Instant);
1725 impl SpacedClock {
1726 fn new() -> Self {
1727 Self(std::time::Instant::now())
1728 }
1729 fn next(&mut self) -> std::time::Instant {
1730 self.0 += std::time::Duration::from_secs(1);
1731 self.0
1732 }
1733 }
1734
1735 #[test]
1736 fn hjkl_moves_cursor() {
1737 let mut s = new_state_with("hello\nworld");
1738 s.tick(&press(KeyCode::Char('l')));
1739 assert_eq!(s.cursor().column, 1);
1740 s.tick(&press(KeyCode::Char('j')));
1741 assert_eq!(s.cursor().line, 1);
1742 s.tick(&press(KeyCode::Char('h')));
1743 assert_eq!(s.cursor().column, 0);
1744 }
1745
1746 #[test]
1747 fn insert_mode_inserts_chars() {
1748 let mut s = new_state_with("");
1749 s.tick(&press(KeyCode::Char('i')));
1750 assert_eq!(s.modal.mode(), Mode::Insert);
1751 s.tick(&press(KeyCode::Char('h')));
1752 s.tick(&press(KeyCode::Char('i')));
1753 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
1754 assert_eq!(s.cursor().column, 2);
1755 }
1756
1757 #[test]
1758 fn esc_returns_to_normal() {
1759 let mut s = new_state_with("");
1760 s.tick(&press(KeyCode::Char('i')));
1761 s.tick(&press(KeyCode::Escape));
1762 assert_eq!(s.modal.mode(), Mode::Normal);
1763 }
1764
1765 #[test]
1766 fn count_prefix_repeats_motion() {
1767 let mut s = new_state_with("abcdefghij");
1768 s.tick(&press(KeyCode::Char('5')));
1769 s.tick(&press(KeyCode::Char('l')));
1770 assert_eq!(s.cursor().column, 5);
1771 }
1772
1773 #[test]
1774 fn close_event_requests_quit() {
1775 let mut s = new_state_with("");
1776 s.tick(&AppEvent::CloseRequested);
1777 assert!(s.quit_requested);
1778 }
1779
1780 #[test]
1781 fn word_next_jumps_past_whitespace() {
1782 let mut s = new_state_with("foo bar baz");
1783 let mut clk = SpacedClock::new();
1786 s.tick_at(&press(KeyCode::Char('w')), clk.next());
1787 assert_eq!(s.cursor().column, 4);
1788 s.tick_at(&press(KeyCode::Char('w')), clk.next());
1789 assert_eq!(s.cursor().column, 8);
1790 }
1791
1792 #[test]
1795 fn leader_sequence_holds_then_resolves() {
1796 let mut s = new_state_with("a\nbb\nccc");
1797 s.keymap.bind_sequence(
1798 Mode::Normal,
1799 vec![Key::Char(','), Key::Char('g')],
1800 Action::Move(Motion::DocEnd),
1801 "doc end",
1802 );
1803 s.on_key(&Key::Char(','));
1805 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
1806 assert_eq!(s.cursor(), Position::ZERO);
1807 s.on_key(&Key::Char('g'));
1809 assert!(s.pending_keys.is_empty());
1810 assert_eq!(s.cursor().line, 2);
1811 }
1812
1813 #[test]
1814 fn two_key_gg_jumps_doc_start() {
1815 let mut s = new_state_with("a\nbb\nccc");
1816 s.keymap.bind_sequence(
1817 Mode::Normal,
1818 vec![Key::Char('g'), Key::Char('g')],
1819 Action::Move(Motion::DocStart),
1820 "doc start",
1821 );
1822 let mut clk = SpacedClock::new();
1823 s.tick_at(&press(KeyCode::Char('j')), clk.next());
1824 s.tick_at(&press(KeyCode::Char('j')), clk.next());
1825 assert_eq!(s.cursor().line, 2);
1826 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
1828 s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
1830 }
1831
1832 #[test]
1833 fn broken_sequence_aborts_and_clears_pending() {
1834 let mut s = new_state_with("hello");
1835 s.keymap.bind_sequence(
1836 Mode::Normal,
1837 vec![Key::Char('g'), Key::Char('g')],
1838 Action::Move(Motion::DocEnd),
1839 "doc end",
1840 );
1841 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
1843 s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
1845 assert_eq!(s.cursor(), Position::ZERO);
1846 }
1847
1848 #[test]
1849 fn single_binding_wins_over_sequence_prefix() {
1850 let mut s = new_state_with("abcde");
1854 let mut clk = SpacedClock::new();
1855 s.tick_at(&press(KeyCode::Char('l')), clk.next());
1856 s.tick_at(&press(KeyCode::Char('l')), clk.next());
1857 assert_eq!(s.cursor().column, 2);
1858 s.keymap.bind_sequence(
1859 Mode::Normal,
1860 vec![Key::Char('h'), Key::Char('z')],
1861 Action::Move(Motion::DocEnd),
1862 "shadowed",
1863 );
1864 s.on_key(&Key::Char('h'));
1865 assert!(s.pending_keys.is_empty(), "single binding should not pend");
1866 assert_eq!(s.cursor().column, 1, "h moved left immediately");
1867 }
1868
1869 #[test]
1872 fn lisp_set_option_writes_live_options() {
1873 let mut s = new_state_with("");
1874 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
1875 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
1876 }
1877
1878 #[test]
1879 fn lisp_insert_modifies_buffer_and_advances_cursor() {
1880 let mut s = new_state_with("");
1881 s.run_lisp(r#"(insert "abc")"#).unwrap();
1882 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
1883 assert_eq!(s.cursor(), Position::new(0, 3));
1884 }
1885
1886 #[test]
1887 fn lisp_message_appends_to_messages() {
1888 let mut s = new_state_with("");
1889 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
1890 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
1891 }
1892
1893 #[test]
1894 fn lisp_reads_snapshot_and_branches_to_effect() {
1895 let mut s = new_state_with("one\ntwo\nthree");
1898 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
1900 .unwrap();
1901 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
1902 }
1903
1904 #[test]
1905 fn lisp_run_command_effect_drives_registry() {
1906 let mut s = new_state_with("");
1910 s.run_lisp(r#"(insert "abc")"#).unwrap();
1911 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
1912 s.run_lisp(r#"(run-command "undo")"#).unwrap();
1913 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
1914 }
1915
1916 #[test]
1917 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
1918 let mut s = new_state_with("");
1923 s.run_lisp(r#"(run-command "quit")"#).unwrap();
1924 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
1925 assert_eq!(
1926 s.modal.minibuffer(),
1927 "",
1928 "quit must not pollute any command line — Normal mode has no minibuffer",
1929 );
1930 }
1931
1932 #[test]
1935 fn lazy_plugin_activates_on_command_trigger() {
1936 let mut s = new_state_with("");
1940 s.register_lazy_plugin(
1941 "user-lazy",
1942 vec![LazyTrigger::Command("LazyGo".into())],
1943 r#"(defoption :name "lazy-loaded" :value "yes")
1944 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
1945 );
1946 assert_eq!(s.plugin_host.pending(), 1);
1947 assert!(
1948 s.options.get("lazy-loaded").is_none(),
1949 "entry not applied yet"
1950 );
1951
1952 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
1954
1955 assert_eq!(
1956 s.options.get("lazy-loaded").map(String::as_str),
1957 Some("yes"),
1958 "the command trigger applied the plugin's entry",
1959 );
1960 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
1961 }
1962
1963 #[test]
1964 fn lazy_plugin_activates_on_filetype() {
1965 let mut s = new_state_with("");
1966 s.register_lazy_plugin(
1967 "user-rust",
1968 vec![LazyTrigger::FileType("rust".into())],
1969 r#"(defoption :name "rust-plugin" :value "on")"#,
1970 );
1971 let n = s.activate_filetype_plugins("rust");
1972 assert_eq!(n, 1);
1973 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
1974 assert_eq!(s.activate_filetype_plugins("rust"), 0);
1976 }
1977
1978 #[test]
1979 fn cached_vm_serves_multiple_run_lisp_calls() {
1980 let mut s = new_state_with("");
1981 s.run_lisp(r#"(message "one")"#).unwrap();
1982 assert!(
1983 s.lisp_vm.is_some(),
1984 "VM should be cached after first run_lisp"
1985 );
1986 s.run_lisp(r#"(message "two")"#).unwrap();
1987 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
1988 }
1989
1990 #[test]
1991 fn lisp_define_persists_across_run_lisp_calls() {
1992 let mut s = new_state_with("");
1995 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
1996 s.run_lisp(r#"(message greeting)"#).unwrap();
1997 assert_eq!(s.messages, vec!["hi".to_string()]);
1998 }
1999
2000 #[test]
2001 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
2002 let mut s = new_state_with("");
2006 s.run_lisp(
2007 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
2008 )
2009 .unwrap();
2010 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
2011 assert_eq!(
2012 s.options.get("col").map(String::as_str),
2013 Some("stale-zero"),
2014 "cursor-column within the same call reads the pre-eval snapshot",
2015 );
2016 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
2019 .unwrap();
2020 assert_eq!(
2021 s.options.get("col2").map(String::as_str),
2022 Some("live-two"),
2023 "a later call sees the refreshed snapshot",
2024 );
2025 }
2026
2027 #[test]
2028 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
2029 let mut s = new_state_with("");
2030 s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
2031 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
2032 assert_eq!(s.cursor(), Position::new(1, 3));
2033 }
2034
2035 #[test]
2036 fn visual_mode_sequence_resolves() {
2037 let mut s = new_state_with("abc");
2038 s.modal.enter(Mode::Visual);
2039 s.keymap.bind_sequence(
2040 Mode::Visual,
2041 vec![Key::Char('g'), Key::Char('e')],
2042 Action::Move(Motion::DocEnd),
2043 "ge",
2044 );
2045 s.on_key(&Key::Char('g'));
2046 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2047 s.on_key(&Key::Char('e'));
2048 assert!(s.pending_keys.is_empty());
2049 assert_eq!(
2050 s.cursor().column,
2051 3,
2052 "ge resolved to doc-end in visual mode"
2053 );
2054 }
2055
2056 #[test]
2057 fn sequence_abort_with_bound_breaking_key_redispatches() {
2058 let mut s = new_state_with("abcde");
2061 s.keymap.bind_sequence(
2062 Mode::Normal,
2063 vec![Key::Char('g'), Key::Char('g')],
2064 Action::Move(Motion::DocEnd),
2065 "gg",
2066 );
2067 s.on_key(&Key::Char('g'));
2068 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2069 s.on_key(&Key::Char('l'));
2070 assert!(s.pending_keys.is_empty());
2071 assert_eq!(
2072 s.cursor().column,
2073 1,
2074 "the breaking key l should re-dispatch as move-right",
2075 );
2076 }
2077
2078 #[test]
2081 fn viewport_contains_cursor_after_every_op() {
2082 let mut s = new_state_small_viewport("", 5, 10);
2086 assert_cursor_in_viewport(&s, "initial");
2087
2088 s.tick(&press(KeyCode::Char('i')));
2091 assert_eq!(s.modal.mode(), Mode::Insert);
2092 for line in 0..30u32 {
2093 for c in "line".chars() {
2094 s.tick(&press(KeyCode::Char(c)));
2095 assert_cursor_in_viewport(&s, "typing chars");
2096 }
2097 s.tick(&press(KeyCode::Enter));
2098 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
2099 }
2100
2101 for i in 0..200u32 {
2104 s.tick(&press(KeyCode::Char('x')));
2105 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
2106 }
2107
2108 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
2110 assert_cursor_in_viewport(&s, "insert_text multiline");
2111
2112 s.tick(&press(KeyCode::Escape));
2114 assert_eq!(s.modal.mode(), Mode::Normal);
2115 for m in [
2116 Motion::DocStart,
2117 Motion::DocEnd,
2118 Motion::Down,
2119 Motion::Down,
2120 Motion::Up,
2121 Motion::Right,
2122 Motion::Right,
2123 Motion::Left,
2124 Motion::LineEnd,
2125 Motion::LineStart,
2126 Motion::GotoLine(1),
2127 Motion::GotoLine(40),
2128 Motion::PageDown,
2129 Motion::PageUp,
2130 ] {
2131 s.apply_motion(m);
2132 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
2133 }
2134
2135 for i in 0..50u32 {
2138 s.apply(&Action::Undo);
2139 assert_cursor_in_viewport(&s, &format!("undo {i}"));
2140 }
2141 for i in 0..50u32 {
2143 s.apply(&Action::Redo);
2144 assert_cursor_in_viewport(&s, &format!("redo {i}"));
2145 }
2146 }
2147
2148 #[test]
2149 fn insert_at_eof_keeps_cursor_in_bounds() {
2150 let mut s = new_state_small_viewport("abc", 5, 10);
2153 s.apply_motion(Motion::DocEnd);
2154 s.tick(&press(KeyCode::Char('i')));
2155 s.tick(&press(KeyCode::Char('d')));
2156 let buf = s.buffers.get(s.active).unwrap();
2157 let clamped = buf.clamp(s.cursor());
2158 assert_eq!(
2159 s.cursor(),
2160 clamped,
2161 "cursor must be clamped in-bounds at EOF"
2162 );
2163 assert_cursor_in_viewport(&s, "insert at eof");
2164 }
2165
2166 #[test]
2167 fn count_prefix_then_sequence_repeats() {
2168 let mut s = new_state_with("a\nb\nc\nd\ne");
2170 s.keymap.bind_sequence(
2171 Mode::Normal,
2172 vec![Key::Char('g'), Key::Char('j')],
2173 Action::Move(Motion::Down),
2174 "gj",
2175 );
2176 s.on_key(&Key::Char('2'));
2177 s.on_key(&Key::Char('g'));
2178 s.on_key(&Key::Char('j'));
2179 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
2180 }
2181
2182 #[test]
2185 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
2186 let mut s = new_state_with(&"x\n".repeat(40));
2192 let t0 = std::time::Instant::now();
2193 let mut delivered = 0u32;
2194 for i in 0..20u32 {
2195 let before = s.cursor().line;
2196 s.tick_at(
2197 &press(KeyCode::Char('j')),
2198 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
2199 );
2200 if s.cursor().line != before {
2201 delivered += 1;
2202 }
2203 }
2204 assert!(
2207 (10..=14).contains(&delivered),
2208 "expected the storm debounced to ~13 moves, got {delivered}",
2209 );
2210 assert!(
2211 delivered < 20,
2212 "the gate must drop SOME storm ticks, not pass all 20",
2213 );
2214 }
2215
2216 #[test]
2217 fn spaced_intentional_taps_all_pass() {
2218 let mut s = new_state_with(&"x\n".repeat(10));
2221 let t0 = std::time::Instant::now();
2222 for i in 0..5u32 {
2223 s.tick_at(
2224 &press(KeyCode::Char('j')),
2225 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2227 );
2228 }
2229 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2230 }
2231
2232 #[test]
2233 fn distinct_keys_have_independent_clocks() {
2234 let mut s = new_state_with("abc\ndef\nghi");
2237 let t = std::time::Instant::now();
2238 s.tick_at(&press(KeyCode::Char('j')), t);
2239 s.tick_at(
2241 &press(KeyCode::Char('j')),
2242 t + std::time::Duration::from_millis(10),
2243 );
2244 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2245 s.tick_at(
2247 &press(KeyCode::Char('l')),
2248 t + std::time::Duration::from_millis(10),
2249 );
2250 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2251 }
2252
2253 #[test]
2256 fn cursor_home_preserves_single_cursor_behavior() {
2257 let mut s = new_state_with("hello\nworld\nthere");
2262 assert_eq!(s.cursor(), Position::ZERO);
2263 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2264
2265 s.apply_motion(Motion::Down);
2266 s.apply_motion(Motion::Right);
2267 s.apply_motion(Motion::Right);
2268 assert_eq!(s.cursor(), Position::new(1, 2));
2269 assert_eq!(s.cursors.count(), 1);
2271
2272 let w = s.layout.active_window().unwrap();
2274 assert!(w.viewport.top_line <= s.cursor().line);
2275 }
2276
2277 #[test]
2278 fn insert_mode_is_ungated_so_repeat_typing_works() {
2279 let mut s = new_state_with("");
2283 s.tick(&press(KeyCode::Char('i')));
2284 assert_eq!(s.modal.mode(), Mode::Insert);
2285 let t = std::time::Instant::now();
2286 for _ in 0..10 {
2287 s.tick_at(&press(KeyCode::Char('x')), t);
2288 }
2289 assert_eq!(
2290 s.buffers.get(s.active).unwrap().to_string(),
2291 "xxxxxxxxxx",
2292 "insert-mode repeat typing is ungated",
2293 );
2294 }
2295}