Skip to main content

skif_core/
span.rs

1//! Source positions: byte spans, locations and offset-to-line/column conversion.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7/// Half-open UTF-8 byte range `[start, end)` with 1-based line and column numbers.
8///
9/// Columns count Unicode scalar values.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "kebab-case")]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[non_exhaustive]
14pub struct Span {
15    /// Byte offset of the first byte.
16    pub start: u32,
17    /// Byte offset one past the last byte.
18    pub end: u32,
19    /// 1-based line of `start`.
20    pub start_line: u32,
21    /// 1-based column of `start`.
22    pub start_col: u32,
23    /// 1-based line of `end`.
24    pub end_line: u32,
25    /// 1-based column of `end`.
26    pub end_col: u32,
27}
28
29/// A file, relative to the workspace root, and an optional position within it.
30#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[non_exhaustive]
34pub struct Location {
35    /// Workspace-relative path.
36    #[serde(serialize_with = "crate::display::serialize_path")]
37    pub path: PathBuf,
38    /// Position within the file, when known.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub span: Option<Span>,
41}
42
43impl Location {
44    /// The file at `path`, optionally at `span`.
45    #[must_use]
46    pub fn new(path: impl Into<PathBuf>, span: Option<Span>) -> Self {
47        Self {
48            path: path.into(),
49            span,
50        }
51    }
52
53    /// The file at `path` as a whole.
54    #[must_use]
55    pub fn file(path: impl Into<PathBuf>) -> Self {
56        Self::new(path, None)
57    }
58}
59
60/// A secondary location that explains a diagnostic.
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[non_exhaustive]
65pub struct Related {
66    /// Why this location is relevant.
67    pub message: String,
68    /// Where it is.
69    pub location: Location,
70}
71
72impl Related {
73    /// A secondary location at `location` that is relevant because of `message`.
74    #[must_use]
75    pub fn new(message: impl Into<String>, location: Location) -> Self {
76        Self {
77            message: message.into(),
78            location,
79        }
80    }
81}
82
83impl Span {
84    /// A span from its byte range and the 1-based lines and columns of both ends.
85    #[must_use]
86    pub const fn new(start: u32, end: u32, start_line: u32, start_col: u32, end_line: u32, end_col: u32) -> Self {
87        Self {
88            start,
89            end,
90            start_line,
91            start_col,
92            end_line,
93            end_col,
94        }
95    }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99struct WideChar {
100    offset: u32,
101    utf8_len: u32,
102    utf16_len: u32,
103}
104
105impl WideChar {
106    const fn end(self) -> u32 {
107        self.offset + self.utf8_len
108    }
109}
110
111/// Converts byte offsets of one source text into line and column numbers.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct LineIndex {
114    line_starts: Vec<u32>,
115    wide_chars: Vec<WideChar>,
116}
117
118fn to_u32(n: usize) -> u32 {
119    u32::try_from(n).unwrap_or(u32::MAX)
120}
121
122impl LineIndex {
123    /// Indexes `source`; lines end at `\n` (so `\r\n` is handled, with the `\r` belonging to the line it ends).
124    #[must_use]
125    pub fn new(source: &str) -> Self {
126        let line_starts = std::iter::once(0)
127            .chain(memchr::memchr_iter(b'\n', source.as_bytes()).map(|i| to_u32(i + 1)))
128            .collect();
129        // Most files are pure ASCII, which `is_ascii` answers with a vectorised scan; only the others pay for a
130        // character walk.
131        let wide_chars = if source.is_ascii() {
132            Vec::new()
133        } else {
134            source
135                .char_indices()
136                .filter(|&(_, c)| c.len_utf8() > 1)
137                .map(|(i, c)| WideChar {
138                    offset: to_u32(i),
139                    utf8_len: to_u32(c.len_utf8()),
140                    utf16_len: to_u32(c.len_utf16()),
141                })
142                .collect()
143        };
144        Self {
145            line_starts,
146            wide_chars,
147        }
148    }
149
150    /// 1-based `(line, column)` of a byte offset; the column counts Unicode scalar values.
151    #[must_use]
152    pub fn line_col(&self, offset: u32) -> (u32, u32) {
153        let (line, start) = self.line_of(offset);
154        let skipped = self.skipped_bytes(start, offset, |w| w.utf8_len - 1);
155        (line, offset - start - skipped + 1)
156    }
157
158    /// Builds a [`Span`] from two byte offsets.
159    #[must_use]
160    pub fn span(&self, start: u32, end: u32) -> Span {
161        let (start_line, start_col) = self.line_col(start);
162        let (end_line, end_col) = self.line_col(end);
163        Span {
164            start,
165            end,
166            start_line,
167            start_col,
168            end_line,
169            end_col,
170        }
171    }
172
173    /// 1-based column of a byte offset counted in UTF-16 code units, as SARIF requires.
174    #[must_use]
175    pub fn utf16_col(&self, offset: u32) -> u32 {
176        let (_, start) = self.line_of(offset);
177        let skipped = self.skipped_bytes(start, offset, |w| w.utf8_len - w.utf16_len);
178        offset - start - skipped + 1
179    }
180
181    fn line_of(&self, offset: u32) -> (u32, u32) {
182        let line = self.line_starts.partition_point(|&start| start <= offset);
183        let start = line
184            .checked_sub(1)
185            .and_then(|i| self.line_starts.get(i))
186            .copied()
187            .unwrap_or(0);
188        (to_u32(line.max(1)), start)
189    }
190
191    fn skipped_bytes(&self, line_start: u32, offset: u32, weight: impl Fn(WideChar) -> u32) -> u32 {
192        let lo = self.wide_chars.partition_point(|w| w.offset < line_start);
193        let hi = self.wide_chars.partition_point(|w| w.end() <= offset).max(lo);
194        self.wide_chars
195            .get(lo..hi)
196            .unwrap_or_default()
197            .iter()
198            .copied()
199            .map(weight)
200            .sum()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn constructors_fill_every_field() {
210        let span = Span::new(1, 4, 1, 2, 1, 5);
211        assert_eq!(
212            span,
213            Span {
214                start: 1,
215                end: 4,
216                start_line: 1,
217                start_col: 2,
218                end_line: 1,
219                end_col: 5
220            }
221        );
222        let related = Related::new("why", Location::new("a.json", Some(span)));
223        assert_eq!(related.message, "why");
224        assert_eq!(related.location.span, Some(span));
225    }
226
227    #[test]
228    fn non_ascii_and_ascii_text_index_alike_up_to_the_wide_characters() {
229        let ascii = LineIndex::new("ab\ncd");
230        assert_eq!(ascii.utf16_col(4), 2);
231        let wide = LineIndex::new("é\nab");
232        assert_eq!(wide.line_col(3), (2, 1));
233        assert_eq!(wide.utf16_col(2), 2);
234    }
235
236    #[test]
237    fn ascii_single_line() {
238        let index = LineIndex::new("hello");
239        assert_eq!(index.line_col(0), (1, 1));
240        assert_eq!(index.line_col(4), (1, 5));
241        assert_eq!(index.line_col(5), (1, 6));
242    }
243
244    #[test]
245    fn multi_line_and_line_ends() {
246        let index = LineIndex::new("ab\ncd\n\nef");
247        let cases = [
248            (0, (1, 1)),
249            (2, (1, 3)),
250            (3, (2, 1)),
251            (5, (2, 3)),
252            (6, (3, 1)),
253            (7, (4, 1)),
254            (9, (4, 3)),
255        ];
256        for (offset, expected) in cases {
257            assert_eq!(index.line_col(offset), expected, "offset {offset}");
258        }
259    }
260
261    #[test]
262    fn crlf_keeps_carriage_return_on_previous_line() {
263        let index = LineIndex::new("ab\r\ncd");
264        assert_eq!(index.line_col(2), (1, 3));
265        assert_eq!(index.line_col(3), (1, 4));
266        assert_eq!(index.line_col(4), (2, 1));
267        assert_eq!(index.line_col(6), (2, 3));
268    }
269
270    #[test]
271    fn multibyte_columns_count_scalar_values() {
272        let index = LineIndex::new("h\u{e9}llo\nw\u{e9}\u{e9}");
273        assert_eq!(index.line_col(1), (1, 2));
274        assert_eq!(index.line_col(3), (1, 3));
275        assert_eq!(index.line_col(6), (1, 6));
276        assert_eq!(index.line_col(7), (2, 1));
277        assert_eq!(index.line_col(10), (2, 3));
278    }
279
280    #[test]
281    fn astral_chars_are_one_column_but_two_utf16_units() {
282        let index = LineIndex::new("a\u{1F600}b\n\u{1F600}c");
283        assert_eq!(index.line_col(5), (1, 3));
284        assert_eq!(index.utf16_col(5), 4);
285        assert_eq!(index.line_col(6), (1, 4));
286        assert_eq!(index.utf16_col(6), 5);
287        assert_eq!(index.line_col(11), (2, 2));
288        assert_eq!(index.utf16_col(11), 3);
289    }
290
291    #[test]
292    fn utf16_col_matches_scalar_col_for_bmp_text() {
293        let index = LineIndex::new("h\u{e9}llo");
294        for offset in [0, 1, 3, 4, 6] {
295            assert_eq!(index.utf16_col(offset), index.line_col(offset).1, "offset {offset}");
296        }
297    }
298
299    #[test]
300    fn span_combines_both_ends() {
301        let index = LineIndex::new("ab\ncd");
302        assert_eq!(
303            index.span(1, 4),
304            Span {
305                start: 1,
306                end: 4,
307                start_line: 1,
308                start_col: 2,
309                end_line: 2,
310                end_col: 2
311            }
312        );
313    }
314
315    #[test]
316    fn empty_source_and_location_serde() {
317        let index = LineIndex::new("");
318        assert_eq!(index.line_col(0), (1, 1));
319        let location = Location {
320            path: "a/package.json".into(),
321            span: None,
322        };
323        assert_eq!(
324            serde_json::to_value(&location).unwrap_or_default(),
325            serde_json::json!({ "path": "a/package.json" })
326        );
327    }
328
329    #[test]
330    fn location_constructors_set_path_and_span() {
331        let whole = Location::file("package.json");
332        assert_eq!(
333            whole,
334            Location {
335                path: "package.json".into(),
336                span: None
337            }
338        );
339        assert_eq!(Location::new("a", whole.span).path, PathBuf::from("a"));
340    }
341}