Skip to main content

zsh/
fish_features.rs

1//! Fish-style features for zshrs - native Rust implementations
2//!
3//! Lifted from fish-shell's Rust codebase for maximum performance.
4//! These run as pure Rust with zero interpreter overhead.
5
6use std::collections::HashSet;
7use std::sync::{LazyLock, Mutex};
8
9// ============================================================================
10// SYNTAX HIGHLIGHTING
11// ============================================================================
12
13/// Highlight roles - what kind of syntax element this is
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
15pub enum HighlightRole {
16    #[default]
17    Normal,
18    Command,
19    Keyword,
20    Statement,
21    Param,
22    Option,
23    Comment,
24    Error,
25    String,
26    Escape,
27    Operator,
28    Redirection,
29    Path,
30    PathValid,
31    Autosuggestion,
32    Selection,
33    Search,
34    Variable,
35    Quote,
36}
37
38/// A highlight specification for a character
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub struct HighlightSpec {
41    pub foreground: HighlightRole,
42    pub background: HighlightRole,
43    pub valid_path: bool,
44    pub force_underline: bool,
45}
46
47impl HighlightSpec {
48    pub fn with_fg(fg: HighlightRole) -> Self {
49        Self {
50            foreground: fg,
51            ..Default::default()
52        }
53    }
54}
55
56/// ANSI color codes for highlight roles
57pub fn role_to_ansi(role: HighlightRole) -> &'static str {
58    match role {
59        HighlightRole::Normal => "\x1b[0m",
60        HighlightRole::Command => "\x1b[1;32m", // Bold green
61        HighlightRole::Keyword => "\x1b[1;34m", // Bold blue
62        HighlightRole::Statement => "\x1b[1;35m", // Bold magenta
63        HighlightRole::Param => "\x1b[0m",      // Normal
64        HighlightRole::Option => "\x1b[36m",    // Cyan
65        HighlightRole::Comment => "\x1b[90m",   // Gray
66        HighlightRole::Error => "\x1b[1;31m",   // Bold red
67        HighlightRole::String => "\x1b[33m",    // Yellow
68        HighlightRole::Escape => "\x1b[1;33m",  // Bold yellow
69        HighlightRole::Operator => "\x1b[1;37m", // Bold white
70        HighlightRole::Redirection => "\x1b[35m", // Magenta
71        HighlightRole::Path => "\x1b[4m",       // Underline
72        HighlightRole::PathValid => "\x1b[4;32m", // Underline green
73        HighlightRole::Autosuggestion => "\x1b[90m", // Gray
74        HighlightRole::Selection => "\x1b[7m",  // Reverse
75        HighlightRole::Search => "\x1b[1;43m",  // Bold yellow bg
76        HighlightRole::Variable => "\x1b[1;36m", // Bold cyan
77        HighlightRole::Quote => "\x1b[33m",     // Yellow
78    }
79}
80
81/// Shell keywords
82const KEYWORDS: &[&str] = &[
83    "if", "then", "else", "elif", "fi", "case", "esac", "for", "while", "until", "do", "done",
84    "in", "function", "select", "time", "coproc", "{", "}", "[[", "]]", "!", "foreach", "end",
85    "repeat", "always",
86];
87
88/// Shell builtins (common ones)
89const BUILTINS: &[&str] = &[
90    "cd", "echo", "exit", "export", "alias", "unalias", "source", ".", "eval", "exec", "set",
91    "unset", "shift", "return", "break", "continue", "read", "readonly", "declare", "local",
92    "typeset", "let", "test", "[", "printf", "kill", "wait", "jobs", "fg", "bg", "disown", "trap",
93    "umask", "ulimit", "hash", "type", "which", "builtin", "command", "enable", "help", "history",
94    "fc", "pushd", "popd", "dirs", "pwd", "true", "false", ":", "getopts", "compgen", "complete",
95    "compopt", "shopt", "bind", "autoload", "zmodload", "zstyle", "zle", "bindkey", "setopt",
96    "unsetopt", "emulate", "whence",
97];
98
99/// Highlight a shell command line
100pub fn highlight_shell(line: &str) -> Vec<HighlightSpec> {
101    let mut colors = vec![HighlightSpec::default(); line.len()];
102    if line.is_empty() {
103        return colors;
104    }
105
106    let mut in_string = false;
107    let mut string_char = '"';
108    let mut in_comment = false;
109    let mut word_start: Option<usize> = None;
110    let mut is_first_word = true;
111    let mut after_pipe_or_semi = false;
112
113    let chars: Vec<char> = line.chars().collect();
114    let mut i = 0;
115
116    while i < chars.len() {
117        let c = chars[i];
118        let byte_pos = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
119
120        // Handle comments
121        if !in_string && c == '#' {
122            in_comment = true;
123        }
124        if in_comment {
125            if byte_pos < colors.len() {
126                colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Comment);
127            }
128            i += 1;
129            continue;
130        }
131
132        // Handle strings
133        if !in_string && (c == '"' || c == '\'') {
134            in_string = true;
135            string_char = c;
136            if byte_pos < colors.len() {
137                colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Quote);
138            }
139            i += 1;
140            continue;
141        }
142        if in_string {
143            if c == string_char {
144                in_string = false;
145                if byte_pos < colors.len() {
146                    colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Quote);
147                }
148            } else if c == '\\' && string_char == '"' && i + 1 < chars.len() {
149                if byte_pos < colors.len() {
150                    colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Escape);
151                }
152                i += 1;
153                let next_byte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
154                if next_byte < colors.len() {
155                    colors[next_byte] = HighlightSpec::with_fg(HighlightRole::Escape);
156                }
157            } else if c == '$' {
158                if byte_pos < colors.len() {
159                    colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Variable);
160                }
161            } else {
162                if byte_pos < colors.len() {
163                    colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::String);
164                }
165            }
166            i += 1;
167            continue;
168        }
169
170        // Handle variables
171        if c == '$' {
172            if byte_pos < colors.len() {
173                colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Variable);
174            }
175            i += 1;
176            // Color the variable name
177            while i < chars.len() {
178                let vc = chars[i];
179                if vc.is_alphanumeric() || vc == '_' || vc == '{' || vc == '}' {
180                    let vbyte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
181                    if vbyte < colors.len() {
182                        colors[vbyte] = HighlightSpec::with_fg(HighlightRole::Variable);
183                    }
184                    i += 1;
185                } else {
186                    break;
187                }
188            }
189            continue;
190        }
191
192        // Handle operators and redirections
193        if c == '|' || c == '&' || c == ';' {
194            if byte_pos < colors.len() {
195                colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Operator);
196            }
197            is_first_word = true;
198            after_pipe_or_semi = true;
199            i += 1;
200            continue;
201        }
202        if c == '>' || c == '<' {
203            if byte_pos < colors.len() {
204                colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Redirection);
205            }
206            // Handle >> or <<
207            if i + 1 < chars.len() && (chars[i + 1] == '>' || chars[i + 1] == '<') {
208                i += 1;
209                let next_byte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
210                if next_byte < colors.len() {
211                    colors[next_byte] = HighlightSpec::with_fg(HighlightRole::Redirection);
212                }
213            }
214            i += 1;
215            continue;
216        }
217
218        // Handle word boundaries
219        if c.is_whitespace() {
220            if let Some(start) = word_start {
221                // End of word - colorize it
222                let word_end = i;
223                let word: String = chars[start..word_end].iter().collect();
224                colorize_word(
225                    &word,
226                    start,
227                    &mut colors,
228                    line,
229                    is_first_word || after_pipe_or_semi,
230                );
231                is_first_word = false;
232                after_pipe_or_semi = false;
233            }
234            word_start = None;
235            i += 1;
236            continue;
237        }
238
239        // Start of word
240        if word_start.is_none() {
241            word_start = Some(i);
242        }
243
244        i += 1;
245    }
246
247    // Handle last word
248    if let Some(start) = word_start {
249        let word: String = chars[start..].iter().collect();
250        colorize_word(
251            &word,
252            start,
253            &mut colors,
254            line,
255            is_first_word || after_pipe_or_semi,
256        );
257    }
258
259    colors
260}
261
262fn colorize_word(
263    word: &str,
264    char_start: usize,
265    colors: &mut [HighlightSpec],
266    line: &str,
267    is_command_position: bool,
268) {
269    let role = if is_command_position {
270        if KEYWORDS.contains(&word) {
271            HighlightRole::Keyword
272        } else if BUILTINS.contains(&word) {
273            HighlightRole::Command
274        } else if command_exists(word) {
275            HighlightRole::Command
276        } else if word.contains('/') && std::path::Path::new(word).exists() {
277            HighlightRole::Command
278        } else {
279            HighlightRole::Error
280        }
281    } else if word.starts_with('-') {
282        HighlightRole::Option
283    } else if std::path::Path::new(word).exists() {
284        HighlightRole::PathValid
285    } else {
286        HighlightRole::Param
287    };
288
289    // Map char position to byte position and colorize
290    for (ci, _) in word.char_indices() {
291        let global_char_idx = char_start + word[..ci].chars().count();
292        if let Some((byte_pos, _)) = line.char_indices().nth(global_char_idx) {
293            if byte_pos < colors.len() {
294                colors[byte_pos] = HighlightSpec::with_fg(role);
295            }
296        }
297    }
298    // Also color the last char
299    let last_char_idx = char_start + word.chars().count() - 1;
300    if let Some((byte_pos, _)) = line.char_indices().nth(last_char_idx) {
301        if byte_pos < colors.len() {
302            colors[byte_pos] = HighlightSpec::with_fg(role);
303        }
304    }
305}
306
307/// Check if a command exists in PATH
308fn command_exists(cmd: &str) -> bool {
309    if cmd.is_empty() {
310        return false;
311    }
312    if let Ok(path) = std::env::var("PATH") {
313        for dir in path.split(':') {
314            let full_path = std::path::Path::new(dir).join(cmd);
315            if full_path.is_file() {
316                return true;
317            }
318        }
319    }
320    false
321}
322
323/// Convert highlight specs to ANSI-colored string
324pub fn colorize_line(line: &str, colors: &[HighlightSpec]) -> String {
325    let mut result = String::with_capacity(line.len() * 2);
326    let mut last_role = HighlightRole::Normal;
327
328    for (i, c) in line.chars().enumerate() {
329        let byte_pos = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(i);
330        let role = colors
331            .get(byte_pos)
332            .map(|s| s.foreground)
333            .unwrap_or(HighlightRole::Normal);
334
335        if role != last_role {
336            result.push_str(role_to_ansi(role));
337            last_role = role;
338        }
339        result.push(c);
340    }
341
342    if last_role != HighlightRole::Normal {
343        result.push_str("\x1b[0m");
344    }
345
346    result
347}
348
349// ============================================================================
350// ABBREVIATIONS
351// ============================================================================
352
353/// Position where abbreviation can expand
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum AbbrPosition {
356    Command,  // Only in command position
357    Anywhere, // Anywhere in the line
358}
359
360/// An abbreviation definition
361#[derive(Debug, Clone)]
362pub struct Abbreviation {
363    pub name: String,
364    pub key: String,
365    pub replacement: String,
366    pub position: AbbrPosition,
367}
368
369impl Abbreviation {
370    pub fn new(name: &str, key: &str, replacement: &str, position: AbbrPosition) -> Self {
371        Self {
372            name: name.to_string(),
373            key: key.to_string(),
374            replacement: replacement.to_string(),
375            position,
376        }
377    }
378
379    pub fn matches(&self, token: &str, is_command_position: bool) -> bool {
380        let position_ok = match self.position {
381            AbbrPosition::Anywhere => true,
382            AbbrPosition::Command => is_command_position,
383        };
384        position_ok && self.key == token
385    }
386}
387
388/// Global abbreviation set
389static ABBRS: LazyLock<Mutex<AbbreviationSet>> =
390    LazyLock::new(|| Mutex::new(AbbreviationSet::default()));
391
392pub fn with_abbrs<R>(cb: impl FnOnce(&AbbreviationSet) -> R) -> R {
393    let abbrs = ABBRS.lock().unwrap();
394    cb(&abbrs)
395}
396
397pub fn with_abbrs_mut<R>(cb: impl FnOnce(&mut AbbreviationSet) -> R) -> R {
398    let mut abbrs = ABBRS.lock().unwrap();
399    cb(&mut abbrs)
400}
401
402#[derive(Default)]
403pub struct AbbreviationSet {
404    abbrs: Vec<Abbreviation>,
405    used_names: HashSet<String>,
406}
407
408impl AbbreviationSet {
409    /// Find matching abbreviation for a token
410    pub fn find_match(&self, token: &str, is_command_position: bool) -> Option<&Abbreviation> {
411        // Later abbreviations take precedence
412        self.abbrs
413            .iter()
414            .rev()
415            .find(|a| a.matches(token, is_command_position))
416    }
417
418    /// Check if any abbreviation matches
419    pub fn has_match(&self, token: &str, is_command_position: bool) -> bool {
420        self.abbrs
421            .iter()
422            .any(|a| a.matches(token, is_command_position))
423    }
424
425    /// Add an abbreviation
426    pub fn add(&mut self, abbr: Abbreviation) {
427        if self.used_names.contains(&abbr.name) {
428            self.abbrs.retain(|a| a.name != abbr.name);
429        }
430        self.used_names.insert(abbr.name.clone());
431        self.abbrs.push(abbr);
432    }
433
434    /// Remove an abbreviation by name
435    pub fn remove(&mut self, name: &str) -> bool {
436        if self.used_names.remove(name) {
437            self.abbrs.retain(|a| a.name != name);
438            true
439        } else {
440            false
441        }
442    }
443
444    /// List all abbreviations
445    pub fn list(&self) -> &[Abbreviation] {
446        &self.abbrs
447    }
448}
449
450/// Expand abbreviations in a line at the current word
451pub fn expand_abbreviation(line: &str, cursor: usize) -> Option<(String, usize)> {
452    // Find the word at cursor
453    let before_cursor = &line[..cursor.min(line.len())];
454    let word_start = before_cursor
455        .rfind(char::is_whitespace)
456        .map(|i| i + 1)
457        .unwrap_or(0);
458    let word = &before_cursor[word_start..];
459
460    if word.is_empty() {
461        return None;
462    }
463
464    // Check if we're in command position
465    let is_command_position = before_cursor[..word_start].trim().is_empty()
466        || before_cursor[..word_start]
467            .trim()
468            .ends_with(|c| c == '|' || c == ';' || c == '&');
469
470    with_abbrs(|set| {
471        set.find_match(word, is_command_position).map(|abbr| {
472            let mut new_line = String::with_capacity(line.len() + abbr.replacement.len());
473            new_line.push_str(&line[..word_start]);
474            new_line.push_str(&abbr.replacement);
475            new_line.push_str(&line[cursor..]);
476            let new_cursor = word_start + abbr.replacement.len();
477            (new_line, new_cursor)
478        })
479    })
480}
481
482// ============================================================================
483// AUTOSUGGESTIONS
484// ============================================================================
485
486/// History-based autosuggestion
487pub struct Autosuggestion {
488    pub text: String,
489    pub is_from_history: bool,
490}
491
492impl Autosuggestion {
493    pub fn empty() -> Self {
494        Self {
495            text: String::new(),
496            is_from_history: false,
497        }
498    }
499
500    pub fn is_empty(&self) -> bool {
501        self.text.is_empty()
502    }
503}
504
505/// Generate autosuggestion from history
506pub fn autosuggest_from_history(line: &str, history: &[String]) -> Autosuggestion {
507    if line.is_empty() {
508        return Autosuggestion::empty();
509    }
510
511    let line_lower = line.to_lowercase();
512
513    // Search history in reverse (most recent first)
514    for entry in history.iter().rev() {
515        // Exact prefix match (case-sensitive)
516        if entry.starts_with(line) && entry.len() > line.len() {
517            return Autosuggestion {
518                text: entry[line.len()..].to_string(),
519                is_from_history: true,
520            };
521        }
522    }
523
524    // Case-insensitive prefix match
525    for entry in history.iter().rev() {
526        let entry_lower = entry.to_lowercase();
527        if entry_lower.starts_with(&line_lower) && entry.len() > line.len() {
528            return Autosuggestion {
529                text: entry[line.len()..].to_string(),
530                is_from_history: true,
531            };
532        }
533    }
534
535    Autosuggestion::empty()
536}
537
538/// Validate autosuggestion (check if command exists, paths valid, etc.)
539pub fn validate_autosuggestion(suggestion: &str, current_line: &str) -> bool {
540    if suggestion.is_empty() {
541        return false;
542    }
543
544    // Get the full command that would result
545    let full_line = format!("{}{}", current_line, suggestion);
546    let words: Vec<&str> = full_line.split_whitespace().collect();
547
548    if words.is_empty() {
549        return true;
550    }
551
552    let cmd = words[0];
553
554    // Check if command exists
555    if !command_exists(cmd) && !BUILTINS.contains(&cmd) && !KEYWORDS.contains(&cmd) {
556        // Check if it's a path
557        if !cmd.contains('/') || !std::path::Path::new(cmd).exists() {
558            return false;
559        }
560    }
561
562    true
563}
564
565// ============================================================================
566// KILLRING (Yank/Paste)
567// ============================================================================
568
569static KILLRING: LazyLock<Mutex<KillRing>> = LazyLock::new(|| Mutex::new(KillRing::new(100)));
570
571pub struct KillRing {
572    entries: Vec<String>,
573    max_size: usize,
574    yank_index: usize,
575}
576
577impl KillRing {
578    pub fn new(max_size: usize) -> Self {
579        Self {
580            entries: Vec::with_capacity(max_size),
581            max_size,
582            yank_index: 0,
583        }
584    }
585
586    /// Add text to killring
587    pub fn add(&mut self, text: String) {
588        if text.is_empty() {
589            return;
590        }
591        // Remove duplicates
592        self.entries.retain(|e| e != &text);
593        self.entries.insert(0, text);
594        if self.entries.len() > self.max_size {
595            self.entries.pop();
596        }
597        self.yank_index = 0;
598    }
599
600    /// Replace last entry (for consecutive kills)
601    pub fn replace(&mut self, text: String) {
602        if text.is_empty() {
603            return;
604        }
605        if self.entries.is_empty() {
606            self.add(text);
607        } else {
608            self.entries[0] = text;
609        }
610    }
611
612    /// Get current yank text
613    pub fn yank(&self) -> Option<&str> {
614        self.entries.get(self.yank_index).map(|s| s.as_str())
615    }
616
617    /// Rotate to next entry (yank-pop)
618    pub fn rotate(&mut self) -> Option<&str> {
619        if self.entries.is_empty() {
620            return None;
621        }
622        self.yank_index = (self.yank_index + 1) % self.entries.len();
623        self.yank()
624    }
625
626    /// Reset yank index
627    pub fn reset_yank(&mut self) {
628        self.yank_index = 0;
629    }
630}
631
632pub fn kill_add(text: String) {
633    KILLRING.lock().unwrap().add(text);
634}
635
636pub fn kill_replace(text: String) {
637    KILLRING.lock().unwrap().replace(text);
638}
639
640pub fn kill_yank() -> Option<String> {
641    KILLRING.lock().unwrap().yank().map(|s| s.to_string())
642}
643
644pub fn kill_yank_rotate() -> Option<String> {
645    KILLRING.lock().unwrap().rotate().map(|s| s.to_string())
646}
647
648// ============================================================================
649// COMMAND VALIDATION
650// ============================================================================
651
652/// Validate a command line for errors
653pub fn validate_command(line: &str) -> ValidationStatus {
654    if line.trim().is_empty() {
655        return ValidationStatus::Valid;
656    }
657
658    // Check for unclosed quotes
659    let mut in_single = false;
660    let mut in_double = false;
661    let mut escaped = false;
662
663    for c in line.chars() {
664        if escaped {
665            escaped = false;
666            continue;
667        }
668        match c {
669            '\\' => escaped = true,
670            '\'' if !in_double => in_single = !in_single,
671            '"' if !in_single => in_double = !in_double,
672            _ => {}
673        }
674    }
675
676    if in_single || in_double {
677        return ValidationStatus::Incomplete;
678    }
679
680    // Check for incomplete commands (trailing | or &&)
681    let trimmed = line.trim();
682    if trimmed.ends_with('|') || trimmed.ends_with("&&") || trimmed.ends_with("||") {
683        return ValidationStatus::Incomplete;
684    }
685
686    // Check for unclosed braces/brackets
687    let mut brace_count = 0i32;
688    let mut bracket_count = 0i32;
689    let mut paren_count = 0i32;
690
691    for c in line.chars() {
692        match c {
693            '{' => brace_count += 1,
694            '}' => brace_count -= 1,
695            '[' => bracket_count += 1,
696            ']' => bracket_count -= 1,
697            '(' => paren_count += 1,
698            ')' => paren_count -= 1,
699            _ => {}
700        }
701        if brace_count < 0 || bracket_count < 0 || paren_count < 0 {
702            return ValidationStatus::Invalid("Unmatched closing bracket".into());
703        }
704    }
705
706    if brace_count > 0 || bracket_count > 0 || paren_count > 0 {
707        return ValidationStatus::Incomplete;
708    }
709
710    ValidationStatus::Valid
711}
712
713#[derive(Debug, Clone, PartialEq)]
714pub enum ValidationStatus {
715    Valid,
716    Incomplete,
717    Invalid(String),
718}
719
720// ============================================================================
721// PRIVATE MODE
722// ============================================================================
723
724static PRIVATE_MODE: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
725
726pub fn is_private_mode() -> bool {
727    *PRIVATE_MODE.lock().unwrap()
728}
729
730pub fn set_private_mode(enabled: bool) {
731    *PRIVATE_MODE.lock().unwrap() = enabled;
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn test_highlight_command() {
740        let line = "ls -la /tmp";
741        let colors = highlight_shell(line);
742        assert!(!colors.is_empty());
743    }
744
745    #[test]
746    fn test_abbreviation() {
747        with_abbrs_mut(|set| {
748            set.add(Abbreviation::new("g", "g", "git", AbbrPosition::Command));
749            set.add(Abbreviation::new(
750                "ga",
751                "ga",
752                "git add",
753                AbbrPosition::Command,
754            ));
755        });
756
757        let result = expand_abbreviation("g", 1);
758        assert!(result.is_some());
759        let (new_line, _) = result.unwrap();
760        assert_eq!(new_line, "git");
761    }
762
763    #[test]
764    fn test_autosuggestion() {
765        let history = vec![
766            "ls -la".to_string(),
767            "git status".to_string(),
768            "git commit -m 'test'".to_string(),
769        ];
770
771        let suggestion = autosuggest_from_history("git s", &history);
772        assert!(!suggestion.is_empty());
773        assert_eq!(suggestion.text, "tatus");
774    }
775
776    #[test]
777    fn test_killring() {
778        kill_add("first".to_string());
779        kill_add("second".to_string());
780
781        assert_eq!(kill_yank(), Some("second".to_string()));
782        assert_eq!(kill_yank_rotate(), Some("first".to_string()));
783    }
784
785    #[test]
786    fn test_validation() {
787        assert_eq!(validate_command("echo hello"), ValidationStatus::Valid);
788        assert_eq!(
789            validate_command("echo \"unclosed"),
790            ValidationStatus::Incomplete
791        );
792        assert_eq!(validate_command("ls |"), ValidationStatus::Incomplete);
793    }
794}