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#[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 ReachedTerminal,
140 MalformedCommand(Vec<String>, String),
143 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, ¤t_section);
182
183 let best_expansion = {
184 let expansion = expanded_commands
185 .first()
186 .map(|(query, _)| query)
187 .unwrap_or(¤t_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 ¤t_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 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}