libsurfer/
fzcmd.rs

1use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
2use regex::Regex;
3
4use lazy_static::lazy_static;
5
6#[derive(PartialEq, Debug)]
7pub enum RestQuery {
8    Something(String),
9    Empty,
10}
11impl RestQuery {
12    pub fn is_empty(&self) -> bool {
13        *self == RestQuery::Empty
14    }
15}
16impl From<&str> for RestQuery {
17    fn from(other: &str) -> Self {
18        match other {
19            "" => RestQuery::Empty,
20            other => RestQuery::Something(other.into()),
21        }
22    }
23}
24impl From<String> for RestQuery {
25    fn from(other: String) -> Self {
26        if other.is_empty() {
27            RestQuery::Empty
28        } else {
29            RestQuery::Something(other)
30        }
31    }
32}
33
34pub type QuerySplitter = dyn Fn(&str) -> (String, String, String, String);
35
36// Removing things that are unused for now would require removal of usable code
37#[allow(dead_code)]
38pub enum ParamGreed {
39    Word,
40    OptionalWord,
41    ToComma,
42    Rest,
43    Custom(&'static QuerySplitter),
44}
45
46pub type Parser<T> = Box<dyn Fn(&str, RestQuery) -> Option<Command<T>>>;
47
48pub enum Command<T> {
49    Terminal(T),
50    NonTerminal(ParamGreed, Vec<String>, Parser<T>),
51}
52
53#[derive(Debug, PartialEq, Clone)]
54pub enum ParseError {
55    InvalidParameter(String),
56    MissingParameters,
57    ExtraParameters(String),
58}
59
60fn separate_first_word(query: &str) -> (String, String, String, String) {
61    lazy_static! {
62        static ref RE: Regex = Regex::new(r#"(\W*)(\w*)(\W?)(.*)"#).unwrap();
63    }
64
65    let captures = RE.captures_iter(query).next().unwrap();
66
67    (
68        captures[1].into(),
69        captures[2].into(),
70        captures[3].into(),
71        captures[4].into(),
72    )
73}
74
75fn separate_optional_word(query: &str) -> (String, String, String, String) {
76    if query.chars().all(|c| c.is_whitespace()) {
77        (
78            "".to_string(),
79            " ".to_string(),
80            "".to_string(),
81            "".to_string(),
82        )
83    } else {
84        separate_first_word(query)
85    }
86}
87
88fn separate_until_comma(query: &str) -> (String, String, String, String) {
89    lazy_static! {
90        static ref RE: Regex = Regex::new(r#"(\W*)([^,]*)(,?)(.*)"#).unwrap();
91    }
92
93    RE.captures_iter(query)
94        .next()
95        .map(|captures| {
96            (
97                captures[1].into(),
98                captures[2].into(),
99                captures[3].into(),
100                captures[4].into(),
101            )
102        })
103        .unwrap_or(("".into(), query.into(), "".into(), "".into()))
104}
105
106fn split_query(query: &str, greed: ParamGreed) -> (String, String, String, String) {
107    match greed {
108        ParamGreed::Word => separate_first_word(query),
109        ParamGreed::OptionalWord => separate_optional_word(query),
110        ParamGreed::ToComma => separate_until_comma(query),
111        ParamGreed::Rest => ("".into(), query.trim_start().into(), "".into(), "".into()),
112        ParamGreed::Custom(matcher) => matcher(query),
113    }
114}
115
116pub fn parse_command<T>(query: &str, command: Command<T>) -> Result<T, ParseError> {
117    match command {
118        Command::Terminal(val) => match query {
119            "" => Ok(val),
120            _ => Err(ParseError::ExtraParameters(query.into())),
121        },
122        Command::NonTerminal(greed, _, parsing_function) => {
123            let (_, greed_match, _delim, rest) = split_query(query, greed);
124
125            match greed_match.as_ref() {
126                "" => Err(ParseError::MissingParameters),
127                param => match parsing_function(param, rest.clone().into()) {
128                    Some(next_command) => parse_command(&rest, next_command),
129                    None => Err(ParseError::InvalidParameter(param.into())),
130                },
131            }
132        }
133    }
134}
135
136pub enum FuzzyError {
137    // Indicates that something went wrong when doing fuzzy expansion which
138    // lead to trying to do fuzzy matching on a terminal
139    ReachedTerminal,
140    // The query was expanded based on the suggestions, but the expansion
141    // was not a valid command
142    MalformedCommand(Vec<String>, String),
143    // The fuzzy expander ran out of input and can not make any more suggestions.
144    // This should never be returned publicly
145    NoMoreInput,
146}
147
148pub struct FuzzyOutput {
149    pub expanded: String,
150    pub suggestions: Result<Vec<(String, Vec<bool>)>, FuzzyError>,
151}
152
153fn handle_non_terminal_fuzz<T>(
154    previous_query: &str,
155    query: &str,
156    greed: ParamGreed,
157    suggestions: &[String],
158    parser: Parser<T>,
159) -> FuzzyOutput {
160    let (leading_whitespace, current_section, delim, rest_query) = split_query(query, greed);
161    let rest_query = delim.clone() + &rest_query;
162
163    if leading_whitespace.is_empty() && current_section.is_empty() {
164        FuzzyOutput {
165            expanded: previous_query.into(),
166            suggestions: if previous_query
167                .chars()
168                .last()
169                .is_some_and(|c| c.is_whitespace())
170            {
171                let s = suggestions
172                    .iter()
173                    .map(|x| (x.to_string(), vec![false; x.len()]))
174                    .collect::<Vec<(String, Vec<bool>)>>();
175                Ok(s)
176            } else {
177                Err(FuzzyError::NoMoreInput)
178            },
179        }
180    } else {
181        let expanded_commands = fuzzy_match(suggestions, &current_section);
182
183        let best_expansion = {
184            let expansion = expanded_commands
185                .first()
186                .map(|(query, _)| query)
187                .unwrap_or(&current_section);
188
189            parser(expansion, rest_query.clone().into()).map(|command| (expansion, command))
190        };
191
192        let full_query = |expansion| previous_query.to_string() + expansion + &delim;
193
194        match best_expansion {
195            Some((expansion, Command::NonTerminal(next_greed, next_suggestions, next_parser))) => {
196                let current_query = full_query(expansion);
197                let next_result = handle_non_terminal_fuzz(
198                    &current_query,
199                    &rest_query,
200                    next_greed,
201                    &next_suggestions,
202                    next_parser,
203                );
204                match next_result {
205                    FuzzyOutput {
206                        suggestions: Err(FuzzyError::NoMoreInput),
207                        ..
208                    } => {
209                        // Return all suggestions for this non-terminal
210                        FuzzyOutput {
211                            expanded: current_query,
212                            suggestions: Ok(expanded_commands),
213                        }
214                    }
215                    future_result => future_result,
216                }
217            }
218            Some((expansion, Command::Terminal(_))) => {
219                let current_query = full_query(expansion);
220                FuzzyOutput {
221                    expanded: current_query,
222                    suggestions: Ok(expanded_commands),
223                }
224            }
225            None => {
226                let err = Err(FuzzyError::MalformedCommand(
227                    suggestions.to_vec(),
228                    query.into(),
229                ));
230                FuzzyOutput {
231                    expanded: previous_query.into(),
232                    suggestions: err,
233                }
234            }
235        }
236    }
237}
238
239pub fn expand_command<T>(query: &str, command: Command<T>) -> FuzzyOutput {
240    match command {
241        Command::NonTerminal(greed, suggestions, parser) => {
242            let fuzz_result = handle_non_terminal_fuzz("", query, greed, &suggestions, parser);
243            match fuzz_result {
244                FuzzyOutput {
245                    expanded,
246                    suggestions: Err(FuzzyError::NoMoreInput),
247                } => {
248                    let suggestion_matches = suggestions
249                        .iter()
250                        .cloned()
251                        .map(|s| {
252                            let falses = (0..s.len()).map(|_| false).collect();
253                            (s, falses)
254                        })
255                        .collect();
256
257                    FuzzyOutput {
258                        expanded,
259                        suggestions: Ok(suggestion_matches),
260                    }
261                }
262                other => other,
263            }
264        }
265        Command::Terminal(_) => FuzzyOutput {
266            expanded: "".into(),
267            suggestions: Err(FuzzyError::ReachedTerminal),
268        },
269    }
270}
271
272fn fuzzy_match(alternatives: &[String], query: &str) -> Vec<(String, Vec<bool>)> {
273    let mut with_scores = alternatives
274        .iter()
275        .map(|option| {
276            let (score, matches) = fuzzy_score(option, query);
277            (option, score, matches)
278        })
279        .collect::<Vec<_>>();
280
281    with_scores.sort_by_key(|(option, score, _)| (-*score, option.len()));
282
283    with_scores
284        .into_iter()
285        .map(|(value, _, matches)| (value.clone(), matches))
286        .collect()
287}
288
289fn fuzzy_score(line: &str, query: &str) -> (i64, Vec<bool>) {
290    let (score, indices) = SkimMatcherV2::default()
291        .fuzzy_indices(line, query)
292        .unwrap_or_default();
293
294    let mut matches = vec![false; line.len()];
295    for i in indices {
296        matches[i] = true
297    }
298    (score, matches)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    #[derive(Debug, PartialEq)]
305    enum CommandOutputs {
306        NoParams,
307        OptionalParam(Option<String>),
308        OneParam(String),
309        ParsedParam(i32),
310        Two(String, String),
311    }
312
313    fn get_parser() -> Command<CommandOutputs> {
314        fn single_word(
315            suggestions: Vec<String>,
316            rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
317        ) -> Option<Command<CommandOutputs>> {
318            Some(Command::NonTerminal(
319                ParamGreed::Word,
320                suggestions,
321                Box::new(move |query, _| rest_command(query)),
322            ))
323        }
324        fn optional_word(
325            suggestions: Vec<String>,
326            rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
327        ) -> Option<Command<CommandOutputs>> {
328            Some(Command::NonTerminal(
329                ParamGreed::OptionalWord,
330                suggestions,
331                Box::new(move |query, _| rest_command(query)),
332            ))
333        }
334        fn single_comma_separation(
335            suggestions: Vec<String>,
336            rest_command: Box<dyn Fn(&str) -> Option<Command<CommandOutputs>>>,
337        ) -> Option<Command<CommandOutputs>> {
338            Some(Command::NonTerminal(
339                ParamGreed::ToComma,
340                suggestions,
341                Box::new(move |query, _| rest_command(query)),
342            ))
343        }
344
345        Command::NonTerminal(
346            ParamGreed::Word,
347            vec!["noparams".into(), "oneparam".into(), "parsedparam".into()],
348            Box::new(|query, _| {
349                let multi_comma = single_comma_separation(
350                    vec![],
351                    Box::new(|first| {
352                        let first = first.to_string();
353                        Some(Command::NonTerminal(
354                            ParamGreed::ToComma,
355                            vec![],
356                            Box::new(move |second, _| {
357                                Some(Command::Terminal(CommandOutputs::Two(
358                                    first.clone(),
359                                    second.into(),
360                                )))
361                            }),
362                        ))
363                    }),
364                );
365                match query {
366                    "noparams" => Some(Command::Terminal(CommandOutputs::NoParams)),
367                    "oneparam" => single_word(
368                        vec![],
369                        Box::new(|word| {
370                            Some(Command::Terminal(CommandOutputs::OneParam(word.into())))
371                        }),
372                    ),
373                    "optionalparam" => optional_word(
374                        vec![],
375                        Box::new(|word| {
376                            Some(Command::Terminal(CommandOutputs::OptionalParam(
377                                if word == " " { None } else { Some(word.into()) },
378                            )))
379                        }),
380                    ),
381                    "parsedparam" => single_word(
382                        vec![],
383                        Box::new(|word| {
384                            word.parse::<i32>()
385                                .map(|int| Command::Terminal(CommandOutputs::ParsedParam(int)))
386                                .ok()
387                        }),
388                    ),
389                    "singlecomma" => single_comma_separation(
390                        vec![],
391                        Box::new(|word| {
392                            word.parse::<i32>()
393                                .map(|int| Command::Terminal(CommandOutputs::ParsedParam(int)))
394                                .ok()
395                        }),
396                    ),
397                    "multicomma" => multi_comma,
398                    _ => None,
399                }
400            }),
401        )
402    }
403
404    #[test]
405    fn basic_parsing_test() {
406        let parser = get_parser();
407
408        let result = parse_command("noparams", parser);
409        assert_eq!(result, Ok(CommandOutputs::NoParams));
410    }
411
412    #[test]
413    fn parsing_with_params_works() {
414        let parser = get_parser();
415        let result = parse_command("oneparam test", parser);
416
417        assert_eq!(result, Ok(CommandOutputs::OneParam("test".into())));
418    }
419
420    #[test]
421    fn parsing_with_parsed_param_works() {
422        let parser = get_parser();
423
424        let result = parse_command("parsedparam 5", parser);
425
426        assert_eq!(result, Ok(CommandOutputs::ParsedParam(5)));
427    }
428    #[test]
429    fn parsing_with_commas_works_with_missing_trailing_comma() {
430        let parser = get_parser();
431
432        let result = parse_command("singlecomma 5", parser);
433
434        assert_eq!(result, Ok(CommandOutputs::ParsedParam(5)));
435    }
436
437    #[test]
438    fn parsing_with_multiple_commas_works() {
439        let parser = get_parser();
440
441        let result = parse_command("multicomma yolo, swag", parser);
442
443        assert_eq!(
444            result,
445            Ok(CommandOutputs::Two("yolo".into(), "swag".into()))
446        );
447    }
448
449    #[test]
450    fn parsing_optional_word_works() {
451        let parser = get_parser();
452
453        let result = parse_command("optionalparam yolo", parser);
454        assert_eq!(
455            result,
456            Ok(CommandOutputs::OptionalParam(Some("yolo".into())))
457        );
458    }
459
460    #[test]
461    fn parsing_optional_without_word_works() {
462        let parser = get_parser();
463
464        let result = parse_command("optionalparam", parser);
465        assert_eq!(result, Ok(CommandOutputs::OptionalParam(None)));
466    }
467
468    macro_rules! test_order {
469        ($list:expr, $query:expr, $expected:expr) => {
470            assert_eq!(
471                fuzzy_match(
472                    &$list.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
473                    $query
474                )
475                .into_iter()
476                .map(|m| m.0.clone())
477                .collect::<Vec<_>>(),
478                $expected.iter().map(|s| s.to_string()).collect::<Vec<_>>()
479            )
480        };
481    }
482
483    #[test]
484    fn shorter_early_matching_string_is_better_than_longer() {
485        test_order!(
486            ["cpu_test_harness", "cpu"],
487            "cpu",
488            ["cpu", "cpu_test_harness"]
489        )
490    }
491}