1use std::sync::LazyLock;
2
3use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
4use regex::Regex;
5
6#[derive(PartialEq, Debug)]
7pub enum RestQuery {
8 Something(String),
9 Empty,
10}
11impl RestQuery {
12 #[must_use]
13 pub fn is_empty(&self) -> bool {
14 *self == RestQuery::Empty
15 }
16}
17impl From<&str> for RestQuery {
18 fn from(other: &str) -> Self {
19 match other {
20 "" => RestQuery::Empty,
21 other => RestQuery::Something(other.into()),
22 }
23 }
24}
25impl From<String> for RestQuery {
26 fn from(other: String) -> Self {
27 if other.is_empty() {
28 RestQuery::Empty
29 } else {
30 RestQuery::Something(other)
31 }
32 }
33}
34
35pub type QuerySplitter = dyn Fn(&str) -> (String, String, String, String);
36
37#[allow(dead_code)]
39pub enum ParamGreed {
40 Word,
41 OptionalWord,
42 ToComma,
43 Rest,
44 Custom(&'static QuerySplitter),
45}
46
47pub type Parser<T> = Box<dyn Fn(&str, RestQuery) -> Option<Command<T>>>;
48
49pub enum Command<T> {
50 Terminal(T),
51 NonTerminal(ParamGreed, Vec<String>, Parser<T>),
52}
53
54#[derive(Debug, PartialEq, Clone)]
55pub enum ParseError {
56 InvalidParameter(String),
57 MissingParameters,
58 ExtraParameters(String),
59}
60
61fn separate_first_word(query: &str) -> (String, String, String, String) {
62 static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\W*)(\w*)(\W?)(.*)").unwrap());
63 let captures = RE.captures_iter(query).next().unwrap();
64
65 (
66 captures[1].into(),
67 captures[2].into(),
68 captures[3].into(),
69 captures[4].into(),
70 )
71}
72
73fn separate_optional_word(query: &str) -> (String, String, String, String) {
74 if query.chars().all(char::is_whitespace) {
75 (String::new(), " ".to_string(), String::new(), String::new())
76 } else {
77 separate_first_word(query)
78 }
79}
80
81fn separate_until_comma(query: &str) -> (String, String, String, String) {
82 static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\W*)([^,]*)(,?)(.*)").unwrap());
83
84 RE.captures_iter(query)
85 .next()
86 .map(|captures| {
87 (
88 captures[1].into(),
89 captures[2].into(),
90 captures[3].into(),
91 captures[4].into(),
92 )
93 })
94 .unwrap_or((String::new(), query.into(), String::new(), String::new()))
95}
96
97fn split_query(query: &str, greed: &ParamGreed) -> (String, String, String, String) {
98 match greed {
99 ParamGreed::Word => separate_first_word(query),
100 ParamGreed::OptionalWord => separate_optional_word(query),
101 ParamGreed::ToComma => separate_until_comma(query),
102 ParamGreed::Rest => (
103 String::new(),
104 query.trim_start().into(),
105 String::new(),
106 String::new(),
107 ),
108 ParamGreed::Custom(matcher) => matcher(query),
109 }
110}
111
112pub fn parse_command<T>(query: &str, command: Command<T>) -> Result<T, ParseError> {
113 match command {
114 Command::Terminal(val) => match query {
115 "" => Ok(val),
116 _ => Err(ParseError::ExtraParameters(query.into())),
117 },
118 Command::NonTerminal(greed, _, parsing_function) => {
119 let (_, greed_match, _delim, rest) = split_query(query, &greed);
120
121 match greed_match.as_ref() {
122 "" => Err(ParseError::MissingParameters),
123 param => match parsing_function(param, rest.clone().into()) {
124 Some(next_command) => parse_command(&rest, next_command),
125 None => Err(ParseError::InvalidParameter(param.into())),
126 },
127 }
128 }
129 }
130}
131
132pub enum FuzzyError {
133 ReachedTerminal,
136 MalformedCommand(Vec<String>, String),
139 NoMoreInput,
142}
143
144pub struct FuzzyOutput {
145 pub expanded: String,
146 pub suggestions: Result<Vec<(String, Vec<bool>)>, FuzzyError>,
147}
148
149fn handle_non_terminal_fuzz<T>(
150 previous_query: &str,
151 query: &str,
152 greed: &ParamGreed,
153 suggestions: &[String],
154 parser: &Parser<T>,
155) -> FuzzyOutput {
156 let (leading_whitespace, current_section, delim, rest_query) = split_query(query, greed);
157 let rest_query = delim.clone() + &rest_query;
158
159 if leading_whitespace.is_empty() && current_section.is_empty() {
160 FuzzyOutput {
161 expanded: previous_query.into(),
162 suggestions: if previous_query
163 .chars()
164 .last()
165 .is_some_and(char::is_whitespace)
166 {
167 let s = suggestions
168 .iter()
169 .map(|x| (x.to_string(), vec![false; x.len()]))
170 .collect::<Vec<(String, Vec<bool>)>>();
171 Ok(s)
172 } else {
173 Err(FuzzyError::NoMoreInput)
174 },
175 }
176 } else {
177 let expanded_commands = fuzzy_match(suggestions, ¤t_section);
178
179 let best_expansion = {
180 let expansion = expanded_commands
181 .first()
182 .map_or(¤t_section, |(query, _)| query);
183
184 parser(expansion, rest_query.clone().into()).map(|command| (expansion, command))
185 };
186
187 let full_query = |expansion| previous_query.to_string() + expansion + &delim;
188
189 match best_expansion {
190 Some((expansion, Command::NonTerminal(next_greed, next_suggestions, next_parser))) => {
191 let current_query = full_query(expansion);
192 let next_result = handle_non_terminal_fuzz(
193 ¤t_query,
194 &rest_query,
195 &next_greed,
196 &next_suggestions,
197 &next_parser,
198 );
199 match next_result {
200 FuzzyOutput {
201 suggestions: Err(FuzzyError::NoMoreInput),
202 ..
203 } => {
204 FuzzyOutput {
206 expanded: current_query,
207 suggestions: Ok(expanded_commands),
208 }
209 }
210 future_result => future_result,
211 }
212 }
213 Some((expansion, Command::Terminal(_))) => {
214 let current_query = full_query(expansion);
215 FuzzyOutput {
216 expanded: current_query,
217 suggestions: Ok(expanded_commands),
218 }
219 }
220 None => {
221 let err = Err(FuzzyError::MalformedCommand(
222 suggestions.to_vec(),
223 query.into(),
224 ));
225 FuzzyOutput {
226 expanded: previous_query.into(),
227 suggestions: err,
228 }
229 }
230 }
231 }
232}
233
234pub fn expand_command<T>(query: &str, command: Command<T>) -> FuzzyOutput {
235 match command {
236 Command::NonTerminal(greed, suggestions, parser) => {
237 let fuzz_result = handle_non_terminal_fuzz("", query, &greed, &suggestions, &parser);
238 match fuzz_result {
239 FuzzyOutput {
240 expanded,
241 suggestions: Err(FuzzyError::NoMoreInput),
242 } => {
243 let suggestion_matches = suggestions
244 .iter()
245 .cloned()
246 .map(|s| {
247 let falses = (0..s.len()).map(|_| false).collect();
248 (s, falses)
249 })
250 .collect();
251
252 FuzzyOutput {
253 expanded,
254 suggestions: Ok(suggestion_matches),
255 }
256 }
257 other => other,
258 }
259 }
260 Command::Terminal(_) => FuzzyOutput {
261 expanded: String::new(),
262 suggestions: Err(FuzzyError::ReachedTerminal),
263 },
264 }
265}
266
267fn fuzzy_match(alternatives: &[String], query: &str) -> Vec<(String, Vec<bool>)> {
268 let mut with_scores = alternatives
269 .iter()
270 .map(|option| {
271 let (score, matches) = fuzzy_score(option, query);
272 (option, score, matches)
273 })
274 .collect::<Vec<_>>();
275
276 with_scores.sort_by_key(|(option, score, _)| (-*score, option.len()));
277
278 with_scores
279 .into_iter()
280 .map(|(value, _, matches)| (value.clone(), matches))
281 .collect()
282}
283
284fn fuzzy_score(line: &str, query: &str) -> (i64, Vec<bool>) {
285 static MATCHER: LazyLock<SkimMatcherV2> = LazyLock::new(SkimMatcherV2::default);
286 let (score, indices) = MATCHER.fuzzy_indices(line, query).unwrap_or_default();
287
288 let mut matches = vec![false; line.len()];
289 indices.iter().for_each(|&i| matches[i] = true);
290 (score, matches)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 #[derive(Debug, PartialEq)]
297 enum CommandOutputs {
298 NoParams,
299 OptionalParam(Option<String>),
300 OneParam(String),
301 ParsedParam(i32),
302 Two(String, String),
303 }
304
305 #[allow(clippy::type_complexity)]
306 fn get_parser() -> Command<CommandOutputs> {
307 fn single_word(
308 suggestions: Vec<String>,
309 rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
310 ) -> Option<Command<CommandOutputs>> {
311 Some(Command::NonTerminal(
312 ParamGreed::Word,
313 suggestions,
314 Box::new(move |query, _| rest_command(query)),
315 ))
316 }
317 fn optional_word(
318 suggestions: Vec<String>,
319 rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
320 ) -> Option<Command<CommandOutputs>> {
321 Some(Command::NonTerminal(
322 ParamGreed::OptionalWord,
323 suggestions,
324 Box::new(move |query, _| rest_command(query)),
325 ))
326 }
327 fn single_comma_separation(
328 suggestions: Vec<String>,
329 rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
330 ) -> Option<Command<CommandOutputs>> {
331 Some(Command::NonTerminal(
332 ParamGreed::ToComma,
333 suggestions,
334 Box::new(move |query, _| rest_command(query)),
335 ))
336 }
337
338 Command::NonTerminal(
339 ParamGreed::Word,
340 vec!["noparams".into(), "oneparam".into(), "parsedparam".into()],
341 Box::new(|query, _| {
342 let multi_comma = single_comma_separation(
343 vec![],
344 Box::new(|first| {
345 let first = first.to_string();
346 Some(Command::NonTerminal(
347 ParamGreed::ToComma,
348 vec![],
349 Box::new(move |second, _| {
350 Some(Command::Terminal(CommandOutputs::Two(
351 first.clone(),
352 second.into(),
353 )))
354 }),
355 ))
356 }),
357 );
358 match query {
359 "noparams" => Some(Command::Terminal(CommandOutputs::NoParams)),
360 "oneparam" => single_word(
361 vec![],
362 Box::new(|word| {
363 Some(Command::Terminal(CommandOutputs::OneParam(word.into())))
364 }),
365 ),
366 "optionalparam" => optional_word(
367 vec![],
368 Box::new(|word| {
369 Some(Command::Terminal(CommandOutputs::OptionalParam(
370 if word == " " { None } else { Some(word.into()) },
371 )))
372 }),
373 ),
374 "parsedparam" => single_word(
375 vec![],
376 Box::new(|word| {
377 word.parse::<i32>()
378 .map(|int| Command::Terminal(CommandOutputs::ParsedParam(int)))
379 .ok()
380 }),
381 ),
382 "singlecomma" => single_comma_separation(
383 vec![],
384 Box::new(|word| {
385 word.parse::<i32>()
386 .map(|int| Command::Terminal(CommandOutputs::ParsedParam(int)))
387 .ok()
388 }),
389 ),
390 "multicomma" => multi_comma,
391 _ => None,
392 }
393 }),
394 )
395 }
396
397 #[test]
398 fn basic_parsing_test() {
399 let parser = get_parser();
400
401 let result = parse_command("noparams", parser);
402 assert_eq!(result, Ok(CommandOutputs::NoParams));
403 }
404
405 #[test]
406 fn parsing_with_params_works() {
407 let parser = get_parser();
408 let result = parse_command("oneparam test", parser);
409
410 assert_eq!(result, Ok(CommandOutputs::OneParam("test".into())));
411 }
412
413 #[test]
414 fn parsing_with_parsed_param_works() {
415 let parser = get_parser();
416
417 let result = parse_command("parsedparam 5", parser);
418
419 assert_eq!(result, Ok(CommandOutputs::ParsedParam(5)));
420 }
421 #[test]
422 fn parsing_with_commas_works_with_missing_trailing_comma() {
423 let parser = get_parser();
424
425 let result = parse_command("singlecomma 5", parser);
426
427 assert_eq!(result, Ok(CommandOutputs::ParsedParam(5)));
428 }
429
430 #[test]
431 fn parsing_with_multiple_commas_works() {
432 let parser = get_parser();
433
434 let result = parse_command("multicomma yolo, swag", parser);
435
436 assert_eq!(
437 result,
438 Ok(CommandOutputs::Two("yolo".into(), "swag".into()))
439 );
440 }
441
442 #[test]
443 fn parsing_optional_word_works() {
444 let parser = get_parser();
445
446 let result = parse_command("optionalparam yolo", parser);
447 assert_eq!(
448 result,
449 Ok(CommandOutputs::OptionalParam(Some("yolo".into())))
450 );
451 }
452
453 #[test]
454 fn parsing_optional_without_word_works() {
455 let parser = get_parser();
456
457 let result = parse_command("optionalparam", parser);
458 assert_eq!(result, Ok(CommandOutputs::OptionalParam(None)));
459 }
460
461 macro_rules! test_order {
462 ($list:expr, $query:expr, $expected:expr) => {
463 assert_eq!(
464 fuzzy_match(
465 &$list.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
466 $query
467 )
468 .into_iter()
469 .map(|m| m.0.clone())
470 .collect::<Vec<_>>(),
471 $expected.iter().map(|s| s.to_string()).collect::<Vec<_>>()
472 )
473 };
474 }
475
476 #[test]
477 fn shorter_early_matching_string_is_better_than_longer() {
478 test_order!(
479 ["cpu_test_harness", "cpu"],
480 "cpu",
481 ["cpu", "cpu_test_harness"]
482 );
483 }
484
485 #[test]
486 fn separate_first_word_handles_empty_string() {
487 let result = separate_first_word("");
488 assert_eq!(
489 result,
490 (String::new(), String::new(), String::new(), String::new())
491 );
492 }
493
494 #[test]
495 fn separate_first_word_handles_whitespace_only() {
496 let result = separate_first_word(" ");
497 assert_eq!(
498 result,
499 (" ".into(), String::new(), String::new(), String::new())
500 );
501 }
502
503 #[test]
504 fn separate_first_word_handles_single_word() {
505 let result = separate_first_word("test");
506 assert_eq!(
507 result,
508 (String::new(), "test".into(), String::new(), String::new())
509 );
510 }
511
512 #[test]
513 fn separate_first_word_handles_word_with_trailing() {
514 let result = separate_first_word("test hello");
515 assert_eq!(
516 result,
517 (String::new(), "test".into(), " ".into(), "hello".into())
518 );
519 }
520
521 #[test]
522 fn separate_until_comma_handles_empty_string() {
523 let result = separate_until_comma("");
524 assert_eq!(
525 result,
526 (String::new(), String::new(), String::new(), String::new())
527 );
528 }
529
530 #[test]
531 fn separate_until_comma_handles_no_comma() {
532 let result = separate_until_comma("test");
533 assert_eq!(
534 result,
535 (String::new(), "test".into(), String::new(), String::new())
536 );
537 }
538
539 #[test]
540 fn separate_until_comma_handles_with_comma() {
541 let result = separate_until_comma("first,second");
542 assert_eq!(
543 result,
544 (String::new(), "first".into(), ",".into(), "second".into())
545 );
546 }
547
548 #[test]
549 fn fuzzy_score_handles_empty_query() {
550 let result = fuzzy_score("test", "");
551 assert_eq!(result.0, 0);
552 assert_eq!(result.1, vec![false, false, false, false]);
553 }
554
555 #[test]
556 fn fuzzy_score_handles_empty_line() {
557 let result = fuzzy_score("", "query");
558 assert_eq!(result.0, 0);
559 assert_eq!(result.1, Vec::<bool>::new());
560 }
561
562 #[test]
563 fn fuzzy_score_handles_exact_match() {
564 let result = fuzzy_score("test", "test");
565 assert!(result.0 > 0);
566 assert_eq!(result.1, vec![true, true, true, true]);
567 }
568
569 #[test]
570 fn fuzzy_match_handles_empty_alternatives() {
571 let result = fuzzy_match(&[], "query");
572 assert_eq!(result, vec![]);
573 }
574
575 #[test]
576 fn fuzzy_match_handles_empty_query() {
577 let alternatives = vec!["test".to_string(), "other".to_string()];
578 let result = fuzzy_match(&alternatives, "");
579 assert_eq!(result.len(), 2);
580 }
581
582 #[test]
583 fn parse_command_rejects_extra_parameters_for_terminal() {
584 let command = Command::Terminal(CommandOutputs::NoParams);
585 let result = parse_command("extra stuff", command);
586 assert_eq!(
587 result,
588 Err(ParseError::ExtraParameters("extra stuff".into()))
589 );
590 }
591
592 #[test]
593 fn parse_command_handles_missing_parameters() {
594 let parser = get_parser();
595 let result = parse_command("oneparam", parser);
596 assert_eq!(result, Err(ParseError::MissingParameters));
597 }
598
599 #[test]
600 fn parse_command_handles_invalid_parameter() {
601 let parser = get_parser();
602 let result = parse_command("parsedparam notanumber", parser);
603 assert_eq!(
604 result,
605 Err(ParseError::InvalidParameter("notanumber".into()))
606 );
607 }
608
609 #[test]
610 fn expand_command_handles_terminal() {
611 let command = Command::Terminal(CommandOutputs::NoParams);
612 let result = expand_command("", command);
613 assert_eq!(result.expanded, "");
614 assert!(matches!(
615 result.suggestions,
616 Err(FuzzyError::ReachedTerminal)
617 ));
618 }
619
620 #[test]
621 fn expand_command_handles_empty_query() {
622 let parser = get_parser();
623 let result = expand_command("", parser);
624 assert_eq!(result.expanded, "");
625 assert!(result.suggestions.is_ok());
626 }
627}