Skip to main content

nms_copilot/
completer.rs

1//! Tab completion for the NMS Copilot REPL.
2//!
3//! Provides context-aware completions:
4//! - Command names (find, show, stats, convert, info, help, exit, quit)
5//! - Subcommand names (show system, show base)
6//! - Flag names (--biome, --nearest, etc.)
7//! - Biome names from the Biome enum
8//! - Base names from the loaded model
9//! - System names from the loaded model
10
11use reedline::{Completer, Span, Suggestion};
12
13/// Completions that depend on the loaded galaxy model.
14#[derive(Clone)]
15pub struct ModelCompletions {
16    /// Known base names (original casing).
17    pub base_names: Vec<String>,
18    /// Known system names (original casing).
19    pub system_names: Vec<String>,
20}
21
22/// REPL tab completer with static command knowledge and dynamic model data.
23pub struct CopilotCompleter {
24    model_data: ModelCompletions,
25}
26
27impl CopilotCompleter {
28    pub fn new(model_data: ModelCompletions) -> Self {
29        Self { model_data }
30    }
31}
32
33const COMMANDS: &[&str] = &[
34    "convert", "exit", "find", "help", "info", "quit", "reset", "route", "set", "show", "stats",
35    "status",
36];
37
38const SHOW_SUBCOMMANDS: &[&str] = &["system", "base"];
39
40const FIND_FLAGS: &[&str] = &[
41    "--biome",
42    "--infested",
43    "--within",
44    "--nearest",
45    "--named",
46    "--discoverer",
47    "--from",
48];
49
50const STATS_FLAGS: &[&str] = &["--biomes", "--discoveries"];
51
52const CONVERT_FLAGS: &[&str] = &[
53    "--glyphs", "--coords", "--ga", "--voxel", "--ssi", "--planet", "--galaxy",
54];
55
56const ROUTE_FLAGS: &[&str] = &[
57    "--algo",
58    "--biome",
59    "--from",
60    "--max-targets",
61    "--round-trip",
62    "--target",
63    "--warp-range",
64    "--within",
65];
66
67const SET_SUBCOMMANDS: &[&str] = &["position", "biome", "warp-range"];
68
69const RESET_TARGETS: &[&str] = &["position", "biome", "warp-range", "all"];
70
71const BIOME_NAMES: &[&str] = &[
72    "Lush",
73    "Toxic",
74    "Scorched",
75    "Radioactive",
76    "Frozen",
77    "Barren",
78    "Dead",
79    "Weird",
80    "Red",
81    "Green",
82    "Blue",
83    "Swamp",
84    "Lava",
85    "Waterworld",
86];
87
88impl Completer for CopilotCompleter {
89    fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
90        let line_to_pos = &line[..pos];
91        let words: Vec<&str> = line_to_pos.split_whitespace().collect();
92        // Lowercase words for case-insensitive context matching
93        let lower: Vec<String> = words.iter().map(|w| w.to_lowercase()).collect();
94        let lower_refs: Vec<&str> = lower.iter().map(|s| s.as_str()).collect();
95        let trailing_space = line_to_pos.ends_with(' ');
96
97        let (partial, candidates) = match lower_refs.as_slice() {
98            [] => ("", COMMANDS.to_vec()),
99            [_] if !trailing_space => (words[0], COMMANDS.to_vec()),
100
101            ["show"] if trailing_space => ("", SHOW_SUBCOMMANDS.to_vec()),
102            ["show", _] if !trailing_space => (words[1], SHOW_SUBCOMMANDS.to_vec()),
103
104            ["show", "base"] if trailing_space => {
105                return self.complete_names("", &self.model_data.base_names, pos);
106            }
107            ["show", "base", _] if !trailing_space => {
108                return self.complete_names(words[2], &self.model_data.base_names, pos);
109            }
110
111            ["show", "system"] if trailing_space => {
112                return self.complete_names("", &self.model_data.system_names, pos);
113            }
114            ["show", "system", _] if !trailing_space => {
115                return self.complete_names(words[2], &self.model_data.system_names, pos);
116            }
117
118            ["set"] if trailing_space => ("", SET_SUBCOMMANDS.to_vec()),
119            ["set", _] if !trailing_space => (words[1], SET_SUBCOMMANDS.to_vec()),
120
121            ["set", "biome"] if trailing_space => {
122                return self.filter_suggestions("", BIOME_NAMES, pos);
123            }
124            ["set", "biome", _] if !trailing_space => {
125                return self.filter_suggestions(words[2], BIOME_NAMES, pos);
126            }
127
128            ["set", "position"] if trailing_space => {
129                return self.complete_names("", &self.model_data.base_names, pos);
130            }
131            ["set", "position", _] if !trailing_space => {
132                return self.complete_names(words[2], &self.model_data.base_names, pos);
133            }
134
135            ["reset"] if trailing_space => ("", RESET_TARGETS.to_vec()),
136            ["reset", _] if !trailing_space => (words[1], RESET_TARGETS.to_vec()),
137
138            [cmd, ..] if *cmd == "find" => {
139                return self.complete_find_context(line_to_pos, &words, pos);
140            }
141
142            [cmd, ..] if *cmd == "route" => {
143                return self.complete_route_context(line_to_pos, &words, pos);
144            }
145
146            [cmd, ..] if *cmd == "stats" => {
147                let partial = if trailing_space {
148                    ""
149                } else {
150                    words.last().copied().unwrap_or("")
151                };
152                (partial, STATS_FLAGS.to_vec())
153            }
154
155            [cmd, ..] if *cmd == "convert" => {
156                let partial = if trailing_space {
157                    ""
158                } else {
159                    words.last().copied().unwrap_or("")
160                };
161                (partial, CONVERT_FLAGS.to_vec())
162            }
163
164            _ => return vec![],
165        };
166
167        self.filter_suggestions(partial, &candidates, pos)
168    }
169}
170
171impl CopilotCompleter {
172    fn complete_find_context(
173        &self,
174        line_to_pos: &str,
175        words: &[&str],
176        pos: usize,
177    ) -> Vec<Suggestion> {
178        let last = if line_to_pos.ends_with(' ') {
179            ""
180        } else {
181            words.last().copied().unwrap_or("")
182        };
183
184        let prev = if line_to_pos.ends_with(' ') {
185            words.last().copied()
186        } else if words.len() >= 2 {
187            Some(words[words.len() - 2])
188        } else {
189            None
190        };
191
192        if prev == Some("--biome") {
193            return self.filter_suggestions(last, BIOME_NAMES, pos);
194        }
195
196        if prev == Some("--from") {
197            return self.complete_names(last, &self.model_data.base_names, pos);
198        }
199
200        self.filter_suggestions(last, FIND_FLAGS, pos)
201    }
202
203    fn complete_route_context(
204        &self,
205        line_to_pos: &str,
206        words: &[&str],
207        pos: usize,
208    ) -> Vec<Suggestion> {
209        let last = if line_to_pos.ends_with(' ') {
210            ""
211        } else {
212            words.last().copied().unwrap_or("")
213        };
214
215        let prev = if line_to_pos.ends_with(' ') {
216            words.last().copied()
217        } else if words.len() >= 2 {
218            Some(words[words.len() - 2])
219        } else {
220            None
221        };
222
223        if prev == Some("--biome") {
224            return self.filter_suggestions(last, BIOME_NAMES, pos);
225        }
226
227        if prev == Some("--from") {
228            return self.complete_names(last, &self.model_data.base_names, pos);
229        }
230
231        self.filter_suggestions(last, ROUTE_FLAGS, pos)
232    }
233
234    fn complete_names(&self, partial: &str, names: &[String], pos: usize) -> Vec<Suggestion> {
235        let lower = partial.to_lowercase();
236        names
237            .iter()
238            .filter(|n| n.to_lowercase().starts_with(&lower))
239            .take(20)
240            .map(|n| {
241                let value = if n.contains(' ') {
242                    format!("\"{n}\"")
243                } else {
244                    n.clone()
245                };
246                Suggestion {
247                    value,
248                    description: None,
249                    style: None,
250                    extra: None,
251                    span: Span::new(pos - partial.len(), pos),
252                    append_whitespace: true,
253                }
254            })
255            .collect()
256    }
257
258    fn filter_suggestions(
259        &self,
260        partial: &str,
261        candidates: &[&str],
262        pos: usize,
263    ) -> Vec<Suggestion> {
264        let lower = partial.to_lowercase();
265        candidates
266            .iter()
267            .filter(|c| c.to_lowercase().starts_with(&lower))
268            .map(|c| Suggestion {
269                value: c.to_string(),
270                description: None,
271                style: None,
272                extra: None,
273                span: Span::new(pos - partial.len(), pos),
274                append_whitespace: true,
275            })
276            .collect()
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn test_completer() -> CopilotCompleter {
285        CopilotCompleter::new(ModelCompletions {
286            base_names: vec![
287                "Acadia National Park".into(),
288                "Alpha Base".into(),
289                "Beta Station".into(),
290            ],
291            system_names: vec!["Gugestor Colony".into(), "Esurad".into()],
292        })
293    }
294
295    #[test]
296    fn test_complete_empty_line_shows_commands() {
297        let mut c = test_completer();
298        let results = c.complete("", 0);
299        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
300        assert!(values.contains(&"find"));
301        assert!(values.contains(&"show"));
302        assert!(values.contains(&"exit"));
303    }
304
305    #[test]
306    fn test_complete_partial_command() {
307        let mut c = test_completer();
308        let results = c.complete("fi", 2);
309        assert_eq!(results.len(), 1);
310        assert_eq!(results[0].value, "find");
311    }
312
313    #[test]
314    fn test_complete_show_subcommands() {
315        let mut c = test_completer();
316        let results = c.complete("show ", 5);
317        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
318        assert!(values.contains(&"system"));
319        assert!(values.contains(&"base"));
320    }
321
322    #[test]
323    fn test_complete_show_base_names() {
324        let mut c = test_completer();
325        let results = c.complete("show base A", 11);
326        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
327        assert!(values.iter().any(|v| v.contains("Acadia")));
328        assert!(values.iter().any(|v| v.contains("Alpha")));
329    }
330
331    #[test]
332    fn test_complete_base_name_with_spaces_is_quoted() {
333        let mut c = test_completer();
334        let results = c.complete("show base Aca", 13);
335        assert!(!results.is_empty());
336        assert!(results[0].value.starts_with('"'));
337    }
338
339    #[test]
340    fn test_complete_find_flags() {
341        let mut c = test_completer();
342        let results = c.complete("find --b", 8);
343        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
344        assert!(values.contains(&"--biome"));
345    }
346
347    #[test]
348    fn test_complete_biome_after_flag() {
349        let mut c = test_completer();
350        let results = c.complete("find --biome L", 14);
351        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
352        assert!(values.contains(&"Lush"));
353        assert!(values.contains(&"Lava"));
354    }
355
356    #[test]
357    fn test_complete_from_base_names() {
358        let mut c = test_completer();
359        let results = c.complete("find --from B", 13);
360        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
361        assert!(values.iter().any(|v| v.contains("Beta")));
362    }
363
364    #[test]
365    fn test_complete_show_system_names() {
366        let mut c = test_completer();
367        let results = c.complete("show system G", 13);
368        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
369        assert!(values.iter().any(|v| v.contains("Gugestor")));
370    }
371
372    #[test]
373    fn test_complete_stats_flags() {
374        let mut c = test_completer();
375        let results = c.complete("stats --b", 9);
376        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
377        assert!(values.contains(&"--biomes"));
378    }
379
380    #[test]
381    fn test_complete_convert_flags() {
382        let mut c = test_completer();
383        let results = c.complete("convert --g", 11);
384        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
385        assert!(values.contains(&"--glyphs"));
386        assert!(values.contains(&"--ga"));
387        assert!(values.contains(&"--galaxy"));
388    }
389
390    #[test]
391    fn test_complete_case_insensitive_command() {
392        let mut c = test_completer();
393        // Typing "FI" should still match "find"
394        let results = c.complete("FI", 2);
395        assert_eq!(results.len(), 1);
396        assert_eq!(results[0].value, "find");
397    }
398
399    #[test]
400    fn test_complete_case_insensitive_show_subcommand() {
401        let mut c = test_completer();
402        let results = c.complete("SHOW ", 5);
403        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
404        assert!(values.contains(&"system"));
405        assert!(values.contains(&"base"));
406    }
407
408    #[test]
409    fn test_complete_case_insensitive_show_base() {
410        let mut c = test_completer();
411        let results = c.complete("Show Base a", 11);
412        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
413        assert!(values.iter().any(|v| v.contains("Acadia")));
414    }
415
416    #[test]
417    fn test_complete_route_flags() {
418        let mut c = test_completer();
419        let results = c.complete("route --b", 9);
420        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
421        assert!(values.contains(&"--biome"));
422    }
423
424    #[test]
425    fn test_complete_route_biome_after_flag() {
426        let mut c = test_completer();
427        let results = c.complete("route --biome L", 15);
428        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
429        assert!(values.contains(&"Lush"));
430        assert!(values.contains(&"Lava"));
431    }
432
433    #[test]
434    fn test_complete_route_from_base_names() {
435        let mut c = test_completer();
436        let results = c.complete("route --from A", 14);
437        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
438        assert!(values.iter().any(|v| v.contains("Alpha")));
439    }
440
441    #[test]
442    fn test_complete_route_all_flags() {
443        let mut c = test_completer();
444        let results = c.complete("route ", 6);
445        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
446        assert!(values.contains(&"--algo"));
447        assert!(values.contains(&"--biome"));
448        assert!(values.contains(&"--from"));
449        assert!(values.contains(&"--target"));
450        assert!(values.contains(&"--warp-range"));
451        assert!(values.contains(&"--within"));
452        assert!(values.contains(&"--round-trip"));
453        assert!(values.contains(&"--max-targets"));
454    }
455
456    #[test]
457    fn test_complete_route_in_command_list() {
458        let mut c = test_completer();
459        let results = c.complete("r", 1);
460        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
461        assert!(values.contains(&"route"));
462        assert!(values.contains(&"reset"));
463    }
464
465    #[test]
466    fn test_complete_case_insensitive_find_flags() {
467        let mut c = test_completer();
468        let results = c.complete("FIND --b", 8);
469        let values: Vec<&str> = results.iter().map(|s| s.value.as_str()).collect();
470        assert!(values.contains(&"--biome"));
471    }
472}