Skip to main content

bock_source/
lib.rs

1//! Bock source — source file loading, span management, and file registry.
2//!
3//! [`Span`] and [`FileId`] are defined in `bock-errors` and re-exported here
4//! so downstream crates can import them from a single convenient location.
5
6use std::path::PathBuf;
7
8pub use bock_errors::{FileId, Span};
9
10// ─── SourceFile ───────────────────────────────────────────────────────────────
11
12/// A loaded source file, keyed by its [`FileId`].
13pub struct SourceFile {
14    pub id: FileId,
15    pub path: PathBuf,
16    pub content: String,
17    /// Byte offsets of the start of each line (line 0 starts at offset 0).
18    line_starts: Vec<usize>,
19}
20
21impl SourceFile {
22    /// Create a new [`SourceFile`], pre-computing line-start offsets.
23    #[must_use]
24    pub fn new(id: FileId, path: PathBuf, content: String) -> Self {
25        let line_starts = std::iter::once(0)
26            .chain(content.match_indices('\n').map(|(i, _)| i + 1))
27            .collect();
28        Self {
29            id,
30            path,
31            content,
32            line_starts,
33        }
34    }
35
36    /// Returns `(line, column)`, both **1-indexed**.
37    ///
38    /// `offset` is a byte offset into the file. Column counts Unicode scalar
39    /// values (characters), not bytes, from the start of the line.
40    ///
41    /// # Panics
42    /// Panics if `offset` is beyond the end of the file.
43    #[must_use]
44    pub fn line_col(&self, offset: usize) -> (usize, usize) {
45        assert!(offset <= self.content.len(), "offset out of range");
46
47        // Binary-search for the last line_start <= offset.
48        let line_idx = match self.line_starts.binary_search(&offset) {
49            Ok(i) => i,
50            Err(i) => i - 1,
51        };
52
53        let line_start = self.line_starts[line_idx];
54        let col = self.content[line_start..offset].chars().count() + 1;
55        (line_idx + 1, col)
56    }
57
58    /// Returns the textual content of the given 1-indexed line (without the
59    /// trailing newline).
60    ///
61    /// # Panics
62    /// Panics if `line` is 0 or beyond the last line.
63    #[must_use]
64    pub fn line_content(&self, line: usize) -> &str {
65        assert!(line >= 1, "line must be 1-indexed");
66        let idx = line - 1;
67        let start = self.line_starts[idx];
68        let end = self
69            .line_starts
70            .get(idx + 1)
71            .map(|&s| {
72                // Trim the '\n' that begins the next line's start marker.
73                if s > 0 && self.content.as_bytes()[s - 1] == b'\n' {
74                    s - 1
75                } else {
76                    s
77                }
78            })
79            .unwrap_or(self.content.len());
80        &self.content[start..end]
81    }
82
83    /// Returns the source text covered by `span`.
84    ///
85    /// # Panics
86    /// Panics if `span` indices are out of bounds.
87    #[must_use]
88    pub fn slice(&self, span: Span) -> &str {
89        &self.content[span.start..span.end]
90    }
91}
92
93// ─── SourceMap ────────────────────────────────────────────────────────────────
94
95/// Manages all source files loaded during a compilation session.
96#[derive(Default)]
97pub struct SourceMap {
98    files: Vec<SourceFile>,
99}
100
101impl SourceMap {
102    /// Create an empty [`SourceMap`].
103    #[must_use]
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Add a file and return its assigned [`FileId`].
109    pub fn add_file(&mut self, path: PathBuf, content: String) -> FileId {
110        let id = FileId(self.files.len() as u32);
111        self.files.push(SourceFile::new(id, path, content));
112        id
113    }
114
115    /// Retrieve a file by its [`FileId`].
116    ///
117    /// # Panics
118    /// Panics if `id` does not correspond to a registered file.
119    #[must_use]
120    pub fn get_file(&self, id: FileId) -> &SourceFile {
121        &self.files[id.0 as usize]
122    }
123}
124
125// ─── Tests ────────────────────────────────────────────────────────────────────
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn make_file(content: &str) -> SourceFile {
132        SourceFile::new(FileId(1), PathBuf::from("test.bock"), content.to_string())
133    }
134
135    // ── line_col ──────────────────────────────────────────────────────────────
136
137    #[test]
138    fn line_col_start_of_file() {
139        let f = make_file("hello\nworld");
140        assert_eq!(f.line_col(0), (1, 1));
141    }
142
143    #[test]
144    fn line_col_end_of_first_line() {
145        let f = make_file("hello\nworld");
146        // offset 4 = 'o' in "hello"
147        assert_eq!(f.line_col(4), (1, 5));
148    }
149
150    #[test]
151    fn line_col_start_of_second_line() {
152        let f = make_file("hello\nworld");
153        // offset 6 = 'w' in "world"
154        assert_eq!(f.line_col(6), (2, 1));
155    }
156
157    #[test]
158    fn line_col_end_of_file() {
159        let f = make_file("ab\ncd");
160        assert_eq!(f.line_col(5), (2, 3));
161    }
162
163    #[test]
164    fn line_col_single_line() {
165        let f = make_file("abcde");
166        assert_eq!(f.line_col(3), (1, 4));
167    }
168
169    // ── UTF-8 / multi-byte ────────────────────────────────────────────────────
170
171    #[test]
172    fn line_col_multibyte_char() {
173        // "é" is 2 bytes (U+00E9), "x" is after it.
174        let f = make_file("aéx");
175        let e_offset = "a".len(); // 1
176        let x_offset = "aé".len(); // 3
177        assert_eq!(f.line_col(e_offset), (1, 2)); // col counts chars
178        assert_eq!(f.line_col(x_offset), (1, 3));
179    }
180
181    #[test]
182    fn line_col_emoji() {
183        // "🦀" is 4 bytes; column should be 2, not 5.
184        let f = make_file("a🦀b");
185        let crab_offset = 1_usize;
186        let b_offset = 1 + "🦀".len(); // 5
187        assert_eq!(f.line_col(crab_offset), (1, 2));
188        assert_eq!(f.line_col(b_offset), (1, 3));
189    }
190
191    #[test]
192    fn line_col_multibyte_on_second_line() {
193        let f = make_file("hello\nwörld");
194        // 'ö' is 2 bytes, starts at offset 7
195        let o_offset = "hello\nw".len(); // 7
196        assert_eq!(f.line_col(o_offset), (2, 2));
197    }
198
199    // ── line_content ──────────────────────────────────────────────────────────
200
201    #[test]
202    fn line_content_first_line() {
203        let f = make_file("hello\nworld");
204        assert_eq!(f.line_content(1), "hello");
205    }
206
207    #[test]
208    fn line_content_second_line() {
209        let f = make_file("hello\nworld");
210        assert_eq!(f.line_content(2), "world");
211    }
212
213    #[test]
214    fn line_content_single_line_no_newline() {
215        let f = make_file("only");
216        assert_eq!(f.line_content(1), "only");
217    }
218
219    #[test]
220    fn line_content_empty_line() {
221        let f = make_file("a\n\nb");
222        assert_eq!(f.line_content(2), "");
223    }
224
225    // ── slice ─────────────────────────────────────────────────────────────────
226
227    #[test]
228    fn slice_basic() {
229        let f = make_file("hello world");
230        let span = Span {
231            file: FileId(1),
232            start: 6,
233            end: 11,
234        };
235        assert_eq!(f.slice(span), "world");
236    }
237
238    #[test]
239    fn slice_whole_file() {
240        let f = make_file("abc");
241        let span = Span {
242            file: FileId(1),
243            start: 0,
244            end: 3,
245        };
246        assert_eq!(f.slice(span), "abc");
247    }
248
249    #[test]
250    fn slice_multibyte() {
251        let f = make_file("a🦀b");
252        let span = Span {
253            file: FileId(1),
254            start: 1,
255            end: 5,
256        };
257        assert_eq!(f.slice(span), "🦀");
258    }
259
260    // ── SourceMap ─────────────────────────────────────────────────────────────
261
262    #[test]
263    fn source_map_add_and_get() {
264        let mut map = SourceMap::new();
265        let id = map.add_file(PathBuf::from("a.bock"), "fn main() {}".to_string());
266        assert_eq!(id, FileId(0));
267        let file = map.get_file(id);
268        assert_eq!(file.content, "fn main() {}");
269    }
270
271    #[test]
272    fn source_map_multiple_files() {
273        let mut map = SourceMap::new();
274        let id0 = map.add_file(PathBuf::from("a.bock"), "aaa".to_string());
275        let id1 = map.add_file(PathBuf::from("b.bock"), "bbb".to_string());
276        assert_eq!(id0, FileId(0));
277        assert_eq!(id1, FileId(1));
278        assert_eq!(map.get_file(id0).content, "aaa");
279        assert_eq!(map.get_file(id1).content, "bbb");
280    }
281
282    #[test]
283    fn source_map_file_id_matches() {
284        let mut map = SourceMap::new();
285        let id = map.add_file(PathBuf::from("x.bock"), "x".to_string());
286        assert_eq!(map.get_file(id).id, id);
287    }
288}