1extern crate self as escriba_runtime;
9
10mod plugin_host;
11pub use plugin_host::{LazyTrigger, PluginHost};
12
13mod operator_pending;
14pub use operator_pending::{OpState, OperatorPending};
15
16use std::collections::HashMap;
17
18use escriba_buffer::BufferSet;
19use escriba_command::{CommandRegistry, EditContext};
20use escriba_search::{Direction as SearchDirection, SearchState};
21use escriba_core::{
22 Action, BufferId, Cursors, Damage, Edit, EditGen, Mode, Motion, Operator, Position, Range,
23 WindowId,
24};
25use escriba_input::{InputOutcome, translate_app_event};
26use escriba_keymap::{Key, Keymap};
27use escriba_mode::ModalState;
28use escriba_ui::{Layout, Rect, Viewport, Window};
29use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, HostEffect, VmError};
30use awase::KeyRepeatGate;
31use madori::AppEvent;
32use std::time::Instant;
33
34pub struct EditorState {
37 pub buffers: BufferSet,
38 pub modal: ModalState,
39 pub search: SearchState,
43 pub keymap: Keymap,
44 pub commands: CommandRegistry,
45 pub layout: Layout,
46 pub active: BufferId,
47 cursors: Cursors,
52 pub quit_requested: bool,
53 pub messages: Vec<String>,
56 pub options: HashMap<String, String>,
60 lisp_vm: Option<EscribaVm>,
66 pub pending_keys: Vec<Key>,
72 repeat_gate: KeyRepeatGate<Key>,
80 pub plugin_host: PluginHost,
85 register: Option<String>,
90 op_pending: zenmai::Stateful<OperatorPending>,
95 edit_gen: EditGen,
100 damage: Damage,
105}
106
107enum SeqStep {
109 Pending,
111 Resolved(Action),
113 Passthrough,
115}
116
117impl EditorState {
118 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
120 let window = Window {
121 id: WindowId(1),
122 buffer_id: active,
123 viewport: Viewport {
124 top_line: 0,
125 left_column: 0,
126 visible_lines: 40,
127 visible_columns: 160,
128 },
129 rect: Rect {
130 x: 0,
131 y: 0,
132 width: 1200,
133 height: 800,
134 },
135 };
136 Self {
137 buffers: initial,
138 modal: ModalState::new(),
139 search: SearchState::new(escriba_search::CaseMode::Smart),
140 keymap: Keymap::default_vim(),
141 commands: CommandRegistry::default_set(),
142 layout: Layout::single(window),
143 active,
144 cursors: Cursors::single(Position::ZERO),
145 quit_requested: false,
146 register: None,
147 op_pending: zenmai::Stateful::new(OpState::Resting),
148 messages: Vec::new(),
149 options: HashMap::new(),
150 lisp_vm: None,
151 pending_keys: Vec::new(),
152 repeat_gate: KeyRepeatGate::new(),
153 plugin_host: PluginHost::default(),
154 edit_gen: EditGen::default(),
155 damage: Damage::None,
156 }
157 }
158
159 #[must_use]
163 pub fn edit_gen(&self) -> EditGen {
164 self.edit_gen
165 }
166
167 fn bump_gen(&mut self) {
169 self.edit_gen = self.edit_gen.next();
170 }
171
172 #[must_use]
174 pub fn damage(&self) -> Damage {
175 self.damage
176 }
177
178 pub fn take_damage(&mut self) -> Damage {
182 std::mem::replace(&mut self.damage, Damage::None)
183 }
184
185 fn active_line_count(&self) -> u32 {
188 self.buffers
189 .get(self.active)
190 .map_or(0, escriba_buffer::Buffer::line_count)
191 }
192
193 pub fn register_lazy_plugin(
199 &mut self,
200 name: impl Into<String>,
201 triggers: Vec<LazyTrigger>,
202 entry_src: impl Into<String>,
203 ) {
204 self.plugin_host.register(name, triggers, entry_src);
205 }
206
207 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
213 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
214 return 0;
215 };
216 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
217 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
218 if let Some(value) = self.options.get("mapleader") {
219 if let Some(key) = escriba_lisp::parse_leader_key(value) {
220 self.keymap.set_leader(key);
221 }
222 }
223 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
224 (cmd.registered + km.keybinds_applied) as usize
225 }
226
227 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
231 let pending = self.plugin_host.pending_for_filetype(filetype);
232 let n = pending.len();
233 for src in pending {
234 self.apply_plugin_entry(&src);
235 }
236 n
237 }
238
239 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
242 let pending = self.plugin_host.pending_for_event(event);
243 let n = pending.len();
244 for src in pending {
245 self.apply_plugin_entry(&src);
246 }
247 n
248 }
249
250 pub fn tick(&mut self, event: &AppEvent) {
255 self.tick_at(event, Instant::now());
256 }
257
258 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
262 match translate_app_event(event) {
263 InputOutcome::Key(k) => {
264 if self.gate_key(&k, now) {
265 self.on_key(&k);
266 }
267 }
268 InputOutcome::Resized { width, height } => {
269 if let Some(w) = self
270 .layout
271 .windows
272 .iter_mut()
273 .find(|w| w.id == self.layout.active)
274 {
275 w.rect.width = width;
276 w.rect.height = height;
277 }
278 self.damage = self.damage.join(Damage::Viewport);
279 self.bump_gen();
280 }
281 InputOutcome::Quit => self.quit_requested = true,
282 InputOutcome::Focus(_) | InputOutcome::None => {}
283 }
284 }
285
286 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
296 match self.modal.mode() {
297 Mode::Normal | Mode::Visual | Mode::VisualLine => {
298 self.repeat_gate.try_pass_at(*key, now)
299 }
300 Mode::Insert | Mode::Command => true,
301 }
302 }
303
304 pub fn on_key(&mut self, key: &Key) {
306 match self.step_sequence(key) {
310 SeqStep::Pending => return,
311 SeqStep::Resolved(action) => {
312 let count = self.modal.pending_count().unwrap_or(1);
313 self.modal.clear_count();
314 for _ in 0..count {
315 self.apply(&action);
316 if self.quit_requested {
317 return;
318 }
319 }
320 return;
321 }
322 SeqStep::Passthrough => {}
323 }
324 let counted = self.keymap.dispatch(&self.modal, key);
325 if matches!(counted.action, Action::Pending) {
327 if let Key::Char(c) = key {
328 if c.is_ascii_digit() {
329 let d = u32::from(*c as u8 - b'0');
330 self.modal.append_count(d);
331 }
332 }
333 return;
334 }
335 self.apply_counted(&counted.action, counted.count);
339 self.modal.clear_count();
341 }
342
343 fn step_sequence(&mut self, key: &Key) -> SeqStep {
355 let mode = self.modal.mode();
356 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
357 return SeqStep::Passthrough;
358 }
359 if !self.pending_keys.is_empty() {
360 let mut seq = self.pending_keys.clone();
361 seq.push(key.clone());
362 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
363 let action = b.action.clone();
364 self.pending_keys.clear();
365 return SeqStep::Resolved(action);
366 }
367 if self.keymap.is_sequence_prefix(mode, &seq) {
368 self.pending_keys = seq;
369 return SeqStep::Pending;
370 }
371 self.pending_keys.clear();
374 }
375 let start = [key.clone()];
376 if self.keymap.is_sequence_prefix(mode, &start)
377 && self.keymap.lookup(mode, key).is_none()
378 {
379 self.pending_keys = start.to_vec();
380 return SeqStep::Pending;
381 }
382 SeqStep::Passthrough
383 }
384
385 #[must_use]
390 pub fn cursor(&self) -> Position {
391 self.cursors.primary()
392 }
393
394 fn set_cursor(&mut self, pos: Position) {
403 let clamped = if let Some(buf) = self.buffers.get(self.active) {
404 buf.clamp(pos)
405 } else {
406 pos
407 };
408 self.cursors.set_primary(clamped);
409 if let Some(w) = self
410 .layout
411 .windows
412 .iter_mut()
413 .find(|w| w.id == self.layout.active)
414 {
415 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
416 }
417 }
418
419 fn apply(&mut self, action: &Action) {
421 self.apply_counted(action, 1);
422 }
423
424 fn apply_counted(&mut self, action: &Action, count: u32) {
432 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
433 for _ in 0..times {
434 self.apply_resolved(&resolved);
435 if self.quit_requested {
436 return;
437 }
438 }
439 }
440 }
441
442 fn active_text(&self) -> String {
444 self.buffers.get(self.active).map(escriba_buffer::Buffer::to_string).unwrap_or_default()
445 }
446
447 fn cursor_char(&self) -> usize {
449 self.buffers
450 .get(self.active)
451 .and_then(|b| b.position_to_char(self.cursor()).ok())
452 .unwrap_or(0)
453 }
454
455 fn land_on(&mut self, step: escriba_search::Step) {
457 if let Some(buf) = self.buffers.get(self.active) {
458 let pos = buf.char_to_position(step.target.start);
459 self.set_cursor(pos);
460 }
461 if let Some(msg) = step.wrapped.message() {
462 self.messages.push(msg.to_string());
463 }
464 }
465
466 fn jump_search(&mut self, reverse: bool) {
470 let at = self.cursor_char();
471 match self.search.repeat(at, reverse) {
472 Some(step) => self.land_on(step),
473 None => {
474 let msg = self.search.pattern().map_or_else(
475 || "E35: No previous regular expression".to_string(),
476 |p| {
477 let mut m = String::from("E486: Pattern not found: ");
478 m.push_str(p.raw());
479 m
480 },
481 );
482 self.messages.push(msg);
483 }
484 }
485 }
486
487 fn preview_search(&mut self) {
494 let text = self.active_text();
495 if let Some(step) = self.search.preview(&text) {
496 if let Some(buf) = self.buffers.get(self.active) {
497 let pos = buf.char_to_position(step.target.start);
498 self.set_cursor(pos);
499 }
500 }
501 }
502
503 fn submit_search(&mut self) {
505 let text = self.active_text();
506 let at = self.cursor_char();
507 let outcome = self.search.accept(&text);
508 match outcome {
509 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
510 self.modal.clear_minibuffer();
511 self.modal.enter(Mode::Normal);
512 match self.search.repeat(at.saturating_sub(1), false) {
513 Some(step) => self.land_on(step),
514 None => {
515 let mut m = String::from("E486: Pattern not found");
516 if let Some(p) = self.search.pattern() {
517 m.push_str(": ");
518 m.push_str(p.raw());
519 }
520 self.messages.push(m);
521 }
522 }
523 }
524 escriba_search::Accepted::NothingToRepeat => {
525 self.modal.clear_minibuffer();
526 self.modal.enter(Mode::Normal);
527 self.messages.push("E35: No previous regular expression".to_string());
528 }
529 escriba_search::Accepted::Invalid(e) => {
532 let mut m = String::from("E383: Invalid search string: ");
533 m.push_str(&e.to_string());
534 self.messages.push(m);
535 }
536 }
537 }
538
539 fn apply_resolved(&mut self, action: &Action) {
540 let lines_before = self.active_line_count();
543 let cline_before = self.cursor().line;
544 match action {
545 Action::Move(m) => self.apply_motion(*m),
546 Action::SearchOpen(dir) => {
547 let origin = self.cursor_char();
551 self.search.open(*dir, origin);
552 self.modal.enter(Mode::Command);
553 }
554 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
555 Action::SearchWord { reverse } => {
556 let dir =
557 if *reverse { SearchDirection::Backward } else { SearchDirection::Forward };
558 let (text, at) = (self.active_text(), self.cursor_char());
559 match self.search.search_word(&text, at, dir) {
560 Some(step) => self.land_on(step),
561 None => self.messages.push("E348: No string under cursor".to_string()),
564 }
565 }
566 Action::ClearSearchHighlight => self.search.clear_highlight(),
567 Action::ChangeMode(m) => {
568 if *m == Mode::Normal && self.search.is_prompting() {
572 if let Some(origin) = self.search.cancel() {
573 if let Some(buf) = self.buffers.get(self.active) {
574 let pos = buf.char_to_position(origin);
575 self.set_cursor(pos);
576 }
577 }
578 }
579 self.modal.enter(*m);
580 }
581 Action::InsertChar(c) => self.insert_char(*c),
582 Action::Edit(edit) => self.apply_edit(edit),
583 Action::Undo => {
584 if let Some(buf) = self.buffers.get_mut(self.active) {
585 let _ = buf.undo();
586 }
587 self.set_cursor(self.cursor());
590 }
591 Action::Redo => {
592 if let Some(buf) = self.buffers.get_mut(self.active) {
593 let _ = buf.redo();
594 }
595 self.set_cursor(self.cursor());
596 }
597 Action::Save => {
598 if let Some(buf) = self.buffers.get_mut(self.active) {
599 let _ = buf.save();
600 }
601 self.set_cursor(self.cursor());
602 }
603 Action::Quit => self.quit_requested = true,
604 Action::SubmitCommand => {
605 if self.search.is_prompting() {
606 self.submit_search();
607 } else {
608 self.submit_command();
609 }
610 }
611 Action::Command { name, args } => self.run_command(name, args),
612 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
613 Action::Operator(_) => {}
616 Action::PromptBackspace => {
617 self.prompt_backspace();
618 if self.search.is_prompting() {
622 self.preview_search();
623 }
624 }
625 Action::PromptHistory { back } => {
626 if self.search.is_prompting() {
627 self.search.history_step(*back);
628 self.modal.clear_minibuffer();
632 if let Some(text) = self.search.prompt().map(|p| p.text.clone()) {
633 self.modal.push_minibuffer_str(&text);
634 }
635 self.preview_search();
636 }
637 }
638 Action::Pending => {}
639 }
640 let lines_after = self.active_line_count();
645 let cline_after = self.cursor().line;
646 let d = match action {
647 Action::SearchOpen(_)
651 | Action::PromptHistory { .. }
652 | Action::PromptBackspace
653 | Action::SearchRepeat { .. }
654 | Action::SearchWord { .. }
655 | Action::ClearSearchHighlight => Damage::Full,
656 Action::InsertChar(_)
657 | Action::Edit(_)
658 | Action::Undo
659 | Action::Redo
660 | Action::ApplyOperator { .. } => {
661 if lines_after == lines_before {
662 Damage::span(cline_before, cline_after)
663 } else {
664 Damage::Lines {
665 from: cline_before.min(cline_after),
666 to: u32::MAX,
667 }
668 }
669 }
670 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
671 Action::Save => Damage::Viewport,
672 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
673 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
674 };
675 self.damage = self.damage.join(d);
676 self.bump_gen();
681 }
682
683 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
693 let buf = self.buffers.get(self.active)?;
694 let pos = from;
695 Some(match motion {
696 Motion::SearchNext | Motion::SearchPrev => {
701 let at = buf.position_to_char(pos).ok()?;
702 let step = self.search.repeat(at, matches!(motion, Motion::SearchPrev))?;
703 buf.char_to_position(step.target.start)
704 }
705 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
706 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
707 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
708 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
709 Motion::LineStart => Position::new(pos.line, 0),
710 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
711 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
712 Motion::DocStart => Position::ZERO,
713 Motion::DocEnd => Position::new(
714 buf.line_count().saturating_sub(1),
715 buf.line_len_chars(buf.line_count().saturating_sub(1)),
716 ),
717 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
718 Motion::WordStartPrev => word_prev(buf, pos),
719 Motion::PageDown | Motion::HalfPageDown => {
720 Position::new(pos.line.saturating_add(10), pos.column)
721 }
722 Motion::PageUp | Motion::HalfPageUp => {
723 Position::new(pos.line.saturating_sub(10), pos.column)
724 }
725 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
726 Motion::ForwardSexp
729 | Motion::BackwardSexp
730 | Motion::UpList
731 | Motion::DownList
732 | Motion::BeginningOfDefun
733 | Motion::EndOfDefun
734 | Motion::BeginningOfSexp
735 | Motion::EndOfSexp => pos,
736 })
737 }
738
739 fn apply_motion(&mut self, motion: Motion) {
740 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
741 return;
742 };
743 self.set_cursor(pos);
746 }
747
748 fn apply_operator(&mut self, op: Operator, motion: Motion) {
755 let from = self.cursor();
756 let Some(to) = self.resolve_motion(from, motion) else {
757 return;
758 };
759 let range = Range { start: from, end: to }.normalized();
760 if range.is_empty() {
761 return;
762 }
763 let text = self
765 .buffers
766 .get(self.active)
767 .and_then(|buf| buf.slice(range).ok());
768 if op.leaves_register() {
769 if let Some(t) = &text {
770 self.register = Some(t.clone());
771 }
772 }
773 match op {
774 Operator::Delete | Operator::Change => {
777 if let Some(buf) = self.buffers.get_mut(self.active) {
778 let _ = buf.apply(&Edit::delete(range));
779 }
780 self.set_cursor(range.start);
781 if op == Operator::Change {
782 self.modal.enter(Mode::Insert);
783 }
784 }
785 Operator::Yank => {
788 self.set_cursor(range.start);
789 }
790 _ => {
794 self.messages
795 .push("operator not yet implemented".to_owned());
796 }
797 }
798 }
799
800 #[must_use]
803 pub fn register(&self) -> Option<&str> {
804 self.register.as_deref()
805 }
806
807 fn insert_char(&mut self, c: char) {
808 if self.modal.mode() == Mode::Command {
809 if self.search.is_prompting() {
813 self.search.push(c);
814 self.modal.push_minibuffer(c);
815 self.preview_search();
816 } else {
817 self.modal.push_minibuffer(c);
818 }
819 return;
820 }
821 let cursor = self.cursor();
822 let Some(buf) = self.buffers.get_mut(self.active) else {
823 return;
824 };
825 let edit = Edit::insert(cursor, c.to_string());
826 if buf.apply(&edit).is_ok() {
827 let next = if c == '\n' {
828 Position::new(cursor.line.saturating_add(1), 0)
829 } else {
830 cursor.shift_right(1)
831 };
832 self.set_cursor(next);
835 }
836 }
837
838 fn prompt_backspace(&mut self) -> bool {
842 if self.modal.mode() != Mode::Command {
843 return false;
844 }
845 if self.search.is_prompting() {
846 if self.search.backspace() {
848 self.modal.clear_minibuffer();
849 self.modal.enter(Mode::Normal);
850 return true;
851 }
852 }
853 self.modal.pop_minibuffer();
854 true
855 }
856
857 fn apply_edit(&mut self, _edit: &Edit) {
858 }
862
863 fn submit_command(&mut self) {
864 let line = self.modal.minibuffer().to_string();
868 self.modal.escape();
869 let (name, args) = parse_command_line(&line);
870 if name.is_empty() {
871 return;
872 }
873 self.run_command(&name, &args);
874 }
875
876 fn run_command(&mut self, name: &str, args: &[String]) {
877 if matches!(name, "noh" | "nohl" | "nohlsearch") {
883 self.search.clear_highlight();
884 return;
885 }
886 if self.plugin_host.pending() > 0 {
892 let pending = self.plugin_host.pending_for_command(name);
893 for src in pending {
894 self.apply_plugin_entry(&src);
895 }
896 }
897 let active = Some(self.active);
898 let mut quit = false;
899 {
900 let mut ctx = EditContext {
901 buffers: &mut self.buffers,
902 active,
903 state: &mut self.modal,
904 quit_requested: &mut quit,
905 };
906 let _ = self.commands.run(name, &mut ctx, args);
907 }
908 if quit {
911 self.quit_requested = true;
912 }
913 }
914
915 #[must_use]
920 pub fn snapshot(&self) -> EditorSnapshot {
921 let current_line = self
922 .buffers
923 .get(self.active)
924 .and_then(|b| b.line(self.cursor().line))
925 .map(|s| s.trim_end_matches('\n').to_string())
926 .unwrap_or_default();
927 let buffer_name = self
928 .buffers
929 .get(self.active)
930 .and_then(|b| b.path.as_ref())
931 .map(|p| p.display().to_string())
932 .unwrap_or_else(|| "[scratch]".to_string());
933 EditorSnapshot {
934 cursor_line: i64::from(self.cursor().line),
935 cursor_column: i64::from(self.cursor().column),
936 current_line,
937 mode: self.modal.mode().as_str().to_string(),
938 buffer_name,
939 }
940 }
941
942 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
958 let mut host = EscribaHost::with_snapshot(self.snapshot());
959 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
960 vm.eval(src, &mut host)?;
961 let effects = host.take_effects();
962 self.apply_host_effects(effects);
963 Ok(())
964 }
965
966 pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
970 for eff in effects {
971 match eff {
972 HostEffect::Message(m) => self.messages.push(m),
973 HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
974 HostEffect::SetOption { name, value } => {
975 self.options.insert(name, value);
976 }
977 HostEffect::InsertText(text) => self.insert_text(&text),
978 }
979 }
980 }
981
982 fn insert_text(&mut self, text: &str) {
985 if text.is_empty() {
986 return;
987 }
988 let cursor = self.cursor();
989 let Some(buf) = self.buffers.get_mut(self.active) else {
990 return;
991 };
992 let edit = Edit::insert(cursor, text.to_string());
993 if buf.apply(&edit).is_ok() {
994 let next = if let Some(nl) = text.rfind('\n') {
995 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
996 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
997 Position::new(cursor.line + added_lines, last_line_len)
998 } else {
999 let n = u32::try_from(text.chars().count()).unwrap_or(0);
1000 cursor.shift_right(n)
1001 };
1002 self.set_cursor(next);
1005 }
1006 }
1007}
1008
1009fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1010 let Some(text) = buf.line(line) else {
1011 return Position::new(line, 0);
1012 };
1013 let col = text
1014 .chars()
1015 .take_while(|c| c.is_whitespace() && *c != '\n')
1016 .count();
1017 Position::new(line, u32::try_from(col).unwrap_or(0))
1018}
1019
1020fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1021 let Some(text) = buf.line(pos.line) else {
1022 return pos;
1023 };
1024 let chars: Vec<char> = text.chars().collect();
1025 let start = pos.column as usize;
1026 let mut i = start;
1027 while i < chars.len() && !chars[i].is_whitespace() {
1028 i += 1;
1029 }
1030 while i < chars.len() && chars[i].is_whitespace() {
1031 i += 1;
1032 }
1033 if i >= chars.len() {
1034 if pos.line + 1 < buf.line_count() {
1036 return Position::new(pos.line + 1, 0);
1037 }
1038 }
1039 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1040}
1041
1042fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1043 let Some(text) = buf.line(pos.line) else {
1044 return pos;
1045 };
1046 let chars: Vec<char> = text.chars().collect();
1047 let mut i = (pos.column as usize).min(chars.len());
1048 while i > 0 && chars[i - 1].is_whitespace() {
1049 i -= 1;
1050 }
1051 while i > 0 && !chars[i - 1].is_whitespace() {
1052 i -= 1;
1053 }
1054 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1055}
1056
1057fn parse_command_line(line: &str) -> (String, Vec<String>) {
1058 let mut parts = line.split_whitespace();
1059 let Some(first) = parts.next() else {
1060 return (String::new(), Vec::new());
1061 };
1062 let head = first.strip_prefix(':').unwrap_or(first);
1063 let name = match head {
1064 "w" => "save",
1065 "q" => "quit",
1066 "u" => "undo",
1067 other => other,
1068 };
1069 (name.to_string(), parts.map(str::to_string).collect())
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use madori::event::{KeyCode, KeyEvent, Modifiers};
1076
1077 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1085 st.apply(&Action::SearchOpen(dir));
1086 for c in pat.chars() {
1087 st.apply(&Action::InsertChar(c));
1088 }
1089 st.apply(&Action::SubmitCommand);
1090 }
1091
1092 #[test]
1093 fn slash_search_moves_the_cursor_to_the_match() {
1094 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1095 type_search(&mut st, SearchDirection::Forward, "charlie");
1096 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1097 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1098 assert_eq!(st.search.matches().len(), 1);
1099 }
1100
1101 #[test]
1102 fn n_and_N_walk_matches_in_both_directions() {
1103 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1104 type_search(&mut st, SearchDirection::Forward, "foo");
1105 let first = st.cursor().line;
1106 st.apply(&Action::SearchRepeat { reverse: false });
1107 let second = st.cursor().line;
1108 assert!(second > first, "n advances ({first} -> {second})");
1109 st.apply(&Action::SearchRepeat { reverse: true });
1110 assert_eq!(st.cursor().line, first, "N comes back");
1111 }
1112
1113 #[test]
1114 fn star_searches_the_word_under_the_cursor() {
1115 let mut st = new_state_with("needle\nhaystack\nneedle\n");
1116 st.apply(&Action::SearchWord { reverse: false });
1117 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1118 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1119 }
1120
1121 #[test]
1122 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1123 let mut st = new_state_with("foo\nbar\nfoo\n");
1124 type_search(&mut st, SearchDirection::Forward, "foo");
1125 let matches_before = st.search.matches().len();
1126
1127 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1128 st.apply(&Action::InsertChar('z'));
1129 st.apply(&Action::ChangeMode(Mode::Normal));
1130
1131 assert!(!st.search.is_prompting(), "prompt gone");
1132 assert_eq!(st.search.pattern().unwrap().raw(), "foo", "old pattern survives");
1133 assert_eq!(st.search.matches().len(), matches_before, "old highlights survive");
1134 }
1135
1136 #[test]
1137 fn a_search_prompt_and_an_ex_command_are_not_confused() {
1138 let mut st = new_state_with("foo\n");
1139 st.apply(&Action::ChangeMode(Mode::Command));
1141 assert!(!st.search.is_prompting(), "`:` must not open a search");
1142 st.apply(&Action::InsertChar('w'));
1143 assert!(st.search.prompt().is_none(), "typed char went to the ex line");
1144 }
1145
1146 #[test]
1147 fn a_missing_pattern_reports_instead_of_failing_silently() {
1148 let mut st = new_state_with("alpha\nbravo\n");
1149 type_search(&mut st, SearchDirection::Forward, "zzz");
1150 assert!(
1151 st.messages.iter().any(|m| m.contains("E486")),
1152 "must report not-found, got {:?}",
1153 st.messages
1154 );
1155 }
1156
1157 #[test]
1158 fn n_without_any_search_reports_rather_than_moving() {
1159 let mut st = new_state_with("alpha\nbravo\n");
1160 let before = st.cursor();
1161 st.apply(&Action::SearchRepeat { reverse: false });
1162 assert_eq!(st.cursor(), before, "cursor must not move");
1163 assert!(st.messages.iter().any(|m| m.contains("E35")), "got {:?}", st.messages);
1164 }
1165
1166 #[test]
1167 fn search_as_a_motion_composes_with_an_operator() {
1168 let mut st = new_state_with("alpha bravo charlie\n");
1170 type_search(&mut st, SearchDirection::Forward, "charlie");
1171 st.set_cursor(Position::new(0, 0));
1172 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1173 assert!(target.is_some(), "search must resolve as a motion");
1174 assert_eq!(target.unwrap().column, 12, "at `charlie`");
1175 }
1176
1177 #[test]
1178 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1179 let st = new_state_with("alpha bravo\n");
1182 assert!(st.resolve_motion(Position::new(0, 5), Motion::SearchNext).is_none());
1183 }
1184
1185 #[test]
1186 fn clear_highlight_keeps_the_pattern_usable() {
1187 let mut st = new_state_with("foo\nbar\nfoo\n");
1188 type_search(&mut st, SearchDirection::Forward, "foo");
1189 st.apply(&Action::ClearSearchHighlight);
1190 assert!(st.search.highlights().is_empty(), "nothing lit");
1191 st.apply(&Action::SearchRepeat { reverse: false });
1192 assert!(st.search.pattern().is_some(), "but n still works");
1193 }
1194
1195 #[test]
1196 fn typing_previews_incrementally_before_commit() {
1197 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1198 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1199 for c in "charlie".chars() {
1200 st.apply(&Action::InsertChar(c));
1201 }
1202 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1204 assert!(st.search.pattern().is_none(), "but nothing is committed");
1205 }
1206
1207 #[test]
1208 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1209 let mut st = new_state_with("alpha\nbravo\n");
1210 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1211 for c in "bravox".chars() {
1212 st.apply(&Action::InsertChar(c));
1213 }
1214 assert_eq!(st.search.prompt().unwrap().text, "bravox");
1215 st.apply(&Action::PromptBackspace);
1216 assert_eq!(st.search.prompt().unwrap().text, "bravo", "typo corrected");
1217 assert_eq!(st.modal.minibuffer(), "bravo", "display stays in lockstep");
1218 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1219 }
1220
1221 #[test]
1222 fn backspacing_past_the_slash_closes_the_prompt() {
1223 let mut st = new_state_with("alpha\n");
1224 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1225 st.apply(&Action::InsertChar('a'));
1226 st.apply(&Action::PromptBackspace);
1227 st.apply(&Action::PromptBackspace);
1228 assert!(!st.search.is_prompting(), "prompt closed");
1229 assert_eq!(st.modal.mode(), Mode::Normal);
1230 }
1231
1232 #[test]
1233 fn noh_clears_highlights_and_keeps_the_pattern() {
1234 let mut st = new_state_with("foo\nbar\nfoo\n");
1235 type_search(&mut st, SearchDirection::Forward, "foo");
1236 assert!(!st.search.highlights().is_empty());
1237 st.run_command("noh", &[]);
1238 assert!(st.search.highlights().is_empty(), ":noh turns them off");
1239 assert!(st.search.pattern().is_some(), "but n still works");
1240 }
1241
1242 #[test]
1243 fn noh_accepts_the_vim_aliases() {
1244 for name in ["noh", "nohl", "nohlsearch"] {
1245 let mut st = new_state_with("foo\nfoo\n");
1246 type_search(&mut st, SearchDirection::Forward, "foo");
1247 st.run_command(name, &[]);
1248 assert!(st.search.highlights().is_empty(), "{name} must clear");
1249 }
1250 }
1251
1252 #[test]
1253 fn backspace_on_the_ex_line_does_not_touch_search_state() {
1254 let mut st = new_state_with("foo\n");
1255 st.apply(&Action::ChangeMode(Mode::Command));
1256 st.apply(&Action::InsertChar('w'));
1257 st.apply(&Action::InsertChar('q'));
1258 st.apply(&Action::PromptBackspace);
1259 assert_eq!(st.modal.minibuffer(), "w");
1260 assert!(st.search.prompt().is_none(), "no search was involved");
1261 }
1262
1263 #[test]
1264 fn up_arrow_recalls_the_previous_search() {
1265 let mut st = new_state_with("alpha\nbravo\n");
1266 type_search(&mut st, SearchDirection::Forward, "bravo");
1267 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1268 st.apply(&Action::PromptHistory { back: true });
1269 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1270 assert_eq!(st.modal.minibuffer(), "bravo", "display follows the prompt");
1271 }
1272
1273 #[test]
1274 fn arrowing_back_down_restores_the_half_typed_pattern() {
1275 let mut st = new_state_with("alpha\nbravo\n");
1276 type_search(&mut st, SearchDirection::Forward, "bravo");
1277 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1278 st.apply(&Action::InsertChar('a'));
1279 st.apply(&Action::PromptHistory { back: true });
1280 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1281 st.apply(&Action::PromptHistory { back: false });
1282 assert_eq!(st.search.prompt().unwrap().text, "a", "the draft comes back");
1283 assert_eq!(st.modal.minibuffer(), "a");
1284 }
1285
1286 #[test]
1287 fn history_arrows_do_nothing_on_the_ex_line() {
1288 let mut st = new_state_with("alpha\n");
1289 st.apply(&Action::ChangeMode(Mode::Command));
1290 st.apply(&Action::InsertChar('w'));
1291 st.apply(&Action::PromptHistory { back: true });
1292 assert_eq!(st.modal.minibuffer(), "w", "ex line untouched");
1293 }
1294
1295 fn new_state_with(text: &str) -> EditorState {
1296 let mut bufs = BufferSet::new();
1297 let id = bufs.scratch(text);
1298 EditorState::new_with_buffer(bufs, id)
1299 }
1300
1301 #[test]
1307 fn edit_gen_advances_on_applied_action_not_on_read() {
1308 let mut s = new_state_with("hello\nworld\n");
1309 let g0 = s.edit_gen();
1310 s.apply(&Action::InsertChar('X'));
1311 assert_ne!(
1312 s.edit_gen(),
1313 g0,
1314 "an applied action must advance the refresh generation",
1315 );
1316 let g1 = s.edit_gen();
1318 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1319 }
1320
1321 #[test]
1326 fn damage_tracks_edit_scope_and_drains() {
1327 let mut s = new_state_with("hello\nworld\n");
1328 assert!(s.damage().is_none(), "a fresh state has no damage");
1329
1330 s.apply(&Action::InsertChar('X')); assert_eq!(
1332 s.damage(),
1333 Damage::Lines { from: 0, to: 0 },
1334 "a local edit damages just its line",
1335 );
1336
1337 let drained = s.take_damage();
1338 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
1339 assert!(s.damage().is_none(), "take_damage drains to None");
1340
1341 s.apply(&Action::InsertChar('\n')); assert_eq!(
1343 s.damage(),
1344 Damage::Lines {
1345 from: 0,
1346 to: u32::MAX,
1347 },
1348 "a line-count change damages to end-of-document",
1349 );
1350 }
1351
1352 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
1356 let mut s = new_state_with(text);
1357 for w in &mut s.layout.windows {
1358 w.viewport.visible_lines = vis_lines;
1359 w.viewport.visible_columns = vis_cols;
1360 }
1361 s
1362 }
1363
1364 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
1369 let w = s.layout.active_window().expect("active window");
1370 let v = w.viewport;
1371 let c = s.cursor();
1372 assert!(
1373 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
1374 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
1375 c.line,
1376 v.top_line,
1377 v.top_line + v.visible_lines,
1378 );
1379 assert!(
1380 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
1381 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
1382 c.column,
1383 v.left_column,
1384 v.left_column + v.visible_columns,
1385 );
1386 }
1387
1388 fn press(kc: KeyCode) -> AppEvent {
1389 AppEvent::Key(KeyEvent {
1390 key: kc,
1391 pressed: true,
1392 modifiers: Modifiers::default(),
1393 text: None,
1394 })
1395 }
1396
1397 fn line0_len(s: &EditorState) -> u32 {
1400 s.buffers.get(s.active).unwrap().line_len_chars(0)
1401 }
1402
1403 #[test]
1404 fn delete_to_line_end_clears_line_and_fills_register() {
1405 let mut s = new_state_with("hello world");
1406 s.apply(&Action::ApplyOperator { op: Operator::Delete, motion: Motion::LineEnd });
1407 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
1408 assert_eq!(s.register(), Some("hello world"), "delete fills the register");
1409 assert_eq!(s.cursor(), Position::ZERO, "cursor lands at the range start");
1410 }
1411
1412 #[test]
1413 fn delete_over_right_motion_removes_one_char() {
1414 let mut s = new_state_with("abc");
1415 s.apply(&Action::ApplyOperator { op: Operator::Delete, motion: Motion::Right });
1416 assert_eq!(s.buffers.get(s.active).unwrap().line(0).as_deref(), Some("bc"));
1417 assert_eq!(s.register(), Some("a"));
1418 }
1419
1420 #[test]
1421 fn change_to_line_end_deletes_and_enters_insert() {
1422 let mut s = new_state_with("hello world");
1423 assert_eq!(s.modal.mode(), Mode::Normal);
1424 s.apply(&Action::ApplyOperator { op: Operator::Change, motion: Motion::LineEnd });
1425 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
1426 assert_eq!(s.modal.mode(), Mode::Insert, "change enters Insert to type the replacement");
1427 assert_eq!(s.register(), Some("hello world"), "change fills the register");
1428 }
1429
1430 #[test]
1431 fn yank_to_line_end_fills_register_without_mutating() {
1432 let mut s = new_state_with("hello world");
1433 s.apply(&Action::ApplyOperator { op: Operator::Yank, motion: Motion::LineEnd });
1434 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
1435 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
1436 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
1437 }
1438
1439 #[test]
1440 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
1441 let mut s = new_state_with("hello world");
1445 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
1446 assert_eq!(target, Position::new(0, 11));
1447 s.apply_motion(Motion::LineEnd);
1448 assert_eq!(s.cursor(), target, "the move path resolves the same target the operator uses");
1449 }
1450
1451 #[test]
1452 fn empty_motion_range_is_a_no_op() {
1453 let mut s = new_state_with("abc");
1456 s.apply(&Action::ApplyOperator { op: Operator::Delete, motion: Motion::LineStart });
1457 assert_eq!(s.buffers.get(s.active).unwrap().line(0).as_deref(), Some("abc"));
1458 assert_eq!(s.register(), None);
1459 }
1460
1461 #[test]
1462 fn operator_then_motion_composes_through_the_pending_fsm() {
1463 let mut s = new_state_with("hello world");
1467 s.apply(&Action::Operator(Operator::Delete));
1468 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
1469 s.apply(&Action::Move(Motion::LineEnd));
1470 assert_eq!(line0_len(&s), 0, "d then $ composes d$ and deletes the line");
1471 assert_eq!(s.register(), Some("hello world"));
1472 }
1473
1474 #[test]
1475 fn change_operator_through_fsm_enters_insert() {
1476 let mut s = new_state_with("hello world");
1477 s.apply(&Action::Operator(Operator::Change));
1478 s.apply(&Action::Move(Motion::LineEnd));
1479 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
1480 }
1481
1482 #[test]
1483 fn lone_motion_after_no_operator_just_moves() {
1484 let mut s = new_state_with("hello world");
1486 s.apply(&Action::Move(Motion::LineEnd));
1487 assert_eq!(s.cursor(), Position::new(0, 11));
1488 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
1489 }
1490
1491 #[test]
1492 fn counted_operator_deletes_count_times() {
1493 let mut s = new_state_with("abcdef");
1497 s.apply_counted(&Action::Operator(Operator::Delete), 3);
1498 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
1499 s.apply(&Action::Move(Motion::Right));
1500 assert_eq!(s.buffers.get(s.active).unwrap().line(0).as_deref(), Some("def"));
1501 }
1502
1503 #[test]
1504 fn operator_and_motion_counts_multiply_end_to_end() {
1505 let mut s = new_state_with("abcdefgh");
1507 s.apply_counted(&Action::Operator(Operator::Delete), 2);
1508 s.apply_counted(&Action::Move(Motion::Right), 3);
1509 assert_eq!(s.buffers.get(s.active).unwrap().line(0).as_deref(), Some("gh"));
1510 }
1511
1512 #[test]
1513 fn bare_counted_motion_still_repeats_no_regression() {
1514 let mut s = new_state_with("a\nb\nc\nd\ne");
1517 s.apply_counted(&Action::Move(Motion::Down), 3);
1518 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
1519 }
1520
1521 struct SpacedClock(std::time::Instant);
1527 impl SpacedClock {
1528 fn new() -> Self {
1529 Self(std::time::Instant::now())
1530 }
1531 fn next(&mut self) -> std::time::Instant {
1532 self.0 += std::time::Duration::from_secs(1);
1533 self.0
1534 }
1535 }
1536
1537 #[test]
1538 fn hjkl_moves_cursor() {
1539 let mut s = new_state_with("hello\nworld");
1540 s.tick(&press(KeyCode::Char('l')));
1541 assert_eq!(s.cursor().column, 1);
1542 s.tick(&press(KeyCode::Char('j')));
1543 assert_eq!(s.cursor().line, 1);
1544 s.tick(&press(KeyCode::Char('h')));
1545 assert_eq!(s.cursor().column, 0);
1546 }
1547
1548 #[test]
1549 fn insert_mode_inserts_chars() {
1550 let mut s = new_state_with("");
1551 s.tick(&press(KeyCode::Char('i')));
1552 assert_eq!(s.modal.mode(), Mode::Insert);
1553 s.tick(&press(KeyCode::Char('h')));
1554 s.tick(&press(KeyCode::Char('i')));
1555 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
1556 assert_eq!(s.cursor().column, 2);
1557 }
1558
1559 #[test]
1560 fn esc_returns_to_normal() {
1561 let mut s = new_state_with("");
1562 s.tick(&press(KeyCode::Char('i')));
1563 s.tick(&press(KeyCode::Escape));
1564 assert_eq!(s.modal.mode(), Mode::Normal);
1565 }
1566
1567 #[test]
1568 fn count_prefix_repeats_motion() {
1569 let mut s = new_state_with("abcdefghij");
1570 s.tick(&press(KeyCode::Char('5')));
1571 s.tick(&press(KeyCode::Char('l')));
1572 assert_eq!(s.cursor().column, 5);
1573 }
1574
1575 #[test]
1576 fn close_event_requests_quit() {
1577 let mut s = new_state_with("");
1578 s.tick(&AppEvent::CloseRequested);
1579 assert!(s.quit_requested);
1580 }
1581
1582 #[test]
1583 fn word_next_jumps_past_whitespace() {
1584 let mut s = new_state_with("foo bar baz");
1585 let mut clk = SpacedClock::new();
1588 s.tick_at(&press(KeyCode::Char('w')), clk.next());
1589 assert_eq!(s.cursor().column, 4);
1590 s.tick_at(&press(KeyCode::Char('w')), clk.next());
1591 assert_eq!(s.cursor().column, 8);
1592 }
1593
1594 #[test]
1597 fn leader_sequence_holds_then_resolves() {
1598 let mut s = new_state_with("a\nbb\nccc");
1599 s.keymap.bind_sequence(
1600 Mode::Normal,
1601 vec![Key::Char(','), Key::Char('g')],
1602 Action::Move(Motion::DocEnd),
1603 "doc end",
1604 );
1605 s.on_key(&Key::Char(','));
1607 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
1608 assert_eq!(s.cursor(), Position::ZERO);
1609 s.on_key(&Key::Char('g'));
1611 assert!(s.pending_keys.is_empty());
1612 assert_eq!(s.cursor().line, 2);
1613 }
1614
1615 #[test]
1616 fn two_key_gg_jumps_doc_start() {
1617 let mut s = new_state_with("a\nbb\nccc");
1618 s.keymap.bind_sequence(
1619 Mode::Normal,
1620 vec![Key::Char('g'), Key::Char('g')],
1621 Action::Move(Motion::DocStart),
1622 "doc start",
1623 );
1624 let mut clk = SpacedClock::new();
1625 s.tick_at(&press(KeyCode::Char('j')), clk.next());
1626 s.tick_at(&press(KeyCode::Char('j')), clk.next());
1627 assert_eq!(s.cursor().line, 2);
1628 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
1630 s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
1632 }
1633
1634 #[test]
1635 fn broken_sequence_aborts_and_clears_pending() {
1636 let mut s = new_state_with("hello");
1637 s.keymap.bind_sequence(
1638 Mode::Normal,
1639 vec![Key::Char('g'), Key::Char('g')],
1640 Action::Move(Motion::DocEnd),
1641 "doc end",
1642 );
1643 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
1645 s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
1647 assert_eq!(s.cursor(), Position::ZERO);
1648 }
1649
1650 #[test]
1651 fn single_binding_wins_over_sequence_prefix() {
1652 let mut s = new_state_with("abcde");
1656 let mut clk = SpacedClock::new();
1657 s.tick_at(&press(KeyCode::Char('l')), clk.next());
1658 s.tick_at(&press(KeyCode::Char('l')), clk.next());
1659 assert_eq!(s.cursor().column, 2);
1660 s.keymap.bind_sequence(
1661 Mode::Normal,
1662 vec![Key::Char('h'), Key::Char('z')],
1663 Action::Move(Motion::DocEnd),
1664 "shadowed",
1665 );
1666 s.on_key(&Key::Char('h'));
1667 assert!(s.pending_keys.is_empty(), "single binding should not pend");
1668 assert_eq!(s.cursor().column, 1, "h moved left immediately");
1669 }
1670
1671 #[test]
1674 fn lisp_set_option_writes_live_options() {
1675 let mut s = new_state_with("");
1676 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
1677 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
1678 }
1679
1680 #[test]
1681 fn lisp_insert_modifies_buffer_and_advances_cursor() {
1682 let mut s = new_state_with("");
1683 s.run_lisp(r#"(insert "abc")"#).unwrap();
1684 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
1685 assert_eq!(s.cursor(), Position::new(0, 3));
1686 }
1687
1688 #[test]
1689 fn lisp_message_appends_to_messages() {
1690 let mut s = new_state_with("");
1691 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
1692 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
1693 }
1694
1695 #[test]
1696 fn lisp_reads_snapshot_and_branches_to_effect() {
1697 let mut s = new_state_with("one\ntwo\nthree");
1700 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
1702 .unwrap();
1703 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
1704 }
1705
1706 #[test]
1707 fn lisp_run_command_effect_drives_registry() {
1708 let mut s = new_state_with("");
1712 s.run_lisp(r#"(insert "abc")"#).unwrap();
1713 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
1714 s.run_lisp(r#"(run-command "undo")"#).unwrap();
1715 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
1716 }
1717
1718 #[test]
1719 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
1720 let mut s = new_state_with("");
1725 s.run_lisp(r#"(run-command "quit")"#).unwrap();
1726 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
1727 assert_eq!(
1728 s.modal.minibuffer(),
1729 "",
1730 "quit must not pollute any command line — Normal mode has no minibuffer",
1731 );
1732 }
1733
1734 #[test]
1737 fn lazy_plugin_activates_on_command_trigger() {
1738 let mut s = new_state_with("");
1742 s.register_lazy_plugin(
1743 "user-lazy",
1744 vec![LazyTrigger::Command("LazyGo".into())],
1745 r#"(defoption :name "lazy-loaded" :value "yes")
1746 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
1747 );
1748 assert_eq!(s.plugin_host.pending(), 1);
1749 assert!(s.options.get("lazy-loaded").is_none(), "entry not applied yet");
1750
1751 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
1753
1754 assert_eq!(
1755 s.options.get("lazy-loaded").map(String::as_str),
1756 Some("yes"),
1757 "the command trigger applied the plugin's entry",
1758 );
1759 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
1760 }
1761
1762 #[test]
1763 fn lazy_plugin_activates_on_filetype() {
1764 let mut s = new_state_with("");
1765 s.register_lazy_plugin(
1766 "user-rust",
1767 vec![LazyTrigger::FileType("rust".into())],
1768 r#"(defoption :name "rust-plugin" :value "on")"#,
1769 );
1770 let n = s.activate_filetype_plugins("rust");
1771 assert_eq!(n, 1);
1772 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
1773 assert_eq!(s.activate_filetype_plugins("rust"), 0);
1775 }
1776
1777 #[test]
1778 fn cached_vm_serves_multiple_run_lisp_calls() {
1779 let mut s = new_state_with("");
1780 s.run_lisp(r#"(message "one")"#).unwrap();
1781 assert!(s.lisp_vm.is_some(), "VM should be cached after first run_lisp");
1782 s.run_lisp(r#"(message "two")"#).unwrap();
1783 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
1784 }
1785
1786 #[test]
1787 fn lisp_define_persists_across_run_lisp_calls() {
1788 let mut s = new_state_with("");
1791 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
1792 s.run_lisp(r#"(message greeting)"#).unwrap();
1793 assert_eq!(s.messages, vec!["hi".to_string()]);
1794 }
1795
1796 #[test]
1797 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
1798 let mut s = new_state_with("");
1802 s.run_lisp(
1803 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
1804 )
1805 .unwrap();
1806 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
1807 assert_eq!(
1808 s.options.get("col").map(String::as_str),
1809 Some("stale-zero"),
1810 "cursor-column within the same call reads the pre-eval snapshot",
1811 );
1812 s.run_lisp(
1815 r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#,
1816 )
1817 .unwrap();
1818 assert_eq!(
1819 s.options.get("col2").map(String::as_str),
1820 Some("live-two"),
1821 "a later call sees the refreshed snapshot",
1822 );
1823 }
1824
1825 #[test]
1826 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
1827 let mut s = new_state_with("");
1828 s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
1829 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
1830 assert_eq!(s.cursor(), Position::new(1, 3));
1831 }
1832
1833 #[test]
1834 fn visual_mode_sequence_resolves() {
1835 let mut s = new_state_with("abc");
1836 s.modal.enter(Mode::Visual);
1837 s.keymap.bind_sequence(
1838 Mode::Visual,
1839 vec![Key::Char('g'), Key::Char('e')],
1840 Action::Move(Motion::DocEnd),
1841 "ge",
1842 );
1843 s.on_key(&Key::Char('g'));
1844 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
1845 s.on_key(&Key::Char('e'));
1846 assert!(s.pending_keys.is_empty());
1847 assert_eq!(s.cursor().column, 3, "ge resolved to doc-end in visual mode");
1848 }
1849
1850 #[test]
1851 fn sequence_abort_with_bound_breaking_key_redispatches() {
1852 let mut s = new_state_with("abcde");
1855 s.keymap.bind_sequence(
1856 Mode::Normal,
1857 vec![Key::Char('g'), Key::Char('g')],
1858 Action::Move(Motion::DocEnd),
1859 "gg",
1860 );
1861 s.on_key(&Key::Char('g'));
1862 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
1863 s.on_key(&Key::Char('l'));
1864 assert!(s.pending_keys.is_empty());
1865 assert_eq!(
1866 s.cursor().column, 1,
1867 "the breaking key l should re-dispatch as move-right",
1868 );
1869 }
1870
1871 #[test]
1874 fn viewport_contains_cursor_after_every_op() {
1875 let mut s = new_state_small_viewport("", 5, 10);
1879 assert_cursor_in_viewport(&s, "initial");
1880
1881 s.tick(&press(KeyCode::Char('i')));
1884 assert_eq!(s.modal.mode(), Mode::Insert);
1885 for line in 0..30u32 {
1886 for c in "line".chars() {
1887 s.tick(&press(KeyCode::Char(c)));
1888 assert_cursor_in_viewport(&s, "typing chars");
1889 }
1890 s.tick(&press(KeyCode::Enter));
1891 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
1892 }
1893
1894 for i in 0..200u32 {
1897 s.tick(&press(KeyCode::Char('x')));
1898 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
1899 }
1900
1901 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
1903 assert_cursor_in_viewport(&s, "insert_text multiline");
1904
1905 s.tick(&press(KeyCode::Escape));
1907 assert_eq!(s.modal.mode(), Mode::Normal);
1908 for m in [
1909 Motion::DocStart,
1910 Motion::DocEnd,
1911 Motion::Down,
1912 Motion::Down,
1913 Motion::Up,
1914 Motion::Right,
1915 Motion::Right,
1916 Motion::Left,
1917 Motion::LineEnd,
1918 Motion::LineStart,
1919 Motion::GotoLine(1),
1920 Motion::GotoLine(40),
1921 Motion::PageDown,
1922 Motion::PageUp,
1923 ] {
1924 s.apply_motion(m);
1925 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
1926 }
1927
1928 for i in 0..50u32 {
1931 s.apply(&Action::Undo);
1932 assert_cursor_in_viewport(&s, &format!("undo {i}"));
1933 }
1934 for i in 0..50u32 {
1936 s.apply(&Action::Redo);
1937 assert_cursor_in_viewport(&s, &format!("redo {i}"));
1938 }
1939 }
1940
1941 #[test]
1942 fn insert_at_eof_keeps_cursor_in_bounds() {
1943 let mut s = new_state_small_viewport("abc", 5, 10);
1946 s.apply_motion(Motion::DocEnd);
1947 s.tick(&press(KeyCode::Char('i')));
1948 s.tick(&press(KeyCode::Char('d')));
1949 let buf = s.buffers.get(s.active).unwrap();
1950 let clamped = buf.clamp(s.cursor());
1951 assert_eq!(s.cursor(), clamped, "cursor must be clamped in-bounds at EOF");
1952 assert_cursor_in_viewport(&s, "insert at eof");
1953 }
1954
1955 #[test]
1956 fn count_prefix_then_sequence_repeats() {
1957 let mut s = new_state_with("a\nb\nc\nd\ne");
1959 s.keymap.bind_sequence(
1960 Mode::Normal,
1961 vec![Key::Char('g'), Key::Char('j')],
1962 Action::Move(Motion::Down),
1963 "gj",
1964 );
1965 s.on_key(&Key::Char('2'));
1966 s.on_key(&Key::Char('g'));
1967 s.on_key(&Key::Char('j'));
1968 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
1969 }
1970
1971 #[test]
1974 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
1975 let mut s = new_state_with(&"x\n".repeat(40));
1981 let t0 = std::time::Instant::now();
1982 let mut delivered = 0u32;
1983 for i in 0..20u32 {
1984 let before = s.cursor().line;
1985 s.tick_at(
1986 &press(KeyCode::Char('j')),
1987 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
1988 );
1989 if s.cursor().line != before {
1990 delivered += 1;
1991 }
1992 }
1993 assert!(
1996 (10..=14).contains(&delivered),
1997 "expected the storm debounced to ~13 moves, got {delivered}",
1998 );
1999 assert!(
2000 delivered < 20,
2001 "the gate must drop SOME storm ticks, not pass all 20",
2002 );
2003 }
2004
2005 #[test]
2006 fn spaced_intentional_taps_all_pass() {
2007 let mut s = new_state_with(&"x\n".repeat(10));
2010 let t0 = std::time::Instant::now();
2011 for i in 0..5u32 {
2012 s.tick_at(
2013 &press(KeyCode::Char('j')),
2014 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2016 );
2017 }
2018 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2019 }
2020
2021 #[test]
2022 fn distinct_keys_have_independent_clocks() {
2023 let mut s = new_state_with("abc\ndef\nghi");
2026 let t = std::time::Instant::now();
2027 s.tick_at(&press(KeyCode::Char('j')), t);
2028 s.tick_at(&press(KeyCode::Char('j')), t + std::time::Duration::from_millis(10));
2030 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2031 s.tick_at(&press(KeyCode::Char('l')), t + std::time::Duration::from_millis(10));
2033 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2034 }
2035
2036 #[test]
2039 fn cursor_home_preserves_single_cursor_behavior() {
2040 let mut s = new_state_with("hello\nworld\nthere");
2045 assert_eq!(s.cursor(), Position::ZERO);
2046 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2047
2048 s.apply_motion(Motion::Down);
2049 s.apply_motion(Motion::Right);
2050 s.apply_motion(Motion::Right);
2051 assert_eq!(s.cursor(), Position::new(1, 2));
2052 assert_eq!(s.cursors.count(), 1);
2054
2055 let w = s.layout.active_window().unwrap();
2057 assert!(w.viewport.top_line <= s.cursor().line);
2058 }
2059
2060 #[test]
2061 fn insert_mode_is_ungated_so_repeat_typing_works() {
2062 let mut s = new_state_with("");
2066 s.tick(&press(KeyCode::Char('i')));
2067 assert_eq!(s.modal.mode(), Mode::Insert);
2068 let t = std::time::Instant::now();
2069 for _ in 0..10 {
2070 s.tick_at(&press(KeyCode::Char('x')), t);
2071 }
2072 assert_eq!(
2073 s.buffers.get(s.active).unwrap().to_string(),
2074 "xxxxxxxxxx",
2075 "insert-mode repeat typing is ungated",
2076 );
2077 }
2078}