next.js/crates/next-code-frame/src/frame.rs
frame.rs539 lines19.0 KB
use std::{fmt::Write, ops::Range};

use anyhow::{Result, bail};
use serde::Deserialize;
use unicode_width::UnicodeWidthChar;

use crate::highlight::{ColorScheme, Language, Lines, apply_line_highlights, extract_highlights};

/// Compute the display width of a string slice in terminal columns.
///
/// Uses Unicode UAX #11 East Asian Width to assign widths: most characters are
/// 1 column, CJK ideographs and many emoji are 2 columns. Control characters
/// and zero-width joiners are 0 columns.
fn str_display_width(s: &str) -> usize {
    s.chars().map(|c| c.width().unwrap_or(0)).sum()
}

/// Compute the display width of the text in `line` between two byte offsets
/// (clamped and snapped to char boundaries).
fn display_width_between(line: &str, byte_start: usize, byte_end: usize) -> usize {
    let start = line.len().min(byte_start);
    let start = line.ceil_char_boundary(start);
    let end = line.len().min(byte_end);
    let end = line.floor_char_boundary(end);
    if start >= end {
        return 0;
    }
    str_display_width(&line[start..end])
}

/// A source location with line and column.
///
/// Both `line` and `column` are **1-indexed**. A value of 0 for either is
/// considered a caller bug and will produce an error.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Location {
    /// 1-indexed line number.
    pub line: usize,
    /// 1-indexed column as a byte offset into the line. `None` means no
    /// column highlighting — only the line itself is highlighted.
    #[serde(default)]
    pub column: Option<usize>,
}

/// Location information for the error in the source code.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeFrameLocation {
    /// Starting location
    pub start: Location,
    /// Optional ending location (line inclusive, column half-open)
    pub end: Option<Location>,
}

/// Options for rendering the code frame
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CodeFrameOptions {
    /// Number of lines to show before the error
    pub lines_above: usize,
    /// Number of lines to show after the error
    pub lines_below: usize,
    /// Whether to use ANSI color output
    pub color: bool,
    /// Whether to attempt syntax highlighting
    pub highlight_code: bool,
    /// Optional message to display with the error
    pub message: Option<String>,
    /// Maximum width for the output in columns. Callers should set this to
    /// the actual display width (e.g., `process.stdout.columns` on the JS
    /// side, or a hard-coded value for browser display).
    pub max_width: usize,
    /// Language hint for keyword highlighting
    #[serde(default)]
    pub language: Language,
}

impl Default for CodeFrameOptions {
    fn default() -> Self {
        Self {
            lines_above: 2,
            lines_below: 3,
            color: false,
            highlight_code: false,
            message: None,
            max_width: 100,
            language: Language::default(),
        }
    }
}

/// Result of applying line truncation.
/// All offsets are in byte space.
struct TruncationResult {
    /// The visible content after truncation (may include "..." prefix/suffix)
    visible_content: String,
    /// The byte offset in the original line where visible source content starts
    byte_offset: usize,
    /// The byte length of any prefix prepended before source content (e.g., "..." = 3)
    prefix_len: usize,
}

/// Convert a source-column range (byte offsets) to display coordinates,
/// accounting for line truncation, Unicode display widths, and available width.
///
/// `line_content` is the original (untruncated) line text used to convert byte
/// offsets into display column widths.
///
/// Returns `(display_col, display_length)` where `display_col` is the
/// number of leading spaces before the `^` markers.
fn marker_display_position(
    line_content: &str,
    col_start: usize,
    col_end: usize,
    truncation_offset: usize,
    available_width: usize,
) -> (usize, usize) {
    debug_assert!(
        col_start >= 1,
        "col_start should be 1-indexed, got {col_start}"
    );
    debug_assert!(
        col_start < col_end,
        "col_start ({col_start}) must be less than col_end ({col_end})"
    );

    let line_len = line_content.len();

    // Convert byte offsets to display widths using the line content.
    // col_start/col_end are 1-indexed byte offsets (exclusive end).
    // byte_start_0 is the 0-indexed byte position of the marker start.
    // col_start as an exclusive byte end = the first byte of the marker.
    let byte_start_0 = (col_start - 1).min(line_len);
    let byte_end_0 = (col_end - 1).min(line_len);

    // Width of text between truncation point and marker start
    let display_before_marker =
        display_width_between(line_content, truncation_offset, byte_start_0);
    // Width of the marked span
    let mut display_marker_width = display_width_between(line_content, byte_start_0, byte_end_0);

    // If the end column extends past the line, each overflow position adds 1
    // display column (matching the old byte-arithmetic behavior for the
    // "one past end" caret position).
    if col_end - 1 > line_len {
        display_marker_width += (col_end - 1) - line_len;
    }
    // If start is also past the end, fall back to byte arithmetic
    if col_start > line_len {
        display_marker_width = (col_end - col_start).max(1);
    }

    // Map source column to display column, accounting for "..." prefix
    let display_col = if truncation_offset > 0 {
        if col_start <= truncation_offset {
            ELLIPSIS_DISPLAY_OFFSET
        } else {
            display_before_marker + ELLIPSIS_DISPLAY_OFFSET
        }
    } else {
        // +1 because the marker line starts with a space after the gutter
        display_before_marker + 1
    };

    // Marker length: at least 1 caret, clamped to available width
    let length = display_marker_width
        .max(1)
        .min(available_width.saturating_sub(display_col.saturating_sub(1)));

    (display_col, length)
}

