Skip to main content

rustledger_loader/
source_map.rs

1//! Source map for tracking file locations.
2
3use rustledger_parser::Span;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7/// A source file in the source map.
8#[derive(Debug, Clone)]
9pub struct SourceFile {
10    /// Unique ID for this file.
11    pub id: usize,
12    /// Path to the file.
13    pub path: PathBuf,
14    /// Source content (shared via Arc to avoid cloning).
15    pub source: Arc<str>,
16    /// Line start offsets (byte positions where each line starts).
17    ///
18    /// Built on first use, not on construction. Every consumer is a
19    /// diagnostic — `line_col`, `line`, `line_start`, `num_lines` — so a
20    /// ledger that reports nothing never needs it, and building it eagerly
21    /// meant scanning the whole source for newlines and keeping a `usize`
22    /// per line: 3.9% of a warm `check` and 320 KB on a 40,000-line ledger,
23    /// for a table nothing read.
24    line_starts: std::sync::OnceLock<Vec<usize>>,
25}
26
27impl SourceFile {
28    /// Create a new source file.
29    const fn new(id: usize, path: PathBuf, source: Arc<str>) -> Self {
30        Self {
31            id,
32            path,
33            source,
34            line_starts: std::sync::OnceLock::new(),
35        }
36    }
37
38    /// The line-start table, built on first use.
39    fn line_starts(&self) -> &[usize] {
40        self.line_starts.get_or_init(|| {
41            std::iter::once(0)
42                .chain(self.source.match_indices('\n').map(|(i, _)| i + 1))
43                .collect()
44        })
45    }
46
47    /// Get the line and column (1-based) for a byte offset.
48    #[must_use]
49    pub fn line_col(&self, offset: usize) -> (usize, usize) {
50        // `partition_point`, not `rposition`: the table is sorted ascending,
51        // so the linear scan this replaces was O(lines) per lookup — fine for
52        // one diagnostic, quadratic for a file that reports thousands. The
53        // predicate is monotone over a sorted slice, so the count of entries
54        // satisfying it is one past the last that does; entry 0 is always 0,
55        // so the count is never zero and the subtraction cannot underflow.
56        let starts = self.line_starts();
57        let line = starts.partition_point(|&start| start <= offset) - 1;
58
59        let col = offset - starts[line];
60
61        (line + 1, col + 1)
62    }
63
64    /// Get the source text for a span.
65    #[must_use]
66    pub fn span_text(&self, span: &Span) -> &str {
67        &self.source[span.start..span.end.min(self.source.len())]
68    }
69
70    /// Get a specific line (1-based).
71    #[must_use]
72    pub fn line(&self, line_num: usize) -> Option<&str> {
73        let starts = self.line_starts();
74        if line_num == 0 || line_num > starts.len() {
75            return None;
76        }
77
78        let start = starts[line_num - 1];
79        let end = if line_num < starts.len() {
80            starts[line_num] - 1 // Exclude newline
81        } else {
82            self.source.len()
83        };
84
85        Some(&self.source[start..end])
86    }
87
88    /// Get the total number of lines.
89    #[must_use]
90    pub fn num_lines(&self) -> usize {
91        self.line_starts().len()
92    }
93
94    /// Get the byte offset where a line starts (1-based line number).
95    ///
96    /// Returns `None` if the line number is out of range.
97    #[must_use]
98    pub fn line_start(&self, line_num: usize) -> Option<usize> {
99        let starts = self.line_starts();
100        if line_num == 0 || line_num > starts.len() {
101            return None;
102        }
103        Some(starts[line_num - 1])
104    }
105}
106
107/// A map of source files for error reporting.
108#[derive(Debug, Default)]
109pub struct SourceMap {
110    files: Vec<SourceFile>,
111}
112
113impl SourceMap {
114    /// Create a new source map.
115    #[must_use]
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Add a file to the source map.
121    ///
122    /// Returns the file ID.
123    ///
124    /// # Panics
125    ///
126    /// Panics if adding this file would produce an ID that collides with
127    /// [`rustledger_parser::SYNTHESIZED_FILE_ID`] (i.e., with more than
128    /// `u16::MAX - 1` = 65,534 loaded files). Directives stored in
129    /// `Spanned<T>` use a `u16` for `file_id`, and the topmost value is
130    /// reserved as a sentinel for plugin-synthesized directives.
131    pub fn add_file(&mut self, path: PathBuf, source: Arc<str>) -> usize {
132        let id = self.files.len();
133        assert!(
134            id < rustledger_parser::SYNTHESIZED_FILE_ID as usize,
135            "SourceMap exceeded {} files; file_id {id} collides with SYNTHESIZED_FILE_ID sentinel",
136            rustledger_parser::SYNTHESIZED_FILE_ID,
137        );
138        self.files.push(SourceFile::new(id, path, source));
139        id
140    }
141
142    /// Get a file by ID.
143    #[must_use]
144    pub fn get(&self, id: usize) -> Option<&SourceFile> {
145        self.files.get(id)
146    }
147
148    /// Get a file by path.
149    #[must_use]
150    pub fn get_by_path(&self, path: &std::path::Path) -> Option<&SourceFile> {
151        self.files.iter().find(|f| f.path == path)
152    }
153
154    /// Get all files.
155    #[must_use]
156    pub fn files(&self) -> &[SourceFile] {
157        &self.files
158    }
159
160    /// Format a span for display.
161    #[must_use]
162    pub fn format_span(&self, file_id: usize, span: &Span) -> String {
163        if let Some(file) = self.get(file_id) {
164            let (line, col) = file.line_col(span.start);
165            format!("{}:{}:{}", file.path.display(), line, col)
166        } else {
167            format!("?:{}..{}", span.start, span.end)
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_line_col() {
178        let source: Arc<str> = "line 1\nline 2\nline 3".into();
179        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
180
181        assert_eq!(file.line_col(0), (1, 1)); // Start of line 1
182        assert_eq!(file.line_col(5), (1, 6)); // "1" in line 1
183        assert_eq!(file.line_col(7), (2, 1)); // Start of line 2
184        assert_eq!(file.line_col(14), (3, 1)); // Start of line 3
185    }
186
187    #[test]
188    fn test_get_line() {
189        let source: Arc<str> = "line 1\nline 2\nline 3".into();
190        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
191
192        assert_eq!(file.line(1), Some("line 1"));
193        assert_eq!(file.line(2), Some("line 2"));
194        assert_eq!(file.line(3), Some("line 3"));
195        assert_eq!(file.line(0), None);
196        assert_eq!(file.line(4), None);
197    }
198
199    #[test]
200    fn test_line_start() {
201        let source: Arc<str> = "line 1\nline 2\nline 3".into();
202        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
203
204        // Happy path - valid line numbers
205        assert_eq!(file.line_start(1), Some(0)); // Line 1 starts at byte 0
206        assert_eq!(file.line_start(2), Some(7)); // Line 2 starts at byte 7 (after "line 1\n")
207        assert_eq!(file.line_start(3), Some(14)); // Line 3 starts at byte 14
208
209        // Boundary conditions
210        assert_eq!(file.line_start(0), None); // Line 0 is invalid (1-based)
211        assert_eq!(file.line_start(4), None); // Line 4 is out of range
212        assert_eq!(file.line_start(100), None); // Way out of range
213    }
214
215    #[test]
216    fn test_source_map() {
217        let mut sm = SourceMap::new();
218        let id = sm.add_file(PathBuf::from("test.beancount"), "content".into());
219
220        assert_eq!(id, 0);
221        assert!(sm.get(0).is_some());
222        assert!(sm.get(1).is_none());
223    }
224}