1use std::collections::HashMap;
20
21use thiserror::Error;
22
23use lattice_grammar::registry::{CommandRegistry, MotionId, TextObjectId};
24use lattice_grammar::{ArgValue, Args, CommandInvocation, CommandKind, Range, Target};
25
26#[derive(Debug, Error, PartialEq, Eq)]
27pub enum ExCommandError {
28 #[error("empty command")]
29 Empty,
30 #[error("unknown command: {0}")]
31 Unknown(String),
32 #[error("trailing characters after command")]
33 TrailingArgs,
34 #[error("malformed substitute: {0}")]
35 BadSubstitute(&'static str),
36 #[error("invalid args: {0}")]
37 BadArgs(String),
38 #[error("`!` is not allowed for `{0}`")]
39 BangNotAllowed(String),
40 #[error("`{name}` uses delimiter syntax; type `{hint}`")]
44 WrongSurfaceForm {
45 name: String,
46 hint: std::borrow::Cow<'static, str>,
50 },
51}
52
53pub fn parse(line: &str, registry: &CommandRegistry) -> Result<CommandInvocation, ExCommandError> {
56 let trimmed = line.trim();
57 if trimmed.is_empty() {
58 return Err(ExCommandError::Empty);
59 }
60
61 if let Some(rest) = trimmed.strip_prefix("'<,'>") {
69 let inner = parse(rest, registry)?;
70 return Ok(inner.with_range(Range::Selection));
71 }
72
73 if let Ok(n) = trimmed.parse::<u32>() {
77 let id = registry
78 .id_by_name("motion:goto-last-line")
79 .ok_or_else(|| ExCommandError::Unknown(trimmed.to_string()))?;
80 return Ok(lattice_grammar::CommandInvocation::of(id)
81 .with_count(lattice_grammar::command::Count(n.max(1))));
82 }
83
84 if let Some(inv) = try_parse_substitute(trimmed, registry)? {
87 return Ok(inv);
88 }
89 if let Some(inv) = try_parse_global(trimmed, registry)? {
90 return Ok(inv);
91 }
92
93 parse_invocation(trimmed, registry)
94}
95
96fn parse_invocation(
100 trimmed: &str,
101 registry: &CommandRegistry,
102) -> Result<CommandInvocation, ExCommandError> {
103 let (raw_cmd, rest) = match trimmed.find(char::is_whitespace) {
106 Some(i) => (&trimmed[..i], trimmed[i..].trim()),
107 None => (trimmed, ""),
108 };
109 let (cmd, bang) = if let Some(stripped) = raw_cmd.strip_suffix('!') {
110 (stripped, true)
111 } else {
112 (raw_cmd, false)
113 };
114
115 if let Some(kind) = parse_kind_word(cmd) {
123 if bang {
124 return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
125 }
126 return parse_kind_prefixed(registry, kind, rest);
127 }
128
129 let id = if let Some(id) = registry.id_by_name(cmd) {
138 id
139 } else if let Some(canonical) = expand_alias(cmd) {
140 registry
141 .id_by_name(canonical)
142 .ok_or_else(|| ExCommandError::Unknown(raw_cmd.to_string()))?
143 } else {
144 return Err(ExCommandError::Unknown(raw_cmd.to_string()));
145 };
146
147 let entry = registry
148 .lookup(id)
149 .ok_or_else(|| ExCommandError::Unknown(raw_cmd.to_string()))?;
150
151 match entry.kind {
163 CommandKind::ExCommand => parse_ex_command(registry, id, entry, raw_cmd, rest, bang),
164 CommandKind::Motion => parse_naked_motion(id, raw_cmd, rest, bang),
165 CommandKind::Operator => parse_operator_with_target(registry, id, raw_cmd, rest, bang),
166 CommandKind::TextObject => Err(ExCommandError::BadArgs(format!(
167 "`{cmd}` is a text-object; pair it with an operator \
168 (`:operator delete {cmd}`) or use chord grammar"
169 ))),
170 CommandKind::Action => parse_naked_action(id, raw_cmd, rest, bang),
171 }
172}
173
174fn parse_kind_word(s: &str) -> Option<CommandKind> {
179 match s {
180 "motion" => Some(CommandKind::Motion),
181 "operator" => Some(CommandKind::Operator),
182 "text-object" => Some(CommandKind::TextObject),
183 _ => None,
184 }
185}
186
187fn parse_kind_prefixed(
193 registry: &CommandRegistry,
194 kind: CommandKind,
195 rest: &str,
196) -> Result<CommandInvocation, ExCommandError> {
197 let trimmed = rest.trim();
198 let (tail, more) = match trimmed.find(char::is_whitespace) {
199 Some(i) => (&trimmed[..i], trimmed[i..].trim()),
200 None => (trimmed, ""),
201 };
202 if tail.is_empty() {
203 return Err(ExCommandError::BadArgs(format!(
204 "`{}` requires a name (e.g. `:{} <name>`)",
205 kind.label(),
206 kind.label()
207 )));
208 }
209 let canonical = format!("{}:{}", kind.label(), tail);
210 let id = registry
211 .id_by_name(&canonical)
212 .ok_or_else(|| ExCommandError::Unknown(canonical.clone()))?;
213 let entry = registry
217 .lookup(id)
218 .ok_or_else(|| ExCommandError::Unknown(canonical.clone()))?;
219 debug_assert_eq!(entry.kind, kind, "kind-prefix lookup inconsistency");
220 let _ = entry;
221 match kind {
222 CommandKind::Motion => parse_naked_motion(id, &canonical, more, false),
223 CommandKind::Operator => parse_operator_with_target(registry, id, &canonical, more, false),
224 CommandKind::TextObject => Err(ExCommandError::BadArgs(format!(
225 "`{tail}` is a text-object; pair it with an operator \
226 (`:operator delete {tail}`) or use chord grammar"
227 ))),
228 CommandKind::ExCommand | CommandKind::Action => unreachable!(),
229 }
230}
231
232fn parse_ex_command(
235 registry: &CommandRegistry,
236 id: lattice_grammar::CommandId,
237 entry: &lattice_grammar::CommandSpec,
238 raw_cmd: &str,
239 rest: &str,
240 bang: bool,
241) -> Result<CommandInvocation, ExCommandError> {
242 let spec =
243 ex_spec_for(registry, id).ok_or_else(|| ExCommandError::Unknown(raw_cmd.to_string()))?;
244 if let lattice_grammar::SurfaceForm::Delimiter { hint } = &spec.surface_form {
255 return Err(ExCommandError::WrongSurfaceForm {
256 name: entry.name.clone(),
257 hint: hint.clone(),
258 });
259 }
260 if bang && !spec.accepts_bang {
261 return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
262 }
263 let args = (spec.parse_args)(rest, bang).map_err(|e| match e {
264 lattice_grammar::CommandError::BadArgs(msg) => ExCommandError::BadArgs(msg),
265 other => ExCommandError::BadArgs(other.to_string()),
266 })?;
267
268 Ok(CommandInvocation::of(id).with_args(args).with_bang(bang))
269}
270
271fn parse_naked_motion(
277 id: lattice_grammar::CommandId,
278 raw_cmd: &str,
279 rest: &str,
280 bang: bool,
281) -> Result<CommandInvocation, ExCommandError> {
282 if bang {
283 return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
284 }
285 if !rest.is_empty() {
286 return Err(ExCommandError::TrailingArgs);
287 }
288 Ok(CommandInvocation::of(id))
289}
290
291fn parse_naked_action(
313 id: lattice_grammar::CommandId,
314 raw_cmd: &str,
315 rest: &str,
316 bang: bool,
317) -> Result<CommandInvocation, ExCommandError> {
318 if bang {
319 return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
320 }
321 let invocation = CommandInvocation::of(id);
322 Ok(if rest.trim().is_empty() {
323 invocation
324 } else {
325 invocation.with_args(lattice_grammar::Args::String(rest.trim().to_string()))
326 })
327}
328
329fn parse_operator_with_target(
344 registry: &CommandRegistry,
345 id: lattice_grammar::CommandId,
346 raw_cmd: &str,
347 rest: &str,
348 bang: bool,
349) -> Result<CommandInvocation, ExCommandError> {
350 if bang {
351 return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
352 }
353 let trimmed = rest.trim();
354 if trimmed.is_empty() {
355 return Err(ExCommandError::BadArgs(format!(
356 "`{raw_cmd}` requires a target; use chord grammar (e.g. `dw`) \
357 or pass a motion / text-object name \
358 (`:operator delete word-forward`)"
359 )));
360 }
361 let target_id = registry
366 .id_by_name(trimmed)
367 .or_else(|| registry.id_by_name(&format!("motion:{trimmed}")))
368 .or_else(|| registry.id_by_name(&format!("text-object:{trimmed}")))
369 .ok_or_else(|| ExCommandError::Unknown(trimmed.to_string()))?;
370 let target_entry = registry
371 .lookup(target_id)
372 .ok_or_else(|| ExCommandError::Unknown(trimmed.to_string()))?;
373 let target = match target_entry.kind {
374 CommandKind::Motion => Target::Motion(MotionId(target_id), Args::None),
375 CommandKind::TextObject => Target::TextObject(TextObjectId(target_id), Args::None),
376 _ => {
377 return Err(ExCommandError::BadArgs(format!(
378 "`{trimmed}` is not a motion or text-object \
379 (operators take motion/text-object targets only)"
380 )));
381 }
382 };
383 Ok(CommandInvocation::of(id).with_target(target))
384}
385
386fn ex_spec_for(
391 registry: &CommandRegistry,
392 id: lattice_grammar::CommandId,
393) -> Option<&lattice_grammar::ExCommandSpec> {
394 registry.ex_command_spec(id)
395}
396
397fn expand_alias(cmd: &str) -> Option<&'static str> {
402 ALIAS_TABLE
403 .iter()
404 .find_map(|(short, canon)| (*short == cmd).then_some(*canon))
405}
406
407static ALIAS_TABLE: &[(&str, &str)] = &[
411 ("w", "ex:write"),
412 ("write", "ex:write"),
413 ("q", "ex:quit"),
414 ("quit", "ex:quit"),
415 ("wq", "ex:write-quit"),
416 ("x", "ex:write-quit"),
417 ("qa", "ex:quit-all"),
420 ("qall", "ex:quit-all"),
421 ("quitall", "ex:quit-all"),
422 ("only", "ex:only"),
425 ("on", "ex:only"),
426 ("split", "ex:split"),
428 ("sp", "ex:split"),
429 ("vsplit", "ex:vsplit"),
430 ("vsp", "ex:vsplit"),
431 ("vs", "ex:vsplit"),
432 ("close", "ex:close"),
433 ("clo", "ex:close"),
434 ("zoom-pane", "ex:zoom-pane"),
440 ("noh", "ex:nohlsearch"),
441 ("nohl", "ex:nohlsearch"),
442 ("nohlsearch", "ex:nohlsearch"),
443 ("reg", "ex:registers"),
444 ("registers", "ex:registers"),
445 ("marks", "ex:marks"),
446 ("d", "ex:delete"),
447 ("delete", "ex:delete"),
448 ("set", "ex:set"),
449 ("colorscheme", "ex:colorscheme"),
451 ("colo", "ex:colorscheme"),
452 ("setlocal", "ex:setlocal"),
453 ("sl", "ex:setlocal"),
454 ("setglobal", "ex:setglobal"),
455 ("sg", "ex:setglobal"),
456 ("e", "ex:edit"),
457 ("edit", "ex:edit"),
458 ("describe-command", "ex:describe-command"),
459 ("describe-buffer", "ex:describe-buffer"),
460 ("apropos", "ex:apropos"),
461 ("describe-key", "ex:describe-key"),
462 ("keymap", "ex:keymap"),
463 ("bn", "ex:bnext"),
464 ("bnext", "ex:bnext"),
465 ("bp", "ex:bprev"),
466 ("bprev", "ex:bprev"),
467 ("tabn", "ex:tabnext"),
469 ("tabnext", "ex:tabnext"),
470 ("tabp", "ex:tabprev"),
471 ("tabprev", "ex:tabprev"),
472 ("tabprevious", "ex:tabprev"),
473 ("tabN", "ex:tabprev"),
474 ("tabnew", "ex:tabnew"),
475 ("tabe", "ex:tabnew"),
476 ("tabedit", "ex:tabnew"),
477 ("tabc", "ex:tabclose"),
478 ("tabclose", "ex:tabclose"),
479 ("term", "ex:terminal"),
481 ("terminal", "ex:terminal"),
482 ("tnew", "ex:terminal"),
483 ("tabterminal", "ex:tabterminal"),
486 ("tabterm", "ex:tabterminal"),
487 ("tabo", "ex:tabonly"),
488 ("tabonly", "ex:tabonly"),
489 ("tabm", "ex:tabmove"),
490 ("tabmove", "ex:tabmove"),
491 ("ls", "ex:buffers"),
496 ("buffers", "ex:buffer-picker"),
497 ("bd", "ex:bdelete"),
498 ("bdelete", "ex:bdelete"),
499 ("b", "ex:buffer-picker"),
500 ("picker", "ex:picker"),
505 ("files", "ex:files"),
506 ("recent", "ex:recent"),
507 ("history", "ex:history"),
508 ("Tree", "ex:filetree"),
509 ("tree", "ex:filetree"),
510 ("Filetree", "ex:filetree"),
511 ("filetree", "ex:filetree"),
512 ("TreeClose", "ex:filetree-close"),
513 ("FiletreeClose", "ex:filetree-close"),
514 ("filetree-close", "ex:filetree-close"),
515 ("Oil", "ex:oil"),
517 ("oil", "ex:oil"),
518 ("format", "ex:format"),
520 ("reload-snippets", "ex:reload-snippets"),
521 ("describe-option", "ex:describe-option"),
522 ("describe-element", "ex:describe-element"),
526 ("describe-face", "ex:describe-element"),
527 ("options", "ex:options"),
528 ("describe-plugin-api", "ex:describe-plugin-api"),
530 ("list-plugin-apis", "ex:list-plugin-apis"),
531 ("export-plugin-api", "ex:export-plugin-api"),
532 ("list-commands", "ex:list-commands"),
533 ("describe-plugin", "ex:describe-plugin"),
534 ("list-plugins", "ex:list-plugins"),
535 ("describe-events", "ex:describe-events"),
536 ("describe-event", "ex:describe-event"),
537 ("list-modes", "ex:list-modes"),
542 ("describe-mode", "ex:describe-mode"),
543 ("describe-active-modes", "ex:describe-active-modes"),
547 ("describe-bindings", "ex:describe-bindings"),
548 (
549 "describe-option-resolution",
550 "ex:describe-option-resolution",
551 ),
552 ("customize", "ex:customize"),
553 ("tutor", "ex:tutor"),
554 ("Tutor", "ex:tutor"),
555 ("tutor-next", "ex:tutor-next"),
556 ("tutor-prev", "ex:tutor-prev"),
557 ("hover", "ex:hover"),
558 ("HoverClose", "ex:hover-close"),
559 ("h", "ex:help"),
560 ("help", "ex:help"),
561 ("help-for-help", "ex:help"),
566 ("diagnostics", "ex:diagnostics"),
567 ("diag", "ex:diagnostics"),
568 ("diag-next", "ex:diag-next"),
569 ("dnext", "ex:diag-next"),
570 ("diag-prev", "ex:diag-prev"),
571 ("dprev", "ex:diag-prev"),
572 ("cnext", "next-error"),
580 ("cn", "next-error"),
581 ("cprev", "previous-error"),
582 ("cp", "previous-error"),
583 ("cc", "error"),
584 ("clist", "error-list"),
586 ("cl", "error-list"),
587 ("cfirst", "first-error"),
588 ("crewind", "first-error"),
589 ("cr", "first-error"),
590 ("clast", "last-error"),
591 ("cnextfile", "next-error-file"),
593 ("cnf", "next-error-file"),
594 ("cprevfile", "previous-error-file"),
595 ("cpf", "previous-error-file"),
596 ("copen", "problems"),
598 ("cclose", "problems-close"),
599 ("messages", "ex:messages"),
630 ("msg", "ex:messages"),
631 ("lsp-log", "ex:lsp-log"),
632 ("lsp-trace", "ex:lsp-trace"),
633 ("lsp-trace-log", "ex:lsp-trace-log"),
634 ("lsp-status", "ex:lsp-status"),
635 ("lsp-references", "ex:lsp-references"),
637 (
639 "lsp-references-to-error-list",
640 "ex:lsp-references-to-error-list",
641 ),
642 (
645 "lsp-diagnostics-to-error-list",
646 "ex:lsp-diagnostics-to-error-list",
647 ),
648 ("lsp-server-log", "ex:lsp-server-log"),
649 ("lsp-restart", "ex:lsp-restart"),
650 ("lsp-progress-cancel", "ex:lsp-progress-cancel"),
651 ("lsp-expand-region", "ex:lsp-expand-region"),
652 ("lsp-shrink-region", "ex:lsp-shrink-region"),
653 ("lsp-log-level", "ex:lsp-log-level"),
654 ("lsp-log-clear", "ex:lsp-log-clear"),
655 ("lsp-symbols", "ex:lsp-symbols"),
657 ("lsp-workspace-symbol", "ex:lsp-workspace-symbol"),
658 ("lsp-incoming-calls", "ex:lsp-incoming-calls"),
660 ("lsp-outgoing-calls", "ex:lsp-outgoing-calls"),
661 ("lsp-supertypes", "ex:lsp-supertypes"),
663 ("lsp-subtypes", "ex:lsp-subtypes"),
664 ("lsp-moniker", "ex:lsp-moniker"),
666 ("lsp-code-lens", "ex:lsp-code-lens"),
668 ("lsp-color-presentation", "ex:lsp-color-presentation"),
670 ("lsp-format", "ex:lsp-format"),
672 ("lsp-format-range", "ex:lsp-format-range"),
673 ("lsp-signature-help", "ex:lsp-signature-help"),
674 ("lsp-complete", "ex:lsp-complete"),
675 ("lsp-rename", "ex:lsp-rename"),
676 ("lsp-code-action", "ex:lsp-code-action"),
677 ("cd", "ex:cd"),
678 ("chdir", "ex:cd"),
679 ("pwd", "ex:pwd"),
680 ("project-root", "ex:project-root"),
683];
684
685pub fn aliases() -> HashMap<&'static str, &'static str> {
688 ALIAS_TABLE.iter().copied().collect()
689}
690
691pub fn resolve_command_name_or_alias(
700 registry: &lattice_grammar::CommandRegistry,
701 name: &str,
702) -> Option<lattice_grammar::CommandId> {
703 if let Some(id) = registry.id_by_name(name) {
704 return Some(id);
705 }
706 let canonical = aliases().get(name).copied()?;
707 registry.id_by_name(canonical)
708}
709
710pub fn preferred_alias_for(canonical: &str) -> Option<&'static str> {
721 ALIAS_TABLE
722 .iter()
723 .filter(|(_, c)| *c == canonical)
724 .map(|(short, _)| *short)
725 .max_by_key(|s| s.len())
726}
727
728fn try_parse_global(
732 input: &str,
733 registry: &CommandRegistry,
734) -> Result<Option<CommandInvocation>, ExCommandError> {
735 let (inverted, rest) = if let Some(rest) = input.strip_prefix("g/") {
736 (false, rest)
737 } else if let Some(rest) = input.strip_prefix("v/") {
738 (true, rest)
739 } else {
740 return Ok(None);
741 };
742 let mut pattern = String::new();
744 let mut chars = rest.chars().peekable();
745 let mut found_delim = false;
746 while let Some(c) = chars.next() {
747 if c == '\\' {
748 if let Some(next) = chars.next() {
749 pattern.push(next);
750 }
751 continue;
752 }
753 if c == '/' {
754 found_delim = true;
755 break;
756 }
757 pattern.push(c);
758 }
759 if !found_delim {
760 return Err(ExCommandError::BadSubstitute(
761 "missing closing `/` after pattern",
762 ));
763 }
764 if pattern.is_empty() {
765 return Err(ExCommandError::BadSubstitute("empty pattern"));
766 }
767 let body: String = chars.collect();
768 if body.is_empty() {
769 return Err(ExCommandError::BadSubstitute("empty body"));
770 }
771 let body_inv = parse(&body, registry)?;
775 let id = registry
776 .id_by_name("ex:global")
777 .ok_or_else(|| ExCommandError::Unknown("ex:global".into()))?;
778 Ok(Some(CommandInvocation::of(id).with_args(Args::List(vec![
779 ArgValue::Pattern(pattern),
780 ArgValue::Bool(inverted),
781 ArgValue::Invocation(Box::new(body_inv)),
782 ]))))
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
793pub enum SubstitutePartialScope {
794 CurrentLine,
795 Whole,
796}
797
798#[derive(Debug, Clone, PartialEq, Eq)]
806pub struct SubstitutePartial {
807 pub scope: SubstitutePartialScope,
808 pub pattern: String,
809 pub replacement: Option<String>,
813 pub flags: Option<String>,
817}
818
819pub fn try_parse_substitute_partial(input: &str) -> Option<SubstitutePartial> {
826 let (scope, body) = if let Some(rest) = input.strip_prefix("%s/") {
827 (SubstitutePartialScope::Whole, rest)
828 } else if let Some(rest) = input.strip_prefix("s/") {
829 (SubstitutePartialScope::CurrentLine, rest)
830 } else {
831 return None;
832 };
833
834 let mut pattern = String::new();
835 let mut replacement: Option<String> = None;
836 let mut flags: Option<String> = None;
837 let mut state = 0u8; let mut chars = body.chars().peekable();
839 while let Some(c) = chars.next() {
840 if c == '\\' {
841 if let Some(next) = chars.next() {
842 match state {
843 0 => pattern.push(next),
844 1 => replacement.get_or_insert_with(String::new).push(next),
845 _ => flags.get_or_insert_with(String::new).push(next),
846 }
847 } else {
848 match state {
852 0 => pattern.push('\\'),
853 1 => replacement.get_or_insert_with(String::new).push('\\'),
854 _ => flags.get_or_insert_with(String::new).push('\\'),
855 }
856 }
857 continue;
858 }
859 if c == '/' {
860 state += 1;
861 if state == 1 {
862 replacement = Some(String::new());
863 } else if state == 2 {
864 flags = Some(String::new());
865 }
866 if state > 2 {
870 flags.get_or_insert_with(String::new).push('/');
871 state = 2;
872 }
873 continue;
874 }
875 match state {
876 0 => pattern.push(c),
877 1 => replacement.get_or_insert_with(String::new).push(c),
878 _ => flags.get_or_insert_with(String::new).push(c),
879 }
880 }
881
882 Some(SubstitutePartial {
883 scope,
884 pattern,
885 replacement,
886 flags,
887 })
888}
889
890fn try_parse_substitute(
891 input: &str,
892 registry: &CommandRegistry,
893) -> Result<Option<CommandInvocation>, ExCommandError> {
894 let (range, body) = if let Some(rest) = input.strip_prefix("%s/") {
895 (Range::Whole, rest)
896 } else if let Some(rest) = input.strip_prefix("s/") {
897 (Range::CurrentLine, rest)
898 } else {
899 return Ok(None);
900 };
901
902 let mut pattern = String::new();
906 let mut replacement = String::new();
907 let mut flags = String::new();
908 let mut state = 0u8; let mut chars = body.chars().peekable();
910 while let Some(c) = chars.next() {
911 if c == '\\' {
912 if let Some(next) = chars.next() {
914 let target = match state {
915 0 => &mut pattern,
916 1 => &mut replacement,
917 _ => &mut flags,
918 };
919 target.push(next);
920 }
921 continue;
922 }
923 if c == '/' {
924 state += 1;
925 if state > 2 {
926 return Err(ExCommandError::BadSubstitute("too many `/` separators"));
927 }
928 continue;
929 }
930 let target = match state {
931 0 => &mut pattern,
932 1 => &mut replacement,
933 _ => &mut flags,
934 };
935 target.push(c);
936 }
937
938 if pattern.is_empty() {
939 return Err(ExCommandError::BadSubstitute("empty pattern"));
940 }
941 let id = registry
942 .id_by_name("ex:substitute")
943 .ok_or_else(|| ExCommandError::Unknown("ex:substitute".into()))?;
944 Ok(Some(CommandInvocation::of(id).with_range(range).with_args(
945 Args::List(vec![
946 ArgValue::Pattern(pattern),
947 ArgValue::String(replacement),
948 ArgValue::String(flags),
949 ]),
950 )))
951}
952
953use lattice_cells::style::Style;
967
968#[derive(Debug, Clone, PartialEq, Eq)]
972pub struct CommandLineSpan {
973 pub range: std::ops::Range<usize>,
974 pub style: Style,
975}
976
977#[derive(Debug, Clone, Default, PartialEq, Eq)]
980pub struct CommandLineDecorations {
981 pub spans: Vec<CommandLineSpan>,
983 pub error: Option<String>,
987 pub param_hint: Option<String>,
990}
991
992impl CommandLineDecorations {
993 pub fn is_empty(&self) -> bool {
995 self.spans.is_empty() && self.error.is_none() && self.param_hint.is_none()
996 }
997}
998
999fn leading_range_len(line: &str) -> Option<usize> {
1004 if line.starts_with("'<,'>") {
1005 Some("'<,'>".len())
1006 } else if line.starts_with('%') {
1007 Some(1)
1008 } else {
1009 None
1010 }
1011}
1012
1013fn format_arg_hint(schema: &[lattice_grammar::args::ArgSpec]) -> String {
1017 use lattice_grammar::args::ArgDefault;
1018 schema
1019 .iter()
1020 .map(|a| match a.default {
1021 ArgDefault::Required => format!("<{}>", a.name),
1022 _ => format!("[<{}>]", a.name),
1023 })
1024 .collect::<Vec<_>>()
1025 .join(" ")
1026}
1027
1028fn tokenize_substitute(line: &str, start: usize, spans: &mut Vec<CommandLineSpan>) {
1033 spans.push(CommandLineSpan {
1035 range: start..start + 1,
1036 style: Style::Keyword,
1037 });
1038 let mut state = 0u8;
1040 let mut field_start: Option<usize> = None;
1041 let mut chars = line[start + 1..].char_indices().peekable();
1042 while let Some((rel, c)) = chars.next() {
1043 let at = start + 1 + rel;
1044 if c == '\\' {
1045 field_start.get_or_insert(at);
1047 let _ = chars.next();
1048 continue;
1049 }
1050 if c == '/' {
1051 if let Some(fs) = field_start.take() {
1053 spans.push(CommandLineSpan {
1054 range: fs..at,
1055 style: field_style(state),
1056 });
1057 }
1058 spans.push(CommandLineSpan {
1059 range: at..at + c.len_utf8(),
1060 style: Style::Punctuation,
1061 });
1062 state = state.saturating_add(1).min(2);
1063 continue;
1064 }
1065 field_start.get_or_insert(at);
1066 }
1067 if let Some(fs) = field_start {
1068 spans.push(CommandLineSpan {
1069 range: fs..line.len(),
1070 style: field_style(state),
1071 });
1072 }
1073}
1074
1075fn field_style(state: u8) -> Style {
1076 match state {
1077 0 | 1 => Style::String, _ => Style::Attribute, }
1080}
1081
1082pub fn command_line_decorations(line: &str, registry: &CommandRegistry) -> CommandLineDecorations {
1086 let mut deco = CommandLineDecorations::default();
1087 if line.trim().is_empty() {
1088 return deco;
1089 }
1090 let end = line.trim_end().len();
1091
1092 if line.trim().chars().all(|c| c.is_ascii_digit()) {
1094 deco.spans.push(CommandLineSpan {
1095 range: 0..end,
1096 style: Style::Number,
1097 });
1098 return deco;
1099 }
1100
1101 let mut cursor = 0usize;
1103 if let Some(rng) = leading_range_len(line) {
1104 deco.spans.push(CommandLineSpan {
1105 range: 0..rng,
1106 style: Style::Number,
1107 });
1108 cursor = rng;
1109 }
1110 let rest = &line[cursor..];
1111
1112 if rest.starts_with("s/") {
1114 tokenize_substitute(line, cursor, &mut deco.spans);
1115 deco.param_hint = Some("<pattern>/<replacement>/[flags]".to_string());
1116 return deco;
1117 }
1118 if rest.starts_with("g/") || rest.starts_with("v/") {
1120 deco.spans.push(CommandLineSpan {
1121 range: cursor..cursor + 1,
1122 style: Style::Keyword,
1123 });
1124 if end > cursor + 1 {
1125 deco.spans.push(CommandLineSpan {
1126 range: cursor + 1..end,
1127 style: Style::Default,
1128 });
1129 }
1130 deco.param_hint = Some("/<pattern>/<command>".to_string());
1131 return deco;
1132 }
1133
1134 let word_rel = rest.find(char::is_whitespace).unwrap_or(rest.len());
1136 let raw_word = &rest[..word_rel];
1137 let (word, has_bang) = raw_word
1138 .strip_suffix('!')
1139 .map(|w| (w, true))
1140 .unwrap_or((raw_word, false));
1141 let word_end = cursor + word.len();
1142
1143 let resolved = if word.is_empty() {
1144 None
1145 } else {
1146 resolve_command_name_or_alias(registry, word)
1147 };
1148 let known = resolved.is_some();
1149 let committed = word_rel < rest.len() || has_bang;
1152 let flag_error = !word.is_empty() && !known && committed;
1153
1154 deco.spans.push(CommandLineSpan {
1155 range: cursor..word_end,
1156 style: if flag_error {
1157 Style::DiagnosticError
1158 } else {
1159 Style::Keyword
1160 },
1161 });
1162 if has_bang {
1163 deco.spans.push(CommandLineSpan {
1164 range: word_end..word_end + 1,
1165 style: Style::Operator,
1166 });
1167 }
1168 let args_start = cursor + raw_word.len();
1169 if args_start < end {
1170 deco.spans.push(CommandLineSpan {
1171 range: args_start..end,
1172 style: Style::Default,
1173 });
1174 }
1175
1176 if flag_error {
1177 deco.error = Some(format!("unknown command: {raw_word}"));
1178 }
1179
1180 if let Some(id) = resolved
1183 && let Some(spec) = registry.lookup(id)
1184 && !spec.args_schema.is_empty()
1185 {
1186 deco.param_hint = Some(format_arg_hint(&spec.args_schema));
1187 }
1188
1189 deco
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 #![allow(clippy::unwrap_used, clippy::panic)]
1195 use super::*;
1196 use lattice_grammar::CommandKind;
1197
1198 fn fixture() -> CommandRegistry {
1199 let mut registry = CommandRegistry::new();
1200 let _ = lattice_grammar::builtins::populate(&mut registry);
1201 let _ = lattice_grammar::ex_commands::populate(&mut registry);
1202 lattice_multibuffer::providers::problems::register_problems_ex_commands(&mut registry);
1207 registry
1208 }
1209
1210 fn invocation_name<'a>(inv: &'a CommandInvocation, registry: &'a CommandRegistry) -> &'a str {
1211 registry
1212 .lookup(inv.command)
1213 .map(|s| s.name.as_str())
1214 .unwrap_or("?")
1215 }
1216
1217 #[test]
1222 fn mb4_known_command_highlights_keyword_with_hint() {
1223 let reg = fixture();
1224 let d = command_line_decorations("write foo.txt", ®);
1225 assert_eq!(d.spans[0].range, 0..5);
1227 assert_eq!(d.spans[0].style, Style::Keyword);
1228 assert!(d.error.is_none(), "known command has no error");
1229 assert!(d.param_hint.is_some(), "`:write` takes a path arg → a hint");
1230 }
1231
1232 #[test]
1235 fn mb4_unknown_committed_command_flags_error() {
1236 let reg = fixture();
1237 let d = command_line_decorations("frobnicate x", ®);
1238 assert_eq!(d.spans[0].style, Style::DiagnosticError);
1239 assert_eq!(d.error.as_deref(), Some("unknown command: frobnicate"));
1240 }
1241
1242 #[test]
1245 fn mb4_uncommitted_prefix_does_not_error() {
1246 let reg = fixture();
1247 let d = command_line_decorations("writ", ®);
1248 assert_eq!(d.spans[0].style, Style::Keyword);
1249 assert!(d.error.is_none());
1250 }
1251
1252 #[test]
1255 fn mb4_substitute_tokenizes_fields() {
1256 let reg = fixture();
1257 let d = command_line_decorations("s/foo/bar/g", ®);
1258 assert_eq!(d.spans[0].range, 0..1);
1260 assert_eq!(d.spans[0].style, Style::Keyword);
1261 assert!(d.spans.iter().any(|s| s.style == Style::Punctuation));
1263 assert!(d.spans.iter().any(|s| s.style == Style::String));
1264 assert!(d.spans.iter().any(|s| s.style == Style::Attribute));
1266 assert!(d.param_hint.is_some());
1267 }
1268
1269 #[test]
1271 fn mb4_range_prefix_is_highlighted() {
1272 let reg = fixture();
1273 let d = command_line_decorations("%s/a/b/", ®);
1274 assert_eq!(d.spans[0].range, 0..1);
1275 assert_eq!(d.spans[0].style, Style::Number);
1276 }
1277
1278 #[test]
1280 fn mb4_empty_line_is_bare() {
1281 let reg = fixture();
1282 assert!(command_line_decorations(" ", ®).is_empty());
1283 }
1284
1285 #[test]
1288 fn partial_substitute_with_pattern_only() {
1289 let p = try_parse_substitute_partial("s/foo").unwrap();
1290 assert_eq!(p.scope, SubstitutePartialScope::CurrentLine);
1291 assert_eq!(p.pattern, "foo");
1292 assert_eq!(p.replacement, None);
1293 assert_eq!(p.flags, None);
1294 }
1295
1296 #[test]
1297 fn partial_substitute_with_pattern_and_typed_delimiter() {
1298 let p = try_parse_substitute_partial("s/foo/").unwrap();
1301 assert_eq!(p.pattern, "foo");
1302 assert_eq!(p.replacement.as_deref(), Some(""));
1303 assert_eq!(p.flags, None);
1304 }
1305
1306 #[test]
1307 fn partial_substitute_with_replacement_in_progress() {
1308 let p = try_parse_substitute_partial("s/foo/bar").unwrap();
1309 assert_eq!(p.pattern, "foo");
1310 assert_eq!(p.replacement.as_deref(), Some("bar"));
1311 }
1312
1313 #[test]
1314 fn partial_substitute_with_flags() {
1315 let p = try_parse_substitute_partial("%s/foo/bar/g").unwrap();
1316 assert_eq!(p.scope, SubstitutePartialScope::Whole);
1317 assert_eq!(p.pattern, "foo");
1318 assert_eq!(p.replacement.as_deref(), Some("bar"));
1319 assert_eq!(p.flags.as_deref(), Some("g"));
1320 }
1321
1322 #[test]
1323 fn partial_substitute_rejects_non_substitute_input() {
1324 assert!(try_parse_substitute_partial("write").is_none());
1325 assert!(try_parse_substitute_partial("/foo").is_none());
1326 assert!(try_parse_substitute_partial("g/foo/d").is_none());
1327 }
1328
1329 #[test]
1330 fn partial_substitute_honors_backslash_escape_in_pattern() {
1331 let p = try_parse_substitute_partial(r"s/foo\/bar").unwrap();
1333 assert_eq!(p.pattern, "foo/bar");
1334 assert_eq!(p.replacement, None);
1335 }
1336
1337 #[test]
1338 fn empty_input_is_empty_error() {
1339 let r = fixture();
1340 assert_eq!(parse("", &r).unwrap_err(), ExCommandError::Empty);
1341 assert_eq!(parse(" ", &r).unwrap_err(), ExCommandError::Empty);
1342 }
1343
1344 #[test]
1345 fn write_short_form_routes_to_registry() {
1346 let r = fixture();
1347 let inv = parse("w", &r).unwrap();
1348 assert_eq!(invocation_name(&inv, &r), "ex:write");
1349 assert!(!inv.bang);
1350 assert_eq!(inv.args, Args::None);
1351 }
1352
1353 #[test]
1354 fn colorscheme_full_and_short_alias_route_to_registry() {
1355 let r = fixture();
1358 let inv = parse("colorscheme catppuccin-macchiato", &r).unwrap();
1359 assert_eq!(invocation_name(&inv, &r), "ex:colorscheme");
1360 assert_eq!(inv.args, Args::String("catppuccin-macchiato".to_string()));
1361 let inv_short = parse("colo catppuccin-mocha", &r).unwrap();
1362 assert_eq!(invocation_name(&inv_short, &r), "ex:colorscheme");
1363 assert_eq!(inv_short.args, Args::String("catppuccin-mocha".to_string()));
1364 }
1365
1366 #[test]
1367 fn colorscheme_without_name_opens_picker() {
1368 let r = fixture();
1373 let inv = parse("colorscheme", &r).unwrap();
1374 assert_eq!(invocation_name(&inv, &r), "ex:colorscheme");
1375 assert_eq!(inv.args, Args::None);
1376 let inv_short = parse("colo", &r).unwrap();
1377 assert_eq!(invocation_name(&inv_short, &r), "ex:colorscheme");
1378 assert_eq!(inv_short.args, Args::None);
1379 }
1380
1381 #[test]
1382 fn write_with_path_carries_string_arg() {
1383 let r = fixture();
1384 let inv = parse("w foo.txt", &r).unwrap();
1385 assert_eq!(invocation_name(&inv, &r), "ex:write");
1386 assert_eq!(inv.args, Args::String("foo.txt".into()));
1387 }
1388
1389 #[test]
1390 fn quit_bang_sets_bang_field() {
1391 let r = fixture();
1392 let inv = parse("q!", &r).unwrap();
1393 assert_eq!(invocation_name(&inv, &r), "ex:quit");
1394 assert!(inv.bang);
1395 }
1396
1397 #[test]
1398 fn quit_long_form_alias_resolves() {
1399 let r = fixture();
1400 let inv = parse("quit", &r).unwrap();
1401 assert_eq!(invocation_name(&inv, &r), "ex:quit");
1402 }
1403
1404 #[test]
1405 fn writequit_aliases_collapse_to_one_command() {
1406 let r = fixture();
1407 let wq = parse("wq", &r).unwrap();
1408 let x = parse("x", &r).unwrap();
1409 assert_eq!(wq.command, x.command);
1410 assert_eq!(invocation_name(&wq, &r), "ex:write-quit");
1411 }
1412
1413 #[test]
1414 fn writequit_bang_propagates() {
1415 let r = fixture();
1416 let inv = parse("wq!", &r).unwrap();
1417 assert!(inv.bang);
1418 assert_eq!(invocation_name(&inv, &r), "ex:write-quit");
1419 }
1420
1421 #[test]
1422 fn buffers_and_b_open_the_picker_ls_keeps_the_text_list() {
1423 let r = fixture();
1426 let buffers = parse("buffers", &r).unwrap();
1427 let b = parse("b", &r).unwrap();
1428 assert_eq!(invocation_name(&buffers, &r), "ex:buffer-picker");
1429 assert_eq!(
1430 buffers.command, b.command,
1431 ":buffers and :b are the same command"
1432 );
1433 assert_eq!(invocation_name(&parse("ls", &r).unwrap(), &r), "ex:buffers");
1434 }
1435
1436 #[test]
1437 fn unknown_command_reports_name() {
1438 let r = fixture();
1439 assert_eq!(
1440 parse("frobnicate", &r).unwrap_err(),
1441 ExCommandError::Unknown("frobnicate".into())
1442 );
1443 }
1444
1445 #[test]
1446 fn bang_on_command_that_does_not_accept_bang_errors() {
1447 let r = fixture();
1448 assert_eq!(
1450 parse("set!", &r).unwrap_err(),
1451 ExCommandError::BangNotAllowed("set!".into())
1452 );
1453 }
1454
1455 #[test]
1456 fn parse_args_propagates_bad_args_error() {
1457 let r = fixture();
1458 let err = parse("set", &r).unwrap_err();
1460 assert!(matches!(err, ExCommandError::BadArgs(_)));
1461 }
1462
1463 #[test]
1464 fn trailing_args_on_no_arg_command_surfaces_bad_args() {
1465 let r = fixture();
1466 let err = parse("q please", &r).unwrap_err();
1468 assert!(matches!(err, ExCommandError::BadArgs(_)));
1469 }
1470
1471 #[test]
1474 fn motion_kind_prefix_dispatches_naked() {
1475 let r = fixture();
1476 let inv = parse("motion goto-first-line", &r).unwrap();
1477 assert_eq!(invocation_name(&inv, &r), "motion:goto-first-line");
1478 assert_eq!(inv.target, None);
1479 assert!(matches!(inv.args, Args::None));
1480 assert!(!inv.bang);
1481 }
1482
1483 #[test]
1484 fn motion_kind_prefix_with_trailing_text_errors() {
1485 let r = fixture();
1486 let err = parse("motion goto-first-line nonsense", &r).unwrap_err();
1487 assert!(matches!(err, ExCommandError::TrailingArgs));
1488 }
1489
1490 #[test]
1491 fn motion_kind_prefix_without_name_errors_helpfully() {
1492 let r = fixture();
1493 let err = parse("motion", &r).unwrap_err();
1494 let msg = match err {
1495 ExCommandError::BadArgs(m) => m,
1496 other => panic!("expected BadArgs, got {other:?}"),
1497 };
1498 assert!(msg.contains("requires a name"), "got: {msg}");
1499 }
1500
1501 #[test]
1502 fn kind_prefix_with_unknown_tail_errors_unknown() {
1503 let r = fixture();
1504 let err = parse("motion no-such-thing", &r).unwrap_err();
1505 match err {
1506 ExCommandError::Unknown(name) => {
1507 assert_eq!(name, "motion:no-such-thing");
1508 }
1509 other => panic!("expected Unknown, got {other:?}"),
1510 }
1511 }
1512
1513 #[test]
1514 fn kind_prefix_rejects_bang_on_kind_word() {
1515 let r = fixture();
1516 let err = parse("motion! goto-first-line", &r).unwrap_err();
1517 assert!(matches!(err, ExCommandError::BangNotAllowed(_)));
1518 }
1519
1520 #[test]
1521 fn operator_with_bare_motion_target_resolves_via_implicit_namespace() {
1522 let r = fixture();
1523 let inv = parse("operator delete word-forward", &r).unwrap();
1526 assert_eq!(invocation_name(&inv, &r), "operator:delete");
1527 match inv.target {
1528 Some(Target::Motion(_, _)) => {}
1529 other => panic!("expected motion target, got {other:?}"),
1530 }
1531 }
1532
1533 #[test]
1534 fn operator_with_full_canonical_target_also_resolves() {
1535 let r = fixture();
1536 let inv = parse("operator delete motion:word-forward", &r).unwrap();
1539 assert_eq!(invocation_name(&inv, &r), "operator:delete");
1540 match inv.target {
1541 Some(Target::Motion(_, _)) => {}
1542 other => panic!("expected motion target, got {other:?}"),
1543 }
1544 }
1545
1546 #[test]
1547 fn operator_with_text_object_target_via_implicit_namespace() {
1548 let r = fixture();
1549 let inv = parse("operator delete inner-word", &r).unwrap();
1550 assert_eq!(invocation_name(&inv, &r), "operator:delete");
1551 match inv.target {
1552 Some(Target::TextObject(_, _)) => {}
1553 other => panic!("expected text-object target, got {other:?}"),
1554 }
1555 }
1556
1557 #[test]
1558 fn operator_without_target_errors_helpfully() {
1559 let r = fixture();
1560 let err = parse("operator delete", &r).unwrap_err();
1561 let msg = match err {
1562 ExCommandError::BadArgs(m) => m,
1563 other => panic!("expected BadArgs, got {other:?}"),
1564 };
1565 assert!(msg.contains("requires a target"), "got: {msg}");
1566 assert!(msg.contains("chord grammar"), "got: {msg}");
1567 }
1568
1569 #[test]
1570 fn operator_with_non_motion_target_errors() {
1571 let r = fixture();
1572 let err = parse("operator delete ex:write", &r).unwrap_err();
1577 let msg = match err {
1578 ExCommandError::BadArgs(m) => m,
1579 other => panic!("expected BadArgs, got {other:?}"),
1580 };
1581 assert!(msg.contains("not a motion or text-object"), "got: {msg}");
1582 }
1583
1584 #[test]
1585 fn naked_text_object_errors_helpfully() {
1586 let r = fixture();
1587 let err = parse("text-object inner-word", &r).unwrap_err();
1588 let msg = match err {
1589 ExCommandError::BadArgs(m) => m,
1590 other => panic!("expected BadArgs, got {other:?}"),
1591 };
1592 assert!(msg.contains("text-object"), "got: {msg}");
1593 assert!(msg.contains("operator"), "got: {msg}");
1594 }
1595
1596 #[test]
1597 fn ex_command_path_unchanged() {
1598 let r = fixture();
1601 let inv = parse("write foo.txt", &r).unwrap();
1602 assert_eq!(invocation_name(&inv, &r), "ex:write");
1603 }
1604
1605 #[test]
1608 fn substitute_current_line_produces_invocation_with_args_list() {
1609 let r = fixture();
1610 let inv = parse("s/foo/bar/", &r).unwrap();
1611 assert_eq!(invocation_name(&inv, &r), "ex:substitute");
1612 assert_eq!(inv.range, Some(Range::CurrentLine));
1613 let list = inv.args.as_list().expect("expected Args::List");
1614 assert_eq!(list.len(), 3);
1615 assert_eq!(list[0], ArgValue::Pattern("foo".into()));
1616 assert_eq!(list[1], ArgValue::String("bar".into()));
1617 assert_eq!(list[2], ArgValue::String(String::new()));
1618 }
1619
1620 #[test]
1621 fn substitute_whole_buffer_sets_range_whole() {
1622 let r = fixture();
1623 let inv = parse("%s/foo/bar/g", &r).unwrap();
1624 assert_eq!(invocation_name(&inv, &r), "ex:substitute");
1625 assert_eq!(inv.range, Some(Range::Whole));
1626 let list = inv.args.as_list().unwrap();
1627 assert_eq!(list[2], ArgValue::String("g".into()));
1628 }
1629
1630 #[test]
1631 fn substitute_with_escaped_slash_in_pattern() {
1632 let r = fixture();
1633 let inv = parse("s/a\\/b/c/", &r).unwrap();
1634 let list = inv.args.as_list().unwrap();
1635 assert_eq!(list[0], ArgValue::Pattern("a/b".into()));
1636 }
1637
1638 #[test]
1639 fn substitute_empty_pattern_is_error() {
1640 let r = fixture();
1641 assert!(matches!(
1642 parse("s//bar/", &r),
1643 Err(ExCommandError::BadSubstitute(_))
1644 ));
1645 }
1646
1647 #[test]
1648 fn substitute_flags_string_carries_through() {
1649 let r = fixture();
1650 let inv = parse("s/foo/bar/gi", &r).unwrap();
1651 let list = inv.args.as_list().unwrap();
1652 assert_eq!(list[2], ArgValue::String("gi".into()));
1655 }
1656
1657 #[test]
1658 fn global_basic_match_with_delete_body() {
1659 let r = fixture();
1660 let inv = parse("g/foo/d", &r).unwrap();
1661 assert_eq!(invocation_name(&inv, &r), "ex:global");
1662 let list = inv.args.as_list().unwrap();
1663 assert_eq!(list[0], ArgValue::Pattern("foo".into()));
1664 assert_eq!(list[1], ArgValue::Bool(false));
1665 let body = list[2].as_invocation().expect("body should be parsed");
1668 assert_eq!(invocation_name(body, &r), "ex:delete");
1669 }
1670
1671 #[test]
1672 fn vglobal_inverts_match() {
1673 let r = fixture();
1674 let inv = parse("v/foo/d", &r).unwrap();
1675 let list = inv.args.as_list().unwrap();
1676 assert_eq!(list[1], ArgValue::Bool(true));
1677 }
1678
1679 #[test]
1680 fn global_body_can_be_substitute() {
1681 let r = fixture();
1684 let inv = parse("g/foo/s/a/b/g", &r).unwrap();
1685 let list = inv.args.as_list().unwrap();
1686 let body = list[2].as_invocation().expect("body should be parsed");
1687 assert_eq!(invocation_name(body, &r), "ex:substitute");
1688 let body_list = body.args.as_list().unwrap();
1689 assert_eq!(body_list[0], ArgValue::Pattern("a".into()));
1690 assert_eq!(body_list[1], ArgValue::String("b".into()));
1691 assert_eq!(body_list[2], ArgValue::String("g".into()));
1692 }
1693
1694 #[test]
1695 fn global_with_unparseable_body_errors_at_parse_time() {
1696 let r = fixture();
1699 let result = parse("g/foo/this-is-not-a-real-command", &r);
1700 assert!(
1701 matches!(result, Err(ExCommandError::Unknown(_))),
1702 "expected Unknown error, got {result:?}"
1703 );
1704 }
1705
1706 #[test]
1707 fn global_with_empty_body_errors_at_parse_time() {
1708 let r = fixture();
1709 let result = parse("g/foo/", &r);
1710 assert!(matches!(result, Err(ExCommandError::BadSubstitute(_))));
1711 }
1712
1713 #[test]
1714 fn nohlsearch_aliases_route_to_one_command() {
1715 let r = fixture();
1716 for s in ["noh", "nohl", "nohlsearch"] {
1717 let inv = parse(s, &r).unwrap();
1718 assert_eq!(invocation_name(&inv, &r), "ex:nohlsearch", "alias `{s}`");
1719 }
1720 }
1721
1722 #[test]
1723 fn registers_aliases_route_to_one_command() {
1724 let r = fixture();
1725 for s in ["reg", "registers"] {
1726 let inv = parse(s, &r).unwrap();
1727 assert_eq!(invocation_name(&inv, &r), "ex:registers");
1728 }
1729 }
1730
1731 #[test]
1732 fn edit_with_path() {
1733 let r = fixture();
1734 let inv = parse("e foo.txt", &r).unwrap();
1735 assert_eq!(invocation_name(&inv, &r), "ex:edit");
1736 assert_eq!(inv.args, Args::String("foo.txt".into()));
1737 assert!(!inv.bang);
1738 }
1739
1740 #[test]
1741 fn edit_force_with_bang() {
1742 let r = fixture();
1743 let inv = parse("e! /tmp/x", &r).unwrap();
1744 assert_eq!(invocation_name(&inv, &r), "ex:edit");
1745 assert!(inv.bang);
1746 assert_eq!(inv.args, Args::String("/tmp/x".into()));
1747 }
1748
1749 #[test]
1750 fn edit_without_path_is_reload() {
1751 let r = fixture();
1752 let inv = parse("e", &r).unwrap();
1753 assert_eq!(invocation_name(&inv, &r), "ex:edit");
1754 assert_eq!(inv.args, Args::None);
1755 }
1756
1757 #[test]
1758 fn set_with_option_parses() {
1759 let r = fixture();
1760 let inv = parse("set number", &r).unwrap();
1761 assert_eq!(invocation_name(&inv, &r), "ex:set");
1762 assert_eq!(inv.args, Args::String("number".into()));
1763 }
1764
1765 #[test]
1766 fn set_short_alias_with_value() {
1767 let r = fixture();
1768 let inv = parse("set nu", &r).unwrap();
1769 assert_eq!(inv.args, Args::String("nu".into()));
1770 }
1771
1772 #[test]
1773 fn marks_routes_to_registry() {
1774 let r = fixture();
1775 let inv = parse("marks", &r).unwrap();
1776 assert_eq!(invocation_name(&inv, &r), "ex:marks");
1777 }
1778
1779 #[test]
1780 fn delete_short_form_routes_to_registry() {
1781 let r = fixture();
1782 for s in ["d", "delete"] {
1783 let inv = parse(s, &r).unwrap();
1784 assert_eq!(invocation_name(&inv, &r), "ex:delete");
1785 }
1786 }
1787
1788 #[test]
1789 fn whitespace_around_command_is_tolerated() {
1790 let r = fixture();
1791 let inv = parse(" w ", &r).unwrap();
1792 assert_eq!(invocation_name(&inv, &r), "ex:write");
1793 let inv = parse("\t w foo.rs \t", &r).unwrap();
1794 assert_eq!(invocation_name(&inv, &r), "ex:write");
1795 assert_eq!(inv.args, Args::String("foo.rs".into()));
1796 }
1797
1798 #[test]
1799 fn motion_name_in_registry_is_not_an_ex_command() {
1800 let r = fixture();
1805 let id = r.id_by_name("motion:word-forward").unwrap();
1806 let entry = r.lookup(id).unwrap();
1807 assert_eq!(entry.kind, CommandKind::Motion);
1808 }
1809
1810 #[test]
1811 fn aliases_table_is_self_consistent() {
1812 let r = fixture();
1814 for (short, canonical) in aliases() {
1815 assert!(
1816 r.id_by_name(canonical).is_some(),
1817 "alias `{short}` -> `{canonical}` not registered"
1818 );
1819 }
1820 }
1821
1822 #[test]
1831 fn picker_commands_resolve_through_user_facing_names() {
1832 let r = fixture();
1833 for line in ["picker files", "files", "recent"] {
1834 let result = parse(line, &r);
1835 assert!(
1836 result.is_ok(),
1837 "expected `:{line}` to parse, got {:?}",
1838 result.err()
1839 );
1840 }
1841 }
1842
1843 #[test]
1850 fn every_ex_command_has_a_typed_name() {
1851 let r = fixture();
1852 let table = aliases();
1853 let delimiter_form = ["ex:substitute", "ex:global"];
1854 let mut missing: Vec<&str> = r
1855 .names()
1856 .filter(|n| n.starts_with("ex:") && !delimiter_form.contains(n))
1857 .filter(|n| !table.values().any(|c| c == n))
1858 .collect();
1859 missing.sort_unstable();
1860 assert!(
1861 missing.is_empty(),
1862 "ex-commands with no `:` spelling: {missing:?}"
1863 );
1864 }
1865
1866 #[test]
1867 fn oil_resolves_in_both_spellings() {
1868 let r = fixture();
1869 for line in ["oil", "Oil", "oil /tmp", "Oil /tmp"] {
1870 assert!(parse(line, &r).is_ok(), "expected `:{line}` to parse");
1871 }
1872 }
1873
1874 #[test]
1875 fn substitute_and_global_register_with_args_schema() {
1876 let r = fixture();
1880 let sub_id = r.id_by_name("ex:substitute").unwrap();
1881 let sub = r.lookup(sub_id).unwrap();
1882 assert_eq!(sub.args_schema.len(), 3);
1883 assert_eq!(sub.args_schema[0].name, "pattern");
1884 assert_eq!(sub.args_schema[1].name, "replacement");
1885 assert_eq!(sub.args_schema[2].name, "flags");
1886
1887 let global_id = r.id_by_name("ex:global").unwrap();
1888 let global = r.lookup(global_id).unwrap();
1889 assert_eq!(global.args_schema.len(), 3);
1890 assert_eq!(global.args_schema[0].name, "pattern");
1891 assert_eq!(global.args_schema[1].name, "inverted");
1892 assert_eq!(global.args_schema[2].name, "body");
1893 }
1894
1895 #[test]
1896 fn keyword_form_of_substitute_returns_wrong_surface_form_error() {
1897 let r = fixture();
1901 let err = parse("ex:substitute foo bar", &r).unwrap_err();
1902 match err {
1903 ExCommandError::WrongSurfaceForm { name, hint } => {
1904 assert_eq!(name, "ex:substitute");
1905 assert!(hint.contains(":s/"));
1906 }
1907 other => panic!("expected WrongSurfaceForm, got {other:?}"),
1908 }
1909 }
1910
1911 #[test]
1912 fn keyword_form_of_global_returns_wrong_surface_form_error() {
1913 let r = fixture();
1914 let err = parse("ex:global //d", &r).unwrap_err();
1915 match err {
1916 ExCommandError::WrongSurfaceForm { name, hint } => {
1917 assert_eq!(name, "ex:global");
1918 assert!(hint.contains(":g/"));
1919 assert!(hint.contains(":v/"));
1920 }
1921 other => panic!("expected WrongSurfaceForm, got {other:?}"),
1922 }
1923 }
1924
1925 #[test]
1926 fn delimiter_form_of_substitute_still_parses() {
1927 let r = fixture();
1930 let inv = parse("s/foo/bar/", &r).unwrap();
1931 assert_eq!(invocation_name(&inv, &r), "ex:substitute");
1932 }
1933
1934 #[test]
1935 fn delimiter_form_of_global_still_parses() {
1936 let r = fixture();
1937 let inv = parse("g/foo/d", &r).unwrap();
1938 assert_eq!(invocation_name(&inv, &r), "ex:global");
1939 }
1940
1941 #[test]
1942 fn bare_integer_routes_to_goto_last_line_with_count() {
1943 let r = fixture();
1944 let inv = parse("42", &r).unwrap();
1945 assert_eq!(invocation_name(&inv, &r), "motion:goto-last-line");
1946 let count = inv.count.expect("bare integer must carry explicit count");
1947 assert_eq!(count.get(), 42);
1948 }
1949
1950 #[test]
1951 fn bare_integer_one_routes_to_goto_last_line_with_count_one() {
1952 let r = fixture();
1953 let inv = parse("1", &r).unwrap();
1954 assert_eq!(invocation_name(&inv, &r), "motion:goto-last-line");
1955 assert_eq!(inv.count.unwrap().get(), 1);
1956 }
1957}