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        (cmd::CHECK, vec![ArgHint::Placeholder("<line_count>")]),
136        (
137            cmd::SEARCH,
138            vec![
139                ArgHint::Placeholder("<line_count|all>"),
140                ArgHint::Placeholder("<target>"),
141                ArgHint::Fixed(vec![search_flag::FUZZY_SHORT, search_flag::FUZZY]),
142            ],
143        ),
144        (cmd::TODO, vec![ArgHint::Placeholder("<content>")]),
145        (cmd::CHAT, vec![ArgHint::Placeholder("<message>")]),
146        (cmd::VOICE, vec![ArgHint::Fixed(vec![vc::ACTION_DOWNLOAD])]),
147        (
148            cmd::CONCAT,
149            vec![
150                ArgHint::Placeholder("<script_name>"),
151                ArgHint::Placeholder("<script_content>"),
152            ],
153        ),
154        (
155            cmd::TIME,
156            vec![
157                ArgHint::Fixed(vec![time_function::COUNTDOWN]),
158                ArgHint::Placeholder("<duration>"),
159            ],
160        ),
161        (cmd::COMPLETION, vec![ArgHint::Fixed(vec!["zsh", "bash"])]),
162        (cmd::VERSION, vec![]),
163        (cmd::HELP, vec![]),
164        (cmd::CLEAR, vec![]),
165        (cmd::EXIT, vec![]),
166    ]
167}
168
169const ALL_NOTE_CATEGORIES: &[&str] = NOTE_CATEGORIES;
170
171impl Completer for CopilotCompleter {
172    type Candidate = Pair;
173
174    fn complete(
175        &self,
176        line: &str,
177        pos: usize,
178        _ctx: &Context<'_>,
179    ) -> rustyline::Result<(usize, Vec<Pair>)> {
180        let line_to_cursor = &line[..pos];
181        let parts: Vec<&str> = line_to_cursor.split_whitespace().collect();
182
183        let trailing_space = line_to_cursor.ends_with(' ');
184        let word_index = if trailing_space {
185            parts.len()
186        } else {
187            parts.len().saturating_sub(1)
188        };
189        let current_word = if trailing_space {
190            ""
191        } else {
192            parts.last().copied().unwrap_or("")
193        };
194        let start_pos = pos - current_word.len();
195
196        // Shell 命令(! 前缀)
197        if !parts.is_empty() && (parts[0] == "!" || parts[0].starts_with('!')) {
198            let candidates = complete_file_path(current_word);
199            return Ok((start_pos, candidates));
200        }
201
202        if word_index == 0 {
203            let mut candidates = Vec::new();
204            let rules = command_completion_rules();
205            for (names, _) in &rules {
206                for name in *names {
207                    if name.starts_with(current_word) {
208                        candidates.push(Pair {
209                            display: name.to_string(),
210                            replacement: name.to_string(),
211                        });
212                    }
213                }
214            }
215            for alias in self.all_aliases() {
216                if alias.starts_with(current_word)
217                    && !command::all_command_keywords().contains(&alias.as_str())
218                {
219                    candidates.push(Pair {
220                        display: alias.clone(),
221                        replacement: alias,
222                    });
223                }
224            }
225            return Ok((start_pos, candidates));
226        }
227
228        let cmd_str = parts[0];
229        let rules = command_completion_rules();
230
231        for (names, arg_hints) in &rules {
232            if names.contains(&cmd_str) {
233                let arg_index = word_index - 1;
234                if arg_index < arg_hints.len() {
235                    let candidates = match &arg_hints[arg_index] {
236                        ArgHint::Alias => self
237                            .all_aliases()
238                            .into_iter()
239                            .filter(|a| a.starts_with(current_word))
240                            .map(|a| Pair {
241                                display: a.clone(),
242                                replacement: a,
243                            })
244                            .collect(),
245                        ArgHint::Category => ALL_NOTE_CATEGORIES
246                            .iter()
247                            .filter(|c| c.starts_with(current_word))
248                            .map(|c| Pair {
249                                display: c.to_string(),
250                                replacement: c.to_string(),
251                            })
252                            .collect(),
253                        ArgHint::Section => self
254                            .all_sections()
255                            .into_iter()
256                            .filter(|s| s.starts_with(current_word))
257                            .map(|s| Pair {
258                                display: s.clone(),
259                                replacement: s,
260                            })
261                            .collect(),
262                        ArgHint::SectionKeys(section) => self
263                            .section_keys(section)
264                            .into_iter()
265                            .filter(|k| k.starts_with(current_word))
266                            .map(|k| Pair {
267                                display: k.clone(),
268                                replacement: k,
269                            })
270                            .collect(),
271                        ArgHint::Fixed(options) => options
272                            .iter()
273                            .filter(|o| !o.is_empty() && o.starts_with(current_word))
274                            .map(|o| Pair {
275                                display: o.to_string(),
276                                replacement: o.to_string(),
277                            })
278                            .collect(),
279                        ArgHint::Placeholder(_) => vec![],
280                        ArgHint::FilePath => complete_file_path(current_word),
281                        ArgHint::None => vec![],
282                    };
283                    return Ok((start_pos, candidates));
284                }
285                break;
286            }
287        }
288
289        // 别名后续参数智能补全
290        if self.config.alias_exists(cmd_str) {
291            if self.config.contains(constants::section::EDITOR, cmd_str) {
292                return Ok((start_pos, complete_file_path(current_word)));
293            }
294            if self.config.contains(constants::section::BROWSER, cmd_str) {
295                let mut candidates: Vec<Pair> = self
296                    .all_aliases()
297                    .into_iter()
298                    .filter(|a| a.starts_with(current_word))
299                    .map(|a| Pair {
300                        display: a.clone(),
301                        replacement: a,
302                    })
303                    .collect();
304                candidates.extend(complete_file_path(current_word));
305                return Ok((start_pos, candidates));
306            }
307            let mut candidates = complete_file_path(current_word);
308            candidates.extend(
309                self.all_aliases()
310                    .into_iter()
311                    .filter(|a| a.starts_with(current_word))
312                    .map(|a| Pair {
313                        display: a.clone(),
314                        replacement: a,
315                    }),
316            );
317            return Ok((start_pos, candidates));
318        }
319
320        Ok((start_pos, vec![]))
321    }
322}
323
324// ========== Hinter ==========
325
326pub struct CopilotHinter {
327    history_hinter: HistoryHinter,
328}
329
330impl CopilotHinter {
331    pub fn new() -> Self {
332        Self {
333            history_hinter: HistoryHinter::new(),
334        }
335    }
336}
337
338impl Hinter for CopilotHinter {
339    type Hint = String;
340
341    fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
342        self.history_hinter.hint(line, pos, ctx)
343    }
344}
345
346// ========== Highlighter ==========
347
348pub struct CopilotHighlighter;
349
350impl Highlighter for CopilotHighlighter {
351    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
352        Cow::Owned(format!("\x1b[90m{}\x1b[0m", hint))
353    }
354
355    fn highlight_char(&self, _line: &str, _pos: usize, _forced: CmdKind) -> bool {
356        true
357    }
358}
359
360// ========== 组合 Helper ==========
361
362pub struct CopilotHelper {
363    pub completer: CopilotCompleter,
364    hinter: CopilotHinter,
365    highlighter: CopilotHighlighter,
366}
367
368impl CopilotHelper {
369    pub fn new(config: &YamlConfig) -> Self {
370        Self {
371            completer: CopilotCompleter::new(config),
372            hinter: CopilotHinter::new(),
373            highlighter: CopilotHighlighter,
374        }
375    }
376
377    pub fn refresh(&mut self, config: &YamlConfig) {
378        self.completer.refresh(config);
379    }
380}
381
382impl Completer for CopilotHelper {
383    type Candidate = Pair;
384
385    fn complete(
386        &self,
387        line: &str,
388        pos: usize,
389        ctx: &Context<'_>,
390    ) -> rustyline::Result<(usize, Vec<Pair>)> {
391        self.completer.complete(line, pos, ctx)
392    }
393}
394
395impl Hinter for CopilotHelper {
396    type Hint = String;
397
398    fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
399        self.hinter.hint(line, pos, ctx)
400    }
401}
402
403impl Highlighter for CopilotHelper {
404    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
405        self.highlighter.highlight_hint(hint)
406    }
407
408    fn highlight_char(&self, line: &str, pos: usize, forced: CmdKind) -> bool {
409        self.highlighter.highlight_char(line, pos, forced)
410    }
411}
412
413impl Validator for CopilotHelper {}
414
415impl rustyline::Helper for CopilotHelper {}
416
417// ========== 文件路径补全 ==========
418
419/// 文件系统路径补全
420pub fn complete_file_path(partial: &str) -> Vec<Pair> {
421    let mut candidates = Vec::new();
422
423    let expanded = if partial.starts_with('~') {
424        if let Some(home) = dirs::home_dir() {
425            partial.replacen('~', &home.to_string_lossy(), 1)
426        } else {
427            partial.to_string()
428        }
429    } else {
430        partial.to_string()
431    };
432
433    let (dir_path, file_prefix) =
434        if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) {
435            (std::path::Path::new(&expanded).to_path_buf(), String::new())
436        } else {
437            let p = std::path::Path::new(&expanded);
438            let parent = p
439                .parent()
440                .unwrap_or(std::path::Path::new("."))
441                .to_path_buf();
442            let fp = p
443                .file_name()
444                .map(|s| s.to_string_lossy().to_string())
445                .unwrap_or_default();
446            (parent, fp)
447        };
448
449    if let Ok(entries) = std::fs::read_dir(&dir_path) {
450        for entry in entries.flatten() {
451            let name = entry.file_name().to_string_lossy().to_string();
452            if name.starts_with('.') && !file_prefix.starts_with('.') {
453                continue;
454            }
455            if name.starts_with(&file_prefix) {
456                let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
457                let full_replacement =
458                    if partial.ends_with('/') || partial.ends_with(std::path::MAIN_SEPARATOR) {
459                        format!("{}{}{}", partial, name, if is_dir { "/" } else { "" })
460                    } else if partial.contains('/') || partial.contains(std::path::MAIN_SEPARATOR) {
461                        let last_sep = partial
462                            .rfind('/')
463                            .or_else(|| partial.rfind(std::path::MAIN_SEPARATOR))
464                            .unwrap();
465                        format!(
466                            "{}/{}{}",
467                            &partial[..last_sep],
468                            name,
469                            if is_dir { "/" } else { "" }
470                        )
471                    } else {
472                        format!("{}{}", name, if is_dir { "/" } else { "" })
473                    };
474                let display_name = format!("{}{}", name, if is_dir { "/" } else { "" });
475                candidates.push(Pair {
476                    display: display_name,
477                    replacement: full_replacement,
478                });
479            }
480        }
481    }
482
483    candidates.sort_by(|a, b| a.display.cmp(&b.display));
484    candidates
485}