Skip to main content

hjkl_ex/
lib.rs

1//! Ex-command registry and dispatch layer for the hjkl editor stack.
2//!
3//! Phase 1: provides an extensible [`Registry`] and a minimal set of
4//! built-in commands (`:q`, `:q!`). Additional commands migrate in
5//! subsequent phases.
6//!
7//! Phase 2a: adds range parsing infrastructure and migrates the no-arg /
8//! no-range terminal commands (`:w`, `:wq`, `:x`, `:wa`, `:wqa`, `:noh`,
9//! `:undo`, `:redo`, `:qall`, `:qall!`, `:wqall`, `:wqall!`).
10
11pub use complete::{
12    ArgSources, CompletionKind, Completions, collect_host_registry_names, collect_registry_names,
13    complete, complete_arg, complete_command_from_names, first_word_end, longest_common_prefix,
14};
15pub use effect::ExEffect;
16pub use expand::{ExpandContext, expand_args, expand_filename};
17pub use range::{LineRange, parse_range};
18pub use registry::{ArgKind, ExCommand, HostCmd, HostRegistry, Registry};
19
20mod builtins;
21mod complete;
22mod effect;
23pub mod expand;
24mod folds;
25mod global;
26mod listings;
27mod parse;
28mod range;
29mod registry;
30mod setopt;
31mod shell;
32
33pub use setopt::all_setting_names;
34
35/// Try to dispatch `input` (without the leading `:`) through the registry.
36///
37/// A leading range prefix (`5`, `5,10`, `.,$`, `%`, `'a,'b`) is parsed and
38/// stripped before command resolution — Phase 2d commands will receive the
39/// range; Phase 2a no-arg commands ignore it. If the range is syntactically
40/// invalid the error is surfaced as `Some(ExEffect::Error(...))`.
41///
42/// Returns:
43/// - `Some(ExEffect)` when a registered command handled it
44/// - `None` when no registered command matched (caller falls back to legacy handling)
45pub fn try_dispatch<H: hjkl_engine::Host>(
46    reg: &Registry<H>,
47    editor: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
48    input: &str,
49) -> Option<ExEffect> {
50    let input = input.trim();
51    if input.is_empty() {
52        return None;
53    }
54
55    // Phase 8a: search-as-address `:/pat` / `:?pat`.
56    // Must be checked before parse_range because `/` and `?` are not valid
57    // range chars and parse_range would return None range + the original input.
58    if input.starts_with('/') || input.starts_with('?') {
59        return Some(handle_search_address(editor, input));
60    }
61
62    // Parse a leading range (`5`, `5,10`, `.,$`, `%`, `'a,'b`).
63    let (range, cmd_str) = match parse_range(input, editor) {
64        Ok(pair) => pair,
65        Err(e) => return Some(ExEffect::Error(e)),
66    };
67
68    // Phase 8a: `:[range]!cmd` shell filter — special-case before split_name_args
69    // because `!` is not alphabetic and split_name_args would return ("", input).
70    if let Some(rest) = cmd_str.strip_prefix('!') {
71        let shell_cmd = rest.trim();
72        return Some(shell::shell_filter_handler(editor, shell_cmd, range));
73    }
74
75    // `:&` / `:&&` (repeat last substitute) — special-case because `&` is not
76    // alphabetic and split_name_args cannot parse it as a command name.
77    // `:&&` keeps the original flags; `:&` drops them (vim semantics).
78    // Must check `&&` before `&` so the double-ampersand form is matched first.
79    if cmd_str == "&&" || cmd_str.starts_with("&& ") {
80        return Some(builtins::repeat_substitute_handler(editor, true, range));
81    }
82    if cmd_str == "&" || cmd_str.starts_with("& ") {
83        return Some(builtins::repeat_substitute_handler(editor, false, range));
84    }
85
86    let (name, args) = parse::split_name_args(cmd_str);
87    if name.is_empty() {
88        // Bare `:N` or bare range — jump to line.
89        return handle_bare_line_number(editor, cmd_str, range);
90    }
91    let cmd = reg.resolve(name)?;
92    // Handler may return None to defer this invocation to the legacy path.
93    (cmd.run)(editor, args, range)
94}
95
96/// Handle `:/pat` / `:?pat` — search-as-address.
97///
98/// Jumps the cursor forward (`/`) or backward (`?`) to the next line
99/// matching `pat`. Empty pattern reuses `editor.last_search()`.
100/// Ported from `hjkl_editor::ex::run` lines 149–181.
101fn handle_search_address<H: hjkl_engine::Host>(
102    editor: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
103    input: &str,
104) -> ExEffect {
105    let forward = input.starts_with('/');
106    let delim = if forward { '/' } else { '?' };
107    let body = &input[1..];
108    let pat_str: String = match body.strip_suffix(delim).unwrap_or(body) {
109        "" => match editor.last_search() {
110            Some(p) if !p.is_empty() => p.to_string(),
111            _ => return ExEffect::Error("no previous search pattern".into()),
112        },
113        s => s.to_string(),
114    };
115    let s = editor.settings();
116    let case_insensitive =
117        s.ignore_case && !(s.smartcase && pat_str.chars().any(|c| c.is_uppercase()));
118    let compile_src: std::borrow::Cow<'_, str> = if case_insensitive {
119        std::borrow::Cow::Owned(format!("(?i){pat_str}"))
120    } else {
121        std::borrow::Cow::Borrowed(pat_str.as_str())
122    };
123    match regex::Regex::new(&compile_src) {
124        Ok(re) => {
125            editor.set_search_pattern(Some(re));
126            if forward {
127                editor.search_advance_forward(false);
128            } else {
129                editor.search_advance_backward(true);
130            }
131            editor.ensure_cursor_in_scrolloff();
132            editor.set_last_search(Some(pat_str), forward);
133            ExEffect::Ok
134        }
135        Err(e) => ExEffect::Error(format!("bad search pattern: {e}")),
136    }
137}
138
139/// Try to dispatch `input` (without the leading `:`) through a host registry.
140///
141/// Returns `Some(ExEffect)` when a host command claimed the invocation,
142/// `None` when no command matched or the matched command deferred.
143///
144/// Unlike [`try_dispatch`] this function does not parse a range prefix — host
145/// commands in Phase 4 don't accept ranges.
146pub fn try_dispatch_host<Ctx>(
147    reg: &HostRegistry<Ctx>,
148    ctx: &mut Ctx,
149    input: &str,
150) -> Option<ExEffect> {
151    let input = input.trim();
152    if input.is_empty() {
153        return None;
154    }
155    let (name, args) = parse::split_name_args(input);
156    if name.is_empty() {
157        return None;
158    }
159    let cmd = reg.resolve(name)?;
160    cmd.run(ctx, args)
161}
162
163/// Handle bare `:N` (jump to line N) and bare `:{range}` (jump to range start).
164///
165/// - `cmd_str` parses as `usize` AND `range.is_none()` → goto that line.
166/// - `range.is_some()` AND `cmd_str.is_empty()` → goto range start (vim semantics).
167/// - Otherwise → `None` (let caller fall back to legacy).
168fn handle_bare_line_number<H: hjkl_engine::Host>(
169    editor: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
170    cmd_str: &str,
171    range: Option<LineRange>,
172) -> Option<ExEffect> {
173    if let Ok(line) = cmd_str.trim().parse::<usize>()
174        && range.is_none()
175    {
176        editor.goto_line(line);
177        return Some(ExEffect::Ok);
178    }
179    if let Some(r) = range
180        && cmd_str.trim().is_empty()
181    {
182        editor.goto_line(r.start_one_based());
183        return Some(ExEffect::Ok);
184    }
185    None
186}
187
188/// Build a [`Registry`] seeded with the Phase 1 + Phase 2a default commands.
189pub fn default_registry<H: hjkl_engine::Host>() -> Registry<H> {
190    let mut reg = Registry::new();
191    builtins::register_builtins(&mut reg);
192    reg
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use hjkl_engine::{DefaultHost, Editor, Options};
199
200    fn make_editor() -> Editor<hjkl_buffer::Buffer, DefaultHost> {
201        let buf = hjkl_buffer::Buffer::new();
202        let host = DefaultHost::new();
203        Editor::new(buf, host, Options::default())
204    }
205
206    fn make_editor_with_lines(lines: &[&str]) -> Editor<hjkl_buffer::Buffer, DefaultHost> {
207        let content = lines.join("\n");
208        let buf = hjkl_buffer::Buffer::from_str(&content);
209        let host = DefaultHost::new();
210        Editor::new(buf, host, Options::default())
211    }
212
213    fn buf_line(editor: &Editor<hjkl_buffer::Buffer, DefaultHost>, row: usize) -> String {
214        hjkl_buffer::rope_line_str(&editor.buffer().rope(), row)
215    }
216
217    fn buf_lines(editor: &Editor<hjkl_buffer::Buffer, DefaultHost>) -> Vec<String> {
218        let rope = editor.buffer().rope();
219        (0..rope.len_lines())
220            .map(|i| hjkl_buffer::rope_line_str(&rope, i))
221            .collect()
222    }
223
224    // ---- Phase 1 tests (kept) ----------------------------------------------
225
226    #[test]
227    fn dispatch_q_returns_quit() {
228        let reg = default_registry::<DefaultHost>();
229        let mut editor = make_editor();
230        let result = try_dispatch(&reg, &mut editor, "q");
231        assert_eq!(
232            result,
233            Some(ExEffect::Quit {
234                force: false,
235                save: false
236            })
237        );
238    }
239
240    #[test]
241    fn dispatch_quit_returns_quit() {
242        let reg = default_registry::<DefaultHost>();
243        let mut editor = make_editor();
244        let result = try_dispatch(&reg, &mut editor, "quit");
245        assert_eq!(
246            result,
247            Some(ExEffect::Quit {
248                force: false,
249                save: false
250            })
251        );
252    }
253
254    #[test]
255    fn dispatch_q_bang_returns_force_quit() {
256        let reg = default_registry::<DefaultHost>();
257        let mut editor = make_editor();
258        let result = try_dispatch(&reg, &mut editor, "q!");
259        assert_eq!(
260            result,
261            Some(ExEffect::Quit {
262                force: true,
263                save: false
264            })
265        );
266    }
267
268    #[test]
269    fn dispatch_nonexistent_returns_none() {
270        let reg = default_registry::<DefaultHost>();
271        let mut editor = make_editor();
272        let result = try_dispatch(&reg, &mut editor, "nonexistent");
273        assert_eq!(result, None);
274    }
275
276    #[test]
277    fn dispatch_empty_returns_none() {
278        let reg = default_registry::<DefaultHost>();
279        let mut editor = make_editor();
280        let result = try_dispatch(&reg, &mut editor, "");
281        assert_eq!(result, None);
282    }
283
284    #[test]
285    fn dispatch_whitespace_only_returns_none() {
286        let reg = default_registry::<DefaultHost>();
287        let mut editor = make_editor();
288        let result = try_dispatch(&reg, &mut editor, "   ");
289        assert_eq!(result, None);
290    }
291
292    // ---- Phase 2a: write ---------------------------------------------------
293
294    #[test]
295    fn dispatch_w_returns_save() {
296        let reg = default_registry::<DefaultHost>();
297        let mut editor = make_editor();
298        assert_eq!(try_dispatch(&reg, &mut editor, "w"), Some(ExEffect::Save));
299    }
300
301    #[test]
302    fn dispatch_write_returns_save() {
303        let reg = default_registry::<DefaultHost>();
304        let mut editor = make_editor();
305        assert_eq!(
306            try_dispatch(&reg, &mut editor, "write"),
307            Some(ExEffect::Save)
308        );
309    }
310
311    #[test]
312    fn dispatch_w_with_path_returns_save_as_phase_2b() {
313        // Phase 2b: handler returns Some(SaveAs) for non-empty args.
314        let reg = default_registry::<DefaultHost>();
315        let mut editor = make_editor();
316        let result = try_dispatch(&reg, &mut editor, "w /tmp/foo.txt");
317        assert_eq!(result, Some(ExEffect::SaveAs("/tmp/foo.txt".into())));
318    }
319
320    #[test]
321    fn dispatch_wa_returns_save() {
322        let reg = default_registry::<DefaultHost>();
323        let mut editor = make_editor();
324        assert_eq!(try_dispatch(&reg, &mut editor, "wa"), Some(ExEffect::Save));
325    }
326
327    #[test]
328    fn dispatch_wall_returns_save() {
329        let reg = default_registry::<DefaultHost>();
330        let mut editor = make_editor();
331        assert_eq!(
332            try_dispatch(&reg, &mut editor, "wall"),
333            Some(ExEffect::Save)
334        );
335    }
336
337    // ---- Phase 2a: wq / x --------------------------------------------------
338
339    #[test]
340    fn dispatch_wq_returns_quit_save() {
341        let reg = default_registry::<DefaultHost>();
342        let mut editor = make_editor();
343        assert_eq!(
344            try_dispatch(&reg, &mut editor, "wq"),
345            Some(ExEffect::Quit {
346                force: false,
347                save: true
348            })
349        );
350    }
351
352    #[test]
353    fn dispatch_x_returns_quit_save() {
354        let reg = default_registry::<DefaultHost>();
355        let mut editor = make_editor();
356        assert_eq!(
357            try_dispatch(&reg, &mut editor, "x"),
358            Some(ExEffect::Quit {
359                force: false,
360                save: true
361            })
362        );
363    }
364
365    #[test]
366    fn dispatch_wq_bang_returns_force_quit_save() {
367        let reg = default_registry::<DefaultHost>();
368        let mut editor = make_editor();
369        assert_eq!(
370            try_dispatch(&reg, &mut editor, "wq!"),
371            Some(ExEffect::Quit {
372                force: true,
373                save: true
374            })
375        );
376    }
377
378    #[test]
379    fn dispatch_x_bang_returns_force_quit_save() {
380        let reg = default_registry::<DefaultHost>();
381        let mut editor = make_editor();
382        assert_eq!(
383            try_dispatch(&reg, &mut editor, "x!"),
384            Some(ExEffect::Quit {
385                force: true,
386                save: true
387            })
388        );
389    }
390
391    // ---- Phase 2a: wqall ---------------------------------------------------
392
393    #[test]
394    fn dispatch_wqa_returns_quit_save() {
395        let reg = default_registry::<DefaultHost>();
396        let mut editor = make_editor();
397        assert_eq!(
398            try_dispatch(&reg, &mut editor, "wqa"),
399            Some(ExEffect::Quit {
400                force: false,
401                save: true
402            })
403        );
404    }
405
406    #[test]
407    fn dispatch_wqall_returns_quit_save() {
408        let reg = default_registry::<DefaultHost>();
409        let mut editor = make_editor();
410        assert_eq!(
411            try_dispatch(&reg, &mut editor, "wqall"),
412            Some(ExEffect::Quit {
413                force: false,
414                save: true
415            })
416        );
417    }
418
419    #[test]
420    fn dispatch_wqa_bang_returns_quit_save() {
421        let reg = default_registry::<DefaultHost>();
422        let mut editor = make_editor();
423        assert_eq!(
424            try_dispatch(&reg, &mut editor, "wqa!"),
425            Some(ExEffect::Quit {
426                force: false,
427                save: true
428            })
429        );
430    }
431
432    #[test]
433    fn dispatch_wqall_bang_returns_quit_save() {
434        let reg = default_registry::<DefaultHost>();
435        let mut editor = make_editor();
436        assert_eq!(
437            try_dispatch(&reg, &mut editor, "wqall!"),
438            Some(ExEffect::Quit {
439                force: false,
440                save: true
441            })
442        );
443    }
444
445    // ---- Phase 2a: qall ----------------------------------------------------
446
447    #[test]
448    fn dispatch_qa_returns_quit_no_save() {
449        let reg = default_registry::<DefaultHost>();
450        let mut editor = make_editor();
451        assert_eq!(
452            try_dispatch(&reg, &mut editor, "qa"),
453            Some(ExEffect::Quit {
454                force: false,
455                save: false
456            })
457        );
458    }
459
460    #[test]
461    fn dispatch_qall_returns_quit_no_save() {
462        let reg = default_registry::<DefaultHost>();
463        let mut editor = make_editor();
464        assert_eq!(
465            try_dispatch(&reg, &mut editor, "qall"),
466            Some(ExEffect::Quit {
467                force: false,
468                save: false
469            })
470        );
471    }
472
473    #[test]
474    fn dispatch_qa_bang_returns_force_quit_no_save() {
475        let reg = default_registry::<DefaultHost>();
476        let mut editor = make_editor();
477        assert_eq!(
478            try_dispatch(&reg, &mut editor, "qa!"),
479            Some(ExEffect::Quit {
480                force: true,
481                save: false
482            })
483        );
484    }
485
486    #[test]
487    fn dispatch_qall_bang_returns_force_quit_no_save() {
488        let reg = default_registry::<DefaultHost>();
489        let mut editor = make_editor();
490        assert_eq!(
491            try_dispatch(&reg, &mut editor, "qall!"),
492            Some(ExEffect::Quit {
493                force: true,
494                save: false
495            })
496        );
497    }
498
499    // ---- Phase 2a: nohlsearch ----------------------------------------------
500
501    #[test]
502    fn dispatch_noh_clears_search_and_returns_ok() {
503        let reg = default_registry::<DefaultHost>();
504        let mut editor = make_editor();
505        assert_eq!(try_dispatch(&reg, &mut editor, "noh"), Some(ExEffect::Ok));
506    }
507
508    #[test]
509    fn dispatch_nohl_returns_ok() {
510        let reg = default_registry::<DefaultHost>();
511        let mut editor = make_editor();
512        assert_eq!(try_dispatch(&reg, &mut editor, "nohl"), Some(ExEffect::Ok));
513    }
514
515    #[test]
516    fn dispatch_nohlsearch_returns_ok() {
517        let reg = default_registry::<DefaultHost>();
518        let mut editor = make_editor();
519        assert_eq!(
520            try_dispatch(&reg, &mut editor, "nohlsearch"),
521            Some(ExEffect::Ok)
522        );
523    }
524
525    // ---- Phase 2a: undo / redo ---------------------------------------------
526
527    #[test]
528    fn dispatch_u_returns_ok() {
529        let reg = default_registry::<DefaultHost>();
530        let mut editor = make_editor();
531        assert_eq!(try_dispatch(&reg, &mut editor, "u"), Some(ExEffect::Ok));
532    }
533
534    #[test]
535    fn dispatch_undo_returns_ok() {
536        let reg = default_registry::<DefaultHost>();
537        let mut editor = make_editor();
538        assert_eq!(try_dispatch(&reg, &mut editor, "undo"), Some(ExEffect::Ok));
539    }
540
541    #[test]
542    fn dispatch_redo_returns_ok() {
543        let reg = default_registry::<DefaultHost>();
544        let mut editor = make_editor();
545        assert_eq!(try_dispatch(&reg, &mut editor, "redo"), Some(ExEffect::Ok));
546    }
547
548    // `red` → min_prefix=3 so `:red` resolves to `:redo`
549    #[test]
550    fn dispatch_red_returns_ok() {
551        let reg = default_registry::<DefaultHost>();
552        let mut editor = make_editor();
553        assert_eq!(try_dispatch(&reg, &mut editor, "red"), Some(ExEffect::Ok));
554    }
555
556    // `:re` — redo needs min_prefix=3 so doesn't match; read matches (min_prefix=1).
557    // `:re` unambiguously resolves to `:read` with no args → None (no path).
558    #[test]
559    fn dispatch_re_resolves_to_read_no_args() {
560        let reg = default_registry::<DefaultHost>();
561        let mut editor = make_editor();
562        // read_handler returns None when no path given, so try_dispatch returns None.
563        assert_eq!(try_dispatch(&reg, &mut editor, "re"), None);
564    }
565
566    // ---- Phase 2b: write with path -----------------------------------------
567
568    #[test]
569    fn dispatch_write_with_path_returns_save_as() {
570        let reg = default_registry::<DefaultHost>();
571        let mut editor = make_editor();
572        assert_eq!(
573            try_dispatch(&reg, &mut editor, "write foo.txt"),
574            Some(ExEffect::SaveAs("foo.txt".into()))
575        );
576    }
577
578    // ---- Phase 2b: edit ----------------------------------------------------
579
580    #[test]
581    fn dispatch_e_with_path_returns_edit_file() {
582        let reg = default_registry::<DefaultHost>();
583        let mut editor = make_editor();
584        assert_eq!(
585            try_dispatch(&reg, &mut editor, "e foo.txt"),
586            Some(ExEffect::EditFile {
587                path: "foo.txt".into(),
588                force: false
589            })
590        );
591    }
592
593    #[test]
594    fn dispatch_edit_with_path_returns_edit_file() {
595        let reg = default_registry::<DefaultHost>();
596        let mut editor = make_editor();
597        assert_eq!(
598            try_dispatch(&reg, &mut editor, "edit src/main.rs"),
599            Some(ExEffect::EditFile {
600                path: "src/main.rs".into(),
601                force: false
602            })
603        );
604    }
605
606    #[test]
607    fn dispatch_e_no_args_returns_edit_file_empty_path() {
608        let reg = default_registry::<DefaultHost>();
609        let mut editor = make_editor();
610        // No-arg edit: reload current buffer (empty path, no force).
611        assert_eq!(
612            try_dispatch(&reg, &mut editor, "e"),
613            Some(ExEffect::EditFile {
614                path: "".into(),
615                force: false
616            })
617        );
618    }
619
620    #[test]
621    fn dispatch_e_bang_with_path_returns_edit_file_force() {
622        let reg = default_registry::<DefaultHost>();
623        let mut editor = make_editor();
624        assert_eq!(
625            try_dispatch(&reg, &mut editor, "e! foo.txt"),
626            Some(ExEffect::EditFile {
627                path: "foo.txt".into(),
628                force: true
629            })
630        );
631    }
632
633    // ---- Phase 2b → 8a: read (now fully handled in hjkl-ex) ------------------
634    //
635    // Phase 8a: `:r` / `:read` now inserts file content directly.
636    // Old tests expected `ReadFile { path }` — updated to the new behavior
637    // (Ok on success, Error when file doesn't exist).
638
639    #[test]
640    fn dispatch_r_with_path_inserts_content_phase8a() {
641        let reg = default_registry::<DefaultHost>();
642        let mut editor = make_editor();
643        let tmp = tempfile::NamedTempFile::new().unwrap();
644        std::fs::write(tmp.path(), "hello\n").unwrap();
645        let path = tmp.path().to_string_lossy().to_string();
646        let result = try_dispatch(&reg, &mut editor, &format!("r {path}"));
647        assert_eq!(result, Some(ExEffect::Ok), "got: {result:?}");
648        let lines = buf_lines(&editor);
649        assert!(lines.contains(&"hello".to_string()), "lines: {lines:?}");
650    }
651
652    #[test]
653    fn dispatch_read_with_path_inserts_content_phase8a() {
654        let reg = default_registry::<DefaultHost>();
655        let mut editor = make_editor();
656        let tmp = tempfile::NamedTempFile::new().unwrap();
657        std::fs::write(tmp.path(), "world\n").unwrap();
658        let path = tmp.path().to_string_lossy().to_string();
659        let result = try_dispatch(&reg, &mut editor, &format!("read {path}"));
660        assert_eq!(result, Some(ExEffect::Ok), "got: {result:?}");
661        let lines = buf_lines(&editor);
662        assert!(lines.contains(&"world".to_string()), "lines: {lines:?}");
663    }
664
665    #[test]
666    fn dispatch_r_no_args_returns_none() {
667        let reg = default_registry::<DefaultHost>();
668        let mut editor = make_editor();
669        assert_eq!(try_dispatch(&reg, &mut editor, "r"), None);
670    }
671
672    // ---- Phase 2b: bdelete -------------------------------------------------
673
674    #[test]
675    fn dispatch_bd_returns_buffer_delete() {
676        let reg = default_registry::<DefaultHost>();
677        let mut editor = make_editor();
678        assert_eq!(
679            try_dispatch(&reg, &mut editor, "bd"),
680            Some(ExEffect::BufferDelete {
681                force: false,
682                wipe: false
683            })
684        );
685    }
686
687    #[test]
688    fn dispatch_bdelete_returns_buffer_delete() {
689        let reg = default_registry::<DefaultHost>();
690        let mut editor = make_editor();
691        assert_eq!(
692            try_dispatch(&reg, &mut editor, "bdelete"),
693            Some(ExEffect::BufferDelete {
694                force: false,
695                wipe: false
696            })
697        );
698    }
699
700    #[test]
701    fn dispatch_bd_bang_returns_buffer_delete_force() {
702        let reg = default_registry::<DefaultHost>();
703        let mut editor = make_editor();
704        assert_eq!(
705            try_dispatch(&reg, &mut editor, "bd!"),
706            Some(ExEffect::BufferDelete {
707                force: true,
708                wipe: false
709            })
710        );
711    }
712
713    #[test]
714    fn dispatch_bdelete_bang_returns_buffer_delete_force() {
715        let reg = default_registry::<DefaultHost>();
716        let mut editor = make_editor();
717        assert_eq!(
718            try_dispatch(&reg, &mut editor, "bdelete!"),
719            Some(ExEffect::BufferDelete {
720                force: true,
721                wipe: false
722            })
723        );
724    }
725
726    // ---- Phase 2b: bwipeout ------------------------------------------------
727
728    #[test]
729    fn dispatch_bw_returns_buffer_wipeout() {
730        let reg = default_registry::<DefaultHost>();
731        let mut editor = make_editor();
732        assert_eq!(
733            try_dispatch(&reg, &mut editor, "bw"),
734            Some(ExEffect::BufferDelete {
735                force: false,
736                wipe: true
737            })
738        );
739    }
740
741    #[test]
742    fn dispatch_bwipeout_returns_buffer_wipeout() {
743        let reg = default_registry::<DefaultHost>();
744        let mut editor = make_editor();
745        assert_eq!(
746            try_dispatch(&reg, &mut editor, "bwipeout"),
747            Some(ExEffect::BufferDelete {
748                force: false,
749                wipe: true
750            })
751        );
752    }
753
754    #[test]
755    fn dispatch_bw_bang_returns_buffer_wipeout_force() {
756        let reg = default_registry::<DefaultHost>();
757        let mut editor = make_editor();
758        assert_eq!(
759            try_dispatch(&reg, &mut editor, "bw!"),
760            Some(ExEffect::BufferDelete {
761                force: true,
762                wipe: true
763            })
764        );
765    }
766
767    #[test]
768    fn dispatch_bwipeout_bang_returns_buffer_wipeout_force() {
769        let reg = default_registry::<DefaultHost>();
770        let mut editor = make_editor();
771        assert_eq!(
772            try_dispatch(&reg, &mut editor, "bwipeout!"),
773            Some(ExEffect::BufferDelete {
774                force: true,
775                wipe: true
776            })
777        );
778    }
779
780    // `:r` resolves to `:read` (min_prefix=1); `:re` also resolves to `:read`
781    // since `:redo` requires min_prefix=3.
782    // Phase 8a: read_handler now acts immediately; non-existent path → Error.
783    #[test]
784    fn dispatch_r_resolves_to_read_not_redo() {
785        let reg = default_registry::<DefaultHost>();
786        let mut editor = make_editor();
787        // `:r foo` → Error (file doesn't exist), confirming `:r` means `:read` not `:redo`.
788        let result = try_dispatch(&reg, &mut editor, "r /nonexistent_test_path");
789        assert!(
790            matches!(result, Some(ExEffect::Error(_))),
791            ":r of nonexistent file should be Error, got: {result:?}"
792        );
793    }
794
795    // ---- Phase 2c: registers -----------------------------------------------
796
797    #[test]
798    fn dispatch_reg_returns_info_titled_registers() {
799        let reg = default_registry::<DefaultHost>();
800        let mut editor = make_editor();
801        let result = try_dispatch(&reg, &mut editor, "reg");
802        match result {
803            Some(ExEffect::InfoTitled { title, content }) => {
804                assert_eq!(title, "registers");
805                assert!(content.starts_with("--- Registers ---"), "got: {content}");
806            }
807            other => panic!("expected Some(InfoTitled), got {other:?}"),
808        }
809    }
810
811    #[test]
812    fn dispatch_registers_returns_info_titled_registers() {
813        let reg = default_registry::<DefaultHost>();
814        let mut editor = make_editor();
815        let result = try_dispatch(&reg, &mut editor, "registers");
816        match result {
817            Some(ExEffect::InfoTitled { title, content }) => {
818                assert_eq!(title, "registers");
819                assert!(content.starts_with("--- Registers ---"), "got: {content}");
820            }
821            other => panic!("expected Some(InfoTitled), got {other:?}"),
822        }
823    }
824
825    // ---- Phase 2c: marks ---------------------------------------------------
826
827    #[test]
828    fn dispatch_marks_returns_info_titled_marks() {
829        let reg = default_registry::<DefaultHost>();
830        let mut editor = make_editor();
831        let result = try_dispatch(&reg, &mut editor, "marks");
832        match result {
833            Some(ExEffect::InfoTitled { title, content }) => {
834                assert_eq!(title, "marks");
835                assert!(content.starts_with("--- Marks ---"), "got: {content}");
836            }
837            other => panic!("expected Some(InfoTitled), got {other:?}"),
838        }
839    }
840
841    // ---- Phase 2c: jumps ---------------------------------------------------
842
843    #[test]
844    fn dispatch_jumps_returns_info_titled_jumps_empty() {
845        let reg = default_registry::<DefaultHost>();
846        let mut editor = make_editor();
847        let result = try_dispatch(&reg, &mut editor, "jumps");
848        match result {
849            Some(ExEffect::InfoTitled { title, content }) => {
850                assert_eq!(title, "jumps");
851                assert!(content.starts_with("(no jumps"), "got: {content}");
852            }
853            other => panic!("expected Some(InfoTitled), got {other:?}"),
854        }
855    }
856
857    // ---- Phase 2c: changes -------------------------------------------------
858
859    #[test]
860    fn dispatch_changes_returns_info_titled_changes_empty() {
861        let reg = default_registry::<DefaultHost>();
862        let mut editor = make_editor();
863        let result = try_dispatch(&reg, &mut editor, "changes");
864        match result {
865            Some(ExEffect::InfoTitled { title, content }) => {
866                assert_eq!(title, "changes");
867                assert!(content.starts_with("(no changes"), "got: {content}");
868            }
869            other => panic!("expected Some(InfoTitled), got {other:?}"),
870        }
871    }
872
873    // ---- Phase 2c: prefix gating (marks) -----------------------------------
874
875    #[test]
876    fn dispatch_m_returns_none_below_min_prefix() {
877        let reg = default_registry::<DefaultHost>();
878        let mut editor = make_editor();
879        // `:m` — below min_prefix=5 for marks; no other registered command starts with "m"
880        assert_eq!(try_dispatch(&reg, &mut editor, "m"), None);
881    }
882
883    #[test]
884    fn dispatch_mark_returns_none_below_min_prefix() {
885        let reg = default_registry::<DefaultHost>();
886        let mut editor = make_editor();
887        // `:mark` is 4 chars, min_prefix=5 → no match
888        assert_eq!(try_dispatch(&reg, &mut editor, "mark"), None);
889    }
890
891    #[test]
892    fn dispatch_marks_full_name_returns_some() {
893        let reg = default_registry::<DefaultHost>();
894        let mut editor = make_editor();
895        assert!(try_dispatch(&reg, &mut editor, "marks").is_some());
896    }
897
898    // ---- Phase 2c: prefix gating (registers) -------------------------------
899
900    // `:r` resolves to `:read` (existing), `:re` resolves to `:read` (no-args → None).
901    // `:reg` is an alias for `:registers` → Info.
902    #[test]
903    fn dispatch_reg_via_alias_returns_info_titled() {
904        let reg = default_registry::<DefaultHost>();
905        let mut editor = make_editor();
906        assert!(matches!(
907            try_dispatch(&reg, &mut editor, "reg"),
908            Some(ExEffect::InfoTitled { .. })
909        ));
910    }
911
912    #[test]
913    fn dispatch_re_still_resolves_to_read_no_args() {
914        // `:re` — resolves to `:read` (min_prefix=1), no path → None
915        let reg = default_registry::<DefaultHost>();
916        let mut editor = make_editor();
917        assert_eq!(try_dispatch(&reg, &mut editor, "re"), None);
918    }
919
920    // ---- Phase 2d: bare line number / bare range ---------------------------
921
922    #[test]
923    fn dispatch_bare_number_jumps_to_line() {
924        // `:5` on a 5-line buffer → cursor row 4 (0-based).
925        let reg = default_registry::<DefaultHost>();
926        let mut editor = make_editor_with_lines(&["a", "b", "c", "d", "e"]);
927        let result = try_dispatch(&reg, &mut editor, "5");
928        assert_eq!(result, Some(ExEffect::Ok));
929        assert_eq!(editor.cursor().0, 4);
930    }
931
932    #[test]
933    fn dispatch_bare_range_jumps_to_range_start() {
934        // `:1,5` → jump to line 1 (cursor row 0).
935        let reg = default_registry::<DefaultHost>();
936        let mut editor = make_editor_with_lines(&["a", "b", "c", "d", "e"]);
937        let result = try_dispatch(&reg, &mut editor, "1,5");
938        assert_eq!(result, Some(ExEffect::Ok));
939        assert_eq!(editor.cursor().0, 0);
940    }
941
942    // ---- Phase 2d: :delete -------------------------------------------------
943
944    #[test]
945    fn dispatch_d_no_range_deletes_cursor_line() {
946        // `:d` with cursor on line 1 (row 0) → removes first line.
947        let reg = default_registry::<DefaultHost>();
948        let mut editor = make_editor_with_lines(&["first", "second", "third"]);
949        let result = try_dispatch(&reg, &mut editor, "d");
950        assert_eq!(result, Some(ExEffect::Ok));
951        // "first" gone; remaining lines start with "second".
952        assert_eq!(buf_line(&editor, 0), "second");
953        assert_eq!(editor.buffer().row_count(), 2);
954    }
955
956    #[test]
957    fn dispatch_1d_deletes_line_1() {
958        // `:1d` → deletes line 1 from a 3-line buffer.
959        let reg = default_registry::<DefaultHost>();
960        let mut editor = make_editor_with_lines(&["first", "second", "third"]);
961        let result = try_dispatch(&reg, &mut editor, "1d");
962        assert_eq!(result, Some(ExEffect::Ok));
963        assert_eq!(buf_line(&editor, 0), "second");
964        assert_eq!(editor.buffer().row_count(), 2);
965    }
966
967    #[test]
968    fn dispatch_1_2d_deletes_lines_1_and_2() {
969        // `:1,2d` → removes first two lines.
970        let reg = default_registry::<DefaultHost>();
971        let mut editor = make_editor_with_lines(&["first", "second", "third"]);
972        let result = try_dispatch(&reg, &mut editor, "1,2d");
973        assert_eq!(result, Some(ExEffect::Ok));
974        assert_eq!(buf_line(&editor, 0), "third");
975        assert_eq!(editor.buffer().row_count(), 1);
976    }
977
978    // ---- Phase 2d: :sort ---------------------------------------------------
979
980    #[test]
981    fn dispatch_sort_sorts_whole_buffer() {
982        let reg = default_registry::<DefaultHost>();
983        let mut editor = make_editor_with_lines(&["banana", "apple", "cherry"]);
984        let result = try_dispatch(&reg, &mut editor, "sort");
985        assert_eq!(result, Some(ExEffect::Ok));
986        let lines = buf_lines(&editor);
987        assert_eq!(lines, vec!["apple", "banana", "cherry"]);
988    }
989
990    #[test]
991    fn dispatch_1_3sort_sorts_range_only() {
992        // `:1,3sort` on 5-line buffer sorts lines 1–3, leaves 4–5 intact.
993        let reg = default_registry::<DefaultHost>();
994        let mut editor = make_editor_with_lines(&["cherry", "apple", "banana", "zebra", "mango"]);
995        let result = try_dispatch(&reg, &mut editor, "1,3sort");
996        assert_eq!(result, Some(ExEffect::Ok));
997        let lines = buf_lines(&editor);
998        assert_eq!(lines[0], "apple");
999        assert_eq!(lines[1], "banana");
1000        assert_eq!(lines[2], "cherry");
1001        assert_eq!(lines[3], "zebra");
1002        assert_eq!(lines[4], "mango");
1003    }
1004
1005    // ---- Phase 2e: :substitute (:s) ----------------------------------------
1006
1007    #[test]
1008    fn substitute_single_occurrence_on_cursor_line() {
1009        // `:s/foo/bar/` — replace first `foo` on current line (row 0).
1010        let reg = default_registry::<DefaultHost>();
1011        let mut editor = make_editor_with_lines(&["foo"]);
1012        let result = try_dispatch(&reg, &mut editor, "s/foo/bar/");
1013        assert_eq!(
1014            result,
1015            Some(ExEffect::Substituted {
1016                count: 1,
1017                lines_changed: 1
1018            })
1019        );
1020        assert_eq!(buf_line(&editor, 0), "bar");
1021    }
1022
1023    #[test]
1024    fn substitute_global_flag_replaces_all_occurrences() {
1025        // `:s/foo/bar/g` — replace every `foo` on current line.
1026        let reg = default_registry::<DefaultHost>();
1027        let mut editor = make_editor_with_lines(&["foo foo foo"]);
1028        let result = try_dispatch(&reg, &mut editor, "s/foo/bar/g");
1029        assert_eq!(
1030            result,
1031            Some(ExEffect::Substituted {
1032                count: 3,
1033                lines_changed: 1
1034            })
1035        );
1036        assert_eq!(buf_line(&editor, 0), "bar bar bar");
1037    }
1038
1039    #[test]
1040    fn substitute_percent_range_applies_to_all_lines() {
1041        // `:%s/foo/bar/g` — whole buffer.
1042        let reg = default_registry::<DefaultHost>();
1043        let mut editor = make_editor_with_lines(&["foo", "foo bar", "baz"]);
1044        let result = try_dispatch(&reg, &mut editor, "%s/foo/bar/g");
1045        assert_eq!(
1046            result,
1047            Some(ExEffect::Substituted {
1048                count: 2,
1049                lines_changed: 2
1050            })
1051        );
1052        assert_eq!(buf_line(&editor, 0), "bar");
1053        assert_eq!(buf_line(&editor, 1), "bar bar");
1054        assert_eq!(buf_line(&editor, 2), "baz");
1055    }
1056
1057    #[test]
1058    fn substitute_explicit_range_applied_correctly() {
1059        // `:1,2s/x/y/` — only lines 1–2 (0-based 0–1); line 3 unchanged.
1060        let reg = default_registry::<DefaultHost>();
1061        let mut editor = make_editor_with_lines(&["x", "x", "x"]);
1062        let result = try_dispatch(&reg, &mut editor, "1,2s/x/y/");
1063        assert_eq!(
1064            result,
1065            Some(ExEffect::Substituted {
1066                count: 2,
1067                lines_changed: 2
1068            })
1069        );
1070        assert_eq!(buf_line(&editor, 0), "y");
1071        assert_eq!(buf_line(&editor, 1), "y");
1072        assert_eq!(buf_line(&editor, 2), "x"); // untouched
1073    }
1074
1075    #[test]
1076    fn substitute_bad_regex_returns_error() {
1077        // `:s/[bad/` — engine parse_substitute should fail (unclosed `[`).
1078        let reg = default_registry::<DefaultHost>();
1079        let mut editor = make_editor_with_lines(&["foo"]);
1080        let result = try_dispatch(&reg, &mut editor, "s/[bad/foo/");
1081        assert!(
1082            matches!(result, Some(ExEffect::Error(_))),
1083            "expected Some(Error(_)), got {result:?}"
1084        );
1085    }
1086
1087    #[test]
1088    fn substitute_no_body_returns_error() {
1089        // `:s` with no args — parse_substitute("") fails (no leading `/`).
1090        let reg = default_registry::<DefaultHost>();
1091        let mut editor = make_editor_with_lines(&["foo"]);
1092        let result = try_dispatch(&reg, &mut editor, "s");
1093        assert!(
1094            matches!(result, Some(ExEffect::Error(_))),
1095            "expected Some(Error(_)), got {result:?}"
1096        );
1097    }
1098
1099    #[test]
1100    fn substitute_empty_pattern_no_prior_search_returns_error() {
1101        // `:s//bar/` — empty pattern with no last_search → engine error.
1102        let reg = default_registry::<DefaultHost>();
1103        let mut editor = make_editor_with_lines(&["foo"]);
1104        let result = try_dispatch(&reg, &mut editor, "s//bar/");
1105        assert!(
1106            matches!(result, Some(ExEffect::Error(_))),
1107            "expected Some(Error(_)), got {result:?}"
1108        );
1109    }
1110
1111    // ---- Phase 3: :set -------------------------------------------------------
1112
1113    #[test]
1114    fn dispatch_set_bare_returns_info() {
1115        let reg = default_registry::<DefaultHost>();
1116        let mut editor = make_editor();
1117        let result = try_dispatch(&reg, &mut editor, "set");
1118        assert!(
1119            matches!(result, Some(ExEffect::Info(_))),
1120            "expected Some(Info(_)), got {result:?}"
1121        );
1122    }
1123
1124    #[test]
1125    fn dispatch_se_prefix_returns_info() {
1126        // `:se` — min_prefix=2 so resolves to `:set`.
1127        let reg = default_registry::<DefaultHost>();
1128        let mut editor = make_editor();
1129        let result = try_dispatch(&reg, &mut editor, "se");
1130        assert!(
1131            matches!(result, Some(ExEffect::Info(_))),
1132            "expected Some(Info(_)), got {result:?}"
1133        );
1134    }
1135
1136    #[test]
1137    fn dispatch_set_number_enables_number() {
1138        let reg = default_registry::<DefaultHost>();
1139        let mut editor = make_editor();
1140        let result = try_dispatch(&reg, &mut editor, "set number");
1141        assert_eq!(result, Some(ExEffect::Ok));
1142        assert!(editor.settings().number);
1143    }
1144
1145    #[test]
1146    fn dispatch_set_nonumber_disables_number() {
1147        let reg = default_registry::<DefaultHost>();
1148        let mut editor = make_editor();
1149        editor.settings_mut().number = true;
1150        let result = try_dispatch(&reg, &mut editor, "set nonumber");
1151        assert_eq!(result, Some(ExEffect::Ok));
1152        assert!(!editor.settings().number);
1153    }
1154
1155    #[test]
1156    fn dispatch_set_tabstop_eq_4() {
1157        let reg = default_registry::<DefaultHost>();
1158        let mut editor = make_editor();
1159        let result = try_dispatch(&reg, &mut editor, "set tabstop=4");
1160        assert_eq!(result, Some(ExEffect::Ok));
1161        assert_eq!(editor.settings().tabstop, 4);
1162    }
1163
1164    // ---- try_dispatch_host tests -------------------------------------------
1165
1166    struct TestCtx {
1167        counter: i32,
1168    }
1169
1170    struct PingCmd;
1171    impl HostCmd<TestCtx> for PingCmd {
1172        fn name(&self) -> &'static str {
1173            "ping"
1174        }
1175        fn aliases(&self) -> &'static [&'static str] {
1176            &["pn"]
1177        }
1178        fn min_prefix(&self) -> usize {
1179            2
1180        }
1181        fn run(&self, ctx: &mut TestCtx, _args: &str) -> Option<ExEffect> {
1182            ctx.counter += 1;
1183            Some(ExEffect::Ok)
1184        }
1185    }
1186
1187    struct EchoCmd;
1188    impl HostCmd<TestCtx> for EchoCmd {
1189        fn name(&self) -> &'static str {
1190            "echo"
1191        }
1192        fn min_prefix(&self) -> usize {
1193            4
1194        }
1195        fn run(&self, _ctx: &mut TestCtx, args: &str) -> Option<ExEffect> {
1196            if args.is_empty() {
1197                None
1198            } else {
1199                Some(ExEffect::Info(args.to_string()))
1200            }
1201        }
1202    }
1203
1204    fn make_host_registry() -> HostRegistry<TestCtx> {
1205        let mut reg = HostRegistry::new();
1206        reg.add(Box::new(PingCmd));
1207        reg.add(Box::new(EchoCmd));
1208        reg
1209    }
1210
1211    #[test]
1212    fn try_dispatch_host_claims_exact_name() {
1213        let reg = make_host_registry();
1214        let mut ctx = TestCtx { counter: 0 };
1215        let result = try_dispatch_host(&reg, &mut ctx, "ping");
1216        assert_eq!(result, Some(ExEffect::Ok));
1217        assert_eq!(ctx.counter, 1);
1218    }
1219
1220    #[test]
1221    fn try_dispatch_host_claims_alias() {
1222        let reg = make_host_registry();
1223        let mut ctx = TestCtx { counter: 0 };
1224        let result = try_dispatch_host(&reg, &mut ctx, "pn");
1225        assert_eq!(result, Some(ExEffect::Ok));
1226        assert_eq!(ctx.counter, 1);
1227    }
1228
1229    #[test]
1230    fn try_dispatch_host_claims_prefix() {
1231        let reg = make_host_registry();
1232        let mut ctx = TestCtx { counter: 0 };
1233        // "pi" meets min_prefix=2 for "ping"
1234        let result = try_dispatch_host(&reg, &mut ctx, "pi");
1235        assert_eq!(result, Some(ExEffect::Ok));
1236    }
1237
1238    #[test]
1239    fn try_dispatch_host_returns_none_on_miss() {
1240        let reg = make_host_registry();
1241        let mut ctx = TestCtx { counter: 0 };
1242        let result = try_dispatch_host(&reg, &mut ctx, "unknown");
1243        assert!(result.is_none());
1244        assert_eq!(ctx.counter, 0);
1245    }
1246
1247    #[test]
1248    fn try_dispatch_host_returns_none_on_empty_input() {
1249        let reg = make_host_registry();
1250        let mut ctx = TestCtx { counter: 0 };
1251        assert!(try_dispatch_host(&reg, &mut ctx, "").is_none());
1252        assert!(try_dispatch_host(&reg, &mut ctx, "   ").is_none());
1253    }
1254
1255    #[test]
1256    fn try_dispatch_host_passes_args() {
1257        let reg = make_host_registry();
1258        let mut ctx = TestCtx { counter: 0 };
1259        let result = try_dispatch_host(&reg, &mut ctx, "echo hello world");
1260        assert_eq!(result, Some(ExEffect::Info("hello world".to_string())));
1261    }
1262
1263    #[test]
1264    fn try_dispatch_host_defers_when_command_returns_none() {
1265        let reg = make_host_registry();
1266        let mut ctx = TestCtx { counter: 0 };
1267        // echo with no args returns None (defers)
1268        let result = try_dispatch_host(&reg, &mut ctx, "echo");
1269        assert!(result.is_none());
1270    }
1271
1272    // ---- Phase 5a: collect_registry_names + completion integration -----------
1273
1274    fn noop_handler(
1275        _editor: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, DefaultHost>,
1276        _args: &str,
1277        _range: Option<crate::range::LineRange>,
1278    ) -> Option<ExEffect> {
1279        Some(ExEffect::Ok)
1280    }
1281
1282    #[test]
1283    fn collect_registry_names_includes_aliases() {
1284        let mut reg = crate::Registry::<DefaultHost>::new();
1285        reg.add(crate::ExCommand {
1286            name: "test",
1287            aliases: &["t1", "t2"],
1288            arg_kind: crate::ArgKind::None,
1289            min_prefix: 1,
1290            run: noop_handler,
1291        });
1292        let names = collect_registry_names(&reg);
1293        assert!(names.contains(&"test".to_string()));
1294        assert!(names.contains(&"t1".to_string()));
1295        assert!(names.contains(&"t2".to_string()));
1296    }
1297
1298    #[test]
1299    fn default_registry_includes_quit_and_q_bang() {
1300        let reg = default_registry::<DefaultHost>();
1301        let names = collect_registry_names(&reg);
1302        assert!(
1303            names.contains(&"quit".to_string()),
1304            "missing 'quit': {names:?}"
1305        );
1306        assert!(names.contains(&"q!".to_string()), "missing 'q!': {names:?}");
1307    }
1308
1309    #[test]
1310    fn complete_through_default_registry() {
1311        let reg = default_registry::<DefaultHost>();
1312        let names = collect_registry_names(&reg);
1313        let result = complete_command_from_names("qu", 2, &names);
1314        assert_eq!(result.kind, CompletionKind::Command);
1315        assert!(
1316            result.candidates.contains(&"quit".to_string()),
1317            "missing 'quit': {:?}",
1318            result.candidates
1319        );
1320        assert!(
1321            result.candidates.contains(&"quit!".to_string()),
1322            "missing 'quit!': {:?}",
1323            result.candidates
1324        );
1325    }
1326
1327    // ---- Phase 8a: foldindent / foldsyntax -----------------------------------
1328
1329    #[test]
1330    fn dispatch_foldindent_on_indented_buffer_returns_info() {
1331        let reg = default_registry::<DefaultHost>();
1332        let mut editor =
1333            make_editor_with_lines(&["fn foo() {", "    let x = 1;", "    let y = 2;", "}"]);
1334        let result = try_dispatch(&reg, &mut editor, "foldindent");
1335        match result {
1336            Some(ExEffect::Info(msg)) => {
1337                assert!(msg.contains("fold"), "got: {msg}");
1338            }
1339            other => panic!("expected Some(Info(_)), got {other:?}"),
1340        }
1341    }
1342
1343    #[test]
1344    fn dispatch_foldi_prefix_resolves_to_foldindent() {
1345        let reg = default_registry::<DefaultHost>();
1346        let mut editor = make_editor_with_lines(&["fn foo() {", "    x;", "}"]);
1347        // min_prefix=5: "foldi" is 5 chars → resolves
1348        let result = try_dispatch(&reg, &mut editor, "foldi");
1349        assert!(result.is_some());
1350    }
1351
1352    #[test]
1353    fn dispatch_foldsyntax_no_ranges_returns_info() {
1354        let reg = default_registry::<DefaultHost>();
1355        let mut editor = make_editor_with_lines(&["fn foo() {", "    bar();", "}"]);
1356        let result = try_dispatch(&reg, &mut editor, "foldsyntax");
1357        assert_eq!(
1358            result,
1359            Some(ExEffect::Info("no syntax block ranges available".into()))
1360        );
1361    }
1362
1363    // ---- Phase 8a: :read (full impl) ----------------------------------------
1364
1365    #[test]
1366    fn dispatch_r_with_path_inserts_content() {
1367        let reg = default_registry::<DefaultHost>();
1368        let mut editor = make_editor_with_lines(&["line1", "line2"]);
1369        // Write a temp file.
1370        let tmp = tempfile::NamedTempFile::new().unwrap();
1371        std::fs::write(tmp.path(), "inserted\n").unwrap();
1372        let path = tmp.path().to_string_lossy().to_string();
1373        let result = try_dispatch(&reg, &mut editor, &format!("r {path}"));
1374        assert_eq!(result, Some(ExEffect::Ok), "got: {result:?}");
1375        let lines = buf_lines(&editor);
1376        assert!(lines.contains(&"inserted".to_string()), "lines: {lines:?}");
1377    }
1378
1379    #[cfg(unix)]
1380    #[test]
1381    fn dispatch_r_shell_cmd_inserts_output() {
1382        let reg = default_registry::<DefaultHost>();
1383        let mut editor = make_editor_with_lines(&["line1"]);
1384        let result = try_dispatch(&reg, &mut editor, "r !echo hello");
1385        assert_eq!(result, Some(ExEffect::Ok), "got: {result:?}");
1386        let lines = buf_lines(&editor);
1387        assert!(lines.contains(&"hello".to_string()), "lines: {lines:?}");
1388    }
1389
1390    #[test]
1391    fn dispatch_r_missing_file_returns_error() {
1392        let reg = default_registry::<DefaultHost>();
1393        let mut editor = make_editor_with_lines(&["line1"]);
1394        let result = try_dispatch(&reg, &mut editor, "r /nonexistent/path/xyz.txt");
1395        assert!(
1396            matches!(result, Some(ExEffect::Error(_))),
1397            "got: {result:?}"
1398        );
1399    }
1400
1401    // ---- Phase 8a: :!cmd shell filter ----------------------------------------
1402
1403    #[test]
1404    fn dispatch_shell_empty_cmd_returns_error() {
1405        let reg = default_registry::<DefaultHost>();
1406        let mut editor = make_editor_with_lines(&["hello"]);
1407        let result = try_dispatch(&reg, &mut editor, "!");
1408        assert!(
1409            matches!(result, Some(ExEffect::Error(_))),
1410            "got: {result:?}"
1411        );
1412    }
1413
1414    #[cfg(unix)]
1415    #[test]
1416    fn dispatch_shell_no_range_returns_info() {
1417        let reg = default_registry::<DefaultHost>();
1418        let mut editor = make_editor_with_lines(&["hello"]);
1419        let result = try_dispatch(&reg, &mut editor, "!echo hello");
1420        match result {
1421            Some(ExEffect::Info(msg)) => assert!(msg.contains("hello"), "got: {msg}"),
1422            other => panic!("expected Some(Info(_)), got {other:?}"),
1423        }
1424    }
1425
1426    #[cfg(unix)]
1427    #[test]
1428    fn dispatch_shell_range_filter() {
1429        let reg = default_registry::<DefaultHost>();
1430        let mut editor = make_editor_with_lines(&["banana", "apple", "cherry"]);
1431        let result = try_dispatch(&reg, &mut editor, "1,3!sort");
1432        assert_eq!(result, Some(ExEffect::Ok), "got: {result:?}");
1433        let lines = buf_lines(&editor);
1434        assert_eq!(lines[0], "apple");
1435        assert_eq!(lines[1], "banana");
1436        assert_eq!(lines[2], "cherry");
1437    }
1438
1439    // ---- Phase 8a: :global / :vglobal ----------------------------------------
1440
1441    #[test]
1442    fn dispatch_g_d_deletes_matching_lines() {
1443        let reg = default_registry::<DefaultHost>();
1444        let mut editor = make_editor_with_lines(&["foo", "bar", "foo"]);
1445        let result = try_dispatch(&reg, &mut editor, "g/foo/d");
1446        assert!(
1447            matches!(result, Some(ExEffect::Substituted { count: 2, .. })),
1448            "got: {result:?}"
1449        );
1450        let lines = buf_lines(&editor);
1451        assert!(!lines.contains(&"foo".to_string()), "lines: {lines:?}");
1452    }
1453
1454    #[test]
1455    fn dispatch_v_d_deletes_non_matching_lines() {
1456        let reg = default_registry::<DefaultHost>();
1457        let mut editor = make_editor_with_lines(&["foo", "bar", "baz"]);
1458        let result = try_dispatch(&reg, &mut editor, "v/foo/d");
1459        assert!(
1460            matches!(result, Some(ExEffect::Substituted { .. })),
1461            "got: {result:?}"
1462        );
1463        let lines = buf_lines(&editor);
1464        assert!(!lines.contains(&"bar".to_string()));
1465        assert!(!lines.contains(&"baz".to_string()));
1466    }
1467
1468    #[test]
1469    fn dispatch_global_full_name_works() {
1470        let reg = default_registry::<DefaultHost>();
1471        let mut editor = make_editor_with_lines(&["foo", "bar"]);
1472        let result = try_dispatch(&reg, &mut editor, "global/foo/d");
1473        assert!(matches!(result, Some(ExEffect::Substituted { .. })));
1474    }
1475
1476    #[test]
1477    fn dispatch_vglobal_full_name_works() {
1478        let reg = default_registry::<DefaultHost>();
1479        let mut editor = make_editor_with_lines(&["foo", "bar"]);
1480        let result = try_dispatch(&reg, &mut editor, "vglobal/foo/d");
1481        assert!(matches!(result, Some(ExEffect::Substituted { .. })));
1482    }
1483
1484    // ---- Phase 8a: search-as-address -----------------------------------------
1485
1486    #[test]
1487    fn dispatch_search_forward_jumps_to_line() {
1488        let reg = default_registry::<DefaultHost>();
1489        let mut editor = make_editor_with_lines(&["apple", "banana", "cherry"]);
1490        let result = try_dispatch(&reg, &mut editor, "/banana");
1491        assert_eq!(result, Some(ExEffect::Ok), "got: {result:?}");
1492        assert_eq!(editor.cursor().0, 1, "cursor should be on row 1 (banana)");
1493    }
1494
1495    #[test]
1496    fn dispatch_search_backward_jumps_to_line() {
1497        let reg = default_registry::<DefaultHost>();
1498        let mut editor = make_editor_with_lines(&["apple", "banana", "cherry"]);
1499        // Move cursor to row 2 (cherry) first.
1500        editor.goto_line(3);
1501        let result = try_dispatch(&reg, &mut editor, "?apple");
1502        assert_eq!(result, Some(ExEffect::Ok), "got: {result:?}");
1503        assert_eq!(editor.cursor().0, 0, "cursor should be on row 0 (apple)");
1504    }
1505
1506    #[test]
1507    fn dispatch_search_bad_pattern_returns_error() {
1508        let reg = default_registry::<DefaultHost>();
1509        let mut editor = make_editor_with_lines(&["foo"]);
1510        let result = try_dispatch(&reg, &mut editor, "/[bad");
1511        assert!(
1512            matches!(result, Some(ExEffect::Error(_))),
1513            "got: {result:?}"
1514        );
1515    }
1516
1517    #[test]
1518    fn dispatch_search_empty_no_prior_returns_error() {
1519        let reg = default_registry::<DefaultHost>();
1520        let mut editor = make_editor_with_lines(&["foo"]);
1521        let result = try_dispatch(&reg, &mut editor, "/");
1522        assert!(
1523            matches!(result, Some(ExEffect::Error(_))),
1524            "got: {result:?}"
1525        );
1526    }
1527
1528    // ---- :& / :&& repeat-last-substitute ------------------------------------
1529
1530    #[test]
1531    fn dispatch_amp_no_prior_sub_returns_error() {
1532        let reg = default_registry::<DefaultHost>();
1533        let mut editor = make_editor_with_lines(&["foo"]);
1534        let result = try_dispatch(&reg, &mut editor, "&");
1535        assert!(
1536            matches!(result, Some(ExEffect::Error(_))),
1537            "expected Error, got {result:?}"
1538        );
1539    }
1540
1541    #[test]
1542    fn dispatch_amp_repeats_last_sub_on_current_line() {
1543        let reg = default_registry::<DefaultHost>();
1544        let mut editor = make_editor_with_lines(&["foo", "foo"]);
1545        let r1 = try_dispatch(&reg, &mut editor, "s/foo/bar/");
1546        assert!(
1547            matches!(r1, Some(ExEffect::Substituted { count: 1, .. })),
1548            "got: {r1:?}"
1549        );
1550        assert_eq!(buf_line(&editor, 0), "bar");
1551        editor.goto_line(2);
1552        let r2 = try_dispatch(&reg, &mut editor, "&");
1553        assert!(
1554            matches!(r2, Some(ExEffect::Substituted { count: 1, .. })),
1555            "expected Substituted(1), got {r2:?}"
1556        );
1557        assert_eq!(buf_line(&editor, 1), "bar");
1558    }
1559
1560    #[test]
1561    fn dispatch_amp_amp_keeps_global_flag() {
1562        let reg = default_registry::<DefaultHost>();
1563        let mut editor = make_editor_with_lines(&["x x x", "x x x"]);
1564        try_dispatch(&reg, &mut editor, "s/x/y/g").unwrap();
1565        assert_eq!(buf_line(&editor, 0), "y y y");
1566        editor.goto_line(2);
1567        let result = try_dispatch(&reg, &mut editor, "&&");
1568        assert!(
1569            matches!(result, Some(ExEffect::Substituted { count: 3, .. })),
1570            "expected Substituted(3), got {result:?}"
1571        );
1572        assert_eq!(buf_line(&editor, 1), "y y y");
1573    }
1574
1575    #[test]
1576    fn dispatch_amp_drops_global_flag() {
1577        let reg = default_registry::<DefaultHost>();
1578        let mut editor = make_editor_with_lines(&["x x x", "x x x"]);
1579        try_dispatch(&reg, &mut editor, "s/x/y/g").unwrap();
1580        assert_eq!(buf_line(&editor, 0), "y y y");
1581        editor.goto_line(2);
1582        let result = try_dispatch(&reg, &mut editor, "&");
1583        assert!(
1584            matches!(result, Some(ExEffect::Substituted { count: 1, .. })),
1585            "expected Substituted(1) (first only), got {result:?}"
1586        );
1587        assert_eq!(buf_line(&editor, 1), "y x x");
1588    }
1589
1590    #[test]
1591    fn dispatch_percent_amp_repeats_on_whole_buffer() {
1592        let reg = default_registry::<DefaultHost>();
1593        let mut editor = make_editor_with_lines(&["foo", "foo", "bar"]);
1594        try_dispatch(&reg, &mut editor, "s/foo/baz/").unwrap();
1595        assert_eq!(buf_line(&editor, 0), "baz");
1596        let result = try_dispatch(&reg, &mut editor, "%&");
1597        assert!(
1598            matches!(result, Some(ExEffect::Substituted { count: 1, .. })),
1599            "expected Substituted(1), got {result:?}"
1600        );
1601        assert_eq!(buf_line(&editor, 1), "baz");
1602        assert_eq!(buf_line(&editor, 2), "bar");
1603    }
1604}