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    remap(outer, |_source| Some(inner.clone()))
829}
830
831/// Per-source entry for streaming variant.
832enum StreamingSourceEntry {
833    /// Has an upstream map: trace mappings through it.
834    Upstream { map: Box<SourceMap>, cache: UpstreamCache },
835    /// No upstream map: pass through with builder source index.
836    Passthrough { builder_src: u32 },
837    /// Empty-string source (from JSON `null`): emit as generated-only.
838    EmptySource,
839    /// Not yet loaded.
840    Unloaded,
841}
842
843/// Streaming variant of [`remap`] that avoids materializing the outer map.
844///
845/// Accepts pre-parsed metadata and a [`MappingsIter`](srcmap_sourcemap::MappingsIter)
846/// over the outer map's VLQ-encoded mappings. Uses [`StreamingGenerator`] to
847/// encode the result on-the-fly without collecting all mappings first.
848///
849/// Because `MappingsIter` yields mappings in sorted order, the streaming
850/// generator can encode VLQ incrementally, avoiding the sort + re-encode
851/// pass that [`remap`] requires.
852///
853/// The `ignore_list` from both upstream and outer maps is propagated.
854/// Invalid segments from the iterator are silently skipped.
855pub fn remap_streaming<'a, F>(
856    mappings_iter: srcmap_sourcemap::MappingsIter<'a>,
857    sources: &[String],
858    names: &[String],
859    sources_content: &[Option<String>],
860    ignore_list: &[u32],
861    file: Option<String>,
862    loader: F,
863) -> SourceMap
864where
865    F: Fn(&str) -> Option<SourceMap>,
866{
867    let mut builder = StreamingGenerator::with_capacity(file, 4096);
868
869    // Flat Vec indexed by outer source index — avoids HashMap per mapping
870    let mut source_entries: Vec<StreamingSourceEntry> =
871        std::iter::repeat_with(|| StreamingSourceEntry::Unloaded).take(sources.len()).collect();
872
873    let mut ignored_sources: HashSet<u32> = HashSet::new();
874
875    // Lazy outer name remap table
876    let mut outer_name_remap: Vec<Option<u32>> = vec![None; names.len()];
877
878    // Pre-compute outer ignore set for O(1) lookups
879    let outer_ignore_set: HashSet<u32> = ignore_list.iter().copied().collect();
880
881    let mut dedup = DedupeState::new();
882
883    for item in mappings_iter {
884        let m = match item {
885            Ok(m) => m,
886            Err(_) => continue,
887        };
888
889        if m.source == NO_SOURCE {
890            trace_and_emit_sourceless(
891                &mut builder,
892                &mut dedup,
893                m.generated_line,
894                m.generated_column,
895            );
896            continue;
897        }
898
899        let si = m.source as usize;
900        if si >= sources.len() {
901            continue;
902        }
903
904        // Load upstream map if not yet cached
905        if matches!(source_entries[si], StreamingSourceEntry::Unloaded) {
906            let source_name = &sources[si];
907            if source_name.is_empty() {
908                source_entries[si] = StreamingSourceEntry::EmptySource;
909            } else {
910                match loader(source_name) {
911                    Some(upstream_sm) => {
912                        let cache = build_upstream_cache(&upstream_sm);
913                        source_entries[si] =
914                            StreamingSourceEntry::Upstream { map: Box::new(upstream_sm), cache };
915                    }
916                    None => {
917                        let idx = builder.add_source(source_name);
918                        if let Some(Some(content)) = sources_content.get(si) {
919                            builder.set_source_content(idx, content.clone());
920                        }
921                        if outer_ignore_set.contains(&m.source) && ignored_sources.insert(idx) {
922                            builder.add_to_ignore_list(idx);
923                        }
924                        source_entries[si] = StreamingSourceEntry::Passthrough { builder_src: idx };
925                    }
926                }
927            }
928        }
929
930        match &mut source_entries[si] {
931            StreamingSourceEntry::Upstream { map, cache } => {
932                if let Some(upstream_m) = lookup_upstream(map, m.original_line, m.original_column) {
933                    trace_and_emit_upstream(
934                        &mut builder,
935                        &mut dedup,
936                        UpstreamEmitContext {
937                            gen_line: m.generated_line,
938                            gen_col: m.generated_column,
939                            upstream_m,
940                            cache,
941                            upstream_map: map,
942                            outer_name_remap: &mut outer_name_remap,
943                            outer_name_idx: m.name,
944                            names,
945                            ignored_sources: &mut ignored_sources,
946                            is_range: m.is_range_mapping,
947                        },
948                    );
949                }
950            }
951            StreamingSourceEntry::Passthrough { builder_src } => {
952                trace_and_emit_passthrough(
953                    &mut builder,
954                    &mut dedup,
955                    PassthroughEmitContext {
956                        gen_line: m.generated_line,
957                        gen_col: m.generated_column,
958                        orig_line: m.original_line,
959                        orig_col: m.original_column,
960                        builder_src: *builder_src,
961                        outer_name_remap: &mut outer_name_remap,
962                        outer_name_idx: m.name,
963                        names,
964                        is_range: m.is_range_mapping,
965                    },
966                );
967            }
968            StreamingSourceEntry::EmptySource => {
969                trace_and_emit_sourceless(
970                    &mut builder,
971                    &mut dedup,
972                    m.generated_line,
973                    m.generated_column,
974                );
975            }
976            StreamingSourceEntry::Unloaded => unreachable!(),
977        }
978    }
979
980    builder.to_decoded_map().expect("streaming VLQ should be valid")
981}
982
983#[inline]
984fn emit_generated_mapping<B: RemapBuilder>(builder: &mut B, gen_line: u32, gen_col: u32) {
985    builder.add_generated_mapping(gen_line, gen_col);
986}
987
988struct UpstreamEmitContext<'a> {
989    gen_line: u32,
990    gen_col: u32,
991    upstream_m: UpstreamLookup,
992    cache: &'a mut UpstreamCache,
993    upstream_map: &'a SourceMap,
994    outer_name_remap: &'a mut [Option<u32>],
995    outer_name_idx: u32,
996    names: &'a [String],
997    ignored_sources: &'a mut HashSet<u32>,
998    is_range: bool,
999}
1000
1001#[inline]
1002fn trace_and_emit_upstream<B: RemapBuilder>(
1003    builder: &mut B,
1004    dedup: &mut DedupeState,
1005    ctx: UpstreamEmitContext<'_>,
1006) {
1007    let UpstreamEmitContext {
1008        gen_line,
1009        gen_col,
1010        upstream_m,
1011        cache,
1012        upstream_map,
1013        outer_name_remap,
1014        outer_name_idx,
1015        names,
1016        ignored_sources,
1017        is_range,
1018    } = ctx;
1019    let builder_src =
1020        resolve_upstream_source(cache, upstream_map, upstream_m.source, builder, ignored_sources);
1021
1022    let builder_name = if upstream_m.name != NO_NAME {
1023        Some(resolve_upstream_name(cache, upstream_map, upstream_m.name, builder))
1024    } else {
1025        resolve_outer_name_cached(outer_name_remap, outer_name_idx, names, builder)
1026    };
1027
1028    if !dedup.skip_source(
1029        gen_line,
1030        builder_src,
1031        upstream_m.original_line,
1032        upstream_m.original_column,
1033        builder_name,
1034    ) {
1035        emit_remapped_mapping(
1036            builder,
1037            gen_line,
1038            gen_col,
1039            builder_src,
1040            upstream_m.original_line,
1041            upstream_m.original_column,
1042            builder_name,
1043            is_range,
1044        );
1045    }
1046    dedup.record_source(
1047        gen_line,
1048        builder_src,
1049        upstream_m.original_line,
1050        upstream_m.original_column,
1051        builder_name,
1052    );
1053}
1054
1055struct PassthroughEmitContext<'a> {
1056    gen_line: u32,
1057    gen_col: u32,
1058    orig_line: u32,
1059    orig_col: u32,
1060    builder_src: u32,
1061    outer_name_remap: &'a mut [Option<u32>],
1062    outer_name_idx: u32,
1063    names: &'a [String],
1064    is_range: bool,
1065}
1066
1067#[inline]
1068fn trace_and_emit_passthrough<B: RemapBuilder>(
1069    builder: &mut B,
1070    dedup: &mut DedupeState,
1071    ctx: PassthroughEmitContext<'_>,
1072) {
1073    let PassthroughEmitContext {
1074        gen_line,
1075        gen_col,
1076        orig_line,
1077        orig_col,
1078        builder_src,
1079        outer_name_remap,
1080        outer_name_idx,
1081        names,
1082        is_range,
1083    } = ctx;
1084    let builder_name = resolve_outer_name_cached(outer_name_remap, outer_name_idx, names, builder);
1085
1086    if !dedup.skip_source(gen_line, builder_src, orig_line, orig_col, builder_name) {
1087        emit_remapped_mapping(
1088            builder,
1089            gen_line,
1090            gen_col,
1091            builder_src,
1092            orig_line,
1093            orig_col,
1094            builder_name,
1095            is_range,
1096        );
1097    }
1098    dedup.record_source(gen_line, builder_src, orig_line, orig_col, builder_name);
1099}
1100
1101#[inline]
1102fn trace_and_emit_sourceless<B: RemapBuilder>(
1103    builder: &mut B,
1104    dedup: &mut DedupeState,
1105    gen_line: u32,
1106    gen_col: u32,
1107) {
1108    if !dedup.skip_sourceless(gen_line) {
1109        emit_generated_mapping(builder, gen_line, gen_col);
1110    }
1111    dedup.record_sourceless(gen_line);
1112}
1113
1114// ── Tests ─────────────────────────────────────────────────────────
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    // ── Concatenation tests ──────────────────────────────────────
1121
1122    #[test]
1123    fn concat_two_simple_maps() {
1124        let a = SourceMap::from_json(
1125            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
1126        )
1127        .unwrap();
1128        let b = SourceMap::from_json(
1129            r#"{"version":3,"sources":["b.js"],"names":[],"mappings":"AAAA"}"#,
1130        )
1131        .unwrap();
1132
1133        let mut builder = ConcatBuilder::new(Some("bundle.js".to_string()));
1134        builder.add_map(&a, 0);
1135        builder.add_map(&b, 1);
1136
1137        let result = builder.build();
1138        assert_eq!(result.sources, vec!["a.js", "b.js"]);
1139        assert_eq!(result.mapping_count(), 2);
1140
1141        let loc0 = result.original_position_for(0, 0).unwrap();
1142        assert_eq!(result.source(loc0.source), "a.js");
1143
1144        let loc1 = result.original_position_for(1, 0).unwrap();
1145        assert_eq!(result.source(loc1.source), "b.js");
1146    }
1147
1148    #[test]
1149    fn concat_deduplicates_sources() {
1150        let a = SourceMap::from_json(
1151            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
1152        )
1153        .unwrap();
1154        let b = SourceMap::from_json(
1155            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
1156        )
1157        .unwrap();
1158
1159        let mut builder = ConcatBuilder::new(None);
1160        builder.add_map(&a, 0);
1161        builder.add_map(&b, 10);
1162
1163        let result = builder.build();
1164        assert_eq!(result.sources.len(), 1);
1165        assert_eq!(result.sources[0], "shared.js");
1166    }
1167
1168    #[test]
1169    fn concat_with_names() {
1170        let a = SourceMap::from_json(
1171            r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AAAAA"}"#,
1172        )
1173        .unwrap();
1174        let b = SourceMap::from_json(
1175            r#"{"version":3,"sources":["b.js"],"names":["bar"],"mappings":"AAAAA"}"#,
1176        )
1177        .unwrap();
1178
1179        let mut builder = ConcatBuilder::new(None);
1180        builder.add_map(&a, 0);
1181        builder.add_map(&b, 1);
1182
1183        let result = builder.build();
1184        assert_eq!(result.names.len(), 2);
1185
1186        let loc0 = result.original_position_for(0, 0).unwrap();
1187        assert_eq!(loc0.name, Some(0));
1188        assert_eq!(result.name(0), "foo");
1189
1190        let loc1 = result.original_position_for(1, 0).unwrap();
1191        assert_eq!(loc1.name, Some(1));
1192        assert_eq!(result.name(1), "bar");
1193    }
1194
1195    #[test]
1196    fn concat_preserves_multi_line_maps() {
1197        let a = SourceMap::from_json(
1198            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA;AACA"}"#,
1199        )
1200        .unwrap();
1201
1202        let mut builder = ConcatBuilder::new(None);
1203        builder.add_map(&a, 5); // offset by 5 lines
1204
1205        let result = builder.build();
1206        assert!(result.original_position_for(5, 0).is_some());
1207        assert!(result.original_position_for(6, 0).is_some());
1208        assert!(result.original_position_for(7, 0).is_some());
1209        assert!(result.original_position_for(4, 0).is_none());
1210    }
1211
1212    #[test]
1213    fn concat_with_sources_content() {
1214        let a = SourceMap::from_json(
1215            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#,
1216        )
1217        .unwrap();
1218
1219        let mut builder = ConcatBuilder::new(None);
1220        builder.add_map(&a, 0);
1221
1222        let result = builder.build();
1223        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
1224    }
1225
1226    #[test]
1227    fn concat_empty_builder() {
1228        let builder = ConcatBuilder::new(Some("empty.js".to_string()));
1229        let result = builder.build();
1230        assert_eq!(result.mapping_count(), 0);
1231        assert_eq!(result.sources.len(), 0);
1232    }
1233
1234    // ── Remapping tests ──────────────────────────────────────────
1235
1236    #[test]
1237    fn remap_single_level() {
1238        // outer: output.js → intermediate.js + other.js (second source has no upstream)
1239        // AAAA maps gen(0,0) → intermediate.js(0,0)
1240        // KCAA maps gen(0,5) → other.js(0,0) (source delta +1)
1241        // ;ADCA maps gen(1,0) → intermediate.js(1,0) (source delta -1, line delta +1)
1242        let outer = SourceMap::from_json(
1243            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":[],"mappings":"AAAA,KCAA;ADCA"}"#,
1244        )
1245        .unwrap();
1246
1247        // inner: intermediate.js → original.js
1248        let inner = SourceMap::from_json(
1249            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
1250        )
1251        .unwrap();
1252
1253        let result =
1254            remap(
1255                &outer,
1256                |source| {
1257                    if source == "intermediate.js" { Some(inner.clone()) } else { None }
1258                },
1259            );
1260
1261        assert!(result.sources.contains(&"original.js".to_string()));
1262        // other.js passes through since loader returns None
1263        assert!(result.sources.contains(&"other.js".to_string()));
1264
1265        // Line 0 col 0 in outer → line 0 col 0 in intermediate → line 1 col 0 in original
1266        let loc = result.original_position_for(0, 0).unwrap();
1267        assert_eq!(result.source(loc.source), "original.js");
1268        assert_eq!(loc.line, 1);
1269    }
1270
1271    #[test]
1272    fn remap_no_upstream_passthrough() {
1273        let outer = SourceMap::from_json(
1274            r#"{"version":3,"sources":["already-original.js"],"names":[],"mappings":"AAAA"}"#,
1275        )
1276        .unwrap();
1277
1278        // No upstream maps — everything passes through
1279        let result = remap(&outer, |_| None);
1280
1281        assert_eq!(result.sources, vec!["already-original.js"]);
1282        let loc = result.original_position_for(0, 0).unwrap();
1283        assert_eq!(result.source(loc.source), "already-original.js");
1284        assert_eq!(loc.line, 0);
1285        assert_eq!(loc.column, 0);
1286    }
1287
1288    #[test]
1289    fn remap_partial_sources() {
1290        // outer has two sources: one with upstream, one without
1291        let outer = SourceMap::from_json(
1292            r#"{"version":3,"sources":["compiled.js","passthrough.js"],"names":[],"mappings":"AAAA,KCCA"}"#,
1293        )
1294        .unwrap();
1295
1296        let inner = SourceMap::from_json(
1297            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
1298        )
1299        .unwrap();
1300
1301        let result =
1302            remap(
1303                &outer,
1304                |source| {
1305                    if source == "compiled.js" { Some(inner.clone()) } else { None }
1306                },
1307            );
1308
1309        // Should have both the remapped source and the passthrough source
1310        assert!(result.sources.contains(&"original.ts".to_string()));
1311        assert!(result.sources.contains(&"passthrough.js".to_string()));
1312    }
1313
1314    #[test]
1315    fn remap_preserves_names() {
1316        let outer = SourceMap::from_json(
1317            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1318        )
1319        .unwrap();
1320
1321        // upstream has no names — outer name should be preserved
1322        let inner = SourceMap::from_json(
1323            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
1324        )
1325        .unwrap();
1326
1327        let result = remap(&outer, |_| Some(inner.clone()));
1328
1329        let loc = result.original_position_for(0, 0).unwrap();
1330        assert!(loc.name.is_some());
1331        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
1332    }
1333
1334    #[test]
1335    fn remap_upstream_name_wins() {
1336        let outer = SourceMap::from_json(
1337            r#"{"version":3,"sources":["compiled.js"],"names":["outerName"],"mappings":"AAAAA"}"#,
1338        )
1339        .unwrap();
1340
1341        // upstream has its own name — should take precedence
1342        let inner = SourceMap::from_json(
1343            r#"{"version":3,"sources":["original.ts"],"names":["innerName"],"mappings":"AAAAA"}"#,
1344        )
1345        .unwrap();
1346
1347        let result = remap(&outer, |_| Some(inner.clone()));
1348
1349        let loc = result.original_position_for(0, 0).unwrap();
1350        assert!(loc.name.is_some());
1351        assert_eq!(result.name(loc.name.unwrap()), "innerName");
1352    }
1353
1354    #[test]
1355    fn remap_sources_content_from_upstream() {
1356        let outer = SourceMap::from_json(
1357            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1358        )
1359        .unwrap();
1360
1361        let inner = SourceMap::from_json(
1362            r#"{"version":3,"sources":["original.ts"],"sourcesContent":["const x = 1;"],"names":[],"mappings":"AAAA"}"#,
1363        )
1364        .unwrap();
1365
1366        let result = remap(&outer, |_| Some(inner.clone()));
1367
1368        assert_eq!(result.sources_content, vec![Some("const x = 1;".to_string())]);
1369    }
1370
1371    // ── Clone needed for SourceMap in tests ──────────────────────
1372
1373    #[test]
1374    fn concat_updates_source_content_on_duplicate() {
1375        // First map has no sourcesContent, second has it for same source
1376        let a = SourceMap::from_json(
1377            r#"{"version":3,"sources":["shared.js"],"names":[],"mappings":"AAAA"}"#,
1378        )
1379        .unwrap();
1380        let b = SourceMap::from_json(
1381            r#"{"version":3,"sources":["shared.js"],"sourcesContent":["var x = 1;"],"names":[],"mappings":"AAAA"}"#,
1382        )
1383        .unwrap();
1384
1385        let mut builder = ConcatBuilder::new(None);
1386        builder.add_map(&a, 0);
1387        builder.add_map(&b, 1);
1388
1389        let result = builder.build();
1390        assert_eq!(result.sources.len(), 1);
1391        assert_eq!(result.sources_content, vec![Some("var x = 1;".to_string())]);
1392    }
1393
1394    #[test]
1395    fn concat_deduplicates_names() {
1396        let a = SourceMap::from_json(
1397            r#"{"version":3,"sources":["a.js"],"names":["sharedName"],"mappings":"AAAAA"}"#,
1398        )
1399        .unwrap();
1400        let b = SourceMap::from_json(
1401            r#"{"version":3,"sources":["b.js"],"names":["sharedName"],"mappings":"AAAAA"}"#,
1402        )
1403        .unwrap();
1404
1405        let mut builder = ConcatBuilder::new(None);
1406        builder.add_map(&a, 0);
1407        builder.add_map(&b, 1);
1408
1409        let result = builder.build();
1410        // Names should be deduplicated
1411        assert_eq!(result.names.len(), 1);
1412        assert_eq!(result.names[0], "sharedName");
1413    }
1414
1415    #[test]
1416    fn concat_with_ignore_list() {
1417        let a = SourceMap::from_json(
1418            r#"{"version":3,"sources":["vendor.js"],"names":[],"mappings":"AAAA","ignoreList":[0]}"#,
1419        )
1420        .unwrap();
1421
1422        let mut builder = ConcatBuilder::new(None);
1423        builder.add_map(&a, 0);
1424
1425        let result = builder.build();
1426        assert_eq!(result.ignore_list, vec![0]);
1427    }
1428
1429    #[test]
1430    fn concat_with_generated_only_mappings() {
1431        // Map with a generated-only segment (1-field segment, no source info)
1432        let a = SourceMap::from_json(
1433            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,AAAA"}"#,
1434        )
1435        .unwrap();
1436
1437        let mut builder = ConcatBuilder::new(None);
1438        builder.add_map(&a, 0);
1439
1440        let result = builder.build();
1441        // Should have both mappings, including the generated-only one
1442        assert!(result.mapping_count() >= 1);
1443    }
1444
1445    #[test]
1446    fn remap_generated_only_passthrough() {
1447        // Outer map with a generated-only segment and two sources (second has no upstream)
1448        // A = generated-only segment at col 0
1449        // ,AAAA = gen(0,4)→a.js(0,0)
1450        // ,KCAA = gen(0,9)→other.js(0,0) (source delta +1)
1451        let outer = SourceMap::from_json(
1452            r#"{"version":3,"sources":["a.js","other.js"],"names":[],"mappings":"A,AAAA,KCAA"}"#,
1453        )
1454        .unwrap();
1455
1456        let inner = SourceMap::from_json(
1457            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1458        )
1459        .unwrap();
1460
1461        let result =
1462            remap(&outer, |source| if source == "a.js" { Some(inner.clone()) } else { None });
1463
1464        // Result should have mappings for the generated-only, remapped, and passthrough
1465        assert!(result.mapping_count() >= 2);
1466        assert!(result.sources.contains(&"original.js".to_string()));
1467        assert!(result.sources.contains(&"other.js".to_string()));
1468    }
1469
1470    #[test]
1471    fn remap_no_upstream_mapping_with_name() {
1472        // Outer has named mapping but upstream lookup finds no match at that position
1473        let outer = SourceMap::from_json(
1474            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1475        )
1476        .unwrap();
1477
1478        // Inner map maps different position (line 5, not line 0)
1479        let inner = SourceMap::from_json(
1480            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1481        )
1482        .unwrap();
1483
1484        let result = remap(&outer, |_| Some(inner.clone()));
1485
1486        // jridgewell drops the segment when upstream trace returns null:
1487        // `if (traced == null) continue;`
1488        // So there should be no mapping at (0,0) in the result.
1489        let loc = result.original_position_for(0, 0);
1490        assert!(loc.is_none());
1491    }
1492
1493    #[test]
1494    fn remap_no_upstream_with_sources_content_and_name() {
1495        let outer = SourceMap::from_json(
1496            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":["fn1"],"mappings":"AAAAA"}"#,
1497        )
1498        .unwrap();
1499
1500        // No upstream — everything passes through
1501        let result = remap(&outer, |_| None);
1502
1503        assert_eq!(result.sources, vec!["a.js"]);
1504        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
1505        let loc = result.original_position_for(0, 0).unwrap();
1506        assert!(loc.name.is_some());
1507        assert_eq!(result.name(loc.name.unwrap()), "fn1");
1508    }
1509
1510    #[test]
1511    fn remap_no_upstream_no_name() {
1512        let outer = SourceMap::from_json(
1513            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#,
1514        )
1515        .unwrap();
1516
1517        let result = remap(&outer, |_| None);
1518        let loc = result.original_position_for(0, 0).unwrap();
1519        assert!(loc.name.is_none());
1520    }
1521
1522    #[test]
1523    fn remap_no_upstream_mapping_no_name() {
1524        // Outer has a mapping with NO name pointing to compiled.js
1525        // AAAA = gen(0,0) → compiled.js(0,0), no name (4-field segment)
1526        let outer = SourceMap::from_json(
1527            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1528        )
1529        .unwrap();
1530
1531        // Inner map only has mappings at line 5, not at line 0
1532        // So original_position_for(0, 0) returns None → takes the None branch
1533        let inner = SourceMap::from_json(
1534            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1535        )
1536        .unwrap();
1537
1538        let result = remap(&outer, |_| Some(inner.clone()));
1539
1540        // jridgewell drops the segment when upstream trace returns null
1541        let loc = result.original_position_for(0, 0);
1542        assert!(loc.is_none());
1543    }
1544
1545    #[test]
1546    fn remap_upstream_found_no_name() {
1547        // Outer has a named mapping, but upstream has NO name
1548        // The upstream mapping is found but has no name_index
1549        // Since upstream has no name, the name resolution falls to the outer name
1550        // This is already covered by remap_preserves_names
1551        //
1552        // What we need instead: outer has NO name AND upstream has NO name
1553        // → name_idx is None → hits the add_mapping branch (line 246-252)
1554        let outer = SourceMap::from_json(
1555            r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA"}"#,
1556        )
1557        .unwrap();
1558
1559        // Inner maps intermediate.js(0,0) → original.js(0,0) with NO name
1560        let inner = SourceMap::from_json(
1561            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1562        )
1563        .unwrap();
1564
1565        let result = remap(&outer, |_| Some(inner.clone()));
1566
1567        assert_eq!(result.sources, vec!["original.js"]);
1568        let loc = result.original_position_for(0, 0).unwrap();
1569        assert_eq!(result.source(loc.source), "original.js");
1570        assert_eq!(loc.line, 0);
1571        assert_eq!(loc.column, 0);
1572        // Neither outer nor upstream has a name, so result has no name
1573        assert!(loc.name.is_none());
1574        assert!(result.names.is_empty());
1575    }
1576
1577    // ── Range mapping preservation tests ────────────────────────
1578
1579    #[test]
1580    fn concat_preserves_range_mappings() {
1581        let a = SourceMap::from_json(
1582            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,CAAC","rangeMappings":"A"}"#,
1583        )
1584        .unwrap();
1585
1586        let mut builder = ConcatBuilder::new(None);
1587        builder.add_map(&a, 0);
1588
1589        let result = builder.build();
1590        assert!(result.has_range_mappings());
1591        let mappings = result.all_mappings();
1592        assert!(mappings[0].is_range_mapping);
1593        assert!(!mappings[1].is_range_mapping);
1594    }
1595
1596    #[test]
1597    fn remap_preserves_range_mappings_passthrough() {
1598        let outer = SourceMap::from_json(
1599            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}"#,
1600        )
1601        .unwrap();
1602
1603        // No upstream — range mapping passes through
1604        let result = remap(&outer, |_| None);
1605        assert!(result.has_range_mappings());
1606        let mappings = result.all_mappings();
1607        assert!(mappings[0].is_range_mapping);
1608    }
1609
1610    #[test]
1611    fn remap_preserves_range_through_upstream() {
1612        let outer = SourceMap::from_json(
1613            r#"{"version":3,"sources":["intermediate.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}"#,
1614        )
1615        .unwrap();
1616
1617        let inner = SourceMap::from_json(
1618            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA"}"#,
1619        )
1620        .unwrap();
1621
1622        let result = remap(&outer, |_| Some(inner.clone()));
1623        assert!(result.has_range_mappings());
1624    }
1625
1626    #[test]
1627    fn remap_non_range_stays_non_range() {
1628        let outer = SourceMap::from_json(
1629            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
1630        )
1631        .unwrap();
1632
1633        let result = remap(&outer, |_| None);
1634        assert!(!result.has_range_mappings());
1635    }
1636
1637    // ── Streaming remapping tests ────────────────────────────────
1638
1639    /// Helper: run `remap_streaming` from a parsed SourceMap, re-encoding
1640    /// the VLQ string from its decoded mappings.
1641    fn streaming_from_sm<F>(sm: &SourceMap, loader: F) -> SourceMap
1642    where
1643        F: Fn(&str) -> Option<SourceMap>,
1644    {
1645        let vlq = sm.encode_mappings();
1646        let iter = srcmap_sourcemap::MappingsIter::new(&vlq);
1647        remap_streaming(
1648            iter,
1649            &sm.sources,
1650            &sm.names,
1651            &sm.sources_content,
1652            &sm.ignore_list,
1653            sm.file.clone(),
1654            loader,
1655        )
1656    }
1657
1658    #[test]
1659    fn streaming_single_level() {
1660        let outer = SourceMap::from_json(
1661            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":[],"mappings":"AAAA,KCAA;ADCA"}"#,
1662        )
1663        .unwrap();
1664
1665        let inner = SourceMap::from_json(
1666            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
1667        )
1668        .unwrap();
1669
1670        let result = streaming_from_sm(&outer, |source| {
1671            if source == "intermediate.js" { Some(inner.clone()) } else { None }
1672        });
1673
1674        assert!(result.sources.contains(&"original.js".to_string()));
1675        assert!(result.sources.contains(&"other.js".to_string()));
1676
1677        let loc = result.original_position_for(0, 0).unwrap();
1678        assert_eq!(result.source(loc.source), "original.js");
1679        assert_eq!(loc.line, 1);
1680    }
1681
1682    #[test]
1683    fn streaming_no_upstream_passthrough() {
1684        let outer = SourceMap::from_json(
1685            r#"{"version":3,"sources":["already-original.js"],"names":[],"mappings":"AAAA"}"#,
1686        )
1687        .unwrap();
1688
1689        let result = streaming_from_sm(&outer, |_| None);
1690
1691        assert_eq!(result.sources, vec!["already-original.js"]);
1692        let loc = result.original_position_for(0, 0).unwrap();
1693        assert_eq!(result.source(loc.source), "already-original.js");
1694        assert_eq!(loc.line, 0);
1695        assert_eq!(loc.column, 0);
1696    }
1697
1698    #[test]
1699    fn streaming_preserves_names() {
1700        let outer = SourceMap::from_json(
1701            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1702        )
1703        .unwrap();
1704
1705        let inner = SourceMap::from_json(
1706            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":"AAAA"}"#,
1707        )
1708        .unwrap();
1709
1710        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1711
1712        let loc = result.original_position_for(0, 0).unwrap();
1713        assert!(loc.name.is_some());
1714        assert_eq!(result.name(loc.name.unwrap()), "myFunc");
1715    }
1716
1717    #[test]
1718    fn streaming_upstream_name_wins() {
1719        let outer = SourceMap::from_json(
1720            r#"{"version":3,"sources":["compiled.js"],"names":["outerName"],"mappings":"AAAAA"}"#,
1721        )
1722        .unwrap();
1723
1724        let inner = SourceMap::from_json(
1725            r#"{"version":3,"sources":["original.ts"],"names":["innerName"],"mappings":"AAAAA"}"#,
1726        )
1727        .unwrap();
1728
1729        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1730
1731        let loc = result.original_position_for(0, 0).unwrap();
1732        assert!(loc.name.is_some());
1733        assert_eq!(result.name(loc.name.unwrap()), "innerName");
1734    }
1735
1736    #[test]
1737    fn streaming_sources_content_from_upstream() {
1738        let outer = SourceMap::from_json(
1739            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1740        )
1741        .unwrap();
1742
1743        let inner = SourceMap::from_json(
1744            r#"{"version":3,"sources":["original.ts"],"sourcesContent":["const x = 1;"],"names":[],"mappings":"AAAA"}"#,
1745        )
1746        .unwrap();
1747
1748        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1749
1750        assert_eq!(result.sources_content, vec![Some("const x = 1;".to_string())]);
1751    }
1752
1753    #[test]
1754    fn streaming_no_upstream_with_sources_content() {
1755        let outer = SourceMap::from_json(
1756            r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":["fn1"],"mappings":"AAAAA"}"#,
1757        )
1758        .unwrap();
1759
1760        let result = streaming_from_sm(&outer, |_| None);
1761
1762        assert_eq!(result.sources, vec!["a.js"]);
1763        assert_eq!(result.sources_content, vec![Some("var a;".to_string())]);
1764        let loc = result.original_position_for(0, 0).unwrap();
1765        assert!(loc.name.is_some());
1766        assert_eq!(result.name(loc.name.unwrap()), "fn1");
1767    }
1768
1769    #[test]
1770    fn streaming_generated_only_passthrough() {
1771        let outer = SourceMap::from_json(
1772            r#"{"version":3,"sources":["a.js","other.js"],"names":[],"mappings":"A,AAAA,KCAA"}"#,
1773        )
1774        .unwrap();
1775
1776        let inner = SourceMap::from_json(
1777            r#"{"version":3,"sources":["original.js"],"names":[],"mappings":"AAAA"}"#,
1778        )
1779        .unwrap();
1780
1781        let result =
1782            streaming_from_sm(
1783                &outer,
1784                |source| {
1785                    if source == "a.js" { Some(inner.clone()) } else { None }
1786                },
1787            );
1788
1789        assert!(result.mapping_count() >= 2);
1790        assert!(result.sources.contains(&"original.js".to_string()));
1791        assert!(result.sources.contains(&"other.js".to_string()));
1792    }
1793
1794    #[test]
1795    fn streaming_matches_remap() {
1796        // Verify streaming produces identical results to non-streaming
1797        let outer = SourceMap::from_json(
1798            r#"{"version":3,"sources":["intermediate.js","other.js"],"names":["foo"],"mappings":"AAAAA,KCAA;ADCA"}"#,
1799        )
1800        .unwrap();
1801
1802        let inner = SourceMap::from_json(
1803            r#"{"version":3,"sources":["original.js"],"sourcesContent":["// src"],"names":["bar"],"mappings":"AAAAA;AACA"}"#,
1804        )
1805        .unwrap();
1806
1807        let loader = |source: &str| -> Option<SourceMap> {
1808            if source == "intermediate.js" { Some(inner.clone()) } else { None }
1809        };
1810
1811        let result_normal = remap(&outer, loader);
1812        let result_stream = streaming_from_sm(&outer, loader);
1813
1814        assert_eq!(result_normal.sources, result_stream.sources);
1815        assert_eq!(result_normal.names, result_stream.names);
1816        assert_eq!(result_normal.sources_content, result_stream.sources_content);
1817        assert_eq!(result_normal.mapping_count(), result_stream.mapping_count());
1818
1819        // Verify all lookups match
1820        for m in result_normal.all_mappings() {
1821            let loc_n = result_normal.original_position_for(m.generated_line, m.generated_column);
1822            let loc_s = result_stream.original_position_for(m.generated_line, m.generated_column);
1823            assert_eq!(loc_n.is_some(), loc_s.is_some());
1824            if let (Some(ln), Some(ls)) = (loc_n, loc_s) {
1825                assert_eq!(result_normal.source(ln.source), result_stream.source(ls.source));
1826                assert_eq!(ln.line, ls.line);
1827                assert_eq!(ln.column, ls.column);
1828            }
1829        }
1830    }
1831
1832    #[test]
1833    fn streaming_no_upstream_mapping_fallback() {
1834        let outer = SourceMap::from_json(
1835            r#"{"version":3,"sources":["compiled.js"],"names":["myFunc"],"mappings":"AAAAA"}"#,
1836        )
1837        .unwrap();
1838
1839        // Inner map maps different position (line 5, not line 0)
1840        let inner = SourceMap::from_json(
1841            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1842        )
1843        .unwrap();
1844
1845        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1846
1847        // jridgewell drops the segment when upstream trace returns null
1848        let loc = result.original_position_for(0, 0);
1849        assert!(loc.is_none());
1850    }
1851
1852    #[test]
1853    fn streaming_no_upstream_mapping_no_name() {
1854        let outer = SourceMap::from_json(
1855            r#"{"version":3,"sources":["compiled.js"],"names":[],"mappings":"AAAA"}"#,
1856        )
1857        .unwrap();
1858
1859        let inner = SourceMap::from_json(
1860            r#"{"version":3,"sources":["original.ts"],"names":[],"mappings":";;;;AAAA"}"#,
1861        )
1862        .unwrap();
1863
1864        let result = streaming_from_sm(&outer, |_| Some(inner.clone()));
1865
1866        // jridgewell drops the segment when upstream trace returns null
1867        let loc = result.original_position_for(0, 0);
1868        assert!(loc.is_none());
1869    }
1870
1871    // ── remap_chain tests ────────────────────────────────────────
1872
1873    #[test]
1874    fn remap_chain_empty() {
1875        assert!(remap_chain(&[]).is_none());
1876    }
1877
1878    #[test]
1879    fn remap_chain_single() {
1880        let sm = SourceMap::from_json(
1881            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
1882        )
1883        .unwrap();
1884        let result = remap_chain(&[&sm]).unwrap();
1885        assert_eq!(result.sources, vec!["a.js"]);
1886        assert_eq!(result.mapping_count(), 1);
1887    }
1888
1889    #[test]
1890    fn remap_chain_two_maps() {
1891        // step1: original.js → intermediate.js
1892        let step1 = SourceMap::from_json(
1893            r#"{"version":3,"file":"intermediate.js","sources":["original.js"],"names":[],"mappings":"AACA;AACA"}"#,
1894        )
1895        .unwrap();
1896        // step2: intermediate.js → output.js
1897        let step2 = SourceMap::from_json(
1898            r#"{"version":3,"file":"output.js","sources":["intermediate.js"],"names":[],"mappings":"AAAA;AACA"}"#,
1899        )
1900        .unwrap();
1901
1902        // Chain: outer (step2) → inner (step1)
1903        let result = remap_chain(&[&step2, &step1]).unwrap();
1904        assert_eq!(result.sources, vec!["original.js"]);
1905
1906        // output line 0 → intermediate line 0 → original line 1
1907        let loc = result.original_position_for(0, 0).unwrap();
1908        assert_eq!(result.source(loc.source), "original.js");
1909        assert_eq!(loc.line, 1);
1910    }
1911
1912    #[test]
1913    fn remap_chain_three_maps() {
1914        // a.js → b.js: line 0 → line 1
1915        let a_to_b = SourceMap::from_json(
1916            r#"{"version":3,"file":"b.js","sources":["a.js"],"names":[],"mappings":"AACA"}"#,
1917        )
1918        .unwrap();
1919        // b.js → c.js: line 0 → line 0
1920        let b_to_c = SourceMap::from_json(
1921            r#"{"version":3,"file":"c.js","sources":["b.js"],"names":[],"mappings":"AAAA"}"#,
1922        )
1923        .unwrap();
1924        // c.js → d.js: line 0 → line 0
1925        let c_to_d = SourceMap::from_json(
1926            r#"{"version":3,"file":"d.js","sources":["c.js"],"names":[],"mappings":"AAAA"}"#,
1927        )
1928        .unwrap();
1929
1930        // Chain: d.js → c.js → b.js → a.js
1931        let result = remap_chain(&[&c_to_d, &b_to_c, &a_to_b]).unwrap();
1932        assert_eq!(result.sources, vec!["a.js"]);
1933
1934        let loc = result.original_position_for(0, 0).unwrap();
1935        assert_eq!(result.source(loc.source), "a.js");
1936        assert_eq!(loc.line, 1);
1937    }
1938
1939    // ── Empty-string source filtering ────────────────────────────
1940
1941    #[test]
1942    fn remap_empty_string_source_filtered() {
1943        // Outer map has an empty-string source (from JSON null)
1944        let outer =
1945            SourceMap::from_json(r#"{"version":3,"sources":[""],"names":[],"mappings":"AAAA"}"#)
1946                .unwrap();
1947
1948        let result = remap(&outer, |_| None);
1949
1950        // Empty-string sources should not appear in output sources
1951        assert!(
1952            !result.sources.iter().any(|s| s.is_empty()),
1953            "empty-string sources should be filtered out"
1954        );
1955        // The segment should be dropped (no source info)
1956        let loc = result.original_position_for(0, 0);
1957        assert!(loc.is_none());
1958    }
1959
1960    #[test]
1961    fn remap_null_source_filtered() {
1962        // JSON null in sources array becomes "" after resolve_sources
1963        let outer =
1964            SourceMap::from_json(r#"{"version":3,"sources":[null],"names":[],"mappings":"AAAA"}"#)
1965                .unwrap();
1966
1967        let result = remap(&outer, |_| None);
1968
1969        assert!(
1970            !result.sources.iter().any(|s| s.is_empty()),
1971            "null sources should be filtered out"
1972        );
1973    }
1974
1975    #[test]
1976    fn streaming_empty_string_source_filtered() {
1977        let outer =
1978            SourceMap::from_json(r#"{"version":3,"sources":[""],"names":[],"mappings":"AAAA"}"#)
1979                .unwrap();
1980
1981        let result = streaming_from_sm(&outer, |_| None);
1982
1983        assert!(
1984            !result.sources.iter().any(|s| s.is_empty()),
1985            "streaming: empty-string sources should be filtered out"
1986        );
1987    }
1988
1989    // ── Mapping deduplication ────────────────────────────────────
1990
1991    #[test]
1992    fn remap_skips_redundant_sourced_segments() {
1993        // Outer has three segments on the same line all mapping to the same
1994        // original position. jridgewell deduplicates the second and third.
1995        // AAAA,EAAA,EAAA = gen(0,0)→src(0,0), gen(0,2)→src(0,0), gen(0,4)→src(0,0)
1996        let outer = SourceMap::from_json(
1997            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAA,EAAA"}"#,
1998        )
1999        .unwrap();
2000
2001        let result = remap(&outer, |_| None);
2002
2003        // Should deduplicate: only 1 segment (the first) should remain
2004        assert_eq!(result.mapping_count(), 1);
2005    }
2006
2007    #[test]
2008    fn remap_keeps_different_sourced_segments() {
2009        // Two segments on the same line mapping to different original columns
2010        // AAAA,EAAC = gen(0,0)→src(0,0), gen(0,2)→src(0,1)
2011        let outer = SourceMap::from_json(
2012            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAC"}"#,
2013        )
2014        .unwrap();
2015
2016        let result = remap(&outer, |_| None);
2017
2018        // Both should be kept (different original positions)
2019        assert_eq!(result.mapping_count(), 2);
2020    }
2021
2022    #[test]
2023    fn remap_skips_sourceless_at_line_start() {
2024        // Outer has a sourceless segment at position 0 on a line
2025        // A = gen(0,0) with no source
2026        let outer = SourceMap::from_json(r#"{"version":3,"sources":[],"names":[],"mappings":"A"}"#)
2027            .unwrap();
2028
2029        let result = remap(&outer, |_| None);
2030
2031        // Sourceless at line start should be dropped
2032        assert_eq!(result.mapping_count(), 0);
2033    }
2034
2035    #[test]
2036    fn streaming_skips_redundant_sourced_segments() {
2037        let outer = SourceMap::from_json(
2038            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAA,EAAA"}"#,
2039        )
2040        .unwrap();
2041
2042        let result = streaming_from_sm(&outer, |_| None);
2043
2044        assert_eq!(result.mapping_count(), 1);
2045    }
2046}