aprender_present_lib/browser/
shell_autocomplete.rs1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[cfg(target_arch = "wasm32")]
12use wasm_bindgen::prelude::*;
13
14const HEADER_SIZE: usize = 32;
16
17#[derive(Debug)]
19pub struct ShellAutocomplete {
20 n: usize,
22 ngrams: HashMap<String, HashMap<String, u32>>,
24 command_freq: HashMap<String, u32>,
26 trie: Trie,
28 total_commands: usize,
30}
31
32#[derive(Debug, Default)]
34struct Trie {
35 children: HashMap<char, Trie>,
36 is_end: bool,
37 command: Option<String>,
38}
39
40impl Trie {
41 fn new() -> Self {
42 Self::default()
43 }
44
45 fn insert(&mut self, word: &str) {
46 let mut node = self;
47 for c in word.chars() {
48 node = node.children.entry(c).or_default();
49 }
50 node.is_end = true;
51 node.command = Some(word.to_string());
52 }
53
54 fn find_prefix(&self, prefix: &str, limit: usize) -> Vec<String> {
55 let mut results = Vec::new();
56 let mut node = self;
57
58 for c in prefix.chars() {
60 match node.children.get(&c) {
61 Some(child) => node = child,
62 None => return results,
63 }
64 }
65
66 Self::collect_commands_recursive(node, &mut results, limit);
68 results
69 }
70
71 fn collect_commands_recursive(node: &Trie, results: &mut Vec<String>, limit: usize) {
72 if results.len() >= limit {
73 return;
74 }
75 if let Some(ref cmd) = node.command {
76 results.push(cmd.clone());
77 }
78 for child in node.children.values() {
79 Self::collect_commands_recursive(child, results, limit);
80 if results.len() >= limit {
81 return;
82 }
83 }
84 }
85}
86
87#[derive(Debug, Serialize, Deserialize)]
89struct MarkovModelData {
90 n: usize,
91 ngrams: HashMap<String, HashMap<String, u32>>,
92 command_freq: HashMap<String, u32>,
93 total_commands: usize,
94 #[serde(default)]
95 last_trained_pos: usize,
96}
97
98const SHELL_MODEL_BYTES: &[u8] = include_bytes!("../../assets/aprender-shell-base.apr");
100
101impl ShellAutocomplete {
102 pub fn new() -> Result<Self, String> {
107 Self::load_from_bytes(SHELL_MODEL_BYTES)
108 }
109
110 pub fn load_from_bytes(bytes: &[u8]) -> Result<Self, String> {
113 if bytes.len() < HEADER_SIZE {
115 return Err("Model file too small".to_string());
116 }
117 if &bytes[0..4] != b"APRN" {
118 return Err(format!("Invalid magic bytes: {:?}", &bytes[0..4]));
119 }
120
121 let metadata_size = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
133 let payload_size =
134 u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
135 let compression = bytes[20];
136
137 let metadata_start = HEADER_SIZE;
139 let metadata_end = metadata_start + metadata_size;
140 let payload_start = metadata_end;
141 let payload_end = payload_start + payload_size;
142
143 if payload_end > bytes.len() {
144 return Err(format!(
145 "Payload extends beyond file: {} > {}",
146 payload_end,
147 bytes.len()
148 ));
149 }
150
151 let payload_compressed = &bytes[payload_start..payload_end];
152
153 let payload_decompressed: Vec<u8> = match compression {
155 0x00 => payload_compressed.to_vec(), #[cfg(feature = "shell-autocomplete")]
157 0x01 | 0x02 => {
158 zstd::decode_all(payload_compressed)
160 .map_err(|e| format!("Failed to decompress: {}", e))?
161 }
162 #[cfg(not(feature = "shell-autocomplete"))]
163 0x01 | 0x02 => {
164 return Err(
165 "Zstd compression requires the 'shell-autocomplete' feature".to_string()
166 );
167 }
168 _ => return Err(format!("Unknown compression type: 0x{:02X}", compression)),
169 };
170
171 let model_data: MarkovModelData = bincode::deserialize(&payload_decompressed)
173 .map_err(|e| format!("Failed to deserialize model: {}", e))?;
174
175 let mut trie = Trie::new();
177 for cmd in model_data.command_freq.keys() {
178 trie.insert(cmd);
179 }
180
181 Ok(Self {
182 n: model_data.n,
183 ngrams: model_data.ngrams,
184 command_freq: model_data.command_freq,
185 trie,
186 total_commands: model_data.total_commands,
187 })
188 }
189
190 pub fn suggest(&self, prefix: &str, count: usize) -> Vec<(String, f32)> {
192 let prefix = prefix.trim();
193 let tokens: Vec<&str> = prefix.split_whitespace().collect();
194 let ends_with_space = prefix.is_empty() || prefix.ends_with(' ');
195
196 let capacity = count * 4;
197 let mut suggestions = Vec::with_capacity(capacity);
198 let mut seen = std::collections::HashSet::with_capacity(capacity);
199
200 self.suggest_from_trie(prefix, capacity, &mut seen, &mut suggestions);
202
203 if !tokens.is_empty() && ends_with_space {
205 let context_start = tokens.len().saturating_sub(self.n - 1);
206 let context = tokens[context_start..].join(" ");
207 self.push_ngram_completions(&context, prefix, None, 0.8, &mut seen, &mut suggestions);
208 }
209
210 if !tokens.is_empty() && !ends_with_space && tokens.len() >= 2 {
212 let partial_token = tokens.last().unwrap_or(&"");
213 let context_tokens = &tokens[..tokens.len() - 1];
214 let context_start = context_tokens.len().saturating_sub(self.n - 1);
215 let context = context_tokens[context_start..].join(" ");
216 let context_prefix = context_tokens.join(" ");
217 self.push_ngram_completions(
218 &context,
219 &context_prefix,
220 Some(partial_token),
221 0.9,
222 &mut seen,
223 &mut suggestions,
224 );
225 }
226
227 if prefix.is_empty() && suggestions.is_empty() {
229 suggestions = self.top_commands();
230 }
231
232 suggestions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
234 suggestions.truncate(count);
235
236 suggestions
237 }
238
239 fn suggest_from_trie(
241 &self,
242 prefix: &str,
243 capacity: usize,
244 seen: &mut std::collections::HashSet<String>,
245 suggestions: &mut Vec<(String, f32)>,
246 ) {
247 for cmd in self.trie.find_prefix(prefix, capacity) {
248 if Self::is_corrupted_command(&cmd) {
249 continue;
250 }
251 let freq = self.command_freq.get(&cmd).copied().unwrap_or(1);
252 let score = freq as f32 / self.total_commands.max(1) as f32;
253 seen.insert(cmd.clone());
254 suggestions.push((cmd, score));
255 }
256 }
257
258 fn push_ngram_completions(
262 &self,
263 context: &str,
264 base: &str,
265 partial: Option<&str>,
266 weight: f32,
267 seen: &mut std::collections::HashSet<String>,
268 suggestions: &mut Vec<(String, f32)>,
269 ) {
270 let Some(next_tokens) = self.ngrams.get(context) else {
271 return;
272 };
273 let total: u32 = next_tokens.values().sum();
274 for (token, ngram_count) in next_tokens {
275 if let Some(p) = partial {
276 if !token.starts_with(p) || Self::is_corrupted_token(token) {
277 continue;
278 }
279 }
280 let completion = format!("{base} {token}");
281 if seen.insert(completion.clone()) {
282 let score = *ngram_count as f32 / total as f32;
283 suggestions.push((completion, score * weight));
284 }
285 }
286 }
287
288 fn top_commands(&self) -> Vec<(String, f32)> {
290 let mut top_cmds: Vec<_> = self
291 .command_freq
292 .iter()
293 .map(|(k, v)| (k.clone(), *v as f32 / self.total_commands.max(1) as f32))
294 .collect();
295 top_cmds.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
296 top_cmds
297 }
298
299 fn is_corrupted_command(cmd: &str) -> bool {
301 if cmd.contains(" ") {
302 return true;
303 }
304 if cmd.trim_end().ends_with('\\') {
305 return true;
306 }
307 cmd.split_whitespace().any(Self::is_corrupted_token)
308 }
309
310 fn is_corrupted_token(token: &str) -> bool {
312 if let Some(dash_pos) = token.find('-') {
313 if dash_pos > 0 && dash_pos < token.len() - 1 {
314 let before = &token[..dash_pos];
315 let after = &token[dash_pos + 1..];
316 let subcommands = [
317 "commit", "checkout", "clone", "push", "pull", "merge", "rebase", "status",
318 "add", "build", "run", "test", "install",
319 ];
320 if subcommands.contains(&before) && (after.len() <= 2 || after.starts_with('-')) {
321 return true;
322 }
323 }
324 }
325 false
326 }
327
328 pub fn suggest_json(&self, prefix: &str, count: usize) -> String {
330 let suggestions = self.suggest(prefix, count);
331 let items: Vec<_> = suggestions
332 .iter()
333 .map(|(text, score)| {
334 format!(
335 r#"{{"text":"{}","score":{:.4}}}"#,
336 text.replace('"', "\\\""),
337 score
338 )
339 })
340 .collect();
341 format!(r#"{{"suggestions":[{}]}}"#, items.join(","))
342 }
343
344 pub fn model_info_json(&self) -> String {
346 format!(
347 r#"{{"model_name":"aprender-shell-base","model_type":"ngram_lm","vocab_size":{},"ngram_size":{},"ngram_count":{},"total_commands":{}}}"#,
348 self.vocab_size(),
349 self.n,
350 self.ngram_count(),
351 self.total_commands
352 )
353 }
354
355 pub fn vocab_size(&self) -> usize {
357 self.command_freq.len()
358 }
359
360 pub fn ngram_count(&self) -> usize {
362 self.ngrams.values().map(HashMap::len).sum()
363 }
364
365 pub fn ngram_size(&self) -> usize {
367 self.n
368 }
369
370 pub fn estimated_memory_bytes(&self) -> usize {
372 let ngram_size: usize = self
373 .ngrams
374 .iter()
375 .map(|(k, v)| k.len() + v.keys().map(|k2| k2.len() + 4).sum::<usize>())
376 .sum();
377 let vocab_size: usize = self.command_freq.keys().map(|k| k.len() + 4).sum();
378 ngram_size + vocab_size + std::mem::size_of::<Self>()
379 }
380}
381
382#[cfg(target_arch = "wasm32")]
388#[wasm_bindgen]
389pub struct ShellAutocompleteDemo {
390 inner: ShellAutocomplete,
391}
392
393#[cfg(target_arch = "wasm32")]
394#[wasm_bindgen]
395impl ShellAutocompleteDemo {
396 #[wasm_bindgen(js_name = "fromBytes")]
405 pub fn from_bytes(bytes: &[u8]) -> Result<ShellAutocompleteDemo, JsValue> {
406 console_error_panic_hook::set_once();
407
408 let inner =
409 ShellAutocomplete::load_from_bytes(bytes).map_err(|e| JsValue::from_str(e.as_str()))?;
410
411 web_sys::console::log_1(
412 &format!(
413 "ShellAutocomplete loaded from bytes: {} commands, {} n-grams",
414 inner.vocab_size(),
415 inner.ngram_count()
416 )
417 .into(),
418 );
419
420 Ok(Self { inner })
421 }
422
423 #[wasm_bindgen(constructor)]
429 pub fn new() -> Result<ShellAutocompleteDemo, JsValue> {
430 console_error_panic_hook::set_once();
431
432 let inner = ShellAutocomplete::new().map_err(|e| JsValue::from_str(&e))?;
433
434 web_sys::console::log_1(
435 &format!(
436 "ShellAutocomplete loaded (embedded): {} commands, {} n-grams",
437 inner.vocab_size(),
438 inner.ngram_count()
439 )
440 .into(),
441 );
442
443 Ok(Self { inner })
444 }
445
446 #[wasm_bindgen]
448 pub fn suggest(&self, prefix: &str, count: usize) -> String {
449 self.inner.suggest_json(prefix, count)
450 }
451
452 #[wasm_bindgen]
454 pub fn model_info(&self) -> String {
455 self.inner.model_info_json()
456 }
457
458 pub fn vocab_size(&self) -> usize {
460 self.inner.vocab_size()
461 }
462
463 pub fn ngram_count(&self) -> usize {
465 self.inner.ngram_count()
466 }
467
468 pub fn ngram_size(&self) -> usize {
470 self.inner.ngram_size()
471 }
472
473 pub fn memory_bytes(&self) -> usize {
475 self.inner.estimated_memory_bytes()
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn test_trie_basic() {
485 let mut trie = Trie::new();
486 trie.insert("git status");
487 trie.insert("git commit");
488 trie.insert("cargo build");
489
490 let results = trie.find_prefix("git", 10);
491 assert_eq!(results.len(), 2);
492 }
493
494 #[test]
495 fn test_corrupted_detection() {
496 assert!(ShellAutocomplete::is_corrupted_command("git commit-m"));
497 assert!(!ShellAutocomplete::is_corrupted_command("git commit -m"));
498 assert!(!ShellAutocomplete::is_corrupted_command(
499 "git checkout feature-branch"
500 ));
501 }
502}