Skip to main content

zsh/zle/
tricky.rs

1//! ZLE tricky - completion and expansion widgets
2//!
3//! Direct port from zsh/Src/Zle/zle_tricky.c
4//!
5//! Implements completion widgets:
6//! - complete-word, menu-complete, reverse-menu-complete
7//! - expand-or-complete, expand-or-complete-prefix
8//! - list-choices, list-expand
9//! - expand-word, expand-history
10//! - spell-word, delete-char-or-list
11//! - magic-space, accept-and-menu-complete
12
13use super::main::Zle;
14
15/// Completion state
16#[derive(Debug, Default, Clone)]
17pub struct CompletionState {
18    /// Whether we're in menu completion mode
19    pub in_menu: bool,
20    /// Current menu index
21    pub menu_index: usize,
22    /// Available completions
23    pub completions: Vec<String>,
24    /// Prefix being completed
25    pub prefix: String,
26    /// Suffix after cursor
27    pub suffix: String,
28    /// Word start position
29    pub word_start: usize,
30    /// Word end position
31    pub word_end: usize,
32    /// Last completion was a menu cycle
33    pub last_menu: bool,
34}
35
36/// Brace info for parameter expansion
37#[derive(Debug, Clone)]
38pub struct BraceInfo {
39    pub str_val: String,
40    pub pos: usize,
41    pub cur_pos: usize,
42    pub qpos: usize,
43    pub curlen: usize,
44}
45
46impl Zle {
47    /// Complete word - trigger completion
48    /// Port of completeword() from zle_tricky.c
49    pub fn complete_word(&mut self, state: &mut CompletionState) {
50        self.do_complete(state, false, false);
51    }
52
53    /// Menu complete - cycle through completions
54    /// Port of menucomplete() from zle_tricky.c
55    pub fn menu_complete(&mut self, state: &mut CompletionState) {
56        if state.in_menu && !state.completions.is_empty() {
57            // Cycle to next completion
58            state.menu_index = (state.menu_index + 1) % state.completions.len();
59            self.apply_completion(state);
60        } else {
61            self.do_complete(state, true, false);
62        }
63    }
64
65    /// Reverse menu complete - cycle backwards
66    /// Port of reversemenucomplete() from zle_tricky.c
67    pub fn reverse_menu_complete(&mut self, state: &mut CompletionState) {
68        if state.in_menu && !state.completions.is_empty() {
69            if state.menu_index == 0 {
70                state.menu_index = state.completions.len() - 1;
71            } else {
72                state.menu_index -= 1;
73            }
74            self.apply_completion(state);
75        }
76    }
77
78    /// Expand or complete - try expansion first, then completion
79    /// Port of expandorcomplete() from zle_tricky.c
80    pub fn expand_or_complete(&mut self, state: &mut CompletionState) {
81        // First try expansion
82        if !self.try_expand() {
83            // Then try completion
84            self.do_complete(state, false, false);
85        }
86    }
87
88    /// Expand or complete prefix - expand/complete keeping suffix
89    /// Port of expandorcompleteprefix() from zle_tricky.c
90    pub fn expand_or_complete_prefix(&mut self, state: &mut CompletionState) {
91        state.suffix = self.zleline[self.zlecs..].iter().collect();
92        self.expand_or_complete(state);
93    }
94
95    /// List choices - show available completions
96    /// Port of listchoices() from zle_tricky.c
97    pub fn list_choices(&mut self, state: &mut CompletionState) {
98        self.do_complete(state, false, true);
99
100        if !state.completions.is_empty() {
101            println!();
102            for (i, c) in state.completions.iter().enumerate() {
103                if i > 0 && i % 5 == 0 {
104                    println!();
105                }
106                print!("{:<16}", c);
107            }
108            println!();
109            self.resetneeded = true;
110        }
111    }
112
113    /// List expand - list possible expansions
114    /// Port of listexpand() from zle_tricky.c
115    pub fn list_expand(&mut self) {
116        let word = self.get_word_at_cursor();
117        let expansions = self.do_expansion(&word);
118
119        if !expansions.is_empty() {
120            println!();
121            for exp in &expansions {
122                println!("{}", exp);
123            }
124            self.resetneeded = true;
125        }
126    }
127
128    /// Expand word - expand current word (glob, history, etc)
129    /// Port of expandword() from zle_tricky.c
130    pub fn expand_word(&mut self) {
131        let _ = self.try_expand();
132    }
133
134    /// Expand history - expand history references
135    /// Port of expandhistory() / doexpandhist() from zle_tricky.c
136    pub fn expand_history(&mut self) {
137        let line: String = self.zleline.iter().collect();
138
139        // Look for history references like !!, !$, !*, etc.
140        let expanded = self.do_expand_hist(&line);
141
142        if expanded != line {
143            self.zleline = expanded.chars().collect();
144            self.zlell = self.zleline.len();
145            if self.zlecs > self.zlell {
146                self.zlecs = self.zlell;
147            }
148            self.resetneeded = true;
149        }
150    }
151
152    /// Magic space - expand history then insert space
153    /// Port of magicspace() from zle_tricky.c
154    pub fn magic_space(&mut self) {
155        self.expand_history();
156        self.self_insert(' ');
157    }
158
159    /// Delete char or list - delete if there's text, else list completions
160    /// Port of deletecharorlist() from zle_tricky.c
161    pub fn delete_char_or_list(&mut self, state: &mut CompletionState) {
162        if self.zlecs < self.zlell {
163            self.delete_char();
164        } else {
165            self.list_choices(state);
166        }
167    }
168
169    /// Accept and menu complete
170    /// Port of acceptandmenucomplete() from zle_tricky.c  
171    pub fn accept_and_menu_complete(&mut self, state: &mut CompletionState) -> Option<String> {
172        let line = self.accept_line();
173        state.in_menu = false;
174        Some(line)
175    }
176
177    /// Spell word - check spelling
178    /// Port of spellword() from zle_tricky.c
179    pub fn spell_word(&mut self) {
180        // Simple spell check - look for common patterns
181        let word = self.get_word_at_cursor();
182        // Would integrate with aspell/hunspell in full implementation
183        let _ = word;
184    }
185
186    /// Internal: perform completion
187    fn do_complete(&mut self, state: &mut CompletionState, menu_mode: bool, list_only: bool) {
188        // Get word at cursor
189        let (word_start, word_end) = self.get_word_bounds();
190        let word: String = self.zleline[word_start..word_end].iter().collect();
191
192        state.word_start = word_start;
193        state.word_end = word_end;
194        state.prefix = word.clone();
195
196        // Get completions (simplified - real impl would call compsys)
197        state.completions = self.get_completions(&word);
198
199        if state.completions.is_empty() {
200            return;
201        }
202
203        if list_only {
204            return;
205        }
206
207        if menu_mode || state.completions.len() > 1 {
208            state.in_menu = true;
209            state.menu_index = 0;
210            self.apply_completion(state);
211        } else if state.completions.len() == 1 {
212            // Single completion - apply directly
213            state.menu_index = 0;
214            self.apply_completion(state);
215            state.in_menu = false;
216        }
217    }
218
219    /// Apply current completion from state
220    fn apply_completion(&mut self, state: &CompletionState) {
221        if state.completions.is_empty() {
222            return;
223        }
224
225        let completion = &state.completions[state.menu_index];
226
227        // Remove old word
228        self.zleline.drain(state.word_start..state.word_end);
229        self.zlell = self.zleline.len();
230        self.zlecs = state.word_start;
231
232        // Insert completion
233        for c in completion.chars() {
234            self.zleline.insert(self.zlecs, c);
235            self.zlecs += 1;
236        }
237        self.zlell = self.zleline.len();
238        self.resetneeded = true;
239    }
240
241    /// Get word at cursor position
242    fn get_word_at_cursor(&self) -> String {
243        let (start, end) = self.get_word_bounds();
244        self.zleline[start..end].iter().collect()
245    }
246
247    /// Get bounds of word at cursor
248    fn get_word_bounds(&self) -> (usize, usize) {
249        let mut start = self.zlecs;
250        let mut end = self.zlecs;
251
252        // Find word start
253        while start > 0 && !self.zleline[start - 1].is_whitespace() {
254            start -= 1;
255        }
256
257        // Find word end
258        while end < self.zlell && !self.zleline[end].is_whitespace() {
259            end += 1;
260        }
261
262        (start, end)
263    }
264
265    /// Try to expand the word at cursor
266    fn try_expand(&mut self) -> bool {
267        let word = self.get_word_at_cursor();
268
269        if word.is_empty() {
270            return false;
271        }
272
273        let expansions = self.do_expansion(&word);
274
275        if expansions.is_empty() || (expansions.len() == 1 && expansions[0] == word) {
276            return false;
277        }
278
279        let (start, end) = self.get_word_bounds();
280
281        // Remove old word
282        self.zleline.drain(start..end);
283        self.zlecs = start;
284
285        // Insert expansions
286        let expanded = expansions.join(" ");
287        for c in expanded.chars() {
288            self.zleline.insert(self.zlecs, c);
289            self.zlecs += 1;
290        }
291        self.zlell = self.zleline.len();
292        self.resetneeded = true;
293
294        true
295    }
296
297    /// Do expansion on a word
298    fn do_expansion(&self, word: &str) -> Vec<String> {
299        let mut results = Vec::new();
300
301        // Check for glob patterns
302        if word.contains('*') || word.contains('?') || word.contains('[') {
303            // Would call glob expansion
304            if let Ok(paths) = glob::glob(word) {
305                for path in paths.flatten() {
306                    results.push(path.display().to_string());
307                }
308            }
309        }
310
311        // Check for tilde expansion
312        if word.starts_with('~') {
313            if let Some(home) = std::env::var_os("HOME") {
314                let expanded = word.replacen('~', home.to_str().unwrap_or("~"), 1);
315                results.push(expanded);
316            }
317        }
318
319        // Check for variable expansion
320        if let Some(var_name) = word.strip_prefix('$') {
321            if let Ok(val) = std::env::var(var_name) {
322                results.push(val);
323            }
324        }
325
326        if results.is_empty() {
327            results.push(word.to_string());
328        }
329
330        results
331    }
332
333    /// Do history expansion
334    fn do_expand_hist(&self, line: &str) -> String {
335        let mut result = line.to_string();
336
337        // !! -> last command (simplified)
338        if result.contains("!!") {
339            result = result.replace("!!", "[last-command]");
340        }
341
342        // !$ -> last argument of last command (simplified)
343        if result.contains("!$") {
344            result = result.replace("!$", "[last-arg]");
345        }
346
347        result
348    }
349
350    /// Get completions for a prefix (simplified)
351    fn get_completions(&self, prefix: &str) -> Vec<String> {
352        let mut completions = Vec::new();
353
354        // Check if it looks like a path
355        if prefix.contains('/') || prefix.starts_with('.') {
356            // Path completion
357            let dir = if let Some(pos) = prefix.rfind('/') {
358                &prefix[..=pos]
359            } else {
360                "./"
361            };
362            let file_prefix = if let Some(pos) = prefix.rfind('/') {
363                &prefix[pos + 1..]
364            } else {
365                prefix
366            };
367
368            if let Ok(entries) = std::fs::read_dir(dir) {
369                for entry in entries.flatten() {
370                    let name = entry.file_name().to_string_lossy().to_string();
371                    if name.starts_with(file_prefix) {
372                        let full_path = if dir == "./" {
373                            name
374                        } else {
375                            format!("{}{}", dir, name)
376                        };
377                        completions.push(full_path);
378                    }
379                }
380            }
381        } else {
382            // Command completion - look in PATH
383            if let Ok(path) = std::env::var("PATH") {
384                for dir in path.split(':') {
385                    if let Ok(entries) = std::fs::read_dir(dir) {
386                        for entry in entries.flatten() {
387                            let name = entry.file_name().to_string_lossy().to_string();
388                            if name.starts_with(prefix) && !completions.contains(&name) {
389                                completions.push(name);
390                            }
391                        }
392                    }
393                }
394            }
395        }
396
397        completions.sort();
398        completions
399    }
400}
401
402/// Meta character for zsh's internal encoding (0x83)
403pub const META: char = '\u{83}';
404
405/// Metafy a line (escape special chars)
406/// Port of metafy_line() from zle_tricky.c
407pub fn metafy_line(s: &str) -> String {
408    let mut result = String::with_capacity(s.len() * 2);
409    for c in s.chars() {
410        if c == META || (c as u32) >= 0x83 {
411            result.push(META);
412            result.push(char::from_u32((c as u32) ^ 32).unwrap_or(c));
413        } else {
414            result.push(c);
415        }
416    }
417    result
418}
419
420/// Unmetafy a line (unescape special chars)
421/// Port of unmetafy_line() from zle_tricky.c
422pub fn unmetafy_line(s: &str) -> String {
423    let mut result = String::with_capacity(s.len());
424    let mut chars = s.chars().peekable();
425
426    while let Some(c) = chars.next() {
427        if c == META {
428            if let Some(&next) = chars.peek() {
429                chars.next();
430                result.push(char::from_u32((next as u32) ^ 32).unwrap_or(next));
431            }
432        } else {
433            result.push(c);
434        }
435    }
436
437    result
438}
439
440/// Get the command being typed
441/// Port of getcurcmd() from zle_tricky.c
442pub fn get_cur_cmd(line: &[char], cursor: usize) -> Option<String> {
443    // Find start of current simple command
444    let mut cmd_start = 0;
445
446    for i in 0..cursor {
447        let c = line[i];
448        if c == ';' || c == '|' || c == '&' || c == '(' || c == ')' || c == '`' {
449            cmd_start = i + 1;
450        }
451    }
452
453    // Skip whitespace
454    while cmd_start < cursor && line[cmd_start].is_whitespace() {
455        cmd_start += 1;
456    }
457
458    // Find end of command word
459    let mut cmd_end = cmd_start;
460    while cmd_end < cursor && !line[cmd_end].is_whitespace() {
461        cmd_end += 1;
462    }
463
464    if cmd_start < cmd_end {
465        Some(line[cmd_start..cmd_end].iter().collect())
466    } else {
467        None
468    }
469}
470
471/// Check if string has real tokens (not escaped)
472/// Port of has_real_token() from zle_tricky.c
473pub fn has_real_token(s: &str) -> bool {
474    let special = ['$', '`', '"', '\'', '\\', '{', '}', '[', ']', '*', '?', '~'];
475
476    let mut escaped = false;
477    for c in s.chars() {
478        if escaped {
479            escaped = false;
480            continue;
481        }
482        if c == '\\' {
483            escaped = true;
484            continue;
485        }
486        if special.contains(&c) {
487            return true;
488        }
489    }
490
491    false
492}
493
494/// Get length of common prefix
495/// Port of pfxlen() from zle_tricky.c
496pub fn pfx_len(s1: &str, s2: &str) -> usize {
497    s1.chars()
498        .zip(s2.chars())
499        .take_while(|(a, b)| a == b)
500        .count()
501}
502
503/// Get length of common suffix
504/// Port of sfxlen() from zle_tricky.c
505pub fn sfx_len(s1: &str, s2: &str) -> usize {
506    s1.chars()
507        .rev()
508        .zip(s2.chars().rev())
509        .take_while(|(a, b)| a == b)
510        .count()
511}
512
513/// Quote a string for shell
514/// Port of quotestring() from zle_tricky.c
515pub fn quote_string(s: &str, style: QuoteStyle) -> String {
516    match style {
517        QuoteStyle::Single => format!("'{}'", s.replace('\'', "'\\''")),
518        QuoteStyle::Double => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
519        QuoteStyle::Dollar => format!("$'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
520        QuoteStyle::Backslash => {
521            let mut result = String::with_capacity(s.len() * 2);
522            for c in s.chars() {
523                if " \t\n\\'\"`$&|;()<>*?[]{}#~".contains(c) {
524                    result.push('\\');
525                }
526                result.push(c);
527            }
528            result
529        }
530    }
531}
532
533#[derive(Debug, Clone, Copy)]
534pub enum QuoteStyle {
535    Single,
536    Double,
537    Dollar,
538    Backslash,
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_pfx_len() {
547        assert_eq!(pfx_len("hello", "help"), 3);
548        assert_eq!(pfx_len("abc", "xyz"), 0);
549        assert_eq!(pfx_len("test", "test"), 4);
550    }
551
552    #[test]
553    fn test_sfx_len() {
554        assert_eq!(sfx_len("testing", "running"), 3);
555        assert_eq!(sfx_len("abc", "xyz"), 0);
556    }
557
558    #[test]
559    fn test_quote_string() {
560        assert_eq!(quote_string("hello", QuoteStyle::Single), "'hello'");
561        assert_eq!(quote_string("it's", QuoteStyle::Single), "'it'\\''s'");
562        assert_eq!(quote_string("hello", QuoteStyle::Double), "\"hello\"");
563    }
564
565    #[test]
566    fn test_has_real_token() {
567        assert!(has_real_token("$HOME"));
568        assert!(has_real_token("*.txt"));
569        assert!(!has_real_token("hello"));
570        assert!(!has_real_token("test\\$var")); // escaped
571    }
572}