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