Skip to main content

deed_diagnostics/
source.rs

1//! Source files and the mapping from byte offsets back to line and column.
2//!
3//! Everything downstream of the lexer works in byte offsets. Line and column
4//! are computed only when a diagnostic is rendered, so the common path never
5//! pays for them.
6
7use crate::span::Span;
8
9/// Handle to a file inside a [`SourceMap`].
10#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
11pub struct FileId(u32);
12
13impl FileId {
14    /// The file's position in the [`SourceMap`] that handed it out.
15    pub fn index(&self) -> u32 {
16        self.0
17    }
18}
19
20/// A one based line and column pair, suitable for showing to a person.
21///
22/// The column counts characters rather than bytes, so an underline lines up
23/// under non-ASCII text.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub struct Location {
26    pub line: u32,
27    pub column: u32,
28}
29
30/// A single source file, with a precomputed index of where each line starts.
31pub struct SourceFile {
32    name: String,
33    text: String,
34    line_starts: Vec<u32>,
35}
36
37impl SourceFile {
38    fn new(name: String, text: String) -> Self {
39        let mut line_starts = vec![0u32];
40        for (offset, byte) in text.bytes().enumerate() {
41            if byte == b'\n' {
42                line_starts.push(offset as u32 + 1);
43            }
44        }
45        Self {
46            name,
47            text,
48            line_starts,
49        }
50    }
51
52    pub fn name(&self) -> &str {
53        &self.name
54    }
55
56    pub fn text(&self) -> &str {
57        &self.text
58    }
59
60    pub fn line_count(&self) -> u32 {
61        self.line_starts.len() as u32
62    }
63
64    /// Resolves a byte offset to a one based line and column.
65    ///
66    /// Offsets past the end of the file clamp to the last position, so a
67    /// diagnostic about unexpected end of input still renders somewhere sane.
68    pub fn location(&self, offset: u32) -> Location {
69        let offset = offset.min(self.text.len() as u32);
70        let line_index = match self.line_starts.binary_search(&offset) {
71            Ok(exact) => exact,
72            Err(next) => next - 1,
73        };
74        let line_start = self.line_starts[line_index] as usize;
75        let column = self.text[line_start..offset as usize].chars().count() as u32 + 1;
76        Location {
77            line: line_index as u32 + 1,
78            column,
79        }
80    }
81
82    /// The text of a one based line, without its trailing newline.
83    pub fn line_text(&self, line: u32) -> &str {
84        if line == 0 || line > self.line_count() {
85            return "";
86        }
87        let start = self.line_starts[line as usize - 1] as usize;
88        let end = self
89            .line_starts
90            .get(line as usize)
91            .map(|&next| next as usize)
92            .unwrap_or(self.text.len());
93        self.text[start..end].trim_end_matches(['\n', '\r'])
94    }
95
96    pub fn slice(&self, span: Span) -> &str {
97        let end = (span.end as usize).min(self.text.len());
98        let start = (span.start as usize).min(end);
99        &self.text[start..end]
100    }
101}
102
103/// Owns every file the compiler has seen.
104#[derive(Default)]
105pub struct SourceMap {
106    files: Vec<SourceFile>,
107}
108
109impl SourceMap {
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    pub fn add(&mut self, name: impl Into<String>, text: impl Into<String>) -> FileId {
115        let id = FileId(self.files.len() as u32);
116        self.files.push(SourceFile::new(name.into(), text.into()));
117        id
118    }
119
120    /// # Panics
121    ///
122    /// Panics if the id came from a different `SourceMap`, which is a bug
123    /// rather than a recoverable condition.
124    pub fn file(&self, id: FileId) -> &SourceFile {
125        &self.files[id.0 as usize]
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::SourceMap;
132
133    fn map(text: &str) -> (SourceMap, super::FileId) {
134        let mut map = SourceMap::new();
135        let id = map.add("test.deed", text);
136        (map, id)
137    }
138
139    #[test]
140    fn locations_are_one_based() {
141        let (map, id) = map("abc\ndef\n");
142        let file = map.file(id);
143        assert_eq!(file.location(0), super::Location { line: 1, column: 1 });
144        assert_eq!(file.location(2), super::Location { line: 1, column: 3 });
145        assert_eq!(file.location(4), super::Location { line: 2, column: 1 });
146    }
147
148    #[test]
149    fn newline_belongs_to_the_line_it_ends() {
150        let (map, id) = map("ab\ncd");
151        let file = map.file(id);
152        assert_eq!(file.location(2), super::Location { line: 1, column: 3 });
153        assert_eq!(file.location(3), super::Location { line: 2, column: 1 });
154    }
155
156    #[test]
157    fn columns_count_characters_not_bytes() {
158        let (map, id) = map("çğü x");
159        let file = map.file(id);
160        let offset = "çğü ".len() as u32;
161        assert_eq!(
162            file.location(offset),
163            super::Location { line: 1, column: 5 }
164        );
165    }
166
167    #[test]
168    fn offsets_past_the_end_clamp() {
169        let (map, id) = map("ab");
170        let file = map.file(id);
171        assert_eq!(file.location(999), super::Location { line: 1, column: 3 });
172    }
173
174    #[test]
175    fn line_text_drops_the_newline() {
176        let (map, id) = map("first\r\nsecond\n");
177        let file = map.file(id);
178        assert_eq!(file.line_text(1), "first");
179        assert_eq!(file.line_text(2), "second");
180        assert_eq!(file.line_text(99), "");
181    }
182
183    #[test]
184    fn the_last_line_is_still_a_line() {
185        // `line > line_count` rejects past-the-end. `>=` would also reject the
186        // final line itself. A file with no trailing newline still has that
187        // last line, and renderers ask for it by number.
188        let (map, id) = map("only");
189        let file = map.file(id);
190        assert_eq!(file.line_count(), 1);
191        assert_eq!(file.line_text(1), "only");
192        assert_eq!(file.line_text(2), "");
193    }
194
195    #[test]
196    fn file_ids_are_the_order_files_were_added() {
197        let mut map = SourceMap::new();
198        let first = map.add("a.deed", "");
199        let second = map.add("b.deed", "");
200        assert_eq!(first.index(), 0);
201        assert_eq!(second.index(), 1);
202        // The id is what `file` looks up by, so swapping either number hands
203        // back the other source.
204        assert_eq!(map.file(first).name(), "a.deed");
205        assert_eq!(map.file(second).name(), "b.deed");
206    }
207}