Skip to main content

asc_hap/
source_map.rs

1//! `ets/sourceMaps.map`: the source maps a debug build packs next to its code.
2//!
3//! The file is one JSON object with a member per compiled module. The key
4//! names the module, `entry|@scope/lib|1.0.0|src/main/ets/pages/Index.ts`
5//! (the package that was built, the package the module is part of, its
6//! version, the path of the compiled file inside it; older tools write the
7//! path alone, `entry/src/main/ets/pages/Index.ts`). The value is a source
8//! map (version 3) from that compiled file to the source it was made from:
9//!
10//! ```json
11//! {"version": 3, "file": "Index.ets", "sources": ["entry/src/main/ets/pages/Index.ets"],
12//!  "names": [], "mappings": "AAAA;AACA,…", "entry-package-info": "entry|1.0.0"}
13//! ```
14//!
15//! The line numbers of the debug info in the `.abc` are lines of the
16//! compiled file, counted from 0: the file the build wrote after turning
17//! `struct` components into classes, which has lines no source line stands
18//! behind. [`ModuleMap::source_line`] takes such a line to the source.
19//!
20//! The file of a large application is tens of megabytes, almost all of it
21//! `mappings`. [`SourceMaps::index`] walks it once and keeps only where the
22//! value of each module is; [`SourceMaps::module`] parses the one it is
23//! asked for.
24
25use std::collections::HashMap;
26use std::ops::Range;
27
28use serde_json::Value;
29
30/// Why a source map table could not be read.
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32#[non_exhaustive]
33pub enum SourceMapError {
34    /// The table itself is not a JSON object, or ends early.
35    #[error("not a JSON object of source maps: {expected} expected at byte {at}")]
36    Malformed {
37        /// What the grammar wants there.
38        expected: &'static str,
39        /// Byte offset into the table.
40        at: usize,
41    },
42    /// One module's value is not a source map.
43    #[error("the source map of {key} does not read: {reason}")]
44    Module {
45        /// The table key of the module.
46        key: String,
47        /// What is wrong with the value.
48        reason: String,
49    },
50}
51
52/// One member of the table: its key and where its value lies in the file.
53#[derive(Debug, Clone, PartialEq, Eq)]
54struct Slot {
55    key: String,
56    /// `None` for a key that names no version (the older spelling).
57    version: Option<String>,
58    value: Range<usize>,
59}
60
61/// The modules of an `ets/sourceMaps.map`, by the record compiled from each.
62/// It holds no part of the file: the bytes are passed again to read a module.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct SourceMaps {
65    slots: Vec<Slot>,
66    /// `pkg/path/of/Module` (no extension) to the slots of that name, one
67    /// per version of the package.
68    by_name: HashMap<String, Vec<usize>>,
69}
70
71impl SourceMaps {
72    /// Find the members of the table in `bytes`. Their values are skipped,
73    /// not parsed.
74    ///
75    /// # Errors
76    /// [`SourceMapError::Malformed`] when `bytes` is not a JSON object, or
77    /// ends inside one.
78    pub fn index(bytes: &[u8]) -> Result<SourceMaps, SourceMapError> {
79        let mut maps = SourceMaps::default();
80        let mut scan = Scanner { bytes, at: 0 };
81        scan.expect(b'{', "'{'")?;
82        if scan.peek() == Some(b'}') {
83            return Ok(maps);
84        }
85        loop {
86            let key = scan.string()?;
87            scan.expect(b':', "':'")?;
88            let value = scan.value()?;
89            // A key that is not text names no module anyone asks for.
90            if let Ok(key) = serde_json::from_slice::<String>(&bytes[key]) {
91                maps.push(key, value);
92            }
93            match scan.peek() {
94                Some(b',') => scan.at += 1,
95                Some(b'}') => return Ok(maps),
96                _ => return Err(scan.malformed("',' or '}'")),
97            }
98        }
99    }
100
101    fn push(&mut self, key: String, value: Range<usize>) {
102        let (name, version) = ModuleKey::parse(&key).record_stem();
103        let slot = self.slots.len();
104        // Records of the older naming leave `src/main` out: `bundle/entry/ets/pages/Index`.
105        if let Some((package, path)) = name.split_once("/src/main/") {
106            self.by_name.entry(format!("{package}/{path}")).or_default().push(slot);
107        }
108        self.by_name.entry(name).or_default().push(slot);
109        self.slots.push(Slot { key, version, value });
110    }
111
112    /// How many modules the table has.
113    pub fn len(&self) -> usize {
114        self.slots.len()
115    }
116
117    /// Whether the table has no modules.
118    pub fn is_empty(&self) -> bool {
119        self.slots.is_empty()
120    }
121
122    /// The keys of the table, in file order.
123    pub fn keys(&self) -> impl Iterator<Item = &str> {
124        self.slots.iter().map(|slot| slot.key.as_str())
125    }
126
127    /// Bytes the index takes on the heap, roughly: its tables by their
128    /// capacity and the keys they own, without the allocator's overhead.
129    pub fn heap_bytes(&self) -> usize {
130        let slots: usize = self.slots.iter().map(|slot| slot.key.capacity() + slot.version.as_ref().map_or(0, String::capacity)).sum();
131        let names: usize = self.by_name.iter().map(|(name, slots)| name.capacity() + slots.capacity() * size_of::<usize>()).sum();
132        // A hash table keeps one control byte per bucket.
133        let buckets = self.by_name.capacity() * (size_of::<(String, Vec<usize>)>() + 1);
134        self.slots.capacity() * size_of::<Slot>() + slots + buckets + names
135    }
136
137    fn slot_of(&self, record: &str) -> Option<&Slot> {
138        let (name, version) = match record.strip_prefix('&') {
139            // `&pkg/path&version`, the version empty for the package that was built.
140            Some(rest) => {
141                let (name, version) = rest.split_once('&')?;
142                (name, Some(version).filter(|version| !version.is_empty()))
143            }
144            // `bundle/module/ets/path`: the table does not know the bundle.
145            None => (record.split_once('/').map_or(record, |(_, rest)| rest), None),
146        };
147        let slots = self.by_name.get(name)?;
148        let mut slots = slots.iter().map(|&slot| &self.slots[slot]);
149        match version {
150            Some(version) => slots.find(|slot| slot.version.as_deref().is_none_or(|own| own == version)),
151            None => slots.next(),
152        }
153    }
154
155    /// The source map of the module record `record` was compiled from
156    /// (`&entry/src/main/ets/pages/Index&`, `&@scope/lib/Index&1.0.0`,
157    /// `com.example.app/entry/ets/pages/Index`), read from `bytes`, the file
158    /// this was indexed from. `None` when the table has no such module.
159    ///
160    /// # Errors
161    /// [`SourceMapError::Module`] when the value is not a source map.
162    pub fn module(&self, bytes: &[u8], record: &str) -> Option<Result<ModuleMap, SourceMapError>> {
163        let slot = self.slot_of(record)?;
164        let failed = |reason: String| SourceMapError::Module { key: slot.key.clone(), reason };
165        let value = bytes.get(slot.value.clone()).ok_or_else(|| failed("the file is shorter than it was".to_owned()));
166        Some(value.and_then(|value| ModuleMap::parse(value).map_err(failed)))
167    }
168}
169
170/// The parts of a key of the table.
171struct ModuleKey<'k> {
172    /// `(package, version)` of the package the module is part of.
173    package: Option<(&'k str, &'k str)>,
174    /// The compiled file, inside the package (or from the project root in
175    /// the older spelling).
176    path: &'k str,
177}
178
179impl<'k> ModuleKey<'k> {
180    fn parse(key: &'k str) -> ModuleKey<'k> {
181        let mut parts = key.splitn(4, '|');
182        match (parts.next(), parts.next(), parts.next(), parts.next()) {
183            (Some(_built), Some(package), Some(version), Some(path)) => ModuleKey { package: Some((package, version)), path },
184            _ => ModuleKey { package: None, path: key },
185        }
186    }
187
188    /// What record names say of the module: `package/path` without the
189    /// extension of the file, and the version.
190    fn record_stem(&self) -> (String, Option<String>) {
191        let file = self.path.rsplit('/').next().unwrap_or(self.path);
192        let stem = match file.rsplit_once('.') {
193            Some((stem, _)) if !stem.is_empty() => &self.path[..self.path.len() - (file.len() - stem.len())],
194            _ => self.path,
195        };
196        match self.package {
197            Some((package, version)) => (format!("{package}/{stem}"), Some(version.to_owned())),
198            None => (stem.to_owned(), None),
199        }
200    }
201}
202
203/// A walk over JSON text that finds where values end without building them.
204struct Scanner<'b> {
205    bytes: &'b [u8],
206    at: usize,
207}
208
209impl Scanner<'_> {
210    fn malformed(&self, expected: &'static str) -> SourceMapError {
211        SourceMapError::Malformed { expected, at: self.at }
212    }
213
214    /// The next byte that is not white space, without consuming it.
215    fn peek(&mut self) -> Option<u8> {
216        while let Some(&byte) = self.bytes.get(self.at) {
217            if !byte.is_ascii_whitespace() {
218                return Some(byte);
219            }
220            self.at += 1;
221        }
222        None
223    }
224
225    fn expect(&mut self, byte: u8, expected: &'static str) -> Result<(), SourceMapError> {
226        if self.peek() != Some(byte) {
227            return Err(self.malformed(expected));
228        }
229        self.at += 1;
230        Ok(())
231    }
232
233    /// A string, quotes included.
234    fn string(&mut self) -> Result<Range<usize>, SourceMapError> {
235        let start = self.at;
236        self.expect(b'"', "a string")?;
237        while let Some(&byte) = self.bytes.get(self.at) {
238            self.at += 1;
239            match byte {
240                b'"' => return Ok(start..self.at),
241                // Whatever is escaped, it is not the closing quote.
242                b'\\' => self.at += 1,
243                _ => {}
244            }
245        }
246        Err(self.malformed("'\"'"))
247    }
248
249    /// Any value: only its brackets and strings are looked at.
250    fn value(&mut self) -> Result<Range<usize>, SourceMapError> {
251        let Some(first) = self.peek() else { return Err(self.malformed("a value")) };
252        let start = self.at;
253        if first == b'"' {
254            return self.string();
255        }
256        if first != b'{' && first != b'[' {
257            // A number or a word: up to what cannot be part of one.
258            let rest = &self.bytes[start..];
259            let len = rest.iter().position(|byte| byte.is_ascii_whitespace() || matches!(byte, b',' | b'}' | b']' | b'"' | b':'));
260            self.at += len.unwrap_or(rest.len());
261            return if self.at == start { Err(self.malformed("a value")) } else { Ok(start..self.at) };
262        }
263        let mut depth = 0usize;
264        while let Some(&byte) = self.bytes.get(self.at) {
265            match byte {
266                b'"' => {
267                    self.string()?;
268                    continue;
269                }
270                b'{' | b'[' => depth += 1,
271                b'}' | b']' => {
272                    depth -= 1;
273                    if depth == 0 {
274                        self.at += 1;
275                        return Ok(start..self.at);
276                    }
277                }
278                _ => {}
279            }
280            self.at += 1;
281        }
282        Err(self.malformed("']' or '}'"))
283    }
284}
285
286/// A line of one of the source files of a module.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
288#[non_exhaustive]
289pub struct SourceLine {
290    /// Which of [`ModuleMap::sources`], by index.
291    pub source: u32,
292    /// The line of that file, counted from 1.
293    pub line: u32,
294}
295
296/// The source map of one module: which source line each line of the
297/// compiled file comes from.
298#[derive(Debug, Clone, Default, PartialEq, Eq)]
299#[non_exhaustive]
300pub struct ModuleMap {
301    /// The source files, as the build names them: from the project root
302    /// (`entry/src/main/ets/pages/Index.ets`,
303    /// `oh_modules/.ohpm/lib@1.0.0/oh_modules/lib/index.ts`).
304    pub sources: Vec<String>,
305    /// How many identifier names the map carries (`names`): none in the
306    /// maps the build tools are known to write.
307    pub names: usize,
308    /// Per line of the compiled file, from 0: the source line its first
309    /// mapped position comes from.
310    lines: Vec<Option<SourceLine>>,
311    /// The sources `lines` name, ascending.
312    mapped: Vec<u32>,
313    /// Whether `mappings` stopped at something that is not a mapping; the
314    /// lines before it stand.
315    pub damaged: bool,
316}
317
318impl ModuleMap {
319    fn parse(value: &[u8]) -> Result<ModuleMap, String> {
320        let value: Value = serde_json::from_slice(value).map_err(|err| err.to_string())?;
321        let Some(mappings) = value.get("mappings").and_then(Value::as_str) else {
322            return Err("no \"mappings\" string".to_owned());
323        };
324        let sources = value.get("sources").and_then(Value::as_array).map_or_else(Vec::new, |sources| {
325            // A source that is not a string keeps its place: mappings name sources by index.
326            sources.iter().map(|source| source.as_str().unwrap_or_default().to_owned()).collect()
327        });
328        let names = value.get("names").and_then(Value::as_array).map_or(0, Vec::len);
329        let (lines, damaged) = decode_lines(mappings, sources.len());
330        let mut mapped: Vec<u32> = lines.iter().flatten().map(|line| line.source).collect();
331        mapped.sort_unstable();
332        mapped.dedup();
333        Ok(ModuleMap { sources, names, lines, mapped, damaged })
334    }
335
336    /// The source files some line of the compiled file comes from, by
337    /// index into [`ModuleMap::sources`], ascending. A module bundled from
338    /// several files has more than one; `sources` may also list files no
339    /// line maps to.
340    pub fn mapped_sources(&self) -> &[u32] {
341        &self.mapped
342    }
343
344    /// The source line that line `compiled` of the compiled file (counted
345    /// from 0, as the debug info of the code counts) was made from; `None`
346    /// for a line the build added, and for one past the end of the map.
347    pub fn source_line(&self, compiled: u32) -> Option<SourceLine> {
348        self.lines.get(usize::try_from(compiled).ok()?).copied().flatten()
349    }
350
351    /// [`ModuleMap::source_line`] of every line of the compiled file.
352    pub fn source_lines(&self) -> &[Option<SourceLine>] {
353        &self.lines
354    }
355}
356
357/// The value of a base 64 digit of a VLQ.
358fn digit(byte: u8) -> Option<i64> {
359    let value = match byte {
360        b'A'..=b'Z' => byte - b'A',
361        b'a'..=b'z' => byte - b'a' + 26,
362        b'0'..=b'9' => byte - b'0' + 52,
363        b'+' => 62,
364        b'/' => 63,
365        _ => return None,
366    };
367    Some(i64::from(value))
368}
369
370/// The fields of one segment (`AAgBC`): up to five numbers, each relative.
371/// `None` for text that is no segment.
372fn segment(text: &[u8]) -> Option<([i64; 5], usize)> {
373    const CONTINUES: i64 = 32;
374    let mut fields = [0i64; 5];
375    let mut count = 0;
376    let (mut value, mut shift) = (0i64, 0u32);
377    for &byte in text {
378        let digit = digit(byte)?;
379        // A field of a real map is a line or a column: it fits many times over.
380        if shift > 40 || count == fields.len() {
381            return None;
382        }
383        value |= (digit & (CONTINUES - 1)) << shift;
384        if digit & CONTINUES != 0 {
385            shift += 5;
386            continue;
387        }
388        fields[count] = if value & 1 == 1 { -(value >> 1) } else { value >> 1 };
389        count += 1;
390        (value, shift) = (0, 0);
391    }
392    (shift == 0).then_some((fields, count))
393}
394
395/// The source line of the first position of each compiled line that comes
396/// from one of the `sources` source files, and whether the text broke off.
397fn decode_lines(mappings: &str, sources: usize) -> (Vec<Option<SourceLine>>, bool) {
398    let mut lines = Vec::new();
399    // Both run on from segment to segment, across lines.
400    let (mut source, mut source_line) = (0i64, 0i64);
401    for line in mappings.as_bytes().split(|&byte| byte == b';') {
402        let mut first = None;
403        for text in line.split(|&byte| byte == b',').filter(|text| !text.is_empty()) {
404            let Some((fields, count)) = segment(text) else { return (lines, true) };
405            // One field is a column nothing stands behind.
406            if count < 4 {
407                continue;
408            }
409            source = source.saturating_add(fields[1]);
410            source_line = source_line.saturating_add(fields[2]);
411            if first.is_none() {
412                // A source the map does not list, or a line below zero, maps nowhere.
413                let source = u32::try_from(source).ok().filter(|&source| (source as usize) < sources);
414                let line = u32::try_from(source_line).ok().and_then(|line| line.checked_add(1));
415                first = source.zip(line).map(|(source, line)| SourceLine { source, line });
416            }
417        }
418        lines.push(first);
419    }
420    (lines, false)
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    const TABLE: &str = r#"{
428      "entry|entry|1.0.0|src/main/ets/pages/Index.ts": {
429        "version": 3, "file": "Index.ets", "sourceRoot": "",
430        "sources": ["entry/src/main/ets/pages/Index.ets"], "names": [],
431        "mappings": ";;AAAA;AACA,IAAI;;AAGJ", "entry-package-info": "entry|1.0.0"},
432      "entry|@scope/lib|2.0.0|src/main/js/lib.js": {
433        "version": 3, "sources": ["oh_modules/@scope/lib/src/main/js/lib.js"], "names": ["a\"}"], "mappings": "AAEA"},
434      "entry|@scope/lib|3.1.0|src/main/js/lib.js": {"sources": [], "mappings": ""},
435      "entry|entry|1.0.0|src/main/ets/broken.ts": {"mappings": 7},
436      "other/src/main/ets/Old.ts": {"sources": ["other/src/main/ets/Old.ets"], "mappings": "AAAA;A*"}
437    }"#;
438
439    /// Lines of the first source file.
440    fn first(lines: &[Option<u32>]) -> Vec<Option<SourceLine>> {
441        lines.iter().map(|line| line.map(|line| SourceLine { source: 0, line })).collect()
442    }
443
444    #[test]
445    fn modules_are_found_by_record() {
446        let maps = SourceMaps::index(TABLE.as_bytes()).unwrap();
447        assert_eq!(maps.len(), 5);
448        assert_eq!(maps.keys().next(), Some("entry|entry|1.0.0|src/main/ets/pages/Index.ts"));
449        let module = |record: &str| maps.module(TABLE.as_bytes(), record);
450
451        let index = module("&entry/src/main/ets/pages/Index&").unwrap().unwrap();
452        assert_eq!((&index.sources[..], index.mapped_sources()), (&["entry/src/main/ets/pages/Index.ets".to_owned()][..], &[0][..]));
453        // Two lines the build added, then source lines 1 and 2, a gap, line 5.
454        assert_eq!(index.source_lines(), first(&[None, None, Some(1), Some(2), None, Some(5)]));
455        assert_eq!((index.source_line(3).map(|at| at.line), index.source_line(4), index.source_line(99)), (Some(2), None, None));
456        assert!(!index.damaged && index.names == 0);
457        // The older record naming has the bundle ahead and no `src/main`.
458        assert_eq!(module("com.example.app/entry/ets/pages/Index").unwrap().unwrap(), index);
459
460        let lib = module("&@scope/lib/src/main/js/lib&2.0.0").unwrap().unwrap();
461        assert_eq!((lib.source_line(0), lib.names), (Some(SourceLine { source: 0, line: 3 }), 1));
462        assert!(module("&@scope/lib/src/main/js/lib&3.1.0").unwrap().unwrap().mapped_sources().is_empty());
463        assert!(module("&@scope/lib/src/main/js/lib&9.9.9").is_none());
464
465        // The older key is the path alone.
466        let old = module("&other/src/main/ets/Old&").unwrap().unwrap();
467        assert_eq!((old.source_lines(), old.damaged), (&first(&[Some(1)])[..], true));
468
469        assert!(matches!(module("&entry/src/main/ets/broken&"), Some(Err(SourceMapError::Module { .. }))));
470        assert!(module("&entry/src/main/ets/pages/Missing&").is_none() && module("&nonsense").is_none() && module("").is_none());
471    }
472
473    #[test]
474    fn the_index_knows_what_it_holds() {
475        assert_eq!(SourceMaps::default().heap_bytes(), 0);
476        let maps = SourceMaps::index(TABLE.as_bytes()).unwrap();
477        let keys: usize = maps.keys().map(str::len).sum();
478        // Its keys, twice over (the table by name has them without the extension), and the slots.
479        assert!(maps.heap_bytes() > keys + maps.len() * size_of::<Slot>(), "{}", maps.heap_bytes());
480        assert!(maps.heap_bytes() < 16 * TABLE.len(), "{}", maps.heap_bytes());
481    }
482
483    #[test]
484    fn malformed_tables_are_errors_not_panics() {
485        assert_eq!(SourceMaps::index(b"{}").unwrap().len(), 0);
486        assert_eq!(SourceMaps::index(b" { \"a\" : [1, {\"b\": \"]\"}] , \"c\":null } ").unwrap().len(), 2);
487        for text in ["", "[]", "{", "{\"a\"", "{\"a\":", "{\"a\":{", "{\"a\":\"x", "{\"a\":1,", "{\"a\":1 \"b\":2}", "{\"a\\", "{,}"] {
488            assert!(matches!(SourceMaps::index(text.as_bytes()), Err(SourceMapError::Malformed { .. })), "{text}");
489        }
490        // Every prefix of a real table is either read or refused.
491        for end in 0..TABLE.len() {
492            let _ = SourceMaps::index(&TABLE.as_bytes()[..end]);
493        }
494        // A table indexed from other bytes does not read past the ones it is given.
495        let maps = SourceMaps::index(TABLE.as_bytes()).unwrap();
496        assert!(matches!(maps.module(b"{}", "&entry/src/main/ets/pages/Index&"), Some(Err(_))));
497    }
498
499    #[test]
500    fn segments_decode_or_stop() {
501        assert_eq!(segment(b"AAgBC"), Some(([0, 0, 16, 1, 0], 4)));
502        assert_eq!(segment(b"D"), Some(([-1, 0, 0, 0, 0], 1)));
503        assert_eq!(segment(b"A*"), None);
504        // A continuation that never ends, and more fields than a segment has.
505        assert_eq!(segment(b"gggggggggggggggg"), None);
506        assert_eq!(segment(b"AAAAAA"), None);
507        assert_eq!(segment(b"g"), None);
508        // A source line that runs below zero maps nowhere.
509        assert_eq!(decode_lines("AADA;AACA", 1), (first(&[None, Some(1)]), false));
510    }
511
512    /// A module made from several files: the second field of a segment
513    /// moves between them, and runs on across lines like the line does.
514    #[test]
515    fn a_line_comes_from_the_source_its_first_segment_names() {
516        let at = |source, line| Some(SourceLine { source, line });
517        // Line 0: source 0 line 1. Line 1: source +1, line +4, then back to source 0.
518        // Line 2: a bare column, then source 1 again. Line 3: source +1, which is not listed.
519        // Line 4: source -2, back in the list.
520        let mappings = "AAAA;ACIA,ADAA;A,ACAA;ACAA;AFCA";
521        let (lines, damaged) = decode_lines(mappings, 2);
522        assert_eq!((lines, damaged), (vec![at(0, 1), at(1, 5), at(1, 5), None, at(0, 6)], false));
523        // A source index below zero maps nowhere either, and does not stop the rest.
524        assert_eq!(decode_lines("ADAA;ACCA", 2).0, vec![None, at(0, 2)]);
525
526        let table = format!(
527            r#"{{"a|a|1.0.0|src/main/ets/bundle.ts": {{"sources": ["a/one.ets", "a/unused.ets", "a/two.ets"], "mappings": "{}"}}}}"#,
528            "AAAA;AECA;ACAA"
529        );
530        let maps = SourceMaps::index(table.as_bytes()).unwrap();
531        let bundle = maps.module(table.as_bytes(), "&a/src/main/ets/bundle&").unwrap().unwrap();
532        assert_eq!(bundle.source_lines(), [at(0, 1), at(2, 2), None]);
533        // What no line names is not a source of the listing.
534        assert_eq!(bundle.mapped_sources(), [0, 2]);
535    }
536}