Skip to main content

srcmap_sourcemap/
utils.rs

1//! Utility functions for source map path resolution, validation, rewriting, and encoding.
2
3use std::path::{Path, PathBuf};
4
5use crate::{GeneratedLocation, Mapping, OriginalLocation, ParseError, SourceMap};
6
7// ── Path utilities (gap #10) ─────────────────────────────────────
8
9/// Find the longest common directory prefix among absolute file paths.
10///
11/// Only considers absolute paths (starting with `/`). Splits by `/` and finds
12/// common path components. Returns the common prefix with a trailing `/`.
13/// Returns `None` if no common prefix exists or fewer than 2 paths are provided.
14pub fn find_common_prefix<'a>(paths: impl Iterator<Item = &'a str>) -> Option<String> {
15    let abs_paths: Vec<&str> = paths.filter(|p| p.starts_with('/')).collect();
16    if abs_paths.len() < 2 {
17        return None;
18    }
19
20    // Split into components and exclude the last component (filename) from each path
21    let first_all: Vec<&str> = abs_paths[0].split('/').collect();
22    let first_dir = &first_all[..first_all.len().saturating_sub(1)];
23    let mut common_len = first_dir.len();
24
25    for path in &abs_paths[1..] {
26        let components: Vec<&str> = path.split('/').collect();
27        let dir = &components[..components.len().saturating_sub(1)];
28        let mut match_len = 0;
29        for (a, b) in first_dir.iter().zip(dir.iter()) {
30            if a != b {
31                break;
32            }
33            match_len += 1;
34        }
35        common_len = common_len.min(match_len);
36    }
37
38    // Must have at least the root component ("") plus one directory
39    if common_len < 2 {
40        return None;
41    }
42
43    let prefix = first_dir[..common_len].join("/");
44    if prefix.is_empty() || prefix == "/" {
45        return None;
46    }
47
48    Some(format!("{prefix}/"))
49}
50
51/// Compute the relative path from `base` to `target`.
52///
53/// Both paths should be absolute or relative to the same root.
54/// Uses `../` for parent directory traversal.
55///
56/// # Examples
57///
58/// ```
59/// use srcmap_sourcemap::utils::make_relative_path;
60/// assert_eq!(make_relative_path("/a/b/c.js", "/a/d/e.js"), "../d/e.js");
61/// ```
62pub fn make_relative_path(base: &str, target: &str) -> String {
63    if base == target {
64        return ".".to_string();
65    }
66
67    let base_parts: Vec<&str> = base.split('/').collect();
68    let target_parts: Vec<&str> = target.split('/').collect();
69
70    // Remove the filename from the base (last component)
71    let base_dir = &base_parts[..base_parts.len().saturating_sub(1)];
72    let target_dir = &target_parts[..target_parts.len().saturating_sub(1)];
73    let target_file = target_parts.last().unwrap_or(&"");
74
75    // Find common prefix length
76    let mut common = 0;
77    for (a, b) in base_dir.iter().zip(target_dir.iter()) {
78        if a != b {
79            break;
80        }
81        common += 1;
82    }
83
84    let ups = base_dir.len() - common;
85    let mut result = String::new();
86
87    for _ in 0..ups {
88        result.push_str("../");
89    }
90
91    for part in &target_dir[common..] {
92        result.push_str(part);
93        result.push('/');
94    }
95
96    result.push_str(target_file);
97
98    if result.is_empty() { ".".to_string() } else { result }
99}
100
101// ── Source map validation (gap #6) ───────────────────────────────
102
103/// Quick check if a JSON string looks like a valid source map.
104///
105/// Performs a lightweight structural check without fully parsing the source map.
106/// Returns `true` if the JSON contains either:
107/// - `version` + `mappings` + at least one of `sources`, `names`, `sourceRoot`, `sourcesContent`
108/// - OR a `sections` field (indexed source map)
109pub fn is_sourcemap(json: &str) -> bool {
110    let Ok(val) = serde_json::from_str::<serde_json::Value>(json) else {
111        return false;
112    };
113
114    let Some(obj) = val.as_object() else {
115        return false;
116    };
117
118    // Indexed source map
119    if obj.contains_key("sections") {
120        return true;
121    }
122
123    // Regular source map: needs version + mappings + at least one source-related field
124    let has_version = obj.contains_key("version");
125    let has_mappings = obj.contains_key("mappings");
126    let has_source_field = obj.contains_key("sources")
127        || obj.contains_key("names")
128        || obj.contains_key("sourceRoot")
129        || obj.contains_key("sourcesContent");
130
131    has_version && has_mappings && has_source_field
132}
133
134// ── URL resolution (gap #5) ──────────────────────────────────────
135
136/// Resolve a relative `sourceMappingURL` against the minified file's URL.
137///
138/// - If `source_map_ref` is already absolute (starts with `http://`, `https://`, or `/`),
139///   returns it as-is.
140/// - If `source_map_ref` starts with `data:`, returns `None` (inline maps).
141/// - Otherwise, replaces the filename portion of `minified_url` with `source_map_ref`
142///   and handles `../` traversal.
143///
144/// # Examples
145///
146/// ```
147/// use srcmap_sourcemap::utils::resolve_source_map_url;
148/// let url = resolve_source_map_url("https://example.com/js/app.js", "app.js.map");
149/// assert_eq!(url, Some("https://example.com/js/app.js.map".to_string()));
150/// ```
151pub fn resolve_source_map_url(minified_url: &str, source_map_ref: &str) -> Option<String> {
152    // Inline data URLs don't need resolution
153    if source_map_ref.starts_with("data:") {
154        return None;
155    }
156
157    // Already absolute
158    if source_map_ref.starts_with("http://")
159        || source_map_ref.starts_with("https://")
160        || source_map_ref.starts_with('/')
161    {
162        return Some(source_map_ref.to_string());
163    }
164
165    // Find the base directory of the minified URL
166    if let Some(last_slash) = minified_url.rfind('/') {
167        let base = &minified_url[..=last_slash];
168        let combined = format!("{base}{source_map_ref}");
169        Some(normalize_path_components(&combined))
170    } else {
171        // No directory component in the URL
172        Some(source_map_ref.to_string())
173    }
174}
175
176/// Resolve a source map reference against a filesystem path.
177///
178/// Uses the parent directory of `minified_path` as the base, joins with `source_map_ref`,
179/// and normalizes `..` components.
180pub fn resolve_source_map_path(minified_path: &Path, source_map_ref: &str) -> Option<PathBuf> {
181    let parent = minified_path.parent()?;
182    let joined = parent.join(source_map_ref);
183
184    // Normalize the path (resolve .. components without requiring the path to exist)
185    Some(normalize_pathbuf(&joined))
186}
187
188/// Normalize `..` and `.` components in a URL path string.
189fn normalize_path_components(url: &str) -> String {
190    // Split off the protocol+host if present
191    let (prefix, path) = if let Some(idx) = url.find("://") {
192        let after_proto = &url[idx + 3..];
193        if let Some(slash_idx) = after_proto.find('/') {
194            let split_at = idx + 3 + slash_idx;
195            (&url[..split_at], &url[split_at..])
196        } else {
197            return url.to_string();
198        }
199    } else {
200        ("", url)
201    };
202
203    let mut segments: Vec<&str> = Vec::new();
204    for segment in path.split('/') {
205        match segment {
206            ".." => {
207                // Never pop past the root empty segment (leading `/`)
208                if segments.len() > 1 {
209                    segments.pop();
210                }
211            }
212            "." | "" if !segments.is_empty() => {
213                // skip `.` and empty segments (from double slashes), except the leading empty
214            }
215            _ => {
216                segments.push(segment);
217            }
218        }
219    }
220
221    let normalized = segments.join("/");
222    format!("{prefix}{normalized}")
223}
224
225/// Normalize a `PathBuf` by resolving `..` and `.` without filesystem access.
226fn normalize_pathbuf(path: &Path) -> PathBuf {
227    let mut components = Vec::new();
228    for component in path.components() {
229        match component {
230            std::path::Component::ParentDir => {
231                components.pop();
232            }
233            std::path::Component::CurDir => {}
234            _ => {
235                components.push(component);
236            }
237        }
238    }
239    components.iter().collect()
240}
241
242// ── Data URL encoding (gap #4) ───────────────────────────────────
243
244const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
245
246/// Convert a source map JSON string to a `data:` URL.
247///
248/// Format: `data:application/json;base64,<base64-encoded-json>`
249///
250/// # Examples
251///
252/// ```
253/// use srcmap_sourcemap::utils::to_data_url;
254/// let url = to_data_url(r#"{"version":3}"#);
255/// assert!(url.starts_with("data:application/json;base64,"));
256/// ```
257pub fn to_data_url(json: &str) -> String {
258    let encoded = base64_encode(json.as_bytes());
259    format!("data:application/json;base64,{encoded}")
260}
261
262/// Encode bytes to base64 (no external dependency).
263fn base64_encode(input: &[u8]) -> String {
264    let mut result = String::with_capacity(input.len().div_ceil(3) * 4);
265    let chunks = input.chunks(3);
266
267    for chunk in chunks {
268        let b0 = chunk[0] as u32;
269        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
270        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
271
272        let triple = (b0 << 16) | (b1 << 8) | b2;
273
274        result.push(BASE64_CHARS[((triple >> 18) & 0x3F) as usize] as char);
275        result.push(BASE64_CHARS[((triple >> 12) & 0x3F) as usize] as char);
276
277        if chunk.len() > 1 {
278            result.push(BASE64_CHARS[((triple >> 6) & 0x3F) as usize] as char);
279        } else {
280            result.push('=');
281        }
282
283        if chunk.len() > 2 {
284            result.push(BASE64_CHARS[(triple & 0x3F) as usize] as char);
285        } else {
286            result.push('=');
287        }
288    }
289
290    result
291}
292
293// ── RewriteOptions (gap #8) ─────────────────────────────────────
294
295/// Options for rewriting source map paths and content.
296pub struct RewriteOptions<'a> {
297    /// Whether to include names in the output (default: true).
298    pub with_names: bool,
299    /// Whether to include sourcesContent in the output (default: true).
300    pub with_source_contents: bool,
301    /// Prefixes to strip from source paths.
302    /// Use `"~"` to auto-detect and strip the common prefix.
303    pub strip_prefixes: &'a [&'a str],
304}
305
306impl Default for RewriteOptions<'_> {
307    fn default() -> Self {
308        Self { with_names: true, with_source_contents: true, strip_prefixes: &[] }
309    }
310}
311
312/// Create a new `SourceMap` with rewritten source paths.
313///
314/// - If `strip_prefixes` contains `"~"`, auto-detects the common prefix via
315///   [`find_common_prefix`].
316/// - Strips matching prefixes from all source paths.
317/// - If `!with_names`, sets all name indices in mappings to `u32::MAX`.
318/// - If `!with_source_contents`, sets all `sourcesContent` entries to `None`.
319///
320/// Preserves all mappings, `ignore_list`, `extensions`, `debug_id`, and `scopes`.
321pub fn rewrite_sources(sm: &SourceMap, options: &RewriteOptions<'_>) -> SourceMap {
322    // Determine prefixes to strip
323    let auto_prefix = if options.strip_prefixes.contains(&"~") {
324        find_common_prefix(sm.sources.iter().map(|s| s.as_str()))
325    } else {
326        None
327    };
328
329    let explicit_prefixes: Vec<&str> =
330        options.strip_prefixes.iter().filter(|&&p| p != "~").copied().collect();
331
332    // Rewrite sources
333    let sources: Vec<String> = sm
334        .sources
335        .iter()
336        .map(|s| {
337            let mut result = s.as_str();
338
339            // Try auto-detected prefix first
340            if let Some(ref prefix) = auto_prefix
341                && let Some(stripped) = result.strip_prefix(prefix.as_str())
342            {
343                result = stripped;
344            }
345
346            // Try explicit prefixes
347            for prefix in &explicit_prefixes {
348                if let Some(stripped) = result.strip_prefix(prefix) {
349                    result = stripped;
350                    break;
351                }
352            }
353
354            result.to_string()
355        })
356        .collect();
357
358    // Handle sources_content
359    let sources_content = if options.with_source_contents {
360        sm.sources_content.clone()
361    } else {
362        vec![None; sm.sources_content.len()]
363    };
364
365    // Handle names and mappings
366    let (names, mappings) = if options.with_names {
367        (sm.names.clone(), sm.all_mappings().to_vec())
368    } else {
369        let cleared_mappings: Vec<Mapping> =
370            sm.all_mappings().iter().map(|m| Mapping { name: u32::MAX, ..*m }).collect();
371        (Vec::new(), cleared_mappings)
372    };
373
374    let mut result = SourceMap::from_parts(
375        sm.file.clone(),
376        sm.source_root.clone(),
377        sources,
378        sources_content,
379        names,
380        mappings,
381        sm.ignore_list.clone(),
382        sm.debug_id.clone(),
383        sm.scopes.clone(),
384    );
385
386    // Preserve extension fields (x_* keys like x_facebook_sources)
387    result.extensions = sm.extensions.clone();
388
389    result
390}
391
392// ── DecodedMap (gap #9) ──────────────────────────────────────────
393
394/// A unified type that can hold any decoded source map variant.
395///
396/// Dispatches lookups to the underlying type. Currently only supports
397/// regular source maps; the `Hermes` variant will be added when the
398/// hermes crate is integrated.
399pub enum DecodedMap {
400    /// A regular source map.
401    Regular(SourceMap),
402}
403
404impl DecodedMap {
405    /// Parse a JSON string and auto-detect the source map type.
406    pub fn from_json(json: &str) -> Result<Self, ParseError> {
407        let sm = SourceMap::from_json(json)?;
408        Ok(Self::Regular(sm))
409    }
410
411    /// Look up the original source position for a generated position (0-based).
412    pub fn original_position_for(&self, line: u32, column: u32) -> Option<OriginalLocation> {
413        match self {
414            DecodedMap::Regular(sm) => sm.original_position_for(line, column),
415        }
416    }
417
418    /// Look up the generated position for an original source position (0-based).
419    pub fn generated_position_for(
420        &self,
421        source: &str,
422        line: u32,
423        column: u32,
424    ) -> Option<GeneratedLocation> {
425        match self {
426            DecodedMap::Regular(sm) => sm.generated_position_for(source, line, column),
427        }
428    }
429
430    /// All source filenames.
431    pub fn sources(&self) -> &[String] {
432        match self {
433            DecodedMap::Regular(sm) => &sm.sources,
434        }
435    }
436
437    /// All name strings.
438    pub fn names(&self) -> &[String] {
439        match self {
440            DecodedMap::Regular(sm) => &sm.names,
441        }
442    }
443
444    /// Resolve a source index to its filename.
445    ///
446    /// # Panics
447    ///
448    /// Panics if `idx` is out of bounds.
449    pub fn source(&self, idx: u32) -> &str {
450        match self {
451            DecodedMap::Regular(sm) => sm.source(idx),
452        }
453    }
454
455    /// Resolve a name index to its string.
456    ///
457    /// # Panics
458    ///
459    /// Panics if `idx` is out of bounds.
460    pub fn name(&self, idx: u32) -> &str {
461        match self {
462            DecodedMap::Regular(sm) => sm.name(idx),
463        }
464    }
465
466    /// The debug ID, if present.
467    pub fn debug_id(&self) -> Option<&str> {
468        match self {
469            DecodedMap::Regular(sm) => sm.debug_id.as_deref(),
470        }
471    }
472
473    /// Set the debug ID.
474    pub fn set_debug_id(&mut self, id: impl Into<String>) {
475        match self {
476            DecodedMap::Regular(sm) => sm.debug_id = Some(id.into()),
477        }
478    }
479
480    /// Serialize to JSON.
481    pub fn to_json(&self) -> String {
482        match self {
483            DecodedMap::Regular(sm) => sm.to_json(),
484        }
485    }
486
487    /// Extract the inner `SourceMap` if this is the `Regular` variant.
488    pub fn into_source_map(self) -> Option<SourceMap> {
489        match self {
490            DecodedMap::Regular(sm) => Some(sm),
491        }
492    }
493}
494
495// ── Tests ────────────────────────────────────────────────────────
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    // ── find_common_prefix ──────────────────────────────────────
502
503    #[test]
504    fn common_prefix_basic() {
505        let paths = vec!["/a/b/c/file1.js", "/a/b/c/file2.js", "/a/b/c/file3.js"];
506        let result = find_common_prefix(paths.into_iter());
507        assert_eq!(result, Some("/a/b/c/".to_string()));
508    }
509
510    #[test]
511    fn common_prefix_different_depths() {
512        let paths = vec!["/a/b/c/file1.js", "/a/b/d/file2.js"];
513        let result = find_common_prefix(paths.into_iter());
514        assert_eq!(result, Some("/a/b/".to_string()));
515    }
516
517    #[test]
518    fn common_prefix_only_root() {
519        let paths = vec!["/a/file1.js", "/b/file2.js"];
520        let result = find_common_prefix(paths.into_iter());
521        // Only the root `/` is common, which is less than 2 components
522        assert_eq!(result, None);
523    }
524
525    #[test]
526    fn common_prefix_single_path() {
527        let paths = vec!["/a/b/c.js"];
528        let result = find_common_prefix(paths.into_iter());
529        assert_eq!(result, None);
530    }
531
532    #[test]
533    fn common_prefix_no_absolute_paths() {
534        let paths = vec!["a/b/c.js", "a/b/d.js"];
535        let result = find_common_prefix(paths.into_iter());
536        assert_eq!(result, None);
537    }
538
539    #[test]
540    fn common_prefix_mixed_absolute_relative() {
541        let paths = vec!["/a/b/c.js", "a/b/d.js", "/a/b/e.js"];
542        let result = find_common_prefix(paths.into_iter());
543        // Only absolute paths are considered, so /a/b/ is common
544        assert_eq!(result, Some("/a/b/".to_string()));
545    }
546
547    #[test]
548    fn common_prefix_empty_iterator() {
549        let paths: Vec<&str> = vec![];
550        let result = find_common_prefix(paths.into_iter());
551        assert_eq!(result, None);
552    }
553
554    #[test]
555    fn common_prefix_identical_paths() {
556        let paths = vec!["/a/b/c.js", "/a/b/c.js"];
557        let result = find_common_prefix(paths.into_iter());
558        assert_eq!(result, Some("/a/b/".to_string()));
559    }
560
561    // ── make_relative_path ──────────────────────────────────────
562
563    #[test]
564    fn relative_path_sibling_dirs() {
565        assert_eq!(make_relative_path("/a/b/c.js", "/a/d/e.js"), "../d/e.js");
566    }
567
568    #[test]
569    fn relative_path_same_dir() {
570        assert_eq!(make_relative_path("/a/b/c.js", "/a/b/d.js"), "d.js");
571    }
572
573    #[test]
574    fn relative_path_same_file() {
575        assert_eq!(make_relative_path("/a/b/c.js", "/a/b/c.js"), ".");
576    }
577
578    #[test]
579    fn relative_path_deeper_target() {
580        assert_eq!(make_relative_path("/a/b/c.js", "/a/b/d/e/f.js"), "d/e/f.js");
581    }
582
583    #[test]
584    fn relative_path_multiple_ups() {
585        assert_eq!(make_relative_path("/a/b/c/d.js", "/a/e.js"), "../../e.js");
586    }
587
588    #[test]
589    fn relative_path_completely_different() {
590        assert_eq!(make_relative_path("/a/b/c.js", "/x/y/z.js"), "../../x/y/z.js");
591    }
592
593    // ── is_sourcemap ────────────────────────────────────────────
594
595    #[test]
596    fn is_sourcemap_regular() {
597        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
598        assert!(is_sourcemap(json));
599    }
600
601    #[test]
602    fn is_sourcemap_indexed() {
603        let json = r#"{"version":3,"sections":[{"offset":{"line":0,"column":0},"map":{"version":3,"sources":[],"names":[],"mappings":""}}]}"#;
604        assert!(is_sourcemap(json));
605    }
606
607    #[test]
608    fn is_sourcemap_with_source_root() {
609        let json = r#"{"version":3,"sourceRoot":"/src/","mappings":"AAAA"}"#;
610        assert!(is_sourcemap(json));
611    }
612
613    #[test]
614    fn is_sourcemap_with_sources_content() {
615        let json = r#"{"version":3,"sourcesContent":["var x;"],"mappings":"AAAA"}"#;
616        assert!(is_sourcemap(json));
617    }
618
619    #[test]
620    fn is_sourcemap_invalid_json() {
621        assert!(!is_sourcemap("not json"));
622    }
623
624    #[test]
625    fn is_sourcemap_missing_version() {
626        let json = r#"{"sources":["a.js"],"mappings":"AAAA"}"#;
627        assert!(!is_sourcemap(json));
628    }
629
630    #[test]
631    fn is_sourcemap_missing_mappings() {
632        let json = r#"{"version":3,"sources":["a.js"]}"#;
633        assert!(!is_sourcemap(json));
634    }
635
636    #[test]
637    fn is_sourcemap_empty_object() {
638        assert!(!is_sourcemap("{}"));
639    }
640
641    #[test]
642    fn is_sourcemap_array() {
643        assert!(!is_sourcemap("[]"));
644    }
645
646    // ── resolve_source_map_url ──────────────────────────────────
647
648    #[test]
649    fn resolve_url_relative() {
650        let result = resolve_source_map_url("https://example.com/js/app.js", "app.js.map");
651        assert_eq!(result, Some("https://example.com/js/app.js.map".to_string()));
652    }
653
654    #[test]
655    fn resolve_url_parent_traversal() {
656        let result = resolve_source_map_url("https://example.com/js/app.js", "../maps/app.js.map");
657        assert_eq!(result, Some("https://example.com/maps/app.js.map".to_string()));
658    }
659
660    #[test]
661    fn resolve_url_absolute_http() {
662        let result = resolve_source_map_url(
663            "https://example.com/js/app.js",
664            "https://cdn.example.com/maps/app.js.map",
665        );
666        assert_eq!(result, Some("https://cdn.example.com/maps/app.js.map".to_string()));
667    }
668
669    #[test]
670    fn resolve_url_absolute_slash() {
671        let result = resolve_source_map_url("https://example.com/js/app.js", "/maps/app.js.map");
672        assert_eq!(result, Some("/maps/app.js.map".to_string()));
673    }
674
675    #[test]
676    fn resolve_url_data_url() {
677        let result = resolve_source_map_url(
678            "https://example.com/js/app.js",
679            "data:application/json;base64,abc",
680        );
681        assert_eq!(result, None);
682    }
683
684    #[test]
685    fn resolve_url_filesystem_path() {
686        let result = resolve_source_map_url("/js/app.js", "app.js.map");
687        assert_eq!(result, Some("/js/app.js.map".to_string()));
688    }
689
690    #[test]
691    fn resolve_url_no_directory() {
692        let result = resolve_source_map_url("app.js", "app.js.map");
693        assert_eq!(result, Some("app.js.map".to_string()));
694    }
695
696    #[test]
697    fn resolve_url_excessive_traversal() {
698        // `..` should not traverse past the URL root
699        let result =
700            resolve_source_map_url("https://example.com/js/app.js", "../../../maps/app.js.map");
701        assert_eq!(result, Some("https://example.com/maps/app.js.map".to_string()));
702    }
703
704    // ── resolve_source_map_path ─────────────────────────────────
705
706    #[test]
707    fn resolve_path_simple() {
708        let result = resolve_source_map_path(Path::new("/js/app.js"), "app.js.map");
709        assert_eq!(result, Some(PathBuf::from("/js/app.js.map")));
710    }
711
712    #[test]
713    fn resolve_path_parent_traversal() {
714        let result = resolve_source_map_path(Path::new("/js/app.js"), "../maps/app.js.map");
715        assert_eq!(result, Some(PathBuf::from("/maps/app.js.map")));
716    }
717
718    #[test]
719    fn resolve_path_subdirectory() {
720        let result = resolve_source_map_path(Path::new("/src/app.js"), "maps/app.js.map");
721        assert_eq!(result, Some(PathBuf::from("/src/maps/app.js.map")));
722    }
723
724    // ── to_data_url ─────────────────────────────────────────────
725
726    #[test]
727    fn data_url_prefix() {
728        let url = to_data_url(r#"{"version":3}"#);
729        assert!(url.starts_with("data:application/json;base64,"));
730    }
731
732    #[test]
733    fn data_url_roundtrip() {
734        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
735        let url = to_data_url(json);
736        let encoded = url.strip_prefix("data:application/json;base64,").unwrap();
737        let decoded = base64_decode(encoded);
738        assert_eq!(decoded, json);
739    }
740
741    #[test]
742    fn data_url_empty_json() {
743        let url = to_data_url("{}");
744        let encoded = url.strip_prefix("data:application/json;base64,").unwrap();
745        let decoded = base64_decode(encoded);
746        assert_eq!(decoded, "{}");
747    }
748
749    #[test]
750    fn base64_encode_padding_1() {
751        // 1 byte input -> 4 chars with 2 padding
752        let encoded = base64_encode(b"A");
753        assert_eq!(encoded, "QQ==");
754    }
755
756    #[test]
757    fn base64_encode_padding_2() {
758        // 2 byte input -> 4 chars with 1 padding
759        let encoded = base64_encode(b"AB");
760        assert_eq!(encoded, "QUI=");
761    }
762
763    #[test]
764    fn base64_encode_no_padding() {
765        // 3 byte input -> 4 chars with no padding
766        let encoded = base64_encode(b"ABC");
767        assert_eq!(encoded, "QUJD");
768    }
769
770    #[test]
771    fn base64_encode_empty() {
772        assert_eq!(base64_encode(b""), "");
773    }
774
775    /// Test helper: decode base64 (only used in tests).
776    fn base64_decode(input: &str) -> String {
777        let mut lookup = [0u8; 128];
778        for (i, &c) in BASE64_CHARS.iter().enumerate() {
779            lookup[c as usize] = i as u8;
780        }
781
782        let bytes: Vec<u8> = input.bytes().filter(|&b| b != b'=').collect();
783        let mut result = Vec::with_capacity(bytes.len() * 3 / 4);
784
785        for chunk in bytes.chunks(4) {
786            let vals: Vec<u8> = chunk.iter().map(|&b| lookup[b as usize]).collect();
787            if vals.len() >= 2 {
788                result.push((vals[0] << 2) | (vals[1] >> 4));
789            }
790            if vals.len() >= 3 {
791                result.push((vals[1] << 4) | (vals[2] >> 2));
792            }
793            if vals.len() >= 4 {
794                result.push((vals[2] << 6) | vals[3]);
795            }
796        }
797
798        String::from_utf8(result).unwrap()
799    }
800
801    // ── RewriteOptions / rewrite_sources ────────────────────────
802
803    #[test]
804    fn rewrite_options_default() {
805        let opts = RewriteOptions::default();
806        assert!(opts.with_names);
807        assert!(opts.with_source_contents);
808        assert!(opts.strip_prefixes.is_empty());
809    }
810
811    fn make_test_sourcemap() -> SourceMap {
812        let json = r#"{
813            "version": 3,
814            "sources": ["/src/app/main.js", "/src/app/utils.js"],
815            "names": ["foo", "bar"],
816            "mappings": "AACA,SCCA",
817            "sourcesContent": ["var foo;", "var bar;"]
818        }"#;
819        SourceMap::from_json(json).unwrap()
820    }
821
822    #[test]
823    fn rewrite_strip_explicit_prefix() {
824        let sm = make_test_sourcemap();
825        let opts = RewriteOptions { strip_prefixes: &["/src/app/"], ..Default::default() };
826        let rewritten = rewrite_sources(&sm, &opts);
827        assert_eq!(rewritten.sources, vec!["main.js", "utils.js"]);
828    }
829
830    #[test]
831    fn rewrite_strip_auto_prefix() {
832        let sm = make_test_sourcemap();
833        let opts = RewriteOptions { strip_prefixes: &["~"], ..Default::default() };
834        let rewritten = rewrite_sources(&sm, &opts);
835        assert_eq!(rewritten.sources, vec!["main.js", "utils.js"]);
836    }
837
838    #[test]
839    fn rewrite_without_names() {
840        let sm = make_test_sourcemap();
841        let opts = RewriteOptions { with_names: false, ..Default::default() };
842        let rewritten = rewrite_sources(&sm, &opts);
843        // All mappings should have name = u32::MAX
844        for m in rewritten.all_mappings() {
845            assert_eq!(m.name, u32::MAX);
846        }
847    }
848
849    #[test]
850    fn rewrite_without_sources_content() {
851        let sm = make_test_sourcemap();
852        let opts = RewriteOptions { with_source_contents: false, ..Default::default() };
853        let rewritten = rewrite_sources(&sm, &opts);
854        for content in &rewritten.sources_content {
855            assert!(content.is_none());
856        }
857    }
858
859    #[test]
860    fn rewrite_preserves_mappings() {
861        let sm = make_test_sourcemap();
862        let opts = RewriteOptions::default();
863        let rewritten = rewrite_sources(&sm, &opts);
864        assert_eq!(rewritten.all_mappings().len(), sm.all_mappings().len());
865        // Position lookups should still work
866        let loc = rewritten.original_position_for(0, 0);
867        assert!(loc.is_some());
868    }
869
870    #[test]
871    fn rewrite_preserves_debug_id() {
872        let json = r#"{
873            "version": 3,
874            "sources": ["a.js"],
875            "names": [],
876            "mappings": "AAAA",
877            "debugId": "test-id-123"
878        }"#;
879        let sm = SourceMap::from_json(json).unwrap();
880        let opts = RewriteOptions::default();
881        let rewritten = rewrite_sources(&sm, &opts);
882        assert_eq!(rewritten.debug_id.as_deref(), Some("test-id-123"));
883    }
884
885    #[test]
886    fn rewrite_preserves_extensions() {
887        let json = r#"{
888            "version": 3,
889            "sources": ["a.js"],
890            "names": [],
891            "mappings": "AAAA",
892            "x_facebook_sources": [[{"names": ["<global>"], "mappings": "AAA"}]]
893        }"#;
894        let sm = SourceMap::from_json(json).unwrap();
895        assert!(sm.extensions.contains_key("x_facebook_sources"));
896
897        let opts = RewriteOptions::default();
898        let rewritten = rewrite_sources(&sm, &opts);
899        assert!(rewritten.extensions.contains_key("x_facebook_sources"));
900        assert_eq!(sm.extensions["x_facebook_sources"], rewritten.extensions["x_facebook_sources"]);
901    }
902
903    #[test]
904    fn rewrite_without_names_clears_names_vec() {
905        let sm = make_test_sourcemap();
906        let opts = RewriteOptions { with_names: false, ..Default::default() };
907        let rewritten = rewrite_sources(&sm, &opts);
908        assert!(rewritten.names.is_empty());
909    }
910
911    #[test]
912    fn rewrite_strip_no_match() {
913        let sm = make_test_sourcemap();
914        let opts = RewriteOptions { strip_prefixes: &["/other/"], ..Default::default() };
915        let rewritten = rewrite_sources(&sm, &opts);
916        assert_eq!(rewritten.sources, sm.sources);
917    }
918
919    // ── DecodedMap ──────────────────────────────────────────────
920
921    #[test]
922    fn decoded_map_from_json() {
923        let json = r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AACAA"}"#;
924        let dm = DecodedMap::from_json(json).unwrap();
925        assert_eq!(dm.sources(), &["a.js"]);
926        assert_eq!(dm.names(), &["foo"]);
927    }
928
929    #[test]
930    fn decoded_map_original_position() {
931        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
932        let dm = DecodedMap::from_json(json).unwrap();
933        let loc = dm.original_position_for(0, 0).unwrap();
934        assert_eq!(dm.source(loc.source), "a.js");
935        assert_eq!(loc.line, 0);
936        assert_eq!(loc.column, 0);
937    }
938
939    #[test]
940    fn decoded_map_generated_position() {
941        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
942        let dm = DecodedMap::from_json(json).unwrap();
943        let pos = dm.generated_position_for("a.js", 0, 0).unwrap();
944        assert_eq!(pos.line, 0);
945        assert_eq!(pos.column, 0);
946    }
947
948    #[test]
949    fn decoded_map_source_and_name() {
950        let json =
951            r#"{"version":3,"sources":["a.js","b.js"],"names":["x","y"],"mappings":"AACAA,GCCA"}"#;
952        let dm = DecodedMap::from_json(json).unwrap();
953        assert_eq!(dm.source(0), "a.js");
954        assert_eq!(dm.source(1), "b.js");
955        assert_eq!(dm.name(0), "x");
956        assert_eq!(dm.name(1), "y");
957    }
958
959    #[test]
960    fn decoded_map_debug_id() {
961        let json = r#"{"version":3,"sources":[],"names":[],"mappings":"","debugId":"abc-123"}"#;
962        let dm = DecodedMap::from_json(json).unwrap();
963        assert_eq!(dm.debug_id(), Some("abc-123"));
964    }
965
966    #[test]
967    fn decoded_map_set_debug_id() {
968        let json = r#"{"version":3,"sources":[],"names":[],"mappings":""}"#;
969        let mut dm = DecodedMap::from_json(json).unwrap();
970        assert_eq!(dm.debug_id(), None);
971        dm.set_debug_id("new-id");
972        assert_eq!(dm.debug_id(), Some("new-id"));
973    }
974
975    #[test]
976    fn decoded_map_to_json() {
977        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
978        let dm = DecodedMap::from_json(json).unwrap();
979        let output = dm.to_json();
980        // Should be valid JSON containing the same data
981        assert!(output.contains("\"version\":3"));
982        assert!(output.contains("\"sources\":[\"a.js\"]"));
983    }
984
985    #[test]
986    fn decoded_map_into_source_map() {
987        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
988        let dm = DecodedMap::from_json(json).unwrap();
989        let sm = dm.into_source_map().unwrap();
990        assert_eq!(sm.sources, vec!["a.js"]);
991    }
992
993    #[test]
994    fn decoded_map_invalid_json() {
995        let result = DecodedMap::from_json("not json");
996        assert!(result.is_err());
997    }
998
999    #[test]
1000    fn decoded_map_roundtrip() {
1001        let json = r#"{"version":3,"sources":["a.js"],"names":["foo"],"mappings":"AACAA","sourcesContent":["var foo;"]}"#;
1002        let dm = DecodedMap::from_json(json).unwrap();
1003        let output = dm.to_json();
1004        let dm2 = DecodedMap::from_json(&output).unwrap();
1005        assert_eq!(dm2.sources(), &["a.js"]);
1006        assert_eq!(dm2.names(), &["foo"]);
1007    }
1008
1009    // ── Integration tests ───────────────────────────────────────
1010
1011    #[test]
1012    fn data_url_with_is_sourcemap() {
1013        let json = r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#;
1014        assert!(is_sourcemap(json));
1015        let url = to_data_url(json);
1016        assert!(url.starts_with("data:application/json;base64,"));
1017    }
1018
1019    #[test]
1020    fn rewrite_then_serialize() {
1021        let sm = make_test_sourcemap();
1022        let opts = RewriteOptions {
1023            strip_prefixes: &["~"],
1024            with_source_contents: false,
1025            ..Default::default()
1026        };
1027        let rewritten = rewrite_sources(&sm, &opts);
1028        let json = rewritten.to_json();
1029        assert!(is_sourcemap(&json));
1030
1031        // Parse back and verify
1032        let parsed = SourceMap::from_json(&json).unwrap();
1033        assert_eq!(parsed.sources, vec!["main.js", "utils.js"]);
1034    }
1035
1036    #[test]
1037    fn decoded_map_rewrite_roundtrip() {
1038        let json = r#"{"version":3,"sources":["/src/a.js","/src/b.js"],"names":["x"],"mappings":"AACAA,GCAA","sourcesContent":["var x;","var y;"]}"#;
1039        let dm = DecodedMap::from_json(json).unwrap();
1040        let sm = dm.into_source_map().unwrap();
1041
1042        let opts = RewriteOptions {
1043            strip_prefixes: &["~"],
1044            with_source_contents: true,
1045            ..Default::default()
1046        };
1047        let rewritten = rewrite_sources(&sm, &opts);
1048        assert_eq!(rewritten.sources, vec!["a.js", "b.js"]);
1049
1050        let dm2 = DecodedMap::Regular(rewritten);
1051        let output = dm2.to_json();
1052        assert!(is_sourcemap(&output));
1053    }
1054}