Skip to content

Commit 25d867e

Browse files
bartlomiejuclaude
andcommitted
feat(runtime): add syntax highlighting for error stack trace source lines
Add a lightweight JS/TS syntax highlighter for source code lines displayed in error stack traces. Uses a simple character-scanning lexer (inspired by Bun's QuickAndDirtyJavaScriptSyntaxHighlighter) that works on arbitrary single lines without requiring syntactically valid code. Features: - Keywords in bright blue, TS type keywords in cyan, strings in green, numbers/booleans in yellow, comments in gray, null in bold, undefined in gray (matching console.log inspect colors) - Template literal interpolation highlighting - Line number gutter prefix (e.g. "42 | source code here") - Error class name in red bold, "(in promise)" in gray - Bold red caret for error position indicator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 574272d commit 25d867e

File tree

3 files changed

+917
-8
lines changed

3 files changed

+917
-8
lines changed

runtime/fmt_errors.rs

Lines changed: 158 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use deno_core::error::format_frame;
1111
use deno_core::url::Url;
1212
use deno_terminal::colors;
1313

14+
use crate::source_highlight::syntax_highlight_source_line;
15+
1416
#[derive(Debug, Clone)]
1517
struct ErrorReference<'a> {
1618
from: &'a JsError,
@@ -108,6 +110,7 @@ impl deno_core::error::ErrorFormat for AnsiColors {
108110
fn format_maybe_source_line(
109111
source_line: Option<&str>,
110112
column_number: Option<i64>,
113+
line_number: Option<i64>,
111114
is_error: bool,
112115
level: usize,
113116
) -> String {
@@ -136,6 +139,25 @@ fn format_maybe_source_line(
136139
);
137140
}
138141

142+
// Build the line number gutter prefix: " 42 | "
143+
let (line_prefix, gutter_width) = if let Some(ln) = line_number {
144+
let ln_str = ln.to_string();
145+
let prefix = if colors::use_color() {
146+
format!("{} {} ", colors::gray(&ln_str), colors::gray("|"))
147+
} else {
148+
format!("{} | ", ln_str)
149+
};
150+
// Width of the gutter in visible characters (for the caret alignment)
151+
let width = ln_str.len() + 3; // "42 | " = digits + " | "
152+
(prefix, width)
153+
} else {
154+
(String::new(), 0)
155+
};
156+
157+
// Build caret padding (accounting for gutter width)
158+
for _ in 0..gutter_width {
159+
s.push(' ');
160+
}
139161
for _i in 0..(column_number - 1) {
140162
if source_line.chars().nth(_i as usize).unwrap() == '\t' {
141163
s.push('\t');
@@ -145,14 +167,18 @@ fn format_maybe_source_line(
145167
}
146168
s.push('^');
147169
let color_underline = if is_error {
148-
colors::red(&s).to_string()
170+
colors::red_bold(&s).to_string()
149171
} else {
150172
colors::cyan(&s).to_string()
151173
};
152174

153175
let indent = format!("{:indent$}", "", indent = level);
176+
let highlighted_source =
177+
syntax_highlight_source_line(source_line, colors::use_color());
154178

155-
format!("\n{indent}{source_line}\n{indent}{color_underline}")
179+
format!(
180+
"\n{indent}{line_prefix}{highlighted_source}\n{indent}{color_underline}"
181+
)
156182
}
157183

158184
fn find_recursive_cause(js_error: &JsError) -> Option<ErrorReference<'_>> {
@@ -220,6 +246,55 @@ fn stack_frame_is_ext(frame: &deno_core::error::JsStackFrame) -> bool {
220246
.unwrap_or(false)
221247
}
222248

249+
/// Colorize an exception message like "Uncaught (in promise) TypeError: msg".
250+
///
251+
/// - "(in promise)" is grayed out
252+
/// - The error class name (e.g. "TypeError") is colored red
253+
fn colorize_exception_message(msg: &str) -> String {
254+
if !colors::use_color() {
255+
return msg.to_string();
256+
}
257+
258+
let mut remaining = msg;
259+
let mut result = String::with_capacity(msg.len() + 40);
260+
261+
// Strip "Uncaught " prefix (will be re-added plain)
262+
let uncaught = remaining.starts_with("Uncaught ");
263+
if uncaught {
264+
result.push_str("Uncaught ");
265+
remaining = &remaining["Uncaught ".len()..];
266+
}
267+
268+
// Handle optional "(in promise) "
269+
if remaining.starts_with("(in promise) ") {
270+
result.push_str(&colors::gray("(in promise)").to_string());
271+
result.push(' ');
272+
remaining = &remaining["(in promise) ".len()..];
273+
}
274+
275+
// Find the error class name — everything up to the first ": "
276+
if let Some(colon_pos) = remaining.find(": ") {
277+
let class_name = &remaining[..colon_pos];
278+
// Only colorize if it looks like an error class name
279+
// (starts with uppercase, contains only alphanumeric chars)
280+
if class_name
281+
.chars()
282+
.next()
283+
.is_some_and(|c| c.is_ascii_uppercase())
284+
&& class_name.chars().all(|c| c.is_ascii_alphanumeric())
285+
{
286+
result.push_str(&colors::red_bold(class_name).to_string());
287+
result.push_str(&remaining[colon_pos..]);
288+
} else {
289+
result.push_str(remaining);
290+
}
291+
} else {
292+
result.push_str(remaining);
293+
}
294+
295+
result
296+
}
297+
223298
fn format_js_error_inner(
224299
js_error: &JsError,
225300
circular: Option<IndexedErrorReference>,
@@ -230,7 +305,7 @@ fn format_js_error_inner(
230305
) -> String {
231306
let mut s = String::new();
232307

233-
s.push_str(&js_error.exception_message);
308+
s.push_str(&colorize_exception_message(&js_error.exception_message));
234309

235310
if let Some(circular) = &circular
236311
&& js_error.is_same_error(circular.reference.to)
@@ -252,16 +327,19 @@ fn format_js_error_inner(
252327
s.push_str(&aggregated_message);
253328
}
254329

255-
let column_number = js_error
330+
let source_frame = js_error
256331
.source_line_frame_index
257-
.and_then(|i| js_error.frames.get(i).unwrap().column_number);
332+
.and_then(|i| js_error.frames.get(i));
333+
let column_number = source_frame.and_then(|f| f.column_number);
334+
let line_number = source_frame.and_then(|f| f.line_number);
258335
s.push_str(&format_maybe_source_line(
259336
if include_source_code {
260337
js_error.source_line.as_deref()
261338
} else {
262339
None
263340
},
264341
column_number,
342+
line_number,
265343
true,
266344
0,
267345
));
@@ -529,17 +607,89 @@ mod tests {
529607

530608
#[test]
531609
fn test_format_none_source_line() {
532-
let actual = format_maybe_source_line(None, None, false, 0);
610+
let actual = format_maybe_source_line(None, None, None, false, 0);
533611
assert_eq!(actual, "");
534612
}
535613

536614
#[test]
537615
fn test_format_some_source_line() {
538-
let actual =
539-
format_maybe_source_line(Some("console.log('foo');"), Some(9), true, 0);
616+
let actual = format_maybe_source_line(
617+
Some("console.log('foo');"),
618+
Some(9),
619+
None,
620+
true,
621+
0,
622+
);
540623
assert_eq!(
541624
strip_ansi_codes(&actual),
542625
"\nconsole.log(\'foo\');\n ^"
543626
);
544627
}
628+
629+
#[test]
630+
fn test_format_source_line_with_line_number() {
631+
let actual = format_maybe_source_line(
632+
Some("console.log('foo');"),
633+
Some(9),
634+
Some(42),
635+
true,
636+
0,
637+
);
638+
let stripped = strip_ansi_codes(&actual);
639+
assert_eq!(stripped, "\n42 | console.log(\'foo\');\n ^");
640+
}
641+
642+
#[test]
643+
fn test_colorize_exception_message_no_color() {
644+
colors::set_use_color(false);
645+
let msg = "Uncaught (in promise) TypeError: foo";
646+
assert_eq!(colorize_exception_message(msg), msg);
647+
colors::set_use_color(true);
648+
}
649+
650+
#[test]
651+
fn test_colorize_exception_message_basic() {
652+
colors::set_use_color(true);
653+
let result =
654+
colorize_exception_message("Uncaught TypeError: something failed");
655+
let stripped = strip_ansi_codes(&result);
656+
assert_eq!(stripped, "Uncaught TypeError: something failed");
657+
// "TypeError" should be red+bold
658+
assert!(result.contains("TypeError"), "result: {result}");
659+
// Should NOT contain plain "Uncaught TypeError" (TypeError must be styled)
660+
assert!(!result.contains("Uncaught TypeError:"), "result: {result}");
661+
}
662+
663+
#[test]
664+
fn test_colorize_exception_message_in_promise() {
665+
colors::set_use_color(true);
666+
let result = colorize_exception_message(
667+
"Uncaught (in promise) Error: something failed",
668+
);
669+
let stripped = strip_ansi_codes(&result);
670+
assert_eq!(stripped, "Uncaught (in promise) Error: something failed");
671+
// "(in promise)" should be styled (gray)
672+
assert!(
673+
!result.contains("Uncaught (in promise) Error"),
674+
"result: {result}"
675+
);
676+
}
677+
678+
#[test]
679+
fn test_colorize_exception_message_no_colon() {
680+
colors::set_use_color(true);
681+
// No ": " in message — should pass through unchanged
682+
let result = colorize_exception_message("Uncaught something");
683+
let stripped = strip_ansi_codes(&result);
684+
assert_eq!(stripped, "Uncaught something");
685+
}
686+
687+
#[test]
688+
fn test_colorize_exception_message_not_class_name() {
689+
colors::set_use_color(true);
690+
// lowercase after "Uncaught " — not an error class name
691+
let result = colorize_exception_message("Uncaught error: something failed");
692+
let stripped = strip_ansi_codes(&result);
693+
assert_eq!(stripped, "Uncaught error: something failed");
694+
}
545695
}

runtime/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pub use worker_bootstrap::WorkerExecutionMode;
5050
pub use worker_bootstrap::WorkerLogLevel;
5151

5252
pub mod shared;
53+
pub mod source_highlight;
5354
pub use deno_features::FeatureChecker;
5455
pub use deno_features::UNSTABLE_ENV_VAR_NAMES;
5556
pub use deno_features::UNSTABLE_FEATURES;

0 commit comments

Comments
 (0)