Skip to main content

srcmap_hermes/
lib.rs

1//! Hermes/React Native source map support.
2//!
3//! React Native uses the Hermes JavaScript engine, and Metro (the RN bundler)
4//! produces source maps with Facebook-specific extensions:
5//!
6//! - `x_facebook_sources` — VLQ-encoded function scope mappings per source
7//! - `x_facebook_offsets` — byte offsets for modules in a RAM bundle
8//! - `x_metro_module_paths` — module paths for Metro bundles
9//!
10//! This crate wraps a regular [`SourceMap`] and adds scope resolution for
11//! Hermes function maps, similar to getsentry/rust-sourcemap's `SourceMapHermes`.
12//!
13//! # Examples
14//!
15//! ```
16//! use srcmap_hermes::SourceMapHermes;
17//!
18//! let json = r#"{
19//!   "version": 3,
20//!   "sources": ["input.js"],
21//!   "names": [],
22//!   "mappings": "AAAA",
23//!   "x_facebook_sources": [
24//!     [{"names": ["<global>", "foo"], "mappings": "AAA,CCA"}]
25//!   ]
26//! }"#;
27//!
28//! let sm = SourceMapHermes::from_json(json).unwrap();
29//! assert!(sm.get_function_map(0).is_some());
30//! ```
31
32use std::fmt;
33use std::ops::{Deref, DerefMut};
34
35use srcmap_codec::{DecodeError, vlq_decode};
36use srcmap_sourcemap::SourceMap;
37
38// ── Error type ──────────────────────────────────────────────────────
39
40/// Errors that can occur when parsing a Hermes source map.
41#[derive(Debug)]
42pub enum HermesError {
43    /// The underlying source map could not be parsed.
44    Parse(srcmap_sourcemap::ParseError),
45    /// A VLQ-encoded function mapping is malformed.
46    Vlq(DecodeError),
47    /// The `x_facebook_sources` structure is invalid.
48    InvalidFunctionMap(String),
49}
50
51impl fmt::Display for HermesError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Parse(e) => write!(f, "source map parse error: {e}"),
55            Self::Vlq(e) => write!(f, "VLQ decode error in function map: {e}"),
56            Self::InvalidFunctionMap(msg) => write!(f, "invalid function map: {msg}"),
57        }
58    }
59}
60
61impl std::error::Error for HermesError {
62    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
63        match self {
64            Self::Parse(e) => Some(e),
65            Self::Vlq(e) => Some(e),
66            Self::InvalidFunctionMap(_) => None,
67        }
68    }
69}
70
71impl From<srcmap_sourcemap::ParseError> for HermesError {
72    fn from(e: srcmap_sourcemap::ParseError) -> Self {
73        Self::Parse(e)
74    }
75}
76
77impl From<DecodeError> for HermesError {
78    fn from(e: DecodeError) -> Self {
79        Self::Vlq(e)
80    }
81}
82
83// ── Types ───────────────────────────────────────────────────────────
84
85/// A scope offset in Hermes function maps.
86/// Represents the start position of a function scope in the generated code.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct HermesScopeOffset {
89    /// 0-based line in the generated code.
90    pub line: u32,
91    /// 0-based column in the generated code.
92    pub column: u32,
93    /// Index into the function map's `names` array.
94    pub name_index: u32,
95}
96
97/// Function map for a single source file.
98/// Contains function names and their scope boundaries.
99#[derive(Debug, Clone)]
100pub struct HermesFunctionMap {
101    /// Function names referenced by scope offsets.
102    pub names: Vec<String>,
103    /// Scope boundaries, sorted by (line, column).
104    pub mappings: Vec<HermesScopeOffset>,
105}
106
107/// A Hermes-enhanced source map wrapping a regular SourceMap.
108/// Adds function scope information from Metro/Hermes extensions.
109pub struct SourceMapHermes {
110    sm: SourceMap,
111    function_maps: Vec<Option<HermesFunctionMap>>,
112    /// Byte offsets for modules in a RAM bundle.
113    x_facebook_offsets: Option<Vec<Option<u32>>>,
114    /// Module paths for Metro bundles.
115    x_metro_module_paths: Option<Vec<String>>,
116}
117
118impl Deref for SourceMapHermes {
119    type Target = SourceMap;
120
121    #[inline]
122    fn deref(&self) -> &SourceMap {
123        &self.sm
124    }
125}
126
127impl DerefMut for SourceMapHermes {
128    #[inline]
129    fn deref_mut(&mut self) -> &mut SourceMap {
130        &mut self.sm
131    }
132}
133
134// ── VLQ scope decoding ──────────────────────────────────────────────
135
136/// Decode VLQ-encoded Hermes function scope mappings.
137///
138/// The mappings string uses standard VLQ encoding with 3 values per segment:
139/// - column delta
140/// - name_index delta
141/// - line delta
142///
143/// Segments are separated by `,`. All values are delta-encoded.
144fn decode_function_mappings(mappings_str: &str) -> Result<Vec<HermesScopeOffset>, HermesError> {
145    let input = mappings_str.as_bytes();
146    if input.is_empty() {
147        return Ok(Vec::new());
148    }
149
150    let mut result = Vec::new();
151    let mut pos = 0;
152
153    // Running delta state
154    let mut prev_column: i64 = 0;
155    let mut prev_name_index: i64 = 0;
156    let mut prev_line: i64 = 0;
157
158    while pos < input.len() {
159        // Skip commas
160        if input[pos] == b',' {
161            pos += 1;
162            continue;
163        }
164
165        // Decode column delta
166        let (col_delta, consumed) = vlq_decode(input, pos)?;
167        pos += consumed;
168        prev_column += col_delta;
169
170        // Decode name_index delta
171        if pos >= input.len() || input[pos] == b',' {
172            return Err(HermesError::InvalidFunctionMap(
173                "expected 3 values per segment, got 1".to_string(),
174            ));
175        }
176        let (name_delta, consumed) = vlq_decode(input, pos)?;
177        pos += consumed;
178        prev_name_index += name_delta;
179
180        // Decode line delta
181        if pos >= input.len() || input[pos] == b',' {
182            return Err(HermesError::InvalidFunctionMap(
183                "expected 3 values per segment, got 2".to_string(),
184            ));
185        }
186        let (line_delta, consumed) = vlq_decode(input, pos)?;
187        pos += consumed;
188        prev_line += line_delta;
189
190        if prev_line < 0 || prev_column < 0 || prev_name_index < 0 {
191            return Err(HermesError::InvalidFunctionMap(
192                "negative accumulated delta value".to_string(),
193            ));
194        }
195
196        result.push(HermesScopeOffset {
197            line: prev_line as u32,
198            column: prev_column as u32,
199            name_index: prev_name_index as u32,
200        });
201    }
202
203    Ok(result)
204}
205
206/// Parse a single function map entry from JSON.
207fn parse_function_map(entry: &serde_json::Value) -> Result<HermesFunctionMap, HermesError> {
208    let names = entry
209        .get("names")
210        .and_then(|n| n.as_array())
211        .ok_or_else(|| HermesError::InvalidFunctionMap("missing 'names' array".to_string()))?
212        .iter()
213        .map(|v| {
214            v.as_str()
215                .ok_or_else(|| HermesError::InvalidFunctionMap("name is not a string".to_string()))
216                .map(|s| s.to_string())
217        })
218        .collect::<Result<Vec<_>, _>>()?;
219
220    let mappings_str = entry
221        .get("mappings")
222        .and_then(|m| m.as_str())
223        .ok_or_else(|| HermesError::InvalidFunctionMap("missing 'mappings' string".to_string()))?;
224
225    let mappings = decode_function_mappings(mappings_str)?;
226
227    Ok(HermesFunctionMap { names, mappings })
228}
229
230/// Parse `x_facebook_sources` from the extensions map.
231///
232/// The structure is:
233/// ```json
234/// [
235///   [{"names": ["fn1", "fn2"], "mappings": "AAA,EC"}],
236///   null,
237///   ...
238/// ]
239/// ```
240///
241/// Each top-level entry corresponds to a source. Each source has an array of
242/// function map entries (usually one). `null` means no function map for that source.
243fn parse_facebook_sources(
244    value: &serde_json::Value,
245) -> Result<Vec<Option<HermesFunctionMap>>, HermesError> {
246    let sources_array = value.as_array().ok_or_else(|| {
247        HermesError::InvalidFunctionMap("x_facebook_sources is not an array".to_string())
248    })?;
249
250    let mut result = Vec::with_capacity(sources_array.len());
251
252    for entry in sources_array {
253        if entry.is_null() {
254            result.push(None);
255            continue;
256        }
257
258        let entries = entry.as_array().ok_or_else(|| {
259            HermesError::InvalidFunctionMap(
260                "x_facebook_sources entry is not an array or null".to_string(),
261            )
262        })?;
263
264        if entries.is_empty() {
265            result.push(None);
266            continue;
267        }
268
269        // Take the first (and typically only) function map entry per source
270        let function_map = parse_function_map(&entries[0])?;
271        result.push(Some(function_map));
272    }
273
274    Ok(result)
275}
276
277/// Parse `x_facebook_offsets` from the extensions map.
278fn parse_facebook_offsets(value: &serde_json::Value) -> Option<Vec<Option<u32>>> {
279    let arr = value.as_array()?;
280    Some(arr.iter().map(|v| v.as_u64().map(|n| n as u32)).collect())
281}
282
283/// Parse `x_metro_module_paths` from the extensions map.
284fn parse_metro_module_paths(value: &serde_json::Value) -> Option<Vec<String>> {
285    let arr = value.as_array()?;
286    Some(
287        arr.iter()
288            .map(|v| v.as_str().unwrap_or("").to_string())
289            .collect(),
290    )
291}
292
293// ── SourceMapHermes impl ────────────────────────────────────────────
294
295impl SourceMapHermes {
296    /// Parse a Hermes source map from JSON.
297    ///
298    /// First parses as a regular source map, then extracts and decodes
299    /// the `x_facebook_sources`, `x_facebook_offsets`, and
300    /// `x_metro_module_paths` extension fields.
301    pub fn from_json(json: &str) -> Result<Self, HermesError> {
302        let sm = SourceMap::from_json(json)?;
303
304        let function_maps = match sm.extensions.get("x_facebook_sources") {
305            Some(value) => parse_facebook_sources(value)?,
306            None => Vec::new(),
307        };
308
309        let x_facebook_offsets = sm
310            .extensions
311            .get("x_facebook_offsets")
312            .and_then(parse_facebook_offsets);
313
314        let x_metro_module_paths = sm
315            .extensions
316            .get("x_metro_module_paths")
317            .and_then(parse_metro_module_paths);
318
319        Ok(Self {
320            sm,
321            function_maps,
322            x_facebook_offsets,
323            x_metro_module_paths,
324        })
325    }
326
327    /// Get a reference to the inner SourceMap.
328    #[inline]
329    pub fn inner(&self) -> &SourceMap {
330        &self.sm
331    }
332
333    /// Consume this Hermes source map and return the inner SourceMap.
334    #[inline]
335    pub fn into_inner(self) -> SourceMap {
336        self.sm
337    }
338
339    /// Get the function map for a source by index.
340    #[inline]
341    pub fn get_function_map(&self, source_idx: u32) -> Option<&HermesFunctionMap> {
342        self.function_maps
343            .get(source_idx as usize)
344            .and_then(|fm| fm.as_ref())
345    }
346
347    /// Find the enclosing function scope for a position in the generated code.
348    ///
349    /// First resolves the generated position to an original location via the
350    /// source map, then looks up the function scope in the correct source's
351    /// function map using the original coordinates. Both `line` and `column`
352    /// are 0-based.
353    pub fn get_scope_for_token(&self, line: u32, column: u32) -> Option<&str> {
354        let loc = self.sm.original_position_for(line, column)?;
355        let function_map = self.get_function_map(loc.source)?;
356
357        if function_map.mappings.is_empty() {
358            return None;
359        }
360
361        // Binary search for greatest lower bound using original coordinates
362        let idx = match function_map.mappings.binary_search_by(|offset| {
363            offset
364                .line
365                .cmp(&loc.line)
366                .then(offset.column.cmp(&loc.column))
367        }) {
368            Ok(i) => i,
369            Err(0) => return None,
370            Err(i) => i - 1,
371        };
372
373        let scope = &function_map.mappings[idx];
374        function_map
375            .names
376            .get(scope.name_index as usize)
377            .map(|n| n.as_str())
378    }
379
380    /// Get the original function name for a position in the generated code.
381    ///
382    /// First looks up the original position via the source map, then finds
383    /// the enclosing function scope in the corresponding source's function map
384    /// using the original (not generated) coordinates.
385    pub fn get_original_function_name(&self, line: u32, column: u32) -> Option<&str> {
386        let loc = self.sm.original_position_for(line, column)?;
387        let function_map = self.get_function_map(loc.source)?;
388
389        if function_map.mappings.is_empty() {
390            return None;
391        }
392
393        // Binary search for greatest lower bound using original coordinates
394        let idx = match function_map.mappings.binary_search_by(|offset| {
395            offset
396                .line
397                .cmp(&loc.line)
398                .then(offset.column.cmp(&loc.column))
399        }) {
400            Ok(i) => i,
401            Err(0) => return None,
402            Err(i) => i - 1,
403        };
404
405        let scope = &function_map.mappings[idx];
406        function_map
407            .names
408            .get(scope.name_index as usize)
409            .map(|n| n.as_str())
410    }
411
412    /// Check if this source map is for a RAM (Random Access Module) bundle.
413    ///
414    /// Returns `true` if `x_facebook_offsets` is present.
415    #[inline]
416    pub fn is_for_ram_bundle(&self) -> bool {
417        self.x_facebook_offsets.is_some()
418    }
419
420    /// Get the `x_facebook_offsets` (byte offsets for RAM bundle modules).
421    #[inline]
422    pub fn x_facebook_offsets(&self) -> Option<&[Option<u32>]> {
423        self.x_facebook_offsets.as_deref()
424    }
425
426    /// Get the `x_metro_module_paths` (module paths for Metro bundles).
427    #[inline]
428    pub fn x_metro_module_paths(&self) -> Option<&[String]> {
429        self.x_metro_module_paths.as_deref()
430    }
431
432    /// Serialize back to JSON, preserving the Facebook extensions.
433    ///
434    /// The inner source map already stores all extension fields (including
435    /// `x_facebook_sources`, `x_facebook_offsets`, `x_metro_module_paths`)
436    /// from the original parse, so this delegates directly.
437    pub fn to_json(&self) -> String {
438        self.sm.to_json()
439    }
440}
441
442impl fmt::Debug for SourceMapHermes {
443    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444        f.debug_struct("SourceMapHermes")
445            .field("sources", &self.sm.sources)
446            .field("function_maps_count", &self.function_maps.len())
447            .field("has_facebook_offsets", &self.x_facebook_offsets.is_some())
448            .field(
449                "has_metro_module_paths",
450                &self.x_metro_module_paths.is_some(),
451            )
452            .finish()
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    fn sample_hermes_json() -> &'static str {
461        r#"{
462            "version": 3,
463            "sources": ["input.js"],
464            "names": ["myFunc"],
465            "mappings": "AAAA;AACA",
466            "x_facebook_sources": [
467                [{"names": ["<global>", "foo", "bar"], "mappings": "AAA,ECA,GGC"}]
468            ]
469        }"#
470    }
471
472    #[test]
473    fn parse_hermes_sourcemap() {
474        let sm = SourceMapHermes::from_json(sample_hermes_json()).unwrap();
475        assert_eq!(sm.sources.len(), 1);
476        assert_eq!(sm.sources[0], "input.js");
477        assert!(sm.get_function_map(0).is_some());
478        assert!(sm.get_function_map(1).is_none());
479    }
480
481    #[test]
482    fn function_map_names() {
483        let sm = SourceMapHermes::from_json(sample_hermes_json()).unwrap();
484        let fm = sm.get_function_map(0).unwrap();
485        assert_eq!(fm.names, vec!["<global>", "foo", "bar"]);
486    }
487
488    #[test]
489    fn function_map_mappings_decoded() {
490        let sm = SourceMapHermes::from_json(sample_hermes_json()).unwrap();
491        let fm = sm.get_function_map(0).unwrap();
492
493        // "AAA" -> col=0, name=0, line=0
494        assert_eq!(
495            fm.mappings[0],
496            HermesScopeOffset {
497                line: 0,
498                column: 0,
499                name_index: 0
500            }
501        );
502
503        // "ECA" -> col delta=2, name delta=1, line delta=0
504        // absolute: col=2, name=1, line=0
505        assert_eq!(
506            fm.mappings[1],
507            HermesScopeOffset {
508                line: 0,
509                column: 2,
510                name_index: 1
511            }
512        );
513
514        // "GGC" -> col delta=3, name delta=3, line delta=1
515        // absolute: col=5, name=4, line=1
516        // Wait, let's decode manually:
517        // G=3, G=3, C=1
518        // col: prev_col(2) + 3 = 5
519        // name: prev_name(1) + 3 = 4  (but we only have 3 names: 0,1,2)
520        // line: prev_line(0) + 1 = 1
521        // Actually that would be out of bounds. Let me re-check the VLQ values.
522        // E = 2, C = 1, A = 0 for the second segment
523        // G = 3, G = 3, C = 1 for the third segment
524        // Third segment absolute: col=2+3=5, name=1+3=4, line=0+1=1
525        // name_index 4 is out of range (only 3 names), but that's just test data
526        assert_eq!(
527            fm.mappings[2],
528            HermesScopeOffset {
529                line: 1,
530                column: 5,
531                name_index: 4
532            }
533        );
534    }
535
536    #[test]
537    fn scope_resolution() {
538        // Create a source map with known function scopes
539        let json = r#"{
540            "version": 3,
541            "sources": ["a.js"],
542            "names": [],
543            "mappings": "AAAA;AACA;AACA;AACA;AACA",
544            "x_facebook_sources": [
545                [{"names": ["<global>", "foo", "bar"], "mappings": "AAA,ECA,AGC"}]
546            ]
547        }"#;
548        // Mappings decode:
549        // Segment 1: "AAA" -> col=0, name=0, line=0 -> <global> at (0,0)
550        // Segment 2: "ECA" -> col delta=2, name delta=1, line delta=0 -> foo at (0,2)
551        // Segment 3: "AGC" -> col delta=0, name delta=3, line delta=1 -> name_index=4...
552        // Hmm, let me use simpler values
553
554        let sm = SourceMapHermes::from_json(json).unwrap();
555        let fm = sm.get_function_map(0).unwrap();
556
557        // First scope: <global> at line=0, column=0
558        assert_eq!(fm.mappings[0].name_index, 0);
559        // line=0 -> lookup_line=1 in get_scope_for_token, but the mapping has line=0
560        // So we need to think about this carefully.
561
562        // get_scope_for_token takes 0-based line, converts to 1-based for comparison.
563        // But our mappings have line=0 from the VLQ.
564        // In Hermes, function map lines are already 1-based in the encoding.
565        // But VLQ "AAA" decodes to line=0 which means the initial delta is 0.
566        // The initial absolute line value starts at 0, so the first line = 0+0 = 0.
567        // This is the raw VLQ value. In Hermes convention, line 0 means "before anything".
568
569        // For scope lookup: we look up line+1 (0-based to 1-based conversion).
570        // So looking up line=0 means lookup_line=1, but our scope at line=0 is before that.
571        // This should still find the scope via GLB.
572    }
573
574    #[test]
575    fn scope_for_token_basic() {
576        // Source map with identity mappings: gen (0,0)->orig (0,0), gen (0,1)->orig (0,1)
577        // Function map scopes in original source: <global> at (0,0), hello at (0,1)
578        let json = r#"{
579            "version": 3,
580            "sources": ["a.js"],
581            "names": [],
582            "mappings": "AAAA,CAAC",
583            "x_facebook_sources": [
584                [{"names": ["<global>", "hello"], "mappings": "AAA,CCA"}]
585            ]
586        }"#;
587        // Source map mappings: gen(0,0)->orig(0,0), gen(0,1)->orig(0,1)
588        // Function map: <global> at orig(0,0), hello at orig(0,1)
589
590        let sm = SourceMapHermes::from_json(json).unwrap();
591
592        // gen(0,0) -> orig(0,0) -> scope <global> (GLB at (0,0))
593        let scope = sm.get_scope_for_token(0, 0);
594        assert_eq!(scope, Some("<global>"));
595
596        // gen(0,1) -> orig(0,1) -> scope hello (GLB at (0,1))
597        let scope = sm.get_scope_for_token(0, 1);
598        assert_eq!(scope, Some("hello"));
599    }
600
601    #[test]
602    fn empty_function_map() {
603        let json = r#"{
604            "version": 3,
605            "sources": ["a.js", "b.js"],
606            "names": [],
607            "mappings": "AAAA",
608            "x_facebook_sources": [
609                [{"names": ["<global>"], "mappings": "AAA"}],
610                null
611            ]
612        }"#;
613
614        let sm = SourceMapHermes::from_json(json).unwrap();
615        assert!(sm.get_function_map(0).is_some());
616        assert!(sm.get_function_map(1).is_none());
617    }
618
619    #[test]
620    fn no_facebook_sources() {
621        let json = r#"{
622            "version": 3,
623            "sources": ["a.js"],
624            "names": [],
625            "mappings": "AAAA"
626        }"#;
627
628        let sm = SourceMapHermes::from_json(json).unwrap();
629        assert!(sm.get_function_map(0).is_none());
630        // get_scope_for_token resolves via source map then checks function map
631        // With no x_facebook_sources, there are no function maps, so returns None
632        assert!(sm.get_scope_for_token(0, 0).is_none());
633    }
634
635    #[test]
636    fn ram_bundle_detection() {
637        let json = r#"{
638            "version": 3,
639            "sources": ["a.js"],
640            "names": [],
641            "mappings": "AAAA",
642            "x_facebook_offsets": [0, 100, null, 300]
643        }"#;
644
645        let sm = SourceMapHermes::from_json(json).unwrap();
646        assert!(sm.is_for_ram_bundle());
647
648        let offsets = sm.x_facebook_offsets().unwrap();
649        assert_eq!(offsets.len(), 4);
650        assert_eq!(offsets[0], Some(0));
651        assert_eq!(offsets[1], Some(100));
652        assert_eq!(offsets[2], None);
653        assert_eq!(offsets[3], Some(300));
654    }
655
656    #[test]
657    fn not_ram_bundle() {
658        let json = r#"{
659            "version": 3,
660            "sources": ["a.js"],
661            "names": [],
662            "mappings": "AAAA"
663        }"#;
664
665        let sm = SourceMapHermes::from_json(json).unwrap();
666        assert!(!sm.is_for_ram_bundle());
667        assert!(sm.x_facebook_offsets().is_none());
668    }
669
670    #[test]
671    fn metro_module_paths() {
672        let json = r#"{
673            "version": 3,
674            "sources": ["a.js"],
675            "names": [],
676            "mappings": "AAAA",
677            "x_metro_module_paths": ["./src/App.js", "./src/utils.js"]
678        }"#;
679
680        let sm = SourceMapHermes::from_json(json).unwrap();
681        let paths = sm.x_metro_module_paths().unwrap();
682        assert_eq!(paths, &["./src/App.js", "./src/utils.js"]);
683    }
684
685    #[test]
686    fn deref_to_sourcemap() {
687        let json = r#"{
688            "version": 3,
689            "sources": ["input.js"],
690            "names": ["x"],
691            "mappings": "AAAA"
692        }"#;
693
694        let sm = SourceMapHermes::from_json(json).unwrap();
695        // Access SourceMap methods via Deref
696        assert_eq!(sm.sources.len(), 1);
697        assert_eq!(sm.source(0), "input.js");
698        assert_eq!(sm.names.len(), 1);
699    }
700
701    #[test]
702    fn into_inner() {
703        let json = r#"{
704            "version": 3,
705            "sources": ["input.js"],
706            "names": [],
707            "mappings": "AAAA"
708        }"#;
709
710        let sm = SourceMapHermes::from_json(json).unwrap();
711        let inner = sm.into_inner();
712        assert_eq!(inner.sources.len(), 1);
713    }
714
715    #[test]
716    fn roundtrip_serialization() {
717        let json = r#"{
718            "version": 3,
719            "sources": ["a.js"],
720            "names": [],
721            "mappings": "AAAA",
722            "x_facebook_sources": [
723                [{"names": ["<global>", "foo"], "mappings": "AAA,CCA"}]
724            ]
725        }"#;
726
727        let sm = SourceMapHermes::from_json(json).unwrap();
728        let output = sm.to_json();
729
730        // Parse it again to verify
731        let sm2 = SourceMapHermes::from_json(&output).unwrap();
732        assert_eq!(sm2.sources.len(), 1);
733        assert!(sm2.get_function_map(0).is_some());
734
735        let fm = sm2.get_function_map(0).unwrap();
736        assert_eq!(fm.names, vec!["<global>", "foo"]);
737        assert_eq!(fm.mappings.len(), 2);
738    }
739
740    #[test]
741    fn roundtrip_with_offsets_and_paths() {
742        let json = r#"{
743            "version": 3,
744            "sources": ["a.js"],
745            "names": [],
746            "mappings": "AAAA",
747            "x_facebook_offsets": [0, 100],
748            "x_metro_module_paths": ["./a.js"]
749        }"#;
750
751        let sm = SourceMapHermes::from_json(json).unwrap();
752        let output = sm.to_json();
753
754        let sm2 = SourceMapHermes::from_json(&output).unwrap();
755        assert!(sm2.is_for_ram_bundle());
756        assert_eq!(sm2.x_facebook_offsets().unwrap(), &[Some(0), Some(100)]);
757        assert_eq!(sm2.x_metro_module_paths().unwrap(), &["./a.js"]);
758    }
759
760    #[test]
761    fn empty_mappings_string() {
762        let json = r#"{
763            "version": 3,
764            "sources": ["a.js"],
765            "names": [],
766            "mappings": "AAAA",
767            "x_facebook_sources": [
768                [{"names": [], "mappings": ""}]
769            ]
770        }"#;
771
772        let sm = SourceMapHermes::from_json(json).unwrap();
773        let fm = sm.get_function_map(0).unwrap();
774        assert!(fm.names.is_empty());
775        assert!(fm.mappings.is_empty());
776    }
777
778    #[test]
779    fn invalid_function_map_missing_names() {
780        let json = r#"{
781            "version": 3,
782            "sources": ["a.js"],
783            "names": [],
784            "mappings": "AAAA",
785            "x_facebook_sources": [
786                [{"mappings": "AAA"}]
787            ]
788        }"#;
789
790        let err = SourceMapHermes::from_json(json).unwrap_err();
791        assert!(matches!(err, HermesError::InvalidFunctionMap(_)));
792    }
793
794    #[test]
795    fn invalid_function_map_missing_mappings() {
796        let json = r#"{
797            "version": 3,
798            "sources": ["a.js"],
799            "names": [],
800            "mappings": "AAAA",
801            "x_facebook_sources": [
802                [{"names": ["foo"]}]
803            ]
804        }"#;
805
806        let err = SourceMapHermes::from_json(json).unwrap_err();
807        assert!(matches!(err, HermesError::InvalidFunctionMap(_)));
808    }
809
810    #[test]
811    fn all_null_facebook_sources() {
812        let json = r#"{
813            "version": 3,
814            "sources": ["a.js", "b.js"],
815            "names": [],
816            "mappings": "AAAA",
817            "x_facebook_sources": [null, null]
818        }"#;
819
820        let sm = SourceMapHermes::from_json(json).unwrap();
821        assert!(sm.get_function_map(0).is_none());
822        assert!(sm.get_function_map(1).is_none());
823    }
824
825    #[test]
826    fn debug_format() {
827        let json = r#"{
828            "version": 3,
829            "sources": ["a.js"],
830            "names": [],
831            "mappings": "AAAA"
832        }"#;
833
834        let sm = SourceMapHermes::from_json(json).unwrap();
835        let debug = format!("{sm:?}");
836        assert!(debug.contains("SourceMapHermes"));
837    }
838
839    #[test]
840    fn error_display() {
841        let err = HermesError::InvalidFunctionMap("test error".to_string());
842        assert_eq!(err.to_string(), "invalid function map: test error");
843
844        let err = HermesError::Vlq(DecodeError::UnexpectedEof { offset: 5 });
845        let msg = err.to_string();
846        assert!(msg.contains("VLQ"));
847    }
848}