Skip to main content

bijux_cli/interface/repl/
completion.rs

1use std::sync::OnceLock;
2
3use super::types::ReplSession;
4use super::types::{
5    REPL_COMPLETION_ENTRY_MAX_CHARS, REPL_COMPLETION_MAX_CANDIDATES,
6    REPL_COMPLETION_REGISTRY_MAX_ENTRIES, REPL_COMPLETION_REGISTRY_MAX_OWNERS,
7    REPL_PLUGIN_COMPLETION_MAX_NAMESPACES,
8};
9use crate::routing::model::built_in_route_paths;
10
11const STATIC_REPL_COMPLETIONS: &[&str] = &[
12    "help",
13    "version",
14    "doctor",
15    "repl",
16    "completion",
17    "inspect",
18    "status",
19    "config",
20    "config list",
21    "config get",
22    "config set",
23    "config unset",
24    "config clear",
25    "config reload",
26    "config export",
27    "config load",
28    "plugins",
29    "plugins list",
30    "plugins inspect",
31    "plugins check",
32    "plugins doctor",
33    "cli",
34    "cli status",
35    "cli paths",
36    "history",
37    "history clear",
38    "memory",
39    "memory list",
40    "memory get",
41    "memory set",
42    "memory delete",
43    "memory clear",
44    ":help",
45    ":set",
46    ":set trace",
47    ":set quiet",
48    ":set format",
49    ":exit",
50    ":quit",
51];
52static CORE_COMPLETION_CACHE: OnceLock<Vec<String>> = OnceLock::new();
53
54fn normalize_completion_value(value: &str) -> Option<String> {
55    let trimmed = value.trim();
56    if trimmed.is_empty() {
57        return None;
58    }
59    let mut normalized = trimmed
60        .chars()
61        .filter(|ch| !ch.is_control())
62        .take(REPL_COMPLETION_ENTRY_MAX_CHARS)
63        .collect::<String>();
64    normalized = normalized.trim().to_string();
65    if normalized.is_empty() {
66        None
67    } else {
68        Some(normalized)
69    }
70}
71
72fn core_completion_candidates() -> &'static [String] {
73    CORE_COMPLETION_CACHE.get_or_init(|| {
74        let mut values = built_in_route_paths().to_vec();
75        values.extend(STATIC_REPL_COMPLETIONS.iter().map(|entry| (*entry).to_string()));
76        let mut normalized = values
77            .into_iter()
78            .filter_map(|entry| normalize_completion_value(&entry))
79            .collect::<Vec<_>>();
80        normalized.sort();
81        normalized.dedup();
82        normalized
83    })
84}
85
86/// Provide command completion candidates for built-ins and plugin hooks.
87#[must_use]
88pub fn completion_candidates(session: &ReplSession, prefix: &str) -> Vec<String> {
89    let mut suggestions = std::collections::BTreeSet::new();
90    let normalized_prefix = prefix.trim_start().to_ascii_lowercase();
91    let last_prefix_token = normalized_prefix.split_whitespace().last().unwrap_or_default();
92
93    let matches_prefix = |value: &str| {
94        let normalized_value = value.to_ascii_lowercase();
95        if normalized_prefix.is_empty() || normalized_value.starts_with(&normalized_prefix) {
96            return true;
97        }
98
99        if last_prefix_token.is_empty() {
100            return false;
101        }
102
103        normalized_value
104            .split_whitespace()
105            .last()
106            .is_some_and(|segment| segment.starts_with(last_prefix_token))
107    };
108
109    let mut push_candidate = |value: &str| {
110        let Some(candidate) = normalize_completion_value(value) else {
111            return;
112        };
113        if matches_prefix(&candidate) {
114            suggestions.insert(candidate);
115            while suggestions.len() > REPL_COMPLETION_MAX_CANDIDATES {
116                let _ = suggestions.pop_last();
117            }
118        }
119    };
120
121    for builtin in core_completion_candidates() {
122        push_candidate(builtin);
123    }
124
125    for values in session.completion_registries.values() {
126        for value in values {
127            push_candidate(value);
128        }
129    }
130
131    for namespace in session.plugin_completion_hooks.keys() {
132        push_candidate(namespace);
133    }
134
135    for values in session.plugin_completion_hooks.values() {
136        for value in values {
137            push_candidate(value);
138        }
139    }
140
141    suggestions.into_iter().collect()
142}
143
144/// Register plugin completion hook for a namespace.
145pub fn register_plugin_completion_hook(
146    session: &mut ReplSession,
147    namespace: &str,
148    suggestions: Vec<String>,
149) {
150    let Some(normalized_namespace) = normalize_completion_value(namespace) else {
151        return;
152    };
153
154    let mut normalized = suggestions
155        .into_iter()
156        .filter_map(|entry| normalize_completion_value(&entry))
157        .collect::<Vec<_>>();
158    normalized.sort();
159    normalized.dedup();
160    normalized.truncate(REPL_COMPLETION_REGISTRY_MAX_ENTRIES);
161
162    session.plugin_completion_hooks.insert(normalized_namespace, normalized);
163    while session.plugin_completion_hooks.len() > REPL_PLUGIN_COMPLETION_MAX_NAMESPACES {
164        let _ = session.plugin_completion_hooks.pop_last();
165    }
166}
167
168/// Register extension completion registry entries under an owner key.
169pub fn register_completion_registry(
170    session: &mut ReplSession,
171    owner: &str,
172    suggestions: Vec<String>,
173) {
174    let Some(normalized_owner) = normalize_completion_value(owner) else {
175        return;
176    };
177
178    let mut normalized = suggestions
179        .into_iter()
180        .filter_map(|entry| normalize_completion_value(&entry))
181        .collect::<Vec<_>>();
182    normalized.sort();
183    normalized.dedup();
184    normalized.truncate(REPL_COMPLETION_REGISTRY_MAX_ENTRIES);
185
186    session.completion_registries.insert(normalized_owner, normalized);
187    while session.completion_registries.len() > REPL_COMPLETION_REGISTRY_MAX_OWNERS {
188        let _ = session.completion_registries.pop_last();
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::{
195        completion_candidates, register_completion_registry, register_plugin_completion_hook,
196    };
197    use crate::interface::repl::session::startup_repl;
198    use crate::interface::repl::types::{
199        REPL_COMPLETION_ENTRY_MAX_CHARS, REPL_COMPLETION_MAX_CANDIDATES,
200        REPL_COMPLETION_REGISTRY_MAX_ENTRIES, REPL_COMPLETION_REGISTRY_MAX_OWNERS,
201        REPL_PLUGIN_COMPLETION_MAX_NAMESPACES,
202    };
203
204    #[test]
205    fn completion_matches_are_case_insensitive_and_last_token_aware() {
206        let (mut session, _) = startup_repl("", None);
207        register_completion_registry(
208            &mut session,
209            "core",
210            vec!["plugins install".to_string(), "status".to_string()],
211        );
212
213        let by_case = completion_candidates(&session, "Sta");
214        assert!(by_case.iter().any(|entry| entry == "status"));
215
216        let by_last_token = completion_candidates(&session, "plugins ins");
217        assert!(by_last_token.iter().any(|entry| entry == "plugins install"));
218    }
219
220    #[test]
221    fn completion_registry_and_plugin_hooks_normalize_keys_and_values() {
222        let (mut session, _) = startup_repl("", None);
223
224        register_completion_registry(
225            &mut session,
226            " owner ",
227            vec![" status ".to_string(), String::new(), "status".to_string()],
228        );
229        register_plugin_completion_hook(
230            &mut session,
231            " plugin.ns ",
232            vec![" plugin.ns cmd ".to_string(), String::new(), "plugin.ns cmd".to_string()],
233        );
234
235        assert!(session.completion_registries.contains_key("owner"));
236        assert_eq!(session.completion_registries["owner"], vec!["status".to_string()]);
237        assert!(session.plugin_completion_hooks.contains_key("plugin.ns"));
238        assert_eq!(session.plugin_completion_hooks["plugin.ns"], vec!["plugin.ns cmd".to_string()]);
239    }
240
241    #[test]
242    fn completion_registry_caps_entry_count_and_entry_size() {
243        let (mut session, _) = startup_repl("", None);
244        let oversized = "x".repeat(REPL_COMPLETION_ENTRY_MAX_CHARS + 64);
245        let values = (0..(REPL_COMPLETION_REGISTRY_MAX_ENTRIES + 20))
246            .map(|idx| format!("item-{idx:04}-{oversized}"))
247            .collect::<Vec<_>>();
248        register_completion_registry(&mut session, "owner", values);
249        assert_eq!(
250            session.completion_registries["owner"].len(),
251            REPL_COMPLETION_REGISTRY_MAX_ENTRIES
252        );
253        assert!(session.completion_registries["owner"]
254            .iter()
255            .all(|entry| entry.chars().count() <= REPL_COMPLETION_ENTRY_MAX_CHARS));
256    }
257
258    #[test]
259    fn completion_candidates_are_bounded() {
260        let (mut session, _) = startup_repl("", None);
261        let values = (0..(REPL_COMPLETION_MAX_CANDIDATES + 32))
262            .map(|idx| format!("status item-{idx:04}"))
263            .collect::<Vec<_>>();
264        register_completion_registry(&mut session, "owner", values);
265        let candidates = completion_candidates(&session, "status item-");
266        assert_eq!(candidates.len(), REPL_COMPLETION_MAX_CANDIDATES);
267        assert_eq!(candidates.first().map(String::as_str), Some("status item-0000"));
268        assert_eq!(candidates.last().map(String::as_str), Some("status item-0511"));
269    }
270
271    #[test]
272    fn completion_registry_owner_count_is_bounded() {
273        let (mut session, _) = startup_repl("", None);
274        for idx in 0..(REPL_COMPLETION_REGISTRY_MAX_OWNERS + 20) {
275            register_completion_registry(
276                &mut session,
277                &format!("owner-{idx:04}"),
278                vec!["status".to_string()],
279            );
280        }
281        assert_eq!(session.completion_registries.len(), REPL_COMPLETION_REGISTRY_MAX_OWNERS);
282    }
283
284    #[test]
285    fn plugin_completion_namespace_count_is_bounded() {
286        let (mut session, _) = startup_repl("", None);
287        for idx in 0..(REPL_PLUGIN_COMPLETION_MAX_NAMESPACES + 20) {
288            register_plugin_completion_hook(
289                &mut session,
290                &format!("plugin-{idx:04}"),
291                vec!["status".to_string()],
292            );
293        }
294        assert_eq!(session.plugin_completion_hooks.len(), REPL_PLUGIN_COMPLETION_MAX_NAMESPACES);
295    }
296}