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