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