/// Renders a code frame showing the location of an error in source code.
///
/// Returns `Ok(None)` when the location is out of range (e.g., the source is
/// empty or the start line exceeds the number of lines). This lets callers
/// distinguish "no code frame to show" from a genuine rendering error.
pub fn render_code_frame(
    source: &str,
    location: &CodeFrameLocation,
    options: &CodeFrameOptions,
) -> Result<Option<String>> {
    // ── Validate and normalize the location ──────────────────────────────
    //
    // All line/column values are 1-indexed on input. We convert to
    // 0-indexed line indices here and validate that the location is
    // coherent. Invalid or out-of-range locations return `None` rather
    // than erroring — the source may have changed since the error was
    // captured (e.g., a racing file edit).

    // Lines and columns must be >0 (1-indexed). A value of 0 is a caller bug.
    if location.start.line == 0 {
        bail!("start.line must be 1-indexed (got 0)");
    }
    if let Some(0) = location.start.column {
        bail!("start.column must be 1-indexed (got 0)");
    }
    if let Some(end) = location.end {
        if end.line == 0 {
            bail!("end.line must be 1-indexed (got 0)");
        }
        if let Some(0) = end.column {
            bail!("end.column must be 1-indexed (got 0)");
        }
    }

    if source.is_empty() {
        return Ok(None);
    }

    // Convert 1-indexed line to 0-indexed.
    let start_line_idx = location.start.line - 1;

    // Start column (None = no column highlighting, just the line)
    let start_column = location.start.column;

    // Compute a generous end line for the windowed scan. We don't know the
    // total line count yet, but we need an upper bound for the window.
    // Clamp to at least start_line_idx so that degenerate locations
    // (end.line < start.line) don't shrink the window below the start.
    let max_end_line = location
        .end
        .map(|e| (e.line - 1).max(start_line_idx))
        .unwrap_or(start_line_idx);

    // Build a windowed line index that only stores offsets for the visible
    // window (plus margin for the skip-scan heuristic). This avoids the
    // O(file_size) cost of scanning every line in large files.
    let first_line_idx = start_line_idx.saturating_sub(options.lines_above);
    let last_line_idx_upper = max_end_line + options.lines_below + 1;
    let lines = Lines::windowed(source, first_line_idx, last_line_idx_upper);
    let line_count = lines.len().get();

    if start_line_idx >= line_count {
        // Start line is past the end of the file — skew between error and code
        return Ok(None);
    }

    // Normalize end location: clamp to valid range and ensure end >= start.
    // If the end location is before the start (invalid input), fall back to
    // a single-point marker at the start position.
    let (end_line_idx, end_column) = match location.end {
        Some(end) => {
            let end_line = (end.line - 1).min(line_count - 1);
            let end_col = end.column.or(start_column.map(|c| c + 1));

            let end_before_start = end_line < start_line_idx
                || (end_line == start_line_idx
                    && end_col.is_some()
                    && start_column.is_some()
                    && end_col.unwrap() <= start_column.unwrap());

            if end_before_start {
                // End is before start — treat as single-point marker
                (start_line_idx, start_column.map(|c| c + 1))
            } else {
                (end_line, end_col)
            }
        }
        None => (start_line_idx, start_column.map(|c| c + 1)),
    };

    // Calculate window of lines to show (0-indexed, last is exclusive)
    let last_line_idx = (end_line_idx + options.lines_below + 1).min(line_count);

    let gutter_width = last_line_idx.ilog10() as usize + 1;

    let max_width = options.max_width;

    // Format: "> N | code" or "  N | code"
    // That's: 2 (marker + space) + gutter_width + SEPARATOR.len()
    let gutter_total_width = 2 + gutter_width + SEPARATOR.len();
    let available_code_width = max_width.saturating_sub(gutter_total_width);

    // Not enough room to show meaningful code — skip the frame.
    const MIN_CODE_WIDTH: usize = 20;
    if available_code_width < MIN_CODE_WIDTH {
        return Ok(None);
    }

    let truncation_offset = calculate_truncation_offset(
        &lines,
        first_line_idx..last_line_idx,
        start_column.unwrap_or(0),
        end_column.unwrap_or(0),
        available_code_width,
    );

    let line_highlights = if options.color && options.highlight_code {
        Some(extract_highlights(
            &lines,
            first_line_idx..last_line_idx,
            options.language,
            Some((truncation_offset, available_code_width)),
        ))
    } else {
        None
    };

    let color_scheme = if options.color {
        ColorScheme::colored()
    } else {
        ColorScheme::plain()
    };
    let mut output = String::new();
    // Track whether we need a newline before the next section.
    // By prepending newlines instead of appending them we avoid a
    // trailing newline that callers would have to strip.
    let mut needs_newline = false;

    // Add message if provided and no column specified
    if let Some(ref message) = options.message
        && start_column.is_none()
    {
        output.extend(std::iter::repeat_n(' ', gutter_total_width));
        output.push_str(color_scheme.message);
        output.push_str(message);
        output.push_str(color_scheme.reset);
        needs_newline = true;
    }

    for line_idx in first_line_idx..last_line_idx {
        let line_content = lines.content(line_idx);
        let is_error_line = line_idx >= start_line_idx && line_idx <= end_line_idx;
        let line_num = line_idx + 1;

        // Apply consistent truncation to all lines (all offsets in bytes)
        let truncation = truncate_line(line_content, truncation_offset, available_code_width);

        let visible_content = if let Some(highlight) = line_highlights
            .as_ref()
            .and_then(|h| h.get(line_idx - first_line_idx))
        {
            apply_line_highlights(
                &truncation.visible_content,
                highlight,
                &color_scheme,
                truncation.byte_offset,
                truncation.prefix_len,
            )
        } else {
            truncation.visible_content
        };

        // Separate from previous line/section
        if needs_newline {
            output.push('\n');
        }
        needs_newline = true;

        if is_error_line {
            output.push_str(color_scheme.marker);
            output.push('>');
            output.push_str(color_scheme.reset);
        } else {
            output.push(' ');
        }
        output.push(' ');
        output.push_str(color_scheme.gutter);
        write!(output, "{:>width$} |", line_num, width = gutter_width).unwrap();
        output.push_str(color_scheme.reset);
        if !visible_content.is_empty() {
            output.push(' ');
            output.push_str(&visible_content);
        }

        // Add marker line if this is an error line with column info
        if is_error_line && let Some(start_col) = start_column {
            let end_col = end_column.unwrap_or(start_col + 1);
            let line_len = line_content.len();

            // Determine which columns to underline on this error line
            let (col_start, col_end) = if start_line_idx == end_line_idx {
                (start_col, end_col)
            } else if line_idx == start_line_idx {
                (start_col, line_len)
            } else if line_idx == end_line_idx {
                (1, end_col)
            } else {
                (1, line_len + 1) // intermediate line: underline everything
            };

            // Clamp to line bounds (1-indexed)
            let col_start = col_start.min(line_len + 1);
            let col_end = col_end.min(line_len + 2);

            // project into display space
            let (marker_col, marker_length) = marker_display_position(
                line_content,
                col_start,
                col_end,
                truncation.byte_offset,
                available_code_width,
            );

            output.push_str("\n  ");
            output.push_str(color_scheme.gutter);
            write!(output, "{:>width$} |", "", width = gutter_width).unwrap();

            output.push_str(color_scheme.reset);
            output.extend(std::iter::repeat_n(' ', marker_col));
            output.push_str(color_scheme.marker);
            output.extend(std::iter::repeat_n('^', marker_length));
            output.push_str(color_scheme.reset);

            if line_idx == end_line_idx
                && let Some(ref message) = options.message
            {
                output.push(' ');
                output.push_str(color_scheme.message);
                output.push_str(message);
                output.push_str(color_scheme.reset);
            }
        }
    }

    Ok(Some(output))
}

