Skip to main content

srcmap_remapping/
lib.rs

1//! Source map concatenation and composition/remapping.
2//!
3//! **Concatenation** merges source maps from multiple bundled files into one,
4//! adjusting line/column offsets. Used by bundlers (esbuild, Rollup, Webpack).
5//!
6//! **Composition/remapping** chains source maps through multiple transforms
7//! (e.g. TS → JS → minified) into a single map pointing to original sources.
8//! Equivalent to `@ampproject/remapping` in the JS ecosystem.
9//!
10//! # Examples
11//!
12//! ## Concatenation
13//!
14//! ```
15//! use srcmap_remapping::ConcatBuilder;
16//! use srcmap_sourcemap::SourceMap;
17//!
18//! fn main() {
19//!     let map_a = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
20//!     let map_b = r#"{"version":3,"sources":["b.js"],"names":[],"mappings":"AAAA"}"#;
21//!
22//!     let mut builder = ConcatBuilder::new(Some("bundle.js".to_string()));
23//!     builder.add_map(&SourceMap::from_json(map_a).unwrap(), 0);
24//!     builder.add_map(&SourceMap::from_json(map_b).unwrap(), 1);
25//!
26//!     let result = builder.build();
27//!     assert_eq!(result.mapping_count(), 2);
28//!     assert_eq!(result.line_count(), 2);
29//! }
30//! ```
31//!
32//! ## Composition / Remapping
33//!
34//! ```
35//! use srcmap_remapping::remap;
36//! use srcmap_sourcemap::SourceMap;
37//!
38//! fn main() {
39//!     // Transform: original.js → intermediate.js → output.js
40//!     let outer = r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA;AACA"}"#;
41//!     let inner = r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#;
42//!
43//!     let result = remap(
44//!         &SourceMap::from_json(outer).unwrap(),
45//!         |source| {
46//!             if source == "intermediate.js" {
47//!                 Some(SourceMap::from_json(inner).unwrap())
48//!             } else {
49//!                 None
50//!             }
51//!         },
52//!     );
53//!
54//!     // Result maps output.js directly to original.js
55//!     assert_eq!(result.sources, vec!["original.js"]);
56//! }
57//! ```
58
59use srcmap_generator::SourceMapGenerator;
60use srcmap_sourcemap::SourceMap;
61use std::collections::HashMap;
62
63// ── Concatenation ─────────────────────────────────────────────────
64
65/// Builder for concatenating multiple source maps into one.
66///
67/// Each added source map is offset by a line delta, producing a single
68/// combined map. Sources and names are deduplicated across inputs.
69pub struct ConcatBuilder {
70    builder: SourceMapGenerator,
71    source_remap: HashMap<String, u32>,
72    name_remap: HashMap<String, u32>,
73}
74
75impl ConcatBuilder {
76    /// Create a new concatenation builder.
77    pub fn new(file: Option<String>) -> Self {
78        Self {
79            builder: SourceMapGenerator::new(file),
80            source_remap: HashMap::new(),
81            name_remap: HashMap::new(),
82        }
83    }
84
85    /// Add a source map to the concatenated output.
86    ///
87    /// `line_offset` is the number of lines to shift all mappings by
88    /// (i.e. the line at which this chunk starts in the output).
89    pub fn add_map(&mut self, sm: &SourceMap, line_offset: u32) {
90        // Remap sources
91        let source_indices: Vec<u32> = sm
92            .sources
93            .iter()
94            .enumerate()
95            .map(|(i, s)| {
96                if let Some(&idx) = self.source_remap.get(s) {
97                    // If this source has content and we don't yet, update it
98                    if let Some(Some(content)) = sm.sources_content.get(i) {
99                        self.builder.set_source_content(idx, content.clone());
100                    }
101                    idx
102                } else {
103                    let idx = self.builder.add_source(s);
104                    if let Some(Some(content)) = sm.sources_content.get(i) {
105                        self.builder.set_source_content(idx, content.clone());
106                    }
107                    self.source_remap.insert(s.clone(), idx);
108                    idx
109                }
110            })
111            .collect();
112
113        // Remap names
114        let name_indices: Vec<u32> = sm
115            .names
116            .iter()
117            .map(|n| {
118                if let Some(&idx) = self.name_remap.get(n) {
119                    idx
120                } else {
121                    let idx = self.builder.add_name(n);
122                    self.name_remap.insert(n.clone(), idx);
123                    idx
124                }
125            })
126            .collect();
127
128        // Copy ignore_list entries
129        for &ignored in &sm.ignore_list {
130            let global_idx = source_indices[ignored as usize];
131            self.builder.add_to_ignore_list(global_idx);
132        }
133
134        // Add all mappings with line offset
135        for m in sm.all_mappings() {
136            let gen_line = m.generated_line + line_offset;
137
138            if m.source == u32::MAX {
139                self.builder
140                    .add_generated_mapping(gen_line, m.generated_column);
141            } else {
142                let src = source_indices[m.source as usize];
143                if m.name != u32::MAX {
144                    let name = name_indices[m.name as usize];
145                    self.builder.add_named_mapping(
146                        gen_line,
147                        m.generated_column,
148                        src,
149                        m.original_line,
150                        m.original_column,
151                        name,
152                    );
153                } else {
154                    self.builder.add_mapping(
155                        gen_line,
156                        m.generated_column,
157                        src,
158                        m.original_line,
159                        m.original_column,
160                    );
161                }
162            }
163        }
164    }
165
166    /// Finish building and return the concatenated source map as JSON.
167    pub fn to_json(&self) -> String {
168        self.builder.to_json()
169    }
170
171    /// Finish building and parse the result into a `SourceMap`.
172    pub fn build(&self) -> SourceMap {
173        let json = self.to_json();
174        SourceMap::from_json(&json).expect("generated JSON should be valid")
175    }
176}
177
178// ── Composition / Remapping ───────────────────────────────────────
179
180/// Remap a source map by resolving each source through upstream source maps.
181///
182/// For each source in the `outer` map, the `loader` function is called to
183/// retrieve the upstream source map. If a source map is returned, mappings
184/// are traced through it to the original source. If `None` is returned,
185/// the source is kept as-is.
186///
187/// This is equivalent to `@ampproject/remapping` in the JS ecosystem.
188pub fn remap<F>(outer: &SourceMap, loader: F) -> SourceMap
189where
190    F: Fn(&str) -> Option<SourceMap>,
191{
192    let mut builder = SourceMapGenerator::new(outer.file.clone());
193
194    // Cache: source name → loaded upstream map (or None)
195    let mut upstream_maps: HashMap<u32, Option<SourceMap>> = HashMap::new();
196
197    for m in outer.all_mappings() {
198        if m.source == u32::MAX {
199            builder.add_generated_mapping(m.generated_line, m.generated_column);
200            continue;
201        }
202
203        let source_name = outer.source(m.source);
204
205        // Load upstream map if we haven't already
206        let upstream = upstream_maps
207            .entry(m.source)
208            .or_insert_with(|| loader(source_name));
209
210        match upstream {
211            Some(upstream_sm) => {
212                // Trace through the upstream map
213                match upstream_sm.original_position_for(m.original_line, m.original_column) {
214                    Some(loc) => {
215                        let orig_source = upstream_sm.source(loc.source);
216                        let src_idx = builder.add_source(orig_source);
217
218                        // Copy sourcesContent from upstream if available
219                        if let Some(Some(content)) =
220                            upstream_sm.sources_content.get(loc.source as usize)
221                        {
222                            builder.set_source_content(src_idx, content.clone());
223                        }
224
225                        // Resolve name: prefer upstream name if available, else outer name
226                        let name_idx = loc
227                            .name
228                            .map(|n| builder.add_name(upstream_sm.name(n)))
229                            .or_else(|| {
230                                if m.name != u32::MAX {
231                                    Some(builder.add_name(outer.name(m.name)))
232                                } else {
233                                    None
234                                }
235                            });
236
237                        match name_idx {
238                            Some(name) => builder.add_named_mapping(
239                                m.generated_line,
240                                m.generated_column,
241                                src_idx,
242                                loc.line,
243                                loc.column,
244                                name,
245                            ),
246                            None => builder.add_mapping(
247                                m.generated_line,
248                                m.generated_column,
249                                src_idx,
250                                loc.line,
251                                loc.column,
252                            ),
253                        }
254                    }
255                    None => {
256                        // No mapping in upstream — keep original reference
257                        let src_idx = builder.add_source(source_name);
258                        if m.name != u32::MAX {
259                            let name = builder.add_name(outer.name(m.name));
260                            builder.add_named_mapping(
261                                m.generated_line,
262                                m.generated_column,
263                                src_idx,
264                                m.original_line,
265                                m.original_column,
266                                name,
267                            );
268                        } else {
269                            builder.add_mapping(
270                                m.generated_line,
271                                m.generated_column,
272                                src_idx,
273                                m.original_line,
274                                m.original_column,
275                            );
276                        }
277                    }
278                }
279            }
280            None => {
281                // No upstream map — pass through as-is
282                let src_idx = builder.add_source(source_name);
283
284                // Copy sourcesContent from outer if available
285                if let Some(Some(content)) = outer.sources_content.get(m.source as usize) {
286                    builder.set_source_content(src_idx, content.clone());
287                }
288
289                if m.name != u32::MAX {
290                    let name = builder.add_name(outer.name(m.name));
291                    builder.add_named_mapping(
292                        m.generated_line,
293                        m.generated_column,
294                        src_idx,
295                        m.original_line,
296                        m.original_column,
297                        name,
298                    );
299                } else {
300                    builder.add_mapping(
301                        m.generated_line,
302                        m.generated_column,
303                        src_idx,
304                        m.original_line,
305                        m.original_column,
306                    );
307                }
308            }
309        }
310    }
311
312    let json = builder.to_json();
313    SourceMap::from_json(&json).expect("generated JSON should be valid")
314}
315
316// ── Tests ─────────────────────────────────────────────────────────
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    // ── Concatenation tests ──────────────────────────────────────
323
324    #[test]
325    fn concat_two_simple_maps() {
326        let a = SourceMap::from_json(
327            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
328        )
329        .unwrap();
330        let b = SourceMap::from_json(
331            r#"{"version":3,"sources":["b.js"],"names":[],"mappings":"AAAA"}"#,
332        )
333        .unwrap();
334
335        let mut builder = ConcatBuilder::new(Some("bundle.js".to_string()));
336        builder.add_map(&a, 0);
337        builder.add_map(&b, 1);
338
339        let result = builder.build();
340        assert_eq!(result.sources, vec!["a.js", "b.js"]);
341        assert_eq!(result.mapping_count(), 2);
342
343        let loc0 = result.original_position_for(0, 0).unwrap();
344        assert_eq!(result.source(loc0.source), "a.js");
345
346        let loc1 = result.original_position_for(1, 0).unwrap();
347        assert_eq!(result.source(loc1.source), "b.js");
348    }
349
350    #[test]
351    fn concat_deduplicates_sources() {
352        let a = SourceMap::from_json(
353            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
354        )
355        .unwrap();
356        let b = SourceMap::from_json(
357            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
358        )
359        .unwrap();
360
361        let mut builder = ConcatBuilder::new(None);
362        builder.add_map(&a, 0);
363        builder.add_map(&b, 10);
364
365        let result = builder.build();
366        assert_eq!(result.sources.len(), 1);
367        assert_eq!(result.sources[0], "shared.js");
368    }
369
370    #[test]
371    fn concat_with_names() {
372        let a = SourceMap::from_json(
373            r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AAAAA"}"#,
374        )
375        .unwrap();
376        let b = SourceMap::from_json(
377            r#"{"version":3,"sources":["b.js"],"names":["bar"],"mappings":"AAAAA"}"#,
378        )
379        .unwrap();
380
381        let mut builder = ConcatBuilder::new(None);
382        builder.add_map(&a, 0);
383        builder.add_map(&b, 1);
384
385        let result = builder.build();
386        assert_eq!(result.names.len(), 2);
387
388        let loc0 = result.original_position_for(0, 0).unwrap();
389        assert_eq!(loc0.name, Some(0));
390        assert_eq!(result.name(0), "foo");
391
392        let loc1 = result.original_position_for(1, 0).unwrap();
393        assert_eq!(loc1.name, Some(1));
394        assert_eq!(result.name(1), "bar");
395    }
396
397    #[test]
398    fn concat_preserves_multi_line_maps() {
399        let a = SourceMap::from_json(
400            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA;AACA"}"#,
401        )
402        .unwrap();
403
404        let mut builder = ConcatBuilder::new(None);
405        builder.add_map(&a, 5); // offset by 5 lines
406
407        let result = builder.build();
408        assert!(result.original_position_for(5, 0).is_some());
409        assert!(result.original_position_for(6, 0).is_some());
410        assert!(result.original_position_for(7, 0).is_some());
411        assert!(result.original_position_for(4, 0).is_none());
412    }
413
414    #[test]
415    fn concat_with_sources_content() {
416        let a = SourceMap::from_json(
417            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#,
418        )
419        .unwrap();
420
421        let mut builder = ConcatBuilder::new(None);
422        builder.add_map(&a, 0);
423
424        let result = builder.build();
425        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
426    }
427
428    #[test]
429    fn concat_empty_builder() {
430        let builder = ConcatBuilder::new(Some("empty.js".to_string()));
431        let result = builder.build();
432        assert_eq!(result.mapping_count(), 0);
433        assert_eq!(result.sources.len(), 0);
434    }
435
436    // ── Remapping tests ──────────────────────────────────────────
437
438    #[test]
439    fn remap_single_level() {
440        // outer: output.js → intermediate.js
441        let outer = SourceMap::from_json(
442            r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA;AACA"}"#,
443        )
444        .unwrap();
445
446        // inner: intermediate.js → original.js
447        let inner = SourceMap::from_json(
448            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
449        )
450        .unwrap();
451
452        let result = remap(&outer, |source| {
453            if source == "intermediate.js" {
454                Some(inner.clone())
455            } else {
456                None
457            }
458        });
459
460        assert_eq!(result.sources, vec!["original.js"]);
461
462        // Line 0 col 0 in outer → line 0 col 0 in intermediate → line 1 col 0 in original
463        let loc = result.original_position_for(0, 0).unwrap();
464        assert_eq!(result.source(loc.source), "original.js");
465        assert_eq!(loc.line, 1);
466    }
467
468    #[test]
469    fn remap_no_upstream_passthrough() {
470        let outer = SourceMap::from_json(
471            r#"{"version":3,"sources":["already-original.js"],"names":[],"mappings":"AAAA"}"#,
472        )
473        .unwrap();
474
475        // No upstream maps — everything passes through
476        let result = remap(&outer, |_| None);
477
478        assert_eq!(result.sources, vec!["already-original.js"]);
479        let loc = result.original_position_for(0, 0).unwrap();
480        assert_eq!(result.source(loc.source), "already-original.js");
481        assert_eq!(loc.line, 0);
482        assert_eq!(loc.column, 0);
483    }
484
485    #[test]
486    fn remap_partial_sources() {
487        // outer has two sources: one with upstream, one without
488        let outer = SourceMap::from_json(
489            r#"{"version":3,"sources":["compiled.js","passthrough.js"],"names":[],"mappings":"AAAA,KCCA"}"#,
490        )
491        .unwrap();
492
493        let inner = SourceMap::from_json(
494            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
495        )
496        .unwrap();
497
498        let result = remap(&outer, |source| {
499            if source == "compiled.js" {
500                Some(inner.clone())
501            } else {
502                None
503            }
504        });
505
506        // Should have both the remapped source and the passthrough source
507        assert!(result.sources.contains(&"original.ts".to_string()));
508        assert!(result.sources.contains(&"passthrough.js".to_string()));
509    }
510
511    #[test]
512    fn remap_preserves_names() {
513        let outer = SourceMap::from_json(
514            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
515        )
516        .unwrap();
517
518        // upstream has no names — outer name should be preserved
519        let inner = SourceMap::from_json(
520            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
521        )
522        .unwrap();
523
524        let result = remap(&outer, |_| Some(inner.clone()));
525
526        let loc = result.original_position_for(0, 0).unwrap();
527        assert!(loc.name.is_some());
528        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
529    }
530
531    #[test]
532    fn remap_upstream_name_wins() {
533        let outer = SourceMap::from_json(
534            r#"{"version":3,"sources":["compiled.js"],"names":["outerName"],"mappings":"AAAAA"}"#,
535        )
536        .unwrap();
537
538        // upstream has its own name — should take precedence
539        let inner = SourceMap::from_json(
540            r#"{"version":3,"sources":["original.ts"],"names":["innerName"],"mappings":"AAAAA"}"#,
541        )
542        .unwrap();
543
544        let result = remap(&outer, |_| Some(inner.clone()));
545
546        let loc = result.original_position_for(0, 0).unwrap();
547        assert!(loc.name.is_some());
548        assert_eq!(result.name(loc.name.unwrap()), "innerName");
549    }
550
551    #[test]
552    fn remap_sources_content_from_upstream() {
553        let outer = SourceMap::from_json(
554            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
555        )
556        .unwrap();
557
558        let inner = SourceMap::from_json(
559            r#"{"version":3,"sources":["original.ts"],"sourcesContent":["const x = 1;"],"names":[],"mappings":"AAAA"}"#,
560        )
561        .unwrap();
562
563        let result = remap(&outer, |_| Some(inner.clone()));
564
565        assert_eq!(
566            result.sources_content,
567            vec![Some("const x = 1;".to_string())]
568        );
569    }
570
571    // ── Clone needed for SourceMap in tests ──────────────────────
572}