ion_rs/location.rs
1use crate::Span;
2use std::cell::{Cell, RefCell};
3use std::rc::Rc;
4
5/// Represents the source location (row, column) of this element in the original Ion text.
6///
7/// The source location metadata is primarily intended for error reporting and debugging purposes,
8/// helping applications provide meaningful feedback to users about the source of issues.
9#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
10pub struct SourceLocation {
11 /// A 1-based row and column pair.
12 /// INVARIANT: both components must be `0` or both must be non-zero.
13 location: (usize, usize),
14}
15
16impl SourceLocation {
17 /// Constructs a new SourceLocation. If either of `row` or `column` is `0`, returns an instance
18 /// with no row/column value (i.e. both row and column are zero). This maintains the invariant
19 /// that the location field must be `(0, 0)` or must have two non-zero values.
20 pub(crate) fn new(row: usize, column: usize) -> SourceLocation {
21 if row == 0 || column == 0 {
22 return Self::empty();
23 }
24 Self {
25 location: (row, column),
26 }
27 }
28
29 /// Constructs a new `SourceLocation` instance that has no row/column available.
30 pub(crate) fn empty() -> SourceLocation {
31 Self { location: (0, 0) }
32 }
33
34 /// If this `SourceLocation` instance has row-column information, returns a tuple containing
35 /// the 1-based row and column numbers. Otherwise, returns [`None`].
36 pub fn row_column(&self) -> Option<(usize, usize)> {
37 match self.location {
38 (0, 0) => None,
39 other => Some(other),
40 }
41 }
42
43 /// If this `SourceLocation` instance has row-column information, returns the 1-based row number.
44 /// Otherwise, returns [`None`].
45 pub fn row(&self) -> Option<usize> {
46 match self.location {
47 (0, 0) => None,
48 (row, _) => Some(row),
49 }
50 }
51
52 /// If this `SourceLocation` instance has row-column information, returns the 1-based column number.
53 /// Otherwise, returns [`None`].
54 pub fn column(&self) -> Option<usize> {
55 match self.location {
56 (0, 0) => None,
57 (_, col) => Some(col),
58 }
59 }
60}
61
62#[cfg(test)]
63mod source_location_tests {
64 use crate::location::SourceLocation;
65 #[test]
66 fn empty_source_location() {
67 let location = SourceLocation::empty();
68 assert_eq!(None, location.row_column());
69 assert_eq!(None, location.row());
70 assert_eq!(None, location.column());
71 }
72
73 #[test]
74 fn non_empty_source_location() {
75 let location = SourceLocation::new(2, 3);
76 assert_eq!(Some((2, 3)), location.row_column());
77 assert_eq!(Some(2), location.row());
78 assert_eq!(Some(3), location.column());
79 }
80}
81
82/// Encapsulates location tracking state and functionality.
83///
84/// This struct is cheap to clone because all of its state is behind a single reference-counted
85/// pointer, which is shared with every clone.
86#[derive(Debug, Clone)]
87pub(crate) struct SourceLocationState {
88 state: Rc<SourceLocationStateInner>,
89}
90
91/// The state shared by a [`SourceLocationState`] and all of its clones.
92///
93/// Both fields live behind the same `Rc` so that it is impossible to construct or replace one
94/// without the other; see the invariants on `last_row`.
95#[derive(Debug)]
96struct SourceLocationStateInner {
97 /// A non-empty vec containing the offset of the start of each row.
98 ///
99 /// INVARIANTS:
100 /// * The first row always starts at offset 0.
101 /// * The offsets are sorted in ascending order. [`SourceLocationState::calculate_location_for_span`]
102 /// binary searches this vec, which is meaningful only if it is sorted, and subtracts the
103 /// located row's offset from the span's start, which would underflow if it were not.
104 /// * The vec is append-only: rows are never removed, reordered, or rewritten, so a row index
105 /// that was once in bounds remains in bounds.
106 row_start_offsets: RefCell<Vec<usize>>,
107 /// The 0-based index into `row_start_offsets` that was resolved by the most recent call to
108 /// [`SourceLocationState::calculate_location_for_span`] -- made through this
109 /// `SourceLocationState` or any of its clones, as the cache is shared with all of them. (The
110 /// [`SourceLocation`] that call returns is 1-based; this is the raw vec index.) It is
111 /// used as the starting point for the next lookup, which then only has to walk forward.
112 ///
113 /// Lookups that arrive in ascending order of span offset are O(1) amortized, because the cursor
114 /// advances at most once per row per ascending run. `impl TryFrom<LazyValue> for Element`
115 /// guarantees that order for literal-backed values by resolving a container's location before
116 /// reading -- and thereby recursively materializing -- its children. Lookups that arrive out of
117 /// order still get correct results, but pay for a binary search; Ion 1.1 template argument
118 /// reordering is a known non-ascending case, because `ExpandedValueSource::via_variable`
119 /// preserves the original argument's span, so a template body that references its parameters
120 /// out of positional order yields descending spans. (This is a separate property from the
121 /// monotonically non-decreasing `stream_offset` that
122 /// [`SourceLocationState::update_from_source`] requires: that one is an invariant, this one is
123 /// only an optimization.)
124 ///
125 /// INVARIANT: this is always a valid index into `row_start_offsets`, because that vec is
126 /// append-only and the two fields are always constructed and replaced together.
127 ///
128 /// The value is purely an optimization: any value satisfying the invariant above still yields
129 /// a correct result, because the cached row is re-validated against each span before it is
130 /// used.
131 last_row: Cell<usize>,
132 /// The number of lookups that could not start from `last_row` and had to binary search
133 /// `row_start_offsets` instead.
134 ///
135 /// Because the cursor is only an optimization, nothing observable depends on this count; it
136 /// exists so that tests can assert the ascending-order property described on `last_row`, which
137 /// no other assertion can detect. It is `#[cfg(test)]` so that production builds do not pay
138 /// for it.
139 #[cfg(test)]
140 fallback_lookups: Cell<usize>,
141}
142
143impl SourceLocationState {
144 pub fn new() -> Self {
145 Self {
146 state: Rc::new(SourceLocationStateInner {
147 row_start_offsets: RefCell::new(vec![0]),
148 last_row: Cell::new(0),
149 #[cfg(test)]
150 fallback_lookups: Cell::new(0),
151 }),
152 }
153 }
154
155 /// The number of lookups made through this `SourceLocationState` or any of its clones that had
156 /// to fall back to a binary search. See `SourceLocationStateInner::fallback_lookups`.
157 #[cfg(test)]
158 pub(crate) fn fallback_lookups(&self) -> usize {
159 self.state.fallback_lookups.get()
160 }
161
162 /// Updates the location tracking state from the given source data.
163 ///
164 /// `stream_offset` is the offset of `data` within the stream as a whole. Callers must supply
165 /// successive pieces of the stream with monotonically non-decreasing `stream_offset` values;
166 /// otherwise the ascending-order invariant of `row_start_offsets` would be violated.
167 pub fn update_from_source<T: AsRef<[u8]>>(&mut self, stream_offset: usize, data: T) {
168 let data = data.as_ref();
169 if !data.is_empty() {
170 let newlines = memchr::memchr_iter(b'\n', data);
171 self.state
172 .row_start_offsets
173 .borrow_mut()
174 .extend(newlines.map(|it| it + stream_offset + 1));
175 }
176 }
177
178 pub fn calculate_location_for_span(&self, span: Span<'_>) -> SourceLocation {
179 let range = span.range();
180 let row_start_offsets = self.state.row_start_offsets.borrow();
181
182 // `row_start_offsets` is sorted, so the row containing `range.start` is the last one whose
183 // offset does not exceed it. `impl TryFrom<LazyValue> for Element` requests locations in
184 // ascending order for literal-backed values, so the cached row is normally the answer or is
185 // a short walk behind it. (Ion 1.1 template argument reordering is a known exception; see
186 // the docs on `last_row`.)
187 let last_row = self.state.last_row.get();
188 let row = if row_start_offsets
189 .get(last_row)
190 .is_some_and(|&start| start <= range.start)
191 {
192 // Advance while the following row also starts at or before `range.start`. Within an
193 // ascending run of lookups the cursor only moves forward, so it advances at most `rows`
194 // times over the whole run -- O(1) amortized per lookup, and a single comparison when
195 // consecutive spans share a row. (The `else` branch below can reset the cursor
196 // backwards, which starts a new run; a forward walk after such a reset is O(rows).)
197 // For a fixed-slice input, where the whole row index is built up front, searching
198 // from scratch on every lookup would instead cost O(log rows) at best and O(rows) for
199 // a linear scan, making a complete read O(rows * spans).
200 let mut row = last_row;
201 while row_start_offsets
202 .get(row + 1)
203 .is_some_and(|&next| next <= range.start)
204 {
205 row += 1;
206 }
207 row
208 } else {
209 // `range.start` precedes the cached row, so the caller is not querying in ascending
210 // order. Still correct, just without the amortized bound.
211 //
212 // `partition_point` counts the offsets at or before `range.start`; the row index is one
213 // less. The count is always at least 1 because of the invariant that the first row
214 // starts at offset 0.
215 #[cfg(test)]
216 self.state
217 .fallback_lookups
218 .set(self.state.fallback_lookups.get() + 1);
219 row_start_offsets.partition_point(|&offset| offset <= range.start) - 1
220 };
221
222 self.state.last_row.set(row);
223 let row_start_offset = row_start_offsets[row];
224 debug_assert!(
225 row_start_offset <= range.start,
226 "row {row} starts at offset {row_start_offset}, which is after the span's start ({})",
227 range.start
228 );
229 let column = range.start - row_start_offset;
230 // Both of these are 0-based counts, and must be incremented to be 1-based row/column
231 SourceLocation::new(row + 1, column + 1)
232 }
233}
234
235#[cfg(test)]
236mod source_location_state_tests {
237 use crate::location::SourceLocationState;
238 use crate::Span;
239 use rstest::rstest;
240
241 /// `"a\nbb\n\nccc"` -- rows start at offsets 0, 2, 5, and 6.
242 fn state() -> SourceLocationState {
243 let mut state = SourceLocationState::new();
244 state.update_from_source(0, b"a\nbb\n\nccc");
245 state
246 }
247
248 fn location_at(state: &SourceLocationState, offset: usize) -> Option<(usize, usize)> {
249 state
250 .calculate_location_for_span(Span::with_offset(offset, b""))
251 .row_column()
252 }
253
254 #[rstest]
255 #[case::first_row_start(0, (1, 1))]
256 #[case::first_row_terminator(1, (1, 2))]
257 #[case::second_row_start(2, (2, 1))]
258 #[case::second_row_interior(3, (2, 2))]
259 #[case::second_row_terminator(4, (2, 3))]
260 #[case::empty_row(5, (3, 1))]
261 #[case::last_row_start(6, (4, 1))]
262 #[case::last_row_interior(8, (4, 3))]
263 fn location_for_offset(#[case] offset: usize, #[case] expected: (usize, usize)) {
264 assert_eq!(Some(expected), location_at(&state(), offset));
265 }
266
267 /// Lookups are cached across calls, so a sequence of them must produce the same locations as
268 /// the same lookups made in isolation, regardless of the order they arrive in. Readers query
269 /// offsets in increasing order, but nothing in the API requires it. Each case is a sequence of
270 /// `(offset, expected location)` pairs applied to a single state instance; each pair is also
271 /// checked against a state whose cache is cold.
272 #[rstest]
273 #[case::ascending([(0, (1, 1)), (1, (1, 2)), (2, (2, 1)), (3, (2, 2)), (4, (2, 3)), (5, (3, 1)), (6, (4, 1)), (8, (4, 3))])]
274 #[case::descending([(8, (4, 3)), (6, (4, 1)), (5, (3, 1)), (4, (2, 3)), (3, (2, 2)), (2, (2, 1)), (1, (1, 2)), (0, (1, 1))])]
275 #[case::repeated_same_row([(6, (4, 1)), (8, (4, 3)), (6, (4, 1)), (8, (4, 3)), (6, (4, 1))])]
276 #[case::alternating_distant_rows([(0, (1, 1)), (8, (4, 3)), (1, (1, 2)), (6, (4, 1)), (2, (2, 1)), (5, (3, 1))])]
277 // Each lookup below must reject the cached row and resolve an earlier one.
278 #[case::backward_cache_misses([(8, (4, 3)), (2, (2, 1)), (0, (1, 1))])]
279 fn lookups_are_order_independent<const N: usize>(
280 #[case] lookups: [(usize, (usize, usize)); N],
281 ) {
282 let warm_state = state();
283 for (offset, expected) in lookups {
284 assert_eq!(
285 Some(expected),
286 location_at(&warm_state, offset),
287 "offset {offset} was wrong when the cache was warm"
288 );
289 assert_eq!(
290 Some(expected),
291 location_at(&state(), offset),
292 "offset {offset} was wrong when the cache was cold"
293 );
294 }
295 }
296
297 /// Rows can be appended after lookups have already populated the cache.
298 #[test]
299 fn location_after_appending_source() {
300 let mut state = state();
301 assert_eq!(Some((4, 3)), location_at(&state, 8));
302 state.update_from_source(9, b"\ndddd");
303 assert_eq!(Some((4, 3)), location_at(&state, 8));
304 assert_eq!(Some((5, 2)), location_at(&state, 11));
305 // Resolving an offset that precedes the cached row forces the binary search fallback to run
306 // over the grown offset table.
307 assert_eq!(Some((2, 2)), location_at(&state, 3));
308 }
309}