Skip to main content

j_cli/interactive/
completer.rs

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
16// ========== 补全器定义 ==========
17
18/// 自定义补全器:根据上下文提供命令、别名、分类等补全
19pub 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/// 命令定义:(命令名列表, 参数位置补全策略)
63#[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
76/// 获取命令的补全规则定义
77pub 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        // Shell 命令(! 前缀)
206        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        // 别名后续参数智能补全
299        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
333// ========== Hinter ==========
334
335pub 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
355// ========== Highlighter ==========
356
357pub 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
369// ========== 组合 Helper ==========
370
371pub 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
426// ========== 文件路径补全 ==========
427
428/// 文件系统路径补全
429pub 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}