Skip to main content

tfparser_core/loader/
source_map.rs

1//! Source storage and `(line, col)` lookup, per [12-hcl-loader.md § 6].
2//!
3//! The loader needs (a) to keep file contents alive long enough to render
4//! spans later (`SourceMap`), and (b) a cheap byte → line/column converter
5//! for every span (`LineIndex`). Both are deliberately simple — building one
6//! `LineIndex` per file is `O(n)`; each `locate` is `O(log n)` over a small
7//! `Vec<u32>`.
8//!
9//! [12-hcl-loader.md § 6]: ../../../specs/12-hcl-loader.md
10
11use std::{
12    collections::HashMap,
13    path::{Path, PathBuf},
14    sync::{Arc, OnceLock, RwLock},
15};
16
17/// 1-based (line, column) position in a source file.
18///
19/// `column` is in **bytes**, matching the rest of the IR's byte-offset
20/// convention. Multi-byte UTF-8 characters straddling a column boundary
21/// will appear as a column at their starting byte.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub struct LineCol {
24    /// 1-based line number.
25    pub line: u32,
26    /// 1-based byte column within that line.
27    pub column: u32,
28}
29
30/// Sorted prefix-sums of line-start byte offsets in a single source file.
31///
32/// Cheap to clone (it owns one `Vec<u32>`); cheap to query (binary search).
33#[derive(Clone, Debug)]
34pub struct LineIndex {
35    line_starts: Vec<u32>,
36}
37
38impl LineIndex {
39    /// Build a [`LineIndex`] from a source string. `O(n)`.
40    ///
41    /// Files larger than `u32::MAX` saturate at the cap; the loader
42    /// already rejects files above 4 MiB, so this is a defence-in-depth
43    /// guard, not the primary cap.
44    #[must_use]
45    pub fn build(src: &str) -> Self {
46        let mut starts = Vec::with_capacity(src.len() / 32 + 4);
47        starts.push(0);
48        for (i, b) in src.bytes().enumerate() {
49            if b == b'\n' {
50                let next = i.saturating_add(1);
51                starts.push(u32::try_from(next).unwrap_or(u32::MAX));
52            }
53        }
54        Self {
55            line_starts: starts,
56        }
57    }
58
59    /// Resolve a byte offset to a 1-based [`LineCol`].
60    ///
61    /// Returns `(1, 1)` when `byte == 0` and the file is empty. Out-of-range
62    /// bytes (> file length) saturate at the last known line's column.
63    #[must_use]
64    pub fn locate(&self, byte: u32) -> LineCol {
65        let line_idx = self
66            .line_starts
67            .partition_point(|&start| start <= byte)
68            .saturating_sub(1);
69        let line_start = self.line_starts.get(line_idx).copied().unwrap_or_default();
70        LineCol {
71            line: u32::try_from(line_idx + 1).unwrap_or(u32::MAX),
72            column: byte.saturating_sub(line_start).saturating_add(1),
73        }
74    }
75}
76
77/// Cache of file contents and line indices, keyed by canonical path.
78///
79/// Used by the loader and the exporter — the exporter renders spans against
80/// the original bytes; eviction is therefore off until the consumer signals
81/// otherwise. Internally backed by a `RwLock<HashMap>` (rare writes, frequent
82/// reads); `DashMap` is overkill for the loader's call pattern (one writer
83/// per file, then read-only).
84#[derive(Debug, Default)]
85pub struct SourceMap {
86    inner: RwLock<HashMap<PathBuf, Arc<SourceEntry>>>,
87}
88
89/// Cached file contents + lazily-built [`LineIndex`].
90#[derive(Debug)]
91struct SourceEntry {
92    src: Arc<str>,
93    line_index: OnceLock<LineIndex>,
94}
95
96impl SourceMap {
97    /// Construct an empty cache.
98    #[must_use]
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Look up cached contents for `path`. Returns `None` if not cached.
104    #[must_use]
105    pub fn get(&self, path: &Path) -> Option<Arc<str>> {
106        let read = self.inner.read().ok()?;
107        read.get(path).map(|entry| Arc::clone(&entry.src))
108    }
109
110    /// Insert (or overwrite) the cached contents for `path`. Returns the
111    /// `Arc<str>` the caller should use going forward.
112    pub fn insert(&self, path: &Path, src: Arc<str>) -> Arc<str> {
113        let entry = Arc::new(SourceEntry {
114            src: Arc::clone(&src),
115            line_index: OnceLock::new(),
116        });
117        if let Ok(mut write) = self.inner.write() {
118            write.insert(path.to_path_buf(), entry);
119        }
120        src
121    }
122
123    /// Build (or reuse a cached) [`LineIndex`] for `path`. Returns `None` if
124    /// the path isn't in the cache.
125    #[must_use]
126    pub fn line_index(&self, path: &Path) -> Option<LineIndex> {
127        let read = self.inner.read().ok()?;
128        let entry = read.get(path)?;
129        let li = entry
130            .line_index
131            .get_or_init(|| LineIndex::build(&entry.src))
132            .clone();
133        Some(li)
134    }
135
136    /// Number of files currently cached.
137    #[must_use]
138    pub fn len(&self) -> usize {
139        self.inner.read().map_or(0, |m| m.len())
140    }
141
142    /// Whether the cache is empty.
143    #[must_use]
144    pub fn is_empty(&self) -> bool {
145        self.len() == 0
146    }
147}
148
149#[cfg(test)]
150#[allow(
151    clippy::unwrap_used,
152    clippy::expect_used,
153    clippy::panic,
154    clippy::indexing_slicing
155)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_line_index_locates_first_byte_at_1_1() {
161        let li = LineIndex::build("");
162        assert_eq!(li.locate(0), LineCol { line: 1, column: 1 });
163    }
164
165    #[test]
166    fn test_line_index_locates_across_newlines() {
167        let src = "abc\ndefgh\ni";
168        let li = LineIndex::build(src);
169        assert_eq!(li.locate(0), LineCol { line: 1, column: 1 });
170        assert_eq!(li.locate(2), LineCol { line: 1, column: 3 });
171        assert_eq!(li.locate(3), LineCol { line: 1, column: 4 }); // newline
172        assert_eq!(li.locate(4), LineCol { line: 2, column: 1 });
173        assert_eq!(li.locate(8), LineCol { line: 2, column: 5 });
174        assert_eq!(li.locate(10), LineCol { line: 3, column: 1 });
175    }
176
177    #[test]
178    fn test_line_index_handles_trailing_newline() {
179        let src = "x\n";
180        let li = LineIndex::build(src);
181        assert_eq!(li.locate(0), LineCol { line: 1, column: 1 });
182        // After the trailing newline, the next position is line 2 col 1.
183        assert_eq!(li.locate(2), LineCol { line: 2, column: 1 });
184    }
185
186    #[test]
187    fn test_line_index_clamps_byte_past_end() {
188        let li = LineIndex::build("abc");
189        let pos = li.locate(1_000);
190        assert_eq!(pos.line, 1);
191        // column is at most the byte offset within the (only) line.
192        assert!(pos.column >= 1);
193    }
194
195    #[test]
196    fn test_source_map_round_trip() {
197        let map = SourceMap::new();
198        let path = PathBuf::from("/tmp/x.tf");
199        let src: Arc<str> = Arc::from("a\nb\nc");
200        let returned = map.insert(&path, Arc::clone(&src));
201        assert_eq!(&*returned, "a\nb\nc");
202        let cached = map.get(&path).unwrap();
203        assert!(Arc::ptr_eq(&cached, &src));
204        assert_eq!(map.len(), 1);
205        let li = map.line_index(&path).unwrap();
206        assert_eq!(li.locate(2), LineCol { line: 2, column: 1 });
207    }
208
209    #[test]
210    fn test_source_map_returns_none_for_uncached() {
211        let map = SourceMap::new();
212        assert!(map.get(Path::new("/nope")).is_none());
213        assert!(map.line_index(Path::new("/nope")).is_none());
214    }
215
216    #[test]
217    fn test_source_map_overwrite_replaces_entry() {
218        let map = SourceMap::new();
219        let path = PathBuf::from("/tmp/x.tf");
220        map.insert(&path, Arc::from("v1"));
221        map.insert(&path, Arc::from("v2"));
222        assert_eq!(&*map.get(&path).unwrap(), "v2");
223    }
224}