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}