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_buffer::TextRev;
24use escriba_command::{CommandRegistry, EditContext};
25use escriba_core::{
26 Action, Anchored, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList, Mode,
27 Motion, Operator, Position, Range, TextEffect, WindowId,
28};
29use escriba_input::{InputOutcome, translate_app_event};
30use escriba_keymap::{Key, Keymap};
31use escriba_mode::ModalState;
32use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
33use escriba_ui::{Layout, Rect, Viewport, Window};
34use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, HostEffect, VmError};
35use madori::AppEvent;
36use std::time::Instant;
37
38pub struct EditorState {
41 pub buffers: BufferSet,
42 pub modal: ModalState,
43 pub search: SearchState,
47 pub keymap: Keymap,
48 pub commands: CommandRegistry,
49 pub layout: Layout,
50 pub active: BufferId,
51 cursors: Cursors,
56 pub quit_requested: bool,
57 pub messages: Vec<String>,
60 search_at: Option<Anchored<usize, TextRev>>,
69 pub jumps: JumpList,
73 pub options: HashMap<String, String>,
77 lisp_vm: Option<EscribaVm>,
83 pub pending_keys: Vec<Key>,
89 repeat_gate: KeyRepeatGate<Key>,
97 pub plugin_host: PluginHost,
102 register: Option<String>,
107 op_pending: zenmai::Stateful<OperatorPending>,
112 edit_gen: EditGen,
117 damage: Damage,
122}
123
124enum SeqStep {
126 Pending,
128 Resolved(Action),
130 Passthrough,
132}
133
134const fn is_discrete_jump(key: &Key) -> bool {
141 matches!(
142 key,
143 Key::Char('n')
144 | Key::Char('N')
145 | Key::Char('*')
146 | Key::Char('#')
147 | Key::Ctrl('o')
148 | Key::Ctrl('i')
149 )
150}
151
152impl EditorState {
153 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
155 let window = Window {
156 id: WindowId(1),
157 buffer_id: active,
158 viewport: Viewport {
159 top_line: 0,
160 left_column: 0,
161 visible_lines: 40,
162 visible_columns: 160,
163 },
164 rect: Rect {
165 x: 0,
166 y: 0,
167 width: 1200,
168 height: 800,
169 },
170 };
171 Self {
172 buffers: initial,
173 modal: ModalState::new(),
174 search: SearchState::new(escriba_search::CaseMode::Smart),
175 search_at: None,
176 jumps: JumpList::new(),
177 keymap: Keymap::default_vim(),
178 commands: CommandRegistry::default_set(),
179 layout: Layout::single(window),
180 active,
181 cursors: Cursors::single(Position::ZERO),
182 quit_requested: false,
183 register: None,
184 op_pending: zenmai::Stateful::new(OpState::Resting),
185 messages: Vec::new(),
186 options: HashMap::new(),
187 lisp_vm: None,
188 pending_keys: Vec::new(),
189 repeat_gate: KeyRepeatGate::new(),
190 plugin_host: PluginHost::default(),
191 edit_gen: EditGen::default(),
192 damage: Damage::None,
193 }
194 }
195
196 #[must_use]
200 pub fn edit_gen(&self) -> EditGen {
201 self.edit_gen
202 }
203
204 fn bump_gen(&mut self) {
206 self.edit_gen = self.edit_gen.next();
207 }
208
209 #[must_use]
211 pub fn damage(&self) -> Damage {
212 self.damage
213 }
214
215 pub fn take_damage(&mut self) -> Damage {
219 std::mem::replace(&mut self.damage, Damage::None)
220 }
221
222 fn active_line_count(&self) -> u32 {
225 self.buffers
226 .get(self.active)
227 .map_or(0, escriba_buffer::Buffer::line_count)
228 }
229
230 pub fn register_lazy_plugin(
236 &mut self,
237 name: impl Into<String>,
238 triggers: Vec<LazyTrigger>,
239 entry_src: impl Into<String>,
240 ) {
241 self.plugin_host.register(name, triggers, entry_src);
242 }
243
244 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
250 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
251 return 0;
252 };
253 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
254 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
255 if let Some(value) = self.options.get("mapleader") {
256 if let Some(key) = escriba_lisp::parse_leader_key(value) {
257 self.keymap.set_leader(key);
258 }
259 }
260 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
261 (cmd.registered + km.keybinds_applied) as usize
262 }
263
264 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
268 let pending = self.plugin_host.pending_for_filetype(filetype);
269 let n = pending.len();
270 for src in pending {
271 self.apply_plugin_entry(&src);
272 }
273 n
274 }
275
276 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
279 let pending = self.plugin_host.pending_for_event(event);
280 let n = pending.len();
281 for src in pending {
282 self.apply_plugin_entry(&src);
283 }
284 n
285 }
286
287 pub fn tick(&mut self, event: &AppEvent) {
292 self.tick_at(event, Instant::now());
293 }
294
295 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
299 match translate_app_event(event) {
300 InputOutcome::Key(k) => {
301 if self.gate_key(&k, now) {
302 self.on_key(&k);
303 }
304 }
305 InputOutcome::Resized { width, height } => {
306 if let Some(w) = self
307 .layout
308 .windows
309 .iter_mut()
310 .find(|w| w.id == self.layout.active)
311 {
312 w.rect.width = width;
313 w.rect.height = height;
314 }
315 self.damage = self.damage.join(Damage::Viewport);
316 self.bump_gen();
317 }
318 InputOutcome::Quit => self.quit_requested = true,
319 InputOutcome::Focus(_) | InputOutcome::None => {}
320 }
321 }
322
323 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
333 match self.modal.mode() {
334 Mode::Normal | Mode::Visual | Mode::VisualLine => {
335 if is_discrete_jump(key) {
341 return true;
342 }
343 self.repeat_gate.try_pass_at(*key, now)
344 }
345 Mode::Insert | Mode::Command => true,
346 }
347 }
348
349 pub fn on_key(&mut self, key: &Key) {
351 match self.step_sequence(key) {
355 SeqStep::Pending => return,
356 SeqStep::Resolved(action) => {
357 let count = self.modal.pending_count().unwrap_or(1);
358 self.modal.clear_count();
359 for _ in 0..count {
360 self.apply(&action);
361 if self.quit_requested {
362 return;
363 }
364 }
365 return;
366 }
367 SeqStep::Passthrough => {}
368 }
369 let counted = self.keymap.dispatch(&self.modal, key);
370 if matches!(counted.action, Action::Pending) {
372 if let Key::Char(c) = key {
373 if c.is_ascii_digit() {
374 let d = u32::from(*c as u8 - b'0');
375 self.modal.append_count(d);
376 }
377 }
378 return;
379 }
380 self.apply_counted(&counted.action, counted.count);
384 self.modal.clear_count();
386 }
387
388 fn step_sequence(&mut self, key: &Key) -> SeqStep {
400 let mode = self.modal.mode();
401 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
402 return SeqStep::Passthrough;
403 }
404 if !self.pending_keys.is_empty() {
405 let mut seq = self.pending_keys.clone();
406 seq.push(key.clone());
407 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
408 let action = b.action.clone();
409 self.pending_keys.clear();
410 return SeqStep::Resolved(action);
411 }
412 if self.keymap.is_sequence_prefix(mode, &seq) {
413 self.pending_keys = seq;
414 return SeqStep::Pending;
415 }
416 self.pending_keys.clear();
419 }
420 let start = [key.clone()];
421 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
422 self.pending_keys = start.to_vec();
423 return SeqStep::Pending;
424 }
425 SeqStep::Passthrough
426 }
427
428 #[must_use]
433 pub fn cursor(&self) -> Position {
434 self.cursors.primary()
435 }
436
437 fn set_cursor(&mut self, pos: Position) {
446 let clamped = if let Some(buf) = self.buffers.get(self.active) {
447 buf.clamp(pos)
448 } else {
449 pos
450 };
451 self.cursors.set_primary(clamped);
452 if let Some(w) = self
453 .layout
454 .windows
455 .iter_mut()
456 .find(|w| w.id == self.layout.active)
457 {
458 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
459 }
460 }
461
462 fn apply(&mut self, action: &Action) {
464 self.apply_counted(action, 1);
465 }
466
467 fn apply_counted(&mut self, action: &Action, count: u32) {
475 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
476 for _ in 0..times {
477 self.apply_resolved(&resolved);
478 if self.quit_requested {
479 return;
480 }
481 }
482 }
483 }
484
485 #[must_use]
489 fn text_rev(&self) -> TextRev {
490 self.buffers
491 .get(self.active)
492 .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
493 }
494
495 fn active_text(&self) -> String {
496 self.buffers
497 .get(self.active)
498 .map(escriba_buffer::Buffer::to_string)
499 .unwrap_or_default()
500 }
501
502 fn cursor_char(&self) -> usize {
504 self.buffers
505 .get(self.active)
506 .and_then(|b| b.position_to_char(self.cursor()).ok())
507 .unwrap_or(0)
508 }
509
510 #[must_use]
518 pub fn status_model(&self) -> StatusModel<'_> {
519 let cursor = self.cursor();
520 let prompt = self.search.prompt();
521
522 let kind = match prompt.map(|p| p.direction) {
523 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
524 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
525 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
528 None => PromptKind::None,
529 };
530
531 StatusModel {
532 mode: self.modal.mode(),
533 line: cursor.line.saturating_add(1) as usize,
534 column: cursor.column.saturating_add(1) as usize,
535 prompt: kind,
536 prompt_text: prompt.map_or_else(|| self.modal.minibuffer(), |p| p.text.as_str()),
537 prompt_caret: prompt.map_or_else(
542 || self.modal.minibuffer().chars().count(),
543 escriba_search::Prompt::caret,
544 ),
545 count: self.match_count(),
546 message: self.messages.last().map(String::as_str),
547 }
548 }
549
550 #[must_use]
556 fn match_count(&self) -> MatchCount {
557 if self.search.is_prompting() {
558 let text = self.active_text();
559 let total = self.search.preview_total(&text);
560 return match self.search.preview(&text) {
561 Some(step) => MatchCount::new(step.index, total),
562 None if total == 0 && !self.search.prompt_is_empty() => MatchCount::None,
566 None => MatchCount::Idle,
567 };
568 }
569 if self.search.pattern().is_none() {
570 return MatchCount::Idle;
571 }
572 let total = self.search.matches().len();
573 let rev = self.text_rev();
576 self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
577 if total == 0 {
578 MatchCount::None
579 } else {
580 MatchCount::Idle
581 },
582 |&i| MatchCount::new(i, total),
583 )
584 }
585
586 fn land_on(&mut self, step: escriba_search::Step) {
587 if let Some(buf) = self.buffers.get(self.active) {
588 let pos = buf.char_to_position(step.target.start);
589 self.set_cursor(pos);
590 }
591 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
595 if let Some(msg) = step.wrapped.message() {
596 self.messages.push(msg.to_string());
597 }
598 }
599
600 fn jump_search(&mut self, reverse: bool) {
604 self.search.relight();
607 self.jumps.push(self.cursor());
609 let at = self.cursor_char();
610 match self.search.repeat(at, reverse) {
611 Some(step) => self.land_on(step),
612 None => {
613 let msg = self.search.pattern().map_or_else(
614 || "E35: No previous regular expression".to_string(),
615 |p| {
616 let mut m = String::from("E486: Pattern not found: ");
617 m.push_str(p.raw());
618 m
619 },
620 );
621 self.messages.push(msg);
622 }
623 }
624 }
625
626 fn preview_search(&mut self) {
633 let text = self.active_text();
634 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
635 return;
636 };
637 let target = self
638 .search
639 .preview(&text)
640 .map_or(origin, |s| s.target.start);
641 if let Some(buf) = self.buffers.get(self.active) {
650 let pos = buf.char_to_position(target);
651 self.set_cursor(pos);
652 }
653 }
654
655 fn submit_search_operated(&mut self, op: Operator) {
664 let text = self.active_text();
665 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
666 return;
667 };
668
669 match self.search.accept(&text) {
670 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
671 self.modal.clear_minibuffer();
672 self.modal.enter(Mode::Normal);
673 match self.search.commit_step(origin) {
674 Some(step) => {
675 if let Some(buf) = self.buffers.get(self.active) {
677 let from = buf.char_to_position(origin);
678 self.jumps.push(from);
679 let target = buf.char_to_position(step.target.start);
680 self.set_cursor(from);
681 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
682 self.apply_operator_to(op, target);
683 }
684 }
685 None => self.report_pattern_not_found(),
686 }
687 }
688 escriba_search::Accepted::NothingToRepeat => {
689 self.modal.clear_minibuffer();
690 self.modal.enter(Mode::Normal);
691 self.messages
692 .push("E35: No previous regular expression".to_string());
693 }
694 escriba_search::Accepted::Invalid(e) => {
695 let mut m = String::from("E383: Invalid search string: ");
696 m.push_str(&e.to_string());
697 self.messages.push(m);
698 }
699 }
700 }
701
702 fn report_pattern_not_found(&mut self) {
705 let mut m = String::from("E486: Pattern not found");
706 if let Some(p) = self.search.pattern() {
707 m.push_str(": ");
708 m.push_str(p.raw());
709 }
710 self.messages.push(m);
711 }
712
713 fn submit_search(&mut self) {
715 let text = self.active_text();
716 let at = self
731 .search
732 .prompt()
733 .map_or_else(|| self.cursor_char(), |p| p.origin);
734 let outcome = self.search.accept(&text);
735 match outcome {
736 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
737 self.modal.clear_minibuffer();
738 self.modal.enter(Mode::Normal);
739 if let Some(buf) = self.buffers.get(self.active) {
744 let origin = buf.char_to_position(at);
745 self.jumps.push(origin);
746 }
747 match self.search.commit_step(at) {
748 Some(step) => self.land_on(step),
749 None => {
750 let mut m = String::from("E486: Pattern not found");
751 if let Some(p) = self.search.pattern() {
752 m.push_str(": ");
753 m.push_str(p.raw());
754 }
755 self.messages.push(m);
756 }
757 }
758 }
759 escriba_search::Accepted::NothingToRepeat => {
760 self.modal.clear_minibuffer();
761 self.modal.enter(Mode::Normal);
762 self.messages
763 .push("E35: No previous regular expression".to_string());
764 }
765 escriba_search::Accepted::Invalid(e) => {
768 let mut m = String::from("E383: Invalid search string: ");
769 m.push_str(&e.to_string());
770 self.messages.push(m);
771 }
772 }
773 }
774
775 fn apply_resolved(&mut self, action: &Action) {
776 let lines_before = self.active_line_count();
779 let cline_before = self.cursor().line;
780 match action {
781 Action::Move(m) => self.apply_motion(*m),
782 Action::SearchOpen(dir) => {
783 let origin = self.cursor_char();
787 self.search.open(*dir, origin);
788 self.modal.enter(Mode::Command);
789 }
790 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
791 Action::SearchWord { reverse } => {
792 let dir = if *reverse {
793 SearchDirection::Backward
794 } else {
795 SearchDirection::Forward
796 };
797 let (text, at) = (self.active_text(), self.cursor_char());
798 self.jumps.push(self.cursor());
800 match self.search.search_word(&text, at, dir) {
801 Some(step) => self.land_on(step),
802 None => self
805 .messages
806 .push("E348: No string under cursor".to_string()),
807 }
808 }
809 Action::ClearSearchHighlight => self.search.clear_highlight(),
810 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
811 Action::JumpBack => {
812 let here = self.cursor();
813 if let Some(pos) = self.jumps.back(here) {
814 self.set_cursor(pos);
815 } else {
816 self.messages
817 .push("E662: At start of changelist".to_string());
818 }
819 }
820 Action::JumpForward => {
821 if let Some(pos) = self.jumps.forward() {
822 self.set_cursor(pos);
823 } else {
824 self.messages.push("E663: At end of changelist".to_string());
825 }
826 }
827 Action::ChangeMode(m) => {
828 if *m == Mode::Normal && self.search.is_prompting() {
832 if let Some(origin) = self.search.cancel() {
833 if let Some(buf) = self.buffers.get(self.active) {
834 let pos = buf.char_to_position(origin);
835 self.set_cursor(pos);
836 }
837 }
838 }
839 self.modal.enter(*m);
840 }
841 Action::InsertChar(c) => self.insert_char(*c),
842 Action::Edit(edit) => self.apply_edit(edit),
843 Action::Undo => {
844 if let Some(buf) = self.buffers.get_mut(self.active) {
845 let _ = buf.undo();
846 }
847 self.set_cursor(self.cursor());
850 }
851 Action::Redo => {
852 if let Some(buf) = self.buffers.get_mut(self.active) {
853 let _ = buf.redo();
854 }
855 self.set_cursor(self.cursor());
856 }
857 Action::Save => {
858 if let Some(buf) = self.buffers.get_mut(self.active) {
859 let _ = buf.save();
860 }
861 self.set_cursor(self.cursor());
862 }
863 Action::Quit => self.quit_requested = true,
864 Action::SubmitCommand => {
865 if self.search.is_prompting() {
866 self.submit_search();
867 } else {
868 self.submit_command();
869 }
870 }
871 Action::Command { name, args } => self.run_command(name, args),
872 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
873 Action::Operator(_) => {}
876 Action::PromptCaret { to } => {
877 if self.search.is_prompting() {
878 self.search.move_caret(*to);
879 }
880 }
881 Action::PromptDelete => {
882 if self.search.is_prompting() {
883 self.search.delete_at_caret();
884 self.preview_search();
885 }
886 }
887 Action::PromptDeleteWord => {
888 if self.search.is_prompting() {
889 self.search.delete_word_before_caret();
890 self.preview_search();
891 }
892 }
893 Action::PromptClearToStart => {
894 if self.search.is_prompting() {
895 self.search.clear_before_caret();
896 self.preview_search();
897 }
898 }
899 Action::PromptBackspace => {
900 self.prompt_backspace();
901 if self.search.is_prompting() {
905 self.preview_search();
906 }
907 }
908 Action::PromptHistory { back } => {
909 if self.search.is_prompting() {
910 self.search.history_step(*back);
911 self.modal.clear_minibuffer();
915 if let Some(text) = self.search.prompt().map(|p| p.text.clone()) {
916 self.modal.push_minibuffer_str(&text);
917 }
918 self.preview_search();
919 }
920 }
921 Action::Pending => {}
922 }
923 let lines_after = self.active_line_count();
928 let cline_after = self.cursor().line;
929 let d = match action {
930 Action::SearchOpen(_)
934 | Action::PromptHistory { .. }
935 | Action::PromptBackspace
936 | Action::PromptCaret { .. }
937 | Action::PromptDelete
938 | Action::PromptDeleteWord
939 | Action::PromptClearToStart
940 | Action::SearchRepeat { .. }
941 | Action::SearchWord { .. }
942 | Action::ClearSearchHighlight
943 | Action::SearchSubmitOperated { .. }
944 | Action::JumpBack
946 | Action::JumpForward => Damage::Full,
947 Action::InsertChar(_)
948 | Action::Edit(_)
949 | Action::Undo
950 | Action::Redo
951 | Action::ApplyOperator { .. } => {
952 if lines_after == lines_before {
953 Damage::span(cline_before, cline_after)
954 } else {
955 Damage::Lines {
956 from: cline_before.min(cline_after),
957 to: u32::MAX,
958 }
959 }
960 }
961 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
962 Action::Save => Damage::Viewport,
963 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
964 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
965 };
966 self.damage = self.damage.join(d);
967 if action.highlight_effect() == HighlightEffect::Clear {
972 self.search.clear_highlight();
973 }
974 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
983 let text = self.active_text();
984 self.search.refresh(&text);
985 }
990 self.bump_gen();
995 }
996
997 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
1007 let buf = self.buffers.get(self.active)?;
1008 let pos = from;
1009 Some(match motion {
1010 Motion::SearchNext | Motion::SearchPrev => {
1015 let at = buf.position_to_char(pos).ok()?;
1016 let step = self
1017 .search
1018 .repeat(at, matches!(motion, Motion::SearchPrev))?;
1019 buf.char_to_position(step.target.start)
1020 }
1021 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
1022 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
1023 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
1024 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
1025 Motion::LineStart => Position::new(pos.line, 0),
1026 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
1027 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
1028 Motion::DocStart => Position::ZERO,
1029 Motion::DocEnd => Position::new(
1030 buf.line_count().saturating_sub(1),
1031 buf.line_len_chars(buf.line_count().saturating_sub(1)),
1032 ),
1033 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
1034 Motion::WordStartPrev => word_prev(buf, pos),
1035 Motion::PageDown | Motion::HalfPageDown => {
1036 Position::new(pos.line.saturating_add(10), pos.column)
1037 }
1038 Motion::PageUp | Motion::HalfPageUp => {
1039 Position::new(pos.line.saturating_sub(10), pos.column)
1040 }
1041 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
1042 Motion::ForwardSexp
1045 | Motion::BackwardSexp
1046 | Motion::UpList
1047 | Motion::DownList
1048 | Motion::BeginningOfDefun
1049 | Motion::EndOfDefun
1050 | Motion::BeginningOfSexp
1051 | Motion::EndOfSexp => pos,
1052 })
1053 }
1054
1055 fn apply_motion(&mut self, motion: Motion) {
1056 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1064 self.jump_search(matches!(motion, Motion::SearchPrev));
1065 return;
1066 }
1067 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1068 return;
1069 };
1070 self.set_cursor(pos);
1073 }
1074
1075 fn apply_operator(&mut self, op: Operator, motion: Motion) {
1082 let from = self.cursor();
1083 let Some(to) = self.resolve_motion(from, motion) else {
1084 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1089 if self.search.pattern().is_none() {
1090 self.messages
1091 .push("E35: No previous regular expression".to_string());
1092 } else {
1093 self.report_pattern_not_found();
1094 }
1095 }
1096 return;
1097 };
1098 self.apply_operator_to(op, to);
1099 }
1100
1101 fn apply_operator_to(&mut self, op: Operator, to: Position) {
1108 let from = self.cursor();
1109 let range = Range {
1110 start: from,
1111 end: to,
1112 }
1113 .normalized();
1114 if range.is_empty() {
1115 return;
1116 }
1117 let text = self
1119 .buffers
1120 .get(self.active)
1121 .and_then(|buf| buf.slice(range).ok());
1122 if op.leaves_register() {
1123 if let Some(t) = &text {
1124 self.register = Some(t.clone());
1125 }
1126 }
1127 match op {
1128 Operator::Delete | Operator::Change => {
1131 if let Some(buf) = self.buffers.get_mut(self.active) {
1132 let _ = buf.apply(&Edit::delete(range));
1133 }
1134 self.set_cursor(range.start);
1135 if op == Operator::Change {
1136 self.modal.enter(Mode::Insert);
1137 }
1138 }
1139 Operator::Yank => {
1142 self.set_cursor(range.start);
1143 }
1144 _ => {
1148 self.messages
1149 .push("operator not yet implemented".to_owned());
1150 }
1151 }
1152 }
1153
1154 #[must_use]
1157 pub fn register(&self) -> Option<&str> {
1158 self.register.as_deref()
1159 }
1160
1161 fn insert_char(&mut self, c: char) {
1162 if self.modal.mode() == Mode::Command {
1163 if self.search.is_prompting() {
1167 self.search.push(c);
1168 self.modal.push_minibuffer(c);
1169 self.preview_search();
1170 } else {
1171 self.modal.push_minibuffer(c);
1172 }
1173 return;
1174 }
1175 let cursor = self.cursor();
1176 let Some(buf) = self.buffers.get_mut(self.active) else {
1177 return;
1178 };
1179 let edit = Edit::insert(cursor, c.to_string());
1180 if buf.apply(&edit).is_ok() {
1181 let next = if c == '\n' {
1182 Position::new(cursor.line.saturating_add(1), 0)
1183 } else {
1184 cursor.shift_right(1)
1185 };
1186 self.set_cursor(next);
1189 }
1190 }
1191
1192 fn prompt_backspace(&mut self) -> bool {
1196 if self.modal.mode() != Mode::Command {
1197 return false;
1198 }
1199 if self.search.is_prompting() {
1200 if self.search.backspace() {
1202 self.modal.clear_minibuffer();
1203 self.modal.enter(Mode::Normal);
1204 return true;
1205 }
1206 }
1207 self.modal.pop_minibuffer();
1208 true
1209 }
1210
1211 fn apply_edit(&mut self, _edit: &Edit) {
1212 }
1216
1217 fn submit_command(&mut self) {
1218 let line = self.modal.minibuffer().to_string();
1222 self.modal.escape();
1223 let (name, args) = parse_command_line(&line);
1224 if name.is_empty() {
1225 return;
1226 }
1227 self.run_command(&name, &args);
1228 }
1229
1230 fn run_command(&mut self, name: &str, args: &[String]) {
1231 if matches!(name, "noh" | "nohl" | "nohlsearch") {
1237 self.search.clear_highlight();
1238 return;
1239 }
1240 if self.plugin_host.pending() > 0 {
1246 let pending = self.plugin_host.pending_for_command(name);
1247 for src in pending {
1248 self.apply_plugin_entry(&src);
1249 }
1250 }
1251 let active = Some(self.active);
1252 let mut quit = false;
1253 {
1254 let mut ctx = EditContext {
1255 buffers: &mut self.buffers,
1256 active,
1257 state: &mut self.modal,
1258 quit_requested: &mut quit,
1259 };
1260 let _ = self.commands.run(name, &mut ctx, args);
1261 }
1262 if quit {
1265 self.quit_requested = true;
1266 }
1267 }
1268
1269 #[must_use]
1274 pub fn snapshot(&self) -> EditorSnapshot {
1275 let current_line = self
1276 .buffers
1277 .get(self.active)
1278 .and_then(|b| b.line(self.cursor().line))
1279 .map(|s| s.trim_end_matches('\n').to_string())
1280 .unwrap_or_default();
1281 let buffer_name = self
1282 .buffers
1283 .get(self.active)
1284 .and_then(|b| b.path.as_ref())
1285 .map(|p| p.display().to_string())
1286 .unwrap_or_else(|| "[scratch]".to_string());
1287 EditorSnapshot {
1288 cursor_line: i64::from(self.cursor().line),
1289 cursor_column: i64::from(self.cursor().column),
1290 current_line,
1291 mode: self.modal.mode().as_str().to_string(),
1292 buffer_name,
1293 }
1294 }
1295
1296 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
1312 let mut host = EscribaHost::with_snapshot(self.snapshot());
1313 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
1314 vm.eval(src, &mut host)?;
1315 let effects = host.take_effects();
1316 self.apply_host_effects(effects);
1317 Ok(())
1318 }
1319
1320 pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1324 for eff in effects {
1325 match eff {
1326 HostEffect::Message(m) => self.messages.push(m),
1327 HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
1328 HostEffect::SetOption { name, value } => {
1329 self.options.insert(name, value);
1330 }
1331 HostEffect::InsertText(text) => self.insert_text(&text),
1332 }
1333 }
1334 }
1335
1336 fn insert_text(&mut self, text: &str) {
1339 if text.is_empty() {
1340 return;
1341 }
1342 let cursor = self.cursor();
1343 let Some(buf) = self.buffers.get_mut(self.active) else {
1344 return;
1345 };
1346 let edit = Edit::insert(cursor, text.to_string());
1347 if buf.apply(&edit).is_ok() {
1348 let next = if let Some(nl) = text.rfind('\n') {
1349 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
1350 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
1351 Position::new(cursor.line + added_lines, last_line_len)
1352 } else {
1353 let n = u32::try_from(text.chars().count()).unwrap_or(0);
1354 cursor.shift_right(n)
1355 };
1356 self.set_cursor(next);
1359 }
1360 }
1361}
1362
1363fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1364 let Some(text) = buf.line(line) else {
1365 return Position::new(line, 0);
1366 };
1367 let col = text
1368 .chars()
1369 .take_while(|c| c.is_whitespace() && *c != '\n')
1370 .count();
1371 Position::new(line, u32::try_from(col).unwrap_or(0))
1372}
1373
1374fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1375 let Some(text) = buf.line(pos.line) else {
1376 return pos;
1377 };
1378 let chars: Vec<char> = text.chars().collect();
1379 let start = pos.column as usize;
1380 let mut i = start;
1381 while i < chars.len() && !chars[i].is_whitespace() {
1382 i += 1;
1383 }
1384 while i < chars.len() && chars[i].is_whitespace() {
1385 i += 1;
1386 }
1387 if i >= chars.len() {
1388 if pos.line + 1 < buf.line_count() {
1390 return Position::new(pos.line + 1, 0);
1391 }
1392 }
1393 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1394}
1395
1396fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1397 let Some(text) = buf.line(pos.line) else {
1398 return pos;
1399 };
1400 let chars: Vec<char> = text.chars().collect();
1401 let mut i = (pos.column as usize).min(chars.len());
1402 while i > 0 && chars[i - 1].is_whitespace() {
1403 i -= 1;
1404 }
1405 while i > 0 && !chars[i - 1].is_whitespace() {
1406 i -= 1;
1407 }
1408 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1409}
1410
1411fn parse_command_line(line: &str) -> (String, Vec<String>) {
1412 let mut parts = line.split_whitespace();
1413 let Some(first) = parts.next() else {
1414 return (String::new(), Vec::new());
1415 };
1416 let head = first.strip_prefix(':').unwrap_or(first);
1417 let name = match head {
1418 "w" => "save",
1419 "q" => "quit",
1420 "u" => "undo",
1421 other => other,
1422 };
1423 (name.to_string(), parts.map(str::to_string).collect())
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428 use super::*;
1429 use madori::event::{KeyCode, KeyEvent, Modifiers};
1430
1431 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1439 st.apply(&Action::SearchOpen(dir));
1440 for c in pat.chars() {
1441 st.apply(&Action::InsertChar(c));
1442 }
1443 st.apply(&Action::SubmitCommand);
1444 }
1445
1446 #[test]
1447 fn slash_search_moves_the_cursor_to_the_match() {
1448 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1449 type_search(&mut st, SearchDirection::Forward, "charlie");
1450 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1451 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1452 assert_eq!(st.search.matches().len(), 1);
1453 }
1454
1455 #[test]
1456 fn n_and_N_walk_matches_in_both_directions() {
1457 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1458 type_search(&mut st, SearchDirection::Forward, "foo");
1459 let first = st.cursor().line;
1460 st.apply(&Action::SearchRepeat { reverse: false });
1461 let second = st.cursor().line;
1462 assert!(second > first, "n advances ({first} -> {second})");
1463 st.apply(&Action::SearchRepeat { reverse: true });
1464 assert_eq!(st.cursor().line, first, "N comes back");
1465 }
1466
1467 #[test]
1468 fn star_searches_the_word_under_the_cursor() {
1469 let mut st = new_state_with("needle\nhaystack\nneedle\n");
1470 st.apply(&Action::SearchWord { reverse: false });
1471 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1472 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1473 }
1474
1475 #[test]
1476 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1477 let mut st = new_state_with("foo\nbar\nfoo\n");
1478 type_search(&mut st, SearchDirection::Forward, "foo");
1479 let matches_before = st.search.matches().len();
1480
1481 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1482 st.apply(&Action::InsertChar('z'));
1483 st.apply(&Action::ChangeMode(Mode::Normal));
1484
1485 assert!(!st.search.is_prompting(), "prompt gone");
1486 assert_eq!(
1487 st.search.pattern().unwrap().raw(),
1488 "foo",
1489 "old pattern survives"
1490 );
1491 assert_eq!(
1492 st.search.matches().len(),
1493 matches_before,
1494 "old highlights survive"
1495 );
1496 }
1497
1498 #[test]
1499 fn a_search_prompt_and_an_ex_command_are_not_confused() {
1500 let mut st = new_state_with("foo\n");
1501 st.apply(&Action::ChangeMode(Mode::Command));
1503 assert!(!st.search.is_prompting(), "`:` must not open a search");
1504 st.apply(&Action::InsertChar('w'));
1505 assert!(
1506 st.search.prompt().is_none(),
1507 "typed char went to the ex line"
1508 );
1509 }
1510
1511 #[test]
1512 fn a_missing_pattern_reports_instead_of_failing_silently() {
1513 let mut st = new_state_with("alpha\nbravo\n");
1514 type_search(&mut st, SearchDirection::Forward, "zzz");
1515 assert!(
1516 st.messages.iter().any(|m| m.contains("E486")),
1517 "must report not-found, got {:?}",
1518 st.messages
1519 );
1520 }
1521
1522 #[test]
1523 fn n_without_any_search_reports_rather_than_moving() {
1524 let mut st = new_state_with("alpha\nbravo\n");
1525 let before = st.cursor();
1526 st.apply(&Action::SearchRepeat { reverse: false });
1527 assert_eq!(st.cursor(), before, "cursor must not move");
1528 assert!(
1529 st.messages.iter().any(|m| m.contains("E35")),
1530 "got {:?}",
1531 st.messages
1532 );
1533 }
1534
1535 #[test]
1536 fn search_as_a_motion_composes_with_an_operator() {
1537 let mut st = new_state_with("alpha bravo charlie\n");
1539 type_search(&mut st, SearchDirection::Forward, "charlie");
1540 st.set_cursor(Position::new(0, 0));
1541 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1542 assert!(target.is_some(), "search must resolve as a motion");
1543 assert_eq!(target.unwrap().column, 12, "at `charlie`");
1544 }
1545
1546 #[test]
1547 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1548 let st = new_state_with("alpha bravo\n");
1551 assert!(
1552 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
1553 .is_none()
1554 );
1555 }
1556
1557 #[test]
1558 fn clear_highlight_keeps_the_pattern_usable() {
1559 let mut st = new_state_with("foo\nbar\nfoo\n");
1560 type_search(&mut st, SearchDirection::Forward, "foo");
1561 st.apply(&Action::ClearSearchHighlight);
1562 assert!(st.search.highlights().is_empty(), "nothing lit");
1563 st.apply(&Action::SearchRepeat { reverse: false });
1564 assert!(st.search.pattern().is_some(), "but n still works");
1565 }
1566
1567 #[test]
1568 fn typing_previews_incrementally_before_commit() {
1569 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1570 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1571 for c in "charlie".chars() {
1572 st.apply(&Action::InsertChar(c));
1573 }
1574 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1576 assert!(st.search.pattern().is_none(), "but nothing is committed");
1577 }
1578
1579 #[test]
1580 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1581 let mut st = new_state_with("alpha\nbravo\n");
1582 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1583 for c in "bravox".chars() {
1584 st.apply(&Action::InsertChar(c));
1585 }
1586 assert_eq!(st.search.prompt().unwrap().text, "bravox");
1587 st.apply(&Action::PromptBackspace);
1588 assert_eq!(st.search.prompt().unwrap().text, "bravo", "typo corrected");
1589 assert_eq!(st.modal.minibuffer(), "bravo", "display stays in lockstep");
1590 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1591 }
1592
1593 #[test]
1594 fn backspacing_past_the_slash_closes_the_prompt() {
1595 let mut st = new_state_with("alpha\n");
1596 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1597 st.apply(&Action::InsertChar('a'));
1598 st.apply(&Action::PromptBackspace);
1599 st.apply(&Action::PromptBackspace);
1600 assert!(!st.search.is_prompting(), "prompt closed");
1601 assert_eq!(st.modal.mode(), Mode::Normal);
1602 }
1603
1604 #[test]
1605 fn noh_clears_highlights_and_keeps_the_pattern() {
1606 let mut st = new_state_with("foo\nbar\nfoo\n");
1607 type_search(&mut st, SearchDirection::Forward, "foo");
1608 assert!(!st.search.highlights().is_empty());
1609 st.run_command("noh", &[]);
1610 assert!(st.search.highlights().is_empty(), ":noh turns them off");
1611 assert!(st.search.pattern().is_some(), "but n still works");
1612 }
1613
1614 #[test]
1615 fn noh_accepts_the_vim_aliases() {
1616 for name in ["noh", "nohl", "nohlsearch"] {
1617 let mut st = new_state_with("foo\nfoo\n");
1618 type_search(&mut st, SearchDirection::Forward, "foo");
1619 st.run_command(name, &[]);
1620 assert!(st.search.highlights().is_empty(), "{name} must clear");
1621 }
1622 }
1623
1624 #[test]
1625 fn backspace_on_the_ex_line_does_not_touch_search_state() {
1626 let mut st = new_state_with("foo\n");
1627 st.apply(&Action::ChangeMode(Mode::Command));
1628 st.apply(&Action::InsertChar('w'));
1629 st.apply(&Action::InsertChar('q'));
1630 st.apply(&Action::PromptBackspace);
1631 assert_eq!(st.modal.minibuffer(), "w");
1632 assert!(st.search.prompt().is_none(), "no search was involved");
1633 }
1634
1635 #[test]
1636 fn up_arrow_recalls_the_previous_search() {
1637 let mut st = new_state_with("alpha\nbravo\n");
1638 type_search(&mut st, SearchDirection::Forward, "bravo");
1639 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1640 st.apply(&Action::PromptHistory { back: true });
1641 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1642 assert_eq!(st.modal.minibuffer(), "bravo", "display follows the prompt");
1643 }
1644
1645 #[test]
1646 fn arrowing_back_down_restores_the_half_typed_pattern() {
1647 let mut st = new_state_with("alpha\nbravo\n");
1648 type_search(&mut st, SearchDirection::Forward, "bravo");
1649 st.apply(&Action::SearchOpen(SearchDirection::Forward));
1650 st.apply(&Action::InsertChar('a'));
1651 st.apply(&Action::PromptHistory { back: true });
1652 assert_eq!(st.search.prompt().unwrap().text, "bravo");
1653 st.apply(&Action::PromptHistory { back: false });
1654 assert_eq!(
1655 st.search.prompt().unwrap().text,
1656 "a",
1657 "the draft comes back"
1658 );
1659 assert_eq!(st.modal.minibuffer(), "a");
1660 }
1661
1662 #[test]
1663 fn history_arrows_do_nothing_on_the_ex_line() {
1664 let mut st = new_state_with("alpha\n");
1665 st.apply(&Action::ChangeMode(Mode::Command));
1666 st.apply(&Action::InsertChar('w'));
1667 st.apply(&Action::PromptHistory { back: true });
1668 assert_eq!(st.modal.minibuffer(), "w", "ex line untouched");
1669 }
1670
1671 fn new_state_with(text: &str) -> EditorState {
1672 let mut bufs = BufferSet::new();
1673 let id = bufs.scratch(text);
1674 EditorState::new_with_buffer(bufs, id)
1675 }
1676
1677 #[test]
1683 fn edit_gen_advances_on_applied_action_not_on_read() {
1684 let mut s = new_state_with("hello\nworld\n");
1685 let g0 = s.edit_gen();
1686 s.apply(&Action::InsertChar('X'));
1687 assert_ne!(
1688 s.edit_gen(),
1689 g0,
1690 "an applied action must advance the refresh generation",
1691 );
1692 let g1 = s.edit_gen();
1694 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1695 }
1696
1697 #[test]
1702 fn damage_tracks_edit_scope_and_drains() {
1703 let mut s = new_state_with("hello\nworld\n");
1704 assert!(s.damage().is_none(), "a fresh state has no damage");
1705
1706 s.apply(&Action::InsertChar('X')); assert_eq!(
1708 s.damage(),
1709 Damage::Lines { from: 0, to: 0 },
1710 "a local edit damages just its line",
1711 );
1712
1713 let drained = s.take_damage();
1714 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
1715 assert!(s.damage().is_none(), "take_damage drains to None");
1716
1717 s.apply(&Action::InsertChar('\n')); assert_eq!(
1719 s.damage(),
1720 Damage::Lines {
1721 from: 0,
1722 to: u32::MAX,
1723 },
1724 "a line-count change damages to end-of-document",
1725 );
1726 }
1727
1728 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
1732 let mut s = new_state_with(text);
1733 for w in &mut s.layout.windows {
1734 w.viewport.visible_lines = vis_lines;
1735 w.viewport.visible_columns = vis_cols;
1736 }
1737 s
1738 }
1739
1740 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
1745 let w = s.layout.active_window().expect("active window");
1746 let v = w.viewport;
1747 let c = s.cursor();
1748 assert!(
1749 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
1750 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
1751 c.line,
1752 v.top_line,
1753 v.top_line + v.visible_lines,
1754 );
1755 assert!(
1756 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
1757 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
1758 c.column,
1759 v.left_column,
1760 v.left_column + v.visible_columns,
1761 );
1762 }
1763
1764 fn press(kc: KeyCode) -> AppEvent {
1765 AppEvent::Key(KeyEvent {
1766 key: kc,
1767 pressed: true,
1768 modifiers: Modifiers::default(),
1769 text: None,
1770 })
1771 }
1772
1773 fn line0_len(s: &EditorState) -> u32 {
1776 s.buffers.get(s.active).unwrap().line_len_chars(0)
1777 }
1778
1779 #[test]
1780 fn delete_to_line_end_clears_line_and_fills_register() {
1781 let mut s = new_state_with("hello world");
1782 s.apply(&Action::ApplyOperator {
1783 op: Operator::Delete,
1784 motion: Motion::LineEnd,
1785 });
1786 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
1787 assert_eq!(
1788 s.register(),
1789 Some("hello world"),
1790 "delete fills the register"
1791 );
1792 assert_eq!(
1793 s.cursor(),
1794 Position::ZERO,
1795 "cursor lands at the range start"
1796 );
1797 }
1798
1799 #[test]
1800 fn delete_over_right_motion_removes_one_char() {
1801 let mut s = new_state_with("abc");
1802 s.apply(&Action::ApplyOperator {
1803 op: Operator::Delete,
1804 motion: Motion::Right,
1805 });
1806 assert_eq!(
1807 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1808 Some("bc")
1809 );
1810 assert_eq!(s.register(), Some("a"));
1811 }
1812
1813 #[test]
1814 fn change_to_line_end_deletes_and_enters_insert() {
1815 let mut s = new_state_with("hello world");
1816 assert_eq!(s.modal.mode(), Mode::Normal);
1817 s.apply(&Action::ApplyOperator {
1818 op: Operator::Change,
1819 motion: Motion::LineEnd,
1820 });
1821 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
1822 assert_eq!(
1823 s.modal.mode(),
1824 Mode::Insert,
1825 "change enters Insert to type the replacement"
1826 );
1827 assert_eq!(
1828 s.register(),
1829 Some("hello world"),
1830 "change fills the register"
1831 );
1832 }
1833
1834 #[test]
1835 fn yank_to_line_end_fills_register_without_mutating() {
1836 let mut s = new_state_with("hello world");
1837 s.apply(&Action::ApplyOperator {
1838 op: Operator::Yank,
1839 motion: Motion::LineEnd,
1840 });
1841 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
1842 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
1843 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
1844 }
1845
1846 #[test]
1847 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
1848 let mut s = new_state_with("hello world");
1852 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
1853 assert_eq!(target, Position::new(0, 11));
1854 s.apply_motion(Motion::LineEnd);
1855 assert_eq!(
1856 s.cursor(),
1857 target,
1858 "the move path resolves the same target the operator uses"
1859 );
1860 }
1861
1862 #[test]
1863 fn empty_motion_range_is_a_no_op() {
1864 let mut s = new_state_with("abc");
1867 s.apply(&Action::ApplyOperator {
1868 op: Operator::Delete,
1869 motion: Motion::LineStart,
1870 });
1871 assert_eq!(
1872 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1873 Some("abc")
1874 );
1875 assert_eq!(s.register(), None);
1876 }
1877
1878 #[test]
1879 fn operator_then_motion_composes_through_the_pending_fsm() {
1880 let mut s = new_state_with("hello world");
1884 s.apply(&Action::Operator(Operator::Delete));
1885 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
1886 s.apply(&Action::Move(Motion::LineEnd));
1887 assert_eq!(
1888 line0_len(&s),
1889 0,
1890 "d then $ composes d$ and deletes the line"
1891 );
1892 assert_eq!(s.register(), Some("hello world"));
1893 }
1894
1895 #[test]
1896 fn change_operator_through_fsm_enters_insert() {
1897 let mut s = new_state_with("hello world");
1898 s.apply(&Action::Operator(Operator::Change));
1899 s.apply(&Action::Move(Motion::LineEnd));
1900 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
1901 }
1902
1903 #[test]
1904 fn lone_motion_after_no_operator_just_moves() {
1905 let mut s = new_state_with("hello world");
1907 s.apply(&Action::Move(Motion::LineEnd));
1908 assert_eq!(s.cursor(), Position::new(0, 11));
1909 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
1910 }
1911
1912 #[test]
1913 fn counted_operator_deletes_count_times() {
1914 let mut s = new_state_with("abcdef");
1918 s.apply_counted(&Action::Operator(Operator::Delete), 3);
1919 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
1920 s.apply(&Action::Move(Motion::Right));
1921 assert_eq!(
1922 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1923 Some("def")
1924 );
1925 }
1926
1927 #[test]
1928 fn operator_and_motion_counts_multiply_end_to_end() {
1929 let mut s = new_state_with("abcdefgh");
1931 s.apply_counted(&Action::Operator(Operator::Delete), 2);
1932 s.apply_counted(&Action::Move(Motion::Right), 3);
1933 assert_eq!(
1934 s.buffers.get(s.active).unwrap().line(0).as_deref(),
1935 Some("gh")
1936 );
1937 }
1938
1939 #[test]
1940 fn bare_counted_motion_still_repeats_no_regression() {
1941 let mut s = new_state_with("a\nb\nc\nd\ne");
1944 s.apply_counted(&Action::Move(Motion::Down), 3);
1945 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
1946 }
1947
1948 struct SpacedClock(std::time::Instant);
1954 impl SpacedClock {
1955 fn new() -> Self {
1956 Self(std::time::Instant::now())
1957 }
1958 fn next(&mut self) -> std::time::Instant {
1959 self.0 += std::time::Duration::from_secs(1);
1960 self.0
1961 }
1962 }
1963
1964 #[test]
1965 fn hjkl_moves_cursor() {
1966 let mut s = new_state_with("hello\nworld");
1967 s.tick(&press(KeyCode::Char('l')));
1968 assert_eq!(s.cursor().column, 1);
1969 s.tick(&press(KeyCode::Char('j')));
1970 assert_eq!(s.cursor().line, 1);
1971 s.tick(&press(KeyCode::Char('h')));
1972 assert_eq!(s.cursor().column, 0);
1973 }
1974
1975 #[test]
1976 fn insert_mode_inserts_chars() {
1977 let mut s = new_state_with("");
1978 s.tick(&press(KeyCode::Char('i')));
1979 assert_eq!(s.modal.mode(), Mode::Insert);
1980 s.tick(&press(KeyCode::Char('h')));
1981 s.tick(&press(KeyCode::Char('i')));
1982 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
1983 assert_eq!(s.cursor().column, 2);
1984 }
1985
1986 #[test]
1987 fn esc_returns_to_normal() {
1988 let mut s = new_state_with("");
1989 s.tick(&press(KeyCode::Char('i')));
1990 s.tick(&press(KeyCode::Escape));
1991 assert_eq!(s.modal.mode(), Mode::Normal);
1992 }
1993
1994 #[test]
1995 fn count_prefix_repeats_motion() {
1996 let mut s = new_state_with("abcdefghij");
1997 s.tick(&press(KeyCode::Char('5')));
1998 s.tick(&press(KeyCode::Char('l')));
1999 assert_eq!(s.cursor().column, 5);
2000 }
2001
2002 #[test]
2003 fn close_event_requests_quit() {
2004 let mut s = new_state_with("");
2005 s.tick(&AppEvent::CloseRequested);
2006 assert!(s.quit_requested);
2007 }
2008
2009 #[test]
2010 fn word_next_jumps_past_whitespace() {
2011 let mut s = new_state_with("foo bar baz");
2012 let mut clk = SpacedClock::new();
2015 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2016 assert_eq!(s.cursor().column, 4);
2017 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2018 assert_eq!(s.cursor().column, 8);
2019 }
2020
2021 #[test]
2024 fn leader_sequence_holds_then_resolves() {
2025 let mut s = new_state_with("a\nbb\nccc");
2026 s.keymap.bind_sequence(
2027 Mode::Normal,
2028 vec![Key::Char(','), Key::Char('g')],
2029 Action::Move(Motion::DocEnd),
2030 "doc end",
2031 );
2032 s.on_key(&Key::Char(','));
2034 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
2035 assert_eq!(s.cursor(), Position::ZERO);
2036 s.on_key(&Key::Char('g'));
2038 assert!(s.pending_keys.is_empty());
2039 assert_eq!(s.cursor().line, 2);
2040 }
2041
2042 #[test]
2043 fn two_key_gg_jumps_doc_start() {
2044 let mut s = new_state_with("a\nbb\nccc");
2045 s.keymap.bind_sequence(
2046 Mode::Normal,
2047 vec![Key::Char('g'), Key::Char('g')],
2048 Action::Move(Motion::DocStart),
2049 "doc start",
2050 );
2051 let mut clk = SpacedClock::new();
2052 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2053 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2054 assert_eq!(s.cursor().line, 2);
2055 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2057 s.on_key(&Key::Char('g')); assert_eq!(s.cursor(), Position::ZERO);
2059 }
2060
2061 #[test]
2062 fn broken_sequence_aborts_and_clears_pending() {
2063 let mut s = new_state_with("hello");
2064 s.keymap.bind_sequence(
2065 Mode::Normal,
2066 vec![Key::Char('g'), Key::Char('g')],
2067 Action::Move(Motion::DocEnd),
2068 "doc end",
2069 );
2070 s.on_key(&Key::Char('g')); assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2072 s.on_key(&Key::Char('x')); assert!(s.pending_keys.is_empty());
2074 assert_eq!(s.cursor(), Position::ZERO);
2075 }
2076
2077 #[test]
2078 fn single_binding_wins_over_sequence_prefix() {
2079 let mut s = new_state_with("abcde");
2083 let mut clk = SpacedClock::new();
2084 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2085 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2086 assert_eq!(s.cursor().column, 2);
2087 s.keymap.bind_sequence(
2088 Mode::Normal,
2089 vec![Key::Char('h'), Key::Char('z')],
2090 Action::Move(Motion::DocEnd),
2091 "shadowed",
2092 );
2093 s.on_key(&Key::Char('h'));
2094 assert!(s.pending_keys.is_empty(), "single binding should not pend");
2095 assert_eq!(s.cursor().column, 1, "h moved left immediately");
2096 }
2097
2098 #[test]
2101 fn lisp_set_option_writes_live_options() {
2102 let mut s = new_state_with("");
2103 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2104 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2105 }
2106
2107 #[test]
2108 fn lisp_insert_modifies_buffer_and_advances_cursor() {
2109 let mut s = new_state_with("");
2110 s.run_lisp(r#"(insert "abc")"#).unwrap();
2111 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2112 assert_eq!(s.cursor(), Position::new(0, 3));
2113 }
2114
2115 #[test]
2116 fn lisp_message_appends_to_messages() {
2117 let mut s = new_state_with("");
2118 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2119 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2120 }
2121
2122 #[test]
2123 fn lisp_reads_snapshot_and_branches_to_effect() {
2124 let mut s = new_state_with("one\ntwo\nthree");
2127 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2129 .unwrap();
2130 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2131 }
2132
2133 #[test]
2134 fn lisp_run_command_effect_drives_registry() {
2135 let mut s = new_state_with("");
2139 s.run_lisp(r#"(insert "abc")"#).unwrap();
2140 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2141 s.run_lisp(r#"(run-command "undo")"#).unwrap();
2142 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2143 }
2144
2145 #[test]
2146 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2147 let mut s = new_state_with("");
2152 s.run_lisp(r#"(run-command "quit")"#).unwrap();
2153 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2154 assert_eq!(
2155 s.modal.minibuffer(),
2156 "",
2157 "quit must not pollute any command line — Normal mode has no minibuffer",
2158 );
2159 }
2160
2161 #[test]
2164 fn lazy_plugin_activates_on_command_trigger() {
2165 let mut s = new_state_with("");
2169 s.register_lazy_plugin(
2170 "user-lazy",
2171 vec![LazyTrigger::Command("LazyGo".into())],
2172 r#"(defoption :name "lazy-loaded" :value "yes")
2173 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
2174 );
2175 assert_eq!(s.plugin_host.pending(), 1);
2176 assert!(
2177 s.options.get("lazy-loaded").is_none(),
2178 "entry not applied yet"
2179 );
2180
2181 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
2183
2184 assert_eq!(
2185 s.options.get("lazy-loaded").map(String::as_str),
2186 Some("yes"),
2187 "the command trigger applied the plugin's entry",
2188 );
2189 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
2190 }
2191
2192 #[test]
2193 fn lazy_plugin_activates_on_filetype() {
2194 let mut s = new_state_with("");
2195 s.register_lazy_plugin(
2196 "user-rust",
2197 vec![LazyTrigger::FileType("rust".into())],
2198 r#"(defoption :name "rust-plugin" :value "on")"#,
2199 );
2200 let n = s.activate_filetype_plugins("rust");
2201 assert_eq!(n, 1);
2202 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
2203 assert_eq!(s.activate_filetype_plugins("rust"), 0);
2205 }
2206
2207 #[test]
2208 fn cached_vm_serves_multiple_run_lisp_calls() {
2209 let mut s = new_state_with("");
2210 s.run_lisp(r#"(message "one")"#).unwrap();
2211 assert!(
2212 s.lisp_vm.is_some(),
2213 "VM should be cached after first run_lisp"
2214 );
2215 s.run_lisp(r#"(message "two")"#).unwrap();
2216 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
2217 }
2218
2219 #[test]
2220 fn lisp_define_persists_across_run_lisp_calls() {
2221 let mut s = new_state_with("");
2224 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
2225 s.run_lisp(r#"(message greeting)"#).unwrap();
2226 assert_eq!(s.messages, vec!["hi".to_string()]);
2227 }
2228
2229 #[test]
2230 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
2231 let mut s = new_state_with("");
2235 s.run_lisp(
2236 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
2237 )
2238 .unwrap();
2239 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
2240 assert_eq!(
2241 s.options.get("col").map(String::as_str),
2242 Some("stale-zero"),
2243 "cursor-column within the same call reads the pre-eval snapshot",
2244 );
2245 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
2248 .unwrap();
2249 assert_eq!(
2250 s.options.get("col2").map(String::as_str),
2251 Some("live-two"),
2252 "a later call sees the refreshed snapshot",
2253 );
2254 }
2255
2256 #[test]
2257 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
2258 let mut s = new_state_with("");
2259 s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
2260 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
2261 assert_eq!(s.cursor(), Position::new(1, 3));
2262 }
2263
2264 #[test]
2265 fn visual_mode_sequence_resolves() {
2266 let mut s = new_state_with("abc");
2267 s.modal.enter(Mode::Visual);
2268 s.keymap.bind_sequence(
2269 Mode::Visual,
2270 vec![Key::Char('g'), Key::Char('e')],
2271 Action::Move(Motion::DocEnd),
2272 "ge",
2273 );
2274 s.on_key(&Key::Char('g'));
2275 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2276 s.on_key(&Key::Char('e'));
2277 assert!(s.pending_keys.is_empty());
2278 assert_eq!(
2279 s.cursor().column,
2280 3,
2281 "ge resolved to doc-end in visual mode"
2282 );
2283 }
2284
2285 #[test]
2286 fn sequence_abort_with_bound_breaking_key_redispatches() {
2287 let mut s = new_state_with("abcde");
2290 s.keymap.bind_sequence(
2291 Mode::Normal,
2292 vec![Key::Char('g'), Key::Char('g')],
2293 Action::Move(Motion::DocEnd),
2294 "gg",
2295 );
2296 s.on_key(&Key::Char('g'));
2297 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2298 s.on_key(&Key::Char('l'));
2299 assert!(s.pending_keys.is_empty());
2300 assert_eq!(
2301 s.cursor().column,
2302 1,
2303 "the breaking key l should re-dispatch as move-right",
2304 );
2305 }
2306
2307 #[test]
2310 fn viewport_contains_cursor_after_every_op() {
2311 let mut s = new_state_small_viewport("", 5, 10);
2315 assert_cursor_in_viewport(&s, "initial");
2316
2317 s.tick(&press(KeyCode::Char('i')));
2320 assert_eq!(s.modal.mode(), Mode::Insert);
2321 for line in 0..30u32 {
2322 for c in "line".chars() {
2323 s.tick(&press(KeyCode::Char(c)));
2324 assert_cursor_in_viewport(&s, "typing chars");
2325 }
2326 s.tick(&press(KeyCode::Enter));
2327 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
2328 }
2329
2330 for i in 0..200u32 {
2333 s.tick(&press(KeyCode::Char('x')));
2334 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
2335 }
2336
2337 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
2339 assert_cursor_in_viewport(&s, "insert_text multiline");
2340
2341 s.tick(&press(KeyCode::Escape));
2343 assert_eq!(s.modal.mode(), Mode::Normal);
2344 for m in [
2345 Motion::DocStart,
2346 Motion::DocEnd,
2347 Motion::Down,
2348 Motion::Down,
2349 Motion::Up,
2350 Motion::Right,
2351 Motion::Right,
2352 Motion::Left,
2353 Motion::LineEnd,
2354 Motion::LineStart,
2355 Motion::GotoLine(1),
2356 Motion::GotoLine(40),
2357 Motion::PageDown,
2358 Motion::PageUp,
2359 ] {
2360 s.apply_motion(m);
2361 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
2362 }
2363
2364 for i in 0..50u32 {
2367 s.apply(&Action::Undo);
2368 assert_cursor_in_viewport(&s, &format!("undo {i}"));
2369 }
2370 for i in 0..50u32 {
2372 s.apply(&Action::Redo);
2373 assert_cursor_in_viewport(&s, &format!("redo {i}"));
2374 }
2375 }
2376
2377 #[test]
2378 fn insert_at_eof_keeps_cursor_in_bounds() {
2379 let mut s = new_state_small_viewport("abc", 5, 10);
2382 s.apply_motion(Motion::DocEnd);
2383 s.tick(&press(KeyCode::Char('i')));
2384 s.tick(&press(KeyCode::Char('d')));
2385 let buf = s.buffers.get(s.active).unwrap();
2386 let clamped = buf.clamp(s.cursor());
2387 assert_eq!(
2388 s.cursor(),
2389 clamped,
2390 "cursor must be clamped in-bounds at EOF"
2391 );
2392 assert_cursor_in_viewport(&s, "insert at eof");
2393 }
2394
2395 #[test]
2396 fn count_prefix_then_sequence_repeats() {
2397 let mut s = new_state_with("a\nb\nc\nd\ne");
2399 s.keymap.bind_sequence(
2400 Mode::Normal,
2401 vec![Key::Char('g'), Key::Char('j')],
2402 Action::Move(Motion::Down),
2403 "gj",
2404 );
2405 s.on_key(&Key::Char('2'));
2406 s.on_key(&Key::Char('g'));
2407 s.on_key(&Key::Char('j'));
2408 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
2409 }
2410
2411 #[test]
2414 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
2415 let mut s = new_state_with(&"x\n".repeat(40));
2421 let t0 = std::time::Instant::now();
2422 let mut delivered = 0u32;
2423 for i in 0..20u32 {
2424 let before = s.cursor().line;
2425 s.tick_at(
2426 &press(KeyCode::Char('j')),
2427 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
2428 );
2429 if s.cursor().line != before {
2430 delivered += 1;
2431 }
2432 }
2433 assert!(
2436 (10..=14).contains(&delivered),
2437 "expected the storm debounced to ~13 moves, got {delivered}",
2438 );
2439 assert!(
2440 delivered < 20,
2441 "the gate must drop SOME storm ticks, not pass all 20",
2442 );
2443 }
2444
2445 #[test]
2446 fn spaced_intentional_taps_all_pass() {
2447 let mut s = new_state_with(&"x\n".repeat(10));
2450 let t0 = std::time::Instant::now();
2451 for i in 0..5u32 {
2452 s.tick_at(
2453 &press(KeyCode::Char('j')),
2454 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2456 );
2457 }
2458 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2459 }
2460
2461 #[test]
2462 fn distinct_keys_have_independent_clocks() {
2463 let mut s = new_state_with("abc\ndef\nghi");
2466 let t = std::time::Instant::now();
2467 s.tick_at(&press(KeyCode::Char('j')), t);
2468 s.tick_at(
2470 &press(KeyCode::Char('j')),
2471 t + std::time::Duration::from_millis(10),
2472 );
2473 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2474 s.tick_at(
2476 &press(KeyCode::Char('l')),
2477 t + std::time::Duration::from_millis(10),
2478 );
2479 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2480 }
2481
2482 #[test]
2485 fn cursor_home_preserves_single_cursor_behavior() {
2486 let mut s = new_state_with("hello\nworld\nthere");
2491 assert_eq!(s.cursor(), Position::ZERO);
2492 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2493
2494 s.apply_motion(Motion::Down);
2495 s.apply_motion(Motion::Right);
2496 s.apply_motion(Motion::Right);
2497 assert_eq!(s.cursor(), Position::new(1, 2));
2498 assert_eq!(s.cursors.count(), 1);
2500
2501 let w = s.layout.active_window().unwrap();
2503 assert!(w.viewport.top_line <= s.cursor().line);
2504 }
2505
2506 #[test]
2507 fn insert_mode_is_ungated_so_repeat_typing_works() {
2508 let mut s = new_state_with("");
2512 s.tick(&press(KeyCode::Char('i')));
2513 assert_eq!(s.modal.mode(), Mode::Insert);
2514 let t = std::time::Instant::now();
2515 for _ in 0..10 {
2516 s.tick_at(&press(KeyCode::Char('x')), t);
2517 }
2518 assert_eq!(
2519 s.buffers.get(s.active).unwrap().to_string(),
2520 "xxxxxxxxxx",
2521 "insert-mode repeat typing is ungated",
2522 );
2523 }
2524}