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