diff --git a/Cargo.lock b/Cargo.lock index 44fc569dff535..6c89ecc445467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3278,13 +3278,12 @@ dependencies = [ [[package]] name = "insta" -version = "1.41.1" +version = "1.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e9ffc4d4892617c50a928c52b2961cb5174b6fc6ebf252b2fac9d21955c48b8" +checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" dependencies = [ "console", - "lazy_static", - "linked-hash-map", + "once_cell", "regex", "serde", "similar", @@ -4314,6 +4313,17 @@ dependencies = [ "turbopack-trace-utils", ] +[[package]] +name = "next-code-frame" +version = "0.0.1" +dependencies = [ + "anyhow", + "insta", + "serde", + "swc_common", + "swc_ecma_lexer", +] + [[package]] name = "next-core" version = "0.1.0" @@ -4422,6 +4432,7 @@ dependencies = [ "napi-derive", "next-api", "next-build", + "next-code-frame", "next-core", "next-custom-transforms", "next-taskless", @@ -4559,11 +4570,10 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.3" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "autocfg", "num-integer", "num-traits", "serde", @@ -4598,11 +4608,10 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "autocfg", "num-traits", ] @@ -4620,9 +4629,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.16" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -6200,9 +6209,9 @@ checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" [[package]] name = "ryu-js" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4950d85bc52415f8432144c97c4791bd0c4f7954de32a7270ee9cccd3c22b12b" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" [[package]] name = "saffron" @@ -10584,6 +10593,7 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "mdxjs", + "next-code-frame", "next-custom-transforms", "next-taskless", "rustc-hash 2.1.1", diff --git a/Cargo.toml b/Cargo.toml index e685d439e41fe..104a906b64cc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/next-api", "crates/next-build-test", "crates/next-build", + "crates/next-code-frame", "crates/next-core", "crates/next-custom-transforms", "crates/next-taskless", @@ -287,6 +288,7 @@ debug = true # Workspace crates next-api = { path = "crates/next-api" } next-build = { path = "crates/next-build" } +next-code-frame = { path = "crates/next-code-frame" } next-core = { path = "crates/next-core" } next-custom-transforms = { path = "crates/next-custom-transforms" } next-taskless = { path = "crates/next-taskless" } diff --git a/crates/napi/Cargo.toml b/crates/napi/Cargo.toml index a0a3ddec1f039..c45978428a885 100644 --- a/crates/napi/Cargo.toml +++ b/crates/napi/Cargo.toml @@ -64,6 +64,7 @@ futures-util = { workspace = true } owo-colors = { workspace = true } napi = { workspace = true } napi-derive = "2" +next-code-frame = { workspace = true } next-custom-transforms = { workspace = true } next-taskless = { workspace = true } rand = { workspace = true } diff --git a/crates/napi/src/code_frame.rs b/crates/napi/src/code_frame.rs new file mode 100644 index 0000000000000..2dcac63d900d2 --- /dev/null +++ b/crates/napi/src/code_frame.rs @@ -0,0 +1,89 @@ +use napi::bindgen_prelude::*; +use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame}; + +#[napi(object)] +pub struct NapiLocation { + pub line: u32, + pub column: Option, +} + +impl From for Location { + fn from(loc: NapiLocation) -> Self { + Location { + line: loc.line as usize, + column: loc.column.unwrap_or(0) as usize, + } + } +} + +#[napi(object)] +pub struct NapiCodeFrameLocation { + pub start: NapiLocation, + pub end: Option, +} + +impl From for CodeFrameLocation { + fn from(loc: NapiCodeFrameLocation) -> Self { + CodeFrameLocation { + start: loc.start.into(), + end: loc.end.map(Into::into), + } + } +} + +#[napi(object)] +#[derive(Default)] +pub struct NapiCodeFrameOptions { + /// Number of lines to show above the error (default: 2) + pub lines_above: Option, + /// Number of lines to show below the error (default: 3) + pub lines_below: Option, + /// Maximum width of the output (default: terminal width) + pub max_width: Option, + /// Whether to use ANSI colors (default: true) + pub force_color: Option, + /// Whether to highlight code syntax (default: true) + pub highlight_code: Option, + /// Optional message to display with the code frame + pub message: Option, +} + +impl From for CodeFrameOptions { + fn from(opts: NapiCodeFrameOptions) -> Self { + CodeFrameOptions { + lines_above: opts.lines_above.unwrap_or(2) as usize, + lines_below: opts.lines_below.unwrap_or(3) as usize, + max_width: opts.max_width.map(|w| w as usize), + use_colors: opts.force_color.unwrap_or(true), + highlight_code: opts.highlight_code.unwrap_or(true), + message: opts.message, + } + } +} + +/// Renders a code frame showing the location of an error in source code +/// +/// This is a Rust implementation that replaces Babel's code-frame for better: +/// - Performance on large files +/// - Handling of long lines +/// - Memory efficiency +/// +/// # Arguments +/// * `source` - The source code to render +/// * `location` - The location to highlight (line and column numbers are 1-indexed) +/// * `options` - Optional configuration +/// +/// # Returns +/// The formatted code frame string, or empty string if the location is invalid +#[napi] +pub fn code_frame_columns( + source: String, + location: NapiCodeFrameLocation, + options: Option, +) -> Result { + let code_frame_location: CodeFrameLocation = location.into(); + let code_frame_options: CodeFrameOptions = options.unwrap_or_default().into(); + + render_code_frame(&source, &code_frame_location, &code_frame_options) + .map_err(|e| Error::from_reason(format!("Failed to render code frame: {}", e))) +} diff --git a/crates/napi/src/lib.rs b/crates/napi/src/lib.rs index d2f7bfc10344f..66a884881b970 100644 --- a/crates/napi/src/lib.rs +++ b/crates/napi/src/lib.rs @@ -42,6 +42,7 @@ use swc_core::{ common::{FilePathMapping, SourceMap}, }; +pub mod code_frame; #[cfg(not(target_arch = "wasm32"))] pub mod css; pub mod lockfile; diff --git a/crates/next-code-frame/Cargo.toml b/crates/next-code-frame/Cargo.toml new file mode 100644 index 0000000000000..fa2c622ad225c --- /dev/null +++ b/crates/next-code-frame/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "next-code-frame" +version = "0.0.1" +description = "Fast, scalable code frame rendering for Next.js error reporting" +license = "MIT" +edition = "2024" + +[lib] +bench = false + +[lints] +workspace = true + +[profile.dev.package] +insta.opt-level = 3 +similar.opt-level = 3 + +[dependencies] +anyhow = { workspace = true } +swc_ecma_lexer = "24.0.0" +swc_common = "15.0.0" +serde = { workspace = true } + +[dev-dependencies] +insta = { version = "1.43.1", features = ["yaml"] } diff --git a/crates/next-code-frame/README.md b/crates/next-code-frame/README.md new file mode 100644 index 0000000000000..bbd7026f89995 --- /dev/null +++ b/crates/next-code-frame/README.md @@ -0,0 +1,24 @@ +# next-code-frame + +Fast, scalable code frame rendering for Next.js error reporting, written in Rust. + +This crate provides functionality similar to `@babel/code-frame` but with several improvements: +- **Scalability**: Handles arbitrarily large files efficiently +- **Long line handling**: Gracefully scrolls long lines to keep error positions visible and avoid overwhelming the terminal with long lines +- **Syntax highlighting**: Uses SWC lexer for accurate JavaScript/TypeScript tokenization + +## Design + +Following the `next-taskless` pattern, this crate: +- Has no dependency on turbo-tasks, allowing use in webpack/rspack codepaths +- Is compilable to WASM for environments without native bindings +- Follows "sans-io" patterns - accepts file content as arguments rather than performing IO + - Modifying it to optionally accept file paths is reasonable future work + +## Features + +- Terminal width detection with sensible defaults +- Syntax highlighting for JS, TS, JSX, TSX +- Graceful degradation for non-JS files or parsing errors +- ANSI color support matching babel-code-frame aesthetics +- Support for single-line and multi-line error ranges diff --git a/crates/next-code-frame/examples/comments_punctuation_demo.rs b/crates/next-code-frame/examples/comments_punctuation_demo.rs new file mode 100644 index 0000000000000..2506ddbf948f1 --- /dev/null +++ b/crates/next-code-frame/examples/comments_punctuation_demo.rs @@ -0,0 +1,27 @@ +use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame}; + +fn main() { + let source = r#"// This is a comment +const x = 42; // inline comment +const obj = { foo: 'bar' }; +/* Multi-line + comment */ +const result = x > 10 ? 'yes' : 'no';"#; + + let location = CodeFrameLocation { + start: Location { line: 2, column: 1 }, + end: Some(Location { + line: 2, + column: 14, // Mark "const x = 42;" + }), + }; + + println!("=== With Syntax Highlighting (showing comments and punctuation) ==="); + let options = CodeFrameOptions { + use_colors: true, + highlight_code: true, + ..Default::default() + }; + let result = render_code_frame(source, &location, &options).unwrap(); + println!("{}", result); +} diff --git a/crates/next-code-frame/examples/complete_highlighting_demo.rs b/crates/next-code-frame/examples/complete_highlighting_demo.rs new file mode 100644 index 0000000000000..5ca45b0104045 --- /dev/null +++ b/crates/next-code-frame/examples/complete_highlighting_demo.rs @@ -0,0 +1,53 @@ +use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame}; + +fn main() { + let source = r#"// Type definition +const greeting: string = "Hello"; +const count = 42 + 10; +const regex = /test\d+/gi; + +/* Calculate result with + ternary operator */ +const result = count > 10 ? "yes" : "no"; + +// Object literal +const obj = { + foo: 'bar', + baz: true, +}; + +// Arrow function with JSX +const Component = () =>
Hello
;"#; + + let location = CodeFrameLocation { + start: Location { line: 8, column: 1 }, + end: Some(Location { + line: 8, + column: 43, + }), + }; + + println!("╔═══════════════════════════════════════════════════════════╗"); + println!("║ Complete Syntax Highlighting Demo (matching Babel) ║"); + println!("╚═══════════════════════════════════════════════════════════╝\n"); + + let options = CodeFrameOptions { + use_colors: true, + highlight_code: true, + ..Default::default() + }; + let result = render_code_frame(source, &location, &options).unwrap(); + println!("{}", result); + + println!("\n╔═══════════════════════════════════════════════════════════╗"); + println!("║ Color Key: ║"); + println!("╠═══════════════════════════════════════════════════════════╣"); + println!("║ \x1b[36mKeywords\x1b[0m (cyan): const, let, var, if, etc. ║"); + println!("║ \x1b[33mIdentifiers\x1b[0m (yellow): variable and function names ║"); + println!("║ \x1b[32mStrings\x1b[0m (green): \"...\", '...', template literals ║"); + println!("║ \x1b[35mNumbers\x1b[0m (magenta): 42, 0x10, bigints ║"); + println!("║ \x1b[33mPunctuation\x1b[0m (yellow): = ; , . : ? + - * / ║"); + println!("║ \x1b[90mComments\x1b[0m (gray): // and /* */ ║"); + println!("║ Brackets (default): ( ) [ ] {{ }} ║"); + println!("╚═══════════════════════════════════════════════════════════╝"); +} diff --git a/crates/next-code-frame/examples/highlighting_demo.rs b/crates/next-code-frame/examples/highlighting_demo.rs new file mode 100644 index 0000000000000..01fd1bb753e63 --- /dev/null +++ b/crates/next-code-frame/examples/highlighting_demo.rs @@ -0,0 +1,39 @@ +use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame}; + +fn main() { + let source = r#"const greeting = "Hello, world!"; +const number = 42; +const regex = /test/g; +const obj = { foo: 'bar' };"#; + + let location = CodeFrameLocation { + start: Location { + line: 2, + column: 16, + }, + end: Some(Location { + line: 2, + column: 18, // Mark "42" + }), + }; + + // Without highlighting + println!("=== Without Highlighting ==="); + let options = CodeFrameOptions { + use_colors: true, + highlight_code: false, + ..Default::default() + }; + let result = render_code_frame(source, &location, &options).unwrap(); + println!("{}", result); + + // With highlighting + println!("\n=== With Syntax Highlighting ==="); + let options = CodeFrameOptions { + use_colors: true, + highlight_code: true, + ..Default::default() + }; + let result = render_code_frame(source, &location, &options).unwrap(); + println!("{}", result); +} diff --git a/crates/next-code-frame/src/frame.rs b/crates/next-code-frame/src/frame.rs new file mode 100644 index 0000000000000..2331286486f6e --- /dev/null +++ b/crates/next-code-frame/src/frame.rs @@ -0,0 +1,463 @@ +use anyhow::{Result, bail}; +use serde::Deserialize; + +use crate::{ + highlight::{ + ColorScheme, LineHighlight, adjust_highlights_for_truncation, apply_line_highlights, + extract_highlights, + }, + terminal::get_terminal_width, +}; + +/// A source location with line and column +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct Location { + /// 1-indexed line number + pub line: usize, + /// 1-indexed column number (0 means no column highlighting) + pub column: usize, +} + +/// Location information for the error in the source code +/// +/// # Column Semantics +/// +/// - `start.column`: **Inclusive** - points to the first character to mark +/// - `end.column`: **EXCLUSIVE** - points one past the last character to mark +/// +/// This follows standard programming range conventions `[start, end)`. +/// +/// Example: To mark "123" at columns 11-13, use: +/// ```ignore +/// CodeFrameLocation { +/// start: Location { line: 1, column: 11 }, +/// end: Some(Location { line: 1, column: 14 }), // Exclusive +/// } +/// ``` +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct CodeFrameLocation { + /// Starting location + pub start: Location, + /// Optional ending location + /// Line is treated inclusively but column is treated exclusively + pub end: Option, +} + +/// Options for rendering the code frame +#[derive(Debug, Clone, Deserialize)] +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 color output + pub use_colors: bool, + /// Whether to attempt syntax highlighting + pub highlight_code: bool, + /// Optional message to display with the error + pub message: Option, + /// Maximum width for the output (None = terminal width) + pub max_width: Option, +} + +impl Default for CodeFrameOptions { + fn default() -> Self { + Self { + lines_above: 2, + lines_below: 3, + use_colors: true, + highlight_code: true, + message: None, + max_width: None, + } + } +} + +#[inline] +fn repeat_char_into(s: &mut String, ch: char, count: usize) { + s.reserve(count); + for _ in 0..count { + s.push(ch); + } +} + +fn apply_line_truncation( + line_content: &str, + truncation_offset: usize, + available_code_width: usize, +) -> (String, usize) { + if truncation_offset > 0 { + truncate_line(line_content, truncation_offset, available_code_width) + } else if line_content.len() > available_code_width { + truncate_line(line_content, 0, available_code_width) + } else { + (line_content.to_string(), 0) + } +} + +fn calculate_marker_position( + location_start_column: usize, + end_column: usize, + line_length: usize, + line_idx: usize, + start_line: usize, + end_line: usize, + column_offset: usize, + available_code_width: usize, +) -> (usize, usize) { + // Allow columns to go one past line length (pointing after last char) + let max_col = line_length + 1; + + // Determine the column range to mark on this line: + // We use exclusive ranges [start, end) internally + // + // API contract: end.column is ALWAYS exclusive (follows [start, end) convention) + // + // For rendering: + // - Single-line: Mark from start.column to end.column (exclusive) + // - First line of multiline: Mark from start.column to end of line + // - Last line of multiline: Mark from column 1 to end.column (exclusive) + let is_single_line_error = start_line == end_line; + + let (range_start, range_end) = if is_single_line_error { + // Single-line: end.column is exclusive, use directly + (location_start_column, end_column) + } else if line_idx == start_line { + // First line of multiline: mark from start to end of line + // line_length already represents the last column position + (location_start_column, line_length) + } else { + // Last line of multiline: mark from column 1 to end.column (exclusive) + (1, end_column) + }; + + // Clamp to reasonable bounds + let range_start = range_start.min(max_col); + // Allow small extension past line for off-by-one, but prevent excessive spans + let reasonable_max = max_col + 1; + let range_end = range_end.min(reasonable_max); + + // Calculate marker position accounting for truncation + // When column_offset > 0, visible content is "...XXXXX" where X starts at column_offset + // Display positions: columns 1-3 are "...", column 4 corresponds to original column_offset + let marker_col = if column_offset > 0 { + // Convert original column to display column + // formula: display_col = (original_col - offset) + 4 + // where 4 accounts for the "..." prefix (3 chars) plus 1-indexing + if range_start < column_offset { + // Error starts before visible window, mark from column 4 (after "...") + 4 + } else { + // Error starts in visible window + (range_start - column_offset) + 4 + } + } else { + // No truncation, use column as-is + range_start.max(1) + }; + + // If range is invalid (end <= start), show single marker at start + let marker_length = if range_end > range_start { + range_end - range_start + } else { + 1 + }; + + // Adjust marker_length if it would extend past available width + let marker_length = marker_length.min(available_code_width.saturating_sub(marker_col - 1)); + + (marker_col, marker_length) +} + +/// Renders a code frame showing the location of an error in source code +pub fn render_code_frame( + source: &str, + location: &CodeFrameLocation, + options: &CodeFrameOptions, +) -> Result { + if source.is_empty() { + return Ok(String::new()); + } + + // Split source into lines + let lines: Vec<&str> = source.lines().collect(); + + // Validate location + let start_line = location.start.line.saturating_sub(1); // Convert to 0-indexed + if start_line >= lines.len() { + return Ok(String::new()); + } + + let end_line = location + .end + .map(|l| l.line.saturating_sub(1).min(lines.len() - 1)) + .unwrap_or(start_line); + if end_line < start_line { + bail!("source location invalid end_line {end_line} < {start_line}"); + } + + // Normalize end_column: + // API contract: end.column is always EXCLUSIVE (follows [start, end) convention) + // - If no end (single-line) and no end.column, default to start.column + 1 (marks 1 char) + // - If end is set but no end.column, that would be None - but the struct requires it together + // - Otherwise use the provided end.column as-is + let end_column = match location.end { + Some(l) => l.column, + None => { + // Single-line error without end defaults to single-char marker + // end_column = start_column + 1 (exclusive) means mark exactly one character + if location.start.column > 0 { + location.start.column + 1 + } else { + 0 + } + } + }; + + // For rendering, we'll clamp columns to valid ranges per-line + + // Calculate window of lines to show + let first_line = start_line.saturating_sub(options.lines_above); + let last_line = (end_line + options.lines_below + 1).min(lines.len()); + + // Extract syntax highlights if enabled, only for the visible line range + // first_line and last_line are already 0-indexed, perfect for the API + let line_highlights: Vec = if options.highlight_code { + extract_highlights(source, first_line..last_line) + } else { + Vec::new() + }; + + // Calculate gutter width (space needed for line numbers) + let max_line_num = last_line; + let gutter_width = format!("{}", max_line_num).len(); + + let max_width = options.max_width.unwrap_or_else(get_terminal_width); + + // Calculate available width for code (accounting for gutter, markers, and padding) + // Format: "> N | code" or " N | code" + // That's: 1 (marker) + 1 (space) + gutter_width + 3 (" | ") + let gutter_total_width = 1 + 1 + gutter_width + 3; + let available_code_width = max_width.saturating_sub(gutter_total_width); + if available_code_width == 0 { + bail!("max_width {max_width} too small to render a code frame") + } + + // Calculate truncation offset for long lines - only if any line actually needs it + // Center the error range if any line in the error range needs truncation + let truncation_offset = calculate_truncation_offset( + &lines, + first_line, + last_line, + start_line, + location.start.column, + end_column, + available_code_width, + ); + + let color_scheme = if options.use_colors { + ColorScheme::colored() + } else { + ColorScheme::plain() + }; + let mut output = String::new(); + + // Add message if provided and no column specified + if let Some(ref message) = options.message + && location.start.column == 0 + { + repeat_char_into(&mut output, ' ', gutter_total_width); + output.push_str(color_scheme.marker); + output.push_str(message); + output.push_str(color_scheme.reset); + output.push('\n'); + } + + // Render each line + for (line_idx, line_content) in lines.iter().enumerate().take(last_line).skip(first_line) { + let is_error_line = line_idx >= start_line && line_idx <= end_line; + let line_num = line_idx + 1; + + // Apply consistent truncation to all lines + let (visible_content, column_offset) = + apply_line_truncation(line_content, truncation_offset, available_code_width); + + // Apply syntax highlighting if enabled + // line_highlights is indexed relative to first_line (0-indexed within the visible range) + let highlight_idx = line_idx.saturating_sub(first_line); + let visible_content = if options.highlight_code && highlight_idx < line_highlights.len() { + // Adjust highlights for truncation + let adjusted_highlight = adjust_highlights_for_truncation( + &line_highlights[highlight_idx], + truncation_offset, + visible_content.len(), + ); + apply_line_highlights(&visible_content, &adjusted_highlight, &color_scheme) + } else { + visible_content + }; + + // Line prefix with number + 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); + output.push_str(&format!("{:>width$}", line_num, width = gutter_width)); + output.push_str(color_scheme.reset); + output.push_str(color_scheme.gutter); + output.push_str(" |"); + output.push_str(color_scheme.reset); + + // Line content (with space separator if not empty) + if !visible_content.is_empty() { + output.push(' '); + output.push_str(&visible_content); + } + output.push('\n'); + + // Add marker line if this is an error line with column info + if is_error_line && location.start.column > 0 { + let (marker_col, marker_length) = calculate_marker_position( + location.start.column, + end_column, + line_content.len(), + line_idx, + start_line, + end_line, + column_offset, + available_code_width, + ); + + output.push(' '); + output.push(' '); + output.push_str(color_scheme.gutter); + output.push_str(&format!("{:>width$} |", "", width = gutter_width)); + output.push_str(color_scheme.reset); + output.push(' '); + repeat_char_into(&mut output, ' ', marker_col - 1); + output.push_str(color_scheme.marker); + repeat_char_into(&mut output, '^', marker_length); + output.push_str(color_scheme.reset); + + // Add message only on the last error line's marker + if line_idx == end_line + && let Some(ref message) = options.message + { + output.push(' '); + output.push_str(color_scheme.marker); + output.push_str(message); + output.push_str(color_scheme.reset); + } + + output.push('\n'); + } + } + + Ok(output) +} +const ELLIPSIS: &str = "..."; +const ELLIPSIS_LEN: usize = 3; + +/// Calculate the truncation offset for all lines in the window. +/// This ensures all lines are "scrolled" to the same position, centering the error range. +fn calculate_truncation_offset( + lines: &[&str], + first_line: usize, + last_line: usize, + _error_line: usize, + start_column: usize, + end_column: usize, + available_width: usize, +) -> usize { + // Check if any line in the window needs truncation + let needs_truncation = (first_line..last_line).any(|i| lines[i].len() > available_width); + + if !needs_truncation { + 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); + + if start_column == 0 { + // No specific error column, start at beginning + return 0; + } + + // 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 midpoint + let half_width = available_with_ellipsis / 2; + + if error_midpoint < half_width { + // Error range is near the start, start from beginning + 0 + } else { + // Center the error range midpoint + error_midpoint.saturating_sub(half_width) + } +} + +/// Truncate a line at a specific offset, adding ellipsis as needed +fn truncate_line(line: &str, offset: usize, max_width: usize) -> (String, usize) { + // If no offset and line fits, return as-is + if offset == 0 && line.len() <= max_width { + return (line.to_string(), 0); + } + + let mut result = String::with_capacity(max_width); + let actual_offset = offset; + + // Add leading ellipsis if we're starting mid-line + if offset > 0 { + result.push_str(ELLIPSIS); + } + + // Calculate how much content we can show + let available_content_width = if offset > 0 { + max_width.saturating_sub(ELLIPSIS_LEN) + } else { + max_width + }; + + // Check if line would extend past the end + let remaining_line = if offset < line.len() { + &line[offset..] + } else { + // Offset is past line length - show just ellipsis + return (ELLIPSIS.to_string(), offset); + }; + + let needs_trailing_ellipsis = remaining_line.len() > available_content_width; + let content_width = if needs_trailing_ellipsis { + available_content_width.saturating_sub(ELLIPSIS_LEN) + } else { + available_content_width.min(remaining_line.len()) + }; + + // Extract the visible portion, being careful about UTF-8 boundaries + let visible_end = remaining_line + .char_indices() + .take(content_width) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + + result.push_str(&remaining_line[..visible_end]); + + if needs_trailing_ellipsis { + result.push_str(ELLIPSIS); + } + + (result, actual_offset) +} diff --git a/crates/next-code-frame/src/highlight.rs b/crates/next-code-frame/src/highlight.rs new file mode 100644 index 0000000000000..c5f1d9f8c77d1 --- /dev/null +++ b/crates/next-code-frame/src/highlight.rs @@ -0,0 +1,709 @@ +use std::ops::Range; + +use swc_common::{SourceMap, Span, comments::SingleThreadedComments}; +use swc_ecma_lexer::{Lexer, StringInput, Syntax, TsSyntax, token::Token}; + +/// A style marker at a specific byte offset in the source +/// Represents either the start or end of a styled region +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct StyleMarker { + /// Byte offset in the source (0-indexed) + pub offset: usize, + /// Whether this is a start (true) or end (false) marker + pub is_start: bool, + /// The token type being styled + pub token_type: TokenType, +} + +/// Highlighting information for a single line +/// Contains sorted style markers that should be applied when rendering the line +#[derive(Debug, Clone)] +pub struct LineHighlight { + /// Line number (1-indexed) + pub line: usize, + /// Byte offset where this line starts in the source + pub line_start_offset: usize, + /// Byte offset where this line ends (exclusive) in the source + pub line_end_offset: usize, + /// Style markers for this line, sorted by offset + /// Offsets are relative to line_start_offset + pub markers: Vec, +} + +/// Token types for syntax highlighting +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TokenType { + Keyword, + Identifier, + String, + Number, + Regex, + JsxTag, + Punctuation, + Comment, +} + +/// ANSI color codes for token types +#[derive(Debug, Clone, Copy)] +pub struct ColorScheme { + pub reset: &'static str, + pub keyword: &'static str, + pub identifier: &'static str, + pub string: &'static str, + pub number: &'static str, + pub regex: &'static str, + pub jsx_tag: &'static str, + pub punctuation: &'static str, + pub comment: &'static str, + pub gutter: &'static str, + pub marker: &'static str, +} + +impl ColorScheme { + /// Get a color scheme with ANSI colors (matching babel-code-frame) + pub const fn colored() -> Self { + Self { + reset: "\x1b[0m", + keyword: "\x1b[36m", // cyan + identifier: "\x1b[33m", // yellow (for capitalized/jsx identifiers) + string: "\x1b[32m", // green + number: "\x1b[35m", // magenta + regex: "\x1b[35m", // magenta + jsx_tag: "\x1b[33m", // yellow + punctuation: "\x1b[33m", // yellow + comment: "\x1b[90m", // gray + gutter: "\x1b[90m", // gray + marker: "\x1b[31m\x1b[1m", // red + bold + } + } + + /// Get a plain color scheme with no ANSI codes (all empty strings) + pub const fn plain() -> Self { + Self { + reset: "", + keyword: "", + identifier: "", + string: "", + number: "", + regex: "", + jsx_tag: "", + punctuation: "", + comment: "", + gutter: "", + marker: "", + } + } + + /// Get the color for a token type + pub fn color_for_token(&self, token_type: TokenType) -> &'static str { + match token_type { + TokenType::Keyword => self.keyword, + TokenType::Identifier => self.identifier, + TokenType::String => self.string, + TokenType::Number => self.number, + TokenType::Regex => self.regex, + TokenType::JsxTag => self.jsx_tag, + TokenType::Punctuation => self.punctuation, + TokenType::Comment => self.comment, + } + } +} + +/// Lex the entire source file and extract token information +/// Returns highlighting information for each line +/// +/// # Parameters +/// - `source`: The source code to highlight +/// - `line_range`: Range of line indices (0-indexed) to produce markers for (start inclusive, end +/// exclusive) Tokens are still lexed for the entire file to maintain correct parsing state, but +/// style markers are only produced for lines within this range. Pass `0..usize::MAX` to produce +/// markers for all lines. +pub fn extract_highlights(source: &str, line_range: Range) -> Vec { + // Create a SourceMap and SourceFile for the lexer + let cm = SourceMap::default(); + let fm = cm.new_source_file( + swc_common::FileName::Custom("input.tsx".into()).into(), + source.to_string(), + ); + + // Convert line_range to byte range for efficient filtering + let byte_range = { + // line_range is already 0-indexed + let start_idx = line_range.start; + let end_idx = line_range.end; + + // Get byte positions for the line range + let start_byte = if start_idx < fm.count_lines() { + let (start_pos, _) = fm.line_bounds(start_idx); + start_pos.0.saturating_sub(1) as usize + } else { + usize::MAX // Out of range, will filter everything + }; + + let end_byte = if end_idx < fm.count_lines() { + let (_, end_pos) = fm.line_bounds(end_idx); + end_pos.0.saturating_sub(1) as usize + } else { + source.len() + }; + + Some((start_byte, end_byte)) + }; + + // Configure syntax for TypeScript + JSX + let syntax = Syntax::Typescript(TsSyntax { + tsx: true, + decorators: true, + ..Default::default() + }); + + // Create comments handler to capture comments + let comments = SingleThreadedComments::default(); + + // Create lexer with comments enabled + let input = StringInput::from(&*fm); + let lexer = Lexer::new(syntax, Default::default(), input, Some(&comments)); + + // Collect all token markers, splitting at line boundaries using had_line_break + let mut all_markers = Vec::new(); + + for token in lexer { + if token.token == Token::Eof { + break; + } + + // Classify token and add markers + if let Some(token_type) = classify_token(&token.token) { + add_token_markers( + &mut all_markers, + &fm, + token.span, + token_type, + token.had_line_break, + byte_range, + ); + } + } + + // Add comment markers + let (leading, trailing) = comments.borrow_all(); + + for (_pos, comment_vec) in leading.iter() { + for comment in comment_vec { + add_token_markers( + &mut all_markers, + &fm, + comment.span, + TokenType::Comment, + false, + byte_range, + ); + } + } + + for (_pos, comment_vec) in trailing.iter() { + for comment in comment_vec { + add_token_markers( + &mut all_markers, + &fm, + comment.span, + TokenType::Comment, + false, + byte_range, + ); + } + } + + // Sort markers by offset + all_markers.sort(); + + // Use SourceFile's line lookup to group markers by line + group_markers_by_line(&all_markers, &fm, source, line_range) +} + +/// Classify a token into a highlighting type +fn classify_token(token: &Token) -> Option { + match token { + // Keywords + Token::Word(word) => match word { + swc_ecma_lexer::token::Word::Null + | swc_ecma_lexer::token::Word::True + | swc_ecma_lexer::token::Word::False + | swc_ecma_lexer::token::Word::Keyword(_) => Some(TokenType::Keyword), + swc_ecma_lexer::token::Word::Ident(_) => Some(TokenType::Identifier), + }, + + // Literals + Token::Str { .. } | Token::Template { .. } => Some(TokenType::String), + Token::Num { .. } | Token::BigInt { .. } => Some(TokenType::Number), + Token::Regex(..) => Some(TokenType::Regex), + + // JSX + Token::JSXTagStart | Token::JSXTagEnd => Some(TokenType::JsxTag), + + // Brackets - leave unstyled like Babel does + Token::LParen + | Token::RParen + | Token::LBrace + | Token::RBrace + | Token::LBracket + | Token::RBracket => None, + + // Punctuation - all other punctuation tokens (yellow in Babel) + // This includes: ; , . ... => : ? operators @ # etc + Token::Arrow + | Token::Dot + | Token::DotDotDot + | Token::Semi + | Token::Comma + | Token::Colon + | Token::QuestionMark + | Token::BinOp(_) + | Token::AssignOp(_) + | Token::Bang + | Token::Tilde + | Token::PlusPlus + | Token::MinusMinus + | Token::Hash + | Token::At + | Token::BackQuote + | Token::DollarLBrace => Some(TokenType::Punctuation), + + // Comments are handled separately via the comments API + // Everything else is left unstyled + _ => None, + } +} + +/// Add start and end markers for a token span +/// Splits markers at line boundaries for multiline tokens using lookup_line +/// Only adds markers if they intersect with the byte_range (if provided) +fn add_token_markers( + markers: &mut Vec, + fm: &swc_common::SourceFile, + span: Span, + token_type: TokenType, + had_line_break: bool, + byte_range: Option<(usize, usize)>, +) { + // BytePos starts at 1, so we need to subtract 1 to get 0-indexed offsets + let start = span.lo.0.saturating_sub(1) as usize; + let end = span.hi.0.saturating_sub(1) as usize; + + if start >= end { + return; + } + + // Early exit if token is completely outside the byte range + if let Some((range_start, range_end)) = byte_range { + // Token is completely before or after the range + if end <= range_start || start >= range_end { + return; + } + } + + // Check if this token spans multiple lines using lookup_line (O(log n)) + // Only do this check when had_line_break is true (rare case - only after newlines) + if had_line_break + && let (Some(start_line), Some(end_line)) = + (fm.lookup_line(span.lo), fm.lookup_line(span.hi)) + && start_line != end_line + { + // Token spans multiple lines - split markers at line boundaries + for line_idx in start_line..=end_line { + let (line_start_pos, line_end_pos) = fm.line_bounds(line_idx); + let line_start = line_start_pos.0.saturating_sub(1) as usize; + let line_end = line_end_pos.0.saturating_sub(1) as usize; + + let marker_start = start.max(line_start); + let marker_end = end.min(line_end); + + if marker_start < marker_end { + // Check if this segment intersects with the byte range + if let Some((range_start, range_end)) = byte_range { + // Skip if this segment is completely outside the range + if marker_end <= range_start || marker_start >= range_end { + continue; + } + } + + markers.push(StyleMarker { + offset: marker_start, + is_start: true, + token_type, + }); + markers.push(StyleMarker { + offset: marker_end, + is_start: false, + token_type, + }); + } + } + return; + } + + // Single-line token - add markers directly + markers.push(StyleMarker { + offset: start, + is_start: true, + token_type, + }); + markers.push(StyleMarker { + offset: end, + is_start: false, + token_type, + }); +} + +/// Group markers by line using SourceFile's line_bounds API +/// Complexity: O(markers + lines) using a single pass with marker index +/// Only returns LineHighlight entries for lines within the specified range +fn group_markers_by_line( + markers: &[StyleMarker], + fm: &swc_common::SourceFile, + source: &str, + line_range: Range, +) -> Vec { + if source.is_empty() { + return Vec::new(); + } + + // Get line count from SourceFile + let line_count = fm.count_lines(); + + // Determine which lines to process (already 0-indexed) + let start_line_idx = line_range.start.min(line_count); + let end_line_idx = line_range.end.min(line_count); + + let output_line_count = end_line_idx.saturating_sub(start_line_idx); + let mut line_highlights: Vec = Vec::with_capacity(output_line_count); + + // Track our position in the markers array (sorted by offset) + let mut marker_idx = 0; + + // Process each line in the range (start..end is exclusive at end, just like Range) + for line_idx in start_line_idx..end_line_idx { + if line_idx >= line_count { + break; + } + + let (line_start_pos, line_end_pos) = fm.line_bounds(line_idx); + let line_start = line_start_pos.0.saturating_sub(1) as usize; + let line_end = line_end_pos.0.saturating_sub(1) as usize; + + let mut line_markers = Vec::new(); + + // Process all markers that fall within this line + // Since markers are sorted, we only need to check from marker_idx forward + while marker_idx < markers.len() { + let marker = &markers[marker_idx]; + let abs_offset = marker.offset; + + // If marker is past this line, we're done with this line + if abs_offset >= line_end { + break; + } + // Skip markers before this line (shouldn't happen if byte_range filtering worked) + if abs_offset < line_start { + marker_idx += 1; + continue; + } + + let rel_offset = abs_offset - line_start; + line_markers.push(StyleMarker { + offset: rel_offset, + is_start: marker.is_start, + token_type: marker.token_type, + }); + + marker_idx += 1; + } + + line_highlights.push(LineHighlight { + line: line_idx + 1, + line_start_offset: line_start, + line_end_offset: line_end, + markers: line_markers, + }); + } + + line_highlights +} + +/// Adjust line highlights for a truncated view of the line +/// Returns a new LineHighlight with markers adjusted for the truncation offset +pub fn adjust_highlights_for_truncation( + line_highlight: &LineHighlight, + truncation_offset: usize, + visible_length: usize, +) -> LineHighlight { + let visible_end = truncation_offset + visible_length; + let mut adjusted_markers = Vec::new(); + + for marker in &line_highlight.markers { + let abs_offset = marker.offset; + + // Skip markers before the visible range + if abs_offset < truncation_offset { + continue; + } + + // Skip markers after the visible range + if abs_offset > visible_end { + continue; + } + + // Adjust offset relative to truncation + adjusted_markers.push(StyleMarker { + offset: abs_offset - truncation_offset, + is_start: marker.is_start, + token_type: marker.token_type, + }); + } + + LineHighlight { + line: line_highlight.line, + line_start_offset: line_highlight.line_start_offset + truncation_offset, + line_end_offset: (line_highlight.line_start_offset + visible_end) + .min(line_highlight.line_end_offset), + markers: adjusted_markers, + } +} + +/// Apply highlights to a line of text +/// Returns the styled text with ANSI codes inserted +pub fn apply_line_highlights( + line: &str, + line_highlight: &LineHighlight, + color_scheme: &ColorScheme, +) -> String { + if line_highlight.markers.is_empty() { + return line.to_string(); + } + + let mut result = String::with_capacity(line.len() + line_highlight.markers.len() * 10); + let mut last_offset = 0; + let mut active_style: Option = None; + + for marker in &line_highlight.markers { + // Add any text before this marker + if marker.offset > last_offset { + let end = marker.offset.min(line.len()); + result.push_str(&line[last_offset..end]); + last_offset = end; + } + + // Apply the style change + if marker.is_start { + result.push_str(color_scheme.color_for_token(marker.token_type)); + active_style = Some(marker.token_type); + } else if active_style == Some(marker.token_type) { + result.push_str(color_scheme.reset); + active_style = None; + } + } + + // Add any remaining text + if last_offset < line.len() { + result.push_str(&line[last_offset..]); + } + + // Reset at end of line if style is still active + if active_style.is_some() { + result.push_str(color_scheme.reset); + } + + result +} + +#[cfg(test)] +pub mod tests { + use super::*; + + /// Strip ANSI escape codes from a string + /// This is useful for testing to verify content without color codes + pub fn strip_ansi_codes(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + // Skip ANSI escape sequence + // Format: ESC [ + if chars.next() == Some('[') { + // Skip until we find a letter (the command character) + for ch in chars.by_ref() { + if ch.is_alphabetic() { + break; + } + } + } + } else { + result.push(ch); + } + } + + result + } + + #[test] + fn test_apply_line_highlights_basic() { + let source = "const foo = 123"; + let highlights = extract_highlights(source, 0..usize::MAX); + let color_scheme = ColorScheme::colored(); + + let result = apply_line_highlights(source, &highlights[0], &color_scheme); + + // Result should contain ANSI codes + assert!(result.contains("\x1b["), "Result should contain ANSI codes"); + // Result should still contain the original text + assert!(result.contains("const"), "Result should contain 'const'"); + assert!(result.contains("foo"), "Result should contain 'foo'"); + assert!(result.contains("123"), "Result should contain '123'"); + } + + #[test] + fn test_apply_line_highlights_plain() { + let source = "const foo = 123"; + let highlights = extract_highlights(source, 0..usize::MAX); + let color_scheme = ColorScheme::plain(); + + let result = apply_line_highlights(source, &highlights[0], &color_scheme); + + // With plain color scheme, result should be identical to input + assert_eq!(result, source); + } + + #[test] + fn test_strip_ansi_codes() { + let input = "\x1b[36mconst\x1b[0m foo = \x1b[35m123\x1b[0m"; + let result = strip_ansi_codes(input); + assert_eq!(result, "const foo = 123"); + } + + #[test] + fn test_strip_ansi_codes_preserves_plain_text() { + let input = "const foo = 123"; + let result = strip_ansi_codes(input); + assert_eq!(result, input); + } + + #[test] + fn test_adjust_highlights_for_truncation() { + let source = "const foo = 123"; + let highlights = extract_highlights(source, 0..usize::MAX); + + // Truncate to show only "foo = 123" (offset 6, length 9) + let adjusted = adjust_highlights_for_truncation(&highlights[0], 6, 9); + + // Markers should be adjusted + assert!(!adjusted.markers.is_empty()); + // There should be at least one start marker in the visible range + assert!(adjusted.markers.iter().any(|m| m.is_start)); + } + + #[test] + fn test_comments_and_punctuation() { + let source = "const x = 42; // comment\nobj.foo = 10;"; + let highlights = extract_highlights(source, 0..usize::MAX); + + // Should have 2 lines + assert_eq!(highlights.len(), 2); + + // First line should have comment markers + let line1_has_comment = highlights[0] + .markers + .iter() + .any(|m| m.token_type == TokenType::Comment); + assert!(line1_has_comment, "First line should have comment markers"); + + // Both lines should have punctuation markers (=, ;, .) + let line1_has_punctuation = highlights[0] + .markers + .iter() + .any(|m| m.token_type == TokenType::Punctuation); + let line2_has_punctuation = highlights[1] + .markers + .iter() + .any(|m| m.token_type == TokenType::Punctuation); + assert!( + line1_has_punctuation, + "First line should have punctuation markers" + ); + assert!( + line2_has_punctuation, + "Second line should have punctuation markers" + ); + } + + #[test] + fn test_multiline_comment() { + let source = "const x = 1;\n/* multi\n line */\nconst y = 2;"; + let highlights = extract_highlights(source, 0..usize::MAX); + + // Should have 4 lines + assert_eq!(highlights.len(), 4); + + // Lines 2 and 3 should have comment markers + let line2_has_comment = highlights[1] + .markers + .iter() + .any(|m| m.token_type == TokenType::Comment); + let line3_has_comment = highlights[2] + .markers + .iter() + .any(|m| m.token_type == TokenType::Comment); + + assert!(line2_has_comment, "Line 2 should have comment marker"); + assert!(line3_has_comment, "Line 3 should have comment marker"); + } + + #[test] + fn test_line_range_filtering() { + let source = "const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nconst e = 5;"; + + // Extract only line indices 1-3 (lines 2-4 in 1-indexed terms, 1..4 because Range is + // exclusive at the end) + let highlights = extract_highlights(source, 1..4); + + // Should only have 3 lines (indices 1, 2, 3 = lines 2, 3, 4 in 1-indexed) + assert_eq!(highlights.len(), 3); + assert_eq!(highlights[0].line, 2); + assert_eq!(highlights[1].line, 3); + assert_eq!(highlights[2].line, 4); + + // Each line should still have markers + assert!( + !highlights[0].markers.is_empty(), + "Line 2 should have markers" + ); + assert!( + !highlights[1].markers.is_empty(), + "Line 3 should have markers" + ); + assert!( + !highlights[2].markers.is_empty(), + "Line 4 should have markers" + ); + } + + #[test] + fn test_line_range_reduces_marker_count() { + let source = "const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nconst e = 5;"; + + // Extract all lines + let all_highlights = extract_highlights(source, 0..usize::MAX); + let all_marker_count: usize = all_highlights.iter().map(|h| h.markers.len()).sum(); + + // Extract only line index 2 (line 3 in 1-indexed terms, 2..3 because Range is exclusive at + // the end) + let filtered_highlights = extract_highlights(source, 2..3); + let filtered_marker_count: usize = + filtered_highlights.iter().map(|h| h.markers.len()).sum(); + + // Filtered should have significantly fewer markers + assert_eq!(filtered_highlights.len(), 1); + assert!(filtered_marker_count < all_marker_count); + assert!(filtered_marker_count > 0, "Should still have some markers"); + } +} diff --git a/crates/next-code-frame/src/lib.rs b/crates/next-code-frame/src/lib.rs new file mode 100644 index 0000000000000..eaffa67c568ec --- /dev/null +++ b/crates/next-code-frame/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!("../README.md")] + +mod frame; +mod highlight; +mod terminal; + +pub use frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame}; +pub use terminal::get_terminal_width; + +#[cfg(test)] +mod tests; diff --git a/crates/next-code-frame/src/terminal.rs b/crates/next-code-frame/src/terminal.rs new file mode 100644 index 0000000000000..d6fdb0c09c96b --- /dev/null +++ b/crates/next-code-frame/src/terminal.rs @@ -0,0 +1,35 @@ +/// Gets the current terminal width, or returns a default if unavailable. +/// +/// Returns 140 as a sensible default when terminal width cannot be detected. +pub fn get_terminal_width() -> usize { + // Try to detect terminal width from environment or system + #[cfg(not(target_arch = "wasm32"))] + { + // Check COLUMNS environment variable first + if let Ok(cols) = std::env::var("COLUMNS") + && let Ok(width) = cols.parse::() + && width > 0 + { + return width; + } + + // Try platform-specific terminal size detection + // For now, we'll use a simple approach + // TODO: Add terminfo/ioctl support if needed + } + + // Default to 140 characters + 140 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_terminal_width() { + let width = get_terminal_width(); + assert!(width > 0); + assert!(width >= 80); // Should be at least 80 chars + } +} diff --git a/crates/next-code-frame/src/tests.rs b/crates/next-code-frame/src/tests.rs new file mode 100644 index 0000000000000..bd6e7b0978508 --- /dev/null +++ b/crates/next-code-frame/src/tests.rs @@ -0,0 +1,631 @@ +use insta::assert_snapshot; + +use crate::{ + CodeFrameLocation, CodeFrameOptions, Location, highlight::tests::strip_ansi_codes, + render_code_frame, +}; + +/// Helper function to render code frame with highlighting enabled and ANSI codes stripped +/// This ensures highlighting doesn't break the basic formatting +fn render_for_snapshot( + source: &str, + location: &CodeFrameLocation, + options: &CodeFrameOptions, +) -> Result { + let mut opts_with_highlighting = options.clone(); + opts_with_highlighting.highlight_code = true; + + let result = render_code_frame(source, location, &opts_with_highlighting)?; + Ok(strip_ansi_codes(&result)) +} + +#[test] +fn test_simple_single_line_error() { + let source = "console.log('hello')"; + let location = CodeFrameLocation { + start: Location { line: 1, column: 1 }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | console.log('hello') + | ^ + "); +} + +#[test] +fn test_empty_source() { + let source = ""; + let location = CodeFrameLocation { + start: Location { line: 1, column: 1 }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @""); +} + +#[test] +fn test_invalid_line_number() { + let source = "line 1\nline 2"; + let location = CodeFrameLocation { + start: Location { + line: 100, + column: 1, + }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @""); +} + +#[test] +fn test_multiline_error() { + let source = "function test() {\n console.log('hello')\n return 42\n}"; + let location = CodeFrameLocation { + start: Location { line: 2, column: 3 }, + end: Some(Location { + line: 3, + column: 12, + }), + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 1 | function test() { + > 2 | console.log('hello') + | ^^^^^^^^^^^^^^^^^^^ + > 3 | return 42 + | ^^^^^^^^^^^ + 4 | } + "); +} + +#[test] +fn test_multiline_error_with_message() { + let source = "function test() {\n console.log('hello')\n return 42\n}"; + let location = CodeFrameLocation { + start: Location { line: 2, column: 3 }, + end: Some(Location { + line: 3, + column: 12, + }), + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + message: Some("Unexpected expression".to_string()), + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 1 | function test() { + > 2 | console.log('hello') + | ^^^^^^^^^^^^^^^^^^^ + > 3 | return 42 + | ^^^^^^^^^^^ Unexpected expression + 4 | } + "); +} + +#[test] +fn test_with_message() { + let source = "const x = 1"; + let location = CodeFrameLocation { + start: Location { line: 1, column: 7 }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + message: Some("Expected semicolon".to_string()), + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | const x = 1 + | ^ Expected semicolon + "); +} + +#[test] +fn test_long_line_single_error() { + // Create a very long line with error in the middle + let long_line = "a".repeat(500); + let source = format!("short\n{}\nshort", long_line); + + let location = CodeFrameLocation { + start: Location { + line: 2, + column: 250, + }, // Error in the middle of the long line + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + max_width: Some(100), + ..Default::default() + }; + + let result = render_for_snapshot(&source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 1 | ... + > 2 | ...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... + | ^ + 3 | ... + "); +} + +#[test] +fn test_long_line_at_start() { + // Error at the beginning of a long line + let long_line = "a".repeat(500); + let source = long_line.clone(); + + let location = CodeFrameLocation { + start: Location { line: 1, column: 5 }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + max_width: Some(100), + ..Default::default() + }; + + let result = render_for_snapshot(&source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... + | ^ + "); +} + +#[test] +fn test_long_line_at_end() { + // Error at the end of a long line + let long_line = "a".repeat(500); + let source = long_line.clone(); + + let location = CodeFrameLocation { + start: Location { + line: 1, + column: 495, + }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + max_width: Some(100), + ..Default::default() + }; + + let result = render_for_snapshot(&source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | ...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | ^ + "); +} + +#[test] +fn test_long_line_multiline_aligned() { + // Multiple long lines should all be truncated at the same offset + let long_line1 = "b".repeat(500); + let long_line2 = "c".repeat(500); + let long_line3 = "d".repeat(500); + let source = format!("{}\n{}\n{}", long_line1, long_line2, long_line3); + + let location = CodeFrameLocation { + start: Location { + line: 2, + column: 250, + }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + max_width: Some(100), + lines_above: 1, + lines_below: 1, + ..Default::default() + }; + + let result = render_for_snapshot(&source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 1 | ...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb... + > 2 | ...cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc... + | ^ + 3 | ...dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd... + "); +} + +#[test] +fn test_context_lines() { + let source = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7"; + let location = CodeFrameLocation { + start: Location { line: 4, column: 1 }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + lines_above: 2, + lines_below: 2, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 2 | line 2 + 3 | line 3 + > 4 | line 4 + | ^ + 5 | line 5 + 6 | line 6 + "); +} + +#[test] +fn test_gutter_width_alignment() { + let source = (1..=100) + .map(|i| format!("line {}", i)) + .collect::>() + .join("\n"); + let location = CodeFrameLocation { + start: Location { + line: 99, + column: 1, + }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + lines_above: 2, + lines_below: 1, + ..Default::default() + }; + + let result = render_for_snapshot(&source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 97 | line 97 + 98 | line 98 + > 99 | line 99 + | ^ + 100 | line 100 + "); +} + +#[test] +fn test_large_file() { + // Test with a multi-megabyte file + let line = "x".repeat(100); + let lines: Vec = (1..=50000) + .map(|i| format!("line {} {}", i, line)) + .collect(); + let source = lines.join("\n"); + + let location = CodeFrameLocation { + start: Location { + line: 25000, + column: 1, + }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + lines_above: 2, + lines_below: 2, + ..Default::default() + }; + + let result = render_for_snapshot(&source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 24998 | line 24998 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + 24999 | line 24999 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + > 25000 | line 25000 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + | ^ + 25001 | line 25001 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + 25002 | line 25002 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + "); +} + +#[test] +fn test_long_error_span() { + // Test error span that is longer than available width + let long_line = "a".repeat(500); + let source = long_line.clone(); + + let location = CodeFrameLocation { + start: Location { + line: 1, + column: 100, + }, + end: Some(Location { + line: 1, + column: 400, + }), // 300 char span + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + max_width: Some(100), + ..Default::default() + }; + + let result = render_for_snapshot(&source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | ...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + "); +} + +#[test] +fn test_markdown_file() { + // Markdown file should not crash (no syntax highlighting) + let source = r#"# Title + +This is a paragraph with some **bold** text. + +```javascript +const x = 1; +``` + +Another paragraph. +"#; + + let location = CodeFrameLocation { + start: Location { + line: 3, + column: 25, + }, + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + 1 | # Title + 2 | + > 3 | This is a paragraph with some **bold** text. + | ^ + 4 | + 5 | ```javascript + 6 | const x = 1; + "); +} + +#[test] +fn test_invalid_column_start_out_of_bounds() { + // Start column beyond line length should be clamped + let source = "short"; + let location = CodeFrameLocation { + start: Location { + line: 1, + column: 100, + }, // Way past end of line + end: None, + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | short + | ^ + "); +} + +#[test] +fn test_invalid_column_end_before_start() { + // End column before start column should show single marker at start + let source = "const x = 123;"; + let location = CodeFrameLocation { + start: Location { + line: 1, + column: 11, + }, // "123" + end: Some(Location { line: 1, column: 5 }), // Before start - invalid + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | const x = 123; + | ^ + "); +} + +#[test] +fn test_invalid_column_both_out_of_bounds() { + // Both columns out of bounds + let source = "abc"; + let location = CodeFrameLocation { + start: Location { + line: 1, + column: 10, + }, + end: Some(Location { + line: 1, + column: 20, + }), + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | abc + | ^ + "); +} + +#[test] +fn test_invalid_multiline_end_column_out_of_bounds() { + // Multiline error with end column out of bounds on last line + let source = "line1\nshort\nline3"; + let location = CodeFrameLocation { + start: Location { line: 1, column: 2 }, + end: Some(Location { + line: 2, + column: 50, + }), // Way past end of "short" + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | line1 + | ^^^ + > 2 | short + | ^^^^^^ + 3 | line3 + "); +} + +#[test] +fn test_column_semantics_explicit_end() { + // Test to clarify: is end_column inclusive or exclusive? + let source = "const x = 123;"; + + // Test 1: Mark just the first digit "1" at column 11 (1-indexed) + // With EXCLUSIVE semantics: end_column should be 12 to mark column 11 + let location = CodeFrameLocation { + start: Location { + line: 1, + column: 11, + }, + end: Some(Location { + line: 1, + column: 12, + }), // Exclusive: marks [11, 12) = column 11 only + }; + let options = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + + let result = render_for_snapshot(source, &location, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | const x = 123; + | ^ + "); + + // Test 2: Mark "123" which spans columns 11-13 (1-indexed) + // With EXCLUSIVE semantics: end_column should be 14 to mark columns 11-13 + let location2 = CodeFrameLocation { + start: Location { + line: 1, + column: 11, + }, + end: Some(Location { + line: 1, + column: 14, + }), // Exclusive: marks [11, 14) = columns 11, 12, 13 + }; + + let result = render_for_snapshot(source, &location2, &options).unwrap(); + assert_snapshot!(result, @r" + > 1 | const x = 123; + | ^^^ + "); +} + +#[test] +fn test_highlighting_doesnt_break_formatting() { + // This test ensures that enabling highlighting doesn't mess up the basic formatting + // We render with highlighting enabled but strip ANSI codes and verify output matches + let source = "const foo = 'bar';"; + let location = CodeFrameLocation { + start: Location { line: 1, column: 7 }, + end: Some(Location { + line: 1, + column: 10, // Exclusive: marks "foo" + }), + }; + + // Render WITHOUT highlighting + let options_plain = CodeFrameOptions { + use_colors: false, + highlight_code: false, + ..Default::default() + }; + let result_plain = render_code_frame(source, &location, &options_plain).unwrap(); + + // Render WITH highlighting but use_colors=false so we get plain output + let options_highlighted = CodeFrameOptions { + use_colors: false, + highlight_code: true, + ..Default::default() + }; + let result_highlighted = render_code_frame(source, &location, &options_highlighted).unwrap(); + + // With use_colors=false, both should be identical + assert_eq!( + result_plain, result_highlighted, + "Highlighting with use_colors=false should produce identical output" + ); + + // Also test with colors enabled and then stripped + let options_colored = CodeFrameOptions { + use_colors: true, + highlight_code: true, + ..Default::default() + }; + let result_colored = render_code_frame(source, &location, &options_colored).unwrap(); + + // Strip ANSI codes + let result_stripped = strip_ansi_codes(&result_colored); + + // After stripping, should match the plain output + assert_eq!( + result_plain, result_stripped, + "Highlighted output with ANSI codes stripped should match plain output" + ); +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index a0a5de2bf57a5..153d184ffcf51 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -24,6 +24,7 @@ getrandom3 = { package="getrandom", version = "0.3", default-features = false, f js-sys = "0.3.59" next-custom-transforms = { workspace = true } next-taskless = { workspace = true } +next-code-frame = { workspace = true } rustc-hash = { workspace = true } serde-wasm-bindgen = "0.4.3" serde_json = "1" diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index f5cb641afc318..85bf919f982f9 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -2,17 +2,18 @@ use std::{fmt::Debug, sync::Arc}; use anyhow::Context; use js_sys::JsString; -use next_custom_transforms::chain_transforms::{custom_before_pass, TransformOptions}; +use next_custom_transforms::chain_transforms::{TransformOptions, custom_before_pass}; use rustc_hash::FxHashMap; use swc_core::{ base::{ + Compiler, config::{JsMinifyOptions, ParseOptions}, - try_with_handler, Compiler, + try_with_handler, }, common::{ + FileName, FilePathMapping, GLOBALS, Mark, SourceMap, comments::{Comments, SingleThreadedComments}, errors::ColorConfig, - FileName, FilePathMapping, Mark, SourceMap, GLOBALS, }, ecma::ast::noop_pass, }; @@ -224,3 +225,20 @@ pub fn expand_next_js_template( ) .map_err(convert_err) } + +#[wasm_bindgen(js_name = "codeFrameColumns")] +pub fn code_frame_columns( + source: Box<[u8]>, + location: JsValue, + options: JsValue, +) -> Result { + use next_code_frame::{CodeFrameLocation, CodeFrameOptions, render_code_frame}; + let location: CodeFrameLocation = serde_wasm_bindgen::from_value(location)?; + let options: CodeFrameOptions = serde_wasm_bindgen::from_value(options)?; + render_code_frame( + str::from_utf8(&source).map_err(convert_err)?, + &location, + &options, + ) + .map_err(convert_err) +} diff --git a/packages/next/src/build/load-jsconfig.ts b/packages/next/src/build/load-jsconfig.ts index 3c45f5a34ea3f..d6b40040ceb54 100644 --- a/packages/next/src/build/load-jsconfig.ts +++ b/packages/next/src/build/load-jsconfig.ts @@ -6,6 +6,7 @@ import { getTypeScriptConfiguration } from '../lib/typescript/getTypeScriptConfi import { readFileSync } from 'fs' import isError from '../lib/is-error' import { hasNecessaryDependencies } from '../lib/has-necessary-dependencies' +import { renderCodeFrame } from '../shared/lib/errors/code-frame' let TSCONFIG_WARNED = false @@ -23,9 +24,7 @@ export function parseJsonFile(filePath: string) { return JSON5.parse(contents) } catch (err) { if (!isError(err)) throw err - const { codeFrameColumns } = - require('next/dist/compiled/babel/code-frame') as typeof import('next/dist/compiled/babel/code-frame') - const codeFrame = codeFrameColumns( + const codeFrame = renderCodeFrame( String(contents), { start: { diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index 08fed01ec5009..0e3edd2b9e265 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -34,6 +34,49 @@ export declare class ExternalObject { [K: symbol]: T } } +export interface NapiLocation { + line: number + column?: number +} +export interface NapiCodeFrameLocation { + start: NapiLocation + end?: NapiLocation +} +export interface NapiCodeFrameOptions { + /** Number of lines to show above the error (default: 2) */ + linesAbove?: number + /** Number of lines to show below the error (default: 3) */ + linesBelow?: number + /** Maximum width of the output (default: terminal width) */ + maxWidth?: number + /** Whether to use ANSI colors (default: true) */ + forceColor?: boolean + /** Whether to highlight code syntax (default: true) */ + highlightCode?: boolean + /** Optional message to display with the code frame */ + message?: string +} +/** + * Renders a code frame showing the location of an error in source code + * + * This is a Rust implementation that replaces Babel's code-frame for better: + * - Performance on large files + * - Handling of long lines + * - Memory efficiency + * + * # Arguments + * * `source` - The source code to render + * * `location` - The location to highlight (line and column numbers are 1-indexed) + * * `options` - Optional configuration + * + * # Returns + * The formatted code frame string, or empty string if the location is invalid + */ +export declare function codeFrameColumns( + source: string, + location: NapiCodeFrameLocation, + options?: NapiCodeFrameOptions | undefined | null +): string export declare function lockfileTryAcquireSync( path: string ): { __napiType: 'Lockfile' } | null diff --git a/packages/next/src/build/swc/generated-wasm.d.ts b/packages/next/src/build/swc/generated-wasm.d.ts index ca6e7525317a3..4f7bb2a25393e 100644 --- a/packages/next/src/build/swc/generated-wasm.d.ts +++ b/packages/next/src/build/swc/generated-wasm.d.ts @@ -3,14 +3,7 @@ /* tslint:disable */ /* eslint-disable */ -export function mdxCompileSync(value: string, opts: any): any -export function mdxCompile(value: string, opts: any): Promise -export function minifySync(s: string, opts: any): any -export function minify(s: string, opts: any): Promise -export function transformSync(s: any, opts: any): any export function transform(s: any, opts: any): Promise -export function parseSync(s: string, opts: any): any -export function parse(s: string, opts: any): Promise export function expandNextJsTemplate( content: Uint8Array, template_path: string, @@ -19,3 +12,15 @@ export function expandNextJsTemplate( injections: any, imports: any ): string +export function codeFrameColumns( + source: Uint8Array, + location: any, + options: any +): string +export function minifySync(s: string, opts: any): any +export function parseSync(s: string, opts: any): any +export function transformSync(s: any, opts: any): any +export function parse(s: string, opts: any): Promise +export function minify(s: string, opts: any): Promise +export function mdxCompile(value: string, opts: any): Promise +export function mdxCompileSync(value: string, opts: any): any diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index f7ed97988e67c..35bea2cc1bd55 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -21,6 +21,8 @@ import type { NapiPartialProjectOptions, NapiProjectOptions, NapiSourceDiagnostic, + NapiCodeFrameLocation, + NapiCodeFrameOptions, } from './generated-native' import type { Binding, @@ -185,6 +187,11 @@ export function getBindingsSync(): Binding { return loadedBindings } +/** Returns the loaded bindings if they are available. Otherwise returns `undefined` */ +export function tryGetBindingsSync(): Binding | undefined { + return loadedBindings +} + /** * Loads the native or wasm binding. * @@ -1296,6 +1303,17 @@ async function loadWasm(importPath = '') { imports ) }, + codeFrameColumns( + source: string, + location: NapiCodeFrameLocation, + options?: NapiCodeFrameOptions + ): string { + return rawBindings.codeFrameColumns( + Buffer.from(source), + location, + options + ) + }, lockfileTryAcquire(_filePath: string) { throw new Error( '`lockfileTryAcquire` is not supported by the wasm bindings.' @@ -1528,6 +1546,7 @@ function loadNative(importPath?: string) { lockfileUnlockSync(lockfile: Lockfile) { return bindings.lockfileUnlockSync(lockfile) }, + codeFrameColumns: bindings.codeFrameColumns, } return loadedBindings } diff --git a/packages/next/src/build/swc/types.ts b/packages/next/src/build/swc/types.ts index 3726cc10aac86..6a150e73f9850 100644 --- a/packages/next/src/build/swc/types.ts +++ b/packages/next/src/build/swc/types.ts @@ -5,6 +5,8 @@ import type { RefCell, NapiTurboEngineOptions, NapiSourceDiagnostic, + NapiCodeFrameOptions, + NapiCodeFrameLocation, } from './generated-native' export type { NapiTurboEngineOptions as TurboEngineOptions } @@ -70,6 +72,11 @@ export interface Binding { lockfileTryAcquireSync(path: string): Lockfile | null lockfileUnlock(lockfile: Lockfile): Promise lockfileUnlockSync(lockfile: Lockfile): void + codeFrameColumns( + source: string, + location: NapiCodeFrameLocation, + options?: NapiCodeFrameOptions + ): string } export type StyledString = @@ -123,6 +130,7 @@ export interface Issue { } documentationLink: string importTraces?: PlainTraceItem[][] + codeFrame?: string } export interface PlainTraceItem { fsName: string diff --git a/packages/next/src/build/turbopack-build/impl.ts b/packages/next/src/build/turbopack-build/impl.ts index 029999ff52be2..1bd235bd1dc96 100644 --- a/packages/next/src/build/turbopack-build/impl.ts +++ b/packages/next/src/build/turbopack-build/impl.ts @@ -106,7 +106,7 @@ export async function turbopackBuild(): Promise<{ let appDirOnly = NextBuildContext.appDirOnly! const entrypoints = await project.writeAllEntrypointsToDisk(appDirOnly) - printBuildErrors(entrypoints, dev) + await printBuildErrors(entrypoints, dev) let routes = entrypoints.routes if (!routes) { diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 78517025cff37..530238995f3cb 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -184,16 +184,18 @@ export function collectRoutesUsingEdgeRuntime( * Processes and categorizes build issues, then logs them as warnings, errors, or fatal errors. * Stops execution if fatal issues are encountered. * + * TODO(luke.sandberg): move codeframe formatting into turbopack so this doesn't need to be async + * * @param entrypoints - The result object containing build issues to process. * @param isDev - A flag indicating if the build is running in development mode. * @return This function does not return a value but logs or throws errors based on the issues. * @throws {Error} If a fatal issue is encountered, this function throws an error. In development mode, we only throw on * 'fatal' and 'bug' issues. In production mode, we also throw on 'error' issues. */ -export function printBuildErrors( +export async function printBuildErrors( entrypoints: TurbopackResult, isDev: boolean -): void { +): Promise { // Issues that we want to stop the server from executing const topLevelFatalIssues = [] // Issues that are true errors, but we believe we can keep running and allow the user to address the issue @@ -209,19 +211,19 @@ export function printBuildErrors( for (const issue of entrypoints.issues) { // We only want to completely shut down the server if (issue.severity === 'fatal' || issue.severity === 'bug') { - const formatted = formatIssue(issue) + const formatted = await formatIssue(issue) if (!seenFatalIssues.has(formatted)) { seenFatalIssues.add(formatted) topLevelFatalIssues.push(formatted) } } else if (isRelevantWarning(issue)) { - const formatted = formatIssue(issue) + const formatted = await formatIssue(issue) if (!seenWarnings.has(formatted)) { seenWarnings.add(formatted) topLevelWarnings.push(formatted) } } else if (issue.severity === 'error') { - const formatted = formatIssue(issue) + const formatted = await formatIssue(issue) if (isDev) { // We want to treat errors as recoverable in development // so that we can show the errors in the site and allow users diff --git a/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseScss.ts b/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseScss.ts index 308f801e2d664..80a05851baf09 100644 --- a/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseScss.ts +++ b/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseScss.ts @@ -1,6 +1,7 @@ import { bold, cyan, red, yellow } from '../../../../lib/picocolors' import { SimpleWebpackError } from './simpleWebpackError' +import { renderCodeFrame } from '../../../../shared/lib/errors/code-frame' const regexScssError = /SassError: (.+)\n\s+on line (\d+) [\s\S]*?>> (.+)\n\s*(-+)\^$/m @@ -22,9 +23,7 @@ export function getScssError( let frame: string | undefined if (fileContent) { try { - const { codeFrameColumns } = - require('next/dist/compiled/babel/code-frame') as typeof import('next/dist/compiled/babel/code-frame') - frame = codeFrameColumns( + frame = renderCodeFrame( fileContent, { start: { line: lineNumber, column } }, { forceColor: true } diff --git a/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/webpackModuleError.ts b/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/webpackModuleError.ts index 8b5706a0ced82..04930a90233ed 100644 --- a/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/webpackModuleError.ts +++ b/packages/next/src/build/webpack/plugins/wellknown-errors-plugin/webpackModuleError.ts @@ -89,7 +89,7 @@ export async function getModuleBuildError( return css } - const scss = getScssError(sourceFilename, sourceContent, err) + const scss = await getScssError(sourceFilename, sourceContent, err) if (scss !== false) { return scss } diff --git a/packages/next/src/lib/typescript/diagnosticFormatter.ts b/packages/next/src/lib/typescript/diagnosticFormatter.ts index f036c00314b49..37aeaa8b3b156 100644 --- a/packages/next/src/lib/typescript/diagnosticFormatter.ts +++ b/packages/next/src/lib/typescript/diagnosticFormatter.ts @@ -1,3 +1,4 @@ +import { renderCodeFrame } from '../../shared/lib/errors/code-frame' import { bold, cyan, red, yellow } from '../picocolors' import path from 'path' @@ -352,8 +353,6 @@ export function getFormattedDiagnostic( message += reason + '\n' if (!isLayoutOrPageError && diagnostic.file) { - const { codeFrameColumns } = - require('next/dist/compiled/babel/code-frame') as typeof import('next/dist/compiled/babel/code-frame') const pos = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!) const line = pos.line + 1 const character = pos.character + 1 @@ -374,15 +373,16 @@ export function getFormattedDiagnostic( '\n' + message - message += - '\n' + - codeFrameColumns( - diagnostic.file.getFullText(diagnostic.file.getSourceFile()), - { - start: { line: line, column: character }, - }, - { forceColor: true } - ) + const codeFrame = renderCodeFrame( + diagnostic.file.getFullText(diagnostic.file.getSourceFile()), + { + start: { line: line, column: character }, + }, + { forceColor: true } + ) + if (codeFrame) { + message += '\n' + codeFrame + } } else if (isLayoutOrPageError && appPath) { message = cyan(appPath) + '\n' + message } diff --git a/packages/next/src/next-devtools/server/shared.ts b/packages/next/src/next-devtools/server/shared.ts index 79174c9441906..83aa4f2f953b6 100644 --- a/packages/next/src/next-devtools/server/shared.ts +++ b/packages/next/src/next-devtools/server/shared.ts @@ -1,4 +1,4 @@ -import { codeFrameColumns } from 'next/dist/compiled/babel/code-frame' +import { renderCodeFrameIfNativeBindingsAvailable } from '../../shared/lib/errors/code-frame' import isInternal from '../../shared/lib/is-internal' import type { StackFrame } from '../../server/lib/parse-stack' import { ignoreListAnonymousStackFramesIfSandwiched as ignoreListAnonymousStackFramesIfSandwichedGeneric } from '../../server/lib/source-maps' @@ -72,16 +72,18 @@ export function getOriginalCodeFrame( return null } - return codeFrameColumns( - source, - { - start: { - // 1-based, but -1 means start line without highlighting - line: frame.line1 ?? -1, - // 1-based, but 0 means whole line without column highlighting - column: frame.column1 ?? 0, + return ( + renderCodeFrameIfNativeBindingsAvailable( + source, + { + start: { + // 1-based, but -1 means start line without highlighting + line: frame.line1 ?? -1, + // 1-based, but 0 means whole line without column highlighting + column: frame.column1 ?? 0, + }, }, - }, - { forceColor: colors } + { forceColor: colors } + ) ?? null ) } diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 160d5e1ec759b..e3a3c953859f0 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -646,7 +646,7 @@ export async function createHotReloaderTurbopack( // Certain crtical issues prevent any entrypoints from being constructed so return early if (!('routes' in entrypoints)) { - printBuildErrors(entrypoints, true) + await printBuildErrors(entrypoints, true) currentEntriesHandlingResolve!() currentEntriesHandlingResolve = undefined @@ -1060,26 +1060,25 @@ export async function createHotReloaderTurbopack( data: { sessionId }, } sendToClient(client, turbopackConnectedMessage) + ;(async function () { + const errors: CompilationError[] = [] - const errors: CompilationError[] = [] - - for (const entryIssues of currentEntryIssues.values()) { - for (const issue of entryIssues.values()) { - if (issue.severity !== 'warning') { - errors.push({ - message: formatIssue(issue), - }) - } else { - printNonFatalIssue(issue) + for (const entryIssues of currentEntryIssues.values()) { + for (const issue of entryIssues.values()) { + if (issue.severity !== 'warning') { + errors.push({ + message: await formatIssue(issue), + }) + } else { + await printNonFatalIssue(issue) + } } } - } - if (devIndicatorServerState.disabledUntil < Date.now()) { - devIndicatorServerState.disabledUntil = 0 - } + if (devIndicatorServerState.disabledUntil < Date.now()) { + devIndicatorServerState.disabledUntil = 0 + } - ;(async function () { const versionInfo = await getVersionInfoCached() const devToolsConfig = await getDevToolsConfig(distDir) @@ -1203,35 +1202,35 @@ export async function createHotReloaderTurbopack( if (thisEntryIssues !== undefined && thisEntryIssues.size > 0) { // If there is an error related to the requesting page we display it instead of the first error - return [...topLevelIssues, ...thisEntryIssues.values()] - .map((issue) => { - const formattedIssue = formatIssue(issue) - if (issue.severity === 'warning') { - printNonFatalIssue(issue) - return null - } else if (isWellKnownError(issue)) { + const issues = [] + for (const issue of [...topLevelIssues, ...thisEntryIssues.values()]) { + if (issue.severity === 'warning') { + await printNonFatalIssue(issue) + } else { + const formattedIssue = await formatIssue(issue) + if (isWellKnownError(issue)) { Log.error(formattedIssue) } - return new Error(formattedIssue) - }) - .filter((error) => error !== null) + issues.push(new Error(formattedIssue)) + } + } } // Otherwise, return all errors across pages const errors = [] for (const issue of topLevelIssues) { if (issue.severity !== 'warning') { - errors.push(new Error(formatIssue(issue))) + errors.push(new Error(await formatIssue(issue))) } } for (const entryIssues of currentEntryIssues.values()) { for (const issue of entryIssues.values()) { if (issue.severity !== 'warning') { - const message = formatIssue(issue) + const message = await formatIssue(issue) errors.push(new Error(message)) } else { - printNonFatalIssue(issue) + await printNonFatalIssue(issue) } } } @@ -1454,7 +1453,7 @@ export async function createHotReloaderTurbopack( case 'end': { sendEnqueuedMessages() - function addToErrorsMap( + async function addToErrorsMap( errorsMap: Map, issueMap: IssuesMap ) { @@ -1462,7 +1461,7 @@ export async function createHotReloaderTurbopack( if (issue.severity === 'warning') continue if (errorsMap.has(key)) continue - const message = formatIssue(issue) + const message = await formatIssue(issue) errorsMap.set(key, { message, @@ -1473,18 +1472,18 @@ export async function createHotReloaderTurbopack( } } - function addErrors( + async function addErrors( errorsMap: Map, issues: EntryIssuesMap ) { for (const issueMap of issues.values()) { - addToErrorsMap(errorsMap, issueMap) + await addToErrorsMap(errorsMap, issueMap) } } const errors = new Map() - addToErrorsMap(errors, currentTopLevelIssues) - addErrors(errors, currentEntryIssues) + await addToErrorsMap(errors, currentTopLevelIssues) + await addErrors(errors, currentEntryIssues) for (const client of [ ...clientsWithoutHtmlRequestId, @@ -1496,7 +1495,7 @@ export async function createHotReloaderTurbopack( } const clientErrors = new Map(errors) - addErrors(clientErrors, state.clientIssues) + await addErrors(clientErrors, state.clientIssues) sendToClient(client, { type: HMR_MESSAGE_SENT_TO_BROWSER.BUILT, diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index f221ad5fe4290..6d69f71050764 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -68,9 +68,9 @@ function shouldEmitOnceWarning(issue: Issue): boolean { /// Print out an issue to the console which should not block /// the build by throwing out or blocking error overlay. -export function printNonFatalIssue(issue: Issue) { +export async function printNonFatalIssue(issue: Issue) { if (isRelevantWarning(issue) && shouldEmitOnceWarning(issue)) { - Log.warn(formatIssue(issue)) + Log.warn(await formatIssue(issue)) } } diff --git a/packages/next/src/shared/lib/errors/code-frame.ts b/packages/next/src/shared/lib/errors/code-frame.ts new file mode 100644 index 0000000000000..dc468b0fa3376 --- /dev/null +++ b/packages/next/src/shared/lib/errors/code-frame.ts @@ -0,0 +1,56 @@ +import { tryGetBindingsSync, getBindingsSync } from '../../../build/swc' +import type { + NapiCodeFrameLocation, + NapiCodeFrameOptions, +} from '../../../build/swc/generated-native' + +/** + * Renders a code frame showing the location of an error in source code + * + * Performs best effort syntax highlighting using ANSI codes and + * + * Uses the native Rust implementation for: + * - Better performance on large files + * - Proper handling of long lines + * - Memory efficiency + * - Accurate syntax highlighting using SWC lexer + * + * @param file - The source code to render + * @param location - The location to highlight (line and column numbers are 1-indexed) + * @param options - Optional configuration + * @returns The formatted code frame string + * @throws if the native bindings have not been installed + */ +export function renderCodeFrame( + file: string, + location: NapiCodeFrameLocation, + options?: NapiCodeFrameOptions +): string { + return getBindingsSync().codeFrameColumns( + file, + location, + defaultOptions(options) + ) +} +/** Same as {@code codeFrame} but returns {@code undefined} if the native bindings have not been loaded yet. */ +export function renderCodeFrameIfNativeBindingsAvailable( + file: string, + location: NapiCodeFrameLocation, + options?: NapiCodeFrameOptions +): string | undefined { + return tryGetBindingsSync()?.codeFrameColumns( + file, + location, + defaultOptions(options) + ) +} + +function defaultOptions( + options: NapiCodeFrameOptions = {} +): NapiCodeFrameOptions { + // default to the terminal width. + if (options.maxWidth === undefined) { + options.maxWidth = process.stdout.columns + } + return options +} diff --git a/packages/next/src/shared/lib/turbopack/utils.ts b/packages/next/src/shared/lib/turbopack/utils.ts index ed73764f9f92d..0cae157baa269 100644 --- a/packages/next/src/shared/lib/turbopack/utils.ts +++ b/packages/next/src/shared/lib/turbopack/utils.ts @@ -11,6 +11,7 @@ import { deobfuscateText } from '../magic-identifier' import type { EntryKey } from './entry-key' import * as Log from '../../../build/output/log' import type { NextConfigComplete } from '../../../server/config-shared' +import { renderCodeFrame } from '../errors/code-frame' type IssueKey = `${Issue['severity']}-${Issue['filePath']}-${string}-${string}` export type IssuesMap = Map @@ -90,7 +91,7 @@ export function processIssues( } } -export function formatIssue(issue: Issue) { +export async function formatIssue(issue: Issue) { const { filePath, title, description, source, importTraces } = issue let { documentationLink } = issue const formattedTitle = renderStyledStringToErrorAnsi(title).replace( @@ -132,24 +133,25 @@ export function formatIssue(issue: Issue) { !isInternal(filePath) ) { const { start, end } = source.range - const { codeFrameColumns } = - require('next/dist/compiled/babel/code-frame') as typeof import('next/dist/compiled/babel/code-frame') - - message += - codeFrameColumns( - source.source.content, - { - start: { - line: start.line + 1, - column: start.column + 1, - }, - end: { - line: end.line + 1, - column: end.column + 1, - }, + + // TODO(lukesandberg): move codeFrame formatting into turbopack + const codeFrame = renderCodeFrame( + source.source.content, + { + start: { + line: start.line + 1, + column: start.column + 1, + }, + end: { + line: end.line + 1, + column: end.column + 1, }, - { forceColor: true } - ).trim() + '\n\n' + }, + { forceColor: true } + ).trim() + if (codeFrame) { + message += codeFrame + '\n\n' + } } if (description) {