Skip to main content

next_code_frame/
frame.rs

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
9/// Compute the display width of a string slice in terminal columns.
10///
11/// Uses Unicode UAX #11 East Asian Width to assign widths: most characters are
12/// 1 column, CJK ideographs and many emoji are 2 columns. Control characters
13/// and zero-width joiners are 0 columns.
14fn str_display_width(s: &str) -> usize {
15    s.chars().map(|c| c.width().unwrap_or(0)).sum()
16}
17
18/// Compute the display width of the text in `line` between two byte offsets
19/// (clamped and snapped to char boundaries).
20fn 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/// A source location with line and column.
32///
33/// Both `line` and `column` are **1-indexed**. A value of 0 for either is
34/// considered a caller bug and will produce an error.
35#[derive(Debug, Clone, Copy, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Location {
38    /// 1-indexed line number.
39    pub line: usize,
40    /// 1-indexed column as a byte offset into the line. `None` means no
41    /// column highlighting — only the line itself is highlighted.
42    #[serde(default)]
43    pub column: Option<usize>,
44}
45
46/// Location information for the error in the source code.
47#[derive(Debug, Clone, Copy, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct CodeFrameLocation {
50    /// Starting location
51    pub start: Location,
52    /// Optional ending location (line inclusive, column half-open)
53    pub end: Option<Location>,
54}
55
56/// Options for rendering the code frame
57#[derive(Debug, Clone, Deserialize)]
58#[serde(rename_all = "camelCase", default)]
59pub struct CodeFrameOptions {
60    /// Number of lines to show before the error
61    pub lines_above: usize,
62    /// Number of lines to show after the error
63    pub lines_below: usize,
64    /// Whether to use ANSI color output
65    pub color: bool,
66    /// Whether to attempt syntax highlighting
67    pub highlight_code: bool,
68    /// Optional message to display with the error
69    pub message: Option<String>,
70    /// Maximum width for the output in columns. Callers should set this to
71    /// the actual display width (e.g., `process.stdout.columns` on the JS
72    /// side, or a hard-coded value for browser display).
73    pub max_width: usize,
74    /// Language hint for keyword highlighting
75    #[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
93/// Result of applying line truncation.
94/// All offsets are in byte space.
95struct TruncationResult {
96    /// The visible content after truncation (may include "..." prefix/suffix)
97    visible_content: String,
98    /// The byte offset in the original line where visible source content starts
99    byte_offset: usize,
100    /// The byte length of any prefix prepended before source content (e.g., "..." = 3)
101    prefix_len: usize,
102}
103
104/// Convert a source-column range (byte offsets) to display coordinates,
105/// accounting for line truncation, Unicode display widths, and available width.
106///
107/// `line_content` is the original (untruncated) line text used to convert byte
108/// offsets into display column widths.
109///
110/// Returns `(display_col, display_length)` where `display_col` is the
111/// number of leading spaces before the `^` markers.
112fn 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    // Convert byte offsets to display widths using the line content.
131    // col_start/col_end are 1-indexed byte offsets (exclusive end).
132    // byte_start_0 is the 0-indexed byte position of the marker start.
133    // col_start as an exclusive byte end = the first byte of the marker.
134    let byte_start_0 = (col_start - 1).min(line_len);
135    let byte_end_0 = (col_end - 1).min(line_len);
136
137    // Width of text between truncation point and marker start
138    let display_before_marker =
139        display_width_between(line_content, truncation_offset, byte_start_0);
140    // Width of the marked span
141    let mut display_marker_width = display_width_between(line_content, byte_start_0, byte_end_0);
142
143    // If the end column extends past the line, each overflow position adds 1
144    // display column (matching the old byte-arithmetic behavior for the
145    // "one past end" caret position).
146    if col_end - 1 > line_len {
147        display_marker_width += (col_end - 1) - line_len;
148    }
149    // If start is also past the end, fall back to byte arithmetic
150    if col_start > line_len {
151        display_marker_width = (col_end - col_start).max(1);
152    }
153
154    // Map source column to display column, accounting for "..." prefix
155    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        // +1 because the marker line starts with a space after the gutter
163        display_before_marker + 1
164    };
165
166    // Marker length: at least 1 caret, clamped to available width
167    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
174/// Renders a code frame showing the location of an error in source code.
175///
176/// Returns `Ok(None)` when the location is out of range (e.g., the source is
177/// empty or the start line exceeds the number of lines). This lets callers
178/// distinguish "no code frame to show" from a genuine rendering error.
179pub fn render_code_frame(
180    source: &str,
181    location: &CodeFrameLocation,
182    options: &CodeFrameOptions,
183) -> Result<Option<String>> {
184    // ── Validate and normalize the location ──────────────────────────────
185    //
186    // All line/column values are 1-indexed on input. We convert to
187    // 0-indexed line indices here and validate that the location is
188    // coherent. Invalid or out-of-range locations return `None` rather
189    // than erroring — the source may have changed since the error was
190    // captured (e.g., a racing file edit).
191
192    // Lines and columns must be >0 (1-indexed). A value of 0 is a caller bug.
193    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    // Convert 1-indexed line to 0-indexed.
213    let start_line_idx = location.start.line - 1;
214
215    // Start column (None = no column highlighting, just the line)
216    let start_column = location.start.column;
217
218    // Compute a generous end line for the windowed scan. We don't know the
219    // total line count yet, but we need an upper bound for the window.
220    // Clamp to at least start_line_idx so that degenerate locations
221    // (end.line < start.line) don't shrink the window below the start.
222    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    // Build a windowed line index that only stores offsets for the visible
228    // window (plus margin for the skip-scan heuristic). This avoids the
229    // O(file_size) cost of scanning every line in large files.
230    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        // Start line is past the end of the file — skew between error and code
237        return Ok(None);
238    }
239
240    // Normalize end location: clamp to valid range and ensure end >= start.
241    // If the end location is before the start (invalid input), fall back to
242    // a single-point marker at the start position.
243    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                // End is before start — treat as single-point marker
256                (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    // Calculate window of lines to show (0-indexed, last is exclusive)
265    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    // Format: "> N | code" or "  N | code"
272    // That's: 2 (marker + space) + gutter_width + SEPARATOR.len()
273    let gutter_total_width = 2 + gutter_width + SEPARATOR.len();
274    let available_code_width = max_width.saturating_sub(gutter_total_width);
275
276    // Not enough room to show meaningful code — skip the frame.
277    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    // Track whether we need a newline before the next section.
308    // By prepending newlines instead of appending them we avoid a
309    // trailing newline that callers would have to strip.
310    let mut needs_newline = false;
311
312    // Add message if provided and no column specified
313    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        // Apply consistent truncation to all lines (all offsets in bytes)
329        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        // Separate from previous line/section
347        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        // Add marker line if this is an error line with column info
369        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            // Determine which columns to underline on this error line
374            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) // intermediate line: underline everything
382            };
383
384            // Clamp to line bounds (1-indexed)
385            let col_start = col_start.min(line_len + 1);
386            let col_end = col_end.min(line_len + 2);
387
388            // project into display space
389            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 = " | ";
423/// Display offset for content after an ellipsis prefix
424const ELLIPSIS_DISPLAY_OFFSET: usize = ELLIPSIS.len() + 1;
425
426/// Calculate the truncation offset (in bytes) for all lines in the window.
427/// This ensures all lines are "scrolled" to the same horizontal position, centering the error
428/// range. Column values are byte offsets; width comparisons use display widths.
429fn 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    // Check if any line in the window needs truncation (using display width)
437    let needs_truncation = window
438        .clone()
439        .any(|i| str_display_width(lines.content(i)) > available_width);
440
441    // All lines are short enough or we don't have an error column so start at beginning
442    if !needs_truncation || start_column == 0 {
443        return 0;
444    }
445
446    // If we need truncation, center the error range
447    // We need to account for the "..." ellipsis (3 chars) on each side
448    let available_with_ellipsis = available_width.saturating_sub(2 * ELLIPSIS.len());
449
450    // Calculate the midpoint of the error range
451    // end_column is exclusive, so the range is [start_column, end_column)
452    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    // Try to center the error range in the window
457    let half_width = available_with_ellipsis / 2;
458
459    error_midpoint.saturating_sub(half_width)
460}
461
462/// Truncate a line at a specific byte offset, adding ellipsis as needed.
463/// The `offset` is snapped forward to the nearest UTF-8 character boundary
464/// to avoid splitting multi-byte characters. `max_width` is in display columns.
465fn truncate_line(line: &str, offset: usize, max_width: usize) -> TruncationResult {
466    // If no offset and line fits, return as-is (using display width)
467    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    // Snap offset to nearest char boundary (forward)
476    let byte_offset = line.ceil_char_boundary(offset);
477
478    let mut result = String::with_capacity(max_width);
479
480    // Add leading ellipsis if we're starting mid-line
481    let prefix_len = if byte_offset > 0 {
482        result.push_str(ELLIPSIS);
483        ELLIPSIS.len()
484    } else {
485        0
486    };
487
488    // Calculate how many display columns are available for content
489    let available_content_width = if byte_offset > 0 {
490        max_width.saturating_sub(ELLIPSIS.len())
491    } else {
492        max_width
493    };
494
495    // Check if offset is past line length
496    let remaining_line = if byte_offset < line.len() {
497        &line[byte_offset..]
498    } else {
499        // Offset is past line length - show just ellipsis
500        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    // Walk characters until we reach the target display width
516    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}