1use reedline::{Completer, Span, Suggestion};
12
13#[derive(Clone)]
15pub struct ModelCompletions {
16 pub base_names: Vec<String>,
18 pub system_names: Vec<String>,
20}
21
22pub 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 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 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}