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