[turbopack] Render code frames on the turbopack side (#90534)

Not that error rendering performance is critical, but it is silly to pass frame information from turbopack to js just to have js pass the same information back to native code to draw a frame.
This commit is contained in:
Luke Sandberg
2026-03-10 18:52:40 -07:00
committed by GitHub
parent 38a1d1d73b
commit ff36c17c14
9 changed files with 118 additions and 38 deletions
Generated
+2
View File
@@ -4650,12 +4650,14 @@ dependencies = [
"once_cell",
"owo-colors",
"rand 0.10.0",
"regex",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"supports-hyperlinks",
"swc_core",
"terminal_hyperlink",
"terminal_size",
"tokio",
"tracing",
"tracing-chrome",
+1
View File
@@ -468,6 +468,7 @@ strsim = "0.11.1"
swc_sourcemap = "9.3.4"
syn = "2.0.100"
tempfile = "3.20.0"
terminal_size = "0.3.0"
thiserror = "1.0.48"
thread_local = "1.1.8"
tokio = "1.43.0"
+2
View File
@@ -73,6 +73,7 @@ rustc-hash = { workspace = true }
serde = "1"
serde_json = "1"
supports-hyperlinks = "3.1.0"
terminal_size = { workspace = true }
terminal_hyperlink = "0.1.0"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
@@ -80,6 +81,7 @@ tracing-chrome = "0.7.2"
url = { workspace = true }
urlencoding = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }
swc_core = { workspace = true, features = [
"base",
@@ -8,6 +8,9 @@ use napi::{
threadsafe_function::{ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use napi_derive::napi;
use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame};
use once_cell::sync::Lazy;
use regex::Regex;
use rustc_hash::FxHashMap;
use serde::Serialize;
use turbo_tasks::{
@@ -124,6 +127,89 @@ pub async fn get_diagnostics<T: Send>(
Ok(Arc::new(diags))
}
/// Returns true if the file path refers to a Next.js/React internal file whose
/// source code frames would be unhelpful (e.g. large bundled vendored files).
///
/// Mirrors the JS `isInternal()` check from
/// `packages/next/src/shared/lib/is-internal.ts`.
fn is_internal(file_path: &str) -> bool {
// Uses [/\\] so both Unix and Windows separators are matched without
// needing to normalize the path
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?x)
# React vendored in Next.js dist/compiled (reactVendoredRe)
[/\\]next[/\\]dist[/\\]compiled[/\\](?:react|react-dom|react-server-dom-webpack|react-server-dom-turbopack|scheduler)[/\\]
# React in node_modules (reactNodeModulesRe)
| node_modules[/\\](?:react|react-dom|scheduler)[/\\]
# Next.js internals (nextInternalsRe)
| node_modules[/\\]next[/\\]
| [/\\]\.next[/\\]static[/\\]chunks[/\\]webpack\.js$
| edge-runtime-webpack\.js$
| webpack-runtime\.js$
",
)
.expect("is_internal regex must compile")
});
RE.is_match(file_path)
}
/// Renders a code frame for the issue's source location, if available.
///
/// This avoids transferring the full source file content across the NAPI
/// boundary just to call back into Rust for code frame rendering.
///
/// Because this accesses the terminal size, this function call should not be cached (e.g. in
/// turbo-tasks).
fn render_issue_code_frame(issue: &PlainIssue) -> Result<Option<String>> {
let Some(source) = issue.source.as_ref() else {
return Ok(None);
};
let Some((start, end)) = source.range else {
return Ok(None);
};
if is_internal(&issue.file_path) {
return Ok(None);
}
let content = match &*source.asset.content {
FileContent::Content(c) => {
let Ok(content) = c.content().to_str() else {
return Ok(None);
};
content
}
FileContent::NotFound => return Ok(None),
};
// SourcePos is 0-indexed; Location is 1-indexed
let location = CodeFrameLocation {
start: Location {
line: (start.line + 1) as usize,
column: Some((start.column + 1) as usize),
},
end: Some(Location {
line: (end.line + 1) as usize,
column: Some((end.column + 1) as usize),
}),
};
render_code_frame(
&content,
&location,
&CodeFrameOptions {
color: true,
highlight_code: true,
max_width: terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(100),
..Default::default()
},
)
}
#[napi(object)]
pub struct NapiIssue {
pub severity: String,
@@ -135,6 +221,9 @@ pub struct NapiIssue {
pub source: Option<NapiIssueSource>,
pub documentation_link: String,
pub import_traces: serde_json::Value,
/// Pre-rendered code frame for the issue's source location, if available.
/// Rendered in Rust to avoid transferring full source file content to JS.
pub code_frame: Option<String>,
}
impl From<&PlainIssue> for NapiIssue {
@@ -155,6 +244,7 @@ impl From<&PlainIssue> for NapiIssue {
source: issue.source.as_ref().map(|source| source.into()),
title: serde_json::to_value(StyledStringSerialize::from(&issue.title)).unwrap(),
import_traces: serde_json::to_value(&issue.import_traces).unwrap(),
code_frame: render_issue_code_frame(issue).unwrap_or_default(),
}
}
}
@@ -233,20 +323,12 @@ impl From<&(SourcePos, SourcePos)> for NapiIssueSourceRange {
#[napi(object)]
pub struct NapiSource {
pub ident: String,
pub content: Option<String>,
}
impl From<&PlainSource> for NapiSource {
fn from(source: &PlainSource) -> Self {
Self {
ident: source.ident.to_string(),
content: match &*source.content {
FileContent::Content(content) => match content.content().to_str() {
Ok(str) => Some(str.into_owned()),
Err(_) => None,
},
FileContent::NotFound => None,
},
}
}
}
+5 -1
View File
@@ -514,6 +514,11 @@ export interface NapiIssue {
source?: NapiIssueSource
documentationLink: string
importTraces: any
/**
* Pre-rendered code frame for the issue's source location, if available.
* Rendered in Rust to avoid transferring full source file content to JS.
*/
codeFrame?: string
}
export interface NapiIssueSource {
source: NapiSource
@@ -525,7 +530,6 @@ export interface NapiIssueSourceRange {
}
export interface NapiSource {
ident: string
content?: string
}
export interface NapiSourcePos {
line: number
+2 -1
View File
@@ -121,7 +121,6 @@ export interface Issue {
source?: {
source: {
ident: string
content?: string
}
range?: {
start: {
@@ -140,6 +139,8 @@ export interface Issue {
}
documentationLink: string
importTraces?: PlainTraceItem[][]
/** Pre-rendered code frame from the Rust NAPI layer */
codeFrame?: string
}
export interface PlainTraceItem {
fsName: string
@@ -11,6 +11,8 @@ export const nextInternalsRe =
export default function isInternal(file: string | null) {
if (!file) return false
// NOTE: native code has a version of the same logic in crates/next-napi-bindings/src/next_api/utils.rs
// keep them in sync.
return (
nextInternalsRe.test(file) ||
@@ -112,6 +112,18 @@ https://nextjs.org/docs/messages/module-not-found
`)
})
it('includes pre-rendered code frame from Rust', () => {
const issue: Issue = {
...baseIssue,
importTraces: [],
codeFrame:
' 1 | const x = 1;\n> 2 | const y = unknown;\n | ^^^^^^^\n 3 | const z = 3;',
}
const output = formatIssue(issue)
expect(output).toContain('const y = unknown')
expect(output).toContain('^^^^^^^')
})
it('handles missing layers in traces', () => {
const trace: PlainTraceItem[] = [
traceItem('src/app/page.ts'),
@@ -6,12 +6,10 @@ import type {
} from '../../../build/swc/types'
import { bold, green, magenta, red } from '../../../lib/picocolors'
import isInternal from '../is-internal'
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>
@@ -128,32 +126,8 @@ export function formatIssue(issue: Issue) {
}
message += '\n'
if (
source?.range &&
source.source.content &&
// ignore Next.js/React internals, as these can often be huge bundled files.
!isInternal(filePath)
) {
const { start, end } = source.range
// 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,
},
end: {
line: end.line + 1,
column: end.column + 1,
},
},
{ color: true }
)
if (frame) {
message += frame.trimEnd() + '\n\n'
}
if (issue.codeFrame) {
message += issue.codeFrame.trimEnd() + '\n\n'
}
if (description) {