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