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