1use crate::command;
2use crate::config::YamlConfig;
3use crate::constants::{
4 self, ALIAS_PATH_SECTIONS, ALL_SECTIONS, LIST_ALL, NOTE_CATEGORIES, cmd, config_key,
5 rmeta_action, search_flag, time_function, voice as vc,
6};
7use rustyline::completion::{Completer, Pair};
8use rustyline::highlight::CmdKind;
9use rustyline::highlight::Highlighter;
10use rustyline::hint::{Hinter, HistoryHinter};
11
12use rustyline::Context;
13use rustyline::validate::Validator;
14use std::borrow::Cow;
15
16pub struct CopilotCompleter {
20 pub config: YamlConfig,
21}
22
23impl CopilotCompleter {
24 pub fn new(config: &YamlConfig) -> Self {
25 Self {
26 config: config.clone(),
27 }
28 }
29
30 pub fn refresh(&mut self, config: &YamlConfig) {
31 self.config = config.clone();
32 }
33
34 fn all_aliases(&self) -> Vec<String> {
35 let mut aliases = Vec::new();
36 for s in ALIAS_PATH_SECTIONS {
37 if let Some(map) = self.config.get_section(s) {
38 aliases.extend(map.keys().cloned());
39 }
40 }
41 aliases.sort();
42 aliases.dedup();
43 aliases
44 }
45
46 fn all_sections(&self) -> Vec<String> {
47 self.config
48 .all_section_names()
49 .iter()
50 .map(|s| s.to_string())
51 .collect()
52 }
53
54 fn section_keys(&self, section: &str) -> Vec<String> {
55 self.config
56 .get_section(section)
57 .map(|m| m.keys().cloned().collect())
58 .unwrap_or_default()
59 }
60}
61
62#[derive(Clone)]
64#[allow(dead_code)]
65pub enum ArgHint {
66 Alias,
67 Category,
68 Section,
69 SectionKeys(String),
70 Fixed(Vec<&'static str>),
71 Placeholder(&'static str),
72 FilePath,
73 None,
74}
75
76pub fn command_completion_rules() -> Vec<(&'static [&'static str], Vec<ArgHint>)> {
78 vec![
79 (
80 cmd::SET,
81 vec![ArgHint::Placeholder("<alias>"), ArgHint::FilePath],
82 ),
83 (cmd::REMOVE, vec![ArgHint::Alias]),
84 (
85 cmd::RENAME,
86 vec![ArgHint::Alias, ArgHint::Placeholder("<new_alias>")],
87 ),
88 (cmd::MODIFY, vec![ArgHint::Alias, ArgHint::FilePath]),
89 (cmd::NOTE, vec![ArgHint::Alias, ArgHint::Category]),
90 (cmd::DENOTE, vec![ArgHint::Alias, ArgHint::Category]),
91 (
92 cmd::LIST,
93 vec![ArgHint::Fixed({
94 let mut v: Vec<&'static str> = vec!["", LIST_ALL];
95 for s in ALL_SECTIONS {
96 v.push(s);
97 }
98 v
99 })],
100 ),
101 (
102 cmd::CONTAIN,
103 vec![ArgHint::Alias, ArgHint::Placeholder("<sections>")],
104 ),
105 (
106 cmd::LOG,
107 vec![
108 ArgHint::Fixed(vec![config_key::MODE]),
109 ArgHint::Fixed(vec![config_key::VERBOSE, config_key::CONCISE]),
110 ],
111 ),
112 (
113 cmd::CHANGE,
114 vec![
115 ArgHint::Section,
116 ArgHint::Placeholder("<field>"),
117 ArgHint::Placeholder("<value>"),
118 ],
119 ),
120 (cmd::REPORT, vec![ArgHint::Placeholder("<content>")]),
121 (
122 cmd::REPORTCTL,
123 vec![
124 ArgHint::Fixed(vec![
125 rmeta_action::NEW,
126 rmeta_action::SYNC,
127 rmeta_action::PUSH,
128 rmeta_action::PULL,
129 rmeta_action::SET_URL,
130 rmeta_action::OPEN,
131 ]),
132 ArgHint::Placeholder("<date|message|url>"),
133 ],
134 ),
135 (
136 cmd::CHECK,
137 vec![ArgHint::Fixed(vec!["open", "<line_count>"])],
138 ),
139 (
140 cmd::SEARCH,
141 vec![
142 ArgHint::Placeholder("<line_count|all>"),
143 ArgHint::Placeholder("<target>"),
144 ArgHint::Fixed(vec![search_flag::FUZZY_SHORT, search_flag::FUZZY]),
145 ],
146 ),
147 (
148 cmd::TODO,
149 vec![
150 ArgHint::Fixed(vec!["list", "add"]),
151 ArgHint::Placeholder("<content>"),
152 ],
153 ),
154 (cmd::CHAT, vec![ArgHint::Placeholder("<message>")]),
155 (cmd::VOICE, vec![ArgHint::Fixed(vec![vc::ACTION_DOWNLOAD])]),
156 (
157 cmd::CONCAT,
158 vec![
159 ArgHint::Placeholder("<script_name>"),
160 ArgHint::Placeholder("<script_content>"),
161 ],
162 ),
163 (
164 cmd::TIME,
165 vec![
166 ArgHint::Fixed(vec![time_function::COUNTDOWN]),
167 ArgHint::Placeholder("<duration>"),
168 ],
169 ),
170 (cmd::COMPLETION, vec![ArgHint::Fixed(vec!["zsh", "bash"])]),
171 (cmd::VERSION, vec![]),
172 (cmd::HELP, vec![]),
173 (cmd::CLEAR, vec![]),
174 (cmd::EXIT, vec![]),
175 ]
176}
177
178const ALL_NOTE_CATEGORIES: &[&str] = NOTE_CATEGORIES;
179
180impl Completer for CopilotCompleter {
181 type Candidate = Pair;
182
183 fn complete(
184 &self,
185 line: &str,
186 pos: usize,
187 _ctx: &Context<'_>,
188 ) -> rustyline::Result<(usize, Vec<Pair>)> {
189 let line_to_cursor = &line[..pos];
190 let parts: Vec<&str> = line_to_cursor.split_whitespace().collect();
191
192 let trailing_space = line_to_cursor.ends_with(' ');
193 let word_index = if trailing_space {
194 parts.len()
195 } else {
196 parts.len().saturating_sub(1)
197 };
198 let current_word = if trailing_space {
199 ""
200 } else {
201 parts.last().copied().unwrap_or("")
202 };
203 let start_pos = pos - current_word.len();
204
205 if !parts.is_empty() && (parts[0] == "!" || parts[0].starts_with('!')) {
207 let candidates = complete_file_path(current_word);
208 return Ok((start_pos, candidates));
209 }
210
211 if word_index == 0 {
212 let mut candidates = Vec::new();
213 let rules = command_completion_rules();
214 for (names, _) in &rules {
215 for name in *names {
216 if name.starts_with(current_word) {
217 candidates.push(Pair {
218 display: name.to_string(),
219 replacement: name.to_string(),
220 });
221 }
222 }
223 }
224 for alias in self.all_aliases() {
225 if alias.starts_with(current_word)
226 && !command::all_command_keywords().contains(&alias.as_str())
227 {
228 candidates.push(Pair {
229 display: alias.clone(),
230 replacement: alias,
231 });
232 }
233 }
234 return Ok((start_pos, candidates));
235 }
236
237 let cmd_str = parts[0];
238 let rules = command_completion_rules();
239
240 for (names, arg_hints) in &rules {
241 if names.contains(&cmd_str) {
242 let arg_index = word_index - 1;
243 if arg_index < arg_hints.len() {
244 let candidates = match &arg_hints[arg_index] {
245 ArgHint::Alias => self
246 .all_aliases()
247 .into_iter()
248 .filter(|a| a.starts_with(current_word))
249 .map(|a| Pair {
250 display: a.clone(),
251 replacement: a,
252 })
253 .collect(),
254 ArgHint::Category => ALL_NOTE_CATEGORIES
255 .iter()
256 .filter(|c| c.starts_with(current_word))
257 .map(|c| Pair {
258 display: c.to_string(),
259 replacement: c.to_string(),
260 })
261 .collect(),
262 ArgHint::Section => self
263 .all_sections()
264 .into_iter()
265 .filter(|s| s.starts_with(current_word))
266 .map(|s| Pair {
267 display: s.clone(),
268 replacement: s,
269 })
270 .collect(),
271 ArgHint::SectionKeys(section) => self
272 .section_keys(section)
273 .into_iter()
274 .filter(|k| k.starts_with(current_word))
275 .map(|k| Pair {
276 display: k.clone(),
277 replacement: k,
278 })
279 .collect(),
280 ArgHint::Fixed(options) => options
281 .iter()
282 .filter(|o| !o.is_empty() && o.starts_with(current_word))
283 .map(|o| Pair {
284 display: o.to_string(),
285 replacement: o.to_string(),
286 })
287 .collect(),
288 ArgHint::Placeholder(_) => vec![],
289 ArgHint::FilePath => complete_file_path(current_word),
290 ArgHint::None => vec![],
291 };
292 return Ok((start_pos, candidates));
293 }
294 break;
295 }
296 }
297
298 if self.config.alias_exists(cmd_str) {
300 if self.config.contains(constants::section::EDITOR, cmd_str) {
301 return Ok((start_pos, complete_file_path(current_word)));
302 }
303 if self.config.contains(constants::section::BROWSER, cmd_str) {
304 let mut candidates: Vec<Pair> = self
305 .all_aliases()
306 .into_iter()
307 .filter(|a| a.starts_with(current_word))
308 .map(|a| Pair {
309 display: a.clone(),
310 replacement: a,
311 })
312 .collect();
313 candidates.extend(complete_file_path(current_word));
314 return Ok((start_pos, candidates));
315 }
316 let mut candidates = complete_file_path(current_word);
317 candidates.extend(
318 self.all_aliases()
319 .into_iter()
320 .filter(|a| a.starts_with(current_word))
321 .map(|a| Pair {
322 display: a.clone(),
323 replacement: a,
324 }),
325 );
326 return Ok((start_pos, candidates));
327 }
328
329 Ok((start_pos, vec![]))
330 }
331}
332
333pub struct CopilotHinter {
336 history_hinter: HistoryHinter,
337}
338
339impl CopilotHinter {
340 pub fn new() -> Self {
341 Self {
342 history_hinter: HistoryHinter::new(),
343 }
344 }
345}
346
347impl Hinter for CopilotHinter {
348 type Hint = String;
349
350 fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
351 self.history_hinter.hint(line, pos, ctx)
352 }
353}
354
355pub struct CopilotHighlighter;
358
359impl Highlighter for CopilotHighlighter {
360 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
361 Cow::Owned(format!("\x1b[90m{}\x1b[0m", hint))
362 }
363
364 fn highlight_char(&self, _line: &str, _pos: usize, _forced: CmdKind) -> bool {
365 true
366 }
367}
368
369pub struct CopilotHelper {
372 pub completer: CopilotCompleter,
373 hinter: CopilotHinter,
374 highlighter: CopilotHighlighter,
375}
376
377impl CopilotHelper {
378 pub fn new(config: &YamlConfig) -> Self {
379 Self {
380 completer: CopilotCompleter::new(config),
381 hinter: CopilotHinter::new(),
382 highlighter: CopilotHighlighter,
383 }
384 }
385
386 pub fn refresh(&mut self, config: &YamlConfig) {
387 self.completer.refresh(config);
388 }
389}
390
391impl Completer for CopilotHelper {
392 type Candidate = Pair;
393
394 fn complete(
395 &self,
396 line: &str,
397 pos: usize,
398 ctx: &Context<'_>,
399 ) -> rustyline::Result<(usize, Vec<Pair>)> {
400 self.completer.complete(line, pos, ctx)
401 }
402}
403
404impl Hinter for CopilotHelper {
405 type Hint = String;
406
407 fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
408 self.hinter.hint(line, pos, ctx)
409 }
410}
411
412impl Highlighter for CopilotHelper {
413 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
414 self.highlighter.highlight_hint(hint)
415 }
416
417 fn highlight_char(&self, line: &str, pos: usize, forced: CmdKind) -> bool {
418 self.highlighter.highlight_char(line, pos, forced)
419 }
420}
421
422impl Validator for CopilotHelper {}
423
424impl rustyline::Helper for CopilotHelper {}
425
426pub fn complete_file_path(partial: &str) -> Vec<Pair> {
430 let mut candidates = Vec::new();
431
432 let expanded = if partial.starts_with('~') {
433 if let Some(home) = dirs::home_dir() {
434 partial.replacen('~', &home.to_string_lossy(), 1)
435 } else {
436 partial.to_string()
437 }
438 } else {
439 partial.to_string()
440 };
441
442 let (dir_path, file_prefix) =
443 if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) {
444 (std::path::Path::new(&expanded).to_path_buf(), String::new())
445 } else {
446 let p = std::path::Path::new(&expanded);
447 let parent = p
448 .parent()
449 .unwrap_or(std::path::Path::new("."))
450 .to_path_buf();
451 let fp = p
452 .file_name()
453 .map(|s| s.to_string_lossy().to_string())
454 .unwrap_or_default();
455 (parent, fp)
456 };
457
458 if let Ok(entries) = std::fs::read_dir(&dir_path) {
459 for entry in entries.flatten() {
460 let name = entry.file_name().to_string_lossy().to_string();
461 if name.starts_with('.') && !file_prefix.starts_with('.') {
462 continue;
463 }
464 if name.starts_with(&file_prefix) {
465 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
466 let full_replacement =
467 if partial.ends_with('/') || partial.ends_with(std::path::MAIN_SEPARATOR) {
468 format!("{}{}{}", partial, name, if is_dir { "/" } else { "" })
469 } else if partial.contains('/') || partial.contains(std::path::MAIN_SEPARATOR) {
470 let last_sep = partial
471 .rfind('/')
472 .or_else(|| partial.rfind(std::path::MAIN_SEPARATOR))
473 .unwrap();
474 format!(
475 "{}/{}{}",
476 &partial[..last_sep],
477 name,
478 if is_dir { "/" } else { "" }
479 )
480 } else {
481 format!("{}{}", name, if is_dir { "/" } else { "" })
482 };
483 let display_name = format!("{}{}", name, if is_dir { "/" } else { "" });
484 candidates.push(Pair {
485 display: display_name,
486 replacement: full_replacement,
487 });
488 }
489 }
490 }
491
492 candidates.sort_by(|a, b| a.display.cmp(&b.display));
493 candidates
494}