mirror of
https://github.com/openai/codex.git
synced 2026-04-28 02:11:08 +03:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`",
|
||||
"```",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) } "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user