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    pub fn add_file(&mut self, path: PathBuf, source: Arc<str>) -> usize {
107        let id = self.files.len();
108        self.files.push(SourceFile::new(id, path, source));
109        id
110    }
111
112    /// Get a file by ID.
113    #[must_use]
114    pub fn get(&self, id: usize) -> Option<&SourceFile> {
115        self.files.get(id)
116    }
117
118    /// Get a file by path.
119    #[must_use]
120    pub fn get_by_path(&self, path: &std::path::Path) -> Option<&SourceFile> {
121        self.files.iter().find(|f| f.path == path)
122    }
123
124    /// Get all files.
125    #[must_use]
126    pub fn files(&self) -> &[SourceFile] {
127        &self.files
128    }
129
130    /// Format a span for display.
131    #[must_use]
132    pub fn format_span(&self, file_id: usize, span: &Span) -> String {
133        if let Some(file) = self.get(file_id) {
134            let (line, col) = file.line_col(span.start);
135            format!("{}:{}:{}", file.path.display(), line, col)
136        } else {
137            format!("?:{}..{}", span.start, span.end)
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_line_col() {
148        let source: Arc<str> = "line 1\nline 2\nline 3".into();
149        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
150
151        assert_eq!(file.line_col(0), (1, 1)); // Start of line 1
152        assert_eq!(file.line_col(5), (1, 6)); // "1" in line 1
153        assert_eq!(file.line_col(7), (2, 1)); // Start of line 2
154        assert_eq!(file.line_col(14), (3, 1)); // Start of line 3
155    }
156
157    #[test]
158    fn test_get_line() {
159        let source: Arc<str> = "line 1\nline 2\nline 3".into();
160        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
161
162        assert_eq!(file.line(1), Some("line 1"));
163        assert_eq!(file.line(2), Some("line 2"));
164        assert_eq!(file.line(3), Some("line 3"));
165        assert_eq!(file.line(0), None);
166        assert_eq!(file.line(4), None);
167    }
168
169    #[test]
170    fn test_line_start() {
171        let source: Arc<str> = "line 1\nline 2\nline 3".into();
172        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
173
174        // Happy path - valid line numbers
175        assert_eq!(file.line_start(1), Some(0)); // Line 1 starts at byte 0
176        assert_eq!(file.line_start(2), Some(7)); // Line 2 starts at byte 7 (after "line 1\n")
177        assert_eq!(file.line_start(3), Some(14)); // Line 3 starts at byte 14
178
179        // Boundary conditions
180        assert_eq!(file.line_start(0), None); // Line 0 is invalid (1-based)
181        assert_eq!(file.line_start(4), None); // Line 4 is out of range
182        assert_eq!(file.line_start(100), None); // Way out of range
183    }
184
185    #[test]
186    fn test_source_map() {
187        let mut sm = SourceMap::new();
188        let id = sm.add_file(PathBuf::from("test.beancount"), "content".into());
189
190        assert_eq!(id, 0);
191        assert!(sm.get(0).is_some());
192        assert!(sm.get(1).is_none());
193    }
194}