1use std::{fmt::Write, ops::Range};
2
3use anyhow::{Result, bail};
4use serde::Deserialize;
5use unicode_width::UnicodeWidthChar;
6
7use crate::highlight::{
8 ANSI_CODE_CYAN_BOLD, ANSI_CODE_RED_BOLD, ANSI_CODE_YELLOW_BOLD, ColorScheme, Language, Lines,
9 apply_line_highlights, extract_highlights,
10};
11
12fn str_display_width(s: &str) -> usize {
18 s.chars().map(|c| c.width().unwrap_or(0)).sum()
19}
20
21fn display_width_between(line: &str, byte_start: usize, byte_end: usize) -> usize {
24 let start = line.len().min(byte_start);
25 let start = line.ceil_char_boundary(start);
26 let end = line.len().min(byte_end);
27 let end = line.floor_char_boundary(end);
28 if start >= end {
29 return 0;
30 }
31 str_display_width(&line[start..end])
32}
33
34#[derive(Debug, Clone, Copy, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct Location {
41 pub line: usize,
43 #[serde(default)]
46 pub column: Option<usize>,
47}
48
49#[derive(Debug, Clone, Copy, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct CodeFrameLocation {
53 pub start: Location,
55 pub end: Option<Location>,
57}
58
59#[derive(Debug, Copy, Clone, Deserialize, PartialEq, Eq)]
61pub enum CodeFrameColorMode {
62 None,
63 Error,
64 Warning,
65 Info,
66}
67
68#[derive(Debug, Clone, Deserialize)]
70#[serde(rename_all = "camelCase", default)]
71pub struct CodeFrameOptions {
72 pub lines_above: usize,
74 pub lines_below: usize,
76 pub color: CodeFrameColorMode,
78 pub highlight_code: bool,
80 pub message: Option<String>,
82 pub max_width: usize,
86 #[serde(default)]
88 pub language: Language,
89}
90
91impl Default for CodeFrameOptions {
92 fn default() -> Self {
93 Self {
94 lines_above: 2,
95 lines_below: 3,
96 color: CodeFrameColorMode::None,
97 highlight_code: false,
98 message: None,
99 max_width: 100,
100 language: Language::default(),
101 }
102 }
103}
104
105struct TruncationResult {
108 visible_content: String,
110 byte_offset: usize,
112 prefix_len: usize,
114}
115
116fn marker_display_position(
125 line_content: &str,
126 col_start: usize,
127 col_end: usize,
128 truncation_offset: usize,
129 available_width: usize,
130) -> (usize, usize) {
131 debug_assert!(
132 col_start >= 1,
133 "col_start should be 1-indexed, got {col_start}"
134 );
135 debug_assert!(
136 col_start < col_end,
137 "col_start ({col_start}) must be less than col_end ({col_end})"
138 );
139
140 let line_len = line_content.len();
141
142 let byte_start_0 = (col_start - 1).min(line_len);
147 let byte_end_0 = (col_end - 1).min(line_len);
148
149 let display_before_marker =
151 display_width_between(line_content, truncation_offset, byte_start_0);
152 let mut display_marker_width = display_width_between(line_content, byte_start_0, byte_end_0);
154
155 if col_end - 1 > line_len {
159 display_marker_width += (col_end - 1) - line_len;
160 }
161 if col_start > line_len {
163 display_marker_width = (col_end - col_start).max(1);
164 }
165
166 let display_col = if truncation_offset > 0 {
168 if col_start <= truncation_offset {
169 ELLIPSIS_DISPLAY_OFFSET
170 } else {
171 display_before_marker + ELLIPSIS_DISPLAY_OFFSET
172 }
173 } else {
174 display_before_marker + 1
176 };
177
178 let length = display_marker_width
180 .max(1)
181 .min(available_width.saturating_sub(display_col.saturating_sub(1)));
182
183 (display_col, length)
184}
185
186pub fn render_code_frame(
192 source: &str,
193 location: &CodeFrameLocation,
194 options: &CodeFrameOptions,
195) -> Result<Option<String>> {
196 if location.start.line == 0 {
206 bail!("start.line must be 1-indexed (got 0)");
207 }
208 if let Some(0) = location.start.column {
209 bail!("start.column must be 1-indexed (got 0)");
210 }
211 if let Some(end) = location.end {
212 if end.line == 0 {
213 bail!("end.line must be 1-indexed (got 0)");
214 }
215 if let Some(0) = end.column {
216 bail!("end.column must be 1-indexed (got 0)");
217 }
218 }
219
220 if source.is_empty() {
221 return Ok(None);
222 }
223
224 let start_line_idx = location.start.line - 1;
226
227 let start_column = location.start.column;
229
230 let max_end_line = location
235 .end
236 .map(|e| (e.line - 1).max(start_line_idx))
237 .unwrap_or(start_line_idx);
238
239 let first_line_idx = start_line_idx.saturating_sub(options.lines_above);
243 let last_line_idx_upper = max_end_line + options.lines_below + 1;
244 let lines = Lines::windowed(source, first_line_idx, last_line_idx_upper);
245 let line_count = lines.len().get();
246
247 if start_line_idx >= line_count {
248 return Ok(None);
250 }
251
252 let (end_line_idx, end_column) = match location.end {
256 Some(end) => {
257 let end_line = (end.line - 1).min(line_count - 1);
258 let end_col = end.column.or(start_column.map(|c| c + 1));
259
260 let end_before_start = end_line < start_line_idx
261 || (end_line == start_line_idx
262 && end_col.is_some()
263 && start_column.is_some()
264 && end_col.unwrap() <= start_column.unwrap());
265
266 if end_before_start {
267 (start_line_idx, start_column.map(|c| c + 1))
269 } else {
270 (end_line, end_col)
271 }
272 }
273 None => (start_line_idx, start_column.map(|c| c + 1)),
274 };
275
276 let last_line_idx = (end_line_idx + options.lines_below + 1).min(line_count);
278
279 let gutter_width = last_line_idx.ilog10() as usize + 1;
280
281 let max_width = options.max_width;
282
283 let gutter_total_width = 2 + gutter_width + SEPARATOR.len();
286 let available_code_width = max_width.saturating_sub(gutter_total_width);
287
288 const MIN_CODE_WIDTH: usize = 20;
290 if available_code_width < MIN_CODE_WIDTH {
291 return Ok(None);
292 }
293
294 let truncation_offset = calculate_truncation_offset(
295 &lines,
296 first_line_idx..last_line_idx,
297 start_column.unwrap_or(0),
298 end_column.unwrap_or(0),
299 available_code_width,
300 );
301
302 let line_highlights = if options.color != CodeFrameColorMode::None && options.highlight_code {
303 Some(extract_highlights(
304 &lines,
305 first_line_idx..last_line_idx,
306 options.language,
307 Some((truncation_offset, available_code_width)),
308 ))
309 } else {
310 None
311 };
312
313 let color_scheme = match options.color {
314 CodeFrameColorMode::None => ColorScheme::plain(),
315 CodeFrameColorMode::Error => ColorScheme::colored(ANSI_CODE_RED_BOLD),
316 CodeFrameColorMode::Warning => ColorScheme::colored(ANSI_CODE_YELLOW_BOLD),
317 CodeFrameColorMode::Info => ColorScheme::colored(ANSI_CODE_CYAN_BOLD),
318 };
319
320 let mut output = String::new();
321 let mut needs_newline = false;
325
326 if let Some(ref message) = options.message
328 && start_column.is_none()
329 {
330 output.extend(std::iter::repeat_n(' ', gutter_total_width));
331 output.push_str(color_scheme.message);
332 output.push_str(message);
333 output.push_str(color_scheme.reset);
334 needs_newline = true;
335 }
336
337 for line_idx in first_line_idx..last_line_idx {
338 let line_content = lines.content(line_idx);
339 let is_error_line = line_idx >= start_line_idx && line_idx <= end_line_idx;
340 let line_num = line_idx + 1;
341
342 let truncation = truncate_line(line_content, truncation_offset, available_code_width);
344
345 let visible_content = if let Some(highlight) = line_highlights
346 .as_ref()
347 .and_then(|h| h.get(line_idx - first_line_idx))
348 {
349 apply_line_highlights(
350 &truncation.visible_content,
351 highlight,
352 &color_scheme,
353 truncation.byte_offset,
354 truncation.prefix_len,
355 )
356 } else {
357 truncation.visible_content
358 };
359
360 if needs_newline {
362 output.push('\n');
363 }
364 needs_newline = true;
365
366 if is_error_line {
367 output.push_str(color_scheme.marker);
368 output.push('>');
369 output.push_str(color_scheme.reset);
370 } else {
371 output.push(' ');
372 }
373 output.push(' ');
374 output.push_str(color_scheme.gutter);
375 write!(output, "{:>width$} |", line_num, width = gutter_width).unwrap();
376 output.push_str(color_scheme.reset);
377 if !visible_content.is_empty() {
378 output.push(' ');
379 output.push_str(&visible_content);
380 }
381
382 if is_error_line && let Some(start_col) = start_column {
384 let end_col = end_column.unwrap_or(start_col + 1);
385 let line_len = line_content.len();
386
387 let (col_start, col_end) = if start_line_idx == end_line_idx {
389 (start_col, end_col)
390 } else if line_idx == start_line_idx {
391 (start_col, line_len)
392 } else if line_idx == end_line_idx {
393 (1, end_col)
394 } else {
395 (1, line_len + 1) };
397
398 let col_start = col_start.min(line_len + 1);
400 let col_end = col_end.min(line_len + 2);
401
402 let (marker_col, marker_length) = marker_display_position(
404 line_content,
405 col_start,
406 col_end,
407 truncation.byte_offset,
408 available_code_width,
409 );
410
411 output.push_str("\n ");
412 output.push_str(color_scheme.gutter);
413 write!(output, "{:>width$} |", "", width = gutter_width).unwrap();
414
415 output.push_str(color_scheme.reset);
416 output.extend(std::iter::repeat_n(' ', marker_col));
417 output.push_str(color_scheme.marker);
418 output.extend(std::iter::repeat_n('^', marker_length));
419 output.push_str(color_scheme.reset);
420
421 if line_idx == end_line_idx
422 && let Some(ref message) = options.message
423 {
424 output.push(' ');
425 output.push_str(color_scheme.message);
426 output.push_str(message);
427 output.push_str(color_scheme.reset);
428 }
429 }
430 }
431
432 Ok(Some(output))
433}
434
435const ELLIPSIS: &str = "...";
436const SEPARATOR: &str = " | ";
437const ELLIPSIS_DISPLAY_OFFSET: usize = ELLIPSIS.len() + 1;
439
440fn calculate_truncation_offset(
444 lines: &Lines<'_>,
445 window: Range<usize>,
446 start_column: usize,
447 end_column: usize,
448 available_width: usize,
449) -> usize {
450 let needs_truncation = window
452 .clone()
453 .any(|i| str_display_width(lines.content(i)) > available_width);
454
455 if !needs_truncation || start_column == 0 {
457 return 0;
458 }
459
460 let available_with_ellipsis = available_width.saturating_sub(2 * ELLIPSIS.len());
463
464 let start_0idx = start_column.saturating_sub(1);
467 let end_0idx = end_column.saturating_sub(1);
468 let error_midpoint = (start_0idx + end_0idx) / 2;
469
470 let half_width = available_with_ellipsis / 2;
472
473 error_midpoint.saturating_sub(half_width)
474}
475
476fn truncate_line(line: &str, offset: usize, max_width: usize) -> TruncationResult {
480 if offset == 0 && str_display_width(line) <= max_width {
482 return TruncationResult {
483 visible_content: line.to_string(),
484 byte_offset: 0,
485 prefix_len: 0,
486 };
487 }
488
489 let byte_offset = line.ceil_char_boundary(offset);
491
492 let mut result = String::with_capacity(max_width);
493
494 let prefix_len = if byte_offset > 0 {
496 result.push_str(ELLIPSIS);
497 ELLIPSIS.len()
498 } else {
499 0
500 };
501
502 let available_content_width = if byte_offset > 0 {
504 max_width.saturating_sub(ELLIPSIS.len())
505 } else {
506 max_width
507 };
508
509 let remaining_line = if byte_offset < line.len() {
511 &line[byte_offset..]
512 } else {
513 return TruncationResult {
515 visible_content: ELLIPSIS.to_string(),
516 byte_offset,
517 prefix_len: ELLIPSIS.len(),
518 };
519 };
520
521 let remaining_display_width = str_display_width(remaining_line);
522 let needs_trailing_ellipsis = remaining_display_width > available_content_width;
523 let target_width = if needs_trailing_ellipsis {
524 available_content_width.saturating_sub(ELLIPSIS.len())
525 } else {
526 available_content_width
527 };
528
529 let mut cumulative_width = 0;
531 let mut visible_end = 0;
532 for (i, c) in remaining_line.char_indices() {
533 let char_width = c.width().unwrap_or(0);
534 if cumulative_width + char_width > target_width {
535 break;
536 }
537 cumulative_width += char_width;
538 visible_end = i + c.len_utf8();
539 }
540
541 result.push_str(&remaining_line[..visible_end]);
542
543 if needs_trailing_ellipsis {
544 result.push_str(ELLIPSIS);
545 }
546
547 TruncationResult {
548 visible_content: result,
549 byte_offset,
550 prefix_len,
551 }
552}