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