Skip to main content

zsh/extensions/
syntax_highlight.rs

1//! Port of fish-shell's `highlight/highlight.rs` (vendor/fish/highlight/highlight.rs) —
2//! native command-line syntax highlighting.
3//!
4//! fish:1 — Functions for syntax highlighting.
5//!
6//! The zsh script plugins (zsh-syntax-highlighting, fast-syntax-highlighting) are
7//! script-level recreations of this fish engine; this ports the origin directly.
8//!
9//! zshrs substrate swaps (each cited at its site):
10//!   * fish AST visitor            → zshrs lexer token stream (`lex_line_tokens`, the
11//!     `inpush` + `LEXFLAGS_ZLE|LEXFLAGS_ACTIVE` + `ctxtlex` pattern of
12//!     zle_tricky.rs:1357-1445 / zsh Src/Zle/zle_tricky.c:1157-1445), with token spans
13//!     from the C word-offset arithmetic: start = zlemetall - wordbeg (lex.c:1886),
14//!     end = zlemetall + 1 - inbufct (lex.c:1884)
15//!   * `fish_color_*` env vars     → `$ZSH_HIGHLIGHT_STYLES[key]` (z-sy-h config compat),
16//!     parsed by `match_highlight` (prompt.rs:3660, zsh Src/prompt.c:2031)
17//!   * `TextFace`                  → packed `zattr` bitmap (zsh_h.rs:4295)
18//!   * abbreviations               → zsh aliases (`aliastab`)
19//!   * `builtin_exists`/`function::exists`/`path_get_path` → `builtintab`/`shfunctab`/
20//!     `cmdnamtab` + `findcmd`
21//!   * fish `%self`                → dropped (no zsh spelling)
22//!   * fish `$var[slice]`          → zsh `$var[subscript]`
23//!
24//! The quoting rules inside `color_string_internal` are re-derived for zsh: `'...'`
25//! (no escapes unless RC_QUOTES), `"..."` (`\\ \" \$ \`` + `$var`), `$'...'` (ANSI-C
26//! escapes — fish's \u/\x/octal validation applies HERE), backticks, bare `\X`.
27
28#![allow(non_snake_case)]
29#![allow(non_camel_case_types)]
30
31use crate::ported::exec::findcmd;
32use crate::ported::hashtable::{aliastab_lock, cmdnamtab_lock, reswdtab_lock};
33use crate::ported::lex::{
34    ctxtlex, incmdpos, inredir, tok, tokstr, untokenize, LEX_LEXFLAGS, LEX_WORDBEG,
35};
36use crate::ported::params::{gethparam, getsparam};
37use crate::ported::prompt::match_highlight;
38use crate::ported::utils::getshfunc;
39use crate::ported::zsh_h::{
40    isset, lextok, zattr, AMPER, AMPERBANG, AUTOCD, BAR_TOK, CASE, CLOBBER, DAMPER, DBAR,
41    DOUTANG, DOUTANGAMP, DOUTANGAMPBANG, DOUTANGBANG, DINANG, DINANGDASH, ENDINPUT, ENVARRAY,
42    ENVSTRING, INANGAMP, INANG_TOK, INOUTANG, INPAR_TOK, INTERACTIVECOMMENTS, IS_REDIROP,
43    LEXERR, LEXFLAGS_ACTIVE, LEXFLAGS_ZLE, NEWLIN, OUTANGAMP, OUTANGAMPBANG, OUTANGBANG,
44    OUTANG_TOK, OUTPAR_TOK, SEMI, SEPER, STRING_LEX, TYPESET,
45};
46use crate::zle_file_tester::{
47    expand_one_no_cmdsubst, FileTester, IsErr, IsFile, OperationContext, RedirectionMode,
48};
49use std::collections::{hash_map::Entry, HashMap};
50use std::sync::Mutex;
51
52/// fish:1306-1317 — Simple value type describing how a character should be highlighted.
53#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
54pub struct HighlightSpec {
55    pub foreground: HighlightRole,
56    pub background: HighlightRole,
57    pub valid_path: bool,
58    pub force_underline: bool,
59}
60
61impl HighlightSpec {
62    /// fish:43-45 — `new`.
63    pub fn new() -> Self {
64        Self::default()
65    }
66    /// fish:47-53 — `with_fg_bg`.
67    pub fn with_fg_bg(fg: HighlightRole, bg: HighlightRole) -> Self {
68        Self {
69            foreground: fg,
70            background: bg,
71            ..Default::default()
72        }
73    }
74    /// fish:55-57 — `with_fg`.
75    pub fn with_fg(fg: HighlightRole) -> Self {
76        Self::with_fg_bg(fg, HighlightRole::normal)
77    }
78    /// fish:59-61 — `with_bg`.
79    pub fn with_bg(bg: HighlightRole) -> Self {
80        Self::with_fg_bg(HighlightRole::normal, bg)
81    }
82    /// fish:63-65 — `with_both`.
83    pub fn with_both(role: HighlightRole) -> Self {
84        Self::with_fg_bg(role, role)
85    }
86}
87
88/// fish:1268-1304 — Describes the role of a span of text.
89#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
90#[repr(u8)]
91pub enum HighlightRole {
92    #[default]
93    normal, // normal text
94    error,   // error
95    command, // command
96    keyword,
97    statement_terminator, // process separator
98    param,                // command parameter (argument)
99    option,               // argument starting with "-", up to a "--"
100    comment,              // comment
101    search_match,         // search match
102    operat,               // operator
103    escape,               // escape sequences
104    quote,                // quoted string
105    redirection,          // redirection
106    autosuggestion,       // autosuggestion
107    selection,
108
109    // fish:1289-1303 — Pager support (kept for name parity; the zshrs completion
110    // menu may consume these later).
111    pager_progress,
112    pager_background,
113    pager_prefix,
114    pager_completion,
115    pager_description,
116    pager_secondary_background,
117    pager_secondary_prefix,
118    pager_secondary_completion,
119    pager_secondary_description,
120    pager_selected_background,
121    pager_selected_prefix,
122    pager_selected_completion,
123    pager_selected_description,
124}
125
126/// fish:692-693 — `ColorArray`: one `HighlightSpec` per character of the buffer.
127pub type ColorArray = Vec<HighlightSpec>;
128
129/// fish:1197-1228 — `get_highlight_var_name`, respelled for zsh: role →
130/// `$ZSH_HIGHLIGHT_STYLES` key (z-sy-h main-highlighter config surface, so existing
131/// user themes apply to the native engine unchanged). Autosuggestion is configured by
132/// `$ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE` (scalar) — special-cased in the resolver.
133fn get_highlight_style_key(role: HighlightRole) -> &'static str {
134    match role {
135        HighlightRole::normal => "default",
136        HighlightRole::error => "unknown-token",
137        HighlightRole::command => "command",
138        HighlightRole::keyword => "reserved-word",
139        HighlightRole::statement_terminator => "commandseparator",
140        HighlightRole::param => "default",
141        HighlightRole::option => "single-hyphen-option",
142        HighlightRole::comment => "comment",
143        HighlightRole::search_match => "history-search-match",
144        HighlightRole::operat => "globbing",
145        HighlightRole::escape => "back-dollar-quoted-argument",
146        HighlightRole::quote => "single-quoted-argument",
147        HighlightRole::redirection => "redirection",
148        HighlightRole::autosuggestion => "autosuggestion",
149        HighlightRole::selection => "selection",
150        // Pager roles have no z-sy-h key; resolver falls through to defaults.
151        _ => "default",
152    }
153}
154
155/// Built-in default style per role, used when `$ZSH_HIGHLIGHT_STYLES` doesn't override.
156/// These mirror the z-sy-h main-highlighter default palette so the native engine looks
157/// identical to the plugin it replaces (z-sy-h defaults: command/builtin/function/alias
158/// fg=green, unknown-token fg=red, reserved-word fg=yellow, comment fg=black+bold,
159/// single/double-quoted fg=yellow, dollar escapes fg=cyan, globbing fg=blue,
160/// path underline; zsh-autosuggestions default: fg=8).
161fn get_default_style(role: HighlightRole) -> &'static str {
162    match role {
163        HighlightRole::error => "fg=red",
164        HighlightRole::command | HighlightRole::keyword => match role {
165            HighlightRole::keyword => "fg=yellow",
166            _ => "fg=green",
167        },
168        HighlightRole::comment => "fg=black,bold",
169        HighlightRole::operat => "fg=blue",
170        HighlightRole::escape => "fg=cyan",
171        HighlightRole::quote => "fg=yellow",
172        HighlightRole::redirection => "none",
173        HighlightRole::autosuggestion => "fg=8",
174        HighlightRole::selection | HighlightRole::search_match => "standout",
175        _ => "none",
176    }
177}
178
179// fish:1230-1266 — Table used to fetch fallback highlights in case the specified one
180// wasn't set.
181fn get_fallback(role: HighlightRole) -> HighlightRole {
182    match role {
183        HighlightRole::normal
184        | HighlightRole::error
185        | HighlightRole::command
186        | HighlightRole::statement_terminator
187        | HighlightRole::param
188        | HighlightRole::search_match
189        | HighlightRole::comment
190        | HighlightRole::operat
191        | HighlightRole::escape
192        | HighlightRole::quote
193        | HighlightRole::redirection
194        | HighlightRole::autosuggestion
195        | HighlightRole::selection
196        | HighlightRole::pager_progress
197        | HighlightRole::pager_background
198        | HighlightRole::pager_prefix
199        | HighlightRole::pager_completion
200        | HighlightRole::pager_description => HighlightRole::normal,
201        HighlightRole::keyword => HighlightRole::command,
202        HighlightRole::option => HighlightRole::param,
203        HighlightRole::pager_secondary_background => HighlightRole::pager_background,
204        HighlightRole::pager_secondary_prefix | HighlightRole::pager_selected_prefix => {
205            HighlightRole::pager_prefix
206        }
207        HighlightRole::pager_secondary_completion | HighlightRole::pager_selected_completion => {
208            HighlightRole::pager_completion
209        }
210        HighlightRole::pager_secondary_description | HighlightRole::pager_selected_description => {
211            HighlightRole::pager_description
212        }
213        HighlightRole::pager_selected_background => HighlightRole::search_match,
214    }
215}
216
217/// fish:222-238 — `parse_text_face_for_highlight`, respelled: parse a z-sy-h style
218/// string ("fg=green,bold", "none", …) into a packed `zattr` via `match_highlight`
219/// (prompt.rs:3660). Returns None for empty/no-op specs so the role chain can fall
220/// through, mirroring fish's `get_unless_empty` + default-face check.
221fn parse_style_for_highlight(spec: &str) -> Option<zattr> {
222    if spec.is_empty() {
223        return None;
224    }
225    let (mask_on, _mask_off) = match_highlight(spec);
226    Some(mask_on)
227}
228
229/// Read `$ZSH_HIGHLIGHT_STYLES[key]`. The assoc arrives from `gethparam` as a flat
230/// key/value sequence.
231fn zsh_highlight_styles_get(key: &str) -> Option<String> {
232    let flat = gethparam("ZSH_HIGHLIGHT_STYLES")?;
233    let mut it = flat.chunks_exact(2);
234    it.find(|kv| kv[0] == key).map(|kv| kv[1].clone())
235}
236
237/// fish:131-138 — highlight_color_resolver_t resolves highlight specs (like "a
238/// command") to actual attributes. It maintains a cache with no invalidation
239/// mechanism. The lifetime of these should typically be one screen redraw.
240#[derive(Default)]
241pub struct HighlightColorResolver {
242    /// fish:136-137 — `cache`.
243    cache: HashMap<HighlightSpec, zattr>,
244}
245
246impl HighlightColorResolver {
247    /// fish:144-147 — `new`.
248    pub fn new() -> Self {
249        Default::default()
250    }
251    /// fish:148-162 — Return a packed attribute for a given highlight spec.
252    pub fn resolve_spec(&mut self, highlight: &HighlightSpec) -> zattr {
253        match self.cache.entry(*highlight) {
254            Entry::Occupied(e) => *e.get(),
255            Entry::Vacant(e) => {
256                let face = Self::resolve_spec_uncached(highlight);
257                e.insert(face);
258                face
259            }
260        }
261    }
262    /// fish:163-219 — `resolve_spec_uncached`.
263    pub fn resolve_spec_uncached(highlight: &HighlightSpec) -> zattr {
264        // fish:164-182 — role → [role, fallback, normal] chain, first configured wins.
265        let resolve_role = |role: HighlightRole| -> zattr {
266            let mut roles: &[HighlightRole] = &[role, get_fallback(role), HighlightRole::normal];
267            for i in [2, 1] {
268                if roles[i - 1] == roles[i] {
269                    roles = &roles[..i];
270                }
271            }
272            for &role in roles {
273                // Autosuggestion config lives in zsh-autosuggestions' scalar param.
274                let configured = if role == HighlightRole::autosuggestion {
275                    getsparam("ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE").filter(|s| !s.is_empty())
276                } else {
277                    zsh_highlight_styles_get(get_highlight_style_key(role))
278                };
279                if let Some(face) = configured.as_deref().and_then(parse_style_for_highlight) {
280                    return face;
281                }
282            }
283            // No user config anywhere in the chain: built-in default palette.
284            parse_style_for_highlight(get_default_style(role)).unwrap_or(0)
285        };
286        let mut face = resolve_role(highlight.foreground);
287
288        // fish:185-194 — background merge is a no-op here: zattr carries fg+bg in one
289        // bitmap and z-sy-h specs already say "bg=…" inline; resolve background role
290        // only when it differs and OR its bg bits in.
291        if highlight.background != highlight.foreground {
292            use crate::ported::zsh_h::{TXTBGCOLOUR, TXT_ATTR_BG_COL_MASK};
293            let bg_face = resolve_role(highlight.background);
294            face |= bg_face & (TXTBGCOLOUR as zattr | TXT_ATTR_BG_COL_MASK);
295        }
296
297        // fish:196-211 — valid_path modifier: merge the `path` style (z-sy-h key;
298        // default underline, matching fish_color_valid_path --underline).
299        if highlight.valid_path {
300            let path_spec = zsh_highlight_styles_get("path").unwrap_or_default();
301            let merged = if path_spec.is_empty() {
302                parse_style_for_highlight("underline")
303            } else {
304                parse_style_for_highlight(&path_spec)
305            };
306            if let Some(m) = merged {
307                face |= m;
308            }
309        }
310
311        // fish:213-216 — force_underline.
312        if highlight.force_underline {
313            face |= crate::ported::zsh_h::TXTUNDERLINE as zattr;
314        }
315
316        face
317    }
318}
319
320/// fish:68-91 — Given a string and list of colors of the same size, return the string
321/// with ANSI escape sequences representing the colors.
322pub fn colorize(text: &str, colors: &[HighlightSpec]) -> Vec<u8> {
323    let chars: Vec<char> = text.chars().collect();
324    assert_eq!(colors.len(), chars.len());
325    let mut rv = HighlightColorResolver::new();
326    let mut out: Vec<u8> = Vec::new();
327
328    let mut last_color: Option<HighlightSpec> = None;
329    for (i, &c) in chars.iter().enumerate() {
330        let color = colors[i];
331        if Some(color) != last_color {
332            let face = rv.resolve_spec(&color);
333            out.extend_from_slice(zattr_to_sgr(face).as_bytes());
334            last_color = Some(color);
335        }
336        // fish:84-86 — reset before a trailing newline.
337        if i + 1 == chars.len() && c == '\n' {
338            out.extend_from_slice(b"\x1b[0m");
339        }
340        let mut buf = [0u8; 4];
341        out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
342    }
343    out.extend_from_slice(b"\x1b[0m"); // fish:89
344    out
345}
346
347/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT FISH COUNTERPART !!!
348/// fish routes attribute output through its Outputter/terminfo stack; zshrs's
349/// ZLE painter consumes `zattr` directly (zle_refresh.rs `to_zattr`/`zwcputc`),
350/// so this SGR string form exists only for `colorize` (batch/CLI output).
351fn zattr_to_sgr(attr: zattr) -> String {
352    use crate::ported::zsh_h::{
353        TXTBGCOLOUR, TXTBOLDFACE, TXTFGCOLOUR, TXTSTANDOUT, TXTUNDERLINE,
354        TXT_ATTR_BG_COL_SHIFT, TXT_ATTR_FG_COL_SHIFT,
355    };
356    let mut s = String::from("\x1b[0");
357    if attr & TXTBOLDFACE as zattr != 0 {
358        s.push_str(";1");
359    }
360    if attr & TXTSTANDOUT as zattr != 0 {
361        s.push_str(";7");
362    }
363    if attr & TXTUNDERLINE as zattr != 0 {
364        s.push_str(";4");
365    }
366    if attr & TXTFGCOLOUR as zattr != 0 {
367        let col = (attr >> TXT_ATTR_FG_COL_SHIFT) & 0xffffff;
368        s.push_str(&format!(";38;5;{}", col));
369    }
370    if attr & TXTBGCOLOUR as zattr != 0 {
371        let col = (attr >> TXT_ATTR_BG_COL_SHIFT) & 0xffffff;
372        s.push_str(&format!(";48;5;{}", col));
373    }
374    s.push('m');
375    s
376}
377
378/// fish:93-113 — Perform syntax highlighting for the shell commands in buff. The
379/// result is stored in the color array as a HighlightSpec for each character in buff.
380///
381/// buffstr: the buffer (metafied, as the ZLE metaline) on which to perform syntax
382/// highlighting; ctx: cancellation check; io_ok: if set, allow IO which may block —
383/// e.g. invalid commands may be detected; cursor: cursor position in the commandline.
384pub fn highlight_shell(
385    buff: &str,
386    color: &mut Vec<HighlightSpec>,
387    ctx: &OperationContext,
388    io_ok: bool,
389    cursor: Option<usize>,
390) {
391    // fish:110 — get_pwd_slash.
392    let working_directory = getsparam("PWD").unwrap_or_else(|| ".".to_owned());
393    let mut highlighter = Highlighter::new(buff, cursor, ctx, working_directory, io_ok);
394    *color = highlighter.highlight();
395}
396
397/// fish:114-129 — `highlight_and_colorize`.
398pub fn highlight_and_colorize(text: &str, ctx: &OperationContext) -> Vec<u8> {
399    let mut colors = Vec::new();
400    highlight_shell(text, &mut colors, ctx, /*io_ok=*/ false, /*cursor=*/ None);
401    colorize(text, &colors)
402}
403
404/// fish parse_constants.rs `StatementDecoration` — the zsh precommand modifiers that
405/// restrict what the following command word may resolve to.
406#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
407pub enum StatementDecoration {
408    #[default]
409    None_,
410    Command, // `command cmd` / `exec cmd` — externals only
411    Builtin, // `builtin cmd` — builtins only
412    Exec,
413}
414
415/// Per-process command-validity cache. `findcmd` walks the whole $PATH on
416/// every miss, and while a command is being typed EVERY prefix is a miss
417/// ("g", "gi", "git" …) — uncached, that is a full PATH stat-walk per
418/// keystroke. fish eats this cost on a background thread; the synchronous
419/// zshrs pass must not. Fingerprinted by $PATH so `rehash`-style changes
420/// invalidate naturally; bounded so a pathological session can't grow it.
421static CMD_VALID_CACHE: Mutex<Option<(String, HashMap<(String, u8), bool>)>> = Mutex::new(None);
422const CMD_VALID_CACHE_MAX: usize = 8192;
423
424pub fn command_is_valid_cached(
425    cmd: &str,
426    decoration: StatementDecoration,
427    working_directory: &str,
428) -> bool {
429    // The table/PATH verdict is cacheable (fingerprinted by $PATH); the
430    // implicit-cd branch depends on the cwd and stays uncached — it is a
431    // single stat, the PATH walk is the expensive part.
432    let path_now = getsparam("PATH").unwrap_or_default();
433    let key = (cmd.to_owned(), decoration as u8);
434    let cached: Option<bool> = {
435        let mut guard = CMD_VALID_CACHE.lock().unwrap();
436        match guard.as_mut() {
437            Some((path, map)) if *path == path_now => map.get(&key).copied(),
438            _ => {
439                *guard = Some((path_now, HashMap::new()));
440                None
441            }
442        }
443    };
444    let tables_valid = cached.unwrap_or_else(|| {
445        let v = command_is_valid_tables(cmd, decoration);
446        let mut guard = CMD_VALID_CACHE.lock().unwrap();
447        if let Some((_, map)) = guard.as_mut() {
448            if map.len() >= CMD_VALID_CACHE_MAX {
449                map.clear();
450            }
451            map.insert(key, v);
452        }
453        v
454    });
455    if tables_valid {
456        return true;
457    }
458    // fish:292-295 — Implicit cd (zsh: AUTO_CD), uncached (cwd-relative).
459    if decoration == StatementDecoration::None_ && isset(AUTOCD) {
460        let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
461        return std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false);
462    }
463    false
464}
465
466/// fish:240-299 — `command_is_valid`.
467pub fn command_is_valid(
468    cmd: &str,
469    decoration: StatementDecoration,
470    working_directory: &str,
471) -> bool {
472    if command_is_valid_tables(cmd, decoration) {
473        return true;
474    }
475    // fish:292-295 — Implicit cd (zsh: AUTO_CD); disabled by `command`/
476    // `builtin`/`exec` decorations (fish:252-267 implicit_cd_ok).
477    if decoration == StatementDecoration::None_ && isset(AUTOCD) {
478        let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
479        if std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false) {
480            return true;
481        }
482    }
483    // fish:297-298 — Return what we got.
484    false
485}
486
487/// fish:246-290 — the table/PATH checks of `command_is_valid` (everything except
488/// the cwd-dependent implicit-cd branch, split out so the cache can hold them).
489fn command_is_valid_tables(cmd: &str, decoration: StatementDecoration) -> bool {
490    // fish:246-267 — Determine which types we check, based on the decoration.
491    let mut builtin_ok = true;
492    let mut function_ok = true;
493    // fish's abbreviations are zsh's aliases.
494    let mut alias_ok = true;
495    let mut command_ok = true;
496    if matches!(
497        decoration,
498        StatementDecoration::Command | StatementDecoration::Exec
499    ) {
500        builtin_ok = false;
501        function_ok = false;
502        alias_ok = false;
503        command_ok = true;
504    } else if decoration == StatementDecoration::Builtin {
505        builtin_ok = true;
506        function_ok = false;
507        alias_ok = false;
508        command_ok = false;
509    }
510
511    // fish:269-270 — Check them.
512    let mut is_valid = false;
513
514    // fish:272-275 — Builtins. (Reserved words resolve at the lexer level and never
515    // reach here as STRING tokens, so no reswdtab check is needed.)
516    if !is_valid && builtin_ok {
517        is_valid = crate::ported::builtin::createbuiltintable().contains_key(cmd);
518    }
519
520    // fish:277-280 — Functions.
521    if !is_valid && function_ok {
522        is_valid = getshfunc(cmd).is_some();
523    }
524
525    // fish:282-285 — Aliases (fish: abbreviations).
526    if !is_valid && alias_ok {
527        is_valid = aliastab_lock()
528            .read()
529            .map(|t| t.get(cmd).is_some())
530            .unwrap_or(false);
531    }
532
533    // fish:287-290 — Regular commands: hashed table first, then a PATH walk.
534    if !is_valid && command_ok {
535        is_valid = cmdnamtab_lock()
536            .read()
537            .map(|t| t.get(cmd).is_some())
538            .unwrap_or(false)
539            || findcmd(cmd, 0, 0).is_some();
540    }
541
542    is_valid
543}
544
545/// fish:301-308 — `has_expand_reserved`: does the string still carry expansion
546/// markers? zsh spelling: any lexer token char left in the ITOK range.
547fn has_expand_reserved(s: &str) -> bool {
548    s.chars().any(|wc| ('\u{84}'..='\u{a1}').contains(&wc))
549}
550
551/// fish:310-341 — Parse a command line. Return the first command, and the first
552/// argument to that command (as a string), if any. This is used to validate
553/// autosuggestions. fish parses an AST; zshrs walks the same token stream the
554/// highlighter uses (continue_after_error + accept_incomplete are what
555/// LEXFLAGS_ACTIVE provides).
556pub fn autosuggest_parse_command(buff: &str) -> Option<(String, String)> {
557    let toks = lex_line_tokens(buff);
558    let mut cmd: Option<String> = None;
559    let mut arg = String::new(); // fish:329
560    for t in &toks {
561        if t.tok == STRING_LEX {
562            match &cmd {
563                None if t.cmdpos => {
564                    // fish:328 — expand the command word.
565                    let text = t.clean_text();
566                    let mut expanded = t.text.clone().unwrap_or_default();
567                    if expand_one_no_cmdsubst(&mut expanded) && !expanded.is_empty() {
568                        cmd = Some(expanded);
569                    } else {
570                        cmd = Some(text);
571                    }
572                }
573                None => (),
574                Some(_) => {
575                    // fish:330-335 — Check if the first argument or redirection is,
576                    // in fact, an argument.
577                    if !t.in_redir {
578                        arg = t.clean_text();
579                    }
580                    break;
581                }
582            }
583        } else if cmd.is_some() {
584            break; // separator/redirection ends the first statement's argument scan
585        }
586    }
587    cmd.map(|c| (c, arg)) // fish:337
588}
589
590/// fish:342-345 — `is_veritable_cd`: it's really `cd`, not something wrapping cd
591/// (fish checks the completion wrap map; the zsh spelling is an alias named cd).
592pub fn is_veritable_cd(expanded_command: &str) -> bool {
593    expanded_command == "cd"
594        && aliastab_lock()
595            .read()
596            .map(|t| t.get("cd").is_none())
597            .unwrap_or(true)
598}
599
600/// fish:347-356 — Given an item from the history which is a proposed autosuggestion,
601/// return whether the autosuggestion is valid. It may not be valid if e.g. it is
602/// attempting to cd into a directory which does not exist.
603///
604/// zsh adaptation: fish history items carry `required_paths` metadata; zsh history
605/// has none, so callers pass an empty slice and only the cd/command checks apply.
606pub fn autosuggest_validate_from_history(
607    item_commandline: &str,
608    required_paths: &[String],
609    working_directory: &str,
610    ctx: &OperationContext,
611) -> bool {
612    // fish:357 — background-thread assertion dropped (synchronous compute).
613    // fish:359-364 — the multi-command suggested_range trim is handled by the caller
614    // (zshrs suggests whole history lines only).
615
616    // fish:366-372 — Parse the string.
617    let Some((parsed_command, mut cd_dir)) = autosuggest_parse_command(item_commandline) else {
618        // This is for autosuggestions which are not decorated commands, e.g. function
619        // declarations.
620        return true;
621    };
622
623    // fish:374-390 — We handle cd specially.
624    if is_veritable_cd(&parsed_command) && !cd_dir.is_empty() {
625        if expand_one_no_cmdsubst(&mut cd_dir) {
626            if "--help".starts_with(&cd_dir) || "-h".starts_with(&cd_dir) {
627                // fish:379-383 — cd --help is always valid.
628                return true;
629            } else {
630                // fish:384-389 — Check the directory target, respecting CDPATH.
631                // Permit the autosuggestion if the path is valid and not our directory.
632                return crate::zle_file_tester::is_potential_cd_path(
633                    &cd_dir,
634                    /*at_cursor=*/ false,
635                    working_directory,
636                    ctx,
637                    Default::default(),
638                );
639            }
640        }
641    }
642
643    // fish:392-398 — Not handled specially. Is the command valid?
644    let cmd_ok =
645        command_is_valid_cached(&parsed_command, StatementDecoration::None_, working_directory);
646    if !cmd_ok {
647        return false;
648    }
649
650    // fish:400-403 — Did the historical command have arguments that look like paths,
651    // which aren't paths now?
652    if !required_paths.is_empty() {
653        let tester = FileTester::new(working_directory.to_owned(), ctx);
654        if !required_paths.iter().all(|p| tester.test_path(p, false)) {
655            return false;
656        }
657    }
658
659    true // fish:405
660}
661
662/// zsh `$var` name characters (fish:common valid_var_name_char, zsh spelling:
663/// alphanumeric or underscore — Src/params.c iident).
664fn valid_var_name_char(c: char) -> bool {
665    c.is_alphanumeric() || c == '_'
666}
667
668fn valid_var_name(s: &str) -> bool {
669    !s.is_empty()
670        && !s.starts_with(|c: char| c.is_ascii_digit())
671        && s.chars().all(valid_var_name_char)
672}
673
674/// zsh single-character special parameters ($?, $#, $$, $!, $@, $*, $-, $0..$9…).
675fn is_special_param_char(c: char) -> bool {
676    matches!(c, '?' | '#' | '$' | '!' | '@' | '*' | '-' | '_') || c.is_ascii_digit()
677}
678
679/// fish:408-471 — Highlights the variable starting with '$', setting colors within
680/// the 'colors' array. Returns the number of characters consumed.
681///
682/// zsh respelling: `$name`, `$name[subscript]`, `${...}` (balanced), single-char
683/// specials. fish's `$$var` chain and slice loop map to zsh's subscript span.
684fn color_variable(inp: &[char], colors: &mut [HighlightSpec]) -> usize {
685    assert_eq!(inp[0], '$');
686
687    let at = |i: usize| -> char { inp.get(i).copied().unwrap_or('\0') };
688
689    // fish:413-429 — Handle an initial run of $s.
690    let mut idx = 0;
691    let mut dollar_count = 0;
692    while at(idx) == '$' {
693        // Our color depends on the next char.
694        let next = at(idx + 1);
695        if next == '$' || valid_var_name_char(next) || is_special_param_char(next) {
696            colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
697        } else if next == '(' || next == '{' || next == '\'' {
698            // zsh: $(cmdsub), ${param}, $'ansi-quote' — the '$' is an operator and the
699            // construct is handled by the caller / string scanner.
700            colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
701            return idx + 1;
702        } else {
703            colors[idx] = HighlightSpec::with_fg(HighlightRole::error);
704        }
705        idx += 1;
706        dollar_count += 1;
707    }
708
709    // Single-char special param ($?, $#, …) — consume exactly one char.
710    if idx == dollar_count && !valid_var_name_char(at(idx)) && is_special_param_char(at(idx)) {
711        colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
712        return idx + 1;
713    }
714
715    // fish:431-445 — Handle a sequence of variable characters.
716    // It may contain an escaped newline - see fish#8444.
717    loop {
718        if valid_var_name_char(at(idx)) {
719            colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
720            idx += 1;
721        } else if at(idx) == '\\' && at(idx + 1) == '\n' {
722            colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
723            idx += 1;
724            colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
725            idx += 1;
726        } else {
727            break;
728        }
729    }
730
731    // fish:447-469 — Handle a subscript (fish: slice), up to dollar_count of them.
732    // Note that we currently don't do any validation of the subscript's contents.
733    for _slice_count in 0..dollar_count {
734        match subscript_length(&inp[idx..]) {
735            Some(slice_len) if slice_len > 0 => {
736                colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
737                colors[idx + slice_len - 1] = HighlightSpec::with_fg(HighlightRole::operat);
738                idx += slice_len;
739            }
740            Some(_slice_len) => {
741                // not a subscript
742                break;
743            }
744            None => {
745                // fish:460-467 — Syntax error: color the variable + the subscript
746                // start red.
747                colors[..=idx].fill(HighlightSpec::with_fg(HighlightRole::error));
748                break;
749            }
750        }
751    }
752    idx
753}
754
755/// fish parse_util `slice_length`, zsh respelling: length of a balanced
756/// `[...]` subscript starting at input, 0 if input doesn't open one, None if
757/// unbalanced.
758fn subscript_length(inp: &[char]) -> Option<usize> {
759    if inp.first() != Some(&'[') {
760        return Some(0);
761    }
762    let mut depth = 0usize;
763    for (i, &c) in inp.iter().enumerate() {
764        match c {
765            '[' => depth += 1,
766            ']' => {
767                depth -= 1;
768                if depth == 0 {
769                    return Some(i + 1);
770                }
771            }
772            _ => (),
773        }
774    }
775    None
776}
777
778/// fish:473-691 — This function is a disaster badly in need of refactoring (fish's
779/// words, kept for the record). It colors an argument or command, without regard to
780/// command substitutions. Quoting rules are zsh's; structure is fish's.
781pub fn color_string_internal(
782    buffstr: &str,
783    base_color: HighlightSpec,
784    colors: &mut [HighlightSpec],
785) {
786    // fish:476-485 — Clarify what we expect.
787    assert!(
788        [
789            HighlightSpec::with_fg(HighlightRole::param),
790            HighlightSpec::with_fg(HighlightRole::option),
791            HighlightSpec::with_fg(HighlightRole::command)
792        ]
793        .contains(&base_color),
794        "Unexpected base color"
795    );
796    let chars: Vec<char> = buffstr.chars().collect();
797    let buff_len = chars.len();
798    colors.fill(base_color);
799
800    // fish:489-493 — fish's %self special-case has no zsh spelling; dropped.
801
802    #[derive(Eq, PartialEq)]
803    enum Mode {
804        unquoted,
805        single_quoted,
806        double_quoted,
807        dollar_quoted, // zsh $'...' — fish's \u/\x/octal validation applies here
808        backtick,      // zsh `...`
809    }
810    let mut mode = Mode::unquoted;
811    let mut unclosed_quote_offset = None;
812    let mut bracket_count = 0;
813    let mut in_pos = 0;
814    while in_pos < buff_len {
815        let c = chars[in_pos];
816        match mode {
817            Mode::unquoted => {
818                if c == '\\' {
819                    // fish:509-593 — bare backslash escape. zsh unquoted `\X` quotes
820                    // X literally (no \n/\t expansion outside $'…'), so color the
821                    // pair as escape; a trailing lone backslash is a continuation.
822                    let backslash_pos = in_pos;
823                    let fill_end = if in_pos + 1 < buff_len {
824                        in_pos + 2
825                    } else {
826                        in_pos + 1
827                    };
828                    colors[backslash_pos..fill_end]
829                        .fill(HighlightSpec::with_fg(HighlightRole::escape));
830                    in_pos += 1; // skip the escaped char; loop tail adds one more
831                } else {
832                    // fish:594-634 — Not a backslash.
833                    match c {
834                        '~' if in_pos == 0 => {
835                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
836                        }
837                        '$' if chars.get(in_pos + 1) == Some(&'\'') => {
838                            // zsh $'...' — enter dollar-quote mode; color both opener
839                            // chars as quote.
840                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
841                            colors[in_pos + 1] = HighlightSpec::with_fg(HighlightRole::quote);
842                            unclosed_quote_offset = Some(in_pos);
843                            in_pos += 1;
844                            mode = Mode::dollar_quoted;
845                        }
846                        '$' => {
847                            assert!(in_pos < buff_len);
848                            in_pos += color_variable(&chars[in_pos..], &mut colors[in_pos..]);
849                            // fish:603-604 — Subtract one to account for the upcoming
850                            // loop increment.
851                            in_pos -= 1;
852                        }
853                        '`' => {
854                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
855                            unclosed_quote_offset = Some(in_pos);
856                            mode = Mode::backtick;
857                        }
858                        '?' | '*' | '(' | ')' => {
859                            // fish:606-611 — globs and grouping.
860                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
861                        }
862                        '{' => {
863                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
864                            bracket_count += 1;
865                        }
866                        '}' => {
867                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
868                            bracket_count -= 1;
869                        }
870                        ',' if bracket_count > 0 => {
871                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
872                        }
873                        '[' | ']' => {
874                            // zsh char classes / subscripts glob
875                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
876                        }
877                        '\'' => {
878                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
879                            unclosed_quote_offset = Some(in_pos);
880                            mode = Mode::single_quoted;
881                        }
882                        '"' => {
883                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
884                            unclosed_quote_offset = Some(in_pos);
885                            mode = Mode::double_quoted;
886                        }
887                        _ => (), // fish:633 — we ignore all other characters
888                    }
889                }
890            }
891            // fish:637-653 — single quoted string, i.e 'foo'. zsh: NO escapes inside
892            // single quotes; `''` inside is a literal ' only under RC_QUOTES (handled
893            // as close+reopen, which colors identically).
894            Mode::single_quoted => {
895                colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
896                if c == '\'' {
897                    mode = Mode::unquoted;
898                }
899            }
900            // fish:654-682 — double quoted string, i.e. "foo".
901            Mode::double_quoted => {
902                // fish:656-660 — subscripts are colored in advance, past `in_pos`,
903                // and we don't want to overwrite that.
904                if colors[in_pos] == base_color {
905                    colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
906                }
907                match c {
908                    '"' => {
909                        mode = Mode::unquoted;
910                    }
911                    '\\' if in_pos + 1 < buff_len => {
912                        // fish:665-674 — zsh dquote escapes: \\ \" \$ \`.
913                        let escaped_char = chars[in_pos + 1];
914                        if matches!(escaped_char, '\\' | '"' | '$' | '`' | '\n') {
915                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::escape);
916                            colors[in_pos + 1] = HighlightSpec::with_fg(HighlightRole::escape);
917                            in_pos += 1; // skip over backslash
918                        }
919                    }
920                    '$' => {
921                        in_pos += color_variable(&chars[in_pos..], &mut colors[in_pos..]);
922                        // fish:677-678 — Subtract one to account for the upcoming
923                        // increment in the loop.
924                        in_pos -= 1;
925                    }
926                    _ => (), // fish:680 — we ignore all other characters
927                }
928            }
929            // zsh $'...' — ANSI-C quoting. fish's escape-sequence validation
930            // (fish:538-591) applies in THIS mode for zsh.
931            Mode::dollar_quoted => {
932                colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
933                if c == '\\' && in_pos + 1 < buff_len {
934                    let mut fill_color = HighlightRole::escape;
935                    let backslash_pos = in_pos;
936                    let mut fill_end = backslash_pos;
937                    in_pos += 1;
938                    let escaped_char = chars[in_pos];
939                    if "abcefnrtv\\'\"?".contains(escaped_char) {
940                        fill_end = in_pos + 1;
941                    } else if "uUxX01234567".contains(escaped_char) {
942                        // fish:538-591 — numeric escapes with base/width/max
943                        // validation.
944                        let mut res: u32 = 0;
945                        let mut chars_max = 2;
946                        let mut base = 16;
947                        let mut max_val = 0x7f_u32; // ASCII_MAX
948
949                        match escaped_char {
950                            'u' => {
951                                chars_max = 4;
952                                max_val = 0xFFFF; // UCS2_MAX
953                                in_pos += 1;
954                            }
955                            'U' => {
956                                chars_max = 8;
957                                // fish:551-553 — Don't exceed the largest Unicode
958                                // code point - see fish#1107.
959                                max_val = 0x10FFFF;
960                                in_pos += 1;
961                            }
962                            'x' | 'X' => {
963                                max_val = 0xFF;
964                                in_pos += 1;
965                            }
966                            _ => {
967                                // fish:560-564 — a digit like \12.
968                                base = 8;
969                                chars_max = 3;
970                            }
971                        }
972
973                        // fish:567-577 — Consume.
974                        for _i in 0..chars_max {
975                            if in_pos == buff_len {
976                                break;
977                            }
978                            let Some(d) = chars[in_pos].to_digit(base) else {
979                                break;
980                            };
981                            res = res.saturating_mul(base).saturating_add(d);
982                            in_pos += 1;
983                        }
984                        // fish:578-581 — in_pos is now at the first character that
985                        // could not be converted (or buff_len).
986                        fill_end = in_pos;
987
988                        // fish:583-586 — It's an error if we exceeded the max value.
989                        if res > max_val {
990                            fill_color = HighlightRole::error;
991                        }
992
993                        // fish:588-590 — Subtract one so the loop increment moves to
994                        // the next character.
995                        in_pos -= 1;
996                    } else {
997                        fill_end = in_pos + 1;
998                    }
999                    if fill_end > backslash_pos {
1000                        colors[backslash_pos..fill_end.min(buff_len)]
1001                            .fill(HighlightSpec::with_fg(fill_color));
1002                    } else {
1003                        colors[backslash_pos] = HighlightSpec::with_fg(fill_color);
1004                        colors[in_pos.min(buff_len - 1)] = HighlightSpec::with_fg(fill_color);
1005                    }
1006                } else if c == '\'' {
1007                    mode = Mode::unquoted;
1008                }
1009            }
1010            // zsh `...` backquote command substitution: color content as quote-ish,
1011            // delimiters as operators.
1012            Mode::backtick => {
1013                if c == '`' {
1014                    colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
1015                    mode = Mode::unquoted;
1016                } else {
1017                    colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
1018                }
1019            }
1020        }
1021        in_pos += 1;
1022    }
1023
1024    // fish:687-690 — Error on unclosed quotes.
1025    if mode != Mode::unquoted {
1026        colors[unclosed_quote_offset.unwrap()] = HighlightSpec::with_fg(HighlightRole::error);
1027    }
1028}
1029
1030// ---------------------------------------------------------------------------
1031// Token-stream driver — the zshrs replacement for fish's AST visit.
1032// ---------------------------------------------------------------------------
1033
1034/// One lexed token with its source span. Spans are char indices into the (metafied)
1035/// input line.
1036#[derive(Clone, Debug)]
1037pub struct TokSpan {
1038    pub tok: lextok,
1039    pub text: Option<String>,
1040    pub start: usize,
1041    pub end: usize,
1042    /// incmdpos() sampled BEFORE this token was lexed — is it in command position?
1043    pub cmdpos: bool,
1044    /// inredir() sampled before this token — is it a redirection target?
1045    pub in_redir: bool,
1046}
1047
1048impl TokSpan {
1049    /// Untokenized, quote-null-stripped text (what the user "meant").
1050    pub fn clean_text(&self) -> String {
1051        let mut s = self.text.clone().unwrap_or_default();
1052        crate::ported::glob::remnulargs(&mut s);
1053        untokenize(&s)
1054    }
1055}
1056
1057/// Lex a (metafied) line into spanned tokens, tolerating in-progress constructs.
1058///
1059/// Direct reuse of the completion machinery's lex pattern —
1060/// zle_tricky.rs:1357-1445 (zsh Src/Zle/zle_tricky.c:1157-1290):
1061/// `zcontext_save` → `LEXFLAGS_ZLE|LEXFLAGS_ACTIVE` → `inpush(dupstrspace(line))` →
1062/// `strinbeg(0)` → `ctxtlex()` loop → `strinend`/`inpop` → `zcontext_restore`.
1063///
1064/// Span arithmetic is the C completion formulas with addedx = 0:
1065///   start = zlemetall - wordbeg          (lex.c:1886 `nwb`)
1066///   end   = zlemetall + 1 - inbufct      (lex.c:1884 `nwe`)
1067///
1068/// ZLEMETACS is parked past every reachable `nwe` while lexing so `gotword`
1069/// (lex.rs:3042, lex.c:1882-1895) never clears LEXFLAGS_ZLE mid-line — wordbeg must
1070/// keep updating for EVERY token, not just up to a completion cursor. The completion
1071/// globals are saved/restored around the walk.
1072pub fn lex_line_tokens(line: &str) -> Vec<TokSpan> {
1073    use crate::ported::zle::compcore::{ADDEDX, ZLEMETACS, ZLEMETALL};
1074    use std::sync::atomic::Ordering;
1075
1076    let ll = line.chars().count() as i32;
1077    let mut out: Vec<TokSpan> = Vec::new();
1078
1079    // Save completion globals we're about to borrow.
1080    let saved_cs = ZLEMETACS.load(Ordering::SeqCst);
1081    let saved_ll = ZLEMETALL.load(Ordering::SeqCst);
1082    let saved_addedx = ADDEDX.load(Ordering::SeqCst);
1083
1084    crate::ported::context::zcontext_save(); // c:1169
1085    // LEX_UNGET_BUF isolation: hgetc drains this Rust-only side channel
1086    // BEFORE every input frame (lex.rs hgetc), and `$(...)` bodies use
1087    // it as a deliberate cross-context handoff — so zcontext_save
1088    // leaves it alone. This walk must isolate it manually: the
1089    // suspended outer parse's ungot chars must not be consumed as line
1090    // content, and the walk's own ungets must not leak back out.
1091    let saved_unget: std::collections::VecDeque<char> =
1092        crate::ported::lex::LEX_UNGET_BUF.with_borrow_mut(std::mem::take);
1093    ZLEMETALL.store(ll, Ordering::SeqCst);
1094    ZLEMETACS.store(ll + 5, Ordering::SeqCst); // park beyond max nwe = ll + 1
1095    ADDEDX.store(0, Ordering::SeqCst);
1096
1097    // c:1170 — see zle_tricky.rs:1369-1375 for why ACTIVE is OR'd in (tolerate
1098    // unterminated quote/backtick/brace while the user is mid-word).
1099    LEX_LEXFLAGS.set(LEXFLAGS_ZLE | LEXFLAGS_ACTIVE);
1100    crate::ported::input::inpush(&crate::ported::zle::zle_tricky::dupstrspace(line), 0, None); // c:1171
1101    crate::ported::hist::strinbeg(0); // c:1172
1102
1103    // No-progress guard: on an unterminated construct the lexer can re-yield
1104    // the same (retyped) LEXERR token without consuming input — inbufct stops
1105    // moving and the loop would spin, accumulating TokSpans without bound
1106    // (observed as a runaway-memory hang on `echo 'unterminated`). C's
1107    // completion loop never hits this because gotword ends its walk at the
1108    // cursor; this walk covers the whole line, so it must self-terminate.
1109    let mut prev_inbufct = i32::MIN;
1110    loop {
1111        let cmdpos_before = incmdpos();
1112        let inredir_before = inredir();
1113        ctxtlex(); // c:1213
1114
1115        // c:1215-1227 — LEXERR fixup: odd Snull/Dnull count means an unterminated
1116        // quote; treat as STRING so the in-progress word still gets colored.
1117        let mut tokv = tok();
1118        let was_lexerr = tokv == LEXERR;
1119        if tokv == LEXERR {
1120            match tokstr() {
1121                None => break,
1122                Some(ts) => {
1123                    use crate::ported::zsh_h::{Dnull, Snull};
1124                    let jcnt = ts.chars().filter(|&c| c == Snull || c == Dnull).count();
1125                    if jcnt & 1 == 1 {
1126                        tokv = STRING_LEX;
1127                    }
1128                }
1129            }
1130        }
1131
1132        if tokv == ENDINPUT {
1133            break; // c:1273
1134        }
1135
1136        let inbufct = crate::ported::input::inbufct.with(|c| c.get());
1137        let wordbeg = LEX_WORDBEG.get();
1138        let start = (ll - wordbeg).clamp(0, ll) as usize; // lex.c:1886
1139        let end = (ll + 1 - inbufct).clamp(start as i32, ll) as usize; // lex.c:1884
1140
1141        let no_progress = inbufct == prev_inbufct;
1142        prev_inbufct = inbufct;
1143
1144        out.push(TokSpan {
1145            tok: tokv,
1146            text: tokstr(),
1147            start,
1148            end,
1149            cmdpos: cmdpos_before,
1150            in_redir: inredir_before,
1151        });
1152
1153        // A LEXERR (even one retyped to STRING) that consumed no input can
1154        // never make progress; neither can any token stream longer than the
1155        // line has characters. Both are hard stops, not errors.
1156        if (was_lexerr && no_progress) || out.len() > (ll as usize + 8) {
1157            break;
1158        }
1159        if was_lexerr && inbufct <= 1 {
1160            break; // trailing dupstrspace space is all that remains
1161        }
1162    }
1163
1164    crate::ported::hist::strinend(); // c:1608
1165    crate::ported::input::inpop(); // c:1609
1166    crate::ported::context::zcontext_restore(); // c:1745
1167    // Put the suspended parse's ungot chars back, discarding anything
1168    // this walk left behind (see the isolation note at the top).
1169    crate::ported::lex::LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
1170
1171    ZLEMETACS.store(saved_cs, Ordering::SeqCst);
1172    ZLEMETALL.store(saved_ll, Ordering::SeqCst);
1173    ADDEDX.store(saved_addedx, Ordering::SeqCst);
1174
1175    out
1176}
1177
1178/// fish:695-715 — Syntax highlighter helper.
1179pub struct Highlighter<'s> {
1180    // fish:697-698 — The string we're highlighting (metafied line).
1181    buff: &'s str,
1182    buff_chars: Vec<char>,
1183    // fish:699-700 — The position of the cursor within the string.
1184    cursor: Option<usize>,
1185    // fish:701-702 — The operation context.
1186    ctx: &'s OperationContext,
1187    // fish:703-704 — Whether it's OK to do I/O.
1188    io_ok: bool,
1189    // fish:705-706 — Working directory.
1190    working_directory: String,
1191    // fish:707-708 — Our component for testing strings for being potential file paths.
1192    file_tester: FileTester<'s>,
1193    // fish:709-710 — The resulting colors.
1194    color_array: ColorArray,
1195    // fish:711-713 — A stack of variables that the current commandline probably
1196    // defines. We mark redirections as valid if they use one of these variables, to
1197    // avoid marking valid targets as error.
1198    pending_variables: Vec<String>,
1199    done: bool,
1200}
1201
1202impl<'s> Highlighter<'s> {
1203    /// fish:718-738 — `new`.
1204    pub fn new(
1205        buff: &'s str,
1206        cursor: Option<usize>,
1207        ctx: &'s OperationContext,
1208        working_directory: String,
1209        can_do_io: bool,
1210    ) -> Self {
1211        let file_tester = FileTester::new(working_directory.clone(), ctx);
1212        Self {
1213            buff,
1214            buff_chars: buff.chars().collect(),
1215            cursor,
1216            ctx,
1217            io_ok: can_do_io,
1218            working_directory,
1219            file_tester,
1220            color_array: vec![],
1221            pending_variables: vec![],
1222            done: false,
1223        }
1224    }
1225
1226    /// fish:739-788 — `highlight`.
1227    pub fn highlight(&mut self) -> ColorArray {
1228        assert!(!self.done);
1229        self.done = true;
1230
1231        self.color_array
1232            .resize(self.buff_chars.len(), HighlightSpec::default());
1233
1234        // fish:752-761 — parse. The lexer flags LEXFLAGS_ZLE|ACTIVE inside
1235        // lex_line_tokens are the zsh spelling of fish's continue_after_error +
1236        // accept_incomplete_tokens + leave_unterminated.
1237        let toks = lex_line_tokens(self.buff);
1238
1239        self.visit_tokens(&toks);
1240        if self.ctx.check_cancel() {
1241            return std::mem::take(&mut self.color_array);
1242        }
1243
1244        // fish:768-772 — Color every comment. The zsh lexer consumes comments as
1245        // whitespace, so recover them from inter-token gaps: an unquoted '#' at a
1246        // word boundary starts a comment when INTERACTIVE_COMMENTS is set.
1247        if isset(INTERACTIVECOMMENTS) {
1248            self.color_gap_comments(&toks);
1249        }
1250
1251        // fish:782-785 — Color every error range: a LEXERR token that survived the
1252        // unterminated-quote fixup is a real lex error.
1253        for t in toks.iter().filter(|t| t.tok == LEXERR) {
1254            self.color_span(t.start, self.buff_chars.len(), HighlightRole::error);
1255        }
1256
1257        std::mem::take(&mut self.color_array)
1258    }
1259
1260    fn io_still_ok(&self) -> bool {
1261        // fish:797-799
1262        self.io_ok && !self.ctx.check_cancel()
1263    }
1264
1265    // fish:876-880 — Colors a range with a given color.
1266    fn color_span(&mut self, start: usize, end: usize, role: HighlightRole) {
1267        let end = end.min(self.color_array.len());
1268        if start < end {
1269            self.color_array[start..end].fill(HighlightSpec::with_fg(role));
1270        }
1271    }
1272
1273    /// Source text of a span.
1274    fn span_text(&self, t: &TokSpan) -> String {
1275        self.buff_chars[t.start.min(self.buff_chars.len())..t.end.min(self.buff_chars.len())]
1276            .iter()
1277            .collect()
1278    }
1279
1280    /// The zshrs replacement for fish's NodeVisitor::visit (fish:1157-1181):
1281    /// walk the token stream, tracking command position, decoration, cd, `--`,
1282    /// assignments and redirections.
1283    fn visit_tokens(&mut self, toks: &[TokSpan]) {
1284        let mut decoration = StatementDecoration::None_;
1285        let mut expanded_cmd = String::new();
1286        let mut is_cd = false;
1287        let mut is_typeset = false;
1288        let mut have_dashdash = false;
1289        let mut i = 0;
1290        while i < toks.len() {
1291            if self.ctx.check_cancel() {
1292                return;
1293            }
1294            let t = &toks[i];
1295            let tokv = t.tok;
1296
1297            if IS_REDIROP(tokv) {
1298                // fish:962-1014 — redirection operator + target.
1299                let target = toks.get(i + 1).filter(|n| n.tok == STRING_LEX);
1300                self.visit_redirection(t, target);
1301                if target.is_some() {
1302                    i += 2;
1303                } else {
1304                    i += 1;
1305                }
1306                continue;
1307            }
1308
1309            match tokv {
1310                SEPER | NEWLIN | SEMI | AMPER | AMPERBANG | BAR_TOK => {
1311                    // fish:912-917 — End/Pipe/Background → statement_terminator.
1312                    self.color_span(t.start, t.end, HighlightRole::statement_terminator);
1313                    decoration = StatementDecoration::None_;
1314                    expanded_cmd.clear();
1315                    is_cd = false;
1316                    is_typeset = false;
1317                    have_dashdash = false;
1318                }
1319                DBAR | DAMPER => {
1320                    // fish:921 — AndAnd/OrOr → operator.
1321                    self.color_span(t.start, t.end, HighlightRole::operat);
1322                    decoration = StatementDecoration::None_;
1323                    expanded_cmd.clear();
1324                    is_cd = false;
1325                    is_typeset = false;
1326                    have_dashdash = false;
1327                }
1328                INPAR_TOK | OUTPAR_TOK | INOUTPAR_LOCAL => {
1329                    self.color_span(t.start, t.end, HighlightRole::operat);
1330                }
1331                ENVSTRING => {
1332                    // fish:1016-1025 — visit_variable_assignment.
1333                    self.visit_variable_assignment(t);
1334                }
1335                ENVARRAY => {
1336                    // zsh `a=(…)`: name colored like an assignment; parens follow as
1337                    // INPAR/OUTPAR tokens.
1338                    self.visit_variable_assignment(t);
1339                }
1340                STRING_LEX => {
1341                    if t.cmdpos && expanded_cmd.is_empty() {
1342                        // fish:1032-1073 — visit_decorated_statement (command word).
1343                        let clean = t.clean_text();
1344                        match clean.as_str() {
1345                            // fish:1033-1036 — color any decoration and keep looking
1346                            // for the real command word.
1347                            "command" => {
1348                                decoration = StatementDecoration::Command;
1349                                self.color_span(t.start, t.end, HighlightRole::keyword);
1350                            }
1351                            "builtin" => {
1352                                decoration = StatementDecoration::Builtin;
1353                                self.color_span(t.start, t.end, HighlightRole::keyword);
1354                            }
1355                            "exec" => {
1356                                decoration = StatementDecoration::Exec;
1357                                self.color_span(t.start, t.end, HighlightRole::keyword);
1358                            }
1359                            "noglob" | "nocorrect" => {
1360                                self.color_span(t.start, t.end, HighlightRole::keyword);
1361                            }
1362                            _ => {
1363                                self.visit_command_word(t, &clean, decoration);
1364                                expanded_cmd = clean;
1365                                is_cd = is_veritable_cd(&expanded_cmd);
1366                                is_typeset = matches!(
1367                                    expanded_cmd.as_str(),
1368                                    "typeset" | "local" | "declare" | "export" | "readonly"
1369                                        | "integer" | "float"
1370                                );
1371                            }
1372                        }
1373                    } else {
1374                        // fish:932-960 — visit_argument.
1375                        if is_typeset {
1376                            // fish:1078-1088 — `set` (zsh: typeset family) defines
1377                            // variables; remember them for redirection validation.
1378                            let arg = t.clean_text();
1379                            let name = arg.split('=').next().unwrap_or("").to_owned();
1380                            if valid_var_name(&name) {
1381                                self.pending_variables.push(name);
1382                            }
1383                        }
1384                        self.visit_argument(t, is_cd, !have_dashdash);
1385                        if self.span_text(t) == "--" {
1386                            have_dashdash = true; // fish:1091-1093
1387                        }
1388                    }
1389                }
1390                tokv if (CASE..=TYPESET).contains(&tokv) => {
1391                    // fish:887-911 — visit_keyword: reserved words.
1392                    self.color_span(t.start, t.end, HighlightRole::keyword);
1393                    if tokv == TYPESET {
1394                        is_typeset = true;
1395                    }
1396                }
1397                LEXERR => {
1398                    self.color_span(t.start, t.end, HighlightRole::error);
1399                }
1400                _ => {
1401                    // fish:928 — default: leave normal.
1402                }
1403            }
1404            i += 1;
1405        }
1406    }
1407
1408    // fish:801-811 + 1038-1073 — Color a command word after validity checking.
1409    fn visit_command_word(&mut self, t: &TokSpan, clean: &str, decoration: StatementDecoration) {
1410        // Reserved words arrive as their own token types; aliases as STRING that
1411        // checkalias() already expanded. What reaches here is a plain command word.
1412        let mut is_valid_cmd = false;
1413        if !self.io_still_ok() {
1414            // fish:1047-1049 — We cannot check if the command is invalid, so just
1415            // assume it's valid.
1416            is_valid_cmd = true;
1417        } else {
1418            // fish:1052-1065 — Check to see if the command is valid.
1419            // Try expanding it. If we cannot, it's an error.
1420            let mut expanded = t.text.clone().unwrap_or_default();
1421            let expanded_ok = expand_one_no_cmdsubst(&mut expanded);
1422            let cmd = if expanded_ok && !expanded.is_empty() {
1423                expanded
1424            } else {
1425                clean.to_owned()
1426            };
1427            if !has_expand_reserved(&cmd) {
1428                is_valid_cmd = command_is_valid_cached(&cmd, decoration, &self.working_directory);
1429            }
1430        }
1431
1432        // fish:1068-1073 — Color our statement.
1433        if is_valid_cmd {
1434            let start = t.start;
1435            let end = t.end.min(self.color_array.len());
1436            let src: String = self.span_text(t);
1437            color_string_internal(
1438                &src,
1439                HighlightSpec::with_fg(HighlightRole::command),
1440                &mut self.color_array[start..end],
1441            );
1442        } else {
1443            self.color_span(t.start, t.end, HighlightRole::error);
1444        }
1445    }
1446
1447    // fish:812-871 + 932-960 — Visit an argument, perhaps knowing that our command
1448    // is cd.
1449    fn visit_argument(&mut self, t: &TokSpan, cmd_is_cd: bool, options_allowed: bool) {
1450        let start = t.start;
1451        let end = t.end.min(self.color_array.len());
1452        if start >= end {
1453            return;
1454        }
1455        let src: String = self.span_text(t);
1456
1457        // fish:820-833 — Color this argument without concern for command
1458        // substitutions.
1459        let base = if options_allowed && src.starts_with('-') {
1460            HighlightRole::option
1461        } else {
1462            HighlightRole::param
1463        };
1464        color_string_internal(
1465            &src,
1466            HighlightSpec::with_fg(base),
1467            &mut self.color_array[start..end],
1468        );
1469
1470        // fish:835-870 — Now do command substitutions: locate `$(…)` spans in the
1471        // source and highlight the contents recursively.
1472        let src_chars: Vec<char> = src.chars().collect();
1473        let mut scan = 0usize;
1474        while let Some((open, close)) = locate_cmdsubst_span(&src_chars, scan) {
1475            // fish:846-851 — highlight the parens (closing paren may be missing on
1476            // an in-progress line).
1477            self.color_span(start + open, start + open + 2, HighlightRole::operat);
1478            let inner_start = open + 2;
1479            let inner_end = close.unwrap_or(src_chars.len());
1480            if let Some(c) = close {
1481                self.color_span(start + c, start + c + 1, HighlightRole::operat);
1482            }
1483            if inner_end > inner_start {
1484                // fish:853-869 — Highlight it recursively.
1485                let arg_cursor = self.cursor.map(|c| c.wrapping_sub(start + inner_start));
1486                let inner_src: String = src_chars[inner_start..inner_end].iter().collect();
1487                let mut cmdsub_highlighter = Highlighter::new(
1488                    &inner_src,
1489                    arg_cursor,
1490                    self.ctx,
1491                    self.working_directory.clone(),
1492                    self.io_still_ok(),
1493                );
1494                let subcolors = cmdsub_highlighter.highlight();
1495                let dst_lo = (start + inner_start).min(self.color_array.len());
1496                let dst_hi = (start + inner_end).min(self.color_array.len());
1497                let n = dst_hi - dst_lo;
1498                self.color_array[dst_lo..dst_hi].copy_from_slice(&subcolors[..n]);
1499            }
1500            scan = inner_end + 1;
1501            if scan >= src_chars.len() {
1502                break;
1503            }
1504        }
1505
1506        if !self.io_still_ok() {
1507            return; // fish:935-937
1508        }
1509
1510        // fish:939-959 — Underline every valid path.
1511        let is_prefix = self
1512            .cursor
1513            .is_some_and(|c| (t.start..=t.end).contains(&c));
1514        let token = t.text.clone().unwrap_or_default();
1515        let test_result = if cmd_is_cd {
1516            self.file_tester.test_cd_path(&token, is_prefix)
1517        } else {
1518            let is_path = self.file_tester.test_path(&token, is_prefix);
1519            Ok(IsFile(is_path))
1520        };
1521        match test_result {
1522            Ok(IsFile(false)) => (),
1523            Ok(IsFile(true)) => {
1524                for i in start..end {
1525                    self.color_array[i].valid_path = true;
1526                }
1527            }
1528            Err(IsErr) => self.color_span(start, end, HighlightRole::error),
1529        }
1530    }
1531
1532    // fish:962-1014 — visit_redirection: operator token + optional target token.
1533    fn visit_redirection(&mut self, op: &TokSpan, target: Option<&TokSpan>) {
1534        // fish:968-980 — Color the operator part like 2>.
1535        self.color_span(op.start, op.end, HighlightRole::redirection);
1536
1537        let Some(target) = target else { return };
1538        let target_text = target.text.clone().unwrap_or_default();
1539
1540        // Heredoc delimiters are words, not files.
1541        if matches!(op.tok, DINANG | DINANGDASH) {
1542            self.color_span(target.start, target.end, HighlightRole::redirection);
1543            return;
1544        }
1545
1546        // fish:982-988 — Check if the argument contains a command substitution. If
1547        // so, highlight it as a param and don't try to do any other validation.
1548        if target_text.contains(crate::ported::zsh_h::Tick)
1549            || target_text.contains(crate::ported::zsh_h::Qtick)
1550            || target_text.contains(crate::ported::zsh_h::Inpar)
1551        {
1552            self.visit_argument(target, false, true);
1553            return;
1554        }
1555
1556        let mode = redir_tok_mode(op.tok, &target.clean_text());
1557
1558        // fish:989-1007 — No command substitution, so we can highlight the target
1559        // file or fd.
1560        let (role, file_exists) = if !self.io_still_ok() {
1561            // fish:991-994 — I/O is disallowed, so we don't have much hope of
1562            // catching anything but gross errors. Assume it's valid.
1563            (HighlightRole::redirection, false)
1564        } else if contains_pending_variable(&self.pending_variables, &target_text) {
1565            // fish:995-997 — Target uses a variable defined by the current
1566            // commandline. Assume it's valid.
1567            (HighlightRole::redirection, false)
1568        } else {
1569            // fish:998-1006 — Validate the redirection target.
1570            if let Ok(IsFile(file_exists)) =
1571                self.file_tester.test_redirection_target(&target_text, mode)
1572            {
1573                (HighlightRole::redirection, file_exists)
1574            } else {
1575                (HighlightRole::error, false)
1576            }
1577        };
1578        self.color_span(target.start, target.end, role);
1579        if file_exists {
1580            // fish:1009-1013
1581            for i in target.start..target.end.min(self.color_array.len()) {
1582                self.color_array[i].valid_path = true;
1583            }
1584        }
1585    }
1586
1587    // fish:1016-1025 — visit_variable_assignment.
1588    fn visit_variable_assignment(&mut self, t: &TokSpan) {
1589        let start = t.start;
1590        let end = t.end.min(self.color_array.len());
1591        if start >= end {
1592            return;
1593        }
1594        let src: String = self.span_text(t);
1595        color_string_internal(
1596            &src,
1597            HighlightSpec::with_fg(HighlightRole::param),
1598            &mut self.color_array[start..end],
1599        );
1600        // fish:1018-1024 — Highlight the '=' in variable assignments as an operator,
1601        // and remember the variable for redirection validation.
1602        if let Some(offset) = src.chars().position(|c| c == '=') {
1603            if start + offset < self.color_array.len() {
1604                self.color_array[start + offset] = HighlightSpec::with_fg(HighlightRole::operat);
1605            }
1606            let var_name: String = src.chars().take(offset).collect();
1607            if valid_var_name(&var_name) {
1608                self.pending_variables.push(var_name);
1609            }
1610        }
1611    }
1612
1613    /// fish:768-772 adaptation — recover comment spans from inter-token gaps (the
1614    /// zsh lexer consumes comments as whitespace under INTERACTIVE_COMMENTS).
1615    fn color_gap_comments(&mut self, toks: &[TokSpan]) {
1616        let len = self.buff_chars.len();
1617        let mut gaps: Vec<(usize, usize)> = Vec::new();
1618        let mut prev_end = 0usize;
1619        for t in toks {
1620            if t.start > prev_end {
1621                gaps.push((prev_end, t.start.min(len)));
1622            }
1623            prev_end = prev_end.max(t.end);
1624        }
1625        if prev_end < len {
1626            gaps.push((prev_end, len));
1627        }
1628        for (lo, hi) in gaps {
1629            let mut j = lo;
1630            while j < hi {
1631                let c = self.buff_chars[j];
1632                if c == '#' {
1633                    // comment runs to end of line within this gap
1634                    let eol = self.buff_chars[j..hi]
1635                        .iter()
1636                        .position(|&c| c == '\n')
1637                        .map(|p| j + p)
1638                        .unwrap_or(hi);
1639                    self.color_span(j, eol, HighlightRole::comment);
1640                    j = eol;
1641                }
1642                j += 1;
1643            }
1644        }
1645    }
1646}
1647
1648/// fish:1124-1132 — Return whether a string contains a command substitution;
1649/// respelled as a char scanner locating `$(`…`)` with paren balance. Returns
1650/// (open_index_of_'$', Some(close_index)) — close is None when unterminated.
1651fn locate_cmdsubst_span(chars: &[char], from: usize) -> Option<(usize, Option<usize>)> {
1652    let mut i = from;
1653    let mut in_squote = false;
1654    while i + 1 < chars.len() {
1655        let c = chars[i];
1656        if in_squote {
1657            if c == '\'' {
1658                in_squote = false;
1659            }
1660        } else if c == '\'' {
1661            in_squote = true;
1662        } else if c == '\\' {
1663            i += 1;
1664        } else if c == '$' && chars[i + 1] == '(' {
1665            // Found the opener. Find the balanced closer.
1666            let mut depth = 0i32;
1667            let mut j = i + 1;
1668            while j < chars.len() {
1669                match chars[j] {
1670                    '(' => depth += 1,
1671                    ')' => {
1672                        depth -= 1;
1673                        if depth == 0 {
1674                            return Some((i, Some(j)));
1675                        }
1676                    }
1677                    _ => (),
1678                }
1679                j += 1;
1680            }
1681            return Some((i, None));
1682        }
1683        i += 1;
1684    }
1685    None
1686}
1687
1688/// fish:1134-1155 — `contains_pending_variable`.
1689fn contains_pending_variable(pending_variables: &[String], haystack: &str) -> bool {
1690    let hay: Vec<char> = haystack.chars().collect();
1691    for var_name in pending_variables {
1692        let needle: Vec<char> = var_name.chars().collect();
1693        if needle.is_empty() || hay.len() < needle.len() {
1694            continue;
1695        }
1696        let mut nextpos = 0usize;
1697        while nextpos + needle.len() <= hay.len() {
1698            let Some(relpos) = hay[nextpos..]
1699                .windows(needle.len())
1700                .position(|w| w == needle.as_slice())
1701            else {
1702                break;
1703            };
1704            let pos = nextpos + relpos;
1705            nextpos = pos + 1;
1706            if pos == 0 || hay[pos - 1] != '$' {
1707                continue; // fish:1144-1146
1708            }
1709            let end = pos + needle.len();
1710            if end < hay.len() && valid_var_name_char(hay[end]) {
1711                continue; // fish:1147-1150
1712            }
1713            return true;
1714        }
1715    }
1716    false
1717}
1718
1719/// Map a zsh redirection token (+ its target text) to the fish RedirectionMode the
1720/// FileTester validates (fish tokenizer.rs PipeOrRedir::mode equivalent).
1721fn redir_tok_mode(tokv: lextok, target: &str) -> RedirectionMode {
1722    let looks_fd = target == "-" || target.chars().all(|c| c.is_ascii_digit());
1723    match tokv {
1724        OUTANG_TOK => {
1725            if isset(CLOBBER) {
1726                RedirectionMode::Overwrite
1727            } else {
1728                RedirectionMode::NoClob
1729            }
1730        }
1731        OUTANGBANG => RedirectionMode::Overwrite,
1732        DOUTANG | DOUTANGBANG => RedirectionMode::Append,
1733        INANG_TOK => RedirectionMode::Input,
1734        INOUTANG => RedirectionMode::Overwrite, // <> creates rw
1735        INANGAMP => RedirectionMode::Fd,
1736        OUTANGAMP | OUTANGAMPBANG => {
1737            if looks_fd {
1738                RedirectionMode::Fd
1739            } else {
1740                RedirectionMode::Overwrite // >& file duplicates both streams to file
1741            }
1742        }
1743        DOUTANGAMP | DOUTANGAMPBANG => {
1744            if looks_fd {
1745                RedirectionMode::Fd
1746            } else {
1747                RedirectionMode::Append
1748            }
1749        }
1750        crate::ported::zsh_h::AMPOUTANG => RedirectionMode::Overwrite,
1751        _ => RedirectionMode::Input,
1752    }
1753}
1754
1755// Local alias: `(` `)` in `x=( … )` array assignments arrive as INPAR/OUTPAR; some
1756// grammars also use INOUTPAR for `()` in function definitions.
1757use crate::ported::zsh_h::INOUTPAR as INOUTPAR_LOCAL;
1758
1759#[cfg(test)]
1760mod tests {
1761    use super::*;
1762
1763    fn lock() -> std::sync::MutexGuard<'static, ()> {
1764        crate::test_util::global_state_lock()
1765    }
1766
1767    fn spans_of(line: &str) -> Vec<(String, i32)> {
1768        lex_line_tokens(line)
1769            .iter()
1770            .map(|t| {
1771                (
1772                    line.chars()
1773                        .skip(t.start)
1774                        .take(t.end - t.start)
1775                        .collect::<String>(),
1776                    t.tok,
1777                )
1778            })
1779            .collect()
1780    }
1781
1782    /// The highlight walk must leave NO residue in the Rust-only
1783    /// `LEX_UNGET_BUF` side channel: `hgetc` (lex.rs:4209) drains it
1784    /// BEFORE every input frame, so leftover chars from a per-keystroke
1785    /// highlight pass are read by the NEXT real interactive parse —
1786    /// `{ print ok }` / `( print sub )` / every brace-body function
1787    /// definition typed interactively died with "parse error near `}'"
1788    /// while the fish engines were enabled (ZSHRS_NATIVE_ZLE_FX=0 made
1789    /// it vanish). The walk must also put back what the SUSPENDED outer
1790    /// parse had ungot before it blocked in zleread.
1791    #[test]
1792    fn lex_line_tokens_leaves_unget_buf_untouched() {
1793        use crate::ported::lex::LEX_UNGET_BUF;
1794        let _g = lock();
1795        for line in ["{ print ok }", "( print sub )", "function q(){ print fq }", "echo hi"] {
1796            // Outer-parse residue that must survive the walk verbatim.
1797            LEX_UNGET_BUF.with_borrow_mut(|b| {
1798                b.clear();
1799                b.push_back('X');
1800                b.push_back('\n');
1801            });
1802            let _ = lex_line_tokens(line);
1803            let after: Vec<char> = LEX_UNGET_BUF.with_borrow(|b| b.iter().copied().collect());
1804            assert_eq!(
1805                after,
1806                vec!['X', '\n'],
1807                "LEX_UNGET_BUF corrupted by highlight walk of {line:?}"
1808            );
1809        }
1810        LEX_UNGET_BUF.with_borrow_mut(|b| b.clear());
1811    }
1812
1813    /// Token spans must slice the source exactly — the C nwb/nwe formulas.
1814    #[test]
1815    fn lex_spans_match_source() {
1816        let _g = lock();
1817        let spans = spans_of("echo hello world");
1818        let words: Vec<&str> = spans
1819            .iter()
1820            .filter(|(_, t)| *t == STRING_LEX)
1821            .map(|(s, _)| s.as_str())
1822            .collect();
1823        assert_eq!(words, vec!["echo", "hello", "world"], "spans {spans:?}");
1824    }
1825
1826    #[test]
1827    fn lex_spans_operators() {
1828        let _g = lock();
1829        let toks = lex_line_tokens("a && b || c");
1830        let ops: Vec<i32> = toks.iter().map(|t| t.tok).collect();
1831        assert!(ops.contains(&DAMPER), "toks {ops:?}");
1832        assert!(ops.contains(&DBAR), "toks {ops:?}");
1833        // Operator spans point at the operator text.
1834        let damper = toks.iter().find(|t| t.tok == DAMPER).unwrap();
1835        let src: String = "a && b || c"
1836            .chars()
1837            .skip(damper.start)
1838            .take(damper.end - damper.start)
1839            .collect();
1840        assert_eq!(src.trim(), "&&");
1841    }
1842
1843    #[test]
1844    fn lex_spans_cmdpos_flag() {
1845        let _g = lock();
1846        let toks = lex_line_tokens("echo foo; ls bar");
1847        let strings: Vec<(String, bool)> = toks
1848            .iter()
1849            .filter(|t| t.tok == STRING_LEX)
1850            .map(|t| (t.clean_text(), t.cmdpos))
1851            .collect();
1852        assert_eq!(
1853            strings,
1854            vec![
1855                ("echo".to_owned(), true),
1856                ("foo".to_owned(), false),
1857                ("ls".to_owned(), true),
1858                ("bar".to_owned(), false)
1859            ]
1860        );
1861    }
1862
1863    /// Unterminated quote at cursor: LEXFLAGS_ACTIVE keeps it a STRING (the
1864    /// zle_tricky c:1215-1227 fixup).
1865    #[test]
1866    fn lex_tolerates_unterminated_quote() {
1867        let _g = lock();
1868        let toks = lex_line_tokens("echo 'in progress");
1869        assert!(
1870            toks.iter().filter(|t| t.tok == STRING_LEX).count() >= 2,
1871            "unterminated quote must still lex as STRING: {:?}",
1872            toks.iter().map(|t| t.tok).collect::<Vec<_>>()
1873        );
1874    }
1875
1876    // fish:475-691 — color_string_internal coverage, zsh quoting.
1877    #[test]
1878    fn color_string_quotes_and_vars() {
1879        let _g = lock();
1880        let s = "a'q'\"d$V\"$X*";
1881        let n = s.chars().count();
1882        let mut colors = vec![HighlightSpec::default(); n];
1883        color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
1884        let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
1885        // a
1886        assert_eq!(roles[0], HighlightRole::param);
1887        // 'q'
1888        assert_eq!(roles[1], HighlightRole::quote);
1889        assert_eq!(roles[2], HighlightRole::quote);
1890        assert_eq!(roles[3], HighlightRole::quote);
1891        // "d — quote
1892        assert_eq!(roles[4], HighlightRole::quote);
1893        assert_eq!(roles[5], HighlightRole::quote);
1894        // $V inside dquotes — operator
1895        assert_eq!(roles[6], HighlightRole::operat);
1896        assert_eq!(roles[7], HighlightRole::operat);
1897        // closing "
1898        assert_eq!(roles[8], HighlightRole::quote);
1899        // $X unquoted — operator
1900        assert_eq!(roles[9], HighlightRole::operat);
1901        assert_eq!(roles[10], HighlightRole::operat);
1902        // * glob — operator
1903        assert_eq!(roles[11], HighlightRole::operat);
1904    }
1905
1906    #[test]
1907    fn color_string_unclosed_quote_is_error() {
1908        let _g = lock();
1909        let s = "'unclosed";
1910        let n = s.chars().count();
1911        let mut colors = vec![HighlightSpec::default(); n];
1912        color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
1913        assert_eq!(colors[0].foreground, HighlightRole::error); // fish:687-690
1914    }
1915
1916    #[test]
1917    fn color_string_dollar_quote_escapes() {
1918        let _g = lock();
1919        // $'\x41' valid escape; $'\U110000' exceeds max → error (fish:583-586).
1920        let s = "$'\\x41'";
1921        let n = s.chars().count();
1922        let mut colors = vec![HighlightSpec::default(); n];
1923        color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
1924        let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
1925        assert_eq!(roles[2], HighlightRole::escape, "roles {roles:?}");
1926
1927        let s2 = "$'\\U110000'";
1928        let n2 = s2.chars().count();
1929        let mut colors2 = vec![HighlightSpec::default(); n2];
1930        color_string_internal(s2, HighlightSpec::with_fg(HighlightRole::param), &mut colors2);
1931        assert_eq!(colors2[2].foreground, HighlightRole::error);
1932    }
1933
1934    #[test]
1935    fn color_variable_subscript() {
1936        let _g = lock();
1937        let s: Vec<char> = "$arr[1]x".chars().collect();
1938        let mut colors = vec![HighlightSpec::default(); s.len()];
1939        let consumed = color_variable(&s, &mut colors);
1940        // $arr[1] consumed; trailing x not.
1941        assert_eq!(consumed, 7);
1942        assert_eq!(colors[0].foreground, HighlightRole::operat);
1943        assert_eq!(colors[4].foreground, HighlightRole::operat); // [
1944        assert_eq!(colors[6].foreground, HighlightRole::operat); // ]
1945    }
1946
1947    // fish:1342-1821 — the highlight_shell integration checks, zsh syntax.
1948    #[test]
1949    fn highlight_valid_and_invalid_command() {
1950        let _g = lock();
1951        let ctx = OperationContext::empty();
1952
1953        // `echo` resolves via builtintab → command color.
1954        let line = "echo hi";
1955        let mut colors = Vec::new();
1956        highlight_shell(line, &mut colors, &ctx, /*io_ok=*/ true, None);
1957        assert_eq!(colors.len(), line.chars().count());
1958        assert_eq!(
1959            colors[0].foreground,
1960            HighlightRole::command,
1961            "colors {colors:?}"
1962        );
1963
1964        // Nonexistent command → error color.
1965        let line2 = "definitely_not_a_cmd_zshrs_x hi";
1966        let mut colors2 = Vec::new();
1967        highlight_shell(line2, &mut colors2, &ctx, /*io_ok=*/ true, None);
1968        assert_eq!(colors2[0].foreground, HighlightRole::error);
1969        // argument keeps param color
1970        let arg_pos = line2.chars().count() - 1;
1971        assert_eq!(colors2[arg_pos].foreground, HighlightRole::param);
1972    }
1973
1974    #[test]
1975    fn highlight_reserved_word_and_separator() {
1976        let _g = lock();
1977        let ctx = OperationContext::empty();
1978        let line = "if true; then echo x; fi";
1979        let mut colors = Vec::new();
1980        highlight_shell(line, &mut colors, &ctx, true, None);
1981        // `if` keyword
1982        assert_eq!(colors[0].foreground, HighlightRole::keyword, "{colors:?}");
1983        // `;` statement terminator
1984        let semi = line.chars().position(|c| c == ';').unwrap();
1985        assert_eq!(colors[semi].foreground, HighlightRole::statement_terminator);
1986    }
1987
1988    #[test]
1989    fn highlight_assignment_equals_operator() {
1990        let _g = lock();
1991        let ctx = OperationContext::empty();
1992        let line = "FOO=bar echo x";
1993        let mut colors = Vec::new();
1994        highlight_shell(line, &mut colors, &ctx, true, None);
1995        let eq = line.chars().position(|c| c == '=').unwrap();
1996        assert_eq!(colors[eq].foreground, HighlightRole::operat, "{colors:?}");
1997    }
1998
1999    #[test]
2000    fn highlight_io_off_assumes_valid() {
2001        let _g = lock();
2002        let ctx = OperationContext::empty();
2003        // fish:1047-1049 — io_ok=false: no validity IO, command assumed valid.
2004        let line = "definitely_not_a_cmd_zshrs_x";
2005        let mut colors = Vec::new();
2006        highlight_shell(line, &mut colors, &ctx, /*io_ok=*/ false, None);
2007        assert_eq!(colors[0].foreground, HighlightRole::command);
2008    }
2009
2010    #[test]
2011    fn resolver_default_palette() {
2012        let _g = lock();
2013        // Without user config, the resolver must produce non-zero attrs for the
2014        // roles with non-"none" defaults.
2015        let attr =
2016            HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
2017                HighlightRole::command,
2018            ));
2019        assert_ne!(attr, 0, "command role must default to a visible style");
2020        let err =
2021            HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
2022                HighlightRole::error,
2023            ));
2024        assert_ne!(err, 0);
2025        assert_ne!(attr, err, "command and error styles must differ");
2026    }
2027
2028    #[test]
2029    fn contains_pending_variable_matches_dollar_use() {
2030        // fish:1134-1155
2031        let vars = vec!["x".to_owned()];
2032        assert!(contains_pending_variable(&vars, "$x"));
2033        assert!(contains_pending_variable(&vars, "dir/$x/log"));
2034        assert!(!contains_pending_variable(&vars, "x"));
2035        assert!(!contains_pending_variable(&vars, "$xy"));
2036    }
2037
2038    #[test]
2039    fn locate_cmdsubst_span_finds_nested() {
2040        let chars: Vec<char> = "a$(b $(c) d)e".chars().collect();
2041        let (open, close) = locate_cmdsubst_span(&chars, 0).unwrap();
2042        assert_eq!(open, 1);
2043        assert_eq!(close, Some(11));
2044        // unterminated
2045        let chars2: Vec<char> = "a$(b".chars().collect();
2046        let (o2, c2) = locate_cmdsubst_span(&chars2, 0).unwrap();
2047        assert_eq!(o2, 1);
2048        assert_eq!(c2, None);
2049    }
2050}