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    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}