1use std::collections::HashMap;
2use std::path::{Component, PathBuf};
3
4use super::{FrameCategory, FrameId, StackId};
5use serde::Serialize;
6
7use crate::sourcemap::SourceMapResolver;
8
9fn normalize_path_components(path: &PathBuf) -> String {
11 let mut components: Vec<Component> = Vec::new();
12
13 for component in path.components() {
14 match component {
15 Component::ParentDir => {
16 if let Some(Component::Normal(_)) = components.last() {
18 components.pop();
19 } else {
20 components.push(component);
21 }
22 }
23 Component::CurDir => {
24 }
26 _ => {
27 components.push(component);
28 }
29 }
30 }
31
32 components
33 .iter()
34 .collect::<PathBuf>()
35 .to_string_lossy()
36 .to_string()
37}
38
39#[derive(Debug, Clone, Serialize)]
45pub struct Sample {
46 pub timestamp_us: u64,
48
49 pub stack_id: StackId,
51
52 pub weight: u64,
56}
57
58impl Sample {
59 pub fn new(timestamp_us: u64, stack_id: StackId, weight: u64) -> Self {
61 Self {
62 timestamp_us,
63 stack_id,
64 weight,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
74pub struct ProfileIR {
75 pub frames: Vec<super::Frame>,
77
78 pub stacks: Vec<super::Stack>,
80
81 pub samples: Vec<Sample>,
83
84 pub profile_type: ProfileType,
86
87 pub duration_us: Option<u64>,
89
90 pub source_file: Option<String>,
92
93 pub sourcemaps_resolved: usize,
95
96 pub profiles_merged: usize,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ProfileType {
103 Cpu,
104 Heap,
105}
106
107impl ProfileIR {
108 pub fn new_cpu(
110 frames: Vec<super::Frame>,
111 stacks: Vec<super::Stack>,
112 samples: Vec<Sample>,
113 duration_us: u64,
114 source_file: Option<String>,
115 ) -> Self {
116 Self {
117 frames,
118 stacks,
119 samples,
120 profile_type: ProfileType::Cpu,
121 duration_us: Some(duration_us),
122 source_file,
123 sourcemaps_resolved: 0,
124 profiles_merged: 1,
125 }
126 }
127
128 pub fn new_heap(
130 frames: Vec<super::Frame>,
131 stacks: Vec<super::Stack>,
132 samples: Vec<Sample>,
133 source_file: Option<String>,
134 ) -> Self {
135 Self {
136 frames,
137 stacks,
138 samples,
139 profile_type: ProfileType::Heap,
140 duration_us: None,
141 source_file,
142 sourcemaps_resolved: 0,
143 profiles_merged: 1,
144 }
145 }
146
147 pub fn total_weight(&self) -> u64 {
149 self.samples.iter().map(|s| s.weight).sum()
150 }
151
152 pub fn sample_count(&self) -> usize {
154 self.samples.len()
155 }
156
157 pub fn get_frame(&self, id: super::FrameId) -> Option<&super::Frame> {
159 self.frames.iter().find(|f| f.id == id)
160 }
161
162 pub fn get_stack(&self, id: StackId) -> Option<&super::Stack> {
164 self.stacks.iter().find(|s| s.id == id)
165 }
166
167 pub fn resolve_sourcemaps(&mut self, sourcemap_dirs: Vec<PathBuf>) -> usize {
171 if sourcemap_dirs.is_empty() {
172 return 0;
173 }
174
175 let mut resolver = SourceMapResolver::new(sourcemap_dirs.clone());
176 let mut resolved_count = 0;
177
178 let base_dir = sourcemap_dirs.first().cloned();
181
182 let mut location_counts: std::collections::HashMap<String, usize> =
184 std::collections::HashMap::new();
185
186 for frame in &mut self.frames {
187 let (Some(file), Some(line), Some(col)) = (&frame.file, frame.line, frame.col) else {
189 continue;
190 };
191
192 if let Some(resolved) = resolver.resolve(file, line, col) {
194 frame.minified_name = Some(frame.name.clone());
196 frame.minified_location = Some(frame.location());
197
198 if let Some(name) = resolved.name {
200 if !name.is_empty() {
201 frame.name = name;
202 }
203 }
204
205 let normalized_path = Self::normalize_sourcemap_path(&resolved.file, &base_dir);
207 frame.file = Some(normalized_path.clone());
208 frame.line = Some(resolved.line);
209 frame.col = Some(resolved.col);
210
211 let loc_key = format!("{}:{}", normalized_path, resolved.line);
213 *location_counts.entry(loc_key).or_insert(0) += 1;
214
215 frame.category = Self::classify_category_from_path(&normalized_path);
217
218 resolved_count += 1;
219 }
220 }
221
222 if resolved_count > 10 {
224 for (loc, count) in &location_counts {
225 let pct = (*count as f64 / resolved_count as f64) * 100.0;
226 if pct > 50.0 && *count > 20 {
227 eprintln!(
228 " ⚠️ Warning: {} frames ({:.0}%) resolved to same location: {}",
229 count, pct, loc
230 );
231 eprintln!(
232 " This usually means the source map points to a bundled file, not original sources."
233 );
234 eprintln!(
235 " Try pointing --sourcemap-dir to a directory with maps to original .ts files."
236 );
237 break;
238 }
239 }
240 }
241
242 self.sourcemaps_resolved = resolved_count;
243 resolved_count
244 }
245
246 fn normalize_sourcemap_path(path: &str, base_dir: &Option<PathBuf>) -> String {
248 let path = path
250 .strip_prefix("webpack://")
251 .or_else(|| path.strip_prefix("webpack:///"))
252 .or_else(|| path.strip_prefix("file://"))
253 .unwrap_or(path);
254
255 let path = if path.contains('/') && !path.starts_with('.') && !path.starts_with('/') {
257 let first_segment = path.split('/').next().unwrap_or("");
259 if !first_segment.contains('.') {
260 path.split_once('/').map(|(_, rest)| rest).unwrap_or(path)
261 } else {
262 path
263 }
264 } else {
265 path
266 };
267
268 if let Some(base) = base_dir {
270 let path_buf = PathBuf::from(path);
271 if path_buf.is_relative() {
272 let resolved = base.join(&path_buf);
273 if let Ok(canonical) = resolved.canonicalize() {
275 return canonical.to_string_lossy().to_string();
276 }
277 return normalize_path_components(&resolved);
279 }
280 }
281
282 path.to_string()
283 }
284
285 fn classify_category_from_path(path: &str) -> FrameCategory {
287 if path.contains("node_modules") {
289 return FrameCategory::Deps;
290 }
291
292 if path.starts_with("node:") || path.contains("internal/") {
294 return FrameCategory::NodeInternal;
295 }
296
297 FrameCategory::App
299 }
300
301 pub fn merge(profiles: Vec<Self>) -> Option<Self> {
308 if profiles.is_empty() {
309 return None;
310 }
311
312 if profiles.len() == 1 {
313 return profiles.into_iter().next();
314 }
315
316 let profile_type = profiles[0].profile_type;
317
318 let mut frame_key_to_id: HashMap<
320 (String, Option<String>, Option<u32>, Option<u32>),
321 FrameId,
322 > = HashMap::new();
323 let mut merged_frames: Vec<super::Frame> = Vec::new();
324
325 let mut stack_key_to_id: HashMap<Vec<FrameId>, StackId> = HashMap::new();
327 let mut merged_stacks: Vec<super::Stack> = Vec::new();
328
329 let mut merged_samples: Vec<Sample> = Vec::new();
330 let mut total_duration: u64 = 0;
331 let mut total_sourcemaps_resolved: usize = 0;
332 let profiles_count = profiles.len();
333
334 for profile in profiles {
335 let mut frame_id_map: HashMap<FrameId, FrameId> = HashMap::new();
337
338 for frame in &profile.frames {
339 let key = (
340 frame.name.clone(),
341 frame.file.clone(),
342 frame.line,
343 frame.col,
344 );
345
346 let new_id = *frame_key_to_id.entry(key).or_insert_with(|| {
347 let id = FrameId(merged_frames.len() as u32);
348 let mut new_frame = frame.clone();
349 new_frame.id = id;
350 merged_frames.push(new_frame);
351 id
352 });
353
354 frame_id_map.insert(frame.id, new_id);
355 }
356
357 let mut stack_id_map: HashMap<StackId, StackId> = HashMap::new();
359
360 for stack in &profile.stacks {
361 let remapped_frames: Vec<FrameId> = stack
363 .frames
364 .iter()
365 .filter_map(|fid| frame_id_map.get(fid).copied())
366 .collect();
367
368 let new_id = *stack_key_to_id
369 .entry(remapped_frames.clone())
370 .or_insert_with(|| {
371 let id = StackId(merged_stacks.len() as u32);
372 merged_stacks.push(super::Stack::new(id, remapped_frames));
373 id
374 });
375
376 stack_id_map.insert(stack.id, new_id);
377 }
378
379 for sample in &profile.samples {
381 if let Some(&new_stack_id) = stack_id_map.get(&sample.stack_id) {
382 merged_samples.push(Sample {
383 timestamp_us: sample.timestamp_us,
384 stack_id: new_stack_id,
385 weight: sample.weight,
386 });
387 }
388 }
389
390 if let Some(dur) = profile.duration_us {
391 total_duration += dur;
392 }
393 total_sourcemaps_resolved += profile.sourcemaps_resolved;
394 }
395
396 Some(Self {
397 frames: merged_frames,
398 stacks: merged_stacks,
399 samples: merged_samples,
400 profile_type,
401 duration_us: if total_duration > 0 {
402 Some(total_duration)
403 } else {
404 None
405 },
406 source_file: Some("(merged)".to_string()),
407 sourcemaps_resolved: total_sourcemaps_resolved,
408 profiles_merged: profiles_count,
409 })
410 }
411}