bijux_cli/interface/repl/
completion.rs1use 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#[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
144pub 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
168pub 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}