Reimplement code frame rendering in native code (#85592)

## What

Replaces the `@babel/code-frame` dependency with a new Rust-based implementation (`next-code-[frame](https://github.com/arthurprs/qfilter/pull/20#issuecomment-3986882055)` crate) for rendering code frames in error messages.

### Why

- **Crash fix**: `@babel/code-frame` uses the `js-tokens` library for syntax highlighting, which has [known issues](https://github.com/lydell/js-tokens?tab=readme-ov-file#known-failures) with large string literals and long lines. This can cause Next.js to throw RangeErrors when rendering errors, hiding the original issue!
- **Long line support**: The old implementation had no concept of terminal width, dumping entire lines into the output. The new implementation uses "horizontal scrolling" — truncating lines and centering the error location in the visible window.
- **Performance**: The Rust implementation only processes the visible line range (typically ~6 lines), not the entire file. Syntax highlighting uses a skip-scan heuristic to start tokenizing near the visible window rather than from byte 0.
- **Dependency reduction**: Drops the semi-unmaintained `@babel/code-frame` bundled dependency in favor of code we control.

### Benchmarks

In-process benchmarks comparing `render_code_frame()` (Rust, via criterion) against `codeFrameColumns()` (Babel, via hrtime with DCE prevention). Both have syntax highlighting and color output enabled. No process startup or file I/O is included in the measurement.

| Scenario | `next-code-frame` (Rust) | `@babel/code-frame` | Speedup |
|---|---|---|---|
| Small file (~490 lines TSX) | **5.4 µs** | 507 µs | **~94x** |
| Large file (~39k lines JS) | **143 µs** | 82.9 ms | **~580x** |
| Large file minified | **51 µs** | - | **-** |

The gap widens with file size because Babel's `highlight()` runs a regex tokenizer over the **entire source** before slicing to the visible window, while the Rust implementation uses a windowed line index and skip-scan heuristic — only processing the visible window regardless of file size.  However, for minified files we do end up tokenizing the whole thing so we end up being only 8x faster

### How

- New `crates/next-code-frame/` Rust crate with:
  - Frame rendering with terminal-width-aware horizontal scrolling
  - Regex-based syntax highlighting (matching Babel's color scheme)
  - Skip-scan heuristic for O(1)-ish highlighting regardless of file size
  - Windowed line index that only scans/stores offsets for the visible region
  - Comprehensive test suite (800+ lines)
- Exposed via both NAPI (native) and WASM bindings
- JS wrappers in `packages/next/src/shared/lib/errors/`:
  - `code-frame.ts` — primary wrapper using native bindings
  - `optional-code-frame.ts` — graceful fallback returning `undefined` if bindings are unavailable
- All existing callsites (`diagnosticFormatter`, `parseScss`, dev overlay, turbopack utils) updated
- for `patch-error-inspect.ts` i adopted an `injection` style approach to avoid coupling to the native dependency

### Concerns / review focus areas

- **Reliability**: The regex-based tokenizer is best-effort and language-agnostic — it should never crash on invalid syntax, but highlighting accuracy may differ from Babel's `js-tokens` in edge cases.
- **Native dependency**: This moves code frame rendering into the native binary. Performance should be better, but worth verifying there are no regressions in environments where native bindings behave differently (e.g. WASM fallback path).
- **Regressions**: The output format and color scheme closely match Babel's, but there may be subtle differences. The horizontal scrolling behavior is new.

Fixes #85357
Closes PACK-5754
This commit is contained in:
Luke Sandberg
2026-03-03 04:59:11 -08:00
committed by GitHub
parent ac86a613cf
commit ce14ca88a4
48 changed files with 3872 additions and 151 deletions
Generated
+22 -4
View File
@@ -3448,16 +3448,16 @@ dependencies = [
[[package]]
name = "insta"
version = "1.41.1"
version = "1.46.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e9ffc4d4892617c50a928c52b2961cb5174b6fc6ebf252b2fac9d21955c48b8"
checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4"
dependencies = [
"console",
"lazy_static",
"linked-hash-map",
"once_cell",
"regex",
"serde",
"similar",
"tempfile",
]
[[package]]
@@ -4512,6 +4512,22 @@ dependencies = [
"turbopack-trace-utils",
]
[[package]]
name = "next-code-frame"
version = "0.0.1"
dependencies = [
"anyhow",
"clap",
"codspeed-criterion-compat",
"insta",
"memchr",
"phf",
"regex",
"regex-automata",
"serde",
"unicode-width 0.2.1",
]
[[package]]
name = "next-core"
version = "0.1.0"
@@ -4626,6 +4642,7 @@ dependencies = [
"napi-derive",
"next-api",
"next-build",
"next-code-frame",
"next-core",
"next-custom-transforms",
"next-taskless",
@@ -10987,6 +11004,7 @@ dependencies = [
"getrandom 0.3.3",
"js-sys",
"mdxjs",
"next-code-frame",
"next-custom-transforms",
"next-taskless",
"rustc-hash 2.1.1",
+3
View File
@@ -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",
@@ -284,6 +285,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", default-features = false }
next-taskless = { path = "crates/next-taskless" }
@@ -410,6 +412,7 @@ lightningcss-napi = { version = "0.4.6", default-features = false, features = [
] }
lzzzz = "2.0.0"
markdown = "1.0.0-alpha.18"
memchr = "2.7.4"
mime = "0.3.16"
napi = { version = "2", default-features = false, features = [
"napi3",
+34
View File
@@ -0,0 +1,34 @@
[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
[[bin]]
name = "code_frame"
path = "src/bin/code_frame.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
memchr = { workspace = true }
phf = { version = "0.11", features = ["macros"] }
regex = { workspace = true }
regex-automata = "0.4"
serde = { workspace = true }
unicode-width = "0.2"
[dev-dependencies]
criterion = { workspace = true }
insta = { version = "1.43.1", features = ["yaml"] }
[[bench]]
name = "code_frame_bench"
harness = false
+82
View File
@@ -0,0 +1,82 @@
# 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 a language-agnostic regex tokenizer for best-effort syntax highlighting
## 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 - the library accepts file content as arguments rather than performing IO
## CLI
A `code_frame` binary is included for quick testing. Pass a filename and start/end positions (1-indexed `line:column`):
```bash
# Highlight a single position
cargo run -p next-code-frame --bin code_frame -- src/app.tsx 10:5
# Highlight a range
cargo run -p next-code-frame --bin code_frame -- src/app.tsx 10:5 10:20
# With an error message
cargo run -p next-code-frame --bin code_frame -- -m "Unexpected token" src/app.tsx 10:5 10:20
```
## Syntax highlighting
The highlighter uses a single compiled regex pass over the visible content to
tokenize strings, comments, numbers, regex literals, and identifiers. Keywords
are identified via a compile-time perfect hash set (`phf`).
### Skip-scan heuristic
For large files, scanning from byte 0 is expensive — the regex tokenizer
dominates runtime. To avoid this, `extract_highlights()` walks backwards from
the visible window looking for a **blank line** and starts the scan there.
A blank line is a safe restart point for single-line constructs (strings,
line comments, regex literals) because they cannot span blank lines.
**Known limitation:** The heuristic can produce incorrect highlighting when a
multi-line construct (block comment or template literal) contains a blank line
that falls between the scan start and the visible window. In this case the
scanner misses the opening delimiter and the closing delimiter / trailing code
may lose its expected coloring. For example:
```js
/** sneaky
*/
const after = 1; // `*/` may lose comment highlighting
```
This is a deliberate tradeoff — blank lines inside block comments or template
literals that span the window boundary are vanishingly rare in practice, and
the consequence is only slightly wrong highlighting, never a failure or missing output.
### Byte-level skip for long lines
When the visible window starts far into a long line (>200 bytes from the
line-level scan start), the heuristic additionally scans backwards from the
visible start for a `;` and restarts the tokenizer there. This is critical for
minified files where the entire source may be a single line — without it the
scanner would tokenize hundreds of kilobytes of invisible content.
**Known limitation:** The `;` can land inside a string literal, causing an
unbalanced quote that cascades incorrect highlighting across the visible window.
In practice minified code has frequent `;` between statements so this rarely
triggers, and the consequence is only incorrect highlighting, never a failure.
## Features
- Caller-provided output width (no terminal detection — sans-io)
- 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
@@ -0,0 +1,207 @@
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Language, Location, render_code_frame};
/// Generate a realistic small TypeScript/React file (~300 lines).
fn generate_small_file() -> String {
let mut lines = Vec::with_capacity(300);
lines.push("import React, { useState, useEffect, useCallback, useMemo } from 'react'");
lines.push("import { useRouter } from 'next/router'");
lines.push("import Link from 'next/link'");
lines.push("");
lines.push("interface User {");
lines.push(" id: string");
lines.push(" name: string");
lines.push(" email: string");
lines.push(" avatar?: string");
lines.push(" role: 'admin' | 'user' | 'moderator'");
lines.push(" createdAt: Date");
lines.push("}");
lines.push("");
// Pad with realistic component code
for i in 0..40 {
lines.push("function ComponentPart() {");
// Use a leaked string so we get &'static str
let s: &'static str =
Box::leak(format!(" const [state{i}, setState{i}] = useState(null)").into_boxed_str());
lines.push(s);
lines.push(" useEffect(() => {");
let s: &'static str = Box::leak(
format!(" fetch('/api/data/{i}').then(r => r.json()).then(setState{i})")
.into_boxed_str(),
);
lines.push(s);
lines.push(" }, [])");
lines.push(" return (");
lines.push(" <div className=\"flex items-center justify-center p-4\">");
let s: &'static str =
Box::leak(format!(" <span>{{state{i}?.name}}</span>").into_boxed_str());
lines.push(s);
lines.push(" </div>");
lines.push(" )");
lines.push("}");
lines.push("");
}
lines.join("\n")
}
/// Generate a large bundled JS file (~30k lines) mimicking react-dom.development.js.
fn generate_large_file(minified: bool) -> String {
let mut lines = Vec::with_capacity(30_000);
lines.push("/**");
lines.push(" * @license React");
lines.push(" * react-dom.development.js");
lines.push(" *");
lines.push(" * Copyright (c) Meta Platforms, Inc. and affiliates.");
lines.push(" */");
lines.push("'use strict';");
lines.push("");
lines.push("(function (global, factory) {");
lines.push(
" typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, \
require('react')) :",
);
lines.push(
" typeof define === 'function' && define.amd ? define(['exports', 'react'], factory) :",
);
lines.push(" (global = global || self, factory(global.ReactDOM = {}, global.React));");
lines.push("}(this, (function (exports, React) { 'use strict';");
lines.push("");
// Generate ~30k lines of realistic bundled code
for i in 0..3000 {
lines.push(
"function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {",
);
// Leaked strings for &'static str
let s: &'static str = Box::leak(
format!(
" var isUnkeyedTopLevelFragment = typeof newChild === 'object' && newChild !== \
null && newChild.type === REACT_FRAGMENT_TYPE_{i} && newChild.key === null;"
)
.into_boxed_str(),
);
lines.push(s);
lines.push(" if (isUnkeyedTopLevelFragment) {");
lines.push(" newChild = newChild.props.children;");
lines.push(" }");
lines.push(" if (typeof newChild === 'object' && newChild !== null) {");
lines.push(" switch (newChild.$$typeof) {");
lines.push(" case REACT_ELEMENT_TYPE:");
lines.push(
" return placeSingleChild(reconcileSingleElement(returnFiber, \
currentFirstChild, newChild, lanes));",
);
lines.push(" }");
lines.push(" }");
lines.push("}");
lines.push("");
}
lines.push("})));");
lines.join(if minified { " " } else { "\n" })
}
fn bench_small_file(c: &mut Criterion) {
let source = generate_small_file();
let line_count = source.lines().count();
let mid = line_count / 2;
let location = CodeFrameLocation {
start: Location {
line: mid,
column: Some(10),
},
end: None,
};
let options = CodeFrameOptions {
highlight_code: true,
color: true,
max_width: 100,
language: Language::JavaScript,
..Default::default()
};
c.bench_function(&format!("rust: small file ({line_count} lines)"), |b| {
b.iter(|| {
let result = render_code_frame(black_box(&source), black_box(&location), &options);
black_box(result).unwrap();
});
});
}
fn bench_large_file(c: &mut Criterion) {
let source = generate_large_file(false);
let line_count = source.lines().count();
let mid = line_count / 2;
let location = CodeFrameLocation {
start: Location {
line: mid,
column: Some(10),
},
end: None,
};
let options = CodeFrameOptions {
highlight_code: true,
color: true,
max_width: 100,
language: Language::JavaScript,
..Default::default()
};
c.bench_function(&format!("rust: large file ({line_count} lines)"), |b| {
b.iter(|| {
let result = render_code_frame(black_box(&source), black_box(&location), &options);
black_box(result).unwrap();
});
});
}
fn bench_minified_file(c: &mut Criterion) {
// Take the large file and collapse lines by stripping whitespace after `;`
// to simulate a minified bundle with very long lines.
let source = generate_large_file(true);
let line_count = source.lines().count();
// Point at a column somewhere in the middle of the long line
let mid_col = source.lines().next().map_or(100, |l| l.len() / 2);
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(mid_col),
},
end: None,
};
let options = CodeFrameOptions {
highlight_code: true,
color: true,
max_width: 100,
language: Language::JavaScript,
..Default::default()
};
c.bench_function(
&format!("rust: minified file ({line_count} lines, ~{mid_col} col)"),
|b| {
b.iter(|| {
let result = render_code_frame(black_box(&source), black_box(&location), &options);
black_box(result).unwrap();
});
},
);
}
criterion_group!(
benches,
bench_small_file,
bench_large_file,
bench_minified_file
);
criterion_main!(benches);
@@ -0,0 +1,83 @@
use std::{fs, process::ExitCode};
use clap::Parser;
use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame};
/// Render a code frame for the given file and position.
///
/// Lines and columns are 1-indexed.
#[derive(Parser)]
#[command(name = "code_frame")]
struct Args {
/// Source file to render
file: String,
/// Start position (line:column, 1-indexed)
start: String,
/// End position (line:column, 1-indexed)
end: Option<String>,
/// Message to display with the error
#[arg(short, long)]
message: Option<String>,
/// Maximum output width in columns
#[arg(short = 'w', long, default_value_t = 100)]
max_width: usize,
}
fn parse_position(s: &str) -> Option<Location> {
let (line, col) = s.split_once(':')?;
Some(Location {
line: line.parse().ok()?,
column: Some(col.parse().ok()?),
})
}
fn main() -> ExitCode {
let args = Args::parse();
let source = match fs::read_to_string(&args.file) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {e}", args.file);
return ExitCode::FAILURE;
}
};
let start = match parse_position(&args.start) {
Some(loc) => loc,
None => {
eprintln!("Invalid start position: {}", args.start);
return ExitCode::FAILURE;
}
};
let end = match args.end.as_deref().map(parse_position) {
Some(Some(loc)) => Some(loc),
Some(None) => {
eprintln!("Invalid end position: {}", args.end.unwrap());
return ExitCode::FAILURE;
}
None => None,
};
let location = CodeFrameLocation { start, end };
let options = CodeFrameOptions {
message: args.message,
max_width: args.max_width,
..Default::default()
};
match render_code_frame(&source, &location, &options) {
Ok(Some(frame)) => {
println!("{frame}");
ExitCode::SUCCESS
}
Ok(None) => {
// Location is out of range (e.g., empty file or line past EOF)
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error rendering code frame: {e}");
ExitCode::FAILURE
}
}
}
+538
View File
@@ -0,0 +1,538 @@
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,
}
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
#![doc = include_str!("../README.md")]
mod frame;
mod highlight;
pub use frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame};
pub use highlight::Language;
#[cfg(test)]
mod tests;
+920
View File
@@ -0,0 +1,920 @@
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.
/// Returns `None` when the code frame cannot be produced (e.g., out-of-range location).
fn render_for_snapshot(
source: &str,
location: &CodeFrameLocation,
options: &CodeFrameOptions,
) -> Result<Option<String>, anyhow::Error> {
let mut opts_with_highlighting = options.clone();
opts_with_highlighting.highlight_code = true;
let result = render_code_frame(source, location, &opts_with_highlighting)?;
Ok(result.map(|s| strip_ansi_codes(&s)))
}
#[test]
fn test_simple_single_line_error() {
let source = "console.log('hello')";
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(1),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
> 1 | console.log('hello')
| ^
");
}
#[test]
fn test_empty_source() {
let source = "";
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(1),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options).unwrap();
assert!(result.is_none(), "empty source should return None");
}
#[test]
fn test_invalid_line_number() {
let source = "line 1\nline 2";
let location = CodeFrameLocation {
start: Location {
line: 100,
column: Some(1),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options).unwrap();
assert!(
result.is_none(),
"out-of-range line number should return None"
);
}
#[test]
fn test_zero_start_column() {
let source = "hello world";
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(0),
},
end: None,
};
let options = CodeFrameOptions::default();
let result = render_code_frame(source, &location, &options);
assert!(result.is_err(), "column 0 should be an error");
}
#[test]
fn test_zero_end_column() {
let source = "hello world";
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(1),
},
end: Some(Location {
line: 1,
column: Some(0),
}),
};
let options = CodeFrameOptions::default();
let result = render_code_frame(source, &location, &options);
assert!(result.is_err(), "end column 0 should be an error");
}
#[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: Some(3),
},
end: Some(Location {
line: 3,
column: Some(12),
}),
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(3),
},
end: Some(Location {
line: 3,
column: Some(12),
}),
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
message: Some("Unexpected expression".to_string()),
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(7),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
message: Some("Expected semicolon".to_string()),
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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 mut long_line = "a".repeat(500);
long_line.replace_range(249..250, "x"); // Mark error location (column 250)
let source = format!("short\n{}\nshort", long_line);
let location = CodeFrameLocation {
start: Location {
line: 2,
column: Some(250),
}, // Error in the middle of the long line
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
max_width: 100,
..Default::default()
};
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
1 | ...
> 2 | ...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
| ^
3 | ...
");
}
#[test]
fn test_long_line_at_start() {
// Error at the beginning of a long line
let mut long_line = "a".repeat(500);
long_line.replace_range(4..5, "x"); // Mark error location (column 5)
let source = long_line.clone();
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(5),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
max_width: 100,
..Default::default()
};
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
> 1 | aaaaxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
| ^
");
}
#[test]
fn test_long_line_at_end() {
// Error at the end of a long line
let mut long_line = "a".repeat(500);
long_line.replace_range(494..495, "x"); // Insert 'x' at error location (column 495)
let source = long_line.clone();
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(495),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
max_width: 100,
..Default::default()
};
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
> 1 | ...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaxaaaaa
| ^
");
}
#[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 mut long_line2 = "c".repeat(500);
long_line2.replace_range(249..250, "x"); // Mark error location (column 250)
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: Some(250),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
max_width: 100,
lines_above: 1,
lines_below: 1,
..Default::default()
};
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
1 | ...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb...
> 2 | ...ccccccccccccccccccccccccccccccccccccccccccccxccccccccccccccccccccccccccccccccccccccccccc...
| ^
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: Some(1),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
lines_above: 2,
lines_below: 2,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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::<Vec<_>>()
.join("\n");
let location = CodeFrameLocation {
start: Location {
line: 99,
column: Some(1),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
lines_above: 2,
lines_below: 1,
..Default::default()
};
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.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 mut lines: Vec<String> = (1..=50000)
.map(|i| format!("line {} {}", i, line))
.collect();
// Mark the error line so the `^` visibly points at something distinct
lines[24999] = format!("ERROR on line 25000 {}", line);
let source = lines.join("\n");
let location = CodeFrameLocation {
start: Location {
line: 25000,
column: Some(1),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
lines_above: 2,
lines_below: 2,
max_width: 140,
..Default::default()
};
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
24998 | line 24998 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
24999 | line 24999 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
> 25000 | ERROR on 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: Some(100),
},
end: Some(Location {
line: 1,
column: Some(400),
}), // 300 char span
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
max_width: 100,
..Default::default()
};
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.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: Some(25),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(100),
}, // Way past end of line
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(11),
}, // "123"
end: Some(Location {
line: 1,
column: Some(5),
}), // Before start - invalid
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(10),
},
end: Some(Location {
line: 1,
column: Some(20),
}),
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(2),
},
end: Some(Location {
line: 2,
column: Some(50),
}), // Way past end of "short"
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(11),
},
end: Some(Location {
line: 1,
column: Some(12),
}), // Exclusive: marks [11, 12) = column 11 only
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.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: Some(11),
},
end: Some(Location {
line: 1,
column: Some(14),
}), // Exclusive: marks [11, 14) = columns 11, 12, 13
};
let result = render_for_snapshot(source, &location2, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
> 1 | const x = 123;
| ^^^
");
}
#[test]
fn test_highlighting_doesnt_break_formatting() {
// Verify highlighting invariant: stripping ANSI from highlighted output must match plain.
// Tests both ASCII and multi-byte (CJK) sources to ensure byte-based SWC token
// boundaries align correctly with char boundaries.
fn assert_highlighting_roundtrips(source: &str, location: &CodeFrameLocation) {
let options_plain = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result_plain = render_code_frame(source, location, &options_plain)
.unwrap()
.unwrap();
// highlight_code=true with color=false should be identical to plain
let options_highlighted = CodeFrameOptions {
color: false,
highlight_code: true,
..Default::default()
};
let result_highlighted = render_code_frame(source, location, &options_highlighted)
.unwrap()
.unwrap();
assert_eq!(
result_plain, result_highlighted,
"Highlighting with color=false should produce identical output"
);
// color=true with ANSI stripped should also match plain
let options_colored = CodeFrameOptions {
color: true,
highlight_code: true,
..Default::default()
};
let result_colored = render_code_frame(source, location, &options_colored)
.unwrap()
.unwrap();
assert_eq!(
result_plain,
strip_ansi_codes(&result_colored),
"Highlighted output with ANSI codes stripped should match plain output"
);
}
// ASCII source
assert_highlighting_roundtrips(
"const foo = 'bar';",
&CodeFrameLocation {
start: Location {
line: 1,
column: Some(7),
},
end: Some(Location {
line: 1,
column: Some(10),
}),
},
);
// Multi-byte (CJK) source — exercises byte-based token boundary alignment
assert_highlighting_roundtrips(
"const 名前 = 'こんにちは';",
&CodeFrameLocation {
start: Location {
line: 1,
column: Some(1),
},
end: None,
},
);
}
// =============================================================================
// Multi-byte character tests
// =============================================================================
#[test]
fn test_multibyte_cjk_no_truncation() {
// CJK characters: each is 3 bytes in UTF-8
// "const 変数 = '値';" has column positions that differ in byte vs char space
let source = "const 変数 = '値';";
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(7),
}, // byte offset of '変'
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
> 1 | const 変数 = '値';
| ^
");
}
#[test]
fn test_multibyte_cjk_with_truncation() {
// Long line with CJK characters forcing truncation.
// Each CJK char is 3 bytes. "あ".repeat(200) = 200 chars, 600 bytes.
// Error in the middle — this is the scenario that would panic without char-boundary snapping.
let cjk_line = "あ".repeat(200);
let source = format!("short\n{}\nshort", cjk_line);
let location = CodeFrameLocation {
start: Location {
line: 2,
// Byte offset pointing to the 100th character (byte 297 = 99*3)
column: Some(99 * 3 + 1),
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
max_width: 80,
..Default::default()
};
// This must not panic — the truncation offset must snap to a char boundary
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.unwrap();
// Just verify it produces output and contains the CJK chars
assert!(!result.is_empty(), "Should produce non-empty output");
assert!(result.contains("あ"), "Should contain CJK characters");
assert!(result.contains("^"), "Should contain marker");
}
#[test]
fn test_multibyte_emoji() {
// Emoji: '😀' is 4 bytes in UTF-8
let source = "const x = '😀😀😀';";
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(12), // byte offset of first emoji within the string
},
end: Some(Location {
line: 1,
column: Some(24), // exclusive end, 3 emojis * 4 bytes = 12 bytes
}),
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
..Default::default()
};
let result = render_for_snapshot(source, &location, &options)
.unwrap()
.unwrap();
assert_snapshot!(result, @r"
> 1 | const x = '😀😀😀';
| ^^^^^^
");
}
#[test]
fn test_multibyte_mixed_with_truncation() {
// Mix of ASCII and CJK with a long line forcing truncation.
// The truncation offset may land between an ASCII and CJK boundary.
let ascii_prefix = "a".repeat(100);
let cjk_section = "漢字".repeat(50); // 100 CJK chars, 300 bytes
let ascii_suffix = "z".repeat(100);
let source = format!("{}{}{}", ascii_prefix, cjk_section, ascii_suffix);
// Error in the CJK section
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(150), // somewhere in the CJK section (byte offset)
},
end: None,
};
let options = CodeFrameOptions {
color: false,
highlight_code: false,
max_width: 80,
..Default::default()
};
// Must not panic
let result = render_for_snapshot(&source, &location, &options)
.unwrap()
.unwrap();
assert!(!result.is_empty(), "Should produce non-empty output");
assert!(result.contains("^"), "Should contain marker");
}
#[test]
fn test_multibyte_cjk_truncation_with_highlighting() {
// The most challenging scenario: truncation + highlighting + multi-byte chars.
// SWC highlight markers are byte-based, truncation offset is byte-based,
// and the char boundary snapping must keep them consistent.
let cjk_line = "あ".repeat(200);
let source = format!("const x = '{}';", cjk_line);
let location = CodeFrameLocation {
start: Location {
line: 1,
column: Some(12), // byte offset of first 'あ' in the string literal
},
end: None,
};
let options = CodeFrameOptions {
color: true,
highlight_code: true,
max_width: 80,
..Default::default()
};
// Must not panic — exercises truncation + highlighting with multi-byte
let result = render_code_frame(&source, &location, &options)
.unwrap()
.unwrap();
// Strip ANSI and verify the plain text is reasonable
let stripped = strip_ansi_codes(&result);
assert!(stripped.contains("あ"), "Should contain CJK characters");
assert!(stripped.contains("^"), "Should contain marker");
}
+1
View File
@@ -65,6 +65,7 @@ futures-util = { workspace = true }
owo-colors = { workspace = true }
napi = { workspace = true }
napi-derive = { workspace = true }
next-code-frame = { workspace = true }
next-custom-transforms = { workspace = true }
next-taskless = { workspace = true }
rand = { workspace = true }
+108
View File
@@ -0,0 +1,108 @@
use napi::bindgen_prelude::*;
use napi_derive::napi;
use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Language, Location, render_code_frame};
/// Default max width when the caller doesn't provide one (e.g., no terminal).
const DEFAULT_MAX_WIDTH: u32 = 100;
#[napi(object)]
pub struct NapiLocation {
pub line: u32,
pub column: Option<u32>,
}
impl From<NapiLocation> for Location {
fn from(loc: NapiLocation) -> Self {
Location {
line: loc.line as usize,
column: loc.column.map(|c| c as usize),
}
}
}
#[napi(object)]
pub struct NapiCodeFrameLocation {
pub start: NapiLocation,
pub end: Option<NapiLocation>,
}
impl From<NapiCodeFrameLocation> 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<u32>,
/// Number of lines to show below the error (default: 3)
pub lines_below: Option<u32>,
/// Maximum width of the output in columns (default: 100)
pub max_width: Option<u32>,
/// Whether to use ANSI colors (default: false)
pub color: Option<bool>,
/// Whether to highlight code syntax (default: follows color)
///
/// This might be useful if syntax highlighting is very expensive or known to be useless for
/// this file. The current syntax rules are optimized for javascript but should work well with
/// other C-like languages.
pub highlight_code: Option<bool>,
/// Optional message to display with the code frame
pub message: Option<String>,
/// Language hint for keyword highlighting: "javascript" (default) or "css"
pub language: Option<String>,
}
fn parse_language(s: &Option<String>) -> Language {
match s.as_deref() {
Some("css") => Language::Css,
_ => Language::JavaScript,
}
}
impl From<NapiCodeFrameOptions> 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.unwrap_or(DEFAULT_MAX_WIDTH) as usize,
color: opts.color.unwrap_or(false),
highlight_code: opts.highlight_code.unwrap_or(opts.color.unwrap_or(false)),
message: opts.message,
language: parse_language(&opts.language),
}
}
}
/// 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 `undefined` if the location is out of
/// range (e.g., empty source or line number past end of file).
#[napi]
pub fn code_frame_columns(
source: String,
location: NapiCodeFrameLocation,
options: Option<NapiCodeFrameOptions>,
) -> Result<Option<String>> {
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:?}")))
}
+1
View File
@@ -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;
+1
View File
@@ -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"
+19
View File
@@ -225,3 +225,22 @@ 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<Option<String>, JsError> {
console_error_panic_hook::set_once();
let location: next_code_frame::CodeFrameLocation = serde_wasm_bindgen::from_value(location)?;
let options: next_code_frame::CodeFrameOptions = serde_wasm_bindgen::from_value(options)?;
next_code_frame::render_code_frame(
str::from_utf8(&source)
.map_err(|e| JsError::new(&format!("Failed to render code frame: {e}")))?,
&location,
&options,
)
.map_err(|e| JsError::new(&format!("Failed to render code frame: {e}")))
}
+2 -3
View File
@@ -1,14 +1,13 @@
use js_sys::JsString;
use mdxjs::{Options, compile};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::future_to_promise;
#[wasm_bindgen(js_name = "mdxCompileSync")]
pub fn mdx_compile_sync(value: JsString, opts: JsValue) -> Result<JsValue, JsValue> {
let value: String = value.into();
let option: Options = serde_wasm_bindgen::from_value(opts)?;
let option: mdxjs::Options = serde_wasm_bindgen::from_value(opts)?;
compile(value.as_str(), &option)
mdxjs::compile(value.as_str(), &option)
.map(|v| serde_wasm_bindgen::to_value(&v).expect("Should able to convert to JsValue"))
.map_err(|v| {
serde_wasm_bindgen::to_value(&v.to_string()).expect("Should able to convert to JsValue")
+2 -1
View File
@@ -1084,5 +1084,6 @@
"1083": "Route \"%s\": Runtime data such as \\`cookies()\\`, \\`headers()\\`, \\`params\\`, or \\`searchParams\\` was accessed outside of \\`<Suspense>\\`. This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/blocking-route",
"1084": "Route \"%s\": Uncached data or \\`connection()\\` was accessed outside of \\`<Suspense>\\`. This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/blocking-route",
"1085": "Route \"%s\": Runtime data such as \\`cookies()\\`, \\`headers()\\`, \\`params\\`, or \\`searchParams\\` was accessed inside \\`generateMetadata\\` or you have file-based metadata such as icons that depend on dynamic params segments. Except for this instance, the page would have been entirely prerenderable which may have been the intended behavior. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata",
"1086": "Route \"%s\": %s This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport"
"1086": "Route \"%s\": %s This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport",
"1087": "Failed to parse \"%s\":\\n%s%s"
}
-1
View File
@@ -130,7 +130,6 @@
"sharp": "^0.34.5"
},
"devDependencies": {
"@babel/code-frame": "7.26.2",
"@babel/core": "7.26.10",
"@babel/eslint-parser": "7.24.6",
"@babel/generator": "7.27.0",
+5
View File
@@ -1002,6 +1002,11 @@ export default async function build(
// Install the native bindings early so we can have synchronous access later.
await installBindings(config.experimental?.useWasmBinary)
// Set up code frame renderer for error formatting
const { installCodeFrameSupport } =
require('../server/lib/install-code-frame') as typeof import('../server/lib/install-code-frame')
installCodeFrameSupport()
process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''
NextBuildContext.config = config
+9 -6
View File
@@ -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 { codeFrameColumns } from '../shared/lib/errors/code-frame'
let TSCONFIG_WARNED = false
@@ -23,19 +24,21 @@ 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(
String(contents),
{
start: {
line: (err as Error & { lineNumber?: number }).lineNumber || 0,
column: (err as Error & { columnNumber?: number }).columnNumber || 0,
line: (err as Error & { lineNumber?: number }).lineNumber || 1,
column:
(err as Error & { columnNumber?: number }).columnNumber ||
undefined,
},
},
{ message: err.message, highlightCode: true }
{ message: err.message, color: true }
)
throw new Error(
`Failed to parse "${filePath}":\n${codeFrame ?? err.message}`
)
throw new Error(`Failed to parse "${filePath}":\n${codeFrame}`)
}
}
+52
View File
@@ -58,6 +58,58 @@ export declare function recvTaskMessageInWorker(
workerId: number
): Promise<NapiTaskMessage>
export declare function sendTaskMessage(message: NapiTaskMessage): Promise<void>
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 in columns (default: 100) */
maxWidth?: number
/** Whether to use ANSI colors (default: false) */
color?: boolean
/**
* Whether to highlight code syntax (default: follows color)
*
* This might be useful if syntax highlighting is very expensive or known to be useless for
* this file. The current syntax rules are optimized for javascript but should work well with
* other C-like languages.
*/
highlightCode?: boolean
/** Optional message to display with the code frame */
message?: string
/** Language hint for keyword highlighting: "javascript" (default) or "css" */
language?: 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 `undefined` if the location is out of
* range (e.g., empty source or line number past end of file).
*/
export declare function codeFrameColumns(
source: string,
location: NapiCodeFrameLocation,
options?: NapiCodeFrameOptions | undefined | null
): string | null
export declare function lockfileTryAcquireSync(
path: string,
content?: string | undefined | null
+12 -7
View File
@@ -3,14 +3,7 @@
/* tslint:disable */
/* eslint-disable */
export function mdxCompileSync(value: string, opts: any): any
export function mdxCompile(value: string, opts: any): Promise<any>
export function minifySync(s: string, opts: any): any
export function minify(s: string, opts: any): Promise<any>
export function transformSync(s: any, opts: any): any
export function transform(s: any, opts: any): Promise<any>
export function parseSync(s: string, opts: any): any
export function parse(s: string, opts: any): Promise<any>
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 | undefined
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<any>
export function minify(s: string, opts: any): Promise<any>
export function mdxCompile(value: string, opts: any): Promise<any>
export function mdxCompileSync(value: string, opts: any): any
+21 -3
View File
@@ -16,6 +16,8 @@ import type {
NapiPartialProjectOptions,
NapiProjectOptions,
NapiSourceDiagnostic,
NapiCodeFrameLocation,
NapiCodeFrameOptions,
} from './generated-native'
import type {
Binding,
@@ -388,7 +390,7 @@ async function tryLoadWasmWithFallback(
}
}
function loadBindingsSync() {
function loadBindingsSync(): Binding {
let attempts: any[] = []
try {
return loadNative()
@@ -1438,6 +1440,17 @@ async function loadWasm(importPath = '') {
imports
)
},
codeFrameColumns(
source: string,
location: NapiCodeFrameLocation,
options?: NapiCodeFrameOptions
): string | undefined {
return rawBindings.codeFrameColumns(
Buffer.from(source),
location,
options
)
},
lockfileTryAcquire(_filePath: string, _content?: string | null) {
throw new Error(
'`lockfileTryAcquire` is not supported by the wasm bindings.'
@@ -1464,7 +1477,7 @@ async function loadWasm(importPath = '') {
* Loads the native (non-wasm) bindings. Prefer `loadBindings` over this API, as that includes a
* wasm fallback.
*/
function loadNative(importPath?: string) {
function loadNative(importPath?: string): Binding {
if (loadedBindings) {
return loadedBindings
}
@@ -1687,8 +1700,13 @@ function loadNative(importPath?: string) {
lockfileUnlockSync(lockfile: Lockfile) {
return bindings.lockfileUnlockSync(lockfile)
},
codeFrameColumns(source, location, options) {
// napi-rs translates Option::None as null but wasm-bindgen translates it to `null`
// convert here for consistency
return bindings.codeFrameColumns(source, location, options) ?? undefined
},
}
return loadedBindings
return loadedBindings!
}
throw attempts
+7
View File
@@ -7,6 +7,8 @@ import type {
NapiSourceDiagnostic,
NapiProjectOptions,
NapiPartialProjectOptions,
NapiCodeFrameOptions,
NapiCodeFrameLocation,
} from './generated-native'
export type { NapiTurboEngineOptions as TurboEngineOptions }
@@ -80,6 +82,11 @@ export interface Binding {
lockfileTryAcquireSync(path: string, content?: string | null): Lockfile | null
lockfileUnlock(lockfile: Lockfile): Promise<void>
lockfileUnlockSync(lockfile: Lockfile): void
codeFrameColumns(
source: string,
location: NapiCodeFrameLocation,
options?: NapiCodeFrameOptions
): string | undefined
}
export type StyledString =
@@ -1,6 +1,7 @@
import { bold, cyan, red, yellow } from '../../../../lib/picocolors'
import { SimpleWebpackError } from './simpleWebpackError'
import { codeFrameColumns } from '../../../../shared/lib/errors/code-frame'
const regexScssError =
/SassError: (.+)\n\s+on line (\d+) [\s\S]*?>> (.+)\n\s*(-+)\^$/m
@@ -22,13 +23,11 @@ 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(
fileContent,
{ start: { line: lineNumber, column } },
{ forceColor: true }
) as string
{ color: true, language: 'css' }
)
} catch {}
}
@@ -36,7 +35,7 @@ export function getScssError(
`${cyan(fileName)}:${yellow(lineNumber.toString())}:${yellow(
column.toString()
)}`,
red(bold('Syntax error')).concat(`: ${reason}\n\n${frame ?? backupFrame}`)
red(bold('Syntax error')).concat(`: ${reason}\n\n${frame || backupFrame}`)
)
}
@@ -1,3 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
module.exports = require('@babel/code-frame')
@@ -1 +0,0 @@
module.exports = require('next/dist/compiled/babel-code-frame')
@@ -1,22 +0,0 @@
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{"name":"@babel/code-frame","main":"index.js","author":"The Babel Team (https://babel.dev/team)","license":"MIT"}
@@ -1 +0,0 @@
module.exports = require('next/dist/compiled/babel-code-frame')
+7
View File
@@ -11,6 +11,8 @@ import type { AppPageModule } from '../server/route-modules/app-page/module'
import type { PagesModule } from '../server/route-modules/pages/module.compiled'
import '../server/node-environment'
import { installBindings } from '../build/swc/install-bindings'
import { installCodeFrameSupport } from '../server/lib/install-code-frame'
process.env.NEXT_IS_EXPORT_WORKER = 'true'
@@ -336,6 +338,11 @@ async function exportPageImpl(
export async function exportPages(
input: ExportPagesInput
): Promise<ExportPagesResult> {
// Load native bindings in the worker process so that code frame rendering
// (which uses the native codeFrameColumns function) works during prerendering.
await installBindings()
installCodeFrameSupport()
const {
exportPaths,
dir,
@@ -1,3 +1,4 @@
import { codeFrameColumns } 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 = codeFrameColumns(
diagnostic.file.getFullText(diagnostic.file.getSourceFile()),
{
start: { line: line, column: character },
},
{ color: true }
)
if (codeFrame) {
message += '\n' + codeFrame
}
} else if (isLayoutOrPageError && appPath) {
message = cyan(appPath) + '\n' + message
}
@@ -220,6 +220,10 @@ export async function verifyAndRunTypeScript({
if (shouldRunTypeCheck) {
const { runTypeCheck } =
require('./typescript/runTypeCheck') as typeof import('./typescript/runTypeCheck')
// Install native bindings so that code frame rendering works in the worker
const { installBindings } =
require('../build/swc/install-bindings') as typeof import('../build/swc/install-bindings')
await installBindings()
const tsPath = deps.resolved.get('typescript')!
const typescript = (await Promise.resolve(
@@ -1,17 +1,19 @@
:host {
/*
/*
* CAUTION: THIS IS A WORKAROUND!
* For now, we use @babel/code-frame to parse the code frame which does not support option to change the color.
* x-ref: https://github.com/babel/babel/blob/efa52324ff835b794c48080f14877b6caf32cd15/packages/babel-code-frame/src/defs.ts#L40-L54
* So, we do a workaround mapping to change the color matching the theme.
* The code frame renderer (next-code-frame) uses hardcoded ANSI colors that don't match
* our theme colors. We do a workaround mapping to change the ANSI color matching the theme.
*
* For example, in @babel/code-frame, the "keyword" is mapped to ANSI "cyan".
* We want the "keyword" to use the "syntax-keyword" color in the theme.
* So, we map the "cyan" to the "syntax-keyword" in the theme.
* next-code-frame color scheme:
* cyan (36m): keywords (const, function, return, etc.)
* yellow (33m): capitalized identifiers (Component names, types)
* magenta (35m): numbers, regex literals
* green (32m): strings
* gray (90m): comments, gutter
*/
/* cyan: keyword */
--color-ansi-cyan: var(--color-syntax-keyword);
/* yellow: capitalized, jsxIdentifier, punctuation */
/* yellow: capitalized identifiers */
--color-ansi-yellow: var(--color-syntax-function);
/* magenta: number, regex */
--color-ansi-magenta: var(--color-syntax-keyword);
@@ -1,4 +1,4 @@
import { codeFrameColumns } from 'next/dist/compiled/babel/code-frame'
import { codeFrameColumns } from '../../shared/lib/errors/code-frame'
import type { StackFrame } from '../../server/lib/parse-stack'
import { ignoreListAnonymousStackFramesIfSandwiched as ignoreListAnonymousStackFramesIfSandwichedGeneric } from '../../server/lib/source-maps'
@@ -67,20 +67,20 @@ export function getOriginalCodeFrame(
source: string | null,
colors: boolean = process.stdout.isTTY
): string | null {
if (!source) {
if (!source || frame.line1 == null) {
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 (
codeFrameColumns(
source,
{
start: {
line: frame.line1,
column: frame.column1 ?? undefined,
},
},
},
{ forceColor: colors }
{ color: colors }
) ?? null
)
}
@@ -429,6 +429,12 @@ export async function createHotReloaderTurbopack(
setBundlerFindSourceMapImplementation(
getSourceMapFromTurbopack.bind(null, project, projectPath)
)
// Set up code frame renderer using native bindings
const { installCodeFrameSupport } =
require('../lib/install-code-frame') as typeof import('../lib/install-code-frame')
installCodeFrameSupport()
opts.onDevServerCleanup?.(async () => {
setBundlerFindSourceMapImplementation(() => undefined)
await project.onExit()
@@ -0,0 +1,13 @@
/**
* Installs code frame rendering support by injecting the renderer into patch-error-inspect.
* This uses dependency injection to avoid hard dependencies on native bindings in runtime code.
*
* Should be called early in dev/build initialization, after native bindings are loaded.
*/
export function installCodeFrameSupport(): void {
const { setCodeFrameRenderer } =
require('../patch-error-inspect') as typeof import('../patch-error-inspect')
const { getOriginalCodeFrame } =
require('../../next-devtools/server/shared') as typeof import('../../next-devtools/server/shared')
setCodeFrameRenderer(getOriginalCodeFrame)
}
@@ -1293,6 +1293,12 @@ export async function setupDevBundler(opts: SetupOpts) {
.relative(opts.dir, opts.pagesDir || opts.appDir || '')
.startsWith('src')
await installBindings(opts.nextConfig.experimental?.useWasmBinary)
// Set up code frame renderer for error formatting
const { installCodeFrameSupport } =
require('../install-code-frame') as typeof import('../install-code-frame')
installCodeFrameSupport()
const result = await startWatcher({
...opts,
isSrcDir,
@@ -11,7 +11,7 @@ import {
sourceMapIgnoreListsEverything,
} from './lib/source-maps'
import { parseStack, type StackFrame } from './lib/parse-stack'
import { getOriginalCodeFrame } from '../next-devtools/server/shared'
import type { IgnorableStackFrame } from '../next-devtools/server/shared'
import { workUnitAsyncStorage } from './app-render/work-unit-async-storage.external'
import { dim, italic } from '../lib/picocolors'
@@ -30,8 +30,29 @@ export function setBundlerFindSourceMapImplementation(
bundlerFindSourceMapPayload = findSourceMapImplementation
}
interface IgnorableStackFrame extends StackFrame {
ignored: boolean
// Code frame renderer - injected by dev/build to avoid hard dependency on native bindings
type CodeFrameRenderer = (
frame: IgnorableStackFrame,
source: string | null,
colors: boolean
) => string | null
let codeFrameRenderer: CodeFrameRenderer | undefined
export function setCodeFrameRenderer(renderer: CodeFrameRenderer): void {
codeFrameRenderer = renderer
}
function getOriginalCodeFrame(
frame: IgnorableStackFrame,
source: string | null,
colors: boolean = process.stdout.isTTY
): string | null {
if (!codeFrameRenderer) {
// No renderer available - gracefully degrade
return null
}
return codeFrameRenderer(frame, source, colors)
}
type SourceMapCache = Map<
@@ -0,0 +1,23 @@
import { 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.
* Requires native bindings to be installed — throws otherwise.
*/
export function codeFrameColumns(
file: string,
location: NapiCodeFrameLocation,
options: NapiCodeFrameOptions = {}
): string | undefined {
// Default to the terminal width
if (options.maxWidth === undefined) {
options.maxWidth = process.stdout.columns
}
return getBindingsSync().codeFrameColumns(file, location, options)
}
export type { NapiCodeFrameLocation, NapiCodeFrameOptions }
+18 -16
View File
@@ -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 { codeFrameColumns } from '../errors/code-frame'
type IssueKey = `${Issue['severity']}-${Issue['filePath']}-${string}-${string}`
export type IssuesMap = Map<IssueKey, Issue>
@@ -134,24 +135,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, it would be more efficient than passing the source back and forth
const frame = codeFrameColumns(
source.source.content,
{
start: {
line: start.line + 1,
column: start.column + 1,
},
{ forceColor: true }
).trim() + '\n\n'
end: {
line: end.line + 1,
column: end.column + 1,
},
},
{ color: true }
)
if (frame) {
message += frame.trimEnd() + '\n\n'
}
}
if (description) {
-23
View File
@@ -990,7 +990,6 @@ export async function ncc_postcss_plugin_stub_for_cssnano_simple(task, opts) {
}
const babelCorePackages = {
'code-frame': 'next/dist/compiled/babel/code-frame',
'@babel/generator': 'next/dist/compiled/babel/generator',
'@babel/traverse': 'next/dist/compiled/babel/traverse',
'@babel/types': 'next/dist/compiled/babel/types',
@@ -1006,12 +1005,6 @@ const babelCorePackages = {
'@babel/core/lib/transformation/plugin-pass':
'next/dist/compiled/babel/core-lib-plugin-pass',
}
externals['next/dist/compiled/babel/code-frame'] =
'next/dist/compiled/babel/code-frame'
externals['next/dist/compiled/babel-code-frame'] =
'next/dist/compiled/babel-code-frame'
Object.assign(externals, babelCorePackages)
export async function ncc_babel_bundle(task, opts) {
@@ -1032,21 +1025,6 @@ export async function ncc_babel_bundle(task, opts) {
.target('src/compiled/babel')
}
export async function ncc_babel_code_frame(task, opts) {
const bundleExternals = {
...externals,
'next/dist/compiled/babel-packages': 'next/dist/compiled/babel-packages',
}
await task
.source('src/bundles/babel-code-frame/index.js')
.ncc({
packageName: '@babel/code-frame',
bundleName: 'babel-code-frame',
externals: bundleExternals,
})
.target('src/compiled/babel-code-frame')
}
export async function ncc_babel_bundle_packages(task, opts) {
const eslintParseFile = join(
dirname(require.resolve('@babel/eslint-parser')),
@@ -2250,7 +2228,6 @@ export async function ncc(task, opts) {
'ncc_tty_browserify',
'ncc_vm_browserify',
'ncc_babel_bundle',
'ncc_babel_code_frame',
'ncc_bytes',
'ncc_ci_info',
'ncc_cli_select',
-4
View File
@@ -528,10 +528,6 @@ declare module 'next/dist/compiled/async-sema' {
export = m
}
declare module 'next/dist/compiled/babel/code-frame' {
export * from '@babel/code-frame'
}
declare module 'next/dist/compiled/@next/font/dist/google' {
export * from '@next/font/google'
}
-3
View File
@@ -1119,9 +1119,6 @@ importers:
specifier: ^0.34.5
version: 0.34.5
devDependencies:
'@babel/code-frame':
specifier: 7.26.2
version: 7.26.2
'@babel/core':
specifier: 7.26.10
version: 7.26.10
@@ -1207,7 +1207,7 @@ describe('Cache Components Errors', () => {
TypeError: <module-function>().get is not a function
at CookiesReadingComponent (app/sync-cookies/page.tsx:18:36)
16 | async function CookiesReadingComponent() {
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync access
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync...
> 18 | const token = (cookies() as any).get('token')
| ^
19 |
@@ -1225,7 +1225,7 @@ describe('Cache Components Errors', () => {
TypeError: <module-function>().get is not a function
at CookiesReadingComponent (webpack:///app/sync-cookies/page.tsx:18:36)
16 | async function CookiesReadingComponent() {
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync access
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync...
> 18 | const token = (cookies() as any).get('token')
| ^
19 |
@@ -1246,7 +1246,7 @@ describe('Cache Components Errors', () => {
at a (app/sync-cookies/page.tsx:18:36)
at b (<anonymous>)
16 | async function CookiesReadingComponent() {
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync access
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync...
> 18 | const token = (cookies() as any).get('token')
| ^
19 |
@@ -1541,7 +1541,7 @@ describe('Cache Components Errors', () => {
TypeError: <module-function>().get is not a function
at HeadersReadingComponent (app/sync-headers/page.tsx:18:40)
16 | async function HeadersReadingComponent() {
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync access
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync...
> 18 | const userAgent = (headers() as any).get('user-agent')
| ^
19 | return (
@@ -1559,7 +1559,7 @@ describe('Cache Components Errors', () => {
TypeError: <module-function>().get is not a function
at HeadersReadingComponent (webpack:///app/sync-headers/page.tsx:18:40)
16 | async function HeadersReadingComponent() {
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync access
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync...
> 18 | const userAgent = (headers() as any).get('user-agent')
| ^
19 | return (
@@ -1580,7 +1580,7 @@ describe('Cache Components Errors', () => {
at a (app/sync-headers/page.tsx:18:40)
at b (<anonymous>)
16 | async function HeadersReadingComponent() {
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync access
17 | // Cast to any as we removed UnsafeUnwrapped types, but still need to test with the sync...
> 18 | const userAgent = (headers() as any).get('user-agent')
| ^
19 | return (
@@ -849,7 +849,9 @@ describe('app-dir - server source maps', () => {
"description": "ignore-listed frames",
"environmentLabel": null,
"label": "Console Error",
"source": "internal-pkg/sourcemapped.ts (9:13) @ runSetOfSets",
"source": "app/ssr-anonymous-stack-frame-sandwich/page.js (7:29) @ Page
> 7 | runHiddenSetOfSetsInternal('ssr-anonymous-stack-frame-sandwich: internal')
| ^",
"stack": [
"<unknown> internal-pkg/sourcemapped.ts (18:43)",
"<unknown> internal-pkg/sourcemapped.ts (11:7)",
@@ -175,8 +175,6 @@ async function readNormalizedNFT(next, name) {
"/node_modules/next/dist/compiled/@opentelemetry/api/index.js",
"/node_modules/next/dist/compiled/async-retry/index.js",
"/node_modules/next/dist/compiled/async-sema/index.js",
"/node_modules/next/dist/compiled/babel-code-frame/index.js",
"/node_modules/next/dist/compiled/babel/code-frame.js",
"/node_modules/next/dist/compiled/busboy/index.js",
"/node_modules/next/dist/compiled/bytes/index.js",
"/node_modules/next/dist/compiled/ci-info/index.js",
@@ -354,7 +352,6 @@ async function readNormalizedNFT(next, name) {
"/node_modules/next/dist/lib/wait.js",
"/node_modules/next/dist/lib/with-promise-cache.js",
"/node_modules/next/dist/lib/worker.js",
"/node_modules/next/dist/next-devtools/server/shared.js",
"/node_modules/next/dist/server/*",
"/node_modules/next/dist/shared/*",
"/node_modules/next/dist/telemetry/anonymous-meta.js",
@@ -451,8 +448,6 @@ async function readNormalizedNFT(next, name) {
"/node_modules/client-only/index.js",
"/node_modules/next/dist/client/components/app-router-headers.js",
"/node_modules/next/dist/compiled/@opentelemetry/api/index.js",
"/node_modules/next/dist/compiled/babel-code-frame/index.js",
"/node_modules/next/dist/compiled/babel/code-frame.js",
"/node_modules/next/dist/compiled/next-server/server.runtime.prod.js",
"/node_modules/next/dist/compiled/source-map/source-map.js",
"/node_modules/next/dist/compiled/stacktrace-parser/stack-trace-parser.cjs.js",