Skip to main content

quickjs_oxide/source/
coordinates.rs

1//! Byte offsets and QuickJS source-coordinate mapping.
2
3use std::error::Error;
4use std::fmt;
5
6/// A byte offset into the original UTF-8 source buffer.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct SourceOffset(u32);
9
10impl SourceOffset {
11    /// Convert a host-sized byte offset to QuickJS's bounded source offset.
12    pub fn try_from_usize(value: usize) -> Result<Self, DebugMetadataError> {
13        u32::try_from(value)
14            .map(Self)
15            .map_err(|_| DebugMetadataError::SourceTooLarge)
16    }
17
18    /// Return the original zero-based byte offset.
19    #[must_use]
20    pub const fn get(self) -> u32 {
21        self.0
22    }
23
24    /// Return the offset as a host index.
25    #[must_use]
26    pub const fn as_usize(self) -> usize {
27        self.0 as usize
28    }
29}
30
31/// A zero-based QuickJS debug line and column.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
33pub struct LineColumn {
34    pub line: u32,
35    pub column: u32,
36}
37
38impl LineColumn {
39    #[must_use]
40    pub const fn new(line: u32, column: u32) -> Self {
41        Self { line, column }
42    }
43
44    /// Convert to the one-based spelling used in JavaScript stack traces.
45    #[must_use]
46    pub const fn one_based(self) -> Option<(u32, u32)> {
47        match (self.line.checked_add(1), self.column.checked_add(1)) {
48            (Some(line), Some(column)) => Some((line, column)),
49            _ => None,
50        }
51    }
52}
53
54/// Failures at the source-offset/debug-location boundary.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum DebugMetadataError {
57    SourceTooLarge,
58    OffsetOutOfBounds,
59    OffsetNotUtf8Boundary,
60    LineOrColumnOverflow,
61}
62
63impl fmt::Display for DebugMetadataError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(match self {
66            Self::SourceTooLarge => "source is too large for QuickJS debug metadata",
67            Self::OffsetOutOfBounds => "source offset is out of bounds",
68            Self::OffsetNotUtf8Boundary => "source offset is not a UTF-8 boundary",
69            Self::LineOrColumnOverflow => "QuickJS debug line or column overflowed",
70        })
71    }
72}
73
74impl Error for DebugMetadataError {}
75
76/// Convert authoritative source-buffer byte offsets with pinned QuickJS's
77/// exact `get_line_col` rules: LF is the only newline and bytes shaped like
78/// UTF-8 continuations do not advance the column. Raw parser inputs need not
79/// themselves be valid UTF-8.
80#[derive(Clone, Copy, Debug)]
81pub struct QuickJsSourceLocator<'source> {
82    source: &'source [u8],
83    utf8: Option<&'source str>,
84}
85
86impl<'source> QuickJsSourceLocator<'source> {
87    #[must_use]
88    pub const fn new(source: &'source str) -> Self {
89        Self {
90            source: source.as_bytes(),
91            utf8: Some(source),
92        }
93    }
94
95    #[must_use]
96    pub const fn from_bytes(source: &'source [u8]) -> Self {
97        Self { source, utf8: None }
98    }
99
100    pub fn locate(self, offset: SourceOffset) -> Result<LineColumn, DebugMetadataError> {
101        self.locate_byte_offset(offset.as_usize())
102    }
103
104    pub fn locate_byte_offset(self, byte_offset: usize) -> Result<LineColumn, DebugMetadataError> {
105        if byte_offset > self.source.len() {
106            return Err(DebugMetadataError::OffsetOutOfBounds);
107        }
108        if self
109            .utf8
110            .is_some_and(|source| !source.is_char_boundary(byte_offset))
111        {
112            return Err(DebugMetadataError::OffsetNotUtf8Boundary);
113        }
114
115        advance_position(LineColumn::default(), &self.source[..byte_offset])
116    }
117
118    /// Prepare bounded-distance lookups for consumers with many source sites.
119    /// One-off diagnostics can keep using the allocation-free locator.
120    pub fn index(self) -> Result<QuickJsSourceIndex<'source>, DebugMetadataError> {
121        let mut checkpoints = Vec::with_capacity(self.source.len() / SOURCE_CHECKPOINT_BYTES + 1);
122        let mut position = LineColumn::default();
123        checkpoints.push(position);
124        for chunk in self.source.as_chunks::<SOURCE_CHECKPOINT_BYTES>().0 {
125            position = advance_position(position, chunk)?;
126            checkpoints.push(position);
127        }
128        Ok(QuickJsSourceIndex {
129            locator: self,
130            checkpoints,
131        })
132    }
133}
134
135const SOURCE_CHECKPOINT_BYTES: usize = 256;
136
137/// Source-owned coordinate index shared across all functions in one lowering.
138/// Construction is O(source bytes); each query scans at most 255 bytes after
139/// an O(1) checkpoint lookup. Storage is eight bytes per 256 source bytes,
140/// excluding Vec metadata/capacity. Long single-line sources remain bounded.
141#[derive(Debug)]
142pub struct QuickJsSourceIndex<'source> {
143    locator: QuickJsSourceLocator<'source>,
144    checkpoints: Vec<LineColumn>,
145}
146
147impl<'source> QuickJsSourceIndex<'source> {
148    pub fn locate(&self, offset: SourceOffset) -> Result<LineColumn, DebugMetadataError> {
149        self.locate_byte_offset(offset.as_usize())
150    }
151
152    pub fn locate_byte_offset(&self, byte_offset: usize) -> Result<LineColumn, DebugMetadataError> {
153        self.validate_byte_offset(byte_offset)?;
154        self.locate_validated_byte_offset(byte_offset)
155    }
156
157    /// Keep adjacent lookups local without changing the shared immutable index.
158    pub(crate) fn cursor(&self) -> QuickJsSourceCursor<'_, 'source> {
159        QuickJsSourceCursor {
160            index: self,
161            previous: None,
162        }
163    }
164
165    /// Locate every offset in `offsets` in one forward pass: each source
166    /// byte is scanned at most once, however the offsets are ordered. The
167    /// offsets are kept as one bit per source byte, so collecting them is
168    /// linear and [`QuickJsLocatedOffsets::locate`] takes constant time.
169    pub(crate) fn locate_all(
170        &self,
171        offsets: impl IntoIterator<Item = SourceOffset>,
172    ) -> QuickJsLocatedOffsets<'_, 'source> {
173        let len = self.locator.source.len();
174        let mut words = vec![0_u64; len / 64 + 1];
175        for offset in offsets {
176            let offset = offset.as_usize();
177            if offset <= len {
178                words[offset / 64] |= 1 << (offset % 64);
179            }
180        }
181        let mut cursor = self.cursor();
182        let mut ranks = Vec::with_capacity(words.len());
183        let mut positions = Vec::new();
184        let mut errors = Vec::new();
185        for (word_index, &word) in words.iter().enumerate() {
186            ranks.push(positions.len());
187            let mut bits = word;
188            while bits != 0 {
189                let offset = word_index * 64 + bits.trailing_zeros() as usize;
190                bits &= bits - 1;
191                let at = positions.len();
192                positions.push(cursor.locate_byte_offset(offset).unwrap_or_else(|error| {
193                    errors.push((at, error));
194                    LineColumn::default()
195                }));
196            }
197        }
198        QuickJsLocatedOffsets {
199            index: self,
200            words,
201            ranks,
202            positions,
203            errors,
204        }
205    }
206
207    fn validate_byte_offset(&self, byte_offset: usize) -> Result<(), DebugMetadataError> {
208        if byte_offset > self.locator.source.len() {
209            return Err(DebugMetadataError::OffsetOutOfBounds);
210        }
211        if self
212            .locator
213            .utf8
214            .is_some_and(|source| !source.is_char_boundary(byte_offset))
215        {
216            return Err(DebugMetadataError::OffsetNotUtf8Boundary);
217        }
218        Ok(())
219    }
220
221    fn locate_validated_byte_offset(
222        &self,
223        byte_offset: usize,
224    ) -> Result<LineColumn, DebugMetadataError> {
225        let checkpoint = byte_offset / SOURCE_CHECKPOINT_BYTES;
226        advance_position(
227            self.checkpoints[checkpoint],
228            &self.locator.source[checkpoint * SOURCE_CHECKPOINT_BYTES..byte_offset],
229        )
230    }
231}
232
233/// A cursor over the source-owned index. Only successful
234/// coordinates are cached, and every lookup retains the index's validation.
235/// Forward lookups within one checkpoint reuse the same byte-level kernel on
236/// a shorter suffix; all other lookups retain the bounded checkpoint scan.
237pub(crate) struct QuickJsSourceCursor<'index, 'source> {
238    index: &'index QuickJsSourceIndex<'source>,
239    previous: Option<(usize, LineColumn)>,
240}
241
242impl QuickJsSourceCursor<'_, '_> {
243    fn locate_byte_offset(&mut self, byte_offset: usize) -> Result<LineColumn, DebugMetadataError> {
244        self.index.validate_byte_offset(byte_offset)?;
245        let position = match self.previous {
246            Some((previous_offset, position))
247                if previous_offset <= byte_offset
248                    && previous_offset / SOURCE_CHECKPOINT_BYTES
249                        == byte_offset / SOURCE_CHECKPOINT_BYTES =>
250            {
251                advance_position(
252                    position,
253                    &self.index.locator.source[previous_offset..byte_offset],
254                )?
255            }
256            _ => self.index.locate_validated_byte_offset(byte_offset)?,
257        };
258        self.previous = Some((byte_offset, position));
259        Ok(position)
260    }
261}
262
263/// The coordinates of a set of offsets, from [`QuickJsSourceIndex::locate_all`].
264/// An offset outside the set is located through the index.
265pub(crate) struct QuickJsLocatedOffsets<'index, 'source> {
266    index: &'index QuickJsSourceIndex<'source>,
267    /// One bit per source byte offset: set for the located offsets.
268    words: Vec<u64>,
269    /// The located offsets before each word of `words`.
270    ranks: Vec<usize>,
271    /// The located offsets' coordinates, in offset order.
272    positions: Vec<LineColumn>,
273    /// The offsets that did not locate, by their position in `positions`.
274    errors: Vec<(usize, DebugMetadataError)>,
275}
276
277impl QuickJsLocatedOffsets<'_, '_> {
278    pub(crate) fn locate(&self, offset: SourceOffset) -> Result<LineColumn, DebugMetadataError> {
279        let byte_offset = offset.as_usize();
280        let bit = 1_u64 << (byte_offset % 64);
281        let Some(&word) = self
282            .words
283            .get(byte_offset / 64)
284            .filter(|&&word| word & bit != 0)
285        else {
286            return self.index.locate(offset);
287        };
288        let at = self.ranks[byte_offset / 64] + (word & (bit - 1)).count_ones() as usize;
289        match self.errors.binary_search_by_key(&at, |&(at, _)| at) {
290            Ok(error) => Err(self.errors[error].1),
291            Err(_) => Ok(self.positions[at]),
292        }
293    }
294}
295
296/// Shared byte-level rule, including malformed UTF-8 and CR-only source.
297fn advance_position(
298    mut position: LineColumn,
299    bytes: &[u8],
300) -> Result<LineColumn, DebugMetadataError> {
301    for &byte in bytes {
302        if byte == b'\n' {
303            position.line = position
304                .line
305                .checked_add(1)
306                .ok_or(DebugMetadataError::LineOrColumnOverflow)?;
307            position.column = 0;
308        } else if byte & 0xc0 != 0x80 {
309            position.column = position
310                .column
311                .checked_add(1)
312                .ok_or(DebugMetadataError::LineOrColumnOverflow)?;
313        }
314    }
315    Ok(position)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn indexed_coordinates_match_scans_at_every_raw_byte_and_utf8_boundary() {
324        let raw = [b'a', 0x80, 0xff, b'\r', b'\n', 0xe2, 0x80, 0xa8].repeat(100);
325        let locator = QuickJsSourceLocator::from_bytes(&raw);
326        let indexed = locator.index().unwrap();
327        for offset in (0..=raw.len() + 1).rev() {
328            assert_eq!(
329                indexed.locate_byte_offset(offset),
330                locator.locate_byte_offset(offset)
331            );
332        }
333        let text = ("é\r\n𝄞\u{2028}".to_owned() + &"x".repeat(600)).repeat(3);
334        let locator = QuickJsSourceLocator::new(&text);
335        let indexed = locator.index().unwrap();
336        for offset in 0..=text.len() + 1 {
337            assert_eq!(
338                indexed.locate_byte_offset(offset),
339                locator.locate_byte_offset(offset)
340            );
341        }
342        let empty = QuickJsSourceLocator::from_bytes(b"").index().unwrap();
343        assert_eq!(empty.locate_byte_offset(0), Ok(LineColumn::new(0, 0)));
344    }
345    #[test]
346    fn cursor_matches_index_and_scan_for_forward_backward_and_invalid_offsets() {
347        let raw = [b'a', 0x80, 0xff, b'\r', b'\n', 0xe2, 0x80, 0xa8].repeat(100);
348        let text = ("é\r\n𝄞\u{2028}".to_owned() + &"x".repeat(600)).repeat(3);
349        for locator in [
350            QuickJsSourceLocator::from_bytes(&raw),
351            QuickJsSourceLocator::new(&text),
352            QuickJsSourceLocator::from_bytes(b""),
353        ] {
354            let index = locator.index().unwrap();
355            let mut cursor = index.cursor();
356            let length = locator.source.len();
357            let offsets = (0..=length + 1)
358                .chain((0..=length + 1).rev())
359                .chain((0..=length).flat_map(|offset| {
360                    [offset, usize::MAX, offset, length.saturating_sub(offset)]
361                }));
362            for offset in offsets {
363                let previous = cursor.previous;
364                let expected = index.locate_byte_offset(offset);
365                assert_eq!(expected, locator.locate_byte_offset(offset));
366                assert_eq!(
367                    cursor.locate_byte_offset(offset),
368                    expected,
369                    "offset={offset}"
370                );
371                if expected.is_err() {
372                    assert_eq!(cursor.previous, previous);
373                }
374            }
375        }
376    }
377
378    #[test]
379    fn cursor_preserves_line_and_column_overflow_and_last_success() {
380        // Synthetic checkpoints exercise overflow without allocating sources
381        // larger than the public offset limit. Both paths use the same kernel.
382        for (bytes, position) in [
383            (&b"\nx"[..], LineColumn::new(u32::MAX, 0)),
384            (&b"x\n"[..], LineColumn::new(0, u32::MAX)),
385        ] {
386            let index = QuickJsSourceIndex {
387                locator: QuickJsSourceLocator::from_bytes(bytes),
388                checkpoints: vec![position],
389            };
390            let mut cursor = index.cursor();
391            assert_eq!(cursor.locate_byte_offset(0), Ok(position));
392            for offset in [1, 2, 0, usize::MAX, 1] {
393                let previous = cursor.previous;
394                let expected = index.locate_byte_offset(offset);
395                assert_eq!(cursor.locate_byte_offset(offset), expected);
396                if expected.is_err() {
397                    assert_eq!(cursor.previous, previous);
398                }
399            }
400        }
401    }
402}