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 word.starts_with('$') {
321            let var_name = &word[1..];
322            if let Ok(val) = std::env::var(var_name) {
323                results.push(val);
324            }
325        }
326
327        if results.is_empty() {
328            results.push(word.to_string());
329        }
330
331        results
332    }
333
334    /// Do history expansion
335    fn do_expand_hist(&self, line: &str) -> String {
336        let mut result = line.to_string();
337
338        // !! -> last command (simplified)
339        if result.contains("!!") {
340            result = result.replace("!!", "[last-command]");
341        }
342
343        // !$ -> last argument of last command (simplified)
344        if result.contains("!$") {
345            result = result.replace("!$", "[last-arg]");
346        }
347
348        result
349    }
350
351    /// Get completions for a prefix (simplified)
352    fn get_completions(&self, prefix: &str) -> Vec<String> {
353        let mut completions = Vec::new();
354
355        // Check if it looks like a path
356        if prefix.contains('/') || prefix.starts_with('.') {
357            // Path completion
358            let dir = if let Some(pos) = prefix.rfind('/') {
359                &prefix[..=pos]
360            } else {
361                "./"
362            };
363            let file_prefix = if let Some(pos) = prefix.rfind('/') {
364                &prefix[pos + 1..]
365            } else {
366                prefix
367            };
368
369            if let Ok(entries) = std::fs::read_dir(dir) {
370                for entry in entries.flatten() {
371                    let name = entry.file_name().to_string_lossy().to_string();
372                    if name.starts_with(file_prefix) {
373                        let full_path = if dir == "./" {
374                            name
375                        } else {
376                            format!("{}{}", dir, name)
377                        };
378                        completions.push(full_path);
379                    }
380                }
381            }
382        } else {
383            // Command completion - look in PATH
384            if let Ok(path) = std::env::var("PATH") {
385                for dir in path.split(':') {
386                    if let Ok(entries) = std::fs::read_dir(dir) {
387                        for entry in entries.flatten() {
388                            let name = entry.file_name().to_string_lossy().to_string();
389                            if name.starts_with(prefix) {
390                                if !completions.contains(&name) {
391                                    completions.push(name);
392                                }
393                            }
394                        }
395                    }
396                }
397            }
398        }
399
400        completions.sort();
401        completions
402    }
403}
404
405/// Meta character for zsh's internal encoding (0x83)
406pub const META: char = '\u{83}';
407
408/// Metafy a line (escape special chars)
409/// Port of metafy_line() from zle_tricky.c
410pub fn metafy_line(s: &str) -> String {
411    let mut result = String::with_capacity(s.len() * 2);
412    for c in s.chars() {
413        if c == META || (c as u32) >= 0x83 {
414            result.push(META);
415            result.push(char::from_u32((c as u32) ^ 32).unwrap_or(c));
416        } else {
417            result.push(c);
418        }
419    }
420    result
421}
422
423/// Unmetafy a line (unescape special chars)
424/// Port of unmetafy_line() from zle_tricky.c
425pub fn unmetafy_line(s: &str) -> String {
426    let mut result = String::with_capacity(s.len());
427    let mut chars = s.chars().peekable();
428
429    while let Some(c) = chars.next() {
430        if c == META {
431            if let Some(&next) = chars.peek() {
432                chars.next();
433                result.push(char::from_u32((next as u32) ^ 32).unwrap_or(next));
434            }
435        } else {
436            result.push(c);
437        }
438    }
439
440    result
441}
442
443/// Get the command being typed
444/// Port of getcurcmd() from zle_tricky.c
445pub fn get_cur_cmd(line: &[char], cursor: usize) -> Option<String> {
446    // Find start of current simple command
447    let mut cmd_start = 0;
448
449    for i in 0..cursor {
450        let c = line[i];
451        if c == ';' || c == '|' || c == '&' || c == '(' || c == ')' || c == '`' {
452            cmd_start = i + 1;
453        }
454    }
455
456    // Skip whitespace
457    while cmd_start < cursor && line[cmd_start].is_whitespace() {
458        cmd_start += 1;
459    }
460
461    // Find end of command word
462    let mut cmd_end = cmd_start;
463    while cmd_end < cursor && !line[cmd_end].is_whitespace() {
464        cmd_end += 1;
465    }
466
467    if cmd_start < cmd_end {
468        Some(line[cmd_start..cmd_end].iter().collect())
469    } else {
470        None
471    }
472}
473
474/// Check if string has real tokens (not escaped)
475/// Port of has_real_token() from zle_tricky.c
476pub fn has_real_token(s: &str) -> bool {
477    let special = ['$', '`', '"', '\'', '\\', '{', '}', '[', ']', '*', '?', '~'];
478
479    let mut escaped = false;
480    for c in s.chars() {
481        if escaped {
482            escaped = false;
483            continue;
484        }
485        if c == '\\' {
486            escaped = true;
487            continue;
488        }
489        if special.contains(&c) {
490            return true;
491        }
492    }
493
494    false
495}
496
497/// Get length of common prefix
498/// Port of pfxlen() from zle_tricky.c
499pub fn pfx_len(s1: &str, s2: &str) -> usize {
500    s1.chars()
501        .zip(s2.chars())
502        .take_while(|(a, b)| a == b)
503        .count()
504}
505
506/// Get length of common suffix
507/// Port of sfxlen() from zle_tricky.c
508pub fn sfx_len(s1: &str, s2: &str) -> usize {
509    s1.chars()
510        .rev()
511        .zip(s2.chars().rev())
512        .take_while(|(a, b)| a == b)
513        .count()
514}
515
516/// Quote a string for shell
517/// Port of quotestring() from zle_tricky.c
518pub fn quote_string(s: &str, style: QuoteStyle) -> String {
519    match style {
520        QuoteStyle::Single => format!("'{}'", s.replace('\'', "'\\''")),
521        QuoteStyle::Double => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
522        QuoteStyle::Dollar => format!("$'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
523        QuoteStyle::Backslash => {
524            let mut result = String::with_capacity(s.len() * 2);
525            for c in s.chars() {
526                if " \t\n\\'\"`$&|;()<>*?[]{}#~".contains(c) {
527                    result.push('\\');
528                }
529                result.push(c);
530            }
531            result
532        }
533    }
534}
535
536#[derive(Debug, Clone, Copy)]
537pub enum QuoteStyle {
538    Single,
539    Double,
540    Dollar,
541    Backslash,
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn test_pfx_len() {
550        assert_eq!(pfx_len("hello", "help"), 3);
551        assert_eq!(pfx_len("abc", "xyz"), 0);
552        assert_eq!(pfx_len("test", "test"), 4);
553    }
554
555    #[test]
556    fn test_sfx_len() {
557        assert_eq!(sfx_len("testing", "running"), 3);
558        assert_eq!(sfx_len("abc", "xyz"), 0);
559    }
560
561    #[test]
562    fn test_quote_string() {
563        assert_eq!(quote_string("hello", QuoteStyle::Single), "'hello'");
564        assert_eq!(quote_string("it's", QuoteStyle::Single), "'it'\\''s'");
565        assert_eq!(quote_string("hello", QuoteStyle::Double), "\"hello\"");
566    }
567
568    #[test]
569    fn test_has_real_token() {
570        assert!(has_real_token("$HOME"));
571        assert!(has_real_token("*.txt"));
572        assert!(!has_real_token("hello"));
573        assert!(!has_real_token("test\\$var")); // escaped
574    }
575}