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::{
8    ANSI_CODE_CYAN_BOLD, ANSI_CODE_RED_BOLD, ANSI_CODE_YELLOW_BOLD, ColorScheme, Language, Lines,
9    apply_line_highlights, extract_highlights,
10};
11
12/// Compute the display width of a string slice in terminal columns.
13///
14/// Uses Unicode UAX #11 East Asian Width to assign widths: most characters are
15/// 1 column, CJK ideographs and many emoji are 2 columns. Control characters
16/// and zero-width joiners are 0 columns.
17fn str_display_width(s: &str) -> usize {
18    s.chars().map(|c| c.width().unwrap_or(0)).sum()
19}
20
21/// Compute the display width of the text in `line` between two byte offsets
22/// (clamped and snapped to char boundaries).
23fn 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/// A source location with line and column.
35///
36/// Both `line` and `column` are **1-indexed**. A value of 0 for either is
37/// considered a caller bug and will produce an error.
38#[derive(Debug, Clone, Copy, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct Location {
41    /// 1-indexed line number.
42    pub line: usize,
43    /// 1-indexed column as a byte offset into the line. `None` means no
44    /// column highlighting — only the line itself is highlighted.
45    #[serde(default)]
46    pub column: Option<usize>,
47}
48
49/// Location information for the error in the source code.
50#[derive(Debug, Clone, Copy, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct CodeFrameLocation {
53    /// Starting location
54    pub start: Location,
55    /// Optional ending location (line inclusive, column half-open)
56    pub end: Option<Location>,
57}
58
59/// The severity of the message (default: "error"), influences the color mode.
60#[derive(Debug, Copy, Clone, Deserialize, PartialEq, Eq)]
61pub enum CodeFrameColorMode {
62    None,
63    Error,
64    Warning,
65    Info,
66}
67
68/// Options for rendering the code frame
69#[derive(Debug, Clone, Deserialize)]
70#[serde(rename_all = "camelCase", default)]
71pub struct CodeFrameOptions {
72    /// Number of lines to show before the error
73    pub lines_above: usize,
74    /// Number of lines to show after the error
75    pub lines_below: usize,
76    /// Whether to use ANSI color output
77    pub color: CodeFrameColorMode,
78    /// Whether to attempt syntax highlighting
79    pub highlight_code: bool,
80    /// Optional message to display with the error
81    pub message: Option<String>,
82    /// Maximum width for the output in columns. Callers should set this to
83    /// the actual display width (e.g., `process.stdout.columns` on the JS
84    /// side, or a hard-coded value for browser display).
85    pub max_width: usize,
86    /// Language hint for keyword highlighting
87    #[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
105/// Result of applying line truncation.
106/// All offsets are in byte space.
107struct TruncationResult {
108    /// The visible content after truncation (may include "..." prefix/suffix)
109    visible_content: String,
110    /// The byte offset in the original line where visible source content starts
111    byte_offset: usize,
112    /// The byte length of any prefix prepended before source content (e.g., "..." = 3)
113    prefix_len: usize,
114}
115
116/// Convert a source-column range (byte offsets) to display coordinates,
117/// accounting for line truncation, Unicode display widths, and available width.
118///
119/// `line_content` is the original (untruncated) line text used to convert byte
120/// offsets into display column widths.
121///
122/// Returns `(display_col, display_length)` where `display_col` is the
123/// number of leading spaces before the `^` markers.
124fn 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    // Convert byte offsets to display widths using the line content.
143    // col_start/col_end are 1-indexed byte offsets (exclusive end).
144    // byte_start_0 is the 0-indexed byte position of the marker start.
145    // col_start as an exclusive byte end = the first byte of the marker.
146    let byte_start_0 = (col_start - 1).min(line_len);
147    let byte_end_0 = (col_end - 1).min(line_len);
148
149    // Width of text between truncation point and marker start
150    let display_before_marker =
151        display_width_between(line_content, truncation_offset, byte_start_0);
152    // Width of the marked span
153    let mut display_marker_width = display_width_between(line_content, byte_start_0, byte_end_0);
154
155    // If the end column extends past the line, each overflow position adds 1
156    // display column (matching the old byte-arithmetic behavior for the
157    // "one past end" caret position).
158    if col_end - 1 > line_len {
159        display_marker_width += (col_end - 1) - line_len;
160    }
161    // If start is also past the end, fall back to byte arithmetic
162    if col_start > line_len {
163        display_marker_width = (col_end - col_start).max(1);
164    }
165
166    // Map source column to display column, accounting for "..." prefix
167    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        // +1 because the marker line starts with a space after the gutter
175        display_before_marker + 1
176    };
177
178    // Marker length: at least 1 caret, clamped to available width
179    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
186/// Renders a code frame showing the location of an error in source code.
187///
188/// Returns `Ok(None)` when the location is out of range (e.g., the source is
189/// empty or the start line exceeds the number of lines). This lets callers
190/// distinguish "no code frame to show" from a genuine rendering error.
191pub fn render_code_frame(
192    source: &str,
193    location: &CodeFrameLocation,
194    options: &CodeFrameOptions,
195) -> Result<Option<String>> {
196    // ── Validate and normalize the location ──────────────────────────────
197    //
198    // All line/column values are 1-indexed on input. We convert to
199    // 0-indexed line indices here and validate that the location is
200    // coherent. Invalid or out-of-range locations return `None` rather
201    // than erroring — the source may have changed since the error was
202    // captured (e.g., a racing file edit).
203
204    // Lines and columns must be >0 (1-indexed). A value of 0 is a caller bug.
205    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    // Convert 1-indexed line to 0-indexed.
225    let start_line_idx = location.start.line - 1;
226
227    // Start column (None = no column highlighting, just the line)
228    let start_column = location.start.column;
229
230    // Compute a generous end line for the windowed scan. We don't know the
231    // total line count yet, but we need an upper bound for the window.
232    // Clamp to at least start_line_idx so that degenerate locations
233    // (end.line < start.line) don't shrink the window below the start.
234    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    // Build a windowed line index that only stores offsets for the visible
240    // window (plus margin for the skip-scan heuristic). This avoids the
241    // O(file_size) cost of scanning every line in large files.
242    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        // Start line is past the end of the file — skew between error and code
249        return Ok(None);
250    }
251
252    // Normalize end location: clamp to valid range and ensure end >= start.
253    // If the end location is before the start (invalid input), fall back to
254    // a single-point marker at the start position.
255    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                // End is before start — treat as single-point marker
268                (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    // Calculate window of lines to show (0-indexed, last is exclusive)
277    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    // Format: "> N | code" or "  N | code"
284    // That's: 2 (marker + space) + gutter_width + SEPARATOR.len()
285    let gutter_total_width = 2 + gutter_width + SEPARATOR.len();
286    let available_code_width = max_width.saturating_sub(gutter_total_width);
287
288    // Not enough room to show meaningful code — skip the frame.
289    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    // Track whether we need a newline before the next section.
322    // By prepending newlines instead of appending them we avoid a
323    // trailing newline that callers would have to strip.
324    let mut needs_newline = false;
325
326    // Add message if provided and no column specified
327    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        // Apply consistent truncation to all lines (all offsets in bytes)
343        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        // Separate from previous line/section
361        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        // Add marker line if this is an error line with column info
383        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            // Determine which columns to underline on this error line
388            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) // intermediate line: underline everything
396            };
397
398            // Clamp to line bounds (1-indexed)
399            let col_start = col_start.min(line_len + 1);
400            let col_end = col_end.min(line_len + 2);
401
402            // project into display space
403            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 = " | ";
437/// Display offset for content after an ellipsis prefix
438const ELLIPSIS_DISPLAY_OFFSET: usize = ELLIPSIS.len() + 1;
439
440/// Calculate the truncation offset (in bytes) for all lines in the window.
441/// This ensures all lines are "scrolled" to the same horizontal position, centering the error
442/// range. Column values are byte offsets; width comparisons use display widths.
443fn 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    // Check if any line in the window needs truncation (using display width)
451    let needs_truncation = window
452        .clone()
453        .any(|i| str_display_width(lines.content(i)) > available_width);
454
455    // All lines are short enough or we don't have an error column so start at beginning
456    if !needs_truncation || start_column == 0 {
457        return 0;
458    }
459
460    // If we need truncation, center the error range
461    // We need to account for the "..." ellipsis (3 chars) on each side
462    let available_with_ellipsis = available_width.saturating_sub(2 * ELLIPSIS.len());
463
464    // Calculate the midpoint of the error range
465    // end_column is exclusive, so the range is [start_column, end_column)
466    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    // Try to center the error range in the window
471    let half_width = available_with_ellipsis / 2;
472
473    error_midpoint.saturating_sub(half_width)
474}
475
476/// Truncate a line at a specific byte offset, adding ellipsis as needed.
477/// The `offset` is snapped forward to the nearest UTF-8 character boundary
478/// to avoid splitting multi-byte characters. `max_width` is in display columns.
479fn truncate_line(line: &str, offset: usize, max_width: usize) -> TruncationResult {
480    // If no offset and line fits, return as-is (using display width)
481    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    // Snap offset to nearest char boundary (forward)
490    let byte_offset = line.ceil_char_boundary(offset);
491
492    let mut result = String::with_capacity(max_width);
493
494    // Add leading ellipsis if we're starting mid-line
495    let prefix_len = if byte_offset > 0 {
496        result.push_str(ELLIPSIS);
497        ELLIPSIS.len()
498    } else {
499        0
500    };
501
502    // Calculate how many display columns are available for content
503    let available_content_width = if byte_offset > 0 {
504        max_width.saturating_sub(ELLIPSIS.len())
505    } else {
506        max_width
507    };
508
509    // Check if offset is past line length
510    let remaining_line = if byte_offset < line.len() {
511        &line[byte_offset..]
512    } else {
513        // Offset is past line length - show just ellipsis
514        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    // Walk characters until we reach the target display width
530    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}