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, StreamingGenerator};
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}
72
73impl ConcatBuilder {
74    /// Create a new concatenation builder.
75    pub fn new(file: Option<String>) -> Self {
76        Self {
77            builder: SourceMapGenerator::new(file),
78        }
79    }
80
81    /// Add a source map to the concatenated output.
82    ///
83    /// `line_offset` is the number of lines to shift all mappings by
84    /// (i.e. the line at which this chunk starts in the output).
85    pub fn add_map(&mut self, sm: &SourceMap, line_offset: u32) {
86        // Remap sources (add_source deduplicates internally)
87        let source_indices: Vec<u32> = sm
88            .sources
89            .iter()
90            .enumerate()
91            .map(|(i, s)| {
92                let idx = self.builder.add_source(s);
93                if let Some(Some(content)) = sm.sources_content.get(i) {
94                    self.builder.set_source_content(idx, content.clone());
95                }
96                idx
97            })
98            .collect();
99
100        // Remap names (add_name deduplicates internally)
101        let name_indices: Vec<u32> = sm.names.iter().map(|n| self.builder.add_name(n)).collect();
102
103        // Copy ignore_list entries
104        for &ignored in &sm.ignore_list {
105            let global_idx = source_indices[ignored as usize];
106            self.builder.add_to_ignore_list(global_idx);
107        }
108
109        // Add all mappings with line offset
110        for m in sm.all_mappings() {
111            let gen_line = m.generated_line + line_offset;
112
113            if m.source == u32::MAX {
114                self.builder
115                    .add_generated_mapping(gen_line, m.generated_column);
116            } else {
117                let src = source_indices[m.source as usize];
118                if m.is_range_mapping {
119                    if m.name != u32::MAX {
120                        let name = name_indices[m.name as usize];
121                        self.builder.add_named_range_mapping(
122                            gen_line,
123                            m.generated_column,
124                            src,
125                            m.original_line,
126                            m.original_column,
127                            name,
128                        );
129                    } else {
130                        self.builder.add_range_mapping(
131                            gen_line,
132                            m.generated_column,
133                            src,
134                            m.original_line,
135                            m.original_column,
136                        );
137                    }
138                } else if m.name != u32::MAX {
139                    let name = name_indices[m.name as usize];
140                    self.builder.add_named_mapping(
141                        gen_line,
142                        m.generated_column,
143                        src,
144                        m.original_line,
145                        m.original_column,
146                        name,
147                    );
148                } else {
149                    self.builder.add_mapping(
150                        gen_line,
151                        m.generated_column,
152                        src,
153                        m.original_line,
154                        m.original_column,
155                    );
156                }
157            }
158        }
159    }
160
161    /// Serialize the current state as a JSON string.
162    pub fn to_json(&self) -> String {
163        self.builder.to_json()
164    }
165
166    /// Serialize the current state as a decoded `SourceMap`.
167    pub fn build(&self) -> SourceMap {
168        self.builder.to_decoded_map()
169    }
170}
171
172// ── Composition / Remapping ───────────────────────────────────────
173
174/// Resolved original-source parameters for a single mapping.
175struct MappingParams<'a> {
176    source: Option<&'a str>,
177    source_content: Option<&'a str>,
178    original_line: u32,
179    original_column: u32,
180    name: Option<&'a str>,
181}
182
183/// Add a mapping to the generator, dispatching to range/non-range variants.
184fn add_mapping_to_builder(
185    builder: &mut SourceMapGenerator,
186    gen_line: u32,
187    gen_col: u32,
188    params: &MappingParams<'_>,
189    is_range: bool,
190) {
191    let source = params.source.expect("source required for source mapping");
192    let src_idx = builder.add_source(source);
193
194    if let Some(content) = params.source_content {
195        builder.set_source_content(src_idx, content.to_string());
196    }
197
198    let name_idx = params.name.map(|n| builder.add_name(n));
199
200    match (name_idx, is_range) {
201        (Some(n), true) => builder.add_named_range_mapping(
202            gen_line,
203            gen_col,
204            src_idx,
205            params.original_line,
206            params.original_column,
207            n,
208        ),
209        (Some(n), false) => builder.add_named_mapping(
210            gen_line,
211            gen_col,
212            src_idx,
213            params.original_line,
214            params.original_column,
215            n,
216        ),
217        (None, true) => builder.add_range_mapping(
218            gen_line,
219            gen_col,
220            src_idx,
221            params.original_line,
222            params.original_column,
223        ),
224        (None, false) => builder.add_mapping(
225            gen_line,
226            gen_col,
227            src_idx,
228            params.original_line,
229            params.original_column,
230        ),
231    }
232}
233
234/// Remap a source map by resolving each source through upstream source maps.
235///
236/// For each source in the `outer` map, the `loader` function is called to
237/// retrieve the upstream source map. If a source map is returned, mappings
238/// are traced through it to the original source. If `None` is returned,
239/// the source is kept as-is.
240///
241/// Range mappings (`is_range_mapping`) are preserved through composition.
242/// The `ignore_list` from both upstream and outer maps is propagated.
243///
244/// This is equivalent to `@ampproject/remapping` in the JS ecosystem.
245pub fn remap<F>(outer: &SourceMap, loader: F) -> SourceMap
246where
247    F: Fn(&str) -> Option<SourceMap>,
248{
249    let mut builder = SourceMapGenerator::new(outer.file.clone());
250
251    // Cache: source name → loaded upstream map (or None)
252    let mut upstream_maps: HashMap<u32, Option<SourceMap>> = HashMap::new();
253    // Track which builder source indices have already been marked as ignored
254    let mut ignored_sources: std::collections::HashSet<u32> = std::collections::HashSet::new();
255
256    for m in outer.all_mappings() {
257        if m.source == u32::MAX {
258            builder.add_generated_mapping(m.generated_line, m.generated_column);
259            continue;
260        }
261
262        let source_name = outer.source(m.source);
263
264        // Load upstream map if we haven't already
265        let upstream = upstream_maps
266            .entry(m.source)
267            .or_insert_with(|| loader(source_name));
268
269        match upstream {
270            Some(upstream_sm) => {
271                // Trace through the upstream map
272                match upstream_sm.original_position_for(m.original_line, m.original_column) {
273                    Some(loc) => {
274                        let orig_source = upstream_sm.source(loc.source);
275                        let source_content = upstream_sm
276                            .sources_content
277                            .get(loc.source as usize)
278                            .and_then(|c| c.as_deref());
279
280                        // Resolve name: prefer upstream name if available, else outer name
281                        let name = loc.name.map(|n| upstream_sm.name(n)).or_else(|| {
282                            if m.name != u32::MAX {
283                                Some(outer.name(m.name))
284                            } else {
285                                None
286                            }
287                        });
288
289                        let params = MappingParams {
290                            source: Some(orig_source),
291                            source_content,
292                            original_line: loc.line,
293                            original_column: loc.column,
294                            name,
295                        };
296
297                        add_mapping_to_builder(
298                            &mut builder,
299                            m.generated_line,
300                            m.generated_column,
301                            &params,
302                            m.is_range_mapping,
303                        );
304
305                        // Propagate ignore_list from upstream map
306                        if upstream_sm.ignore_list.contains(&loc.source) {
307                            let src_idx = builder.add_source(orig_source);
308                            if ignored_sources.insert(src_idx) {
309                                builder.add_to_ignore_list(src_idx);
310                            }
311                        }
312                    }
313                    None => {
314                        // No mapping in upstream — keep original reference
315                        let name = if m.name != u32::MAX {
316                            Some(outer.name(m.name))
317                        } else {
318                            None
319                        };
320
321                        let params = MappingParams {
322                            source: Some(source_name),
323                            source_content: None,
324                            original_line: m.original_line,
325                            original_column: m.original_column,
326                            name,
327                        };
328
329                        add_mapping_to_builder(
330                            &mut builder,
331                            m.generated_line,
332                            m.generated_column,
333                            &params,
334                            m.is_range_mapping,
335                        );
336                    }
337                }
338            }
339            None => {
340                // No upstream map — pass through as-is
341                let source_content = outer
342                    .sources_content
343                    .get(m.source as usize)
344                    .and_then(|c| c.as_deref());
345
346                let name = if m.name != u32::MAX {
347                    Some(outer.name(m.name))
348                } else {
349                    None
350                };
351
352                let params = MappingParams {
353                    source: Some(source_name),
354                    source_content,
355                    original_line: m.original_line,
356                    original_column: m.original_column,
357                    name,
358                };
359
360                add_mapping_to_builder(
361                    &mut builder,
362                    m.generated_line,
363                    m.generated_column,
364                    &params,
365                    m.is_range_mapping,
366                );
367
368                // Propagate ignore_list from outer map
369                if outer.ignore_list.contains(&m.source) {
370                    let src_idx = builder.add_source(source_name);
371                    if ignored_sources.insert(src_idx) {
372                        builder.add_to_ignore_list(src_idx);
373                    }
374                }
375            }
376        }
377    }
378
379    builder.to_decoded_map()
380}
381
382/// Add a mapping to a streaming generator, dispatching to range/non-range variants.
383fn add_mapping_to_streaming(
384    builder: &mut StreamingGenerator,
385    gen_line: u32,
386    gen_col: u32,
387    params: &MappingParams<'_>,
388    is_range: bool,
389) {
390    let source = params.source.expect("source required for source mapping");
391    let src_idx = builder.add_source(source);
392
393    if let Some(content) = params.source_content {
394        builder.set_source_content(src_idx, content.to_string());
395    }
396
397    let name_idx = params.name.map(|n| builder.add_name(n));
398
399    match (name_idx, is_range) {
400        (Some(n), true) => builder.add_named_range_mapping(
401            gen_line,
402            gen_col,
403            src_idx,
404            params.original_line,
405            params.original_column,
406            n,
407        ),
408        (Some(n), false) => builder.add_named_mapping(
409            gen_line,
410            gen_col,
411            src_idx,
412            params.original_line,
413            params.original_column,
414            n,
415        ),
416        (None, true) => builder.add_range_mapping(
417            gen_line,
418            gen_col,
419            src_idx,
420            params.original_line,
421            params.original_column,
422        ),
423        (None, false) => builder.add_mapping(
424            gen_line,
425            gen_col,
426            src_idx,
427            params.original_line,
428            params.original_column,
429        ),
430    }
431}
432
433/// Streaming variant of [`remap`] that avoids materializing the outer map.
434///
435/// Accepts pre-parsed metadata and a [`MappingsIter`](srcmap_sourcemap::MappingsIter)
436/// over the outer map's VLQ-encoded mappings. Uses [`StreamingGenerator`] to
437/// encode the result on-the-fly without collecting all mappings first.
438///
439/// Because `MappingsIter` yields mappings in sorted order, the streaming
440/// generator can encode VLQ incrementally, avoiding the sort + re-encode
441/// pass that [`remap`] requires.
442///
443/// The `ignore_list` from both upstream and outer maps is propagated.
444/// Invalid segments from the iterator are silently skipped.
445pub fn remap_streaming<'a, F>(
446    mappings_iter: srcmap_sourcemap::MappingsIter<'a>,
447    sources: &[String],
448    names: &[String],
449    sources_content: &[Option<String>],
450    ignore_list: &[u32],
451    file: Option<String>,
452    loader: F,
453) -> SourceMap
454where
455    F: Fn(&str) -> Option<SourceMap>,
456{
457    let mut builder = StreamingGenerator::new(file);
458
459    // Cache: source index → loaded upstream map (or None)
460    let mut upstream_maps: HashMap<u32, Option<SourceMap>> = HashMap::new();
461    // Track which builder source indices have already been marked as ignored
462    let mut ignored_sources: std::collections::HashSet<u32> = std::collections::HashSet::new();
463
464    for item in mappings_iter {
465        let m = match item {
466            Ok(m) => m,
467            Err(_) => continue, // skip invalid segments
468        };
469
470        if m.source == u32::MAX {
471            builder.add_generated_mapping(m.generated_line, m.generated_column);
472            continue;
473        }
474
475        let Some(source_name) = sources.get(m.source as usize) else {
476            continue;
477        };
478
479        // Load upstream map if we haven't already
480        let upstream = upstream_maps
481            .entry(m.source)
482            .or_insert_with(|| loader(source_name));
483
484        match upstream {
485            Some(upstream_sm) => {
486                // Trace through the upstream map
487                match upstream_sm.original_position_for(m.original_line, m.original_column) {
488                    Some(loc) => {
489                        let orig_source = upstream_sm.source(loc.source);
490                        let source_content = upstream_sm
491                            .sources_content
492                            .get(loc.source as usize)
493                            .and_then(|c| c.as_deref());
494
495                        // Resolve name: prefer upstream name if available, else outer name
496                        let name = loc.name.map(|n| upstream_sm.name(n)).or_else(|| {
497                            if m.name != u32::MAX {
498                                names.get(m.name as usize).map(|s| s.as_str())
499                            } else {
500                                None
501                            }
502                        });
503
504                        let params = MappingParams {
505                            source: Some(orig_source),
506                            source_content,
507                            original_line: loc.line,
508                            original_column: loc.column,
509                            name,
510                        };
511
512                        add_mapping_to_streaming(
513                            &mut builder,
514                            m.generated_line,
515                            m.generated_column,
516                            &params,
517                            m.is_range_mapping,
518                        );
519
520                        // Propagate ignore_list from upstream map
521                        if upstream_sm.ignore_list.contains(&loc.source) {
522                            let src_idx = builder.add_source(orig_source);
523                            if ignored_sources.insert(src_idx) {
524                                builder.add_to_ignore_list(src_idx);
525                            }
526                        }
527                    }
528                    None => {
529                        // No mapping in upstream — keep original reference
530                        let name = if m.name != u32::MAX {
531                            names.get(m.name as usize).map(|s| s.as_str())
532                        } else {
533                            None
534                        };
535
536                        let params = MappingParams {
537                            source: Some(source_name),
538                            source_content: None,
539                            original_line: m.original_line,
540                            original_column: m.original_column,
541                            name,
542                        };
543
544                        add_mapping_to_streaming(
545                            &mut builder,
546                            m.generated_line,
547                            m.generated_column,
548                            &params,
549                            m.is_range_mapping,
550                        );
551                    }
552                }
553            }
554            None => {
555                // No upstream map — pass through as-is
556                let source_content = sources_content
557                    .get(m.source as usize)
558                    .and_then(|c| c.as_deref());
559
560                let name = if m.name != u32::MAX {
561                    names.get(m.name as usize).map(|s| s.as_str())
562                } else {
563                    None
564                };
565
566                let params = MappingParams {
567                    source: Some(source_name),
568                    source_content,
569                    original_line: m.original_line,
570                    original_column: m.original_column,
571                    name,
572                };
573
574                add_mapping_to_streaming(
575                    &mut builder,
576                    m.generated_line,
577                    m.generated_column,
578                    &params,
579                    m.is_range_mapping,
580                );
581
582                // Propagate ignore_list from outer
583                if ignore_list.contains(&m.source) {
584                    let src_idx = builder.add_source(source_name);
585                    if ignored_sources.insert(src_idx) {
586                        builder.add_to_ignore_list(src_idx);
587                    }
588                }
589            }
590        }
591    }
592
593    builder
594        .to_decoded_map()
595        .expect("streaming VLQ should be valid")
596}
597
598// ── Tests ─────────────────────────────────────────────────────────
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    // ── Concatenation tests ──────────────────────────────────────
605
606    #[test]
607    fn concat_two_simple_maps() {
608        let a = SourceMap::from_json(
609            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
610        )
611        .unwrap();
612        let b = SourceMap::from_json(
613            r#"{"version":3,"sources":["b.js"],"names":[],"mappings":"AAAA"}"#,
614        )
615        .unwrap();
616
617        let mut builder = ConcatBuilder::new(Some("bundle.js".to_string()));
618        builder.add_map(&a, 0);
619        builder.add_map(&b, 1);
620
621        let result = builder.build();
622        assert_eq!(result.sources, vec!["a.js", "b.js"]);
623        assert_eq!(result.mapping_count(), 2);
624
625        let loc0 = result.original_position_for(0, 0).unwrap();
626        assert_eq!(result.source(loc0.source), "a.js");
627
628        let loc1 = result.original_position_for(1, 0).unwrap();
629        assert_eq!(result.source(loc1.source), "b.js");
630    }
631
632    #[test]
633    fn concat_deduplicates_sources() {
634        let a = SourceMap::from_json(
635            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
636        )
637        .unwrap();
638        let b = SourceMap::from_json(
639            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
640        )
641        .unwrap();
642
643        let mut builder = ConcatBuilder::new(None);
644        builder.add_map(&a, 0);
645        builder.add_map(&b, 10);
646
647        let result = builder.build();
648        assert_eq!(result.sources.len(), 1);
649        assert_eq!(result.sources[0], "shared.js");
650    }
651
652    #[test]
653    fn concat_with_names() {
654        let a = SourceMap::from_json(
655            r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AAAAA"}"#,
656        )
657        .unwrap();
658        let b = SourceMap::from_json(
659            r#"{"version":3,"sources":["b.js"],"names":["bar"],"mappings":"AAAAA"}"#,
660        )
661        .unwrap();
662
663        let mut builder = ConcatBuilder::new(None);
664        builder.add_map(&a, 0);
665        builder.add_map(&b, 1);
666
667        let result = builder.build();
668        assert_eq!(result.names.len(), 2);
669
670        let loc0 = result.original_position_for(0, 0).unwrap();
671        assert_eq!(loc0.name, Some(0));
672        assert_eq!(result.name(0), "foo");
673
674        let loc1 = result.original_position_for(1, 0).unwrap();
675        assert_eq!(loc1.name, Some(1));
676        assert_eq!(result.name(1), "bar");
677    }
678
679    #[test]
680    fn concat_preserves_multi_line_maps() {
681        let a = SourceMap::from_json(
682            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA;AACA"}"#,
683        )
684        .unwrap();
685
686        let mut builder = ConcatBuilder::new(None);
687        builder.add_map(&a, 5); // offset by 5 lines
688
689        let result = builder.build();
690        assert!(result.original_position_for(5, 0).is_some());
691        assert!(result.original_position_for(6, 0).is_some());
692        assert!(result.original_position_for(7, 0).is_some());
693        assert!(result.original_position_for(4, 0).is_none());
694    }
695
696    #[test]
697    fn concat_with_sources_content() {
698        let a = SourceMap::from_json(
699            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#,
700        )
701        .unwrap();
702
703        let mut builder = ConcatBuilder::new(None);
704        builder.add_map(&a, 0);
705
706        let result = builder.build();
707        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
708    }
709
710    #[test]
711    fn concat_empty_builder() {
712        let builder = ConcatBuilder::new(Some("empty.js".to_string()));
713        let result = builder.build();
714        assert_eq!(result.mapping_count(), 0);
715        assert_eq!(result.sources.len(), 0);
716    }
717
718    // ── Remapping tests ──────────────────────────────────────────
719
720    #[test]
721    fn remap_single_level() {
722        // outer: output.js → intermediate.js + other.js (second source has no upstream)
723        // AAAA maps gen(0,0) → intermediate.js(0,0)
724        // KCAA maps gen(0,5) → other.js(0,0) (source delta +1)
725        // ;ADCA maps gen(1,0) → intermediate.js(1,0) (source delta -1, line delta +1)
726        let outer = SourceMap::from_json(
727            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":[],"mappings":"AAAA,KCAA;ADCA"}"#,
728        )
729        .unwrap();
730
731        // inner: intermediate.js → original.js
732        let inner = SourceMap::from_json(
733            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
734        )
735        .unwrap();
736
737        let result = remap(&outer, |source| {
738            if source == "intermediate.js" {
739                Some(inner.clone())
740            } else {
741                None
742            }
743        });
744
745        assert!(result.sources.contains(&"original.js".to_string()));
746        // other.js passes through since loader returns None
747        assert!(result.sources.contains(&"other.js".to_string()));
748
749        // Line 0 col 0 in outer → line 0 col 0 in intermediate → line 1 col 0 in original
750        let loc = result.original_position_for(0, 0).unwrap();
751        assert_eq!(result.source(loc.source), "original.js");
752        assert_eq!(loc.line, 1);
753    }
754
755    #[test]
756    fn remap_no_upstream_passthrough() {
757        let outer = SourceMap::from_json(
758            r#"{"version":3,"sources":["already-original.js"],"names":[],"mappings":"AAAA"}"#,
759        )
760        .unwrap();
761
762        // No upstream maps — everything passes through
763        let result = remap(&outer, |_| None);
764
765        assert_eq!(result.sources, vec!["already-original.js"]);
766        let loc = result.original_position_for(0, 0).unwrap();
767        assert_eq!(result.source(loc.source), "already-original.js");
768        assert_eq!(loc.line, 0);
769        assert_eq!(loc.column, 0);
770    }
771
772    #[test]
773    fn remap_partial_sources() {
774        // outer has two sources: one with upstream, one without
775        let outer = SourceMap::from_json(
776            r#"{"version":3,"sources":["compiled.js","passthrough.js"],"names":[],"mappings":"AAAA,KCCA"}"#,
777        )
778        .unwrap();
779
780        let inner = SourceMap::from_json(
781            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
782        )
783        .unwrap();
784
785        let result = remap(&outer, |source| {
786            if source == "compiled.js" {
787                Some(inner.clone())
788            } else {
789                None
790            }
791        });
792
793        // Should have both the remapped source and the passthrough source
794        assert!(result.sources.contains(&"original.ts".to_string()));
795        assert!(result.sources.contains(&"passthrough.js".to_string()));
796    }
797
798    #[test]
799    fn remap_preserves_names() {
800        let outer = SourceMap::from_json(
801            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
802        )
803        .unwrap();
804
805        // upstream has no names — outer name should be preserved
806        let inner = SourceMap::from_json(
807            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
808        )
809        .unwrap();
810
811        let result = remap(&outer, |_| Some(inner.clone()));
812
813        let loc = result.original_position_for(0, 0).unwrap();
814        assert!(loc.name.is_some());
815        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
816    }
817
818    #[test]
819    fn remap_upstream_name_wins() {
820        let outer = SourceMap::from_json(
821            r#"{"version":3,"sources":["compiled.js"],"names":["outerName"],"mappings":"AAAAA"}"#,
822        )
823        .unwrap();
824
825        // upstream has its own name — should take precedence
826        let inner = SourceMap::from_json(
827            r#"{"version":3,"sources":["original.ts"],"names":["innerName"],"mappings":"AAAAA"}"#,
828        )
829        .unwrap();
830
831        let result = remap(&outer, |_| Some(inner.clone()));
832
833        let loc = result.original_position_for(0, 0).unwrap();
834        assert!(loc.name.is_some());
835        assert_eq!(result.name(loc.name.unwrap()), "innerName");
836    }
837
838    #[test]
839    fn remap_sources_content_from_upstream() {
840        let outer = SourceMap::from_json(
841            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
842        )
843        .unwrap();
844
845        let inner = SourceMap::from_json(
846            r#"{"version":3,"sources":["original.ts"],"sourcesContent":["const x = 1;"],"names":[],"mappings":"AAAA"}"#,
847        )
848        .unwrap();
849
850        let result = remap(&outer, |_| Some(inner.clone()));
851
852        assert_eq!(
853            result.sources_content,
854            vec![Some("const x = 1;".to_string())]
855        );
856    }
857
858    // ── Clone needed for SourceMap in tests ──────────────────────
859
860    #[test]
861    fn concat_updates_source_content_on_duplicate() {
862        // First map has no sourcesContent, second has it for same source
863        let a = SourceMap::from_json(
864            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
865        )
866        .unwrap();
867        let b = SourceMap::from_json(
868            r#"{"version":3,"sources":["shared.js"],"sourcesContent":["var x = 1;"],"names":[],"mappings":"AAAA"}"#,
869        )
870        .unwrap();
871
872        let mut builder = ConcatBuilder::new(None);
873        builder.add_map(&a, 0);
874        builder.add_map(&b, 1);
875
876        let result = builder.build();
877        assert_eq!(result.sources.len(), 1);
878        assert_eq!(result.sources_content, vec![Some("var x = 1;".to_string())]);
879    }
880
881    #[test]
882    fn concat_deduplicates_names() {
883        let a = SourceMap::from_json(
884            r#"{"version":3,"sources":["a.js"],"names":["sharedName"],"mappings":"AAAAA"}"#,
885        )
886        .unwrap();
887        let b = SourceMap::from_json(
888            r#"{"version":3,"sources":["b.js"],"names":["sharedName"],"mappings":"AAAAA"}"#,
889        )
890        .unwrap();
891
892        let mut builder = ConcatBuilder::new(None);
893        builder.add_map(&a, 0);
894        builder.add_map(&b, 1);
895
896        let result = builder.build();
897        // Names should be deduplicated
898        assert_eq!(result.names.len(), 1);
899        assert_eq!(result.names[0], "sharedName");
900    }
901
902    #[test]
903    fn concat_with_ignore_list() {
904        let a = SourceMap::from_json(
905            r#"{"version":3,"sources":["vendor.js"],"names":[],"mappings":"AAAA","ignoreList":[0]}"#,
906        )
907        .unwrap();
908
909        let mut builder = ConcatBuilder::new(None);
910        builder.add_map(&a, 0);
911
912        let result = builder.build();
913        assert_eq!(result.ignore_list, vec![0]);
914    }
915
916    #[test]
917    fn concat_with_generated_only_mappings() {
918        // Map with a generated-only segment (1-field segment, no source info)
919        let a = SourceMap::from_json(
920            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,AAAA"}"#,
921        )
922        .unwrap();
923
924        let mut builder = ConcatBuilder::new(None);
925        builder.add_map(&a, 0);
926
927        let result = builder.build();
928        // Should have both mappings, including the generated-only one
929        assert!(result.mapping_count() >= 1);
930    }
931
932    #[test]
933    fn remap_generated_only_passthrough() {
934        // Outer map with a generated-only segment and two sources (second has no upstream)
935        // A = generated-only segment at col 0
936        // ,AAAA = gen(0,4)→a.js(0,0)
937        // ,KCAA = gen(0,9)→other.js(0,0) (source delta +1)
938        let outer = SourceMap::from_json(
939            r#"{"version":3,"sources":["a.js","other.js"],"names":[],"mappings":"A,AAAA,KCAA"}"#,
940        )
941        .unwrap();
942
943        let inner = SourceMap::from_json(
944            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
945        )
946        .unwrap();
947
948        let result = remap(&outer, |source| {
949            if source == "a.js" {
950                Some(inner.clone())
951            } else {
952                None
953            }
954        });
955
956        // Result should have mappings for the generated-only, remapped, and passthrough
957        assert!(result.mapping_count() >= 2);
958        assert!(result.sources.contains(&"original.js".to_string()));
959        assert!(result.sources.contains(&"other.js".to_string()));
960    }
961
962    #[test]
963    fn remap_no_upstream_mapping_with_name() {
964        // Outer has named mapping but upstream lookup finds no match at that position
965        let outer = SourceMap::from_json(
966            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
967        )
968        .unwrap();
969
970        // Inner map maps different position (line 5, not line 0)
971        let inner = SourceMap::from_json(
972            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
973        )
974        .unwrap();
975
976        let result = remap(&outer, |_| Some(inner.clone()));
977
978        // The outer mapping at (0,0) maps to (0,0) in compiled.js
979        // Inner doesn't have a mapping at (0,0), so it falls through
980        // The name from outer should be preserved
981        let loc = result.original_position_for(0, 0).unwrap();
982        assert!(loc.name.is_some());
983        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
984    }
985
986    #[test]
987    fn remap_no_upstream_with_sources_content_and_name() {
988        let outer = SourceMap::from_json(
989            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":["fn1"],"mappings":"AAAAA"}"#,
990        )
991        .unwrap();
992
993        // No upstream — everything passes through
994        let result = remap(&outer, |_| None);
995
996        assert_eq!(result.sources, vec!["a.js"]);
997        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
998        let loc = result.original_position_for(0, 0).unwrap();
999        assert!(loc.name.is_some());
1000        assert_eq!(result.name(loc.name.unwrap()), "fn1");
1001    }
1002
1003    #[test]
1004    fn remap_no_upstream_no_name() {
1005        let outer = SourceMap::from_json(
1006            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#,
1007        )
1008        .unwrap();
1009
1010        let result = remap(&outer, |_| None);
1011        let loc = result.original_position_for(0, 0).unwrap();
1012        assert!(loc.name.is_none());
1013    }
1014
1015    #[test]
1016    fn remap_no_upstream_mapping_no_name() {
1017        // Outer has a mapping with NO name pointing to compiled.js
1018        // AAAA = gen(0,0) → compiled.js(0,0), no name (4-field segment)
1019        let outer = SourceMap::from_json(
1020            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1021        )
1022        .unwrap();
1023
1024        // Inner map only has mappings at line 5, not at line 0
1025        // So original_position_for(0, 0) returns None → takes the None branch
1026        // Since the outer mapping has no name, this hits the else at lines 268-272
1027        let inner = SourceMap::from_json(
1028            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1029        )
1030        .unwrap();
1031
1032        let result = remap(&outer, |_| Some(inner.clone()));
1033
1034        // Falls through to the None branch (no upstream match at position)
1035        // Since outer has no name, the mapping is added without a name
1036        let loc = result.original_position_for(0, 0).unwrap();
1037        assert_eq!(result.source(loc.source), "compiled.js");
1038        assert_eq!(loc.line, 0);
1039        assert_eq!(loc.column, 0);
1040        assert!(loc.name.is_none());
1041    }
1042
1043    #[test]
1044    fn remap_upstream_found_no_name() {
1045        // Outer has a named mapping, but upstream has NO name
1046        // The upstream mapping is found but has no name_index
1047        // Since upstream has no name, the name resolution falls to the outer name
1048        // This is already covered by remap_preserves_names
1049        //
1050        // What we need instead: outer has NO name AND upstream has NO name
1051        // → name_idx is None → hits the add_mapping branch (line 246-252)
1052        let outer = SourceMap::from_json(
1053            r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA"}"#,
1054        )
1055        .unwrap();
1056
1057        // Inner maps intermediate.js(0,0) → original.js(0,0) with NO name
1058        let inner = SourceMap::from_json(
1059            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1060        )
1061        .unwrap();
1062
1063        let result = remap(&outer, |_| Some(inner.clone()));
1064
1065        assert_eq!(result.sources, vec!["original.js"]);
1066        let loc = result.original_position_for(0, 0).unwrap();
1067        assert_eq!(result.source(loc.source), "original.js");
1068        assert_eq!(loc.line, 0);
1069        assert_eq!(loc.column, 0);
1070        // Neither outer nor upstream has a name, so result has no name
1071        assert!(loc.name.is_none());
1072        assert!(result.names.is_empty());
1073    }
1074
1075    // ── Range mapping preservation tests ────────────────────────
1076
1077    #[test]
1078    fn concat_preserves_range_mappings() {
1079        let a = SourceMap::from_json(
1080            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,CAAC","rangeMappings":"A"}"#,
1081        )
1082        .unwrap();
1083
1084        let mut builder = ConcatBuilder::new(None);
1085        builder.add_map(&a, 0);
1086
1087        let result = builder.build();
1088        assert!(result.has_range_mappings());
1089        let mappings = result.all_mappings();
1090        assert!(mappings[0].is_range_mapping);
1091        assert!(!mappings[1].is_range_mapping);
1092    }
1093
1094    #[test]
1095    fn remap_preserves_range_mappings_passthrough() {
1096        let outer = SourceMap::from_json(
1097            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}"#,
1098        )
1099        .unwrap();
1100
1101        // No upstream — range mapping passes through
1102        let result = remap(&outer, |_| None);
1103        assert!(result.has_range_mappings());
1104        let mappings = result.all_mappings();
1105        assert!(mappings[0].is_range_mapping);
1106    }
1107
1108    #[test]
1109    fn remap_preserves_range_through_upstream() {
1110        let outer = SourceMap::from_json(
1111            r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}"#,
1112        )
1113        .unwrap();
1114
1115        let inner = SourceMap::from_json(
1116            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA"}"#,
1117        )
1118        .unwrap();
1119
1120        let result = remap(&outer, |_| Some(inner.clone()));
1121        assert!(result.has_range_mappings());
1122    }
1123
1124    #[test]
1125    fn remap_non_range_stays_non_range() {
1126        let outer = SourceMap::from_json(
1127            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
1128        )
1129        .unwrap();
1130
1131        let result = remap(&outer, |_| None);
1132        assert!(!result.has_range_mappings());
1133    }
1134
1135    // ── Streaming remapping tests ────────────────────────────────
1136
1137    /// Helper: run `remap_streaming` from a parsed SourceMap, re-encoding
1138    /// the VLQ string from its decoded mappings.
1139    fn streaming_from_sm<F>(sm: &SourceMap, loader: F) -> SourceMap
1140    where
1141        F: Fn(&str) -> Option<SourceMap>,
1142    {
1143        let vlq = sm.encode_mappings();
1144        let iter = srcmap_sourcemap::MappingsIter::new(&vlq);
1145        remap_streaming(
1146            iter,
1147            &sm.sources,
1148            &sm.names,
1149            &sm.sources_content,
1150            &sm.ignore_list,
1151            sm.file.clone(),
1152            loader,
1153        )
1154    }
1155
1156    #[test]
1157    fn streaming_single_level() {
1158        let outer = SourceMap::from_json(
1159            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":[],"mappings":"AAAA,KCAA;ADCA"}"#,
1160        )
1161        .unwrap();
1162
1163        let inner = SourceMap::from_json(
1164            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
1165        )
1166        .unwrap();
1167
1168        let result = streaming_from_sm(&outer, |source| {
1169            if source == "intermediate.js" {
1170                Some(inner.clone())
1171            } else {
1172                None
1173            }
1174        });
1175
1176        assert!(result.sources.contains(&"original.js".to_string()));
1177        assert!(result.sources.contains(&"other.js".to_string()));
1178
1179        let loc = result.original_position_for(0, 0).unwrap();
1180        assert_eq!(result.source(loc.source), "original.js");
1181        assert_eq!(loc.line, 1);
1182    }
1183
1184    #[test]
1185    fn streaming_no_upstream_passthrough() {
1186        let outer = SourceMap::from_json(
1187            r#"{"version":3,"sources":["already-original.js"],"names":[],"mappings":"AAAA"}"#,
1188        )
1189        .unwrap();
1190
1191        let result = streaming_from_sm(&outer, |_| None);
1192
1193        assert_eq!(result.sources, vec!["already-original.js"]);
1194        let loc = result.original_position_for(0, 0).unwrap();
1195        assert_eq!(result.source(loc.source), "already-original.js");
1196        assert_eq!(loc.line, 0);
1197        assert_eq!(loc.column, 0);
1198    }
1199
1200    #[test]
1201    fn streaming_preserves_names() {
1202        let outer = SourceMap::from_json(
1203            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1204        )
1205        .unwrap();
1206
1207        let inner = SourceMap::from_json(
1208            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
1209        )
1210        .unwrap();
1211
1212        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1213
1214        let loc = result.original_position_for(0, 0).unwrap();
1215        assert!(loc.name.is_some());
1216        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
1217    }
1218
1219    #[test]
1220    fn streaming_upstream_name_wins() {
1221        let outer = SourceMap::from_json(
1222            r#"{"version":3,"sources":["compiled.js"],"names":["outerName"],"mappings":"AAAAA"}"#,
1223        )
1224        .unwrap();
1225
1226        let inner = SourceMap::from_json(
1227            r#"{"version":3,"sources":["original.ts"],"names":["innerName"],"mappings":"AAAAA"}"#,
1228        )
1229        .unwrap();
1230
1231        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1232
1233        let loc = result.original_position_for(0, 0).unwrap();
1234        assert!(loc.name.is_some());
1235        assert_eq!(result.name(loc.name.unwrap()), "innerName");
1236    }
1237
1238    #[test]
1239    fn streaming_sources_content_from_upstream() {
1240        let outer = SourceMap::from_json(
1241            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1242        )
1243        .unwrap();
1244
1245        let inner = SourceMap::from_json(
1246            r#"{"version":3,"sources":["original.ts"],"sourcesContent":["const x = 1;"],"names":[],"mappings":"AAAA"}"#,
1247        )
1248        .unwrap();
1249
1250        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1251
1252        assert_eq!(
1253            result.sources_content,
1254            vec![Some("const x = 1;".to_string())]
1255        );
1256    }
1257
1258    #[test]
1259    fn streaming_no_upstream_with_sources_content() {
1260        let outer = SourceMap::from_json(
1261            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":["fn1"],"mappings":"AAAAA"}"#,
1262        )
1263        .unwrap();
1264
1265        let result = streaming_from_sm(&outer, |_| None);
1266
1267        assert_eq!(result.sources, vec!["a.js"]);
1268        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
1269        let loc = result.original_position_for(0, 0).unwrap();
1270        assert!(loc.name.is_some());
1271        assert_eq!(result.name(loc.name.unwrap()), "fn1");
1272    }
1273
1274    #[test]
1275    fn streaming_generated_only_passthrough() {
1276        let outer = SourceMap::from_json(
1277            r#"{"version":3,"sources":["a.js","other.js"],"names":[],"mappings":"A,AAAA,KCAA"}"#,
1278        )
1279        .unwrap();
1280
1281        let inner = SourceMap::from_json(
1282            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1283        )
1284        .unwrap();
1285
1286        let result = streaming_from_sm(&outer, |source| {
1287            if source == "a.js" {
1288                Some(inner.clone())
1289            } else {
1290                None
1291            }
1292        });
1293
1294        assert!(result.mapping_count() >= 2);
1295        assert!(result.sources.contains(&"original.js".to_string()));
1296        assert!(result.sources.contains(&"other.js".to_string()));
1297    }
1298
1299    #[test]
1300    fn streaming_matches_remap() {
1301        // Verify streaming produces identical results to non-streaming
1302        let outer = SourceMap::from_json(
1303            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":["foo"],"mappings":"AAAAA,KCAA;ADCA"}"#,
1304        )
1305        .unwrap();
1306
1307        let inner = SourceMap::from_json(
1308            r#"{"version":3,"sources":["original.js"],"sourcesContent":["// src"],"names":["bar"],"mappings":"AAAAA;AACA"}"#,
1309        )
1310        .unwrap();
1311
1312        let loader = |source: &str| -> Option<SourceMap> {
1313            if source == "intermediate.js" {
1314                Some(inner.clone())
1315            } else {
1316                None
1317            }
1318        };
1319
1320        let result_normal = remap(&outer, loader);
1321        let result_stream = streaming_from_sm(&outer, loader);
1322
1323        assert_eq!(result_normal.sources, result_stream.sources);
1324        assert_eq!(result_normal.names, result_stream.names);
1325        assert_eq!(result_normal.sources_content, result_stream.sources_content);
1326        assert_eq!(result_normal.mapping_count(), result_stream.mapping_count());
1327
1328        // Verify all lookups match
1329        for m in result_normal.all_mappings() {
1330            let loc_n = result_normal.original_position_for(m.generated_line, m.generated_column);
1331            let loc_s = result_stream.original_position_for(m.generated_line, m.generated_column);
1332            assert_eq!(loc_n.is_some(), loc_s.is_some());
1333            if let (Some(ln), Some(ls)) = (loc_n, loc_s) {
1334                assert_eq!(
1335                    result_normal.source(ln.source),
1336                    result_stream.source(ls.source)
1337                );
1338                assert_eq!(ln.line, ls.line);
1339                assert_eq!(ln.column, ls.column);
1340            }
1341        }
1342    }
1343
1344    #[test]
1345    fn streaming_no_upstream_mapping_fallback() {
1346        let outer = SourceMap::from_json(
1347            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1348        )
1349        .unwrap();
1350
1351        // Inner map maps different position (line 5, not line 0)
1352        let inner = SourceMap::from_json(
1353            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1354        )
1355        .unwrap();
1356
1357        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1358
1359        let loc = result.original_position_for(0, 0).unwrap();
1360        assert!(loc.name.is_some());
1361        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
1362    }
1363
1364    #[test]
1365    fn streaming_no_upstream_mapping_no_name() {
1366        let outer = SourceMap::from_json(
1367            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1368        )
1369        .unwrap();
1370
1371        let inner = SourceMap::from_json(
1372            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1373        )
1374        .unwrap();
1375
1376        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1377
1378        let loc = result.original_position_for(0, 0).unwrap();
1379        assert_eq!(result.source(loc.source), "compiled.js");
1380        assert!(loc.name.is_none());
1381    }
1382}