diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3375f6d8..f23890bf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1424,6 +1424,8 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "tree-sitter-bash", + "tree-sitter-highlight", "unicode-segmentation", "unicode-width 0.2.1", "url", @@ -6261,9 +6263,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd2a058a86cfece0bf96f7cce1021efef9c8ed0e892ab74639173e5ed7a34fa" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" dependencies = [ "cc", "regex", @@ -6283,6 +6285,18 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-highlight" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3" +dependencies = [ + "regex", + "streaming-iterator", + "thiserror 2.0.16", + "tree-sitter", +] + [[package]] name = "tree-sitter-language" version = "0.1.5" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index bcbe8445..ca40b1a5 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -175,8 +175,9 @@ tracing = "0.1.41" tracing-appender = "0.2.3" tracing-subscriber = "0.3.20" tracing-test = "0.2.5" -tree-sitter = "0.25.9" -tree-sitter-bash = "0.25.0" +tree-sitter = "0.25.10" +tree-sitter-bash = "0.25" +tree-sitter-highlight = "0.25.10" ts-rs = "11" unicode-segmentation = "1.12.0" unicode-width = "0.2" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index f42f555c..2c6f32a2 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -68,6 +68,8 @@ strum_macros = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } +tree-sitter-highlight = { workspace = true } +tree-sitter-bash = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index 393aa337..e6d200cc 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -1,81 +1,146 @@ -use codex_core::bash::try_parse_bash; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use std::sync::OnceLock; +use tree_sitter_highlight::Highlight; +use tree_sitter_highlight::HighlightConfiguration; +use tree_sitter_highlight::HighlightEvent; +use tree_sitter_highlight::Highlighter; -/// Convert the full bash script into per-line styled content by first -/// computing operator-dimmed spans across the entire script, then splitting -/// by newlines and dimming heredoc body lines. Performs a single parse and -/// reuses it for both highlighting and heredoc detection. -pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec> { - // Parse once; use the tree for both highlighting and heredoc body detection. - let spans: Vec> = if let Some(tree) = try_parse_bash(script) { - // Single walk: collect operator ranges and heredoc rows. - let root = tree.root_node(); - let mut cursor = root.walk(); - let mut stack = vec![root]; - let mut ranges: Vec<(usize, usize)> = Vec::new(); - while let Some(node) = stack.pop() { - if !node.is_named() && !node.is_extra() { - let kind = node.kind(); - let is_quote = matches!(kind, "\"" | "'" | "`"); - let is_whitespace = kind.trim().is_empty(); - if !is_quote && !is_whitespace { - ranges.push((node.start_byte(), node.end_byte())); - } - } else if node.kind() == "heredoc_body" { - ranges.push((node.start_byte(), node.end_byte())); - } - for child in node.children(&mut cursor) { - stack.push(child); - } - } - if ranges.is_empty() { - ranges.push((script.len(), script.len())); - } - ranges.sort_by_key(|(st, _)| *st); - let mut spans: Vec> = Vec::new(); - let mut i = 0usize; - for (start, end) in ranges.into_iter() { - let dim_start = start.max(i); - let dim_end = end; - if dim_start < dim_end { - if dim_start > i { - spans.push(script[i..dim_start].to_string().into()); - } - spans.push(script[dim_start..dim_end].to_string().dim()); - i = dim_end; - } - } - if i < script.len() { - spans.push(script[i..].to_string().into()); - } - spans - } else { - vec![script.to_string().into()] - }; - // Split spans into lines preserving style boundaries and highlights across newlines. - let mut lines: Vec> = vec![Line::from("")]; - for sp in spans { - let style = sp.style; - let text = sp.content.into_owned(); - for (i, part) in text.split('\n').enumerate() { - if i > 0 { - lines.push(Line::from("")); - } - if part.is_empty() { - continue; - } - let span = Span { - style, - content: std::borrow::Cow::Owned(part.to_string()), - }; - if let Some(last) = lines.last_mut() { - last.spans.push(span); - } +// Ref: https://github.com/tree-sitter/tree-sitter-bash/blob/master/queries/highlights.scm +#[derive(Copy, Clone)] +enum BashHighlight { + Comment, + Constant, + Embedded, + Function, + Keyword, + Number, + Operator, + Property, + String, +} + +impl BashHighlight { + const ALL: [Self; 9] = [ + Self::Comment, + Self::Constant, + Self::Embedded, + Self::Function, + Self::Keyword, + Self::Number, + Self::Operator, + Self::Property, + Self::String, + ]; + + const fn as_str(self) -> &'static str { + match self { + Self::Comment => "comment", + Self::Constant => "constant", + Self::Embedded => "embedded", + Self::Function => "function", + Self::Keyword => "keyword", + Self::Number => "number", + Self::Operator => "operator", + Self::Property => "property", + Self::String => "string", } } - lines + + fn style(self) -> Style { + match self { + Self::Comment | Self::Operator | Self::String => Style::default().dim(), + _ => Style::default(), + } + } +} + +static HIGHLIGHT_CONFIG: OnceLock = OnceLock::new(); + +fn highlight_names() -> &'static [&'static str] { + static NAMES: OnceLock<[&'static str; BashHighlight::ALL.len()]> = OnceLock::new(); + NAMES + .get_or_init(|| BashHighlight::ALL.map(BashHighlight::as_str)) + .as_slice() +} + +fn highlight_config() -> &'static HighlightConfiguration { + HIGHLIGHT_CONFIG.get_or_init(|| { + let language = tree_sitter_bash::LANGUAGE.into(); + #[expect(clippy::expect_used)] + let mut config = HighlightConfiguration::new( + language, + "bash", + tree_sitter_bash::HIGHLIGHT_QUERY, + "", + "", + ) + .expect("load bash highlight query"); + config.configure(highlight_names()); + config + }) +} + +fn highlight_for(highlight: Highlight) -> BashHighlight { + BashHighlight::ALL[highlight.0] +} + +fn push_segment(lines: &mut Vec>, segment: &str, style: Option