1use super::parsing::{flush_current_line, push_blank_line};
2use super::{CODE_LINE_NUMBER_MIN_WIDTH, MarkdownLine, MarkdownSegment, RenderMarkdownOptions};
3use crate::tui::config::loader::SyntaxHighlightingConfig;
4use crate::tui::ui::syntax_highlight;
5use crate::tui::ui::theme::ThemeStyles;
6use crate::tui::utils::diff_styles::{
7 DiffLineType, current_diff_render_style_context, diff_add_fg, diff_del_fg, style_content_ansi,
8 style_file_header_new_ansi, style_file_header_old_ansi, style_hunk_header_ansi, style_sign_ansi,
9};
10use anstyle::{Color as AnstyleColor, Effects, Style};
11use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
12use std::fmt::Write;
13use syntect::util::LinesWithEndings;
14use vtcode_commons::diff_paths::{
15 is_diff_addition_line, is_diff_deletion_line, is_diff_header_line, is_diff_new_file_marker_line,
16 is_prose_language_hint, language_hint_from_path, looks_like_diff_content, parse_diff_git_path,
17 parse_diff_marker_path,
18};
19use vtcode_commons::diff_preview::{DiffDisplayKind, DiffDisplayLine, annotate_word_level_diffs};
20
21const DIFF_SUMMARY_PREFIX: &str = "• Diff ";
22
23#[derive(Clone, Debug)]
24pub(crate) struct CodeBlockState {
25 pub(crate) language: Option<String>,
26 pub(crate) buffer: String,
27}
28
29pub(crate) struct CodeBlockRenderEnv<'a> {
30 pub(crate) lines: &'a mut Vec<MarkdownLine>,
31 pub(crate) current_line: &'a mut MarkdownLine,
32 pub(crate) blockquote_depth: usize,
33 pub(crate) list_continuation_prefix: &'a str,
34 pub(crate) pending_list_prefix: &'a mut Option<String>,
35 pub(crate) base_style: Style,
36 pub(crate) theme_styles: &'a ThemeStyles,
37 pub(crate) highlight_config: Option<&'a SyntaxHighlightingConfig>,
38 pub(crate) render_options: RenderMarkdownOptions,
39}
40
41pub(crate) fn handle_code_block_event(
42 event: &Event<'_>,
43 code_block: &mut Option<CodeBlockState>,
44 env: &mut CodeBlockRenderEnv<'_>,
45) -> bool {
46 if code_block.is_none() {
47 return false;
48 }
49
50 match event {
51 Event::Text(text) => {
52 if let Some(state) = code_block.as_mut() {
53 state.buffer.push_str(text);
54 }
55 true
56 }
57 Event::End(TagEnd::CodeBlock) => {
58 finalize_code_block(env, code_block, true, true);
59 true
60 }
61 _ => false,
62 }
63}
64
65pub(crate) fn finalize_unclosed_code_block(code_block: &mut Option<CodeBlockState>, env: &mut CodeBlockRenderEnv<'_>) {
66 finalize_code_block(env, code_block, false, false);
67}
68
69fn finalize_code_block(
70 env: &mut CodeBlockRenderEnv<'_>,
71 code_block: &mut Option<CodeBlockState>,
72 allow_table_reparse: bool,
73 append_trailing_blank_line: bool,
74) {
75 flush_current_line(
76 env.lines,
77 env.current_line,
78 env.blockquote_depth,
79 env.list_continuation_prefix,
80 env.pending_list_prefix,
81 env.base_style,
82 );
83 if let Some(state) = code_block.take() {
84 let rendered = render_code_block_state(&state, env, allow_table_reparse);
85 env.lines.extend(rendered);
86 if append_trailing_blank_line {
87 push_blank_line(env.lines);
88 }
89 }
90}
91
92fn render_code_block_state(
93 state: &CodeBlockState,
94 env: &CodeBlockRenderEnv<'_>,
95 allow_table_reparse: bool,
96) -> Vec<MarkdownLine> {
97 if allow_table_reparse
98 && !env.render_options.disable_code_block_table_reparse
99 && code_block_contains_table(&state.buffer, state.language.as_deref())
100 {
101 return render_markdown_code_block_table(
102 &state.buffer,
103 env.base_style,
104 env.theme_styles,
105 env.highlight_config,
106 env.render_options,
107 );
108 }
109
110 let prefix = build_prefix_segments(env.blockquote_depth, env.list_continuation_prefix, env.base_style);
111 highlight_code_block(
112 &state.buffer,
113 state.language.as_deref(),
114 env.highlight_config,
115 env.theme_styles,
116 env.base_style,
117 &prefix,
118 env.render_options.preserve_code_indentation,
119 )
120}
121
122fn render_markdown_code_block_table(
123 source: &str,
124 base_style: Style,
125 theme_styles: &ThemeStyles,
126 highlight_config: Option<&SyntaxHighlightingConfig>,
127 render_options: RenderMarkdownOptions,
128) -> Vec<MarkdownLine> {
129 let mut nested_options = render_options;
130 nested_options.disable_code_block_table_reparse = true;
131 super::render_markdown_to_lines_with_options(source, base_style, theme_styles, highlight_config, nested_options)
132}
133
134fn build_prefix_segments(
135 _blockquote_depth: usize,
136 list_continuation_prefix: &str,
137 base_style: Style,
138) -> Vec<MarkdownSegment> {
139 let mut segments = Vec::with_capacity(usize::from(!list_continuation_prefix.is_empty()));
140 if !list_continuation_prefix.is_empty() {
141 segments.push(MarkdownSegment::new(base_style, list_continuation_prefix));
142 }
143 segments
144}
145
146fn highlight_code_block(
147 code: &str,
148 language: Option<&str>,
149 highlight_config: Option<&SyntaxHighlightingConfig>,
150 theme_styles: &ThemeStyles,
151 base_style: Style,
152 prefix_segments: &[MarkdownSegment],
153 preserve_code_indentation: bool,
154) -> Vec<MarkdownLine> {
155 let mut lines = Vec::new();
156
157 let normalized_code = normalize_code_indentation(code, language, preserve_code_indentation);
158 let code_to_display = &normalized_code;
159 if is_diff_language(language) || (language.is_none() && looks_like_diff_content(code_to_display)) {
160 return render_diff_code_block(code_to_display, theme_styles, base_style, prefix_segments);
161 }
162 let use_line_numbers = language.is_some_and(|lang| !lang.trim().is_empty()) && !is_diff_language(language);
163
164 if let Some(config) = highlight_config.filter(|cfg| cfg.enabled)
165 && let Some(highlighted) = try_highlight(code_to_display, language, config)
166 {
167 let source_lines: Vec<&str> = code_to_display.lines().collect();
168 let line_count = source_line_count(code_to_display);
169 let number_width = line_number_width(line_count);
170 let gutter_style = line_number_style(theme_styles, base_style);
171 let code_style = code_block_style(theme_styles, base_style);
172 let mut line_number = 1usize;
173 for (index, src) in source_lines.iter().enumerate() {
174 let Some(segments) = highlighted.get(index) else {
175 break;
176 };
177 let is_omitted = parse_omitted_line_count(src).is_some();
178 let (gutter_text, omitted) = if use_line_numbers {
179 let (text, om) = format_gutter_text(line_number, number_width, src);
180 (Some(text), om)
181 } else {
182 (None, 1)
183 };
184 let mut line = code_line_with_prefix(prefix_segments, gutter_text.as_deref(), gutter_style);
185 if is_omitted {
186 line.push_segment(gutter_style, src);
187 } else {
188 let mut in_leading_whitespace = true;
189 for (style, text) in segments {
190 let is_leading_whitespace = in_leading_whitespace && text.trim().is_empty();
191 if is_leading_whitespace {
192 line.push_segment(code_style, text);
193 } else {
194 in_leading_whitespace = false;
195 line.push_segment(*style, text);
196 }
197 }
198 }
199 line_number = line_number.saturating_add(omitted);
200 lines.push(line);
201 }
202 return lines;
203 }
204
205 let mut line_number = 1usize;
206 let line_count = source_line_count(code_to_display);
207 let number_width = line_number_width(line_count);
208 let gutter_style = line_number_style(theme_styles, base_style);
209
210 for raw_line in LinesWithEndings::from(code_to_display) {
211 let trimmed = raw_line.trim_end_matches('\n');
212 let is_omitted = parse_omitted_line_count(trimmed).is_some();
213 let (gutter_text, omitted) = if use_line_numbers {
214 let (text, om) = format_gutter_text(line_number, number_width, trimmed);
215 (Some(text), om)
216 } else {
217 (None, 1)
218 };
219 let mut line = code_line_with_prefix(prefix_segments, gutter_text.as_deref(), gutter_style);
220 if !trimmed.is_empty() {
221 if is_omitted {
222 line.push_segment(gutter_style, trimmed);
223 } else {
224 line.push_segment(code_block_style(theme_styles, base_style), trimmed);
225 }
226 }
227 lines.push(line);
228 line_number = line_number.saturating_add(omitted);
229 }
230
231 lines
232}
233
234pub(crate) fn normalize_diff_lines(code: &str) -> Vec<String> {
235 #[derive(Default)]
236 struct DiffBlock {
237 header: String,
238 path: String,
239 lines: Vec<String>,
240 additions: usize,
241 deletions: usize,
242 }
243
244 let mut preface = Vec::new();
245 let mut blocks = Vec::new();
246 let mut current: Option<DiffBlock> = None;
247 let mut fallback_additions = 0usize;
248 let mut fallback_deletions = 0usize;
249 let mut fallback_path: Option<String> = None;
250 let mut summary_insert_index: Option<usize> = None;
251
252 for line in code.lines() {
253 if fallback_path.is_none() {
254 fallback_path = parse_diff_marker_path(line);
255 }
256 if summary_insert_index.is_none() && is_diff_new_file_marker_line(line.trim_start()) {
257 summary_insert_index = Some(preface.len());
258 }
259 bump_diff_counters(line, &mut fallback_additions, &mut fallback_deletions);
260
261 if let Some(path) = parse_diff_git_path(line) {
262 if let Some(block) = current.take() {
263 blocks.push(block);
264 }
265 current = Some(DiffBlock {
266 header: line.to_string(),
267 path,
268 lines: Vec::new(),
269 additions: 0,
270 deletions: 0,
271 });
272 continue;
273 }
274
275 let rewritten = rewrite_diff_line(line);
276 if let Some(block) = current.as_mut() {
277 bump_diff_counters(line, &mut block.additions, &mut block.deletions);
278 block.lines.push(rewritten);
279 } else {
280 preface.push(rewritten);
281 }
282 }
283
284 if let Some(block) = current {
285 blocks.push(block);
286 }
287
288 if blocks.is_empty() {
289 let path = fallback_path.unwrap_or_else(|| "file".to_string());
290 let summary = format_diff_summary(path.as_str(), fallback_additions, fallback_deletions);
291
292 let mut output = Vec::with_capacity(preface.len() + 1);
293 if let Some(idx) = summary_insert_index {
294 output.extend(preface[..=idx].iter().cloned());
295 output.push(summary);
296 output.extend(preface[idx + 1..].iter().cloned());
297 } else {
298 output.push(summary);
299 output.extend(preface);
300 }
301 return output;
302 }
303
304 let mut output = Vec::new();
305 output.extend(preface);
306 for block in blocks {
307 output.push(block.header);
308 output.push(format_diff_summary(block.path.as_str(), block.additions, block.deletions));
309 output.extend(block.lines);
310 }
311 output
312}
313
314fn render_diff_code_block(
315 code: &str,
316 theme_styles: &ThemeStyles,
317 base_style: Style,
318 prefix_segments: &[MarkdownSegment],
319) -> Vec<MarkdownLine> {
320 render_diff_code_block_with_context(
321 code,
322 theme_styles,
323 base_style,
324 prefix_segments,
325 current_diff_render_style_context(),
326 )
327}
328
329pub(crate) fn render_diff_code_block_with_context(
330 code: &str,
331 theme_styles: &ThemeStyles,
332 base_style: Style,
333 prefix_segments: &[MarkdownSegment],
334 style_context: crate::tui::utils::diff_styles::DiffRenderStyleContext,
335) -> Vec<MarkdownLine> {
336 let mut lines = Vec::new();
337 let context_style = code_block_style(theme_styles, base_style);
338 let metadata_style = Style::new()
339 .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::BrightBlack)))
340 .effects(Effects::DIMMED);
341 let hunk_style = style_hunk_header_ansi(style_context);
342 let added_style = style_content_ansi(DiffLineType::Insert, style_context);
343 let removed_style = style_content_ansi(DiffLineType::Delete, style_context);
344 let added_marker_style = style_sign_ansi(DiffLineType::Insert, style_context);
345 let removed_marker_style = style_sign_ansi(DiffLineType::Delete, style_context);
346 let file_old_style = style_file_header_old_ansi(style_context);
347 let file_new_style = style_file_header_new_ansi(style_context);
348 let added_background = added_style.get_bg_color();
349 let removed_background = removed_style.get_bg_color();
350 let added_word = style_context.add_word_bg();
351 let removed_word = style_context.del_word_bg();
352 let mut current_language: Option<String> = None;
353 let normalized = normalize_diff_lines(code);
354 let word_ranges_by_line = word_level_ranges_for_normalized(&normalized);
355
356 for (line_index, line) in normalized.iter().enumerate() {
357 let trimmed = line.trim_end_matches('\n');
358 let trimmed_start = trimmed.trim_start();
359 if let Some(path) = diff_path_for_language(trimmed_start) {
360 current_language = language_hint_from_path(&path);
361 }
362 if let Some((path, additions, deletions)) = parse_diff_summary_line(trimmed_start) {
363 let leading_len = trimmed.len().saturating_sub(trimmed_start.len());
364 let leading = &trimmed[..leading_len];
365 let mut line = prefixed_line(prefix_segments);
366 if !leading.is_empty() {
367 line.push_segment(context_style, leading);
368 }
369 let count_style = |kind: DiffLineType| {
372 let fg = match kind {
373 DiffLineType::Insert => Some(diff_add_fg(style_context.theme(), style_context.level())),
374 DiffLineType::Delete => Some(diff_del_fg(style_context.theme(), style_context.level())),
375 DiffLineType::Context => None,
376 };
377 Style::new().fg_color(fg)
378 };
379 line.push_segment(context_style, &format!("{DIFF_SUMMARY_PREFIX}{path} ("));
380 line.push_segment(count_style(DiffLineType::Insert), &format!("+{additions}"));
381 line.push_segment(context_style, " ");
382 line.push_segment(count_style(DiffLineType::Delete), &format!("-{deletions}"));
383 line.push_segment(context_style, ")");
384 lines.push(line);
385 continue;
386 }
387
388 let mut line = prefixed_line(prefix_segments);
389 if !trimmed.is_empty() {
390 if trimmed_start.starts_with("diff --git ") || trimmed_start.starts_with("index ") {
391 line.push_segment(metadata_style, trimmed);
392 } else if trimmed_start.starts_with("--- ") || trimmed_start.starts_with("*** Delete File:") {
393 line.push_segment(file_old_style, trimmed);
394 } else if trimmed_start.starts_with("+++ ") || trimmed_start.starts_with("*** Add File:") {
395 line.push_segment(file_new_style, trimmed);
396 } else if is_diff_header_line(trimmed_start) {
397 line.push_segment(hunk_style, trimmed);
398 } else if is_diff_addition_line(trimmed_start) {
399 line.push_segment(added_marker_style, "+");
400 let body = &trimmed_start[1..];
401 if body.is_empty() {
402 line.push_segment(added_style, " ");
403 } else {
404 let word_ranges = word_ranges_by_line.get(line_index).map(Vec::as_slice).unwrap_or(&[]);
405 push_highlighted_diff_body(
406 &mut line,
407 body,
408 current_language.as_deref(),
409 added_style,
410 added_background,
411 word_ranges,
412 added_word,
413 );
414 }
415 paint_line_background(&mut line, added_background);
416 line.set_line_background(added_background);
417 } else if trimmed_start.starts_with("*** Update File:") {
418 line.push_segment(added_marker_style, "+");
419 line.push_segment(added_style, trimmed_start);
420 paint_line_background(&mut line, added_background);
421 line.set_line_background(added_background);
422 } else if is_diff_deletion_line(trimmed_start) {
423 line.push_segment(removed_marker_style, "-");
424 let body = &trimmed_start[1..];
425 if body.is_empty() {
426 line.push_segment(removed_style, " ");
427 } else {
428 let word_ranges = word_ranges_by_line.get(line_index).map(Vec::as_slice).unwrap_or(&[]);
429 push_highlighted_diff_body(
430 &mut line,
431 body,
432 current_language.as_deref(),
433 removed_style,
434 removed_background,
435 word_ranges,
436 removed_word,
437 );
438 }
439 paint_line_background(&mut line, removed_background);
440 line.set_line_background(removed_background);
441 } else if let Some(context_body) = trimmed.strip_prefix(' ') {
442 line.push_segment(context_style, " ");
443 line.push_segment(context_style, context_body);
444 } else {
445 line.push_segment(context_style, trimmed);
446 }
447 }
448 lines.push(line);
449 }
450
451 lines
452}
453
454fn paint_line_background(line: &mut MarkdownLine, background: Option<AnstyleColor>) {
457 let Some(background) = background else {
458 return;
459 };
460 for segment in &mut line.segments {
461 if segment.style.get_bg_color().is_none() {
462 segment.style = segment.style.bg_color(Some(background));
463 }
464 }
465}
466
467fn push_highlighted_diff_body(
470 line: &mut MarkdownLine,
471 body: &str,
472 language: Option<&str>,
473 fallback: Style,
474 row_background: Option<AnstyleColor>,
475 word_ranges: &[(usize, usize)],
476 word_background: Option<AnstyleColor>,
477) {
478 let segments = render_diff_content_segments(body, language, fallback);
479 if segments.is_empty() {
480 line.push_segment(fallback.bg_color(row_background), " ");
481 return;
482 }
483
484 let mut cursor = 0usize;
485 for segment in segments {
486 let segment_len = segment.text.len();
487 let segment_end = cursor.saturating_add(segment_len);
488 let mut boundaries = vec![0, segment_len];
489 for &(start, end) in word_ranges {
490 if start < segment_end && end > cursor {
491 boundaries.push(start.max(cursor).saturating_sub(cursor).min(segment_len));
492 boundaries.push(end.min(segment_end).saturating_sub(cursor).min(segment_len));
493 }
494 }
495 boundaries.sort_unstable();
496 boundaries.dedup();
497
498 for pair in boundaries.windows(2) {
499 let start = pair[0];
500 let end = pair[1];
501 if start >= end {
502 continue;
503 }
504 let global_start = cursor.saturating_add(start);
505 let global_end = cursor.saturating_add(end);
506 let changed = word_background.is_some()
507 && word_ranges
508 .iter()
509 .any(|&(range_start, range_end)| global_start < range_end && global_end > range_start);
510 let background = if changed { word_background } else { row_background };
511 let mut style = segment.style.bg_color(background);
512 if style.get_fg_color().is_none() {
513 style = style.fg_color(fallback.get_fg_color());
514 }
515 line.push_segment(style, &segment.text[start..end]);
516 }
517 cursor = segment_end;
518 }
519}
520
521fn word_level_ranges_for_normalized(normalized: &[String]) -> Vec<Vec<(usize, usize)>> {
524 let mut display = Vec::with_capacity(normalized.len());
525 for line in normalized {
526 let trimmed = line.trim_end_matches('\n');
527 let trimmed_start = trimmed.trim_start();
528 let kind = if is_diff_addition_line(trimmed_start) {
529 DiffDisplayKind::Addition
530 } else if is_diff_deletion_line(trimmed_start) {
531 DiffDisplayKind::Deletion
532 } else {
533 DiffDisplayKind::Context
534 };
535 let body = match kind {
536 DiffDisplayKind::Addition | DiffDisplayKind::Deletion => {
537 trimmed_start.strip_prefix(['+', '-']).unwrap_or("").to_owned()
538 }
539 DiffDisplayKind::Context | DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => trimmed.to_owned(),
540 };
541 display.push(DiffDisplayLine::body(kind, None, None, body));
542 }
543 annotate_word_level_diffs(&mut display);
544 display.into_iter().map(|line| line.changed).collect()
545}
546
547fn diff_path_for_language(line: &str) -> Option<String> {
548 parse_diff_git_path(line).or_else(|| parse_diff_marker_path(line)).or_else(|| {
549 ["*** Update File:", "*** Add File:", "*** Delete File:"]
550 .iter()
551 .find_map(|prefix| line.strip_prefix(prefix).map(str::trim).filter(|path| !path.is_empty()))
552 .map(ToOwned::to_owned)
553 })
554}
555
556fn parse_diff_summary_line(line: &str) -> Option<(&str, usize, usize)> {
557 let summary = line.strip_prefix(DIFF_SUMMARY_PREFIX)?;
558 let (path, counts) = summary.rsplit_once(" (")?;
559 let counts = counts.strip_suffix(')')?;
560 let mut parts = counts.split_whitespace();
561 let additions = parts.next()?.strip_prefix('+')?.parse().ok()?;
562 let deletions = parts.next()?.strip_prefix('-')?.parse().ok()?;
563 Some((path, additions, deletions))
564}
565
566fn format_diff_summary(path: &str, additions: usize, deletions: usize) -> String {
567 format!("{DIFF_SUMMARY_PREFIX}{path} (+{additions} -{deletions})")
568}
569
570fn append_prefix_segments(line: &mut MarkdownLine, prefix_segments: &[MarkdownSegment]) {
571 for segment in prefix_segments {
572 line.push_segment(segment.style, &segment.text);
573 }
574}
575
576fn prefixed_line(prefix_segments: &[MarkdownSegment]) -> MarkdownLine {
577 let mut line = MarkdownLine::default();
578 append_prefix_segments(&mut line, prefix_segments);
579 line
580}
581
582fn line_number_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
583 let source = if base_style == theme_styles.tool_output {
584 theme_styles.tool_detail
585 } else {
586 base_style
587 };
588 let blended_fg = source
591 .get_fg_color()
592 .and_then(|fg| vtcode_commons::colors::blend_colors(&fg, &theme_styles.background, 0.35));
593 let mut style = Style::new();
594 if let Some(color) = blended_fg {
595 style = style.fg_color(Some(color));
596 } else if let Some(fg) = source.get_fg_color() {
597 style = style.fg_color(Some(fg));
598 } else {
599 style = style.fg_color(Some(theme_styles.foreground));
601 }
602 style
603}
604
605fn format_gutter_text(line_num: usize, width: usize, line_text: &str) -> (String, usize) {
607 if let Some(omitted) = parse_omitted_line_count(line_text) {
608 let range_end = line_num.saturating_add(omitted.saturating_sub(1));
609 let range = format!("{line_num}-{range_end}");
610 (format!("{range:>width$} "), omitted)
611 } else {
612 (format!("{line_num:>width$} "), 1)
613 }
614}
615
616fn code_line_with_prefix(
617 prefix_segments: &[MarkdownSegment],
618 gutter_text: Option<&str>,
619 gutter_style: Style,
620) -> MarkdownLine {
621 let mut line = MarkdownLine::default();
622 append_prefix_segments(&mut line, prefix_segments);
623 if let Some(text) = gutter_text {
624 line.push_segment(gutter_style, text);
625 }
626 line
627}
628
629fn line_number_width(line_count: usize) -> usize {
630 let digits = line_count.max(1).to_string().len();
631 digits.max(CODE_LINE_NUMBER_MIN_WIDTH)
632}
633
634fn source_line_count(code: &str) -> usize {
635 code.lines().map(|line| parse_omitted_line_count(line).unwrap_or(1)).sum()
636}
637
638fn parse_omitted_line_count(text: &str) -> Option<usize> {
641 let trimmed = text.trim();
642 let after = trimmed.strip_prefix("… [+")?;
643 let end = after.find(' ')?;
644 let count_str = &after[..end];
645 count_str.parse::<usize>().ok()
646}
647
648fn code_block_contains_table(content: &str, language: Option<&str>) -> bool {
649 let Some(lang) = language else {
650 return false;
651 };
652 let lang_lower = lang.to_ascii_lowercase();
653 if !matches!(lang_lower.as_str(), "markdown" | "md") {
654 return false;
655 }
656
657 let trimmed = content.trim();
658 if trimmed.is_empty() {
659 return false;
660 }
661
662 let mut has_pipe_line = false;
663 let mut has_separator = false;
664 for line in trimmed.lines().take(4) {
665 let line = line.trim();
666 if line.contains('|') {
667 has_pipe_line = true;
668 }
669 if line.starts_with('|') && line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
670 has_separator = true;
671 }
672 }
673 if !has_pipe_line || !has_separator {
674 return false;
675 }
676
677 let options = Options::ENABLE_TABLES;
678 let parser = Parser::new_ext(trimmed, options);
679 for event in parser {
680 match event {
681 Event::Start(Tag::Table(_)) => return true,
682 Event::Start(Tag::Paragraph) | Event::Text(_) | Event::SoftBreak => continue,
683 _ => return false,
684 }
685 }
686 false
687}
688
689fn rewrite_diff_line(line: &str) -> String {
690 line.to_string()
694}
695
696fn bump_diff_counters(line: &str, additions: &mut usize, deletions: &mut usize) {
697 let trimmed = line.trim_start();
698 if is_diff_addition_line(trimmed) {
699 *additions += 1;
700 } else if is_diff_deletion_line(trimmed) {
701 *deletions += 1;
702 }
703}
704
705fn is_diff_language(language: Option<&str>) -> bool {
706 language.is_some_and(|lang| matches!(lang.to_ascii_lowercase().as_str(), "diff" | "patch" | "udiff" | "git"))
707}
708
709fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
710 let base_fg = base_style.get_fg_color();
711 let theme_fg = theme_styles.output.get_fg_color();
712 let fg = if base_style.get_effects().contains(Effects::DIMMED) {
713 base_fg.or(theme_fg)
714 } else {
715 theme_fg.or(base_fg)
716 };
717 let mut style = base_style;
718 if let Some(color) = fg {
719 style = style.fg_color(Some(color));
720 } else {
721 style = style.fg_color(Some(theme_styles.foreground));
723 }
724 style
725}
726
727pub(crate) fn normalize_code_indentation(code: &str, language: Option<&str>, preserve_indentation: bool) -> String {
728 if preserve_indentation {
729 return code.to_string();
730 }
731 let has_language_hint = language.is_some_and(|hint| {
732 matches!(
733 hint.to_lowercase().as_str(),
734 "rust"
735 | "rs"
736 | "python"
737 | "py"
738 | "javascript"
739 | "js"
740 | "jsx"
741 | "typescript"
742 | "ts"
743 | "tsx"
744 | "go"
745 | "golang"
746 | "java"
747 | "cpp"
748 | "c"
749 | "php"
750 | "html"
751 | "css"
752 | "sql"
753 | "csharp"
754 | "bash"
755 | "sh"
756 | "swift"
757 )
758 });
759
760 if !has_language_hint && language.is_some() {
761 return code.to_string();
762 }
763
764 let lines: Vec<&str> = code.lines().collect();
765 let min_indent = lines
766 .iter()
767 .filter(|line| !line.trim().is_empty())
768 .map(|line| &line[..line.len() - line.trim_start().len()])
769 .reduce(|acc, p| {
770 let mut len = 0;
771 for (c1, c2) in acc.chars().zip(p.chars()) {
772 if c1 != c2 {
773 break;
774 }
775 len += c1.len_utf8();
776 }
777 &acc[..len]
778 })
779 .map(|s| s.len())
780 .unwrap_or(0);
781
782 let normalized = lines
783 .iter()
784 .map(|line| {
785 if line.trim().is_empty() {
786 line
787 } else if line.len() >= min_indent {
788 &line[min_indent..]
789 } else {
790 line
791 }
792 })
793 .collect::<Vec<_>>()
794 .join("\n");
795
796 if code.ends_with('\n') {
797 format!("{normalized}\n")
798 } else {
799 normalized
800 }
801}
802
803pub fn highlight_line_for_diff(line: &str, language: Option<&str>) -> Option<Vec<(Style, String)>> {
804 syntax_highlight::highlight_line_to_anstyle_segments(
805 line,
806 language,
807 syntax_highlight::get_active_syntax_theme(),
808 true,
809 )
810 .map(|segments| {
811 segments
812 .into_iter()
813 .map(|(style, text)| {
814 let fg = style.get_fg_color().map(|c| match c {
815 anstyle::Color::Rgb(rgb) => {
816 let brighten = |v: u8| (v as u16 * 120 / 100).min(255) as u8;
817 anstyle::Color::Rgb(anstyle::RgbColor(brighten(rgb.0), brighten(rgb.1), brighten(rgb.2)))
818 }
819 anstyle::Color::Ansi(ansi) => match ansi {
820 anstyle::AnsiColor::Black => anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite),
821 anstyle::AnsiColor::Red => anstyle::Color::Ansi(anstyle::AnsiColor::BrightRed),
822 anstyle::AnsiColor::Green => anstyle::Color::Ansi(anstyle::AnsiColor::BrightGreen),
823 anstyle::AnsiColor::Yellow => anstyle::Color::Ansi(anstyle::AnsiColor::BrightYellow),
824 anstyle::AnsiColor::Blue => anstyle::Color::Ansi(anstyle::AnsiColor::BrightBlue),
825 anstyle::AnsiColor::Magenta => anstyle::Color::Ansi(anstyle::AnsiColor::BrightMagenta),
826 anstyle::AnsiColor::Cyan => anstyle::Color::Ansi(anstyle::AnsiColor::BrightCyan),
827 anstyle::AnsiColor::White => anstyle::Color::Ansi(anstyle::AnsiColor::BrightWhite),
828 other => anstyle::Color::Ansi(other),
829 },
830 other => other,
831 });
832 let bg = style.get_bg_color();
833 let new_style = style.fg_color(fg).bg_color(bg);
834 (new_style, text)
835 })
836 .collect()
837 })
838}
839
840pub(crate) fn render_diff_content_segments(
841 content: &str,
842 language: Option<&str>,
843 fallback_style: Style,
844) -> Vec<MarkdownSegment> {
845 let text = content.trim_end_matches('\n');
846 if text.is_empty() {
847 return vec![MarkdownSegment::new(fallback_style, " ")];
848 }
849
850 if is_prose_language_hint(language) {
853 return vec![MarkdownSegment::new(fallback_style, text)];
854 }
855
856 let hint = language.map(str::trim).filter(|hint| !hint.is_empty());
860 let Some(hint) = hint else {
861 return vec![MarkdownSegment::new(fallback_style, text)];
862 };
863 if std::ptr::eq(syntax_highlight::find_syntax_by_token(hint), syntax_highlight::find_syntax_plain_text()) {
864 return vec![MarkdownSegment::new(fallback_style, text)];
865 }
866
867 if let Some(segments) = highlight_line_for_diff(text, Some(hint))
868 && !segments.is_empty()
869 {
870 return segments
871 .into_iter()
872 .map(|(style, text)| MarkdownSegment::new(style, text))
873 .collect();
874 }
875
876 vec![MarkdownSegment::new(fallback_style, text)]
877}
878
879fn try_highlight(
880 code: &str,
881 language: Option<&str>,
882 config: &SyntaxHighlightingConfig,
883) -> Option<Vec<Vec<(Style, String)>>> {
884 let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
885 if max_bytes > 0 && code.len() > max_bytes {
886 return None;
887 }
888
889 if let Some(lang) = language
890 && !config.enabled_languages.is_empty()
891 {
892 let direct_match = config.enabled_languages.iter().any(|entry| entry.eq_ignore_ascii_case(lang));
893 if !direct_match {
894 let syntax_ref = syntax_highlight::find_syntax_by_token(lang);
895 let resolved_match = config
896 .enabled_languages
897 .iter()
898 .any(|entry| entry.eq_ignore_ascii_case(&syntax_ref.name));
899 if !resolved_match {
900 return None;
901 }
902 }
903 }
904
905 let rendered = syntax_highlight::highlight_code_to_anstyle_line_segments(code, language, &config.theme, true);
906
907 Some(rendered)
908}
909
910#[derive(Clone, Debug)]
911pub struct HighlightedSegment {
912 style: Style,
913 text: String,
914}
915
916pub fn highlight_code_to_segments(
917 code: &str,
918 language: Option<&str>,
919 theme_name: &str,
920) -> Vec<Vec<HighlightedSegment>> {
921 syntax_highlight::highlight_code_to_anstyle_line_segments(code, language, theme_name, true)
922 .into_iter()
923 .map(|segments| {
924 segments
925 .into_iter()
926 .map(|(style, text)| HighlightedSegment { style, text })
927 .collect()
928 })
929 .collect()
930}
931
932pub fn highlight_code_to_ansi(code: &str, language: Option<&str>, theme_name: &str) -> Vec<String> {
933 let segments = highlight_code_to_segments(code, language, theme_name);
934 segments
935 .into_iter()
936 .map(|line_segments| {
937 let mut ansi_line = String::new();
938 for seg in line_segments {
939 let rendered = seg.style.render();
940 let _ = write!(ansi_line, "{rendered}{}{}", seg.text, anstyle::Reset);
941 }
942 ansi_line
943 })
944 .collect()
945}