Skip to main content

libsurfer/
fzcmd.rs

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// Removing things that are unused for now would require removal of usable code
38#[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    // Indicates that something went wrong when doing fuzzy expansion which
134    // lead to trying to do fuzzy matching on a terminal
135    ReachedTerminal,
136    // The query was expanded based on the suggestions, but the expansion
137    // was not a valid command
138    MalformedCommand(Vec<String>, String),
139    // The fuzzy expander ran out of input and can not make any more suggestions.
140    // This should never be returned publicly
141    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, &current_section);
178
179        let best_expansion = {
180            let expansion = expanded_commands
181                .first()
182                .map_or(&current_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                    &current_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                        // Return all suggestions for this non-terminal
205                        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}