initiative_core/world/command/
autocomplete.rs

1use super::ParsedThing;
2use crate::app::{AppMeta, Autocomplete, AutocompleteSuggestion};
3use crate::utils::{quoted_words, CaseInsensitiveStr};
4use crate::world::npc::{Age, Ethnicity, Gender, NpcData, Species};
5use crate::world::place::{PlaceData, PlaceType};
6use crate::world::thing::ThingData;
7use async_trait::async_trait;
8use std::collections::HashSet;
9use std::str::FromStr;
10
11struct ParsedInput<'a> {
12    name_desc: &'a str,
13    name: &'a str,
14    desc: &'a str,
15    desc_lower: Option<String>,
16    partial: &'a str,
17}
18
19impl ParsedInput<'_> {
20    fn suggestion(&self, suggestion: &str) -> String {
21        format!("{}{}", self.name_desc, suggestion)
22    }
23
24    fn desc_lower(&self) -> &str {
25        if let Some(s) = &self.desc_lower {
26            s.as_str()
27        } else {
28            self.desc
29        }
30    }
31}
32
33impl<'a> From<&'a str> for ParsedInput<'a> {
34    fn from(input: &'a str) -> Self {
35        let name_desc_split = if let Some(comma_pos) = input.rfind(',').map(|i| i + ','.len_utf8())
36        {
37            if let Some(non_whitespace_pos) = input[comma_pos..].find(|c: char| !c.is_whitespace())
38            {
39                comma_pos + non_whitespace_pos
40            } else {
41                input.len()
42            }
43        } else {
44            0
45        };
46
47        let desc_partial_split = if input.ends_with(|c: char| c == ',' || c.is_whitespace()) {
48            input.len()
49        } else {
50            quoted_words(input)
51                .last()
52                .map_or_else(|| input.len(), |word| word.range().start)
53        };
54
55        let desc = &input[name_desc_split..desc_partial_split];
56
57        Self {
58            name_desc: &input[..desc_partial_split],
59            name: &input[..name_desc_split],
60            desc,
61            desc_lower: if desc.chars().any(char::is_uppercase) {
62                Some(desc.to_lowercase())
63            } else {
64                None
65            },
66            partial: &input[desc_partial_split..],
67        }
68    }
69}
70
71fn autocomplete_trailing_name<T: FromStr + Into<ThingData>>(
72    input: &str,
73) -> Option<AutocompleteSuggestion> {
74    if !quoted_words(input)
75        .skip(1)
76        .any(|word| word.as_str().in_ci(&["named", "called"]))
77    {
78        return None;
79    }
80
81    let mut input_iter = input.split_inclusive(char::is_whitespace).rev();
82    let len_named = input_iter
83        .find_map(|s| {
84            if s.trim().in_ci(&["named", "called"]) {
85                Some(s.len())
86            } else {
87                None
88            }
89        })
90        .unwrap();
91    let before_pos: usize = input_iter.map(|s| s.len()).sum();
92    let after_pos = before_pos + len_named;
93
94    if let Ok(thing) = input[..before_pos].trim().parse::<T>().map(|t| t.into()) {
95        if after_pos >= input.trim_end().len() && thing.name().is_none() {
96            let mut suggestion = input.to_string();
97            if !suggestion.ends_with(char::is_whitespace) {
98                suggestion.push(' ');
99            }
100            suggestion.push_str("[name]");
101            Some(AutocompleteSuggestion::new(suggestion, "specify a name"))
102        } else {
103            Some(AutocompleteSuggestion::new(
104                input.to_string(),
105                format!("create {}", thing.display_description()),
106            ))
107        }
108    } else {
109        None
110    }
111}
112
113fn autocomplete_terms<T: Default + FromStr + Into<ThingData>>(
114    input: &str,
115    basic_terms: &[&str],
116    vocabulary: &[(&str, &str, &[&str])],
117) -> Vec<AutocompleteSuggestion> {
118    if let Some(result) = autocomplete_trailing_name::<T>(input) {
119        return vec![result];
120    }
121
122    const ARTICLES: &[&str] = &["a", "an"];
123
124    let parsed: ParsedInput = input.into();
125
126    if parsed.partial.is_empty() || parsed.partial.in_ci(ARTICLES) {
127        // Ends with a space or ignored word - suggest new word categories
128        if quoted_words(parsed.desc).all(|word| word.as_str().in_ci(ARTICLES)) {
129            let thing_data: ThingData = T::default().into();
130
131            let suggestion = format!(
132                "{}{}[{} description]",
133                input,
134                if input.ends_with(|c: char| !c.is_whitespace()) {
135                    " "
136                } else {
137                    ""
138                },
139                thing_data.as_str(),
140            );
141
142            vec![AutocompleteSuggestion::new(
143                suggestion,
144                format!("create {}", thing_data.display_description()),
145            )]
146        } else if let Ok(thing_data) = parsed.name_desc.parse::<T>().map(|t| t.into()) {
147            let mut suggestions = Vec::new();
148
149            let words: HashSet<&str> = quoted_words(parsed.desc_lower())
150                .map(|word| word.as_str())
151                .collect();
152
153            if thing_data.name().is_none() {
154                suggestions.push(AutocompleteSuggestion::new(
155                    parsed.suggestion("named [name]"),
156                    "specify a name",
157                ));
158            }
159
160            for (placeholder, description, terms) in vocabulary {
161                if !terms.iter().any(|term| words.contains(term)) {
162                    suggestions.push(AutocompleteSuggestion::new(
163                        parsed.suggestion(&format!("[{}]", placeholder)),
164                        description.to_string(),
165                    ));
166                }
167            }
168
169            suggestions
170        } else {
171            Vec::new()
172        }
173    } else if !parsed.desc.is_empty() {
174        // Multiple words: make suggestions if existing words made sense.
175        let words: HashSet<&str> = {
176            quoted_words(parsed.desc_lower())
177                .map(|word| word.as_str())
178                .filter(|s| s != &parsed.partial && !s.in_ci(ARTICLES))
179                .collect()
180        };
181
182        if words.is_empty() || parsed.name_desc.parse::<T>().is_ok() {
183            vocabulary
184                .iter()
185                .filter(|(_, _, terms)| !terms.iter().any(|term| words.contains(term)))
186                .flat_map(|(_, _, terms)| terms.iter())
187                .chain(basic_terms.iter().filter(|term| !words.contains(*term)))
188                .filter(|term| term.starts_with_ci(parsed.partial))
189                .map(|term| parsed.suggestion(term))
190                .filter_map(|term| {
191                    if let Ok(thing_data) = term.parse::<T>().map(|t| t.into()) {
192                        Some(AutocompleteSuggestion::new(
193                            term,
194                            format!("create {}", thing_data.display_description()),
195                        ))
196                    } else {
197                        None
198                    }
199                })
200                .chain(
201                    if parsed.name.is_empty() {
202                        &["named [name]", "called [name]"][..]
203                    } else {
204                        &[][..]
205                    }
206                    .iter()
207                    .filter(|s| s.starts_with_ci(parsed.partial))
208                    .map(|s| AutocompleteSuggestion::new(parsed.suggestion(s), "specify a name")),
209                )
210                .collect::<HashSet<_>>()
211                .drain()
212                .collect()
213        } else {
214            Vec::new()
215        }
216    } else {
217        // First word, autocomplete all known vocabulary
218        vocabulary
219            .iter()
220            .flat_map(|(_, _, terms)| terms.iter())
221            .chain(basic_terms.iter())
222            .filter(|s| s.starts_with_ci(parsed.partial))
223            .filter_map(|term| {
224                let suggestion = parsed.suggestion(term);
225                suggestion.parse::<T>().ok().map(|thing_data| {
226                    AutocompleteSuggestion::new(
227                        suggestion,
228                        format!("create {}", thing_data.into().display_description()),
229                    )
230                })
231            })
232            .collect::<HashSet<_>>()
233            .drain()
234            .collect()
235    }
236}
237
238#[async_trait(?Send)]
239impl Autocomplete for PlaceData {
240    async fn autocomplete(input: &str, _app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
241        autocomplete_terms::<ParsedThing<PlaceData>>(
242            input,
243            &["place"],
244            &[(
245                "place type",
246                "specify a place type (eg. inn)",
247                &PlaceType::get_words().collect::<Vec<_>>(),
248            )],
249        )
250    }
251}
252
253#[async_trait(?Send)]
254impl Autocomplete for NpcData {
255    async fn autocomplete(input: &str, _app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
256        if let Some(word) = quoted_words(input).last().filter(|w| {
257            let s = w.as_str();
258            s.starts_with(|c: char| c.is_ascii_digit())
259                && "-year-old".starts_with_ci(s.trim_start_matches(|c: char| c.is_ascii_digit()))
260        }) {
261            let term = {
262                let word_str = word.as_str();
263                format!(
264                    "{}{}-year-old",
265                    &input[..word.range().start],
266                    &word_str[..word_str
267                        .find(|c: char| !c.is_ascii_digit())
268                        .unwrap_or(word_str.len())]
269                )
270            };
271
272            if let Some(summary) =
273                term.parse::<ParsedThing<ThingData>>()
274                    .ok()
275                    .and_then(|parsed_thing| {
276                        parsed_thing
277                            .thing_data
278                            .npc_data()
279                            .map(|npc| format!("create {}", npc.display_description()))
280                    })
281            {
282                vec![AutocompleteSuggestion::new(term, summary)]
283            } else {
284                Vec::new()
285            }
286        } else {
287            autocomplete_terms::<ParsedThing<NpcData>>(
288                input,
289                &["character", "npc", "person"],
290                &[
291                    (
292                        "age",
293                        "specify an age (eg. \"elderly\")",
294                        &Age::get_words().collect::<Vec<_>>(),
295                    ),
296                    (
297                        "ethnicity",
298                        "specify an ethnicity (eg. \"elvish\")",
299                        &Ethnicity::get_words().collect::<Vec<_>>(),
300                    ),
301                    (
302                        "gender",
303                        "specify a gender",
304                        &Gender::get_words().collect::<Vec<_>>(),
305                    ),
306                    (
307                        "species",
308                        "specify a species (eg. \"dwarf\")",
309                        &Species::get_words().collect::<Vec<_>>(),
310                    ),
311                ],
312            )
313        }
314    }
315}
316
317#[cfg(test)]
318mod test {
319    use super::*;
320    use crate::test_utils as test;
321
322    #[test]
323    fn parsed_input_suggestion_test() {
324        assert_eq!(
325            "Foo, an inn",
326            ParsedInput {
327                name_desc: "Foo, an ",
328                name: "Foo, ",
329                desc: "an ",
330                desc_lower: None,
331                partial: "i",
332            }
333            .suggestion("inn"),
334        );
335    }
336
337    #[test]
338    fn parsed_input_test_empty() {
339        let parsed_input: ParsedInput = "".into();
340        assert_eq!("", parsed_input.name_desc);
341        assert_eq!("", parsed_input.name);
342        assert_eq!("", parsed_input.desc);
343        assert_eq!("", parsed_input.partial);
344    }
345
346    #[test]
347    fn parsed_input_test_one_word() {
348        let parsed_input: ParsedInput = "foo".into();
349        assert_eq!("", parsed_input.name_desc);
350        assert_eq!("", parsed_input.name);
351        assert_eq!("", parsed_input.desc);
352        assert_eq!("foo", parsed_input.partial);
353    }
354
355    #[test]
356    fn parsed_input_test_multiple_words() {
357        let parsed_input: ParsedInput = "foo bar baz".into();
358        assert_eq!("foo bar ", parsed_input.name_desc);
359        assert_eq!("", parsed_input.name);
360        assert_eq!("foo bar ", parsed_input.desc);
361        assert_eq!("baz", parsed_input.partial);
362    }
363
364    #[test]
365    fn parsed_input_test_trailing_whitespace() {
366        let parsed_input: ParsedInput = "foo bar baz ".into();
367        assert_eq!("foo bar baz ", parsed_input.name_desc);
368        assert_eq!("", parsed_input.name);
369        assert_eq!("foo bar baz ", parsed_input.desc);
370        assert_eq!("", parsed_input.partial);
371    }
372
373    #[test]
374    fn parsed_input_test_name_only() {
375        let parsed_input: ParsedInput = "Foo, ".into();
376        assert_eq!("Foo, ", parsed_input.name_desc);
377        assert_eq!("Foo, ", parsed_input.name);
378        assert_eq!("", parsed_input.desc);
379        assert_eq!("", parsed_input.partial);
380    }
381
382    #[test]
383    fn parsed_input_test_name_trailing_word() {
384        let parsed_input: ParsedInput = "Foo, bar".into();
385        assert_eq!("Foo, ", parsed_input.name_desc);
386        assert_eq!("Foo, ", parsed_input.name);
387        assert_eq!("", parsed_input.desc);
388        assert_eq!("bar", parsed_input.partial);
389    }
390
391    #[test]
392    fn parsed_input_test_name_trailing_words() {
393        let parsed_input: ParsedInput = "Foo, a bar".into();
394        assert_eq!("Foo, a ", parsed_input.name_desc);
395        assert_eq!("Foo, ", parsed_input.name);
396        assert_eq!("a ", parsed_input.desc);
397        assert_eq!("bar", parsed_input.partial);
398    }
399
400    #[test]
401    fn parsed_input_test_name_trailing_whitespace() {
402        let parsed_input: ParsedInput = "Foo, a bar ".into();
403        assert_eq!("Foo, a bar ", parsed_input.name_desc);
404        assert_eq!("Foo, ", parsed_input.name);
405        assert_eq!("a bar ", parsed_input.desc);
406        assert_eq!("", parsed_input.partial);
407    }
408
409    #[tokio::test]
410    async fn place_autocomplete_test() {
411        test::assert_autocomplete_eq!(
412            [
413                ("inn", "create inn"),
414                ("imports-shop", "create imports-shop"),
415                ("island", "create island"),
416            ],
417            PlaceData::autocomplete("i", &test::app_meta()).await,
418        );
419
420        test::assert_autocomplete_eq!(
421            [
422                ("an inn", "create inn"),
423                ("an imports-shop", "create imports-shop"),
424                ("an island", "create island"),
425            ],
426            PlaceData::autocomplete("an i", &test::app_meta()).await,
427        );
428
429        test::assert_autocomplete_eq!(
430            [("an inn named [name]", "specify a name")],
431            PlaceData::autocomplete("an inn n", &test::app_meta()).await,
432        );
433
434        test::assert_empty!(
435            PlaceData::autocomplete("a streetcar named desire", &test::app_meta()).await,
436        );
437
438        test::assert_empty!(PlaceData::autocomplete("Foo, an inn n", &test::app_meta()).await);
439    }
440
441    #[tokio::test]
442    async fn place_autocomplete_test_typing() {
443        {
444            let input = "a bar called Heaven";
445            let app_meta = test::app_meta();
446
447            for i in 2..input.len() {
448                assert_ne!(
449                    Vec::<AutocompleteSuggestion>::new(),
450                    PlaceData::autocomplete(&input[..i], &app_meta).await,
451                    "Input: {}",
452                    &input[..i],
453                );
454            }
455        }
456
457        {
458            let input = "Foo, inn";
459            let app_meta = test::app_meta();
460
461            for i in 4..input.len() {
462                assert_ne!(
463                    Vec::<AutocompleteSuggestion>::new(),
464                    PlaceData::autocomplete(&input[..i], &app_meta).await,
465                    "Input: {}",
466                    &input[..i],
467                );
468            }
469        }
470    }
471
472    #[tokio::test]
473    async fn autocomplete_test_npc() {
474        test::assert_autocomplete_eq!(
475            [
476                ("elf [age]", "specify an age (eg. \"elderly\")"),
477                ("elf [ethnicity]", "specify an ethnicity (eg. \"elvish\")"),
478                ("elf [gender]", "specify a gender"),
479                ("elf named [name]", "specify a name"),
480            ],
481            NpcData::autocomplete("elf ", &test::app_meta()).await,
482        );
483
484        test::assert_autocomplete_eq!(
485            [
486                ("human [age]", "specify an age (eg. \"elderly\")"),
487                ("human [gender]", "specify a gender"),
488                ("human named [name]", "specify a name"),
489            ],
490            NpcData::autocomplete("human ", &test::app_meta()).await,
491        );
492    }
493
494    #[tokio::test]
495    async fn npc_autocomplete_test_typing() {
496        let input = "an elderly elvish dwarf woman named Tiramisu";
497        let app_meta = test::app_meta();
498
499        for i in 3..input.len() {
500            assert_ne!(
501                Vec::<AutocompleteSuggestion>::new(),
502                NpcData::autocomplete(&input[..i], &app_meta).await,
503                "Input: {}",
504                &input[..i],
505            );
506        }
507    }
508}