fix(tui): wrap syntax-highlighted diff lines, add highlight guardrails

Integrate syntax highlighting into diff rendering and markdown code
blocks, with proper line wrapping that preserves styled spans.

- Add detect_lang_for_path() for extension-based language detection
- Syntax-aware diff rendering for Add/Delete/Update file changes
- wrap_styled_spans() splits long highlighted lines across display rows
- Buffer fenced code blocks in markdown_render for batch highlighting
- Add snapshot tests for highlighted diff wrapping
- Replace code_block_unhighlighted test with 3 syntax color assertions
This commit is contained in:
Felipe Coury
2026-02-08 21:05:59 -03:00
parent 776e4b0aa8
commit 1ddc3d7b00
5 changed files with 504 additions and 59 deletions

View File

@@ -12,8 +12,11 @@ use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use unicode_width::UnicodeWidthChar;
use crate::exec_command::relativize_to_home;
use crate::render::Insets;
use crate::render::highlight::highlight_code_to_styled_spans;
use crate::render::line_utils::prefix_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::InsetRenderable;
@@ -42,13 +45,13 @@ impl DiffSummary {
impl Renderable for FileChange {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut lines = vec![];
render_change(self, &mut lines, area.width as usize);
render_change(self, &mut lines, area.width as usize, None);
Paragraph::new(lines).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let mut lines = vec![];
render_change(self, &mut lines, width as usize);
render_change(self, &mut lines, width as usize, None);
lines.len() as u16
}
}
@@ -185,38 +188,79 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
out.push(RtLine::from(header));
}
let lang = detect_lang_for_path(&r.path);
let mut lines = vec![];
render_change(&r.change, &mut lines, wrap_cols - 4);
render_change(&r.change, &mut lines, wrap_cols - 4, lang.as_deref());
out.extend(prefix_lines(lines, " ".into(), " ".into()));
}
out
}
fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
/// Detect the programming language for a file path by its extension.
/// Returns the raw extension string for `normalize_lang` / `find_syntax`
/// to resolve downstream.
fn detect_lang_for_path(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?;
Some(ext.to_string())
}
fn render_change(
change: &FileChange,
out: &mut Vec<RtLine<'static>>,
width: usize,
lang: Option<&str>,
) {
match change {
FileChange::Add { content } => {
// Pre-highlight the entire file content as a whole.
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
));
let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i));
if let Some(spans) = syn {
out.extend(push_wrapped_diff_line_with_syntax(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
spans,
));
} else {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
));
}
}
}
FileChange::Delete { content } => {
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
));
let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i));
if let Some(spans) = syn {
out.extend(push_wrapped_diff_line_with_syntax(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
spans,
));
} else {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
));
}
}
}
FileChange::Update { unified_diff, .. } => {
@@ -256,38 +300,77 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
// Per-line highlighting for unified diffs.
let highlight_line = |s: &str| -> Option<Vec<RtSpan<'static>>> {
let l = lang?;
let spans = highlight_code_to_styled_spans(s, l)?;
spans.into_iter().next()
};
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
));
if let Some(syn) = highlight_line(s) {
out.extend(push_wrapped_diff_line_with_syntax(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
&syn,
));
} else {
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
));
}
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
));
if let Some(syn) = highlight_line(s) {
out.extend(push_wrapped_diff_line_with_syntax(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
&syn,
));
} else {
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
));
}
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
));
if let Some(syn) = highlight_line(s) {
out.extend(push_wrapped_diff_line_with_syntax(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
&syn,
));
} else {
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
));
}
old_ln += 1;
new_ln += 1;
}
@@ -348,27 +431,101 @@ fn push_wrapped_diff_line(
text: &str,
width: usize,
line_number_width: usize,
) -> Vec<RtLine<'static>> {
push_wrapped_diff_line_inner(line_number, kind, text, width, line_number_width, None)
}
fn push_wrapped_diff_line_with_syntax(
line_number: usize,
kind: DiffLineType,
text: &str,
width: usize,
line_number_width: usize,
syntax_spans: &[RtSpan<'static>],
) -> Vec<RtLine<'static>> {
push_wrapped_diff_line_inner(
line_number,
kind,
text,
width,
line_number_width,
Some(syntax_spans),
)
}
fn push_wrapped_diff_line_inner(
line_number: usize,
kind: DiffLineType,
text: &str,
width: usize,
line_number_width: usize,
syntax_spans: Option<&[RtSpan<'static>]>,
) -> Vec<RtLine<'static>> {
let ln_str = line_number.to_string();
let mut remaining_text: &str = text;
// Reserve a fixed number of spaces (equal to the widest line number plus a
// trailing spacer) so the sign column stays aligned across the diff block.
let gutter_width = line_number_width.max(1);
let prefix_cols = gutter_width + 1;
let mut first = true;
let (sign_char, line_style) = match kind {
DiffLineType::Insert => ('+', style_add()),
DiffLineType::Delete => ('-', style_del()),
DiffLineType::Context => (' ', style_context()),
};
// When we have syntax spans, compose them with the diff style for a richer
// view. The sign character keeps the diff color; content gets syntax colors
// with an overlay modifier for delete lines (dim).
if let Some(syn_spans) = syntax_spans {
let gutter = format!("{ln_str:>gutter_width$} ");
let sign = format!("{sign_char}");
let dim_overlay = matches!(kind, DiffLineType::Delete);
// Apply dim overlay to syntax spans if this is a delete line.
let styled: Vec<RtSpan<'static>> = syn_spans
.iter()
.map(|sp| {
let mut style = sp.style;
if dim_overlay {
style.add_modifier |= Modifier::DIM;
}
RtSpan::styled(sp.content.clone().into_owned(), style)
})
.collect();
// Determine how many display columns remain for content after the
// gutter and sign character.
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
// Wrap the styled content spans to fit within the available columns.
let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols);
let mut lines: Vec<RtLine<'static>> = Vec::new();
for (i, chunk) in wrapped_chunks.into_iter().enumerate() {
let mut row_spans: Vec<RtSpan<'static>> = Vec::new();
if i == 0 {
// First line: gutter + sign + content
row_spans.push(RtSpan::styled(gutter.clone(), style_gutter()));
row_spans.push(RtSpan::styled(sign.clone(), line_style));
} else {
// Continuation: empty gutter + two-space indent (matches
// the plain-text wrapping continuation style).
let cont_gutter = format!("{:gutter_width$} ", "");
row_spans.push(RtSpan::styled(cont_gutter, style_gutter()));
}
row_spans.extend(chunk);
lines.push(RtLine::from(row_spans));
}
return lines;
}
// Fallback: no syntax spans — use the original wrapping behavior.
let mut remaining_text: &str = text;
let mut first = true;
let mut lines: Vec<RtLine<'static>> = Vec::new();
loop {
// Fit the content for the current terminal row:
// compute how many columns are available after the prefix, then split
// at a UTF-8 character boundary so this row's chunk fits exactly.
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
let split_at_byte_index = remaining_text
.char_indices()
@@ -379,9 +536,7 @@ fn push_wrapped_diff_line(
remaining_text = rest;
if first {
// Build gutter (right-aligned line number plus spacer) as a dimmed span
let gutter = format!("{ln_str:>gutter_width$} ");
// Content with a sign ('+'/'-'/' ') styled per diff kind
let content = format!("{sign_char}{chunk}");
lines.push(RtLine::from(vec![
RtSpan::styled(gutter, style_gutter()),
@@ -389,7 +544,6 @@ fn push_wrapped_diff_line(
]));
first = false;
} else {
// Continuation lines keep a space for the sign column so content aligns
let gutter = format!("{:gutter_width$} ", "");
lines.push(RtLine::from(vec![
RtSpan::styled(gutter, style_gutter()),
@@ -403,6 +557,73 @@ fn push_wrapped_diff_line(
lines
}
/// Split styled spans into chunks that fit within `max_cols` display columns.
/// Returns one `Vec<RtSpan>` per output line. Styles are preserved across
/// split boundaries so that wrapping never loses syntax coloring.
fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSpan<'static>>> {
let mut result: Vec<Vec<RtSpan<'static>>> = Vec::new();
let mut current_line: Vec<RtSpan<'static>> = Vec::new();
let mut col: usize = 0;
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut remaining = text;
while !remaining.is_empty() {
// Accumulate characters until we fill the line.
let mut byte_end = 0;
let mut chars_col = 0;
for ch in remaining.chars() {
let w = ch.width().unwrap_or(0);
if col + chars_col + w > max_cols && byte_end > 0 {
// Adding this character would exceed the line width and we
// already have some content — break here.
break;
}
byte_end += ch.len_utf8();
chars_col += w;
}
if byte_end == 0 {
// Single character wider than remaining space — force onto a
// new line so we make progress.
if !current_line.is_empty() {
result.push(std::mem::take(&mut current_line));
}
// Take at least one character to avoid an infinite loop.
let Some(ch) = remaining.chars().next() else {
break;
};
let ch_len = ch.len_utf8();
current_line.push(RtSpan::styled(remaining[..ch_len].to_string(), style));
col = ch.width().unwrap_or(1);
remaining = &remaining[ch_len..];
continue;
}
let (chunk, rest) = remaining.split_at(byte_end);
current_line.push(RtSpan::styled(chunk.to_string(), style));
col += chars_col;
remaining = rest;
// If we exactly filled or exceeded the line, start a new one.
if col >= max_cols && !remaining.is_empty() {
result.push(std::mem::take(&mut current_line));
col = 0;
}
}
}
// Push the last line (always at least one, even if empty).
if !current_line.is_empty() || result.is_empty() {
result.push(current_line);
}
result
}
fn line_number_width(max_line_number: usize) -> usize {
if max_line_number == 0 {
1
@@ -694,4 +915,99 @@ mod tests {
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
}
#[test]
fn ui_snapshot_syntax_highlighted_insert_wraps() {
// A long Rust line that exceeds 80 cols with syntax highlighting should
// wrap to multiple output lines rather than being clipped.
let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_one) }";
let syntax_spans =
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
let spans = &syntax_spans[0];
let lines = push_wrapped_diff_line_with_syntax(
1,
DiffLineType::Insert,
long_rust,
80,
line_number_width(1),
spans,
);
assert!(
lines.len() > 1,
"syntax-highlighted long line should wrap to multiple lines, got {}",
lines.len()
);
snapshot_lines("syntax_highlighted_insert_wraps", lines, 90, 10);
}
#[test]
fn ui_snapshot_syntax_highlighted_insert_wraps_text() {
let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_one) }";
let syntax_spans =
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
let spans = &syntax_spans[0];
let lines = push_wrapped_diff_line_with_syntax(
1,
DiffLineType::Insert,
long_rust,
80,
line_number_width(1),
spans,
);
snapshot_lines_text("syntax_highlighted_insert_wraps_text", &lines);
}
#[test]
fn detect_lang_for_common_paths() {
// Standard extensions are detected.
assert!(detect_lang_for_path(Path::new("foo.rs")).is_some());
assert!(detect_lang_for_path(Path::new("bar.py")).is_some());
assert!(detect_lang_for_path(Path::new("app.tsx")).is_some());
// Extensionless files return None.
assert!(detect_lang_for_path(Path::new("Makefile")).is_none());
assert!(detect_lang_for_path(Path::new("randomfile")).is_none());
}
#[test]
fn wrap_styled_spans_single_line() {
// Content that fits in one line should produce exactly one chunk.
let spans = vec![RtSpan::raw("short")];
let result = wrap_styled_spans(&spans, 80);
assert_eq!(result.len(), 1);
}
#[test]
fn wrap_styled_spans_splits_long_content() {
// Content wider than max_cols should produce multiple chunks.
let long_text = "a".repeat(100);
let spans = vec![RtSpan::raw(long_text)];
let result = wrap_styled_spans(&spans, 40);
assert!(
result.len() >= 3,
"100 chars at 40 cols should produce at least 3 lines, got {}",
result.len()
);
}
#[test]
fn wrap_styled_spans_preserves_styles() {
// Verify that styles survive split boundaries.
let style = Style::default().fg(Color::Green);
let text = "x".repeat(50);
let spans = vec![RtSpan::styled(text, style)];
let result = wrap_styled_spans(&spans, 20);
for chunk in &result {
for span in chunk {
assert_eq!(span.style, style, "style should be preserved across wraps");
}
}
}
}

View File

@@ -1,3 +1,4 @@
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
@@ -99,6 +100,8 @@ where
pending_marker_line: bool,
in_paragraph: bool,
in_code_block: bool,
code_block_lang: Option<String>,
code_block_buffer: String,
wrap_width: Option<usize>,
current_line_content: Option<Line<'static>>,
current_initial_indent: Vec<Span<'static>>,
@@ -124,6 +127,8 @@ where
pending_marker_line: false,
in_paragraph: false,
in_code_block: false,
code_block_lang: None,
code_block_buffer: String::new(),
wrap_width,
current_line_content: None,
current_initial_indent: Vec::new(),
@@ -278,6 +283,17 @@ where
self.push_line(Line::default());
}
self.pending_marker_line = false;
// When inside a fenced code block with a known language, accumulate
// text into the buffer for batch highlighting in end_codeblock().
if self.in_code_block && self.code_block_lang.is_some() {
if !self.code_block_buffer.is_empty() {
self.code_block_buffer.push('\n');
}
self.code_block_buffer.push_str(&text);
return;
}
if self.in_code_block && !self.needs_newline {
let has_content = self
.current_line_content
@@ -394,12 +410,18 @@ where
self.needs_newline = false;
}
fn start_codeblock(&mut self, _lang: Option<String>, indent: Option<Span<'static>>) {
fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
self.flush_current_line();
if !self.text.lines.is_empty() {
self.push_blank_line();
}
self.in_code_block = true;
// Store the language for syntax highlighting; clear the buffer.
let lang = lang.filter(|l| !l.is_empty());
self.code_block_lang = lang;
self.code_block_buffer.clear();
self.indent_stack.push(IndentContext::new(
vec![indent.unwrap_or_default()],
None,
@@ -409,6 +431,22 @@ where
}
fn end_codeblock(&mut self) {
// If we buffered code for a known language, syntax-highlight it now.
if let Some(lang) = self.code_block_lang.take() {
let code = std::mem::take(&mut self.code_block_buffer);
// Trim trailing newline to avoid a spurious empty line.
let code = code.trim_end_matches('\n');
if !code.is_empty() {
let highlighted = highlight_code_to_lines(code, &lang);
for hl_line in highlighted {
self.push_line(Line::default());
for span in hl_line.spans {
self.push_span(span);
}
}
}
}
self.needs_newline = true;
self.in_code_block = false;
self.indent_stack.pop();

View File

@@ -652,10 +652,71 @@ fn link() {
}
#[test]
fn code_block_unhighlighted() {
fn code_block_known_lang_has_syntax_colors() {
let text = render_markdown_text("```rust\nfn main() {}\n```\n");
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]);
assert_eq!(text, expected);
let content: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
// Content should be preserved; ignore trailing empty line from highlighting.
let content: Vec<&str> = content.iter().map(|s| s.as_str()).filter(|s| !s.is_empty()).collect();
assert_eq!(content, vec!["fn main() {}"]);
// At least one span should have non-default style (syntax highlighting).
let has_colored_span = text
.lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|sp| sp.style.fg.is_some());
assert!(has_colored_span, "expected syntax-highlighted spans with color");
}
#[test]
fn code_block_unknown_lang_plain() {
let text = render_markdown_text("```xyzlang\nhello world\n```\n");
let content: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
let content: Vec<&str> = content.iter().map(|s| s.as_str()).filter(|s| !s.is_empty()).collect();
assert_eq!(content, vec!["hello world"]);
// No syntax coloring for unknown language — all spans have default style.
let has_colored_span = text
.lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|sp| sp.style.fg.is_some());
assert!(!has_colored_span, "expected no syntax coloring for unknown lang");
}
#[test]
fn code_block_no_lang_plain() {
let text = render_markdown_text("```\nno lang specified\n```\n");
let content: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
let content: Vec<&str> = content.iter().map(|s| s.as_str()).filter(|s| !s.is_empty()).collect();
assert_eq!(content, vec!["no lang specified"]);
}
#[test]
@@ -721,16 +782,25 @@ Here is a code block that shows another fenced block:
.collect::<String>()
})
.collect();
// Filter empty trailing lines for stability; the code block may or may
// not emit a trailing blank depending on the highlighting path.
let trimmed: Vec<&str> = {
let mut v: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
while v.last() == Some(&"") {
v.pop();
}
v
};
assert_eq!(
lines,
trimmed,
vec![
"Here is a code block that shows another fenced block:".to_string(),
String::new(),
"```md".to_string(),
"# Inside fence".to_string(),
"- bullet".to_string(),
"- `inline code`".to_string(),
"```".to_string(),
"Here is a code block that shows another fenced block:",
"",
"```md",
"# Inside fence",
"- bullet",
"- `inline code`",
"```",
]
);
}

View File

@@ -0,0 +1,14 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"1 +fn very_long_function_name(arg_one: String, arg_two: String, arg_three: Strin "
" g, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_o "
" ne) } "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,7 @@
---
source: tui/src/diff_render.rs
expression: text
---
1 +fn very_long_function_name(arg_one: String, arg_two: String, arg_three: Strin
g, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_o
ne) }