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    line_starts: Vec<usize>,
18}
19
20impl SourceFile {
21    /// Create a new source file.
22    fn new(id: usize, path: PathBuf, source: Arc<str>) -> Self {
23        let line_starts = std::iter::once(0)
24            .chain(source.match_indices('\n').map(|(i, _)| i + 1))
25            .collect();
26
27        Self {
28            id,
29            path,
30            source,
31            line_starts,
32        }
33    }
34
35    /// Get the line and column (1-based) for a byte offset.
36    #[must_use]
37    pub fn line_col(&self, offset: usize) -> (usize, usize) {
38        let line = self
39            .line_starts
40            .iter()
41            .rposition(|&start| start <= offset)
42            .unwrap_or(0);
43
44        let col = offset - self.line_starts[line];
45
46        (line + 1, col + 1)
47    }
48
49    /// Get the source text for a span.
50    #[must_use]
51    pub fn span_text(&self, span: &Span) -> &str {
52        &self.source[span.start..span.end.min(self.source.len())]
53    }
54
55    /// Get a specific line (1-based).
56    #[must_use]
57    pub fn line(&self, line_num: usize) -> Option<&str> {
58        if line_num == 0 || line_num > self.line_starts.len() {
59            return None;
60        }
61
62        let start = self.line_starts[line_num - 1];
63        let end = if line_num < self.line_starts.len() {
64            self.line_starts[line_num] - 1 // Exclude newline
65        } else {
66            self.source.len()
67        };
68
69        Some(&self.source[start..end])
70    }
71
72    /// Get the total number of lines.
73    #[must_use]
74    pub const fn num_lines(&self) -> usize {
75        self.line_starts.len()
76    }
77
78    /// Get the byte offset where a line starts (1-based line number).
79    ///
80    /// Returns `None` if the line number is out of range.
81    #[must_use]
82    pub fn line_start(&self, line_num: usize) -> Option<usize> {
83        if line_num == 0 || line_num > self.line_starts.len() {
84            return None;
85        }
86        Some(self.line_starts[line_num - 1])
87    }
88}
89
90/// A map of source files for error reporting.
91#[derive(Debug, Default)]
92pub struct SourceMap {
93    files: Vec<SourceFile>,
94}
95
96impl SourceMap {
97    /// Create a new source map.
98    #[must_use]
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Add a file to the source map.
104    ///
105    /// Returns the file ID.
106    ///
107    /// # Panics
108    ///
109    /// Panics if adding this file would produce an ID that collides with
110    /// [`rustledger_parser::SYNTHESIZED_FILE_ID`] (i.e., with more than
111    /// `u16::MAX - 1` = 65,534 loaded files). Directives stored in
112    /// `Spanned<T>` use a `u16` for `file_id`, and the topmost value is
113    /// reserved as a sentinel for plugin-synthesized directives.
114    pub fn add_file(&mut self, path: PathBuf, source: Arc<str>) -> usize {
115        let id = self.files.len();
116        assert!(
117            id < rustledger_parser::SYNTHESIZED_FILE_ID as usize,
118            "SourceMap exceeded {} files; file_id {id} collides with SYNTHESIZED_FILE_ID sentinel",
119            rustledger_parser::SYNTHESIZED_FILE_ID,
120        );
121        self.files.push(SourceFile::new(id, path, source));
122        id
123    }
124
125    /// Get a file by ID.
126    #[must_use]
127    pub fn get(&self, id: usize) -> Option<&SourceFile> {
128        self.files.get(id)
129    }
130
131    /// Get a file by path.
132    #[must_use]
133    pub fn get_by_path(&self, path: &std::path::Path) -> Option<&SourceFile> {
134        self.files.iter().find(|f| f.path == path)
135    }
136
137    /// Get all files.
138    #[must_use]
139    pub fn files(&self) -> &[SourceFile] {
140        &self.files
141    }
142
143    /// Format a span for display.
144    #[must_use]
145    pub fn format_span(&self, file_id: usize, span: &Span) -> String {
146        if let Some(file) = self.get(file_id) {
147            let (line, col) = file.line_col(span.start);
148            format!("{}:{}:{}", file.path.display(), line, col)
149        } else {
150            format!("?:{}..{}", span.start, span.end)
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_line_col() {
161        let source: Arc<str> = "line 1\nline 2\nline 3".into();
162        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
163
164        assert_eq!(file.line_col(0), (1, 1)); // Start of line 1
165        assert_eq!(file.line_col(5), (1, 6)); // "1" in line 1
166        assert_eq!(file.line_col(7), (2, 1)); // Start of line 2
167        assert_eq!(file.line_col(14), (3, 1)); // Start of line 3
168    }
169
170    #[test]
171    fn test_get_line() {
172        let source: Arc<str> = "line 1\nline 2\nline 3".into();
173        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
174
175        assert_eq!(file.line(1), Some("line 1"));
176        assert_eq!(file.line(2), Some("line 2"));
177        assert_eq!(file.line(3), Some("line 3"));
178        assert_eq!(file.line(0), None);
179        assert_eq!(file.line(4), None);
180    }
181
182    #[test]
183    fn test_line_start() {
184        let source: Arc<str> = "line 1\nline 2\nline 3".into();
185        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
186
187        // Happy path - valid line numbers
188        assert_eq!(file.line_start(1), Some(0)); // Line 1 starts at byte 0
189        assert_eq!(file.line_start(2), Some(7)); // Line 2 starts at byte 7 (after "line 1\n")
190        assert_eq!(file.line_start(3), Some(14)); // Line 3 starts at byte 14
191
192        // Boundary conditions
193        assert_eq!(file.line_start(0), None); // Line 0 is invalid (1-based)
194        assert_eq!(file.line_start(4), None); // Line 4 is out of range
195        assert_eq!(file.line_start(100), None); // Way out of range
196    }
197
198    #[test]
199    fn test_source_map() {
200        let mut sm = SourceMap::new();
201        let id = sm.add_file(PathBuf::from("test.beancount"), "content".into());
202
203        assert_eq!(id, 0);
204        assert!(sm.get(0).is_some());
205        assert!(sm.get(1).is_none());
206    }
207}