Skip to main content

srcmap_sourcemap/
lib.rs

1//! High-performance source map parser and consumer (ECMA-426).
2//!
3//! Parses source map JSON and provides O(log n) position lookups.
4//! Uses a flat, cache-friendly representation internally.
5//!
6//! # Examples
7//!
8//! ```
9//! use srcmap_sourcemap::SourceMap;
10//!
11//! let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA;AACA"}"#;
12//! let sm = SourceMap::from_json(json).unwrap();
13//!
14//! // Look up original position for generated line 0, column 0
15//! let loc = sm.original_position_for(0, 0).unwrap();
16//! assert_eq!(sm.source(loc.source), "input.js");
17//! assert_eq!(loc.line, 0);
18//! assert_eq!(loc.column, 0);
19//!
20//! // Reverse lookup
21//! let pos = sm.generated_position_for("input.js", 0, 0).unwrap();
22//! assert_eq!(pos.line, 0);
23//! assert_eq!(pos.column, 0);
24//! ```
25
26use std::cell::{Cell, OnceCell, RefCell};
27use std::collections::HashMap;
28use std::fmt;
29use std::io;
30
31use serde::Deserialize;
32use srcmap_codec::{DecodeError, vlq_encode_unsigned};
33use srcmap_scopes::ScopeInfo;
34
35pub mod js_identifiers;
36pub mod source_view;
37pub mod utils;
38
39pub use source_view::SourceView;
40
41// ── Constants ──────────────────────────────────────────────────────
42
43const NO_SOURCE: u32 = u32::MAX;
44const NO_NAME: u32 = u32::MAX;
45
46// ── Public types ───────────────────────────────────────────────────
47
48/// A single decoded mapping entry. Compact at 28 bytes (6 × u32 + bool with padding).
49///
50/// Maps a position in the generated output to an optional position in an
51/// original source file. Stored contiguously in a `Vec<Mapping>` sorted by
52/// `(generated_line, generated_column)` for cache-friendly binary search.
53#[derive(Debug, Clone, Copy)]
54pub struct Mapping {
55    /// 0-based line in the generated output.
56    pub generated_line: u32,
57    /// 0-based column in the generated output.
58    pub generated_column: u32,
59    /// Index into `SourceMap::sources`. `u32::MAX` if this mapping has no source.
60    pub source: u32,
61    /// 0-based line in the original source (only meaningful when `source != u32::MAX`).
62    pub original_line: u32,
63    /// 0-based column in the original source (only meaningful when `source != u32::MAX`).
64    pub original_column: u32,
65    /// Index into `SourceMap::names`. `u32::MAX` if this mapping has no name.
66    pub name: u32,
67    /// Whether this mapping is a range mapping (ECMA-426).
68    pub is_range_mapping: bool,
69}
70
71/// Result of an [`SourceMap::original_position_for`] lookup.
72///
73/// All indices are 0-based. Use [`SourceMap::source`] and [`SourceMap::name`]
74/// to resolve the `source` and `name` indices to strings.
75#[derive(Debug, Clone)]
76pub struct OriginalLocation {
77    /// Index into `SourceMap::sources`.
78    pub source: u32,
79    /// 0-based line in the original source.
80    pub line: u32,
81    /// 0-based column in the original source.
82    pub column: u32,
83    /// Index into `SourceMap::names`, if the mapping has a name.
84    pub name: Option<u32>,
85}
86
87/// Result of a [`SourceMap::generated_position_for`] lookup.
88///
89/// All values are 0-based.
90#[derive(Debug, Clone)]
91pub struct GeneratedLocation {
92    /// 0-based line in the generated output.
93    pub line: u32,
94    /// 0-based column in the generated output.
95    pub column: u32,
96}
97
98/// Search bias for position lookups.
99///
100/// Controls how non-exact matches are resolved during binary search:
101/// - `GreatestLowerBound` (default): find the closest mapping at or before the position
102/// - `LeastUpperBound`: find the closest mapping at or after the position
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub enum Bias {
105    /// Return the closest position at or before the requested position (default).
106    #[default]
107    GreatestLowerBound,
108    /// Return the closest position at or after the requested position.
109    LeastUpperBound,
110}
111
112/// A mapped range: original start/end positions for a generated range.
113///
114/// Returned by [`SourceMap::map_range`]. Both endpoints must resolve to the
115/// same source file.
116#[derive(Debug, Clone)]
117pub struct MappedRange {
118    /// Index into `SourceMap::sources`.
119    pub source: u32,
120    /// 0-based start line in the original source.
121    pub original_start_line: u32,
122    /// 0-based start column in the original source.
123    pub original_start_column: u32,
124    /// 0-based end line in the original source.
125    pub original_end_line: u32,
126    /// 0-based end column in the original source.
127    pub original_end_column: u32,
128}
129
130/// Errors that can occur during source map parsing.
131#[derive(Debug)]
132pub enum ParseError {
133    /// The JSON could not be deserialized.
134    Json(serde_json::Error),
135    /// The VLQ mappings string is malformed.
136    Vlq(DecodeError),
137    /// The `version` field is not `3`.
138    InvalidVersion(u32),
139    /// The ECMA-426 scopes data could not be decoded.
140    Scopes(srcmap_scopes::ScopesError),
141    /// A section map in an indexed source map is itself an indexed map (not allowed per ECMA-426).
142    NestedIndexMap,
143    /// Sections in an indexed source map are not in ascending (line, column) order.
144    SectionsNotOrdered,
145    /// The data URL is malformed (not a valid `data:application/json` URL).
146    InvalidDataUrl,
147}
148
149impl fmt::Display for ParseError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Json(e) => write!(f, "JSON parse error: {e}"),
153            Self::Vlq(e) => write!(f, "VLQ decode error: {e}"),
154            Self::InvalidVersion(v) => write!(f, "unsupported source map version: {v}"),
155            Self::Scopes(e) => write!(f, "scopes decode error: {e}"),
156            Self::NestedIndexMap => write!(f, "section map must not be an indexed source map"),
157            Self::SectionsNotOrdered => {
158                write!(f, "sections must be in ascending (line, column) order")
159            }
160            Self::InvalidDataUrl => write!(f, "malformed data URL"),
161        }
162    }
163}
164
165impl std::error::Error for ParseError {
166    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
167        match self {
168            Self::Json(e) => Some(e),
169            Self::Vlq(e) => Some(e),
170            Self::Scopes(e) => Some(e),
171            Self::InvalidVersion(_)
172            | Self::NestedIndexMap
173            | Self::SectionsNotOrdered
174            | Self::InvalidDataUrl => None,
175        }
176    }
177}
178
179impl From<serde_json::Error> for ParseError {
180    fn from(e: serde_json::Error) -> Self {
181        Self::Json(e)
182    }
183}
184
185impl From<DecodeError> for ParseError {
186    fn from(e: DecodeError) -> Self {
187        Self::Vlq(e)
188    }
189}
190
191impl From<srcmap_scopes::ScopesError> for ParseError {
192    fn from(e: srcmap_scopes::ScopesError) -> Self {
193        Self::Scopes(e)
194    }
195}
196
197// ── Helpers ────────────────────────────────────────────────────────
198
199/// Resolve source filenames by applying `source_root` prefix and replacing `None` with empty string.
200pub fn resolve_sources(raw_sources: &[Option<String>], source_root: &str) -> Vec<String> {
201    raw_sources
202        .iter()
203        .map(|s| match s {
204            Some(s) if !source_root.is_empty() => format!("{source_root}{s}"),
205            Some(s) => s.clone(),
206            None => String::new(),
207        })
208        .collect()
209}
210
211/// Build a source filename -> index lookup map.
212fn build_source_map(sources: &[String]) -> HashMap<String, u32> {
213    sources
214        .iter()
215        .enumerate()
216        .map(|(i, s)| (s.clone(), i as u32))
217        .collect()
218}
219
220// ── Raw JSON structure ─────────────────────────────────────────────
221
222#[derive(Deserialize)]
223struct RawSourceMap<'a> {
224    version: u32,
225    #[serde(default)]
226    file: Option<String>,
227    #[serde(default, rename = "sourceRoot")]
228    source_root: Option<String>,
229    #[serde(default)]
230    sources: Vec<Option<String>>,
231    #[serde(default, rename = "sourcesContent")]
232    sources_content: Option<Vec<Option<String>>>,
233    #[serde(default)]
234    names: Vec<String>,
235    #[serde(default, borrow)]
236    mappings: &'a str,
237    #[serde(default, rename = "ignoreList")]
238    ignore_list: Option<Vec<u32>>,
239    /// Deprecated Chrome DevTools field, fallback for `ignoreList`.
240    #[serde(default, rename = "x_google_ignoreList")]
241    x_google_ignore_list: Option<Vec<u32>>,
242    /// Debug ID for associating generated files with source maps (ECMA-426).
243    /// Accepts both `debugId` (spec) and `debug_id` (Sentry compat).
244    #[serde(default, rename = "debugId", alias = "debug_id")]
245    debug_id: Option<String>,
246    /// Scopes and variables (ECMA-426 scopes proposal).
247    #[serde(default, borrow)]
248    scopes: Option<&'a str>,
249    /// Range mappings (ECMA-426).
250    #[serde(default, borrow, rename = "rangeMappings")]
251    range_mappings: Option<&'a str>,
252    /// Indexed source maps use `sections` instead of `mappings`.
253    #[serde(default)]
254    sections: Option<Vec<RawSection>>,
255    /// Catch-all for unknown extension fields (x_*).
256    #[serde(flatten)]
257    extensions: HashMap<String, serde_json::Value>,
258}
259
260/// A section in an indexed source map.
261#[derive(Deserialize)]
262struct RawSection {
263    offset: RawOffset,
264    map: Box<serde_json::value::RawValue>,
265}
266
267#[derive(Deserialize)]
268struct RawOffset {
269    line: u32,
270    column: u32,
271}
272
273/// Lightweight version that skips sourcesContent allocation.
274/// Used by WASM bindings where sourcesContent is kept JS-side.
275///
276/// Note: Indexed/sectioned source maps are detected via the `sections` field
277/// and must be rejected by callers (LazySourceMap does not support them).
278#[derive(Deserialize)]
279pub struct RawSourceMapLite<'a> {
280    pub version: u32,
281    #[serde(default)]
282    pub file: Option<String>,
283    #[serde(default, rename = "sourceRoot")]
284    pub source_root: Option<String>,
285    #[serde(default)]
286    pub sources: Vec<Option<String>>,
287    #[serde(default)]
288    pub names: Vec<String>,
289    #[serde(default, borrow)]
290    pub mappings: &'a str,
291    #[serde(default, rename = "ignoreList")]
292    pub ignore_list: Option<Vec<u32>>,
293    #[serde(default, rename = "x_google_ignoreList")]
294    pub x_google_ignore_list: Option<Vec<u32>>,
295    #[serde(default, rename = "debugId", alias = "debug_id")]
296    pub debug_id: Option<String>,
297    #[serde(default, borrow)]
298    pub scopes: Option<&'a str>,
299    #[serde(default, borrow, rename = "rangeMappings")]
300    pub range_mappings: Option<&'a str>,
301    /// Indexed source maps use `sections` instead of `mappings`.
302    /// Presence is checked to reject indexed maps in lazy parse paths.
303    #[serde(default)]
304    pub sections: Option<Vec<serde_json::Value>>,
305}
306
307// ── SourceMap ──────────────────────────────────────────────────────
308
309/// A fully-parsed source map with O(log n) position lookups.
310///
311/// Supports both regular and indexed (sectioned) source maps, `ignoreList`,
312/// `debugId`, scopes (ECMA-426), and extension fields. All positions are
313/// 0-based lines and columns.
314///
315/// # Construction
316///
317/// - [`SourceMap::from_json`] — parse from a JSON string (most common)
318/// - [`SourceMap::from_parts`] — build from pre-decoded components
319/// - [`SourceMap::from_vlq`] — parse from pre-extracted parts + raw VLQ string
320/// - [`SourceMap::from_json_lines`] — partial parse for a line range
321///
322/// # Lookups
323///
324/// - [`SourceMap::original_position_for`] — forward: generated → original
325/// - [`SourceMap::generated_position_for`] — reverse: original → generated (lazy index)
326/// - [`SourceMap::all_generated_positions_for`] — all reverse matches
327/// - [`SourceMap::map_range`] — map a generated range to its original range
328///
329/// For cases where you only need a few lookups and want to avoid decoding
330/// all mappings upfront, see [`LazySourceMap`].
331#[derive(Debug, Clone)]
332pub struct SourceMap {
333    pub file: Option<String>,
334    pub source_root: Option<String>,
335    pub sources: Vec<String>,
336    pub sources_content: Vec<Option<String>>,
337    pub names: Vec<String>,
338    pub ignore_list: Vec<u32>,
339    /// Extension fields (x_* keys) preserved for passthrough.
340    pub extensions: HashMap<String, serde_json::Value>,
341    /// Debug ID (UUID) for associating generated files with source maps (ECMA-426).
342    pub debug_id: Option<String>,
343    /// Decoded scope and variable information (ECMA-426 scopes proposal).
344    pub scopes: Option<ScopeInfo>,
345
346    /// Flat decoded mappings, ordered by (generated_line, generated_column).
347    mappings: Vec<Mapping>,
348
349    /// `line_offsets[i]` = index of first mapping on generated line `i`.
350    /// `line_offsets[line_count]` = mappings.len() (sentinel).
351    line_offsets: Vec<u32>,
352
353    /// Indices into `mappings`, sorted by (source, original_line, original_column).
354    /// Built lazily on first `generated_position_for` call.
355    reverse_index: OnceCell<Vec<u32>>,
356
357    /// Source filename → index for O(1) lookup by name.
358    source_map: HashMap<String, u32>,
359
360    /// Cached flag: true if any mapping has `is_range_mapping == true`.
361    has_range_mappings: bool,
362}
363
364impl SourceMap {
365    /// Parse a source map from a JSON string.
366    /// Supports both regular and indexed (sectioned) source maps.
367    pub fn from_json(json: &str) -> Result<Self, ParseError> {
368        Self::from_json_inner(json, true)
369    }
370
371    /// Parse a source map from JSON, skipping sourcesContent allocation.
372    /// Useful for WASM bindings where sourcesContent is kept on the JS side.
373    /// The resulting SourceMap has an empty `sources_content` vec.
374    pub fn from_json_no_content(json: &str) -> Result<Self, ParseError> {
375        let raw: RawSourceMapLite<'_> = serde_json::from_str(json)?;
376
377        if raw.version != 3 {
378            return Err(ParseError::InvalidVersion(raw.version));
379        }
380
381        let source_root = raw.source_root.as_deref().unwrap_or("");
382        let sources = resolve_sources(&raw.sources, source_root);
383        let source_map = build_source_map(&sources);
384        let (mut mappings, line_offsets) = decode_mappings(raw.mappings)?;
385
386        if let Some(range_mappings_str) = raw.range_mappings
387            && !range_mappings_str.is_empty()
388        {
389            decode_range_mappings(range_mappings_str, &mut mappings, &line_offsets)?;
390        }
391
392        let num_sources = sources.len();
393        let scopes = match raw.scopes {
394            Some(scopes_str) if !scopes_str.is_empty() => Some(srcmap_scopes::decode_scopes(
395                scopes_str,
396                &raw.names,
397                num_sources,
398            )?),
399            _ => None,
400        };
401
402        let ignore_list = match raw.ignore_list {
403            Some(list) => list,
404            None => raw.x_google_ignore_list.unwrap_or_default(),
405        };
406
407        let has_range_mappings = mappings.iter().any(|m| m.is_range_mapping);
408
409        Ok(Self {
410            file: raw.file,
411            source_root: raw.source_root,
412            sources,
413            sources_content: Vec::new(),
414            names: raw.names,
415            ignore_list,
416            extensions: HashMap::new(),
417            debug_id: raw.debug_id,
418            scopes,
419            mappings,
420            line_offsets,
421            reverse_index: OnceCell::new(),
422            source_map,
423            has_range_mappings,
424        })
425    }
426
427    /// Internal parser with control over whether indexed maps (sections) are allowed.
428    fn from_json_inner(json: &str, allow_sections: bool) -> Result<Self, ParseError> {
429        let raw: RawSourceMap<'_> = serde_json::from_str(json)?;
430
431        if raw.version != 3 {
432            return Err(ParseError::InvalidVersion(raw.version));
433        }
434
435        // Handle indexed source maps (sections)
436        if let Some(sections) = raw.sections {
437            if !allow_sections {
438                return Err(ParseError::NestedIndexMap);
439            }
440            return Self::from_sections(raw.file, sections);
441        }
442
443        Self::from_regular(raw)
444    }
445
446    /// Parse a regular (non-indexed) source map.
447    fn from_regular(raw: RawSourceMap<'_>) -> Result<Self, ParseError> {
448        let source_root = raw.source_root.as_deref().unwrap_or("");
449        let sources = resolve_sources(&raw.sources, source_root);
450        let sources_content = raw.sources_content.unwrap_or_default();
451        let source_map = build_source_map(&sources);
452
453        // Decode mappings directly into flat Mapping vec
454        let (mut mappings, line_offsets) = decode_mappings(raw.mappings)?;
455
456        // Decode range mappings if present
457        if let Some(range_mappings_str) = raw.range_mappings
458            && !range_mappings_str.is_empty()
459        {
460            decode_range_mappings(range_mappings_str, &mut mappings, &line_offsets)?;
461        }
462
463        // Decode scopes if present
464        let num_sources = sources.len();
465        let scopes = match raw.scopes {
466            Some(scopes_str) if !scopes_str.is_empty() => Some(srcmap_scopes::decode_scopes(
467                scopes_str,
468                &raw.names,
469                num_sources,
470            )?),
471            _ => None,
472        };
473
474        // Use x_google_ignoreList as fallback only when ignoreList is absent
475        let ignore_list = match raw.ignore_list {
476            Some(list) => list,
477            None => raw.x_google_ignore_list.unwrap_or_default(),
478        };
479
480        // Filter extensions to only keep x_* and x-* fields
481        let extensions: HashMap<String, serde_json::Value> = raw
482            .extensions
483            .into_iter()
484            .filter(|(k, _)| k.starts_with("x_") || k.starts_with("x-"))
485            .collect();
486
487        let has_range_mappings = mappings.iter().any(|m| m.is_range_mapping);
488
489        Ok(Self {
490            file: raw.file,
491            source_root: raw.source_root,
492            sources,
493            sources_content,
494            names: raw.names,
495            ignore_list,
496            extensions,
497            debug_id: raw.debug_id,
498            scopes,
499            mappings,
500            line_offsets,
501            reverse_index: OnceCell::new(),
502            source_map,
503            has_range_mappings,
504        })
505    }
506
507    /// Flatten an indexed source map (with sections) into a regular one.
508    fn from_sections(file: Option<String>, sections: Vec<RawSection>) -> Result<Self, ParseError> {
509        let mut all_sources: Vec<String> = Vec::new();
510        let mut all_sources_content: Vec<Option<String>> = Vec::new();
511        let mut all_names: Vec<String> = Vec::new();
512        let mut all_mappings: Vec<Mapping> = Vec::new();
513        let mut all_ignore_list: Vec<u32> = Vec::new();
514        let mut max_line: u32 = 0;
515
516        // Source/name dedup maps to merge across sections
517        let mut source_index_map: HashMap<String, u32> = HashMap::new();
518        let mut name_index_map: HashMap<String, u32> = HashMap::new();
519
520        // Validate section ordering (must be in ascending line, column order)
521        for i in 1..sections.len() {
522            let prev = &sections[i - 1].offset;
523            let curr = &sections[i].offset;
524            if (curr.line, curr.column) <= (prev.line, prev.column) {
525                return Err(ParseError::SectionsNotOrdered);
526            }
527        }
528
529        for section in &sections {
530            // Section maps must not be indexed maps themselves (ECMA-426)
531            let sub = Self::from_json_inner(section.map.get(), false)?;
532
533            let line_offset = section.offset.line;
534            let col_offset = section.offset.column;
535
536            // Map section source indices to global indices
537            let source_remap: Vec<u32> = sub
538                .sources
539                .iter()
540                .enumerate()
541                .map(|(i, s)| {
542                    if let Some(&existing) = source_index_map.get(s) {
543                        existing
544                    } else {
545                        let idx = all_sources.len() as u32;
546                        all_sources.push(s.clone());
547                        // Add sourcesContent if available
548                        let content = sub.sources_content.get(i).cloned().unwrap_or(None);
549                        all_sources_content.push(content);
550                        source_index_map.insert(s.clone(), idx);
551                        idx
552                    }
553                })
554                .collect();
555
556            // Map section name indices to global indices
557            let name_remap: Vec<u32> = sub
558                .names
559                .iter()
560                .map(|n| {
561                    if let Some(&existing) = name_index_map.get(n) {
562                        existing
563                    } else {
564                        let idx = all_names.len() as u32;
565                        all_names.push(n.clone());
566                        name_index_map.insert(n.clone(), idx);
567                        idx
568                    }
569                })
570                .collect();
571
572            // Add ignore_list entries (remapped to global source indices)
573            for &idx in &sub.ignore_list {
574                let global_idx = source_remap[idx as usize];
575                if !all_ignore_list.contains(&global_idx) {
576                    all_ignore_list.push(global_idx);
577                }
578            }
579
580            // Remap and offset all mappings from this section
581            for m in &sub.mappings {
582                let gen_line = m.generated_line + line_offset;
583                let gen_col = if m.generated_line == 0 {
584                    m.generated_column + col_offset
585                } else {
586                    m.generated_column
587                };
588
589                all_mappings.push(Mapping {
590                    generated_line: gen_line,
591                    generated_column: gen_col,
592                    source: if m.source == NO_SOURCE {
593                        NO_SOURCE
594                    } else {
595                        source_remap[m.source as usize]
596                    },
597                    original_line: m.original_line,
598                    original_column: m.original_column,
599                    name: if m.name == NO_NAME {
600                        NO_NAME
601                    } else {
602                        name_remap[m.name as usize]
603                    },
604                    is_range_mapping: m.is_range_mapping,
605                });
606
607                if gen_line > max_line {
608                    max_line = gen_line;
609                }
610            }
611        }
612
613        // Sort mappings by (generated_line, generated_column)
614        all_mappings.sort_unstable_by(|a, b| {
615            a.generated_line
616                .cmp(&b.generated_line)
617                .then(a.generated_column.cmp(&b.generated_column))
618        });
619
620        // Build line_offsets
621        let line_count = if all_mappings.is_empty() {
622            0
623        } else {
624            max_line as usize + 1
625        };
626        let mut line_offsets: Vec<u32> = vec![0; line_count + 1];
627        let mut current_line: usize = 0;
628        for (i, m) in all_mappings.iter().enumerate() {
629            while current_line < m.generated_line as usize {
630                current_line += 1;
631                if current_line < line_offsets.len() {
632                    line_offsets[current_line] = i as u32;
633                }
634            }
635        }
636        // Fill sentinel
637        if !line_offsets.is_empty() {
638            let last = all_mappings.len() as u32;
639            for offset in line_offsets.iter_mut().skip(current_line + 1) {
640                *offset = last;
641            }
642        }
643
644        let source_map = build_source_map(&all_sources);
645        let has_range_mappings = all_mappings.iter().any(|m| m.is_range_mapping);
646
647        Ok(Self {
648            file,
649            source_root: None,
650            sources: all_sources,
651            sources_content: all_sources_content,
652            names: all_names,
653            ignore_list: all_ignore_list,
654            extensions: HashMap::new(),
655            debug_id: None,
656            scopes: None, // TODO: merge scopes from sections
657            mappings: all_mappings,
658            line_offsets,
659            reverse_index: OnceCell::new(),
660            source_map,
661            has_range_mappings,
662        })
663    }
664
665    /// Look up the original source position for a generated position.
666    ///
667    /// Both `line` and `column` are 0-based.
668    /// Returns `None` if no mapping exists or the mapping has no source.
669    pub fn original_position_for(&self, line: u32, column: u32) -> Option<OriginalLocation> {
670        self.original_position_for_with_bias(line, column, Bias::GreatestLowerBound)
671    }
672
673    /// Look up the original source position with a search bias.
674    ///
675    /// Both `line` and `column` are 0-based.
676    /// - `GreatestLowerBound`: find the closest mapping at or before the column (default)
677    /// - `LeastUpperBound`: find the closest mapping at or after the column
678    pub fn original_position_for_with_bias(
679        &self,
680        line: u32,
681        column: u32,
682        bias: Bias,
683    ) -> Option<OriginalLocation> {
684        let line_idx = line as usize;
685        if line_idx + 1 >= self.line_offsets.len() {
686            return self.range_mapping_fallback(line, column);
687        }
688
689        let start = self.line_offsets[line_idx] as usize;
690        let end = self.line_offsets[line_idx + 1] as usize;
691
692        if start == end {
693            return self.range_mapping_fallback(line, column);
694        }
695
696        let line_mappings = &self.mappings[start..end];
697
698        let idx = match bias {
699            Bias::GreatestLowerBound => {
700                match line_mappings.binary_search_by_key(&column, |m| m.generated_column) {
701                    Ok(i) => i,
702                    Err(0) => return self.range_mapping_fallback(line, column),
703                    Err(i) => i - 1,
704                }
705            }
706            Bias::LeastUpperBound => {
707                match line_mappings.binary_search_by_key(&column, |m| m.generated_column) {
708                    Ok(i) => i,
709                    Err(i) => {
710                        if i >= line_mappings.len() {
711                            return None;
712                        }
713                        i
714                    }
715                }
716            }
717        };
718
719        let mapping = &line_mappings[idx];
720
721        if mapping.source == NO_SOURCE {
722            return None;
723        }
724
725        if mapping.is_range_mapping && column >= mapping.generated_column {
726            let column_delta = column - mapping.generated_column;
727            return Some(OriginalLocation {
728                source: mapping.source,
729                line: mapping.original_line,
730                column: mapping.original_column + column_delta,
731                name: if mapping.name == NO_NAME {
732                    None
733                } else {
734                    Some(mapping.name)
735                },
736            });
737        }
738
739        Some(OriginalLocation {
740            source: mapping.source,
741            line: mapping.original_line,
742            column: mapping.original_column,
743            name: if mapping.name == NO_NAME {
744                None
745            } else {
746                Some(mapping.name)
747            },
748        })
749    }
750
751    /// Fall back to range mappings when no exact mapping is found for the position.
752    ///
753    /// Uses `saturating_sub` for column delta to prevent underflow when the
754    /// query column is before the range mapping's generated column.
755    fn range_mapping_fallback(&self, line: u32, column: u32) -> Option<OriginalLocation> {
756        let line_idx = line as usize;
757        let search_end = if line_idx + 1 < self.line_offsets.len() {
758            self.line_offsets[line_idx] as usize
759        } else {
760            self.mappings.len()
761        };
762        if search_end == 0 {
763            return None;
764        }
765        let last_mapping = &self.mappings[search_end - 1];
766        if !last_mapping.is_range_mapping || last_mapping.source == NO_SOURCE {
767            return None;
768        }
769        let line_delta = line - last_mapping.generated_line;
770        let column_delta = if line_delta == 0 {
771            column.saturating_sub(last_mapping.generated_column)
772        } else {
773            0
774        };
775        Some(OriginalLocation {
776            source: last_mapping.source,
777            line: last_mapping.original_line + line_delta,
778            column: last_mapping.original_column + column_delta,
779            name: if last_mapping.name == NO_NAME {
780                None
781            } else {
782                Some(last_mapping.name)
783            },
784        })
785    }
786
787    /// Look up the generated position for an original source position.
788    ///
789    /// `source` is the source filename. `line` and `column` are 0-based.
790    /// Uses `GreatestLowerBound` by default (finds closest mapping at or before the position),
791    /// matching `@jridgewell/trace-mapping`'s `generatedPositionFor` semantics.
792    pub fn generated_position_for(
793        &self,
794        source: &str,
795        line: u32,
796        column: u32,
797    ) -> Option<GeneratedLocation> {
798        self.generated_position_for_with_bias(source, line, column, Bias::GreatestLowerBound)
799    }
800
801    /// Look up the generated position with a search bias.
802    ///
803    /// `source` is the source filename. `line` and `column` are 0-based.
804    /// - `GreatestLowerBound`: find the closest mapping at or before the position (default)
805    /// - `LeastUpperBound`: find the closest mapping at or after the position
806    pub fn generated_position_for_with_bias(
807        &self,
808        source: &str,
809        line: u32,
810        column: u32,
811        bias: Bias,
812    ) -> Option<GeneratedLocation> {
813        let &source_idx = self.source_map.get(source)?;
814
815        let reverse_index = self
816            .reverse_index
817            .get_or_init(|| build_reverse_index(&self.mappings));
818
819        // Binary search in reverse_index for (source, line, column)
820        let idx = reverse_index.partition_point(|&i| {
821            let m = &self.mappings[i as usize];
822            (m.source, m.original_line, m.original_column) < (source_idx, line, column)
823        });
824
825        // jridgewell's generatedPositionFor searches within a single original
826        // line only, so both GLB and LUB must be constrained to the same line.
827        match bias {
828            Bias::GreatestLowerBound => {
829                // partition_point gives us the first element >= target.
830                // For GLB, we want the element at or before on the SAME line.
831                if idx < reverse_index.len() {
832                    let mapping = &self.mappings[reverse_index[idx] as usize];
833                    if mapping.source == source_idx
834                        && mapping.original_line == line
835                        && mapping.original_column == column
836                    {
837                        return Some(GeneratedLocation {
838                            line: mapping.generated_line,
839                            column: mapping.generated_column,
840                        });
841                    }
842                }
843                // No exact match: use the element before (greatest lower bound)
844                if idx == 0 {
845                    return None;
846                }
847                let mapping = &self.mappings[reverse_index[idx - 1] as usize];
848                if mapping.source != source_idx || mapping.original_line != line {
849                    return None;
850                }
851                Some(GeneratedLocation {
852                    line: mapping.generated_line,
853                    column: mapping.generated_column,
854                })
855            }
856            Bias::LeastUpperBound => {
857                if idx >= reverse_index.len() {
858                    return None;
859                }
860                let mapping = &self.mappings[reverse_index[idx] as usize];
861                if mapping.source != source_idx || mapping.original_line != line {
862                    return None;
863                }
864                // On exact match, scan forward to find the last mapping with the
865                // same (source, origLine, origCol). This matches jridgewell's
866                // upperBound behavior: when multiple generated positions map to
867                // the same original position, return the last one.
868                // On non-exact match, return the first element > target as-is.
869                if mapping.original_column == column {
870                    let mut last_idx = idx;
871                    while last_idx + 1 < reverse_index.len() {
872                        let next = &self.mappings[reverse_index[last_idx + 1] as usize];
873                        if next.source != source_idx
874                            || next.original_line != line
875                            || next.original_column != column
876                        {
877                            break;
878                        }
879                        last_idx += 1;
880                    }
881                    let last_mapping = &self.mappings[reverse_index[last_idx] as usize];
882                    return Some(GeneratedLocation {
883                        line: last_mapping.generated_line,
884                        column: last_mapping.generated_column,
885                    });
886                }
887                Some(GeneratedLocation {
888                    line: mapping.generated_line,
889                    column: mapping.generated_column,
890                })
891            }
892        }
893    }
894
895    /// Find all generated positions for an original source position.
896    ///
897    /// `source` is the source filename. `line` and `column` are 0-based.
898    /// Returns all generated positions that map back to this original location.
899    pub fn all_generated_positions_for(
900        &self,
901        source: &str,
902        line: u32,
903        column: u32,
904    ) -> Vec<GeneratedLocation> {
905        let Some(&source_idx) = self.source_map.get(source) else {
906            return Vec::new();
907        };
908
909        let reverse_index = self
910            .reverse_index
911            .get_or_init(|| build_reverse_index(&self.mappings));
912
913        // Find the first entry matching (source, line, column)
914        let start = reverse_index.partition_point(|&i| {
915            let m = &self.mappings[i as usize];
916            (m.source, m.original_line, m.original_column) < (source_idx, line, column)
917        });
918
919        let mut results = Vec::new();
920
921        for &ri in &reverse_index[start..] {
922            let m = &self.mappings[ri as usize];
923            if m.source != source_idx || m.original_line != line || m.original_column != column {
924                break;
925            }
926            results.push(GeneratedLocation {
927                line: m.generated_line,
928                column: m.generated_column,
929            });
930        }
931
932        results
933    }
934
935    /// Map a generated range to its original range.
936    ///
937    /// Given a generated range `(start_line:start_column → end_line:end_column)`,
938    /// maps both endpoints through the source map and returns the original range.
939    /// Both endpoints must resolve to the same source file.
940    pub fn map_range(
941        &self,
942        start_line: u32,
943        start_column: u32,
944        end_line: u32,
945        end_column: u32,
946    ) -> Option<MappedRange> {
947        let start = self.original_position_for(start_line, start_column)?;
948        let end = self.original_position_for(end_line, end_column)?;
949
950        // Both endpoints must map to the same source
951        if start.source != end.source {
952            return None;
953        }
954
955        Some(MappedRange {
956            source: start.source,
957            original_start_line: start.line,
958            original_start_column: start.column,
959            original_end_line: end.line,
960            original_end_column: end.column,
961        })
962    }
963
964    /// Resolve a source index to its filename.
965    ///
966    /// # Panics
967    ///
968    /// Panics if `index` is out of bounds. Use [`get_source`](Self::get_source)
969    /// for a non-panicking alternative.
970    #[inline]
971    pub fn source(&self, index: u32) -> &str {
972        &self.sources[index as usize]
973    }
974
975    /// Resolve a source index to its filename, returning `None` if out of bounds.
976    #[inline]
977    pub fn get_source(&self, index: u32) -> Option<&str> {
978        self.sources.get(index as usize).map(|s| s.as_str())
979    }
980
981    /// Resolve a name index to its string.
982    ///
983    /// # Panics
984    ///
985    /// Panics if `index` is out of bounds. Use [`get_name`](Self::get_name)
986    /// for a non-panicking alternative.
987    #[inline]
988    pub fn name(&self, index: u32) -> &str {
989        &self.names[index as usize]
990    }
991
992    /// Resolve a name index to its string, returning `None` if out of bounds.
993    #[inline]
994    pub fn get_name(&self, index: u32) -> Option<&str> {
995        self.names.get(index as usize).map(|s| s.as_str())
996    }
997
998    /// Find the source index for a filename.
999    #[inline]
1000    pub fn source_index(&self, name: &str) -> Option<u32> {
1001        self.source_map.get(name).copied()
1002    }
1003
1004    /// Total number of decoded mappings.
1005    #[inline]
1006    pub fn mapping_count(&self) -> usize {
1007        self.mappings.len()
1008    }
1009
1010    /// Number of generated lines.
1011    #[inline]
1012    pub fn line_count(&self) -> usize {
1013        self.line_offsets.len().saturating_sub(1)
1014    }
1015
1016    /// Get all mappings for a generated line (0-based).
1017    #[inline]
1018    pub fn mappings_for_line(&self, line: u32) -> &[Mapping] {
1019        let line_idx = line as usize;
1020        if line_idx + 1 >= self.line_offsets.len() {
1021            return &[];
1022        }
1023        let start = self.line_offsets[line_idx] as usize;
1024        let end = self.line_offsets[line_idx + 1] as usize;
1025        &self.mappings[start..end]
1026    }
1027
1028    /// Iterate all mappings.
1029    #[inline]
1030    pub fn all_mappings(&self) -> &[Mapping] {
1031        &self.mappings
1032    }
1033
1034    /// Serialize the source map back to JSON.
1035    ///
1036    /// Produces a valid source map v3 JSON string that can be written to a file
1037    /// or embedded in a data URL.
1038    pub fn to_json(&self) -> String {
1039        self.to_json_with_options(false)
1040    }
1041
1042    /// Serialize the source map back to JSON with options.
1043    ///
1044    /// If `exclude_content` is true, `sourcesContent` is omitted from the output.
1045    pub fn to_json_with_options(&self, exclude_content: bool) -> String {
1046        let mappings = self.encode_mappings();
1047
1048        // Encode scopes first — this may add new names that need to be in the names array
1049        let scopes_encoded = if let Some(ref scopes_info) = self.scopes {
1050            let mut names_clone = self.names.clone();
1051            let s = srcmap_scopes::encode_scopes(scopes_info, &mut names_clone);
1052            Some((s, names_clone))
1053        } else {
1054            None
1055        };
1056        let names_for_json = match &scopes_encoded {
1057            Some((_, expanded_names)) => expanded_names,
1058            None => &self.names,
1059        };
1060
1061        let source_root_prefix = self.source_root.as_deref().unwrap_or("");
1062
1063        let mut json = String::with_capacity(256 + mappings.len());
1064        json.push_str(r#"{"version":3"#);
1065
1066        if let Some(ref file) = self.file {
1067            json.push_str(r#","file":"#);
1068            json_quote_into(&mut json, file);
1069        }
1070
1071        if let Some(ref root) = self.source_root {
1072            json.push_str(r#","sourceRoot":"#);
1073            json_quote_into(&mut json, root);
1074        }
1075
1076        // Strip sourceRoot prefix from sources to avoid double-application on roundtrip
1077        json.push_str(r#","sources":["#);
1078        for (i, s) in self.sources.iter().enumerate() {
1079            if i > 0 {
1080                json.push(',');
1081            }
1082            let source_name = if !source_root_prefix.is_empty() {
1083                s.strip_prefix(source_root_prefix).unwrap_or(s)
1084            } else {
1085                s
1086            };
1087            json_quote_into(&mut json, source_name);
1088        }
1089        json.push(']');
1090
1091        if !exclude_content
1092            && !self.sources_content.is_empty()
1093            && self.sources_content.iter().any(|c| c.is_some())
1094        {
1095            json.push_str(r#","sourcesContent":["#);
1096            for (i, c) in self.sources_content.iter().enumerate() {
1097                if i > 0 {
1098                    json.push(',');
1099                }
1100                match c {
1101                    Some(content) => json_quote_into(&mut json, content),
1102                    None => json.push_str("null"),
1103                }
1104            }
1105            json.push(']');
1106        }
1107
1108        json.push_str(r#","names":["#);
1109        for (i, n) in names_for_json.iter().enumerate() {
1110            if i > 0 {
1111                json.push(',');
1112            }
1113            json_quote_into(&mut json, n);
1114        }
1115        json.push(']');
1116
1117        // VLQ mappings are pure base64/,/; — no escaping needed
1118        json.push_str(r#","mappings":""#);
1119        json.push_str(&mappings);
1120        json.push('"');
1121
1122        if let Some(range_mappings) = self.encode_range_mappings() {
1123            // Range mappings are also pure VLQ — no escaping needed
1124            json.push_str(r#","rangeMappings":""#);
1125            json.push_str(&range_mappings);
1126            json.push('"');
1127        }
1128
1129        if !self.ignore_list.is_empty() {
1130            use std::fmt::Write;
1131            json.push_str(r#","ignoreList":["#);
1132            for (i, &idx) in self.ignore_list.iter().enumerate() {
1133                if i > 0 {
1134                    json.push(',');
1135                }
1136                let _ = write!(json, "{idx}");
1137            }
1138            json.push(']');
1139        }
1140
1141        if let Some(ref id) = self.debug_id {
1142            json.push_str(r#","debugId":"#);
1143            json_quote_into(&mut json, id);
1144        }
1145
1146        // scopes (ECMA-426 scopes proposal)
1147        if let Some((ref s, _)) = scopes_encoded {
1148            json.push_str(r#","scopes":"#);
1149            json_quote_into(&mut json, s);
1150        }
1151
1152        // Emit extension fields (x_* and x-* keys)
1153        let mut ext_keys: Vec<&String> = self.extensions.keys().collect();
1154        ext_keys.sort();
1155        for key in ext_keys {
1156            if let Some(val) = self.extensions.get(key) {
1157                json.push(',');
1158                json_quote_into(&mut json, key);
1159                json.push(':');
1160                json.push_str(&serde_json::to_string(val).unwrap_or_default());
1161            }
1162        }
1163
1164        json.push('}');
1165        json
1166    }
1167
1168    /// Construct a `SourceMap` from pre-built parts.
1169    ///
1170    /// This avoids the encode-then-decode round-trip used in composition pipelines.
1171    /// Mappings must be sorted by (generated_line, generated_column).
1172    /// Use `u32::MAX` for `source`/`name` fields to indicate absence.
1173    #[allow(clippy::too_many_arguments)]
1174    pub fn from_parts(
1175        file: Option<String>,
1176        source_root: Option<String>,
1177        sources: Vec<String>,
1178        sources_content: Vec<Option<String>>,
1179        names: Vec<String>,
1180        mappings: Vec<Mapping>,
1181        ignore_list: Vec<u32>,
1182        debug_id: Option<String>,
1183        scopes: Option<ScopeInfo>,
1184    ) -> Self {
1185        // Build line_offsets from sorted mappings
1186        let line_count = mappings.last().map_or(0, |m| m.generated_line as usize + 1);
1187        let mut line_offsets: Vec<u32> = vec![0; line_count + 1];
1188        let mut current_line: usize = 0;
1189        for (i, m) in mappings.iter().enumerate() {
1190            while current_line < m.generated_line as usize {
1191                current_line += 1;
1192                if current_line < line_offsets.len() {
1193                    line_offsets[current_line] = i as u32;
1194                }
1195            }
1196        }
1197        // Fill remaining with sentinel
1198        if !line_offsets.is_empty() {
1199            let last = mappings.len() as u32;
1200            for offset in line_offsets.iter_mut().skip(current_line + 1) {
1201                *offset = last;
1202            }
1203        }
1204
1205        let source_map = build_source_map(&sources);
1206        let has_range_mappings = mappings.iter().any(|m| m.is_range_mapping);
1207
1208        Self {
1209            file,
1210            source_root,
1211            sources,
1212            sources_content,
1213            names,
1214            ignore_list,
1215            extensions: HashMap::new(),
1216            debug_id,
1217            scopes,
1218            mappings,
1219            line_offsets,
1220            reverse_index: OnceCell::new(),
1221            source_map,
1222            has_range_mappings,
1223        }
1224    }
1225
1226    /// Build a source map from pre-parsed components and a VLQ mappings string.
1227    ///
1228    /// This is the fast path for WASM: JS does `JSON.parse()` (V8-native speed),
1229    /// then only the VLQ mappings string crosses into WASM for decoding.
1230    /// Avoids copying large `sourcesContent` into WASM linear memory.
1231    #[allow(clippy::too_many_arguments)]
1232    pub fn from_vlq(
1233        mappings_str: &str,
1234        sources: Vec<String>,
1235        names: Vec<String>,
1236        file: Option<String>,
1237        source_root: Option<String>,
1238        sources_content: Vec<Option<String>>,
1239        ignore_list: Vec<u32>,
1240        debug_id: Option<String>,
1241    ) -> Result<Self, ParseError> {
1242        Self::from_vlq_with_range_mappings(
1243            mappings_str,
1244            sources,
1245            names,
1246            file,
1247            source_root,
1248            sources_content,
1249            ignore_list,
1250            debug_id,
1251            None,
1252        )
1253    }
1254
1255    /// Build a source map from pre-parsed components, a VLQ mappings string,
1256    /// and an optional range mappings string.
1257    #[allow(clippy::too_many_arguments)]
1258    pub fn from_vlq_with_range_mappings(
1259        mappings_str: &str,
1260        sources: Vec<String>,
1261        names: Vec<String>,
1262        file: Option<String>,
1263        source_root: Option<String>,
1264        sources_content: Vec<Option<String>>,
1265        ignore_list: Vec<u32>,
1266        debug_id: Option<String>,
1267        range_mappings_str: Option<&str>,
1268    ) -> Result<Self, ParseError> {
1269        let (mut mappings, line_offsets) = decode_mappings(mappings_str)?;
1270        if let Some(rm_str) = range_mappings_str
1271            && !rm_str.is_empty()
1272        {
1273            decode_range_mappings(rm_str, &mut mappings, &line_offsets)?;
1274        }
1275        let source_map = build_source_map(&sources);
1276        let has_range_mappings = mappings.iter().any(|m| m.is_range_mapping);
1277        Ok(Self {
1278            file,
1279            source_root,
1280            sources,
1281            sources_content,
1282            names,
1283            ignore_list,
1284            extensions: HashMap::new(),
1285            debug_id,
1286            scopes: None,
1287            mappings,
1288            line_offsets,
1289            reverse_index: OnceCell::new(),
1290            source_map,
1291            has_range_mappings,
1292        })
1293    }
1294
1295    /// Create a builder for incrementally constructing a `SourceMap`.
1296    ///
1297    /// The builder accepts iterators for sources, names, and mappings,
1298    /// avoiding the need to pre-collect into `Vec`s.
1299    ///
1300    /// ```
1301    /// use srcmap_sourcemap::{SourceMap, Mapping};
1302    ///
1303    /// let sm = SourceMap::builder()
1304    ///     .file("output.js")
1305    ///     .sources(["input.ts"])
1306    ///     .sources_content([Some("let x = 1;")])
1307    ///     .names(["x"])
1308    ///     .mappings([Mapping {
1309    ///         generated_line: 0,
1310    ///         generated_column: 0,
1311    ///         source: 0,
1312    ///         original_line: 0,
1313    ///         original_column: 0,
1314    ///         name: 0,
1315    ///         is_range_mapping: false,
1316    ///     }])
1317    ///     .build();
1318    ///
1319    /// assert_eq!(sm.mapping_count(), 1);
1320    /// ```
1321    pub fn builder() -> SourceMapBuilder {
1322        SourceMapBuilder::new()
1323    }
1324
1325    /// Parse a source map from JSON, decoding only mappings for lines in `[start_line, end_line)`.
1326    ///
1327    /// This is useful for large source maps where only a subset of lines is needed.
1328    /// VLQ state is maintained through skipped lines (required for correct delta decoding),
1329    /// but `Mapping` structs are only allocated for lines in the requested range.
1330    pub fn from_json_lines(json: &str, start_line: u32, end_line: u32) -> Result<Self, ParseError> {
1331        let raw: RawSourceMap<'_> = serde_json::from_str(json)?;
1332
1333        if raw.version != 3 {
1334            return Err(ParseError::InvalidVersion(raw.version));
1335        }
1336
1337        let source_root = raw.source_root.as_deref().unwrap_or("");
1338        let sources = resolve_sources(&raw.sources, source_root);
1339        let sources_content = raw.sources_content.unwrap_or_default();
1340        let source_map = build_source_map(&sources);
1341
1342        // Decode only the requested line range
1343        let (mappings, line_offsets) = decode_mappings_range(raw.mappings, start_line, end_line)?;
1344
1345        // Decode scopes if present
1346        let num_sources = sources.len();
1347        let scopes = match raw.scopes {
1348            Some(scopes_str) if !scopes_str.is_empty() => Some(srcmap_scopes::decode_scopes(
1349                scopes_str,
1350                &raw.names,
1351                num_sources,
1352            )?),
1353            _ => None,
1354        };
1355
1356        let ignore_list = match raw.ignore_list {
1357            Some(list) => list,
1358            None => raw.x_google_ignore_list.unwrap_or_default(),
1359        };
1360
1361        // Filter extensions to only keep x_* and x-* fields
1362        let extensions: HashMap<String, serde_json::Value> = raw
1363            .extensions
1364            .into_iter()
1365            .filter(|(k, _)| k.starts_with("x_") || k.starts_with("x-"))
1366            .collect();
1367
1368        let has_range_mappings = mappings.iter().any(|m| m.is_range_mapping);
1369
1370        Ok(Self {
1371            file: raw.file,
1372            source_root: raw.source_root,
1373            sources,
1374            sources_content,
1375            names: raw.names,
1376            ignore_list,
1377            extensions,
1378            debug_id: raw.debug_id,
1379            scopes,
1380            mappings,
1381            line_offsets,
1382            reverse_index: OnceCell::new(),
1383            source_map,
1384            has_range_mappings,
1385        })
1386    }
1387
1388    /// Encode all mappings back to a VLQ mappings string.
1389    pub fn encode_mappings(&self) -> String {
1390        if self.mappings.is_empty() {
1391            return String::new();
1392        }
1393
1394        let mut out: Vec<u8> = Vec::with_capacity(self.mappings.len() * 6);
1395
1396        let mut prev_gen_col: i64 = 0;
1397        let mut prev_source: i64 = 0;
1398        let mut prev_orig_line: i64 = 0;
1399        let mut prev_orig_col: i64 = 0;
1400        let mut prev_name: i64 = 0;
1401        let mut prev_gen_line: u32 = 0;
1402        let mut first_in_line = true;
1403
1404        for m in &self.mappings {
1405            while prev_gen_line < m.generated_line {
1406                out.push(b';');
1407                prev_gen_line += 1;
1408                prev_gen_col = 0;
1409                first_in_line = true;
1410            }
1411
1412            if !first_in_line {
1413                out.push(b',');
1414            }
1415            first_in_line = false;
1416
1417            srcmap_codec::vlq_encode(&mut out, m.generated_column as i64 - prev_gen_col);
1418            prev_gen_col = m.generated_column as i64;
1419
1420            if m.source != NO_SOURCE {
1421                srcmap_codec::vlq_encode(&mut out, m.source as i64 - prev_source);
1422                prev_source = m.source as i64;
1423
1424                srcmap_codec::vlq_encode(&mut out, m.original_line as i64 - prev_orig_line);
1425                prev_orig_line = m.original_line as i64;
1426
1427                srcmap_codec::vlq_encode(&mut out, m.original_column as i64 - prev_orig_col);
1428                prev_orig_col = m.original_column as i64;
1429
1430                if m.name != NO_NAME {
1431                    srcmap_codec::vlq_encode(&mut out, m.name as i64 - prev_name);
1432                    prev_name = m.name as i64;
1433                }
1434            }
1435        }
1436
1437        // SAFETY: vlq_encode only pushes bytes from BASE64_ENCODE (all ASCII),
1438        // and we only add b';' and b',' — all valid UTF-8.
1439        debug_assert!(out.is_ascii());
1440        unsafe { String::from_utf8_unchecked(out) }
1441    }
1442
1443    pub fn encode_range_mappings(&self) -> Option<String> {
1444        if !self.has_range_mappings {
1445            return None;
1446        }
1447        let line_count = self.line_offsets.len().saturating_sub(1);
1448        let mut out: Vec<u8> = Vec::new();
1449        for line_idx in 0..line_count {
1450            if line_idx > 0 {
1451                out.push(b';');
1452            }
1453            let start = self.line_offsets[line_idx] as usize;
1454            let end = self.line_offsets[line_idx + 1] as usize;
1455            let mut prev_offset: u64 = 0;
1456            let mut first_on_line = true;
1457            for (i, mapping) in self.mappings[start..end].iter().enumerate() {
1458                if mapping.is_range_mapping {
1459                    if !first_on_line {
1460                        out.push(b',');
1461                    }
1462                    first_on_line = false;
1463                    vlq_encode_unsigned(&mut out, i as u64 - prev_offset);
1464                    prev_offset = i as u64;
1465                }
1466            }
1467        }
1468        while out.last() == Some(&b';') {
1469            out.pop();
1470        }
1471        if out.is_empty() {
1472            return None;
1473        }
1474        // SAFETY: vlq_encode_unsigned only pushes ASCII base64 chars,
1475        // and we only add b';' and b',' — all valid UTF-8.
1476        debug_assert!(out.is_ascii());
1477        Some(unsafe { String::from_utf8_unchecked(out) })
1478    }
1479
1480    #[inline]
1481    pub fn has_range_mappings(&self) -> bool {
1482        self.has_range_mappings
1483    }
1484
1485    #[inline]
1486    pub fn range_mapping_count(&self) -> usize {
1487        self.mappings.iter().filter(|m| m.is_range_mapping).count()
1488    }
1489
1490    /// Parse a source map from a `data:` URL.
1491    ///
1492    /// Supports both base64-encoded and plain JSON data URLs:
1493    /// - `data:application/json;base64,<base64-encoded-json>`
1494    /// - `data:application/json;charset=utf-8;base64,<base64-encoded-json>`
1495    /// - `data:application/json,<json>`
1496    ///
1497    /// Returns [`ParseError::InvalidDataUrl`] if the URL format is not recognized
1498    /// or base64 decoding fails.
1499    pub fn from_data_url(url: &str) -> Result<Self, ParseError> {
1500        let rest = url
1501            .strip_prefix("data:application/json")
1502            .ok_or(ParseError::InvalidDataUrl)?;
1503
1504        // Try base64-encoded variants first
1505        let json = if let Some(data) = rest
1506            .strip_prefix(";base64,")
1507            .or_else(|| rest.strip_prefix(";charset=utf-8;base64,"))
1508            .or_else(|| rest.strip_prefix(";charset=UTF-8;base64,"))
1509        {
1510            base64_decode(data).ok_or(ParseError::InvalidDataUrl)?
1511        } else if let Some(data) = rest.strip_prefix(',') {
1512            // Plain JSON — percent-decode if needed
1513            if data.contains('%') {
1514                percent_decode(data)
1515            } else {
1516                data.to_string()
1517            }
1518        } else {
1519            return Err(ParseError::InvalidDataUrl);
1520        };
1521
1522        Self::from_json(&json)
1523    }
1524
1525    /// Serialize the source map JSON to a writer.
1526    ///
1527    /// Equivalent to calling [`to_json`](Self::to_json) and writing the result.
1528    /// The full JSON string is built in memory before writing.
1529    pub fn to_writer(&self, mut writer: impl io::Write) -> io::Result<()> {
1530        let json = self.to_json();
1531        writer.write_all(json.as_bytes())
1532    }
1533
1534    /// Serialize the source map JSON to a writer with options.
1535    ///
1536    /// If `exclude_content` is true, `sourcesContent` is omitted from the output.
1537    pub fn to_writer_with_options(
1538        &self,
1539        mut writer: impl io::Write,
1540        exclude_content: bool,
1541    ) -> io::Result<()> {
1542        let json = self.to_json_with_options(exclude_content);
1543        writer.write_all(json.as_bytes())
1544    }
1545
1546    /// Serialize the source map to a `data:` URL.
1547    ///
1548    /// Format: `data:application/json;base64,<base64-encoded-json>`
1549    pub fn to_data_url(&self) -> String {
1550        utils::to_data_url(&self.to_json())
1551    }
1552
1553    // ── Mutable setters ─────────────────────────────────────────
1554
1555    /// Set or clear the `file` property.
1556    pub fn set_file(&mut self, file: Option<String>) {
1557        self.file = file;
1558    }
1559
1560    /// Set or clear the `sourceRoot` property.
1561    pub fn set_source_root(&mut self, source_root: Option<String>) {
1562        self.source_root = source_root;
1563    }
1564
1565    /// Set or clear the `debugId` property.
1566    pub fn set_debug_id(&mut self, debug_id: Option<String>) {
1567        self.debug_id = debug_id;
1568    }
1569
1570    /// Set the `ignoreList` property.
1571    pub fn set_ignore_list(&mut self, ignore_list: Vec<u32>) {
1572        self.ignore_list = ignore_list;
1573    }
1574
1575    /// Replace the sources array and rebuild the source index lookup map.
1576    pub fn set_sources(&mut self, sources: Vec<Option<String>>) {
1577        let source_root = self.source_root.as_deref().unwrap_or("");
1578        self.sources = resolve_sources(&sources, source_root);
1579        self.source_map = build_source_map(&self.sources);
1580        // Invalidate the reverse index since source indices may have changed
1581        self.reverse_index = OnceCell::new();
1582    }
1583}
1584
1585// ── LazySourceMap ──────────────────────────────────────────────────
1586
1587/// Cumulative VLQ state at a line boundary.
1588#[derive(Debug, Clone, Copy)]
1589struct VlqState {
1590    source_index: i64,
1591    original_line: i64,
1592    original_column: i64,
1593    name_index: i64,
1594}
1595
1596/// Pre-scanned line info for O(1) random access into the raw mappings string.
1597#[derive(Debug, Clone)]
1598struct LineInfo {
1599    /// Byte offset into the raw mappings string where this line starts.
1600    byte_offset: usize,
1601    /// Byte offset where this line ends (exclusive, at `;` or end of string).
1602    byte_end: usize,
1603    /// Cumulative VLQ state at the start of this line.
1604    state: VlqState,
1605}
1606
1607/// A lazily-decoded source map that defers VLQ mappings decoding until needed.
1608///
1609/// For large source maps (100MB+), this avoids decoding all mappings upfront.
1610/// JSON metadata (sources, names, etc.) is parsed eagerly, but VLQ mappings
1611/// are decoded on a per-line basis on demand.
1612///
1613/// Not thread-safe (`!Sync`). Uses `RefCell`/`Cell` for internal caching.
1614/// Intended for single-threaded use (WASM) or with external synchronization.
1615///
1616/// # Examples
1617///
1618/// ```
1619/// use srcmap_sourcemap::LazySourceMap;
1620///
1621/// let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA;AACA"}"#;
1622/// let sm = LazySourceMap::from_json(json).unwrap();
1623///
1624/// // Mappings are only decoded when accessed
1625/// let loc = sm.original_position_for(0, 0).unwrap();
1626/// assert_eq!(sm.source(loc.source), "input.js");
1627/// ```
1628#[derive(Debug)]
1629pub struct LazySourceMap {
1630    pub file: Option<String>,
1631    pub source_root: Option<String>,
1632    pub sources: Vec<String>,
1633    pub sources_content: Vec<Option<String>>,
1634    pub names: Vec<String>,
1635    pub ignore_list: Vec<u32>,
1636    pub extensions: HashMap<String, serde_json::Value>,
1637    pub debug_id: Option<String>,
1638    pub scopes: Option<ScopeInfo>,
1639
1640    /// Raw VLQ mappings string (owned).
1641    raw_mappings: String,
1642
1643    /// Pre-scanned line info for O(1) line access.
1644    /// In fast-scan mode, VlqState is zeroed and decoded progressively.
1645    line_info: Vec<LineInfo>,
1646
1647    /// Cache of decoded lines: line index -> `Vec<Mapping>`.
1648    decoded_lines: RefCell<HashMap<u32, Vec<Mapping>>>,
1649
1650    /// Source filename -> index for O(1) lookup by name.
1651    source_map: HashMap<String, u32>,
1652
1653    /// Whether line_info was built with fast-scan (no VLQ state tracking).
1654    /// If true, decode_line must decode sequentially from the start.
1655    fast_scan: bool,
1656
1657    /// Highest line fully decoded so far (for progressive decode in fast-scan mode).
1658    /// VLQ state at the end of this line is stored in `decode_state`.
1659    decode_watermark: Cell<u32>,
1660    decode_state: Cell<VlqState>,
1661}
1662
1663impl LazySourceMap {
1664    /// Parse a source map from JSON, deferring VLQ mappings decoding.
1665    ///
1666    /// Parses all JSON metadata eagerly but stores the raw mappings string.
1667    /// VLQ mappings are decoded per-line on demand.
1668    pub fn from_json(json: &str) -> Result<Self, ParseError> {
1669        let raw: RawSourceMap<'_> = serde_json::from_str(json)?;
1670
1671        if raw.version != 3 {
1672            return Err(ParseError::InvalidVersion(raw.version));
1673        }
1674
1675        let source_root = raw.source_root.as_deref().unwrap_or("");
1676        let sources = resolve_sources(&raw.sources, source_root);
1677        let sources_content = raw.sources_content.unwrap_or_default();
1678        let source_map = build_source_map(&sources);
1679
1680        // Pre-scan the raw mappings string to find semicolon positions
1681        // and compute cumulative VLQ state at each line boundary.
1682        let raw_mappings = raw.mappings.to_string();
1683        let line_info = prescan_mappings(&raw_mappings)?;
1684
1685        // Decode scopes if present
1686        let num_sources = sources.len();
1687        let scopes = match raw.scopes {
1688            Some(scopes_str) if !scopes_str.is_empty() => Some(srcmap_scopes::decode_scopes(
1689                scopes_str,
1690                &raw.names,
1691                num_sources,
1692            )?),
1693            _ => None,
1694        };
1695
1696        let ignore_list = match raw.ignore_list {
1697            Some(list) => list,
1698            None => raw.x_google_ignore_list.unwrap_or_default(),
1699        };
1700
1701        // Filter extensions to only keep x_* and x-* fields
1702        let extensions: HashMap<String, serde_json::Value> = raw
1703            .extensions
1704            .into_iter()
1705            .filter(|(k, _)| k.starts_with("x_") || k.starts_with("x-"))
1706            .collect();
1707
1708        Ok(Self {
1709            file: raw.file,
1710            source_root: raw.source_root,
1711            sources,
1712            sources_content,
1713            names: raw.names,
1714            ignore_list,
1715            extensions,
1716            debug_id: raw.debug_id,
1717            scopes,
1718            raw_mappings,
1719            line_info,
1720            decoded_lines: RefCell::new(HashMap::new()),
1721            source_map,
1722            fast_scan: false,
1723            decode_watermark: Cell::new(0),
1724            decode_state: Cell::new(VlqState {
1725                source_index: 0,
1726                original_line: 0,
1727                original_column: 0,
1728                name_index: 0,
1729            }),
1730        })
1731    }
1732
1733    /// Parse a source map from JSON, skipping sourcesContent allocation
1734    /// and deferring VLQ mappings decoding.
1735    ///
1736    /// Useful for WASM bindings where sourcesContent is kept on the JS side.
1737    ///
1738    /// Returns `ParseError::NestedIndexMap` if the JSON contains `sections`
1739    /// (indexed source maps are not supported by `LazySourceMap`).
1740    pub fn from_json_no_content(json: &str) -> Result<Self, ParseError> {
1741        let raw: RawSourceMapLite<'_> = serde_json::from_str(json)?;
1742
1743        if raw.version != 3 {
1744            return Err(ParseError::InvalidVersion(raw.version));
1745        }
1746
1747        // LazySourceMap does not support indexed/sectioned source maps.
1748        // Use SourceMap::from_json() for indexed maps.
1749        if raw.sections.is_some() {
1750            return Err(ParseError::NestedIndexMap);
1751        }
1752
1753        let source_root = raw.source_root.as_deref().unwrap_or("");
1754        let sources = resolve_sources(&raw.sources, source_root);
1755        let source_map = build_source_map(&sources);
1756
1757        let raw_mappings = raw.mappings.to_string();
1758        let line_info = prescan_mappings(&raw_mappings)?;
1759
1760        let num_sources = sources.len();
1761        let scopes = match raw.scopes {
1762            Some(scopes_str) if !scopes_str.is_empty() => Some(srcmap_scopes::decode_scopes(
1763                scopes_str,
1764                &raw.names,
1765                num_sources,
1766            )?),
1767            _ => None,
1768        };
1769
1770        let ignore_list = match raw.ignore_list {
1771            Some(list) => list,
1772            None => raw.x_google_ignore_list.unwrap_or_default(),
1773        };
1774
1775        Ok(Self {
1776            file: raw.file,
1777            source_root: raw.source_root,
1778            sources,
1779            sources_content: Vec::new(),
1780            names: raw.names,
1781            ignore_list,
1782            extensions: HashMap::new(),
1783            debug_id: raw.debug_id,
1784            scopes,
1785            raw_mappings,
1786            line_info,
1787            decoded_lines: RefCell::new(HashMap::new()),
1788            source_map,
1789            fast_scan: false,
1790            decode_watermark: Cell::new(0),
1791            decode_state: Cell::new(VlqState {
1792                source_index: 0,
1793                original_line: 0,
1794                original_column: 0,
1795                name_index: 0,
1796            }),
1797        })
1798    }
1799
1800    /// Build a lazy source map from pre-parsed components.
1801    ///
1802    /// The raw VLQ mappings string is prescanned but not decoded.
1803    /// sourcesContent is NOT included. Does not support indexed source maps.
1804    pub fn from_vlq(
1805        mappings: &str,
1806        sources: Vec<String>,
1807        names: Vec<String>,
1808        file: Option<String>,
1809        source_root: Option<String>,
1810        ignore_list: Vec<u32>,
1811        debug_id: Option<String>,
1812    ) -> Result<Self, ParseError> {
1813        let source_map = build_source_map(&sources);
1814        let raw_mappings = mappings.to_string();
1815        let line_info = prescan_mappings(&raw_mappings)?;
1816
1817        Ok(Self {
1818            file,
1819            source_root,
1820            sources,
1821            sources_content: Vec::new(),
1822            names,
1823            ignore_list,
1824            extensions: HashMap::new(),
1825            debug_id,
1826            scopes: None,
1827            raw_mappings,
1828            line_info,
1829            decoded_lines: RefCell::new(HashMap::new()),
1830            source_map,
1831            fast_scan: false,
1832            decode_watermark: Cell::new(0),
1833            decode_state: Cell::new(VlqState {
1834                source_index: 0,
1835                original_line: 0,
1836                original_column: 0,
1837                name_index: 0,
1838            }),
1839        })
1840    }
1841
1842    /// Parse a source map from JSON using fast-scan mode.
1843    ///
1844    /// Only scans for semicolons at construction (no VLQ decode at all).
1845    /// VLQ state is computed progressively on demand. This gives the fastest
1846    /// possible parse time at the cost of first-lookup needing sequential decode.
1847    /// sourcesContent is skipped.
1848    ///
1849    /// Returns `ParseError::NestedIndexMap` if the JSON contains `sections`
1850    /// (indexed source maps are not supported by `LazySourceMap`).
1851    pub fn from_json_fast(json: &str) -> Result<Self, ParseError> {
1852        let raw: RawSourceMapLite<'_> = serde_json::from_str(json)?;
1853
1854        if raw.version != 3 {
1855            return Err(ParseError::InvalidVersion(raw.version));
1856        }
1857
1858        // LazySourceMap does not support indexed/sectioned source maps.
1859        // Use SourceMap::from_json() for indexed maps.
1860        if raw.sections.is_some() {
1861            return Err(ParseError::NestedIndexMap);
1862        }
1863
1864        let source_root = raw.source_root.as_deref().unwrap_or("");
1865        let sources = resolve_sources(&raw.sources, source_root);
1866        let source_map = build_source_map(&sources);
1867        let raw_mappings = raw.mappings.to_string();
1868
1869        // Fast scan: just find semicolons, no VLQ decode
1870        let line_info = fast_scan_lines(&raw_mappings);
1871
1872        let ignore_list = match raw.ignore_list {
1873            Some(list) => list,
1874            None => raw.x_google_ignore_list.unwrap_or_default(),
1875        };
1876
1877        Ok(Self {
1878            file: raw.file,
1879            source_root: raw.source_root,
1880            sources,
1881            sources_content: Vec::new(),
1882            names: raw.names,
1883            ignore_list,
1884            extensions: HashMap::new(),
1885            debug_id: raw.debug_id,
1886            scopes: None,
1887            raw_mappings,
1888            line_info,
1889            decoded_lines: RefCell::new(HashMap::new()),
1890            source_map,
1891            fast_scan: true,
1892            decode_watermark: Cell::new(0),
1893            decode_state: Cell::new(VlqState {
1894                source_index: 0,
1895                original_line: 0,
1896                original_column: 0,
1897                name_index: 0,
1898            }),
1899        })
1900    }
1901
1902    /// Decode a single line's VLQ segment into mappings, given the initial VLQ state.
1903    /// Returns the decoded mappings and the final VLQ state after this line.
1904    ///
1905    /// Uses absolute byte positions into `raw_mappings` (matching `walk_vlq_state`
1906    /// and `prescan_mappings` patterns).
1907    fn decode_line_with_state(
1908        &self,
1909        line: u32,
1910        mut state: VlqState,
1911    ) -> Result<(Vec<Mapping>, VlqState), DecodeError> {
1912        let line_idx = line as usize;
1913        if line_idx >= self.line_info.len() {
1914            return Ok((Vec::new(), state));
1915        }
1916
1917        let info = &self.line_info[line_idx];
1918        let bytes = self.raw_mappings.as_bytes();
1919        let end = info.byte_end;
1920
1921        let mut mappings = Vec::new();
1922        let mut source_index = state.source_index;
1923        let mut original_line = state.original_line;
1924        let mut original_column = state.original_column;
1925        let mut name_index = state.name_index;
1926        let mut generated_column: i64 = 0;
1927        let mut pos = info.byte_offset;
1928
1929        while pos < end {
1930            let byte = bytes[pos];
1931            if byte == b',' {
1932                pos += 1;
1933                continue;
1934            }
1935
1936            generated_column += vlq_fast(bytes, &mut pos)?;
1937
1938            if pos < end && bytes[pos] != b',' && bytes[pos] != b';' {
1939                source_index += vlq_fast(bytes, &mut pos)?;
1940                if pos >= end || bytes[pos] == b',' || bytes[pos] == b';' {
1941                    return Err(DecodeError::InvalidSegmentLength {
1942                        fields: 2,
1943                        offset: pos,
1944                    });
1945                }
1946                original_line += vlq_fast(bytes, &mut pos)?;
1947                if pos >= end || bytes[pos] == b',' || bytes[pos] == b';' {
1948                    return Err(DecodeError::InvalidSegmentLength {
1949                        fields: 3,
1950                        offset: pos,
1951                    });
1952                }
1953                original_column += vlq_fast(bytes, &mut pos)?;
1954
1955                let name = if pos < end && bytes[pos] != b',' && bytes[pos] != b';' {
1956                    name_index += vlq_fast(bytes, &mut pos)?;
1957                    name_index as u32
1958                } else {
1959                    NO_NAME
1960                };
1961
1962                mappings.push(Mapping {
1963                    generated_line: line,
1964                    generated_column: generated_column as u32,
1965                    source: source_index as u32,
1966                    original_line: original_line as u32,
1967                    original_column: original_column as u32,
1968                    name,
1969                    is_range_mapping: false,
1970                });
1971            } else {
1972                mappings.push(Mapping {
1973                    generated_line: line,
1974                    generated_column: generated_column as u32,
1975                    source: NO_SOURCE,
1976                    original_line: 0,
1977                    original_column: 0,
1978                    name: NO_NAME,
1979                    is_range_mapping: false,
1980                });
1981            }
1982        }
1983
1984        state.source_index = source_index;
1985        state.original_line = original_line;
1986        state.original_column = original_column;
1987        state.name_index = name_index;
1988        Ok((mappings, state))
1989    }
1990
1991    /// Decode a single line's mappings on demand.
1992    ///
1993    /// Returns the cached result if the line has already been decoded.
1994    /// The line index is 0-based.
1995    pub fn decode_line(&self, line: u32) -> Result<Vec<Mapping>, DecodeError> {
1996        // Check cache first
1997        if let Some(cached) = self.decoded_lines.borrow().get(&line) {
1998            return Ok(cached.clone());
1999        }
2000
2001        let line_idx = line as usize;
2002        if line_idx >= self.line_info.len() {
2003            return Ok(Vec::new());
2004        }
2005
2006        if self.fast_scan {
2007            // In fast-scan mode, VLQ state is not pre-computed.
2008            // Decode sequentially from the watermark (or line 0 for backward seeks).
2009            // For both forward and backward walks, use cached lines where available
2010            // and only walk VLQ bytes to compute state for already-decoded lines.
2011            let watermark = self.decode_watermark.get();
2012            let start = if line >= watermark { watermark } else { 0 };
2013            let mut state = if line >= watermark {
2014                self.decode_state.get()
2015            } else {
2016                VlqState {
2017                    source_index: 0,
2018                    original_line: 0,
2019                    original_column: 0,
2020                    name_index: 0,
2021                }
2022            };
2023
2024            for l in start..=line {
2025                let info = &self.line_info[l as usize];
2026                if self.decoded_lines.borrow().contains_key(&l) {
2027                    // Already cached — just walk VLQ bytes to compute end-state
2028                    let bytes = self.raw_mappings.as_bytes();
2029                    state = walk_vlq_state(bytes, info.byte_offset, info.byte_end, state)?;
2030                } else {
2031                    let (mappings, new_state) = self.decode_line_with_state(l, state)?;
2032                    state = new_state;
2033                    self.decoded_lines.borrow_mut().insert(l, mappings);
2034                }
2035            }
2036
2037            // Update watermark (only advance, never regress)
2038            if line + 1 > self.decode_watermark.get() {
2039                self.decode_watermark.set(line + 1);
2040                self.decode_state.set(state);
2041            }
2042
2043            let cached = self.decoded_lines.borrow().get(&line).cloned();
2044            return Ok(cached.unwrap_or_default());
2045        }
2046
2047        // Normal mode: line_info has pre-computed VLQ state
2048        let state = self.line_info[line_idx].state;
2049        let (mappings, _) = self.decode_line_with_state(line, state)?;
2050        self.decoded_lines
2051            .borrow_mut()
2052            .insert(line, mappings.clone());
2053        Ok(mappings)
2054    }
2055
2056    /// Look up the original source position for a generated position.
2057    ///
2058    /// Both `line` and `column` are 0-based.
2059    /// Returns `None` if no mapping exists or the mapping has no source.
2060    pub fn original_position_for(&self, line: u32, column: u32) -> Option<OriginalLocation> {
2061        let line_mappings = self.decode_line(line).ok()?;
2062
2063        if line_mappings.is_empty() {
2064            return None;
2065        }
2066
2067        // Binary search for greatest lower bound
2068        let idx = match line_mappings.binary_search_by_key(&column, |m| m.generated_column) {
2069            Ok(i) => i,
2070            Err(0) => return None,
2071            Err(i) => i - 1,
2072        };
2073
2074        let mapping = &line_mappings[idx];
2075
2076        if mapping.source == NO_SOURCE {
2077            return None;
2078        }
2079
2080        Some(OriginalLocation {
2081            source: mapping.source,
2082            line: mapping.original_line,
2083            column: mapping.original_column,
2084            name: if mapping.name == NO_NAME {
2085                None
2086            } else {
2087                Some(mapping.name)
2088            },
2089        })
2090    }
2091
2092    /// Number of generated lines in the source map.
2093    #[inline]
2094    pub fn line_count(&self) -> usize {
2095        self.line_info.len()
2096    }
2097
2098    /// Resolve a source index to its filename.
2099    ///
2100    /// # Panics
2101    ///
2102    /// Panics if `index` is out of bounds. Use [`get_source`](Self::get_source)
2103    /// for a non-panicking alternative.
2104    #[inline]
2105    pub fn source(&self, index: u32) -> &str {
2106        &self.sources[index as usize]
2107    }
2108
2109    /// Resolve a source index to its filename, returning `None` if out of bounds.
2110    #[inline]
2111    pub fn get_source(&self, index: u32) -> Option<&str> {
2112        self.sources.get(index as usize).map(|s| s.as_str())
2113    }
2114
2115    /// Resolve a name index to its string.
2116    ///
2117    /// # Panics
2118    ///
2119    /// Panics if `index` is out of bounds. Use [`get_name`](Self::get_name)
2120    /// for a non-panicking alternative.
2121    #[inline]
2122    pub fn name(&self, index: u32) -> &str {
2123        &self.names[index as usize]
2124    }
2125
2126    /// Resolve a name index to its string, returning `None` if out of bounds.
2127    #[inline]
2128    pub fn get_name(&self, index: u32) -> Option<&str> {
2129        self.names.get(index as usize).map(|s| s.as_str())
2130    }
2131
2132    /// Find the source index for a filename.
2133    #[inline]
2134    pub fn source_index(&self, name: &str) -> Option<u32> {
2135        self.source_map.get(name).copied()
2136    }
2137
2138    /// Get all mappings for a line (decoding on demand).
2139    pub fn mappings_for_line(&self, line: u32) -> Vec<Mapping> {
2140        self.decode_line(line).unwrap_or_default()
2141    }
2142
2143    /// Fully decode all mappings into a regular `SourceMap`.
2144    ///
2145    /// Useful when you need the full map after lazy exploration.
2146    pub fn into_sourcemap(self) -> Result<SourceMap, ParseError> {
2147        let (mappings, line_offsets) = decode_mappings(&self.raw_mappings)?;
2148        let has_range_mappings = mappings.iter().any(|m| m.is_range_mapping);
2149
2150        Ok(SourceMap {
2151            file: self.file,
2152            source_root: self.source_root,
2153            sources: self.sources.clone(),
2154            sources_content: self.sources_content,
2155            names: self.names,
2156            ignore_list: self.ignore_list,
2157            extensions: self.extensions,
2158            debug_id: self.debug_id,
2159            scopes: self.scopes,
2160            mappings,
2161            line_offsets,
2162            reverse_index: OnceCell::new(),
2163            source_map: self.source_map,
2164            has_range_mappings,
2165        })
2166    }
2167}
2168
2169/// Pre-scan the raw mappings string to find semicolon positions and compute
2170/// cumulative VLQ state at each line boundary.
2171fn prescan_mappings(input: &str) -> Result<Vec<LineInfo>, DecodeError> {
2172    if input.is_empty() {
2173        return Ok(Vec::new());
2174    }
2175
2176    let bytes = input.as_bytes();
2177    let len = bytes.len();
2178
2179    // Count lines for pre-allocation
2180    let line_count = bytes.iter().filter(|&&b| b == b';').count() + 1;
2181    let mut line_info: Vec<LineInfo> = Vec::with_capacity(line_count);
2182
2183    let mut source_index: i64 = 0;
2184    let mut original_line: i64 = 0;
2185    let mut original_column: i64 = 0;
2186    let mut name_index: i64 = 0;
2187    let mut pos: usize = 0;
2188
2189    loop {
2190        let line_start = pos;
2191        let state = VlqState {
2192            source_index,
2193            original_line,
2194            original_column,
2195            name_index,
2196        };
2197
2198        let mut saw_semicolon = false;
2199
2200        // Walk the VLQ data, updating cumulative state but not allocating mappings
2201        while pos < len {
2202            let byte = bytes[pos];
2203
2204            if byte == b';' {
2205                pos += 1;
2206                saw_semicolon = true;
2207                break;
2208            }
2209
2210            if byte == b',' {
2211                pos += 1;
2212                continue;
2213            }
2214
2215            // Field 1: generated column (skip value, it resets per line)
2216            vlq_fast(bytes, &mut pos)?;
2217
2218            if pos < len && bytes[pos] != b',' && bytes[pos] != b';' {
2219                // Field 2: source index
2220                source_index += vlq_fast(bytes, &mut pos)?;
2221
2222                // Reject 2-field segments (only 1, 4, or 5 are valid per ECMA-426)
2223                if pos >= len || bytes[pos] == b',' || bytes[pos] == b';' {
2224                    return Err(DecodeError::InvalidSegmentLength {
2225                        fields: 2,
2226                        offset: pos,
2227                    });
2228                }
2229
2230                // Field 3: original line
2231                original_line += vlq_fast(bytes, &mut pos)?;
2232
2233                // Reject 3-field segments (only 1, 4, or 5 are valid per ECMA-426)
2234                if pos >= len || bytes[pos] == b',' || bytes[pos] == b';' {
2235                    return Err(DecodeError::InvalidSegmentLength {
2236                        fields: 3,
2237                        offset: pos,
2238                    });
2239                }
2240
2241                // Field 4: original column
2242                original_column += vlq_fast(bytes, &mut pos)?;
2243
2244                // Field 5: name (optional)
2245                if pos < len && bytes[pos] != b',' && bytes[pos] != b';' {
2246                    name_index += vlq_fast(bytes, &mut pos)?;
2247                }
2248            }
2249        }
2250
2251        // byte_end is before the semicolon (or end of string)
2252        let byte_end = if saw_semicolon { pos - 1 } else { pos };
2253
2254        line_info.push(LineInfo {
2255            byte_offset: line_start,
2256            byte_end,
2257            state,
2258        });
2259
2260        if !saw_semicolon {
2261            break;
2262        }
2263    }
2264
2265    Ok(line_info)
2266}
2267
2268/// Walk VLQ bytes for a line to compute end state, without producing Mapping structs.
2269fn walk_vlq_state(
2270    bytes: &[u8],
2271    start: usize,
2272    end: usize,
2273    mut state: VlqState,
2274) -> Result<VlqState, DecodeError> {
2275    let mut pos = start;
2276    while pos < end {
2277        let byte = bytes[pos];
2278        if byte == b',' {
2279            pos += 1;
2280            continue;
2281        }
2282
2283        // Field 1: generated column (skip, resets per line)
2284        vlq_fast(bytes, &mut pos)?;
2285
2286        if pos < end && bytes[pos] != b',' && bytes[pos] != b';' {
2287            state.source_index += vlq_fast(bytes, &mut pos)?;
2288            if pos >= end || bytes[pos] == b',' || bytes[pos] == b';' {
2289                return Err(DecodeError::InvalidSegmentLength {
2290                    fields: 2,
2291                    offset: pos,
2292                });
2293            }
2294            state.original_line += vlq_fast(bytes, &mut pos)?;
2295            if pos >= end || bytes[pos] == b',' || bytes[pos] == b';' {
2296                return Err(DecodeError::InvalidSegmentLength {
2297                    fields: 3,
2298                    offset: pos,
2299                });
2300            }
2301            state.original_column += vlq_fast(bytes, &mut pos)?;
2302            if pos < end && bytes[pos] != b',' && bytes[pos] != b';' {
2303                state.name_index += vlq_fast(bytes, &mut pos)?;
2304            }
2305        }
2306    }
2307    Ok(state)
2308}
2309
2310/// Fast scan: single-pass scan to find semicolons and record line byte offsets.
2311/// No VLQ decoding at all. VlqState is zeroed — must be computed progressively.
2312fn fast_scan_lines(input: &str) -> Vec<LineInfo> {
2313    if input.is_empty() {
2314        return Vec::new();
2315    }
2316
2317    let bytes = input.as_bytes();
2318    let len = bytes.len();
2319    let zero_state = VlqState {
2320        source_index: 0,
2321        original_line: 0,
2322        original_column: 0,
2323        name_index: 0,
2324    };
2325
2326    // Single pass: grow dynamically instead of double-scanning for semicolon count
2327    let mut line_info = Vec::new();
2328    let mut pos = 0;
2329    loop {
2330        let line_start = pos;
2331
2332        // Scan to next semicolon or end of string
2333        while pos < len && bytes[pos] != b';' {
2334            pos += 1;
2335        }
2336
2337        line_info.push(LineInfo {
2338            byte_offset: line_start,
2339            byte_end: pos,
2340            state: zero_state, // Will be computed progressively on demand
2341        });
2342
2343        if pos >= len {
2344            break;
2345        }
2346        pos += 1; // skip ';'
2347    }
2348
2349    line_info
2350}
2351
2352/// Result of parsing a sourceMappingURL reference.
2353#[derive(Debug, Clone, PartialEq, Eq)]
2354pub enum SourceMappingUrl {
2355    /// An inline base64 data URI containing the source map JSON.
2356    Inline(String),
2357    /// An external URL or relative path to the source map file.
2358    External(String),
2359}
2360
2361/// Extract the sourceMappingURL from generated source code.
2362///
2363/// Looks for `//# sourceMappingURL=<url>` or `//@ sourceMappingURL=<url>` comments.
2364/// For inline data URIs (`data:application/json;base64,...`), decodes the base64 content.
2365/// Returns `None` if no sourceMappingURL is found.
2366pub fn parse_source_mapping_url(source: &str) -> Option<SourceMappingUrl> {
2367    // Search backwards from the end (sourceMappingURL is typically the last line)
2368    for line in source.lines().rev() {
2369        let trimmed = line.trim();
2370        let url = if let Some(rest) = trimmed.strip_prefix("//# sourceMappingURL=") {
2371            rest.trim()
2372        } else if let Some(rest) = trimmed.strip_prefix("//@ sourceMappingURL=") {
2373            rest.trim()
2374        } else if let Some(rest) = trimmed.strip_prefix("/*# sourceMappingURL=") {
2375            rest.trim_end_matches("*/").trim()
2376        } else if let Some(rest) = trimmed.strip_prefix("/*@ sourceMappingURL=") {
2377            rest.trim_end_matches("*/").trim()
2378        } else {
2379            continue;
2380        };
2381
2382        if url.is_empty() {
2383            continue;
2384        }
2385
2386        // Check for inline data URI
2387        if let Some(base64_data) = url
2388            .strip_prefix("data:application/json;base64,")
2389            .or_else(|| url.strip_prefix("data:application/json;charset=utf-8;base64,"))
2390            .or_else(|| url.strip_prefix("data:application/json;charset=UTF-8;base64,"))
2391        {
2392            // Decode base64
2393            let decoded = base64_decode(base64_data);
2394            if let Some(json) = decoded {
2395                return Some(SourceMappingUrl::Inline(json));
2396            }
2397        }
2398
2399        return Some(SourceMappingUrl::External(url.to_string()));
2400    }
2401
2402    None
2403}
2404
2405/// Simple base64 decoder (no dependencies).
2406/// Decode percent-encoded strings (e.g. `%7B` → `{`).
2407fn percent_decode(input: &str) -> String {
2408    let mut output = Vec::with_capacity(input.len());
2409    let bytes = input.as_bytes();
2410    let mut i = 0;
2411    while i < bytes.len() {
2412        if bytes[i] == b'%'
2413            && i + 2 < bytes.len()
2414            && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
2415        {
2416            output.push((hi << 4) | lo);
2417            i += 3;
2418            continue;
2419        }
2420        output.push(bytes[i]);
2421        i += 1;
2422    }
2423    String::from_utf8(output).unwrap_or_else(|_| input.to_string())
2424}
2425
2426fn hex_val(b: u8) -> Option<u8> {
2427    match b {
2428        b'0'..=b'9' => Some(b - b'0'),
2429        b'a'..=b'f' => Some(b - b'a' + 10),
2430        b'A'..=b'F' => Some(b - b'A' + 10),
2431        _ => None,
2432    }
2433}
2434
2435fn base64_decode(input: &str) -> Option<String> {
2436    let input = input.trim();
2437    let bytes: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
2438
2439    let mut output = Vec::with_capacity(bytes.len() * 3 / 4);
2440
2441    for chunk in bytes.chunks(4) {
2442        let mut buf = [0u8; 4];
2443        let mut len = 0;
2444
2445        for &b in chunk {
2446            if b == b'=' {
2447                break;
2448            }
2449            let val = match b {
2450                b'A'..=b'Z' => b - b'A',
2451                b'a'..=b'z' => b - b'a' + 26,
2452                b'0'..=b'9' => b - b'0' + 52,
2453                b'+' => 62,
2454                b'/' => 63,
2455                _ => return None,
2456            };
2457            buf[len] = val;
2458            len += 1;
2459        }
2460
2461        if len >= 2 {
2462            output.push((buf[0] << 2) | (buf[1] >> 4));
2463        }
2464        if len >= 3 {
2465            output.push((buf[1] << 4) | (buf[2] >> 2));
2466        }
2467        if len >= 4 {
2468            output.push((buf[2] << 6) | buf[3]);
2469        }
2470    }
2471
2472    String::from_utf8(output).ok()
2473}
2474
2475/// Validate a source map with deep structural checks.
2476///
2477/// Performs bounds checking, segment ordering verification, source resolution,
2478/// and unreferenced sources detection beyond basic JSON parsing.
2479pub fn validate_deep(sm: &SourceMap) -> Vec<String> {
2480    let mut warnings = Vec::new();
2481
2482    // Check segment ordering (must be sorted by generated position)
2483    let mut prev_line: u32 = 0;
2484    let mut prev_col: u32 = 0;
2485    let mappings = sm.all_mappings();
2486    for m in mappings {
2487        if m.generated_line < prev_line
2488            || (m.generated_line == prev_line && m.generated_column < prev_col)
2489        {
2490            warnings.push(format!(
2491                "mappings out of order at {}:{}",
2492                m.generated_line, m.generated_column
2493            ));
2494        }
2495        prev_line = m.generated_line;
2496        prev_col = m.generated_column;
2497    }
2498
2499    // Check source indices in bounds
2500    for m in mappings {
2501        if m.source != NO_SOURCE && m.source as usize >= sm.sources.len() {
2502            warnings.push(format!(
2503                "source index {} out of bounds (max {})",
2504                m.source,
2505                sm.sources.len()
2506            ));
2507        }
2508        if m.name != NO_NAME && m.name as usize >= sm.names.len() {
2509            warnings.push(format!(
2510                "name index {} out of bounds (max {})",
2511                m.name,
2512                sm.names.len()
2513            ));
2514        }
2515    }
2516
2517    // Check ignoreList indices in bounds
2518    for &idx in &sm.ignore_list {
2519        if idx as usize >= sm.sources.len() {
2520            warnings.push(format!(
2521                "ignoreList index {} out of bounds (max {})",
2522                idx,
2523                sm.sources.len()
2524            ));
2525        }
2526    }
2527
2528    // Detect unreferenced sources
2529    let mut referenced_sources = std::collections::HashSet::new();
2530    for m in mappings {
2531        if m.source != NO_SOURCE {
2532            referenced_sources.insert(m.source);
2533        }
2534    }
2535    for (i, source) in sm.sources.iter().enumerate() {
2536        if !referenced_sources.contains(&(i as u32)) {
2537            warnings.push(format!("source \"{source}\" (index {i}) is unreferenced"));
2538        }
2539    }
2540
2541    warnings
2542}
2543
2544/// Append a JSON-quoted string to the output buffer.
2545fn json_quote_into(out: &mut String, s: &str) {
2546    let bytes = s.as_bytes();
2547    out.push('"');
2548
2549    let mut start = 0;
2550    for (i, &b) in bytes.iter().enumerate() {
2551        let escape = match b {
2552            b'"' => "\\\"",
2553            b'\\' => "\\\\",
2554            b'\n' => "\\n",
2555            b'\r' => "\\r",
2556            b'\t' => "\\t",
2557            // Remaining control chars (excluding \n, \r, \t handled above)
2558            0x00..=0x08 | 0x0b | 0x0c | 0x0e..=0x1f => {
2559                if start < i {
2560                    out.push_str(&s[start..i]);
2561                }
2562                use std::fmt::Write;
2563                let _ = write!(out, "\\u{:04x}", b);
2564                start = i + 1;
2565                continue;
2566            }
2567            _ => continue,
2568        };
2569        if start < i {
2570            out.push_str(&s[start..i]);
2571        }
2572        out.push_str(escape);
2573        start = i + 1;
2574    }
2575
2576    if start < bytes.len() {
2577        out.push_str(&s[start..]);
2578    }
2579
2580    out.push('"');
2581}
2582
2583// ── Internal: decode VLQ mappings directly into flat Mapping vec ───
2584
2585/// Base64 decode lookup table (byte → 6-bit value, 0xFF = invalid).
2586const B64: [u8; 128] = {
2587    let mut table = [0xFFu8; 128];
2588    let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2589    let mut i = 0u8;
2590    while i < 64 {
2591        table[chars[i as usize] as usize] = i;
2592        i += 1;
2593    }
2594    table
2595};
2596
2597/// Inline VLQ decode optimized for the hot path (no function call overhead).
2598/// Most source map VLQ values fit in 1-2 base64 characters.
2599#[inline(always)]
2600fn vlq_fast(bytes: &[u8], pos: &mut usize) -> Result<i64, DecodeError> {
2601    let p = *pos;
2602    if p >= bytes.len() {
2603        return Err(DecodeError::UnexpectedEof { offset: p });
2604    }
2605
2606    let b0 = bytes[p];
2607    if b0 >= 128 {
2608        return Err(DecodeError::InvalidBase64 {
2609            byte: b0,
2610            offset: p,
2611        });
2612    }
2613    let d0 = B64[b0 as usize];
2614    if d0 == 0xFF {
2615        return Err(DecodeError::InvalidBase64 {
2616            byte: b0,
2617            offset: p,
2618        });
2619    }
2620
2621    // Fast path: single character VLQ (values -15..15)
2622    if (d0 & 0x20) == 0 {
2623        *pos = p + 1;
2624        let val = (d0 >> 1) as i64;
2625        return Ok(if (d0 & 1) != 0 { -val } else { val });
2626    }
2627
2628    // Multi-character VLQ
2629    let mut result: u64 = (d0 & 0x1F) as u64;
2630    let mut shift: u32 = 5;
2631    let mut i = p + 1;
2632
2633    loop {
2634        if i >= bytes.len() {
2635            return Err(DecodeError::UnexpectedEof { offset: i });
2636        }
2637        let b = bytes[i];
2638        if b >= 128 {
2639            return Err(DecodeError::InvalidBase64 { byte: b, offset: i });
2640        }
2641        let d = B64[b as usize];
2642        if d == 0xFF {
2643            return Err(DecodeError::InvalidBase64 { byte: b, offset: i });
2644        }
2645        i += 1;
2646
2647        if shift >= 60 {
2648            return Err(DecodeError::VlqOverflow { offset: p });
2649        }
2650
2651        result += ((d & 0x1F) as u64) << shift;
2652        shift += 5;
2653
2654        if (d & 0x20) == 0 {
2655            break;
2656        }
2657    }
2658
2659    *pos = i;
2660    let value = if (result & 1) == 1 {
2661        -((result >> 1) as i64)
2662    } else {
2663        (result >> 1) as i64
2664    };
2665    Ok(value)
2666}
2667
2668#[inline(always)]
2669fn vlq_unsigned_fast(bytes: &[u8], pos: &mut usize) -> Result<u64, DecodeError> {
2670    let p = *pos;
2671    if p >= bytes.len() {
2672        return Err(DecodeError::UnexpectedEof { offset: p });
2673    }
2674    let b0 = bytes[p];
2675    if b0 >= 128 {
2676        return Err(DecodeError::InvalidBase64 {
2677            byte: b0,
2678            offset: p,
2679        });
2680    }
2681    let d0 = B64[b0 as usize];
2682    if d0 == 0xFF {
2683        return Err(DecodeError::InvalidBase64 {
2684            byte: b0,
2685            offset: p,
2686        });
2687    }
2688    if (d0 & 0x20) == 0 {
2689        *pos = p + 1;
2690        return Ok(d0 as u64);
2691    }
2692    let mut result: u64 = (d0 & 0x1F) as u64;
2693    let mut shift: u32 = 5;
2694    let mut i = p + 1;
2695    loop {
2696        if i >= bytes.len() {
2697            return Err(DecodeError::UnexpectedEof { offset: i });
2698        }
2699        let b = bytes[i];
2700        if b >= 128 {
2701            return Err(DecodeError::InvalidBase64 { byte: b, offset: i });
2702        }
2703        let d = B64[b as usize];
2704        if d == 0xFF {
2705            return Err(DecodeError::InvalidBase64 { byte: b, offset: i });
2706        }
2707        i += 1;
2708        if shift >= 60 {
2709            return Err(DecodeError::VlqOverflow { offset: p });
2710        }
2711        result |= ((d & 0x1F) as u64) << shift;
2712        shift += 5;
2713        if (d & 0x20) == 0 {
2714            break;
2715        }
2716    }
2717    *pos = i;
2718    Ok(result)
2719}
2720
2721fn decode_range_mappings(
2722    input: &str,
2723    mappings: &mut [Mapping],
2724    line_offsets: &[u32],
2725) -> Result<(), DecodeError> {
2726    let bytes = input.as_bytes();
2727    let len = bytes.len();
2728    let mut pos: usize = 0;
2729    let mut generated_line: usize = 0;
2730    while pos < len {
2731        let line_start = if generated_line + 1 < line_offsets.len() {
2732            line_offsets[generated_line] as usize
2733        } else {
2734            break;
2735        };
2736        // Bound range marking to this line's mappings only
2737        let line_end = if generated_line + 2 < line_offsets.len() {
2738            line_offsets[generated_line + 1] as usize
2739        } else {
2740            mappings.len()
2741        };
2742        let mut mapping_index: u64 = 0;
2743        while pos < len {
2744            let byte = bytes[pos];
2745            if byte == b';' {
2746                pos += 1;
2747                break;
2748            }
2749            if byte == b',' {
2750                pos += 1;
2751                continue;
2752            }
2753            let offset = vlq_unsigned_fast(bytes, &mut pos)?;
2754            mapping_index += offset;
2755            let abs_idx = line_start + mapping_index as usize;
2756            if abs_idx < line_end {
2757                mappings[abs_idx].is_range_mapping = true;
2758            }
2759        }
2760        generated_line += 1;
2761    }
2762    Ok(())
2763}
2764
2765fn decode_mappings(input: &str) -> Result<(Vec<Mapping>, Vec<u32>), DecodeError> {
2766    if input.is_empty() {
2767        return Ok((Vec::new(), vec![0]));
2768    }
2769
2770    let bytes = input.as_bytes();
2771    let len = bytes.len();
2772
2773    // Pre-count lines and segments in a single pass for capacity hints
2774    let mut semicolons = 0usize;
2775    let mut commas = 0usize;
2776    for &b in bytes {
2777        semicolons += (b == b';') as usize;
2778        commas += (b == b',') as usize;
2779    }
2780    let line_count = semicolons + 1;
2781    let approx_segments = commas + line_count;
2782
2783    let mut mappings: Vec<Mapping> = Vec::with_capacity(approx_segments);
2784    let mut line_offsets: Vec<u32> = Vec::with_capacity(line_count + 1);
2785
2786    let mut source_index: i64 = 0;
2787    let mut original_line: i64 = 0;
2788    let mut original_column: i64 = 0;
2789    let mut name_index: i64 = 0;
2790    let mut generated_line: u32 = 0;
2791    let mut pos: usize = 0;
2792
2793    loop {
2794        line_offsets.push(mappings.len() as u32);
2795        let mut generated_column: i64 = 0;
2796        let mut saw_semicolon = false;
2797
2798        while pos < len {
2799            let byte = bytes[pos];
2800
2801            if byte == b';' {
2802                pos += 1;
2803                saw_semicolon = true;
2804                break;
2805            }
2806
2807            if byte == b',' {
2808                pos += 1;
2809                continue;
2810            }
2811
2812            // Field 1: generated column
2813            generated_column += vlq_fast(bytes, &mut pos)?;
2814
2815            if pos < len && bytes[pos] != b',' && bytes[pos] != b';' {
2816                // Field 2: source index
2817                source_index += vlq_fast(bytes, &mut pos)?;
2818
2819                // Reject 2-field segments (only 1, 4, or 5 are valid per ECMA-426)
2820                if pos >= len || bytes[pos] == b',' || bytes[pos] == b';' {
2821                    return Err(DecodeError::InvalidSegmentLength {
2822                        fields: 2,
2823                        offset: pos,
2824                    });
2825                }
2826
2827                // Field 3: original line
2828                original_line += vlq_fast(bytes, &mut pos)?;
2829
2830                // Reject 3-field segments (only 1, 4, or 5 are valid per ECMA-426)
2831                if pos >= len || bytes[pos] == b',' || bytes[pos] == b';' {
2832                    return Err(DecodeError::InvalidSegmentLength {
2833                        fields: 3,
2834                        offset: pos,
2835                    });
2836                }
2837
2838                // Field 4: original column
2839                original_column += vlq_fast(bytes, &mut pos)?;
2840
2841                // Field 5: name (optional)
2842                let name = if pos < len && bytes[pos] != b',' && bytes[pos] != b';' {
2843                    name_index += vlq_fast(bytes, &mut pos)?;
2844                    name_index as u32
2845                } else {
2846                    NO_NAME
2847                };
2848
2849                mappings.push(Mapping {
2850                    generated_line,
2851                    generated_column: generated_column as u32,
2852                    source: source_index as u32,
2853                    original_line: original_line as u32,
2854                    original_column: original_column as u32,
2855                    name,
2856                    is_range_mapping: false,
2857                });
2858            } else {
2859                // 1-field segment: no source info
2860                mappings.push(Mapping {
2861                    generated_line,
2862                    generated_column: generated_column as u32,
2863                    source: NO_SOURCE,
2864                    original_line: 0,
2865                    original_column: 0,
2866                    name: NO_NAME,
2867                    is_range_mapping: false,
2868                });
2869            }
2870        }
2871
2872        if !saw_semicolon {
2873            break;
2874        }
2875        generated_line += 1;
2876    }
2877
2878    // Sentinel for line range computation
2879    line_offsets.push(mappings.len() as u32);
2880
2881    Ok((mappings, line_offsets))
2882}
2883
2884/// Decode VLQ mappings for a subset of lines `[start_line, end_line)`.
2885///
2886/// Walks VLQ state for all lines up to `end_line`, but only allocates Mapping
2887/// structs for lines in the requested range. The returned `line_offsets` is
2888/// indexed by the actual generated line number (not relative to start_line),
2889/// so that `mappings_for_line(line)` works correctly with the real line values.
2890fn decode_mappings_range(
2891    input: &str,
2892    start_line: u32,
2893    end_line: u32,
2894) -> Result<(Vec<Mapping>, Vec<u32>), DecodeError> {
2895    // Cap end_line against actual line count to prevent OOM on pathological input.
2896    // Count semicolons to determine actual line count.
2897    let actual_lines = if input.is_empty() {
2898        0u32
2899    } else {
2900        input.as_bytes().iter().filter(|&&b| b == b';').count() as u32 + 1
2901    };
2902    let end_line = end_line.min(actual_lines);
2903
2904    if input.is_empty() || start_line >= end_line {
2905        return Ok((Vec::new(), vec![0; end_line as usize + 1]));
2906    }
2907
2908    let bytes = input.as_bytes();
2909    let len = bytes.len();
2910
2911    let mut mappings: Vec<Mapping> = Vec::new();
2912
2913    let mut source_index: i64 = 0;
2914    let mut original_line: i64 = 0;
2915    let mut original_column: i64 = 0;
2916    let mut name_index: i64 = 0;
2917    let mut generated_line: u32 = 0;
2918    let mut pos: usize = 0;
2919
2920    // Track which line each mapping starts at, so we can build line_offsets after
2921    // We use a vec of (line, mapping_start_index) pairs for lines in range
2922    let mut line_starts: Vec<(u32, u32)> = Vec::new();
2923
2924    loop {
2925        let in_range = generated_line >= start_line && generated_line < end_line;
2926        if in_range {
2927            line_starts.push((generated_line, mappings.len() as u32));
2928        }
2929
2930        let mut generated_column: i64 = 0;
2931        let mut saw_semicolon = false;
2932
2933        while pos < len {
2934            let byte = bytes[pos];
2935
2936            if byte == b';' {
2937                pos += 1;
2938                saw_semicolon = true;
2939                break;
2940            }
2941
2942            if byte == b',' {
2943                pos += 1;
2944                continue;
2945            }
2946
2947            // Field 1: generated column
2948            generated_column += vlq_fast(bytes, &mut pos)?;
2949
2950            if pos < len && bytes[pos] != b',' && bytes[pos] != b';' {
2951                // Field 2: source index
2952                source_index += vlq_fast(bytes, &mut pos)?;
2953
2954                // Reject 2-field segments (only 1, 4, or 5 are valid per ECMA-426)
2955                if pos >= len || bytes[pos] == b',' || bytes[pos] == b';' {
2956                    return Err(DecodeError::InvalidSegmentLength {
2957                        fields: 2,
2958                        offset: pos,
2959                    });
2960                }
2961
2962                // Field 3: original line
2963                original_line += vlq_fast(bytes, &mut pos)?;
2964
2965                // Reject 3-field segments (only 1, 4, or 5 are valid per ECMA-426)
2966                if pos >= len || bytes[pos] == b',' || bytes[pos] == b';' {
2967                    return Err(DecodeError::InvalidSegmentLength {
2968                        fields: 3,
2969                        offset: pos,
2970                    });
2971                }
2972
2973                // Field 4: original column
2974                original_column += vlq_fast(bytes, &mut pos)?;
2975
2976                // Field 5: name (optional)
2977                let name = if pos < len && bytes[pos] != b',' && bytes[pos] != b';' {
2978                    name_index += vlq_fast(bytes, &mut pos)?;
2979                    name_index as u32
2980                } else {
2981                    NO_NAME
2982                };
2983
2984                if in_range {
2985                    mappings.push(Mapping {
2986                        generated_line,
2987                        generated_column: generated_column as u32,
2988                        source: source_index as u32,
2989                        original_line: original_line as u32,
2990                        original_column: original_column as u32,
2991                        name,
2992                        is_range_mapping: false,
2993                    });
2994                }
2995            } else {
2996                // 1-field segment: no source info
2997                if in_range {
2998                    mappings.push(Mapping {
2999                        generated_line,
3000                        generated_column: generated_column as u32,
3001                        source: NO_SOURCE,
3002                        original_line: 0,
3003                        original_column: 0,
3004                        name: NO_NAME,
3005                        is_range_mapping: false,
3006                    });
3007                }
3008            }
3009        }
3010
3011        if !saw_semicolon {
3012            break;
3013        }
3014        generated_line += 1;
3015
3016        // Stop early once we've passed end_line
3017        if generated_line >= end_line {
3018            break;
3019        }
3020    }
3021
3022    // Build line_offsets indexed by actual line number.
3023    // Size = end_line + 1 so that line_offsets[line] and line_offsets[line+1] both exist
3024    // for any line < end_line.
3025    let total = mappings.len() as u32;
3026    let mut line_offsets: Vec<u32> = vec![total; end_line as usize + 1];
3027
3028    // Fill from our recorded line starts (in reverse so gaps get forward-filled correctly)
3029    // First set lines before start_line to 0 (they have no mappings, and 0..0 = empty)
3030    for i in 0..=start_line as usize {
3031        if i < line_offsets.len() {
3032            line_offsets[i] = 0;
3033        }
3034    }
3035
3036    // Set each recorded line start
3037    for &(line, offset) in &line_starts {
3038        line_offsets[line as usize] = offset;
3039    }
3040
3041    // Forward-fill gaps within the range: if a line in [start_line, end_line) wasn't
3042    // in line_starts, it should point to the same offset as the next line with mappings.
3043    // We do a backward pass: for lines not explicitly set, they should get the offset
3044    // of the next line (which means "empty line" since start == end for that range).
3045    // Actually, the approach used in decode_mappings is forward: each line offset is
3046    // set when we encounter it. Lines not encountered get the sentinel (total).
3047    // But we also need lines before start_line to be "empty" (start == end).
3048    // Since lines < start_line all have offset 0 and line start_line also starts at 0
3049    // (or wherever the first mapping is), lines before start_line correctly have 0..0 = empty.
3050
3051    // However, there's a subtlety: if start_line has no mappings (empty line in range),
3052    // it would have been set to 0 by line_starts but then line_offsets[start_line+1]
3053    // might also be 0, making an empty range. Let's just ensure the forward-fill works:
3054    // lines within range that have no mappings should have the same offset as the next
3055    // line's start.
3056
3057    // Actually the simplest correct approach: walk forward from start_line to end_line.
3058    // For each line not in line_starts, set it to the value of the previous line's end
3059    // (which is the current mapping count at that point).
3060    // We already recorded line_starts in order, so let's use them directly.
3061
3062    // Reset line_offsets for lines in [start_line, end_line] to sentinel
3063    for i in start_line as usize..=end_line as usize {
3064        if i < line_offsets.len() {
3065            line_offsets[i] = total;
3066        }
3067    }
3068
3069    // Set recorded line starts
3070    for &(line, offset) in &line_starts {
3071        line_offsets[line as usize] = offset;
3072    }
3073
3074    // Forward-fill: lines in the range that weren't recorded should get
3075    // the offset of the next line (so they appear empty)
3076    // Walk backward from end_line to start_line
3077    let mut next_offset = total;
3078    for i in (start_line as usize..end_line as usize).rev() {
3079        if line_offsets[i] == total {
3080            // This line wasn't in the input at all, point to next_offset
3081            line_offsets[i] = next_offset;
3082        } else {
3083            next_offset = line_offsets[i];
3084        }
3085    }
3086
3087    // Lines before start_line should all be empty.
3088    // Make consecutive lines point to 0 so start == end == 0.
3089    for offset in line_offsets.iter_mut().take(start_line as usize) {
3090        *offset = 0;
3091    }
3092
3093    Ok((mappings, line_offsets))
3094}
3095
3096/// Build reverse index: mapping indices sorted by (source, original_line, original_column).
3097fn build_reverse_index(mappings: &[Mapping]) -> Vec<u32> {
3098    let mut indices: Vec<u32> = (0..mappings.len() as u32)
3099        .filter(|&i| mappings[i as usize].source != NO_SOURCE)
3100        .collect();
3101
3102    indices.sort_unstable_by(|&a, &b| {
3103        let ma = &mappings[a as usize];
3104        let mb = &mappings[b as usize];
3105        ma.source
3106            .cmp(&mb.source)
3107            .then(ma.original_line.cmp(&mb.original_line))
3108            .then(ma.original_column.cmp(&mb.original_column))
3109            .then(ma.generated_line.cmp(&mb.generated_line))
3110            .then(ma.generated_column.cmp(&mb.generated_column))
3111    });
3112
3113    indices
3114}
3115
3116// ── Streaming iterator ────────────────────────────────────────────
3117
3118/// Lazy iterator over VLQ-encoded source map mappings.
3119///
3120/// Decodes one mapping at a time without allocating a full `Vec<Mapping>`.
3121/// Useful for streaming composition pipelines where intermediate allocation
3122/// is undesirable.
3123///
3124/// # Examples
3125///
3126/// ```
3127/// use srcmap_sourcemap::MappingsIter;
3128///
3129/// let vlq = "AAAA;AACA,EAAA;AACA";
3130/// let mappings: Vec<_> = MappingsIter::new(vlq).collect::<Result<_, _>>().unwrap();
3131/// assert_eq!(mappings.len(), 4);
3132/// assert_eq!(mappings[0].generated_line, 0);
3133/// assert_eq!(mappings[1].generated_line, 1);
3134/// ```
3135pub struct MappingsIter<'a> {
3136    bytes: &'a [u8],
3137    len: usize,
3138    pos: usize,
3139    source_index: i64,
3140    original_line: i64,
3141    original_column: i64,
3142    name_index: i64,
3143    generated_line: u32,
3144    generated_column: i64,
3145    done: bool,
3146}
3147
3148impl<'a> MappingsIter<'a> {
3149    /// Create a new iterator over VLQ-encoded mappings.
3150    pub fn new(vlq: &'a str) -> Self {
3151        let bytes = vlq.as_bytes();
3152        Self {
3153            bytes,
3154            len: bytes.len(),
3155            pos: 0,
3156            source_index: 0,
3157            original_line: 0,
3158            original_column: 0,
3159            name_index: 0,
3160            generated_line: 0,
3161            generated_column: 0,
3162            done: false,
3163        }
3164    }
3165}
3166
3167impl Iterator for MappingsIter<'_> {
3168    type Item = Result<Mapping, DecodeError>;
3169
3170    fn next(&mut self) -> Option<Self::Item> {
3171        if self.done {
3172            return None;
3173        }
3174
3175        loop {
3176            if self.pos >= self.len {
3177                self.done = true;
3178                return None;
3179            }
3180
3181            let byte = self.bytes[self.pos];
3182
3183            if byte == b';' {
3184                self.pos += 1;
3185                self.generated_line += 1;
3186                self.generated_column = 0;
3187                continue;
3188            }
3189
3190            if byte == b',' {
3191                self.pos += 1;
3192                continue;
3193            }
3194
3195            // Field 1: generated column
3196            match vlq_fast(self.bytes, &mut self.pos) {
3197                Ok(delta) => self.generated_column += delta,
3198                Err(e) => {
3199                    self.done = true;
3200                    return Some(Err(e));
3201                }
3202            }
3203
3204            if self.pos < self.len && self.bytes[self.pos] != b',' && self.bytes[self.pos] != b';' {
3205                // Field 2: source index
3206                match vlq_fast(self.bytes, &mut self.pos) {
3207                    Ok(delta) => self.source_index += delta,
3208                    Err(e) => {
3209                        self.done = true;
3210                        return Some(Err(e));
3211                    }
3212                }
3213                // Reject 2-field segments (only 1, 4, or 5 are valid per ECMA-426)
3214                if self.pos >= self.len
3215                    || self.bytes[self.pos] == b','
3216                    || self.bytes[self.pos] == b';'
3217                {
3218                    self.done = true;
3219                    return Some(Err(DecodeError::InvalidSegmentLength {
3220                        fields: 2,
3221                        offset: self.pos,
3222                    }));
3223                }
3224                // Field 3: original line
3225                match vlq_fast(self.bytes, &mut self.pos) {
3226                    Ok(delta) => self.original_line += delta,
3227                    Err(e) => {
3228                        self.done = true;
3229                        return Some(Err(e));
3230                    }
3231                }
3232                // Reject 3-field segments (only 1, 4, or 5 are valid per ECMA-426)
3233                if self.pos >= self.len
3234                    || self.bytes[self.pos] == b','
3235                    || self.bytes[self.pos] == b';'
3236                {
3237                    self.done = true;
3238                    return Some(Err(DecodeError::InvalidSegmentLength {
3239                        fields: 3,
3240                        offset: self.pos,
3241                    }));
3242                }
3243                // Field 4: original column
3244                match vlq_fast(self.bytes, &mut self.pos) {
3245                    Ok(delta) => self.original_column += delta,
3246                    Err(e) => {
3247                        self.done = true;
3248                        return Some(Err(e));
3249                    }
3250                }
3251
3252                // Field 5: name (optional)
3253                let name = if self.pos < self.len
3254                    && self.bytes[self.pos] != b','
3255                    && self.bytes[self.pos] != b';'
3256                {
3257                    match vlq_fast(self.bytes, &mut self.pos) {
3258                        Ok(delta) => {
3259                            self.name_index += delta;
3260                            self.name_index as u32
3261                        }
3262                        Err(e) => {
3263                            self.done = true;
3264                            return Some(Err(e));
3265                        }
3266                    }
3267                } else {
3268                    NO_NAME
3269                };
3270
3271                return Some(Ok(Mapping {
3272                    generated_line: self.generated_line,
3273                    generated_column: self.generated_column as u32,
3274                    source: self.source_index as u32,
3275                    original_line: self.original_line as u32,
3276                    original_column: self.original_column as u32,
3277                    name,
3278                    is_range_mapping: false,
3279                }));
3280            } else {
3281                // 1-field segment: no source info
3282                return Some(Ok(Mapping {
3283                    generated_line: self.generated_line,
3284                    generated_column: self.generated_column as u32,
3285                    source: NO_SOURCE,
3286                    original_line: 0,
3287                    original_column: 0,
3288                    name: NO_NAME,
3289                    is_range_mapping: false,
3290                }));
3291            }
3292        }
3293    }
3294}
3295
3296// ── Builder ────────────────────────────────────────────────────────
3297
3298/// Builder for incrementally constructing a [`SourceMap`] from iterators.
3299///
3300/// Avoids the need to pre-collect sources, names, and mappings into `Vec`s.
3301/// Delegates to [`SourceMap::from_parts`] internally.
3302pub struct SourceMapBuilder {
3303    file: Option<String>,
3304    source_root: Option<String>,
3305    sources: Vec<String>,
3306    sources_content: Vec<Option<String>>,
3307    names: Vec<String>,
3308    mappings: Vec<Mapping>,
3309    ignore_list: Vec<u32>,
3310    debug_id: Option<String>,
3311    scopes: Option<ScopeInfo>,
3312}
3313
3314impl SourceMapBuilder {
3315    pub fn new() -> Self {
3316        Self {
3317            file: None,
3318            source_root: None,
3319            sources: Vec::new(),
3320            sources_content: Vec::new(),
3321            names: Vec::new(),
3322            mappings: Vec::new(),
3323            ignore_list: Vec::new(),
3324            debug_id: None,
3325            scopes: None,
3326        }
3327    }
3328
3329    pub fn file(mut self, file: impl Into<String>) -> Self {
3330        self.file = Some(file.into());
3331        self
3332    }
3333
3334    pub fn source_root(mut self, root: impl Into<String>) -> Self {
3335        self.source_root = Some(root.into());
3336        self
3337    }
3338
3339    pub fn sources(mut self, sources: impl IntoIterator<Item = impl Into<String>>) -> Self {
3340        self.sources = sources.into_iter().map(Into::into).collect();
3341        self
3342    }
3343
3344    pub fn sources_content(
3345        mut self,
3346        content: impl IntoIterator<Item = Option<impl Into<String>>>,
3347    ) -> Self {
3348        self.sources_content = content.into_iter().map(|c| c.map(Into::into)).collect();
3349        self
3350    }
3351
3352    pub fn names(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
3353        self.names = names.into_iter().map(Into::into).collect();
3354        self
3355    }
3356
3357    pub fn mappings(mut self, mappings: impl IntoIterator<Item = Mapping>) -> Self {
3358        self.mappings = mappings.into_iter().collect();
3359        self
3360    }
3361
3362    pub fn ignore_list(mut self, list: impl IntoIterator<Item = u32>) -> Self {
3363        self.ignore_list = list.into_iter().collect();
3364        self
3365    }
3366
3367    pub fn debug_id(mut self, id: impl Into<String>) -> Self {
3368        self.debug_id = Some(id.into());
3369        self
3370    }
3371
3372    pub fn scopes(mut self, scopes: ScopeInfo) -> Self {
3373        self.scopes = Some(scopes);
3374        self
3375    }
3376
3377    /// Consume the builder and produce a [`SourceMap`].
3378    ///
3379    /// Mappings must be sorted by (generated_line, generated_column).
3380    pub fn build(self) -> SourceMap {
3381        SourceMap::from_parts(
3382            self.file,
3383            self.source_root,
3384            self.sources,
3385            self.sources_content,
3386            self.names,
3387            self.mappings,
3388            self.ignore_list,
3389            self.debug_id,
3390            self.scopes,
3391        )
3392    }
3393}
3394
3395impl Default for SourceMapBuilder {
3396    fn default() -> Self {
3397        Self::new()
3398    }
3399}
3400
3401// ── Tests ──────────────────────────────────────────────────────────
3402
3403#[cfg(test)]
3404mod tests {
3405    use super::*;
3406
3407    fn simple_map() -> &'static str {
3408        r#"{"version":3,"sources":["input.js"],"names":["hello"],"mappings":"AAAA;AACA,EAAA;AACA"}"#
3409    }
3410
3411    #[test]
3412    fn parse_basic() {
3413        let sm = SourceMap::from_json(simple_map()).unwrap();
3414        assert_eq!(sm.sources, vec!["input.js"]);
3415        assert_eq!(sm.names, vec!["hello"]);
3416        assert_eq!(sm.line_count(), 3);
3417        assert!(sm.mapping_count() > 0);
3418    }
3419
3420    #[test]
3421    fn to_json_roundtrip() {
3422        let json = simple_map();
3423        let sm = SourceMap::from_json(json).unwrap();
3424        let output = sm.to_json();
3425
3426        // Parse the output back and verify it produces identical lookups
3427        let sm2 = SourceMap::from_json(&output).unwrap();
3428        assert_eq!(sm2.sources, sm.sources);
3429        assert_eq!(sm2.names, sm.names);
3430        assert_eq!(sm2.mapping_count(), sm.mapping_count());
3431        assert_eq!(sm2.line_count(), sm.line_count());
3432
3433        // Verify all lookups match
3434        for m in sm.all_mappings() {
3435            let loc1 = sm.original_position_for(m.generated_line, m.generated_column);
3436            let loc2 = sm2.original_position_for(m.generated_line, m.generated_column);
3437            match (loc1, loc2) {
3438                (Some(a), Some(b)) => {
3439                    assert_eq!(a.source, b.source);
3440                    assert_eq!(a.line, b.line);
3441                    assert_eq!(a.column, b.column);
3442                    assert_eq!(a.name, b.name);
3443                }
3444                (None, None) => {}
3445                _ => panic!(
3446                    "lookup mismatch at ({}, {})",
3447                    m.generated_line, m.generated_column
3448                ),
3449            }
3450        }
3451    }
3452
3453    #[test]
3454    fn to_json_roundtrip_large() {
3455        let json = generate_test_sourcemap(50, 10, 3);
3456        let sm = SourceMap::from_json(&json).unwrap();
3457        let output = sm.to_json();
3458        let sm2 = SourceMap::from_json(&output).unwrap();
3459
3460        assert_eq!(sm2.mapping_count(), sm.mapping_count());
3461
3462        // Spot-check lookups
3463        for line in (0..sm.line_count() as u32).step_by(5) {
3464            for col in [0u32, 10, 20, 50] {
3465                let a = sm.original_position_for(line, col);
3466                let b = sm2.original_position_for(line, col);
3467                match (a, b) {
3468                    (Some(a), Some(b)) => {
3469                        assert_eq!(a.source, b.source);
3470                        assert_eq!(a.line, b.line);
3471                        assert_eq!(a.column, b.column);
3472                    }
3473                    (None, None) => {}
3474                    _ => panic!("mismatch at ({line}, {col})"),
3475                }
3476            }
3477        }
3478    }
3479
3480    #[test]
3481    fn to_json_preserves_fields() {
3482        let json = r#"{"version":3,"file":"out.js","sourceRoot":"src/","sources":["app.ts"],"sourcesContent":["const x = 1;"],"names":["x"],"mappings":"AAAAA","ignoreList":[0]}"#;
3483        let sm = SourceMap::from_json(json).unwrap();
3484        let output = sm.to_json();
3485
3486        assert!(output.contains(r#""file":"out.js""#));
3487        assert!(output.contains(r#""sourceRoot":"src/""#));
3488        assert!(output.contains(r#""sourcesContent":["const x = 1;"]"#));
3489        assert!(output.contains(r#""ignoreList":[0]"#));
3490
3491        // Note: sources will have sourceRoot prepended
3492        let sm2 = SourceMap::from_json(&output).unwrap();
3493        assert_eq!(sm2.file.as_deref(), Some("out.js"));
3494        assert_eq!(sm2.ignore_list, vec![0]);
3495    }
3496
3497    #[test]
3498    fn original_position_for_exact_match() {
3499        let sm = SourceMap::from_json(simple_map()).unwrap();
3500        let loc = sm.original_position_for(0, 0).unwrap();
3501        assert_eq!(loc.source, 0);
3502        assert_eq!(loc.line, 0);
3503        assert_eq!(loc.column, 0);
3504    }
3505
3506    #[test]
3507    fn original_position_for_column_within_segment() {
3508        let sm = SourceMap::from_json(simple_map()).unwrap();
3509        // Column 5 on line 1: should snap to the mapping at column 2
3510        let loc = sm.original_position_for(1, 5);
3511        assert!(loc.is_some());
3512    }
3513
3514    #[test]
3515    fn original_position_for_nonexistent_line() {
3516        let sm = SourceMap::from_json(simple_map()).unwrap();
3517        assert!(sm.original_position_for(999, 0).is_none());
3518    }
3519
3520    #[test]
3521    fn original_position_for_before_first_mapping() {
3522        // Line 1 first mapping is at column 2. Column 0 should return None.
3523        let sm = SourceMap::from_json(simple_map()).unwrap();
3524        let loc = sm.original_position_for(1, 0);
3525        // Column 0 on line 1: the first mapping at col 0 (AACA decodes to col=0, src delta=1...)
3526        // Actually depends on exact VLQ values. Let's just verify it doesn't crash.
3527        let _ = loc;
3528    }
3529
3530    #[test]
3531    fn generated_position_for_basic() {
3532        let sm = SourceMap::from_json(simple_map()).unwrap();
3533        let loc = sm.generated_position_for("input.js", 0, 0).unwrap();
3534        assert_eq!(loc.line, 0);
3535        assert_eq!(loc.column, 0);
3536    }
3537
3538    #[test]
3539    fn generated_position_for_unknown_source() {
3540        let sm = SourceMap::from_json(simple_map()).unwrap();
3541        assert!(sm.generated_position_for("nonexistent.js", 0, 0).is_none());
3542    }
3543
3544    #[test]
3545    fn parse_invalid_version() {
3546        let json = r#"{"version":2,"sources":[],"names":[],"mappings":""}"#;
3547        let err = SourceMap::from_json(json).unwrap_err();
3548        assert!(matches!(err, ParseError::InvalidVersion(2)));
3549    }
3550
3551    #[test]
3552    fn parse_empty_mappings() {
3553        let json = r#"{"version":3,"sources":[],"names":[],"mappings":""}"#;
3554        let sm = SourceMap::from_json(json).unwrap();
3555        assert_eq!(sm.mapping_count(), 0);
3556        assert!(sm.original_position_for(0, 0).is_none());
3557    }
3558
3559    #[test]
3560    fn parse_with_source_root() {
3561        let json = r#"{"version":3,"sourceRoot":"src/","sources":["foo.js"],"names":[],"mappings":"AAAA"}"#;
3562        let sm = SourceMap::from_json(json).unwrap();
3563        assert_eq!(sm.sources, vec!["src/foo.js"]);
3564    }
3565
3566    #[test]
3567    fn parse_with_sources_content() {
3568        let json = r#"{"version":3,"sources":["a.js"],"sourcesContent":["var x = 1;"],"names":[],"mappings":"AAAA"}"#;
3569        let sm = SourceMap::from_json(json).unwrap();
3570        assert_eq!(sm.sources_content, vec![Some("var x = 1;".to_string())]);
3571    }
3572
3573    #[test]
3574    fn mappings_for_line() {
3575        let sm = SourceMap::from_json(simple_map()).unwrap();
3576        let line0 = sm.mappings_for_line(0);
3577        assert!(!line0.is_empty());
3578        let empty = sm.mappings_for_line(999);
3579        assert!(empty.is_empty());
3580    }
3581
3582    #[test]
3583    fn large_sourcemap_lookup() {
3584        // Generate a realistic source map
3585        let json = generate_test_sourcemap(500, 20, 5);
3586        let sm = SourceMap::from_json(&json).unwrap();
3587
3588        // Verify lookups work across the whole map
3589        for line in [0, 10, 100, 250, 499] {
3590            let mappings = sm.mappings_for_line(line);
3591            if let Some(m) = mappings.first() {
3592                let loc = sm.original_position_for(line, m.generated_column);
3593                assert!(loc.is_some(), "lookup failed for line {line}");
3594            }
3595        }
3596    }
3597
3598    #[test]
3599    fn reverse_lookup_roundtrip() {
3600        let json = generate_test_sourcemap(100, 10, 3);
3601        let sm = SourceMap::from_json(&json).unwrap();
3602
3603        // Pick a mapping and verify forward + reverse roundtrip
3604        let mapping = &sm.mappings[50];
3605        if mapping.source != NO_SOURCE {
3606            let source_name = sm.source(mapping.source);
3607            let result = sm.generated_position_for(
3608                source_name,
3609                mapping.original_line,
3610                mapping.original_column,
3611            );
3612            assert!(result.is_some(), "reverse lookup failed");
3613        }
3614    }
3615
3616    #[test]
3617    fn all_generated_positions_for_basic() {
3618        let sm = SourceMap::from_json(simple_map()).unwrap();
3619        let results = sm.all_generated_positions_for("input.js", 0, 0);
3620        assert!(!results.is_empty(), "should find at least one position");
3621        assert_eq!(results[0].line, 0);
3622        assert_eq!(results[0].column, 0);
3623    }
3624
3625    #[test]
3626    fn all_generated_positions_for_unknown_source() {
3627        let sm = SourceMap::from_json(simple_map()).unwrap();
3628        let results = sm.all_generated_positions_for("nonexistent.js", 0, 0);
3629        assert!(results.is_empty());
3630    }
3631
3632    #[test]
3633    fn all_generated_positions_for_no_match() {
3634        let sm = SourceMap::from_json(simple_map()).unwrap();
3635        let results = sm.all_generated_positions_for("input.js", 999, 999);
3636        assert!(results.is_empty());
3637    }
3638
3639    #[test]
3640    fn encode_mappings_roundtrip() {
3641        let json = generate_test_sourcemap(50, 10, 3);
3642        let sm = SourceMap::from_json(&json).unwrap();
3643        let encoded = sm.encode_mappings();
3644        // Re-parse with encoded mappings
3645        let json2 = format!(
3646            r#"{{"version":3,"sources":{sources},"names":{names},"mappings":"{mappings}"}}"#,
3647            sources = serde_json::to_string(&sm.sources).unwrap(),
3648            names = serde_json::to_string(&sm.names).unwrap(),
3649            mappings = encoded,
3650        );
3651        let sm2 = SourceMap::from_json(&json2).unwrap();
3652        assert_eq!(sm2.mapping_count(), sm.mapping_count());
3653    }
3654
3655    #[test]
3656    fn indexed_source_map() {
3657        let json = r#"{
3658            "version": 3,
3659            "file": "bundle.js",
3660            "sections": [
3661                {
3662                    "offset": {"line": 0, "column": 0},
3663                    "map": {
3664                        "version": 3,
3665                        "sources": ["a.js"],
3666                        "names": ["foo"],
3667                        "mappings": "AAAAA"
3668                    }
3669                },
3670                {
3671                    "offset": {"line": 10, "column": 0},
3672                    "map": {
3673                        "version": 3,
3674                        "sources": ["b.js"],
3675                        "names": ["bar"],
3676                        "mappings": "AAAAA"
3677                    }
3678                }
3679            ]
3680        }"#;
3681
3682        let sm = SourceMap::from_json(json).unwrap();
3683
3684        // Should have both sources
3685        assert_eq!(sm.sources.len(), 2);
3686        assert!(sm.sources.contains(&"a.js".to_string()));
3687        assert!(sm.sources.contains(&"b.js".to_string()));
3688
3689        // Should have both names
3690        assert_eq!(sm.names.len(), 2);
3691        assert!(sm.names.contains(&"foo".to_string()));
3692        assert!(sm.names.contains(&"bar".to_string()));
3693
3694        // First section: line 0, col 0 should map to a.js
3695        let loc = sm.original_position_for(0, 0).unwrap();
3696        assert_eq!(sm.source(loc.source), "a.js");
3697        assert_eq!(loc.line, 0);
3698        assert_eq!(loc.column, 0);
3699
3700        // Second section: line 10, col 0 should map to b.js
3701        let loc = sm.original_position_for(10, 0).unwrap();
3702        assert_eq!(sm.source(loc.source), "b.js");
3703        assert_eq!(loc.line, 0);
3704        assert_eq!(loc.column, 0);
3705    }
3706
3707    #[test]
3708    fn indexed_source_map_shared_sources() {
3709        // Two sections referencing the same source
3710        let json = r#"{
3711            "version": 3,
3712            "sections": [
3713                {
3714                    "offset": {"line": 0, "column": 0},
3715                    "map": {
3716                        "version": 3,
3717                        "sources": ["shared.js"],
3718                        "names": [],
3719                        "mappings": "AAAA"
3720                    }
3721                },
3722                {
3723                    "offset": {"line": 5, "column": 0},
3724                    "map": {
3725                        "version": 3,
3726                        "sources": ["shared.js"],
3727                        "names": [],
3728                        "mappings": "AACA"
3729                    }
3730                }
3731            ]
3732        }"#;
3733
3734        let sm = SourceMap::from_json(json).unwrap();
3735
3736        // Should deduplicate sources
3737        assert_eq!(sm.sources.len(), 1);
3738        assert_eq!(sm.sources[0], "shared.js");
3739
3740        // Both sections should resolve to the same source
3741        let loc0 = sm.original_position_for(0, 0).unwrap();
3742        let loc5 = sm.original_position_for(5, 0).unwrap();
3743        assert_eq!(loc0.source, loc5.source);
3744    }
3745
3746    #[test]
3747    fn parse_ignore_list() {
3748        let json = r#"{"version":3,"sources":["app.js","node_modules/lib.js"],"names":[],"mappings":"AAAA;ACAA","ignoreList":[1]}"#;
3749        let sm = SourceMap::from_json(json).unwrap();
3750        assert_eq!(sm.ignore_list, vec![1]);
3751    }
3752
3753    /// Helper: build a source map JSON from absolute mappings data.
3754    fn build_sourcemap_json(
3755        sources: &[&str],
3756        names: &[&str],
3757        mappings_data: &[Vec<Vec<i64>>],
3758    ) -> String {
3759        let converted: Vec<Vec<srcmap_codec::Segment>> = mappings_data
3760            .iter()
3761            .map(|line| {
3762                line.iter()
3763                    .map(|seg| srcmap_codec::Segment::from(seg.as_slice()))
3764                    .collect()
3765            })
3766            .collect();
3767        let encoded = srcmap_codec::encode(&converted);
3768        format!(
3769            r#"{{"version":3,"sources":[{}],"names":[{}],"mappings":"{}"}}"#,
3770            sources
3771                .iter()
3772                .map(|s| format!("\"{s}\""))
3773                .collect::<Vec<_>>()
3774                .join(","),
3775            names
3776                .iter()
3777                .map(|n| format!("\"{n}\""))
3778                .collect::<Vec<_>>()
3779                .join(","),
3780            encoded,
3781        )
3782    }
3783
3784    // ── 1. Edge cases in decode_mappings ────────────────────────────
3785
3786    #[test]
3787    fn decode_multiple_consecutive_semicolons() {
3788        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;;;AACA"}"#;
3789        let sm = SourceMap::from_json(json).unwrap();
3790        assert_eq!(sm.line_count(), 4);
3791        assert!(sm.mappings_for_line(1).is_empty());
3792        assert!(sm.mappings_for_line(2).is_empty());
3793        assert!(!sm.mappings_for_line(0).is_empty());
3794        assert!(!sm.mappings_for_line(3).is_empty());
3795    }
3796
3797    #[test]
3798    fn decode_trailing_semicolons() {
3799        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;;"}"#;
3800        let sm = SourceMap::from_json(json).unwrap();
3801        assert_eq!(sm.line_count(), 3);
3802        assert!(!sm.mappings_for_line(0).is_empty());
3803        assert!(sm.mappings_for_line(1).is_empty());
3804        assert!(sm.mappings_for_line(2).is_empty());
3805    }
3806
3807    #[test]
3808    fn decode_leading_comma() {
3809        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":",AAAA"}"#;
3810        let sm = SourceMap::from_json(json).unwrap();
3811        assert_eq!(sm.mapping_count(), 1);
3812        let m = &sm.all_mappings()[0];
3813        assert_eq!(m.generated_line, 0);
3814        assert_eq!(m.generated_column, 0);
3815    }
3816
3817    #[test]
3818    fn decode_single_field_segments() {
3819        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,C"}"#;
3820        let sm = SourceMap::from_json(json).unwrap();
3821        assert_eq!(sm.mapping_count(), 2);
3822        for m in sm.all_mappings() {
3823            assert_eq!(m.source, NO_SOURCE);
3824        }
3825        assert_eq!(sm.all_mappings()[0].generated_column, 0);
3826        assert_eq!(sm.all_mappings()[1].generated_column, 1);
3827        assert!(sm.original_position_for(0, 0).is_none());
3828        assert!(sm.original_position_for(0, 1).is_none());
3829    }
3830
3831    #[test]
3832    fn decode_five_field_segments_with_names() {
3833        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0, 0], vec![10, 0, 0, 5, 1]]];
3834        let json = build_sourcemap_json(&["app.js"], &["foo", "bar"], &mappings_data);
3835        let sm = SourceMap::from_json(&json).unwrap();
3836        assert_eq!(sm.mapping_count(), 2);
3837        assert_eq!(sm.all_mappings()[0].name, 0);
3838        assert_eq!(sm.all_mappings()[1].name, 1);
3839
3840        let loc = sm.original_position_for(0, 0).unwrap();
3841        assert_eq!(loc.name, Some(0));
3842        assert_eq!(sm.name(0), "foo");
3843
3844        let loc = sm.original_position_for(0, 10).unwrap();
3845        assert_eq!(loc.name, Some(1));
3846        assert_eq!(sm.name(1), "bar");
3847    }
3848
3849    #[test]
3850    fn decode_large_vlq_values() {
3851        let mappings_data = vec![vec![vec![500_i64, 0, 1000, 2000]]];
3852        let json = build_sourcemap_json(&["big.js"], &[], &mappings_data);
3853        let sm = SourceMap::from_json(&json).unwrap();
3854        assert_eq!(sm.mapping_count(), 1);
3855        let m = &sm.all_mappings()[0];
3856        assert_eq!(m.generated_column, 500);
3857        assert_eq!(m.original_line, 1000);
3858        assert_eq!(m.original_column, 2000);
3859
3860        let loc = sm.original_position_for(0, 500).unwrap();
3861        assert_eq!(loc.line, 1000);
3862        assert_eq!(loc.column, 2000);
3863    }
3864
3865    #[test]
3866    fn decode_only_semicolons() {
3867        let json = r#"{"version":3,"sources":[],"names":[],"mappings":";;;"}"#;
3868        let sm = SourceMap::from_json(json).unwrap();
3869        assert_eq!(sm.line_count(), 4);
3870        assert_eq!(sm.mapping_count(), 0);
3871        for line in 0..4 {
3872            assert!(sm.mappings_for_line(line).is_empty());
3873        }
3874    }
3875
3876    #[test]
3877    fn decode_mixed_single_and_four_field_segments() {
3878        let mappings_data = vec![vec![srcmap_codec::Segment::four(5, 0, 0, 0)]];
3879        let four_field_encoded = srcmap_codec::encode(&mappings_data);
3880        let combined_mappings = format!("A,{four_field_encoded}");
3881        let json = format!(
3882            r#"{{"version":3,"sources":["x.js"],"names":[],"mappings":"{combined_mappings}"}}"#,
3883        );
3884        let sm = SourceMap::from_json(&json).unwrap();
3885        assert_eq!(sm.mapping_count(), 2);
3886        assert_eq!(sm.all_mappings()[0].source, NO_SOURCE);
3887        assert_eq!(sm.all_mappings()[1].source, 0);
3888    }
3889
3890    // ── 2. Source map parsing ───────────────────────────────────────
3891
3892    #[test]
3893    fn parse_missing_optional_fields() {
3894        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
3895        let sm = SourceMap::from_json(json).unwrap();
3896        assert!(sm.file.is_none());
3897        assert!(sm.source_root.is_none());
3898        assert!(sm.sources_content.is_empty());
3899        assert!(sm.ignore_list.is_empty());
3900    }
3901
3902    #[test]
3903    fn parse_with_file_field() {
3904        let json =
3905            r#"{"version":3,"file":"output.js","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
3906        let sm = SourceMap::from_json(json).unwrap();
3907        assert_eq!(sm.file.as_deref(), Some("output.js"));
3908    }
3909
3910    #[test]
3911    fn parse_null_entries_in_sources() {
3912        let json = r#"{"version":3,"sources":["a.js",null,"c.js"],"names":[],"mappings":"AAAA"}"#;
3913        let sm = SourceMap::from_json(json).unwrap();
3914        assert_eq!(sm.sources.len(), 3);
3915        assert_eq!(sm.sources[0], "a.js");
3916        assert_eq!(sm.sources[1], "");
3917        assert_eq!(sm.sources[2], "c.js");
3918    }
3919
3920    #[test]
3921    fn parse_null_entries_in_sources_with_source_root() {
3922        let json = r#"{"version":3,"sourceRoot":"lib/","sources":["a.js",null],"names":[],"mappings":"AAAA"}"#;
3923        let sm = SourceMap::from_json(json).unwrap();
3924        assert_eq!(sm.sources[0], "lib/a.js");
3925        assert_eq!(sm.sources[1], "");
3926    }
3927
3928    #[test]
3929    fn parse_empty_names_array() {
3930        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
3931        let sm = SourceMap::from_json(json).unwrap();
3932        assert!(sm.names.is_empty());
3933    }
3934
3935    #[test]
3936    fn parse_invalid_json() {
3937        let result = SourceMap::from_json("not valid json");
3938        assert!(result.is_err());
3939        assert!(matches!(result.unwrap_err(), ParseError::Json(_)));
3940    }
3941
3942    #[test]
3943    fn parse_json_missing_version() {
3944        let result = SourceMap::from_json(r#"{"sources":[],"names":[],"mappings":""}"#);
3945        assert!(result.is_err());
3946    }
3947
3948    #[test]
3949    fn parse_multiple_sources_overlapping_original_positions() {
3950        let mappings_data = vec![vec![vec![0_i64, 0, 5, 10], vec![10, 1, 5, 10]]];
3951        let json = build_sourcemap_json(&["a.js", "b.js"], &[], &mappings_data);
3952        let sm = SourceMap::from_json(&json).unwrap();
3953
3954        let loc0 = sm.original_position_for(0, 0).unwrap();
3955        assert_eq!(loc0.source, 0);
3956        assert_eq!(sm.source(loc0.source), "a.js");
3957
3958        let loc1 = sm.original_position_for(0, 10).unwrap();
3959        assert_eq!(loc1.source, 1);
3960        assert_eq!(sm.source(loc1.source), "b.js");
3961
3962        assert_eq!(loc0.line, loc1.line);
3963        assert_eq!(loc0.column, loc1.column);
3964    }
3965
3966    #[test]
3967    fn parse_sources_content_with_null_entries() {
3968        let json = r#"{"version":3,"sources":["a.js","b.js"],"sourcesContent":["content a",null],"names":[],"mappings":"AAAA"}"#;
3969        let sm = SourceMap::from_json(json).unwrap();
3970        assert_eq!(sm.sources_content.len(), 2);
3971        assert_eq!(sm.sources_content[0], Some("content a".to_string()));
3972        assert_eq!(sm.sources_content[1], None);
3973    }
3974
3975    #[test]
3976    fn parse_empty_sources_and_names() {
3977        let json = r#"{"version":3,"sources":[],"names":[],"mappings":""}"#;
3978        let sm = SourceMap::from_json(json).unwrap();
3979        assert!(sm.sources.is_empty());
3980        assert!(sm.names.is_empty());
3981        assert_eq!(sm.mapping_count(), 0);
3982    }
3983
3984    // ── 3. Position lookups ─────────────────────────────────────────
3985
3986    #[test]
3987    fn lookup_exact_match() {
3988        let mappings_data = vec![vec![
3989            vec![0_i64, 0, 10, 20],
3990            vec![5, 0, 10, 25],
3991            vec![15, 0, 11, 0],
3992        ]];
3993        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
3994        let sm = SourceMap::from_json(&json).unwrap();
3995
3996        let loc = sm.original_position_for(0, 5).unwrap();
3997        assert_eq!(loc.line, 10);
3998        assert_eq!(loc.column, 25);
3999    }
4000
4001    #[test]
4002    fn lookup_before_first_segment() {
4003        let mappings_data = vec![vec![vec![5_i64, 0, 0, 0]]];
4004        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4005        let sm = SourceMap::from_json(&json).unwrap();
4006
4007        assert!(sm.original_position_for(0, 0).is_none());
4008        assert!(sm.original_position_for(0, 4).is_none());
4009    }
4010
4011    #[test]
4012    fn lookup_between_segments() {
4013        let mappings_data = vec![vec![
4014            vec![0_i64, 0, 1, 0],
4015            vec![10, 0, 2, 0],
4016            vec![20, 0, 3, 0],
4017        ]];
4018        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4019        let sm = SourceMap::from_json(&json).unwrap();
4020
4021        let loc = sm.original_position_for(0, 7).unwrap();
4022        assert_eq!(loc.line, 1);
4023        assert_eq!(loc.column, 0);
4024
4025        let loc = sm.original_position_for(0, 15).unwrap();
4026        assert_eq!(loc.line, 2);
4027        assert_eq!(loc.column, 0);
4028    }
4029
4030    #[test]
4031    fn lookup_after_last_segment() {
4032        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0], vec![10, 0, 1, 5]]];
4033        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4034        let sm = SourceMap::from_json(&json).unwrap();
4035
4036        let loc = sm.original_position_for(0, 100).unwrap();
4037        assert_eq!(loc.line, 1);
4038        assert_eq!(loc.column, 5);
4039    }
4040
4041    #[test]
4042    fn lookup_empty_lines_no_mappings() {
4043        let mappings_data = vec![
4044            vec![vec![0_i64, 0, 0, 0]],
4045            vec![],
4046            vec![vec![0_i64, 0, 2, 0]],
4047        ];
4048        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4049        let sm = SourceMap::from_json(&json).unwrap();
4050
4051        assert!(sm.original_position_for(1, 0).is_none());
4052        assert!(sm.original_position_for(1, 10).is_none());
4053        assert!(sm.original_position_for(0, 0).is_some());
4054        assert!(sm.original_position_for(2, 0).is_some());
4055    }
4056
4057    #[test]
4058    fn lookup_line_with_single_mapping() {
4059        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0]]];
4060        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4061        let sm = SourceMap::from_json(&json).unwrap();
4062
4063        let loc = sm.original_position_for(0, 0).unwrap();
4064        assert_eq!(loc.line, 0);
4065        assert_eq!(loc.column, 0);
4066
4067        let loc = sm.original_position_for(0, 50).unwrap();
4068        assert_eq!(loc.line, 0);
4069        assert_eq!(loc.column, 0);
4070    }
4071
4072    #[test]
4073    fn lookup_column_0_vs_column_nonzero() {
4074        let mappings_data = vec![vec![vec![0_i64, 0, 10, 0], vec![8, 0, 20, 5]]];
4075        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4076        let sm = SourceMap::from_json(&json).unwrap();
4077
4078        let loc0 = sm.original_position_for(0, 0).unwrap();
4079        assert_eq!(loc0.line, 10);
4080        assert_eq!(loc0.column, 0);
4081
4082        let loc8 = sm.original_position_for(0, 8).unwrap();
4083        assert_eq!(loc8.line, 20);
4084        assert_eq!(loc8.column, 5);
4085
4086        let loc4 = sm.original_position_for(0, 4).unwrap();
4087        assert_eq!(loc4.line, 10);
4088    }
4089
4090    #[test]
4091    fn lookup_beyond_last_line() {
4092        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0]]];
4093        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4094        let sm = SourceMap::from_json(&json).unwrap();
4095
4096        assert!(sm.original_position_for(1, 0).is_none());
4097        assert!(sm.original_position_for(100, 0).is_none());
4098    }
4099
4100    #[test]
4101    fn lookup_single_field_returns_none() {
4102        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A"}"#;
4103        let sm = SourceMap::from_json(json).unwrap();
4104        assert_eq!(sm.mapping_count(), 1);
4105        assert!(sm.original_position_for(0, 0).is_none());
4106    }
4107
4108    // ── 4. Reverse lookups (generated_position_for) ─────────────────
4109
4110    #[test]
4111    fn reverse_lookup_exact_match() {
4112        let mappings_data = vec![
4113            vec![vec![0_i64, 0, 0, 0]],
4114            vec![vec![4, 0, 1, 0], vec![10, 0, 1, 8]],
4115            vec![vec![0, 0, 2, 0]],
4116        ];
4117        let json = build_sourcemap_json(&["main.js"], &[], &mappings_data);
4118        let sm = SourceMap::from_json(&json).unwrap();
4119
4120        let loc = sm.generated_position_for("main.js", 1, 8).unwrap();
4121        assert_eq!(loc.line, 1);
4122        assert_eq!(loc.column, 10);
4123    }
4124
4125    #[test]
4126    fn reverse_lookup_no_match() {
4127        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0], vec![10, 0, 0, 10]]];
4128        let json = build_sourcemap_json(&["main.js"], &[], &mappings_data);
4129        let sm = SourceMap::from_json(&json).unwrap();
4130
4131        assert!(sm.generated_position_for("main.js", 99, 0).is_none());
4132    }
4133
4134    #[test]
4135    fn reverse_lookup_unknown_source() {
4136        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0]]];
4137        let json = build_sourcemap_json(&["main.js"], &[], &mappings_data);
4138        let sm = SourceMap::from_json(&json).unwrap();
4139
4140        assert!(sm.generated_position_for("unknown.js", 0, 0).is_none());
4141    }
4142
4143    #[test]
4144    fn reverse_lookup_multiple_mappings_same_original() {
4145        let mappings_data = vec![vec![vec![0_i64, 0, 5, 10]], vec![vec![20, 0, 5, 10]]];
4146        let json = build_sourcemap_json(&["src.js"], &[], &mappings_data);
4147        let sm = SourceMap::from_json(&json).unwrap();
4148
4149        let loc = sm.generated_position_for("src.js", 5, 10);
4150        assert!(loc.is_some());
4151        let loc = loc.unwrap();
4152        assert!(
4153            (loc.line == 0 && loc.column == 0) || (loc.line == 1 && loc.column == 20),
4154            "Expected (0,0) or (1,20), got ({},{})",
4155            loc.line,
4156            loc.column
4157        );
4158    }
4159
4160    #[test]
4161    fn reverse_lookup_with_multiple_sources() {
4162        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0], vec![10, 1, 0, 0]]];
4163        let json = build_sourcemap_json(&["a.js", "b.js"], &[], &mappings_data);
4164        let sm = SourceMap::from_json(&json).unwrap();
4165
4166        let loc_a = sm.generated_position_for("a.js", 0, 0).unwrap();
4167        assert_eq!(loc_a.line, 0);
4168        assert_eq!(loc_a.column, 0);
4169
4170        let loc_b = sm.generated_position_for("b.js", 0, 0).unwrap();
4171        assert_eq!(loc_b.line, 0);
4172        assert_eq!(loc_b.column, 10);
4173    }
4174
4175    #[test]
4176    fn reverse_lookup_skips_single_field_segments() {
4177        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,KAAAA"}"#;
4178        let sm = SourceMap::from_json(json).unwrap();
4179
4180        let loc = sm.generated_position_for("a.js", 0, 0).unwrap();
4181        assert_eq!(loc.line, 0);
4182        assert_eq!(loc.column, 5);
4183    }
4184
4185    #[test]
4186    fn reverse_lookup_finds_each_original_line() {
4187        let mappings_data = vec![
4188            vec![vec![0_i64, 0, 0, 0]],
4189            vec![vec![0, 0, 1, 0]],
4190            vec![vec![0, 0, 2, 0]],
4191            vec![vec![0, 0, 3, 0]],
4192        ];
4193        let json = build_sourcemap_json(&["x.js"], &[], &mappings_data);
4194        let sm = SourceMap::from_json(&json).unwrap();
4195
4196        for orig_line in 0..4 {
4197            let loc = sm.generated_position_for("x.js", orig_line, 0).unwrap();
4198            assert_eq!(
4199                loc.line, orig_line,
4200                "reverse lookup for orig line {orig_line}"
4201            );
4202            assert_eq!(loc.column, 0);
4203        }
4204    }
4205
4206    // ── 5. ignoreList ───────────────────────────────────────────────
4207
4208    #[test]
4209    fn parse_with_ignore_list_multiple() {
4210        let json = r#"{"version":3,"sources":["app.js","node_modules/lib.js","vendor.js"],"names":[],"mappings":"AAAA","ignoreList":[1,2]}"#;
4211        let sm = SourceMap::from_json(json).unwrap();
4212        assert_eq!(sm.ignore_list, vec![1, 2]);
4213    }
4214
4215    #[test]
4216    fn parse_with_empty_ignore_list() {
4217        let json =
4218            r#"{"version":3,"sources":["app.js"],"names":[],"mappings":"AAAA","ignoreList":[]}"#;
4219        let sm = SourceMap::from_json(json).unwrap();
4220        assert!(sm.ignore_list.is_empty());
4221    }
4222
4223    #[test]
4224    fn parse_without_ignore_list_field() {
4225        let json = r#"{"version":3,"sources":["app.js"],"names":[],"mappings":"AAAA"}"#;
4226        let sm = SourceMap::from_json(json).unwrap();
4227        assert!(sm.ignore_list.is_empty());
4228    }
4229
4230    // ── Additional edge case tests ──────────────────────────────────
4231
4232    #[test]
4233    fn source_index_lookup() {
4234        let json = r#"{"version":3,"sources":["a.js","b.js","c.js"],"names":[],"mappings":"AAAA"}"#;
4235        let sm = SourceMap::from_json(json).unwrap();
4236        assert_eq!(sm.source_index("a.js"), Some(0));
4237        assert_eq!(sm.source_index("b.js"), Some(1));
4238        assert_eq!(sm.source_index("c.js"), Some(2));
4239        assert_eq!(sm.source_index("d.js"), None);
4240    }
4241
4242    #[test]
4243    fn all_mappings_returns_complete_list() {
4244        let mappings_data = vec![
4245            vec![vec![0_i64, 0, 0, 0], vec![5, 0, 0, 5]],
4246            vec![vec![0, 0, 1, 0]],
4247        ];
4248        let json = build_sourcemap_json(&["x.js"], &[], &mappings_data);
4249        let sm = SourceMap::from_json(&json).unwrap();
4250        assert_eq!(sm.all_mappings().len(), 3);
4251        assert_eq!(sm.mapping_count(), 3);
4252    }
4253
4254    #[test]
4255    fn line_count_matches_decoded_lines() {
4256        let mappings_data = vec![
4257            vec![vec![0_i64, 0, 0, 0]],
4258            vec![],
4259            vec![vec![0_i64, 0, 2, 0]],
4260            vec![],
4261            vec![],
4262        ];
4263        let json = build_sourcemap_json(&["x.js"], &[], &mappings_data);
4264        let sm = SourceMap::from_json(&json).unwrap();
4265        assert_eq!(sm.line_count(), 5);
4266    }
4267
4268    #[test]
4269    fn parse_error_display() {
4270        let err = ParseError::InvalidVersion(5);
4271        assert_eq!(format!("{err}"), "unsupported source map version: 5");
4272
4273        let json_err = SourceMap::from_json("{}").unwrap_err();
4274        let display = format!("{json_err}");
4275        assert!(display.contains("JSON parse error") || display.contains("missing field"));
4276    }
4277
4278    #[test]
4279    fn original_position_name_none_for_four_field() {
4280        let mappings_data = vec![vec![vec![0_i64, 0, 5, 10]]];
4281        let json = build_sourcemap_json(&["a.js"], &["unused_name"], &mappings_data);
4282        let sm = SourceMap::from_json(&json).unwrap();
4283
4284        let loc = sm.original_position_for(0, 0).unwrap();
4285        assert!(loc.name.is_none());
4286    }
4287
4288    #[test]
4289    fn forward_and_reverse_roundtrip_comprehensive() {
4290        let mappings_data = vec![
4291            vec![vec![0_i64, 0, 0, 0], vec![10, 0, 0, 10], vec![20, 1, 5, 0]],
4292            vec![vec![0, 0, 1, 0], vec![5, 1, 6, 3]],
4293            vec![vec![0, 0, 2, 0]],
4294        ];
4295        let json = build_sourcemap_json(&["a.js", "b.js"], &[], &mappings_data);
4296        let sm = SourceMap::from_json(&json).unwrap();
4297
4298        for m in sm.all_mappings() {
4299            if m.source == NO_SOURCE {
4300                continue;
4301            }
4302            let source_name = sm.source(m.source);
4303
4304            let orig = sm
4305                .original_position_for(m.generated_line, m.generated_column)
4306                .unwrap();
4307            assert_eq!(orig.source, m.source);
4308            assert_eq!(orig.line, m.original_line);
4309            assert_eq!(orig.column, m.original_column);
4310
4311            let gen_loc = sm
4312                .generated_position_for(source_name, m.original_line, m.original_column)
4313                .unwrap();
4314            assert_eq!(gen_loc.line, m.generated_line);
4315            assert_eq!(gen_loc.column, m.generated_column);
4316        }
4317    }
4318
4319    // ── 6. Comprehensive edge case tests ────────────────────────────
4320
4321    // -- sourceRoot edge cases --
4322
4323    #[test]
4324    fn source_root_with_multiple_sources() {
4325        let json = r#"{"version":3,"sourceRoot":"lib/","sources":["a.js","b.js","c.js"],"names":[],"mappings":"AAAA,KACA,KACA"}"#;
4326        let sm = SourceMap::from_json(json).unwrap();
4327        assert_eq!(sm.sources, vec!["lib/a.js", "lib/b.js", "lib/c.js"]);
4328    }
4329
4330    #[test]
4331    fn source_root_empty_string() {
4332        let json =
4333            r#"{"version":3,"sourceRoot":"","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4334        let sm = SourceMap::from_json(json).unwrap();
4335        assert_eq!(sm.sources, vec!["a.js"]);
4336    }
4337
4338    #[test]
4339    fn source_root_preserved_in_to_json() {
4340        let json =
4341            r#"{"version":3,"sourceRoot":"src/","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4342        let sm = SourceMap::from_json(json).unwrap();
4343        let output = sm.to_json();
4344        assert!(output.contains(r#""sourceRoot":"src/""#));
4345    }
4346
4347    #[test]
4348    fn source_root_reverse_lookup_uses_prefixed_name() {
4349        let json =
4350            r#"{"version":3,"sourceRoot":"src/","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4351        let sm = SourceMap::from_json(json).unwrap();
4352        // Must use the prefixed name for reverse lookups
4353        assert!(sm.generated_position_for("src/a.js", 0, 0).is_some());
4354        assert!(sm.generated_position_for("a.js", 0, 0).is_none());
4355    }
4356
4357    #[test]
4358    fn source_root_with_trailing_slash() {
4359        let json =
4360            r#"{"version":3,"sourceRoot":"src/","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4361        let sm = SourceMap::from_json(json).unwrap();
4362        assert_eq!(sm.sources[0], "src/a.js");
4363    }
4364
4365    #[test]
4366    fn source_root_without_trailing_slash() {
4367        let json =
4368            r#"{"version":3,"sourceRoot":"src","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4369        let sm = SourceMap::from_json(json).unwrap();
4370        // sourceRoot is applied as raw prefix during parsing
4371        assert_eq!(sm.sources[0], "srca.js");
4372        // Roundtrip should strip the prefix back correctly
4373        let output = sm.to_json();
4374        let sm2 = SourceMap::from_json(&output).unwrap();
4375        assert_eq!(sm2.sources[0], "srca.js");
4376    }
4377
4378    // -- JSON/parsing error cases --
4379
4380    #[test]
4381    fn parse_empty_json_object() {
4382        // {} has no version field
4383        let result = SourceMap::from_json("{}");
4384        assert!(result.is_err());
4385    }
4386
4387    #[test]
4388    fn parse_version_0() {
4389        let json = r#"{"version":0,"sources":[],"names":[],"mappings":""}"#;
4390        assert!(matches!(
4391            SourceMap::from_json(json).unwrap_err(),
4392            ParseError::InvalidVersion(0)
4393        ));
4394    }
4395
4396    #[test]
4397    fn parse_version_4() {
4398        let json = r#"{"version":4,"sources":[],"names":[],"mappings":""}"#;
4399        assert!(matches!(
4400            SourceMap::from_json(json).unwrap_err(),
4401            ParseError::InvalidVersion(4)
4402        ));
4403    }
4404
4405    #[test]
4406    fn parse_extra_unknown_fields_ignored() {
4407        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","x_custom_field":true,"x_debug":{"foo":"bar"}}"#;
4408        let sm = SourceMap::from_json(json).unwrap();
4409        assert_eq!(sm.mapping_count(), 1);
4410    }
4411
4412    #[test]
4413    fn parse_vlq_error_propagated() {
4414        // '!' is not valid base64 — should surface as VLQ error
4415        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AA!A"}"#;
4416        let result = SourceMap::from_json(json);
4417        assert!(result.is_err());
4418        assert!(matches!(result.unwrap_err(), ParseError::Vlq(_)));
4419    }
4420
4421    #[test]
4422    fn parse_truncated_vlq_error() {
4423        // 'g' has continuation bit set — truncated VLQ
4424        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"g"}"#;
4425        let result = SourceMap::from_json(json);
4426        assert!(result.is_err());
4427    }
4428
4429    // -- to_json edge cases --
4430
4431    #[test]
4432    fn to_json_produces_valid_json() {
4433        let json = r#"{"version":3,"file":"out.js","sourceRoot":"src/","sources":["a.ts","b.ts"],"sourcesContent":["const x = 1;\nconst y = \"hello\";",null],"names":["x","y"],"mappings":"AAAAA,KACAC;AACA","ignoreList":[1]}"#;
4434        let sm = SourceMap::from_json(json).unwrap();
4435        let output = sm.to_json();
4436        // Must be valid JSON that serde can parse
4437        let _: serde_json::Value = serde_json::from_str(&output).unwrap();
4438    }
4439
4440    #[test]
4441    fn to_json_escapes_special_chars() {
4442        let json = r#"{"version":3,"sources":["path/with\"quotes.js"],"sourcesContent":["line1\nline2\ttab\\backslash"],"names":[],"mappings":"AAAA"}"#;
4443        let sm = SourceMap::from_json(json).unwrap();
4444        let output = sm.to_json();
4445        let _: serde_json::Value = serde_json::from_str(&output).unwrap();
4446        let sm2 = SourceMap::from_json(&output).unwrap();
4447        assert_eq!(
4448            sm2.sources_content[0].as_deref(),
4449            Some("line1\nline2\ttab\\backslash")
4450        );
4451    }
4452
4453    #[test]
4454    fn to_json_empty_map() {
4455        let json = r#"{"version":3,"sources":[],"names":[],"mappings":""}"#;
4456        let sm = SourceMap::from_json(json).unwrap();
4457        let output = sm.to_json();
4458        let sm2 = SourceMap::from_json(&output).unwrap();
4459        assert_eq!(sm2.mapping_count(), 0);
4460        assert!(sm2.sources.is_empty());
4461    }
4462
4463    #[test]
4464    fn to_json_roundtrip_with_names() {
4465        let mappings_data = vec![vec![
4466            vec![0_i64, 0, 0, 0, 0],
4467            vec![10, 0, 0, 10, 1],
4468            vec![20, 0, 1, 0, 2],
4469        ]];
4470        let json = build_sourcemap_json(&["src.js"], &["foo", "bar", "baz"], &mappings_data);
4471        let sm = SourceMap::from_json(&json).unwrap();
4472        let output = sm.to_json();
4473        let sm2 = SourceMap::from_json(&output).unwrap();
4474
4475        for m in sm2.all_mappings() {
4476            if m.source != NO_SOURCE && m.name != NO_NAME {
4477                let loc = sm2
4478                    .original_position_for(m.generated_line, m.generated_column)
4479                    .unwrap();
4480                assert!(loc.name.is_some());
4481            }
4482        }
4483    }
4484
4485    // -- Indexed source map edge cases --
4486
4487    #[test]
4488    fn indexed_source_map_column_offset() {
4489        let json = r#"{
4490            "version": 3,
4491            "sections": [
4492                {
4493                    "offset": {"line": 0, "column": 10},
4494                    "map": {
4495                        "version": 3,
4496                        "sources": ["a.js"],
4497                        "names": [],
4498                        "mappings": "AAAA"
4499                    }
4500                }
4501            ]
4502        }"#;
4503        let sm = SourceMap::from_json(json).unwrap();
4504        // Mapping at col 0 in section should be offset to col 10 (first line only)
4505        let loc = sm.original_position_for(0, 10).unwrap();
4506        assert_eq!(loc.line, 0);
4507        assert_eq!(loc.column, 0);
4508        // Before the offset should have no mapping
4509        assert!(sm.original_position_for(0, 0).is_none());
4510    }
4511
4512    #[test]
4513    fn indexed_source_map_column_offset_only_first_line() {
4514        // Column offset only applies to the first line of a section
4515        let json = r#"{
4516            "version": 3,
4517            "sections": [
4518                {
4519                    "offset": {"line": 0, "column": 20},
4520                    "map": {
4521                        "version": 3,
4522                        "sources": ["a.js"],
4523                        "names": [],
4524                        "mappings": "AAAA;AAAA"
4525                    }
4526                }
4527            ]
4528        }"#;
4529        let sm = SourceMap::from_json(json).unwrap();
4530        // Line 0: column offset applies
4531        let loc = sm.original_position_for(0, 20).unwrap();
4532        assert_eq!(loc.column, 0);
4533        // Line 1: column offset does NOT apply
4534        let loc = sm.original_position_for(1, 0).unwrap();
4535        assert_eq!(loc.column, 0);
4536    }
4537
4538    #[test]
4539    fn indexed_source_map_empty_section() {
4540        let json = r#"{
4541            "version": 3,
4542            "sections": [
4543                {
4544                    "offset": {"line": 0, "column": 0},
4545                    "map": {
4546                        "version": 3,
4547                        "sources": [],
4548                        "names": [],
4549                        "mappings": ""
4550                    }
4551                },
4552                {
4553                    "offset": {"line": 5, "column": 0},
4554                    "map": {
4555                        "version": 3,
4556                        "sources": ["b.js"],
4557                        "names": [],
4558                        "mappings": "AAAA"
4559                    }
4560                }
4561            ]
4562        }"#;
4563        let sm = SourceMap::from_json(json).unwrap();
4564        assert_eq!(sm.sources.len(), 1);
4565        let loc = sm.original_position_for(5, 0).unwrap();
4566        assert_eq!(sm.source(loc.source), "b.js");
4567    }
4568
4569    #[test]
4570    fn indexed_source_map_with_sources_content() {
4571        let json = r#"{
4572            "version": 3,
4573            "sections": [
4574                {
4575                    "offset": {"line": 0, "column": 0},
4576                    "map": {
4577                        "version": 3,
4578                        "sources": ["a.js"],
4579                        "sourcesContent": ["var a = 1;"],
4580                        "names": [],
4581                        "mappings": "AAAA"
4582                    }
4583                },
4584                {
4585                    "offset": {"line": 5, "column": 0},
4586                    "map": {
4587                        "version": 3,
4588                        "sources": ["b.js"],
4589                        "sourcesContent": ["var b = 2;"],
4590                        "names": [],
4591                        "mappings": "AAAA"
4592                    }
4593                }
4594            ]
4595        }"#;
4596        let sm = SourceMap::from_json(json).unwrap();
4597        assert_eq!(sm.sources_content.len(), 2);
4598        assert_eq!(sm.sources_content[0], Some("var a = 1;".to_string()));
4599        assert_eq!(sm.sources_content[1], Some("var b = 2;".to_string()));
4600    }
4601
4602    #[test]
4603    fn indexed_source_map_with_ignore_list() {
4604        let json = r#"{
4605            "version": 3,
4606            "sections": [
4607                {
4608                    "offset": {"line": 0, "column": 0},
4609                    "map": {
4610                        "version": 3,
4611                        "sources": ["app.js", "vendor.js"],
4612                        "names": [],
4613                        "mappings": "AAAA",
4614                        "ignoreList": [1]
4615                    }
4616                }
4617            ]
4618        }"#;
4619        let sm = SourceMap::from_json(json).unwrap();
4620        assert!(!sm.ignore_list.is_empty());
4621    }
4622
4623    // -- Boundary conditions --
4624
4625    #[test]
4626    fn lookup_max_column_on_line() {
4627        let mappings_data = vec![vec![vec![0_i64, 0, 0, 0]]];
4628        let json = build_sourcemap_json(&["a.js"], &[], &mappings_data);
4629        let sm = SourceMap::from_json(&json).unwrap();
4630        // Very large column — should snap to the last mapping on line
4631        let loc = sm.original_position_for(0, u32::MAX - 1).unwrap();
4632        assert_eq!(loc.line, 0);
4633        assert_eq!(loc.column, 0);
4634    }
4635
4636    #[test]
4637    fn mappings_for_line_beyond_end() {
4638        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4639        let sm = SourceMap::from_json(json).unwrap();
4640        assert!(sm.mappings_for_line(u32::MAX).is_empty());
4641    }
4642
4643    #[test]
4644    fn source_with_unicode_path() {
4645        let json =
4646            r#"{"version":3,"sources":["src/日本語.ts"],"names":["変数"],"mappings":"AAAAA"}"#;
4647        let sm = SourceMap::from_json(json).unwrap();
4648        assert_eq!(sm.sources[0], "src/日本語.ts");
4649        assert_eq!(sm.names[0], "変数");
4650        let loc = sm.original_position_for(0, 0).unwrap();
4651        assert_eq!(sm.source(loc.source), "src/日本語.ts");
4652        assert_eq!(sm.name(loc.name.unwrap()), "変数");
4653    }
4654
4655    #[test]
4656    fn to_json_roundtrip_unicode_sources() {
4657        let json = r#"{"version":3,"sources":["src/日本語.ts"],"sourcesContent":["const 変数 = 1;"],"names":["変数"],"mappings":"AAAAA"}"#;
4658        let sm = SourceMap::from_json(json).unwrap();
4659        let output = sm.to_json();
4660        let _: serde_json::Value = serde_json::from_str(&output).unwrap();
4661        let sm2 = SourceMap::from_json(&output).unwrap();
4662        assert_eq!(sm2.sources[0], "src/日本語.ts");
4663        assert_eq!(sm2.sources_content[0], Some("const 変数 = 1;".to_string()));
4664    }
4665
4666    #[test]
4667    fn many_sources_lookup() {
4668        // 100 sources, verify source_index works for all
4669        let sources: Vec<String> = (0..100).map(|i| format!("src/file{i}.js")).collect();
4670        let source_strs: Vec<&str> = sources.iter().map(|s| s.as_str()).collect();
4671        let mappings_data = vec![
4672            sources
4673                .iter()
4674                .enumerate()
4675                .map(|(i, _)| vec![(i * 10) as i64, i as i64, 0, 0])
4676                .collect::<Vec<_>>(),
4677        ];
4678        let json = build_sourcemap_json(&source_strs, &[], &mappings_data);
4679        let sm = SourceMap::from_json(&json).unwrap();
4680
4681        for (i, src) in sources.iter().enumerate() {
4682            assert_eq!(sm.source_index(src), Some(i as u32));
4683        }
4684    }
4685
4686    #[test]
4687    fn clone_sourcemap() {
4688        let json = r#"{"version":3,"sources":["a.js"],"names":["x"],"mappings":"AAAAA"}"#;
4689        let sm = SourceMap::from_json(json).unwrap();
4690        let sm2 = sm.clone();
4691        assert_eq!(sm2.sources, sm.sources);
4692        assert_eq!(sm2.mapping_count(), sm.mapping_count());
4693        let loc = sm2.original_position_for(0, 0).unwrap();
4694        assert_eq!(sm2.source(loc.source), "a.js");
4695    }
4696
4697    #[test]
4698    fn parse_debug_id() {
4699        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","debugId":"85314830-023f-4cf1-a267-535f4e37bb17"}"#;
4700        let sm = SourceMap::from_json(json).unwrap();
4701        assert_eq!(
4702            sm.debug_id.as_deref(),
4703            Some("85314830-023f-4cf1-a267-535f4e37bb17")
4704        );
4705    }
4706
4707    #[test]
4708    fn parse_debug_id_snake_case() {
4709        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","debug_id":"85314830-023f-4cf1-a267-535f4e37bb17"}"#;
4710        let sm = SourceMap::from_json(json).unwrap();
4711        assert_eq!(
4712            sm.debug_id.as_deref(),
4713            Some("85314830-023f-4cf1-a267-535f4e37bb17")
4714        );
4715    }
4716
4717    #[test]
4718    fn parse_no_debug_id() {
4719        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4720        let sm = SourceMap::from_json(json).unwrap();
4721        assert_eq!(sm.debug_id, None);
4722    }
4723
4724    #[test]
4725    fn debug_id_roundtrip() {
4726        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","debugId":"85314830-023f-4cf1-a267-535f4e37bb17"}"#;
4727        let sm = SourceMap::from_json(json).unwrap();
4728        let output = sm.to_json();
4729        assert!(output.contains(r#""debugId":"85314830-023f-4cf1-a267-535f4e37bb17""#));
4730        let sm2 = SourceMap::from_json(&output).unwrap();
4731        assert_eq!(sm.debug_id, sm2.debug_id);
4732    }
4733
4734    #[test]
4735    fn debug_id_not_in_json_when_absent() {
4736        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
4737        let sm = SourceMap::from_json(json).unwrap();
4738        let output = sm.to_json();
4739        assert!(!output.contains("debugId"));
4740    }
4741
4742    /// Generate a test source map JSON with realistic structure.
4743    fn generate_test_sourcemap(lines: usize, segs_per_line: usize, num_sources: usize) -> String {
4744        let sources: Vec<String> = (0..num_sources)
4745            .map(|i| format!("src/file{i}.js"))
4746            .collect();
4747        let names: Vec<String> = (0..20).map(|i| format!("var{i}")).collect();
4748
4749        let mut mappings_parts = Vec::with_capacity(lines);
4750        let mut gen_col;
4751        let mut src: i64 = 0;
4752        let mut src_line: i64 = 0;
4753        let mut src_col: i64;
4754        let mut name: i64 = 0;
4755
4756        for _ in 0..lines {
4757            gen_col = 0i64;
4758            let mut line_parts = Vec::with_capacity(segs_per_line);
4759
4760            for s in 0..segs_per_line {
4761                let gc_delta = 2 + (s as i64 * 3) % 20;
4762                gen_col += gc_delta;
4763
4764                let src_delta = if s % 7 == 0 { 1 } else { 0 };
4765                src = (src + src_delta) % num_sources as i64;
4766
4767                src_line += 1;
4768                src_col = (s as i64 * 5 + 1) % 30;
4769
4770                let has_name = s % 4 == 0;
4771                if has_name {
4772                    name = (name + 1) % names.len() as i64;
4773                }
4774
4775                // Build segment using codec encode
4776                let segment = if has_name {
4777                    srcmap_codec::Segment::five(gen_col, src, src_line, src_col, name)
4778                } else {
4779                    srcmap_codec::Segment::four(gen_col, src, src_line, src_col)
4780                };
4781
4782                line_parts.push(segment);
4783            }
4784
4785            mappings_parts.push(line_parts);
4786        }
4787
4788        let encoded = srcmap_codec::encode(&mappings_parts);
4789
4790        format!(
4791            r#"{{"version":3,"sources":[{}],"names":[{}],"mappings":"{}"}}"#,
4792            sources
4793                .iter()
4794                .map(|s| format!("\"{s}\""))
4795                .collect::<Vec<_>>()
4796                .join(","),
4797            names
4798                .iter()
4799                .map(|n| format!("\"{n}\""))
4800                .collect::<Vec<_>>()
4801                .join(","),
4802            encoded,
4803        )
4804    }
4805
4806    // ── Bias tests ───────────────────────────────────────────────
4807
4808    /// Map with multiple mappings per line for bias testing:
4809    /// Line 0: col 0 → src:0:0, col 5 → src:0:5, col 10 → src:0:10
4810    fn bias_map() -> &'static str {
4811        // AAAA = 0,0,0,0  KAAK = 5,0,0,5  KAAK = 5,0,0,5 (delta)
4812        r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA,KAAK,KAAK"}"#
4813    }
4814
4815    #[test]
4816    fn original_position_glb_exact_match() {
4817        let sm = SourceMap::from_json(bias_map()).unwrap();
4818        let loc = sm
4819            .original_position_for_with_bias(0, 5, Bias::GreatestLowerBound)
4820            .unwrap();
4821        assert_eq!(loc.column, 5);
4822    }
4823
4824    #[test]
4825    fn original_position_glb_snaps_left() {
4826        let sm = SourceMap::from_json(bias_map()).unwrap();
4827        // Column 7 should snap to the mapping at column 5
4828        let loc = sm
4829            .original_position_for_with_bias(0, 7, Bias::GreatestLowerBound)
4830            .unwrap();
4831        assert_eq!(loc.column, 5);
4832    }
4833
4834    #[test]
4835    fn original_position_lub_exact_match() {
4836        let sm = SourceMap::from_json(bias_map()).unwrap();
4837        let loc = sm
4838            .original_position_for_with_bias(0, 5, Bias::LeastUpperBound)
4839            .unwrap();
4840        assert_eq!(loc.column, 5);
4841    }
4842
4843    #[test]
4844    fn original_position_lub_snaps_right() {
4845        let sm = SourceMap::from_json(bias_map()).unwrap();
4846        // Column 3 with LUB should snap to the mapping at column 5
4847        let loc = sm
4848            .original_position_for_with_bias(0, 3, Bias::LeastUpperBound)
4849            .unwrap();
4850        assert_eq!(loc.column, 5);
4851    }
4852
4853    #[test]
4854    fn original_position_lub_before_first() {
4855        let sm = SourceMap::from_json(bias_map()).unwrap();
4856        // Column 0 with LUB should find mapping at column 0
4857        let loc = sm
4858            .original_position_for_with_bias(0, 0, Bias::LeastUpperBound)
4859            .unwrap();
4860        assert_eq!(loc.column, 0);
4861    }
4862
4863    #[test]
4864    fn original_position_lub_after_last() {
4865        let sm = SourceMap::from_json(bias_map()).unwrap();
4866        // Column 15 with LUB should return None (no mapping at or after 15)
4867        let loc = sm.original_position_for_with_bias(0, 15, Bias::LeastUpperBound);
4868        assert!(loc.is_none());
4869    }
4870
4871    #[test]
4872    fn original_position_glb_before_first() {
4873        let sm = SourceMap::from_json(bias_map()).unwrap();
4874        // Column 0 with GLB should find mapping at column 0
4875        let loc = sm
4876            .original_position_for_with_bias(0, 0, Bias::GreatestLowerBound)
4877            .unwrap();
4878        assert_eq!(loc.column, 0);
4879    }
4880
4881    #[test]
4882    fn generated_position_lub() {
4883        let sm = SourceMap::from_json(bias_map()).unwrap();
4884        // LUB: find first generated position at or after original col 3
4885        let loc = sm
4886            .generated_position_for_with_bias("input.js", 0, 3, Bias::LeastUpperBound)
4887            .unwrap();
4888        assert_eq!(loc.column, 5);
4889    }
4890
4891    #[test]
4892    fn generated_position_glb() {
4893        let sm = SourceMap::from_json(bias_map()).unwrap();
4894        // GLB: find last generated position at or before original col 7
4895        let loc = sm
4896            .generated_position_for_with_bias("input.js", 0, 7, Bias::GreatestLowerBound)
4897            .unwrap();
4898        assert_eq!(loc.column, 5);
4899    }
4900
4901    #[test]
4902    fn generated_position_for_default_bias_is_glb() {
4903        // The default bias must be GreatestLowerBound to match jridgewell's
4904        // generatedPositionFor semantics.
4905        let sm = SourceMap::from_json(bias_map()).unwrap();
4906        // With GLB: looking for original col 7, GLB finds the mapping at col 5
4907        let glb = sm.generated_position_for("input.js", 0, 7).unwrap();
4908        let glb_explicit = sm
4909            .generated_position_for_with_bias("input.js", 0, 7, Bias::GreatestLowerBound)
4910            .unwrap();
4911        assert_eq!(glb.line, glb_explicit.line);
4912        assert_eq!(glb.column, glb_explicit.column);
4913    }
4914
4915    // ── Range mapping tests ──────────────────────────────────────
4916
4917    #[test]
4918    fn map_range_basic() {
4919        let sm = SourceMap::from_json(bias_map()).unwrap();
4920        let range = sm.map_range(0, 0, 0, 10).unwrap();
4921        assert_eq!(range.source, 0);
4922        assert_eq!(range.original_start_line, 0);
4923        assert_eq!(range.original_start_column, 0);
4924        assert_eq!(range.original_end_line, 0);
4925        assert_eq!(range.original_end_column, 10);
4926    }
4927
4928    #[test]
4929    fn map_range_no_mapping() {
4930        let sm = SourceMap::from_json(bias_map()).unwrap();
4931        // Line 5 doesn't exist
4932        let range = sm.map_range(0, 0, 5, 0);
4933        assert!(range.is_none());
4934    }
4935
4936    #[test]
4937    fn map_range_different_sources() {
4938        // Map with two sources: line 0 → src0, line 1 → src1
4939        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA;ACAA"}"#;
4940        let sm = SourceMap::from_json(json).unwrap();
4941        // Start maps to a.js, end maps to b.js → should return None
4942        let range = sm.map_range(0, 0, 1, 0);
4943        assert!(range.is_none());
4944    }
4945
4946    // ── Phase 10 tests ───────────────────────────────────────────
4947
4948    #[test]
4949    fn extension_fields_preserved() {
4950        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","x_facebook_sources":[[{"names":["<global>"]}]],"x_google_linecount":42}"#;
4951        let sm = SourceMap::from_json(json).unwrap();
4952
4953        assert!(sm.extensions.contains_key("x_facebook_sources"));
4954        assert!(sm.extensions.contains_key("x_google_linecount"));
4955        assert_eq!(
4956            sm.extensions.get("x_google_linecount"),
4957            Some(&serde_json::json!(42))
4958        );
4959
4960        // Round-trip preserves extension fields
4961        let output = sm.to_json();
4962        assert!(output.contains("x_facebook_sources"));
4963        assert!(output.contains("x_google_linecount"));
4964    }
4965
4966    #[test]
4967    fn x_google_ignorelist_fallback() {
4968        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA","x_google_ignoreList":[1]}"#;
4969        let sm = SourceMap::from_json(json).unwrap();
4970        assert_eq!(sm.ignore_list, vec![1]);
4971    }
4972
4973    #[test]
4974    fn ignorelist_takes_precedence_over_x_google() {
4975        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA","ignoreList":[0],"x_google_ignoreList":[1]}"#;
4976        let sm = SourceMap::from_json(json).unwrap();
4977        assert_eq!(sm.ignore_list, vec![0]);
4978    }
4979
4980    #[test]
4981    fn source_mapping_url_external() {
4982        let source = "var a = 1;\n//# sourceMappingURL=app.js.map\n";
4983        let result = parse_source_mapping_url(source).unwrap();
4984        assert_eq!(result, SourceMappingUrl::External("app.js.map".to_string()));
4985    }
4986
4987    #[test]
4988    fn source_mapping_url_inline() {
4989        let json = r#"{"version":3,"sources":[],"names":[],"mappings":""}"#;
4990        let b64 = base64_encode_simple(json);
4991        let source =
4992            format!("var a = 1;\n//# sourceMappingURL=data:application/json;base64,{b64}\n");
4993        match parse_source_mapping_url(&source).unwrap() {
4994            SourceMappingUrl::Inline(decoded) => {
4995                assert_eq!(decoded, json);
4996            }
4997            _ => panic!("expected inline"),
4998        }
4999    }
5000
5001    #[test]
5002    fn source_mapping_url_at_sign() {
5003        let source = "var a = 1;\n//@ sourceMappingURL=old-style.map";
5004        let result = parse_source_mapping_url(source).unwrap();
5005        assert_eq!(
5006            result,
5007            SourceMappingUrl::External("old-style.map".to_string())
5008        );
5009    }
5010
5011    #[test]
5012    fn source_mapping_url_css_comment() {
5013        let source = "body { }\n/*# sourceMappingURL=styles.css.map */";
5014        let result = parse_source_mapping_url(source).unwrap();
5015        assert_eq!(
5016            result,
5017            SourceMappingUrl::External("styles.css.map".to_string())
5018        );
5019    }
5020
5021    #[test]
5022    fn source_mapping_url_none() {
5023        let source = "var a = 1;";
5024        assert!(parse_source_mapping_url(source).is_none());
5025    }
5026
5027    #[test]
5028    fn exclude_content_option() {
5029        let json = r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#;
5030        let sm = SourceMap::from_json(json).unwrap();
5031
5032        let with_content = sm.to_json();
5033        assert!(with_content.contains("sourcesContent"));
5034
5035        let without_content = sm.to_json_with_options(true);
5036        assert!(!without_content.contains("sourcesContent"));
5037    }
5038
5039    #[test]
5040    fn validate_deep_clean_map() {
5041        let sm = SourceMap::from_json(simple_map()).unwrap();
5042        let warnings = validate_deep(&sm);
5043        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
5044    }
5045
5046    #[test]
5047    fn validate_deep_unreferenced_source() {
5048        // Source "unused.js" has no mappings pointing to it
5049        let json =
5050            r#"{"version":3,"sources":["used.js","unused.js"],"names":[],"mappings":"AAAA"}"#;
5051        let sm = SourceMap::from_json(json).unwrap();
5052        let warnings = validate_deep(&sm);
5053        assert!(warnings.iter().any(|w| w.contains("unused.js")));
5054    }
5055
5056    // ── from_parts tests ──────────────────────────────────────────
5057
5058    #[test]
5059    fn from_parts_basic() {
5060        let mappings = vec![
5061            Mapping {
5062                generated_line: 0,
5063                generated_column: 0,
5064                source: 0,
5065                original_line: 0,
5066                original_column: 0,
5067                name: NO_NAME,
5068                is_range_mapping: false,
5069            },
5070            Mapping {
5071                generated_line: 1,
5072                generated_column: 4,
5073                source: 0,
5074                original_line: 1,
5075                original_column: 2,
5076                name: NO_NAME,
5077                is_range_mapping: false,
5078            },
5079        ];
5080
5081        let sm = SourceMap::from_parts(
5082            Some("out.js".to_string()),
5083            None,
5084            vec!["input.js".to_string()],
5085            vec![Some("var x = 1;".to_string())],
5086            vec![],
5087            mappings,
5088            vec![],
5089            None,
5090            None,
5091        );
5092
5093        assert_eq!(sm.line_count(), 2);
5094        assert_eq!(sm.mapping_count(), 2);
5095
5096        let loc = sm.original_position_for(0, 0).unwrap();
5097        assert_eq!(loc.source, 0);
5098        assert_eq!(loc.line, 0);
5099        assert_eq!(loc.column, 0);
5100
5101        let loc = sm.original_position_for(1, 4).unwrap();
5102        assert_eq!(loc.line, 1);
5103        assert_eq!(loc.column, 2);
5104    }
5105
5106    #[test]
5107    fn from_parts_empty() {
5108        let sm = SourceMap::from_parts(
5109            None,
5110            None,
5111            vec![],
5112            vec![],
5113            vec![],
5114            vec![],
5115            vec![],
5116            None,
5117            None,
5118        );
5119        assert_eq!(sm.line_count(), 0);
5120        assert_eq!(sm.mapping_count(), 0);
5121        assert!(sm.original_position_for(0, 0).is_none());
5122    }
5123
5124    #[test]
5125    fn from_parts_with_names() {
5126        let mappings = vec![Mapping {
5127            generated_line: 0,
5128            generated_column: 0,
5129            source: 0,
5130            original_line: 0,
5131            original_column: 0,
5132            name: 0,
5133            is_range_mapping: false,
5134        }];
5135
5136        let sm = SourceMap::from_parts(
5137            None,
5138            None,
5139            vec!["input.js".to_string()],
5140            vec![],
5141            vec!["myVar".to_string()],
5142            mappings,
5143            vec![],
5144            None,
5145            None,
5146        );
5147
5148        let loc = sm.original_position_for(0, 0).unwrap();
5149        assert_eq!(loc.name, Some(0));
5150        assert_eq!(sm.name(0), "myVar");
5151    }
5152
5153    #[test]
5154    fn from_parts_roundtrip_via_json() {
5155        let json = generate_test_sourcemap(50, 10, 3);
5156        let sm = SourceMap::from_json(&json).unwrap();
5157
5158        let sm2 = SourceMap::from_parts(
5159            sm.file.clone(),
5160            sm.source_root.clone(),
5161            sm.sources.clone(),
5162            sm.sources_content.clone(),
5163            sm.names.clone(),
5164            sm.all_mappings().to_vec(),
5165            sm.ignore_list.clone(),
5166            sm.debug_id.clone(),
5167            None,
5168        );
5169
5170        assert_eq!(sm2.mapping_count(), sm.mapping_count());
5171        assert_eq!(sm2.line_count(), sm.line_count());
5172
5173        // Spot-check lookups
5174        for m in sm.all_mappings() {
5175            if m.source != NO_SOURCE {
5176                let a = sm.original_position_for(m.generated_line, m.generated_column);
5177                let b = sm2.original_position_for(m.generated_line, m.generated_column);
5178                match (a, b) {
5179                    (Some(a), Some(b)) => {
5180                        assert_eq!(a.source, b.source);
5181                        assert_eq!(a.line, b.line);
5182                        assert_eq!(a.column, b.column);
5183                    }
5184                    (None, None) => {}
5185                    _ => panic!("mismatch at ({}, {})", m.generated_line, m.generated_column),
5186                }
5187            }
5188        }
5189    }
5190
5191    #[test]
5192    fn from_parts_reverse_lookup() {
5193        let mappings = vec![
5194            Mapping {
5195                generated_line: 0,
5196                generated_column: 0,
5197                source: 0,
5198                original_line: 10,
5199                original_column: 5,
5200                name: NO_NAME,
5201                is_range_mapping: false,
5202            },
5203            Mapping {
5204                generated_line: 1,
5205                generated_column: 8,
5206                source: 0,
5207                original_line: 20,
5208                original_column: 0,
5209                name: NO_NAME,
5210                is_range_mapping: false,
5211            },
5212        ];
5213
5214        let sm = SourceMap::from_parts(
5215            None,
5216            None,
5217            vec!["src.js".to_string()],
5218            vec![],
5219            vec![],
5220            mappings,
5221            vec![],
5222            None,
5223            None,
5224        );
5225
5226        let loc = sm.generated_position_for("src.js", 10, 5).unwrap();
5227        assert_eq!(loc.line, 0);
5228        assert_eq!(loc.column, 0);
5229
5230        let loc = sm.generated_position_for("src.js", 20, 0).unwrap();
5231        assert_eq!(loc.line, 1);
5232        assert_eq!(loc.column, 8);
5233    }
5234
5235    #[test]
5236    fn from_parts_sparse_lines() {
5237        let mappings = vec![
5238            Mapping {
5239                generated_line: 0,
5240                generated_column: 0,
5241                source: 0,
5242                original_line: 0,
5243                original_column: 0,
5244                name: NO_NAME,
5245                is_range_mapping: false,
5246            },
5247            Mapping {
5248                generated_line: 5,
5249                generated_column: 0,
5250                source: 0,
5251                original_line: 5,
5252                original_column: 0,
5253                name: NO_NAME,
5254                is_range_mapping: false,
5255            },
5256        ];
5257
5258        let sm = SourceMap::from_parts(
5259            None,
5260            None,
5261            vec!["src.js".to_string()],
5262            vec![],
5263            vec![],
5264            mappings,
5265            vec![],
5266            None,
5267            None,
5268        );
5269
5270        assert_eq!(sm.line_count(), 6);
5271        assert!(sm.original_position_for(0, 0).is_some());
5272        assert!(sm.original_position_for(2, 0).is_none());
5273        assert!(sm.original_position_for(5, 0).is_some());
5274    }
5275
5276    // ── from_json_lines tests ────────────────────────────────────
5277
5278    #[test]
5279    fn from_json_lines_basic() {
5280        let json = generate_test_sourcemap(10, 5, 2);
5281        let sm_full = SourceMap::from_json(&json).unwrap();
5282
5283        // Decode only lines 3..7
5284        let sm_partial = SourceMap::from_json_lines(&json, 3, 7).unwrap();
5285
5286        // Verify mappings for lines in range match
5287        for line in 3..7u32 {
5288            let full_mappings = sm_full.mappings_for_line(line);
5289            let partial_mappings = sm_partial.mappings_for_line(line);
5290            assert_eq!(
5291                full_mappings.len(),
5292                partial_mappings.len(),
5293                "line {line} mapping count mismatch"
5294            );
5295            for (a, b) in full_mappings.iter().zip(partial_mappings.iter()) {
5296                assert_eq!(a.generated_column, b.generated_column);
5297                assert_eq!(a.source, b.source);
5298                assert_eq!(a.original_line, b.original_line);
5299                assert_eq!(a.original_column, b.original_column);
5300                assert_eq!(a.name, b.name);
5301            }
5302        }
5303    }
5304
5305    #[test]
5306    fn from_json_lines_first_lines() {
5307        let json = generate_test_sourcemap(10, 5, 2);
5308        let sm_full = SourceMap::from_json(&json).unwrap();
5309        let sm_partial = SourceMap::from_json_lines(&json, 0, 3).unwrap();
5310
5311        for line in 0..3u32 {
5312            let full_mappings = sm_full.mappings_for_line(line);
5313            let partial_mappings = sm_partial.mappings_for_line(line);
5314            assert_eq!(full_mappings.len(), partial_mappings.len());
5315        }
5316    }
5317
5318    #[test]
5319    fn from_json_lines_last_lines() {
5320        let json = generate_test_sourcemap(10, 5, 2);
5321        let sm_full = SourceMap::from_json(&json).unwrap();
5322        let sm_partial = SourceMap::from_json_lines(&json, 7, 10).unwrap();
5323
5324        for line in 7..10u32 {
5325            let full_mappings = sm_full.mappings_for_line(line);
5326            let partial_mappings = sm_partial.mappings_for_line(line);
5327            assert_eq!(full_mappings.len(), partial_mappings.len(), "line {line}");
5328        }
5329    }
5330
5331    #[test]
5332    fn from_json_lines_empty_range() {
5333        let json = generate_test_sourcemap(10, 5, 2);
5334        let sm = SourceMap::from_json_lines(&json, 5, 5).unwrap();
5335        assert_eq!(sm.mapping_count(), 0);
5336    }
5337
5338    #[test]
5339    fn from_json_lines_beyond_end() {
5340        let json = generate_test_sourcemap(5, 3, 1);
5341        // Request lines beyond what exists
5342        let sm = SourceMap::from_json_lines(&json, 3, 100).unwrap();
5343        // Should have mappings for lines 3 and 4 (the ones that exist in the range)
5344        assert!(sm.mapping_count() > 0);
5345    }
5346
5347    #[test]
5348    fn from_json_lines_single_line() {
5349        let json = generate_test_sourcemap(10, 5, 2);
5350        let sm_full = SourceMap::from_json(&json).unwrap();
5351        let sm_partial = SourceMap::from_json_lines(&json, 5, 6).unwrap();
5352
5353        let full_mappings = sm_full.mappings_for_line(5);
5354        let partial_mappings = sm_partial.mappings_for_line(5);
5355        assert_eq!(full_mappings.len(), partial_mappings.len());
5356    }
5357
5358    // ── LazySourceMap tests ──────────────────────────────────────
5359
5360    #[test]
5361    fn lazy_basic_lookup() {
5362        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA;AACA"}"#;
5363        let sm = LazySourceMap::from_json(json).unwrap();
5364
5365        assert_eq!(sm.line_count(), 2);
5366        assert_eq!(sm.sources, vec!["input.js"]);
5367
5368        let loc = sm.original_position_for(0, 0).unwrap();
5369        assert_eq!(sm.source(loc.source), "input.js");
5370        assert_eq!(loc.line, 0);
5371        assert_eq!(loc.column, 0);
5372    }
5373
5374    #[test]
5375    fn lazy_multiple_lines() {
5376        let json = generate_test_sourcemap(20, 5, 3);
5377        let sm_eager = SourceMap::from_json(&json).unwrap();
5378        let sm_lazy = LazySourceMap::from_json(&json).unwrap();
5379
5380        assert_eq!(sm_lazy.line_count(), sm_eager.line_count());
5381
5382        // Verify lookups match for every mapping
5383        for m in sm_eager.all_mappings() {
5384            if m.source == NO_SOURCE {
5385                continue;
5386            }
5387            let eager_loc = sm_eager
5388                .original_position_for(m.generated_line, m.generated_column)
5389                .unwrap();
5390            let lazy_loc = sm_lazy
5391                .original_position_for(m.generated_line, m.generated_column)
5392                .unwrap();
5393            assert_eq!(eager_loc.source, lazy_loc.source);
5394            assert_eq!(eager_loc.line, lazy_loc.line);
5395            assert_eq!(eager_loc.column, lazy_loc.column);
5396            assert_eq!(eager_loc.name, lazy_loc.name);
5397        }
5398    }
5399
5400    #[test]
5401    fn lazy_empty_mappings() {
5402        let json = r#"{"version":3,"sources":[],"names":[],"mappings":""}"#;
5403        let sm = LazySourceMap::from_json(json).unwrap();
5404        assert_eq!(sm.line_count(), 0);
5405        assert!(sm.original_position_for(0, 0).is_none());
5406    }
5407
5408    #[test]
5409    fn lazy_empty_lines() {
5410        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;;;AACA"}"#;
5411        let sm = LazySourceMap::from_json(json).unwrap();
5412        assert_eq!(sm.line_count(), 4);
5413
5414        assert!(sm.original_position_for(0, 0).is_some());
5415        assert!(sm.original_position_for(1, 0).is_none());
5416        assert!(sm.original_position_for(2, 0).is_none());
5417        assert!(sm.original_position_for(3, 0).is_some());
5418    }
5419
5420    #[test]
5421    fn lazy_decode_line_caching() {
5422        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,KACA;AACA"}"#;
5423        let sm = LazySourceMap::from_json(json).unwrap();
5424
5425        // First call decodes
5426        let line0_a = sm.decode_line(0).unwrap();
5427        // Second call should return cached
5428        let line0_b = sm.decode_line(0).unwrap();
5429        assert_eq!(line0_a.len(), line0_b.len());
5430        assert_eq!(line0_a[0].generated_column, line0_b[0].generated_column);
5431    }
5432
5433    #[test]
5434    fn lazy_with_names() {
5435        let json = r#"{"version":3,"sources":["input.js"],"names":["foo","bar"],"mappings":"AAAAA,KACAC"}"#;
5436        let sm = LazySourceMap::from_json(json).unwrap();
5437
5438        let loc = sm.original_position_for(0, 0).unwrap();
5439        assert_eq!(loc.name, Some(0));
5440        assert_eq!(sm.name(0), "foo");
5441
5442        let loc = sm.original_position_for(0, 5).unwrap();
5443        assert_eq!(loc.name, Some(1));
5444        assert_eq!(sm.name(1), "bar");
5445    }
5446
5447    #[test]
5448    fn lazy_nonexistent_line() {
5449        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
5450        let sm = LazySourceMap::from_json(json).unwrap();
5451        assert!(sm.original_position_for(99, 0).is_none());
5452        let line = sm.decode_line(99).unwrap();
5453        assert!(line.is_empty());
5454    }
5455
5456    #[test]
5457    fn lazy_into_sourcemap() {
5458        let json = generate_test_sourcemap(20, 5, 3);
5459        let sm_eager = SourceMap::from_json(&json).unwrap();
5460        let sm_lazy = LazySourceMap::from_json(&json).unwrap();
5461        let sm_converted = sm_lazy.into_sourcemap().unwrap();
5462
5463        assert_eq!(sm_converted.mapping_count(), sm_eager.mapping_count());
5464        assert_eq!(sm_converted.line_count(), sm_eager.line_count());
5465
5466        // Verify all lookups match
5467        for m in sm_eager.all_mappings() {
5468            let a = sm_eager.original_position_for(m.generated_line, m.generated_column);
5469            let b = sm_converted.original_position_for(m.generated_line, m.generated_column);
5470            match (a, b) {
5471                (Some(a), Some(b)) => {
5472                    assert_eq!(a.source, b.source);
5473                    assert_eq!(a.line, b.line);
5474                    assert_eq!(a.column, b.column);
5475                }
5476                (None, None) => {}
5477                _ => panic!("mismatch at ({}, {})", m.generated_line, m.generated_column),
5478            }
5479        }
5480    }
5481
5482    #[test]
5483    fn lazy_source_index_lookup() {
5484        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA;ACAA"}"#;
5485        let sm = LazySourceMap::from_json(json).unwrap();
5486        assert_eq!(sm.source_index("a.js"), Some(0));
5487        assert_eq!(sm.source_index("b.js"), Some(1));
5488        assert_eq!(sm.source_index("c.js"), None);
5489    }
5490
5491    #[test]
5492    fn lazy_mappings_for_line() {
5493        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,KACA;AACA"}"#;
5494        let sm = LazySourceMap::from_json(json).unwrap();
5495
5496        let line0 = sm.mappings_for_line(0);
5497        assert_eq!(line0.len(), 2);
5498
5499        let line1 = sm.mappings_for_line(1);
5500        assert_eq!(line1.len(), 1);
5501
5502        let line99 = sm.mappings_for_line(99);
5503        assert!(line99.is_empty());
5504    }
5505
5506    #[test]
5507    fn lazy_large_map_selective_decode() {
5508        // Generate a large map but only decode a few lines
5509        let json = generate_test_sourcemap(100, 10, 5);
5510        let sm_eager = SourceMap::from_json(&json).unwrap();
5511        let sm_lazy = LazySourceMap::from_json(&json).unwrap();
5512
5513        // Only decode lines 50 and 75
5514        for line in [50, 75] {
5515            let eager_mappings = sm_eager.mappings_for_line(line);
5516            let lazy_mappings = sm_lazy.mappings_for_line(line);
5517            assert_eq!(
5518                eager_mappings.len(),
5519                lazy_mappings.len(),
5520                "line {line} count mismatch"
5521            );
5522            for (a, b) in eager_mappings.iter().zip(lazy_mappings.iter()) {
5523                assert_eq!(a.generated_column, b.generated_column);
5524                assert_eq!(a.source, b.source);
5525                assert_eq!(a.original_line, b.original_line);
5526                assert_eq!(a.original_column, b.original_column);
5527                assert_eq!(a.name, b.name);
5528            }
5529        }
5530    }
5531
5532    #[test]
5533    fn lazy_single_field_segments() {
5534        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,KAAAA"}"#;
5535        let sm = LazySourceMap::from_json(json).unwrap();
5536
5537        // First segment is single-field (no source info)
5538        assert!(sm.original_position_for(0, 0).is_none());
5539        // Second segment has source info
5540        let loc = sm.original_position_for(0, 5).unwrap();
5541        assert_eq!(loc.source, 0);
5542    }
5543
5544    // ── Coverage gap tests ──────────────────────────────────────────
5545
5546    #[test]
5547    fn parse_error_display_vlq() {
5548        let err = ParseError::Vlq(srcmap_codec::DecodeError::UnexpectedEof { offset: 3 });
5549        assert!(err.to_string().contains("VLQ decode error"));
5550    }
5551
5552    #[test]
5553    fn parse_error_display_scopes() {
5554        let err = ParseError::Scopes(srcmap_scopes::ScopesError::UnclosedScope);
5555        assert!(err.to_string().contains("scopes decode error"));
5556    }
5557
5558    #[test]
5559    fn indexed_map_with_names_in_sections() {
5560        let json = r#"{
5561            "version": 3,
5562            "sections": [
5563                {
5564                    "offset": {"line": 0, "column": 0},
5565                    "map": {
5566                        "version": 3,
5567                        "sources": ["a.js"],
5568                        "names": ["foo"],
5569                        "mappings": "AAAAA"
5570                    }
5571                },
5572                {
5573                    "offset": {"line": 1, "column": 0},
5574                    "map": {
5575                        "version": 3,
5576                        "sources": ["a.js"],
5577                        "names": ["foo"],
5578                        "mappings": "AAAAA"
5579                    }
5580                }
5581            ]
5582        }"#;
5583        let sm = SourceMap::from_json(json).unwrap();
5584        // Sources and names should be deduplicated
5585        assert_eq!(sm.sources.len(), 1);
5586        assert_eq!(sm.names.len(), 1);
5587    }
5588
5589    #[test]
5590    fn indexed_map_with_ignore_list() {
5591        let json = r#"{
5592            "version": 3,
5593            "sections": [
5594                {
5595                    "offset": {"line": 0, "column": 0},
5596                    "map": {
5597                        "version": 3,
5598                        "sources": ["vendor.js"],
5599                        "names": [],
5600                        "mappings": "AAAA",
5601                        "ignoreList": [0]
5602                    }
5603                }
5604            ]
5605        }"#;
5606        let sm = SourceMap::from_json(json).unwrap();
5607        assert_eq!(sm.ignore_list, vec![0]);
5608    }
5609
5610    #[test]
5611    fn indexed_map_with_generated_only_segment() {
5612        // Section with a generated-only (1-field) segment
5613        let json = r#"{
5614            "version": 3,
5615            "sections": [
5616                {
5617                    "offset": {"line": 0, "column": 0},
5618                    "map": {
5619                        "version": 3,
5620                        "sources": ["a.js"],
5621                        "names": [],
5622                        "mappings": "A,AAAA"
5623                    }
5624                }
5625            ]
5626        }"#;
5627        let sm = SourceMap::from_json(json).unwrap();
5628        assert!(sm.mapping_count() >= 1);
5629    }
5630
5631    #[test]
5632    fn indexed_map_empty_mappings() {
5633        let json = r#"{
5634            "version": 3,
5635            "sections": [
5636                {
5637                    "offset": {"line": 0, "column": 0},
5638                    "map": {
5639                        "version": 3,
5640                        "sources": [],
5641                        "names": [],
5642                        "mappings": ""
5643                    }
5644                }
5645            ]
5646        }"#;
5647        let sm = SourceMap::from_json(json).unwrap();
5648        assert_eq!(sm.mapping_count(), 0);
5649    }
5650
5651    #[test]
5652    fn generated_position_glb_exact_match() {
5653        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAE,OAAO"}"#;
5654        let sm = SourceMap::from_json(json).unwrap();
5655
5656        let loc = sm.generated_position_for_with_bias("a.js", 0, 0, Bias::GreatestLowerBound);
5657        assert!(loc.is_some());
5658        assert_eq!(loc.unwrap().column, 0);
5659    }
5660
5661    #[test]
5662    fn generated_position_glb_no_exact_match() {
5663        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAE"}"#;
5664        let sm = SourceMap::from_json(json).unwrap();
5665
5666        // Look for position between two mappings
5667        let loc = sm.generated_position_for_with_bias("a.js", 0, 0, Bias::GreatestLowerBound);
5668        assert!(loc.is_some());
5669    }
5670
5671    #[test]
5672    fn generated_position_glb_wrong_source() {
5673        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA,KCCA"}"#;
5674        let sm = SourceMap::from_json(json).unwrap();
5675
5676        // GLB for position in b.js that doesn't exist at that location
5677        let loc = sm.generated_position_for_with_bias("b.js", 5, 0, Bias::GreatestLowerBound);
5678        // Should find something or nothing depending on whether there's a mapping before
5679        // The key is that source filtering works
5680        if let Some(l) = loc {
5681            // Verify returned position is valid (line 0 is the only generated line)
5682            assert_eq!(l.line, 0);
5683        }
5684    }
5685
5686    #[test]
5687    fn generated_position_lub_wrong_source() {
5688        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
5689        let sm = SourceMap::from_json(json).unwrap();
5690
5691        // LUB for non-existent source
5692        let loc =
5693            sm.generated_position_for_with_bias("nonexistent.js", 0, 0, Bias::LeastUpperBound);
5694        assert!(loc.is_none());
5695    }
5696
5697    #[test]
5698    fn to_json_with_ignore_list() {
5699        let json = r#"{"version":3,"sources":["vendor.js"],"names":[],"mappings":"AAAA","ignoreList":[0]}"#;
5700        let sm = SourceMap::from_json(json).unwrap();
5701        let output = sm.to_json();
5702        assert!(output.contains("\"ignoreList\":[0]"));
5703    }
5704
5705    #[test]
5706    fn to_json_with_extensions() {
5707        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","x_custom":"test_value"}"#;
5708        let sm = SourceMap::from_json(json).unwrap();
5709        let output = sm.to_json();
5710        assert!(output.contains("x_custom"));
5711        assert!(output.contains("test_value"));
5712    }
5713
5714    #[test]
5715    fn from_parts_empty_mappings() {
5716        let sm = SourceMap::from_parts(
5717            None,
5718            None,
5719            vec!["a.js".to_string()],
5720            vec![Some("content".to_string())],
5721            vec![],
5722            vec![],
5723            vec![],
5724            None,
5725            None,
5726        );
5727        assert_eq!(sm.mapping_count(), 0);
5728        assert_eq!(sm.sources, vec!["a.js"]);
5729    }
5730
5731    #[test]
5732    fn from_vlq_basic() {
5733        let sm = SourceMap::from_vlq(
5734            "AAAA;AACA",
5735            vec!["a.js".to_string()],
5736            vec![],
5737            Some("out.js".to_string()),
5738            None,
5739            vec![Some("content".to_string())],
5740            vec![],
5741            None,
5742        )
5743        .unwrap();
5744
5745        assert_eq!(sm.file.as_deref(), Some("out.js"));
5746        assert_eq!(sm.sources, vec!["a.js"]);
5747        let loc = sm.original_position_for(0, 0).unwrap();
5748        assert_eq!(sm.source(loc.source), "a.js");
5749        assert_eq!(loc.line, 0);
5750    }
5751
5752    #[test]
5753    fn from_json_lines_basic_coverage() {
5754        let json =
5755            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA"}"#;
5756        let sm = SourceMap::from_json_lines(json, 1, 3).unwrap();
5757        // Should have mappings for lines 1 and 2
5758        assert!(sm.original_position_for(1, 0).is_some());
5759        assert!(sm.original_position_for(2, 0).is_some());
5760    }
5761
5762    #[test]
5763    fn from_json_lines_with_source_root() {
5764        let json = r#"{"version":3,"sourceRoot":"src/","sources":["a.js"],"names":[],"mappings":"AAAA;AACA"}"#;
5765        let sm = SourceMap::from_json_lines(json, 0, 2).unwrap();
5766        assert_eq!(sm.sources[0], "src/a.js");
5767    }
5768
5769    #[test]
5770    fn from_json_lines_with_null_source() {
5771        let json = r#"{"version":3,"sources":[null,"a.js"],"names":[],"mappings":"AAAA,KCCA"}"#;
5772        let sm = SourceMap::from_json_lines(json, 0, 1).unwrap();
5773        assert_eq!(sm.sources.len(), 2);
5774    }
5775
5776    #[test]
5777    fn json_escaping_special_chars_sourcemap() {
5778        // Build a source map with special chars in source name and content via JSON
5779        // The source name has a newline, the content has \r\n, tab, quotes, backslash, and control char
5780        let json = r#"{"version":3,"sources":["path/with\nnewline.js"],"sourcesContent":["line1\r\nline2\t\"quoted\"\\\u0001"],"names":[],"mappings":"AAAA"}"#;
5781        let sm = SourceMap::from_json(json).unwrap();
5782        // Roundtrip through to_json and re-parse
5783        let output = sm.to_json();
5784        let sm2 = SourceMap::from_json(&output).unwrap();
5785        assert_eq!(sm.sources[0], sm2.sources[0]);
5786        assert_eq!(sm.sources_content[0], sm2.sources_content[0]);
5787    }
5788
5789    #[test]
5790    fn to_json_exclude_content() {
5791        let json = r#"{"version":3,"sources":["a.js"],"sourcesContent":["var a;"],"names":[],"mappings":"AAAA"}"#;
5792        let sm = SourceMap::from_json(json).unwrap();
5793        let output = sm.to_json_with_options(true);
5794        assert!(!output.contains("sourcesContent"));
5795        let output_with = sm.to_json_with_options(false);
5796        assert!(output_with.contains("sourcesContent"));
5797    }
5798
5799    #[test]
5800    fn encode_mappings_with_name() {
5801        // Ensure encode_mappings handles the name field (5th VLQ)
5802        let json = r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AAAAA"}"#;
5803        let sm = SourceMap::from_json(json).unwrap();
5804        let encoded = sm.encode_mappings();
5805        assert_eq!(encoded, "AAAAA");
5806    }
5807
5808    #[test]
5809    fn encode_mappings_generated_only() {
5810        // Generated-only segments (NO_SOURCE) in encode
5811        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,AAAA"}"#;
5812        let sm = SourceMap::from_json(json).unwrap();
5813        let encoded = sm.encode_mappings();
5814        let roundtrip = SourceMap::from_json(&format!(
5815            r#"{{"version":3,"sources":["a.js"],"names":[],"mappings":"{}"}}"#,
5816            encoded
5817        ))
5818        .unwrap();
5819        assert_eq!(roundtrip.mapping_count(), sm.mapping_count());
5820    }
5821
5822    #[test]
5823    fn map_range_single_result() {
5824        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA,EAAC,OAAO"}"#;
5825        let sm = SourceMap::from_json(json).unwrap();
5826        // map_range from col 0 to a mapped column
5827        let result = sm.map_range(0, 0, 0, 1);
5828        assert!(result.is_some());
5829        let range = result.unwrap();
5830        assert_eq!(range.source, 0);
5831    }
5832
5833    #[test]
5834    fn scopes_in_from_json() {
5835        // Source map with scopes field - build scopes string, then embed in JSON
5836        let info = srcmap_scopes::ScopeInfo {
5837            scopes: vec![Some(srcmap_scopes::OriginalScope {
5838                start: srcmap_scopes::Position { line: 0, column: 0 },
5839                end: srcmap_scopes::Position { line: 5, column: 0 },
5840                name: None,
5841                kind: None,
5842                is_stack_frame: false,
5843                variables: vec![],
5844                children: vec![],
5845            })],
5846            ranges: vec![],
5847        };
5848        let mut names = vec![];
5849        let scopes_str = srcmap_scopes::encode_scopes(&info, &mut names);
5850
5851        let json = format!(
5852            r#"{{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","scopes":"{scopes_str}"}}"#
5853        );
5854
5855        let sm = SourceMap::from_json(&json).unwrap();
5856        assert!(sm.scopes.is_some());
5857    }
5858
5859    #[test]
5860    fn from_json_lines_with_scopes() {
5861        let info = srcmap_scopes::ScopeInfo {
5862            scopes: vec![Some(srcmap_scopes::OriginalScope {
5863                start: srcmap_scopes::Position { line: 0, column: 0 },
5864                end: srcmap_scopes::Position { line: 5, column: 0 },
5865                name: None,
5866                kind: None,
5867                is_stack_frame: false,
5868                variables: vec![],
5869                children: vec![],
5870            })],
5871            ranges: vec![],
5872        };
5873        let mut names = vec![];
5874        let scopes_str = srcmap_scopes::encode_scopes(&info, &mut names);
5875        let json = format!(
5876            r#"{{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA","scopes":"{scopes_str}"}}"#
5877        );
5878        let sm = SourceMap::from_json_lines(&json, 0, 2).unwrap();
5879        assert!(sm.scopes.is_some());
5880    }
5881
5882    #[test]
5883    fn from_json_lines_with_extensions() {
5884        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","x_custom":"val","not_x":"skip"}"#;
5885        let sm = SourceMap::from_json_lines(json, 0, 1).unwrap();
5886        assert!(sm.extensions.contains_key("x_custom"));
5887        assert!(!sm.extensions.contains_key("not_x"));
5888    }
5889
5890    #[test]
5891    fn lazy_sourcemap_version_error() {
5892        let json = r#"{"version":2,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
5893        let err = LazySourceMap::from_json(json).unwrap_err();
5894        assert!(matches!(err, ParseError::InvalidVersion(2)));
5895    }
5896
5897    #[test]
5898    fn lazy_sourcemap_with_source_root() {
5899        let json =
5900            r#"{"version":3,"sourceRoot":"src/","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
5901        let sm = LazySourceMap::from_json(json).unwrap();
5902        assert_eq!(sm.sources[0], "src/a.js");
5903    }
5904
5905    #[test]
5906    fn lazy_sourcemap_with_ignore_list_and_extensions() {
5907        let json = r#"{"version":3,"sources":["v.js"],"names":[],"mappings":"AAAA","ignoreList":[0],"x_custom":"val","not_x":"skip"}"#;
5908        let sm = LazySourceMap::from_json(json).unwrap();
5909        assert_eq!(sm.ignore_list, vec![0]);
5910        assert!(sm.extensions.contains_key("x_custom"));
5911        assert!(!sm.extensions.contains_key("not_x"));
5912    }
5913
5914    #[test]
5915    fn lazy_sourcemap_with_scopes() {
5916        let info = srcmap_scopes::ScopeInfo {
5917            scopes: vec![Some(srcmap_scopes::OriginalScope {
5918                start: srcmap_scopes::Position { line: 0, column: 0 },
5919                end: srcmap_scopes::Position { line: 5, column: 0 },
5920                name: None,
5921                kind: None,
5922                is_stack_frame: false,
5923                variables: vec![],
5924                children: vec![],
5925            })],
5926            ranges: vec![],
5927        };
5928        let mut names = vec![];
5929        let scopes_str = srcmap_scopes::encode_scopes(&info, &mut names);
5930        let json = format!(
5931            r#"{{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","scopes":"{scopes_str}"}}"#
5932        );
5933        let sm = LazySourceMap::from_json(&json).unwrap();
5934        assert!(sm.scopes.is_some());
5935    }
5936
5937    #[test]
5938    fn lazy_sourcemap_null_source() {
5939        let json = r#"{"version":3,"sources":[null,"a.js"],"names":[],"mappings":"AAAA,KCCA"}"#;
5940        let sm = LazySourceMap::from_json(json).unwrap();
5941        assert_eq!(sm.sources.len(), 2);
5942    }
5943
5944    #[test]
5945    fn indexed_map_multi_line_section() {
5946        // Multi-line section to exercise line_offsets building in from_sections
5947        let json = r#"{
5948            "version": 3,
5949            "sections": [
5950                {
5951                    "offset": {"line": 0, "column": 0},
5952                    "map": {
5953                        "version": 3,
5954                        "sources": ["a.js"],
5955                        "names": [],
5956                        "mappings": "AAAA;AACA;AACA"
5957                    }
5958                },
5959                {
5960                    "offset": {"line": 5, "column": 0},
5961                    "map": {
5962                        "version": 3,
5963                        "sources": ["b.js"],
5964                        "names": [],
5965                        "mappings": "AAAA;AACA"
5966                    }
5967                }
5968            ]
5969        }"#;
5970        let sm = SourceMap::from_json(json).unwrap();
5971        assert!(sm.original_position_for(0, 0).is_some());
5972        assert!(sm.original_position_for(5, 0).is_some());
5973    }
5974
5975    #[test]
5976    fn source_mapping_url_extraction() {
5977        // External URL
5978        let input = "var x = 1;\n//# sourceMappingURL=bundle.js.map";
5979        let url = parse_source_mapping_url(input);
5980        assert!(matches!(url, Some(SourceMappingUrl::External(ref s)) if s == "bundle.js.map"));
5981
5982        // CSS comment style
5983        let input = "body { }\n/*# sourceMappingURL=style.css.map */";
5984        let url = parse_source_mapping_url(input);
5985        assert!(matches!(url, Some(SourceMappingUrl::External(ref s)) if s == "style.css.map"));
5986
5987        // @ sign variant
5988        let input = "var x;\n//@ sourceMappingURL=old-style.map";
5989        let url = parse_source_mapping_url(input);
5990        assert!(matches!(url, Some(SourceMappingUrl::External(ref s)) if s == "old-style.map"));
5991
5992        // CSS @ variant
5993        let input = "body{}\n/*@ sourceMappingURL=old-css.map */";
5994        let url = parse_source_mapping_url(input);
5995        assert!(matches!(url, Some(SourceMappingUrl::External(ref s)) if s == "old-css.map"));
5996
5997        // No URL
5998        let input = "var x = 1;";
5999        let url = parse_source_mapping_url(input);
6000        assert!(url.is_none());
6001
6002        // Empty URL
6003        let input = "//# sourceMappingURL=";
6004        let url = parse_source_mapping_url(input);
6005        assert!(url.is_none());
6006
6007        // Inline data URI
6008        let map_json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
6009        let encoded = base64_encode_simple(map_json);
6010        let input = format!("var x;\n//# sourceMappingURL=data:application/json;base64,{encoded}");
6011        let url = parse_source_mapping_url(&input);
6012        assert!(matches!(url, Some(SourceMappingUrl::Inline(_))));
6013    }
6014
6015    #[test]
6016    fn validate_deep_unreferenced_coverage() {
6017        // Map with an unreferenced source
6018        let sm = SourceMap::from_parts(
6019            None,
6020            None,
6021            vec!["used.js".to_string(), "unused.js".to_string()],
6022            vec![None, None],
6023            vec![],
6024            vec![Mapping {
6025                generated_line: 0,
6026                generated_column: 0,
6027                source: 0,
6028                original_line: 0,
6029                original_column: 0,
6030                name: NO_NAME,
6031                is_range_mapping: false,
6032            }],
6033            vec![],
6034            None,
6035            None,
6036        );
6037        let warnings = validate_deep(&sm);
6038        assert!(warnings.iter().any(|w| w.contains("unreferenced")));
6039    }
6040
6041    #[test]
6042    fn from_json_lines_generated_only_segment() {
6043        // from_json_lines with 1-field segments to exercise the generated-only branch
6044        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A,AAAA;AACA"}"#;
6045        let sm = SourceMap::from_json_lines(json, 0, 2).unwrap();
6046        assert!(sm.mapping_count() >= 2);
6047    }
6048
6049    #[test]
6050    fn from_json_lines_with_names() {
6051        let json = r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AAAAA;AACAA"}"#;
6052        let sm = SourceMap::from_json_lines(json, 0, 2).unwrap();
6053        let loc = sm.original_position_for(0, 0).unwrap();
6054        assert_eq!(loc.name, Some(0));
6055    }
6056
6057    #[test]
6058    fn from_parts_with_line_gap() {
6059        // Mappings with a gap between lines to exercise line_offsets forward fill
6060        let sm = SourceMap::from_parts(
6061            None,
6062            None,
6063            vec!["a.js".to_string()],
6064            vec![None],
6065            vec![],
6066            vec![
6067                Mapping {
6068                    generated_line: 0,
6069                    generated_column: 0,
6070                    source: 0,
6071                    original_line: 0,
6072                    original_column: 0,
6073                    name: NO_NAME,
6074                    is_range_mapping: false,
6075                },
6076                Mapping {
6077                    generated_line: 5,
6078                    generated_column: 0,
6079                    source: 0,
6080                    original_line: 5,
6081                    original_column: 0,
6082                    name: NO_NAME,
6083                    is_range_mapping: false,
6084                },
6085            ],
6086            vec![],
6087            None,
6088            None,
6089        );
6090        assert!(sm.original_position_for(0, 0).is_some());
6091        assert!(sm.original_position_for(5, 0).is_some());
6092        // Lines 1-4 have no mappings
6093        assert!(sm.original_position_for(1, 0).is_none());
6094    }
6095
6096    #[test]
6097    fn lazy_decode_line_with_names_and_generated_only() {
6098        // LazySourceMap with both named and generated-only segments
6099        let json = r#"{"version":3,"sources":["a.js"],"names":["fn"],"mappings":"A,AAAAC"}"#;
6100        let sm = LazySourceMap::from_json(json).unwrap();
6101        let line = sm.decode_line(0).unwrap();
6102        assert!(line.len() >= 2);
6103        // First is generated-only
6104        assert_eq!(line[0].source, NO_SOURCE);
6105        // Second has name
6106        assert_ne!(line[1].name, NO_NAME);
6107    }
6108
6109    #[test]
6110    fn generated_position_glb_source_mismatch() {
6111        // a.js maps at (0,0)->(0,0), b.js maps at (0,5)->(1,0)
6112        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA,KCCA"}"#;
6113        let sm = SourceMap::from_json(json).unwrap();
6114
6115        // LUB for source that exists but position is way beyond all mappings
6116        let loc = sm.generated_position_for_with_bias("a.js", 100, 0, Bias::LeastUpperBound);
6117        assert!(loc.is_none());
6118
6119        // GLB for position before the only mapping in b.js (b.js has mapping at original 1,0)
6120        // Searching for (0,0) in b.js: partition_point finds first >= target,
6121        // then idx-1 if not exact, but that idx-1 maps to a.js (source mismatch), so None
6122        let loc = sm.generated_position_for_with_bias("b.js", 0, 0, Bias::GreatestLowerBound);
6123        assert!(loc.is_none());
6124
6125        // GLB for exact position in b.js
6126        let loc = sm.generated_position_for_with_bias("b.js", 1, 0, Bias::GreatestLowerBound);
6127        assert!(loc.is_some());
6128
6129        // LUB source mismatch: search for position in b.js that lands on a.js mapping
6130        let loc = sm.generated_position_for_with_bias("b.js", 99, 0, Bias::LeastUpperBound);
6131        assert!(loc.is_none());
6132    }
6133
6134    // ── Coverage gap tests ───────────────────────────────────────────
6135
6136    #[test]
6137    fn from_json_invalid_scopes_error() {
6138        // Invalid scopes string to trigger ParseError::Scopes
6139        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","scopes":"!!invalid!!"}"#;
6140        let err = SourceMap::from_json(json).unwrap_err();
6141        assert!(matches!(err, ParseError::Scopes(_)));
6142    }
6143
6144    #[test]
6145    fn lazy_from_json_invalid_scopes_error() {
6146        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","scopes":"!!invalid!!"}"#;
6147        let err = LazySourceMap::from_json(json).unwrap_err();
6148        assert!(matches!(err, ParseError::Scopes(_)));
6149    }
6150
6151    #[test]
6152    fn from_json_lines_invalid_scopes_error() {
6153        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","scopes":"!!invalid!!"}"#;
6154        let err = SourceMap::from_json_lines(json, 0, 1).unwrap_err();
6155        assert!(matches!(err, ParseError::Scopes(_)));
6156    }
6157
6158    #[test]
6159    fn from_json_lines_invalid_version() {
6160        let json = r#"{"version":2,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
6161        let err = SourceMap::from_json_lines(json, 0, 1).unwrap_err();
6162        assert!(matches!(err, ParseError::InvalidVersion(2)));
6163    }
6164
6165    #[test]
6166    fn indexed_map_with_ignore_list_remapped() {
6167        // Indexed map with 2 sections that have overlapping ignore_list
6168        let json = r#"{
6169            "version": 3,
6170            "sections": [{
6171                "offset": {"line": 0, "column": 0},
6172                "map": {
6173                    "version": 3,
6174                    "sources": ["a.js", "b.js"],
6175                    "names": [],
6176                    "mappings": "AAAA;ACAA",
6177                    "ignoreList": [1]
6178                }
6179            }, {
6180                "offset": {"line": 5, "column": 0},
6181                "map": {
6182                    "version": 3,
6183                    "sources": ["b.js", "c.js"],
6184                    "names": [],
6185                    "mappings": "AAAA;ACAA",
6186                    "ignoreList": [0]
6187                }
6188            }]
6189        }"#;
6190        let sm = SourceMap::from_json(json).unwrap();
6191        // b.js should be deduped across sections, ignore_list should have b.js global index
6192        assert!(!sm.ignore_list.is_empty());
6193    }
6194
6195    #[test]
6196    fn to_json_with_debug_id() {
6197        let sm = SourceMap::from_parts(
6198            Some("out.js".to_string()),
6199            None,
6200            vec!["a.js".to_string()],
6201            vec![None],
6202            vec![],
6203            vec![Mapping {
6204                generated_line: 0,
6205                generated_column: 0,
6206                source: 0,
6207                original_line: 0,
6208                original_column: 0,
6209                name: NO_NAME,
6210                is_range_mapping: false,
6211            }],
6212            vec![],
6213            Some("abc-123".to_string()),
6214            None,
6215        );
6216        let json = sm.to_json();
6217        assert!(json.contains(r#""debugId":"abc-123""#));
6218    }
6219
6220    #[test]
6221    fn to_json_with_ignore_list_and_extensions() {
6222        let mut sm = SourceMap::from_parts(
6223            None,
6224            None,
6225            vec!["a.js".to_string(), "b.js".to_string()],
6226            vec![None, None],
6227            vec![],
6228            vec![Mapping {
6229                generated_line: 0,
6230                generated_column: 0,
6231                source: 0,
6232                original_line: 0,
6233                original_column: 0,
6234                name: NO_NAME,
6235                is_range_mapping: false,
6236            }],
6237            vec![1],
6238            None,
6239            None,
6240        );
6241        sm.extensions
6242            .insert("x_test".to_string(), serde_json::json!(42));
6243        let json = sm.to_json();
6244        assert!(json.contains("\"ignoreList\":[1]"));
6245        assert!(json.contains("\"x_test\":42"));
6246    }
6247
6248    #[test]
6249    fn from_vlq_with_all_options() {
6250        let sm = SourceMap::from_vlq(
6251            "AAAA;AACA",
6252            vec!["a.js".to_string()],
6253            vec![],
6254            Some("out.js".to_string()),
6255            Some("src/".to_string()),
6256            vec![Some("content".to_string())],
6257            vec![0],
6258            Some("debug-123".to_string()),
6259        )
6260        .unwrap();
6261        assert_eq!(sm.source(0), "a.js");
6262        assert!(sm.original_position_for(0, 0).is_some());
6263        assert!(sm.original_position_for(1, 0).is_some());
6264    }
6265
6266    #[test]
6267    fn lazy_into_sourcemap_roundtrip() {
6268        let json = r#"{"version":3,"sources":["a.js"],"names":["x"],"mappings":"AAAAA;AACAA"}"#;
6269        let lazy = LazySourceMap::from_json(json).unwrap();
6270        let sm = lazy.into_sourcemap().unwrap();
6271        assert!(sm.original_position_for(0, 0).is_some());
6272        assert!(sm.original_position_for(1, 0).is_some());
6273        assert_eq!(sm.name(0), "x");
6274    }
6275
6276    #[test]
6277    fn lazy_original_position_for_no_match() {
6278        // LazySourceMap: column before any mapping should return None (Err(0) branch)
6279        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"KAAA"}"#;
6280        let sm = LazySourceMap::from_json(json).unwrap();
6281        // Column 0 is before column 5 (K = 5), should return None
6282        assert!(sm.original_position_for(0, 0).is_none());
6283    }
6284
6285    #[test]
6286    fn lazy_original_position_for_empty_line() {
6287        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":";AAAA"}"#;
6288        let sm = LazySourceMap::from_json(json).unwrap();
6289        // Line 0 is empty
6290        assert!(sm.original_position_for(0, 0).is_none());
6291        // Line 1 has mapping
6292        assert!(sm.original_position_for(1, 0).is_some());
6293    }
6294
6295    #[test]
6296    fn lazy_original_position_generated_only() {
6297        // Only a 1-field (generated-only) segment on line 0
6298        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"A;AAAA"}"#;
6299        let sm = LazySourceMap::from_json(json).unwrap();
6300        // Line 0 has only generated-only segment → returns None
6301        assert!(sm.original_position_for(0, 0).is_none());
6302        // Line 1 has a 4-field segment → returns Some
6303        assert!(sm.original_position_for(1, 0).is_some());
6304    }
6305
6306    #[test]
6307    fn from_json_lines_null_source() {
6308        let json = r#"{"version":3,"sources":[null,"a.js"],"names":[],"mappings":"ACAA"}"#;
6309        let sm = SourceMap::from_json_lines(json, 0, 1).unwrap();
6310        assert!(sm.mapping_count() >= 1);
6311    }
6312
6313    #[test]
6314    fn from_json_lines_with_source_root_prefix() {
6315        let json =
6316            r#"{"version":3,"sourceRoot":"lib/","sources":["b.js"],"names":[],"mappings":"AAAA"}"#;
6317        let sm = SourceMap::from_json_lines(json, 0, 1).unwrap();
6318        assert_eq!(sm.source(0), "lib/b.js");
6319    }
6320
6321    #[test]
6322    fn generated_position_for_glb_idx_zero() {
6323        // When the reverse index partition_point returns 0, GLB should return None
6324        // Create a map where source "a.js" only has mapping at original (5,0)
6325        // Searching for (0,0) in GLB mode: partition_point returns 0 (nothing <= (0,0)), so None
6326        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAKA"}"#;
6327        let sm = SourceMap::from_json(json).unwrap();
6328        let loc = sm.generated_position_for_with_bias("a.js", 0, 0, Bias::GreatestLowerBound);
6329        assert!(loc.is_none());
6330    }
6331
6332    #[test]
6333    fn from_json_lines_with_ignore_list() {
6334        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA;ACAA","ignoreList":[1]}"#;
6335        let sm = SourceMap::from_json_lines(json, 0, 2).unwrap();
6336        assert_eq!(sm.ignore_list, vec![1]);
6337    }
6338
6339    #[test]
6340    fn validate_deep_out_of_order_mappings() {
6341        // Manually construct a map with out-of-order segments
6342        let sm = SourceMap::from_parts(
6343            None,
6344            None,
6345            vec!["a.js".to_string()],
6346            vec![None],
6347            vec![],
6348            vec![
6349                Mapping {
6350                    generated_line: 1,
6351                    generated_column: 0,
6352                    source: 0,
6353                    original_line: 0,
6354                    original_column: 0,
6355                    name: NO_NAME,
6356                    is_range_mapping: false,
6357                },
6358                Mapping {
6359                    generated_line: 0,
6360                    generated_column: 0,
6361                    source: 0,
6362                    original_line: 0,
6363                    original_column: 0,
6364                    name: NO_NAME,
6365                    is_range_mapping: false,
6366                },
6367            ],
6368            vec![],
6369            None,
6370            None,
6371        );
6372        let warnings = validate_deep(&sm);
6373        assert!(warnings.iter().any(|w| w.contains("out of order")));
6374    }
6375
6376    #[test]
6377    fn validate_deep_out_of_bounds_source() {
6378        let sm = SourceMap::from_parts(
6379            None,
6380            None,
6381            vec!["a.js".to_string()],
6382            vec![None],
6383            vec![],
6384            vec![Mapping {
6385                generated_line: 0,
6386                generated_column: 0,
6387                source: 5,
6388                original_line: 0,
6389                original_column: 0,
6390                name: NO_NAME,
6391                is_range_mapping: false,
6392            }],
6393            vec![],
6394            None,
6395            None,
6396        );
6397        let warnings = validate_deep(&sm);
6398        assert!(
6399            warnings
6400                .iter()
6401                .any(|w| w.contains("source index") && w.contains("out of bounds"))
6402        );
6403    }
6404
6405    #[test]
6406    fn validate_deep_out_of_bounds_name() {
6407        let sm = SourceMap::from_parts(
6408            None,
6409            None,
6410            vec!["a.js".to_string()],
6411            vec![None],
6412            vec!["foo".to_string()],
6413            vec![Mapping {
6414                generated_line: 0,
6415                generated_column: 0,
6416                source: 0,
6417                original_line: 0,
6418                original_column: 0,
6419                name: 5,
6420                is_range_mapping: false,
6421            }],
6422            vec![],
6423            None,
6424            None,
6425        );
6426        let warnings = validate_deep(&sm);
6427        assert!(
6428            warnings
6429                .iter()
6430                .any(|w| w.contains("name index") && w.contains("out of bounds"))
6431        );
6432    }
6433
6434    #[test]
6435    fn validate_deep_out_of_bounds_ignore_list() {
6436        let sm = SourceMap::from_parts(
6437            None,
6438            None,
6439            vec!["a.js".to_string()],
6440            vec![None],
6441            vec![],
6442            vec![Mapping {
6443                generated_line: 0,
6444                generated_column: 0,
6445                source: 0,
6446                original_line: 0,
6447                original_column: 0,
6448                name: NO_NAME,
6449                is_range_mapping: false,
6450            }],
6451            vec![10],
6452            None,
6453            None,
6454        );
6455        let warnings = validate_deep(&sm);
6456        assert!(
6457            warnings
6458                .iter()
6459                .any(|w| w.contains("ignoreList") && w.contains("out of bounds"))
6460        );
6461    }
6462
6463    #[test]
6464    fn source_mapping_url_inline_decoded() {
6465        // Test that inline data URIs actually decode base64 and return the parsed map
6466        let map_json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
6467        let encoded = base64_encode_simple(map_json);
6468        let input = format!("var x;\n//# sourceMappingURL=data:application/json;base64,{encoded}");
6469        let url = parse_source_mapping_url(&input);
6470        match url {
6471            Some(SourceMappingUrl::Inline(json)) => {
6472                assert!(json.contains("version"));
6473                assert!(json.contains("AAAA"));
6474            }
6475            _ => panic!("expected inline source map"),
6476        }
6477    }
6478
6479    #[test]
6480    fn source_mapping_url_charset_variant() {
6481        let map_json = r#"{"version":3}"#;
6482        let encoded = base64_encode_simple(map_json);
6483        let input =
6484            format!("x\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,{encoded}");
6485        let url = parse_source_mapping_url(&input);
6486        assert!(matches!(url, Some(SourceMappingUrl::Inline(_))));
6487    }
6488
6489    #[test]
6490    fn source_mapping_url_invalid_base64_falls_through_to_external() {
6491        // Data URI with invalid base64 that fails to decode should still return External
6492        let input = "x\n//# sourceMappingURL=data:application/json;base64,!!!invalid!!!";
6493        let url = parse_source_mapping_url(input);
6494        // Invalid base64 → base64_decode returns None → falls through to External
6495        assert!(matches!(url, Some(SourceMappingUrl::External(_))));
6496    }
6497
6498    #[test]
6499    fn from_json_lines_with_extensions_preserved() {
6500        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","x_custom":99}"#;
6501        let sm = SourceMap::from_json_lines(json, 0, 1).unwrap();
6502        assert!(sm.extensions.contains_key("x_custom"));
6503    }
6504
6505    // Helper for base64 encoding in tests
6506    fn base64_encode_simple(input: &str) -> String {
6507        const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
6508        let bytes = input.as_bytes();
6509        let mut result = String::new();
6510        for chunk in bytes.chunks(3) {
6511            let b0 = chunk[0] as u32;
6512            let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
6513            let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
6514            let n = (b0 << 16) | (b1 << 8) | b2;
6515            result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
6516            result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
6517            if chunk.len() > 1 {
6518                result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
6519            } else {
6520                result.push('=');
6521            }
6522            if chunk.len() > 2 {
6523                result.push(CHARS[(n & 0x3F) as usize] as char);
6524            } else {
6525                result.push('=');
6526            }
6527        }
6528        result
6529    }
6530
6531    // ── MappingsIter tests ──────────────────────────────────────
6532
6533    #[test]
6534    fn mappings_iter_matches_decode() {
6535        let vlq = "AAAA;AACA,EAAA;AACA";
6536        let iter_mappings: Vec<Mapping> = MappingsIter::new(vlq).collect::<Result<_, _>>().unwrap();
6537        let (decoded, _) = decode_mappings(vlq).unwrap();
6538        assert_eq!(iter_mappings.len(), decoded.len());
6539        for (a, b) in iter_mappings.iter().zip(decoded.iter()) {
6540            assert_eq!(a.generated_line, b.generated_line);
6541            assert_eq!(a.generated_column, b.generated_column);
6542            assert_eq!(a.source, b.source);
6543            assert_eq!(a.original_line, b.original_line);
6544            assert_eq!(a.original_column, b.original_column);
6545            assert_eq!(a.name, b.name);
6546        }
6547    }
6548
6549    #[test]
6550    fn mappings_iter_empty() {
6551        let mappings: Vec<Mapping> = MappingsIter::new("").collect::<Result<_, _>>().unwrap();
6552        assert!(mappings.is_empty());
6553    }
6554
6555    #[test]
6556    fn mappings_iter_generated_only() {
6557        let vlq = "A,AAAA";
6558        let mappings: Vec<Mapping> = MappingsIter::new(vlq).collect::<Result<_, _>>().unwrap();
6559        assert_eq!(mappings.len(), 2);
6560        assert_eq!(mappings[0].source, u32::MAX);
6561        assert_eq!(mappings[1].source, 0);
6562    }
6563
6564    #[test]
6565    fn mappings_iter_with_names() {
6566        let vlq = "AAAAA";
6567        let mappings: Vec<Mapping> = MappingsIter::new(vlq).collect::<Result<_, _>>().unwrap();
6568        assert_eq!(mappings.len(), 1);
6569        assert_eq!(mappings[0].name, 0);
6570    }
6571
6572    #[test]
6573    fn mappings_iter_multiple_lines() {
6574        let vlq = "AAAA;AACA;AACA";
6575        let mappings: Vec<Mapping> = MappingsIter::new(vlq).collect::<Result<_, _>>().unwrap();
6576        assert_eq!(mappings.len(), 3);
6577        assert_eq!(mappings[0].generated_line, 0);
6578        assert_eq!(mappings[1].generated_line, 1);
6579        assert_eq!(mappings[2].generated_line, 2);
6580    }
6581    // ── Range mappings tests ──────────────────────────────────────
6582
6583    #[test]
6584    fn range_mappings_basic_decode() {
6585        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA,CAAC,GAAG","rangeMappings":"A,C"}"#;
6586        let sm = SourceMap::from_json(json).unwrap();
6587        assert!(sm.all_mappings()[0].is_range_mapping);
6588        assert!(!sm.all_mappings()[1].is_range_mapping);
6589        assert!(sm.all_mappings()[2].is_range_mapping);
6590    }
6591
6592    #[test]
6593    fn range_mapping_lookup_with_delta() {
6594        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA,GAAG","rangeMappings":"A"}"#;
6595        let sm = SourceMap::from_json(json).unwrap();
6596        assert_eq!(sm.original_position_for(0, 0).unwrap().column, 0);
6597        assert_eq!(sm.original_position_for(0, 1).unwrap().column, 1);
6598        assert_eq!(sm.original_position_for(0, 2).unwrap().column, 2);
6599        assert_eq!(sm.original_position_for(0, 3).unwrap().column, 3);
6600    }
6601
6602    #[test]
6603    fn range_mapping_cross_line() {
6604        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}"#;
6605        let sm = SourceMap::from_json(json).unwrap();
6606        assert_eq!(sm.original_position_for(1, 5).unwrap().line, 1);
6607        assert_eq!(sm.original_position_for(1, 5).unwrap().column, 0);
6608        assert_eq!(sm.original_position_for(2, 10).unwrap().line, 2);
6609    }
6610
6611    #[test]
6612    fn range_mapping_encode_roundtrip() {
6613        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA,CAAC,GAAG","rangeMappings":"A,C"}"#;
6614        assert_eq!(
6615            SourceMap::from_json(json)
6616                .unwrap()
6617                .encode_range_mappings()
6618                .unwrap(),
6619            "A,C"
6620        );
6621    }
6622
6623    #[test]
6624    fn no_range_mappings_test() {
6625        let sm = SourceMap::from_json(
6626            r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA"}"#,
6627        )
6628        .unwrap();
6629        assert!(!sm.has_range_mappings());
6630        assert!(sm.encode_range_mappings().is_none());
6631    }
6632
6633    #[test]
6634    fn range_mappings_multi_line_test() {
6635        let sm = SourceMap::from_json(r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA,CAAC;AAAA","rangeMappings":"A;A"}"#).unwrap();
6636        assert!(sm.all_mappings()[0].is_range_mapping);
6637        assert!(!sm.all_mappings()[1].is_range_mapping);
6638        assert!(sm.all_mappings()[2].is_range_mapping);
6639    }
6640
6641    #[test]
6642    fn range_mappings_json_roundtrip() {
6643        let sm = SourceMap::from_json(r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA,CAAC,GAAG","rangeMappings":"A,C"}"#).unwrap();
6644        let output = sm.to_json();
6645        assert!(output.contains("rangeMappings"));
6646        assert_eq!(
6647            SourceMap::from_json(&output).unwrap().range_mapping_count(),
6648            2
6649        );
6650    }
6651
6652    #[test]
6653    fn range_mappings_absent_from_json_test() {
6654        assert!(
6655            !SourceMap::from_json(
6656                r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA"}"#
6657            )
6658            .unwrap()
6659            .to_json()
6660            .contains("rangeMappings")
6661        );
6662    }
6663
6664    #[test]
6665    fn range_mapping_fallback_test() {
6666        let sm = SourceMap::from_json(r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA;KACK","rangeMappings":"A"}"#).unwrap();
6667        let loc = sm.original_position_for(1, 2).unwrap();
6668        assert_eq!(loc.line, 1);
6669        assert_eq!(loc.column, 0);
6670    }
6671
6672    #[test]
6673    fn range_mapping_no_fallback_non_range() {
6674        assert!(
6675            SourceMap::from_json(
6676                r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA"}"#
6677            )
6678            .unwrap()
6679            .original_position_for(1, 5)
6680            .is_none()
6681        );
6682    }
6683
6684    #[test]
6685    fn range_mapping_from_vlq_test() {
6686        let sm = SourceMap::from_vlq_with_range_mappings(
6687            "AAAA,CAAC",
6688            vec!["input.js".into()],
6689            vec![],
6690            None,
6691            None,
6692            vec![],
6693            vec![],
6694            None,
6695            Some("A"),
6696        )
6697        .unwrap();
6698        assert!(sm.all_mappings()[0].is_range_mapping);
6699        assert!(!sm.all_mappings()[1].is_range_mapping);
6700    }
6701
6702    #[test]
6703    fn range_mapping_encode_multi_line_test() {
6704        let sm = SourceMap::from_json(r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA,CAAC;AAAA,CAAC","rangeMappings":"A;B"}"#).unwrap();
6705        assert!(sm.all_mappings()[0].is_range_mapping);
6706        assert!(!sm.all_mappings()[1].is_range_mapping);
6707        assert!(!sm.all_mappings()[2].is_range_mapping);
6708        assert!(sm.all_mappings()[3].is_range_mapping);
6709        assert_eq!(sm.encode_range_mappings().unwrap(), "A;B");
6710    }
6711
6712    #[test]
6713    fn range_mapping_from_parts_test() {
6714        let sm = SourceMap::from_parts(
6715            None,
6716            None,
6717            vec!["input.js".into()],
6718            vec![],
6719            vec![],
6720            vec![
6721                Mapping {
6722                    generated_line: 0,
6723                    generated_column: 0,
6724                    source: 0,
6725                    original_line: 0,
6726                    original_column: 0,
6727                    name: NO_NAME,
6728                    is_range_mapping: true,
6729                },
6730                Mapping {
6731                    generated_line: 0,
6732                    generated_column: 5,
6733                    source: 0,
6734                    original_line: 0,
6735                    original_column: 5,
6736                    name: NO_NAME,
6737                    is_range_mapping: false,
6738                },
6739            ],
6740            vec![],
6741            None,
6742            None,
6743        );
6744        assert_eq!(sm.original_position_for(0, 2).unwrap().column, 2);
6745        assert_eq!(sm.original_position_for(0, 6).unwrap().column, 5);
6746    }
6747
6748    #[test]
6749    fn range_mapping_indexed_test() {
6750        let sm = SourceMap::from_json(r#"{"version":3,"sections":[{"offset":{"line":0,"column":0},"map":{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","rangeMappings":"A"}}]}"#).unwrap();
6751        assert!(sm.has_range_mappings());
6752        assert_eq!(sm.original_position_for(1, 3).unwrap().line, 1);
6753    }
6754
6755    #[test]
6756    fn range_mapping_empty_string_test() {
6757        assert!(!SourceMap::from_json(r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"AAAA","rangeMappings":""}"#).unwrap().has_range_mappings());
6758    }
6759
6760    #[test]
6761    fn range_mapping_lub_no_underflow() {
6762        // Range mapping at col 5, query col 2 with LUB bias
6763        // LUB should find the mapping at col 5, but NOT apply range delta
6764        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"KAAK","rangeMappings":"A"}"#;
6765        let sm = SourceMap::from_json(json).unwrap();
6766
6767        let loc = sm.original_position_for_with_bias(0, 2, Bias::LeastUpperBound);
6768        assert!(loc.is_some());
6769        let loc = loc.unwrap();
6770        // Should return the mapping's own position, not apply a delta
6771        assert_eq!(loc.line, 0);
6772        assert_eq!(loc.column, 5);
6773    }
6774
6775    // ── Builder tests ──────────────────────────────────────────────
6776
6777    #[test]
6778    fn builder_basic() {
6779        let sm = SourceMap::builder()
6780            .file("output.js")
6781            .sources(["input.ts"])
6782            .sources_content([Some("let x = 1;")])
6783            .names(["x"])
6784            .mappings([Mapping {
6785                generated_line: 0,
6786                generated_column: 0,
6787                source: 0,
6788                original_line: 0,
6789                original_column: 4,
6790                name: 0,
6791                is_range_mapping: false,
6792            }])
6793            .build();
6794
6795        assert_eq!(sm.file.as_deref(), Some("output.js"));
6796        assert_eq!(sm.sources, vec!["input.ts"]);
6797        assert_eq!(sm.sources_content, vec![Some("let x = 1;".to_string())]);
6798        assert_eq!(sm.names, vec!["x"]);
6799        assert_eq!(sm.mapping_count(), 1);
6800
6801        let loc = sm.original_position_for(0, 0).unwrap();
6802        assert_eq!(sm.source(loc.source), "input.ts");
6803        assert_eq!(loc.column, 4);
6804        assert_eq!(sm.name(loc.name.unwrap()), "x");
6805    }
6806
6807    #[test]
6808    fn builder_empty() {
6809        let sm = SourceMap::builder().build();
6810        assert_eq!(sm.mapping_count(), 0);
6811        assert_eq!(sm.sources.len(), 0);
6812        assert_eq!(sm.names.len(), 0);
6813        assert!(sm.file.is_none());
6814    }
6815
6816    #[test]
6817    fn builder_multiple_sources() {
6818        let sm = SourceMap::builder()
6819            .sources(["a.ts", "b.ts", "c.ts"])
6820            .sources_content([Some("// a"), Some("// b"), None])
6821            .mappings([
6822                Mapping {
6823                    generated_line: 0,
6824                    generated_column: 0,
6825                    source: 0,
6826                    original_line: 0,
6827                    original_column: 0,
6828                    name: u32::MAX,
6829                    is_range_mapping: false,
6830                },
6831                Mapping {
6832                    generated_line: 1,
6833                    generated_column: 0,
6834                    source: 1,
6835                    original_line: 0,
6836                    original_column: 0,
6837                    name: u32::MAX,
6838                    is_range_mapping: false,
6839                },
6840                Mapping {
6841                    generated_line: 2,
6842                    generated_column: 0,
6843                    source: 2,
6844                    original_line: 0,
6845                    original_column: 0,
6846                    name: u32::MAX,
6847                    is_range_mapping: false,
6848                },
6849            ])
6850            .build();
6851
6852        assert_eq!(sm.sources.len(), 3);
6853        assert_eq!(sm.mapping_count(), 3);
6854        assert_eq!(sm.line_count(), 3);
6855
6856        let loc0 = sm.original_position_for(0, 0).unwrap();
6857        assert_eq!(sm.source(loc0.source), "a.ts");
6858
6859        let loc1 = sm.original_position_for(1, 0).unwrap();
6860        assert_eq!(sm.source(loc1.source), "b.ts");
6861
6862        let loc2 = sm.original_position_for(2, 0).unwrap();
6863        assert_eq!(sm.source(loc2.source), "c.ts");
6864    }
6865
6866    #[test]
6867    fn builder_with_iterators() {
6868        let source_names: Vec<String> = (0..5).map(|i| format!("mod_{i}.ts")).collect();
6869        let mappings = (0..5u32).map(|i| Mapping {
6870            generated_line: i,
6871            generated_column: 0,
6872            source: i,
6873            original_line: i,
6874            original_column: 0,
6875            name: u32::MAX,
6876            is_range_mapping: false,
6877        });
6878
6879        let sm = SourceMap::builder()
6880            .sources(source_names.iter().map(|s| s.as_str()))
6881            .mappings(mappings)
6882            .build();
6883
6884        assert_eq!(sm.sources.len(), 5);
6885        assert_eq!(sm.mapping_count(), 5);
6886        for i in 0..5u32 {
6887            let loc = sm.original_position_for(i, 0).unwrap();
6888            assert_eq!(sm.source(loc.source), format!("mod_{i}.ts"));
6889        }
6890    }
6891
6892    #[test]
6893    fn builder_ignore_list_and_debug_id() {
6894        let sm = SourceMap::builder()
6895            .sources(["app.ts", "node_modules/lib.js"])
6896            .ignore_list([1])
6897            .debug_id("85314830-023f-4cf1-a267-535f4e37bb17")
6898            .build();
6899
6900        assert_eq!(sm.ignore_list, vec![1]);
6901        assert_eq!(
6902            sm.debug_id.as_deref(),
6903            Some("85314830-023f-4cf1-a267-535f4e37bb17")
6904        );
6905    }
6906
6907    #[test]
6908    fn builder_range_mappings() {
6909        let sm = SourceMap::builder()
6910            .sources(["input.ts"])
6911            .mappings([
6912                Mapping {
6913                    generated_line: 0,
6914                    generated_column: 0,
6915                    source: 0,
6916                    original_line: 0,
6917                    original_column: 0,
6918                    name: u32::MAX,
6919                    is_range_mapping: true,
6920                },
6921                Mapping {
6922                    generated_line: 0,
6923                    generated_column: 10,
6924                    source: 0,
6925                    original_line: 5,
6926                    original_column: 0,
6927                    name: u32::MAX,
6928                    is_range_mapping: false,
6929                },
6930            ])
6931            .build();
6932
6933        assert!(sm.has_range_mappings());
6934        assert_eq!(sm.mapping_count(), 2);
6935    }
6936
6937    #[test]
6938    fn builder_json_roundtrip() {
6939        let sm = SourceMap::builder()
6940            .file("out.js")
6941            .source_root("/src/")
6942            .sources(["a.ts", "b.ts"])
6943            .sources_content([Some("// a"), Some("// b")])
6944            .names(["foo", "bar"])
6945            .mappings([
6946                Mapping {
6947                    generated_line: 0,
6948                    generated_column: 0,
6949                    source: 0,
6950                    original_line: 0,
6951                    original_column: 0,
6952                    name: 0,
6953                    is_range_mapping: false,
6954                },
6955                Mapping {
6956                    generated_line: 1,
6957                    generated_column: 5,
6958                    source: 1,
6959                    original_line: 3,
6960                    original_column: 2,
6961                    name: 1,
6962                    is_range_mapping: false,
6963                },
6964            ])
6965            .build();
6966
6967        let json = sm.to_json();
6968        let sm2 = SourceMap::from_json(&json).unwrap();
6969
6970        assert_eq!(sm2.file, sm.file);
6971        // source_root is prepended to sources on parse
6972        assert_eq!(sm2.sources, vec!["/src/a.ts", "/src/b.ts"]);
6973        assert_eq!(sm2.names, sm.names);
6974        assert_eq!(sm2.mapping_count(), sm.mapping_count());
6975
6976        for m in sm.all_mappings() {
6977            let a = sm.original_position_for(m.generated_line, m.generated_column);
6978            let b = sm2.original_position_for(m.generated_line, m.generated_column);
6979            match (a, b) {
6980                (Some(a), Some(b)) => {
6981                    assert_eq!(a.source, b.source);
6982                    assert_eq!(a.line, b.line);
6983                    assert_eq!(a.column, b.column);
6984                    assert_eq!(a.name, b.name);
6985                }
6986                (None, None) => {}
6987                _ => panic!("lookup mismatch"),
6988            }
6989        }
6990    }
6991
6992    // ── Tests for review fixes ────────────────────────────────────
6993
6994    #[test]
6995    fn range_mapping_fallback_column_underflow() {
6996        // Range mapping at col 5, query line 0 col 2 — column < generated_column
6997        // This should NOT panic (saturating_sub prevents u32 underflow)
6998        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"KAAK","rangeMappings":"A"}"#;
6999        let sm = SourceMap::from_json(json).unwrap();
7000        // Query col 2, but the range mapping starts at col 5
7001        // GLB should snap to col 5 mapping, and the range delta should saturate to 0
7002        let loc = sm.original_position_for(0, 2);
7003        // No mapping at col < 5 on this line, so None is expected
7004        assert!(loc.is_none());
7005    }
7006
7007    #[test]
7008    fn range_mapping_fallback_cross_line_column_zero() {
7009        // Range mapping on line 0, col 10, orig(0,10). Query line 1, col 0.
7010        // line_delta = 1, column_delta = 0 (else branch).
7011        // Result: orig_line = 0 + 1 = 1, orig_column = 10 + 0 = 10.
7012        let json = r#"{"version":3,"sources":["input.js"],"names":[],"mappings":"UAAU","rangeMappings":"A"}"#;
7013        let sm = SourceMap::from_json(json).unwrap();
7014        let loc = sm.original_position_for(1, 0).unwrap();
7015        assert_eq!(loc.line, 1);
7016        assert_eq!(loc.column, 10);
7017    }
7018
7019    #[test]
7020    fn vlq_overflow_at_shift_60() {
7021        // Build a VLQ that uses exactly shift=60 (13 continuation chars + 1 terminator)
7022        // This should be rejected by vlq_fast (shift >= 60)
7023        // 13 continuation chars: each is base64 with continuation bit set (e.g. 'g' = 0x20 | 0x00)
7024        // followed by a terminator (e.g. 'A' = 0x00)
7025        let overflow_vlq = "ggggggggggggggA"; // 14 continuation + terminator
7026        let json = format!(
7027            r#"{{"version":3,"sources":["a.js"],"names":[],"mappings":"{}"}}"#,
7028            overflow_vlq
7029        );
7030        let result = SourceMap::from_json(&json);
7031        assert!(result.is_err());
7032        assert!(matches!(result.unwrap_err(), ParseError::Vlq(_)));
7033    }
7034
7035    #[test]
7036    fn lazy_sourcemap_rejects_indexed_maps() {
7037        let json = r#"{"version":3,"sections":[{"offset":{"line":0,"column":0},"map":{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}}]}"#;
7038        let result = LazySourceMap::from_json_fast(json);
7039        assert!(result.is_err());
7040        assert!(matches!(result.unwrap_err(), ParseError::NestedIndexMap));
7041
7042        let result = LazySourceMap::from_json_no_content(json);
7043        assert!(result.is_err());
7044        assert!(matches!(result.unwrap_err(), ParseError::NestedIndexMap));
7045    }
7046
7047    #[test]
7048    fn lazy_sourcemap_regular_map_still_works() {
7049        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA"}"#;
7050        let sm = LazySourceMap::from_json_fast(json).unwrap();
7051        let loc = sm.original_position_for(0, 0).unwrap();
7052        assert_eq!(sm.source(loc.source), "a.js");
7053        assert_eq!(loc.line, 0);
7054    }
7055
7056    #[test]
7057    fn lazy_sourcemap_get_source_name_bounds() {
7058        let json = r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AAAAA"}"#;
7059        let sm = LazySourceMap::from_json_fast(json).unwrap();
7060        assert_eq!(sm.get_source(0), Some("a.js"));
7061        assert_eq!(sm.get_source(1), None);
7062        assert_eq!(sm.get_source(u32::MAX), None);
7063        assert_eq!(sm.get_name(0), Some("foo"));
7064        assert_eq!(sm.get_name(1), None);
7065        assert_eq!(sm.get_name(u32::MAX), None);
7066    }
7067
7068    #[test]
7069    fn lazy_sourcemap_backward_seek() {
7070        // Test that backward seek works correctly in fast-scan mode
7071        let json =
7072            r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA"}"#;
7073        let sm = LazySourceMap::from_json_fast(json).unwrap();
7074
7075        // Forward: decode lines 0, 1, 2, 3
7076        let loc3 = sm.original_position_for(3, 0).unwrap();
7077        assert_eq!(loc3.line, 3);
7078
7079        // Backward: seek line 1 (below watermark of 4)
7080        let loc1 = sm.original_position_for(1, 0).unwrap();
7081        assert_eq!(loc1.line, 1);
7082
7083        // Forward again: line 4
7084        let loc4 = sm.original_position_for(4, 0).unwrap();
7085        assert_eq!(loc4.line, 4);
7086
7087        // Backward again to line 0
7088        let loc0 = sm.original_position_for(0, 0).unwrap();
7089        assert_eq!(loc0.line, 0);
7090    }
7091
7092    #[test]
7093    fn lazy_sourcemap_fast_scan_vs_prescan_consistency() {
7094        // Verify fast_scan and prescan produce identical lookup results
7095        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":["x","y"],"mappings":"AAAAA,KACAC;ACAAD,KACAC"}"#;
7096        let fast = LazySourceMap::from_json_fast(json).unwrap();
7097        let prescan = LazySourceMap::from_json_no_content(json).unwrap();
7098
7099        for line in 0..2 {
7100            for col in [0, 5, 10] {
7101                let a = fast.original_position_for(line, col);
7102                let b = prescan.original_position_for(line, col);
7103                match (&a, &b) {
7104                    (Some(a), Some(b)) => {
7105                        assert_eq!(a.source, b.source, "line={line}, col={col}");
7106                        assert_eq!(a.line, b.line, "line={line}, col={col}");
7107                        assert_eq!(a.column, b.column, "line={line}, col={col}");
7108                        assert_eq!(a.name, b.name, "line={line}, col={col}");
7109                    }
7110                    (None, None) => {}
7111                    _ => panic!("mismatch at line={line}, col={col}: {a:?} vs {b:?}"),
7112                }
7113            }
7114        }
7115    }
7116
7117    #[test]
7118    fn mappings_iter_rejects_two_field_segment() {
7119        // "AA" is 2 fields (generated column + source index, missing original line/column)
7120        let result: Result<Vec<_>, _> = MappingsIter::new("AA").collect();
7121        assert!(result.is_err());
7122        assert!(matches!(
7123            result.unwrap_err(),
7124            DecodeError::InvalidSegmentLength { fields: 2, .. }
7125        ));
7126    }
7127
7128    #[test]
7129    fn mappings_iter_rejects_three_field_segment() {
7130        // "AAA" is 3 fields (generated column + source index + original line, missing original column)
7131        let result: Result<Vec<_>, _> = MappingsIter::new("AAA").collect();
7132        assert!(result.is_err());
7133        assert!(matches!(
7134            result.unwrap_err(),
7135            DecodeError::InvalidSegmentLength { fields: 3, .. }
7136        ));
7137    }
7138
7139    #[test]
7140    fn decode_mappings_range_caps_end_line() {
7141        // Pathological end_line should not OOM — capped against actual line count
7142        let mappings = "AAAA;AACA";
7143        let (result, offsets) = decode_mappings_range(mappings, 0, 1_000_000).unwrap();
7144        // Should produce mappings for the 2 actual lines, not allocate 1M entries
7145        assert_eq!(result.len(), 2);
7146        assert!(offsets.len() <= 3); // 2 lines + sentinel
7147    }
7148
7149    #[test]
7150    fn decode_range_mappings_cross_line_bound_check() {
7151        // Range mapping index that exceeds the current line's mappings
7152        // should NOT mark a mapping on the next line
7153        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA;AAAA","rangeMappings":"E"}"#;
7154        let sm = SourceMap::from_json(json).unwrap();
7155        // Line 0 has 1 mapping (idx 0). rangeMappings="E" encodes index 2, which is out of bounds
7156        // for line 0. Line 1's mapping (idx 1) should NOT be marked as range mapping.
7157        assert!(!sm.all_mappings()[1].is_range_mapping);
7158    }
7159
7160    #[test]
7161    fn fast_scan_lines_empty() {
7162        let result = fast_scan_lines("");
7163        assert!(result.is_empty());
7164    }
7165
7166    #[test]
7167    fn fast_scan_lines_no_semicolons() {
7168        let result = fast_scan_lines("AAAA,CAAC");
7169        assert_eq!(result.len(), 1);
7170        assert_eq!(result[0].byte_offset, 0);
7171        assert_eq!(result[0].byte_end, 9);
7172    }
7173
7174    #[test]
7175    fn fast_scan_lines_only_semicolons() {
7176        let result = fast_scan_lines(";;;");
7177        assert_eq!(result.len(), 4);
7178        for info in &result {
7179            assert_eq!(info.byte_offset, info.byte_end); // empty lines
7180        }
7181    }
7182
7183    // ── from_data_url ────────────────────────────────────────────
7184
7185    #[test]
7186    fn from_data_url_base64() {
7187        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7188        let encoded = base64_encode_simple(json);
7189        let url = format!("data:application/json;base64,{encoded}");
7190        let sm = SourceMap::from_data_url(&url).unwrap();
7191        assert_eq!(sm.sources, vec!["a.js"]);
7192        let loc = sm.original_position_for(0, 0).unwrap();
7193        assert_eq!(loc.line, 0);
7194        assert_eq!(loc.column, 0);
7195    }
7196
7197    #[test]
7198    fn from_data_url_base64_charset_utf8() {
7199        let json = r#"{"version":3,"sources":["b.js"],"names":[],"mappings":"AAAA"}"#;
7200        let encoded = base64_encode_simple(json);
7201        let url = format!("data:application/json;charset=utf-8;base64,{encoded}");
7202        let sm = SourceMap::from_data_url(&url).unwrap();
7203        assert_eq!(sm.sources, vec!["b.js"]);
7204    }
7205
7206    #[test]
7207    fn from_data_url_plain_json() {
7208        let json = r#"{"version":3,"sources":["c.js"],"names":[],"mappings":"AAAA"}"#;
7209        let url = format!("data:application/json,{json}");
7210        let sm = SourceMap::from_data_url(&url).unwrap();
7211        assert_eq!(sm.sources, vec!["c.js"]);
7212    }
7213
7214    #[test]
7215    fn from_data_url_percent_encoded() {
7216        let url = "data:application/json,%7B%22version%22%3A3%2C%22sources%22%3A%5B%22d.js%22%5D%2C%22names%22%3A%5B%5D%2C%22mappings%22%3A%22AAAA%22%7D";
7217        let sm = SourceMap::from_data_url(url).unwrap();
7218        assert_eq!(sm.sources, vec!["d.js"]);
7219    }
7220
7221    #[test]
7222    fn from_data_url_invalid_prefix() {
7223        let result = SourceMap::from_data_url("data:text/plain;base64,abc");
7224        assert!(result.is_err());
7225    }
7226
7227    #[test]
7228    fn from_data_url_not_a_data_url() {
7229        let result = SourceMap::from_data_url("https://example.com/foo.map");
7230        assert!(result.is_err());
7231    }
7232
7233    #[test]
7234    fn from_data_url_invalid_base64() {
7235        let result = SourceMap::from_data_url("data:application/json;base64,!!!invalid!!!");
7236        assert!(result.is_err());
7237    }
7238
7239    #[test]
7240    fn from_data_url_roundtrip_with_to_data_url() {
7241        use crate::utils::to_data_url;
7242        let json = r#"{"version":3,"sources":["round.js"],"names":["x"],"mappings":"AACAA"}"#;
7243        let url = to_data_url(json);
7244        let sm = SourceMap::from_data_url(&url).unwrap();
7245        assert_eq!(sm.sources, vec!["round.js"]);
7246        assert_eq!(sm.names, vec!["x"]);
7247    }
7248
7249    // ── to_writer ────────────────────────────────────────────────
7250
7251    #[test]
7252    fn to_writer_basic() {
7253        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7254        let sm = SourceMap::from_json(json).unwrap();
7255        let mut buf = Vec::new();
7256        sm.to_writer(&mut buf).unwrap();
7257        let output = String::from_utf8(buf).unwrap();
7258        assert!(output.contains("\"version\":3"));
7259        assert!(output.contains("\"sources\":[\"a.js\"]"));
7260        // Verify it parses back correctly
7261        let sm2 = SourceMap::from_json(&output).unwrap();
7262        assert_eq!(sm2.sources, sm.sources);
7263    }
7264
7265    #[test]
7266    fn to_writer_matches_to_json() {
7267        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":["foo"],"mappings":"AACAA,GCAA","sourcesContent":["var foo;","var bar;"]}"#;
7268        let sm = SourceMap::from_json(json).unwrap();
7269        let expected = sm.to_json();
7270        let mut buf = Vec::new();
7271        sm.to_writer(&mut buf).unwrap();
7272        let output = String::from_utf8(buf).unwrap();
7273        assert_eq!(output, expected);
7274    }
7275
7276    #[test]
7277    fn to_writer_with_options_excludes_content() {
7278        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","sourcesContent":["var x;"]}"#;
7279        let sm = SourceMap::from_json(json).unwrap();
7280        let mut buf = Vec::new();
7281        sm.to_writer_with_options(&mut buf, true).unwrap();
7282        let output = String::from_utf8(buf).unwrap();
7283        assert!(!output.contains("sourcesContent"));
7284    }
7285
7286    // ── Setter tests ─────────────────────────────────────────────
7287
7288    #[test]
7289    fn set_file() {
7290        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7291        let mut sm = SourceMap::from_json(json).unwrap();
7292        assert_eq!(sm.file, None);
7293
7294        sm.set_file(Some("output.js".to_string()));
7295        assert_eq!(sm.file, Some("output.js".to_string()));
7296        assert!(sm.to_json().contains(r#""file":"output.js""#));
7297
7298        sm.set_file(None);
7299        assert_eq!(sm.file, None);
7300        assert!(!sm.to_json().contains("file"));
7301    }
7302
7303    #[test]
7304    fn set_source_root() {
7305        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7306        let mut sm = SourceMap::from_json(json).unwrap();
7307        assert_eq!(sm.source_root, None);
7308
7309        sm.set_source_root(Some("src/".to_string()));
7310        assert_eq!(sm.source_root, Some("src/".to_string()));
7311        assert!(sm.to_json().contains(r#""sourceRoot":"src/""#));
7312
7313        sm.set_source_root(None);
7314        assert_eq!(sm.source_root, None);
7315    }
7316
7317    #[test]
7318    fn set_debug_id() {
7319        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7320        let mut sm = SourceMap::from_json(json).unwrap();
7321        assert_eq!(sm.debug_id, None);
7322
7323        sm.set_debug_id(Some("abc-123".to_string()));
7324        assert_eq!(sm.debug_id, Some("abc-123".to_string()));
7325        assert!(sm.to_json().contains(r#""debugId":"abc-123""#));
7326
7327        sm.set_debug_id(None);
7328        assert_eq!(sm.debug_id, None);
7329        assert!(!sm.to_json().contains("debugId"));
7330    }
7331
7332    #[test]
7333    fn set_ignore_list() {
7334        let json = r#"{"version":3,"sources":["a.js","b.js"],"names":[],"mappings":"AAAA"}"#;
7335        let mut sm = SourceMap::from_json(json).unwrap();
7336        assert!(sm.ignore_list.is_empty());
7337
7338        sm.set_ignore_list(vec![0, 1]);
7339        assert_eq!(sm.ignore_list, vec![0, 1]);
7340        assert!(sm.to_json().contains("\"ignoreList\":[0,1]"));
7341
7342        sm.set_ignore_list(vec![]);
7343        assert!(sm.ignore_list.is_empty());
7344        assert!(!sm.to_json().contains("ignoreList"));
7345    }
7346
7347    #[test]
7348    fn set_sources() {
7349        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7350        let mut sm = SourceMap::from_json(json).unwrap();
7351        assert_eq!(sm.sources, vec!["a.js"]);
7352
7353        sm.set_sources(vec![Some("x.js".to_string()), Some("y.js".to_string())]);
7354        assert_eq!(sm.sources, vec!["x.js", "y.js"]);
7355        assert_eq!(sm.source_index("x.js"), Some(0));
7356        assert_eq!(sm.source_index("y.js"), Some(1));
7357        assert_eq!(sm.source_index("a.js"), None);
7358    }
7359
7360    #[test]
7361    fn set_sources_with_source_root() {
7362        let json =
7363            r#"{"version":3,"sourceRoot":"src/","sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7364        let mut sm = SourceMap::from_json(json).unwrap();
7365        assert_eq!(sm.sources, vec!["src/a.js"]);
7366
7367        sm.set_sources(vec![Some("b.js".to_string())]);
7368        assert_eq!(sm.sources, vec!["src/b.js"]);
7369    }
7370
7371    #[test]
7372    fn to_data_url_roundtrip() {
7373        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
7374        let sm = SourceMap::from_json(json).unwrap();
7375        let url = sm.to_data_url();
7376        assert!(url.starts_with("data:application/json;base64,"));
7377        let sm2 = SourceMap::from_data_url(&url).unwrap();
7378        assert_eq!(sm.sources, sm2.sources);
7379        assert_eq!(sm.to_json(), sm2.to_json());
7380    }
7381}