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