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, HighlightEffect, JumpList, Mode, Motion,
26 Operator, Position, Range, 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 jumps: JumpList,
67 pub options: HashMap<String, String>,
71 lisp_vm: Option<EscribaVm>,
77 pub pending_keys: Vec<Key>,
83 repeat_gate: KeyRepeatGate<Key>,
91 pub plugin_host: PluginHost,
96 register: Option<String>,
101 op_pending: zenmai::Stateful<OperatorPending>,
106 edit_gen: EditGen,
111 damage: Damage,
116}
117
118enum SeqStep {
120 Pending,
122 Resolved(Action),
124 Passthrough,
126}
127
128const fn is_discrete_jump(key: &Key) -> bool {
135 matches!(
136 key,
137 Key::Char('n')
138 | Key::Char('N')
139 | Key::Char('*')
140 | Key::Char('#')
141 | Key::Ctrl('o')
142 | Key::Ctrl('i')
143 )
144}
145
146impl EditorState {
147 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
149 let window = Window {
150 id: WindowId(1),
151 buffer_id: active,
152 viewport: Viewport {
153 top_line: 0,
154 left_column: 0,
155 visible_lines: 40,
156 visible_columns: 160,
157 },
158 rect: Rect {
159 x: 0,
160 y: 0,
161 width: 1200,
162 height: 800,
163 },
164 };
165 Self {
166 buffers: initial,
167 modal: ModalState::new(),
168 search: SearchState::new(escriba_search::CaseMode::Smart),
169 search_at: None,
170 jumps: JumpList::new(),
171 keymap: Keymap::default_vim(),
172 commands: CommandRegistry::default_set(),
173 layout: Layout::single(window),
174 active,
175 cursors: Cursors::single(Position::ZERO),
176 quit_requested: false,
177 register: None,
178 op_pending: zenmai::Stateful::new(OpState::Resting),
179 messages: Vec::new(),
180 options: HashMap::new(),
181 lisp_vm: None,
182 pending_keys: Vec::new(),
183 repeat_gate: KeyRepeatGate::new(),
184 plugin_host: PluginHost::default(),
185 edit_gen: EditGen::default(),
186 damage: Damage::None,
187 }
188 }
189
190 #[must_use]
194 pub fn edit_gen(&self) -> EditGen {
195 self.edit_gen
196 }
197
198 fn bump_gen(&mut self) {
200 self.edit_gen = self.edit_gen.next();
201 }
202
203 #[must_use]
205 pub fn damage(&self) -> Damage {
206 self.damage
207 }
208
209 pub fn take_damage(&mut self) -> Damage {
213 std::mem::replace(&mut self.damage, Damage::None)
214 }
215
216 fn active_line_count(&self) -> u32 {
219 self.buffers
220 .get(self.active)
221 .map_or(0, escriba_buffer::Buffer::line_count)
222 }
223
224 pub fn register_lazy_plugin(
230 &mut self,
231 name: impl Into<String>,
232 triggers: Vec<LazyTrigger>,
233 entry_src: impl Into<String>,
234 ) {
235 self.plugin_host.register(name, triggers, entry_src);
236 }
237
238 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
244 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
245 return 0;
246 };
247 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
248 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
249 if let Some(value) = self.options.get("mapleader") {
250 if let Some(key) = escriba_lisp::parse_leader_key(value) {
251 self.keymap.set_leader(key);
252 }
253 }
254 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
255 (cmd.registered + km.keybinds_applied) as usize
256 }
257
258 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
262 let pending = self.plugin_host.pending_for_filetype(filetype);
263 let n = pending.len();
264 for src in pending {
265 self.apply_plugin_entry(&src);
266 }
267 n
268 }
269
270 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
273 let pending = self.plugin_host.pending_for_event(event);
274 let n = pending.len();
275 for src in pending {
276 self.apply_plugin_entry(&src);
277 }
278 n
279 }
280
281 pub fn tick(&mut self, event: &AppEvent) {
286 self.tick_at(event, Instant::now());
287 }
288
289 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
293 match translate_app_event(event) {
294 InputOutcome::Key(k) => {
295 if self.gate_key(&k, now) {
296 self.on_key(&k);
297 }
298 }
299 InputOutcome::Resized { width, height } => {
300 if let Some(w) = self
301 .layout
302 .windows
303 .iter_mut()
304 .find(|w| w.id == self.layout.active)
305 {
306 w.rect.width = width;
307 w.rect.height = height;
308 }
309 self.damage = self.damage.join(Damage::Viewport);
310 self.bump_gen();
311 }
312 InputOutcome::Quit => self.quit_requested = true,
313 InputOutcome::Focus(_) | InputOutcome::None => {}
314 }
315 }
316
317 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
327 match self.modal.mode() {
328 Mode::Normal | Mode::Visual | Mode::VisualLine => {
329 if is_discrete_jump(key) {
335 return true;
336 }
337 self.repeat_gate.try_pass_at(*key, now)
338 }
339 Mode::Insert | Mode::Command => true,
340 }
341 }
342
343 pub fn on_key(&mut self, key: &Key) {
345 match self.step_sequence(key) {
349 SeqStep::Pending => return,
350 SeqStep::Resolved(action) => {
351 let count = self.modal.pending_count().unwrap_or(1);
352 self.modal.clear_count();
353 for _ in 0..count {
354 self.apply(&action);
355 if self.quit_requested {
356 return;
357 }
358 }
359 return;
360 }
361 SeqStep::Passthrough => {}
362 }
363 let counted = self.keymap.dispatch(&self.modal, key);
364 if matches!(counted.action, Action::Pending) {
366 if let Key::Char(c) = key {
367 if c.is_ascii_digit() {
368 let d = u32::from(*c as u8 - b'0');
369 self.modal.append_count(d);
370 }
371 }
372 return;
373 }
374 self.apply_counted(&counted.action, counted.count);
378 self.modal.clear_count();
380 }
381
382 fn step_sequence(&mut self, key: &Key) -> SeqStep {
394 let mode = self.modal.mode();
395 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
396 return SeqStep::Passthrough;
397 }
398 if !self.pending_keys.is_empty() {
399 let mut seq = self.pending_keys.clone();
400 seq.push(key.clone());
401 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
402 let action = b.action.clone();
403 self.pending_keys.clear();
404 return SeqStep::Resolved(action);
405 }
406 if self.keymap.is_sequence_prefix(mode, &seq) {
407 self.pending_keys = seq;
408 return SeqStep::Pending;
409 }
410 self.pending_keys.clear();
413 }
414 let start = [key.clone()];
415 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
416 self.pending_keys = start.to_vec();
417 return SeqStep::Pending;
418 }
419 SeqStep::Passthrough
420 }
421
422 #[must_use]
427 pub fn cursor(&self) -> Position {
428 self.cursors.primary()
429 }
430
431 fn set_cursor(&mut self, pos: Position) {
440 let clamped = if let Some(buf) = self.buffers.get(self.active) {
441 buf.clamp(pos)
442 } else {
443 pos
444 };
445 self.cursors.set_primary(clamped);
446 if let Some(w) = self
447 .layout
448 .windows
449 .iter_mut()
450 .find(|w| w.id == self.layout.active)
451 {
452 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
453 }
454 }
455
456 fn apply(&mut self, action: &Action) {
458 self.apply_counted(action, 1);
459 }
460
461 fn apply_counted(&mut self, action: &Action, count: u32) {
469 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
470 for _ in 0..times {
471 self.apply_resolved(&resolved);
472 if self.quit_requested {
473 return;
474 }
475 }
476 }
477 }
478
479 fn active_text(&self) -> String {
481 self.buffers
482 .get(self.active)
483 .map(escriba_buffer::Buffer::to_string)
484 .unwrap_or_default()
485 }
486
487 fn cursor_char(&self) -> usize {
489 self.buffers
490 .get(self.active)
491 .and_then(|b| b.position_to_char(self.cursor()).ok())
492 .unwrap_or(0)
493 }
494
495 #[must_use]
503 pub fn status_model(&self) -> StatusModel<'_> {
504 let cursor = self.cursor();
505 let prompt = self.search.prompt();
506
507 let kind = match prompt.map(|p| p.direction) {
508 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
509 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
510 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
513 None => PromptKind::None,
514 };
515
516 StatusModel {
517 mode: self.modal.mode(),
518 line: cursor.line.saturating_add(1) as usize,
519 column: cursor.column.saturating_add(1) as usize,
520 prompt: kind,
521 prompt_text: prompt.map_or_else(|| self.modal.minibuffer(), |p| p.text.as_str()),
522 count: self.match_count(),
523 message: self.messages.last().map(String::as_str),
524 }
525 }
526
527 #[must_use]
533 fn match_count(&self) -> MatchCount {
534 if self.search.is_prompting() {
535 let text = self.active_text();
536 let total = self.search.preview_total(&text);
537 return match self.search.preview(&text) {
538 Some(step) => MatchCount::new(step.index, total),
539 None if total == 0 && !self.search.prompt_is_empty() => MatchCount::None,
543 None => MatchCount::Idle,
544 };
545 }
546 if self.search.pattern().is_none() {
547 return MatchCount::Idle;
548 }
549 let total = self.search.matches().len();
550 self.search_at.map_or(
551 if total == 0 {
552 MatchCount::None
553 } else {
554 MatchCount::Idle
555 },
556 |i| MatchCount::new(i, total),
557 )
558 }
559
560 fn land_on(&mut self, step: escriba_search::Step) {
561 if let Some(buf) = self.buffers.get(self.active) {
562 let pos = buf.char_to_position(step.target.start);
563 self.set_cursor(pos);
564 }
565 self.search_at = Some(step.index);
569 if let Some(msg) = step.wrapped.message() {
570 self.messages.push(msg.to_string());
571 }
572 }
573
574 fn jump_search(&mut self, reverse: bool) {
578 self.search.relight();
581 self.jumps.push(self.cursor());
583 let at = self.cursor_char();
584 match self.search.repeat(at, reverse) {
585 Some(step) => self.land_on(step),
586 None => {
587 let msg = self.search.pattern().map_or_else(
588 || "E35: No previous regular expression".to_string(),
589 |p| {
590 let mut m = String::from("E486: Pattern not found: ");
591 m.push_str(p.raw());
592 m
593 },
594 );
595 self.messages.push(msg);
596 }
597 }
598 }
599
600 fn preview_search(&mut self) {
607 let text = self.active_text();
608 if let Some(step) = self.search.preview(&text) {
609 if let Some(buf) = self.buffers.get(self.active) {
610 let pos = buf.char_to_position(step.target.start);
611 self.set_cursor(pos);
612 }
613 }
614 }
615
616 fn submit_search_operated(&mut self, op: Operator) {
625 let text = self.active_text();
626 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
627 return;
628 };
629
630 match self.search.accept(&text) {
631 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
632 self.modal.clear_minibuffer();
633 self.modal.enter(Mode::Normal);
634 match self.search.commit_step(origin) {
635 Some(step) => {
636 if let Some(buf) = self.buffers.get(self.active) {
638 let from = buf.char_to_position(origin);
639 self.jumps.push(from);
640 let target = buf.char_to_position(step.target.start);
641 self.set_cursor(from);
642 self.search_at = Some(step.index);
643 self.apply_operator_to(op, target);
644 }
645 }
646 None => self.report_pattern_not_found(),
647 }
648 }
649 escriba_search::Accepted::NothingToRepeat => {
650 self.modal.clear_minibuffer();
651 self.modal.enter(Mode::Normal);
652 self.messages
653 .push("E35: No previous regular expression".to_string());
654 }
655 escriba_search::Accepted::Invalid(e) => {
656 let mut m = String::from("E383: Invalid search string: ");
657 m.push_str(&e.to_string());
658 self.messages.push(m);
659 }
660 }
661 }
662
663 fn report_pattern_not_found(&mut self) {
666 let mut m = String::from("E486: Pattern not found");
667 if let Some(p) = self.search.pattern() {
668 m.push_str(": ");
669 m.push_str(p.raw());
670 }
671 self.messages.push(m);
672 }
673
674 fn submit_search(&mut self) {
676 let text = self.active_text();
677 let at = self
692 .search
693 .prompt()
694 .map_or_else(|| self.cursor_char(), |p| p.origin);
695 let outcome = self.search.accept(&text);
696 match outcome {
697 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
698 self.modal.clear_minibuffer();
699 self.modal.enter(Mode::Normal);
700 if let Some(buf) = self.buffers.get(self.active) {
705 let origin = buf.char_to_position(at);
706 self.jumps.push(origin);
707 }
708 match self.search.commit_step(at) {
709 Some(step) => self.land_on(step),
710 None => {
711 let mut m = String::from("E486: Pattern not found");
712 if let Some(p) = self.search.pattern() {
713 m.push_str(": ");
714 m.push_str(p.raw());
715 }
716 self.messages.push(m);
717 }
718 }
719 }
720 escriba_search::Accepted::NothingToRepeat => {
721 self.modal.clear_minibuffer();
722 self.modal.enter(Mode::Normal);
723 self.messages
724 .push("E35: No previous regular expression".to_string());
725 }
726 escriba_search::Accepted::Invalid(e) => {
729 let mut m = String::from("E383: Invalid search string: ");
730 m.push_str(&e.to_string());
731 self.messages.push(m);
732 }
733 }
734 }
735
736 fn apply_resolved(&mut self, action: &Action) {
737 let lines_before = self.active_line_count();
740 let cline_before = self.cursor().line;
741 match action {
742 Action::Move(m) => self.apply_motion(*m),
743 Action::SearchOpen(dir) => {
744 let origin = self.cursor_char();
748 self.search.open(*dir, origin);
749 self.modal.enter(Mode::Command);
750 }
751 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
752 Action::SearchWord { reverse } => {
753 let dir = if *reverse {
754 SearchDirection::Backward
755 } else {
756 SearchDirection::Forward
757 };
758 let (text, at) = (self.active_text(), self.cursor_char());
759 self.jumps.push(self.cursor());
761 match self.search.search_word(&text, at, dir) {
762 Some(step) => self.land_on(step),
763 None => self
766 .messages
767 .push("E348: No string under cursor".to_string()),
768 }
769 }
770 Action::ClearSearchHighlight => self.search.clear_highlight(),
771 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
772 Action::JumpBack => {
773 let here = self.cursor();
774 if let Some(pos) = self.jumps.back(here) {
775 self.set_cursor(pos);
776 } else {
777 self.messages
778 .push("E662: At start of changelist".to_string());
779 }
780 }
781 Action::JumpForward => {
782 if let Some(pos) = self.jumps.forward() {
783 self.set_cursor(pos);
784 } else {
785 self.messages.push("E663: At end of changelist".to_string());
786 }
787 }
788 Action::ChangeMode(m) => {
789 if *m == Mode::Normal && self.search.is_prompting() {
793 if let Some(origin) = self.search.cancel() {
794 if let Some(buf) = self.buffers.get(self.active) {
795 let pos = buf.char_to_position(origin);
796 self.set_cursor(pos);
797 }
798 }
799 }
800 self.modal.enter(*m);
801 }
802 Action::InsertChar(c) => self.insert_char(*c),
803 Action::Edit(edit) => self.apply_edit(edit),
804 Action::Undo => {
805 if let Some(buf) = self.buffers.get_mut(self.active) {
806 let _ = buf.undo();
807 }
808 self.set_cursor(self.cursor());
811 }
812 Action::Redo => {
813 if let Some(buf) = self.buffers.get_mut(self.active) {
814 let _ = buf.redo();
815 }
816 self.set_cursor(self.cursor());
817 }
818 Action::Save => {
819 if let Some(buf) = self.buffers.get_mut(self.active) {
820 let _ = buf.save();
821 }
822 self.set_cursor(self.cursor());
823 }
824 Action::Quit => self.quit_requested = true,
825 Action::SubmitCommand => {
826 if self.search.is_prompting() {
827 self.submit_search();
828 } else {
829 self.submit_command();
830 }
831 }
832 Action::Command { name, args } => self.run_command(name, args),
833 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
834 Action::Operator(_) => {}
837 Action::PromptBackspace => {
838 self.prompt_backspace();
839 if self.search.is_prompting() {
843 self.preview_search();
844 }
845 }
846 Action::PromptHistory { back } => {
847 if self.search.is_prompting() {
848 self.search.history_step(*back);
849 self.modal.clear_minibuffer();
853 if let Some(text) = self.search.prompt().map(|p| p.text.clone()) {
854 self.modal.push_minibuffer_str(&text);
855 }
856 self.preview_search();
857 }
858 }
859 Action::Pending => {}
860 }
861 let lines_after = self.active_line_count();
866 let cline_after = self.cursor().line;
867 let d = match action {
868 Action::SearchOpen(_)
872 | Action::PromptHistory { .. }
873 | Action::PromptBackspace
874 | Action::SearchRepeat { .. }
875 | Action::SearchWord { .. }
876 | Action::ClearSearchHighlight
877 | Action::SearchSubmitOperated { .. }
878 | Action::JumpBack
880 | Action::JumpForward => Damage::Full,
881 Action::InsertChar(_)
882 | Action::Edit(_)
883 | Action::Undo
884 | Action::Redo
885 | Action::ApplyOperator { .. } => {
886 if lines_after == lines_before {
887 Damage::span(cline_before, cline_after)
888 } else {
889 Damage::Lines {
890 from: cline_before.min(cline_after),
891 to: u32::MAX,
892 }
893 }
894 }
895 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
896 Action::Save => Damage::Viewport,
897 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
898 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
899 };
900 self.damage = self.damage.join(d);
901 if action.highlight_effect() == HighlightEffect::Clear {
906 self.search.clear_highlight();
907 }
908 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
917 let text = self.active_text();
918 self.search.refresh(&text);
919 let at = self.cursor_char();
925 self.search_at = self.search.matches().iter().position(|m| m.contains(at));
926 }
927 self.bump_gen();
932 }
933
934 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
944 let buf = self.buffers.get(self.active)?;
945 let pos = from;
946 Some(match motion {
947 Motion::SearchNext | Motion::SearchPrev => {
952 let at = buf.position_to_char(pos).ok()?;
953 let step = self
954 .search
955 .repeat(at, matches!(motion, Motion::SearchPrev))?;
956 buf.char_to_position(step.target.start)
957 }
958 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
959 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
960 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
961 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
962 Motion::LineStart => Position::new(pos.line, 0),
963 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
964 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
965 Motion::DocStart => Position::ZERO,
966 Motion::DocEnd => Position::new(
967 buf.line_count().saturating_sub(1),
968 buf.line_len_chars(buf.line_count().saturating_sub(1)),
969 ),
970 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
971 Motion::WordStartPrev => word_prev(buf, pos),
972 Motion::PageDown | Motion::HalfPageDown => {
973 Position::new(pos.line.saturating_add(10), pos.column)
974 }
975 Motion::PageUp | Motion::HalfPageUp => {
976 Position::new(pos.line.saturating_sub(10), pos.column)
977 }
978 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
979 Motion::ForwardSexp
982 | Motion::BackwardSexp
983 | Motion::UpList
984 | Motion::DownList
985 | Motion::BeginningOfDefun
986 | Motion::EndOfDefun
987 | Motion::BeginningOfSexp
988 | Motion::EndOfSexp => pos,
989 })
990 }
991
992 fn apply_motion(&mut self, motion: Motion) {
993 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1001 self.jump_search(matches!(motion, Motion::SearchPrev));
1002 return;
1003 }
1004 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1005 return;
1006 };
1007 self.set_cursor(pos);
1010 }
1011
1012 fn apply_operator(&mut self, op: Operator, motion: Motion) {
1019 let from = self.cursor();
1020 let Some(to) = self.resolve_motion(from, motion) else {
1021 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1026 if self.search.pattern().is_none() {
1027 self.messages
1028 .push("E35: No previous regular expression".to_string());
1029 } else {
1030 self.report_pattern_not_found();
1031 }
1032 }
1033 return;
1034 };
1035 self.apply_operator_to(op, to);
1036 }
1037
1038 fn apply_operator_to(&mut self, op: Operator, to: Position) {
1045 let from = self.cursor();
1046 let range = Range {
1047 start: from,
1048 end: to,
1049 }
1050 .normalized();
1051 if range.is_empty() {
1052 return;
1053 }
1054 let text = self
1056 .buffers
1057 .get(self.active)
1058 .and_then(|buf| buf.slice(range).ok());
1059 if op.leaves_register() {
1060 if let Some(t) = &text {
1061 self.register = Some(t.clone());
1062 }
1063 }
1064 match op {
1065 Operator::Delete | Operator::Change => {
1068 if let Some(buf) = self.buffers.get_mut(self.active) {
1069 let _ = buf.apply(&Edit::delete(range));
1070 }
1071 self.set_cursor(range.start);
1072 if op == Operator::Change {
1073 self.modal.enter(Mode::Insert);
1074 }
1075 }
1076 Operator::Yank => {
1079 self.set_cursor(range.start);
1080 }
1081 _ => {
1085 self.messages
1086 .push("operator not yet implemented".to_owned());
1087 }
1088 }
1089 }
1090
1091 #[must_use]
1094 pub fn register(&self) -> Option<&str> {
1095 self.register.as_deref()
1096 }
1097
1098 fn insert_char(&mut self, c: char) {
1099 if self.modal.mode() == Mode::Command {
1100 if self.search.is_prompting() {
1104 self.search.push(c);
1105 self.modal.push_minibuffer(c);
1106 self.preview_search();
1107 } else {
1108 self.modal.push_minibuffer(c);
1109 }
1110 return;
1111 }
1112 let cursor = self.cursor();
1113 let Some(buf) = self.buffers.get_mut(self.active) else {
1114 return;
1115 };
1116 let edit = Edit::insert(cursor, c.to_string());
1117 if buf.apply(&edit).is_ok() {
1118 let next = if c == '\n' {
1119 Position::new(cursor.line.saturating_add(1), 0)
1120 } else {
1121 cursor.shift_right(1)
1122 };
1123 self.set_cursor(next);
1126 }
1127 }
1128
1129 fn prompt_backspace(&mut self) -> bool {
1133 if self.modal.mode() != Mode::Command {
1134 return false;
1135 }
1136 if self.search.is_prompting() {
1137 if self.search.backspace() {
1139 self.modal.clear_minibuffer();
1140 self.modal.enter(Mode::Normal);
1141 return true;
1142 }
1143 }
1144 self.modal.pop_minibuffer();
1145 true
1146 }
1147
1148 fn apply_edit(&mut self, _edit: &Edit) {
1149 }
1153
1154 fn submit_command(&mut self) {
1155 let line = self.modal.minibuffer().to_string();
1159 self.modal.escape();
1160 let (name, args) = parse_command_line(&line);
1161 if name.is_empty() {
1162 return;
1163 }
1164 self.run_command(&name, &args);
1165 }
1166
1167 fn run_command(&mut self, name: &str, args: &[String]) {
1168 if matches!(name, "noh" | "nohl" | "nohlsearch") {
1174 self.search.clear_highlight();
1175 return;
1176 }
1177 if self.plugin_host.pending() > 0 {
1183 let pending = self.plugin_host.pending_for_command(name);
1184 for src in pending {
1185 self.apply_plugin_entry(&src);
1186 }
1187 }
1188 let active = Some(self.active);
1189 let mut quit = false;
1190 {
1191 let mut ctx = EditContext {
1192 buffers: &mut self.buffers,
1193 active,
1194 state: &mut self.modal,
1195 quit_requested: &mut quit,
1196 };
1197 let _ = self.commands.run(name, &mut ctx, args);
1198 }
1199 if quit {
1202 self.quit_requested = true;
1203 }
1204 }
1205
1206 #[must_use]
1211 pub fn snapshot(&self) -> EditorSnapshot {
1212 let current_line = self
1213 .buffers
1214 .get(self.active)
1215 .and_then(|b| b.line(self.cursor().line))
1216 .map(|s| s.trim_end_matches('\n').to_string())
1217 .unwrap_or_default();
1218 let buffer_name = self
1219 .buffers
1220 .get(self.active)
1221 .and_then(|b| b.path.as_ref())
1222 .map(|p| p.display().to_string())
1223 .unwrap_or_else(|| "[scratch]".to_string());
1224 EditorSnapshot {
1225 cursor_line: i64::from(self.cursor().line),
1226 cursor_column: i64::from(self.cursor().column),
1227 current_line,
1228 mode: self.modal.mode().as_str().to_string(),
1229 buffer_name,
1230 }
1231 }
1232
1233 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
1249 let mut host = EscribaHost::with_snapshot(self.snapshot());
1250 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
1251 vm.eval(src, &mut host)?;
1252 let effects = host.take_effects();
1253 self.apply_host_effects(effects);
1254 Ok(())
1255 }
1256
1257 pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1261 for eff in effects {
1262 match eff {
1263 HostEffect::Message(m) => self.messages.push(m),
1264 HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
1265 HostEffect::SetOption { name, value } => {
1266 self.options.insert(name, value);
1267 }
1268 HostEffect::InsertText(text) => self.insert_text(&text),
1269 }
1270 }
1271 }
1272
1273 fn insert_text(&mut self, text: &str) {
1276 if text.is_empty() {
1277 return;
1278 }
1279 let cursor = self.cursor();
1280 let Some(buf) = self.buffers.get_mut(self.active) else {
1281 return;
1282 };
1283 let edit = Edit::insert(cursor, text.to_string());
1284 if buf.apply(&edit).is_ok() {
1285 let next = if let Some(nl) = text.rfind('\n') {
1286 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
1287 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
1288 Position::new(cursor.line + added_lines, last_line_len)
1289 } else {
1290 let n = u32::try_from(text.chars().count()).unwrap_or(0);
1291 cursor.shift_right(n)
1292 };
1293 self.set_cursor(next);
1296 }
1297 }
1298}
1299
1300fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1301 let Some(text) = buf.line(line) else {
1302 return Position::new(line, 0);
1303 };
1304 let col = text
1305 .chars()
1306 .take_while(|c| c.is_whitespace() && *c != '\n')
1307 .count();
1308 Position::new(line, u32::try_from(col).unwrap_or(0))
1309}
1310
1311fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1312 let Some(text) = buf.line(pos.line) else {
1313 return pos;
1314 };
1315 let chars: Vec<char> = text.chars().collect();
1316 let start = pos.column as usize;
1317 let mut i = start;
1318 while i < chars.len() && !chars[i].is_whitespace() {
1319 i += 1;
1320 }
1321 while i < chars.len() && chars[i].is_whitespace() {
1322 i += 1;
1323 }
1324 if i >= chars.len() {
1325 if pos.line + 1 < buf.line_count() {
1327 return Position::new(pos.line + 1, 0);
1328 }
1329 }
1330 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1331}
1332
1333fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1334 let Some(text) = buf.line(pos.line) else {
1335 return pos;
1336 };
1337 let chars: Vec<char> = text.chars().collect();
1338 let mut i = (pos.column as usize).min(chars.len());
1339 while i > 0 && chars[i - 1].is_whitespace() {
1340 i -= 1;
1341 }
1342 while i > 0 && !chars[i - 1].is_whitespace() {
1343 i -= 1;
1344 }
1345 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1346}
1347
1348fn parse_command_line(line: &str) -> (String, Vec<String>) {
1349 let mut parts = line.split_whitespace();
1350 let Some(first) = parts.next() else {
1351 return (String::new(), Vec::new());
1352 };
1353 let head = first.strip_prefix(':').unwrap_or(first);
1354 let name = match head {
1355 "w" => "save",
1356 "q" => "quit",
1357 "u" => "undo",
1358 other => other,
1359 };
1360 (name.to_string(), parts.map(str::to_string).collect())
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365 use super::*;
1366 use madori::event::{KeyCode, KeyEvent, Modifiers};
1367
1368 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1376 st.apply(&Action::SearchOpen(dir));
1377 for c in pat.chars() {
1378 st.apply(&Action::InsertChar(c));
1379 }
1380 st.apply(&Action::SubmitCommand);
1381 }
1382
1383 #[test]
1384 fn slash_search_moves_the_cursor_to_the_match() {
1385 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1386 type_search(&mut st, SearchDirection::Forward, "charlie");
1387 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1388 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1389 assert_eq!(st.search.matches().len(), 1);
1390 }
1391
1392 #[test]
1393 fn n_and_N_walk_matches_in_both_directions() {
1394 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1395 type_search(&mut st, SearchDirection::Forward, "foo");
1396 let first = st.cursor().line;
1397 st.apply(&Action::SearchRepeat { reverse: false });
1398 let second = st.cursor().line;
1399 assert!(second > first, "n advances ({first} -> {second})");
1400 st.apply(&Action::SearchRepeat { reverse: true });
1401 assert_eq!(st.cursor().line, first, "N comes back");
1402 }
1403
1404 #[test]
1405 fn star_searches_the_word_under_the_cursor() {
1406 let mut st = new_state_with("needle\nhaystack\nneedle\n");
1407 st.apply(&Action::SearchWord { reverse: false });
1408 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1409 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1410 }
1411
1412 #[test]
1413 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1414 let mut st = new_state_with("foo\nbar\nfoo\n");
1415 type_search(&mut st, SearchDirection::Forward, "foo");
1416 let matches_before = st.search.matches().len();
1417
1418 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1419 st.apply(&Action::InsertChar('z'));
1420 st.apply(&Action::ChangeMode(Mode::Normal));
1421
1422 assert!(!st.search.is_prompting(), "prompt gone");
1423 assert_eq!(
1424 st.search.pattern().unwrap().raw(),
1425 "foo",
1426 "old pattern survives"
1427 );
1428 assert_eq!(
1429 st.search.matches().len(),
1430 matches_before,
1431 "old highlights survive"
1432 );
1433 }
1434
1435 #[test]
1436 fn a_search_prompt_and_an_ex_command_are_not_confused() {
1437 let mut st = new_state_with("foo\n");
1438 st.apply(&Action::ChangeMode(Mode::Command));
1440 assert!(!st.search.is_prompting(), "`:` must not open a search");
1441 st.apply(&Action::InsertChar('w'));
1442 assert!(
1443 st.search.prompt().is_none(),
1444 "typed char went to the ex line"
1445 );
1446 }
1447
1448 #[test]
1449 fn a_missing_pattern_reports_instead_of_failing_silently() {
1450 let mut st = new_state_with("alpha\nbravo\n");
1451 type_search(&mut st, SearchDirection::Forward, "zzz");
1452 assert!(
1453 st.messages.iter().any(|m| m.contains("E486")),
1454 "must report not-found, got {:?}",
1455 st.messages
1456 );
1457 }
1458
1459 #[test]
1460 fn n_without_any_search_reports_rather_than_moving() {
1461 let mut st = new_state_with("alpha\nbravo\n");
1462 let before = st.cursor();
1463 st.apply(&Action::SearchRepeat { reverse: false });
1464 assert_eq!(st.cursor(), before, "cursor must not move");
1465 assert!(
1466 st.messages.iter().any(|m| m.contains("E35")),
1467 "got {:?}",
1468 st.messages
1469 );
1470 }
1471
1472 #[test]
1473 fn search_as_a_motion_composes_with_an_operator() {
1474 let mut st = new_state_with("alpha bravo charlie\n");
1476 type_search(&mut st, SearchDirection::Forward, "charlie");
1477 st.set_cursor(Position::new(0, 0));
1478 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1479 assert!(target.is_some(), "search must resolve as a motion");
1480 assert_eq!(target.unwrap().column, 12, "at `charlie`");
1481 }
1482
1483 #[test]
1484 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1485 let st = new_state_with("alpha bravo\n");
1488 assert!(
1489 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
1490 .is_none()
1491 );
1492 }
1493
1494 #[test]
1495 fn clear_highlight_keeps_the_pattern_usable() {
1496 let mut st = new_state_with("foo\nbar\nfoo\n");
1497 type_search(&mut st, SearchDirection::Forward, "foo");
1498 st.apply(&Action::ClearSearchHighlight);
1499 assert!(st.search.highlights().is_empty(), "nothing lit");
1500 st.apply(&Action::SearchRepeat { reverse: false });
1501 assert!(st.search.pattern().is_some(), "but n still works");
1502 }
1503
1504 #[test]
1505 fn typing_previews_incrementally_before_commit() {
1506 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1507 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1508 for c in "charlie".chars() {
1509 st.apply(&Action::InsertChar(c));
1510 }
1511 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1513 assert!(st.search.pattern().is_none(), "but nothing is committed");
1514 }
1515
1516 #[test]
1517 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1518 let mut st = new_state_with("alpha\nbravo\n");
1519 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1520 for c in "bravox".chars() {
1521 st.apply(&Action::InsertChar(c));
1522 }
1523 assert_eq!(st.search.prompt().unwrap().text, "bravox");
1524 st.apply(&Action::PromptBackspace);
1525 assert_eq!(st.search.prompt().unwrap().text, "bravo", "typo corrected");
1526 assert_eq!(st.modal.minibuffer(), "bravo", "display stays in lockstep");
1527 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1528 }
1529
1530 #[test]
1531 fn backspacing_past_the_slash_closes_the_prompt() {
1532 let mut st = new_state_with("alpha\n");
1533 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1534 st.apply(&Action::InsertChar('a'));
1535 st.apply(&Action::PromptBackspace);
1536 st.apply(&Action::PromptBackspace);
1537 assert!(!st.search.is_prompting(), "prompt closed");
1538 assert_eq!(st.modal.mode(), Mode::Normal);
1539 }
1540
1541 #[test]
1542 fn noh_clears_highlights_and_keeps_the_pattern() {
1543 let mut st = new_state_with("foo\nbar\nfoo\n");
1544 type_search(&mut st, SearchDirection::Forward, "foo");
1545 assert!(!st.search.highlights().is_empty());
1546 st.run_command("noh", &[]);
1547 assert!(st.search.highlights().is_empty(), ":noh turns them off");
1548 assert!(st.search.pattern().is_some(), "but n still works");
1549 }
1550
1551 #[test]
1552 fn noh_accepts_the_vim_aliases() {
1553 for name in ["noh", "nohl", "nohlsearch"] {
1554 let mut st = new_state_with("foo\nfoo\n");
1555 type_search(&mut st, SearchDirection::Forward, "foo");
1556 st.run_command(name, &[]);
1557 assert!(st.search.highlights().is_empty(), "{name} must clear");
1558 }
1559 }
1560
1561 #[test]
1562 fn backspace_on_the_ex_line_does_not_touch_search_state() {
1563 let mut st = new_state_with("foo\n");
1564 st.apply(&Action::ChangeMode(Mode::Command));
1565 st.apply(&Action::InsertChar('w'));
1566 st.apply(&Action::InsertChar('q'));
1567 st.apply(&Action::PromptBackspace);
1568 assert_eq!(st.modal.minibuffer(), "w");
1569 assert!(st.search.prompt().is_none(), "no search was involved");
1570 }
1571
1572 #[test]
1573 fn up_arrow_recalls_the_previous_search() {
1574 let mut st = new_state_with("alpha\nbravo\n");
1575 type_search(&mut st, SearchDirection::Forward, "bravo");
1576 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1577 st.apply(&Action::PromptHistory { back: true });
1578 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1579 assert_eq!(st.modal.minibuffer(), "bravo", "display follows the prompt");
1580 }
1581
1582 #[test]
1583 fn arrowing_back_down_restores_the_half_typed_pattern() {
1584 let mut st = new_state_with("alpha\nbravo\n");
1585 type_search(&mut st, SearchDirection::Forward, "bravo");
1586 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1587 st.apply(&Action::InsertChar('a'));
1588 st.apply(&Action::PromptHistory { back: true });
1589 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1590 st.apply(&Action::PromptHistory { back: false });
1591 assert_eq!(
1592 st.search.prompt().unwrap().text,
1593 "a",
1594 "the draft comes back"
1595 );
1596 assert_eq!(st.modal.minibuffer(), "a");
1597 }
1598
1599 #[test]
1600 fn history_arrows_do_nothing_on_the_ex_line() {
1601 let mut st = new_state_with("alpha\n");
1602 st.apply(&Action::ChangeMode(Mode::Command));
1603 st.apply(&Action::InsertChar('w'));
1604 st.apply(&Action::PromptHistory { back: true });
1605 assert_eq!(st.modal.minibuffer(), "w", "ex line untouched");
1606 }
1607
1608 fn new_state_with(text: &str) -> EditorState {
1609 let mut bufs = BufferSet::new();
1610 let id = bufs.scratch(text);
1611 EditorState::new_with_buffer(bufs, id)
1612 }
1613
1614 #[test]
1620 fn edit_gen_advances_on_applied_action_not_on_read() {
1621 let mut s = new_state_with("hello\nworld\n");
1622 let g0 = s.edit_gen();
1623 s.apply(&Action::InsertChar('X'));
1624 assert_ne!(
1625 s.edit_gen(),
1626 g0,
1627 "an applied action must advance the refresh generation",
1628 );
1629 let g1 = s.edit_gen();
1631 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1632 }
1633
1634 #[test]
1639 fn damage_tracks_edit_scope_and_drains() {
1640 let mut s = new_state_with("hello\nworld\n");
1641 assert!(s.damage().is_none(), "a fresh state has no damage");
1642
1643 s.apply(&Action::InsertChar('X')); assert_eq!(
1645 s.damage(),
1646 Damage::Lines { from: 0, to: 0 },
1647 "a local edit damages just its line",
1648 );
1649
1650 let drained = s.take_damage();
1651 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
1652 assert!(s.damage().is_none(), "take_damage drains to None");
1653
1654 s.apply(&Action::InsertChar('\n')); assert_eq!(
1656 s.damage(),
1657 Damage::Lines {
1658 from: 0,
1659 to: u32::MAX,
1660 },
1661 "a line-count change damages to end-of-document",
1662 );
1663 }
1664
1665 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
1669 let mut s = new_state_with(text);
1670 for w in &mut s.layout.windows {
1671 w.viewport.visible_lines = vis_lines;
1672 w.viewport.visible_columns = vis_cols;
1673 }
1674 s
1675 }
1676
1677 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
1682 let w = s.layout.active_window().expect("active window");
1683 let v = w.viewport;
1684 let c = s.cursor();
1685 assert!(
1686 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
1687 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
1688 c.line,
1689 v.top_line,
1690 v.top_line + v.visible_lines,
1691 );
1692 assert!(
1693 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
1694 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
1695 c.column,
1696 v.left_column,
1697 v.left_column + v.visible_columns,
1698 );
1699 }
1700
1701 fn press(kc: KeyCode) -> AppEvent {
1702 AppEvent::Key(KeyEvent {
1703 key: kc,
1704 pressed: true,
1705 modifiers: Modifiers::default(),
1706 text: None,
1707 })
1708 }
1709
1710 fn line0_len(s: &EditorState) -> u32 {
1713 s.buffers.get(s.active).unwrap().line_len_chars(0)
1714 }
1715
1716 #[test]
1717 fn delete_to_line_end_clears_line_and_fills_register() {
1718 let mut s = new_state_with("hello world");
1719 s.apply(&Action::ApplyOperator {
1720 op: Operator::Delete,
1721 motion: Motion::LineEnd,
1722 });
1723 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
1724 assert_eq!(
1725 s.register(),
1726 Some("hello world"),
1727 "delete fills the register"
1728 );
1729 assert_eq!(
1730 s.cursor(),
1731 Position::ZERO,
1732 "cursor lands at the range start"
1733 );
1734 }
1735
1736 #[test]
1737 fn delete_over_right_motion_removes_one_char() {
1738 let mut s = new_state_with("abc");
1739 s.apply(&Action::ApplyOperator {
1740 op: Operator::Delete,
1741 motion: Motion::Right,
1742 });
1743 assert_eq!(
1744 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1745 Some("bc")
1746 );
1747 assert_eq!(s.register(), Some("a"));
1748 }
1749
1750 #[test]
1751 fn change_to_line_end_deletes_and_enters_insert() {
1752 let mut s = new_state_with("hello world");
1753 assert_eq!(s.modal.mode(), Mode::Normal);
1754 s.apply(&Action::ApplyOperator {
1755 op: Operator::Change,
1756 motion: Motion::LineEnd,
1757 });
1758 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
1759 assert_eq!(
1760 s.modal.mode(),
1761 Mode::Insert,
1762 "change enters Insert to type the replacement"
1763 );
1764 assert_eq!(
1765 s.register(),
1766 Some("hello world"),
1767 "change fills the register"
1768 );
1769 }
1770
1771 #[test]
1772 fn yank_to_line_end_fills_register_without_mutating() {
1773 let mut s = new_state_with("hello world");
1774 s.apply(&Action::ApplyOperator {
1775 op: Operator::Yank,
1776 motion: Motion::LineEnd,
1777 });
1778 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
1779 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
1780 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
1781 }
1782
1783 #[test]
1784 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
1785 let mut s = new_state_with("hello world");
1789 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
1790 assert_eq!(target, Position::new(0, 11));
1791 s.apply_motion(Motion::LineEnd);
1792 assert_eq!(
1793 s.cursor(),
1794 target,
1795 "the move path resolves the same target the operator uses"
1796 );
1797 }
1798
1799 #[test]
1800 fn empty_motion_range_is_a_no_op() {
1801 let mut s = new_state_with("abc");
1804 s.apply(&Action::ApplyOperator {
1805 op: Operator::Delete,
1806 motion: Motion::LineStart,
1807 });
1808 assert_eq!(
1809 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1810 Some("abc")
1811 );
1812 assert_eq!(s.register(), None);
1813 }
1814
1815 #[test]
1816 fn operator_then_motion_composes_through_the_pending_fsm() {
1817 let mut s = new_state_with("hello world");
1821 s.apply(&Action::Operator(Operator::Delete));
1822 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
1823 s.apply(&Action::Move(Motion::LineEnd));
1824 assert_eq!(
1825 line0_len(&s),
1826 0,
1827 "d then $ composes d$ and deletes the line"
1828 );
1829 assert_eq!(s.register(), Some("hello world"));
1830 }
1831
1832 #[test]
1833 fn change_operator_through_fsm_enters_insert() {
1834 let mut s = new_state_with("hello world");
1835 s.apply(&Action::Operator(Operator::Change));
1836 s.apply(&Action::Move(Motion::LineEnd));
1837 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
1838 }
1839
1840 #[test]
1841 fn lone_motion_after_no_operator_just_moves() {
1842 let mut s = new_state_with("hello world");
1844 s.apply(&Action::Move(Motion::LineEnd));
1845 assert_eq!(s.cursor(), Position::new(0, 11));
1846 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
1847 }
1848
1849 #[test]
1850 fn counted_operator_deletes_count_times() {
1851 let mut s = new_state_with("abcdef");
1855 s.apply_counted(&Action::Operator(Operator::Delete), 3);
1856 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
1857 s.apply(&Action::Move(Motion::Right));
1858 assert_eq!(
1859 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1860 Some("def")
1861 );
1862 }
1863
1864 #[test]
1865 fn operator_and_motion_counts_multiply_end_to_end() {
1866 let mut s = new_state_with("abcdefgh");
1868 s.apply_counted(&Action::Operator(Operator::Delete), 2);
1869 s.apply_counted(&Action::Move(Motion::Right), 3);
1870 assert_eq!(
1871 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1872 Some("gh")
1873 );
1874 }
1875
1876 #[test]
1877 fn bare_counted_motion_still_repeats_no_regression() {
1878 let mut s = new_state_with("a\nb\nc\nd\ne");
1881 s.apply_counted(&Action::Move(Motion::Down), 3);
1882 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
1883 }
1884
1885 struct SpacedClock(std::time::Instant);
1891 impl SpacedClock {
1892 fn new() -> Self {
1893 Self(std::time::Instant::now())
1894 }
1895 fn next(&mut self) -> std::time::Instant {
1896 self.0 += std::time::Duration::from_secs(1);
1897 self.0
1898 }
1899 }
1900
1901 #[test]
1902 fn hjkl_moves_cursor() {
1903 let mut s = new_state_with("hello\nworld");
1904 s.tick(&press(KeyCode::Char('l')));
1905 assert_eq!(s.cursor().column, 1);
1906 s.tick(&press(KeyCode::Char('j')));
1907 assert_eq!(s.cursor().line, 1);
1908 s.tick(&press(KeyCode::Char('h')));
1909 assert_eq!(s.cursor().column, 0);
1910 }
1911
1912 #[test]
1913 fn insert_mode_inserts_chars() {
1914 let mut s = new_state_with("");
1915 s.tick(&press(KeyCode::Char('i')));
1916 assert_eq!(s.modal.mode(), Mode::Insert);
1917 s.tick(&press(KeyCode::Char('h')));
1918 s.tick(&press(KeyCode::Char('i')));
1919 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
1920 assert_eq!(s.cursor().column, 2);
1921 }
1922
1923 #[test]
1924 fn esc_returns_to_normal() {
1925 let mut s = new_state_with("");
1926 s.tick(&press(KeyCode::Char('i')));
1927 s.tick(&press(KeyCode::Escape));
1928 assert_eq!(s.modal.mode(), Mode::Normal);
1929 }
1930
1931 #[test]
1932 fn count_prefix_repeats_motion() {
1933 let mut s = new_state_with("abcdefghij");
1934 s.tick(&press(KeyCode::Char('5')));
1935 s.tick(&press(KeyCode::Char('l')));
1936 assert_eq!(s.cursor().column, 5);
1937 }
1938
1939 #[test]
1940 fn close_event_requests_quit() {
1941 let mut s = new_state_with("");
1942 s.tick(&AppEvent::CloseRequested);
1943 assert!(s.quit_requested);
1944 }
1945
1946 #[test]
1947 fn word_next_jumps_past_whitespace() {
1948 let mut s = new_state_with("foo bar baz");
1949 let mut clk = SpacedClock::new();
1952 s.tick_at(&press(KeyCode::Char('w')), clk.next());
1953 assert_eq!(s.cursor().column, 4);
1954 s.tick_at(&press(KeyCode::Char('w')), clk.next());
1955 assert_eq!(s.cursor().column, 8);
1956 }
1957
1958 #[test]
1961 fn leader_sequence_holds_then_resolves() {
1962 let mut s = new_state_with("a\nbb\nccc");
1963 s.keymap.bind_sequence(
1964 Mode::Normal,
1965 vec![Key::Char(','), Key::Char('g')],
1966 Action::Move(Motion::DocEnd),
1967 "doc end",
1968 );
1969 s.on_key(&Key::Char(','));
1971 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
1972 assert_eq!(s.cursor(), Position::ZERO);
1973 s.on_key(&Key::Char('g'));
1975 assert!(s.pending_keys.is_empty());
1976 assert_eq!(s.cursor().line, 2);
1977 }
1978
1979 #[test]
1980 fn two_key_gg_jumps_doc_start() {
1981 let mut s = new_state_with("a\nbb\nccc");
1982 s.keymap.bind_sequence(
1983 Mode::Normal,
1984 vec![Key::Char('g'), Key::Char('g')],
1985 Action::Move(Motion::DocStart),
1986 "doc start",
1987 );
1988 let mut clk = SpacedClock::new();
1989 s.tick_at(&press(KeyCode::Char('j')), clk.next());
1990 s.tick_at(&press(KeyCode::Char('j')), clk.next());
1991 assert_eq!(s.cursor().line, 2);
1992 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
1994 s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
1996 }
1997
1998 #[test]
1999 fn broken_sequence_aborts_and_clears_pending() {
2000 let mut s = new_state_with("hello");
2001 s.keymap.bind_sequence(
2002 Mode::Normal,
2003 vec![Key::Char('g'), Key::Char('g')],
2004 Action::Move(Motion::DocEnd),
2005 "doc end",
2006 );
2007 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2009 s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
2011 assert_eq!(s.cursor(), Position::ZERO);
2012 }
2013
2014 #[test]
2015 fn single_binding_wins_over_sequence_prefix() {
2016 let mut s = new_state_with("abcde");
2020 let mut clk = SpacedClock::new();
2021 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2022 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2023 assert_eq!(s.cursor().column, 2);
2024 s.keymap.bind_sequence(
2025 Mode::Normal,
2026 vec![Key::Char('h'), Key::Char('z')],
2027 Action::Move(Motion::DocEnd),
2028 "shadowed",
2029 );
2030 s.on_key(&Key::Char('h'));
2031 assert!(s.pending_keys.is_empty(), "single binding should not pend");
2032 assert_eq!(s.cursor().column, 1, "h moved left immediately");
2033 }
2034
2035 #[test]
2038 fn lisp_set_option_writes_live_options() {
2039 let mut s = new_state_with("");
2040 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2041 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2042 }
2043
2044 #[test]
2045 fn lisp_insert_modifies_buffer_and_advances_cursor() {
2046 let mut s = new_state_with("");
2047 s.run_lisp(r#"(insert "abc")"#).unwrap();
2048 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2049 assert_eq!(s.cursor(), Position::new(0, 3));
2050 }
2051
2052 #[test]
2053 fn lisp_message_appends_to_messages() {
2054 let mut s = new_state_with("");
2055 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2056 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2057 }
2058
2059 #[test]
2060 fn lisp_reads_snapshot_and_branches_to_effect() {
2061 let mut s = new_state_with("one\ntwo\nthree");
2064 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2066 .unwrap();
2067 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2068 }
2069
2070 #[test]
2071 fn lisp_run_command_effect_drives_registry() {
2072 let mut s = new_state_with("");
2076 s.run_lisp(r#"(insert "abc")"#).unwrap();
2077 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2078 s.run_lisp(r#"(run-command "undo")"#).unwrap();
2079 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2080 }
2081
2082 #[test]
2083 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2084 let mut s = new_state_with("");
2089 s.run_lisp(r#"(run-command "quit")"#).unwrap();
2090 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2091 assert_eq!(
2092 s.modal.minibuffer(),
2093 "",
2094 "quit must not pollute any command line — Normal mode has no minibuffer",
2095 );
2096 }
2097
2098 #[test]
2101 fn lazy_plugin_activates_on_command_trigger() {
2102 let mut s = new_state_with("");
2106 s.register_lazy_plugin(
2107 "user-lazy",
2108 vec![LazyTrigger::Command("LazyGo".into())],
2109 r#"(defoption :name "lazy-loaded" :value "yes")
2110 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
2111 );
2112 assert_eq!(s.plugin_host.pending(), 1);
2113 assert!(
2114 s.options.get("lazy-loaded").is_none(),
2115 "entry not applied yet"
2116 );
2117
2118 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
2120
2121 assert_eq!(
2122 s.options.get("lazy-loaded").map(String::as_str),
2123 Some("yes"),
2124 "the command trigger applied the plugin's entry",
2125 );
2126 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
2127 }
2128
2129 #[test]
2130 fn lazy_plugin_activates_on_filetype() {
2131 let mut s = new_state_with("");
2132 s.register_lazy_plugin(
2133 "user-rust",
2134 vec![LazyTrigger::FileType("rust".into())],
2135 r#"(defoption :name "rust-plugin" :value "on")"#,
2136 );
2137 let n = s.activate_filetype_plugins("rust");
2138 assert_eq!(n, 1);
2139 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
2140 assert_eq!(s.activate_filetype_plugins("rust"), 0);
2142 }
2143
2144 #[test]
2145 fn cached_vm_serves_multiple_run_lisp_calls() {
2146 let mut s = new_state_with("");
2147 s.run_lisp(r#"(message "one")"#).unwrap();
2148 assert!(
2149 s.lisp_vm.is_some(),
2150 "VM should be cached after first run_lisp"
2151 );
2152 s.run_lisp(r#"(message "two")"#).unwrap();
2153 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
2154 }
2155
2156 #[test]
2157 fn lisp_define_persists_across_run_lisp_calls() {
2158 let mut s = new_state_with("");
2161 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
2162 s.run_lisp(r#"(message greeting)"#).unwrap();
2163 assert_eq!(s.messages, vec!["hi".to_string()]);
2164 }
2165
2166 #[test]
2167 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
2168 let mut s = new_state_with("");
2172 s.run_lisp(
2173 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
2174 )
2175 .unwrap();
2176 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
2177 assert_eq!(
2178 s.options.get("col").map(String::as_str),
2179 Some("stale-zero"),
2180 "cursor-column within the same call reads the pre-eval snapshot",
2181 );
2182 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
2185 .unwrap();
2186 assert_eq!(
2187 s.options.get("col2").map(String::as_str),
2188 Some("live-two"),
2189 "a later call sees the refreshed snapshot",
2190 );
2191 }
2192
2193 #[test]
2194 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
2195 let mut s = new_state_with("");
2196 s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
2197 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
2198 assert_eq!(s.cursor(), Position::new(1, 3));
2199 }
2200
2201 #[test]
2202 fn visual_mode_sequence_resolves() {
2203 let mut s = new_state_with("abc");
2204 s.modal.enter(Mode::Visual);
2205 s.keymap.bind_sequence(
2206 Mode::Visual,
2207 vec![Key::Char('g'), Key::Char('e')],
2208 Action::Move(Motion::DocEnd),
2209 "ge",
2210 );
2211 s.on_key(&Key::Char('g'));
2212 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2213 s.on_key(&Key::Char('e'));
2214 assert!(s.pending_keys.is_empty());
2215 assert_eq!(
2216 s.cursor().column,
2217 3,
2218 "ge resolved to doc-end in visual mode"
2219 );
2220 }
2221
2222 #[test]
2223 fn sequence_abort_with_bound_breaking_key_redispatches() {
2224 let mut s = new_state_with("abcde");
2227 s.keymap.bind_sequence(
2228 Mode::Normal,
2229 vec![Key::Char('g'), Key::Char('g')],
2230 Action::Move(Motion::DocEnd),
2231 "gg",
2232 );
2233 s.on_key(&Key::Char('g'));
2234 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2235 s.on_key(&Key::Char('l'));
2236 assert!(s.pending_keys.is_empty());
2237 assert_eq!(
2238 s.cursor().column,
2239 1,
2240 "the breaking key l should re-dispatch as move-right",
2241 );
2242 }
2243
2244 #[test]
2247 fn viewport_contains_cursor_after_every_op() {
2248 let mut s = new_state_small_viewport("", 5, 10);
2252 assert_cursor_in_viewport(&s, "initial");
2253
2254 s.tick(&press(KeyCode::Char('i')));
2257 assert_eq!(s.modal.mode(), Mode::Insert);
2258 for line in 0..30u32 {
2259 for c in "line".chars() {
2260 s.tick(&press(KeyCode::Char(c)));
2261 assert_cursor_in_viewport(&s, "typing chars");
2262 }
2263 s.tick(&press(KeyCode::Enter));
2264 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
2265 }
2266
2267 for i in 0..200u32 {
2270 s.tick(&press(KeyCode::Char('x')));
2271 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
2272 }
2273
2274 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
2276 assert_cursor_in_viewport(&s, "insert_text multiline");
2277
2278 s.tick(&press(KeyCode::Escape));
2280 assert_eq!(s.modal.mode(), Mode::Normal);
2281 for m in [
2282 Motion::DocStart,
2283 Motion::DocEnd,
2284 Motion::Down,
2285 Motion::Down,
2286 Motion::Up,
2287 Motion::Right,
2288 Motion::Right,
2289 Motion::Left,
2290 Motion::LineEnd,
2291 Motion::LineStart,
2292 Motion::GotoLine(1),
2293 Motion::GotoLine(40),
2294 Motion::PageDown,
2295 Motion::PageUp,
2296 ] {
2297 s.apply_motion(m);
2298 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
2299 }
2300
2301 for i in 0..50u32 {
2304 s.apply(&Action::Undo);
2305 assert_cursor_in_viewport(&s, &format!("undo {i}"));
2306 }
2307 for i in 0..50u32 {
2309 s.apply(&Action::Redo);
2310 assert_cursor_in_viewport(&s, &format!("redo {i}"));
2311 }
2312 }
2313
2314 #[test]
2315 fn insert_at_eof_keeps_cursor_in_bounds() {
2316 let mut s = new_state_small_viewport("abc", 5, 10);
2319 s.apply_motion(Motion::DocEnd);
2320 s.tick(&press(KeyCode::Char('i')));
2321 s.tick(&press(KeyCode::Char('d')));
2322 let buf = s.buffers.get(s.active).unwrap();
2323 let clamped = buf.clamp(s.cursor());
2324 assert_eq!(
2325 s.cursor(),
2326 clamped,
2327 "cursor must be clamped in-bounds at EOF"
2328 );
2329 assert_cursor_in_viewport(&s, "insert at eof");
2330 }
2331
2332 #[test]
2333 fn count_prefix_then_sequence_repeats() {
2334 let mut s = new_state_with("a\nb\nc\nd\ne");
2336 s.keymap.bind_sequence(
2337 Mode::Normal,
2338 vec![Key::Char('g'), Key::Char('j')],
2339 Action::Move(Motion::Down),
2340 "gj",
2341 );
2342 s.on_key(&Key::Char('2'));
2343 s.on_key(&Key::Char('g'));
2344 s.on_key(&Key::Char('j'));
2345 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
2346 }
2347
2348 #[test]
2351 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
2352 let mut s = new_state_with(&"x\n".repeat(40));
2358 let t0 = std::time::Instant::now();
2359 let mut delivered = 0u32;
2360 for i in 0..20u32 {
2361 let before = s.cursor().line;
2362 s.tick_at(
2363 &press(KeyCode::Char('j')),
2364 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
2365 );
2366 if s.cursor().line != before {
2367 delivered += 1;
2368 }
2369 }
2370 assert!(
2373 (10..=14).contains(&delivered),
2374 "expected the storm debounced to ~13 moves, got {delivered}",
2375 );
2376 assert!(
2377 delivered < 20,
2378 "the gate must drop SOME storm ticks, not pass all 20",
2379 );
2380 }
2381
2382 #[test]
2383 fn spaced_intentional_taps_all_pass() {
2384 let mut s = new_state_with(&"x\n".repeat(10));
2387 let t0 = std::time::Instant::now();
2388 for i in 0..5u32 {
2389 s.tick_at(
2390 &press(KeyCode::Char('j')),
2391 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2393 );
2394 }
2395 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2396 }
2397
2398 #[test]
2399 fn distinct_keys_have_independent_clocks() {
2400 let mut s = new_state_with("abc\ndef\nghi");
2403 let t = std::time::Instant::now();
2404 s.tick_at(&press(KeyCode::Char('j')), t);
2405 s.tick_at(
2407 &press(KeyCode::Char('j')),
2408 t + std::time::Duration::from_millis(10),
2409 );
2410 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2411 s.tick_at(
2413 &press(KeyCode::Char('l')),
2414 t + std::time::Duration::from_millis(10),
2415 );
2416 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2417 }
2418
2419 #[test]
2422 fn cursor_home_preserves_single_cursor_behavior() {
2423 let mut s = new_state_with("hello\nworld\nthere");
2428 assert_eq!(s.cursor(), Position::ZERO);
2429 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2430
2431 s.apply_motion(Motion::Down);
2432 s.apply_motion(Motion::Right);
2433 s.apply_motion(Motion::Right);
2434 assert_eq!(s.cursor(), Position::new(1, 2));
2435 assert_eq!(s.cursors.count(), 1);
2437
2438 let w = s.layout.active_window().unwrap();
2440 assert!(w.viewport.top_line <= s.cursor().line);
2441 }
2442
2443 #[test]
2444 fn insert_mode_is_ungated_so_repeat_typing_works() {
2445 let mut s = new_state_with("");
2449 s.tick(&press(KeyCode::Char('i')));
2450 assert_eq!(s.modal.mode(), Mode::Insert);
2451 let t = std::time::Instant::now();
2452 for _ in 0..10 {
2453 s.tick_at(&press(KeyCode::Char('x')), t);
2454 }
2455 assert_eq!(
2456 s.buffers.get(s.active).unwrap().to_string(),
2457 "xxxxxxxxxx",
2458 "insert-mode repeat typing is ungated",
2459 );
2460 }
2461}