mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
Generated
+22
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:?}")))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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')
|
||||
@@ -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 }
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Generated
-3
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user