1use std::{fmt::Write, ops::Range};
2
3use anyhow::{Result, bail};
4use serde::Deserialize;
5use unicode_width::UnicodeWidthChar;
6
7use crate::highlight::{ColorScheme, Language, Lines, apply_line_highlights, extract_highlights};
8
9fn str_display_width(s: &str) -> usize {
15 s.chars().map(|c| c.width().unwrap_or(0)).sum()
16}
17
18fn display_width_between(line: &str, byte_start: usize, byte_end: usize) -> usize {
21 let start = line.len().min(byte_start);
22 let start = line.ceil_char_boundary(start);
23 let end = line.len().min(byte_end);
24 let end = line.floor_char_boundary(end);
25 if start >= end {
26 return 0;
27 }
28 str_display_width(&line[start..end])
29}
30
31#[derive(Debug, Clone, Copy, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Location {
38 pub line: usize,
40 #[serde(default)]
43 pub column: Option<usize>,
44}
45
46#[derive(Debug, Clone, Copy, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct CodeFrameLocation {
50 pub start: Location,
52 pub end: Option<Location>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
58#[serde(rename_all = "camelCase", default)]
59pub struct CodeFrameOptions {
60 pub lines_above: usize,
62 pub lines_below: usize,
64 pub color: bool,
66 pub highlight_code: bool,
68 pub message: Option<String>,
70 pub max_width: usize,
74 #[serde(default)]
76 pub language: Language,
77}
78
79impl Default for CodeFrameOptions {
80 fn default() -> Self {
81 Self {
82 lines_above: 2,
83 lines_below: 3,
84 color: false,
85 highlight_code: false,
86 message: None,
87 max_width: 100,
88 language: Language::default(),
89 }
90 }
91}
92
93struct TruncationResult {
96 visible_content: String,
98 byte_offset: usize,
100 prefix_len: usize,
102}
103
104fn marker_display_position(
113 line_content: &str,
114 col_start: usize,
115 col_end: usize,
116 truncation_offset: usize,
117 available_width: usize,
118) -> (usize, usize) {
119 debug_assert!(
120 col_start >= 1,
121 "col_start should be 1-indexed, got {col_start}"
122 );
123 debug_assert!(
124 col_start < col_end,
125 "col_start ({col_start}) must be less than col_end ({col_end})"
126 );
127
128 let line_len = line_content.len();
129
130 let byte_start_0 = (col_start - 1).min(line_len);
135 let byte_end_0 = (col_end - 1).min(line_len);
136
137 let display_before_marker =
139 display_width_between(line_content, truncation_offset, byte_start_0);
140 let mut display_marker_width = display_width_between(line_content, byte_start_0, byte_end_0);
142
143 if col_end - 1 > line_len {
147 display_marker_width += (col_end - 1) - line_len;
148 }
149 if col_start > line_len {
151 display_marker_width = (col_end - col_start).max(1);
152 }
153
154 let display_col = if truncation_offset > 0 {
156 if col_start <= truncation_offset {
157 ELLIPSIS_DISPLAY_OFFSET
158 } else {
159 display_before_marker + ELLIPSIS_DISPLAY_OFFSET
160 }
161 } else {
162 display_before_marker + 1
164 };
165
166 let length = display_marker_width
168 .max(1)
169 .min(available_width.saturating_sub(display_col.saturating_sub(1)));
170
171 (display_col, length)
172}
173
174pub fn render_code_frame(
180 source: &str,
181 location: &CodeFrameLocation,
182 options: &CodeFrameOptions,
183) -> Result<Option<String>> {
184 if location.start.line == 0 {
194 bail!("start.line must be 1-indexed (got 0)");
195 }
196 if let Some(0) = location.start.column {
197 bail!("start.column must be 1-indexed (got 0)");
198 }
199 if let Some(end) = location.end {
200 if end.line == 0 {
201 bail!("end.line must be 1-indexed (got 0)");
202 }
203 if let Some(0) = end.column {
204 bail!("end.column must be 1-indexed (got 0)");
205 }
206 }
207
208 if source.is_empty() {
209 return Ok(None);
210 }
211
212 let start_line_idx = location.start.line - 1;
214
215 let start_column = location.start.column;
217
218 let max_end_line = location
223 .end
224 .map(|e| (e.line - 1).max(start_line_idx))
225 .unwrap_or(start_line_idx);
226
227 let first_line_idx = start_line_idx.saturating_sub(options.lines_above);
231 let last_line_idx_upper = max_end_line + options.lines_below + 1;
232 let lines = Lines::windowed(source, first_line_idx, last_line_idx_upper);
233 let line_count = lines.len().get();
234
235 if start_line_idx >= line_count {
236 return Ok(None);
238 }
239
240 let (end_line_idx, end_column) = match location.end {
244 Some(end) => {
245 let end_line = (end.line - 1).min(line_count - 1);
246 let end_col = end.column.or(start_column.map(|c| c + 1));
247
248 let end_before_start = end_line < start_line_idx
249 || (end_line == start_line_idx
250 && end_col.is_some()
251 && start_column.is_some()
252 && end_col.unwrap() <= start_column.unwrap());
253
254 if end_before_start {
255 (start_line_idx, start_column.map(|c| c + 1))
257 } else {
258 (end_line, end_col)
259 }
260 }
261 None => (start_line_idx, start_column.map(|c| c + 1)),
262 };
263
264 let last_line_idx = (end_line_idx + options.lines_below + 1).min(line_count);
266
267 let gutter_width = last_line_idx.ilog10() as usize + 1;
268
269 let max_width = options.max_width;
270
271 let gutter_total_width = 2 + gutter_width + SEPARATOR.len();
274 let available_code_width = max_width.saturating_sub(gutter_total_width);
275
276 const MIN_CODE_WIDTH: usize = 20;
278 if available_code_width < MIN_CODE_WIDTH {
279 return Ok(None);
280 }
281
282 let truncation_offset = calculate_truncation_offset(
283 &lines,
284 first_line_idx..last_line_idx,
285 start_column.unwrap_or(0),
286 end_column.unwrap_or(0),
287 available_code_width,
288 );
289
290 let line_highlights = if options.color && options.highlight_code {
291 Some(extract_highlights(
292 &lines,
293 first_line_idx..last_line_idx,
294 options.language,
295 Some((truncation_offset, available_code_width)),
296 ))
297 } else {
298 None
299 };
300
301 let color_scheme = if options.color {
302 ColorScheme::colored()
303 } else {
304 ColorScheme::plain()
305 };
306 let mut output = String::new();
307 let mut needs_newline = false;
311
312 if let Some(ref message) = options.message
314 && start_column.is_none()
315 {
316 output.extend(std::iter::repeat_n(' ', gutter_total_width));
317 output.push_str(color_scheme.message);
318 output.push_str(message);
319 output.push_str(color_scheme.reset);
320 needs_newline = true;
321 }
322
323 for line_idx in first_line_idx..last_line_idx {
324 let line_content = lines.content(line_idx);
325 let is_error_line = line_idx >= start_line_idx && line_idx <= end_line_idx;
326 let line_num = line_idx + 1;
327
328 let truncation = truncate_line(line_content, truncation_offset, available_code_width);
330
331 let visible_content = if let Some(highlight) = line_highlights
332 .as_ref()
333 .and_then(|h| h.get(line_idx - first_line_idx))
334 {
335 apply_line_highlights(
336 &truncation.visible_content,
337 highlight,
338 &color_scheme,
339 truncation.byte_offset,
340 truncation.prefix_len,
341 )
342 } else {
343 truncation.visible_content
344 };
345
346 if needs_newline {
348 output.push('\n');
349 }
350 needs_newline = true;
351
352 if is_error_line {
353 output.push_str(color_scheme.marker);
354 output.push('>');
355 output.push_str(color_scheme.reset);
356 } else {
357 output.push(' ');
358 }
359 output.push(' ');
360 output.push_str(color_scheme.gutter);
361 write!(output, "{:>width$} |", line_num, width = gutter_width).unwrap();
362 output.push_str(color_scheme.reset);
363 if !visible_content.is_empty() {
364 output.push(' ');
365 output.push_str(&visible_content);
366 }
367
368 if is_error_line && let Some(start_col) = start_column {
370 let end_col = end_column.unwrap_or(start_col + 1);
371 let line_len = line_content.len();
372
373 let (col_start, col_end) = if start_line_idx == end_line_idx {
375 (start_col, end_col)
376 } else if line_idx == start_line_idx {
377 (start_col, line_len)
378 } else if line_idx == end_line_idx {
379 (1, end_col)
380 } else {
381 (1, line_len + 1) };
383
384 let col_start = col_start.min(line_len + 1);
386 let col_end = col_end.min(line_len + 2);
387
388 let (marker_col, marker_length) = marker_display_position(
390 line_content,
391 col_start,
392 col_end,
393 truncation.byte_offset,
394 available_code_width,
395 );
396
397 output.push_str("\n ");
398 output.push_str(color_scheme.gutter);
399 write!(output, "{:>width$} |", "", width = gutter_width).unwrap();
400
401 output.push_str(color_scheme.reset);
402 output.extend(std::iter::repeat_n(' ', marker_col));
403 output.push_str(color_scheme.marker);
404 output.extend(std::iter::repeat_n('^', marker_length));
405 output.push_str(color_scheme.reset);
406
407 if line_idx == end_line_idx
408 && let Some(ref message) = options.message
409 {
410 output.push(' ');
411 output.push_str(color_scheme.message);
412 output.push_str(message);
413 output.push_str(color_scheme.reset);
414 }
415 }
416 }
417
418 Ok(Some(output))
419}
420
421const ELLIPSIS: &str = "...";
422const SEPARATOR: &str = " | ";
423const ELLIPSIS_DISPLAY_OFFSET: usize = ELLIPSIS.len() + 1;
425
426fn calculate_truncation_offset(
430 lines: &Lines<'_>,
431 window: Range<usize>,
432 start_column: usize,
433 end_column: usize,
434 available_width: usize,
435) -> usize {
436 let needs_truncation = window
438 .clone()
439 .any(|i| str_display_width(lines.content(i)) > available_width);
440
441 if !needs_truncation || start_column == 0 {
443 return 0;
444 }
445
446 let available_with_ellipsis = available_width.saturating_sub(2 * ELLIPSIS.len());
449
450 let start_0idx = start_column.saturating_sub(1);
453 let end_0idx = end_column.saturating_sub(1);
454 let error_midpoint = (start_0idx + end_0idx) / 2;
455
456 let half_width = available_with_ellipsis / 2;
458
459 error_midpoint.saturating_sub(half_width)
460}
461
462fn truncate_line(line: &str, offset: usize, max_width: usize) -> TruncationResult {
466 if offset == 0 && str_display_width(line) <= max_width {
468 return TruncationResult {
469 visible_content: line.to_string(),
470 byte_offset: 0,
471 prefix_len: 0,
472 };
473 }
474
475 let byte_offset = line.ceil_char_boundary(offset);
477
478 let mut result = String::with_capacity(max_width);
479
480 let prefix_len = if byte_offset > 0 {
482 result.push_str(ELLIPSIS);
483 ELLIPSIS.len()
484 } else {
485 0
486 };
487
488 let available_content_width = if byte_offset > 0 {
490 max_width.saturating_sub(ELLIPSIS.len())
491 } else {
492 max_width
493 };
494
495 let remaining_line = if byte_offset < line.len() {
497 &line[byte_offset..]
498 } else {
499 return TruncationResult {
501 visible_content: ELLIPSIS.to_string(),
502 byte_offset,
503 prefix_len: ELLIPSIS.len(),
504 };
505 };
506
507 let remaining_display_width = str_display_width(remaining_line);
508 let needs_trailing_ellipsis = remaining_display_width > available_content_width;
509 let target_width = if needs_trailing_ellipsis {
510 available_content_width.saturating_sub(ELLIPSIS.len())
511 } else {
512 available_content_width
513 };
514
515 let mut cumulative_width = 0;
517 let mut visible_end = 0;
518 for (i, c) in remaining_line.char_indices() {
519 let char_width = c.width().unwrap_or(0);
520 if cumulative_width + char_width > target_width {
521 break;
522 }
523 cumulative_width += char_width;
524 visible_end = i + c.len_utf8();
525 }
526
527 result.push_str(&remaining_line[..visible_end]);
528
529 if needs_trailing_ellipsis {
530 result.push_str(ELLIPSIS);
531 }
532
533 TruncationResult {
534 visible_content: result,
535 byte_offset,
536 prefix_len,
537 }
538}