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 fn num_lines(&self) -> usize {
75        self.line_starts.len()
76    }
77}
78
79/// A map of source files for error reporting.
80#[derive(Debug, Default)]
81pub struct SourceMap {
82    files: Vec<SourceFile>,
83}
84
85impl SourceMap {
86    /// Create a new source map.
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Add a file to the source map.
93    ///
94    /// Returns the file ID.
95    pub fn add_file(&mut self, path: PathBuf, source: Arc<str>) -> usize {
96        let id = self.files.len();
97        self.files.push(SourceFile::new(id, path, source));
98        id
99    }
100
101    /// Get a file by ID.
102    #[must_use]
103    pub fn get(&self, id: usize) -> Option<&SourceFile> {
104        self.files.get(id)
105    }
106
107    /// Get a file by path.
108    #[must_use]
109    pub fn get_by_path(&self, path: &std::path::Path) -> Option<&SourceFile> {
110        self.files.iter().find(|f| f.path == path)
111    }
112
113    /// Get all files.
114    #[must_use]
115    pub fn files(&self) -> &[SourceFile] {
116        &self.files
117    }
118
119    /// Format a span for display.
120    #[must_use]
121    pub fn format_span(&self, file_id: usize, span: &Span) -> String {
122        if let Some(file) = self.get(file_id) {
123            let (line, col) = file.line_col(span.start);
124            format!("{}:{}:{}", file.path.display(), line, col)
125        } else {
126            format!("?:{}..{}", span.start, span.end)
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_line_col() {
137        let source: Arc<str> = "line 1\nline 2\nline 3".into();
138        let file = SourceFile::new(0, PathBuf::from("test.beancount"), source);
139
140        assert_eq!(file.line_col(0), (1, 1)); // Start of line 1
141        assert_eq!(file.line_col(5), (1, 6)); // "1" in line 1
142        assert_eq!(file.line_col(7), (2, 1)); // Start of line 2
143        assert_eq!(file.line_col(14), (3, 1)); // Start of line 3
144    }
145
146    #[test]
147    fn test_get_line() {
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(1), Some("line 1"));
152        assert_eq!(file.line(2), Some("line 2"));
153        assert_eq!(file.line(3), Some("line 3"));
154        assert_eq!(file.line(0), None);
155        assert_eq!(file.line(4), None);
156    }
157
158    #[test]
159    fn test_source_map() {
160        let mut sm = SourceMap::new();
161        let id = sm.add_file(PathBuf::from("test.beancount"), "content".into());
162
163        assert_eq!(id, 0);
164        assert!(sm.get(0).is_some());
165        assert!(sm.get(1).is_none());
166    }
167}