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::HashSet;
62
63const NO_SOURCE: u32 = u32::MAX;
64const NO_NAME: u32 = u32::MAX;
65
66// ── Concatenation ─────────────────────────────────────────────────
67
68/// Builder for concatenating multiple source maps into one.
69///
70/// Each added source map is offset by a line delta, producing a single
71/// combined map. Sources and names are deduplicated across inputs.
72pub struct ConcatBuilder {
73    builder: SourceMapGenerator,
74}
75
76impl ConcatBuilder {
77    /// Create a new concatenation builder.
78    pub fn new(file: Option<String>) -> Self {
79        Self { builder: SourceMapGenerator::new(file) }
80    }
81
82    /// Add a source map to the concatenated output.
83    ///
84    /// `line_offset` is the number of lines to shift all mappings by
85    /// (i.e. the line at which this chunk starts in the output).
86    pub fn add_map(&mut self, sm: &SourceMap, line_offset: u32) {
87        // Pre-build source index remap table (once per input map, not per mapping)
88        let source_indices: Vec<u32> = sm
89            .sources
90            .iter()
91            .enumerate()
92            .map(|(i, s)| {
93                let idx = self.builder.add_source(s);
94                if let Some(Some(content)) = sm.sources_content.get(i) {
95                    self.builder.set_source_content(idx, content.clone());
96                }
97                idx
98            })
99            .collect();
100
101        // Pre-build name index remap table (once per input map)
102        let name_indices: Vec<u32> = sm.names.iter().map(|n| self.builder.add_name(n)).collect();
103
104        // Copy ignore_list entries
105        for &ignored in &sm.ignore_list {
106            let global_idx = source_indices[ignored as usize];
107            self.builder.add_to_ignore_list(global_idx);
108        }
109
110        // Add all mappings with line offset, using pre-built index tables
111        for m in sm.all_mappings() {
112            let gen_line = m.generated_line + line_offset;
113
114            if m.source == NO_SOURCE {
115                self.builder.add_generated_mapping(gen_line, m.generated_column);
116            } else {
117                let src = source_indices[m.source as usize];
118                let has_name = m.name != NO_NAME;
119                match (has_name, m.is_range_mapping) {
120                    (true, true) => self.builder.add_named_range_mapping(
121                        gen_line,
122                        m.generated_column,
123                        src,
124                        m.original_line,
125                        m.original_column,
126                        name_indices[m.name as usize],
127                    ),
128                    (true, false) => self.builder.add_named_mapping(
129                        gen_line,
130                        m.generated_column,
131                        src,
132                        m.original_line,
133                        m.original_column,
134                        name_indices[m.name as usize],
135                    ),
136                    (false, true) => self.builder.add_range_mapping(
137                        gen_line,
138                        m.generated_column,
139                        src,
140                        m.original_line,
141                        m.original_column,
142                    ),
143                    (false, false) => self.builder.add_mapping(
144                        gen_line,
145                        m.generated_column,
146                        src,
147                        m.original_line,
148                        m.original_column,
149                    ),
150                }
151            }
152        }
153    }
154
155    /// Serialize the current state as a JSON string.
156    pub fn to_json(&self) -> String {
157        self.builder.to_json()
158    }
159
160    /// Serialize the current state as a decoded `SourceMap`.
161    pub fn build(&self) -> SourceMap {
162        self.builder.to_decoded_map()
163    }
164}
165
166// ── Composition / Remapping ───────────────────────────────────────
167
168/// Cached per-upstream-map data: lazy index remap tables.
169/// Sources and names are only registered in the builder when a mapping
170/// actually references them, matching jridgewell's behavior of not
171/// including unreferenced sources/names in the output.
172struct UpstreamCache {
173    /// upstream source idx → builder source idx (lazily populated)
174    source_remap: Vec<Option<u32>>,
175    /// upstream name idx → builder name idx (lazily populated)
176    name_remap: Vec<Option<u32>>,
177}
178
179/// Build lazy index remap tables for an upstream map.
180fn build_upstream_cache(upstream_sm: &SourceMap) -> UpstreamCache {
181    UpstreamCache {
182        source_remap: vec![None; upstream_sm.sources.len()],
183        name_remap: vec![None; upstream_sm.names.len()],
184    }
185}
186
187/// Resolve an upstream source index to a builder source index, lazily
188/// registering the source (and its content/ignore status) on first use.
189#[inline]
190fn resolve_upstream_source(
191    cache: &mut UpstreamCache,
192    upstream_sm: &SourceMap,
193    upstream_src: u32,
194    builder: &mut SourceMapGenerator,
195    ignored_sources: &mut HashSet<u32>,
196) -> u32 {
197    let si = upstream_src as usize;
198    if let Some(idx) = cache.source_remap[si] {
199        return idx;
200    }
201    let idx = builder.add_source(&upstream_sm.sources[si]);
202    if let Some(Some(content)) = upstream_sm.sources_content.get(si) {
203        builder.set_source_content(idx, content.clone());
204    }
205    if upstream_sm.ignore_list.contains(&upstream_src) && ignored_sources.insert(idx) {
206        builder.add_to_ignore_list(idx);
207    }
208    cache.source_remap[si] = Some(idx);
209    idx
210}
211
212/// Resolve an upstream name index to a builder name index, lazily
213/// registering the name on first use.
214#[inline]
215fn resolve_upstream_name(
216    cache: &mut UpstreamCache,
217    upstream_sm: &SourceMap,
218    upstream_name: u32,
219    builder: &mut SourceMapGenerator,
220) -> u32 {
221    let ni = upstream_name as usize;
222    if let Some(idx) = cache.name_remap[ni] {
223        return idx;
224    }
225    let idx = builder.add_name(&upstream_sm.names[ni]);
226    cache.name_remap[ni] = Some(idx);
227    idx
228}
229
230/// Resolve an upstream source index to a streaming builder source index.
231#[inline]
232fn resolve_upstream_source_streaming(
233    cache: &mut UpstreamCache,
234    upstream_sm: &SourceMap,
235    upstream_src: u32,
236    builder: &mut StreamingGenerator,
237    ignored_sources: &mut HashSet<u32>,
238) -> u32 {
239    let si = upstream_src as usize;
240    if let Some(idx) = cache.source_remap[si] {
241        return idx;
242    }
243    let idx = builder.add_source(&upstream_sm.sources[si]);
244    if let Some(Some(content)) = upstream_sm.sources_content.get(si) {
245        builder.set_source_content(idx, content.clone());
246    }
247    if upstream_sm.ignore_list.contains(&upstream_src) && ignored_sources.insert(idx) {
248        builder.add_to_ignore_list(idx);
249    }
250    cache.source_remap[si] = Some(idx);
251    idx
252}
253
254/// Resolve an upstream name index to a streaming builder name index.
255#[inline]
256fn resolve_upstream_name_streaming(
257    cache: &mut UpstreamCache,
258    upstream_sm: &SourceMap,
259    upstream_name: u32,
260    builder: &mut StreamingGenerator,
261) -> u32 {
262    let ni = upstream_name as usize;
263    if let Some(idx) = cache.name_remap[ni] {
264        return idx;
265    }
266    let idx = builder.add_name(&upstream_sm.names[ni]);
267    cache.name_remap[ni] = Some(idx);
268    idx
269}
270
271/// Look up the original position using the upstream map's line_offsets for O(1)
272/// line access, then binary search within the line slice.
273/// This is semantically equivalent to `upstream_sm.original_position_for()` with
274/// `GreatestLowerBound` bias, but inlined to avoid function call overhead and
275/// to return the raw `Mapping` reference for index-based remapping.
276///
277/// Falls back to range mapping search when the queried line has no mappings or the
278/// column is before the first mapping on the line — matching `original_position_for`.
279#[inline]
280fn lookup_upstream(upstream_sm: &SourceMap, line: u32, column: u32) -> Option<UpstreamLookup> {
281    let line_mappings = upstream_sm.mappings_for_line(line);
282    if line_mappings.is_empty() {
283        return fallback_to_full_lookup(upstream_sm, line, column);
284    }
285
286    let idx = match line_mappings.binary_search_by_key(&column, |m| m.generated_column) {
287        Ok(i) => i,
288        Err(0) => return fallback_to_full_lookup(upstream_sm, line, column),
289        Err(i) => i - 1,
290    };
291
292    let mapping = &line_mappings[idx];
293    if mapping.source == NO_SOURCE {
294        return None;
295    }
296
297    let original_column = if mapping.is_range_mapping && column >= mapping.generated_column {
298        mapping.original_column + (column - mapping.generated_column)
299    } else {
300        mapping.original_column
301    };
302
303    Some(UpstreamLookup {
304        source: mapping.source,
305        original_line: mapping.original_line,
306        original_column,
307        name: mapping.name,
308    })
309}
310
311/// Result of looking up a position in an upstream source map.
312/// Carries the resolved source/name indices and original position directly,
313/// so callers don't need to re-inspect the mapping.
314struct UpstreamLookup {
315    source: u32,
316    original_line: u32,
317    original_column: u32,
318    name: u32,
319}
320
321/// Fall back to the full `original_position_for` when the inlined lookup can't
322/// resolve (empty line or column before first mapping). This handles range mapping
323/// fallback correctly. Only called on the rare path where the line has no direct
324/// mappings, so the function call overhead is acceptable.
325fn fallback_to_full_lookup(
326    upstream_sm: &SourceMap,
327    line: u32,
328    column: u32,
329) -> Option<UpstreamLookup> {
330    let loc = upstream_sm.original_position_for(line, column)?;
331    Some(UpstreamLookup {
332        source: loc.source,
333        original_line: loc.line,
334        original_column: loc.column,
335        name: loc.name.unwrap_or(NO_NAME),
336    })
337}
338
339/// Emit a mapping to the builder using pre-built index remap tables.
340/// Uses indices directly, avoiding per-mapping string hashing.
341#[inline]
342#[allow(
343    clippy::too_many_arguments,
344    reason = "passing remapped indices avoids per-mapping hashing in the hot path"
345)]
346fn emit_remapped_mapping(
347    builder: &mut SourceMapGenerator,
348    gen_line: u32,
349    gen_col: u32,
350    builder_src: u32,
351    orig_line: u32,
352    orig_col: u32,
353    builder_name: Option<u32>,
354    is_range: bool,
355) {
356    match (builder_name, is_range) {
357        (Some(n), true) => {
358            builder.add_named_range_mapping(gen_line, gen_col, builder_src, orig_line, orig_col, n);
359        }
360        (Some(n), false) => {
361            builder.add_named_mapping(gen_line, gen_col, builder_src, orig_line, orig_col, n);
362        }
363        (None, true) => {
364            builder.add_range_mapping(gen_line, gen_col, builder_src, orig_line, orig_col);
365        }
366        (None, false) => {
367            builder.add_mapping(gen_line, gen_col, builder_src, orig_line, orig_col);
368        }
369    }
370}
371
372/// Emit a mapping to the streaming builder using pre-built index remap tables.
373#[inline]
374#[allow(clippy::too_many_arguments, reason = "streaming path mirrors the indexed hot-path API")]
375fn emit_remapped_mapping_streaming(
376    builder: &mut StreamingGenerator,
377    gen_line: u32,
378    gen_col: u32,
379    builder_src: u32,
380    orig_line: u32,
381    orig_col: u32,
382    builder_name: Option<u32>,
383    is_range: bool,
384) {
385    match (builder_name, is_range) {
386        (Some(n), true) => {
387            builder.add_named_range_mapping(gen_line, gen_col, builder_src, orig_line, orig_col, n);
388        }
389        (Some(n), false) => {
390            builder.add_named_mapping(gen_line, gen_col, builder_src, orig_line, orig_col, n);
391        }
392        (None, true) => {
393            builder.add_range_mapping(gen_line, gen_col, builder_src, orig_line, orig_col);
394        }
395        (None, false) => {
396            builder.add_mapping(gen_line, gen_col, builder_src, orig_line, orig_col);
397        }
398    }
399}
400
401/// Per-source entry: either an upstream map + cache, or a passthrough.
402/// Using an enum avoids two separate HashMap lookups per mapping.
403enum SourceEntry {
404    /// Has an upstream map: trace mappings through it.
405    Upstream { map: Box<SourceMap>, cache: UpstreamCache },
406    /// No upstream map: pass through with builder source index.
407    Passthrough { builder_src: u32 },
408    /// Empty-string source (from JSON `null`): emit as generated-only.
409    /// Matches jridgewell's behavior where `!source` triggers a sourceless segment.
410    EmptySource,
411    /// Not yet loaded.
412    Unloaded,
413}
414
415/// State for tracking the last emitted segment per generated line.
416/// Used to implement jridgewell's `skipSourceless` and `skipSource` deduplication.
417struct DedupeState {
418    /// Generated line of the last emitted segment.
419    last_gen_line: u32,
420    /// Index of the last emitted segment on the current generated line (0-based).
421    line_index: u32,
422    /// Whether the last emitted segment was sourceless.
423    last_was_sourceless: bool,
424    /// (source, orig_line, orig_col, name) of the last emitted sourced segment on this line.
425    last_source: Option<(u32, u32, u32, Option<u32>)>,
426}
427
428impl DedupeState {
429    fn new() -> Self {
430        Self {
431            last_gen_line: u32::MAX,
432            line_index: 0,
433            last_was_sourceless: false,
434            last_source: None,
435        }
436    }
437
438    /// Check if a sourceless segment should be skipped (jridgewell's `skipSourceless`).
439    /// Skip if: (1) first segment on the line, or (2) previous segment was also sourceless.
440    fn skip_sourceless(&self, gen_line: u32) -> bool {
441        if gen_line != self.last_gen_line {
442            // First segment on a new line → skip
443            return true;
444        }
445        // Consecutive sourceless → skip
446        self.last_was_sourceless
447    }
448
449    /// Check if a sourced segment should be skipped (jridgewell's `skipSource`).
450    /// Skip if previous segment on the same line has identical (source, line, col, name).
451    fn skip_source(
452        &self,
453        gen_line: u32,
454        source: u32,
455        orig_line: u32,
456        orig_col: u32,
457        name: Option<u32>,
458    ) -> bool {
459        if gen_line != self.last_gen_line {
460            // First segment on a new line → never skip
461            return false;
462        }
463        if self.last_was_sourceless {
464            // Previous was sourceless → never skip (transition to sourced)
465            return false;
466        }
467        // Skip if identical to the previous sourced segment
468        self.last_source == Some((source, orig_line, orig_col, name))
469    }
470
471    /// Record that a sourceless segment was emitted.
472    fn record_sourceless(&mut self, gen_line: u32) {
473        if gen_line != self.last_gen_line {
474            self.last_gen_line = gen_line;
475            self.line_index = 0;
476            self.last_source = None;
477        }
478        self.line_index += 1;
479        self.last_was_sourceless = true;
480    }
481
482    /// Record that a sourced segment was emitted.
483    fn record_source(
484        &mut self,
485        gen_line: u32,
486        source: u32,
487        orig_line: u32,
488        orig_col: u32,
489        name: Option<u32>,
490    ) {
491        if gen_line != self.last_gen_line {
492            self.last_gen_line = gen_line;
493            self.line_index = 0;
494        }
495        self.line_index += 1;
496        self.last_was_sourceless = false;
497        self.last_source = Some((source, orig_line, orig_col, name));
498    }
499}
500
501/// Remap a source map by resolving each source through upstream source maps.
502///
503/// For each source in the `outer` map, the `loader` function is called to
504/// retrieve the upstream source map. If a source map is returned, mappings
505/// are traced through it to the original source. If `None` is returned,
506/// the source is kept as-is.
507///
508/// Range mappings (`is_range_mapping`) are preserved through composition.
509/// The `ignore_list` from both upstream and outer maps is propagated.
510///
511/// Redundant mappings are skipped to match `@jridgewell/remapping` output:
512/// - Sourceless segments at position 0 on a line are dropped.
513/// - Consecutive sourceless segments on the same line are dropped.
514/// - Sourced segments identical to the previous segment on the same line are dropped.
515///
516/// This is equivalent to `@ampproject/remapping` in the JS ecosystem.
517pub fn remap<F>(outer: &SourceMap, loader: F) -> SourceMap
518where
519    F: Fn(&str) -> Option<SourceMap>,
520{
521    let mapping_count = outer.mapping_count();
522    let source_count = outer.sources.len();
523    let mut builder = SourceMapGenerator::with_capacity(outer.file.clone(), mapping_count);
524    // Mappings are emitted in the same order as outer (already sorted).
525    builder.set_assume_sorted(true);
526
527    // Flat Vec indexed by outer source index — avoids HashMap per mapping.
528    let mut source_entries: Vec<SourceEntry> =
529        std::iter::repeat_with(|| SourceEntry::Unloaded).take(source_count).collect();
530
531    let mut ignored_sources: HashSet<u32> = HashSet::new();
532
533    // Lazy outer name passthrough table (outer name idx → builder name idx)
534    let mut outer_name_remap: Vec<Option<u32>> = vec![None; outer.names.len()];
535
536    // Pre-compute outer ignore set for O(1) lookups
537    let outer_ignore_set: HashSet<u32> = outer.ignore_list.iter().copied().collect();
538
539    let mut dedup = DedupeState::new();
540
541    for m in outer.all_mappings() {
542        if m.source == NO_SOURCE {
543            if !dedup.skip_sourceless(m.generated_line) {
544                builder.add_generated_mapping(m.generated_line, m.generated_column);
545            }
546            dedup.record_sourceless(m.generated_line);
547            continue;
548        }
549
550        let si = m.source as usize;
551
552        // Load upstream map if not yet cached — Vec index, no hash
553        if matches!(source_entries[si], SourceEntry::Unloaded) {
554            let source_name = outer.source(m.source);
555            // Empty-string sources (from JSON null) are treated as generated-only,
556            // matching jridgewell's `if (!source)` check in addSegmentInternal.
557            if source_name.is_empty() {
558                source_entries[si] = SourceEntry::EmptySource;
559            } else {
560                match loader(source_name) {
561                    Some(upstream_sm) => {
562                        let cache = build_upstream_cache(&upstream_sm);
563                        source_entries[si] =
564                            SourceEntry::Upstream { map: Box::new(upstream_sm), cache };
565                    }
566                    None => {
567                        let idx = builder.add_source(source_name);
568                        if let Some(Some(content)) = outer.sources_content.get(si) {
569                            builder.set_source_content(idx, content.clone());
570                        }
571                        if outer_ignore_set.contains(&m.source) && ignored_sources.insert(idx) {
572                            builder.add_to_ignore_list(idx);
573                        }
574                        source_entries[si] = SourceEntry::Passthrough { builder_src: idx };
575                    }
576                }
577            }
578        }
579
580        match &mut source_entries[si] {
581            SourceEntry::Upstream { map, cache } => {
582                match lookup_upstream(map, m.original_line, m.original_column) {
583                    Some(upstream_m) => {
584                        let builder_src = resolve_upstream_source(
585                            cache,
586                            map,
587                            upstream_m.source,
588                            &mut builder,
589                            &mut ignored_sources,
590                        );
591
592                        // Resolve name: prefer upstream name, fall back to outer name
593                        let builder_name = if upstream_m.name != NO_NAME {
594                            Some(resolve_upstream_name(cache, map, upstream_m.name, &mut builder))
595                        } else if m.name != NO_NAME {
596                            let name_idx = *outer_name_remap[m.name as usize]
597                                .get_or_insert_with(|| builder.add_name(outer.name(m.name)));
598                            Some(name_idx)
599                        } else {
600                            None
601                        };
602
603                        if !dedup.skip_source(
604                            m.generated_line,
605                            builder_src,
606                            upstream_m.original_line,
607                            upstream_m.original_column,
608                            builder_name,
609                        ) {
610                            emit_remapped_mapping(
611                                &mut builder,
612                                m.generated_line,
613                                m.generated_column,
614                                builder_src,
615                                upstream_m.original_line,
616                                upstream_m.original_column,
617                                builder_name,
618                                m.is_range_mapping,
619                            );
620                        }
621                        dedup.record_source(
622                            m.generated_line,
623                            builder_src,
624                            upstream_m.original_line,
625                            upstream_m.original_column,
626                            builder_name,
627                        );
628                    }
629                    None => {
630                        // No mapping in upstream — drop the segment entirely.
631                        // jridgewell's traceMappings does `if (traced == null) continue;`
632                    }
633                }
634            }
635            SourceEntry::Passthrough { builder_src } => {
636                let builder_src = *builder_src;
637
638                let builder_name = if m.name != NO_NAME {
639                    Some(
640                        *outer_name_remap[m.name as usize]
641                            .get_or_insert_with(|| builder.add_name(outer.name(m.name))),
642                    )
643                } else {
644                    None
645                };
646
647                if !dedup.skip_source(
648                    m.generated_line,
649                    builder_src,
650                    m.original_line,
651                    m.original_column,
652                    builder_name,
653                ) {
654                    emit_remapped_mapping(
655                        &mut builder,
656                        m.generated_line,
657                        m.generated_column,
658                        builder_src,
659                        m.original_line,
660                        m.original_column,
661                        builder_name,
662                        m.is_range_mapping,
663                    );
664                }
665                dedup.record_source(
666                    m.generated_line,
667                    builder_src,
668                    m.original_line,
669                    m.original_column,
670                    builder_name,
671                );
672            }
673            SourceEntry::EmptySource => {
674                // Empty-string source → emit as generated-only (no source info)
675                if !dedup.skip_sourceless(m.generated_line) {
676                    builder.add_generated_mapping(m.generated_line, m.generated_column);
677                }
678                dedup.record_sourceless(m.generated_line);
679            }
680            SourceEntry::Unloaded => unreachable!(),
681        }
682    }
683
684    builder.to_decoded_map()
685}
686
687/// Compose a chain of pre-parsed source maps into a single source map.
688///
689/// Takes a slice of source maps in chain order: the first map is the outermost
690/// (final transform), and the last is the innermost (closest to original sources).
691/// Each consecutive pair is composed, threading mappings from generated → original.
692///
693/// This is more ergonomic than [`remap`] for cases where all maps are already
694/// parsed (e.g. Rolldown), since no loader closure is needed.
695///
696/// Returns the composed source map, or `None` if the slice is empty.
697///
698/// # Examples
699///
700/// ```
701/// use srcmap_remapping::remap_chain;
702/// use srcmap_sourcemap::SourceMap;
703///
704/// let step1 = r#"{"version":3,"file":"inter.js","sources":["original.js"],"names":[],"mappings":"AAAA;AACA"}"#;
705/// let step2 = r#"{"version":3,"file":"output.js","sources":["inter.js"],"names":[],"mappings":"AAAA;AACA"}"#;
706///
707/// let maps: Vec<SourceMap> = vec![
708///     SourceMap::from_json(step2).unwrap(),
709///     SourceMap::from_json(step1).unwrap(),
710/// ];
711/// let refs: Vec<&SourceMap> = maps.iter().collect();
712/// let result = remap_chain(&refs);
713/// assert!(result.is_some());
714/// let result = result.unwrap();
715/// assert_eq!(result.sources, vec!["original.js"]);
716/// ```
717pub fn remap_chain(maps: &[&SourceMap]) -> Option<SourceMap> {
718    if maps.is_empty() {
719        return None;
720    }
721    if maps.len() == 1 {
722        return Some(maps[0].clone());
723    }
724
725    // Compose from the end: start with the second-to-last as outer,
726    // last as inner, then work backwards.
727    // maps[0] is outermost, maps[len-1] is innermost.
728    // We compose pairwise: result = remap(maps[0], maps[1]), then
729    // result = remap(result, maps[2]), etc. But actually the chain is:
730    // maps[0] (outermost) sources reference maps[1], which sources reference maps[2], etc.
731    // So we compose maps[0] with maps[1], then the result with maps[2], etc.
732    // But remap expects a loader that returns maps for each source.
733    // For a simple chain, each map has sources that map to the next map in the chain.
734
735    // Start with the last two and work forward
736    let mut current = compose_pair(maps[maps.len() - 2], maps[maps.len() - 1]);
737
738    // Compose with remaining maps from right to left
739    for i in (0..maps.len() - 2).rev() {
740        current = compose_pair(maps[i], &current);
741    }
742
743    Some(current)
744}
745
746/// Compose two source maps: outer maps generated → intermediate, inner maps intermediate → original.
747/// All sources in outer are resolved through inner.
748fn compose_pair(outer: &SourceMap, inner: &SourceMap) -> SourceMap {
749    remap(outer, |_source| Some(inner.clone()))
750}
751
752/// Per-source entry for streaming variant.
753enum StreamingSourceEntry {
754    /// Has an upstream map: trace mappings through it.
755    Upstream { map: Box<SourceMap>, cache: UpstreamCache },
756    /// No upstream map: pass through with builder source index.
757    Passthrough { builder_src: u32 },
758    /// Empty-string source (from JSON `null`): emit as generated-only.
759    EmptySource,
760    /// Not yet loaded.
761    Unloaded,
762}
763
764/// Streaming variant of [`remap`] that avoids materializing the outer map.
765///
766/// Accepts pre-parsed metadata and a [`MappingsIter`](srcmap_sourcemap::MappingsIter)
767/// over the outer map's VLQ-encoded mappings. Uses [`StreamingGenerator`] to
768/// encode the result on-the-fly without collecting all mappings first.
769///
770/// Because `MappingsIter` yields mappings in sorted order, the streaming
771/// generator can encode VLQ incrementally, avoiding the sort + re-encode
772/// pass that [`remap`] requires.
773///
774/// The `ignore_list` from both upstream and outer maps is propagated.
775/// Invalid segments from the iterator are silently skipped.
776pub fn remap_streaming<'a, F>(
777    mappings_iter: srcmap_sourcemap::MappingsIter<'a>,
778    sources: &[String],
779    names: &[String],
780    sources_content: &[Option<String>],
781    ignore_list: &[u32],
782    file: Option<String>,
783    loader: F,
784) -> SourceMap
785where
786    F: Fn(&str) -> Option<SourceMap>,
787{
788    let mut builder = StreamingGenerator::with_capacity(file, 4096);
789
790    // Flat Vec indexed by outer source index — avoids HashMap per mapping
791    let mut source_entries: Vec<StreamingSourceEntry> =
792        std::iter::repeat_with(|| StreamingSourceEntry::Unloaded).take(sources.len()).collect();
793
794    let mut ignored_sources: HashSet<u32> = HashSet::new();
795
796    // Lazy outer name remap table
797    let mut outer_name_remap: Vec<Option<u32>> = vec![None; names.len()];
798
799    // Pre-compute outer ignore set for O(1) lookups
800    let outer_ignore_set: HashSet<u32> = ignore_list.iter().copied().collect();
801
802    let mut dedup = DedupeState::new();
803
804    for item in mappings_iter {
805        let m = match item {
806            Ok(m) => m,
807            Err(_) => continue,
808        };
809
810        if m.source == NO_SOURCE {
811            if !dedup.skip_sourceless(m.generated_line) {
812                builder.add_generated_mapping(m.generated_line, m.generated_column);
813            }
814            dedup.record_sourceless(m.generated_line);
815            continue;
816        }
817
818        let si = m.source as usize;
819        if si >= sources.len() {
820            continue;
821        }
822
823        // Load upstream map if not yet cached
824        if matches!(source_entries[si], StreamingSourceEntry::Unloaded) {
825            let source_name = &sources[si];
826            if source_name.is_empty() {
827                source_entries[si] = StreamingSourceEntry::EmptySource;
828            } else {
829                match loader(source_name) {
830                    Some(upstream_sm) => {
831                        let cache = build_upstream_cache(&upstream_sm);
832                        source_entries[si] =
833                            StreamingSourceEntry::Upstream { map: Box::new(upstream_sm), cache };
834                    }
835                    None => {
836                        let idx = builder.add_source(source_name);
837                        if let Some(Some(content)) = sources_content.get(si) {
838                            builder.set_source_content(idx, content.clone());
839                        }
840                        if outer_ignore_set.contains(&m.source) && ignored_sources.insert(idx) {
841                            builder.add_to_ignore_list(idx);
842                        }
843                        source_entries[si] = StreamingSourceEntry::Passthrough { builder_src: idx };
844                    }
845                }
846            }
847        }
848
849        match &mut source_entries[si] {
850            StreamingSourceEntry::Upstream { map, cache } => {
851                match lookup_upstream(map, m.original_line, m.original_column) {
852                    Some(upstream_m) => {
853                        let builder_src = resolve_upstream_source_streaming(
854                            cache,
855                            map,
856                            upstream_m.source,
857                            &mut builder,
858                            &mut ignored_sources,
859                        );
860
861                        let builder_name = if upstream_m.name != NO_NAME {
862                            Some(resolve_upstream_name_streaming(
863                                cache,
864                                map,
865                                upstream_m.name,
866                                &mut builder,
867                            ))
868                        } else {
869                            resolve_outer_name(&mut outer_name_remap, m.name, names, &mut builder)
870                        };
871
872                        if !dedup.skip_source(
873                            m.generated_line,
874                            builder_src,
875                            upstream_m.original_line,
876                            upstream_m.original_column,
877                            builder_name,
878                        ) {
879                            emit_remapped_mapping_streaming(
880                                &mut builder,
881                                m.generated_line,
882                                m.generated_column,
883                                builder_src,
884                                upstream_m.original_line,
885                                upstream_m.original_column,
886                                builder_name,
887                                m.is_range_mapping,
888                            );
889                        }
890                        dedup.record_source(
891                            m.generated_line,
892                            builder_src,
893                            upstream_m.original_line,
894                            upstream_m.original_column,
895                            builder_name,
896                        );
897                    }
898                    None => {
899                        // No mapping in upstream — drop the segment entirely.
900                        // jridgewell's traceMappings does `if (traced == null) continue;`
901                    }
902                }
903            }
904            StreamingSourceEntry::Passthrough { builder_src } => {
905                let builder_src = *builder_src;
906
907                let builder_name =
908                    resolve_outer_name(&mut outer_name_remap, m.name, names, &mut builder);
909
910                if !dedup.skip_source(
911                    m.generated_line,
912                    builder_src,
913                    m.original_line,
914                    m.original_column,
915                    builder_name,
916                ) {
917                    emit_remapped_mapping_streaming(
918                        &mut builder,
919                        m.generated_line,
920                        m.generated_column,
921                        builder_src,
922                        m.original_line,
923                        m.original_column,
924                        builder_name,
925                        m.is_range_mapping,
926                    );
927                }
928                dedup.record_source(
929                    m.generated_line,
930                    builder_src,
931                    m.original_line,
932                    m.original_column,
933                    builder_name,
934                );
935            }
936            StreamingSourceEntry::EmptySource => {
937                if !dedup.skip_sourceless(m.generated_line) {
938                    builder.add_generated_mapping(m.generated_line, m.generated_column);
939                }
940                dedup.record_sourceless(m.generated_line);
941            }
942            StreamingSourceEntry::Unloaded => unreachable!(),
943        }
944    }
945
946    builder.to_decoded_map().expect("streaming VLQ should be valid")
947}
948
949/// Resolve an outer name index to a builder name index, caching the result.
950#[inline]
951fn resolve_outer_name(
952    outer_name_remap: &mut [Option<u32>],
953    name_idx: u32,
954    names: &[String],
955    builder: &mut StreamingGenerator,
956) -> Option<u32> {
957    if name_idx == NO_NAME {
958        return None;
959    }
960    let slot = outer_name_remap.get_mut(name_idx as usize)?;
961    if let Some(idx) = *slot {
962        return Some(idx);
963    }
964    let outer_name = names.get(name_idx as usize)?;
965    let idx = builder.add_name(outer_name);
966    *slot = Some(idx);
967    Some(idx)
968}
969
970// ── Tests ─────────────────────────────────────────────────────────
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975
976    // ── Concatenation tests ──────────────────────────────────────
977
978    #[test]
979    fn concat_two_simple_maps() {
980        let a = SourceMap::from_json(
981            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
982        )
983        .unwrap();
984        let b = SourceMap::from_json(
985            r#"{"version":3,"sources":["b.js"],"names":[],"mappings":"AAAA"}"#,
986        )
987        .unwrap();
988
989        let mut builder = ConcatBuilder::new(Some("bundle.js".to_string()));
990        builder.add_map(&a, 0);
991        builder.add_map(&b, 1);
992
993        let result = builder.build();
994        assert_eq!(result.sources, vec!["a.js", "b.js"]);
995        assert_eq!(result.mapping_count(), 2);
996
997        let loc0 = result.original_position_for(0, 0).unwrap();
998        assert_eq!(result.source(loc0.source), "a.js");
999
1000        let loc1 = result.original_position_for(1, 0).unwrap();
1001        assert_eq!(result.source(loc1.source), "b.js");
1002    }
1003
1004    #[test]
1005    fn concat_deduplicates_sources() {
1006        let a = SourceMap::from_json(
1007            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
1008        )
1009        .unwrap();
1010        let b = SourceMap::from_json(
1011            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
1012        )
1013        .unwrap();
1014
1015        let mut builder = ConcatBuilder::new(None);
1016        builder.add_map(&a, 0);
1017        builder.add_map(&b, 10);
1018
1019        let result = builder.build();
1020        assert_eq!(result.sources.len(), 1);
1021        assert_eq!(result.sources[0], "shared.js");
1022    }
1023
1024    #[test]
1025    fn concat_with_names() {
1026        let a = SourceMap::from_json(
1027            r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AAAAA"}"#,
1028        )
1029        .unwrap();
1030        let b = SourceMap::from_json(
1031            r#"{"version":3,"sources":["b.js"],"names":["bar"],"mappings":"AAAAA"}"#,
1032        )
1033        .unwrap();
1034
1035        let mut builder = ConcatBuilder::new(None);
1036        builder.add_map(&a, 0);
1037        builder.add_map(&b, 1);
1038
1039        let result = builder.build();
1040        assert_eq!(result.names.len(), 2);
1041
1042        let loc0 = result.original_position_for(0, 0).unwrap();
1043        assert_eq!(loc0.name, Some(0));
1044        assert_eq!(result.name(0), "foo");
1045
1046        let loc1 = result.original_position_for(1, 0).unwrap();
1047        assert_eq!(loc1.name, Some(1));
1048        assert_eq!(result.name(1), "bar");
1049    }
1050
1051    #[test]
1052    fn concat_preserves_multi_line_maps() {
1053        let a = SourceMap::from_json(
1054            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA;AACA"}"#,
1055        )
1056        .unwrap();
1057
1058        let mut builder = ConcatBuilder::new(None);
1059        builder.add_map(&a, 5); // offset by 5 lines
1060
1061        let result = builder.build();
1062        assert!(result.original_position_for(5, 0).is_some());
1063        assert!(result.original_position_for(6, 0).is_some());
1064        assert!(result.original_position_for(7, 0).is_some());
1065        assert!(result.original_position_for(4, 0).is_none());
1066    }
1067
1068    #[test]
1069    fn concat_with_sources_content() {
1070        let a = SourceMap::from_json(
1071            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#,
1072        )
1073        .unwrap();
1074
1075        let mut builder = ConcatBuilder::new(None);
1076        builder.add_map(&a, 0);
1077
1078        let result = builder.build();
1079        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
1080    }
1081
1082    #[test]
1083    fn concat_empty_builder() {
1084        let builder = ConcatBuilder::new(Some("empty.js".to_string()));
1085        let result = builder.build();
1086        assert_eq!(result.mapping_count(), 0);
1087        assert_eq!(result.sources.len(), 0);
1088    }
1089
1090    // ── Remapping tests ──────────────────────────────────────────
1091
1092    #[test]
1093    fn remap_single_level() {
1094        // outer: output.js → intermediate.js + other.js (second source has no upstream)
1095        // AAAA maps gen(0,0) → intermediate.js(0,0)
1096        // KCAA maps gen(0,5) → other.js(0,0) (source delta +1)
1097        // ;ADCA maps gen(1,0) → intermediate.js(1,0) (source delta -1, line delta +1)
1098        let outer = SourceMap::from_json(
1099            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":[],"mappings":"AAAA,KCAA;ADCA"}"#,
1100        )
1101        .unwrap();
1102
1103        // inner: intermediate.js → original.js
1104        let inner = SourceMap::from_json(
1105            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
1106        )
1107        .unwrap();
1108
1109        let result =
1110            remap(
1111                &outer,
1112                |source| {
1113                    if source == "intermediate.js" { Some(inner.clone()) } else { None }
1114                },
1115            );
1116
1117        assert!(result.sources.contains(&"original.js".to_string()));
1118        // other.js passes through since loader returns None
1119        assert!(result.sources.contains(&"other.js".to_string()));
1120
1121        // Line 0 col 0 in outer → line 0 col 0 in intermediate → line 1 col 0 in original
1122        let loc = result.original_position_for(0, 0).unwrap();
1123        assert_eq!(result.source(loc.source), "original.js");
1124        assert_eq!(loc.line, 1);
1125    }
1126
1127    #[test]
1128    fn remap_no_upstream_passthrough() {
1129        let outer = SourceMap::from_json(
1130            r#"{"version":3,"sources":["already-original.js"],"names":[],"mappings":"AAAA"}"#,
1131        )
1132        .unwrap();
1133
1134        // No upstream maps — everything passes through
1135        let result = remap(&outer, |_| None);
1136
1137        assert_eq!(result.sources, vec!["already-original.js"]);
1138        let loc = result.original_position_for(0, 0).unwrap();
1139        assert_eq!(result.source(loc.source), "already-original.js");
1140        assert_eq!(loc.line, 0);
1141        assert_eq!(loc.column, 0);
1142    }
1143
1144    #[test]
1145    fn remap_partial_sources() {
1146        // outer has two sources: one with upstream, one without
1147        let outer = SourceMap::from_json(
1148            r#"{"version":3,"sources":["compiled.js","passthrough.js"],"names":[],"mappings":"AAAA,KCCA"}"#,
1149        )
1150        .unwrap();
1151
1152        let inner = SourceMap::from_json(
1153            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
1154        )
1155        .unwrap();
1156
1157        let result =
1158            remap(
1159                &outer,
1160                |source| {
1161                    if source == "compiled.js" { Some(inner.clone()) } else { None }
1162                },
1163            );
1164
1165        // Should have both the remapped source and the passthrough source
1166        assert!(result.sources.contains(&"original.ts".to_string()));
1167        assert!(result.sources.contains(&"passthrough.js".to_string()));
1168    }
1169
1170    #[test]
1171    fn remap_preserves_names() {
1172        let outer = SourceMap::from_json(
1173            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1174        )
1175        .unwrap();
1176
1177        // upstream has no names — outer name should be preserved
1178        let inner = SourceMap::from_json(
1179            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
1180        )
1181        .unwrap();
1182
1183        let result = remap(&outer, |_| Some(inner.clone()));
1184
1185        let loc = result.original_position_for(0, 0).unwrap();
1186        assert!(loc.name.is_some());
1187        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
1188    }
1189
1190    #[test]
1191    fn remap_upstream_name_wins() {
1192        let outer = SourceMap::from_json(
1193            r#"{"version":3,"sources":["compiled.js"],"names":["outerName"],"mappings":"AAAAA"}"#,
1194        )
1195        .unwrap();
1196
1197        // upstream has its own name — should take precedence
1198        let inner = SourceMap::from_json(
1199            r#"{"version":3,"sources":["original.ts"],"names":["innerName"],"mappings":"AAAAA"}"#,
1200        )
1201        .unwrap();
1202
1203        let result = remap(&outer, |_| Some(inner.clone()));
1204
1205        let loc = result.original_position_for(0, 0).unwrap();
1206        assert!(loc.name.is_some());
1207        assert_eq!(result.name(loc.name.unwrap()), "innerName");
1208    }
1209
1210    #[test]
1211    fn remap_sources_content_from_upstream() {
1212        let outer = SourceMap::from_json(
1213            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1214        )
1215        .unwrap();
1216
1217        let inner = SourceMap::from_json(
1218            r#"{"version":3,"sources":["original.ts"],"sourcesContent":["const x = 1;"],"names":[],"mappings":"AAAA"}"#,
1219        )
1220        .unwrap();
1221
1222        let result = remap(&outer, |_| Some(inner.clone()));
1223
1224        assert_eq!(result.sources_content, vec![Some("const x = 1;".to_string())]);
1225    }
1226
1227    // ── Clone needed for SourceMap in tests ──────────────────────
1228
1229    #[test]
1230    fn concat_updates_source_content_on_duplicate() {
1231        // First map has no sourcesContent, second has it for same source
1232        let a = SourceMap::from_json(
1233            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
1234        )
1235        .unwrap();
1236        let b = SourceMap::from_json(
1237            r#"{"version":3,"sources":["shared.js"],"sourcesContent":["var x = 1;"],"names":[],"mappings":"AAAA"}"#,
1238        )
1239        .unwrap();
1240
1241        let mut builder = ConcatBuilder::new(None);
1242        builder.add_map(&a, 0);
1243        builder.add_map(&b, 1);
1244
1245        let result = builder.build();
1246        assert_eq!(result.sources.len(), 1);
1247        assert_eq!(result.sources_content, vec![Some("var x = 1;".to_string())]);
1248    }
1249
1250    #[test]
1251    fn concat_deduplicates_names() {
1252        let a = SourceMap::from_json(
1253            r#"{"version":3,"sources":["a.js"],"names":["sharedName"],"mappings":"AAAAA"}"#,
1254        )
1255        .unwrap();
1256        let b = SourceMap::from_json(
1257            r#"{"version":3,"sources":["b.js"],"names":["sharedName"],"mappings":"AAAAA"}"#,
1258        )
1259        .unwrap();
1260
1261        let mut builder = ConcatBuilder::new(None);
1262        builder.add_map(&a, 0);
1263        builder.add_map(&b, 1);
1264
1265        let result = builder.build();
1266        // Names should be deduplicated
1267        assert_eq!(result.names.len(), 1);
1268        assert_eq!(result.names[0], "sharedName");
1269    }
1270
1271    #[test]
1272    fn concat_with_ignore_list() {
1273        let a = SourceMap::from_json(
1274            r#"{"version":3,"sources":["vendor.js"],"names":[],"mappings":"AAAA","ignoreList":[0]}"#,
1275        )
1276        .unwrap();
1277
1278        let mut builder = ConcatBuilder::new(None);
1279        builder.add_map(&a, 0);
1280
1281        let result = builder.build();
1282        assert_eq!(result.ignore_list, vec![0]);
1283    }
1284
1285    #[test]
1286    fn concat_with_generated_only_mappings() {
1287        // Map with a generated-only segment (1-field segment, no source info)
1288        let a = SourceMap::from_json(
1289            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,AAAA"}"#,
1290        )
1291        .unwrap();
1292
1293        let mut builder = ConcatBuilder::new(None);
1294        builder.add_map(&a, 0);
1295
1296        let result = builder.build();
1297        // Should have both mappings, including the generated-only one
1298        assert!(result.mapping_count() >= 1);
1299    }
1300
1301    #[test]
1302    fn remap_generated_only_passthrough() {
1303        // Outer map with a generated-only segment and two sources (second has no upstream)
1304        // A = generated-only segment at col 0
1305        // ,AAAA = gen(0,4)→a.js(0,0)
1306        // ,KCAA = gen(0,9)→other.js(0,0) (source delta +1)
1307        let outer = SourceMap::from_json(
1308            r#"{"version":3,"sources":["a.js","other.js"],"names":[],"mappings":"A,AAAA,KCAA"}"#,
1309        )
1310        .unwrap();
1311
1312        let inner = SourceMap::from_json(
1313            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1314        )
1315        .unwrap();
1316
1317        let result =
1318            remap(&outer, |source| if source == "a.js" { Some(inner.clone()) } else { None });
1319
1320        // Result should have mappings for the generated-only, remapped, and passthrough
1321        assert!(result.mapping_count() >= 2);
1322        assert!(result.sources.contains(&"original.js".to_string()));
1323        assert!(result.sources.contains(&"other.js".to_string()));
1324    }
1325
1326    #[test]
1327    fn remap_no_upstream_mapping_with_name() {
1328        // Outer has named mapping but upstream lookup finds no match at that position
1329        let outer = SourceMap::from_json(
1330            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1331        )
1332        .unwrap();
1333
1334        // Inner map maps different position (line 5, not line 0)
1335        let inner = SourceMap::from_json(
1336            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1337        )
1338        .unwrap();
1339
1340        let result = remap(&outer, |_| Some(inner.clone()));
1341
1342        // jridgewell drops the segment when upstream trace returns null:
1343        // `if (traced == null) continue;`
1344        // So there should be no mapping at (0,0) in the result.
1345        let loc = result.original_position_for(0, 0);
1346        assert!(loc.is_none());
1347    }
1348
1349    #[test]
1350    fn remap_no_upstream_with_sources_content_and_name() {
1351        let outer = SourceMap::from_json(
1352            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":["fn1"],"mappings":"AAAAA"}"#,
1353        )
1354        .unwrap();
1355
1356        // No upstream — everything passes through
1357        let result = remap(&outer, |_| None);
1358
1359        assert_eq!(result.sources, vec!["a.js"]);
1360        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
1361        let loc = result.original_position_for(0, 0).unwrap();
1362        assert!(loc.name.is_some());
1363        assert_eq!(result.name(loc.name.unwrap()), "fn1");
1364    }
1365
1366    #[test]
1367    fn remap_no_upstream_no_name() {
1368        let outer = SourceMap::from_json(
1369            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#,
1370        )
1371        .unwrap();
1372
1373        let result = remap(&outer, |_| None);
1374        let loc = result.original_position_for(0, 0).unwrap();
1375        assert!(loc.name.is_none());
1376    }
1377
1378    #[test]
1379    fn remap_no_upstream_mapping_no_name() {
1380        // Outer has a mapping with NO name pointing to compiled.js
1381        // AAAA = gen(0,0) → compiled.js(0,0), no name (4-field segment)
1382        let outer = SourceMap::from_json(
1383            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1384        )
1385        .unwrap();
1386
1387        // Inner map only has mappings at line 5, not at line 0
1388        // So original_position_for(0, 0) returns None → takes the None branch
1389        let inner = SourceMap::from_json(
1390            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1391        )
1392        .unwrap();
1393
1394        let result = remap(&outer, |_| Some(inner.clone()));
1395
1396        // jridgewell drops the segment when upstream trace returns null
1397        let loc = result.original_position_for(0, 0);
1398        assert!(loc.is_none());
1399    }
1400
1401    #[test]
1402    fn remap_upstream_found_no_name() {
1403        // Outer has a named mapping, but upstream has NO name
1404        // The upstream mapping is found but has no name_index
1405        // Since upstream has no name, the name resolution falls to the outer name
1406        // This is already covered by remap_preserves_names
1407        //
1408        // What we need instead: outer has NO name AND upstream has NO name
1409        // → name_idx is None → hits the add_mapping branch (line 246-252)
1410        let outer = SourceMap::from_json(
1411            r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA"}"#,
1412        )
1413        .unwrap();
1414
1415        // Inner maps intermediate.js(0,0) → original.js(0,0) with NO name
1416        let inner = SourceMap::from_json(
1417            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1418        )
1419        .unwrap();
1420
1421        let result = remap(&outer, |_| Some(inner.clone()));
1422
1423        assert_eq!(result.sources, vec!["original.js"]);
1424        let loc = result.original_position_for(0, 0).unwrap();
1425        assert_eq!(result.source(loc.source), "original.js");
1426        assert_eq!(loc.line, 0);
1427        assert_eq!(loc.column, 0);
1428        // Neither outer nor upstream has a name, so result has no name
1429        assert!(loc.name.is_none());
1430        assert!(result.names.is_empty());
1431    }
1432
1433    // ── Range mapping preservation tests ────────────────────────
1434
1435    #[test]
1436    fn concat_preserves_range_mappings() {
1437        let a = SourceMap::from_json(
1438            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,CAAC","rangeMappings":"A"}"#,
1439        )
1440        .unwrap();
1441
1442        let mut builder = ConcatBuilder::new(None);
1443        builder.add_map(&a, 0);
1444
1445        let result = builder.build();
1446        assert!(result.has_range_mappings());
1447        let mappings = result.all_mappings();
1448        assert!(mappings[0].is_range_mapping);
1449        assert!(!mappings[1].is_range_mapping);
1450    }
1451
1452    #[test]
1453    fn remap_preserves_range_mappings_passthrough() {
1454        let outer = SourceMap::from_json(
1455            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}"#,
1456        )
1457        .unwrap();
1458
1459        // No upstream — range mapping passes through
1460        let result = remap(&outer, |_| None);
1461        assert!(result.has_range_mappings());
1462        let mappings = result.all_mappings();
1463        assert!(mappings[0].is_range_mapping);
1464    }
1465
1466    #[test]
1467    fn remap_preserves_range_through_upstream() {
1468        let outer = SourceMap::from_json(
1469            r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}"#,
1470        )
1471        .unwrap();
1472
1473        let inner = SourceMap::from_json(
1474            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA"}"#,
1475        )
1476        .unwrap();
1477
1478        let result = remap(&outer, |_| Some(inner.clone()));
1479        assert!(result.has_range_mappings());
1480    }
1481
1482    #[test]
1483    fn remap_non_range_stays_non_range() {
1484        let outer = SourceMap::from_json(
1485            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
1486        )
1487        .unwrap();
1488
1489        let result = remap(&outer, |_| None);
1490        assert!(!result.has_range_mappings());
1491    }
1492
1493    // ── Streaming remapping tests ────────────────────────────────
1494
1495    /// Helper: run `remap_streaming` from a parsed SourceMap, re-encoding
1496    /// the VLQ string from its decoded mappings.
1497    fn streaming_from_sm<F>(sm: &SourceMap, loader: F) -> SourceMap
1498    where
1499        F: Fn(&str) -> Option<SourceMap>,
1500    {
1501        let vlq = sm.encode_mappings();
1502        let iter = srcmap_sourcemap::MappingsIter::new(&vlq);
1503        remap_streaming(
1504            iter,
1505            &sm.sources,
1506            &sm.names,
1507            &sm.sources_content,
1508            &sm.ignore_list,
1509            sm.file.clone(),
1510            loader,
1511        )
1512    }
1513
1514    #[test]
1515    fn streaming_single_level() {
1516        let outer = SourceMap::from_json(
1517            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":[],"mappings":"AAAA,KCAA;ADCA"}"#,
1518        )
1519        .unwrap();
1520
1521        let inner = SourceMap::from_json(
1522            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
1523        )
1524        .unwrap();
1525
1526        let result = streaming_from_sm(&outer, |source| {
1527            if source == "intermediate.js" { Some(inner.clone()) } else { None }
1528        });
1529
1530        assert!(result.sources.contains(&"original.js".to_string()));
1531        assert!(result.sources.contains(&"other.js".to_string()));
1532
1533        let loc = result.original_position_for(0, 0).unwrap();
1534        assert_eq!(result.source(loc.source), "original.js");
1535        assert_eq!(loc.line, 1);
1536    }
1537
1538    #[test]
1539    fn streaming_no_upstream_passthrough() {
1540        let outer = SourceMap::from_json(
1541            r#"{"version":3,"sources":["already-original.js"],"names":[],"mappings":"AAAA"}"#,
1542        )
1543        .unwrap();
1544
1545        let result = streaming_from_sm(&outer, |_| None);
1546
1547        assert_eq!(result.sources, vec!["already-original.js"]);
1548        let loc = result.original_position_for(0, 0).unwrap();
1549        assert_eq!(result.source(loc.source), "already-original.js");
1550        assert_eq!(loc.line, 0);
1551        assert_eq!(loc.column, 0);
1552    }
1553
1554    #[test]
1555    fn streaming_preserves_names() {
1556        let outer = SourceMap::from_json(
1557            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1558        )
1559        .unwrap();
1560
1561        let inner = SourceMap::from_json(
1562            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
1563        )
1564        .unwrap();
1565
1566        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1567
1568        let loc = result.original_position_for(0, 0).unwrap();
1569        assert!(loc.name.is_some());
1570        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
1571    }
1572
1573    #[test]
1574    fn streaming_upstream_name_wins() {
1575        let outer = SourceMap::from_json(
1576            r#"{"version":3,"sources":["compiled.js"],"names":["outerName"],"mappings":"AAAAA"}"#,
1577        )
1578        .unwrap();
1579
1580        let inner = SourceMap::from_json(
1581            r#"{"version":3,"sources":["original.ts"],"names":["innerName"],"mappings":"AAAAA"}"#,
1582        )
1583        .unwrap();
1584
1585        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1586
1587        let loc = result.original_position_for(0, 0).unwrap();
1588        assert!(loc.name.is_some());
1589        assert_eq!(result.name(loc.name.unwrap()), "innerName");
1590    }
1591
1592    #[test]
1593    fn streaming_sources_content_from_upstream() {
1594        let outer = SourceMap::from_json(
1595            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1596        )
1597        .unwrap();
1598
1599        let inner = SourceMap::from_json(
1600            r#"{"version":3,"sources":["original.ts"],"sourcesContent":["const x = 1;"],"names":[],"mappings":"AAAA"}"#,
1601        )
1602        .unwrap();
1603
1604        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1605
1606        assert_eq!(result.sources_content, vec![Some("const x = 1;".to_string())]);
1607    }
1608
1609    #[test]
1610    fn streaming_no_upstream_with_sources_content() {
1611        let outer = SourceMap::from_json(
1612            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":["fn1"],"mappings":"AAAAA"}"#,
1613        )
1614        .unwrap();
1615
1616        let result = streaming_from_sm(&outer, |_| None);
1617
1618        assert_eq!(result.sources, vec!["a.js"]);
1619        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
1620        let loc = result.original_position_for(0, 0).unwrap();
1621        assert!(loc.name.is_some());
1622        assert_eq!(result.name(loc.name.unwrap()), "fn1");
1623    }
1624
1625    #[test]
1626    fn streaming_generated_only_passthrough() {
1627        let outer = SourceMap::from_json(
1628            r#"{"version":3,"sources":["a.js","other.js"],"names":[],"mappings":"A,AAAA,KCAA"}"#,
1629        )
1630        .unwrap();
1631
1632        let inner = SourceMap::from_json(
1633            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1634        )
1635        .unwrap();
1636
1637        let result =
1638            streaming_from_sm(
1639                &outer,
1640                |source| {
1641                    if source == "a.js" { Some(inner.clone()) } else { None }
1642                },
1643            );
1644
1645        assert!(result.mapping_count() >= 2);
1646        assert!(result.sources.contains(&"original.js".to_string()));
1647        assert!(result.sources.contains(&"other.js".to_string()));
1648    }
1649
1650    #[test]
1651    fn streaming_matches_remap() {
1652        // Verify streaming produces identical results to non-streaming
1653        let outer = SourceMap::from_json(
1654            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":["foo"],"mappings":"AAAAA,KCAA;ADCA"}"#,
1655        )
1656        .unwrap();
1657
1658        let inner = SourceMap::from_json(
1659            r#"{"version":3,"sources":["original.js"],"sourcesContent":["// src"],"names":["bar"],"mappings":"AAAAA;AACA"}"#,
1660        )
1661        .unwrap();
1662
1663        let loader = |source: &str| -> Option<SourceMap> {
1664            if source == "intermediate.js" { Some(inner.clone()) } else { None }
1665        };
1666
1667        let result_normal = remap(&outer, loader);
1668        let result_stream = streaming_from_sm(&outer, loader);
1669
1670        assert_eq!(result_normal.sources, result_stream.sources);
1671        assert_eq!(result_normal.names, result_stream.names);
1672        assert_eq!(result_normal.sources_content, result_stream.sources_content);
1673        assert_eq!(result_normal.mapping_count(), result_stream.mapping_count());
1674
1675        // Verify all lookups match
1676        for m in result_normal.all_mappings() {
1677            let loc_n = result_normal.original_position_for(m.generated_line, m.generated_column);
1678            let loc_s = result_stream.original_position_for(m.generated_line, m.generated_column);
1679            assert_eq!(loc_n.is_some(), loc_s.is_some());
1680            if let (Some(ln), Some(ls)) = (loc_n, loc_s) {
1681                assert_eq!(result_normal.source(ln.source), result_stream.source(ls.source));
1682                assert_eq!(ln.line, ls.line);
1683                assert_eq!(ln.column, ls.column);
1684            }
1685        }
1686    }
1687
1688    #[test]
1689    fn streaming_no_upstream_mapping_fallback() {
1690        let outer = SourceMap::from_json(
1691            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1692        )
1693        .unwrap();
1694
1695        // Inner map maps different position (line 5, not line 0)
1696        let inner = SourceMap::from_json(
1697            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1698        )
1699        .unwrap();
1700
1701        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1702
1703        // jridgewell drops the segment when upstream trace returns null
1704        let loc = result.original_position_for(0, 0);
1705        assert!(loc.is_none());
1706    }
1707
1708    #[test]
1709    fn streaming_no_upstream_mapping_no_name() {
1710        let outer = SourceMap::from_json(
1711            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1712        )
1713        .unwrap();
1714
1715        let inner = SourceMap::from_json(
1716            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1717        )
1718        .unwrap();
1719
1720        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1721
1722        // jridgewell drops the segment when upstream trace returns null
1723        let loc = result.original_position_for(0, 0);
1724        assert!(loc.is_none());
1725    }
1726
1727    // ── remap_chain tests ────────────────────────────────────────
1728
1729    #[test]
1730    fn remap_chain_empty() {
1731        assert!(remap_chain(&[]).is_none());
1732    }
1733
1734    #[test]
1735    fn remap_chain_single() {
1736        let sm = SourceMap::from_json(
1737            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
1738        )
1739        .unwrap();
1740        let result = remap_chain(&[&sm]).unwrap();
1741        assert_eq!(result.sources, vec!["a.js"]);
1742        assert_eq!(result.mapping_count(), 1);
1743    }
1744
1745    #[test]
1746    fn remap_chain_two_maps() {
1747        // step1: original.js → intermediate.js
1748        let step1 = SourceMap::from_json(
1749            r#"{"version":3,"file":"intermediate.js","sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
1750        )
1751        .unwrap();
1752        // step2: intermediate.js → output.js
1753        let step2 = SourceMap::from_json(
1754            r#"{"version":3,"file":"output.js","sources":["intermediate.js"],"names":[],"mappings":"AAAA;AACA"}"#,
1755        )
1756        .unwrap();
1757
1758        // Chain: outer (step2) → inner (step1)
1759        let result = remap_chain(&[&step2, &step1]).unwrap();
1760        assert_eq!(result.sources, vec!["original.js"]);
1761
1762        // output line 0 → intermediate line 0 → original line 1
1763        let loc = result.original_position_for(0, 0).unwrap();
1764        assert_eq!(result.source(loc.source), "original.js");
1765        assert_eq!(loc.line, 1);
1766    }
1767
1768    #[test]
1769    fn remap_chain_three_maps() {
1770        // a.js → b.js: line 0 → line 1
1771        let a_to_b = SourceMap::from_json(
1772            r#"{"version":3,"file":"b.js","sources":["a.js"],"names":[],"mappings":"AACA"}"#,
1773        )
1774        .unwrap();
1775        // b.js → c.js: line 0 → line 0
1776        let b_to_c = SourceMap::from_json(
1777            r#"{"version":3,"file":"c.js","sources":["b.js"],"names":[],"mappings":"AAAA"}"#,
1778        )
1779        .unwrap();
1780        // c.js → d.js: line 0 → line 0
1781        let c_to_d = SourceMap::from_json(
1782            r#"{"version":3,"file":"d.js","sources":["c.js"],"names":[],"mappings":"AAAA"}"#,
1783        )
1784        .unwrap();
1785
1786        // Chain: d.js → c.js → b.js → a.js
1787        let result = remap_chain(&[&c_to_d, &b_to_c, &a_to_b]).unwrap();
1788        assert_eq!(result.sources, vec!["a.js"]);
1789
1790        let loc = result.original_position_for(0, 0).unwrap();
1791        assert_eq!(result.source(loc.source), "a.js");
1792        assert_eq!(loc.line, 1);
1793    }
1794
1795    // ── Empty-string source filtering ────────────────────────────
1796
1797    #[test]
1798    fn remap_empty_string_source_filtered() {
1799        // Outer map has an empty-string source (from JSON null)
1800        let outer =
1801            SourceMap::from_json(r#"{"version":3,"sources":[""],"names":[],"mappings":"AAAA"}"#)
1802                .unwrap();
1803
1804        let result = remap(&outer, |_| None);
1805
1806        // Empty-string sources should not appear in output sources
1807        assert!(
1808            !result.sources.iter().any(|s| s.is_empty()),
1809            "empty-string sources should be filtered out"
1810        );
1811        // The segment should be dropped (no source info)
1812        let loc = result.original_position_for(0, 0);
1813        assert!(loc.is_none());
1814    }
1815
1816    #[test]
1817    fn remap_null_source_filtered() {
1818        // JSON null in sources array becomes "" after resolve_sources
1819        let outer =
1820            SourceMap::from_json(r#"{"version":3,"sources":[null],"names":[],"mappings":"AAAA"}"#)
1821                .unwrap();
1822
1823        let result = remap(&outer, |_| None);
1824
1825        assert!(
1826            !result.sources.iter().any(|s| s.is_empty()),
1827            "null sources should be filtered out"
1828        );
1829    }
1830
1831    #[test]
1832    fn streaming_empty_string_source_filtered() {
1833        let outer =
1834            SourceMap::from_json(r#"{"version":3,"sources":[""],"names":[],"mappings":"AAAA"}"#)
1835                .unwrap();
1836
1837        let result = streaming_from_sm(&outer, |_| None);
1838
1839        assert!(
1840            !result.sources.iter().any(|s| s.is_empty()),
1841            "streaming: empty-string sources should be filtered out"
1842        );
1843    }
1844
1845    // ── Mapping deduplication ────────────────────────────────────
1846
1847    #[test]
1848    fn remap_skips_redundant_sourced_segments() {
1849        // Outer has three segments on the same line all mapping to the same
1850        // original position. jridgewell deduplicates the second and third.
1851        // AAAA,EAAA,EAAA = gen(0,0)→src(0,0), gen(0,2)→src(0,0), gen(0,4)→src(0,0)
1852        let outer = SourceMap::from_json(
1853            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAA,EAAA"}"#,
1854        )
1855        .unwrap();
1856
1857        let result = remap(&outer, |_| None);
1858
1859        // Should deduplicate: only 1 segment (the first) should remain
1860        assert_eq!(result.mapping_count(), 1);
1861    }
1862
1863    #[test]
1864    fn remap_keeps_different_sourced_segments() {
1865        // Two segments on the same line mapping to different original columns
1866        // AAAA,EAAC = gen(0,0)→src(0,0), gen(0,2)→src(0,1)
1867        let outer = SourceMap::from_json(
1868            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAC"}"#,
1869        )
1870        .unwrap();
1871
1872        let result = remap(&outer, |_| None);
1873
1874        // Both should be kept (different original positions)
1875        assert_eq!(result.mapping_count(), 2);
1876    }
1877
1878    #[test]
1879    fn remap_skips_sourceless_at_line_start() {
1880        // Outer has a sourceless segment at position 0 on a line
1881        // A = gen(0,0) with no source
1882        let outer = SourceMap::from_json(r#"{"version":3,"sources":[],"names":[],"mappings":"A"}"#)
1883            .unwrap();
1884
1885        let result = remap(&outer, |_| None);
1886
1887        // Sourceless at line start should be dropped
1888        assert_eq!(result.mapping_count(), 0);
1889    }
1890
1891    #[test]
1892    fn streaming_skips_redundant_sourced_segments() {
1893        let outer = SourceMap::from_json(
1894            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAA,EAAA"}"#,
1895        )
1896        .unwrap();
1897
1898        let result = streaming_from_sm(&outer, |_| None);
1899
1900        assert_eq!(result.mapping_count(), 1);
1901    }
1902}