#![allow(non_snake_case)]
#![allow(non_camel_case_types)]
use crate::ported::exec::findcmd;
use crate::ported::hashtable::{aliastab_lock, cmdnamtab_lock, reswdtab_lock};
use crate::ported::lex::{
ctxtlex, incmdpos, inredir, tok, tokstr, untokenize, LEX_LEXFLAGS, LEX_WORDBEG,
};
use crate::ported::params::{gethparam, getsparam};
use crate::ported::prompt::match_highlight;
use crate::ported::utils::getshfunc;
use crate::ported::zsh_h::{
isset, lextok, zattr, AMPER, AMPERBANG, AUTOCD, BAR_TOK, CASE, CLOBBER, DAMPER, DBAR,
DOUTANG, DOUTANGAMP, DOUTANGAMPBANG, DOUTANGBANG, DINANG, DINANGDASH, ENDINPUT, ENVARRAY,
ENVSTRING, INANGAMP, INANG_TOK, INOUTANG, INPAR_TOK, INTERACTIVECOMMENTS, IS_REDIROP,
LEXERR, LEXFLAGS_ACTIVE, LEXFLAGS_ZLE, NEWLIN, OUTANGAMP, OUTANGAMPBANG, OUTANGBANG,
OUTANG_TOK, OUTPAR_TOK, SEMI, SEPER, STRING_LEX, TYPESET,
};
use crate::zle_file_tester::{
expand_one_no_cmdsubst, FileTester, IsErr, IsFile, OperationContext, RedirectionMode,
};
use std::collections::{hash_map::Entry, HashMap};
use std::sync::Mutex;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub struct HighlightSpec {
pub foreground: HighlightRole,
pub background: HighlightRole,
pub valid_path: bool,
pub force_underline: bool,
}
impl HighlightSpec {
pub fn new() -> Self {
Self::default()
}
pub fn with_fg_bg(fg: HighlightRole, bg: HighlightRole) -> Self {
Self {
foreground: fg,
background: bg,
..Default::default()
}
}
pub fn with_fg(fg: HighlightRole) -> Self {
Self::with_fg_bg(fg, HighlightRole::normal)
}
pub fn with_bg(bg: HighlightRole) -> Self {
Self::with_fg_bg(HighlightRole::normal, bg)
}
pub fn with_both(role: HighlightRole) -> Self {
Self::with_fg_bg(role, role)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[repr(u8)]
pub enum HighlightRole {
#[default]
normal, error, command, keyword,
statement_terminator, param, option, comment, search_match, operat, escape, quote, redirection, autosuggestion, selection,
pager_progress,
pager_background,
pager_prefix,
pager_completion,
pager_description,
pager_secondary_background,
pager_secondary_prefix,
pager_secondary_completion,
pager_secondary_description,
pager_selected_background,
pager_selected_prefix,
pager_selected_completion,
pager_selected_description,
}
pub type ColorArray = Vec<HighlightSpec>;
fn get_highlight_style_key(role: HighlightRole) -> &'static str {
match role {
HighlightRole::normal => "default",
HighlightRole::error => "unknown-token",
HighlightRole::command => "command",
HighlightRole::keyword => "reserved-word",
HighlightRole::statement_terminator => "commandseparator",
HighlightRole::param => "default",
HighlightRole::option => "single-hyphen-option",
HighlightRole::comment => "comment",
HighlightRole::search_match => "history-search-match",
HighlightRole::operat => "globbing",
HighlightRole::escape => "back-dollar-quoted-argument",
HighlightRole::quote => "single-quoted-argument",
HighlightRole::redirection => "redirection",
HighlightRole::autosuggestion => "autosuggestion",
HighlightRole::selection => "selection",
_ => "default",
}
}
fn get_default_style(role: HighlightRole) -> &'static str {
match role {
HighlightRole::error => "fg=red",
HighlightRole::command | HighlightRole::keyword => match role {
HighlightRole::keyword => "fg=yellow",
_ => "fg=green",
},
HighlightRole::comment => "fg=black,bold",
HighlightRole::operat => "fg=blue",
HighlightRole::escape => "fg=cyan",
HighlightRole::quote => "fg=yellow",
HighlightRole::redirection => "none",
HighlightRole::autosuggestion => "fg=8",
HighlightRole::selection | HighlightRole::search_match => "standout",
_ => "none",
}
}
fn get_fallback(role: HighlightRole) -> HighlightRole {
match role {
HighlightRole::normal
| HighlightRole::error
| HighlightRole::command
| HighlightRole::statement_terminator
| HighlightRole::param
| HighlightRole::search_match
| HighlightRole::comment
| HighlightRole::operat
| HighlightRole::escape
| HighlightRole::quote
| HighlightRole::redirection
| HighlightRole::autosuggestion
| HighlightRole::selection
| HighlightRole::pager_progress
| HighlightRole::pager_background
| HighlightRole::pager_prefix
| HighlightRole::pager_completion
| HighlightRole::pager_description => HighlightRole::normal,
HighlightRole::keyword => HighlightRole::command,
HighlightRole::option => HighlightRole::param,
HighlightRole::pager_secondary_background => HighlightRole::pager_background,
HighlightRole::pager_secondary_prefix | HighlightRole::pager_selected_prefix => {
HighlightRole::pager_prefix
}
HighlightRole::pager_secondary_completion | HighlightRole::pager_selected_completion => {
HighlightRole::pager_completion
}
HighlightRole::pager_secondary_description | HighlightRole::pager_selected_description => {
HighlightRole::pager_description
}
HighlightRole::pager_selected_background => HighlightRole::search_match,
}
}
fn parse_style_for_highlight(spec: &str) -> Option<zattr> {
if spec.is_empty() {
return None;
}
let (mask_on, _mask_off) = match_highlight(spec);
Some(mask_on)
}
fn zsh_highlight_styles_get(key: &str) -> Option<String> {
let flat = gethparam("ZSH_HIGHLIGHT_STYLES")?;
let mut it = flat.chunks_exact(2);
it.find(|kv| kv[0] == key).map(|kv| kv[1].clone())
}
#[derive(Default)]
pub struct HighlightColorResolver {
cache: HashMap<HighlightSpec, zattr>,
}
impl HighlightColorResolver {
pub fn new() -> Self {
Default::default()
}
pub fn resolve_spec(&mut self, highlight: &HighlightSpec) -> zattr {
match self.cache.entry(*highlight) {
Entry::Occupied(e) => *e.get(),
Entry::Vacant(e) => {
let face = Self::resolve_spec_uncached(highlight);
e.insert(face);
face
}
}
}
pub fn resolve_spec_uncached(highlight: &HighlightSpec) -> zattr {
let resolve_role = |role: HighlightRole| -> zattr {
let mut roles: &[HighlightRole] = &[role, get_fallback(role), HighlightRole::normal];
for i in [2, 1] {
if roles[i - 1] == roles[i] {
roles = &roles[..i];
}
}
for &role in roles {
let configured = if role == HighlightRole::autosuggestion {
getsparam("ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE").filter(|s| !s.is_empty())
} else {
zsh_highlight_styles_get(get_highlight_style_key(role))
};
if let Some(face) = configured.as_deref().and_then(parse_style_for_highlight) {
return face;
}
}
parse_style_for_highlight(get_default_style(role)).unwrap_or(0)
};
let mut face = resolve_role(highlight.foreground);
if highlight.background != highlight.foreground {
use crate::ported::zsh_h::{TXTBGCOLOUR, TXT_ATTR_BG_COL_MASK};
let bg_face = resolve_role(highlight.background);
face |= bg_face & (TXTBGCOLOUR as zattr | TXT_ATTR_BG_COL_MASK);
}
if highlight.valid_path {
let path_spec = zsh_highlight_styles_get("path").unwrap_or_default();
let merged = if path_spec.is_empty() {
parse_style_for_highlight("underline")
} else {
parse_style_for_highlight(&path_spec)
};
if let Some(m) = merged {
face |= m;
}
}
if highlight.force_underline {
face |= crate::ported::zsh_h::TXTUNDERLINE as zattr;
}
face
}
}
pub fn colorize(text: &str, colors: &[HighlightSpec]) -> Vec<u8> {
let chars: Vec<char> = text.chars().collect();
assert_eq!(colors.len(), chars.len());
let mut rv = HighlightColorResolver::new();
let mut out: Vec<u8> = Vec::new();
let mut last_color: Option<HighlightSpec> = None;
for (i, &c) in chars.iter().enumerate() {
let color = colors[i];
if Some(color) != last_color {
let face = rv.resolve_spec(&color);
out.extend_from_slice(zattr_to_sgr(face).as_bytes());
last_color = Some(color);
}
if i + 1 == chars.len() && c == '\n' {
out.extend_from_slice(b"\x1b[0m");
}
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
out.extend_from_slice(b"\x1b[0m"); out
}
fn zattr_to_sgr(attr: zattr) -> String {
use crate::ported::zsh_h::{
TXTBGCOLOUR, TXTBOLDFACE, TXTFGCOLOUR, TXTSTANDOUT, TXTUNDERLINE,
TXT_ATTR_BG_COL_SHIFT, TXT_ATTR_FG_COL_SHIFT,
};
let mut s = String::from("\x1b[0");
if attr & TXTBOLDFACE as zattr != 0 {
s.push_str(";1");
}
if attr & TXTSTANDOUT as zattr != 0 {
s.push_str(";7");
}
if attr & TXTUNDERLINE as zattr != 0 {
s.push_str(";4");
}
if attr & TXTFGCOLOUR as zattr != 0 {
let col = (attr >> TXT_ATTR_FG_COL_SHIFT) & 0xffffff;
s.push_str(&format!(";38;5;{}", col));
}
if attr & TXTBGCOLOUR as zattr != 0 {
let col = (attr >> TXT_ATTR_BG_COL_SHIFT) & 0xffffff;
s.push_str(&format!(";48;5;{}", col));
}
s.push('m');
s
}
pub fn highlight_shell(
buff: &str,
color: &mut Vec<HighlightSpec>,
ctx: &OperationContext,
io_ok: bool,
cursor: Option<usize>,
) {
let working_directory = getsparam("PWD").unwrap_or_else(|| ".".to_owned());
let mut highlighter = Highlighter::new(buff, cursor, ctx, working_directory, io_ok);
*color = highlighter.highlight();
}
pub fn highlight_and_colorize(text: &str, ctx: &OperationContext) -> Vec<u8> {
let mut colors = Vec::new();
highlight_shell(text, &mut colors, ctx, false, None);
colorize(text, &colors)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StatementDecoration {
#[default]
None_,
Command, Builtin, Exec,
}
static CMD_VALID_CACHE: Mutex<Option<(String, HashMap<(String, u8), bool>)>> = Mutex::new(None);
const CMD_VALID_CACHE_MAX: usize = 8192;
pub fn command_is_valid_cached(
cmd: &str,
decoration: StatementDecoration,
working_directory: &str,
) -> bool {
let path_now = getsparam("PATH").unwrap_or_default();
let key = (cmd.to_owned(), decoration as u8);
let cached: Option<bool> = {
let mut guard = CMD_VALID_CACHE.lock().unwrap();
match guard.as_mut() {
Some((path, map)) if *path == path_now => map.get(&key).copied(),
_ => {
*guard = Some((path_now, HashMap::new()));
None
}
}
};
let tables_valid = cached.unwrap_or_else(|| {
let v = command_is_valid_tables(cmd, decoration);
let mut guard = CMD_VALID_CACHE.lock().unwrap();
if let Some((_, map)) = guard.as_mut() {
if map.len() >= CMD_VALID_CACHE_MAX {
map.clear();
}
map.insert(key, v);
}
v
});
if tables_valid {
return true;
}
if decoration == StatementDecoration::None_ && isset(AUTOCD) {
let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
return std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false);
}
false
}
pub fn command_is_valid(
cmd: &str,
decoration: StatementDecoration,
working_directory: &str,
) -> bool {
if command_is_valid_tables(cmd, decoration) {
return true;
}
if decoration == StatementDecoration::None_ && isset(AUTOCD) {
let path = crate::zle_file_tester::path_apply_working_directory(cmd, working_directory);
if std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false) {
return true;
}
}
false
}
fn command_is_valid_tables(cmd: &str, decoration: StatementDecoration) -> bool {
let mut builtin_ok = true;
let mut function_ok = true;
let mut alias_ok = true;
let mut command_ok = true;
if matches!(
decoration,
StatementDecoration::Command | StatementDecoration::Exec
) {
builtin_ok = false;
function_ok = false;
alias_ok = false;
command_ok = true;
} else if decoration == StatementDecoration::Builtin {
builtin_ok = true;
function_ok = false;
alias_ok = false;
command_ok = false;
}
let mut is_valid = false;
if !is_valid && builtin_ok {
is_valid = crate::ported::builtin::createbuiltintable().contains_key(cmd);
}
if !is_valid && function_ok {
is_valid = getshfunc(cmd).is_some();
}
if !is_valid && alias_ok {
is_valid = aliastab_lock()
.read()
.map(|t| t.get(cmd).is_some())
.unwrap_or(false);
}
if !is_valid && command_ok {
is_valid = cmdnamtab_lock()
.read()
.map(|t| t.get(cmd).is_some())
.unwrap_or(false)
|| findcmd(cmd, 0, 0).is_some();
}
is_valid
}
fn has_expand_reserved(s: &str) -> bool {
s.chars().any(|wc| ('\u{84}'..='\u{a1}').contains(&wc))
}
pub fn autosuggest_parse_command(buff: &str) -> Option<(String, String)> {
let toks = lex_line_tokens(buff);
let mut cmd: Option<String> = None;
let mut arg = String::new(); for t in &toks {
if t.tok == STRING_LEX {
match &cmd {
None if t.cmdpos => {
let text = t.clean_text();
let mut expanded = t.text.clone().unwrap_or_default();
if expand_one_no_cmdsubst(&mut expanded) && !expanded.is_empty() {
cmd = Some(expanded);
} else {
cmd = Some(text);
}
}
None => (),
Some(_) => {
if !t.in_redir {
arg = t.clean_text();
}
break;
}
}
} else if cmd.is_some() {
break; }
}
cmd.map(|c| (c, arg)) }
pub fn is_veritable_cd(expanded_command: &str) -> bool {
expanded_command == "cd"
&& aliastab_lock()
.read()
.map(|t| t.get("cd").is_none())
.unwrap_or(true)
}
pub fn autosuggest_validate_from_history(
item_commandline: &str,
required_paths: &[String],
working_directory: &str,
ctx: &OperationContext,
) -> bool {
let Some((parsed_command, mut cd_dir)) = autosuggest_parse_command(item_commandline) else {
return true;
};
if is_veritable_cd(&parsed_command) && !cd_dir.is_empty() {
if expand_one_no_cmdsubst(&mut cd_dir) {
if "--help".starts_with(&cd_dir) || "-h".starts_with(&cd_dir) {
return true;
} else {
return crate::zle_file_tester::is_potential_cd_path(
&cd_dir,
false,
working_directory,
ctx,
Default::default(),
);
}
}
}
let cmd_ok =
command_is_valid_cached(&parsed_command, StatementDecoration::None_, working_directory);
if !cmd_ok {
return false;
}
if !required_paths.is_empty() {
let tester = FileTester::new(working_directory.to_owned(), ctx);
if !required_paths.iter().all(|p| tester.test_path(p, false)) {
return false;
}
}
true }
fn valid_var_name_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn valid_var_name(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with(|c: char| c.is_ascii_digit())
&& s.chars().all(valid_var_name_char)
}
fn is_special_param_char(c: char) -> bool {
matches!(c, '?' | '#' | '$' | '!' | '@' | '*' | '-' | '_') || c.is_ascii_digit()
}
fn color_variable(inp: &[char], colors: &mut [HighlightSpec]) -> usize {
assert_eq!(inp[0], '$');
let at = |i: usize| -> char { inp.get(i).copied().unwrap_or('\0') };
let mut idx = 0;
let mut dollar_count = 0;
while at(idx) == '$' {
let next = at(idx + 1);
if next == '$' || valid_var_name_char(next) || is_special_param_char(next) {
colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
} else if next == '(' || next == '{' || next == '\'' {
colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
return idx + 1;
} else {
colors[idx] = HighlightSpec::with_fg(HighlightRole::error);
}
idx += 1;
dollar_count += 1;
}
if idx == dollar_count && !valid_var_name_char(at(idx)) && is_special_param_char(at(idx)) {
colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
return idx + 1;
}
loop {
if valid_var_name_char(at(idx)) {
colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
idx += 1;
} else if at(idx) == '\\' && at(idx + 1) == '\n' {
colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
idx += 1;
colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
idx += 1;
} else {
break;
}
}
for _slice_count in 0..dollar_count {
match subscript_length(&inp[idx..]) {
Some(slice_len) if slice_len > 0 => {
colors[idx] = HighlightSpec::with_fg(HighlightRole::operat);
colors[idx + slice_len - 1] = HighlightSpec::with_fg(HighlightRole::operat);
idx += slice_len;
}
Some(_slice_len) => {
break;
}
None => {
colors[..=idx].fill(HighlightSpec::with_fg(HighlightRole::error));
break;
}
}
}
idx
}
fn subscript_length(inp: &[char]) -> Option<usize> {
if inp.first() != Some(&'[') {
return Some(0);
}
let mut depth = 0usize;
for (i, &c) in inp.iter().enumerate() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
_ => (),
}
}
None
}
pub fn color_string_internal(
buffstr: &str,
base_color: HighlightSpec,
colors: &mut [HighlightSpec],
) {
assert!(
[
HighlightSpec::with_fg(HighlightRole::param),
HighlightSpec::with_fg(HighlightRole::option),
HighlightSpec::with_fg(HighlightRole::command)
]
.contains(&base_color),
"Unexpected base color"
);
let chars: Vec<char> = buffstr.chars().collect();
let buff_len = chars.len();
colors.fill(base_color);
#[derive(Eq, PartialEq)]
enum Mode {
unquoted,
single_quoted,
double_quoted,
dollar_quoted, backtick, }
let mut mode = Mode::unquoted;
let mut unclosed_quote_offset = None;
let mut bracket_count = 0;
let mut in_pos = 0;
while in_pos < buff_len {
let c = chars[in_pos];
match mode {
Mode::unquoted => {
if c == '\\' {
let backslash_pos = in_pos;
let fill_end = if in_pos + 1 < buff_len {
in_pos + 2
} else {
in_pos + 1
};
colors[backslash_pos..fill_end]
.fill(HighlightSpec::with_fg(HighlightRole::escape));
in_pos += 1; } else {
match c {
'~' if in_pos == 0 => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
}
'$' if chars.get(in_pos + 1) == Some(&'\'') => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
colors[in_pos + 1] = HighlightSpec::with_fg(HighlightRole::quote);
unclosed_quote_offset = Some(in_pos);
in_pos += 1;
mode = Mode::dollar_quoted;
}
'$' => {
assert!(in_pos < buff_len);
in_pos += color_variable(&chars[in_pos..], &mut colors[in_pos..]);
in_pos -= 1;
}
'`' => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
unclosed_quote_offset = Some(in_pos);
mode = Mode::backtick;
}
'?' | '*' | '(' | ')' => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
}
'{' => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
bracket_count += 1;
}
'}' => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
bracket_count -= 1;
}
',' if bracket_count > 0 => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
}
'[' | ']' => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
}
'\'' => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
unclosed_quote_offset = Some(in_pos);
mode = Mode::single_quoted;
}
'"' => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
unclosed_quote_offset = Some(in_pos);
mode = Mode::double_quoted;
}
_ => (), }
}
}
Mode::single_quoted => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
if c == '\'' {
mode = Mode::unquoted;
}
}
Mode::double_quoted => {
if colors[in_pos] == base_color {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
}
match c {
'"' => {
mode = Mode::unquoted;
}
'\\' if in_pos + 1 < buff_len => {
let escaped_char = chars[in_pos + 1];
if matches!(escaped_char, '\\' | '"' | '$' | '`' | '\n') {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::escape);
colors[in_pos + 1] = HighlightSpec::with_fg(HighlightRole::escape);
in_pos += 1; }
}
'$' => {
in_pos += color_variable(&chars[in_pos..], &mut colors[in_pos..]);
in_pos -= 1;
}
_ => (), }
}
Mode::dollar_quoted => {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
if c == '\\' && in_pos + 1 < buff_len {
let mut fill_color = HighlightRole::escape;
let backslash_pos = in_pos;
let mut fill_end = backslash_pos;
in_pos += 1;
let escaped_char = chars[in_pos];
if "abcefnrtv\\'\"?".contains(escaped_char) {
fill_end = in_pos + 1;
} else if "uUxX01234567".contains(escaped_char) {
let mut res: u32 = 0;
let mut chars_max = 2;
let mut base = 16;
let mut max_val = 0x7f_u32;
match escaped_char {
'u' => {
chars_max = 4;
max_val = 0xFFFF; in_pos += 1;
}
'U' => {
chars_max = 8;
max_val = 0x10FFFF;
in_pos += 1;
}
'x' | 'X' => {
max_val = 0xFF;
in_pos += 1;
}
_ => {
base = 8;
chars_max = 3;
}
}
for _i in 0..chars_max {
if in_pos == buff_len {
break;
}
let Some(d) = chars[in_pos].to_digit(base) else {
break;
};
res = res.saturating_mul(base).saturating_add(d);
in_pos += 1;
}
fill_end = in_pos;
if res > max_val {
fill_color = HighlightRole::error;
}
in_pos -= 1;
} else {
fill_end = in_pos + 1;
}
if fill_end > backslash_pos {
colors[backslash_pos..fill_end.min(buff_len)]
.fill(HighlightSpec::with_fg(fill_color));
} else {
colors[backslash_pos] = HighlightSpec::with_fg(fill_color);
colors[in_pos.min(buff_len - 1)] = HighlightSpec::with_fg(fill_color);
}
} else if c == '\'' {
mode = Mode::unquoted;
}
}
Mode::backtick => {
if c == '`' {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::operat);
mode = Mode::unquoted;
} else {
colors[in_pos] = HighlightSpec::with_fg(HighlightRole::quote);
}
}
}
in_pos += 1;
}
if mode != Mode::unquoted {
colors[unclosed_quote_offset.unwrap()] = HighlightSpec::with_fg(HighlightRole::error);
}
}
#[derive(Clone, Debug)]
pub struct TokSpan {
pub tok: lextok,
pub text: Option<String>,
pub start: usize,
pub end: usize,
pub cmdpos: bool,
pub in_redir: bool,
}
impl TokSpan {
pub fn clean_text(&self) -> String {
let mut s = self.text.clone().unwrap_or_default();
crate::ported::glob::remnulargs(&mut s);
untokenize(&s)
}
}
pub fn lex_line_tokens(line: &str) -> Vec<TokSpan> {
use crate::ported::zle::compcore::{ADDEDX, ZLEMETACS, ZLEMETALL};
use std::sync::atomic::Ordering;
let ll = line.chars().count() as i32;
let mut out: Vec<TokSpan> = Vec::new();
let saved_cs = ZLEMETACS.load(Ordering::SeqCst);
let saved_ll = ZLEMETALL.load(Ordering::SeqCst);
let saved_addedx = ADDEDX.load(Ordering::SeqCst);
crate::ported::context::zcontext_save(); ZLEMETALL.store(ll, Ordering::SeqCst);
ZLEMETACS.store(ll + 5, Ordering::SeqCst); ADDEDX.store(0, Ordering::SeqCst);
LEX_LEXFLAGS.set(LEXFLAGS_ZLE | LEXFLAGS_ACTIVE);
crate::ported::input::inpush(&crate::ported::zle::zle_tricky::dupstrspace(line), 0, None); crate::ported::hist::strinbeg(0);
let mut prev_inbufct = i32::MIN;
loop {
let cmdpos_before = incmdpos();
let inredir_before = inredir();
ctxtlex();
let mut tokv = tok();
let was_lexerr = tokv == LEXERR;
if tokv == LEXERR {
match tokstr() {
None => break,
Some(ts) => {
use crate::ported::zsh_h::{Dnull, Snull};
let jcnt = ts.chars().filter(|&c| c == Snull || c == Dnull).count();
if jcnt & 1 == 1 {
tokv = STRING_LEX;
}
}
}
}
if tokv == ENDINPUT {
break; }
let inbufct = crate::ported::input::inbufct.with(|c| c.get());
let wordbeg = LEX_WORDBEG.get();
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;
prev_inbufct = inbufct;
out.push(TokSpan {
tok: tokv,
text: tokstr(),
start,
end,
cmdpos: cmdpos_before,
in_redir: inredir_before,
});
if (was_lexerr && no_progress) || out.len() > (ll as usize + 8) {
break;
}
if was_lexerr && inbufct <= 1 {
break; }
}
crate::ported::hist::strinend(); crate::ported::input::inpop(); crate::ported::context::zcontext_restore();
ZLEMETACS.store(saved_cs, Ordering::SeqCst);
ZLEMETALL.store(saved_ll, Ordering::SeqCst);
ADDEDX.store(saved_addedx, Ordering::SeqCst);
out
}
pub struct Highlighter<'s> {
buff: &'s str,
buff_chars: Vec<char>,
cursor: Option<usize>,
ctx: &'s OperationContext,
io_ok: bool,
working_directory: String,
file_tester: FileTester<'s>,
color_array: ColorArray,
pending_variables: Vec<String>,
done: bool,
}
impl<'s> Highlighter<'s> {
pub fn new(
buff: &'s str,
cursor: Option<usize>,
ctx: &'s OperationContext,
working_directory: String,
can_do_io: bool,
) -> Self {
let file_tester = FileTester::new(working_directory.clone(), ctx);
Self {
buff,
buff_chars: buff.chars().collect(),
cursor,
ctx,
io_ok: can_do_io,
working_directory,
file_tester,
color_array: vec![],
pending_variables: vec![],
done: false,
}
}
pub fn highlight(&mut self) -> ColorArray {
assert!(!self.done);
self.done = true;
self.color_array
.resize(self.buff_chars.len(), HighlightSpec::default());
let toks = lex_line_tokens(self.buff);
self.visit_tokens(&toks);
if self.ctx.check_cancel() {
return std::mem::take(&mut self.color_array);
}
if isset(INTERACTIVECOMMENTS) {
self.color_gap_comments(&toks);
}
for t in toks.iter().filter(|t| t.tok == LEXERR) {
self.color_span(t.start, self.buff_chars.len(), HighlightRole::error);
}
std::mem::take(&mut self.color_array)
}
fn io_still_ok(&self) -> bool {
self.io_ok && !self.ctx.check_cancel()
}
fn color_span(&mut self, start: usize, end: usize, role: HighlightRole) {
let end = end.min(self.color_array.len());
if start < end {
self.color_array[start..end].fill(HighlightSpec::with_fg(role));
}
}
fn span_text(&self, t: &TokSpan) -> String {
self.buff_chars[t.start.min(self.buff_chars.len())..t.end.min(self.buff_chars.len())]
.iter()
.collect()
}
fn visit_tokens(&mut self, toks: &[TokSpan]) {
let mut decoration = StatementDecoration::None_;
let mut expanded_cmd = String::new();
let mut is_cd = false;
let mut is_typeset = false;
let mut have_dashdash = false;
let mut i = 0;
while i < toks.len() {
if self.ctx.check_cancel() {
return;
}
let t = &toks[i];
let tokv = t.tok;
if IS_REDIROP(tokv) {
let target = toks.get(i + 1).filter(|n| n.tok == STRING_LEX);
self.visit_redirection(t, target);
if target.is_some() {
i += 2;
} else {
i += 1;
}
continue;
}
match tokv {
SEPER | NEWLIN | SEMI | AMPER | AMPERBANG | BAR_TOK => {
self.color_span(t.start, t.end, HighlightRole::statement_terminator);
decoration = StatementDecoration::None_;
expanded_cmd.clear();
is_cd = false;
is_typeset = false;
have_dashdash = false;
}
DBAR | DAMPER => {
self.color_span(t.start, t.end, HighlightRole::operat);
decoration = StatementDecoration::None_;
expanded_cmd.clear();
is_cd = false;
is_typeset = false;
have_dashdash = false;
}
INPAR_TOK | OUTPAR_TOK | INOUTPAR_LOCAL => {
self.color_span(t.start, t.end, HighlightRole::operat);
}
ENVSTRING => {
self.visit_variable_assignment(t);
}
ENVARRAY => {
self.visit_variable_assignment(t);
}
STRING_LEX => {
if t.cmdpos && expanded_cmd.is_empty() {
let clean = t.clean_text();
match clean.as_str() {
"command" => {
decoration = StatementDecoration::Command;
self.color_span(t.start, t.end, HighlightRole::keyword);
}
"builtin" => {
decoration = StatementDecoration::Builtin;
self.color_span(t.start, t.end, HighlightRole::keyword);
}
"exec" => {
decoration = StatementDecoration::Exec;
self.color_span(t.start, t.end, HighlightRole::keyword);
}
"noglob" | "nocorrect" => {
self.color_span(t.start, t.end, HighlightRole::keyword);
}
_ => {
self.visit_command_word(t, &clean, decoration);
expanded_cmd = clean;
is_cd = is_veritable_cd(&expanded_cmd);
is_typeset = matches!(
expanded_cmd.as_str(),
"typeset" | "local" | "declare" | "export" | "readonly"
| "integer" | "float"
);
}
}
} else {
if is_typeset {
let arg = t.clean_text();
let name = arg.split('=').next().unwrap_or("").to_owned();
if valid_var_name(&name) {
self.pending_variables.push(name);
}
}
self.visit_argument(t, is_cd, !have_dashdash);
if self.span_text(t) == "--" {
have_dashdash = true; }
}
}
tokv if (CASE..=TYPESET).contains(&tokv) => {
self.color_span(t.start, t.end, HighlightRole::keyword);
if tokv == TYPESET {
is_typeset = true;
}
}
LEXERR => {
self.color_span(t.start, t.end, HighlightRole::error);
}
_ => {
}
}
i += 1;
}
}
fn visit_command_word(&mut self, t: &TokSpan, clean: &str, decoration: StatementDecoration) {
let mut is_valid_cmd = false;
if !self.io_still_ok() {
is_valid_cmd = true;
} else {
let mut expanded = t.text.clone().unwrap_or_default();
let expanded_ok = expand_one_no_cmdsubst(&mut expanded);
let cmd = if expanded_ok && !expanded.is_empty() {
expanded
} else {
clean.to_owned()
};
if !has_expand_reserved(&cmd) {
is_valid_cmd = command_is_valid_cached(&cmd, decoration, &self.working_directory);
}
}
if is_valid_cmd {
let start = t.start;
let end = t.end.min(self.color_array.len());
let src: String = self.span_text(t);
color_string_internal(
&src,
HighlightSpec::with_fg(HighlightRole::command),
&mut self.color_array[start..end],
);
} else {
self.color_span(t.start, t.end, HighlightRole::error);
}
}
fn visit_argument(&mut self, t: &TokSpan, cmd_is_cd: bool, options_allowed: bool) {
let start = t.start;
let end = t.end.min(self.color_array.len());
if start >= end {
return;
}
let src: String = self.span_text(t);
let base = if options_allowed && src.starts_with('-') {
HighlightRole::option
} else {
HighlightRole::param
};
color_string_internal(
&src,
HighlightSpec::with_fg(base),
&mut self.color_array[start..end],
);
let src_chars: Vec<char> = src.chars().collect();
let mut scan = 0usize;
while let Some((open, close)) = locate_cmdsubst_span(&src_chars, scan) {
self.color_span(start + open, start + open + 2, HighlightRole::operat);
let inner_start = open + 2;
let inner_end = close.unwrap_or(src_chars.len());
if let Some(c) = close {
self.color_span(start + c, start + c + 1, HighlightRole::operat);
}
if inner_end > inner_start {
let arg_cursor = self.cursor.map(|c| c.wrapping_sub(start + inner_start));
let inner_src: String = src_chars[inner_start..inner_end].iter().collect();
let mut cmdsub_highlighter = Highlighter::new(
&inner_src,
arg_cursor,
self.ctx,
self.working_directory.clone(),
self.io_still_ok(),
);
let subcolors = cmdsub_highlighter.highlight();
let dst_lo = (start + inner_start).min(self.color_array.len());
let dst_hi = (start + inner_end).min(self.color_array.len());
let n = dst_hi - dst_lo;
self.color_array[dst_lo..dst_hi].copy_from_slice(&subcolors[..n]);
}
scan = inner_end + 1;
if scan >= src_chars.len() {
break;
}
}
if !self.io_still_ok() {
return; }
let is_prefix = self
.cursor
.is_some_and(|c| (t.start..=t.end).contains(&c));
let token = t.text.clone().unwrap_or_default();
let test_result = if cmd_is_cd {
self.file_tester.test_cd_path(&token, is_prefix)
} else {
let is_path = self.file_tester.test_path(&token, is_prefix);
Ok(IsFile(is_path))
};
match test_result {
Ok(IsFile(false)) => (),
Ok(IsFile(true)) => {
for i in start..end {
self.color_array[i].valid_path = true;
}
}
Err(IsErr) => self.color_span(start, end, HighlightRole::error),
}
}
fn visit_redirection(&mut self, op: &TokSpan, target: Option<&TokSpan>) {
self.color_span(op.start, op.end, HighlightRole::redirection);
let Some(target) = target else { return };
let target_text = target.text.clone().unwrap_or_default();
if matches!(op.tok, DINANG | DINANGDASH) {
self.color_span(target.start, target.end, HighlightRole::redirection);
return;
}
if target_text.contains(crate::ported::zsh_h::Tick)
|| target_text.contains(crate::ported::zsh_h::Qtick)
|| target_text.contains(crate::ported::zsh_h::Inpar)
{
self.visit_argument(target, false, true);
return;
}
let mode = redir_tok_mode(op.tok, &target.clean_text());
let (role, file_exists) = if !self.io_still_ok() {
(HighlightRole::redirection, false)
} else if contains_pending_variable(&self.pending_variables, &target_text) {
(HighlightRole::redirection, false)
} else {
if let Ok(IsFile(file_exists)) =
self.file_tester.test_redirection_target(&target_text, mode)
{
(HighlightRole::redirection, file_exists)
} else {
(HighlightRole::error, false)
}
};
self.color_span(target.start, target.end, role);
if file_exists {
for i in target.start..target.end.min(self.color_array.len()) {
self.color_array[i].valid_path = true;
}
}
}
fn visit_variable_assignment(&mut self, t: &TokSpan) {
let start = t.start;
let end = t.end.min(self.color_array.len());
if start >= end {
return;
}
let src: String = self.span_text(t);
color_string_internal(
&src,
HighlightSpec::with_fg(HighlightRole::param),
&mut self.color_array[start..end],
);
if let Some(offset) = src.chars().position(|c| c == '=') {
if start + offset < self.color_array.len() {
self.color_array[start + offset] = HighlightSpec::with_fg(HighlightRole::operat);
}
let var_name: String = src.chars().take(offset).collect();
if valid_var_name(&var_name) {
self.pending_variables.push(var_name);
}
}
}
fn color_gap_comments(&mut self, toks: &[TokSpan]) {
let len = self.buff_chars.len();
let mut gaps: Vec<(usize, usize)> = Vec::new();
let mut prev_end = 0usize;
for t in toks {
if t.start > prev_end {
gaps.push((prev_end, t.start.min(len)));
}
prev_end = prev_end.max(t.end);
}
if prev_end < len {
gaps.push((prev_end, len));
}
for (lo, hi) in gaps {
let mut j = lo;
while j < hi {
let c = self.buff_chars[j];
if c == '#' {
let eol = self.buff_chars[j..hi]
.iter()
.position(|&c| c == '\n')
.map(|p| j + p)
.unwrap_or(hi);
self.color_span(j, eol, HighlightRole::comment);
j = eol;
}
j += 1;
}
}
}
}
fn locate_cmdsubst_span(chars: &[char], from: usize) -> Option<(usize, Option<usize>)> {
let mut i = from;
let mut in_squote = false;
while i + 1 < chars.len() {
let c = chars[i];
if in_squote {
if c == '\'' {
in_squote = false;
}
} else if c == '\'' {
in_squote = true;
} else if c == '\\' {
i += 1;
} else if c == '$' && chars[i + 1] == '(' {
let mut depth = 0i32;
let mut j = i + 1;
while j < chars.len() {
match chars[j] {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some((i, Some(j)));
}
}
_ => (),
}
j += 1;
}
return Some((i, None));
}
i += 1;
}
None
}
fn contains_pending_variable(pending_variables: &[String], haystack: &str) -> bool {
let hay: Vec<char> = haystack.chars().collect();
for var_name in pending_variables {
let needle: Vec<char> = var_name.chars().collect();
if needle.is_empty() || hay.len() < needle.len() {
continue;
}
let mut nextpos = 0usize;
while nextpos + needle.len() <= hay.len() {
let Some(relpos) = hay[nextpos..]
.windows(needle.len())
.position(|w| w == needle.as_slice())
else {
break;
};
let pos = nextpos + relpos;
nextpos = pos + 1;
if pos == 0 || hay[pos - 1] != '$' {
continue; }
let end = pos + needle.len();
if end < hay.len() && valid_var_name_char(hay[end]) {
continue; }
return true;
}
}
false
}
fn redir_tok_mode(tokv: lextok, target: &str) -> RedirectionMode {
let looks_fd = target == "-" || target.chars().all(|c| c.is_ascii_digit());
match tokv {
OUTANG_TOK => {
if isset(CLOBBER) {
RedirectionMode::Overwrite
} else {
RedirectionMode::NoClob
}
}
OUTANGBANG => RedirectionMode::Overwrite,
DOUTANG | DOUTANGBANG => RedirectionMode::Append,
INANG_TOK => RedirectionMode::Input,
INOUTANG => RedirectionMode::Overwrite, INANGAMP => RedirectionMode::Fd,
OUTANGAMP | OUTANGAMPBANG => {
if looks_fd {
RedirectionMode::Fd
} else {
RedirectionMode::Overwrite }
}
DOUTANGAMP | DOUTANGAMPBANG => {
if looks_fd {
RedirectionMode::Fd
} else {
RedirectionMode::Append
}
}
crate::ported::zsh_h::AMPOUTANG => RedirectionMode::Overwrite,
_ => RedirectionMode::Input,
}
}
use crate::ported::zsh_h::INOUTPAR as INOUTPAR_LOCAL;
#[cfg(test)]
mod tests {
use super::*;
fn lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_util::global_state_lock()
}
fn spans_of(line: &str) -> Vec<(String, i32)> {
lex_line_tokens(line)
.iter()
.map(|t| {
(
line.chars()
.skip(t.start)
.take(t.end - t.start)
.collect::<String>(),
t.tok,
)
})
.collect()
}
#[test]
fn lex_spans_match_source() {
let _g = lock();
let spans = spans_of("echo hello world");
let words: Vec<&str> = spans
.iter()
.filter(|(_, t)| *t == STRING_LEX)
.map(|(s, _)| s.as_str())
.collect();
assert_eq!(words, vec!["echo", "hello", "world"], "spans {spans:?}");
}
#[test]
fn lex_spans_operators() {
let _g = lock();
let toks = lex_line_tokens("a && b || c");
let ops: Vec<i32> = toks.iter().map(|t| t.tok).collect();
assert!(ops.contains(&DAMPER), "toks {ops:?}");
assert!(ops.contains(&DBAR), "toks {ops:?}");
let damper = toks.iter().find(|t| t.tok == DAMPER).unwrap();
let src: String = "a && b || c"
.chars()
.skip(damper.start)
.take(damper.end - damper.start)
.collect();
assert_eq!(src.trim(), "&&");
}
#[test]
fn lex_spans_cmdpos_flag() {
let _g = lock();
let toks = lex_line_tokens("echo foo; ls bar");
let strings: Vec<(String, bool)> = toks
.iter()
.filter(|t| t.tok == STRING_LEX)
.map(|t| (t.clean_text(), t.cmdpos))
.collect();
assert_eq!(
strings,
vec![
("echo".to_owned(), true),
("foo".to_owned(), false),
("ls".to_owned(), true),
("bar".to_owned(), false)
]
);
}
#[test]
fn lex_tolerates_unterminated_quote() {
let _g = lock();
let toks = lex_line_tokens("echo 'in progress");
assert!(
toks.iter().filter(|t| t.tok == STRING_LEX).count() >= 2,
"unterminated quote must still lex as STRING: {:?}",
toks.iter().map(|t| t.tok).collect::<Vec<_>>()
);
}
#[test]
fn color_string_quotes_and_vars() {
let _g = lock();
let s = "a'q'\"d$V\"$X*";
let n = s.chars().count();
let mut colors = vec![HighlightSpec::default(); n];
color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
assert_eq!(roles[0], HighlightRole::param);
assert_eq!(roles[1], HighlightRole::quote);
assert_eq!(roles[2], HighlightRole::quote);
assert_eq!(roles[3], HighlightRole::quote);
assert_eq!(roles[4], HighlightRole::quote);
assert_eq!(roles[5], HighlightRole::quote);
assert_eq!(roles[6], HighlightRole::operat);
assert_eq!(roles[7], HighlightRole::operat);
assert_eq!(roles[8], HighlightRole::quote);
assert_eq!(roles[9], HighlightRole::operat);
assert_eq!(roles[10], HighlightRole::operat);
assert_eq!(roles[11], HighlightRole::operat);
}
#[test]
fn color_string_unclosed_quote_is_error() {
let _g = lock();
let s = "'unclosed";
let n = s.chars().count();
let mut colors = vec![HighlightSpec::default(); n];
color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
assert_eq!(colors[0].foreground, HighlightRole::error); }
#[test]
fn color_string_dollar_quote_escapes() {
let _g = lock();
let s = "$'\\x41'";
let n = s.chars().count();
let mut colors = vec![HighlightSpec::default(); n];
color_string_internal(s, HighlightSpec::with_fg(HighlightRole::param), &mut colors);
let roles: Vec<HighlightRole> = colors.iter().map(|c| c.foreground).collect();
assert_eq!(roles[2], HighlightRole::escape, "roles {roles:?}");
let s2 = "$'\\U110000'";
let n2 = s2.chars().count();
let mut colors2 = vec![HighlightSpec::default(); n2];
color_string_internal(s2, HighlightSpec::with_fg(HighlightRole::param), &mut colors2);
assert_eq!(colors2[2].foreground, HighlightRole::error);
}
#[test]
fn color_variable_subscript() {
let _g = lock();
let s: Vec<char> = "$arr[1]x".chars().collect();
let mut colors = vec![HighlightSpec::default(); s.len()];
let consumed = color_variable(&s, &mut colors);
assert_eq!(consumed, 7);
assert_eq!(colors[0].foreground, HighlightRole::operat);
assert_eq!(colors[4].foreground, HighlightRole::operat); assert_eq!(colors[6].foreground, HighlightRole::operat); }
#[test]
fn highlight_valid_and_invalid_command() {
let _g = lock();
let ctx = OperationContext::empty();
let line = "echo hi";
let mut colors = Vec::new();
highlight_shell(line, &mut colors, &ctx, true, None);
assert_eq!(colors.len(), line.chars().count());
assert_eq!(
colors[0].foreground,
HighlightRole::command,
"colors {colors:?}"
);
let line2 = "definitely_not_a_cmd_zshrs_x hi";
let mut colors2 = Vec::new();
highlight_shell(line2, &mut colors2, &ctx, true, None);
assert_eq!(colors2[0].foreground, HighlightRole::error);
let arg_pos = line2.chars().count() - 1;
assert_eq!(colors2[arg_pos].foreground, HighlightRole::param);
}
#[test]
fn highlight_reserved_word_and_separator() {
let _g = lock();
let ctx = OperationContext::empty();
let line = "if true; then echo x; fi";
let mut colors = Vec::new();
highlight_shell(line, &mut colors, &ctx, true, None);
assert_eq!(colors[0].foreground, HighlightRole::keyword, "{colors:?}");
let semi = line.chars().position(|c| c == ';').unwrap();
assert_eq!(colors[semi].foreground, HighlightRole::statement_terminator);
}
#[test]
fn highlight_assignment_equals_operator() {
let _g = lock();
let ctx = OperationContext::empty();
let line = "FOO=bar echo x";
let mut colors = Vec::new();
highlight_shell(line, &mut colors, &ctx, true, None);
let eq = line.chars().position(|c| c == '=').unwrap();
assert_eq!(colors[eq].foreground, HighlightRole::operat, "{colors:?}");
}
#[test]
fn highlight_io_off_assumes_valid() {
let _g = lock();
let ctx = OperationContext::empty();
let line = "definitely_not_a_cmd_zshrs_x";
let mut colors = Vec::new();
highlight_shell(line, &mut colors, &ctx, false, None);
assert_eq!(colors[0].foreground, HighlightRole::command);
}
#[test]
fn resolver_default_palette() {
let _g = lock();
let attr =
HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
HighlightRole::command,
));
assert_ne!(attr, 0, "command role must default to a visible style");
let err =
HighlightColorResolver::resolve_spec_uncached(&HighlightSpec::with_fg(
HighlightRole::error,
));
assert_ne!(err, 0);
assert_ne!(attr, err, "command and error styles must differ");
}
#[test]
fn contains_pending_variable_matches_dollar_use() {
let vars = vec!["x".to_owned()];
assert!(contains_pending_variable(&vars, "$x"));
assert!(contains_pending_variable(&vars, "dir/$x/log"));
assert!(!contains_pending_variable(&vars, "x"));
assert!(!contains_pending_variable(&vars, "$xy"));
}
#[test]
fn locate_cmdsubst_span_finds_nested() {
let chars: Vec<char> = "a$(b $(c) d)e".chars().collect();
let (open, close) = locate_cmdsubst_span(&chars, 0).unwrap();
assert_eq!(open, 1);
assert_eq!(close, Some(11));
let chars2: Vec<char> = "a$(b".chars().collect();
let (o2, c2) = locate_cmdsubst_span(&chars2, 0).unwrap();
assert_eq!(o2, 1);
assert_eq!(c2, None);
}
}