Skip to main content

bamts_compiler/
source.rs

1use std::{fmt, path::Path, sync::Arc};
2
3/// Identifies one source file within a compilation.
4#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
5#[repr(transparent)]
6pub struct SourceId(u32);
7
8impl SourceId {
9    /// Creates an identifier from its compiler-assigned value.
10    #[must_use]
11    pub const fn new(value: u32) -> Self {
12        Self(value)
13    }
14
15    /// Returns the compiler-assigned value.
16    #[must_use]
17    pub const fn get(self) -> u32 {
18        self.0
19    }
20}
21
22impl From<u32> for SourceId {
23    fn from(value: u32) -> Self {
24        Self::new(value)
25    }
26}
27/// The canonical identity of one source in a resolved program.
28///
29/// The path is filesystem-canonical and the numeric id is assigned once by the
30/// compiler's program loader; consumers must not substitute mtimes or allocations.
31#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct SourceIdentity {
33    source_id: SourceId,
34    path: Arc<Path>,
35}
36
37impl SourceIdentity {
38    pub(crate) fn new(source_id: SourceId, path: Arc<Path>) -> Self {
39        Self { source_id, path }
40    }
41
42    #[must_use]
43    pub const fn source_id(&self) -> SourceId {
44        self.source_id
45    }
46
47    #[must_use]
48    pub fn path(&self) -> &Path {
49        &self.path
50    }
51}
52
53/// A zero-based offset measured in UTF-16 code units.
54#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
55#[repr(transparent)]
56pub struct Utf16Pos(usize);
57
58impl Utf16Pos {
59    /// The first UTF-16 position in a source.
60    pub const ZERO: Self = Self(0);
61
62    /// Creates a UTF-16 coordinate. A [`SourceText`] validates it against text.
63    #[must_use]
64    pub const fn new(offset: usize) -> Self {
65        Self(offset)
66    }
67
68    /// Returns the coordinate's UTF-16 code-unit offset.
69    #[must_use]
70    pub const fn get(self) -> usize {
71        self.0
72    }
73}
74
75impl From<usize> for Utf16Pos {
76    fn from(offset: usize) -> Self {
77        Self::new(offset)
78    }
79}
80
81/// A half-open range of UTF-16 source positions.
82#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
83pub struct TextRange {
84    start: Utf16Pos,
85    end: Utf16Pos,
86}
87
88impl TextRange {
89    /// Creates a range when its endpoints are ordered.
90    pub const fn new(start: Utf16Pos, end: Utf16Pos) -> Result<Self, SourcePositionError> {
91        if start.get() > end.get() {
92            return Err(SourcePositionError::RangeStartAfterEnd { start, end });
93        }
94
95        Ok(Self { start, end })
96    }
97
98    /// Returns the inclusive start coordinate.
99    #[must_use]
100    pub const fn start(self) -> Utf16Pos {
101        self.start
102    }
103
104    /// Returns the exclusive end coordinate.
105    #[must_use]
106    pub const fn end(self) -> Utf16Pos {
107        self.end
108    }
109
110    /// Returns whether the range contains no UTF-16 code units.
111    #[must_use]
112    pub const fn is_empty(self) -> bool {
113        self.start.get() == self.end.get()
114    }
115
116    /// Returns the range length in UTF-16 code units.
117    #[must_use]
118    pub const fn len(self) -> usize {
119        self.end.get() - self.start.get()
120    }
121}
122
123/// The syntax accepted for a source file.
124#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
125pub enum ScriptKind {
126    JavaScript,
127    JavaScriptReact,
128    TypeScript,
129    TypeScriptReact,
130    Json,
131}
132
133/// A failed checked source-position operation.
134///
135/// This enum is deliberately closed: callers can exhaustively distinguish an
136/// out-of-bounds coordinate from a coordinate that splits one encoded character.
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub enum SourcePositionError {
139    ByteOffsetOutOfBounds { offset: usize, len: usize },
140    ByteOffsetInsideCodePoint { offset: usize },
141    Utf16PositionOutOfBounds { position: Utf16Pos, len: Utf16Pos },
142    Utf16PositionInsideSurrogatePair { position: Utf16Pos },
143    RangeStartAfterEnd { start: Utf16Pos, end: Utf16Pos },
144}
145
146impl fmt::Display for SourcePositionError {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match *self {
149            Self::ByteOffsetOutOfBounds { offset, len } => {
150                write!(
151                    formatter,
152                    "byte offset {offset} exceeds source length {len}"
153                )
154            }
155            Self::ByteOffsetInsideCodePoint { offset } => {
156                write!(formatter, "byte offset {offset} splits a UTF-8 code point")
157            }
158            Self::Utf16PositionOutOfBounds { position, len } => write!(
159                formatter,
160                "UTF-16 position {} exceeds source length {}",
161                position.get(),
162                len.get()
163            ),
164            Self::Utf16PositionInsideSurrogatePair { position } => write!(
165                formatter,
166                "UTF-16 position {} splits a surrogate pair",
167                position.get()
168            ),
169            Self::RangeStartAfterEnd { start, end } => write!(
170                formatter,
171                "range start {} follows range end {}",
172                start.get(),
173                end.get()
174            ),
175        }
176    }
177}
178
179impl std::error::Error for SourcePositionError {}
180
181#[derive(Clone, Copy, Debug)]
182struct BoundaryCheckpoint {
183    byte: usize,
184    utf16: Utf16Pos,
185}
186
187/// Immutable source text with checked UTF-8 byte and UTF-16 coordinate mapping.
188///
189/// The map records only boundaries immediately after non-ASCII code points. Between
190/// checkpoints, byte and UTF-16 offsets advance together through ASCII, so binary
191/// search plus one subtraction converts either direction without a per-code-point map.
192#[derive(Clone, Debug)]
193pub struct SourceText {
194    text: Arc<str>,
195    checkpoints: Arc<[BoundaryCheckpoint]>,
196    line_starts: Arc<[Utf16Pos]>,
197    utf16_len: Utf16Pos,
198}
199
200impl SourceText {
201    /// Stores source text and precomputes its immutable position indexes.
202    #[must_use]
203    pub fn new(text: impl Into<Arc<str>>) -> Self {
204        Self::from_arc(text.into())
205    }
206
207    /// Stores an existing shared source allocation without copying its text.
208    #[must_use]
209    pub fn from_arc(text: Arc<str>) -> Self {
210        let mut checkpoints = vec![BoundaryCheckpoint {
211            byte: 0,
212            utf16: Utf16Pos::ZERO,
213        }];
214        let mut line_starts = vec![Utf16Pos::ZERO];
215        let mut utf16_offset = 0;
216        let mut characters = text.char_indices().peekable();
217
218        while let Some((byte_start, character)) = characters.next() {
219            utf16_offset += character.len_utf16();
220
221            if !character.is_ascii() {
222                checkpoints.push(BoundaryCheckpoint {
223                    byte: byte_start + character.len_utf8(),
224                    utf16: Utf16Pos::new(utf16_offset),
225                });
226            }
227
228            let ends_line = character == '\n'
229                || (character == '\r' && !matches!(characters.peek(), Some(&(_, '\n'))))
230                || character == '\u{2028}'
231                || character == '\u{2029}';
232            if ends_line {
233                line_starts.push(Utf16Pos::new(utf16_offset));
234            }
235        }
236
237        Self {
238            text,
239            checkpoints: Arc::from(checkpoints),
240            line_starts: Arc::from(line_starts),
241            utf16_len: Utf16Pos::new(utf16_offset),
242        }
243    }
244
245    /// Returns the original UTF-8 source text.
246    #[must_use]
247    pub fn as_str(&self) -> &str {
248        self.text.as_ref()
249    }
250
251    /// Returns the source length in UTF-16 code units.
252    #[must_use]
253    pub const fn len_utf16(&self) -> Utf16Pos {
254        self.utf16_len
255    }
256
257    /// Returns whether the source has no code points.
258    #[must_use]
259    pub fn is_empty(&self) -> bool {
260        self.text.is_empty()
261    }
262
263    /// Converts a UTF-8 byte boundary to a UTF-16 boundary.
264    pub fn byte_to_utf16(&self, byte_offset: usize) -> Result<Utf16Pos, SourcePositionError> {
265        if byte_offset > self.text.len() {
266            return Err(SourcePositionError::ByteOffsetOutOfBounds {
267                offset: byte_offset,
268                len: self.text.len(),
269            });
270        }
271        if !self.text.is_char_boundary(byte_offset) {
272            return Err(SourcePositionError::ByteOffsetInsideCodePoint {
273                offset: byte_offset,
274            });
275        }
276
277        let checkpoint_index = self
278            .checkpoints
279            .partition_point(|checkpoint| checkpoint.byte <= byte_offset)
280            .saturating_sub(1);
281        let checkpoint = &self.checkpoints[checkpoint_index];
282        Ok(Utf16Pos::new(
283            checkpoint.utf16.get() + (byte_offset - checkpoint.byte),
284        ))
285    }
286
287    /// Converts a UTF-16 boundary to a UTF-8 byte boundary.
288    pub fn utf16_to_byte(&self, position: Utf16Pos) -> Result<usize, SourcePositionError> {
289        if position > self.utf16_len {
290            return Err(SourcePositionError::Utf16PositionOutOfBounds {
291                position,
292                len: self.utf16_len,
293            });
294        }
295
296        let checkpoint_index = self
297            .checkpoints
298            .partition_point(|checkpoint| checkpoint.utf16 <= position)
299            .saturating_sub(1);
300        let checkpoint = &self.checkpoints[checkpoint_index];
301        let byte_offset = checkpoint.byte + (position.get() - checkpoint.utf16.get());
302
303        if !self.text.is_char_boundary(byte_offset) {
304            return Err(SourcePositionError::Utf16PositionInsideSurrogatePair { position });
305        }
306
307        Ok(byte_offset)
308    }
309
310    /// Returns a source-relative, ordered range after validating both endpoints.
311    pub fn range(&self, start: Utf16Pos, end: Utf16Pos) -> Result<TextRange, SourcePositionError> {
312        self.utf16_to_byte(start)?;
313        self.utf16_to_byte(end)?;
314        TextRange::new(start, end)
315    }
316
317    /// Returns the zero-based line and UTF-16 column of a valid source boundary.
318    ///
319    /// `\r\n` contributes one line break, with the next line beginning after the
320    /// `\n`. A bare `\r`, a bare `\n`, `\u{2028}` (LINE SEPARATOR), and `\u{2029}`
321    /// (PARAGRAPH SEPARATOR) each also begin a new line.
322    pub fn line_column(&self, position: Utf16Pos) -> Result<(usize, usize), SourcePositionError> {
323        self.utf16_to_byte(position)?;
324
325        let line_index = self
326            .line_starts
327            .partition_point(|line_start| *line_start <= position)
328            .saturating_sub(1);
329        let line_start = self.line_starts[line_index];
330        Ok((line_index, position.get() - line_start.get()))
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::{SourcePositionError, SourceText, TextRange, Utf16Pos};
337
338    #[test]
339    fn ascii_boundaries_round_trip() {
340        let source = SourceText::new("hello");
341
342        assert_eq!(source.len_utf16(), Utf16Pos::new(5));
343        for offset in 0..=5 {
344            assert_eq!(source.byte_to_utf16(offset), Ok(Utf16Pos::new(offset)));
345            assert_eq!(source.utf16_to_byte(Utf16Pos::new(offset)), Ok(offset));
346        }
347        assert_eq!(source.line_column(Utf16Pos::new(5)), Ok((0, 5)));
348    }
349
350    #[test]
351    fn bmp_code_points_preserve_utf16_width_but_not_byte_width() {
352        let source = SourceText::new("aΓ©δΈ­");
353
354        assert_eq!(source.byte_to_utf16(0), Ok(Utf16Pos::new(0)));
355        assert_eq!(source.byte_to_utf16(1), Ok(Utf16Pos::new(1)));
356        assert_eq!(source.byte_to_utf16(3), Ok(Utf16Pos::new(2)));
357        assert_eq!(source.byte_to_utf16(6), Ok(Utf16Pos::new(3)));
358        assert_eq!(source.utf16_to_byte(Utf16Pos::new(2)), Ok(3));
359        assert_eq!(
360            source.byte_to_utf16(2),
361            Err(SourcePositionError::ByteOffsetInsideCodePoint { offset: 2 })
362        );
363    }
364
365    #[test]
366    fn astral_code_points_use_two_utf16_units() {
367        let source = SourceText::new("aπŸ˜€b");
368
369        assert_eq!(source.byte_to_utf16(1), Ok(Utf16Pos::new(1)));
370        assert_eq!(source.byte_to_utf16(5), Ok(Utf16Pos::new(3)));
371        assert_eq!(source.byte_to_utf16(6), Ok(Utf16Pos::new(4)));
372        assert_eq!(source.utf16_to_byte(Utf16Pos::new(1)), Ok(1));
373        assert_eq!(source.utf16_to_byte(Utf16Pos::new(3)), Ok(5));
374        assert_eq!(source.utf16_to_byte(Utf16Pos::new(4)), Ok(6));
375        assert_eq!(
376            source.utf16_to_byte(Utf16Pos::new(2)),
377            Err(SourcePositionError::Utf16PositionInsideSurrogatePair {
378                position: Utf16Pos::new(2),
379            })
380        );
381    }
382
383    #[test]
384    fn combining_marks_each_advance_the_utf16_column() {
385        let source = SourceText::new("e\u{301}x");
386
387        assert_eq!(source.byte_to_utf16(1), Ok(Utf16Pos::new(1)));
388        assert_eq!(source.byte_to_utf16(3), Ok(Utf16Pos::new(2)));
389        assert_eq!(source.byte_to_utf16(4), Ok(Utf16Pos::new(3)));
390        assert_eq!(source.line_column(Utf16Pos::new(2)), Ok((0, 2)));
391    }
392
393    #[test]
394    fn crlf_is_one_line_break_and_columns_are_utf16_units() {
395        let source = SourceText::new("a\r\nπŸ˜€\nb");
396
397        assert_eq!(source.len_utf16(), Utf16Pos::new(7));
398        assert_eq!(source.line_column(Utf16Pos::new(0)), Ok((0, 0)));
399        assert_eq!(source.line_column(Utf16Pos::new(2)), Ok((0, 2)));
400        assert_eq!(source.line_column(Utf16Pos::new(3)), Ok((1, 0)));
401        assert_eq!(source.line_column(Utf16Pos::new(5)), Ok((1, 2)));
402        assert_eq!(source.line_column(Utf16Pos::new(6)), Ok((2, 0)));
403        assert_eq!(source.line_column(Utf16Pos::new(7)), Ok((2, 1)));
404    }
405
406    #[test]
407    fn unicode_line_and_paragraph_separators_advance_line_and_column() {
408        // "a\u{2028}b\u{2029}c"
409        // Offsets in UTF-16 code units:
410        // 0: 'a' (len_utf16 = 1)
411        // 1: '\u{2028}' (len_utf16 = 1) -> line break after offset 1
412        // 2: 'b' (len_utf16 = 1)
413        // 3: '\u{2029}' (len_utf16 = 1) -> line break after offset 3
414        // 4: 'c' (len_utf16 = 1)
415        let source = SourceText::new("a\u{2028}b\u{2029}c");
416
417        assert_eq!(source.len_utf16(), Utf16Pos::new(5));
418
419        // Before U+2028
420        assert_eq!(source.line_column(Utf16Pos::new(0)), Ok((0, 0)));
421        assert_eq!(source.line_column(Utf16Pos::new(1)), Ok((0, 1)));
422
423        // After U+2028 / before 'b'
424        assert_eq!(source.line_column(Utf16Pos::new(2)), Ok((1, 0)));
425
426        // Before U+2029
427        assert_eq!(source.line_column(Utf16Pos::new(3)), Ok((1, 1)));
428
429        // After U+2029 / before 'c'
430        assert_eq!(source.line_column(Utf16Pos::new(4)), Ok((2, 0)));
431        assert_eq!(source.line_column(Utf16Pos::new(5)), Ok((2, 1)));
432    }
433
434    #[test]
435    fn empty_and_end_positions_are_valid_boundaries() {
436        let empty = SourceText::new("");
437        assert!(empty.is_empty());
438        assert_eq!(empty.byte_to_utf16(0), Ok(Utf16Pos::ZERO));
439        assert_eq!(empty.utf16_to_byte(Utf16Pos::ZERO), Ok(0));
440        assert_eq!(empty.line_column(Utf16Pos::ZERO), Ok((0, 0)));
441
442        let source = SourceText::new("πŸ˜€");
443        assert_eq!(source.byte_to_utf16(4), Ok(Utf16Pos::new(2)));
444        assert_eq!(source.utf16_to_byte(Utf16Pos::new(2)), Ok(4));
445    }
446
447    #[test]
448    fn invalid_byte_and_utf16_offsets_have_distinct_errors() {
449        let source = SourceText::new("πŸ˜€");
450
451        assert_eq!(
452            source.byte_to_utf16(1),
453            Err(SourcePositionError::ByteOffsetInsideCodePoint { offset: 1 })
454        );
455        assert_eq!(
456            source.byte_to_utf16(5),
457            Err(SourcePositionError::ByteOffsetOutOfBounds { offset: 5, len: 4 })
458        );
459        assert_eq!(
460            source.utf16_to_byte(Utf16Pos::new(1)),
461            Err(SourcePositionError::Utf16PositionInsideSurrogatePair {
462                position: Utf16Pos::new(1),
463            })
464        );
465        assert_eq!(
466            source.utf16_to_byte(Utf16Pos::new(3)),
467            Err(SourcePositionError::Utf16PositionOutOfBounds {
468                position: Utf16Pos::new(3),
469                len: Utf16Pos::new(2),
470            })
471        );
472    }
473
474    #[test]
475    fn ranges_cannot_be_reversed_and_source_ranges_validate_boundaries() {
476        assert_eq!(
477            TextRange::new(Utf16Pos::new(3), Utf16Pos::new(1)),
478            Err(SourcePositionError::RangeStartAfterEnd {
479                start: Utf16Pos::new(3),
480                end: Utf16Pos::new(1),
481            })
482        );
483
484        let source = SourceText::new("aπŸ˜€b");
485        let range = source
486            .range(Utf16Pos::new(1), Utf16Pos::new(3))
487            .expect("the emoji's outer boundaries are valid");
488        assert_eq!(range.start(), Utf16Pos::new(1));
489        assert_eq!(range.end(), Utf16Pos::new(3));
490        assert_eq!(range.len(), 2);
491        assert_eq!(
492            source.range(Utf16Pos::new(3), Utf16Pos::new(1)),
493            Err(SourcePositionError::RangeStartAfterEnd {
494                start: Utf16Pos::new(3),
495                end: Utf16Pos::new(1),
496            })
497        );
498        assert_eq!(
499            source.range(Utf16Pos::new(1), Utf16Pos::new(2)),
500            Err(SourcePositionError::Utf16PositionInsideSurrogatePair {
501                position: Utf16Pos::new(2),
502            })
503        );
504    }
505}