const ELLIPSIS: &str = "...";
const SEPARATOR: &str = " | ";
/// Display offset for content after an ellipsis prefix
const ELLIPSIS_DISPLAY_OFFSET: usize = ELLIPSIS.len() + 1;

/// Calculate the truncation offset (in bytes) for all lines in the window.
/// This ensures all lines are "scrolled" to the same horizontal position, centering the error
/// range. Column values are byte offsets; width comparisons use display widths.
fn calculate_truncation_offset(
    lines: &Lines<'_>,
    window: Range<usize>,
    start_column: usize,
    end_column: usize,
    available_width: usize,
) -> usize {
    // Check if any line in the window needs truncation (using display width)
    let needs_truncation = window
        .clone()
        .any(|i| str_display_width(lines.content(i)) > available_width);

    // All lines are short enough or we don't have an error column so start at beginning
    if !needs_truncation || start_column == 0 {
        return 0;
    }

    // If we need truncation, center the error range
    // We need to account for the "..." ellipsis (3 chars) on each side
    let available_with_ellipsis = available_width.saturating_sub(2 * ELLIPSIS.len());

    // Calculate the midpoint of the error range
    // end_column is exclusive, so the range is [start_column, end_column)
    let start_0idx = start_column.saturating_sub(1);
    let end_0idx = end_column.saturating_sub(1);
    let error_midpoint = (start_0idx + end_0idx) / 2;

    // Try to center the error range in the window
    let half_width = available_with_ellipsis / 2;

    error_midpoint.saturating_sub(half_width)
}

