1use std::path::{Path, PathBuf};
20
21use fallow_types::discover::FileId;
22use fallow_types::extract::MemberKind;
23use fallow_types::trace_error::{
24 ErrorTrace, ErrorTraceCandidate, ErrorTraceCounts, ErrorTraceFrame, ErrorTraceSchemaVersion,
25 FrameOrigin, FrameResolution,
26};
27use rustc_hash::FxHashMap;
28
29use crate::graph::ModuleGraph;
30use crate::module_graph::RetainedModuleGraph;
31use crate::trace::trace_impl::{matching_module_indexes, relativize};
32
33pub const MAX_STACK_TRACE_BYTES: u64 = 1024 * 1024;
38
39const MAX_REPORTED_FRAMES: usize = 256;
43
44const MAX_FRAME_CANDIDATES: usize = 10;
48
49const BUILD_OUTPUT_SEGMENTS: &[&str] = &["dist", "build", "out", ".next"];
53
54const DEPENDENCY_SEGMENT: &str = "node_modules";
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct RawFrame {
60 pub raw: String,
62 pub function: Option<String>,
65 pub is_constructor: bool,
67 pub is_async: bool,
69 pub file: Option<String>,
71 pub line: Option<u32>,
73 pub column: Option<u32>,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct ParsedStackTrace {
80 pub header: Option<String>,
82 pub frames: Vec<RawFrame>,
84 pub unparsed_lines: usize,
86}
87
88#[must_use]
94pub fn parse_stack_trace(input: &str) -> ParsedStackTrace {
95 let mut parsed = ParsedStackTrace::default();
96 for line in input.lines() {
97 let trimmed = line.trim();
98 if trimmed.is_empty() {
99 continue;
100 }
101 if let Some(frame) = parse_frame(trimmed) {
102 parsed.frames.push(frame);
103 continue;
104 }
105 if parsed.header.is_none() && parsed.frames.is_empty() {
106 parsed.header = Some(trimmed.to_string());
107 continue;
108 }
109 parsed.unparsed_lines += 1;
110 }
111 parsed
112}
113
114fn parse_frame(trimmed: &str) -> Option<RawFrame> {
116 parse_v8_frame(trimmed).or_else(|| parse_at_sign_frame(trimmed))
117}
118
119fn parse_v8_frame(trimmed: &str) -> Option<RawFrame> {
121 let rest = trimmed.strip_prefix("at ")?.trim_start();
122 let (name_part, location_part) = match rest.strip_suffix(')') {
127 Some(head) => match head.rfind(" (") {
128 Some(index) => (&head[..index], &head[index + 2..]),
129 None => ("", rest),
130 },
131 None => ("", rest),
132 };
133
134 let mut name = name_part.trim();
135 let mut is_async = false;
136 let mut is_constructor = false;
137 if let Some(stripped) = name.strip_prefix("async ") {
138 is_async = true;
139 name = stripped.trim_start();
140 }
141 if let Some(stripped) = name.strip_prefix("new ") {
142 is_constructor = true;
143 name = stripped.trim_start();
144 }
145
146 let (file, line, column) = parse_location(location_part);
147 Some(RawFrame {
148 raw: trimmed.to_string(),
149 function: named_function(name),
150 is_constructor,
151 is_async,
152 file,
153 line,
154 column,
155 })
156}
157
158fn parse_at_sign_frame(trimmed: &str) -> Option<RawFrame> {
163 let (name_part, location_part) = trimmed.rsplit_once('@')?;
164 let (file, line, column) = parse_location(location_part);
165 let file = file?;
166 line?;
167 Some(RawFrame {
168 raw: trimmed.to_string(),
169 function: named_function(name_part.trim()),
170 is_constructor: false,
171 is_async: false,
172 file: Some(file),
173 line,
174 column,
175 })
176}
177
178fn named_function(name: &str) -> Option<String> {
180 if name.is_empty() || name == "<anonymous>" {
181 return None;
182 }
183 Some(name.to_string())
184}
185
186fn parse_location(location: &str) -> (Option<String>, Option<u32>, Option<u32>) {
189 let location = location.trim();
190 if location.is_empty() || location == "native" || location == "<anonymous>" {
191 return (None, None, None);
192 }
193 let (head, column) = peel_number(location);
194 let (head, line) = if column.is_some() {
195 peel_number(head)
196 } else {
197 (head, None)
198 };
199 let (line, column) = match (line, column) {
202 (Some(line), column) => (Some(line), column),
203 (None, Some(single)) => (Some(single), None),
204 (None, None) => (None, None),
205 };
206 (normalize_frame_path(head), line, column)
207}
208
209fn peel_number(text: &str) -> (&str, Option<u32>) {
211 let Some((head, tail)) = text.rsplit_once(':') else {
212 return (text, None);
213 };
214 if tail.is_empty() || !tail.bytes().all(|byte| byte.is_ascii_digit()) {
215 return (text, None);
216 }
217 match tail.parse::<u32>() {
218 Ok(value) => (head, Some(value)),
219 Err(_) => (text, None),
220 }
221}
222
223fn normalize_frame_path(path: &str) -> Option<String> {
230 let path = path.trim().replace('\\', "/");
231 if path.is_empty() {
232 return None;
233 }
234 if let Some(rest) = path.strip_prefix("file://") {
235 let rest = rest
238 .strip_prefix('/')
239 .filter(|tail| is_windows_drive_prefixed(tail))
240 .unwrap_or(rest);
241 return (!rest.is_empty()).then(|| rest.to_string());
242 }
243 for scheme in ["https://", "http://"] {
244 if let Some(rest) = path.strip_prefix(scheme) {
245 let authority_end = rest.find('/')?;
248 let remainder = &rest[authority_end..];
249 return (remainder.len() > 1).then(|| remainder.to_string());
250 }
251 }
252 Some(path)
253}
254
255fn is_windows_drive_prefixed(path: &str) -> bool {
257 let mut bytes = path.bytes();
258 matches!(
259 (bytes.next(), bytes.next(), bytes.next()),
260 (Some(drive), Some(b':'), Some(b'/')) if drive.is_ascii_alphabetic()
261 )
262}
263
264fn has_path_segment(path: &str, segment: &str) -> bool {
266 path.split('/').any(|component| component == segment)
267}
268
269fn is_runtime_internal(path: &str) -> bool {
271 path.starts_with("node:") || path.starts_with("internal/")
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276struct Identifier<'a> {
277 printed: &'a str,
279 owner: Option<&'a str>,
282 name: &'a str,
284}
285
286fn identifier_parts(function: &str) -> Option<Identifier<'_>> {
293 if function.is_empty()
294 || function
295 .chars()
296 .any(|c| c.is_whitespace() || c == '<' || c == '>' || c == '/' || c == '\\')
297 {
298 return None;
299 }
300 let mut segments = function.split('.');
301 let mut owner = None;
302 let mut name = segments.next()?;
303 if name.is_empty() {
304 return None;
305 }
306 for segment in segments {
307 if segment.is_empty() {
308 return None;
309 }
310 owner = Some(name);
311 name = segment;
312 }
313 Some(Identifier {
314 printed: function,
315 owner,
316 name,
317 })
318}
319
320const fn member_kind_label(kind: MemberKind) -> &'static str {
322 match kind {
323 MemberKind::ClassMethod => "class-method",
324 MemberKind::ClassProperty => "class-property",
325 MemberKind::EnumMember => "enum-member",
326 MemberKind::StoreMember => "store-member",
327 MemberKind::NamespaceMember => "namespace-member",
328 }
329}
330
331struct LocatedCandidate {
333 file_id: FileId,
334 file: String,
335 symbol: String,
336 member: Option<String>,
337 kind: &'static str,
338 span_start: u32,
339}
340
341struct TraceContext<'a> {
348 root: &'a Path,
350 canonical_root: Option<PathBuf>,
352 resolved_paths: FxHashMap<String, Option<String>>,
355 line_offsets: FxHashMap<FileId, Option<Vec<u32>>>,
357}
358
359impl<'a> TraceContext<'a> {
360 fn new(root: &'a Path) -> Self {
361 Self {
362 root,
363 canonical_root: dunce::canonicalize(root).ok(),
364 resolved_paths: FxHashMap::default(),
365 line_offsets: FxHashMap::default(),
366 }
367 }
368
369 fn root_relative(&mut self, path: &str) -> Option<String> {
384 if !Path::new(path).is_absolute() {
385 return None;
386 }
387 if let Some(cached) = self.resolved_paths.get(path) {
388 return cached.clone();
389 }
390 let resolved = dunce::canonicalize(path).ok().and_then(|canonical| {
391 let root = self.canonical_root.as_deref().unwrap_or(self.root);
392 let relative = canonical.strip_prefix(root).ok()?;
393 Some(relative.to_string_lossy().replace('\\', "/"))
394 });
395 self.resolved_paths
396 .insert(path.to_string(), resolved.clone());
397 resolved
398 }
399}
400
401#[must_use]
403pub fn resolve_stack_trace(
404 graph: &RetainedModuleGraph,
405 root: &Path,
406 parsed: ParsedStackTrace,
407 source: String,
408) -> ErrorTrace {
409 resolve_with_graph(graph.as_graph(), root, parsed, source)
410}
411
412fn resolve_with_graph(
413 graph: &ModuleGraph,
414 root: &Path,
415 parsed: ParsedStackTrace,
416 source: String,
417) -> ErrorTrace {
418 let ParsedStackTrace {
419 header,
420 frames,
421 unparsed_lines,
422 } = parsed;
423
424 let frames_omitted = frames.len().saturating_sub(MAX_REPORTED_FRAMES);
425 let mut context = TraceContext::new(root);
426 let mut counts = ErrorTraceCounts {
427 frames_omitted,
428 unparsed_lines,
429 ..ErrorTraceCounts::default()
430 };
431
432 let resolved_frames: Vec<ErrorTraceFrame> = frames
433 .into_iter()
434 .take(MAX_REPORTED_FRAMES)
435 .enumerate()
436 .map(|(index, frame)| resolve_frame(graph, &mut context, index, frame))
437 .collect();
438
439 counts.frames = resolved_frames.len();
440 for frame in &resolved_frames {
441 match frame.origin {
442 FrameOrigin::InProject => counts.in_project += 1,
443 FrameOrigin::NodeModules => counts.node_modules += 1,
444 FrameOrigin::OutOfCorpus => counts.out_of_corpus += 1,
445 }
446 match frame.resolution {
447 FrameResolution::Resolved => counts.resolved += 1,
448 FrameResolution::Ambiguous => counts.ambiguous += 1,
449 FrameResolution::NotFound => counts.not_found += 1,
450 FrameResolution::NotAttempted => counts.not_attempted += 1,
451 }
452 }
453
454 let reason = summary_reason(&counts, header.is_some());
455
456 ErrorTrace {
457 schema_version: ErrorTraceSchemaVersion::V1,
458 source,
459 header,
460 reason,
461 frames: resolved_frames,
462 counts,
463 }
464}
465
466fn summary_reason(counts: &ErrorTraceCounts, has_header: bool) -> String {
475 use std::fmt::Write as _;
476
477 if counts.frames == 0 {
478 let input_lines = counts.unparsed_lines + usize::from(has_header);
479 return if input_lines == 0 {
480 "no stack frames in the input".to_string()
481 } else {
482 format!(
483 "no stack frames recognised in {input_lines} non-blank input {}",
484 plural(input_lines, "line", "lines")
485 )
486 };
487 }
488 let mut reason = format!(
489 "{} {}: {} resolved, {} ambiguous, {} not found, {} not attempted",
490 counts.frames,
491 plural(counts.frames, "frame", "frames"),
492 counts.resolved,
493 counts.ambiguous,
494 counts.not_found,
495 counts.not_attempted
496 );
497 if counts.frames_omitted > 0 {
498 let _ = write!(
499 reason,
500 " ({} further frames omitted)",
501 counts.frames_omitted
502 );
503 }
504 if counts.unparsed_lines > 0 {
505 let _ = write!(
506 reason,
507 "; {} further input {} not recognised as a frame",
508 counts.unparsed_lines,
509 plural(counts.unparsed_lines, "line", "lines")
510 );
511 }
512 reason
513}
514
515fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
516 if count == 1 { one } else { many }
517}
518
519struct FrameOutcome {
521 resolution: FrameResolution,
522 candidates: Vec<ErrorTraceCandidate>,
523 candidates_omitted: usize,
524 line_mismatch: bool,
525 reason: String,
526}
527
528impl FrameOutcome {
529 fn not_attempted(reason: String) -> Self {
531 Self {
532 resolution: FrameResolution::NotAttempted,
533 candidates: Vec::new(),
534 candidates_omitted: 0,
535 line_mismatch: false,
536 reason,
537 }
538 }
539}
540
541fn resolve_frame(
544 graph: &ModuleGraph,
545 context: &mut TraceContext<'_>,
546 index: usize,
547 frame: RawFrame,
548) -> ErrorTraceFrame {
549 let (origin, outcome) = classify_and_look_up(graph, context, &frame);
550 let RawFrame {
551 raw,
552 function,
553 is_constructor,
554 is_async,
555 file,
556 line,
557 column,
558 } = frame;
559 ErrorTraceFrame {
560 index,
561 raw,
562 function,
563 is_constructor,
564 is_async,
565 file,
566 line,
567 column,
568 origin,
569 resolution: outcome.resolution,
570 candidates: outcome.candidates,
571 candidates_omitted: outcome.candidates_omitted,
572 line_mismatch: outcome.line_mismatch,
573 reason: outcome.reason,
574 }
575}
576
577fn classify_and_look_up(
582 graph: &ModuleGraph,
583 context: &mut TraceContext<'_>,
584 frame: &RawFrame,
585) -> (FrameOrigin, FrameOutcome) {
586 let Some(path) = frame.file.as_deref() else {
587 return (
588 FrameOrigin::OutOfCorpus,
589 FrameOutcome::not_attempted("frame carries no source location".to_string()),
590 );
591 };
592
593 if has_path_segment(path, DEPENDENCY_SEGMENT) {
594 return (
595 FrameOrigin::NodeModules,
596 FrameOutcome::not_attempted(
597 "frame is in an installed dependency, not in project source".to_string(),
598 ),
599 );
600 }
601
602 let resolved = context.root_relative(path);
607 let match_path = resolved.as_deref().unwrap_or(path);
608 let root = context.root;
609 let module_indexes = matching_module_indexes(graph, root, match_path);
610 if module_indexes.is_empty() {
611 return (
612 FrameOrigin::OutOfCorpus,
613 FrameOutcome::not_attempted(out_of_corpus_reason(path)),
614 );
615 }
616
617 let Some(name) = frame.function.as_deref() else {
618 return (
619 FrameOrigin::InProject,
620 FrameOutcome::not_attempted(
621 "frame carries no function identifier to look up".to_string(),
622 ),
623 );
624 };
625
626 let Some(identifier) = identifier_parts(name) else {
627 return (
628 FrameOrigin::InProject,
629 FrameOutcome::not_attempted(format!("'{name}' is not an addressable identifier")),
630 );
631 };
632
633 (
634 FrameOrigin::InProject,
635 look_up(graph, context, &module_indexes, identifier, frame.line),
636 )
637}
638
639fn out_of_corpus_reason(path: &str) -> String {
644 if is_runtime_internal(path) {
645 return format!("'{path}' is a runtime internal, not project source");
646 }
647 if BUILD_OUTPUT_SEGMENTS
648 .iter()
649 .any(|segment| has_path_segment(path, segment))
650 {
651 return format!(
652 "'{path}' is generated build output; resolving it back to source needs a source map, which this command does not read"
653 );
654 }
655 format!("'{path}' is not a module in the analysed project")
656}
657
658fn look_up(
661 graph: &ModuleGraph,
662 context: &mut TraceContext<'_>,
663 module_indexes: &[usize],
664 identifier: Identifier<'_>,
665 frame_line: Option<u32>,
666) -> FrameOutcome {
667 let name = identifier.printed;
668 let located = collect_candidates(
669 graph,
670 context.root,
671 module_indexes,
672 identifier.owner,
673 identifier.name,
674 );
675 let total = located.len();
676 let candidates_omitted = total.saturating_sub(MAX_FRAME_CANDIDATES);
677 let single_file_id = located.first().map(|candidate| candidate.file_id);
678 let candidates: Vec<ErrorTraceCandidate> = located
679 .into_iter()
680 .take(MAX_FRAME_CANDIDATES)
681 .map(|candidate| resolve_candidate_line(graph, candidate, &mut context.line_offsets))
682 .collect();
683
684 let mut line_mismatch = false;
685 let (resolution, mut reason) = match total {
686 0 => (
687 FrameResolution::NotFound,
688 format!(
689 "no definition named '{name}' is exported from the module this frame points at; a module-local function is not in the graph's definition set"
690 ),
691 ),
692 1 => {
693 let hit = &candidates[0];
694 let target = hit.member.as_ref().map_or_else(
695 || format!("{}:{}", hit.file, hit.symbol),
696 |member| format!("{}:{}.{member}", hit.file, hit.symbol),
697 );
698 (
699 FrameResolution::Resolved,
700 format!("'{name}' names {target} ({})", hit.kind),
701 )
702 }
703 _ => (
704 FrameResolution::Ambiguous,
705 format!("'{name}' names {total} definitions; none is preferred"),
706 ),
707 };
708
709 if resolution == FrameResolution::Resolved
714 && let Some(file_id) = single_file_id
715 && let Some(note) = line_mismatch_note(graph, context, file_id, &candidates[0], frame_line)
716 {
717 line_mismatch = true;
718 reason.push_str(¬e);
719 }
720
721 FrameOutcome {
722 resolution,
723 candidates,
724 candidates_omitted,
725 line_mismatch,
726 reason,
727 }
728}
729
730fn line_mismatch_note(
742 graph: &ModuleGraph,
743 context: &mut TraceContext<'_>,
744 file_id: FileId,
745 candidate: &ErrorTraceCandidate,
746 frame_line: Option<u32>,
747) -> Option<String> {
748 let frame_line = frame_line?;
749 let candidate_line = candidate.line?;
750 let nearest = nearest_declaration_at_or_above(graph, context, file_id, frame_line);
751 if nearest
752 .as_ref()
753 .is_some_and(|(name, line)| *line == candidate_line && name == &candidate_label(candidate))
754 {
755 return None;
756 }
757 let matched = candidate_label(candidate);
758 Some(match nearest {
759 Some((name, line)) => format!(
760 "; the frame's line {frame_line} sits after the declaration of '{name}' at line {line}, not after '{matched}' at line {candidate_line}, so verify this is the definition that ran"
761 ),
762 None => format!(
763 "; the frame's line {frame_line} is above every definition this file declares, including '{matched}' at line {candidate_line}, so verify this is the definition that ran"
764 ),
765 })
766}
767
768fn candidate_label(candidate: &ErrorTraceCandidate) -> String {
770 candidate.member.as_ref().map_or_else(
771 || candidate.symbol.clone(),
772 |member| format!("{}.{member}", candidate.symbol),
773 )
774}
775
776fn nearest_declaration_at_or_above(
783 graph: &ModuleGraph,
784 context: &mut TraceContext<'_>,
785 file_id: FileId,
786 line: u32,
787) -> Option<(String, u32)> {
788 let module = graph.modules.get(file_id.0 as usize)?;
789 let offsets = read_line_offsets(graph, file_id, &mut context.line_offsets)?;
790 let declaration_line =
791 |start: u32| fallow_types::extract::byte_offset_to_line_col(offsets, start).0;
792 let mut nearest: Option<(usize, Option<usize>, u32)> = None;
795 let mut consider = |export_index: usize, member_index: Option<usize>, declared: u32| {
796 if declared <= line && nearest.is_none_or(|(_, _, best)| declared > best) {
797 nearest = Some((export_index, member_index, declared));
798 }
799 };
800 for (export_index, export) in module.exports.iter().enumerate() {
801 consider(export_index, None, declaration_line(export.span.start));
802 for (member_index, member) in export.members.iter().enumerate() {
803 consider(
804 export_index,
805 Some(member_index),
806 declaration_line(member.span.start),
807 );
808 }
809 }
810
811 let (export_index, member_index, declared) = nearest?;
812 let export = module.exports.get(export_index)?;
813 let name = match member_index.and_then(|index| export.members.get(index)) {
814 Some(member) => format!("{}.{}", export.name, member.name),
815 None => export.name.to_string(),
816 };
817 Some((name, declared))
818}
819
820fn read_line_offsets<'a>(
822 graph: &ModuleGraph,
823 file_id: FileId,
824 line_offsets: &'a mut FxHashMap<FileId, Option<Vec<u32>>>,
825) -> Option<&'a Vec<u32>> {
826 line_offsets
827 .entry(file_id)
828 .or_insert_with(|| {
829 graph
830 .modules
831 .get(file_id.0 as usize)
832 .and_then(|module| std::fs::read_to_string(&module.path).ok())
833 .map(|source| fallow_types::extract::compute_line_offsets(&source))
834 })
835 .as_ref()
836}
837
838fn collect_candidates(
848 graph: &ModuleGraph,
849 root: &Path,
850 module_indexes: &[usize],
851 owner: Option<&str>,
852 name: &str,
853) -> Vec<LocatedCandidate> {
854 let mut candidates: Vec<LocatedCandidate> = Vec::new();
855 for &index in module_indexes {
856 let Some(module) = graph.modules.get(index) else {
857 continue;
858 };
859 let file = relativize(&module.path, root);
860 for export in &module.exports {
861 if export.name.matches_str(name) {
862 candidates.push(LocatedCandidate {
863 file_id: module.file_id,
864 file: file.clone(),
865 symbol: export.name.to_string(),
866 member: None,
867 kind: "export",
868 span_start: export.span.start,
869 });
870 }
871 if owner.is_some_and(|owner| export.name.matches_str(owner)) {
872 for member in &export.members {
873 if member.name == name {
874 candidates.push(LocatedCandidate {
875 file_id: module.file_id,
876 file: file.clone(),
877 symbol: export.name.to_string(),
878 member: Some(member.name.clone()),
879 kind: member_kind_label(member.kind),
880 span_start: member.span.start,
881 });
882 }
883 }
884 }
885 }
886 }
887 candidates.sort_by(|left, right| {
888 left.file
889 .cmp(&right.file)
890 .then_with(|| left.symbol.cmp(&right.symbol))
891 .then_with(|| left.member.cmp(&right.member))
892 });
893 candidates.dedup_by(|left, right| {
894 left.file == right.file && left.symbol == right.symbol && left.member == right.member
895 });
896 candidates
897}
898
899fn resolve_candidate_line(
903 graph: &ModuleGraph,
904 candidate: LocatedCandidate,
905 line_offsets: &mut FxHashMap<FileId, Option<Vec<u32>>>,
906) -> ErrorTraceCandidate {
907 let line = read_line_offsets(graph, candidate.file_id, line_offsets).map(|offsets| {
908 fallow_types::extract::byte_offset_to_line_col(offsets, candidate.span_start).0
909 });
910 ErrorTraceCandidate {
911 file: candidate.file,
912 symbol: candidate.symbol,
913 member: candidate.member,
914 kind: candidate.kind.to_string(),
915 line,
916 }
917}
918
919pub fn trace_error_with_session(
925 session: &crate::session::AnalysisSession,
926 input: &str,
927 source: String,
928) -> crate::EngineResult<ErrorTrace> {
929 let output = session.analyze_dead_code_with_shared_artifacts(false, true)?;
930 let graph = output
931 .graph
932 .as_ref()
933 .ok_or_else(|| crate::EngineError::new("trace-error requires a retained module graph"))?;
934 Ok(resolve_stack_trace(
935 graph,
936 session.root(),
937 parse_stack_trace(input),
938 source,
939 ))
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 fn frames(input: &str) -> Vec<RawFrame> {
947 parse_stack_trace(input).frames
948 }
949
950 #[test]
951 fn reads_the_v8_frame_forms() {
952 let parsed = parse_stack_trace(
953 "TypeError: user.load is not a function\n\
954 \x20 at loadUser (/proj/src/services/user.ts:42:11)\n\
955 \x20 at async UserService.load (/proj/src/user.ts:10:3)\n\
956 \x20 at new Widget (/proj/src/widget.tsx:5:9)\n\
957 \x20 at /proj/src/bare.ts:7:2\n",
958 );
959
960 assert_eq!(
961 parsed.header.as_deref(),
962 Some("TypeError: user.load is not a function")
963 );
964 assert_eq!(parsed.unparsed_lines, 0);
965 assert_eq!(parsed.frames.len(), 4);
966
967 assert_eq!(parsed.frames[0].function.as_deref(), Some("loadUser"));
968 assert_eq!(
969 parsed.frames[0].file.as_deref(),
970 Some("/proj/src/services/user.ts")
971 );
972 assert_eq!(parsed.frames[0].line, Some(42));
973 assert_eq!(parsed.frames[0].column, Some(11));
974
975 assert_eq!(
976 parsed.frames[1].function.as_deref(),
977 Some("UserService.load")
978 );
979 assert!(parsed.frames[1].is_async);
980 assert!(!parsed.frames[1].is_constructor);
981
982 assert_eq!(parsed.frames[2].function.as_deref(), Some("Widget"));
983 assert!(parsed.frames[2].is_constructor);
984
985 assert_eq!(parsed.frames[3].function, None);
986 assert_eq!(parsed.frames[3].file.as_deref(), Some("/proj/src/bare.ts"));
987 assert_eq!(parsed.frames[3].line, Some(7));
988 }
989
990 #[test]
991 fn reads_the_at_sign_frame_form() {
992 let parsed = parse_stack_trace(
993 "loadUser@/proj/src/user.ts:42:11\n@/proj/src/index.ts:3:1\nboot@https://app.test/assets/main.js:1:20\n",
994 );
995
996 assert_eq!(parsed.frames.len(), 3);
997 assert_eq!(parsed.frames[0].function.as_deref(), Some("loadUser"));
998 assert_eq!(parsed.frames[0].file.as_deref(), Some("/proj/src/user.ts"));
999 assert_eq!(parsed.frames[0].line, Some(42));
1000 assert_eq!(parsed.frames[1].function, None);
1001 assert_eq!(
1002 parsed.frames[2].file.as_deref(),
1003 Some("/assets/main.js"),
1004 "an http location keeps its path component and drops the authority"
1005 );
1006 }
1007
1008 #[test]
1009 fn a_line_with_a_stray_at_sign_is_not_a_frame() {
1010 let parsed = parse_stack_trace("reported by dev@example.com\n");
1011
1012 assert!(parsed.frames.is_empty());
1013 assert_eq!(
1014 parsed.header.as_deref(),
1015 Some("reported by dev@example.com")
1016 );
1017 assert_eq!(parsed.unparsed_lines, 0);
1018 }
1019
1020 #[test]
1021 fn unrecognised_lines_are_counted_never_dropped() {
1022 let parsed = parse_stack_trace("Error: boom\nnot a frame\nalso not a frame\n");
1023
1024 assert!(parsed.frames.is_empty());
1025 assert_eq!(parsed.header.as_deref(), Some("Error: boom"));
1026 assert_eq!(parsed.unparsed_lines, 2);
1027 }
1028
1029 #[test]
1030 fn an_empty_input_parses_to_nothing() {
1031 let parsed = parse_stack_trace("");
1032
1033 assert_eq!(parsed, ParsedStackTrace::default());
1034 }
1035
1036 #[test]
1037 fn the_frameless_reason_counts_the_header_line_it_describes() {
1038 let parsed = parse_stack_trace("something went wrong\nsee the logs\nand the dashboard\n");
1043 assert_eq!(parsed.unparsed_lines, 2);
1044 let counts = ErrorTraceCounts {
1045 unparsed_lines: parsed.unparsed_lines,
1046 ..ErrorTraceCounts::default()
1047 };
1048
1049 assert_eq!(
1050 summary_reason(&counts, parsed.header.is_some()),
1051 "no stack frames recognised in 3 non-blank input lines"
1052 );
1053 }
1054
1055 #[test]
1056 fn a_single_unrecognised_line_is_reported_as_one_input_line() {
1057 let parsed = parse_stack_trace("something went wrong\n");
1058 assert_eq!(parsed.unparsed_lines, 0);
1059 let counts = ErrorTraceCounts {
1060 unparsed_lines: parsed.unparsed_lines,
1061 ..ErrorTraceCounts::default()
1062 };
1063
1064 assert_eq!(
1065 summary_reason(&counts, parsed.header.is_some()),
1066 "no stack frames recognised in 1 non-blank input line"
1067 );
1068 }
1069
1070 #[test]
1071 fn an_input_with_no_lines_at_all_still_reads_as_empty() {
1072 let counts = ErrorTraceCounts::default();
1073
1074 assert_eq!(
1075 summary_reason(&counts, false),
1076 "no stack frames in the input"
1077 );
1078 }
1079
1080 #[test]
1081 fn the_frame_reason_marks_unparsed_lines_as_further_than_what_it_listed() {
1082 let counts = ErrorTraceCounts {
1086 frames: 1,
1087 resolved: 1,
1088 unparsed_lines: 2,
1089 ..ErrorTraceCounts::default()
1090 };
1091
1092 assert_eq!(
1093 summary_reason(&counts, true),
1094 "1 frame: 1 resolved, 0 ambiguous, 0 not found, 0 not attempted; \
1095 2 further input lines not recognised as a frame"
1096 );
1097 }
1098
1099 #[test]
1100 fn a_native_or_anonymous_location_carries_no_path() {
1101 let parsed = frames(" at doThing (native)\n at Array.forEach (<anonymous>)\n");
1102
1103 assert_eq!(parsed.len(), 2);
1104 assert_eq!(parsed[0].file, None);
1105 assert_eq!(parsed[0].line, None);
1106 assert_eq!(parsed[1].file, None);
1107 }
1108
1109 #[test]
1110 fn a_file_url_keeps_a_windows_drive_letter() {
1111 let parsed = frames(
1112 " at run (file:///C:/proj/src/a.ts:3:4)\n at run (file:///proj/src/b.ts:5:6)\n",
1113 );
1114
1115 assert_eq!(parsed[0].file.as_deref(), Some("C:/proj/src/a.ts"));
1116 assert_eq!(parsed[0].line, Some(3));
1117 assert_eq!(parsed[1].file.as_deref(), Some("/proj/src/b.ts"));
1118 }
1119
1120 #[test]
1121 fn a_backslash_windows_path_is_forward_slashed_and_keeps_its_numbers() {
1122 let parsed = frames(" at run (C:\\proj\\src\\a.ts:12:4)\n");
1123
1124 assert_eq!(parsed[0].file.as_deref(), Some("C:/proj/src/a.ts"));
1125 assert_eq!(parsed[0].line, Some(12));
1126 assert_eq!(parsed[0].column, Some(4));
1127 }
1128
1129 #[test]
1130 fn a_node_internal_frame_keeps_its_scheme() {
1131 let parsed = frames(
1132 " at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n",
1133 );
1134
1135 assert_eq!(
1136 parsed[0].file.as_deref(),
1137 Some("node:internal/process/task_queues")
1138 );
1139 assert!(is_runtime_internal("node:internal/process/task_queues"));
1140 }
1141
1142 #[test]
1143 fn a_location_without_a_column_puts_its_number_on_the_line() {
1144 let parsed = frames(" at run (/proj/src/a.ts:12)\n");
1145
1146 assert_eq!(parsed[0].file.as_deref(), Some("/proj/src/a.ts"));
1147 assert_eq!(parsed[0].line, Some(12));
1148 assert_eq!(parsed[0].column, None);
1149 }
1150
1151 #[test]
1152 fn identifier_parts_split_on_the_last_dot() {
1153 let parts = |name| identifier_parts(name).map(|id| (id.owner, id.name));
1154 assert_eq!(parts("run"), Some((None, "run")));
1155 assert_eq!(parts("Task.run"), Some((Some("Task"), "run")));
1156 assert_eq!(parts("ns.Task.run"), Some((Some("Task"), "run")));
1157 }
1158
1159 #[test]
1160 fn identifier_parts_reject_non_addressable_names() {
1161 for name in [
1162 "Object.<anonymous>",
1163 "outer/<",
1164 "global code",
1165 "Task..run",
1166 "",
1167 ] {
1168 assert_eq!(identifier_parts(name), None, "name was {name:?}");
1169 }
1170 }
1171
1172 #[test]
1173 fn an_absolute_frame_path_resolves_through_a_symlinked_root() {
1174 let dir = tempfile::tempdir().expect("tempdir");
1175 let real = dir.path().join("real/src");
1176 std::fs::create_dir_all(&real).expect("project dirs");
1177 std::fs::write(real.join("index.ts"), "export const run = () => 0;\n").expect("source");
1178 let linked = dir.path().join("linked");
1179 #[cfg(unix)]
1180 std::os::unix::fs::symlink(dir.path().join("real"), &linked).expect("symlink");
1181 #[cfg(windows)]
1182 std::os::windows::fs::symlink_dir(dir.path().join("real"), &linked).expect("symlink");
1183
1184 let root = dir.path().join("real");
1185 let mut context = TraceContext::new(&root);
1186 let frame = linked
1187 .join("src/index.ts")
1188 .to_string_lossy()
1189 .replace('\\', "/");
1190
1191 assert_eq!(
1192 context.root_relative(&frame).as_deref(),
1193 Some("src/index.ts"),
1194 "a frame spelled through a symlink must resolve to the module's own \
1195 root-relative path"
1196 );
1197 }
1198
1199 #[test]
1200 fn a_relative_or_missing_frame_path_is_never_rewritten() {
1201 let dir = tempfile::tempdir().expect("tempdir");
1202 let mut context = TraceContext::new(dir.path());
1203
1204 assert_eq!(context.root_relative("src/index.ts"), None);
1205 assert_eq!(
1206 context
1207 .root_relative(&dir.path().join("gone.ts").to_string_lossy())
1208 .as_deref(),
1209 None,
1210 "a path that does not exist resolves to nothing rather than to \
1211 something else"
1212 );
1213 }
1214
1215 #[test]
1216 fn path_segment_matching_is_component_wise() {
1217 assert!(has_path_segment(
1218 "/proj/node_modules/x/index.js",
1219 "node_modules"
1220 ));
1221 assert!(!has_path_segment(
1222 "/proj/my_node_modules_shim/a.js",
1223 "node_modules"
1224 ));
1225 assert!(has_path_segment("/proj/dist/main.js", "dist"));
1226 assert!(!has_path_segment("/proj/src/distance.ts", "dist"));
1227 }
1228}