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.
167    pub(crate) fn locate_all(
168        &self,
169        mut offsets: Vec<SourceOffset>,
170    ) -> QuickJsLocatedOffsets<'_, 'source> {
171        offsets.sort_unstable();
172        offsets.dedup();
173        let mut cursor = self.cursor();
174        let mut positions = Vec::with_capacity(offsets.len());
175        let mut errors = Vec::new();
176        for (at, &offset) in offsets.iter().enumerate() {
177            positions.push(cursor.locate(offset).unwrap_or_else(|error| {
178                errors.push((at, error));
179                LineColumn::default()
180            }));
181        }
182        QuickJsLocatedOffsets {
183            index: self,
184            offsets,
185            positions,
186            errors,
187        }
188    }
189
190    fn validate_byte_offset(&self, byte_offset: usize) -> Result<(), DebugMetadataError> {
191        if byte_offset > self.locator.source.len() {
192            return Err(DebugMetadataError::OffsetOutOfBounds);
193        }
194        if self
195            .locator
196            .utf8
197            .is_some_and(|source| !source.is_char_boundary(byte_offset))
198        {
199            return Err(DebugMetadataError::OffsetNotUtf8Boundary);
200        }
201        Ok(())
202    }
203
204    fn locate_validated_byte_offset(
205        &self,
206        byte_offset: usize,
207    ) -> Result<LineColumn, DebugMetadataError> {
208        let checkpoint = byte_offset / SOURCE_CHECKPOINT_BYTES;
209        advance_position(
210            self.checkpoints[checkpoint],
211            &self.locator.source[checkpoint * SOURCE_CHECKPOINT_BYTES..byte_offset],
212        )
213    }
214}
215
216/// A cursor over the source-owned index. Only successful
217/// coordinates are cached, and every lookup retains the index's validation.
218/// Forward lookups within one checkpoint reuse the same byte-level kernel on
219/// a shorter suffix; all other lookups retain the bounded checkpoint scan.
220pub(crate) struct QuickJsSourceCursor<'index, 'source> {
221    index: &'index QuickJsSourceIndex<'source>,
222    previous: Option<(usize, LineColumn)>,
223}
224
225impl QuickJsSourceCursor<'_, '_> {
226    pub(crate) fn locate(
227        &mut self,
228        offset: SourceOffset,
229    ) -> Result<LineColumn, DebugMetadataError> {
230        self.locate_byte_offset(offset.as_usize())
231    }
232
233    fn locate_byte_offset(&mut self, byte_offset: usize) -> Result<LineColumn, DebugMetadataError> {
234        self.index.validate_byte_offset(byte_offset)?;
235        let position = match self.previous {
236            Some((previous_offset, position))
237                if previous_offset <= byte_offset
238                    && previous_offset / SOURCE_CHECKPOINT_BYTES
239                        == byte_offset / SOURCE_CHECKPOINT_BYTES =>
240            {
241                advance_position(
242                    position,
243                    &self.index.locator.source[previous_offset..byte_offset],
244                )?
245            }
246            _ => self.index.locate_validated_byte_offset(byte_offset)?,
247        };
248        self.previous = Some((byte_offset, position));
249        Ok(position)
250    }
251}
252
253/// The coordinates of a set of offsets, from [`QuickJsSourceIndex::locate_all`].
254/// An offset outside the set is located through the index.
255pub(crate) struct QuickJsLocatedOffsets<'index, 'source> {
256    index: &'index QuickJsSourceIndex<'source>,
257    /// Sorted and distinct.
258    offsets: Vec<SourceOffset>,
259    positions: Vec<LineColumn>,
260    /// The offsets that did not locate, by their position in `offsets`.
261    errors: Vec<(usize, DebugMetadataError)>,
262}
263
264impl QuickJsLocatedOffsets<'_, '_> {
265    pub(crate) fn locate(&self, offset: SourceOffset) -> Result<LineColumn, DebugMetadataError> {
266        let Ok(at) = self.offsets.binary_search(&offset) else {
267            return self.index.locate(offset);
268        };
269        match self.errors.binary_search_by_key(&at, |&(at, _)| at) {
270            Ok(error) => Err(self.errors[error].1),
271            Err(_) => Ok(self.positions[at]),
272        }
273    }
274}
275
276/// Shared byte-level rule, including malformed UTF-8 and CR-only source.
277fn advance_position(
278    mut position: LineColumn,
279    bytes: &[u8],
280) -> Result<LineColumn, DebugMetadataError> {
281    for &byte in bytes {
282        if byte == b'\n' {
283            position.line = position
284                .line
285                .checked_add(1)
286                .ok_or(DebugMetadataError::LineOrColumnOverflow)?;
287            position.column = 0;
288        } else if byte & 0xc0 != 0x80 {
289            position.column = position
290                .column
291                .checked_add(1)
292                .ok_or(DebugMetadataError::LineOrColumnOverflow)?;
293        }
294    }
295    Ok(position)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn indexed_coordinates_match_scans_at_every_raw_byte_and_utf8_boundary() {
304        let raw = [b'a', 0x80, 0xff, b'\r', b'\n', 0xe2, 0x80, 0xa8].repeat(100);
305        let locator = QuickJsSourceLocator::from_bytes(&raw);
306        let indexed = locator.index().unwrap();
307        for offset in (0..=raw.len() + 1).rev() {
308            assert_eq!(
309                indexed.locate_byte_offset(offset),
310                locator.locate_byte_offset(offset)
311            );
312        }
313        let text = ("é\r\n𝄞\u{2028}".to_owned() + &"x".repeat(600)).repeat(3);
314        let locator = QuickJsSourceLocator::new(&text);
315        let indexed = locator.index().unwrap();
316        for offset in 0..=text.len() + 1 {
317            assert_eq!(
318                indexed.locate_byte_offset(offset),
319                locator.locate_byte_offset(offset)
320            );
321        }
322        let empty = QuickJsSourceLocator::from_bytes(b"").index().unwrap();
323        assert_eq!(empty.locate_byte_offset(0), Ok(LineColumn::new(0, 0)));
324    }
325    #[test]
326    fn cursor_matches_index_and_scan_for_forward_backward_and_invalid_offsets() {
327        let raw = [b'a', 0x80, 0xff, b'\r', b'\n', 0xe2, 0x80, 0xa8].repeat(100);
328        let text = ("é\r\n𝄞\u{2028}".to_owned() + &"x".repeat(600)).repeat(3);
329        for locator in [
330            QuickJsSourceLocator::from_bytes(&raw),
331            QuickJsSourceLocator::new(&text),
332            QuickJsSourceLocator::from_bytes(b""),
333        ] {
334            let index = locator.index().unwrap();
335            let mut cursor = index.cursor();
336            let length = locator.source.len();
337            let offsets = (0..=length + 1)
338                .chain((0..=length + 1).rev())
339                .chain((0..=length).flat_map(|offset| {
340                    [offset, usize::MAX, offset, length.saturating_sub(offset)]
341                }));
342            for offset in offsets {
343                let previous = cursor.previous;
344                let expected = index.locate_byte_offset(offset);
345                assert_eq!(expected, locator.locate_byte_offset(offset));
346                assert_eq!(
347                    cursor.locate_byte_offset(offset),
348                    expected,
349                    "offset={offset}"
350                );
351                if expected.is_err() {
352                    assert_eq!(cursor.previous, previous);
353                }
354            }
355        }
356    }
357
358    #[test]
359    fn cursor_preserves_line_and_column_overflow_and_last_success() {
360        // Synthetic checkpoints exercise overflow without allocating sources
361        // larger than the public offset limit. Both paths use the same kernel.
362        for (bytes, position) in [
363            (&b"\nx"[..], LineColumn::new(u32::MAX, 0)),
364            (&b"x\n"[..], LineColumn::new(0, u32::MAX)),
365        ] {
366            let index = QuickJsSourceIndex {
367                locator: QuickJsSourceLocator::from_bytes(bytes),
368                checkpoints: vec![position],
369            };
370            let mut cursor = index.cursor();
371            assert_eq!(cursor.locate_byte_offset(0), Ok(position));
372            for offset in [1, 2, 0, usize::MAX, 1] {
373                let previous = cursor.previous;
374                let expected = index.locate_byte_offset(offset);
375                assert_eq!(cursor.locate_byte_offset(offset), expected);
376                if expected.is_err() {
377                    assert_eq!(cursor.previous, previous);
378                }
379            }
380        }
381    }
382}