Skip to main content

gdck_syntax/
text.rs

1//! Byte-oriented text primitives shared by the lexer, parser and tree.
2
3use std::fmt;
4
5/// A half-open byte range into the source text.
6///
7/// Ranges are byte offsets, not char offsets. GDScript source is UTF-8 and the
8/// lexer only ever splits on ASCII boundaries, so every range produced here is
9/// guaranteed to land on a char boundary.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
11pub struct TextRange {
12    start: u32,
13    end: u32,
14}
15
16impl TextRange {
17    #[must_use]
18    pub const fn new(start: u32, end: u32) -> Self {
19        debug_assert!(start <= end);
20        Self { start, end }
21    }
22
23    /// A zero-width range at `offset`, used for synthetic tokens such as
24    /// `Indent`, `Dedent` and `Eof`.
25    #[must_use]
26    pub const fn empty(offset: u32) -> Self {
27        Self {
28            start: offset,
29            end: offset,
30        }
31    }
32
33    #[must_use]
34    pub const fn start(self) -> u32 {
35        self.start
36    }
37
38    #[must_use]
39    pub const fn end(self) -> u32 {
40        self.end
41    }
42
43    #[must_use]
44    pub const fn len(self) -> u32 {
45        self.end - self.start
46    }
47
48    #[must_use]
49    pub const fn is_empty(self) -> bool {
50        self.start == self.end
51    }
52
53    /// The smallest range covering both `self` and `other`.
54    #[must_use]
55    pub fn cover(self, other: Self) -> Self {
56        Self {
57            start: self.start.min(other.start),
58            end: self.end.max(other.end),
59        }
60    }
61
62    /// Slice `text` with this range.
63    #[must_use]
64    pub fn slice(self, text: &str) -> &str {
65        &text[self.start as usize..self.end as usize]
66    }
67}
68
69impl fmt::Display for TextRange {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{}..{}", self.start, self.end)
72    }
73}
74
75/// A 1-based line and column pair, for human-facing diagnostics.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
77pub struct LineCol {
78    pub line: u32,
79    /// 1-based column counted in UTF-8 bytes from the start of the line.
80    pub col: u32,
81}
82
83impl fmt::Display for LineCol {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(f, "{}:{}", self.line, self.col)
86    }
87}
88
89/// Maps byte offsets to line/column positions.
90#[derive(Debug, Clone)]
91pub struct LineIndex {
92    /// Byte offset of the first character of each line.
93    line_starts: Vec<u32>,
94}
95
96impl LineIndex {
97    #[must_use]
98    pub fn new(text: &str) -> Self {
99        let mut line_starts = vec![0];
100        for (offset, byte) in text.bytes().enumerate() {
101            if byte == b'\n' {
102                line_starts.push(offset as u32 + 1);
103            }
104        }
105        Self { line_starts }
106    }
107
108    /// Resolve a byte offset to a 1-based line and column.
109    #[must_use]
110    pub fn line_col(&self, offset: u32) -> LineCol {
111        // partition_point gives the number of line starts <= offset, which is
112        // exactly the 1-based line number.
113        let line = self.line_starts.partition_point(|&start| start <= offset);
114        let line_start = self.line_starts[line - 1];
115        LineCol {
116            line: line as u32,
117            col: offset - line_start + 1,
118        }
119    }
120
121    /// Number of lines in the indexed text.
122    #[must_use]
123    pub fn line_count(&self) -> usize {
124        self.line_starts.len()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn range_basics() {
134        let range = TextRange::new(2, 5);
135        assert_eq!(range.len(), 3);
136        assert!(!range.is_empty());
137        assert_eq!(range.slice("abcdefg"), "cde");
138        assert!(TextRange::empty(4).is_empty());
139        assert_eq!(TextRange::new(1, 3).cover(TextRange::new(7, 9)).len(), 8);
140    }
141
142    #[test]
143    fn line_col_resolves_positions() {
144        let index = LineIndex::new("ab\ncd\n\nef");
145        assert_eq!(index.line_col(0), LineCol { line: 1, col: 1 });
146        assert_eq!(index.line_col(1), LineCol { line: 1, col: 2 });
147        // The newline itself still belongs to the line it terminates.
148        assert_eq!(index.line_col(2), LineCol { line: 1, col: 3 });
149        assert_eq!(index.line_col(3), LineCol { line: 2, col: 1 });
150        assert_eq!(index.line_col(6), LineCol { line: 3, col: 1 });
151        assert_eq!(index.line_col(7), LineCol { line: 4, col: 1 });
152        assert_eq!(index.line_count(), 4);
153    }
154
155    #[test]
156    fn line_col_counts_utf8_bytes() {
157        // "é" is two bytes, so the following char sits at byte column 3.
158        let index = LineIndex::new("é!");
159        assert_eq!(index.line_col(2), LineCol { line: 1, col: 3 });
160    }
161}