Skip to main content

raps_cli/shell/
hinter.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4use std::collections::HashMap;
5
6use reedline::{Hinter, History};
7
8use super::CommandInfo;
9use super::command_tree::{build_command_map, build_command_tree};
10
11/// Inline hints showing command syntax and required parameters.
12pub struct RapsHinter {
13    commands: Vec<CommandInfo>,
14    command_map: HashMap<String, CommandInfo>,
15    /// Completable portion of the current hint (for right-arrow acceptance)
16    current_completion: String,
17}
18
19impl RapsHinter {
20    pub fn new() -> Self {
21        let commands = build_command_tree();
22        let command_map = build_command_map(&commands);
23        Self {
24            commands,
25            command_map,
26            current_completion: String::new(),
27        }
28    }
29}
30
31impl Default for RapsHinter {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl Hinter for RapsHinter {
38    fn handle(
39        &mut self,
40        line: &str,
41        pos: usize,
42        _history: &dyn History,
43        use_ansi_coloring: bool,
44        _cwd: &str,
45    ) -> String {
46        // Only show hints when cursor is at the end
47        if pos < line.len() {
48            self.current_completion.clear();
49            return String::new();
50        }
51
52        match get_hint_raw(&self.commands, &self.command_map, line) {
53            Some((display, complete_up_to)) => {
54                self.current_completion = if complete_up_to > 0 {
55                    display[..complete_up_to].to_string()
56                } else {
57                    String::new()
58                };
59
60                if use_ansi_coloring {
61                    // Dim cyan for hint text, matching the old rustyline style
62                    format!("\x1b[2;36m{display}\x1b[0m")
63                } else {
64                    display
65                }
66            }
67            None => {
68                self.current_completion.clear();
69                String::new()
70            }
71        }
72    }
73
74    fn complete_hint(&self) -> String {
75        self.current_completion.clone()
76    }
77
78    fn next_hint_token(&self) -> String {
79        self.current_completion
80            .split_once(' ')
81            .map(|(first, _)| first.to_string())
82            .unwrap_or_else(|| self.current_completion.clone())
83    }
84}
85
86/// Generate a hint for the current input, returning (display_text, complete_up_to).
87pub(super) fn get_hint_raw(
88    commands: &[CommandInfo],
89    command_map: &HashMap<String, CommandInfo>,
90    line: &str,
91) -> Option<(String, usize)> {
92    if line.is_empty() {
93        return None;
94    }
95
96    let parts: Vec<&str> = line.split_whitespace().collect();
97    let trailing_space = line.ends_with(' ');
98
99    match parts.len() {
100        1 if !trailing_space => {
101            // Partial command - find matching command and show full name
102            let partial = parts[0].to_lowercase();
103            for cmd in commands {
104                if cmd.name.starts_with(&partial) && cmd.name != partial {
105                    let suffix = &cmd.name[partial.len()..];
106                    let mut hint = suffix.to_string();
107
108                    // Add subcommand hint if available
109                    if !cmd.subcommands.is_empty() {
110                        hint.push_str(" <subcommand>");
111                    } else if !cmd.params.is_empty() {
112                        hint.push(' ');
113                        hint.push_str(&cmd.params.join(" "));
114                    }
115
116                    return Some((hint, suffix.len()));
117                }
118            }
119        }
120        1 if trailing_space => {
121            // Complete command - show subcommands or params
122            let cmd_name = parts[0].to_lowercase();
123            if let Some(cmd) = commands.iter().find(|c| c.name == cmd_name) {
124                if !cmd.subcommands.is_empty() {
125                    let subcmd_names: Vec<&str> =
126                        cmd.subcommands.iter().take(3).map(|s| s.name).collect();
127                    let hint = format!("<{}...>", subcmd_names.join("|"));
128                    return Some((hint, 0));
129                } else if !cmd.params.is_empty() {
130                    let hint = cmd.params.join(" ");
131                    return Some((hint, 0));
132                }
133            }
134        }
135        2 if !trailing_space => {
136            // Partial subcommand
137            let cmd_name = parts[0].to_lowercase();
138            let partial = parts[1].to_lowercase();
139
140            if let Some(cmd) = commands.iter().find(|c| c.name == cmd_name) {
141                for subcmd in cmd.subcommands {
142                    if subcmd.name.starts_with(&partial) && subcmd.name != partial {
143                        let suffix = &subcmd.name[partial.len()..];
144                        let mut hint = suffix.to_string();
145
146                        if !subcmd.params.is_empty() {
147                            hint.push(' ');
148                            hint.push_str(&subcmd.params.join(" "));
149                        }
150
151                        return Some((hint, suffix.len()));
152                    }
153                }
154            }
155        }
156        2 if trailing_space => {
157            // Complete subcommand - show params
158            let cmd_name = parts[0].to_lowercase();
159            let sub_name = parts[1].to_lowercase();
160            let key = format!("{} {}", cmd_name, sub_name);
161
162            if let Some(cmd) = command_map.get(&key) {
163                if !cmd.params.is_empty() {
164                    let hint = cmd.params.join(" ");
165                    return Some((hint, 0));
166                } else if !cmd.flags.is_empty() {
167                    let hint = format!("[{}]", cmd.flags.first().unwrap_or(&""));
168                    return Some((hint, 0));
169                }
170            }
171        }
172        n if n >= 3 => {
173            // Show remaining params
174            let cmd_name = parts[0].to_lowercase();
175            let sub_name = parts[1].to_lowercase();
176            let key = format!("{} {}", cmd_name, sub_name);
177
178            if let Some(cmd) = command_map.get(&key) {
179                // Count how many positional args we have (excluding flags)
180                let positional_count = parts[2..].iter().filter(|p| !p.starts_with('-')).count();
181
182                if positional_count < cmd.params.len() {
183                    let remaining: Vec<&str> =
184                        cmd.params.iter().skip(positional_count).copied().collect();
185                    if !remaining.is_empty() && trailing_space {
186                        let hint = remaining.join(" ");
187                        return Some((hint, 0));
188                    }
189                }
190            }
191        }
192        _ => {}
193    }
194
195    None
196}