1use lattice_grammar::{ArgSpec, CommandKind, CommandRegistry};
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum CommandLineSlot {
20 CommandName {
23 prefix: String,
25 replace_start: usize,
29 },
30 Arg {
35 command_name: String,
36 arg_index: usize,
37 arg_spec: ArgSpec,
38 prefix: String,
39 replace_start: usize,
40 },
41 BeyondSchema { command_name: String },
44 DelimiterBody { command_name: String, body: String },
50 UnknownCommand { word: String, replace_start: usize },
53 Empty,
56}
57
58impl CommandLineSlot {
59 pub fn prefix(&self) -> &str {
61 match self {
62 Self::CommandName { prefix, .. } => prefix,
63 Self::Arg { prefix, .. } => prefix,
64 Self::UnknownCommand { word, .. } => word,
65 Self::DelimiterBody { body, .. } => body,
66 Self::BeyondSchema { .. } | Self::Empty => "",
67 }
68 }
69
70 pub fn replace_start(&self) -> Option<usize> {
71 match self {
72 Self::CommandName { replace_start, .. } => Some(*replace_start),
73 Self::Arg { replace_start, .. } => Some(*replace_start),
74 Self::UnknownCommand { replace_start, .. } => Some(*replace_start),
75 Self::Empty => Some(0),
76 Self::BeyondSchema { .. } | Self::DelimiterBody { .. } => None,
77 }
78 }
79}
80
81pub fn current_slot(
90 line: &str,
91 cursor: usize,
92 registry: &CommandRegistry,
93 alias_resolve: &dyn Fn(&str) -> Option<String>,
94) -> CommandLineSlot {
95 let line = &line[..cursor.min(line.len())];
96
97 if line.trim().is_empty() {
99 return CommandLineSlot::Empty;
100 }
101
102 if let Some(rest) = line.strip_prefix("s/") {
106 return CommandLineSlot::DelimiterBody {
107 command_name: "ex:substitute".into(),
108 body: rest.to_string(),
109 };
110 }
111 if let Some(rest) = line.strip_prefix("%s/") {
112 return CommandLineSlot::DelimiterBody {
113 command_name: "ex:substitute".into(),
114 body: rest.to_string(),
115 };
116 }
117 if let Some(rest) = line.strip_prefix("g/") {
118 return CommandLineSlot::DelimiterBody {
119 command_name: "ex:global".into(),
120 body: rest.to_string(),
121 };
122 }
123 if let Some(rest) = line.strip_prefix("v/") {
124 return CommandLineSlot::DelimiterBody {
125 command_name: "ex:global".into(),
126 body: rest.to_string(),
127 };
128 }
129
130 let leading_ws = line.bytes().take_while(|b| b.is_ascii_whitespace()).count();
133 let after_ws = &line[leading_ws..];
134 let cmd_end = after_ws.find(char::is_whitespace).unwrap_or(after_ws.len());
135
136 if cmd_end == after_ws.len() {
137 return CommandLineSlot::CommandName {
139 prefix: after_ws.to_string(),
140 replace_start: leading_ws,
141 };
142 }
143
144 let cmd_word_with_bang = &after_ws[..cmd_end];
146 let cmd_word = cmd_word_with_bang.trim_end_matches('!');
149 let id = if let Some(id) = registry.id_by_name(cmd_word) {
156 id
157 } else if let Some(canonical) = alias_resolve(cmd_word) {
158 match registry.id_by_name(&canonical) {
159 Some(id) => id,
160 None => {
161 return CommandLineSlot::UnknownCommand {
162 word: cmd_word.to_string(),
163 replace_start: leading_ws,
164 };
165 }
166 }
167 } else {
168 return CommandLineSlot::UnknownCommand {
169 word: cmd_word.to_string(),
170 replace_start: leading_ws,
171 };
172 };
173 let spec = match registry.lookup(id) {
174 Some(s) => s,
175 None => {
176 return CommandLineSlot::UnknownCommand {
177 word: cmd_word.to_string(),
178 replace_start: leading_ws,
179 };
180 }
181 };
182 if spec.kind != CommandKind::ExCommand {
183 return CommandLineSlot::UnknownCommand {
184 word: cmd_word.to_string(),
185 replace_start: leading_ws,
186 };
187 }
188
189 let after_cmd = &after_ws[cmd_end..];
191 let post_cmd_ws = after_cmd
192 .bytes()
193 .take_while(|b| b.is_ascii_whitespace())
194 .count();
195 let arg_text = &after_cmd[post_cmd_ws..];
196
197 let arg_index = arg_text.split_ascii_whitespace().count();
200 let (current_prefix, prefix_offset) =
205 if arg_text.ends_with(char::is_whitespace) || arg_text.is_empty() {
206 ("", arg_text.len())
207 } else {
208 let last_ws = arg_text
210 .rfind(char::is_whitespace)
211 .map(|i| i + 1)
212 .unwrap_or(0);
213 (&arg_text[last_ws..], last_ws)
214 };
215 let arg_index = if arg_text.ends_with(char::is_whitespace) || arg_text.is_empty() {
216 arg_index } else {
218 arg_index - 1 };
220
221 let replace_start = leading_ws + cmd_end + post_cmd_ws + prefix_offset;
222
223 if arg_index >= spec.args_schema.len() {
224 return CommandLineSlot::BeyondSchema {
225 command_name: spec.name.clone(),
226 };
227 }
228 CommandLineSlot::Arg {
229 command_name: spec.name.clone(),
230 arg_index,
231 arg_spec: spec.args_schema[arg_index].clone(),
232 prefix: current_prefix.to_string(),
233 replace_start,
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 #![allow(clippy::unwrap_used, clippy::panic)]
240 use super::*;
241 use lattice_grammar::ex_commands;
242
243 fn fixture() -> CommandRegistry {
244 let mut r = CommandRegistry::new();
245 let _ = lattice_grammar::builtins::populate(&mut r);
246 let _ = ex_commands::populate(&mut r);
247 r
248 }
249
250 fn aliases(short: &str) -> Option<String> {
251 let table: &[(&str, &str)] = &[
252 ("w", "ex:write"),
253 ("write", "ex:write"),
254 ("q", "ex:quit"),
255 ("quit", "ex:quit"),
256 ("e", "ex:edit"),
257 ("edit", "ex:edit"),
258 ("set", "ex:set"),
259 ("describe-command", "ex:describe-command"),
260 ("describe-key", "ex:describe-key"),
261 ("apropos", "ex:apropos"),
262 ];
263 table
264 .iter()
265 .find_map(|(s, c)| (*s == short).then(|| (*c).to_string()))
266 }
267
268 #[test]
269 fn empty_line_is_empty_slot() {
270 let r = fixture();
271 let slot = current_slot("", 0, &r, &aliases);
272 assert!(matches!(slot, CommandLineSlot::Empty));
273 }
274
275 #[test]
276 fn whitespace_only_line_is_empty_slot() {
277 let r = fixture();
278 let slot = current_slot(" ", 3, &r, &aliases);
279 assert!(matches!(slot, CommandLineSlot::Empty));
280 }
281
282 #[test]
283 fn partial_command_word_is_command_name_slot() {
284 let r = fixture();
285 let slot = current_slot("descri", 6, &r, &aliases);
286 match slot {
287 CommandLineSlot::CommandName {
288 prefix,
289 replace_start,
290 } => {
291 assert_eq!(prefix, "descri");
292 assert_eq!(replace_start, 0);
293 }
294 other => panic!("expected CommandName, got {other:?}"),
295 }
296 }
297
298 #[test]
299 fn unknown_command_is_unknown_slot() {
300 let r = fixture();
301 let slot = current_slot("xyzzy ", 6, &r, &aliases);
302 assert!(matches!(slot, CommandLineSlot::UnknownCommand { .. }));
303 }
304
305 #[test]
306 fn first_arg_after_known_command_is_arg_slot_index_zero() {
307 let r = fixture();
308 let line = "describe-command moti";
311 let slot = current_slot(line, line.len(), &r, &aliases);
312 match slot {
313 CommandLineSlot::Arg {
314 command_name,
315 arg_index,
316 arg_spec,
317 prefix,
318 ..
319 } => {
320 assert_eq!(command_name, "ex:describe-command");
321 assert_eq!(arg_index, 0);
322 assert_eq!(arg_spec.name, "name");
323 assert_eq!(prefix, "moti");
324 }
325 other => panic!("expected Arg, got {other:?}"),
326 }
327 }
328
329 #[test]
330 fn replace_start_points_at_prefix_start() {
331 let r = fixture();
332 let line = "describe-command moti";
333 let slot = current_slot(line, line.len(), &r, &aliases);
335 match slot {
336 CommandLineSlot::Arg { replace_start, .. } => {
337 assert_eq!(replace_start, 17);
338 }
339 other => panic!("expected Arg, got {other:?}"),
340 }
341 }
342
343 #[test]
344 fn cursor_on_trailing_whitespace_after_command_is_next_arg_with_empty_prefix() {
345 let r = fixture();
346 let line = "describe-command ";
347 let slot = current_slot(line, line.len(), &r, &aliases);
348 match slot {
349 CommandLineSlot::Arg {
350 arg_index, prefix, ..
351 } => {
352 assert_eq!(arg_index, 0);
353 assert_eq!(prefix, "");
354 }
355 other => panic!("expected Arg with empty prefix, got {other:?}"),
356 }
357 }
358
359 #[test]
360 fn cursor_past_schema_args_is_beyond_schema() {
361 let r = fixture();
362 let line = "quit foo";
364 let slot = current_slot(line, line.len(), &r, &aliases);
365 assert!(matches!(slot, CommandLineSlot::BeyondSchema { .. }));
366 }
367
368 #[test]
369 fn substitute_form_is_delimiter_body() {
370 let r = fixture();
371 let line = "s/foo/bar/";
372 let slot = current_slot(line, line.len(), &r, &aliases);
373 match slot {
374 CommandLineSlot::DelimiterBody { command_name, body } => {
375 assert_eq!(command_name, "ex:substitute");
376 assert_eq!(body, "foo/bar/");
377 }
378 other => panic!("expected DelimiterBody, got {other:?}"),
379 }
380 }
381
382 #[test]
383 fn percent_substitute_form_is_delimiter_body() {
384 let r = fixture();
385 let line = "%s/foo/bar/g";
386 let slot = current_slot(line, line.len(), &r, &aliases);
387 match slot {
388 CommandLineSlot::DelimiterBody { command_name, .. } => {
389 assert_eq!(command_name, "ex:substitute");
390 }
391 other => panic!("expected DelimiterBody, got {other:?}"),
392 }
393 }
394
395 #[test]
396 fn global_form_is_delimiter_body() {
397 let r = fixture();
398 let line = "g/pattern/d";
399 let slot = current_slot(line, line.len(), &r, &aliases);
400 match slot {
401 CommandLineSlot::DelimiterBody { command_name, .. } => {
402 assert_eq!(command_name, "ex:global");
403 }
404 other => panic!("expected DelimiterBody, got {other:?}"),
405 }
406 }
407
408 #[test]
409 fn vglobal_form_is_delimiter_body() {
410 let r = fixture();
411 let line = "v/pattern/d";
412 let slot = current_slot(line, line.len(), &r, &aliases);
413 assert!(matches!(slot, CommandLineSlot::DelimiterBody { .. }));
414 }
415
416 #[test]
417 fn nested_path_arg_keeps_full_prefix_across_slash() {
418 let r = fixture();
423 let line = "e crates/latt";
424 let slot = current_slot(line, line.len(), &r, &aliases);
425 match slot {
426 CommandLineSlot::Arg {
427 prefix,
428 command_name,
429 ..
430 } => {
431 assert_eq!(command_name, "ex:edit");
432 assert_eq!(prefix, "crates/latt", "prefix should retain dir part");
433 }
434 other => panic!("expected Arg(ex:edit, prefix=crates/latt), got {other:?}"),
435 }
436 }
437
438 #[test]
439 fn cursor_in_middle_of_arg_truncates_prefix_at_cursor() {
440 let r = fixture();
441 let line = "describe-command motion";
444 let slot = current_slot(line, 21, &r, &aliases);
445 match slot {
446 CommandLineSlot::Arg { prefix, .. } => assert_eq!(prefix, "moti"),
447 other => panic!("expected Arg, got {other:?}"),
448 }
449 }
450
451 #[test]
452 fn bang_after_command_does_not_break_resolution() {
453 let r = fixture();
454 let line = "q!";
455 let slot = current_slot(line, line.len(), &r, &aliases);
456 assert!(matches!(slot, CommandLineSlot::CommandName { .. }));
459 }
460
461 #[test]
462 fn slot_prefix_helper_returns_correct_text_for_each_variant() {
463 assert_eq!(
464 CommandLineSlot::CommandName {
465 prefix: "abc".into(),
466 replace_start: 0,
467 }
468 .prefix(),
469 "abc"
470 );
471 assert_eq!(CommandLineSlot::Empty.prefix(), "");
472 assert_eq!(
473 CommandLineSlot::BeyondSchema {
474 command_name: "x".into()
475 }
476 .prefix(),
477 ""
478 );
479 }
480}