Skip to main content

zsh/extensions/
fish_features.rs

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