1#![allow(non_snake_case)]
29#![allow(non_camel_case_types)]
30
31use crate::ported::exec::findcmd;
32use crate::ported::hashtable::{aliastab_lock, cmdnamtab_lock, reswdtab_lock};
33use crate::ported::lex::{
34 ctxtlex, incmdpos, inredir, tok, tokstr, untokenize, LEX_LEXFLAGS, LEX_WORDBEG,
35};
36use crate::ported::params::{gethparam, getsparam};
37use crate::ported::prompt::match_highlight;
38use crate::ported::utils::getshfunc;
39use crate::ported::zsh_h::{
40 isset, lextok, zattr, AMPER, AMPERBANG, AUTOCD, BAR_TOK, CASE, CLOBBER, DAMPER, DBAR,
41 DOUTANG, DOUTANGAMP, DOUTANGAMPBANG, DOUTANGBANG, DINANG, DINANGDASH, ENDINPUT, ENVARRAY,
42 ENVSTRING, INANGAMP, INANG_TOK, INOUTANG, INPAR_TOK, INTERACTIVECOMMENTS, IS_REDIROP,
43 LEXERR, LEXFLAGS_ACTIVE, LEXFLAGS_ZLE, NEWLIN, OUTANGAMP, OUTANGAMPBANG, OUTANGBANG,
44 OUTANG_TOK, OUTPAR_TOK, SEMI, SEPER, STRING_LEX, TYPESET,
45};
46use crate::zle_file_tester::{
47 expand_one_no_cmdsubst, FileTester, IsErr, IsFile, OperationContext, RedirectionMode,
48};
49use std::collections::{hash_map::Entry, HashMap};
50use std::sync::Mutex;
51
52#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
54pub struct HighlightSpec {
55 pub foreground: HighlightRole,
56 pub background: HighlightRole,
57 pub valid_path: bool,
58 pub force_underline: bool,
59}
60
61impl HighlightSpec {
62 pub fn new() -> Self {
64 Self::default()
65 }
66 pub fn with_fg_bg(fg: HighlightRole, bg: HighlightRole) -> Self {
68 Self {
69 foreground: fg,
70 background: bg,
71 ..Default::default()
72 }
73 }
74 pub fn with_fg(fg: HighlightRole) -> Self {
76 Self::with_fg_bg(fg, HighlightRole::normal)
77 }
78 pub fn with_bg(bg: HighlightRole) -> Self {
80 Self::with_fg_bg(HighlightRole::normal, bg)
81 }
82 pub fn with_both(role: HighlightRole) -> Self {
84 Self::with_fg_bg(role, role)
85 }
86}
87
88#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
90#[repr(u8)]
91pub enum HighlightRole {
92 #[default]
93 normal, error, command, keyword,
97 statement_terminator, param, option, comment, search_match, operat, escape, quote, redirection, autosuggestion, selection,
108
109 pager_progress,
112 pager_background,
113 pager_prefix,
114 pager_completion,
115 pager_description,
116 pager_secondary_background,
117 pager_secondary_prefix,
118 pager_secondary_completion,
119 pager_secondary_description,
120 pager_selected_background,
121 pager_selected_prefix,
122 pager_selected_completion,
123 pager_selected_description,
124}
125
126pub type ColorArray = Vec<HighlightSpec>;
128
129fn get_highlight_style_key(role: HighlightRole) -> &'static str {
134 match role {
135 HighlightRole::normal => "default",
136 HighlightRole::error => "unknown-token",
137 HighlightRole::command => "command",
138 HighlightRole::keyword => "reserved-word",
139 HighlightRole::statement_terminator => "commandseparator",
140 HighlightRole::param => "default",
141 HighlightRole::option => "single-hyphen-option",
142 HighlightRole::comment => "comment",
143 HighlightRole::search_match => "history-search-match",
144 HighlightRole::operat => "globbing",
145 HighlightRole::escape => "back-dollar-quoted-argument",
146 HighlightRole::quote => "single-quoted-argument",
147 HighlightRole::redirection => "redirection",
148 HighlightRole::autosuggestion => "autosuggestion",
149 HighlightRole::selection => "selection",
150 _ => "default",
152 }
153}
154
155fn get_default_style(role: HighlightRole) -> &'static str {
162 match role {
163 HighlightRole::error => "fg=red",
164 HighlightRole::command | HighlightRole::keyword => match role {
165 HighlightRole::keyword => "fg=yellow",
166 _ => "fg=green",
167 },
168 HighlightRole::comment => "fg=black,bold",
169 HighlightRole::operat => "fg=blue",
170 HighlightRole::escape => "fg=cyan",
171 HighlightRole::quote => "fg=yellow",
172 HighlightRole::redirection => "none",
173 HighlightRole::autosuggestion => "fg=8",
174 HighlightRole::selection | HighlightRole::search_match => "standout",
175 _ => "none",
176 }
177}
178
179fn get_fallback(role: HighlightRole) -> HighlightRole {
182 match role {
183 HighlightRole::normal
184 | HighlightRole::error
185 | HighlightRole::command
186 | HighlightRole::statement_terminator
187 | HighlightRole::param
188 | HighlightRole::search_match
189 | HighlightRole::comment
190 | HighlightRole::operat
191 | HighlightRole::escape
192 | HighlightRole::quote
193 | HighlightRole::redirection
194 | HighlightRole::autosuggestion
195 | HighlightRole::selection
196 | HighlightRole::pager_progress
197 | HighlightRole::pager_background
198 | HighlightRole::pager_prefix
199 | HighlightRole::pager_completion
200 | HighlightRole::pager_description => HighlightRole::normal,
201 HighlightRole::keyword => HighlightRole::command,
202 HighlightRole::option => HighlightRole::param,
203 HighlightRole::pager_secondary_background => HighlightRole::pager_background,
204 HighlightRole::pager_secondary_prefix | HighlightRole::pager_selected_prefix => {
205 HighlightRole::pager_prefix
206 }
207 HighlightRole::pager_secondary_completion | HighlightRole::pager_selected_completion => {
208 HighlightRole::pager_completion
209 }
210 HighlightRole::pager_secondary_description | HighlightRole::pager_selected_description => {
211 HighlightRole::pager_description
212 }
213 HighlightRole::pager_selected_background => HighlightRole::search_match,
214 }
215}
216
217fn parse_style_for_highlight(spec: &str) -> Option<zattr> {
222 if spec.is_empty() {
223 return None;
224 }
225 let (mask_on, _mask_off) = match_highlight(spec);
226 Some(mask_on)
227}
228
229fn zsh_highlight_styles_get(key: &str) -> Option<String> {
232 let flat = gethparam("ZSH_HIGHLIGHT_STYLES")?;
233 let mut it = flat.chunks_exact(2);
234 it.find(|kv| kv[0] == key).map(|kv| kv[1].clone())
235}
236
237#[derive(Default)]
241pub struct HighlightColorResolver {
242 cache: HashMap<HighlightSpec, zattr>,
244}
245
246impl HighlightColorResolver {
247 pub fn new() -> Self {
249 Default::default()
250 }
251 pub fn resolve_spec(&mut self, highlight: &HighlightSpec) -> zattr {
253 match self.cache.entry(*highlight) {
254 Entry::Occupied(e) => *e.get(),
255 Entry::Vacant(e) => {
256 let face = Self::resolve_spec_uncached(highlight);
257 e.insert(face);
258 face
259 }
260 }
261 }
262 pub fn resolve_spec_uncached(highlight: &HighlightSpec) -> zattr {
264 let resolve_role = |role: HighlightRole| -> zattr {
266 let mut roles: &[HighlightRole] = &[role, get_fallback(role), HighlightRole::normal];
267 for i in [2, 1] {
268 if roles[i - 1] == roles[i] {
269 roles = &roles[..i];
270 }
271 }
272 for &role in roles {
273 let configured = if role == HighlightRole::autosuggestion {
275 getsparam("ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE").filter(|s| !s.is_empty())
276 } else {
277 zsh_highlight_styles_get(get_highlight_style_key(role))
278 };
279 if let Some(face) = configured.as_deref().and_then(parse_style_for_highlight) {
280 return face;
281 }
282 }
283 parse_style_for_highlight(get_default_style(role)).unwrap_or(0)
285 };
286 let mut face = resolve_role(highlight.foreground);
287
288 if highlight.background != highlight.foreground {
292 use crate::ported::zsh_h::{TXTBGCOLOUR, TXT_ATTR_BG_COL_MASK};
293 let bg_face = resolve_role(highlight.background);
294 face |= bg_face & (TXTBGCOLOUR as zattr | TXT_ATTR_BG_COL_MASK);
295 }
296
297 if highlight.valid_path {
300 let path_spec = zsh_highlight_styles_get("path").unwrap_or_default();
301 let merged = if path_spec.is_empty() {
302 parse_style_for_highlight("underline")
303 } else {
304 parse_style_for_highlight(&path_spec)
305 };
306 if let Some(m) = merged {
307 face |= m;
308 }
309 }
310
311 if highlight.force_underline {
313 face |= crate::ported::zsh_h::TXTUNDERLINE as zattr;
314 }
315
316 face
317 }
318}
319
320pub fn colorize(text: &str, colors: &[HighlightSpec]) -> Vec<u8> {
323 let chars: Vec<char> = text.chars().collect();
324 assert_eq!(colors.len(), chars.len());
325 let mut rv = HighlightColorResolver::new();
326 let mut out: Vec<u8> = Vec::new();
327
328 let mut last_color: Option<HighlightSpec> = None;
329 for (i, &c) in chars.iter().enumerate() {
330 let color = colors[i];
331 if Some(color) != last_color {
332 let face = rv.resolve_spec(&color);
333 out.extend_from_slice(zattr_to_sgr(face).as_bytes());
334 last_color = Some(color);
335 }
336 if i + 1 == chars.len() && c == '\n' {
338 out.extend_from_slice(b"\x1b[0m");
339 }
340 let mut buf = [0u8; 4];
341 out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
342 }
343 out.extend_from_slice(b"\x1b[0m"); out
345}
346
347fn zattr_to_sgr(attr: zattr) -> String {
352 use crate::ported::zsh_h::{
353 TXTBGCOLOUR, TXTBOLDFACE, TXTFGCOLOUR, TXTSTANDOUT, TXTUNDERLINE,
354 TXT_ATTR_BG_COL_SHIFT, TXT_ATTR_FG_COL_SHIFT,
355 };
356 let mut s = String::from("\x1b[0");
357 if attr & TXTBOLDFACE as zattr != 0 {
358 s.push_str(";1");
359 }
360 if attr & TXTSTANDOUT as zattr != 0 {
361 s.push_str(";7");
362 }
363 if attr & TXTUNDERLINE as zattr != 0 {
364 s.push_str(";4");
365 }
366 if attr & TXTFGCOLOUR as zattr != 0 {
367 let col = (attr >> TXT_ATTR_FG_COL_SHIFT) & 0xffffff;
368 s.push_str(&format!(";38;5;{}", col));
369 }
370 if attr & TXTBGCOLOUR as zattr != 0 {
371 let col = (attr >> TXT_ATTR_BG_COL_SHIFT) & 0xffffff;
372 s.push_str(&format!(";48;5;{}", col));
373 }
374 s.push('m');
375 s
376}
377
378pub fn highlight_shell(
385 buff: &str,
386 color: &mut Vec<HighlightSpec>,
387 ctx: &OperationContext,
388 io_ok: bool,
389 cursor: Option<usize>,
390) {
391 let working_directory = getsparam("PWD").unwrap_or_else(|| ".".to_owned());
393 let mut highlighter = Highlighter::new(buff, cursor, ctx, working_directory, io_ok);
394 *color = highlighter.highlight();
395}
396
397pub fn highlight_and_colorize(text: &str, ctx: &OperationContext) -> Vec<u8> {
399 let mut colors = Vec::new();
400 highlight_shell(text, &mut colors, ctx, false, None);
401 colorize(text, &colors)
402}
403
404#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
407pub enum StatementDecoration {
408 #[default]
409 None_,
410 Command, Builtin, Exec,
413}
414
415static CMD_VALID_CACHE: Mutex<Option<(String, HashMap<(String, u8), bool>)>> = Mutex::new(None);
422const CMD_VALID_CACHE_MAX: usize = 8192;
423
424pub fn command_is_valid_cached(
425 cmd: &str,
426 decoration: StatementDecoration,
427 working_directory: &str,
428) -> bool {
429 let path_now = getsparam("PATH").unwrap_or_default();
433 let key = (cmd.to_owned(), decoration as u8);
434 let cached: Option<bool> = {
435 let mut guard = CMD_VALID_CACHE.lock().unwrap();
436 match guard.as_mut() {
437 Some((path, map)) if *path == path_now => map.get(&key).copied(),
438 _ => {
439 *guard = Some((path_now, HashMap::new()));
440 None
441 }
442 }
443 };
444 let tables_valid = cached.unwrap_or_else(|| {
445 let v = command_is_valid_tables(cmd, decoration);
446 let mut guard = CMD_VALID_CACHE.lock().unwrap();
447 if let Some((_, map)) = guard.as_mut() {
448 if map.len() >= CMD_VALID_CACHE_MAX {
449 map.clear();
450 }
451 map.insert(key, v);
452 }
453 v
454 });
455 if tables_valid {
456 return true;
457 }
458 if decoration == StatementDecoration::None_ && isset(AUTOCD) {
460 let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
461 return std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false);
462 }
463 false
464}
465
466pub fn command_is_valid(
468 cmd: &str,
469 decoration: StatementDecoration,
470 working_directory: &str,
471) -> bool {
472 if command_is_valid_tables(cmd, decoration) {
473 return true;
474 }
475 if decoration == StatementDecoration::None_ && isset(AUTOCD) {
478 let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
479 if std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false) {
480 return true;
481 }
482 }
483 false
485}
486
487fn command_is_valid_tables(cmd: &str, decoration: StatementDecoration) -> bool {
490 let mut builtin_ok = true;
492 let mut function_ok = true;
493 let mut alias_ok = true;
495 let mut command_ok = true;
496 if matches!(
497 decoration,
498 StatementDecoration::Command | StatementDecoration::Exec
499 ) {
500 builtin_ok = false;
501 function_ok = false;
502 alias_ok = false;
503 command_ok = true;
504 } else if decoration == StatementDecoration::Builtin {
505 builtin_ok = true;
506 function_ok = false;
507 alias_ok = false;
508 command_ok = false;
509 }
510
511 let mut is_valid = false;
513
514 if !is_valid && builtin_ok {
517 is_valid = crate::ported::builtin::createbuiltintable().contains_key(cmd);
518 }
519
520 if !is_valid && function_ok {
522 is_valid = getshfunc(cmd).is_some();
523 }
524
525 if !is_valid && alias_ok {
527 is_valid = aliastab_lock()
528 .read()
529 .map(|t| t.get(cmd).is_some())
530 .unwrap_or(false);
531 }
532
533 if !is_valid && command_ok {
535 is_valid = cmdnamtab_lock()
536 .read()
537 .map(|t| t.get(cmd).is_some())
538 .unwrap_or(false)
539 || findcmd(cmd, 0, 0).is_some();
540 }
541
542 is_valid
543}
544
545fn has_expand_reserved(s: &str) -> bool {
548 s.chars().any(|wc| ('\u{84}'..='\u{a1}').contains(&wc))
549}
550
551pub fn autosuggest_parse_command(buff: &str) -> Option<(String, String)> {
557 let toks = lex_line_tokens(buff);
558 let mut cmd: Option<String> = None;
559 let mut arg = String::new(); for t in &toks {
561 if t.tok == STRING_LEX {
562 match &cmd {
563 None if t.cmdpos => {
564 let text = t.clean_text();
566 let mut expanded = t.text.clone().unwrap_or_default();
567 if expand_one_no_cmdsubst(&mut expanded) && !expanded.is_empty() {
568 cmd = Some(expanded);
569 } else {
570 cmd = Some(text);
571 }
572 }
573 None => (),
574 Some(_) => {
575 if !t.in_redir {
578 arg = t.clean_text();
579 }
580 break;
581 }
582 }
583 } else if cmd.is_some() {
584 break; }
586 }
587 cmd.map(|c| (c, arg)) }
589
590pub fn is_veritable_cd(expanded_command: &str) -> bool {
593 expanded_command == "cd"
594 && aliastab_lock()
595 .read()
596 .map(|t| t.get("cd").is_none())
597 .unwrap_or(true)
598}
599
600pub fn autosuggest_validate_from_history(
607 item_commandline: &str,
608 required_paths: &[String],
609 working_directory: &str,
610 ctx: &OperationContext,
611) -> bool {
612 let Some((parsed_command, mut cd_dir)) = autosuggest_parse_command(item_commandline) else {
618 return true;
621 };
622
623 if is_veritable_cd(&parsed_command) && !cd_dir.is_empty() {
625 if expand_one_no_cmdsubst(&mut cd_dir) {
626 if "--help".starts_with(&cd_dir) || "-h".starts_with(&cd_dir) {
627 return true;
629 } else {
630 return crate::zle_file_tester::is_potential_cd_path(
633 &cd_dir,
634 false,
635 working_directory,
636 ctx,
637 Default::default(),
638 );
639 }
640 }
641 }
642
643 let cmd_ok =
645 command_is_valid_cached(&parsed_command, StatementDecoration::None_, working_directory);
646 if !cmd_ok {
647 return false;
648 }
649
650 if !required_paths.is_empty() {
653 let tester = FileTester::new(working_directory.to_owned(), ctx);
654 if !required_paths.iter().all(|p| tester.test_path(p, false)) {
655 return false;
656 }
657 }
658
659 true }
661
662fn valid_var_name_char(c: char) -> bool {
665 c.is_alphanumeric() || c == '_'
666}
667
668fn valid_var_name(s: &str) -> bool {
669 !s.is_empty()
670 && !s.starts_with(|c: char| c.is_ascii_digit())
671 && s.chars().all(valid_var_name_char)
672}
673
674fn is_special_param_char(c: char) -> bool {
676 matches!(c, '?' | '#' | '$' | '!' | '@' | '*' | '-' | '_') || c.is_ascii_digit()
677}
678
679fn color_variable(inp: &[char], colors: &mut [HighlightSpec]) -> usize {
685 assert_eq!(inp[0], '$');
686
687 let at = |i: usize| -> char { inp.get(i).copied().unwrap_or('\0') };
688
689 let mut idx = 0;
691 let mut dollar_count = 0;
692 while at(idx) == '$' {
693 let next = at(idx + 1);
695 if next == '$' || valid_var_name_char(next) || is_special_param_char(next) {
696 colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
697 } else if next == '(' || next == '{' || next == '\'' {
698 colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
701 return idx + 1;
702 } else {
703 colors[idx] = HighlightSpec::with_fg(HighlightRole::error);
704 }
705 idx += 1;
706 dollar_count += 1;
707 }
708
709 if idx == dollar_count && !valid_var_name_char(at(idx)) && is_special_param_char(at(idx)) {
711 colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
712 return idx + 1;
713 }
714
715 loop {
718 if valid_var_name_char(at(idx)) {
719 colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
720 idx += 1;
721 } else if at(idx) == '\\' && at(idx + 1) == '\n' {
722 colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
723 idx += 1;
724 colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
725 idx += 1;
726 } else {
727 break;
728 }
729 }
730
731 for _slice_count in 0..dollar_count {
734 match subscript_length(&inp[idx..]) {
735 Some(slice_len) if slice_len > 0 => {
736 colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
737 colors[idx + slice_len - 1] = HighlightSpec::with_fg(HighlightRole::operat);
738 idx += slice_len;
739 }
740 Some(_slice_len) => {
741 break;
743 }
744 None => {
745 colors[..=idx].fill(HighlightSpec::with_fg(HighlightRole::error));
748 break;
749 }
750 }
751 }
752 idx
753}
754
755fn subscript_length(inp: &[char]) -> Option<usize> {
759 if inp.first() != Some(&'[') {
760 return Some(0);
761 }
762 let mut depth = 0usize;
763 for (i, &c) in inp.iter().enumerate() {
764 match c {
765 '[' => depth += 1,
766 ']' => {
767 depth -= 1;
768 if depth == 0 {
769 return Some(i + 1);
770 }
771 }
772 _ => (),
773 }
774 }
775 None
776}
777
778pub fn color_string_internal(
782 buffstr: &str,
783 base_color: HighlightSpec,
784 colors: &mut [HighlightSpec],
785) {
786 assert!(
788 [
789 HighlightSpec::with_fg(HighlightRole::param),
790 HighlightSpec::with_fg(HighlightRole::option),
791 HighlightSpec::with_fg(HighlightRole::command)
792 ]
793 .contains(&base_color),
794 "Unexpected base color"
795 );
796 let chars: Vec<char> = buffstr.chars().collect();
797 let buff_len = chars.len();
798 colors.fill(base_color);
799
800 #[derive(Eq, PartialEq)]
803 enum Mode {
804 unquoted,
805 single_quoted,
806 double_quoted,
807 dollar_quoted, backtick, }
810 let mut mode = Mode::unquoted;
811 let mut unclosed_quote_offset = None;
812 let mut bracket_count = 0;
813 let mut in_pos = 0;
814 while in_pos < buff_len {
815 let c = chars[in_pos];
816 match mode {
817 Mode::unquoted => {
818 if c == '\\' {
819 let backslash_pos = in_pos;
823 let fill_end = if in_pos + 1 < buff_len {
824 in_pos + 2
825 } else {
826 in_pos + 1
827 };
828 colors[backslash_pos..fill_end]
829 .fill(HighlightSpec::with_fg(HighlightRole::escape));
830 in_pos += 1; } else {
832 match c {
834 '~' if in_pos == 0 => {
835 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
836 }
837 '$' if chars.get(in_pos + 1) == Some(&'\'') => {
838 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
841 colors[in_pos + 1] = HighlightSpec::with_fg(HighlightRole::quote);
842 unclosed_quote_offset = Some(in_pos);
843 in_pos += 1;
844 mode = Mode::dollar_quoted;
845 }
846 '$' => {
847 assert!(in_pos < buff_len);
848 in_pos += color_variable(&chars[in_pos..], &mut colors[in_pos..]);
849 in_pos -= 1;
852 }
853 '`' => {
854 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
855 unclosed_quote_offset = Some(in_pos);
856 mode = Mode::backtick;
857 }
858 '?' | '*' | '(' | ')' => {
859 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
861 }
862 '{' => {
863 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
864 bracket_count += 1;
865 }
866 '}' => {
867 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
868 bracket_count -= 1;
869 }
870 ',' if bracket_count > 0 => {
871 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
872 }
873 '[' | ']' => {
874 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
876 }
877 '\'' => {
878 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
879 unclosed_quote_offset = Some(in_pos);
880 mode = Mode::single_quoted;
881 }
882 '"' => {
883 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
884 unclosed_quote_offset = Some(in_pos);
885 mode = Mode::double_quoted;
886 }
887 _ => (), }
889 }
890 }
891 Mode::single_quoted => {
895 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
896 if c == '\'' {
897 mode = Mode::unquoted;
898 }
899 }
900 Mode::double_quoted => {
902 if colors[in_pos] == base_color {
905 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
906 }
907 match c {
908 '"' => {
909 mode = Mode::unquoted;
910 }
911 '\\' if in_pos + 1 < buff_len => {
912 let escaped_char = chars[in_pos + 1];
914 if matches!(escaped_char, '\\' | '"' | '$' | '`' | '\n') {
915 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::escape);
916 colors[in_pos + 1] = HighlightSpec::with_fg(HighlightRole::escape);
917 in_pos += 1; }
919 }
920 '$' => {
921 in_pos += color_variable(&chars[in_pos..], &mut colors[in_pos..]);
922 in_pos -= 1;
925 }
926 _ => (), }
928 }
929 Mode::dollar_quoted => {
932 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
933 if c == '\\' && in_pos + 1 < buff_len {
934 let mut fill_color = HighlightRole::escape;
935 let backslash_pos = in_pos;
936 let mut fill_end = backslash_pos;
937 in_pos += 1;
938 let escaped_char = chars[in_pos];
939 if "abcefnrtv\\'\"?".contains(escaped_char) {
940 fill_end = in_pos + 1;
941 } else if "uUxX01234567".contains(escaped_char) {
942 let mut res: u32 = 0;
945 let mut chars_max = 2;
946 let mut base = 16;
947 let mut max_val = 0x7f_u32; match escaped_char {
950 'u' => {
951 chars_max = 4;
952 max_val = 0xFFFF; in_pos += 1;
954 }
955 'U' => {
956 chars_max = 8;
957 max_val = 0x10FFFF;
960 in_pos += 1;
961 }
962 'x' | 'X' => {
963 max_val = 0xFF;
964 in_pos += 1;
965 }
966 _ => {
967 base = 8;
969 chars_max = 3;
970 }
971 }
972
973 for _i in 0..chars_max {
975 if in_pos == buff_len {
976 break;
977 }
978 let Some(d) = chars[in_pos].to_digit(base) else {
979 break;
980 };
981 res = res.saturating_mul(base).saturating_add(d);
982 in_pos += 1;
983 }
984 fill_end = in_pos;
987
988 if res > max_val {
990 fill_color = HighlightRole::error;
991 }
992
993 in_pos -= 1;
996 } else {
997 fill_end = in_pos + 1;
998 }
999 if fill_end > backslash_pos {
1000 colors[backslash_pos..fill_end.min(buff_len)]
1001 .fill(HighlightSpec::with_fg(fill_color));
1002 } else {
1003 colors[backslash_pos] = HighlightSpec::with_fg(fill_color);
1004 colors[in_pos.min(buff_len - 1)] = HighlightSpec::with_fg(fill_color);
1005 }
1006 } else if c == '\'' {
1007 mode = Mode::unquoted;
1008 }
1009 }
1010 Mode::backtick => {
1013 if c == '`' {
1014 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
1015 mode = Mode::unquoted;
1016 } else {
1017 colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
1018 }
1019 }
1020 }
1021 in_pos += 1;
1022 }
1023
1024 if mode != Mode::unquoted {
1026 colors[unclosed_quote_offset.unwrap()] = HighlightSpec::with_fg(HighlightRole::error);
1027 }
1028}
1029
1030#[derive(Clone, Debug)]
1037pub struct TokSpan {
1038 pub tok: lextok,
1039 pub text: Option<String>,
1040 pub start: usize,
1041 pub end: usize,
1042 pub cmdpos: bool,
1044 pub in_redir: bool,
1046}
1047
1048impl TokSpan {
1049 pub fn clean_text(&self) -> String {
1051 let mut s = self.text.clone().unwrap_or_default();
1052 crate::ported::glob::remnulargs(&mut s);
1053 untokenize(&s)
1054 }
1055}
1056
1057pub fn lex_line_tokens(line: &str) -> Vec<TokSpan> {
1073 use crate::ported::zle::compcore::{ADDEDX, ZLEMETACS, ZLEMETALL};
1074 use std::sync::atomic::Ordering;
1075
1076 let ll = line.chars().count() as i32;
1077 let mut out: Vec<TokSpan> = Vec::new();
1078
1079 let saved_cs = ZLEMETACS.load(Ordering::SeqCst);
1081 let saved_ll = ZLEMETALL.load(Ordering::SeqCst);
1082 let saved_addedx = ADDEDX.load(Ordering::SeqCst);
1083
1084 crate::ported::context::zcontext_save(); let saved_unget: std::collections::VecDeque<char> =
1092 crate::ported::lex::LEX_UNGET_BUF.with_borrow_mut(std::mem::take);
1093 ZLEMETALL.store(ll, Ordering::SeqCst);
1094 ZLEMETACS.store(ll + 5, Ordering::SeqCst); ADDEDX.store(0, Ordering::SeqCst);
1096
1097 LEX_LEXFLAGS.set(LEXFLAGS_ZLE | LEXFLAGS_ACTIVE);
1100 crate::ported::input::inpush(&crate::ported::zle::zle_tricky::dupstrspace(line), 0, None); crate::ported::hist::strinbeg(0); let mut prev_inbufct = i32::MIN;
1110 loop {
1111 let cmdpos_before = incmdpos();
1112 let inredir_before = inredir();
1113 ctxtlex(); let mut tokv = tok();
1118 let was_lexerr = tokv == LEXERR;
1119 if tokv == LEXERR {
1120 match tokstr() {
1121 None => break,
1122 Some(ts) => {
1123 use crate::ported::zsh_h::{Dnull, Snull};
1124 let jcnt = ts.chars().filter(|&c| c == Snull || c == Dnull).count();
1125 if jcnt & 1 == 1 {
1126 tokv = STRING_LEX;
1127 }
1128 }
1129 }
1130 }
1131
1132 if tokv == ENDINPUT {
1133 break; }
1135
1136 let inbufct = crate::ported::input::inbufct.with(|c| c.get());
1137 let wordbeg = LEX_WORDBEG.get();
1138 let start = (ll - wordbeg).clamp(0, ll) as usize; let end = (ll + 1 - inbufct).clamp(start as i32, ll) as usize; let no_progress = inbufct == prev_inbufct;
1142 prev_inbufct = inbufct;
1143
1144 out.push(TokSpan {
1145 tok: tokv,
1146 text: tokstr(),
1147 start,
1148 end,
1149 cmdpos: cmdpos_before,
1150 in_redir: inredir_before,
1151 });
1152
1153 if (was_lexerr && no_progress) || out.len() > (ll as usize + 8) {
1157 break;
1158 }
1159 if was_lexerr && inbufct <= 1 {
1160 break; }
1162 }
1163
1164 crate::ported::hist::strinend(); crate::ported::input::inpop(); crate::ported::context::zcontext_restore(); crate::ported::lex::LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
1170
1171 ZLEMETACS.store(saved_cs, Ordering::SeqCst);
1172 ZLEMETALL.store(saved_ll, Ordering::SeqCst);
1173 ADDEDX.store(saved_addedx, Ordering::SeqCst);
1174
1175 out
1176}
1177
1178pub struct Highlighter<'s> {
1180 buff: &'s str,
1182 buff_chars: Vec<char>,
1183 cursor: Option<usize>,
1185 ctx: &'s OperationContext,
1187 io_ok: bool,
1189 working_directory: String,
1191 file_tester: FileTester<'s>,
1193 color_array: ColorArray,
1195 pending_variables: Vec<String>,
1199 done: bool,
1200}
1201
1202impl<'s> Highlighter<'s> {
1203 pub fn new(
1205 buff: &'s str,
1206 cursor: Option<usize>,
1207 ctx: &'s OperationContext,
1208 working_directory: String,
1209 can_do_io: bool,
1210 ) -> Self {
1211 let file_tester = FileTester::new(working_directory.clone(), ctx);
1212 Self {
1213 buff,
1214 buff_chars: buff.chars().collect(),
1215 cursor,
1216 ctx,
1217 io_ok: can_do_io,
1218 working_directory,
1219 file_tester,
1220 color_array: vec![],
1221 pending_variables: vec![],
1222 done: false,
1223 }
1224 }
1225
1226 pub fn highlight(&mut self) -> ColorArray {
1228 assert!(!self.done);
1229 self.done = true;
1230
1231 self.color_array
1232 .resize(self.buff_chars.len(), HighlightSpec::default());
1233
1234 let toks = lex_line_tokens(self.buff);
1238
1239 self.visit_tokens(&toks);
1240 if self.ctx.check_cancel() {
1241 return std::mem::take(&mut self.color_array);
1242 }
1243
1244 if isset(INTERACTIVECOMMENTS) {
1248 self.color_gap_comments(&toks);
1249 }
1250
1251 for t in toks.iter().filter(|t| t.tok == LEXERR) {
1254 self.color_span(t.start, self.buff_chars.len(), HighlightRole::error);
1255 }
1256
1257 std::mem::take(&mut self.color_array)
1258 }
1259
1260 fn io_still_ok(&self) -> bool {
1261 self.io_ok && !self.ctx.check_cancel()
1263 }
1264
1265 fn color_span(&mut self, start: usize, end: usize, role: HighlightRole) {
1267 let end = end.min(self.color_array.len());
1268 if start < end {
1269 self.color_array[start..end].fill(HighlightSpec::with_fg(role));
1270 }
1271 }
1272
1273 fn span_text(&self, t: &TokSpan) -> String {
1275 self.buff_chars[t.start.min(self.buff_chars.len())..t.end.min(self.buff_chars.len())]
1276 .iter()
1277 .collect()
1278 }
1279
1280 fn visit_tokens(&mut self, toks: &[TokSpan]) {
1284 let mut decoration = StatementDecoration::None_;
1285 let mut expanded_cmd = String::new();
1286 let mut is_cd = false;
1287 let mut is_typeset = false;
1288 let mut have_dashdash = false;
1289 let mut i = 0;
1290 while i < toks.len() {
1291 if self.ctx.check_cancel() {
1292 return;
1293 }
1294 let t = &toks[i];
1295 let tokv = t.tok;
1296
1297 if IS_REDIROP(tokv) {
1298 let target = toks.get(i + 1).filter(|n| n.tok == STRING_LEX);
1300 self.visit_redirection(t, target);
1301 if target.is_some() {
1302 i += 2;
1303 } else {
1304 i += 1;
1305 }
1306 continue;
1307 }
1308
1309 match tokv {
1310 SEPER | NEWLIN | SEMI | AMPER | AMPERBANG | BAR_TOK => {
1311 self.color_span(t.start, t.end, HighlightRole::statement_terminator);
1313 decoration = StatementDecoration::None_;
1314 expanded_cmd.clear();
1315 is_cd = false;
1316 is_typeset = false;
1317 have_dashdash = false;
1318 }
1319 DBAR | DAMPER => {
1320 self.color_span(t.start, t.end, HighlightRole::operat);
1322 decoration = StatementDecoration::None_;
1323 expanded_cmd.clear();
1324 is_cd = false;
1325 is_typeset = false;
1326 have_dashdash = false;
1327 }
1328 INPAR_TOK | OUTPAR_TOK | INOUTPAR_LOCAL => {
1329 self.color_span(t.start, t.end, HighlightRole::operat);
1330 }
1331 ENVSTRING => {
1332 self.visit_variable_assignment(t);
1334 }
1335 ENVARRAY => {
1336 self.visit_variable_assignment(t);
1339 }
1340 STRING_LEX => {
1341 if t.cmdpos && expanded_cmd.is_empty() {
1342 let clean = t.clean_text();
1344 match clean.as_str() {
1345 "command" => {
1348 decoration = StatementDecoration::Command;
1349 self.color_span(t.start, t.end, HighlightRole::keyword);
1350 }
1351 "builtin" => {
1352 decoration = StatementDecoration::Builtin;
1353 self.color_span(t.start, t.end, HighlightRole::keyword);
1354 }
1355 "exec" => {
1356 decoration = StatementDecoration::Exec;
1357 self.color_span(t.start, t.end, HighlightRole::keyword);
1358 }
1359 "noglob" | "nocorrect" => {
1360 self.color_span(t.start, t.end, HighlightRole::keyword);
1361 }
1362 _ => {
1363 self.visit_command_word(t, &clean, decoration);
1364 expanded_cmd = clean;
1365 is_cd = is_veritable_cd(&expanded_cmd);
1366 is_typeset = matches!(
1367 expanded_cmd.as_str(),
1368 "typeset" | "local" | "declare" | "export" | "readonly"
1369 | "integer" | "float"
1370 );
1371 }
1372 }
1373 } else {
1374 if is_typeset {
1376 let arg = t.clean_text();
1379 let name = arg.split('=').next().unwrap_or("").to_owned();
1380 if valid_var_name(&name) {
1381 self.pending_variables.push(name);
1382 }
1383 }
1384 self.visit_argument(t, is_cd, !have_dashdash);
1385 if self.span_text(t) == "--" {
1386 have_dashdash = true; }
1388 }
1389 }
1390 tokv if (CASE..=TYPESET).contains(&tokv) => {
1391 self.color_span(t.start, t.end, HighlightRole::keyword);
1393 if tokv == TYPESET {
1394 is_typeset = true;
1395 }
1396 }
1397 LEXERR => {
1398 self.color_span(t.start, t.end, HighlightRole::error);
1399 }
1400 _ => {
1401 }
1403 }
1404 i += 1;
1405 }
1406 }
1407
1408 fn visit_command_word(&mut self, t: &TokSpan, clean: &str, decoration: StatementDecoration) {
1410 let mut is_valid_cmd = false;
1413 if !self.io_still_ok() {
1414 is_valid_cmd = true;
1417 } else {
1418 let mut expanded = t.text.clone().unwrap_or_default();
1421 let expanded_ok = expand_one_no_cmdsubst(&mut expanded);
1422 let cmd = if expanded_ok && !expanded.is_empty() {
1423 expanded
1424 } else {
1425 clean.to_owned()
1426 };
1427 if !has_expand_reserved(&cmd) {
1428 is_valid_cmd = command_is_valid_cached(&cmd, decoration, &self.working_directory);
1429 }
1430 }
1431
1432 if is_valid_cmd {
1434 let start = t.start;
1435 let end = t.end.min(self.color_array.len());
1436 let src: String = self.span_text(t);
1437 color_string_internal(
1438 &src,
1439 HighlightSpec::with_fg(HighlightRole::command),
1440 &mut self.color_array[start..end],
1441 );
1442 } else {
1443 self.color_span(t.start, t.end, HighlightRole::error);
1444 }
1445 }
1446
1447 fn visit_argument(&mut self, t: &TokSpan, cmd_is_cd: bool, options_allowed: bool) {
1450 let start = t.start;
1451 let end = t.end.min(self.color_array.len());
1452 if start >= end {
1453 return;
1454 }
1455 let src: String = self.span_text(t);
1456
1457 let base = if options_allowed && src.starts_with('-') {
1460 HighlightRole::option
1461 } else {
1462 HighlightRole::param
1463 };
1464 color_string_internal(
1465 &src,
1466 HighlightSpec::with_fg(base),
1467 &mut self.color_array[start..end],
1468 );
1469
1470 let src_chars: Vec<char> = src.chars().collect();
1473 let mut scan = 0usize;
1474 while let Some((open, close)) = locate_cmdsubst_span(&src_chars, scan) {
1475 self.color_span(start + open, start + open + 2, HighlightRole::operat);
1478 let inner_start = open + 2;
1479 let inner_end = close.unwrap_or(src_chars.len());
1480 if let Some(c) = close {
1481 self.color_span(start + c, start + c + 1, HighlightRole::operat);
1482 }
1483 if inner_end > inner_start {
1484 let arg_cursor = self.cursor.map(|c| c.wrapping_sub(start + inner_start));
1486 let inner_src: String = src_chars[inner_start..inner_end].iter().collect();
1487 let mut cmdsub_highlighter = Highlighter::new(
1488 &inner_src,
1489 arg_cursor,
1490 self.ctx,
1491 self.working_directory.clone(),
1492 self.io_still_ok(),
1493 );
1494 let subcolors = cmdsub_highlighter.highlight();
1495 let dst_lo = (start + inner_start).min(self.color_array.len());
1496 let dst_hi = (start + inner_end).min(self.color_array.len());
1497 let n = dst_hi - dst_lo;
1498 self.color_array[dst_lo..dst_hi].copy_from_slice(&subcolors[..n]);
1499 }
1500 scan = inner_end + 1;
1501 if scan >= src_chars.len() {
1502 break;
1503 }
1504 }
1505
1506 if !self.io_still_ok() {
1507 return; }
1509
1510 let is_prefix = self
1512 .cursor
1513 .is_some_and(|c| (t.start..=t.end).contains(&c));
1514 let token = t.text.clone().unwrap_or_default();
1515 let test_result = if cmd_is_cd {
1516 self.file_tester.test_cd_path(&token, is_prefix)
1517 } else {
1518 let is_path = self.file_tester.test_path(&token, is_prefix);
1519 Ok(IsFile(is_path))
1520 };
1521 match test_result {
1522 Ok(IsFile(false)) => (),
1523 Ok(IsFile(true)) => {
1524 for i in start..end {
1525 self.color_array[i].valid_path = true;
1526 }
1527 }
1528 Err(IsErr) => self.color_span(start, end, HighlightRole::error),
1529 }
1530 }
1531
1532 fn visit_redirection(&mut self, op: &TokSpan, target: Option<&TokSpan>) {
1534 self.color_span(op.start, op.end, HighlightRole::redirection);
1536
1537 let Some(target) = target else { return };
1538 let target_text = target.text.clone().unwrap_or_default();
1539
1540 if matches!(op.tok, DINANG | DINANGDASH) {
1542 self.color_span(target.start, target.end, HighlightRole::redirection);
1543 return;
1544 }
1545
1546 if target_text.contains(crate::ported::zsh_h::Tick)
1549 || target_text.contains(crate::ported::zsh_h::Qtick)
1550 || target_text.contains(crate::ported::zsh_h::Inpar)
1551 {
1552 self.visit_argument(target, false, true);
1553 return;
1554 }
1555
1556 let mode = redir_tok_mode(op.tok, &target.clean_text());
1557
1558 let (role, file_exists) = if !self.io_still_ok() {
1561 (HighlightRole::redirection, false)
1564 } else if contains_pending_variable(&self.pending_variables, &target_text) {
1565 (HighlightRole::redirection, false)
1568 } else {
1569 if let Ok(IsFile(file_exists)) =
1571 self.file_tester.test_redirection_target(&target_text, mode)
1572 {
1573 (HighlightRole::redirection, file_exists)
1574 } else {
1575 (HighlightRole::error, false)
1576 }
1577 };
1578 self.color_span(target.start, target.end, role);
1579 if file_exists {
1580 for i in target.start..target.end.min(self.color_array.len()) {
1582 self.color_array[i].valid_path = true;
1583 }
1584 }
1585 }
1586
1587 fn visit_variable_assignment(&mut self, t: &TokSpan) {
1589 let start = t.start;
1590 let end = t.end.min(self.color_array.len());
1591 if start >= end {
1592 return;
1593 }
1594 let src: String = self.span_text(t);
1595 color_string_internal(
1596 &src,
1597 HighlightSpec::with_fg(HighlightRole::param),
1598 &mut self.color_array[start..end],
1599 );
1600 if let Some(offset) = src.chars().position(|c| c == '=') {
1603 if start + offset < self.color_array.len() {
1604 self.color_array[start + offset] = HighlightSpec::with_fg(HighlightRole::operat);
1605 }
1606 let var_name: String = src.chars().take(offset).collect();
1607 if valid_var_name(&var_name) {
1608 self.pending_variables.push(var_name);
1609 }
1610 }
1611 }
1612
1613 fn color_gap_comments(&mut self, toks: &[TokSpan]) {
1616 let len = self.buff_chars.len();
1617 let mut gaps: Vec<(usize, usize)> = Vec::new();
1618 let mut prev_end = 0usize;
1619 for t in toks {
1620 if t.start > prev_end {
1621 gaps.push((prev_end, t.start.min(len)));
1622 }
1623 prev_end = prev_end.max(t.end);
1624 }
1625 if prev_end < len {
1626 gaps.push((prev_end, len));
1627 }
1628 for (lo, hi) in gaps {
1629 let mut j = lo;
1630 while j < hi {
1631 let c = self.buff_chars[j];
1632 if c == '#' {
1633 let eol = self.buff_chars[j..hi]
1635 .iter()
1636 .position(|&c| c == '\n')
1637 .map(|p| j + p)
1638 .unwrap_or(hi);
1639 self.color_span(j, eol, HighlightRole::comment);
1640 j = eol;
1641 }
1642 j += 1;
1643 }
1644 }
1645 }
1646}
1647
1648fn locate_cmdsubst_span(chars: &[char], from: usize) -> Option<(usize, Option<usize>)> {
1652 let mut i = from;
1653 let mut in_squote = false;
1654 while i + 1 < chars.len() {
1655 let c = chars[i];
1656 if in_squote {
1657 if c == '\'' {
1658 in_squote = false;
1659 }
1660 } else if c == '\'' {
1661 in_squote = true;
1662 } else if c == '\\' {
1663 i += 1;
1664 } else if c == '$' && chars[i + 1] == '(' {
1665 let mut depth = 0i32;
1667 let mut j = i + 1;
1668 while j < chars.len() {
1669 match chars[j] {
1670 '(' => depth += 1,
1671 ')' => {
1672 depth -= 1;
1673 if depth == 0 {
1674 return Some((i, Some(j)));
1675 }
1676 }
1677 _ => (),
1678 }
1679 j += 1;
1680 }
1681 return Some((i, None));
1682 }
1683 i += 1;
1684 }
1685 None
1686}
1687
1688fn contains_pending_variable(pending_variables: &[String], haystack: &str) -> bool {
1690 let hay: Vec<char> = haystack.chars().collect();
1691 for var_name in pending_variables {
1692 let needle: Vec<char> = var_name.chars().collect();
1693 if needle.is_empty() || hay.len() < needle.len() {
1694 continue;
1695 }
1696 let mut nextpos = 0usize;
1697 while nextpos + needle.len() <= hay.len() {
1698 let Some(relpos) = hay[nextpos..]
1699 .windows(needle.len())
1700 .position(|w| w == needle.as_slice())
1701 else {
1702 break;
1703 };
1704 let pos = nextpos + relpos;
1705 nextpos = pos + 1;
1706 if pos == 0 || hay[pos - 1] != '$' {
1707 continue; }
1709 let end = pos + needle.len();
1710 if end < hay.len() && valid_var_name_char(hay[end]) {
1711 continue; }
1713 return true;
1714 }
1715 }
1716 false
1717}
1718
1719fn redir_tok_mode(tokv: lextok, target: &str) -> RedirectionMode {
1722 let looks_fd = target == "-" || target.chars().all(|c| c.is_ascii_digit());
1723 match tokv {
1724 OUTANG_TOK => {
1725 if isset(CLOBBER) {
1726 RedirectionMode::Overwrite
1727 } else {
1728 RedirectionMode::NoClob
1729 }
1730 }
1731 OUTANGBANG => RedirectionMode::Overwrite,
1732 DOUTANG | DOUTANGBANG => RedirectionMode::Append,
1733 INANG_TOK => RedirectionMode::Input,
1734 INOUTANG => RedirectionMode::Overwrite, INANGAMP => RedirectionMode::Fd,
1736 OUTANGAMP | OUTANGAMPBANG => {
1737 if looks_fd {
1738 RedirectionMode::Fd
1739 } else {
1740 RedirectionMode::Overwrite }
1742 }
1743 DOUTANGAMP | DOUTANGAMPBANG => {
1744 if looks_fd {
1745 RedirectionMode::Fd
1746 } else {
1747 RedirectionMode::Append
1748 }
1749 }
1750 crate::ported::zsh_h::AMPOUTANG => RedirectionMode::Overwrite,
1751 _ => RedirectionMode::Input,
1752 }
1753}
1754
1755use crate::ported::zsh_h::INOUTPAR as INOUTPAR_LOCAL;
1758
1759#[cfg(test)]
1760mod tests {
1761 use super::*;
1762
1763 fn lock() -> std::sync::MutexGuard<'static, ()> {
1764 crate::test_util::global_state_lock()
1765 }
1766
1767 fn spans_of(line: &str) -> Vec<(String, i32)> {
1768 lex_line_tokens(line)
1769 .iter()
1770 .map(|t| {
1771 (
1772 line.chars()
1773 .skip(t.start)
1774 .take(t.end - t.start)
1775 .collect::<String>(),
1776 t.tok,
1777 )
1778 })
1779 .collect()
1780 }
1781
1782 #[test]
1792 fn lex_line_tokens_leaves_unget_buf_untouched() {
1793 use crate::ported::lex::LEX_UNGET_BUF;
1794 let _g = lock();
1795 for line in ["{ print ok }", "( print sub )", "function q(){ print fq }", "echo hi"] {
1796 LEX_UNGET_BUF.with_borrow_mut(|b| {
1798 b.clear();
1799 b.push_back('X');
1800 b.push_back('\n');
1801 });
1802 let _ = lex_line_tokens(line);
1803 let after: Vec<char> = LEX_UNGET_BUF.with_borrow(|b| b.iter().copied().collect());
1804 assert_eq!(
1805 after,
1806 vec!['X', '\n'],
1807 "LEX_UNGET_BUF corrupted by highlight walk of {line:?}"
1808 );
1809 }
1810 LEX_UNGET_BUF.with_borrow_mut(|b| b.clear());
1811 }
1812
1813 #[test]
1815 fn lex_spans_match_source() {
1816 let _g = lock();
1817 let spans = spans_of("echo hello world");
1818 let words: Vec<&str> = spans
1819 .iter()
1820 .filter(|(_, t)| *t == STRING_LEX)
1821 .map(|(s, _)| s.as_str())
1822 .collect();
1823 assert_eq!(words, vec!["echo", "hello", "world"], "spans {spans:?}");
1824 }
1825
1826 #[test]
1827 fn lex_spans_operators() {
1828 let _g = lock();
1829 let toks = lex_line_tokens("a && b || c");
1830 let ops: Vec<i32> = toks.iter().map(|t| t.tok).collect();
1831 assert!(ops.contains(&DAMPER), "toks {ops:?}");
1832 assert!(ops.contains(&DBAR), "toks {ops:?}");
1833 let damper = toks.iter().find(|t| t.tok == DAMPER).unwrap();
1835 let src: String = "a && b || c"
1836 .chars()
1837 .skip(damper.start)
1838 .take(damper.end - damper.start)
1839 .collect();
1840 assert_eq!(src.trim(), "&&");
1841 }
1842
1843 #[test]
1844 fn lex_spans_cmdpos_flag() {
1845 let _g = lock();
1846 let toks = lex_line_tokens("echo foo; ls bar");
1847 let strings: Vec<(String, bool)> = toks
1848 .iter()
1849 .filter(|t| t.tok == STRING_LEX)
1850 .map(|t| (t.clean_text(), t.cmdpos))
1851 .collect();
1852 assert_eq!(
1853 strings,
1854 vec![
1855 ("echo".to_owned(), true),
1856 ("foo".to_owned(), false),
1857 ("ls".to_owned(), true),
1858 ("bar".to_owned(), false)
1859 ]
1860 );
1861 }
1862
1863 #[test]
1866 fn lex_tolerates_unterminated_quote() {
1867 let _g = lock();
1868 let toks = lex_line_tokens("echo 'in progress");
1869 assert!(
1870 toks.iter().filter(|t| t.tok == STRING_LEX).count() >= 2,
1871 "unterminated quote must still lex as STRING: {:?}",
1872 toks.iter().map(|t| t.tok).collect::<Vec<_>>()
1873 );
1874 }
1875
1876 #[test]
1878 fn color_string_quotes_and_vars() {
1879 let _g = lock();
1880 let s = "a'q'\"d$V\"$X*";
1881 let n = s.chars().count();
1882 let mut colors = vec![HighlightSpec::default(); n];
1883 color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
1884 let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
1885 assert_eq!(roles[0], HighlightRole::param);
1887 assert_eq!(roles[1], HighlightRole::quote);
1889 assert_eq!(roles[2], HighlightRole::quote);
1890 assert_eq!(roles[3], HighlightRole::quote);
1891 assert_eq!(roles[4], HighlightRole::quote);
1893 assert_eq!(roles[5], HighlightRole::quote);
1894 assert_eq!(roles[6], HighlightRole::operat);
1896 assert_eq!(roles[7], HighlightRole::operat);
1897 assert_eq!(roles[8], HighlightRole::quote);
1899 assert_eq!(roles[9], HighlightRole::operat);
1901 assert_eq!(roles[10], HighlightRole::operat);
1902 assert_eq!(roles[11], HighlightRole::operat);
1904 }
1905
1906 #[test]
1907 fn color_string_unclosed_quote_is_error() {
1908 let _g = lock();
1909 let s = "'unclosed";
1910 let n = s.chars().count();
1911 let mut colors = vec![HighlightSpec::default(); n];
1912 color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
1913 assert_eq!(colors[0].foreground, HighlightRole::error); }
1915
1916 #[test]
1917 fn color_string_dollar_quote_escapes() {
1918 let _g = lock();
1919 let s = "$'\\x41'";
1921 let n = s.chars().count();
1922 let mut colors = vec![HighlightSpec::default(); n];
1923 color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
1924 let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
1925 assert_eq!(roles[2], HighlightRole::escape, "roles {roles:?}");
1926
1927 let s2 = "$'\\U110000'";
1928 let n2 = s2.chars().count();
1929 let mut colors2 = vec![HighlightSpec::default(); n2];
1930 color_string_internal(s2, HighlightSpec::with_fg(HighlightRole::param), &mut colors2);
1931 assert_eq!(colors2[2].foreground, HighlightRole::error);
1932 }
1933
1934 #[test]
1935 fn color_variable_subscript() {
1936 let _g = lock();
1937 let s: Vec<char> = "$arr[1]x".chars().collect();
1938 let mut colors = vec![HighlightSpec::default(); s.len()];
1939 let consumed = color_variable(&s, &mut colors);
1940 assert_eq!(consumed, 7);
1942 assert_eq!(colors[0].foreground, HighlightRole::operat);
1943 assert_eq!(colors[4].foreground, HighlightRole::operat); assert_eq!(colors[6].foreground, HighlightRole::operat); }
1946
1947 #[test]
1949 fn highlight_valid_and_invalid_command() {
1950 let _g = lock();
1951 let ctx = OperationContext::empty();
1952
1953 let line = "echo hi";
1955 let mut colors = Vec::new();
1956 highlight_shell(line, &mut colors, &ctx, true, None);
1957 assert_eq!(colors.len(), line.chars().count());
1958 assert_eq!(
1959 colors[0].foreground,
1960 HighlightRole::command,
1961 "colors {colors:?}"
1962 );
1963
1964 let line2 = "definitely_not_a_cmd_zshrs_x hi";
1966 let mut colors2 = Vec::new();
1967 highlight_shell(line2, &mut colors2, &ctx, true, None);
1968 assert_eq!(colors2[0].foreground, HighlightRole::error);
1969 let arg_pos = line2.chars().count() - 1;
1971 assert_eq!(colors2[arg_pos].foreground, HighlightRole::param);
1972 }
1973
1974 #[test]
1975 fn highlight_reserved_word_and_separator() {
1976 let _g = lock();
1977 let ctx = OperationContext::empty();
1978 let line = "if true; then echo x; fi";
1979 let mut colors = Vec::new();
1980 highlight_shell(line, &mut colors, &ctx, true, None);
1981 assert_eq!(colors[0].foreground, HighlightRole::keyword, "{colors:?}");
1983 let semi = line.chars().position(|c| c == ';').unwrap();
1985 assert_eq!(colors[semi].foreground, HighlightRole::statement_terminator);
1986 }
1987
1988 #[test]
1989 fn highlight_assignment_equals_operator() {
1990 let _g = lock();
1991 let ctx = OperationContext::empty();
1992 let line = "FOO=bar echo x";
1993 let mut colors = Vec::new();
1994 highlight_shell(line, &mut colors, &ctx, true, None);
1995 let eq = line.chars().position(|c| c == '=').unwrap();
1996 assert_eq!(colors[eq].foreground, HighlightRole::operat, "{colors:?}");
1997 }
1998
1999 #[test]
2000 fn highlight_io_off_assumes_valid() {
2001 let _g = lock();
2002 let ctx = OperationContext::empty();
2003 let line = "definitely_not_a_cmd_zshrs_x";
2005 let mut colors = Vec::new();
2006 highlight_shell(line, &mut colors, &ctx, false, None);
2007 assert_eq!(colors[0].foreground, HighlightRole::command);
2008 }
2009
2010 #[test]
2011 fn resolver_default_palette() {
2012 let _g = lock();
2013 let attr =
2016 HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
2017 HighlightRole::command,
2018 ));
2019 assert_ne!(attr, 0, "command role must default to a visible style");
2020 let err =
2021 HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
2022 HighlightRole::error,
2023 ));
2024 assert_ne!(err, 0);
2025 assert_ne!(attr, err, "command and error styles must differ");
2026 }
2027
2028 #[test]
2029 fn contains_pending_variable_matches_dollar_use() {
2030 let vars = vec!["x".to_owned()];
2032 assert!(contains_pending_variable(&vars, "$x"));
2033 assert!(contains_pending_variable(&vars, "dir/$x/log"));
2034 assert!(!contains_pending_variable(&vars, "x"));
2035 assert!(!contains_pending_variable(&vars, "$xy"));
2036 }
2037
2038 #[test]
2039 fn locate_cmdsubst_span_finds_nested() {
2040 let chars: Vec<char> = "a$(b $(c) d)e".chars().collect();
2041 let (open, close) = locate_cmdsubst_span(&chars, 0).unwrap();
2042 assert_eq!(open, 1);
2043 assert_eq!(close, Some(11));
2044 let chars2: Vec<char> = "a$(b".chars().collect();
2046 let (o2, c2) = locate_cmdsubst_span(&chars2, 0).unwrap();
2047 assert_eq!(o2, 1);
2048 assert_eq!(c2, None);
2049 }
2050}