/// Truncate a line at a specific byte offset, adding ellipsis as needed.
/// The `offset` is snapped forward to the nearest UTF-8 character boundary
/// to avoid splitting multi-byte characters. `max_width` is in display columns.
fn truncate_line(line: &str, offset: usize, max_width: usize) -> TruncationResult {
    // If no offset and line fits, return as-is (using display width)
    if offset == 0 && str_display_width(line) <= max_width {
        return TruncationResult {
            visible_content: line.to_string(),
            byte_offset: 0,
            prefix_len: 0,
        };
    }

    // Snap offset to nearest char boundary (forward)
    let byte_offset = line.ceil_char_boundary(offset);

    let mut result = String::with_capacity(max_width);

    // Add leading ellipsis if we're starting mid-line
    let prefix_len = if byte_offset > 0 {
        result.push_str(ELLIPSIS);
        ELLIPSIS.len()
    } else {
        0
    };

    // Calculate how many display columns are available for content
    let available_content_width = if byte_offset > 0 {
        max_width.saturating_sub(ELLIPSIS.len())
    } else {
        max_width
    };

    // Check if offset is past line length
    let remaining_line = if byte_offset < line.len() {
        &line[byte_offset..]
    } else {
        // Offset is past line length - show just ellipsis
        return TruncationResult {
            visible_content: ELLIPSIS.to_string(),
            byte_offset,
            prefix_len: ELLIPSIS.len(),
        };
    };

    let remaining_display_width = str_display_width(remaining_line);
    let needs_trailing_ellipsis = remaining_display_width > available_content_width;
    let target_width = if needs_trailing_ellipsis {
        available_content_width.saturating_sub(ELLIPSIS.len())
    } else {
        available_content_width
    };

    // Walk characters until we reach the target display width
    let mut cumulative_width = 0;
    let mut visible_end = 0;
    for (i, c) in remaining_line.char_indices() {
        let char_width = c.width().unwrap_or(0);
        if cumulative_width + char_width > target_width {
            break;
        }
        cumulative_width += char_width;
        visible_end = i + c.len_utf8();
    }

    result.push_str(&remaining_line[..visible_end]);

    if needs_trailing_ellipsis {
        result.push_str(ELLIPSIS);
    }

    TruncationResult {
        visible_content: result,
        byte_offset,
        prefix_len,
    }
}
Quest for Codev2.0.0
/
SIGN IN