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