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//! WHAT IS FISH AND WHAT IS NOT: the LEXING and the walk are fish's. The PALETTE
10//! and the word-level classification are `fast-syntax-highlighting`'s, because
11//! f-sy-h is the plugin this engine replaces on a daily-driver rc and the two
12//! disagreed almost everywhere fish's dozen faces met f-sy-h's sixty style keys.
13//! Every such site carries a `fast-highlight:NNN` citation (line numbers into
14//! `~/.zinit/plugins/zdharma-continuum---fast-syntax-highlighting/fast-highlight`,
15//! whose `FAST_HIGHLIGHT_STYLES` defaults start at :58) next to the `fish:NNN`
16//! one it overrides. The differences are structural, not cosmetic:
17//!   * fish paints glob / brace / paren / `$` characters one at a time; f-sy-h
18//!     resolves the WHOLE word to one style (fast-highlight:1040-1090) and then
19//!     runs a separate brackets pass that colours every bracket by nesting depth
20//!     (fast-string-highlight:1-70).
21//!   * fish asks "is this command valid" (a boolean); f-sy-h asks WHAT it is, in
22//!     one fixed order — alias, global alias, function, builtin, command, suffix
23//!     alias, reserved word, directory (fast-highlight:295-322) — and each answer
24//!     has its own style key.
25//!   * f-sy-h re-highlights the body of a command substitution under a SECOND
26//!     palette (fast-highlight:113, the `free` theme), and runs per-command
27//!     argument highlighters ("chromas", fast-highlight:171-232).
28//!
29//! zshrs substrate swaps (each cited at its site):
30//!   * fish AST visitor            → zshrs lexer token stream (`lex_line_tokens`, the
31//!     `inpush` + `LEXFLAGS_ZLE|LEXFLAGS_ACTIVE` + `ctxtlex` pattern of
32//!     zle_tricky.rs:1357-1445 / zsh Src/Zle/zle_tricky.c:1157-1445), with token spans
33//!     from the C word-offset arithmetic: start = zlemetall - wordbeg (lex.c:1886),
34//!     end = zlemetall + 1 - inbufct (lex.c:1884)
35//!   * `fish_color_*` env vars     → `$ZSH_HIGHLIGHT_STYLES[key]` (z-sy-h config compat),
36//!     parsed by `match_highlight` (prompt.rs:3660, zsh Src/prompt.c:2031)
37//!   * `TextFace`                  → packed `zattr` bitmap (zsh_h.rs:4295)
38//!   * abbreviations               → zsh aliases (`aliastab`)
39//!   * `builtin_exists`/`function::exists`/`path_get_path` → `builtintab`/`shfunctab`/
40//!     `cmdnamtab` + `findcmd`
41//!   * fish `%self`                → dropped (no zsh spelling)
42//!   * fish `$var[slice]`          → zsh `$var[subscript]`
43//!
44//! The quoting rules inside `color_string_internal` are re-derived for zsh: `'...'`
45//! (no escapes unless RC_QUOTES), `"..."` (`\\ \" \$ \`` + `$var`), `$'...'` (ANSI-C
46//! escapes — fish's \u/\x/octal validation applies HERE), backticks, bare `\X`.
47
48#![allow(non_snake_case)]
49#![allow(non_camel_case_types)]
50
51use crate::ported::exec::findcmd;
52use crate::ported::hashtable::{aliastab_lock, cmdnamtab_lock, reswdtab_lock, sufaliastab_lock};
53use crate::ported::lex::{
54    ctxtlex, incmdpos, inredir, tok, tokstr, untokenize, LEX_LEXFLAGS, LEX_WORDBEG,
55};
56use crate::ported::params::{gethkparam, gethparam, getsparam};
57use crate::ported::prompt::match_highlight;
58use crate::ported::utils::getshfunc;
59use crate::ported::zsh_h::{
60    isset, lextok, zattr, AMPER, AMPERBANG, AUTOCD, BANG_TOK, BARAMP, BAR_TOK, CASE, CLOBBER,
61    DAMPER, DBAR, DINANG, DINANGDASH, DINBRACK, DINPAR, DOUTANG, DOUTANGAMP, DOUTANGAMPBANG,
62    DOUTANGBANG, DOUTBRACK, DOUTPAR, DSEMI, ENDINPUT, ENVARRAY, ENVSTRING, INANGAMP, INANG_TOK,
63    INBRACE_TOK, INOUTANG, INOUTPAR, INPAR_TOK, INTERACTIVECOMMENTS, IS_REDIROP, LEXERR,
64    LEXFLAGS_ACTIVE, LEXFLAGS_ZLE, NEWLIN, OUTANGAMP, OUTANGAMPBANG, OUTANGBANG, OUTANG_TOK,
65    OUTBRACE_TOK, OUTPAR_TOK, SEMI, SEMIAMP, SEMIBAR, SEPER, STRING_LEX, TRINANG, TYPESET,
66};
67use crate::zle_file_tester::{
68    expand_one_no_cmdsubst, FileTester, IsErr, IsFile, OperationContext, RedirectionMode,
69};
70use std::collections::{hash_map::Entry, HashMap};
71use std::sync::Mutex;
72
73/// fish:1306-1317 — Simple value type describing how a character should be highlighted.
74#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
75pub struct HighlightSpec {
76    pub foreground: HighlightRole,
77    pub background: HighlightRole,
78    pub valid_path: bool,
79    pub force_underline: bool,
80    /// fast-highlight:113 / :890-899 — f-sy-h flips `FAST_THEME_NAME` to the
81    /// `secondary` theme (`free` by default) before re-highlighting the body of
82    /// a command substitution, so `print $(ls)` shows `ls` in the secondary
83    /// palette's command colour, not the primary green.  This flag selects that
84    /// palette for one span; fish has no such notion.
85    pub secondary: bool,
86}
87
88impl HighlightSpec {
89    /// fish:43-45 — `new`.
90    pub fn new() -> Self {
91        Self::default()
92    }
93    /// fish:47-53 — `with_fg_bg`.
94    pub fn with_fg_bg(fg: HighlightRole, bg: HighlightRole) -> Self {
95        Self {
96            foreground: fg,
97            background: bg,
98            ..Default::default()
99        }
100    }
101    /// fish:55-57 — `with_fg`.
102    pub fn with_fg(fg: HighlightRole) -> Self {
103        Self::with_fg_bg(fg, HighlightRole::normal)
104    }
105    /// fish:59-61 — `with_bg`.
106    pub fn with_bg(bg: HighlightRole) -> Self {
107        Self::with_fg_bg(HighlightRole::normal, bg)
108    }
109    /// fish:63-65 — `with_both`.
110    pub fn with_both(role: HighlightRole) -> Self {
111        Self::with_fg_bg(role, role)
112    }
113}
114
115/// fish:1268-1304 — Describes the role of a span of text.
116#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
117#[repr(u8)]
118pub enum HighlightRole {
119    #[default]
120    normal, // normal text
121    error,   // error
122    command, // command
123    keyword,
124    statement_terminator, // process separator
125    param,                // command parameter (argument)
126    option,               // argument starting with "-", up to a "--"
127    comment,              // comment
128    search_match,         // search match
129    operat,               // operator
130    escape,               // escape sequences
131    quote,                // quoted string
132    redirection,          // redirection
133    autosuggestion,       // autosuggestion
134    selection,
135
136    // fish:1289-1303 — Pager support (kept for name parity; the zshrs completion
137    // menu may consume these later).
138    pager_progress,
139    pager_background,
140    pager_prefix,
141    pager_completion,
142    pager_description,
143    pager_secondary_background,
144    pager_secondary_prefix,
145    pager_secondary_completion,
146    pager_secondary_description,
147    pager_selected_background,
148    pager_selected_prefix,
149    pager_selected_completion,
150    pager_selected_description,
151
152    // ------------------------------------------------------------------
153    // fast-syntax-highlighting roles (fast-highlight:58-113, the
154    // `FAST_HIGHLIGHT_STYLES` default theme).  fish has no counterpart for
155    // these — its palette is a dozen faces where f-sy-h's is sixty — but
156    // f-sy-h is what this engine replaces on a daily-driver rc, so every
157    // style key it paints needs a role here or the two cannot agree.
158    // ------------------------------------------------------------------
159    precommand,           // fast-highlight:67  fg=green
160    alias_,               // fast-highlight:61  fg=green
161    suffix_alias,         // fast-highlight:62  fg=green
162    global_alias,         // fast-highlight:63  bg=blue
163    builtin_,             // fast-highlight:64  fg=green
164    function_,            // fast-highlight:65  fg=green
165    hashed_command,       // fast-highlight:69  fg=green
166    path,                 // fast-highlight:70  fg=magenta
167    path_to_dir,          // fast-highlight:71  fg=magenta,underline
168    globbing,             // fast-highlight:73  fg=blue,bold
169    history_expansion,    // fast-highlight:75  fg=blue,bold
170    double_hyphen_option, // fast-highlight:77 fg=cyan
171    dquoted,              // fast-highlight:80  fg=yellow (double-quoted-argument)
172    dollar_quoted,        // fast-highlight:81  fg=yellow (dollar-quoted-argument)
173    dollar_in_dquote,     // fast-highlight:82  fg=cyan
174    variable,             // fast-highlight:86  fg=113
175    mathvar,              // fast-highlight:87  fg=blue,bold
176    mathnum,              // fast-highlight:88  fg=magenta
177    matherr,              // fast-highlight:89  fg=red
178    assign,               // fast-highlight:84  none
179    assign_array_bracket, // fast-highlight:90 fg=green
180    here_string_tri,      // fast-highlight:96  fg=yellow
181    here_string_text,     // fast-highlight:97  bg=18
182    single_sq_bracket,    // fast-highlight:106 fg=green
183    double_sq_bracket,    // fast-highlight:107 fg=green
184    double_paren,         // fast-highlight:108 fg=yellow
185    bracket_level_1,      // fast-highlight:103 fg=green,bold
186    bracket_level_2,      // fast-highlight:104 fg=yellow,bold
187    bracket_level_3,      // fast-highlight:105 fg=cyan,bold
188    case_input,           // fast-highlight:99  fg=green
189    case_parentheses,     // fast-highlight:100 fg=yellow
190    case_condition,       // fast-highlight:101 bg=blue
191    globbing_ext,         // fast-highlight:74  fg=13
192    here_string_var,      // fast-highlight:98  fg=cyan,bg=18
193    for_loop_variable,    // fast-highlight:92  none
194    for_loop_operator,    // fast-highlight:93  fg=yellow
195    for_loop_number,      // fast-highlight:94  fg=magenta
196    for_loop_separator,   // fast-highlight:95  fg=yellow,bold
197    correct_subtle,       // fast-highlight:109 fg=12
198    incorrect_subtle,     // fast-highlight:110 fg=red
199}
200
201/// fish:692-693 — `ColorArray`: one `HighlightSpec` per character of the buffer.
202pub type ColorArray = Vec<HighlightSpec>;
203
204/// fish:1197-1228 — `get_highlight_var_name`, respelled for zsh: role →
205/// `$ZSH_HIGHLIGHT_STYLES` key (z-sy-h main-highlighter config surface, so existing
206/// user themes apply to the native engine unchanged). Autosuggestion is configured by
207/// `$ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE` (scalar) — special-cased in the resolver.
208fn get_highlight_style_key(role: HighlightRole) -> &'static str {
209    match role {
210        HighlightRole::normal => "default",
211        HighlightRole::error => "unknown-token",
212        HighlightRole::command => "command",
213        HighlightRole::keyword => "reserved-word",
214        HighlightRole::statement_terminator => "commandseparator",
215        HighlightRole::param => "default",
216        HighlightRole::option => "single-hyphen-option",
217        HighlightRole::comment => "comment",
218        HighlightRole::search_match => "history-search-match",
219        HighlightRole::operat => "globbing",
220        HighlightRole::escape => "back-dollar-quoted-argument",
221        HighlightRole::quote => "single-quoted-argument",
222        HighlightRole::redirection => "redirection",
223        HighlightRole::autosuggestion => "autosuggestion",
224        HighlightRole::selection => "selection",
225
226        // f-sy-h keys (fast-highlight:58-113).
227        HighlightRole::precommand => "precommand",
228        HighlightRole::alias_ => "alias",
229        HighlightRole::suffix_alias => "suffix-alias",
230        HighlightRole::global_alias => "global-alias",
231        HighlightRole::builtin_ => "builtin",
232        HighlightRole::function_ => "function",
233        HighlightRole::hashed_command => "hashed-command",
234        HighlightRole::path => "path",
235        HighlightRole::path_to_dir => "path-to-dir",
236        HighlightRole::globbing => "globbing",
237        HighlightRole::history_expansion => "history-expansion",
238        HighlightRole::double_hyphen_option => "double-hyphen-option",
239        HighlightRole::dquoted => "double-quoted-argument",
240        HighlightRole::dollar_quoted => "dollar-quoted-argument",
241        HighlightRole::dollar_in_dquote => "back-or-dollar-double-quoted-argument",
242        HighlightRole::variable => "variable",
243        HighlightRole::mathvar => "mathvar",
244        HighlightRole::mathnum => "mathnum",
245        HighlightRole::matherr => "matherr",
246        HighlightRole::assign => "assign",
247        HighlightRole::assign_array_bracket => "assign-array-bracket",
248        HighlightRole::here_string_tri => "here-string-tri",
249        HighlightRole::here_string_text => "here-string-text",
250        HighlightRole::single_sq_bracket => "single-sq-bracket",
251        HighlightRole::double_sq_bracket => "double-sq-bracket",
252        HighlightRole::double_paren => "double-paren",
253        HighlightRole::bracket_level_1 => "bracket-level-1",
254        HighlightRole::bracket_level_2 => "bracket-level-2",
255        HighlightRole::bracket_level_3 => "bracket-level-3",
256        HighlightRole::case_input => "case-input",
257        HighlightRole::case_parentheses => "case-parentheses",
258        HighlightRole::case_condition => "case-condition",
259        HighlightRole::globbing_ext => "globbing-ext",
260        HighlightRole::here_string_var => "here-string-var",
261        HighlightRole::for_loop_variable => "for-loop-variable",
262        HighlightRole::for_loop_operator => "for-loop-operator",
263        HighlightRole::for_loop_number => "for-loop-number",
264        HighlightRole::for_loop_separator => "for-loop-separator",
265        HighlightRole::correct_subtle => "correct-subtle",
266        HighlightRole::incorrect_subtle => "incorrect-subtle",
267
268        // Pager roles have no z-sy-h key; resolver falls through to defaults.
269        _ => "default",
270    }
271}
272
273/// Built-in default style per role, used when `$ZSH_HIGHLIGHT_STYLES` doesn't override.
274/// These mirror the z-sy-h main-highlighter default palette so the native engine looks
275/// identical to the plugin it replaces (z-sy-h defaults: command/builtin/function/alias
276/// fg=green, unknown-token fg=red, reserved-word fg=yellow, comment fg=black+bold,
277/// single/double-quoted fg=yellow, dollar escapes fg=cyan, globbing fg=blue,
278/// path underline; zsh-autosuggestions default: fg=8).
279fn get_default_style(role: HighlightRole) -> &'static str {
280    match role {
281        // f-sy-h's default is `unknown-token = fg=red,bold`
282        // (fast-highlight:58), and it is what this engine replaces on a
283        // daily-driver rc. Measured over a pty against real zsh + f-sy-h,
284        // an incomplete command word emits `\e[1m\e[31m` (bold red) and
285        // this engine emitted a plain `\e[31m`, so the two diverged on
286        // every partially-typed command. z-sy-h's plain `fg=red` is the
287        // odd one out here; $ZSH_HIGHLIGHT_STYLES[unknown-token] still
288        // overrides for anyone who wants it back.
289        HighlightRole::error => "fg=red,bold",
290        // fast-highlight:64-69 — builtin / function / command / precommand /
291        // hashed-command / alias / suffix-alias are all fg=green.
292        HighlightRole::command
293        | HighlightRole::precommand
294        | HighlightRole::alias_
295        | HighlightRole::suffix_alias
296        | HighlightRole::builtin_
297        | HighlightRole::function_
298        | HighlightRole::hashed_command => "fg=green",
299        HighlightRole::global_alias => "bg=blue", // fast-highlight:63
300        HighlightRole::keyword => "fg=yellow",    // fast-highlight:59 reserved-word
301        HighlightRole::comment => "fg=black,bold", // fast-highlight:85
302        // Anything still routed through the generic operator role paints like a
303        // glob, which is what f-sy-h does for a word carrying one.
304        HighlightRole::operat | HighlightRole::globbing => "fg=blue,bold", // fast-highlight:73
305        HighlightRole::escape => "fg=cyan",                                // fast-highlight:83
306        HighlightRole::quote | HighlightRole::dquoted | HighlightRole::dollar_quoted => {
307            "fg=yellow" // fast-highlight:79-81
308        }
309        HighlightRole::redirection => "none", // fast-highlight:84
310        HighlightRole::autosuggestion => "fg=8",
311        HighlightRole::selection | HighlightRole::search_match => "standout",
312        HighlightRole::option | HighlightRole::double_hyphen_option => "fg=cyan", // :76-77
313        HighlightRole::path => "fg=magenta",                                      // :70
314        HighlightRole::path_to_dir => "fg=magenta,underline",                     // :71
315        HighlightRole::history_expansion => "fg=blue,bold",                       // :75
316        HighlightRole::dollar_in_dquote => "fg=cyan",                             // :82
317        HighlightRole::variable => "fg=113",                                      // :86
318        HighlightRole::mathvar => "fg=blue,bold",                                 // :87
319        HighlightRole::mathnum => "fg=magenta",                                   // :88
320        HighlightRole::matherr => "fg=red",                                       // :89
321        HighlightRole::assign => "none",                                          // :84
322        HighlightRole::assign_array_bracket => "fg=green",                        // :90
323        HighlightRole::here_string_tri => "fg=yellow",                            // :96
324        HighlightRole::here_string_text => "bg=18",                               // :97
325        HighlightRole::single_sq_bracket | HighlightRole::double_sq_bracket => "fg=green", // :106-107
326        HighlightRole::double_paren => "fg=yellow",                                        // :108
327        HighlightRole::bracket_level_1 => "fg=green,bold",                                 // :103
328        HighlightRole::bracket_level_2 => "fg=yellow,bold",                                // :104
329        HighlightRole::bracket_level_3 => "fg=cyan,bold",                                  // :105
330        HighlightRole::case_input => "fg=green",                                           // :99
331        HighlightRole::case_parentheses => "fg=yellow",                                    // :100
332        HighlightRole::case_condition => "bg=blue",                                        // :101
333        HighlightRole::globbing_ext => "fg=13",                                            // :74
334        HighlightRole::here_string_var => "fg=cyan,bg=18",                                 // :98
335        HighlightRole::for_loop_variable => "none",                                        // :92
336        HighlightRole::for_loop_operator => "fg=yellow",                                   // :93
337        HighlightRole::for_loop_number => "fg=magenta",                                    // :94
338        HighlightRole::for_loop_separator => "fg=yellow,bold",                             // :95
339        HighlightRole::correct_subtle => "fg=12",                                          // :109
340        HighlightRole::incorrect_subtle => "fg=red",                                       // :110
341        _ => "none",
342    }
343}
344
345/// The `free` theme (themes/free.ini, materialised as
346/// `$FAST_WORK_DIR/secondary_theme.zsh`), which is what
347/// `FAST_HIGHLIGHT_STYLES[secondary]` names by default (fast-highlight:113).
348/// Used for the body of a command substitution.
349fn get_secondary_default_style(role: HighlightRole) -> &'static str {
350    match role {
351        HighlightRole::error => "fg=red,bold",
352        HighlightRole::command
353        | HighlightRole::precommand
354        | HighlightRole::alias_
355        | HighlightRole::suffix_alias
356        | HighlightRole::builtin_
357        | HighlightRole::function_
358        | HighlightRole::hashed_command
359        | HighlightRole::case_input => "fg=180",
360        HighlightRole::keyword => "fg=150",
361        HighlightRole::global_alias | HighlightRole::case_condition => "bg=19",
362        HighlightRole::path => "fg=166",
363        HighlightRole::path_to_dir => "fg=166,underline",
364        HighlightRole::operat | HighlightRole::globbing => "fg=112",
365        HighlightRole::history_expansion => "fg=blue,bold",
366        HighlightRole::option | HighlightRole::double_hyphen_option => "fg=110",
367        HighlightRole::quote | HighlightRole::dquoted | HighlightRole::dollar_quoted => "fg=150",
368        HighlightRole::escape | HighlightRole::dollar_in_dquote => "fg=110",
369        HighlightRole::comment => "fg=black,bold",
370        HighlightRole::variable => "none",
371        HighlightRole::mathvar => "fg=blue,bold",
372        HighlightRole::mathnum => "fg=166",
373        HighlightRole::matherr => "fg=red",
374        HighlightRole::assign_array_bracket => "fg=180",
375        HighlightRole::here_string_tri => "fg=yellow",
376        HighlightRole::here_string_text => "bg=19",
377        HighlightRole::single_sq_bracket | HighlightRole::double_sq_bracket => "fg=180",
378        HighlightRole::double_paren => "fg=150",
379        HighlightRole::case_parentheses => "fg=116",
380        HighlightRole::globbing_ext => "fg=118",
381        HighlightRole::here_string_var => "fg=110,bg=19",
382        HighlightRole::for_loop_variable => "none",
383        HighlightRole::for_loop_operator => "fg=150",
384        HighlightRole::for_loop_number => "fg=150",
385        HighlightRole::for_loop_separator => "fg=109",
386        HighlightRole::correct_subtle => "bg=55",
387        HighlightRole::incorrect_subtle => "bg=52",
388        HighlightRole::bracket_level_1 => "fg=130",
389        HighlightRole::bracket_level_2 => "fg=70",
390        HighlightRole::bracket_level_3 => "fg=69",
391        HighlightRole::autosuggestion => "fg=8",
392        HighlightRole::selection | HighlightRole::search_match => "standout",
393        _ => "none",
394    }
395}
396
397// fish:1230-1266 — Table used to fetch fallback highlights in case the specified one
398// wasn't set.
399fn get_fallback(role: HighlightRole) -> HighlightRole {
400    match role {
401        HighlightRole::normal
402        | HighlightRole::error
403        | HighlightRole::command
404        | HighlightRole::statement_terminator
405        | HighlightRole::param
406        | HighlightRole::search_match
407        | HighlightRole::comment
408        | HighlightRole::operat
409        | HighlightRole::escape
410        | HighlightRole::quote
411        | HighlightRole::redirection
412        | HighlightRole::autosuggestion
413        | HighlightRole::selection
414        | HighlightRole::pager_progress
415        | HighlightRole::pager_background
416        | HighlightRole::pager_prefix
417        | HighlightRole::pager_completion
418        | HighlightRole::pager_description => HighlightRole::normal,
419        // Every f-sy-h key carries its own default (fast-highlight:58-113 is a
420        // flat `: ${FAST_HIGHLIGHT_STYLES[key]:=…}` list with no inheritance),
421        // so these roles fall back to THEMSELVES: the resolver then collapses
422        // the chain to [role] and lands on the built-in default rather than
423        // borrowing whatever the user put in `default`.
424        HighlightRole::keyword
425        | HighlightRole::option
426        | HighlightRole::precommand
427        | HighlightRole::alias_
428        | HighlightRole::suffix_alias
429        | HighlightRole::global_alias
430        | HighlightRole::builtin_
431        | HighlightRole::function_
432        | HighlightRole::hashed_command
433        | HighlightRole::path
434        | HighlightRole::path_to_dir
435        | HighlightRole::globbing
436        | HighlightRole::history_expansion
437        | HighlightRole::double_hyphen_option
438        | HighlightRole::dquoted
439        | HighlightRole::dollar_quoted
440        | HighlightRole::dollar_in_dquote
441        | HighlightRole::variable
442        | HighlightRole::mathvar
443        | HighlightRole::mathnum
444        | HighlightRole::matherr
445        | HighlightRole::assign
446        | HighlightRole::assign_array_bracket
447        | HighlightRole::here_string_tri
448        | HighlightRole::here_string_text
449        | HighlightRole::single_sq_bracket
450        | HighlightRole::double_sq_bracket
451        | HighlightRole::double_paren
452        | HighlightRole::bracket_level_1
453        | HighlightRole::bracket_level_2
454        | HighlightRole::bracket_level_3
455        | HighlightRole::case_input
456        | HighlightRole::case_parentheses
457        | HighlightRole::case_condition
458        | HighlightRole::globbing_ext
459        | HighlightRole::here_string_var
460        | HighlightRole::for_loop_variable
461        | HighlightRole::for_loop_operator
462        | HighlightRole::for_loop_number
463        | HighlightRole::for_loop_separator
464        | HighlightRole::correct_subtle
465        | HighlightRole::incorrect_subtle => role,
466        HighlightRole::pager_secondary_background => HighlightRole::pager_background,
467        HighlightRole::pager_secondary_prefix | HighlightRole::pager_selected_prefix => {
468            HighlightRole::pager_prefix
469        }
470        HighlightRole::pager_secondary_completion | HighlightRole::pager_selected_completion => {
471            HighlightRole::pager_completion
472        }
473        HighlightRole::pager_secondary_description | HighlightRole::pager_selected_description => {
474            HighlightRole::pager_description
475        }
476        HighlightRole::pager_selected_background => HighlightRole::search_match,
477    }
478}
479
480/// fish:222-238 — `parse_text_face_for_highlight`, respelled: parse a z-sy-h style
481/// string ("fg=green,bold", "none", …) into a packed `zattr` via `match_highlight`
482/// (prompt.rs:3660). Returns None for empty/no-op specs so the role chain can fall
483/// through, mirroring fish's `get_unless_empty` + default-face check.
484fn parse_style_for_highlight(spec: &str) -> Option<zattr> {
485    if spec.is_empty() {
486        return None;
487    }
488    let (mask_on, _mask_off, _) = match_highlight(spec, None);
489    Some(mask_on)
490}
491
492/// Read `$ZSH_HIGHLIGHT_STYLES[key]`.
493///
494/// This looked like a flat key/value sequence and was read with
495/// `gethparam(…).chunks_exact(2)` — but `gethparam` is
496/// `paramvalarr(…, SCANPM_WANTVALS)` (params.rs:6300, zsh Src/params.c:3118):
497/// VALUES ONLY.  The chunk walk therefore compared a style string against the
498/// requested key, never matched, and every `$ZSH_HIGHLIGHT_STYLES` entry a user
499/// set was silently ignored.  Keys come from `gethkparam`
500/// (`SCANPM_WANTKEYS`, params.rs:6417); both scan the same hash in the same
501/// order, which is exactly what `${(kv)h}` relies on.
502fn zsh_highlight_styles_get(key: &str) -> Option<String> {
503    let keys = gethkparam("ZSH_HIGHLIGHT_STYLES")?;
504    let vals = gethparam("ZSH_HIGHLIGHT_STYLES")?;
505    keys.iter()
506        .zip(vals.iter())
507        .find(|(k, _)| k.as_str() == key)
508        .map(|(_, v)| v.clone())
509}
510
511/// fish:131-138 — highlight_color_resolver_t resolves highlight specs (like "a
512/// command") to actual attributes. It maintains a cache with no invalidation
513/// mechanism. The lifetime of these should typically be one screen redraw.
514#[derive(Default)]
515pub struct HighlightColorResolver {
516    /// fish:136-137 — `cache`.
517    cache: HashMap<HighlightSpec, zattr>,
518}
519
520impl HighlightColorResolver {
521    /// fish:144-147 — `new`.
522    pub fn new() -> Self {
523        Default::default()
524    }
525    /// fish:148-162 — Return a packed attribute for a given highlight spec.
526    pub fn resolve_spec(&mut self, highlight: &HighlightSpec) -> zattr {
527        match self.cache.entry(*highlight) {
528            Entry::Occupied(e) => *e.get(),
529            Entry::Vacant(e) => {
530                let face = Self::resolve_spec_uncached(highlight);
531                e.insert(face);
532                face
533            }
534        }
535    }
536    /// fish:163-219 — `resolve_spec_uncached`.
537    pub fn resolve_spec_uncached(highlight: &HighlightSpec) -> zattr {
538        // fish:164-182 — role → [role, fallback, normal] chain, first configured wins.
539        let secondary = highlight.secondary;
540        let resolve_role = |role: HighlightRole| -> zattr {
541            let mut roles: &[HighlightRole] = &[role, get_fallback(role), HighlightRole::normal];
542            for i in [2, 1] {
543                if roles[i - 1] == roles[i] {
544                    roles = &roles[..i];
545                }
546            }
547            for &role in roles {
548                // Autosuggestion config lives in zsh-autosuggestions' scalar param.
549                let configured = if role == HighlightRole::autosuggestion {
550                    getsparam("ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE").filter(|s| !s.is_empty())
551                } else if secondary {
552                    // f-sy-h keys the secondary theme by prefixing the theme
553                    // name (`$FAST_HIGHLIGHT_STYLES[freecommand]`); the z-sy-h
554                    // config surface this engine reads has no theme names, so
555                    // the same idea is spelled `secondary-<key>`.
556                    zsh_highlight_styles_get(&format!(
557                        "secondary-{}",
558                        get_highlight_style_key(role)
559                    ))
560                } else {
561                    zsh_highlight_styles_get(get_highlight_style_key(role))
562                };
563                if let Some(face) = configured.as_deref().and_then(parse_style_for_highlight) {
564                    return face;
565                }
566            }
567            // No user config anywhere in the chain: built-in default palette.
568            let spec = if secondary {
569                get_secondary_default_style(role)
570            } else {
571                get_default_style(role)
572            };
573            parse_style_for_highlight(spec).unwrap_or(0)
574        };
575        let mut face = resolve_role(highlight.foreground);
576
577        // fish:185-194 — background merge is a no-op here: zattr carries fg+bg in one
578        // bitmap and z-sy-h specs already say "bg=…" inline; resolve background role
579        // only when it differs and OR its bg bits in.
580        if highlight.background != highlight.foreground {
581            use crate::ported::zsh_h::{TXTBGCOLOUR, TXT_ATTR_BG_COL_MASK};
582            let bg_face = resolve_role(highlight.background);
583            face |= bg_face & (TXTBGCOLOUR as zattr | TXT_ATTR_BG_COL_MASK);
584        }
585
586        // fish:196-211 — valid_path modifier: merge the `path` style (z-sy-h key;
587        // default underline, matching fish_color_valid_path --underline).
588        if highlight.valid_path {
589            let path_spec = zsh_highlight_styles_get("path").unwrap_or_default();
590            let merged = if path_spec.is_empty() {
591                parse_style_for_highlight("underline")
592            } else {
593                parse_style_for_highlight(&path_spec)
594            };
595            if let Some(m) = merged {
596                face |= m;
597            }
598        }
599
600        // fish:213-216 — force_underline.
601        if highlight.force_underline {
602            face |= crate::ported::zsh_h::TXTUNDERLINE as zattr;
603        }
604
605        face
606    }
607}
608
609/// fish:68-91 — Given a string and list of colors of the same size, return the string
610/// with ANSI escape sequences representing the colors.
611pub fn colorize(text: &str, colors: &[HighlightSpec]) -> Vec<u8> {
612    let chars: Vec<char> = text.chars().collect();
613    assert_eq!(colors.len(), chars.len());
614    let mut rv = HighlightColorResolver::new();
615    let mut out: Vec<u8> = Vec::new();
616
617    let mut last_color: Option<HighlightSpec> = None;
618    for (i, &c) in chars.iter().enumerate() {
619        let color = colors[i];
620        if Some(color) != last_color {
621            let face = rv.resolve_spec(&color);
622            out.extend_from_slice(zattr_to_sgr(face).as_bytes());
623            last_color = Some(color);
624        }
625        // fish:84-86 — reset before a trailing newline.
626        if i + 1 == chars.len() && c == '\n' {
627            out.extend_from_slice(b"\x1b[0m");
628        }
629        let mut buf = [0u8; 4];
630        out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
631    }
632    out.extend_from_slice(b"\x1b[0m"); // fish:89
633    out
634}
635
636/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT FISH COUNTERPART !!!
637/// fish routes attribute output through its Outputter/terminfo stack; zshrs's
638/// ZLE painter consumes `zattr` directly (zle_refresh.rs `to_zattr`/`zwcputc`),
639/// so this SGR string form exists only for `colorize` (batch/CLI output).
640fn zattr_to_sgr(attr: zattr) -> String {
641    use crate::ported::zsh_h::{
642        TXTBGCOLOUR, TXTBOLDFACE, TXTFGCOLOUR, TXTSTANDOUT, TXTUNDERLINE, TXT_ATTR_BG_COL_SHIFT,
643        TXT_ATTR_FG_COL_SHIFT,
644    };
645    let mut s = String::from("\x1b[0");
646    if attr & TXTBOLDFACE as zattr != 0 {
647        s.push_str(";1");
648    }
649    if attr & TXTSTANDOUT as zattr != 0 {
650        s.push_str(";7");
651    }
652    if attr & TXTUNDERLINE as zattr != 0 {
653        s.push_str(";4");
654    }
655    if attr & TXTFGCOLOUR as zattr != 0 {
656        let col = (attr >> TXT_ATTR_FG_COL_SHIFT) & 0xffffff;
657        s.push_str(&format!(";38;5;{}", col));
658    }
659    if attr & TXTBGCOLOUR as zattr != 0 {
660        let col = (attr >> TXT_ATTR_BG_COL_SHIFT) & 0xffffff;
661        s.push_str(&format!(";48;5;{}", col));
662    }
663    s.push('m');
664    s
665}
666
667/// fish:93-113 — Perform syntax highlighting for the shell commands in buff. The
668/// result is stored in the color array as a HighlightSpec for each character in buff.
669///
670/// buffstr: the buffer (metafied, as the ZLE metaline) on which to perform syntax
671/// highlighting; ctx: cancellation check; io_ok: if set, allow IO which may block —
672/// e.g. invalid commands may be detected; cursor: cursor position in the commandline.
673/// Every registry a command word can dispatch as a builtin, asked in one
674/// place so the next registry added to the shell needs one edit, not one per
675/// highlighter site.
676///
677/// zshrs has FOUR of them and only the first is C's:
678///
679///   * `createbuiltintable()` — the ported `Src/builtin.c:40-137` table;
680///   * `EXT_BUILTIN_NAMES` — zshrs extension builtins (`provenance`,
681///     `dbview`, `zcache`, …), gated by `builtin_in_builtintab` so a
682///     `disable`d name or `--zsh` emulation takes them back out. Membership
683///     has to be tested FIRST: `builtin_in_builtintab` alone reports every
684///     unknown string as available (`builtin_owning_module` returns None and
685///     its `None => true` arm answers yes);
686///   * `ZSHRS_BUILTIN_NAMES` — the daemon `z*` family (`zjob`, `zls`, `zid`,
687///     `zping`, …), a SECOND extension registry that dispatches by name
688///     through `try_dispatch` and has no `createbuiltintable` entry. Every
689///     consumer has had to be patched for it one at a time (`builtin zjob`,
690///     `whence -w zjob`, `${(k)builtins}`); the highlighter was the next one,
691///     which is why `zjob` painted as an unknown token while `whence -v zjob`
692///     answered "zjob is a shell builtin" in the same shell;
693///   * `native_cmds` — builtins contributed by the linking binary, which is
694///     how the fat `zshrs-native` build gets `git` / `arb` / `stryke`. Uses
695///     `is_enabled`, not `is_registered`, so `disable git` un-highlights it
696///     the same way it un-dispatches it.
697fn builtin_word_available(word: &str) -> bool {
698    crate::ported::builtin::createbuiltintable().contains_key(word)
699        || (crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&word)
700            && crate::ext_builtins::builtin_in_builtintab(word))
701        || crate::daemon::builtins::is_zshrs_builtin(word)
702        || crate::native_cmds::is_enabled(word)
703}
704
705pub fn highlight_shell(
706    buff: &str,
707    color: &mut Vec<HighlightSpec>,
708    ctx: &OperationContext,
709    io_ok: bool,
710    cursor: Option<usize>,
711) {
712    // fish:110 — get_pwd_slash.
713    let working_directory = getsparam("PWD").unwrap_or_else(|| ".".to_owned());
714    let mut highlighter = Highlighter::new(buff, cursor, ctx, working_directory, io_ok);
715    *color = highlighter.highlight();
716}
717
718/// fish:114-129 — `highlight_and_colorize`.
719pub fn highlight_and_colorize(text: &str, ctx: &OperationContext) -> Vec<u8> {
720    let mut colors = Vec::new();
721    highlight_shell(
722        text,
723        &mut colors,
724        ctx,
725        /*io_ok=*/ false,
726        /*cursor=*/ None,
727    );
728    colorize(text, &colors)
729}
730
731/// fish parse_constants.rs `StatementDecoration` — the zsh precommand modifiers that
732/// restrict what the following command word may resolve to.
733#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
734pub enum StatementDecoration {
735    #[default]
736    None_,
737    Command, // `command cmd` / `exec cmd` — externals only
738    Builtin, // `builtin cmd` — builtins only
739    Exec,
740}
741
742/// Per-process command-validity cache. `findcmd` walks the whole $PATH on
743/// every miss, and while a command is being typed EVERY prefix is a miss
744/// ("g", "gi", "git" …) — uncached, that is a full PATH stat-walk per
745/// keystroke. fish eats this cost on a background thread; the synchronous
746/// zshrs pass must not. Fingerprinted by $PATH so `rehash`-style changes
747/// invalidate naturally; bounded so a pathological session can't grow it.
748static CMD_VALID_CACHE: Mutex<Option<(String, HashMap<(String, u8), bool>)>> = Mutex::new(None);
749const CMD_VALID_CACHE_MAX: usize = 8192;
750
751pub fn command_is_valid_cached(
752    cmd: &str,
753    decoration: StatementDecoration,
754    working_directory: &str,
755) -> bool {
756    // The table/PATH verdict is cacheable (fingerprinted by $PATH); the
757    // implicit-cd branch depends on the cwd and stays uncached — it is a
758    // single stat, the PATH walk is the expensive part.
759    let path_now = getsparam("PATH").unwrap_or_default();
760    let key = (cmd.to_owned(), decoration as u8);
761    let cached: Option<bool> = {
762        let mut guard = CMD_VALID_CACHE.lock().unwrap();
763        match guard.as_mut() {
764            Some((path, map)) if *path == path_now => map.get(&key).copied(),
765            _ => {
766                *guard = Some((path_now, HashMap::new()));
767                None
768            }
769        }
770    };
771    let tables_valid = cached.unwrap_or_else(|| {
772        let v = command_is_valid_tables(cmd, decoration);
773        let mut guard = CMD_VALID_CACHE.lock().unwrap();
774        if let Some((_, map)) = guard.as_mut() {
775            if map.len() >= CMD_VALID_CACHE_MAX {
776                map.clear();
777            }
778            map.insert(key, v);
779        }
780        v
781    });
782    if tables_valid {
783        return true;
784    }
785    // fish:292-295 — Implicit cd (zsh: AUTO_CD), uncached (cwd-relative).
786    if decoration == StatementDecoration::None_ && isset(AUTOCD) {
787        let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
788        return std::fs::metadata(&path)
789            .map(|m| m.is_dir())
790            .unwrap_or(false);
791    }
792    false
793}
794
795/// fish:240-299 — `command_is_valid`.
796pub fn command_is_valid(
797    cmd: &str,
798    decoration: StatementDecoration,
799    working_directory: &str,
800) -> bool {
801    if command_is_valid_tables(cmd, decoration) {
802        return true;
803    }
804    // fish:292-295 — Implicit cd (zsh: AUTO_CD); disabled by `command`/
805    // `builtin`/`exec` decorations (fish:252-267 implicit_cd_ok).
806    if decoration == StatementDecoration::None_ && isset(AUTOCD) {
807        let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
808        if std::fs::metadata(&path)
809            .map(|m| m.is_dir())
810            .unwrap_or(false)
811        {
812            return true;
813        }
814    }
815    // fish:297-298 — Return what we got.
816    false
817}
818
819/// fish:246-290 — the table/PATH checks of `command_is_valid` (everything except
820/// the cwd-dependent implicit-cd branch, split out so the cache can hold them).
821fn command_is_valid_tables(cmd: &str, decoration: StatementDecoration) -> bool {
822    // fish:246-267 — Determine which types we check, based on the decoration.
823    let mut builtin_ok = true;
824    let mut function_ok = true;
825    // fish's abbreviations are zsh's aliases.
826    let mut alias_ok = true;
827    let mut command_ok = true;
828    if matches!(
829        decoration,
830        StatementDecoration::Command | StatementDecoration::Exec
831    ) {
832        builtin_ok = false;
833        function_ok = false;
834        alias_ok = false;
835        command_ok = true;
836    } else if decoration == StatementDecoration::Builtin {
837        builtin_ok = true;
838        function_ok = false;
839        alias_ok = false;
840        command_ok = false;
841    }
842
843    // fish:269-270 — Check them.
844    let mut is_valid = false;
845
846    // fish:272-275 — Builtins. (Reserved words resolve at the lexer level and never
847    // reach here as STRING tokens, so no reswdtab check is needed.)
848    if !is_valid && builtin_ok {
849        is_valid = builtin_word_available(cmd);
850    }
851
852    // fish:277-280 — Functions.
853    if !is_valid && function_ok {
854        is_valid = getshfunc(cmd).is_some();
855    }
856
857    // fish:282-285 — Aliases (fish: abbreviations).
858    if !is_valid && alias_ok {
859        is_valid = aliastab_lock()
860            .read()
861            .map(|t| t.get(cmd).is_some())
862            .unwrap_or(false);
863    }
864
865    // fish:287-290 — Regular commands: hashed table first, then a PATH walk.
866    if !is_valid && command_ok {
867        is_valid = cmdnamtab_lock()
868            .read()
869            .map(|t| t.get(cmd).is_some())
870            .unwrap_or(false)
871            || findcmd(cmd, 0, 0).is_some();
872    }
873
874    is_valid
875}
876
877/// fast-highlight:1168 — `${+parameters[$name]}`: does a parameter of this name
878/// exist?  `getsparam` alone answers only for scalars, so ask the parameter
879/// table directly the way `typeset -p`'s lookup does.
880fn math_name_exists(name: &str) -> bool {
881    crate::ported::params::paramtab()
882        .read()
883        .map(|t| t.get(name).is_some())
884        .unwrap_or(false)
885}
886
887/// fast-highlight:1273 — `: ${expanded_path:=${(Q)~__arg}}`: quote removal plus
888/// tilde expansion, and NOTHING that can execute. Returns None when the word
889/// still carries an expansion or globbing marker (f-sy-h's `${(Q)~…}` leaves
890/// those unresolved and the `-e`/`-d` tests then fail).
891///
892/// This must NOT go through `expand_one_no_cmdsubst`: that calls `singsub`,
893/// which runs a command substitution mid-keystroke — typing
894/// `print "a $(ls) b"` executed `l` at the moment the buffer read
895/// `print "a $(l`.
896fn fsh_expand_for_path(tokenized: &str) -> Option<String> {
897    use crate::ported::zsh_h::{Bnull, Bnullkeep, Comma, Dash, Dnull, Nularg, Snull, Tilde};
898    let mut s = tokenized.to_owned();
899    // `=cmd` — zsh EQUALS expansion. `${(Q)~=ls}` yields the command's path, so
900    // f-sy-h styles `ls =ls` with a magenta `=ls`.
901    if let Some(rest) = s.strip_prefix(crate::ported::zsh_h::Equals) {
902        return crate::ported::exec::findcmd(rest, 0, 0);
903    }
904    if s.starts_with(Tilde) {
905        // Must go through the QuietErrs-wrapped helper: a bare `filesubstr`
906        // raises zsh's errflag for `~nosuchuser`, and an errflag raised while
907        // ZLE is repainting aborts the read loop (typing `ls ~root` froze the
908        // line at `ls ~`).
909        crate::zle_file_tester::expand_tilde_quiet(&mut s);
910    }
911    // zle_file_tester.rs:330-350 (`is_potential_path`) — the same
912    // quote-null-is-fine / anything-else-in-the-ITOK-range-is-magic split.
913    let mut out = String::new();
914    for c in s.chars() {
915        match c {
916            Snull | Dnull | Bnull | Bnullkeep | Nularg => (),
917            Tilde => out.push('~'),
918            Comma => out.push(','),
919            Dash => out.push('-'),
920            c if ('\u{e184}'..='\u{e1a1}').contains(&c) => return None,
921            c => out.push(c),
922        }
923    }
924    if out.is_empty() {
925        None
926    } else {
927        Some(out)
928    }
929}
930
931/// The subset of f-sy-h's chroma table (fast-highlight:171-232) that this
932/// engine implements.  The other 32 chromas — `git`, `grep`, `awk`, `docker`,
933/// `ssh`, `make`, `printf`, the `-subcommand.ch` family and the rest — are not
934/// ported: each is a separate argument-grammar script, and several of them
935/// shell out or write files on every keystroke.
936#[derive(Clone, Copy, PartialEq)]
937pub enum Chroma {
938    Autoload, // →chroma/-autoload.ch
939    Source,   // →chroma/-source.ch  (`source` and `.`)
940    Printf,   // →chroma/-printf.ch
941}
942
943impl Chroma {
944    fn for_command(cmd: &str) -> Option<Chroma> {
945        match cmd {
946            "autoload" => Some(Chroma::Autoload),
947            "source" | "." => Some(Chroma::Source),
948            "printf" => Some(Chroma::Printf),
949            _ => None,
950        }
951    }
952}
953
954/// -printf.ch:63 — the conversion pattern
955/// `%[#+ 0-]#[0-9]#([.][0-9]#)(#c0,1)[diouxXfFeEgGaAcsb]`, as (start, end)
956/// char ranges within a printf format word.
957fn printf_conversions(w: &[char]) -> Vec<(usize, usize)> {
958    const FLAGS: &str = "#+ 0-";
959    const CONV: &str = "diouxXfFeEgGaAcsb";
960    let mut out = Vec::new();
961    let mut i = 0usize;
962    while i < w.len() {
963        if w[i] != '%' {
964            i += 1;
965            continue;
966        }
967        let start = i;
968        let mut j = i + 1;
969        while j < w.len() && FLAGS.contains(w[j]) {
970            j += 1;
971        }
972        while j < w.len() && w[j].is_ascii_digit() {
973            j += 1;
974        }
975        if j < w.len() && w[j] == '.' {
976            j += 1;
977            while j < w.len() && w[j].is_ascii_digit() {
978                j += 1;
979            }
980        }
981        if j < w.len() && CONV.contains(w[j]) {
982            out.push((start, j + 1));
983            i = j + 1;
984        } else {
985            i = start + 1;
986        }
987    }
988    out
989}
990
991/// fast-highlight:884 — does the (tokenized) word contain a live quote?  The
992/// lexer answers this for us: a quote that survived as a `Snull`/`Dnull` token
993/// really opened a quoted section, while a `\"` is a plain character.
994fn fsh_has_quote(tokenized: &str) -> bool {
995    use crate::ported::zsh_h::{Dnull, Snull};
996    tokenized.chars().any(|c| c == Snull || c == Dnull)
997}
998
999/// fast-highlight:1045 — `*([^\\]##|"(#b)"|"(#B)"|"(#m)"|"(#c")*`: does the word
1000/// carry an EXTENDED glob operator?
1001fn fsh_is_globbing_ext(word: &str) -> bool {
1002    if word.contains("(#b)")
1003        || word.contains("(#B)")
1004        || word.contains("(#m)")
1005        || word.contains("(#c")
1006    {
1007        return true;
1008    }
1009    // A `##` that is not backslash-escaped.
1010    let c: Vec<char> = word.chars().collect();
1011    (1..c.len()).any(|i| c[i] == '#' && c[i - 1] == '#' && (i < 2 || c[i - 2] != '\\'))
1012}
1013
1014/// fast-highlight:562-563 — the option words that `command` / `exec` accept
1015/// while still leaving the NEXT word in command position: a `-` followed only
1016/// by characters from `set` (`-pvV-` for `command`, `-cla-` for `exec`).
1017fn fsh_afp_option(word: &str, set: &str) -> bool {
1018    let Some(rest) = word.strip_prefix('-') else {
1019        return false;
1020    };
1021    !rest.is_empty() && rest.chars().all(|c| set.contains(c))
1022}
1023
1024/// fast-highlight:121-130 — the value-1 entries of
1025/// `__FAST_HIGHLIGHT_TOKEN_TYPES` (`builtin`, `command`, `exec`, `nocorrect`,
1026/// `noglob`, `pkexec`), plus `sudo`/`doas`, which fast-highlight:620-623
1027/// special-cases into the same `precommand` style with the same
1028/// "next word is still a command" effect.
1029///
1030/// fish's equivalent set is only `command`/`builtin`/`exec` (its
1031/// `StatementDecoration`), so `sudo ls` left `ls` uncoloured and `nocorrect`
1032/// took the reserved-word face.
1033pub fn fsh_is_precommand(word: &str) -> bool {
1034    matches!(
1035        word,
1036        "builtin" | "command" | "exec" | "nocorrect" | "noglob" | "pkexec" | "sudo" | "doas"
1037    )
1038}
1039
1040/// fish:301-308 — `has_expand_reserved`: does the string still carry expansion
1041/// markers? zsh spelling: any lexer token char left in the ITOK range.
1042fn has_expand_reserved(s: &str) -> bool {
1043    s.chars().any(|wc| ('\u{e184}'..='\u{e1a1}').contains(&wc))
1044}
1045
1046/// fish:310-341 — Parse a command line. Return the first command, and the first
1047/// argument to that command (as a string), if any. This is used to validate
1048/// autosuggestions. fish parses an AST; zshrs walks the same token stream the
1049/// highlighter uses (continue_after_error + accept_incomplete are what
1050/// LEXFLAGS_ACTIVE provides).
1051pub fn autosuggest_parse_command(buff: &str) -> Option<(String, String)> {
1052    let toks = lex_line_tokens(buff);
1053    let mut cmd: Option<String> = None;
1054    let mut arg = String::new(); // fish:329
1055    for t in &toks {
1056        if t.tok == STRING_LEX {
1057            match &cmd {
1058                None if t.cmdpos => {
1059                    // fish:328 — expand the command word.
1060                    let text = t.clean_text();
1061                    let mut expanded = t.text.clone().unwrap_or_default();
1062                    if expand_one_no_cmdsubst(&mut expanded) && !expanded.is_empty() {
1063                        cmd = Some(expanded);
1064                    } else {
1065                        cmd = Some(text);
1066                    }
1067                }
1068                None => (),
1069                Some(_) => {
1070                    // fish:330-335 — Check if the first argument or redirection is,
1071                    // in fact, an argument.
1072                    if !t.in_redir {
1073                        arg = t.clean_text();
1074                    }
1075                    break;
1076                }
1077            }
1078        } else if cmd.is_some() {
1079            break; // separator/redirection ends the first statement's argument scan
1080        }
1081    }
1082    cmd.map(|c| (c, arg)) // fish:337
1083}
1084
1085/// fish:342-345 — `is_veritable_cd`: it's really `cd`, not something wrapping cd
1086/// (fish checks the completion wrap map; the zsh spelling is an alias named cd).
1087pub fn is_veritable_cd(expanded_command: &str) -> bool {
1088    expanded_command == "cd"
1089        && aliastab_lock()
1090            .read()
1091            .map(|t| t.get("cd").is_none())
1092            .unwrap_or(true)
1093}
1094
1095/// fish:347-356 — Given an item from the history which is a proposed autosuggestion,
1096/// return whether the autosuggestion is valid. It may not be valid if e.g. it is
1097/// attempting to cd into a directory which does not exist.
1098///
1099/// zsh adaptation: fish history items carry `required_paths` metadata; zsh history
1100/// has none, so callers pass an empty slice and only the cd/command checks apply.
1101pub fn autosuggest_validate_from_history(
1102    item_commandline: &str,
1103    required_paths: &[String],
1104    working_directory: &str,
1105    ctx: &OperationContext,
1106) -> bool {
1107    // fish:357 — background-thread assertion dropped (synchronous compute).
1108    // fish:359-364 — the multi-command suggested_range trim is handled by the caller
1109    // (zshrs suggests whole history lines only).
1110
1111    // fish:366-372 — Parse the string.
1112    let Some((parsed_command, mut cd_dir)) = autosuggest_parse_command(item_commandline) else {
1113        // This is for autosuggestions which are not decorated commands, e.g. function
1114        // declarations.
1115        return true;
1116    };
1117
1118    // fish:374-390 — We handle cd specially.
1119    if is_veritable_cd(&parsed_command) && !cd_dir.is_empty() {
1120        if expand_one_no_cmdsubst(&mut cd_dir) {
1121            if "--help".starts_with(&cd_dir) || "-h".starts_with(&cd_dir) {
1122                // fish:379-383 — cd --help is always valid.
1123                return true;
1124            } else {
1125                // fish:384-389 — Check the directory target, respecting CDPATH.
1126                // Permit the autosuggestion if the path is valid and not our directory.
1127                return crate::zle_file_tester::is_potential_cd_path(
1128                    &cd_dir,
1129                    /*at_cursor=*/ false,
1130                    working_directory,
1131                    ctx,
1132                    Default::default(),
1133                );
1134            }
1135        }
1136    }
1137
1138    // fish:392-398 — Not handled specially. Is the command valid?
1139    let cmd_ok = command_is_valid_cached(
1140        &parsed_command,
1141        StatementDecoration::None_,
1142        working_directory,
1143    );
1144    if !cmd_ok {
1145        return false;
1146    }
1147
1148    // fish:400-403 — Did the historical command have arguments that look like paths,
1149    // which aren't paths now?
1150    if !required_paths.is_empty() {
1151        let tester = FileTester::new(working_directory.to_owned(), ctx);
1152        if !required_paths.iter().all(|p| tester.test_path(p, false)) {
1153            return false;
1154        }
1155    }
1156
1157    true // fish:405
1158}
1159
1160/// zsh `$var` name characters (fish:common valid_var_name_char, zsh spelling:
1161/// alphanumeric or underscore — Src/params.c iident).
1162fn valid_var_name_char(c: char) -> bool {
1163    c.is_alphanumeric() || c == '_'
1164}
1165
1166fn valid_var_name(s: &str) -> bool {
1167    !s.is_empty()
1168        && !s.starts_with(|c: char| c.is_ascii_digit())
1169        && s.chars().all(valid_var_name_char)
1170}
1171
1172/// zsh single-character special parameters ($?, $#, $$, $!, $@, $*, $-, $0..$9…).
1173fn is_special_param_char(c: char) -> bool {
1174    matches!(c, '?' | '#' | '$' | '!' | '@' | '*' | '-' | '_') || c.is_ascii_digit()
1175}
1176
1177/// fish:408-471 — Highlights the variable starting with '$', setting colors within
1178/// the 'colors' array. Returns the number of characters consumed.
1179///
1180/// zsh respelling: `$name`, `$name[subscript]`, `${...}` (balanced), single-char
1181/// specials. fish's `$$var` chain and slice loop map to zsh's subscript span.
1182fn color_variable(inp: &[char], colors: &mut [HighlightSpec], role: HighlightRole) -> usize {
1183    assert_eq!(inp[0], '$');
1184
1185    let at = |i: usize| -> char { inp.get(i).copied().unwrap_or('\0') };
1186
1187    // fish:413-429 — Handle an initial run of $s.
1188    let mut idx = 0;
1189    let mut dollar_count = 0;
1190    while at(idx) == '$' {
1191        // Our color depends on the next char.
1192        let next = at(idx + 1);
1193        if next == '$' || valid_var_name_char(next) || is_special_param_char(next) {
1194            colors[idx] = HighlightSpec::with_fg(role);
1195        } else if next == '(' || next == '{' || next == '\'' {
1196            // zsh: $(cmdsub), ${param}, $'ansi-quote' — the '$' is an operator and the
1197            // construct is handled by the caller / string scanner.
1198            colors[idx] = HighlightSpec::with_fg(role);
1199            return idx + 1;
1200        } else {
1201            colors[idx] = HighlightSpec::with_fg(HighlightRole::error);
1202        }
1203        idx += 1;
1204        dollar_count += 1;
1205    }
1206
1207    // Single-char special param ($?, $#, …) — consume exactly one char.
1208    if idx == dollar_count && !valid_var_name_char(at(idx)) && is_special_param_char(at(idx)) {
1209        colors[idx] = HighlightSpec::with_fg(role);
1210        return idx + 1;
1211    }
1212
1213    // fish:431-445 — Handle a sequence of variable characters.
1214    // It may contain an escaped newline - see fish#8444.
1215    loop {
1216        if valid_var_name_char(at(idx)) {
1217            colors[idx] = HighlightSpec::with_fg(role);
1218            idx += 1;
1219        } else if at(idx) == '\\' && at(idx + 1) == '\n' {
1220            colors[idx] = HighlightSpec::with_fg(role);
1221            idx += 1;
1222            colors[idx] = HighlightSpec::with_fg(role);
1223            idx += 1;
1224        } else {
1225            break;
1226        }
1227    }
1228
1229    // fish:447-469 — Handle a subscript (fish: slice), up to dollar_count of them.
1230    // Note that we currently don't do any validation of the subscript's contents.
1231    for _slice_count in 0..dollar_count {
1232        match subscript_length(&inp[idx..]) {
1233            Some(slice_len) if slice_len > 0 => {
1234                colors[idx] = HighlightSpec::with_fg(role);
1235                colors[idx + slice_len - 1] = HighlightSpec::with_fg(role);
1236                idx += slice_len;
1237            }
1238            Some(_slice_len) => {
1239                // not a subscript
1240                break;
1241            }
1242            None => {
1243                // fish:460-467 — Syntax error: color the variable + the subscript
1244                // start red.
1245                colors[..=idx].fill(HighlightSpec::with_fg(HighlightRole::error));
1246                break;
1247            }
1248        }
1249    }
1250    idx
1251}
1252
1253/// fish parse_util `slice_length`, zsh respelling: length of a balanced
1254/// `[...]` subscript starting at input, 0 if input doesn't open one, None if
1255/// unbalanced.
1256fn subscript_length(inp: &[char]) -> Option<usize> {
1257    if inp.first() != Some(&'[') {
1258        return Some(0);
1259    }
1260    let mut depth = 0usize;
1261    for (i, &c) in inp.iter().enumerate() {
1262        match c {
1263            '[' => depth += 1,
1264            ']' => {
1265                depth -= 1;
1266                if depth == 0 {
1267                    return Some(i + 1);
1268                }
1269            }
1270            _ => (),
1271        }
1272    }
1273    None
1274}
1275
1276/// fish:473-691 — This function is a disaster badly in need of refactoring (fish's
1277/// words, kept for the record). It colors an argument or command, without regard to
1278/// command substitutions. Quoting rules are zsh's; structure is fish's.
1279pub fn color_string_internal(
1280    buffstr: &str,
1281    base_color: HighlightSpec,
1282    colors: &mut [HighlightSpec],
1283) {
1284    // fish:476-485 — Clarify what we expect.
1285    // fish restricts this to three faces; f-sy-h resolves a word to one of a
1286    // dozen styles (alias, path, globbing, variable, assign, …) before the
1287    // string scanner runs, so the base can be any of them.  What must NOT reach
1288    // here is a face the scanner would overwrite on every character.
1289    assert!(
1290        !matches!(
1291            base_color.foreground,
1292            HighlightRole::quote
1293                | HighlightRole::dquoted
1294                | HighlightRole::dollar_quoted
1295                | HighlightRole::escape
1296        ),
1297        "Unexpected base color"
1298    );
1299    let chars: Vec<char> = buffstr.chars().collect();
1300    let buff_len = chars.len();
1301    colors.fill(base_color);
1302
1303    // fish:489-493 — fish's %self special-case has no zsh spelling; dropped.
1304
1305    #[derive(Eq, PartialEq)]
1306    #[allow(dead_code)]
1307    enum Mode {
1308        unquoted,
1309        single_quoted,
1310        double_quoted,
1311        dollar_quoted, // zsh $'...' — fish's \u/\x/octal validation applies here
1312        backtick,      // zsh `...`
1313    }
1314    let mut mode = Mode::unquoted;
1315    let mut unclosed_quote_offset = None;
1316    let mut bracket_count = 0;
1317    let mut in_pos = 0;
1318    while in_pos < buff_len {
1319        let c = chars[in_pos];
1320        match mode {
1321            Mode::unquoted => {
1322                if c == '\\' {
1323                    // fish:509-593 colours a bare `\X` with its escape face.
1324                    // f-sy-h does not: outside a quote the backslash is part of
1325                    // the word and takes the word's own style
1326                    // (fast-highlight:1040-1090 has no backslash arm), so
1327                    // `print a\ b` was cyan here and plain in f-sy-h.  Still
1328                    // skip the escaped character so a `\'` or `\"` cannot open
1329                    // a quote.
1330                    in_pos += 1; // skip the escaped char; loop tail adds one more
1331                } else {
1332                    // fish:594-634 — Not a backslash.
1333                    //
1334                    // Everything fish paints per-character out here — `~`, `$`,
1335                    // `?`, `*`, `(`, `)`, `{`, `}`, `,`, `[`, `]` — is decided
1336                    // by f-sy-h at WORD granularity instead
1337                    // (fast-highlight:1040-1090: the whole word is `globbing`
1338                    // or `variable` or `path`), with the brackets pass painting
1339                    // bracket characters afterwards.  Painting them here as
1340                    // operators is what made `ls *.c` colour only the `*` and
1341                    // `print ${HOME}` colour only the punctuation.
1342                    match c {
1343                        '$' if chars.get(in_pos + 1) == Some(&'\'') => {
1344                            // zsh $'...' — enter dollar-quote mode; color both opener
1345                            // chars as quote (fast-highlight:879-882).
1346                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::dollar_quoted);
1347                            colors[in_pos + 1] =
1348                                HighlightSpec::with_fg(HighlightRole::dollar_quoted);
1349                            unclosed_quote_offset = Some(in_pos);
1350                            in_pos += 1;
1351                            mode = Mode::dollar_quoted;
1352                        }
1353                        '`' => {
1354                            // fast-highlight:81 — `back-quoted-argument` is
1355                            // `none`; only the contents are re-highlighted.
1356                            unclosed_quote_offset = Some(in_pos);
1357                            mode = Mode::backtick;
1358                        }
1359                        '\'' => {
1360                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
1361                            unclosed_quote_offset = Some(in_pos);
1362                            mode = Mode::single_quoted;
1363                        }
1364                        '"' => {
1365                            // fast-highlight:80 — the opening quote is part of
1366                            // the `double-quoted-argument` run, not the
1367                            // single-quoted one.
1368                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::dquoted);
1369                            unclosed_quote_offset = Some(in_pos);
1370                            mode = Mode::double_quoted;
1371                        }
1372                        _ => (), // fish:633 — we ignore all other characters
1373                    }
1374                }
1375            }
1376            // fish:637-653 — single quoted string, i.e 'foo'. zsh: NO escapes inside
1377            // single quotes; `''` inside is a literal ' only under RC_QUOTES (handled
1378            // as close+reopen, which colors identically).
1379            Mode::single_quoted => {
1380                colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
1381                if c == '\'' {
1382                    mode = Mode::unquoted;
1383                }
1384            }
1385            // fish:654-682 — double quoted string, i.e. "foo".
1386            Mode::double_quoted => {
1387                // fish:656-660 — subscripts are colored in advance, past `in_pos`,
1388                // and we don't want to overwrite that.
1389                if colors[in_pos] == base_color {
1390                    // fast-highlight:80 — `double-quoted-argument` is its own
1391                    // style key, distinct from `single-quoted-argument`.
1392                    colors[in_pos] = HighlightSpec::with_fg(HighlightRole::dquoted);
1393                }
1394                match c {
1395                    '"' => {
1396                        mode = Mode::unquoted;
1397                    }
1398                    '\\' if in_pos + 1 < buff_len => {
1399                        // fish:665-674 — zsh dquote escapes: \\ \" \$ \`.
1400                        let escaped_char = chars[in_pos + 1];
1401                        if matches!(escaped_char, '\\' | '"' | '$' | '`' | '\n') {
1402                            colors[in_pos] = HighlightSpec::with_fg(HighlightRole::escape);
1403                            colors[in_pos + 1] = HighlightSpec::with_fg(HighlightRole::escape);
1404                            in_pos += 1; // skip over backslash
1405                        }
1406                    }
1407                    '$' => {
1408                        // fast-highlight:82 / :1358 — a `$…` inside `"…"` takes
1409                        // `back-or-dollar-double-quoted-argument` (cyan), not
1410                        // the generic operator face.
1411                        in_pos += color_variable(
1412                            &chars[in_pos..],
1413                            &mut colors[in_pos..],
1414                            HighlightRole::dollar_in_dquote,
1415                        );
1416                        // fish:677-678 — Subtract one to account for the upcoming
1417                        // increment in the loop.
1418                        in_pos -= 1;
1419                    }
1420                    _ => (), // fish:680 — we ignore all other characters
1421                }
1422            }
1423            // zsh $'...' — ANSI-C quoting. fish's escape-sequence validation
1424            // (fish:538-591) applies in THIS mode for zsh.
1425            Mode::dollar_quoted => {
1426                // fast-highlight:81 — `dollar-quoted-argument`.
1427                colors[in_pos] = HighlightSpec::with_fg(HighlightRole::dollar_quoted);
1428                if c == '\\' && in_pos + 1 < buff_len {
1429                    let mut fill_color = HighlightRole::escape;
1430                    let backslash_pos = in_pos;
1431                    let mut fill_end = backslash_pos;
1432                    in_pos += 1;
1433                    let escaped_char = chars[in_pos];
1434                    if "abcefnrtv\\'\"?".contains(escaped_char) {
1435                        fill_end = in_pos + 1;
1436                    } else if "uUxX01234567".contains(escaped_char) {
1437                        // fish:538-591 — numeric escapes with base/width/max
1438                        // validation.
1439                        let mut res: u32 = 0;
1440                        let mut chars_max = 2;
1441                        let mut base = 16;
1442                        let mut max_val = 0x7f_u32; // ASCII_MAX
1443
1444                        match escaped_char {
1445                            'u' => {
1446                                chars_max = 4;
1447                                max_val = 0xFFFF; // UCS2_MAX
1448                                in_pos += 1;
1449                            }
1450                            'U' => {
1451                                chars_max = 8;
1452                                // fish:551-553 — Don't exceed the largest Unicode
1453                                // code point - see fish#1107.
1454                                max_val = 0x10FFFF;
1455                                in_pos += 1;
1456                            }
1457                            'x' | 'X' => {
1458                                max_val = 0xFF;
1459                                in_pos += 1;
1460                            }
1461                            _ => {
1462                                // fish:560-564 — a digit like \12.
1463                                base = 8;
1464                                chars_max = 3;
1465                            }
1466                        }
1467
1468                        // fish:567-577 — Consume.
1469                        let first_digit = in_pos;
1470                        for _i in 0..chars_max {
1471                            if in_pos == buff_len {
1472                                break;
1473                            }
1474                            let Some(d) = chars[in_pos].to_digit(base) else {
1475                                break;
1476                            };
1477                            res = res.saturating_mul(base).saturating_add(d);
1478                            in_pos += 1;
1479                        }
1480                        // fish:578-581 — in_pos is now at the first character that
1481                        // could not be converted (or buff_len).
1482                        fill_end = in_pos;
1483
1484                        // fish:583-586 errors when the escape exceeds the code
1485                        // point maximum.  f-sy-h's `-fast-highlight-dollar-string`
1486                        // (fast-highlight:1198-1230) validates only the SHAPE —
1487                        // `\x`/`\X`/`\u`/`\U` must be followed by at least one hex
1488                        // digit — and never the value, so `$'\U110000'` is a
1489                        // plain escape there and was red here.
1490                        let _ = max_val;
1491                        if res == 0
1492                            && in_pos == first_digit
1493                            && matches!(escaped_char, 'u' | 'U' | 'x' | 'X')
1494                        {
1495                            fill_color = HighlightRole::error;
1496                        }
1497
1498                        // fish:588-590 — Subtract one so the loop increment moves to
1499                        // the next character.
1500                        in_pos -= 1;
1501                    } else {
1502                        fill_end = in_pos + 1;
1503                    }
1504                    if fill_end > backslash_pos {
1505                        colors[backslash_pos..fill_end.min(buff_len)]
1506                            .fill(HighlightSpec::with_fg(fill_color));
1507                    } else {
1508                        colors[backslash_pos] = HighlightSpec::with_fg(fill_color);
1509                        colors[in_pos.min(buff_len - 1)] = HighlightSpec::with_fg(fill_color);
1510                    }
1511                } else if c == '\'' {
1512                    mode = Mode::unquoted;
1513                }
1514            }
1515            // zsh `...` backquote command substitution: color content as quote-ish,
1516            // delimiters as operators.
1517            Mode::backtick => {
1518                // fast-highlight:81 — `back-quoted-argument` is `none`: the
1519                // ticks and their contents keep the word's own base face here,
1520                // and the caller recurses into the body.  fish painted the ticks
1521                // as operators and the body as a quoted string.
1522                if c == '`' {
1523                    mode = Mode::unquoted;
1524                }
1525            }
1526        }
1527        in_pos += 1;
1528    }
1529
1530    // fish:687-690 marks the opening quote of an unterminated string with its
1531    // error face.  f-sy-h does not: an in-progress `print 'abc` is styled
1532    // `single-quoted-argument` all the way to the cursor
1533    // (fast-highlight:884-960 has no unterminated branch), and flagging it red
1534    // meant every string was red until its closing quote was typed.
1535    let _ = (mode, unclosed_quote_offset);
1536}
1537
1538// ---------------------------------------------------------------------------
1539// Token-stream driver — the zshrs replacement for fish's AST visit.
1540// ---------------------------------------------------------------------------
1541
1542/// One lexed token with its source span. Spans are char indices into the (metafied)
1543/// input line.
1544#[derive(Clone, Debug)]
1545pub struct TokSpan {
1546    pub tok: lextok,
1547    pub text: Option<String>,
1548    pub start: usize,
1549    pub end: usize,
1550    /// incmdpos() sampled BEFORE this token was lexed — is it in command position?
1551    pub cmdpos: bool,
1552    /// inredir() sampled before this token — is it a redirection target?
1553    pub in_redir: bool,
1554}
1555
1556impl TokSpan {
1557    /// Untokenized, quote-null-stripped text (what the user "meant").
1558    pub fn clean_text(&self) -> String {
1559        let mut s = self.text.clone().unwrap_or_default();
1560        crate::ported::glob::remnulargs(&mut s);
1561        untokenize(&s)
1562    }
1563}
1564
1565/// Lex a (metafied) line into spanned tokens, tolerating in-progress constructs.
1566///
1567/// Direct reuse of the completion machinery's lex pattern —
1568/// zle_tricky.rs:1357-1445 (zsh Src/Zle/zle_tricky.c:1157-1290):
1569/// `zcontext_save` → `LEXFLAGS_ZLE|LEXFLAGS_ACTIVE` → `inpush(dupstrspace(line))` →
1570/// `strinbeg(0)` → `ctxtlex()` loop → `strinend`/`inpop` → `zcontext_restore`.
1571///
1572/// Span arithmetic is the C completion formulas with addedx = 0:
1573///   start = zlemetall - wordbeg          (lex.c:1886 `nwb`)
1574///   end   = zlemetall + 1 - inbufct      (lex.c:1884 `nwe`)
1575///
1576/// ZLEMETACS is parked past every reachable `nwe` while lexing so `gotword`
1577/// (lex.rs:3042, lex.c:1882-1895) never clears LEXFLAGS_ZLE mid-line — wordbeg must
1578/// keep updating for EVERY token, not just up to a completion cursor. The completion
1579/// globals are saved/restored around the walk.
1580pub fn lex_line_tokens(line: &str) -> Vec<TokSpan> {
1581    use crate::ported::zle::compcore::{ADDEDX, ZLEMETACS, ZLEMETALL};
1582    use std::sync::atomic::Ordering;
1583
1584    let ll = line.chars().count() as i32;
1585    let mut out: Vec<TokSpan> = Vec::new();
1586
1587    // Save completion globals we're about to borrow.
1588    let saved_cs = ZLEMETACS.load(Ordering::SeqCst);
1589    let saved_ll = ZLEMETALL.load(Ordering::SeqCst);
1590    let saved_addedx = ADDEDX.load(Ordering::SeqCst);
1591
1592    // Alias expansion has to be OFF for this walk. `checkalias()` pushes the
1593    // alias BODY into the input stack, so `inbufct` (which the span arithmetic
1594    // subtracts from) counts the expansion's characters, not the typed word's:
1595    // a 7-character `myalias` came back as a 6-character span and the last
1596    // character stayed uncoloured. f-sy-h never expands either — it looks the
1597    // word up in `$aliases` and styles it (fast-highlight:300-303 / :668-676).
1598    let saved_noaliases = crate::ported::lex::noaliases();
1599    crate::ported::lex::set_noaliases(true);
1600
1601    crate::ported::context::zcontext_save(); // c:1169
1602                                             // LEX_UNGET_BUF isolation: hgetc drains this Rust-only side channel
1603                                             // BEFORE every input frame (lex.rs hgetc), and `$(...)` bodies use
1604                                             // it as a deliberate cross-context handoff — so zcontext_save
1605                                             // leaves it alone. This walk must isolate it manually: the
1606                                             // suspended outer parse's ungot chars must not be consumed as line
1607                                             // content, and the walk's own ungets must not leak back out.
1608    let saved_unget: std::collections::VecDeque<char> =
1609        crate::ported::lex::LEX_UNGET_BUF.with_borrow_mut(std::mem::take);
1610    ZLEMETALL.store(ll, Ordering::SeqCst);
1611    ZLEMETACS.store(ll + 5, Ordering::SeqCst); // park beyond max nwe = ll + 1
1612    ADDEDX.store(0, Ordering::SeqCst);
1613
1614    // c:1170 — see zle_tricky.rs:1369-1375 for why ACTIVE is OR'd in (tolerate
1615    // unterminated quote/backtick/brace while the user is mid-word).
1616    LEX_LEXFLAGS.set(LEXFLAGS_ZLE | LEXFLAGS_ACTIVE);
1617    crate::ported::input::inpush(&crate::ported::zle::zle_tricky::dupstrspace(line), 0, None); // c:1171
1618    crate::ported::hist::strinbeg(0); // c:1172
1619
1620    // No-progress guard: on an unterminated construct the lexer can re-yield
1621    // the same (retyped) LEXERR token without consuming input — inbufct stops
1622    // moving and the loop would spin, accumulating TokSpans without bound
1623    // (observed as a runaway-memory hang on `echo 'unterminated`). C's
1624    // completion loop never hits this because gotword ends its walk at the
1625    // cursor; this walk covers the whole line, so it must self-terminate.
1626    let mut prev_inbufct = i32::MIN;
1627    loop {
1628        let cmdpos_before = incmdpos();
1629        let inredir_before = inredir();
1630        ctxtlex(); // c:1213
1631
1632        // c:1215-1227 — LEXERR fixup: odd Snull/Dnull count means an unterminated
1633        // quote; treat as STRING so the in-progress word still gets colored.
1634        let mut tokv = tok();
1635        let was_lexerr = tokv == LEXERR;
1636        if tokv == LEXERR {
1637            match tokstr() {
1638                None => break,
1639                Some(ts) => {
1640                    use crate::ported::zsh_h::{Dnull, Snull};
1641                    let jcnt = ts.chars().filter(|&c| c == Snull || c == Dnull).count();
1642                    if jcnt & 1 == 1 {
1643                        tokv = STRING_LEX;
1644                    }
1645                }
1646            }
1647        }
1648
1649        if tokv == ENDINPUT {
1650            break; // c:1273
1651        }
1652
1653        let inbufct = crate::ported::input::inbufct.with(|c| c.get());
1654        let wordbeg = LEX_WORDBEG.get();
1655        let start = (ll - wordbeg).clamp(0, ll) as usize; // lex.c:1886
1656        let end = (ll + 1 - inbufct).clamp(start as i32, ll) as usize; // lex.c:1884
1657
1658        let no_progress = inbufct == prev_inbufct;
1659        prev_inbufct = inbufct;
1660
1661        out.push(TokSpan {
1662            tok: tokv,
1663            text: tokstr(),
1664            start,
1665            end,
1666            cmdpos: cmdpos_before,
1667            in_redir: inredir_before,
1668        });
1669
1670        // A LEXERR (even one retyped to STRING) that consumed no input can
1671        // never make progress; neither can any token stream longer than the
1672        // line has characters. Both are hard stops, not errors.
1673        if (was_lexerr && no_progress) || out.len() > (ll as usize + 8) {
1674            break;
1675        }
1676        if was_lexerr && inbufct <= 1 {
1677            break; // trailing dupstrspace space is all that remains
1678        }
1679    }
1680
1681    crate::ported::hist::strinend(); // c:1608
1682    crate::ported::input::inpop(); // c:1609
1683    crate::ported::context::zcontext_restore(); // c:1745
1684                                                // Put the suspended parse's ungot chars back, discarding anything
1685                                                // this walk left behind (see the isolation note at the top).
1686    crate::ported::lex::LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
1687
1688    crate::ported::lex::set_noaliases(saved_noaliases);
1689
1690    ZLEMETACS.store(saved_cs, Ordering::SeqCst);
1691    ZLEMETALL.store(saved_ll, Ordering::SeqCst);
1692    ADDEDX.store(saved_addedx, Ordering::SeqCst);
1693
1694    out
1695}
1696
1697/// fish:695-715 — Syntax highlighter helper.
1698pub struct Highlighter<'s> {
1699    // fish:697-698 — The string we're highlighting (metafied line).
1700    buff: &'s str,
1701    buff_chars: Vec<char>,
1702    // fish:699-700 — The position of the cursor within the string.
1703    cursor: Option<usize>,
1704    // fish:701-702 — The operation context.
1705    ctx: &'s OperationContext,
1706    // fish:703-704 — Whether it's OK to do I/O.
1707    io_ok: bool,
1708    // fish:705-706 — Working directory.
1709    working_directory: String,
1710    // fish:707-708 — Our component for testing strings for being potential file paths.
1711    file_tester: FileTester<'s>,
1712    // fish:709-710 — The resulting colors.
1713    color_array: ColorArray,
1714    // fish:711-713 — A stack of variables that the current commandline probably
1715    // defines. We mark redirections as valid if they use one of these variables, to
1716    // avoid marking valid targets as error.
1717    pending_variables: Vec<String>,
1718    /// fast-string-highlight:24 `_FAST_COMPLEX_BRACKETS` — char offsets of
1719    /// brackets the main pass already styled structurally (`[[`, `]]`, `((`,
1720    /// `))`, `[`, `]`, the parens of an array assignment).  The brackets pass
1721    /// skips these instead of repainting them by nesting level.
1722    complex_brackets: Vec<usize>,
1723    done: bool,
1724}
1725
1726impl<'s> Highlighter<'s> {
1727    /// fish:718-738 — `new`.
1728    pub fn new(
1729        buff: &'s str,
1730        cursor: Option<usize>,
1731        ctx: &'s OperationContext,
1732        working_directory: String,
1733        can_do_io: bool,
1734    ) -> Self {
1735        let file_tester = FileTester::new(working_directory.clone(), ctx);
1736        Self {
1737            buff,
1738            buff_chars: buff.chars().collect(),
1739            cursor,
1740            ctx,
1741            io_ok: can_do_io,
1742            working_directory,
1743            file_tester,
1744            color_array: vec![],
1745            pending_variables: vec![],
1746            complex_brackets: vec![],
1747            done: false,
1748        }
1749    }
1750
1751    /// fish:739-788 — `highlight`.
1752    pub fn highlight(&mut self) -> ColorArray {
1753        assert!(!self.done);
1754        self.done = true;
1755
1756        self.color_array
1757            .resize(self.buff_chars.len(), HighlightSpec::default());
1758
1759        // fish:752-761 — parse. The lexer flags LEXFLAGS_ZLE|ACTIVE inside
1760        // lex_line_tokens are the zsh spelling of fish's continue_after_error +
1761        // accept_incomplete_tokens + leave_unterminated.
1762        let toks = lex_line_tokens(self.buff);
1763
1764        self.visit_tokens(&toks);
1765        if self.ctx.check_cancel() {
1766            return std::mem::take(&mut self.color_array);
1767        }
1768
1769        // fish:768-772 — Color every comment. The zsh lexer consumes comments as
1770        // whitespace, so recover them from inter-token gaps: an unquoted '#' at a
1771        // word boundary starts a comment when INTERACTIVE_COMMENTS is set.
1772        if isset(INTERACTIVECOMMENTS) {
1773            self.color_gap_comments(&toks);
1774        }
1775
1776        // fish:782-785 — Color every error range: a LEXERR token that survived the
1777        // unterminated-quote fixup is a real lex error.
1778        for t in toks.iter().filter(|t| t.tok == LEXERR) {
1779            self.color_span(t.start, self.buff_chars.len(), HighlightRole::error);
1780        }
1781
1782        // fast-syntax-highlighting.plugin.zsh:96-98 — after the main pass,
1783        // `-fast-highlight-string-process` repaints every bracket by nesting
1784        // level.  fish has nothing like it; without it `(ls)`, `{ ls }`,
1785        // `${HOME}`, `$path[1]` and `*(.)` all came out the wrong colour.
1786        self.highlight_brackets();
1787
1788        std::mem::take(&mut self.color_array)
1789    }
1790
1791    fn io_still_ok(&self) -> bool {
1792        // fish:797-799
1793        self.io_ok && !self.ctx.check_cancel()
1794    }
1795
1796    // fish:876-880 — Colors a range with a given color.
1797    fn color_span(&mut self, start: usize, end: usize, role: HighlightRole) {
1798        let end = end.min(self.color_array.len());
1799        if start < end {
1800            self.color_array[start..end].fill(HighlightSpec::with_fg(role));
1801        }
1802    }
1803
1804    /// Source text of a span.
1805    fn span_text(&self, t: &TokSpan) -> String {
1806        self.buff_chars[t.start.min(self.buff_chars.len())..t.end.min(self.buff_chars.len())]
1807            .iter()
1808            .collect()
1809    }
1810
1811    /// The zshrs replacement for fish's NodeVisitor::visit (fish:1157-1181):
1812    /// walk the token stream, tracking command position, decoration, cd, `--`,
1813    /// assignments and redirections.
1814    fn visit_tokens(&mut self, toks: &[TokSpan]) {
1815        let mut decoration = StatementDecoration::None_;
1816        // A precommand modifier (`command`, `builtin`, `exec`, `noglob`,
1817        // `nocorrect`) is itself the cmdpos word, so the REAL command word
1818        // that follows it is not flagged cmdpos by the lexer. fish's comment
1819        // at the decoration arms says "keep looking for the real command
1820        // word"; without this flag that never happened, so `command id` left
1821        // `id` unvisited — neither validated nor coloured (an invalid word
1822        // like `command zzqwx` was equally uncoloured, so the error case was
1823        // invisible too).
1824        let mut after_decoration = false;
1825        let mut expanded_cmd = String::new();
1826        let mut is_cd = false;
1827        let mut is_typeset = false;
1828        let mut have_dashdash = false;
1829        // fast-highlight:385/478/492 — `highlight_glob` starts at 1 for every
1830        // command and is cleared by `noglob`, so `noglob echo *` leaves the
1831        // `*` plain instead of painting it as a glob.
1832        let mut highlight_glob = true;
1833        // fast-highlight:1004-1008 — `<<<` sets BIT_here_string (128) on the
1834        // NEXT word, which is then painted `here-string-text` rather than
1835        // treated as a filename.
1836        let mut here_string_word = false;
1837        // fast-highlight:701-710 — `name=(` opens an array assignment; the
1838        // matching `)` is an `assign-array-bracket`, not a subshell paren.
1839        let mut in_array_assignment = false;
1840        // fast-highlight:659-664 — `for` and `function` put a NAME, not a
1841        // command, in the next word slot, and `case`'s next word is the
1842        // subject expression.  Without this the zsh lexer's `incmdpos` flag
1843        // routes `in` (of `for i in …`) and `f` (of `function f`) through the
1844        // command-word validator, which paints them unknown-token red.
1845        let mut name_word = false;
1846        // fast-highlight:659-660 — BIT_for: from `for` up to the `;`, every word
1847        // is a loop variable or list element, never a command.
1848        let mut for_mode = false;
1849        // fast-highlight:662-663 / :488-500 — the `case` word ladder:
1850        // subject → `in` → pattern → `)` → body → `;;` → pattern …
1851        #[derive(PartialEq, Clone, Copy)]
1852        enum CaseState {
1853            None_,
1854            Subject,
1855            In,
1856            Pattern,
1857            Body,
1858        }
1859        let mut case_state = CaseState::None_;
1860        // fast-highlight:543-557 — after `sudo`/`doas`, `-x` words stay options
1861        // and command position slides along; the flags in `-Cgprtu` swallow one
1862        // argument first (BIT_sudo_arg).
1863        let mut sudo_opt = false;
1864        let mut sudo_arg = false;
1865        // fast-highlight:561-563 (BIT_afpcmd) — `command -pvV…` / `exec -cla…`
1866        // likewise keep command position.
1867        let mut afp: Option<String> = None;
1868        // fast-highlight:1188-1199 — `repeat` swallows its count word like a
1869        // redirection target and leaves `this_word = 3`, which makes the NEXT
1870        // word resolve as a command but style an UNRESOLVED one `default`
1871        // instead of `unknown-token` (fast-highlight:795-796, `this_word & 14`).
1872        let mut repeat_count = false;
1873        let mut soft_unknown = false;
1874        // fast-highlight:653-654 — inside `[[ … ]]` nothing is a command word.
1875        let mut in_cond = false;
1876        // fast-highlight:612-615 — `always` is only a reserved word right after
1877        // the `}` that closed a `{ … }` block.
1878        let mut after_close_brace = false;
1879        // fast-highlight:686 — `eval` sets BIT_eval (256); fast-highlight:884-899
1880        // then re-highlights each quoted argument's BODY under the secondary
1881        // theme, leaving the quotes themselves unstyled.
1882        let mut eval_mode = false;
1883        // fast-highlight:171-232 — `FAST_HIGHLIGHT[chroma-<cmd>]`: per-command
1884        // argument highlighters.  Only the two that a zshrc is built out of are
1885        // ported (see `Chroma`); the other 32 are not.
1886        let mut chroma: Option<Chroma> = None;
1887        let mut chroma_words = 0usize;
1888        let mut chroma_skip = false;
1889        let mut i = 0;
1890        while i < toks.len() {
1891            if self.ctx.check_cancel() {
1892                return;
1893            }
1894            let t = &toks[i];
1895            let tokv = t.tok;
1896
1897            if IS_REDIROP(tokv) {
1898                // fast-highlight:1004-1008 — `<<<` is a here-string triple, not
1899                // a redirection to a file: the operator takes `here-string-tri`
1900                // and the word after it is text, not a filename.
1901                if tokv == TRINANG {
1902                    self.color_span(t.start, t.end, HighlightRole::here_string_tri);
1903                    here_string_word = true;
1904                    i += 1;
1905                    continue;
1906                }
1907                // fish:962-1014 — redirection operator + target.
1908                let target = toks.get(i + 1).filter(|n| n.tok == STRING_LEX);
1909                self.visit_redirection(t, target);
1910                if target.is_some() {
1911                    i += 2;
1912                } else {
1913                    i += 1;
1914                }
1915                continue;
1916            }
1917
1918            match tokv {
1919                // fish:912-921 — End/Pipe/Background/AndAnd/OrOr.
1920                //
1921                // fast-highlight:152-158 lists `|`, `||`, `;`, `&`, `&&`, `|&`,
1922                // `&!`, `&|` as ONE class (__arg_type 3) painted
1923                // `commandseparator`, whose default is `none`
1924                // (fast-highlight:68) — fish splits them into a terminator face
1925                // and an operator face, and this engine's operator face is the
1926                // glob colour, so `ls && ls` came out with a blue `&&`.
1927                SEPER | NEWLIN | SEMI | AMPER | AMPERBANG | BAR_TOK | BARAMP | DBAR | DAMPER => {
1928                    self.color_span(t.start, t.end, HighlightRole::statement_terminator);
1929                    decoration = StatementDecoration::None_;
1930                    expanded_cmd.clear();
1931                    is_cd = false;
1932                    is_typeset = false;
1933                    have_dashdash = false;
1934                    highlight_glob = true; // fast-highlight:492
1935                    name_word = false;
1936                    for_mode = false; // fast-highlight:482
1937                    sudo_opt = false;
1938                    sudo_arg = false;
1939                    afp = None;
1940                    repeat_count = false;
1941                    soft_unknown = false;
1942                    after_close_brace = false;
1943                    eval_mode = false;
1944                    chroma = None;
1945                    chroma_words = 0;
1946                    chroma_skip = false;
1947                }
1948                DSEMI | SEMIAMP | SEMIBAR => {
1949                    // fast-highlight:1058-1060 — `;;` / `;&` / `;|` end a case
1950                    // item and put the next word back in pattern position.
1951                    if case_state == CaseState::Body {
1952                        case_state = CaseState::Pattern;
1953                    }
1954                    expanded_cmd.clear();
1955                    is_cd = false;
1956                    is_typeset = false;
1957                    have_dashdash = false;
1958                    highlight_glob = true;
1959                }
1960                INPAR_TOK | OUTPAR_TOK => {
1961                    // fast-highlight:785-793 — a subshell paren is styled
1962                    // `reserved-word`; fast-highlight:840-847 — the `)` closing
1963                    // an array assignment is an `assign-array-bracket`.  Both
1964                    // are then overpainted by the brackets pass unless they were
1965                    // recorded as "complex" (fast-string-highlight:24).
1966                    if tokv == OUTPAR_TOK && in_array_assignment {
1967                        // fast-highlight:840-847 — the `)` closing an array
1968                        // assignment.
1969                        in_array_assignment = false;
1970                        self.color_span(t.start, t.end, HighlightRole::assign_array_bracket);
1971                        self.complex_brackets.push(t.start);
1972                    } else if tokv == OUTPAR_TOK && case_state == CaseState::Pattern {
1973                        // fast-highlight:100 — the `)` closing a case pattern is
1974                        // `case-parentheses`, and it is not a bracket pair the
1975                        // brackets pass should claim.
1976                        case_state = CaseState::Body;
1977                        // fast-highlight:491-493 — the word after `)` is back in
1978                        // command position; zsh's lexer does not set `incmdpos`
1979                        // there, so hand it over explicitly.
1980                        after_decoration = true;
1981                        self.color_span(t.start, t.end, HighlightRole::case_parentheses);
1982                        self.complex_brackets.push(t.start);
1983                    } else {
1984                        self.color_span(t.start, t.end, HighlightRole::keyword);
1985                    }
1986                }
1987                ENVSTRING => {
1988                    // fish:1016-1025 — visit_variable_assignment.
1989                    self.visit_variable_assignment(t);
1990                }
1991                ENVARRAY => {
1992                    // zsh `a=(…)`: the ENVARRAY span is `name=(`. fast-highlight:
1993                    // 701-710 paints the word `assign` and the `(` itself
1994                    // `assign-array-bracket`, and records the paren as complex so
1995                    // the brackets pass leaves it alone.
1996                    self.visit_variable_assignment(t);
1997                    self.mark_assign_parens(t, &mut in_array_assignment);
1998                }
1999                STRING_LEX if here_string_word => {
2000                    // fast-highlight:1115-1118 — the word after `<<<` is
2001                    // `here-string-text`, then `-fast-highlight-string` paints
2002                    // any `$…` inside it `here-string-var`.
2003                    here_string_word = false;
2004                    self.color_span(t.start, t.end, HighlightRole::here_string_text);
2005                    let n = self.color_array.len();
2006                    let (lo, hi) = (t.start.min(n), t.end.min(n));
2007                    let body: Vec<char> = self.buff_chars[lo..hi].to_vec();
2008                    let mut k = 0usize;
2009                    while k < body.len() {
2010                        if body[k] == '$' {
2011                            k += color_variable(
2012                                &body[k..],
2013                                &mut self.color_array[lo + k..hi],
2014                                HighlightRole::here_string_var,
2015                            );
2016                        } else {
2017                            k += 1;
2018                        }
2019                    }
2020                }
2021                STRING_LEX if case_state == CaseState::Subject => {
2022                    // fast-highlight:99 — the word after `case` is `case-input`.
2023                    case_state = CaseState::In;
2024                    self.color_span(t.start, t.end, HighlightRole::case_input);
2025                }
2026                STRING_LEX if case_state == CaseState::In => {
2027                    // The literal `in` of `case … in`; f-sy-h resolves it through
2028                    // `-fast-highlight-main-type`, which finds it in `$reswords`
2029                    // (fast-highlight:312-313) and paints `reserved-word`.
2030                    case_state = CaseState::Pattern;
2031                    self.color_span(t.start, t.end, HighlightRole::keyword);
2032                }
2033                STRING_LEX if case_state == CaseState::Pattern => {
2034                    // fast-highlight:101 — `case-condition`, a background style.
2035                    let n = self.color_array.len();
2036                    let (lo, hi) = (t.start.min(n), t.end.min(n));
2037                    self.color_array[lo..hi]
2038                        .fill(HighlightSpec::with_bg(HighlightRole::case_condition));
2039                }
2040                STRING_LEX if for_mode || name_word => {
2041                    // fast-highlight:659-664 — a `for` list element, a `for` /
2042                    // `function` NAME and the `in` of a for-list are never
2043                    // command words, but they DO take the ordinary non-command
2044                    // dispatch (`this_word & 14` at fast-highlight:793), so a
2045                    // glob in `for f in *.c` still paints as a glob.
2046                    name_word = false;
2047                    self.visit_argument(t, false, true, highlight_glob);
2048                }
2049                STRING_LEX if repeat_count => {
2050                    // fast-highlight:1191-1198 — the repeat-count word is
2051                    // consumed like a redirection target: no styling at all.
2052                    repeat_count = false;
2053                    soft_unknown = true;
2054                    after_decoration = true;
2055                }
2056                STRING_LEX if sudo_arg => {
2057                    // fast-highlight:559-560 — the argument of `sudo -u`, `-g`, …
2058                    sudo_arg = false;
2059                    sudo_opt = true;
2060                    after_decoration = true;
2061                }
2062                STRING_LEX if sudo_opt && t.clean_text().starts_with('-') => {
2063                    // fast-highlight:546-557
2064                    let clean = t.clean_text();
2065                    self.color_span(
2066                        t.start,
2067                        t.end,
2068                        if clean.starts_with("--") {
2069                            HighlightRole::double_hyphen_option
2070                        } else {
2071                            HighlightRole::option
2072                        },
2073                    );
2074                    if matches!(clean.as_str(), "-C" | "-g" | "-p" | "-r" | "-t" | "-u") {
2075                        sudo_arg = true;
2076                        sudo_opt = false;
2077                    }
2078                    after_decoration = true;
2079                }
2080                STRING_LEX
2081                    if afp.as_deref() == Some("command")
2082                        && fsh_afp_option(&t.clean_text(), "pvV-") =>
2083                {
2084                    // fast-highlight:562 — `command -p|-v|-V|--`
2085                    self.color_span(t.start, t.end, HighlightRole::option);
2086                    after_decoration = true;
2087                }
2088                STRING_LEX
2089                    if afp.as_deref() == Some("exec")
2090                        && fsh_afp_option(&t.clean_text(), "cla-") =>
2091                {
2092                    // fast-highlight:563 — `exec -c|-l|-a|-`
2093                    self.color_span(t.start, t.end, HighlightRole::option);
2094                    after_decoration = true;
2095                }
2096                STRING_LEX if after_close_brace && t.clean_text() == "always" => {
2097                    // fast-highlight:612-615 — the `always` of a try-always
2098                    // block: "de facto a reserved word, although not de jure".
2099                    self.color_span(t.start, t.end, HighlightRole::keyword);
2100                    after_decoration = true;
2101                }
2102                STRING_LEX if after_close_brace && t.clean_text() == "{" => {
2103                    // fast-highlight:471 — after a delimiter (`}`, `))`, `]]`)
2104                    // a `{` is on command position again. zsh's lexer hands this
2105                    // one back as a plain STRING (it is past `always`, so its
2106                    // `incmdpos` is already false), so the brace has to be
2107                    // recognised here or the block's first word never gets
2108                    // validated.
2109                    after_close_brace = false;
2110                    self.color_span(t.start, t.end, HighlightRole::keyword);
2111                    expanded_cmd.clear();
2112                    after_decoration = true;
2113                }
2114                STRING_LEX => {
2115                    after_close_brace = false;
2116                    if in_cond {
2117                        // fast-highlight:813 — inside `[[ … ]]` every word takes
2118                        // the non-command dispatch.
2119                        self.visit_argument(t, false, true, highlight_glob);
2120                    } else if (t.cmdpos || after_decoration) && expanded_cmd.is_empty() {
2121                        // fish:1032-1073 — visit_decorated_statement (command word).
2122                        let clean = t.clean_text();
2123                        if clean == "noglob" {
2124                            highlight_glob = false; // fast-highlight:478
2125                        }
2126                        // fish:1033-1036 — color any decoration and keep looking
2127                        // for the real command word.
2128                        //
2129                        // fast-highlight:616-623 — a precommand modifier takes
2130                        // the `precommand` style and hands command position to
2131                        // the next word.  The set is the value-1 entries of
2132                        // `__FAST_HIGHLIGHT_TOKEN_TYPES` (fast-highlight:
2133                        // 125-130) plus `sudo`/`doas` (fast-highlight:620).
2134                        if toks.get(i + 1).map(|n| n.tok) == Some(INOUTPAR) {
2135                            // fast-highlight:865-868 — `name()` is a function
2136                            // DEFINITION: the `()` arm does `reply[-1]=()`,
2137                            // dropping the unknown-token style the name would
2138                            // otherwise have taken (the function does not exist
2139                            // yet, so validating it always fails).
2140                            after_decoration = false;
2141                        } else if fsh_is_precommand(&clean) {
2142                            decoration = match clean.as_str() {
2143                                "command" => StatementDecoration::Command,
2144                                "builtin" => StatementDecoration::Builtin,
2145                                "exec" => StatementDecoration::Exec,
2146                                _ => decoration,
2147                            };
2148                            if matches!(clean.as_str(), "sudo" | "doas") {
2149                                sudo_opt = true; // fast-highlight:620-623
2150                            }
2151                            if matches!(clean.as_str(), "command" | "exec") {
2152                                afp = Some(clean.clone()); // fast-highlight:617
2153                            }
2154                            self.color_span(t.start, t.end, HighlightRole::precommand);
2155                            after_decoration = true;
2156                        } else {
2157                            let soft = std::mem::take(&mut soft_unknown);
2158                            self.visit_command_word_soft(t, &clean, decoration, soft);
2159                            after_decoration = false;
2160                            expanded_cmd = clean;
2161                            is_cd = is_veritable_cd(&expanded_cmd);
2162                            eval_mode = expanded_cmd == "eval"; // fast-highlight:686
2163                            chroma = Chroma::for_command(&expanded_cmd);
2164                            chroma_words = 0;
2165                            chroma_skip = false;
2166                            is_typeset = matches!(
2167                                expanded_cmd.as_str(),
2168                                "typeset"
2169                                    | "local"
2170                                    | "declare"
2171                                    | "export"
2172                                    | "readonly"
2173                                    | "integer"
2174                                    | "float"
2175                            );
2176                        }
2177                    } else {
2178                        // fish:932-960 — visit_argument.
2179                        if is_typeset {
2180                            // fish:1078-1088 — `set` (zsh: typeset family) defines
2181                            // variables; remember them for redirection validation.
2182                            let arg = t.clean_text();
2183                            let name = arg.split('=').next().unwrap_or("").to_owned();
2184                            if valid_var_name(&name) {
2185                                self.pending_variables.push(name);
2186                            }
2187                        }
2188                        if let Some(c) = chroma {
2189                            if self.visit_chroma_argument(t, c, &mut chroma_words, &mut chroma_skip)
2190                            {
2191                                i += 1;
2192                                continue;
2193                            }
2194                            self.visit_argument(t, is_cd, !have_dashdash, highlight_glob);
2195                        } else if eval_mode && self.visit_eval_argument(t) {
2196                            // handled: the quoted body was re-highlighted
2197                        } else if is_typeset {
2198                            // fast-highlight:667 / :698 — `typeset` and friends
2199                            // push 'T' onto `braces_stack`, and while it is
2200                            // there EVERY following word is an `assign`
2201                            // (default `none`), options included: f-sy-h paints
2202                            // `typeset -g x` with a plain `-g`, not a cyan one.
2203                            self.color_span(t.start, t.end, HighlightRole::assign);
2204                            self.mark_assign_parens(t, &mut in_array_assignment);
2205                        } else {
2206                            self.visit_argument(t, is_cd, !have_dashdash, highlight_glob);
2207                        }
2208                        if self.span_text(t) == "--" {
2209                            have_dashdash = true; // fish:1091-1093
2210                        }
2211                    }
2212                }
2213                DINBRACK | DOUTBRACK => {
2214                    // fast-highlight:640 / :820 — `[[` and `]]` have their own
2215                    // style key, and both are recorded as "complex" brackets so
2216                    // the brackets pass does not repaint them
2217                    // (fast-highlight:653-654, :826-827).
2218                    self.color_span(t.start, t.end, HighlightRole::double_sq_bracket);
2219                    self.complex_brackets.push(t.start);
2220                    self.complex_brackets.push(t.start + 1);
2221                    in_cond = tokv == DINBRACK;
2222                }
2223                INOUTPAR => {
2224                    // fast-highlight:860-868 — `()`, an anonymous function or a
2225                    // function-definition marker: `reserved-word`, and recorded
2226                    // complex so the brackets pass does not claim it.
2227                    self.color_span(t.start, t.end, HighlightRole::keyword);
2228                    self.complex_brackets.push(t.start);
2229                    self.complex_brackets.push(t.start + 1);
2230                    expanded_cmd.clear();
2231                    after_decoration = true;
2232                }
2233                DINPAR | DOUTPAR => {
2234                    // fast-highlight:765-780 — `((` and `))` are `double-paren`,
2235                    // recorded complex so the brackets pass leaves them alone,
2236                    // and the expression between them goes through
2237                    // `-fast-highlight-math-string` (fast-highlight:771).
2238                    //
2239                    // zsh's lexer does NOT hand this back as one token: a
2240                    // `for ((i=0;i<3;i++))` header arrives as DINPAR `((`,
2241                    // DINPAR `i=0;`, DINPAR `i<3;`, DOUTPAR `i++))` — one token
2242                    // per `;`-separated clause, only the first carrying the
2243                    // opening `((` and only the last the closing `))`.  Both
2244                    // delimiters therefore have to be located in the source
2245                    // rather than assumed at the token edges.
2246                    let n = self.buff_chars.len();
2247                    let mut body_lo = t.start.min(n);
2248                    let mut body_end = t.end.min(n);
2249                    if self.buff_chars.get(body_lo) == Some(&'(')
2250                        && self.buff_chars.get(body_lo + 1) == Some(&'(')
2251                    {
2252                        self.color_span(body_lo, body_lo + 2, HighlightRole::double_paren);
2253                        self.complex_brackets.push(body_lo);
2254                        self.complex_brackets.push(body_lo + 1);
2255                        body_lo += 2;
2256                    }
2257                    {
2258                        let closer = if body_end >= body_lo + 2
2259                            && self.buff_chars[body_end - 2] == ')'
2260                            && self.buff_chars[body_end - 1] == ')'
2261                        {
2262                            body_end -= 2;
2263                            Some(body_end)
2264                        } else if self.buff_chars.get(body_end) == Some(&')')
2265                            && self.buff_chars.get(body_end + 1) == Some(&')')
2266                        {
2267                            Some(body_end)
2268                        } else {
2269                            None
2270                        };
2271                        if for_mode {
2272                            // fast-highlight:1010-1044 — a `((…))` right after
2273                            // `for` is a C-style loop header ('F' on
2274                            // `braces_stack`), styled with the for-loop keys
2275                            // rather than the math ones: identifiers plain,
2276                            // operator runs yellow, numbers magenta, the `;`
2277                            // separators yellow+bold.  The closing `))` is part
2278                            // of the trailing operator run there, so it is NOT
2279                            // repainted `double-paren`.
2280                            self.highlight_for_loop(body_lo, body_end);
2281                        } else {
2282                            self.highlight_math(body_lo, body_end);
2283                        }
2284                        if let Some(c) = closer {
2285                            self.color_span(c, c + 2, HighlightRole::double_paren);
2286                            self.complex_brackets.push(c);
2287                            self.complex_brackets.push(c + 1);
2288                        }
2289                    }
2290                }
2291                INBRACE_TOK | OUTBRACE_TOK => {
2292                    // fast-highlight:641-648 — `{` / `}` as a command word are
2293                    // `reserved-word`; they are NOT complex, so the brackets
2294                    // pass paints them by nesting level on top.
2295                    self.color_span(t.start, t.end, HighlightRole::keyword);
2296                    // fast-highlight:646-648 — `}` sets BIT_always (16) and `{`
2297                    // opens a fresh command position.
2298                    expanded_cmd.clear();
2299                    is_typeset = false;
2300                    after_decoration = tokv == INBRACE_TOK;
2301                    after_close_brace = tokv == OUTBRACE_TOK;
2302                }
2303                BANG_TOK => {
2304                    // fast-highlight:148 — `!` is a reserved word (control flow),
2305                    // and the word after it is still a command.
2306                    self.color_span(t.start, t.end, HighlightRole::keyword);
2307                    after_decoration = true;
2308                }
2309                tokv if (CASE..=TYPESET).contains(&tokv) => {
2310                    // fish:887-911 — visit_keyword: reserved words.
2311                    //
2312                    // fast-highlight:611-700 resolves a command-position word in
2313                    // ONE fixed order, and `reserved` is near the END of it
2314                    // (fast-highlight:295-322 `-fast-highlight-main-type`):
2315                    //   precommand → alias → global alias → function → builtin
2316                    //   → command → suffix alias → reserved
2317                    // So a zsh reserved word that ALSO resolves earlier takes
2318                    // the earlier style: `nocorrect` is a precommand
2319                    // (fast-highlight:128) and paints green, and `time` paints
2320                    // green wherever /usr/bin/time exists because `$+commands`
2321                    // is consulted before `$reswords`.  The whole typeset family
2322                    // lands on `builtin` the same way — `declare`, `export`,
2323                    // `float`, `integer`, `local`, `readonly` and `typeset` all
2324                    // lex to the single TYPESET token (hashtable.rs RESWDS) and
2325                    // all are real builtins, which is why f-sy-h greens them
2326                    // while `if`/`while` stay yellow.
2327                    let clean = t.clean_text();
2328                    let role = if fsh_is_precommand(&clean) {
2329                        after_decoration = true;
2330                        HighlightRole::precommand
2331                    } else {
2332                        self.classify_command_word(&clean)
2333                            .unwrap_or(HighlightRole::keyword)
2334                    };
2335                    self.color_span(t.start, t.end, role);
2336                    // fast-highlight:659-666 — `for` and `case` are followed by
2337                    // a name / subject, and zsh's `function` likewise.
2338                    match clean.as_str() {
2339                        "for" | "foreach" | "select" => for_mode = true,
2340                        "case" => case_state = CaseState::Subject,
2341                        "esac" => case_state = CaseState::None_,
2342                        "function" => name_word = true,
2343                        "repeat" => repeat_count = true, // fast-highlight:1188
2344                        _ => (),
2345                    }
2346                    if fsh_is_precommand(&clean) && matches!(clean.as_str(), "nocorrect" | "noglob")
2347                    {
2348                        // no extra state: these take no options
2349                    }
2350                    if tokv == TYPESET {
2351                        is_typeset = true;
2352                    }
2353                }
2354                LEXERR => {
2355                    self.color_span(t.start, t.end, HighlightRole::error);
2356                }
2357                _ => {
2358                    // fish:928 — default: leave normal.
2359                }
2360            }
2361            i += 1;
2362        }
2363    }
2364
2365    /// fast-highlight:295-340 — `-fast-highlight-main-type`, respelled onto the
2366    /// zsh hash tables this shell already owns, in f-sy-h's EXACT lookup order:
2367    ///
2368    ///   alias → global alias → function → builtin → command → suffix alias →
2369    ///   reserved → dirpath
2370    ///
2371    /// Returns the style role for the word, or None when nothing claims it (the
2372    /// caller decides between `unknown-token` and `reserved-word`).
2373    ///
2374    /// fish asks a boolean `command_is_valid` and paints one `command` face; that
2375    /// cannot reproduce f-sy-h, which gives `alias`, `global alias`, `suffix
2376    /// alias` and `dirpath` each their own style key — a global alias is
2377    /// `bg=blue`, not green, and a bare `foo.txt` with a suffix alias defined is
2378    /// green, not an unknown token.
2379    fn classify_command_word(&self, word: &str) -> Option<HighlightRole> {
2380        if word.is_empty() {
2381            return None;
2382        }
2383        // fast-highlight:300-303 — aliases first, split by kind.
2384        if let Ok(tab) = aliastab_lock().read() {
2385            if let Some(node) = tab.get(word) {
2386                use crate::ported::zsh_h::ALIAS_GLOBAL;
2387                return Some(if node.node.flags & ALIAS_GLOBAL as i32 != 0 {
2388                    HighlightRole::global_alias
2389                } else if word.len() > 1 && word[1..].contains('=') {
2390                    // fast-highlight:670-673 — the "insane alias" (`a=b`).
2391                    HighlightRole::error
2392                } else {
2393                    HighlightRole::alias_
2394                });
2395            }
2396        }
2397        // fast-highlight:304-305 — functions.
2398        if getshfunc(word).is_some() {
2399            return Some(HighlightRole::function_);
2400        }
2401        // fast-highlight:306-307 — builtins.  `[` has its own style key
2402        // (fast-highlight:679-682).
2403        if builtin_word_available(word)
2404        {
2405            return Some(if word == "[" {
2406                HighlightRole::single_sq_bracket
2407            } else {
2408                HighlightRole::builtin_
2409            });
2410        }
2411        // fast-highlight:308-309 — external commands (`$+commands`, i.e. the
2412        // hashed table plus a $PATH walk).
2413        if cmdnamtab_lock()
2414            .read()
2415            .map(|t| t.get(word).is_some())
2416            .unwrap_or(false)
2417            || findcmd(word, 0, 0).is_some()
2418        {
2419            return Some(HighlightRole::command);
2420        }
2421        // fast-highlight:310-311 — suffix aliases, keyed by the extension.
2422        if let Some((_, ext)) = word.rsplit_once('.') {
2423            if !ext.is_empty()
2424                && sufaliastab_lock()
2425                    .read()
2426                    .map(|t| t.get(ext).is_some())
2427                    .unwrap_or(false)
2428            {
2429                return Some(HighlightRole::suffix_alias);
2430            }
2431        }
2432        // fast-highlight:312-313 — reserved words.
2433        if reswdtab_lock()
2434            .read()
2435            .map(|t| t.get(word).is_some())
2436            .unwrap_or(false)
2437        {
2438            return Some(HighlightRole::keyword);
2439        }
2440        // fast-highlight:327-335 — a directory is `dirpath`, styled
2441        // `path-to-dir` (fast-highlight:694).
2442        let dir =
2443            crate::zle_file_tester::path_apply_working_directory(word, &self.working_directory);
2444        if std::fs::metadata(&dir).map(|m| m.is_dir()).unwrap_or(false) {
2445            return Some(HighlightRole::path_to_dir);
2446        }
2447        None
2448    }
2449
2450    /// fast-highlight:795-796 — a command word reached with `this_word & 14`
2451    /// (the `repeat`-count aftermath) styles an UNRESOLVED word `default`, not
2452    /// `unknown-token`: `repeat 2 zzqwx` leaves `zzqwx` plain while
2453    /// `repeat 2 print` still greens `print`.
2454    fn visit_command_word_soft(
2455        &mut self,
2456        t: &TokSpan,
2457        clean: &str,
2458        decoration: StatementDecoration,
2459        soft: bool,
2460    ) {
2461        if !soft {
2462            return self.visit_command_word(t, clean, decoration);
2463        }
2464        if let Some(role) = self.classify_command_word(clean) {
2465            if role != HighlightRole::error {
2466                self.color_span(t.start, t.end, role);
2467            }
2468        }
2469    }
2470
2471    // fish:801-811 + 1038-1073 — Color a command word after validity checking.
2472    fn visit_command_word(&mut self, t: &TokSpan, clean: &str, _decoration: StatementDecoration) {
2473        // Reserved words arrive as their own token types; aliases as STRING that
2474        // checkalias() already expanded. What reaches here is a plain command word.
2475        if !self.io_still_ok() {
2476            // fish:1047-1049 — We cannot check if the command is invalid, so just
2477            // assume it's valid.
2478            self.color_span(t.start, t.end, HighlightRole::command);
2479            return;
2480        }
2481        // fish:1052-1065 — Check to see if the command is valid.
2482        // Try expanding it. If we cannot, it's an error.
2483        let mut expanded = t.text.clone().unwrap_or_default();
2484        let expanded_ok = expand_one_no_cmdsubst(&mut expanded);
2485        let cmd = if expanded_ok && !expanded.is_empty() {
2486            expanded
2487        } else {
2488            clean.to_owned()
2489        };
2490        // fast-highlight:743-747 — a history expansion in command position
2491        // (`sudo !!`) is `history-expansion`, checked before the type lookup.
2492        let histchar = crate::ported::hist::bangchar.load(std::sync::atomic::Ordering::SeqCst);
2493        if histchar != 0 {
2494            let mut it = clean.chars();
2495            if it.next() == char::from_u32(histchar as u32) && it.next().is_some() {
2496                self.color_span(t.start, t.end, HighlightRole::history_expansion);
2497                return;
2498            }
2499        }
2500
2501        // fast-highlight:638-696 — the resolved KIND picks the style; f-sy-h has
2502        // no single "is it valid" question.  A word still carrying expansion
2503        // markers can't be resolved, so it is assumed good (fish:1063).
2504        let role = if has_expand_reserved(&cmd) {
2505            Some(HighlightRole::command)
2506        } else {
2507            self.classify_command_word(&cmd)
2508        };
2509
2510        // fish:1068-1073 — Color our statement.
2511        match role {
2512            Some(role) => {
2513                let start = t.start;
2514                let end = t.end.min(self.color_array.len());
2515                let src: String = self.span_text(t);
2516                color_string_internal(
2517                    &src,
2518                    HighlightSpec::with_fg(role),
2519                    &mut self.color_array[start..end],
2520                );
2521            }
2522            None => self.color_span(t.start, t.end, HighlightRole::error),
2523        }
2524        if role == Some(HighlightRole::single_sq_bracket) {
2525            // fast-highlight:681 — `[` is recorded complex so the brackets pass
2526            // leaves it alone.
2527            self.complex_brackets.push(t.start);
2528        }
2529    }
2530
2531    // fish:812-871 + 932-960 — Visit an argument, perhaps knowing that our command
2532    // is cd.
2533    //
2534    // The dispatch order is f-sy-h's non-command-word `case` arm
2535    // (fast-highlight:869-1090), which resolves the WHOLE word to one style
2536    // rather than painting glob/brace/paren characters individually the way
2537    // fish does:
2538    //   `--`/`--opt` → double-hyphen-option   (fast-highlight:869-878)
2539    //   `-opt`       → single-hyphen-option   (fast-highlight:881)
2540    //   quoted       → the quote styles       (fast-highlight:884-960)
2541    //   `*`/`?` word → globbing               (fast-highlight:1047-1048)
2542    //   `$…`         → variable               (fast-highlight:1049-1050)
2543    //   `!…`         → history-expansion      (fast-highlight:1062-1063)
2544    //   global alias → global-alias           (fast-highlight:1068-1069)
2545    //   existing path→ path / path-to-dir     (fast-highlight:1279-1280)
2546    fn visit_argument(
2547        &mut self,
2548        t: &TokSpan,
2549        cmd_is_cd: bool,
2550        options_allowed: bool,
2551        highlight_glob: bool,
2552    ) {
2553        let start = t.start;
2554        let end = t.end.min(self.color_array.len());
2555        if start >= end {
2556            return;
2557        }
2558        let src: String = self.span_text(t);
2559        let clean = t.clean_text();
2560        // The TOKENIZED text: the lexer has already turned every live glob
2561        // character into its token (Star, Quest, …) and left quoted ones as
2562        // plain characters, which is exactly the question
2563        // `[[ $__arg = ([*?]*|*[^\\][*?]*) ]]` (fast-highlight:1047) asks.  The
2564        // literal span cannot answer it — `ls '*'` looks identical to `ls *`.
2565        let tokenized = t.text.clone().unwrap_or_default();
2566
2567        // fast-highlight:729-737 / :955-965 — `$((…))`: the `$` takes the
2568        // in-string dollar style, the `((` and `))` are `double-paren` (and
2569        // complex, so the brackets pass skips them), and the expression between
2570        // them goes through the math highlighter.
2571        if clean.starts_with("$((") {
2572            self.color_span(start, start + 1, HighlightRole::dollar_in_dquote);
2573            self.color_span(start + 1, start + 3, HighlightRole::double_paren);
2574            self.complex_brackets.push(start + 1);
2575            self.complex_brackets.push(start + 2);
2576            let mut body_end = end;
2577            if clean.ends_with("))") && end >= start + 5 {
2578                body_end = end - 2;
2579                self.color_span(body_end, end, HighlightRole::double_paren);
2580                self.complex_brackets.push(body_end);
2581                self.complex_brackets.push(body_end + 1);
2582            }
2583            self.highlight_math(start + 3, body_end);
2584            return;
2585        }
2586
2587        // fast-highlight:869-881 — options, gated by a preceding bare `--`.
2588        let base = if options_allowed && clean.starts_with("--") {
2589            HighlightRole::double_hyphen_option
2590        } else if options_allowed && clean.starts_with('-') {
2591            // fast-highlight:881 — the pattern is `'-'*`, so a bare `-` counts.
2592            HighlightRole::option
2593        } else if fsh_has_quote(&tokenized) {
2594            // fast-highlight:884 — the `[\"\']*|…` arm: once a word contains an
2595            // unescaped quote, f-sy-h hands the WHOLE word to
2596            // `-fast-highlight-string` and never applies a word-level style, so
2597            // the unquoted remainder stays `default`.  Painting `variable` under
2598            // it left the `$`, `f` and `}` of `${(f)"$(ls)"}` at 113.
2599            HighlightRole::param
2600        } else if clean == "]" {
2601            // fast-highlight:833-836 — the `]` of a `[ … ]` test.
2602            self.complex_brackets.push(t.start);
2603            HighlightRole::single_sq_bracket
2604        } else if let Some(role) = self.fsh_plain_word_role(&clean, &tokenized, highlight_glob) {
2605            role
2606        } else {
2607            HighlightRole::param
2608        };
2609        color_string_internal(
2610            &src,
2611            HighlightSpec::with_fg(base),
2612            &mut self.color_array[start..end],
2613        );
2614
2615        // fish:835-870 — Now do command substitutions: locate `$(…)` spans in the
2616        // source and highlight the contents recursively.
2617        let src_chars: Vec<char> = src.chars().collect();
2618        let mut scan = 0usize;
2619        while let Some((open, close)) = locate_cmdsubst_span(&src_chars, scan) {
2620            // fish:846-851 paints the `$(` and `)` with its operator face.
2621            // f-sy-h leaves them to the brackets pass (fast-string-highlight:60),
2622            // which gives them `bracket-level-N`, while the leading `$` keeps the
2623            // word's own `variable` style (fast-highlight:1049-1050).
2624            let inner_start = open + 2;
2625            let inner_end = close.unwrap_or(src_chars.len());
2626            // fast-highlight:803 (`$__arg = \$\([^\(]*`) and the recursion in
2627            // `-fast-highlight-string`: only a substitution with NO nested paren
2628            // is re-highlighted.  `print $(print $(ls))` leaves the outer body
2629            // on the word's own `variable` style and recurses into `$(ls)` only.
2630            let nested = src_chars[inner_start..inner_end].contains(&'(');
2631            if nested {
2632                scan = inner_start;
2633                continue;
2634            }
2635            if inner_end > inner_start {
2636                // fish:853-869 — Highlight it recursively.
2637                let arg_cursor = self.cursor.map(|c| c.wrapping_sub(start + inner_start));
2638                let inner_src: String = src_chars[inner_start..inner_end].iter().collect();
2639                let mut cmdsub_highlighter = Highlighter::new(
2640                    &inner_src,
2641                    arg_cursor,
2642                    self.ctx,
2643                    self.working_directory.clone(),
2644                    self.io_still_ok(),
2645                );
2646                let mut subcolors = cmdsub_highlighter.highlight();
2647                // fast-highlight:890-899 — the recursion happens under the
2648                // SECONDARY theme.
2649                for c in subcolors.iter_mut() {
2650                    c.secondary = true;
2651                }
2652                let dst_lo = (start + inner_start).min(self.color_array.len());
2653                let dst_hi = (start + inner_end).min(self.color_array.len());
2654                // Overlay only the cells the sub-pass actually styled.  f-sy-h
2655                // appends region entries on top of the word's own style, so the
2656                // gaps between the nested command's tokens keep the outer
2657                // `variable` colour (`ls $(print /tmp)` — the space between
2658                // `print` and `/tmp` is 113, not default).
2659                for (k, sub) in subcolors.iter().take(dst_hi - dst_lo).enumerate() {
2660                    if sub.foreground != HighlightRole::normal
2661                        || sub.background != HighlightRole::normal
2662                    {
2663                        self.color_array[dst_lo + k] = *sub;
2664                    }
2665                }
2666            }
2667            scan = inner_end + 1;
2668            if scan >= src_chars.len() {
2669                break;
2670            }
2671        }
2672
2673        // fast-highlight:1244-1258 — a backquoted substitution is re-highlighted
2674        // the same way (its ticks keep the `back-quoted-argument` style, which is
2675        // `none`).  fish has no backtick syntax at all, so nothing here recursed
2676        // and `print \`ls\`` left `ls` uncoloured.
2677        self.highlight_backticks(&src_chars, start);
2678
2679        let _ = cmd_is_cd; // fish:939-959 cd-path validation — see below.
2680    }
2681
2682    /// fast-highlight:171-232 — one of f-sy-h's per-command argument
2683    /// highlighters ("chromas"), for the two commands a zshrc is mostly made of.
2684    /// Returns true when the chroma styled the word itself.
2685    fn visit_chroma_argument(
2686        &mut self,
2687        t: &TokSpan,
2688        c: Chroma,
2689        words: &mut usize,
2690        skip: &mut bool,
2691    ) -> bool {
2692        let clean = t.clean_text();
2693        if c == Chroma::Printf {
2694            // -printf.ch:44-53 — options are NOT styled by the chroma (they
2695            // pass through), and `-v` swallows the variable name after it.
2696            if clean.starts_with('-') {
2697                *skip = clean == "-v";
2698                return false;
2699            }
2700            if *skip {
2701                *skip = false;
2702                return false;
2703            }
2704        }
2705        // -autoload.ch:52-55 / -source.ch:41-45 — options keep their own style.
2706        let opt_lead = match c {
2707            Chroma::Autoload => clean.starts_with('-') || clean.starts_with('+'),
2708            Chroma::Source => clean.starts_with('-'),
2709            Chroma::Printf => false,
2710        };
2711        if opt_lead {
2712            self.color_span(
2713                t.start,
2714                t.end,
2715                if clean.starts_with("--") {
2716                    HighlightRole::double_hyphen_option
2717                } else {
2718                    HighlightRole::option
2719                },
2720            );
2721            return true;
2722        }
2723        *words += 1;
2724        match c {
2725            Chroma::Autoload => {
2726                // -autoload.ch:62-72 — a name found under $fpath is
2727                // `correct-subtle`, one that is not is `incorrect-subtle`.
2728                // Words that begin with `$`, `/` or a backtick are skipped.
2729                if clean.starts_with('$') || clean.starts_with('/') || clean.starts_with('`') {
2730                    return false;
2731                }
2732                let found = crate::ported::params::getaparam("fpath")
2733                    .unwrap_or_default()
2734                    .iter()
2735                    .any(|dir| std::path::Path::new(&format!("{dir}/{clean}")).exists());
2736                self.color_span(
2737                    t.start,
2738                    t.end,
2739                    if found {
2740                        HighlightRole::correct_subtle
2741                    } else {
2742                        HighlightRole::incorrect_subtle
2743                    },
2744                );
2745                true
2746            }
2747            Chroma::Source => {
2748                // -source.ch:50-58 — only the FIRST non-option word is checked;
2749                // the second and later pass through to the ordinary dispatch.
2750                //
2751                // f-sy-h decides `correct-subtle` vs `incorrect-subtle` by
2752                // COPYING the file into $FAST_WORK_DIR and running `zcompile`
2753                // on it — on every keystroke.  That side effect has no place on
2754                // zshrs's ZLE path, so the file's mere existence stands in for
2755                // "compiles": a readable file is `correct-subtle` and a missing
2756                // one falls through (which is also what f-sy-h does when the
2757                // copy fails, -source.ch:54).  The one case that differs is an
2758                // existing file with a syntax error, which f-sy-h reddens.
2759                if *words != 1 {
2760                    return false;
2761                }
2762                let Some(path) = fsh_expand_for_path(&t.text.clone().unwrap_or_default()) else {
2763                    return false;
2764                };
2765                let abs = crate::zle_file_tester::path_apply_working_directory(
2766                    &path,
2767                    &self.working_directory,
2768                );
2769                if std::fs::metadata(&abs)
2770                    .map(|m| m.is_file())
2771                    .unwrap_or(false)
2772                {
2773                    self.color_span(t.start, t.end, HighlightRole::correct_subtle);
2774                    return true;
2775                }
2776                false
2777            }
2778            Chroma::Printf => {
2779                // -printf.ch:54-70 — only the FIRST non-option word is the
2780                // format: it takes its quote style, and every `%`-conversion
2781                // inside it is painted `mathnum`.
2782                if *words != 1 {
2783                    return false;
2784                }
2785                let n = self.buff_chars.len();
2786                let (lo, hi) = (t.start.min(n), t.end.min(n));
2787                let q = self.buff_chars.get(lo).copied();
2788                if q == Some('"') {
2789                    self.color_span(lo, hi, HighlightRole::dquoted);
2790                } else if q == Some('\'') {
2791                    self.color_span(lo, hi, HighlightRole::quote);
2792                }
2793                for (a, b) in printf_conversions(&self.buff_chars[lo..hi]) {
2794                    self.color_span(lo + a, lo + b, HighlightRole::mathnum);
2795                }
2796                true
2797            }
2798        }
2799    }
2800
2801    /// fast-highlight:884-899 — `eval 'print hi'`: the quoted argument's BODY is
2802    /// re-highlighted as a command line under the secondary theme, and the
2803    /// quotes themselves keep the `recursive-base` style (unset in the default
2804    /// theme, hence plain).  Returns false when the argument is not quoted, so
2805    /// the caller falls back to the ordinary dispatch.
2806    fn visit_eval_argument(&mut self, t: &TokSpan) -> bool {
2807        let n = self.buff_chars.len();
2808        let (lo, hi) = (t.start.min(n), t.end.min(n));
2809        if hi <= lo + 1 {
2810            return false;
2811        }
2812        let q = self.buff_chars[lo];
2813        if (q != '\'' && q != '"') || self.buff_chars[hi - 1] != q {
2814            return false;
2815        }
2816        let inner: String = self.buff_chars[lo + 1..hi - 1].iter().collect();
2817        if inner.is_empty() {
2818            return true;
2819        }
2820        let mut sub = Highlighter::new(
2821            &inner,
2822            None,
2823            self.ctx,
2824            self.working_directory.clone(),
2825            self.io_still_ok(),
2826        );
2827        let mut subcolors = sub.highlight();
2828        for c in subcolors.iter_mut() {
2829            c.secondary = true;
2830        }
2831        for (k, s) in subcolors.iter().enumerate() {
2832            if s.foreground != HighlightRole::normal || s.background != HighlightRole::normal {
2833                self.color_array[lo + 1 + k] = *s;
2834            }
2835        }
2836        true
2837    }
2838
2839    /// Re-highlight the body of every ``…`` span in an argument, under the
2840    /// secondary theme (fast-highlight:1244-1258).
2841    fn highlight_backticks(&mut self, src_chars: &[char], start: usize) {
2842        let mut i = 0usize;
2843        let mut in_squote = false;
2844        while i < src_chars.len() {
2845            match src_chars[i] {
2846                '\\' => i += 1,
2847                '\'' => in_squote = !in_squote,
2848                '`' if !in_squote => {
2849                    let body_lo = i + 1;
2850                    let mut j = body_lo;
2851                    while j < src_chars.len() && src_chars[j] != '`' {
2852                        if src_chars[j] == '\\' {
2853                            j += 1;
2854                        }
2855                        j += 1;
2856                    }
2857                    let body_hi = j.min(src_chars.len());
2858                    if body_hi > body_lo {
2859                        let inner: String = src_chars[body_lo..body_hi].iter().collect();
2860                        let mut sub = Highlighter::new(
2861                            &inner,
2862                            None,
2863                            self.ctx,
2864                            self.working_directory.clone(),
2865                            self.io_still_ok(),
2866                        );
2867                        let mut subcolors = sub.highlight();
2868                        for c in subcolors.iter_mut() {
2869                            c.secondary = true;
2870                        }
2871                        let dst_lo = (start + body_lo).min(self.color_array.len());
2872                        let dst_hi = (start + body_hi).min(self.color_array.len());
2873                        for (k, sub) in subcolors.iter().take(dst_hi - dst_lo).enumerate() {
2874                            if sub.foreground != HighlightRole::normal
2875                                || sub.background != HighlightRole::normal
2876                            {
2877                                self.color_array[dst_lo + k] = *sub;
2878                            }
2879                        }
2880                    }
2881                    i = body_hi;
2882                }
2883                _ => (),
2884            }
2885            i += 1;
2886        }
2887    }
2888
2889    /// fast-highlight:1040-1090 — the tail of the non-command-word dispatch:
2890    /// glob, variable, history expansion, global alias, path.  Returns None when
2891    /// nothing claims the word (f-sy-h leaves it `default`).
2892    ///
2893    /// `src` is the still-tokenized span (so a glob char is a lexer token, not a
2894    /// literal `*`); `clean` is what the user typed.
2895    fn fsh_plain_word_role(
2896        &self,
2897        clean: &str,
2898        src: &str,
2899        highlight_glob: bool,
2900    ) -> Option<HighlightRole> {
2901        if clean.is_empty() {
2902            return None;
2903        }
2904        // fast-highlight:1045-1046 — an extended-glob word (`(#b)`, `(#B)`,
2905        // `(#m)`, `(#c…)` or a bare `##`) is `globbing-ext`, checked BEFORE the
2906        // plain glob test.
2907        if fsh_is_globbing_ext(clean) {
2908            return Some(if highlight_glob {
2909                HighlightRole::globbing_ext
2910            } else {
2911                HighlightRole::param
2912            });
2913        }
2914        // fast-highlight:1047-1048 — `[[ $__arg = ([*?]*|*[^\\][*?]*) ]]`: a word
2915        // carrying an UNQUOTED `*` or `?` is a glob, and the whole word takes the
2916        // glob style.  The zsh lexer already answered "was it quoted" for us by
2917        // turning the live ones into Star/Quest tokens.
2918        {
2919            use crate::ported::zsh_h::{Quest, Star};
2920            if src.chars().any(|c| c == Star || c == Quest) {
2921                return Some(if highlight_glob {
2922                    HighlightRole::globbing
2923                } else {
2924                    HighlightRole::param
2925                });
2926            }
2927        }
2928        // fast-highlight:1049-1050 — a word STARTING with `$` is a variable.
2929        if clean.starts_with('$') {
2930            return Some(HighlightRole::variable);
2931        }
2932        // fast-highlight:1062-1063 — history expansion (`!!`, `!ls`, `!$`).
2933        let histchar = crate::ported::hist::bangchar.load(std::sync::atomic::Ordering::SeqCst);
2934        if histchar != 0 {
2935            let mut it = clean.chars();
2936            if it.next() == char::from_u32(histchar as u32) && it.next().is_some() {
2937                return Some(HighlightRole::history_expansion);
2938            }
2939        }
2940        // fast-highlight:1068-1069 — a global alias used as an argument.
2941        if let Ok(tab) = aliastab_lock().read() {
2942            if let Some(node) = tab.get(clean) {
2943                use crate::ported::zsh_h::ALIAS_GLOBAL;
2944                if node.node.flags & ALIAS_GLOBAL as i32 != 0 {
2945                    return Some(HighlightRole::global_alias);
2946                }
2947            }
2948        }
2949        // fast-highlight:1279-1280 (`-fast-highlight-check-path`) — a directory
2950        // is `path-to-dir`, anything else that EXISTS is `path`.  Note f-sy-h
2951        // tests `-d`/`-e` on the expanded word and nothing else: it has no
2952        // notion of fish's "is a prefix of a valid path", which is why fish
2953        // underlined a bare `c` in `arr=(a b c)` whenever a `c*` file existed.
2954        if !self.io_still_ok() {
2955            return None; // fish:935-937
2956        }
2957        let expanded = fsh_expand_for_path(src)?;
2958        let abs = crate::zle_file_tester::path_apply_working_directory(
2959            &expanded,
2960            &self.working_directory,
2961        );
2962        match std::fs::metadata(&abs) {
2963            Ok(md) if md.is_dir() => Some(HighlightRole::path_to_dir),
2964            Ok(_) => Some(HighlightRole::path),
2965            Err(_) => None,
2966        }
2967    }
2968
2969    // fish:962-1014 — visit_redirection: operator token + optional target token.
2970    fn visit_redirection(&mut self, op: &TokSpan, target: Option<&TokSpan>) {
2971        // fish:968-980 — Color the operator part like 2>.
2972        self.color_span(op.start, op.end, HighlightRole::redirection);
2973
2974        let Some(target) = target else { return };
2975        let target_text = target.text.clone().unwrap_or_default();
2976
2977        // fish:982-1013 validates the target and paints an unwritable / missing
2978        // one `unknown-token`.  f-sy-h has no such check: a redirection target
2979        // runs through the ordinary word dispatch (fast-highlight:1040-1090), so
2980        // `cat < /etc/hosts` is a magenta path and `ls > /nope` is plain.
2981        let _ = (&target_text, redir_tok_mode(op.tok, &target.clean_text()));
2982        self.visit_argument(target, false, /*options_allowed=*/ true, true);
2983    }
2984
2985    // fish:1016-1025 — visit_variable_assignment.
2986    fn visit_variable_assignment(&mut self, t: &TokSpan) {
2987        let start = t.start;
2988        let end = t.end.min(self.color_array.len());
2989        if start >= end {
2990            return;
2991        }
2992        let src: String = self.span_text(t);
2993        // fast-highlight:698-699 — the whole assignment word takes the `assign`
2994        // style, whose default is `none` (fast-highlight:84).  fish:1018-1024
2995        // paints the `=` with its operator face, which this engine renders as
2996        // the glob colour, so every `FOO=bar` grew a blue `=`.
2997        color_string_internal(
2998            &src,
2999            HighlightSpec::with_fg(HighlightRole::assign),
3000            &mut self.color_array[start..end],
3001        );
3002        // fast-highlight:729-737 — `NAME=$((…))`: the arithmetic part of an
3003        // assignment gets the same `$` / `((` / math / `))` treatment as a bare
3004        // `$((…))` argument.
3005        if let Some(eq) = src.chars().position(|c| c == '=') {
3006            let rest: String = src.chars().skip(eq + 1).collect();
3007            if rest.starts_with("$((") {
3008                let lo = start + eq + 1;
3009                self.color_span(lo, lo + 1, HighlightRole::dollar_in_dquote);
3010                self.color_span(lo + 1, lo + 3, HighlightRole::double_paren);
3011                self.complex_brackets.push(lo + 1);
3012                self.complex_brackets.push(lo + 2);
3013                let mut body_end = end;
3014                if rest.ends_with("))") && end >= lo + 5 {
3015                    body_end = end - 2;
3016                    self.color_span(body_end, end, HighlightRole::double_paren);
3017                    self.complex_brackets.push(body_end);
3018                    self.complex_brackets.push(body_end + 1);
3019                }
3020                self.highlight_math(lo + 3, body_end);
3021            }
3022        }
3023
3024        // fish:1022-1024 — remember the variable for redirection validation.
3025        if let Some(offset) = src.chars().position(|c| c == '=') {
3026            let var_name: String = src.chars().take(offset).collect();
3027            if valid_var_name(&var_name) {
3028                self.pending_variables.push(var_name);
3029            }
3030        }
3031    }
3032
3033    /// Port of the `braces_stack = F*` arm of fast-highlight:1012-1044 — the
3034    /// C-style `for ((init; cond; step))` header.  Four independent scans, in
3035    /// f-sy-h's order, each overwriting the previous:
3036    ///   identifiers → `for-loop-variable`, operator runs → `for-loop-operator`,
3037    ///   digit runs → `for-loop-number`, and a trailing `;` →
3038    ///   `for-loop-separator`.
3039    fn highlight_for_loop(&mut self, lo: usize, hi: usize) {
3040        let hi = hi.min(self.buff_chars.len());
3041        if lo >= hi {
3042            return;
3043        }
3044        const OPS: &str = "+<>=:*|&^~-";
3045        let mut i = lo;
3046        while i < hi {
3047            let c = self.buff_chars[i];
3048            if c.is_alphabetic() || c == '_' {
3049                let mut j = i;
3050                while j < hi && (self.buff_chars[j].is_alphanumeric() || self.buff_chars[j] == '_')
3051                {
3052                    j += 1;
3053                }
3054                self.color_span(i, j, HighlightRole::for_loop_variable);
3055                i = j;
3056            } else if c.is_ascii_digit() {
3057                let mut j = i;
3058                while j < hi && self.buff_chars[j].is_ascii_digit() {
3059                    j += 1;
3060                }
3061                self.color_span(i, j, HighlightRole::for_loop_number);
3062                i = j;
3063            } else if OPS.contains(c) {
3064                let mut j = i;
3065                while j < hi && OPS.contains(self.buff_chars[j]) {
3066                    j += 1;
3067                }
3068                self.color_span(i, j, HighlightRole::for_loop_operator);
3069                i = j;
3070            } else if c == ';' {
3071                self.color_span(i, i + 1, HighlightRole::for_loop_separator);
3072                i += 1;
3073            } else {
3074                i += 1;
3075            }
3076        }
3077    }
3078
3079    /// fast-highlight:701-710 — inside an assignment word, the `(` opening an
3080    /// array value (and the `)` closing it, when both land in the same token)
3081    /// are `assign-array-bracket` and are recorded complex so the brackets pass
3082    /// leaves them at that flat green instead of the bold bracket-level colour.
3083    fn mark_assign_parens(&mut self, t: &TokSpan, in_array_assignment: &mut bool) {
3084        let n = self.buff_chars.len();
3085        let (lo, hi) = (t.start.min(n), t.end.min(n));
3086        let Some(eq) = self.buff_chars[lo..hi].iter().position(|&c| c == '=') else {
3087            return;
3088        };
3089        let Some(open) = self.buff_chars[lo + eq..hi].iter().position(|&c| c == '(') else {
3090            return;
3091        };
3092        let open = lo + eq + open;
3093        if open != lo + eq + 1 {
3094            return; // not `NAME=(`
3095        }
3096        self.color_span(open, open + 1, HighlightRole::assign_array_bracket);
3097        self.complex_brackets.push(open);
3098        match self.buff_chars[open..hi].iter().rposition(|&c| c == ')') {
3099            Some(rel) => {
3100                let close = open + rel;
3101                self.color_span(close, close + 1, HighlightRole::assign_array_bracket);
3102                self.complex_brackets.push(close);
3103            }
3104            None => *in_array_assignment = true,
3105        }
3106    }
3107
3108    /// Port of `-fast-highlight-math-string` (fast-highlight:1160-1190).
3109    ///
3110    /// It walks a math expression and styles only three kinds of token, leaving
3111    /// operators and whitespace alone:
3112    ///   * a run of digits           → `mathnum`
3113    ///   * a bare identifier         → `mathvar` if the parameter exists,
3114    ///                                 `matherr` if it does not
3115    ///   * a `$name` / `${name}`     → `back-or-dollar-double-quoted-argument`
3116    ///                                 if set, `matherr` if not
3117    ///
3118    /// fish has no math highlighting at all; it colours a `$((…))` body with the
3119    /// generic error face the moment the arithmetic does not parse, which is why
3120    /// `print $((1+2))` came out entirely red.
3121    fn highlight_math(&mut self, lo: usize, hi: usize) {
3122        let hi = hi.min(self.buff_chars.len());
3123        if lo >= hi {
3124            return;
3125        }
3126        let mut i = lo;
3127        while i < hi {
3128            let c = self.buff_chars[i];
3129            if c.is_ascii_digit() {
3130                let mut j = i;
3131                while j < hi && (self.buff_chars[j].is_ascii_digit() || self.buff_chars[j] == '.') {
3132                    j += 1;
3133                }
3134                self.color_span(i, j, HighlightRole::mathnum); // fast-highlight:1166
3135                i = j;
3136            } else if c.is_alphabetic() || c == '_' {
3137                let mut j = i;
3138                while j < hi && (self.buff_chars[j].is_alphanumeric() || self.buff_chars[j] == '_')
3139                {
3140                    j += 1;
3141                }
3142                let name: String = self.buff_chars[i..j].iter().collect();
3143                let role = if math_name_exists(&name) || self.pending_variables.contains(&name) {
3144                    HighlightRole::mathvar // fast-highlight:1168
3145                } else {
3146                    HighlightRole::matherr // fast-highlight:1169
3147                };
3148                self.color_span(i, j, role);
3149                i = j;
3150            } else if c == '$' {
3151                let mut j = i + 1;
3152                let braced = self.buff_chars.get(j) == Some(&'{');
3153                if braced {
3154                    j += 1;
3155                }
3156                let name_lo = j;
3157                while j < hi && (self.buff_chars[j].is_alphanumeric() || self.buff_chars[j] == '_')
3158                {
3159                    j += 1;
3160                }
3161                let name: String = self.buff_chars[name_lo..j].iter().collect();
3162                if braced && self.buff_chars.get(j) == Some(&'}') {
3163                    j += 1;
3164                }
3165                let role = if !name.is_empty()
3166                    && (math_name_exists(&name) || self.pending_variables.contains(&name))
3167                {
3168                    HighlightRole::dollar_in_dquote // fast-highlight:1175
3169                } else {
3170                    HighlightRole::matherr // fast-highlight:1177
3171                };
3172                self.color_span(i, j, role);
3173                i = j;
3174            } else {
3175                i += 1;
3176            }
3177        }
3178    }
3179
3180    /// Port of `-fast-highlight-string-process` (fast-string-highlight:1-70),
3181    /// the brackets highlighter that `FAST_HIGHLIGHT[use_brackets]` turns on by
3182    /// default (fast-highlight:168).
3183    ///
3184    /// It walks the buffer tracking `'`, `"` and `$'` quoting plus backslash
3185    /// escapes, pushes `(`/`{`/`[` onto a level stack, pairs each closer with
3186    /// the top of the stack, then paints every bracket position: a matched pair
3187    /// gets `bracket-level-N` where N is `((level-1) %% 3) + 1`
3188    /// (fast-string-highlight:60), and an unmatched bracket gets
3189    /// `unknown-token` (fast-string-highlight:60, the `||` branch).
3190    fn highlight_brackets(&mut self) {
3191        let chars = &self.buff_chars;
3192        let n = chars.len();
3193        // fast-string-highlight:11 — pos → level, and the matched-pair set.
3194        let mut pos_level: Vec<(usize, i32)> = Vec::new();
3195        let mut stack: Vec<usize> = Vec::new();
3196        let mut paired: Vec<bool> = Vec::new();
3197        // Quoting state: fast-string-highlight:17-56.
3198        #[derive(PartialEq)]
3199        enum Q {
3200            None_,
3201            Single,
3202            Double,
3203            DollarSingle,
3204        }
3205        let mut quoting = Q::None_;
3206        let mut i = 0usize;
3207        while i < n {
3208            let c = chars[i];
3209            if c == '\\' {
3210                // fast-string-highlight:18-21 — a backslash escapes the next
3211                // char, except inside `'…'`.
3212                if quoting == Q::Single {
3213                    i += 1;
3214                } else {
3215                    i += 2;
3216                }
3217                continue;
3218            }
3219            match c {
3220                '"' if quoting != Q::Single && quoting != Q::DollarSingle => {
3221                    quoting = if quoting == Q::Double {
3222                        Q::None_
3223                    } else {
3224                        Q::Double
3225                    };
3226                }
3227                '\'' if quoting != Q::Double => {
3228                    quoting = match quoting {
3229                        Q::Single | Q::DollarSingle => Q::None_,
3230                        // fast-string-highlight:50-54 — `$'` opens an ANSI-C
3231                        // quote, which keeps its own escape rules.
3232                        _ if i > 0 && chars[i - 1] == '$' => Q::DollarSingle,
3233                        _ => Q::Single,
3234                    };
3235                }
3236                '(' | '{' | '[' if quoting == Q::None_ && !self.complex_brackets.contains(&i) => {
3237                    stack.push(i);
3238                    pos_level.push((i, stack.len() as i32));
3239                    paired.push(false);
3240                }
3241                ')' | '}' | ']' if quoting == Q::None_ && !self.complex_brackets.contains(&i) => {
3242                    if let Some(open) = stack.pop() {
3243                        pos_level.push((i, stack.len() as i32 + 1));
3244                        paired.push(false);
3245                        let opener = chars[open];
3246                        // fast-string-highlight:29 — `pair_map` match.
3247                        let matches = matches!((opener, c), ('(', ')') | ('{', '}') | ('[', ']'));
3248                        if matches {
3249                            let last = paired.len() - 1;
3250                            paired[last] = true;
3251                            if let Some(k) = pos_level.iter().position(|&(p, _)| p == open) {
3252                                paired[k] = true;
3253                            }
3254                        }
3255                    } else {
3256                        // fast-string-highlight:35 — level -1: unmatched closer.
3257                        pos_level.push((i, -1));
3258                        paired.push(false);
3259                    }
3260                }
3261                _ => (),
3262            }
3263            i += 1;
3264        }
3265        for (k, &(pos, level)) in pos_level.iter().enumerate() {
3266            let role = if paired[k] {
3267                match (level - 1).rem_euclid(3) + 1 {
3268                    1 => HighlightRole::bracket_level_1,
3269                    2 => HighlightRole::bracket_level_2,
3270                    _ => HighlightRole::bracket_level_3,
3271                }
3272            } else {
3273                HighlightRole::error
3274            };
3275            self.color_span(pos, pos + 1, role);
3276        }
3277    }
3278
3279    /// fish:768-772 adaptation — recover comment spans from inter-token gaps (the
3280    /// zsh lexer consumes comments as whitespace under INTERACTIVE_COMMENTS).
3281    fn color_gap_comments(&mut self, toks: &[TokSpan]) {
3282        let len = self.buff_chars.len();
3283        let mut gaps: Vec<(usize, usize)> = Vec::new();
3284        let mut prev_end = 0usize;
3285        for t in toks {
3286            if t.start > prev_end {
3287                gaps.push((prev_end, t.start.min(len)));
3288            }
3289            prev_end = prev_end.max(t.end);
3290        }
3291        if prev_end < len {
3292            gaps.push((prev_end, len));
3293        }
3294        for (lo, hi) in gaps {
3295            let mut j = lo;
3296            while j < hi {
3297                let c = self.buff_chars[j];
3298                if c == '#' {
3299                    // comment runs to end of line within this gap
3300                    let eol = self.buff_chars[j..hi]
3301                        .iter()
3302                        .position(|&c| c == '\n')
3303                        .map(|p| j + p)
3304                        .unwrap_or(hi);
3305                    self.color_span(j, eol, HighlightRole::comment);
3306                    j = eol;
3307                }
3308                j += 1;
3309            }
3310        }
3311    }
3312}
3313
3314/// fish:1124-1132 — Return whether a string contains a command substitution;
3315/// respelled as a char scanner locating `$(`…`)` with paren balance. Returns
3316/// (open_index_of_'$', Some(close_index)) — close is None when unterminated.
3317fn locate_cmdsubst_span(chars: &[char], from: usize) -> Option<(usize, Option<usize>)> {
3318    let mut i = from;
3319    let mut in_squote = false;
3320    while i + 1 < chars.len() {
3321        let c = chars[i];
3322        if in_squote {
3323            if c == '\'' {
3324                in_squote = false;
3325            }
3326        } else if c == '\'' {
3327            in_squote = true;
3328        } else if c == '\\' {
3329            i += 1;
3330        } else if c == '$' && chars[i + 1] == '(' {
3331            // Found the opener. Find the balanced closer.
3332            let mut depth = 0i32;
3333            let mut j = i + 1;
3334            while j < chars.len() {
3335                match chars[j] {
3336                    '(' => depth += 1,
3337                    ')' => {
3338                        depth -= 1;
3339                        if depth == 0 {
3340                            return Some((i, Some(j)));
3341                        }
3342                    }
3343                    _ => (),
3344                }
3345                j += 1;
3346            }
3347            return Some((i, None));
3348        }
3349        i += 1;
3350    }
3351    None
3352}
3353
3354/// fish:1134-1155 — `contains_pending_variable`.
3355fn contains_pending_variable(pending_variables: &[String], haystack: &str) -> bool {
3356    let hay: Vec<char> = haystack.chars().collect();
3357    for var_name in pending_variables {
3358        let needle: Vec<char> = var_name.chars().collect();
3359        if needle.is_empty() || hay.len() < needle.len() {
3360            continue;
3361        }
3362        let mut nextpos = 0usize;
3363        while nextpos + needle.len() <= hay.len() {
3364            let Some(relpos) = hay[nextpos..]
3365                .windows(needle.len())
3366                .position(|w| w == needle.as_slice())
3367            else {
3368                break;
3369            };
3370            let pos = nextpos + relpos;
3371            nextpos = pos + 1;
3372            if pos == 0 || hay[pos - 1] != '$' {
3373                continue; // fish:1144-1146
3374            }
3375            let end = pos + needle.len();
3376            if end < hay.len() && valid_var_name_char(hay[end]) {
3377                continue; // fish:1147-1150
3378            }
3379            return true;
3380        }
3381    }
3382    false
3383}
3384
3385/// Map a zsh redirection token (+ its target text) to the fish RedirectionMode the
3386/// FileTester validates (fish tokenizer.rs PipeOrRedir::mode equivalent).
3387fn redir_tok_mode(tokv: lextok, target: &str) -> RedirectionMode {
3388    let looks_fd = target == "-" || target.chars().all(|c| c.is_ascii_digit());
3389    match tokv {
3390        OUTANG_TOK => {
3391            if isset(CLOBBER) {
3392                RedirectionMode::Overwrite
3393            } else {
3394                RedirectionMode::NoClob
3395            }
3396        }
3397        OUTANGBANG => RedirectionMode::Overwrite,
3398        DOUTANG | DOUTANGBANG => RedirectionMode::Append,
3399        INANG_TOK => RedirectionMode::Input,
3400        INOUTANG => RedirectionMode::Overwrite, // <> creates rw
3401        INANGAMP => RedirectionMode::Fd,
3402        OUTANGAMP | OUTANGAMPBANG => {
3403            if looks_fd {
3404                RedirectionMode::Fd
3405            } else {
3406                RedirectionMode::Overwrite // >& file duplicates both streams to file
3407            }
3408        }
3409        DOUTANGAMP | DOUTANGAMPBANG => {
3410            if looks_fd {
3411                RedirectionMode::Fd
3412            } else {
3413                RedirectionMode::Append
3414            }
3415        }
3416        crate::ported::zsh_h::AMPOUTANG => RedirectionMode::Overwrite,
3417        _ => RedirectionMode::Input,
3418    }
3419}
3420
3421// Local alias: `(` `)` in `x=( … )` array assignments arrive as INPAR/OUTPAR; some
3422// grammars also use INOUTPAR for `()` in function definitions.
3423
3424#[cfg(test)]
3425mod tests {
3426    use super::*;
3427
3428    fn lock() -> std::sync::MutexGuard<'static, ()> {
3429        crate::test_util::global_state_lock()
3430    }
3431
3432    fn spans_of(line: &str) -> Vec<(String, i32)> {
3433        lex_line_tokens(line)
3434            .iter()
3435            .map(|t| {
3436                (
3437                    line.chars()
3438                        .skip(t.start)
3439                        .take(t.end - t.start)
3440                        .collect::<String>(),
3441                    t.tok,
3442                )
3443            })
3444            .collect()
3445    }
3446
3447    /// The highlight walk must leave NO residue in the Rust-only
3448    /// `LEX_UNGET_BUF` side channel: `hgetc` (lex.rs:4209) drains it
3449    /// BEFORE every input frame, so leftover chars from a per-keystroke
3450    /// highlight pass are read by the NEXT real interactive parse —
3451    /// `{ print ok }` / `( print sub )` / every brace-body function
3452    /// definition typed interactively died with "parse error near `}'"
3453    /// while the fish engines were enabled (ZSHRS_NATIVE_ZLE_FX=0 made
3454    /// it vanish). The walk must also put back what the SUSPENDED outer
3455    /// parse had ungot before it blocked in zleread.
3456    #[test]
3457    fn lex_line_tokens_leaves_unget_buf_untouched() {
3458        use crate::ported::lex::LEX_UNGET_BUF;
3459        let _g = lock();
3460        for line in [
3461            "{ print ok }",
3462            "( print sub )",
3463            "function q(){ print fq }",
3464            "echo hi",
3465        ] {
3466            // Outer-parse residue that must survive the walk verbatim.
3467            LEX_UNGET_BUF.with_borrow_mut(|b| {
3468                b.clear();
3469                b.push_back('X');
3470                b.push_back('\n');
3471            });
3472            let _ = lex_line_tokens(line);
3473            let after: Vec<char> = LEX_UNGET_BUF.with_borrow(|b| b.iter().copied().collect());
3474            assert_eq!(
3475                after,
3476                vec!['X', '\n'],
3477                "LEX_UNGET_BUF corrupted by highlight walk of {line:?}"
3478            );
3479        }
3480        LEX_UNGET_BUF.with_borrow_mut(|b| b.clear());
3481    }
3482
3483    /// Token spans must slice the source exactly — the C nwb/nwe formulas.
3484    #[test]
3485    fn lex_spans_match_source() {
3486        let _g = lock();
3487        let spans = spans_of("echo hello world");
3488        let words: Vec<&str> = spans
3489            .iter()
3490            .filter(|(_, t)| *t == STRING_LEX)
3491            .map(|(s, _)| s.as_str())
3492            .collect();
3493        assert_eq!(words, vec!["echo", "hello", "world"], "spans {spans:?}");
3494    }
3495
3496    // ======================================================================
3497    // fast-syntax-highlighting parity.
3498    //
3499    // Every test below pins a case where fish's palette or structure
3500    // DISAGREED with f-sy-h and the difference was measured over a pty
3501    // (both shells driven side by side, compared on rendered cell
3502    // attributes).  The f-sy-h line that decides each case is cited at the
3503    // assertion.
3504    // ======================================================================
3505
3506    fn roles(line: &str) -> Vec<HighlightRole> {
3507        let ctx = OperationContext::empty();
3508        let mut colors = Vec::new();
3509        highlight_shell(line, &mut colors, &ctx, /*io_ok=*/ true, None);
3510        colors.iter().map(|c| c.foreground).collect()
3511    }
3512
3513    fn role_of(line: &str, word: &str) -> HighlightRole {
3514        let at = line.find(word).expect("word not in line");
3515        let at = line[..at].chars().count();
3516        roles(line)[at]
3517    }
3518
3519    /// fast-highlight:121-130 + :620 — the precommand set. The NEGATIVE half is
3520    /// the point: a prefix or a superstring of a precommand is an ordinary word,
3521    /// and treating it as one would hand command position to the wrong token.
3522    #[test]
3523    fn precommand_table_is_exact_membership() {
3524        for yes in [
3525            "builtin",
3526            "command",
3527            "exec",
3528            "nocorrect",
3529            "noglob",
3530            "pkexec",
3531            "sudo",
3532            "doas",
3533        ] {
3534            assert!(fsh_is_precommand(yes), "{yes} is a precommand");
3535        }
3536        for no in ["", "sud", "commander", "execute", "noglobber", "Sudo", "-"] {
3537            assert!(!fsh_is_precommand(no), "{no} is NOT a precommand");
3538        }
3539    }
3540
3541    /// Each of the four builtin registries paints as a builtin at command
3542    /// position. The daemon `z*` family was the one the highlighter never
3543    /// asked about: `zjob` came out an unknown token (error colour) while
3544    /// `whence -v zjob` answered "zjob is a shell builtin" in the same shell.
3545    #[test]
3546    fn builtin_word_available_asks_every_registry() {
3547        // c-port table (Src/builtin.c:40-137).
3548        assert!(builtin_word_available("print"), "core builtin");
3549        // EXT_BUILTIN_NAMES.
3550        assert!(
3551            builtin_word_available("dbview"),
3552            "zshrs extension builtin"
3553        );
3554        // ZSHRS_BUILTIN_NAMES — the daemon z* family, dispatched by name.
3555        for name in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
3556            assert!(
3557                builtin_word_available(name),
3558                "daemon builtin {name} must not paint as an unknown token"
3559            );
3560        }
3561        // A name in none of the four registries stays unknown, or the
3562        // predicate is answering yes to everything and pins nothing.
3563        assert!(
3564            !builtin_word_available("definitely_not_a_builtin_xyzzy"),
3565            "unknown words must stay unknown"
3566        );
3567    }
3568
3569    /// fast-highlight:562-563 — `command -pvV--` / `exec -cla-` keep command
3570    /// position; anything else after them does not.
3571    #[test]
3572    fn afp_option_predicate_both_directions() {
3573        assert!(fsh_afp_option("-v", "pvV-"));
3574        assert!(fsh_afp_option("-pv", "pvV-"));
3575        assert!(fsh_afp_option("--", "pvV-"));
3576        assert!(fsh_afp_option("-a", "cla-"));
3577        assert!(
3578            !fsh_afp_option("-x", "pvV-"),
3579            "-x is not a `command` option"
3580        );
3581        assert!(
3582            !fsh_afp_option("-", "pvV-"),
3583            "a bare - has no option letters"
3584        );
3585        assert!(!fsh_afp_option("v", "pvV-"), "no leading hyphen");
3586        assert!(!fsh_afp_option("", "pvV-"));
3587    }
3588
3589    /// fast-highlight:1045 — `globbing-ext` detection. The escaped `\##` case is
3590    /// the negative half: f-sy-h's pattern is `[^\\]##`, so a backslash in front
3591    /// keeps it an ordinary word.
3592    #[test]
3593    fn globbing_ext_predicate_both_directions() {
3594        for yes in ["(#b)a*", "(#B)x", "(#m)y", "(#c1,3)z", "a##b"] {
3595            assert!(fsh_is_globbing_ext(yes), "{yes} carries an extended glob");
3596        }
3597        for no in ["", "a#b", "plain", "*.c", "a\\##b"] {
3598            assert!(!fsh_is_globbing_ext(no), "{no} has no extended glob");
3599        }
3600    }
3601
3602    /// -printf.ch:63 — the `%`-conversion scanner. The negatives matter: a bare
3603    /// `%` or an unterminated conversion must not be painted.
3604    #[test]
3605    fn printf_conversion_scanner_both_directions() {
3606        let scan = |s: &str| {
3607            let c: Vec<char> = s.chars().collect();
3608            printf_conversions(&c)
3609        };
3610        assert_eq!(scan("'%s'"), vec![(1, 3)]);
3611        assert_eq!(scan("\"%s=%d\\n\""), vec![(1, 3), (4, 6)]);
3612        assert_eq!(scan("'%-10.3f'"), vec![(1, 8)]);
3613        assert!(scan("no conversions here").is_empty());
3614        assert!(scan("100%").is_empty(), "a trailing %% converts nothing");
3615        assert!(scan("%z").is_empty(), "z is not a conversion letter");
3616        assert!(scan("%").is_empty());
3617    }
3618
3619    /// fast-highlight:295-322 — `-fast-highlight-main-type` resolves a
3620    /// command-position word in ONE order, and `reserved` sits near the END of
3621    /// it. fish gives every reserved word its keyword face; f-sy-h gives
3622    /// `nocorrect` the precommand face (it is in the precommand table) and the
3623    /// typeset family the builtin face, while control flow stays reserved.
3624    #[test]
3625    fn reserved_words_resolve_through_the_type_ladder() {
3626        let _g = lock();
3627        assert_eq!(role_of("if true; then :; fi", "if"), HighlightRole::keyword);
3628        assert_eq!(
3629            role_of("while :; do :; done", "while"),
3630            HighlightRole::keyword
3631        );
3632        assert_eq!(
3633            role_of("nocorrect ls", "nocorrect"),
3634            HighlightRole::precommand
3635        );
3636        for decl in [
3637            "typeset", "declare", "local", "export", "readonly", "integer",
3638        ] {
3639            let line = format!("{decl} x");
3640            assert_eq!(
3641                role_of(&line, decl),
3642                HighlightRole::builtin_,
3643                "{decl} is a builtin before it is a reserved word"
3644            );
3645        }
3646    }
3647
3648    /// fast-highlight:616-623 — a precommand hands command position to the next
3649    /// word. fish's decoration arm is gated on the lexer's `cmdpos` flag, which
3650    /// the MODIFIER holds, so the real command word was never visited: `sudo ls`
3651    /// left `ls` uncoloured and `command zzqwx` never went red.
3652    #[test]
3653    fn precommand_passes_command_position_along() {
3654        let _g = lock();
3655        assert_eq!(role_of("sudo ls", "sudo"), HighlightRole::precommand);
3656        assert_ne!(
3657            role_of("sudo ls", "ls"),
3658            HighlightRole::param,
3659            "the word after sudo is a command"
3660        );
3661        assert_eq!(
3662            role_of("command zzqwx_not_a_command", "zzqwx_not_a_command"),
3663            HighlightRole::error,
3664            "and an invalid one still reports"
3665        );
3666        // fast-highlight:546-557 — `sudo -u NAME cmd`: the flag is an option,
3667        // NAME is its argument, and command position survives both.
3668        assert_eq!(role_of("sudo -u root ls", "-u"), HighlightRole::option);
3669        assert_eq!(role_of("sudo -u root ls", "root"), HighlightRole::normal);
3670        assert_ne!(role_of("sudo -u root ls", "ls"), HighlightRole::param);
3671    }
3672
3673    /// fast-highlight:1047-1048 — a word carrying a LIVE `*`/`?` is `globbing`
3674    /// whole, not one painted character. The quoted half is the negative: the
3675    /// lexer leaves a quoted `*` as a plain character, so `ls '*.c'` is a string.
3676    #[test]
3677    fn glob_word_is_whole_word_and_quoting_defeats_it() {
3678        let _g = lock();
3679        let r = roles("ls *.c");
3680        assert!(
3681            r[3..6].iter().all(|&x| x == HighlightRole::globbing),
3682            "the whole word globs, not just the star: {r:?}"
3683        );
3684        let q = roles("ls '*.c'");
3685        assert!(
3686            !q.iter().any(|&x| x == HighlightRole::globbing),
3687            "a quoted glob is a string: {q:?}"
3688        );
3689        // fast-highlight:478 — `noglob` turns the whole thing off.
3690        let n = roles("noglob print *.c");
3691        assert!(
3692            !n.iter().any(|&x| x == HighlightRole::globbing),
3693            "noglob suppresses glob styling: {n:?}"
3694        );
3695    }
3696
3697    /// fast-highlight:76-77 — options have their own cyan style, and a bare `--`
3698    /// switches it off for the rest of the command (fish:1091-1093).
3699    #[test]
3700    fn option_words_take_the_option_styles() {
3701        let _g = lock();
3702        assert_eq!(role_of("ls -l", "-l"), HighlightRole::option);
3703        assert_eq!(role_of("ls -", "-"), HighlightRole::option);
3704        assert_eq!(
3705            role_of("ls --color", "--color"),
3706            HighlightRole::double_hyphen_option
3707        );
3708        let after = roles("ls -- -l");
3709        assert_eq!(
3710            after[6],
3711            HighlightRole::param,
3712            "after a bare -- nothing is an option: {after:?}"
3713        );
3714    }
3715
3716    /// fast-string-highlight:60 — the brackets pass. A matched pair takes
3717    /// `bracket-level-N` by nesting depth; an unmatched one takes
3718    /// `unknown-token`. fish has no such pass and painted every bracket with its
3719    /// generic operator face.
3720    #[test]
3721    fn brackets_are_levelled_and_unmatched_ones_report() {
3722        let _g = lock();
3723        let r = roles("(ls)");
3724        assert_eq!(r[0], HighlightRole::bracket_level_1);
3725        assert_eq!(r[3], HighlightRole::bracket_level_1);
3726        let n = roles("print ${(U)HOME}");
3727        assert_eq!(n[7], HighlightRole::bracket_level_1, "{{ is level 1");
3728        assert_eq!(n[8], HighlightRole::bracket_level_2, "( inside is level 2");
3729        assert_eq!(n[10], HighlightRole::bracket_level_2);
3730        assert_eq!(n[15], HighlightRole::bracket_level_1);
3731        let bad = roles("print (");
3732        assert_eq!(
3733            bad[6],
3734            HighlightRole::error,
3735            "an unmatched bracket is unknown-token: {bad:?}"
3736        );
3737    }
3738
3739    /// fast-highlight:152-158 — `|`, `||`, `;`, `&`, `&&` are ONE class painted
3740    /// `commandseparator` (default `none`). fish splits them and routed `&&`
3741    /// through its operator face, which this engine renders as the glob colour.
3742    #[test]
3743    fn every_separator_is_a_commandseparator() {
3744        let _g = lock();
3745        for (line, at) in [
3746            ("ls && ls", 3),
3747            ("ls || ls", 3),
3748            ("ls; ls", 2),
3749            ("ls | ls", 3),
3750        ] {
3751            let r = roles(line);
3752            assert_eq!(
3753                r[at],
3754                HighlightRole::statement_terminator,
3755                "{line} at {at}: {r:?}"
3756            );
3757        }
3758    }
3759
3760    /// fast-highlight:1279-1280 — a path is styled only when it EXISTS: `-d`
3761    /// gives `path-to-dir`, `-e` gives `path`, and nothing else is styled.
3762    /// fish additionally underlines any word that is a PREFIX of an existing
3763    /// path while the cursor is on it, which f-sy-h never does — that is what
3764    /// underlined the bare `c` of `arr=(a b c)` whenever a `c*` file existed.
3765    #[test]
3766    fn paths_are_styled_only_when_they_exist() {
3767        let _g = lock();
3768        assert_eq!(role_of("cat /etc/hosts", "/etc/hosts"), HighlightRole::path);
3769        assert_eq!(role_of("cat /tmp", "/tmp"), HighlightRole::path_to_dir);
3770        assert_eq!(
3771            role_of("cat /nonexistent_zzqwx", "/nonexistent_zzqwx"),
3772            HighlightRole::param,
3773            "a path that does not exist is a plain word"
3774        );
3775        assert_eq!(
3776            role_of("cat /etc/host", "/etc/host"),
3777            HighlightRole::param,
3778            "a PREFIX of an existing path is not a path"
3779        );
3780    }
3781
3782    /// fast-highlight:86 vs :82 — `$var` is `variable` (fg=113) on its own and
3783    /// `back-or-dollar-double-quoted-argument` (cyan) inside `"…"`. fish used one
3784    /// operator face for both.
3785    #[test]
3786    fn variable_role_depends_on_quoting() {
3787        let _g = lock();
3788        assert_eq!(role_of("print $HOME", "$HOME"), HighlightRole::variable);
3789        assert_eq!(
3790            role_of("print \"a $HOME b\"", "$HOME"),
3791            HighlightRole::dollar_in_dquote
3792        );
3793    }
3794
3795    /// fish:687-690 marks the opening quote of an unterminated string with its
3796    /// error face; f-sy-h styles the in-progress string normally, so every
3797    /// string was red until its closing quote was typed.
3798    #[test]
3799    fn an_unterminated_quote_is_not_an_error() {
3800        let _g = lock();
3801        let r = roles("print 'abc");
3802        assert_eq!(r[6], HighlightRole::quote, "{r:?}");
3803        assert!(!r.iter().any(|&x| x == HighlightRole::error), "{r:?}");
3804        let d = roles("print \"abc");
3805        assert_eq!(d[6], HighlightRole::dquoted, "{d:?}");
3806        assert!(!d.iter().any(|&x| x == HighlightRole::error), "{d:?}");
3807    }
3808
3809    /// fast-highlight:75 / :743-747 / :1062-1063 — history expansion has its own
3810    /// style, in argument AND command position (`sudo !!`), and a lone `!` is
3811    /// NOT one (f-sy-h requires `-n ${__arg[2]}`).
3812    #[test]
3813    fn history_expansion_needs_a_word_after_the_bang() {
3814        let _g = lock();
3815        assert_eq!(role_of("print !!", "!!"), HighlightRole::history_expansion);
3816        assert_eq!(
3817            role_of("sudo !!", "!!"),
3818            HighlightRole::history_expansion,
3819            "also in command position"
3820        );
3821        assert_ne!(
3822            role_of("! ls", "!"),
3823            HighlightRole::history_expansion,
3824            "a lone ! is the negation reserved word, not an expansion"
3825        );
3826    }
3827
3828    /// fast-highlight:113 + :890-899 — the body of a command substitution is
3829    /// re-highlighted under the SECONDARY theme. The nesting rule is the other
3830    /// half: fast-highlight:803 recurses only into a `$(` with no nested paren,
3831    /// so the OUTER body of `$(print $(ls))` keeps the word's own style.
3832    #[test]
3833    fn command_substitution_body_uses_the_secondary_theme() {
3834        let _g = lock();
3835        let ctx = OperationContext::empty();
3836        let mut colors = Vec::new();
3837        highlight_shell("print $(ls)", &mut colors, &ctx, true, None);
3838        assert!(colors[8].secondary, "the nested command is secondary");
3839        assert!(!colors[6].secondary, "the leading $ is not");
3840
3841        let mut nested = Vec::new();
3842        highlight_shell("print $(print $(ls))", &mut nested, &ctx, true, None);
3843        assert!(
3844            !nested[8].secondary,
3845            "an outer body with a nested paren is not recursed: {:?}",
3846            &nested[6..14]
3847        );
3848        assert!(nested[16].secondary, "the innermost one is");
3849    }
3850
3851    /// fast-highlight:1160-1190 — math highlighting. `mathvar` vs `matherr` is
3852    /// the predicate: an existing parameter is blue, an unknown one is red.
3853    #[test]
3854    fn math_numbers_and_variables_split_on_existence() {
3855        let _g = lock();
3856        let r = roles("print $((1+2))");
3857        assert_eq!(r[9], HighlightRole::mathnum, "{r:?}");
3858        assert_eq!(r[11], HighlightRole::mathnum, "{r:?}");
3859        assert_eq!(r[7], HighlightRole::double_paren, "{r:?}");
3860        crate::ported::params::setsparam("zzmath_defined", "1");
3861        assert_eq!(
3862            role_of("print $((zzmath_defined))", "zzmath_defined"),
3863            HighlightRole::mathvar,
3864            "a defined parameter is a mathvar"
3865        );
3866        crate::ported::params::unsetparam("zzmath_defined");
3867        assert_eq!(
3868            role_of("print $((zzqwx_no_such_param))", "zzqwx_no_such_param"),
3869            HighlightRole::matherr,
3870            "an unset name is a math error"
3871        );
3872    }
3873
3874    /// fast-highlight:96-98 — `<<<` is `here-string-tri` and the word after it is
3875    /// `here-string-text`, not a filename to validate.
3876    #[test]
3877    fn here_string_operator_and_text() {
3878        let _g = lock();
3879        assert_eq!(role_of("cat <<< hi", "<<<"), HighlightRole::here_string_tri);
3880        assert_eq!(role_of("cat <<< hi", "hi"), HighlightRole::here_string_text);
3881        assert_eq!(
3882            role_of("cat <<< $HOME", "$HOME"),
3883            HighlightRole::here_string_var
3884        );
3885    }
3886
3887    /// fast-highlight:667 / :698 — while `braces_stack` carries 'T', every word
3888    /// after a typeset-family command is an `assign`, options included.
3889    #[test]
3890    fn typeset_arguments_are_assignments_not_options() {
3891        let _g = lock();
3892        assert_eq!(role_of("typeset -g x", "-g"), HighlightRole::assign);
3893        assert_eq!(role_of("declare -a arr", "-a"), HighlightRole::assign);
3894        assert_eq!(
3895            role_of("ls -g", "-g"),
3896            HighlightRole::option,
3897            "outside typeset the same word IS an option"
3898        );
3899    }
3900
3901    /// -autoload.ch:62-72 — the autoload chroma: a name found under $fpath is
3902    /// `correct-subtle`, one that is not is `incorrect-subtle`.
3903    #[test]
3904    fn autoload_chroma_splits_on_fpath() {
3905        let _g = lock();
3906        let dir = std::env::temp_dir().join("zshrs_hl_fpath_test");
3907        std::fs::create_dir_all(&dir).unwrap();
3908        std::fs::write(dir.join("zzfn_present"), "zzfn_present() { : }").unwrap();
3909        crate::ported::params::setaparam("fpath", vec![dir.to_string_lossy().into_owned()]);
3910        assert_eq!(
3911            role_of("autoload -Uz zzfn_present", "zzfn_present"),
3912            HighlightRole::correct_subtle
3913        );
3914        assert_eq!(
3915            role_of("autoload -Uz zzfn_absent", "zzfn_absent"),
3916            HighlightRole::incorrect_subtle
3917        );
3918        assert_eq!(
3919            role_of("autoload -Uz zzfn_present", "-Uz"),
3920            HighlightRole::option
3921        );
3922        let _ = std::fs::remove_dir_all(&dir);
3923    }
3924
3925    /// The configuration contract: `$ZSH_HIGHLIGHT_STYLES[key]` must still beat
3926    /// every built-in default this port introduced.
3927    #[test]
3928    fn zsh_highlight_styles_overrides_the_new_defaults() {
3929        let _g = lock();
3930        let plain = HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
3931            HighlightRole::globbing,
3932        ));
3933        crate::ported::params::sethparam(
3934            "ZSH_HIGHLIGHT_STYLES",
3935            vec!["globbing".into(), "fg=magenta".into()],
3936        );
3937        let themed = HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
3938            HighlightRole::globbing,
3939        ));
3940        crate::ported::params::unsetparam("ZSH_HIGHLIGHT_STYLES");
3941        assert_ne!(
3942            plain, themed,
3943            "the user's globbing style must beat the built-in fg=blue,bold"
3944        );
3945    }
3946
3947    #[test]
3948    fn lex_spans_operators() {
3949        let _g = lock();
3950        let toks = lex_line_tokens("a && b || c");
3951        let ops: Vec<i32> = toks.iter().map(|t| t.tok).collect();
3952        assert!(ops.contains(&DAMPER), "toks {ops:?}");
3953        assert!(ops.contains(&DBAR), "toks {ops:?}");
3954        // Operator spans point at the operator text.
3955        let damper = toks.iter().find(|t| t.tok == DAMPER).unwrap();
3956        let src: String = "a && b || c"
3957            .chars()
3958            .skip(damper.start)
3959            .take(damper.end - damper.start)
3960            .collect();
3961        assert_eq!(src.trim(), "&&");
3962    }
3963
3964    #[test]
3965    fn lex_spans_cmdpos_flag() {
3966        let _g = lock();
3967        let toks = lex_line_tokens("echo foo; ls bar");
3968        let strings: Vec<(String, bool)> = toks
3969            .iter()
3970            .filter(|t| t.tok == STRING_LEX)
3971            .map(|t| (t.clean_text(), t.cmdpos))
3972            .collect();
3973        assert_eq!(
3974            strings,
3975            vec![
3976                ("echo".to_owned(), true),
3977                ("foo".to_owned(), false),
3978                ("ls".to_owned(), true),
3979                ("bar".to_owned(), false)
3980            ]
3981        );
3982    }
3983
3984    /// Unterminated quote at cursor: LEXFLAGS_ACTIVE keeps it a STRING (the
3985    /// zle_tricky c:1215-1227 fixup).
3986    #[test]
3987    fn lex_tolerates_unterminated_quote() {
3988        let _g = lock();
3989        let toks = lex_line_tokens("echo 'in progress");
3990        assert!(
3991            toks.iter().filter(|t| t.tok == STRING_LEX).count() >= 2,
3992            "unterminated quote must still lex as STRING: {:?}",
3993            toks.iter().map(|t| t.tok).collect::<Vec<_>>()
3994        );
3995    }
3996
3997    // fish:475-691 — color_string_internal coverage, zsh quoting.
3998    #[test]
3999    fn color_string_quotes_and_vars() {
4000        let _g = lock();
4001        let s = "a'q'\"d$V\"$X*";
4002        let n = s.chars().count();
4003        let mut colors = vec![HighlightSpec::default(); n];
4004        color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
4005        let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
4006        // a
4007        assert_eq!(roles[0], HighlightRole::param);
4008        // 'q'
4009        assert_eq!(roles[1], HighlightRole::quote);
4010        assert_eq!(roles[2], HighlightRole::quote);
4011        assert_eq!(roles[3], HighlightRole::quote);
4012        // "d — double-quoted-argument (fast-highlight:80), a DIFFERENT style
4013        // key from single-quoted-argument
4014        assert_eq!(roles[4], HighlightRole::dquoted);
4015        assert_eq!(roles[5], HighlightRole::dquoted);
4016        // $V inside dquotes — back-or-dollar-double-quoted-argument
4017        // (fast-highlight:82), not the generic operator face
4018        assert_eq!(roles[6], HighlightRole::dollar_in_dquote);
4019        assert_eq!(roles[7], HighlightRole::dollar_in_dquote);
4020        // closing "
4021        assert_eq!(roles[8], HighlightRole::dquoted);
4022        // Outside a quote f-sy-h decides at WORD granularity
4023        // (fast-highlight:1047-1050), so the string scanner leaves the trailing
4024        // `$X*` on the base face; painting `$`/`X`/`*` individually here is what
4025        // made `ls *.c` colour only the `*`.
4026        assert_eq!(roles[9], HighlightRole::param);
4027        assert_eq!(roles[10], HighlightRole::param);
4028        assert_eq!(roles[11], HighlightRole::param);
4029    }
4030
4031    #[test]
4032    fn color_string_unclosed_quote_keeps_the_quote_style() {
4033        let _g = lock();
4034        // fish:687-690 flags the opening quote of an unterminated string with
4035        // its error face. f-sy-h has no unterminated branch at all
4036        // (fast-highlight:884-960): the in-progress string keeps
4037        // `single-quoted-argument`, which is what a user sees for every string
4038        // they have not finished typing.
4039        let s = "'unclosed";
4040        let n = s.chars().count();
4041        let mut colors = vec![HighlightSpec::default(); n];
4042        color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
4043        assert!(colors.iter().all(|c| c.foreground == HighlightRole::quote));
4044    }
4045
4046    #[test]
4047    fn color_string_dollar_quote_escapes() {
4048        let _g = lock();
4049        // $'\x41' — a valid escape.
4050        let s = "$'\\x41'";
4051        let n = s.chars().count();
4052        let mut colors = vec![HighlightSpec::default(); n];
4053        color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
4054        let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
4055        assert_eq!(roles[2], HighlightRole::escape, "roles {roles:?}");
4056
4057        // fish:583-586 errors when a numeric escape exceeds the code point
4058        // maximum. `-fast-highlight-dollar-string` (fast-highlight:1198-1230)
4059        // validates only the SHAPE, never the value, so an over-large escape is
4060        // an ordinary escape there.
4061        let s2 = "$'\\U110000'";
4062        let n2 = s2.chars().count();
4063        let mut colors2 = vec![HighlightSpec::default(); n2];
4064        color_string_internal(
4065            s2,
4066            HighlightSpec::with_fg(HighlightRole::param),
4067            &mut colors2,
4068        );
4069        assert_eq!(colors2[2].foreground, HighlightRole::escape, "{colors2:?}");
4070
4071        // The shape check that DOES fire: `\x` with no hex digit after it
4072        // (fast-highlight:1221-1224).
4073        let s3 = "$'\\x'";
4074        let n3 = s3.chars().count();
4075        let mut colors3 = vec![HighlightSpec::default(); n3];
4076        color_string_internal(
4077            s3,
4078            HighlightSpec::with_fg(HighlightRole::param),
4079            &mut colors3,
4080        );
4081        assert_eq!(colors3[2].foreground, HighlightRole::error, "{colors3:?}");
4082    }
4083
4084    #[test]
4085    fn color_variable_subscript() {
4086        let _g = lock();
4087        let s: Vec<char> = "$arr[1]x".chars().collect();
4088        let mut colors = vec![HighlightSpec::default(); s.len()];
4089        let consumed = color_variable(&s, &mut colors, HighlightRole::variable);
4090        // $arr[1] consumed; trailing x not.
4091        assert_eq!(consumed, 7);
4092        assert_eq!(colors[0].foreground, HighlightRole::variable);
4093        assert_eq!(colors[4].foreground, HighlightRole::variable); // [
4094        assert_eq!(colors[6].foreground, HighlightRole::variable); // ]
4095    }
4096
4097    /// zshrs's EXTENSION builtins (`provenance`, `dbview`, `zcache`, …)
4098    /// live in EXT_BUILTIN_NAMES, not in `createbuiltintable`. The command
4099    /// check consulted only the core table, so every one of them painted
4100    /// as an unknown token even though `whence -w provenance` reports
4101    /// `builtin` and `${+builtins[provenance]}` is 1 in the same shell.
4102    ///
4103    /// The negative half matters just as much: `builtin_in_builtintab` is
4104    /// NOT a membership test on its own — `builtin_owning_module` returns
4105    /// None for an unknown name and its `None => true` arm reports every
4106    /// string as available. Calling it without the EXT_BUILTIN_NAMES
4107    /// membership check first turned literally every word green.
4108    #[test]
4109    fn extension_builtins_are_commands_but_unknown_words_are_not() {
4110        let _g = lock();
4111        let ctx = OperationContext::empty();
4112
4113        for name in ["provenance", "dbview"] {
4114            let line = format!("{name} -m x");
4115            let mut colors = Vec::new();
4116            highlight_shell(&line, &mut colors, &ctx, true, None);
4117            assert_eq!(
4118                colors[0].foreground,
4119                HighlightRole::builtin_,
4120                "{name} is an extension builtin and must colour as a builtin"
4121            );
4122        }
4123
4124        // A near-miss prefix of a real extension builtin must NOT pass.
4125        for bogus in ["provenanc", "zzqwx", "dbvie"] {
4126            let line = format!("{bogus} arg");
4127            let mut colors = Vec::new();
4128            highlight_shell(&line, &mut colors, &ctx, true, None);
4129            assert_eq!(
4130                colors[0].foreground,
4131                HighlightRole::error,
4132                "{bogus} is not a command and must colour as unknown-token"
4133            );
4134        }
4135    }
4136
4137    // fish:1342-1821 — the highlight_shell integration checks, zsh syntax.
4138    #[test]
4139    fn highlight_valid_and_invalid_command() {
4140        let _g = lock();
4141        let ctx = OperationContext::empty();
4142
4143        // `echo` resolves via builtintab. fast-highlight:306-307 gives a builtin
4144        // its OWN style key (`builtin`), ahead of `command` in the resolution
4145        // order — both default to fg=green but a theme can separate them.
4146        let line = "echo hi";
4147        let mut colors = Vec::new();
4148        highlight_shell(line, &mut colors, &ctx, /*io_ok=*/ true, None);
4149        assert_eq!(colors.len(), line.chars().count());
4150        assert_eq!(
4151            colors[0].foreground,
4152            HighlightRole::builtin_,
4153            "colors {colors:?}"
4154        );
4155
4156        // Nonexistent command → error color.
4157        let line2 = "definitely_not_a_cmd_zshrs_x hi";
4158        let mut colors2 = Vec::new();
4159        highlight_shell(line2, &mut colors2, &ctx, /*io_ok=*/ true, None);
4160        assert_eq!(colors2[0].foreground, HighlightRole::error);
4161        // argument keeps param color
4162        let arg_pos = line2.chars().count() - 1;
4163        assert_eq!(colors2[arg_pos].foreground, HighlightRole::param);
4164    }
4165
4166    #[test]
4167    fn highlight_reserved_word_and_separator() {
4168        let _g = lock();
4169        let ctx = OperationContext::empty();
4170        let line = "if true; then echo x; fi";
4171        let mut colors = Vec::new();
4172        highlight_shell(line, &mut colors, &ctx, true, None);
4173        // `if` keyword
4174        assert_eq!(colors[0].foreground, HighlightRole::keyword, "{colors:?}");
4175        // `;` statement terminator
4176        let semi = line.chars().position(|c| c == ';').unwrap();
4177        assert_eq!(colors[semi].foreground, HighlightRole::statement_terminator);
4178    }
4179
4180    /// fast-highlight:698-699 — an assignment word is ONE style, `assign`,
4181    /// whose default is `none` (fast-highlight:84). fish:1018-1024 singles the
4182    /// `=` out with its operator face, which this engine renders as the glob
4183    /// colour, so every `FOO=bar` grew a blue `=` that f-sy-h never draws.
4184    #[test]
4185    fn highlight_assignment_is_one_flat_assign_span() {
4186        let _g = lock();
4187        let ctx = OperationContext::empty();
4188        let line = "FOO=bar echo x";
4189        let mut colors = Vec::new();
4190        highlight_shell(line, &mut colors, &ctx, true, None);
4191        let eq = line.chars().position(|c| c == '=').unwrap();
4192        for i in 0..line.chars().position(|c| c == ' ').unwrap() {
4193            assert_eq!(colors[i].foreground, HighlightRole::assign, "{colors:?}");
4194        }
4195        assert_eq!(colors[eq].foreground, HighlightRole::assign, "{colors:?}");
4196    }
4197
4198    #[test]
4199    fn highlight_io_off_assumes_valid() {
4200        let _g = lock();
4201        let ctx = OperationContext::empty();
4202        // fish:1047-1049 — io_ok=false: no validity IO, command assumed valid.
4203        let line = "definitely_not_a_cmd_zshrs_x";
4204        let mut colors = Vec::new();
4205        highlight_shell(line, &mut colors, &ctx, /*io_ok=*/ false, None);
4206        assert_eq!(colors[0].foreground, HighlightRole::command);
4207    }
4208
4209    #[test]
4210    fn resolver_default_palette() {
4211        let _g = lock();
4212        // Without user config, the resolver must produce non-zero attrs for the
4213        // roles with non-"none" defaults.
4214        let attr = HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
4215            HighlightRole::command,
4216        ));
4217        assert_ne!(attr, 0, "command role must default to a visible style");
4218        let err = HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
4219            HighlightRole::error,
4220        ));
4221        assert_ne!(err, 0);
4222        assert_ne!(attr, err, "command and error styles must differ");
4223    }
4224
4225    #[test]
4226    fn contains_pending_variable_matches_dollar_use() {
4227        // fish:1134-1155
4228        let vars = vec!["x".to_owned()];
4229        assert!(contains_pending_variable(&vars, "$x"));
4230        assert!(contains_pending_variable(&vars, "dir/$x/log"));
4231        assert!(!contains_pending_variable(&vars, "x"));
4232        assert!(!contains_pending_variable(&vars, "$xy"));
4233    }
4234
4235    #[test]
4236    fn locate_cmdsubst_span_finds_nested() {
4237        let chars: Vec<char> = "a$(b $(c) d)e".chars().collect();
4238        let (open, close) = locate_cmdsubst_span(&chars, 0).unwrap();
4239        assert_eq!(open, 1);
4240        assert_eq!(close, Some(11));
4241        // unterminated
4242        let chars2: Vec<char> = "a$(b".chars().collect();
4243        let (o2, c2) = locate_cmdsubst_span(&chars2, 0).unwrap();
4244        assert_eq!(o2, 1);
4245        assert_eq!(c2, None);
4246    }
4247}