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 for i in indices {
290 matches[i] = true;
291 }
292 (score, matches)
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 #[derive(Debug, PartialEq)]
299 enum CommandOutputs {
300 NoParams,
301 OptionalParam(Option<String>),
302 OneParam(String),
303 ParsedParam(i32),
304 Two(String, String),
305 }
306
307 fn get_parser() -> Command<CommandOutputs> {
308 fn single_word(
309 suggestions: Vec<String>,
310 rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
311 ) -> Option<Command<CommandOutputs>> {
312 Some(Command::NonTerminal(
313 ParamGreed::Word,
314 suggestions,
315 Box::new(move |query, _| rest_command(query)),
316 ))
317 }
318 fn optional_word(
319 suggestions: Vec<String>,
320 rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
321 ) -> Option<Command<CommandOutputs>> {
322 Some(Command::NonTerminal(
323 ParamGreed::OptionalWord,
324 suggestions,
325 Box::new(move |query, _| rest_command(query)),
326 ))
327 }
328 fn single_comma_separation(
329 suggestions: Vec<String>,
330 rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
331 ) -> Option<Command<CommandOutputs>> {
332 Some(Command::NonTerminal(
333 ParamGreed::ToComma,
334 suggestions,
335 Box::new(move |query, _| rest_command(query)),
336 ))
337 }
338
339 Command::NonTerminal(
340 ParamGreed::Word,
341 vec!["noparams".into(), "oneparam".into(), "parsedparam".into()],
342 Box::new(|query, _| {
343 let multi_comma = single_comma_separation(
344 vec![],
345 Box::new(|first| {
346 let first = first.to_string();
347 Some(Command::NonTerminal(
348 ParamGreed::ToComma,
349 vec![],
350 Box::new(move |second, _| {
351 Some(Command::Terminal(CommandOutputs::Two(
352 first.clone(),
353 second.into(),
354 )))
355 }),
356 ))
357 }),
358 );
359 match query {
360 "noparams" => Some(Command::Terminal(CommandOutputs::NoParams)),
361 "oneparam" => single_word(
362 vec![],
363 Box::new(|word| {
364 Some(Command::Terminal(CommandOutputs::OneParam(word.into())))
365 }),
366 ),
367 "optionalparam" => optional_word(
368 vec![],
369 Box::new(|word| {
370 Some(Command::Terminal(CommandOutputs::OptionalParam(
371 if word == " " { None } else { Some(word.into()) },
372 )))
373 }),
374 ),
375 "parsedparam" => single_word(
376 vec![],
377 Box::new(|word| {
378 word.parse::<i32>()
379 .map(|int| Command::Terminal(CommandOutputs::ParsedParam(int)))
380 .ok()
381 }),
382 ),
383 "singlecomma" => single_comma_separation(
384 vec![],
385 Box::new(|word| {
386 word.parse::<i32>()
387 .map(|int| Command::Terminal(CommandOutputs::ParsedParam(int)))
388 .ok()
389 }),
390 ),
391 "multicomma" => multi_comma,
392 _ => None,
393 }
394 }),
395 )
396 }
397
398 #[test]
399 fn basic_parsing_test() {
400 let parser = get_parser();
401
402 let result = parse_command("noparams", parser);
403 assert_eq!(result, Ok(CommandOutputs::NoParams));
404 }
405
406 #[test]
407 fn parsing_with_params_works() {
408 let parser = get_parser();
409 let result = parse_command("oneparam test", parser);
410
411 assert_eq!(result, Ok(CommandOutputs::OneParam("test".into())));
412 }
413
414 #[test]
415 fn parsing_with_parsed_param_works() {
416 let parser = get_parser();
417
418 let result = parse_command("parsedparam 5", parser);
419
420 assert_eq!(result, Ok(CommandOutputs::ParsedParam(5)));
421 }
422 #[test]
423 fn parsing_with_commas_works_with_missing_trailing_comma() {
424 let parser = get_parser();
425
426 let result = parse_command("singlecomma 5", parser);
427
428 assert_eq!(result, Ok(CommandOutputs::ParsedParam(5)));
429 }
430
431 #[test]
432 fn parsing_with_multiple_commas_works() {
433 let parser = get_parser();
434
435 let result = parse_command("multicomma yolo, swag", parser);
436
437 assert_eq!(
438 result,
439 Ok(CommandOutputs::Two("yolo".into(), "swag".into()))
440 );
441 }
442
443 #[test]
444 fn parsing_optional_word_works() {
445 let parser = get_parser();
446
447 let result = parse_command("optionalparam yolo", parser);
448 assert_eq!(
449 result,
450 Ok(CommandOutputs::OptionalParam(Some("yolo".into())))
451 );
452 }
453
454 #[test]
455 fn parsing_optional_without_word_works() {
456 let parser = get_parser();
457
458 let result = parse_command("optionalparam", parser);
459 assert_eq!(result, Ok(CommandOutputs::OptionalParam(None)));
460 }
461
462 macro_rules! test_order {
463 ($list:expr, $query:expr, $expected:expr) => {
464 assert_eq!(
465 fuzzy_match(
466 &$list.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
467 $query
468 )
469 .into_iter()
470 .map(|m| m.0.clone())
471 .collect::<Vec<_>>(),
472 $expected.iter().map(|s| s.to_string()).collect::<Vec<_>>()
473 )
474 };
475 }
476
477 #[test]
478 fn shorter_early_matching_string_is_better_than_longer() {
479 test_order!(
480 ["cpu_test_harness", "cpu"],
481 "cpu",
482 ["cpu", "cpu_test_harness"]
483 );
484 }
485
486 #[test]
487 fn separate_first_word_handles_empty_string() {
488 let result = separate_first_word("");
489 assert_eq!(
490 result,
491 (String::new(), String::new(), String::new(), String::new())
492 );
493 }
494
495 #[test]
496 fn separate_first_word_handles_whitespace_only() {
497 let result = separate_first_word(" ");
498 assert_eq!(
499 result,
500 (" ".into(), String::new(), String::new(), String::new())
501 );
502 }
503
504 #[test]
505 fn separate_first_word_handles_single_word() {
506 let result = separate_first_word("test");
507 assert_eq!(
508 result,
509 (String::new(), "test".into(), String::new(), String::new())
510 );
511 }
512
513 #[test]
514 fn separate_first_word_handles_word_with_trailing() {
515 let result = separate_first_word("test hello");
516 assert_eq!(
517 result,
518 (String::new(), "test".into(), " ".into(), "hello".into())
519 );
520 }
521
522 #[test]
523 fn separate_until_comma_handles_empty_string() {
524 let result = separate_until_comma("");
525 assert_eq!(
526 result,
527 (String::new(), String::new(), String::new(), String::new())
528 );
529 }
530
531 #[test]
532 fn separate_until_comma_handles_no_comma() {
533 let result = separate_until_comma("test");
534 assert_eq!(
535 result,
536 (String::new(), "test".into(), String::new(), String::new())
537 );
538 }
539
540 #[test]
541 fn separate_until_comma_handles_with_comma() {
542 let result = separate_until_comma("first,second");
543 assert_eq!(
544 result,
545 (String::new(), "first".into(), ",".into(), "second".into())
546 );
547 }
548
549 #[test]
550 fn fuzzy_score_handles_empty_query() {
551 let result = fuzzy_score("test", "");
552 assert_eq!(result.0, 0);
553 assert_eq!(result.1, vec![false, false, false, false]);
554 }
555
556 #[test]
557 fn fuzzy_score_handles_empty_line() {
558 let result = fuzzy_score("", "query");
559 assert_eq!(result.0, 0);
560 assert_eq!(result.1, Vec::<bool>::new());
561 }
562
563 #[test]
564 fn fuzzy_score_handles_exact_match() {
565 let result = fuzzy_score("test", "test");
566 assert!(result.0 > 0);
567 assert_eq!(result.1, vec![true, true, true, true]);
568 }
569
570 #[test]
571 fn fuzzy_match_handles_empty_alternatives() {
572 let result = fuzzy_match(&[], "query");
573 assert_eq!(result, vec![]);
574 }
575
576 #[test]
577 fn fuzzy_match_handles_empty_query() {
578 let alternatives = vec!["test".to_string(), "other".to_string()];
579 let result = fuzzy_match(&alternatives, "");
580 assert_eq!(result.len(), 2);
581 }
582
583 #[test]
584 fn parse_command_rejects_extra_parameters_for_terminal() {
585 let command = Command::Terminal(CommandOutputs::NoParams);
586 let result = parse_command("extra stuff", command);
587 assert_eq!(
588 result,
589 Err(ParseError::ExtraParameters("extra stuff".into()))
590 );
591 }
592
593 #[test]
594 fn parse_command_handles_missing_parameters() {
595 let parser = get_parser();
596 let result = parse_command("oneparam", parser);
597 assert_eq!(result, Err(ParseError::MissingParameters));
598 }
599
600 #[test]
601 fn parse_command_handles_invalid_parameter() {
602 let parser = get_parser();
603 let result = parse_command("parsedparam notanumber", parser);
604 assert_eq!(
605 result,
606 Err(ParseError::InvalidParameter("notanumber".into()))
607 );
608 }
609
610 #[test]
611 fn expand_command_handles_terminal() {
612 let command = Command::Terminal(CommandOutputs::NoParams);
613 let result = expand_command("", command);
614 assert_eq!(result.expanded, "");
615 assert!(matches!(
616 result.suggestions,
617 Err(FuzzyError::ReachedTerminal)
618 ));
619 }
620
621 #[test]
622 fn expand_command_handles_empty_query() {
623 let parser = get_parser();
624 let result = expand_command("", parser);
625 assert_eq!(result.expanded, "");
626 assert!(result.suggestions.is_ok());
627 }
628}