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