Skip to main content

srcmap_generator/
lib.rs

1//! High-performance source map generator (ECMA-426).
2//!
3//! Builds source maps incrementally by adding mappings one at a time.
4//! Outputs standard source map v3 JSON.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use srcmap_generator::SourceMapGenerator;
10//!
11//! fn main() {
12//!     let mut builder = SourceMapGenerator::new(Some("bundle.js".to_string()));
13//!
14//!     let src = builder.add_source("src/app.ts");
15//!     builder.set_source_content(src, "const x = 1;".to_string());
16//!
17//!     let name = builder.add_name("x");
18//!     builder.add_named_mapping(0, 0, src, 0, 6, name);
19//!     builder.add_mapping(1, 0, src, 1, 0);
20//!
21//!     let json = builder.to_json();
22//!     assert!(json.contains(r#""version":3"#));
23//!     assert!(json.contains(r#""sources":["src/app.ts"]"#));
24//! }
25//! ```
26
27use std::collections::HashMap;
28
29use srcmap_codec::vlq_encode;
30use srcmap_scopes::ScopeInfo;
31
32// ── Public types ───────────────────────────────────────────────────
33
34/// A mapping from generated position to original position.
35#[derive(Debug, Clone)]
36pub struct Mapping {
37    pub generated_line: u32,
38    pub generated_column: u32,
39    pub source: Option<u32>,
40    pub original_line: u32,
41    pub original_column: u32,
42    pub name: Option<u32>,
43}
44
45/// Builder for creating source maps incrementally.
46#[derive(Debug)]
47pub struct SourceMapGenerator {
48    file: Option<String>,
49    source_root: Option<String>,
50    sources: Vec<String>,
51    sources_content: Vec<Option<String>>,
52    names: Vec<String>,
53    mappings: Vec<Mapping>,
54    ignore_list: Vec<u32>,
55    debug_id: Option<String>,
56    scopes: Option<ScopeInfo>,
57
58    // Dedup maps for O(1) lookup
59    source_map: HashMap<String, u32>,
60    name_map: HashMap<String, u32>,
61}
62
63impl SourceMapGenerator {
64    /// Create a new empty source map generator.
65    pub fn new(file: Option<String>) -> Self {
66        Self {
67            file,
68            source_root: None,
69            sources: Vec::new(),
70            sources_content: Vec::new(),
71            names: Vec::new(),
72            mappings: Vec::new(),
73            ignore_list: Vec::new(),
74            debug_id: None,
75            scopes: None,
76            source_map: HashMap::new(),
77            name_map: HashMap::new(),
78        }
79    }
80
81    /// Set the source root prefix.
82    pub fn set_source_root(&mut self, root: String) {
83        self.source_root = Some(root);
84    }
85
86    /// Set the debug ID (UUID) for this source map (ECMA-426).
87    pub fn set_debug_id(&mut self, id: String) {
88        self.debug_id = Some(id);
89    }
90
91    /// Set scope and variable information (ECMA-426 scopes proposal).
92    pub fn set_scopes(&mut self, scopes: ScopeInfo) {
93        self.scopes = Some(scopes);
94    }
95
96    /// Register a source file and return its index.
97    pub fn add_source(&mut self, source: &str) -> u32 {
98        if let Some(&idx) = self.source_map.get(source) {
99            return idx;
100        }
101        let idx = self.sources.len() as u32;
102        self.sources.push(source.to_string());
103        self.sources_content.push(None);
104        self.source_map.insert(source.to_string(), idx);
105        idx
106    }
107
108    /// Set the content for a source file.
109    pub fn set_source_content(&mut self, source_idx: u32, content: String) {
110        if (source_idx as usize) < self.sources_content.len() {
111            self.sources_content[source_idx as usize] = Some(content);
112        }
113    }
114
115    /// Register a name and return its index.
116    pub fn add_name(&mut self, name: &str) -> u32 {
117        if let Some(&idx) = self.name_map.get(name) {
118            return idx;
119        }
120        let idx = self.names.len() as u32;
121        self.names.push(name.to_string());
122        self.name_map.insert(name.to_string(), idx);
123        idx
124    }
125
126    /// Add a source index to the ignore list.
127    pub fn add_to_ignore_list(&mut self, source_idx: u32) {
128        if !self.ignore_list.contains(&source_idx) {
129            self.ignore_list.push(source_idx);
130        }
131    }
132
133    /// Add a mapping with no source information (generated-only).
134    pub fn add_generated_mapping(&mut self, generated_line: u32, generated_column: u32) {
135        self.mappings.push(Mapping {
136            generated_line,
137            generated_column,
138            source: None,
139            original_line: 0,
140            original_column: 0,
141            name: None,
142        });
143    }
144
145    /// Add a mapping from generated position to original position.
146    pub fn add_mapping(
147        &mut self,
148        generated_line: u32,
149        generated_column: u32,
150        source: u32,
151        original_line: u32,
152        original_column: u32,
153    ) {
154        self.mappings.push(Mapping {
155            generated_line,
156            generated_column,
157            source: Some(source),
158            original_line,
159            original_column,
160            name: None,
161        });
162    }
163
164    /// Add a mapping with a name.
165    pub fn add_named_mapping(
166        &mut self,
167        generated_line: u32,
168        generated_column: u32,
169        source: u32,
170        original_line: u32,
171        original_column: u32,
172        name: u32,
173    ) {
174        self.mappings.push(Mapping {
175            generated_line,
176            generated_column,
177            source: Some(source),
178            original_line,
179            original_column,
180            name: Some(name),
181        });
182    }
183
184    /// Add a mapping only if it differs from the previous mapping on the same line.
185    ///
186    /// This skips redundant mappings where the source position is identical
187    /// to the last mapping, which reduces output size without losing information.
188    /// Used by bundlers and minifiers to avoid bloating source maps.
189    pub fn maybe_add_mapping(
190        &mut self,
191        generated_line: u32,
192        generated_column: u32,
193        source: u32,
194        original_line: u32,
195        original_column: u32,
196    ) -> bool {
197        if let Some(last) = self.mappings.last()
198            && last.generated_line == generated_line
199            && last.source == Some(source)
200            && last.original_line == original_line
201            && last.original_column == original_column
202        {
203            return false;
204        }
205        self.add_mapping(
206            generated_line,
207            generated_column,
208            source,
209            original_line,
210            original_column,
211        );
212        true
213    }
214
215    /// Encode all mappings to a VLQ-encoded string.
216    fn encode_mappings(&self) -> String {
217        if self.mappings.is_empty() {
218            return String::new();
219        }
220
221        // Sort mappings by (generated_line, generated_column)
222        let mut sorted: Vec<&Mapping> = self.mappings.iter().collect();
223        sorted.sort_unstable_by(|a, b| {
224            a.generated_line
225                .cmp(&b.generated_line)
226                .then(a.generated_column.cmp(&b.generated_column))
227        });
228
229        #[cfg(feature = "parallel")]
230        if sorted.len() >= 4096 {
231            return Self::encode_parallel_impl(&sorted);
232        }
233
234        Self::encode_sequential_impl(&sorted)
235    }
236
237    fn encode_sequential_impl(sorted: &[&Mapping]) -> String {
238        let mut out: Vec<u8> = Vec::with_capacity(sorted.len() * 6);
239
240        let mut prev_gen_col: i64 = 0;
241        let mut prev_source: i64 = 0;
242        let mut prev_orig_line: i64 = 0;
243        let mut prev_orig_col: i64 = 0;
244        let mut prev_name: i64 = 0;
245        let mut prev_gen_line: u32 = 0;
246        let mut first_in_line = true;
247
248        for m in sorted {
249            while prev_gen_line < m.generated_line {
250                out.push(b';');
251                prev_gen_line += 1;
252                prev_gen_col = 0;
253                first_in_line = true;
254            }
255
256            if !first_in_line {
257                out.push(b',');
258            }
259            first_in_line = false;
260
261            vlq_encode(&mut out, m.generated_column as i64 - prev_gen_col);
262            prev_gen_col = m.generated_column as i64;
263
264            if let Some(source) = m.source {
265                vlq_encode(&mut out, source as i64 - prev_source);
266                prev_source = source as i64;
267
268                vlq_encode(&mut out, m.original_line as i64 - prev_orig_line);
269                prev_orig_line = m.original_line as i64;
270
271                vlq_encode(&mut out, m.original_column as i64 - prev_orig_col);
272                prev_orig_col = m.original_column as i64;
273
274                if let Some(name) = m.name {
275                    vlq_encode(&mut out, name as i64 - prev_name);
276                    prev_name = name as i64;
277                }
278            }
279        }
280
281        // SAFETY: VLQ output is always valid ASCII/UTF-8
282        unsafe { String::from_utf8_unchecked(out) }
283    }
284
285    #[cfg(feature = "parallel")]
286    fn encode_parallel_impl(sorted: &[&Mapping]) -> String {
287        use rayon::prelude::*;
288
289        let max_line = sorted.last().unwrap().generated_line as usize;
290
291        // Build line ranges: (start_idx, end_idx) into sorted slice
292        let mut line_ranges: Vec<(usize, usize)> = vec![(0, 0); max_line + 1];
293        let mut i = 0;
294        while i < sorted.len() {
295            let line = sorted[i].generated_line as usize;
296            let start = i;
297            while i < sorted.len() && sorted[i].generated_line as usize == line {
298                i += 1;
299            }
300            line_ranges[line] = (start, i);
301        }
302
303        // Sequential scan: compute cumulative state at each line boundary
304        let mut states: Vec<(i64, i64, i64, i64)> = Vec::with_capacity(max_line + 1);
305        let mut prev_source: i64 = 0;
306        let mut prev_orig_line: i64 = 0;
307        let mut prev_orig_col: i64 = 0;
308        let mut prev_name: i64 = 0;
309
310        for &(start, end) in &line_ranges {
311            states.push((prev_source, prev_orig_line, prev_orig_col, prev_name));
312            for m in &sorted[start..end] {
313                if let Some(source) = m.source {
314                    prev_source = source as i64;
315                    prev_orig_line = m.original_line as i64;
316                    prev_orig_col = m.original_column as i64;
317                    if let Some(name) = m.name {
318                        prev_name = name as i64;
319                    }
320                }
321            }
322        }
323
324        // Parallel: encode each line independently
325        let encoded_lines: Vec<Vec<u8>> = line_ranges
326            .par_iter()
327            .zip(states.par_iter())
328            .map(|(&(start, end), &(s, ol, oc, n))| {
329                if start == end {
330                    return Vec::new();
331                }
332                encode_mapping_slice(&sorted[start..end], s, ol, oc, n)
333            })
334            .collect();
335
336        // Join with semicolons
337        let total_len = encoded_lines.iter().map(|l| l.len()).sum::<usize>() + max_line;
338        let mut out: Vec<u8> = Vec::with_capacity(total_len);
339        for (i, bytes) in encoded_lines.iter().enumerate() {
340            if i > 0 {
341                out.push(b';');
342            }
343            out.extend_from_slice(bytes);
344        }
345
346        // SAFETY: VLQ output is always valid ASCII/UTF-8
347        unsafe { String::from_utf8_unchecked(out) }
348    }
349
350    /// Generate the source map as a JSON string.
351    pub fn to_json(&self) -> String {
352        let mappings = self.encode_mappings();
353
354        // Encode scopes (may introduce names not yet in self.names)
355        let (scopes_str, names_for_json) = if let Some(ref scopes_info) = self.scopes {
356            let mut names = self.names.clone();
357            let s = srcmap_scopes::encode_scopes(scopes_info, &mut names);
358            (Some(s), names)
359        } else {
360            (None, self.names.clone())
361        };
362
363        let mut json = String::with_capacity(256 + mappings.len());
364        json.push_str(r#"{"version":3"#);
365
366        if let Some(ref file) = self.file {
367            json.push_str(r#","file":"#);
368            json.push_str(&json_quote(file));
369        }
370
371        if let Some(ref root) = self.source_root {
372            json.push_str(r#","sourceRoot":"#);
373            json.push_str(&json_quote(root));
374        }
375
376        // sources
377        json.push_str(r#","sources":["#);
378        for (i, s) in self.sources.iter().enumerate() {
379            if i > 0 {
380                json.push(',');
381            }
382            json.push_str(&json_quote(s));
383        }
384        json.push(']');
385
386        // sourcesContent (only if any content is set)
387        if self.sources_content.iter().any(|c| c.is_some()) {
388            json.push_str(r#","sourcesContent":["#);
389
390            #[cfg(feature = "parallel")]
391            {
392                use rayon::prelude::*;
393
394                let total_content: usize = self
395                    .sources_content
396                    .iter()
397                    .map(|c| c.as_ref().map_or(0, |s| s.len()))
398                    .sum();
399
400                if self.sources_content.len() >= 8 && total_content >= 8192 {
401                    let quoted: Vec<String> = self
402                        .sources_content
403                        .par_iter()
404                        .map(|c| match c {
405                            Some(content) => json_quote(content),
406                            None => "null".to_string(),
407                        })
408                        .collect();
409                    for (i, q) in quoted.iter().enumerate() {
410                        if i > 0 {
411                            json.push(',');
412                        }
413                        json.push_str(q);
414                    }
415                } else {
416                    for (i, c) in self.sources_content.iter().enumerate() {
417                        if i > 0 {
418                            json.push(',');
419                        }
420                        match c {
421                            Some(content) => json.push_str(&json_quote(content)),
422                            None => json.push_str("null"),
423                        }
424                    }
425                }
426            }
427
428            #[cfg(not(feature = "parallel"))]
429            for (i, c) in self.sources_content.iter().enumerate() {
430                if i > 0 {
431                    json.push(',');
432                }
433                match c {
434                    Some(content) => json.push_str(&json_quote(content)),
435                    None => json.push_str("null"),
436                }
437            }
438
439            json.push(']');
440        }
441
442        // names
443        json.push_str(r#","names":["#);
444        for (i, n) in names_for_json.iter().enumerate() {
445            if i > 0 {
446                json.push(',');
447            }
448            json.push_str(&json_quote(n));
449        }
450        json.push(']');
451
452        // mappings
453        json.push_str(r#","mappings":"#);
454        json.push_str(&json_quote(&mappings));
455
456        // ignoreList
457        if !self.ignore_list.is_empty() {
458            json.push_str(r#","ignoreList":["#);
459            for (i, &idx) in self.ignore_list.iter().enumerate() {
460                if i > 0 {
461                    json.push(',');
462                }
463                json.push_str(&idx.to_string());
464            }
465            json.push(']');
466        }
467
468        // debugId
469        if let Some(ref id) = self.debug_id {
470            json.push_str(r#","debugId":"#);
471            json.push_str(&json_quote(id));
472        }
473
474        // scopes (ECMA-426 scopes proposal)
475        if let Some(ref s) = scopes_str {
476            json.push_str(r#","scopes":"#);
477            json.push_str(&json_quote(s));
478        }
479
480        json.push('}');
481        json
482    }
483
484    /// Get the number of mappings.
485    pub fn mapping_count(&self) -> usize {
486        self.mappings.len()
487    }
488}
489
490/// Encode a slice of mappings for a single line to VLQ bytes.
491///
492/// Generated column starts at 0 (reset per line).
493/// Cumulative state is passed in from the sequential pre-scan.
494#[cfg(feature = "parallel")]
495fn encode_mapping_slice(
496    mappings: &[&Mapping],
497    init_source: i64,
498    init_orig_line: i64,
499    init_orig_col: i64,
500    init_name: i64,
501) -> Vec<u8> {
502    let mut buf = Vec::with_capacity(mappings.len() * 6);
503    let mut prev_gen_col: i64 = 0;
504    let mut prev_source = init_source;
505    let mut prev_orig_line = init_orig_line;
506    let mut prev_orig_col = init_orig_col;
507    let mut prev_name = init_name;
508    let mut first = true;
509
510    for m in mappings {
511        if !first {
512            buf.push(b',');
513        }
514        first = false;
515
516        vlq_encode(&mut buf, m.generated_column as i64 - prev_gen_col);
517        prev_gen_col = m.generated_column as i64;
518
519        if let Some(source) = m.source {
520            vlq_encode(&mut buf, source as i64 - prev_source);
521            prev_source = source as i64;
522
523            vlq_encode(&mut buf, m.original_line as i64 - prev_orig_line);
524            prev_orig_line = m.original_line as i64;
525
526            vlq_encode(&mut buf, m.original_column as i64 - prev_orig_col);
527            prev_orig_col = m.original_column as i64;
528
529            if let Some(name) = m.name {
530                vlq_encode(&mut buf, name as i64 - prev_name);
531                prev_name = name as i64;
532            }
533        }
534    }
535
536    buf
537}
538
539/// JSON-quote a string (with escape handling).
540fn json_quote(s: &str) -> String {
541    let mut out = String::with_capacity(s.len() + 2);
542    out.push('"');
543    for c in s.chars() {
544        match c {
545            '"' => out.push_str("\\\""),
546            '\\' => out.push_str("\\\\"),
547            '\n' => out.push_str("\\n"),
548            '\r' => out.push_str("\\r"),
549            '\t' => out.push_str("\\t"),
550            c if c < '\x20' => {
551                out.push_str(&format!("\\u{:04x}", c as u32));
552            }
553            c => out.push(c),
554        }
555    }
556    out.push('"');
557    out
558}
559
560// ── Tests ──────────────────────────────────────────────────────────
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn empty_generator() {
568        let builder = SourceMapGenerator::new(None);
569        let json = builder.to_json();
570        assert!(json.contains(r#""version":3"#));
571        assert!(json.contains(r#""mappings":"""#));
572    }
573
574    #[test]
575    fn simple_mapping() {
576        let mut builder = SourceMapGenerator::new(Some("output.js".to_string()));
577        let src = builder.add_source("input.js");
578        builder.add_mapping(0, 0, src, 0, 0);
579
580        let json = builder.to_json();
581        assert!(json.contains(r#""file":"output.js""#));
582        assert!(json.contains(r#""sources":["input.js"]"#));
583
584        // Verify roundtrip with parser
585        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
586        let loc = sm.original_position_for(0, 0).unwrap();
587        assert_eq!(sm.source(loc.source), "input.js");
588        assert_eq!(loc.line, 0);
589        assert_eq!(loc.column, 0);
590    }
591
592    #[test]
593    fn mapping_with_name() {
594        let mut builder = SourceMapGenerator::new(None);
595        let src = builder.add_source("input.js");
596        let name = builder.add_name("myFunction");
597        builder.add_named_mapping(0, 0, src, 0, 0, name);
598
599        let json = builder.to_json();
600        assert!(json.contains(r#""names":["myFunction"]"#));
601
602        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
603        let loc = sm.original_position_for(0, 0).unwrap();
604        assert_eq!(loc.name, Some(0));
605        assert_eq!(sm.name(0), "myFunction");
606    }
607
608    #[test]
609    fn multiple_lines() {
610        let mut builder = SourceMapGenerator::new(None);
611        let src = builder.add_source("input.js");
612        builder.add_mapping(0, 0, src, 0, 0);
613        builder.add_mapping(1, 4, src, 1, 2);
614        builder.add_mapping(2, 0, src, 2, 0);
615
616        let json = builder.to_json();
617        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
618        assert_eq!(sm.line_count(), 3);
619
620        let loc = sm.original_position_for(1, 4).unwrap();
621        assert_eq!(loc.line, 1);
622        assert_eq!(loc.column, 2);
623    }
624
625    #[test]
626    fn multiple_sources() {
627        let mut builder = SourceMapGenerator::new(None);
628        let a = builder.add_source("a.js");
629        let b = builder.add_source("b.js");
630        builder.add_mapping(0, 0, a, 0, 0);
631        builder.add_mapping(1, 0, b, 0, 0);
632
633        let json = builder.to_json();
634        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
635
636        let loc0 = sm.original_position_for(0, 0).unwrap();
637        let loc1 = sm.original_position_for(1, 0).unwrap();
638        assert_eq!(sm.source(loc0.source), "a.js");
639        assert_eq!(sm.source(loc1.source), "b.js");
640    }
641
642    #[test]
643    fn source_content() {
644        let mut builder = SourceMapGenerator::new(None);
645        let src = builder.add_source("input.js");
646        builder.set_source_content(src, "var x = 1;".to_string());
647        builder.add_mapping(0, 0, src, 0, 0);
648
649        let json = builder.to_json();
650        assert!(json.contains(r#""sourcesContent":["var x = 1;"]"#));
651
652        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
653        assert_eq!(sm.sources_content[0], Some("var x = 1;".to_string()));
654    }
655
656    #[test]
657    fn source_root() {
658        let mut builder = SourceMapGenerator::new(None);
659        builder.set_source_root("src/".to_string());
660        let src = builder.add_source("input.js");
661        builder.add_mapping(0, 0, src, 0, 0);
662
663        let json = builder.to_json();
664        assert!(json.contains(r#""sourceRoot":"src/""#));
665    }
666
667    #[test]
668    fn ignore_list() {
669        let mut builder = SourceMapGenerator::new(None);
670        let _app = builder.add_source("app.js");
671        let lib = builder.add_source("node_modules/lib.js");
672        builder.add_to_ignore_list(lib);
673        builder.add_mapping(0, 0, lib, 0, 0);
674
675        let json = builder.to_json();
676        assert!(json.contains(r#""ignoreList":[1]"#));
677    }
678
679    #[test]
680    fn generated_only_mapping() {
681        let mut builder = SourceMapGenerator::new(None);
682        builder.add_generated_mapping(0, 0);
683
684        let json = builder.to_json();
685        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
686        // Generated-only mapping → no source info
687        assert!(sm.original_position_for(0, 0).is_none());
688    }
689
690    #[test]
691    fn dedup_sources_and_names() {
692        let mut builder = SourceMapGenerator::new(None);
693        let s1 = builder.add_source("input.js");
694        let s2 = builder.add_source("input.js"); // duplicate
695        assert_eq!(s1, s2);
696
697        let n1 = builder.add_name("foo");
698        let n2 = builder.add_name("foo"); // duplicate
699        assert_eq!(n1, n2);
700
701        assert_eq!(builder.sources.len(), 1);
702        assert_eq!(builder.names.len(), 1);
703    }
704
705    #[test]
706    fn large_roundtrip() {
707        let mut builder = SourceMapGenerator::new(Some("bundle.js".to_string()));
708
709        for i in 0..5 {
710            builder.add_source(&format!("src/file{i}.js"));
711        }
712        for i in 0..10 {
713            builder.add_name(&format!("var{i}"));
714        }
715
716        // Add 1000 mappings across 100 lines
717        for line in 0..100u32 {
718            for col in 0..10u32 {
719                let src = (line * 10 + col) % 5;
720                let name = if col % 3 == 0 { Some(col % 10) } else { None };
721
722                match name {
723                    Some(n) => builder.add_named_mapping(line, col * 10, src, line, col * 5, n),
724                    None => builder.add_mapping(line, col * 10, src, line, col * 5),
725                }
726            }
727        }
728
729        let json = builder.to_json();
730        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
731
732        assert_eq!(sm.mapping_count(), 1000);
733        assert_eq!(sm.line_count(), 100);
734
735        // Verify a few lookups
736        let loc = sm.original_position_for(50, 30).unwrap();
737        assert_eq!(loc.line, 50);
738        assert_eq!(loc.column, 15);
739    }
740
741    #[test]
742    fn json_escaping() {
743        let mut builder = SourceMapGenerator::new(None);
744        let src = builder.add_source("path/with\"quotes.js");
745        builder.set_source_content(src, "line1\nline2\ttab".to_string());
746        builder.add_mapping(0, 0, src, 0, 0);
747
748        let json = builder.to_json();
749        // Should be valid JSON
750        let _: serde_json::Value = serde_json::from_str(&json).unwrap();
751    }
752
753    #[test]
754    fn maybe_add_mapping_skips_redundant() {
755        let mut builder = SourceMapGenerator::new(None);
756        let src = builder.add_source("input.js");
757
758        // First mapping — always added
759        assert!(builder.maybe_add_mapping(0, 0, src, 10, 0));
760        // Same source position, different generated column — redundant, skipped
761        assert!(!builder.maybe_add_mapping(0, 5, src, 10, 0));
762        // Different source position — added
763        assert!(builder.maybe_add_mapping(0, 10, src, 11, 0));
764        // Different generated line, same source position as last — added (new line resets)
765        assert!(builder.maybe_add_mapping(1, 0, src, 11, 0));
766
767        assert_eq!(builder.mapping_count(), 3);
768
769        let json = builder.to_json();
770        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
771        assert_eq!(sm.mapping_count(), 3);
772    }
773
774    #[test]
775    fn maybe_add_mapping_different_source() {
776        let mut builder = SourceMapGenerator::new(None);
777        let a = builder.add_source("a.js");
778        let b = builder.add_source("b.js");
779
780        assert!(builder.maybe_add_mapping(0, 0, a, 0, 0));
781        // Same line/col but different source — not redundant
782        assert!(builder.maybe_add_mapping(0, 5, b, 0, 0));
783
784        assert_eq!(builder.mapping_count(), 2);
785    }
786
787    #[test]
788    fn empty_lines_between_mappings() {
789        let mut builder = SourceMapGenerator::new(None);
790        let src = builder.add_source("input.js");
791        builder.add_mapping(0, 0, src, 0, 0);
792        // Skip lines 1-4
793        builder.add_mapping(5, 0, src, 5, 0);
794
795        let json = builder.to_json();
796        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
797
798        // Line 0 should have a mapping
799        assert!(sm.original_position_for(0, 0).is_some());
800        // Lines 1-4 should have no mappings
801        assert!(sm.original_position_for(2, 0).is_none());
802        // Line 5 should have a mapping
803        assert!(sm.original_position_for(5, 0).is_some());
804    }
805
806    #[test]
807    fn debug_id() {
808        let mut builder = SourceMapGenerator::new(None);
809        builder.set_debug_id("85314830-023f-4cf1-a267-535f4e37bb17".to_string());
810        let src = builder.add_source("input.js");
811        builder.add_mapping(0, 0, src, 0, 0);
812
813        let json = builder.to_json();
814        assert!(json.contains(r#""debugId":"85314830-023f-4cf1-a267-535f4e37bb17""#));
815
816        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
817        assert_eq!(
818            sm.debug_id.as_deref(),
819            Some("85314830-023f-4cf1-a267-535f4e37bb17")
820        );
821    }
822
823    #[test]
824    fn scopes_roundtrip() {
825        use srcmap_scopes::{
826            Binding, CallSite, GeneratedRange, OriginalScope, Position, ScopeInfo,
827        };
828
829        let mut builder = SourceMapGenerator::new(Some("bundle.js".to_string()));
830        let src = builder.add_source("input.js");
831        builder.set_source_content(
832            src,
833            "function hello(name) {\n  return name;\n}\nhello('world');".to_string(),
834        );
835        let name_hello = builder.add_name("hello");
836        builder.add_named_mapping(0, 0, src, 0, 0, name_hello);
837        builder.add_mapping(1, 0, src, 1, 0);
838
839        // Set scopes
840        builder.set_scopes(ScopeInfo {
841            scopes: vec![Some(OriginalScope {
842                start: Position { line: 0, column: 0 },
843                end: Position {
844                    line: 3,
845                    column: 14,
846                },
847                name: None,
848                kind: Some("global".to_string()),
849                is_stack_frame: false,
850                variables: vec!["hello".to_string()],
851                children: vec![OriginalScope {
852                    start: Position { line: 0, column: 9 },
853                    end: Position { line: 2, column: 1 },
854                    name: Some("hello".to_string()),
855                    kind: Some("function".to_string()),
856                    is_stack_frame: true,
857                    variables: vec!["name".to_string()],
858                    children: vec![],
859                }],
860            })],
861            ranges: vec![GeneratedRange {
862                start: Position { line: 0, column: 0 },
863                end: Position {
864                    line: 3,
865                    column: 14,
866                },
867                is_stack_frame: false,
868                is_hidden: false,
869                definition: Some(0),
870                call_site: None,
871                bindings: vec![Binding::Expression("hello".to_string())],
872                children: vec![GeneratedRange {
873                    start: Position { line: 0, column: 9 },
874                    end: Position { line: 2, column: 1 },
875                    is_stack_frame: true,
876                    is_hidden: false,
877                    definition: Some(1),
878                    call_site: None,
879                    bindings: vec![Binding::Expression("name".to_string())],
880                    children: vec![],
881                }],
882            }],
883        });
884
885        let json = builder.to_json();
886
887        // Verify scopes field is present
888        assert!(json.contains(r#""scopes":"#));
889
890        // Parse back and verify
891        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
892        assert!(sm.scopes.is_some());
893
894        let scopes_info = sm.scopes.unwrap();
895
896        // Verify original scopes
897        assert_eq!(scopes_info.scopes.len(), 1);
898        let root_scope = scopes_info.scopes[0].as_ref().unwrap();
899        assert_eq!(root_scope.kind.as_deref(), Some("global"));
900        assert_eq!(root_scope.variables, vec!["hello"]);
901        assert_eq!(root_scope.children.len(), 1);
902
903        let fn_scope = &root_scope.children[0];
904        assert_eq!(fn_scope.name.as_deref(), Some("hello"));
905        assert_eq!(fn_scope.kind.as_deref(), Some("function"));
906        assert!(fn_scope.is_stack_frame);
907        assert_eq!(fn_scope.variables, vec!["name"]);
908
909        // Verify generated ranges
910        assert_eq!(scopes_info.ranges.len(), 1);
911        let outer = &scopes_info.ranges[0];
912        assert_eq!(outer.definition, Some(0));
913        assert_eq!(
914            outer.bindings,
915            vec![Binding::Expression("hello".to_string())]
916        );
917        assert_eq!(outer.children.len(), 1);
918
919        let inner = &outer.children[0];
920        assert_eq!(inner.definition, Some(1));
921        assert!(inner.is_stack_frame);
922        assert_eq!(
923            inner.bindings,
924            vec![Binding::Expression("name".to_string())]
925        );
926    }
927
928    #[test]
929    fn scopes_with_inlining_roundtrip() {
930        use srcmap_scopes::{
931            Binding, CallSite, GeneratedRange, OriginalScope, Position, ScopeInfo,
932        };
933
934        let mut builder = SourceMapGenerator::new(None);
935        let src = builder.add_source("input.js");
936        builder.add_mapping(0, 0, src, 0, 0);
937
938        builder.set_scopes(ScopeInfo {
939            scopes: vec![Some(OriginalScope {
940                start: Position { line: 0, column: 0 },
941                end: Position {
942                    line: 10,
943                    column: 0,
944                },
945                name: None,
946                kind: None,
947                is_stack_frame: false,
948                variables: vec!["x".to_string()],
949                children: vec![OriginalScope {
950                    start: Position { line: 1, column: 0 },
951                    end: Position { line: 4, column: 1 },
952                    name: Some("greet".to_string()),
953                    kind: Some("function".to_string()),
954                    is_stack_frame: true,
955                    variables: vec!["msg".to_string()],
956                    children: vec![],
957                }],
958            })],
959            ranges: vec![GeneratedRange {
960                start: Position { line: 0, column: 0 },
961                end: Position {
962                    line: 10,
963                    column: 0,
964                },
965                is_stack_frame: false,
966                is_hidden: false,
967                definition: Some(0),
968                call_site: None,
969                bindings: vec![Binding::Expression("_x".to_string())],
970                children: vec![GeneratedRange {
971                    start: Position { line: 6, column: 0 },
972                    end: Position { line: 8, column: 0 },
973                    is_stack_frame: true,
974                    is_hidden: false,
975                    definition: Some(1),
976                    call_site: Some(CallSite {
977                        source_index: 0,
978                        line: 8,
979                        column: 0,
980                    }),
981                    bindings: vec![Binding::Expression("\"Hello\"".to_string())],
982                    children: vec![],
983                }],
984            }],
985        });
986
987        let json = builder.to_json();
988        let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
989        let info = sm.scopes.unwrap();
990
991        // Verify call site on inlined range
992        let inlined = &info.ranges[0].children[0];
993        assert_eq!(
994            inlined.call_site,
995            Some(CallSite {
996                source_index: 0,
997                line: 8,
998                column: 0,
999            })
1000        );
1001        assert_eq!(
1002            inlined.bindings,
1003            vec![Binding::Expression("\"Hello\"".to_string())]
1004        );
1005    }
1006
1007    #[cfg(feature = "parallel")]
1008    mod parallel_tests {
1009        use super::*;
1010
1011        fn build_large_generator(lines: u32, cols_per_line: u32) -> SourceMapGenerator {
1012            let mut builder = SourceMapGenerator::new(Some("bundle.js".to_string()));
1013            for i in 0..10 {
1014                let src = builder.add_source(&format!("src/file{i}.js"));
1015                builder.set_source_content(
1016                    src,
1017                    format!("// source file {i}\n{}", "x = 1;\n".repeat(100)),
1018                );
1019            }
1020            for i in 0..20 {
1021                builder.add_name(&format!("var{i}"));
1022            }
1023
1024            for line in 0..lines {
1025                for col in 0..cols_per_line {
1026                    let src = (line * cols_per_line + col) % 10;
1027                    let name = if col % 3 == 0 {
1028                        Some((col % 20) as u32)
1029                    } else {
1030                        None
1031                    };
1032                    match name {
1033                        Some(n) => builder.add_named_mapping(line, col * 10, src, line, col * 5, n),
1034                        None => builder.add_mapping(line, col * 10, src, line, col * 5),
1035                    }
1036                }
1037            }
1038            builder
1039        }
1040
1041        #[test]
1042        fn parallel_large_roundtrip() {
1043            let builder = build_large_generator(500, 20);
1044            let json = builder.to_json();
1045            let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
1046            assert_eq!(sm.mapping_count(), 10000);
1047            assert_eq!(sm.line_count(), 500);
1048
1049            // Verify lookups
1050            let loc = sm.original_position_for(250, 50).unwrap();
1051            assert_eq!(loc.line, 250);
1052            assert_eq!(loc.column, 25);
1053        }
1054
1055        #[test]
1056        fn parallel_matches_sequential() {
1057            let builder = build_large_generator(500, 20);
1058
1059            // Sort mappings the same way encode_mappings does
1060            let mut sorted: Vec<&Mapping> = builder.mappings.iter().collect();
1061            sorted.sort_unstable_by(|a, b| {
1062                a.generated_line
1063                    .cmp(&b.generated_line)
1064                    .then(a.generated_column.cmp(&b.generated_column))
1065            });
1066
1067            let sequential = SourceMapGenerator::encode_sequential_impl(&sorted);
1068            let parallel = SourceMapGenerator::encode_parallel_impl(&sorted);
1069            assert_eq!(sequential, parallel);
1070        }
1071
1072        #[test]
1073        fn parallel_with_sparse_lines() {
1074            let mut builder = SourceMapGenerator::new(None);
1075            let src = builder.add_source("input.js");
1076
1077            // Add mappings on lines 0, 100, 200, ... (sparse)
1078            for i in 0..50 {
1079                let line = i * 100;
1080                for col in 0..100u32 {
1081                    builder.add_mapping(line, col * 10, src, line, col * 5);
1082                }
1083            }
1084
1085            let json = builder.to_json();
1086            let sm = srcmap_sourcemap::SourceMap::from_json(&json).unwrap();
1087            assert_eq!(sm.mapping_count(), 5000);
1088
1089            // Verify empty lines have no mappings
1090            assert!(sm.original_position_for(50, 0).is_none());
1091            // Verify populated lines work
1092            let loc = sm.original_position_for(200, 50).unwrap();
1093            assert_eq!(loc.line, 200);
1094            assert_eq!(loc.column, 25);
1095        }
1096    }
1097}