Skip to main content

php_rs_parser/
source_map.rs

1/// Maps byte offsets (as used in [`Span`]) to line/column positions.
2///
3/// Build once per source file, then query as many offsets as needed in O(log n) each.
4///
5/// Lines and columns are **0-based** — the LSP convention. Call
6/// [`LineCol::to_one_based`] if you need 1-based positions.
7///
8/// # Example
9///
10/// ```
11/// use php_rs_parser::source_map::{SourceMap, LineCol};
12///
13/// let src = "<?php\necho 'hi';\n";
14/// let map = SourceMap::new(src);
15///
16/// assert_eq!(map.offset_to_line_col(0), LineCol { line: 0, col: 0 });
17/// assert_eq!(map.offset_to_line_col(6), LineCol { line: 1, col: 0 });
18/// ```
19use php_ast::Span;
20
21/// A 0-based line/column position.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct LineCol {
24    /// 0-based line number.
25    pub line: u32,
26    /// 0-based UTF-8 byte column offset within the line.
27    pub col: u32,
28}
29
30impl LineCol {
31    /// Convert to 1-based line and column (e.g. for human-readable diagnostics).
32    pub fn to_one_based(self) -> (u32, u32) {
33        (self.line + 1, self.col + 1)
34    }
35}
36
37/// A line/column range corresponding to a [`Span`].
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct LineColSpan {
40    /// Range start.
41    pub start: LineCol,
42    /// Range end.
43    pub end: LineCol,
44}
45
46/// Pre-computed index of line-start byte offsets for a source string.
47///
48/// Construction is O(n) in the source length. Each lookup is O(log n) in the
49/// number of lines (binary search), which is effectively O(1) for typical files.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SourceMap {
52    /// Byte offset of the start of each line. `line_starts[0]` is always 0.
53    line_starts: Vec<u32>,
54    /// Source length in bytes; `u32::MAX` for [`SourceMap::empty`].
55    len: u32,
56}
57
58impl SourceMap {
59    /// Build an index from the given source text.
60    pub fn new(source: &str) -> Self {
61        let mut line_starts = Vec::with_capacity(source.len() / 40 + 1);
62        line_starts.push(0u32);
63        for pos in memchr::memchr_iter(b'\n', source.as_bytes()) {
64            line_starts.push((pos + 1) as u32);
65        }
66        Self {
67            line_starts,
68            len: source.len() as u32,
69        }
70    }
71
72    /// A no-op map for callers that never query line/column positions.
73    /// Used when [`ParseOptions::source_map`](crate::ParseOptions::source_map) is `false`.
74    /// `offset_to_line_col` on an empty map returns `LineCol { line: 0, col: offset }`.
75    pub fn empty() -> Self {
76        Self {
77            line_starts: vec![0u32],
78            len: u32::MAX,
79        }
80    }
81
82    /// Total number of lines in the source.
83    pub fn line_count(&self) -> usize {
84        self.line_starts.len()
85    }
86
87    /// Byte offset where the given 0-based line starts.
88    /// Returns `None` if the line is out of range.
89    pub fn line_start(&self, line: u32) -> Option<u32> {
90        self.line_starts.get(line as usize).copied()
91    }
92
93    /// Convert a byte offset to a 0-based line/column.
94    ///
95    /// If `offset` is past the end of the source, the position is clamped to
96    /// the last line.
97    pub fn offset_to_line_col(&self, offset: u32) -> LineCol {
98        let line = match self.line_starts.binary_search(&offset) {
99            Ok(exact) => exact,
100            Err(after) => after - 1,
101        };
102        let col = offset - self.line_starts[line];
103        LineCol {
104            line: line as u32,
105            col,
106        }
107    }
108
109    /// Convert a [`Span`] to a start/end [`LineColSpan`].
110    pub fn span_to_line_col(&self, span: Span) -> LineColSpan {
111        LineColSpan {
112            start: self.offset_to_line_col(span.start),
113            end: self.offset_to_line_col(span.end),
114        }
115    }
116
117    /// Convert a 0-based line/column to a byte offset; `None` past the line's `\n` or the end of the source.
118    pub fn line_col_to_offset(&self, lc: LineCol) -> Option<u32> {
119        let line = lc.line as usize;
120        let start = *self.line_starts.get(line)?;
121        let end = match self.line_starts.get(line + 1) {
122            Some(next_start) => next_start - 1,
123            None => self.len,
124        };
125        start.checked_add(lc.col).filter(|&offset| offset <= end)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn empty_source() {
135        let map = SourceMap::new("");
136        assert_eq!(map.line_count(), 1);
137        assert_eq!(map.offset_to_line_col(0), LineCol { line: 0, col: 0 });
138    }
139
140    #[test]
141    fn single_line_no_newline() {
142        let map = SourceMap::new("<?php echo 1;");
143        assert_eq!(map.line_count(), 1);
144        assert_eq!(map.offset_to_line_col(0), LineCol { line: 0, col: 0 });
145        assert_eq!(map.offset_to_line_col(6), LineCol { line: 0, col: 6 });
146    }
147
148    #[test]
149    fn multiple_lines() {
150        let src = "<?php\necho 'hi';\nreturn;\n";
151        let map = SourceMap::new(src);
152        assert_eq!(map.line_count(), 4); // 3 lines + trailing empty line after last \n
153
154        // First char of line 0
155        assert_eq!(map.offset_to_line_col(0), LineCol { line: 0, col: 0 });
156        // First char of line 1
157        assert_eq!(map.offset_to_line_col(6), LineCol { line: 1, col: 0 });
158        // 'e' of echo on line 1
159        assert_eq!(map.offset_to_line_col(6), LineCol { line: 1, col: 0 });
160        // First char of line 2
161        assert_eq!(map.offset_to_line_col(17), LineCol { line: 2, col: 0 });
162    }
163
164    #[test]
165    fn span_conversion() {
166        let src = "<?php\necho 'hi';\n";
167        let map = SourceMap::new(src);
168        let span = Span::new(6, 10); // "echo"
169        let lc = map.span_to_line_col(span);
170        assert_eq!(lc.start, LineCol { line: 1, col: 0 });
171        assert_eq!(lc.end, LineCol { line: 1, col: 4 });
172    }
173
174    #[test]
175    fn round_trip() {
176        let src = "<?php\necho 'hi';\nreturn;\n";
177        let map = SourceMap::new(src);
178        let lc = LineCol { line: 1, col: 5 };
179        let offset = map.line_col_to_offset(lc).unwrap();
180        assert_eq!(map.offset_to_line_col(offset), lc);
181    }
182
183    #[test]
184    fn column_past_line_end_is_rejected() {
185        let map = SourceMap::new("ab\ncd");
186        assert_eq!(map.line_col_to_offset(LineCol { line: 0, col: 2 }), Some(2));
187        assert_eq!(map.line_col_to_offset(LineCol { line: 0, col: 3 }), None);
188        assert_eq!(map.line_col_to_offset(LineCol { line: 1, col: 2 }), Some(5));
189        assert_eq!(map.line_col_to_offset(LineCol { line: 1, col: 3 }), None);
190        assert_eq!(
191            map.line_col_to_offset(LineCol {
192                line: 1,
193                col: u32::MAX
194            }),
195            None
196        );
197    }
198
199    #[test]
200    fn empty_map_accepts_any_column() {
201        let map = SourceMap::empty();
202        assert_eq!(
203            map.line_col_to_offset(LineCol { line: 0, col: 42 }),
204            Some(42)
205        );
206        assert_eq!(map.line_col_to_offset(LineCol { line: 1, col: 0 }), None);
207    }
208
209    #[test]
210    fn one_based() {
211        let lc = LineCol { line: 0, col: 0 };
212        assert_eq!(lc.to_one_based(), (1, 1));
213        let lc = LineCol { line: 2, col: 5 };
214        assert_eq!(lc.to_one_based(), (3, 6));
215    }
216
217    #[test]
218    fn line_start_lookup() {
219        let src = "aaa\nbbb\nccc";
220        let map = SourceMap::new(src);
221        assert_eq!(map.line_start(0), Some(0));
222        assert_eq!(map.line_start(1), Some(4));
223        assert_eq!(map.line_start(2), Some(8));
224        assert_eq!(map.line_start(3), None);
225    }
226
227    #[test]
228    fn crlf_treated_as_two_bytes() {
229        // \r\n: \r is col 0 on line 0, \n triggers new line at offset 2
230        let src = "a\r\nb";
231        let map = SourceMap::new(src);
232        assert_eq!(map.line_count(), 2);
233        // 'b' is at offset 3, line 1 starts at offset 3
234        assert_eq!(map.offset_to_line_col(3), LineCol { line: 1, col: 0 });
235    }
236}