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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
use crate::Span;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
/// Represents the source location (row, column) of this element in the original Ion text.
///
/// The source location metadata is primarily intended for error reporting and debugging purposes,
/// helping applications provide meaningful feedback to users about the source of issues.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
pub struct SourceLocation {
/// A 1-based row and column pair.
/// INVARIANT: both components must be `0` or both must be non-zero.
location: (usize, usize),
}
impl SourceLocation {
/// Constructs a new SourceLocation. If either of `row` or `column` is `0`, returns an instance
/// with no row/column value (i.e. both row and column are zero). This maintains the invariant
/// that the location field must be `(0, 0)` or must have two non-zero values.
pub(crate) fn new(row: usize, column: usize) -> SourceLocation {
if row == 0 || column == 0 {
return Self::empty();
}
Self {
location: (row, column),
}
}
/// Constructs a new `SourceLocation` instance that has no row/column available.
pub(crate) fn empty() -> SourceLocation {
Self { location: (0, 0) }
}
/// If this `SourceLocation` instance has row-column information, returns a tuple containing
/// the 1-based row and column numbers. Otherwise, returns [`None`].
pub fn row_column(&self) -> Option<(usize, usize)> {
match self.location {
(0, 0) => None,
other => Some(other),
}
}
/// If this `SourceLocation` instance has row-column information, returns the 1-based row number.
/// Otherwise, returns [`None`].
pub fn row(&self) -> Option<usize> {
match self.location {
(0, 0) => None,
(row, _) => Some(row),
}
}
/// If this `SourceLocation` instance has row-column information, returns the 1-based column number.
/// Otherwise, returns [`None`].
pub fn column(&self) -> Option<usize> {
match self.location {
(0, 0) => None,
(_, col) => Some(col),
}
}
}
#[cfg(test)]
mod source_location_tests {
use crate::location::SourceLocation;
#[test]
fn empty_source_location() {
let location = SourceLocation::empty();
assert_eq!(None, location.row_column());
assert_eq!(None, location.row());
assert_eq!(None, location.column());
}
#[test]
fn non_empty_source_location() {
let location = SourceLocation::new(2, 3);
assert_eq!(Some((2, 3)), location.row_column());
assert_eq!(Some(2), location.row());
assert_eq!(Some(3), location.column());
}
}
/// Encapsulates location tracking state and functionality.
///
/// This struct is cheap to clone because all of its state is behind a single reference-counted
/// pointer, which is shared with every clone.
#[derive(Debug, Clone)]
pub(crate) struct SourceLocationState {
state: Rc<SourceLocationStateInner>,
}
/// The state shared by a [`SourceLocationState`] and all of its clones.
///
/// Both fields live behind the same `Rc` so that it is impossible to construct or replace one
/// without the other; see the invariants on `last_row`.
#[derive(Debug)]
struct SourceLocationStateInner {
/// A non-empty vec containing the offset of the start of each row.
///
/// INVARIANTS:
/// * The first row always starts at offset 0.
/// * The offsets are sorted in ascending order. [`SourceLocationState::calculate_location_for_span`]
/// binary searches this vec, which is meaningful only if it is sorted, and subtracts the
/// located row's offset from the span's start, which would underflow if it were not.
/// * The vec is append-only: rows are never removed, reordered, or rewritten, so a row index
/// that was once in bounds remains in bounds.
row_start_offsets: RefCell<Vec<usize>>,
/// The 0-based index into `row_start_offsets` that was resolved by the most recent call to
/// [`SourceLocationState::calculate_location_for_span`] -- made through this
/// `SourceLocationState` or any of its clones, as the cache is shared with all of them. (The
/// [`SourceLocation`] that call returns is 1-based; this is the raw vec index.) It is
/// used as the starting point for the next lookup, which then only has to walk forward.
///
/// Lookups that arrive in ascending order of span offset are O(1) amortized, because the cursor
/// advances at most once per row per ascending run. `impl TryFrom<LazyValue> for Element`
/// guarantees that order for literal-backed values by resolving a container's location before
/// reading -- and thereby recursively materializing -- its children. Lookups that arrive out of
/// order still get correct results, but pay for a binary search; Ion 1.1 template argument
/// reordering is a known non-ascending case, because `ExpandedValueSource::via_variable`
/// preserves the original argument's span, so a template body that references its parameters
/// out of positional order yields descending spans. (This is a separate property from the
/// monotonically non-decreasing `stream_offset` that
/// [`SourceLocationState::update_from_source`] requires: that one is an invariant, this one is
/// only an optimization.)
///
/// INVARIANT: this is always a valid index into `row_start_offsets`, because that vec is
/// append-only and the two fields are always constructed and replaced together.
///
/// The value is purely an optimization: any value satisfying the invariant above still yields
/// a correct result, because the cached row is re-validated against each span before it is
/// used.
last_row: Cell<usize>,
/// The number of lookups that could not start from `last_row` and had to binary search
/// `row_start_offsets` instead.
///
/// Because the cursor is only an optimization, nothing observable depends on this count; it
/// exists so that tests can assert the ascending-order property described on `last_row`, which
/// no other assertion can detect. It is `#[cfg(test)]` so that production builds do not pay
/// for it.
#[cfg(test)]
fallback_lookups: Cell<usize>,
}
impl SourceLocationState {
pub fn new() -> Self {
Self {
state: Rc::new(SourceLocationStateInner {
row_start_offsets: RefCell::new(vec![0]),
last_row: Cell::new(0),
#[cfg(test)]
fallback_lookups: Cell::new(0),
}),
}
}
/// The number of lookups made through this `SourceLocationState` or any of its clones that had
/// to fall back to a binary search. See `SourceLocationStateInner::fallback_lookups`.
#[cfg(test)]
pub(crate) fn fallback_lookups(&self) -> usize {
self.state.fallback_lookups.get()
}
/// Updates the location tracking state from the given source data.
///
/// `stream_offset` is the offset of `data` within the stream as a whole. Callers must supply
/// successive pieces of the stream with monotonically non-decreasing `stream_offset` values;
/// otherwise the ascending-order invariant of `row_start_offsets` would be violated.
pub fn update_from_source<T: AsRef<[u8]>>(&mut self, stream_offset: usize, data: T) {
let data = data.as_ref();
if !data.is_empty() {
let newlines = memchr::memchr_iter(b'\n', data);
self.state
.row_start_offsets
.borrow_mut()
.extend(newlines.map(|it| it + stream_offset + 1));
}
}
pub fn calculate_location_for_span(&self, span: Span<'_>) -> SourceLocation {
let range = span.range();
let row_start_offsets = self.state.row_start_offsets.borrow();
// `row_start_offsets` is sorted, so the row containing `range.start` is the last one whose
// offset does not exceed it. `impl TryFrom<LazyValue> for Element` requests locations in
// ascending order for literal-backed values, so the cached row is normally the answer or is
// a short walk behind it. (Ion 1.1 template argument reordering is a known exception; see
// the docs on `last_row`.)
let last_row = self.state.last_row.get();
let row = if row_start_offsets
.get(last_row)
.is_some_and(|&start| start <= range.start)
{
// Advance while the following row also starts at or before `range.start`. Within an
// ascending run of lookups the cursor only moves forward, so it advances at most `rows`
// times over the whole run -- O(1) amortized per lookup, and a single comparison when
// consecutive spans share a row. (The `else` branch below can reset the cursor
// backwards, which starts a new run; a forward walk after such a reset is O(rows).)
// For a fixed-slice input, where the whole row index is built up front, searching
// from scratch on every lookup would instead cost O(log rows) at best and O(rows) for
// a linear scan, making a complete read O(rows * spans).
let mut row = last_row;
while row_start_offsets
.get(row + 1)
.is_some_and(|&next| next <= range.start)
{
row += 1;
}
row
} else {
// `range.start` precedes the cached row, so the caller is not querying in ascending
// order. Still correct, just without the amortized bound.
//
// `partition_point` counts the offsets at or before `range.start`; the row index is one
// less. The count is always at least 1 because of the invariant that the first row
// starts at offset 0.
#[cfg(test)]
self.state
.fallback_lookups
.set(self.state.fallback_lookups.get() + 1);
row_start_offsets.partition_point(|&offset| offset <= range.start) - 1
};
self.state.last_row.set(row);
let row_start_offset = row_start_offsets[row];
debug_assert!(
row_start_offset <= range.start,
"row {row} starts at offset {row_start_offset}, which is after the span's start ({})",
range.start
);
let column = range.start - row_start_offset;
// Both of these are 0-based counts, and must be incremented to be 1-based row/column
SourceLocation::new(row + 1, column + 1)
}
}
#[cfg(test)]
mod source_location_state_tests {
use crate::location::SourceLocationState;
use crate::Span;
use rstest::rstest;
/// `"a\nbb\n\nccc"` -- rows start at offsets 0, 2, 5, and 6.
fn state() -> SourceLocationState {
let mut state = SourceLocationState::new();
state.update_from_source(0, b"a\nbb\n\nccc");
state
}
fn location_at(state: &SourceLocationState, offset: usize) -> Option<(usize, usize)> {
state
.calculate_location_for_span(Span::with_offset(offset, b""))
.row_column()
}
#[rstest]
#[case::first_row_start(0, (1, 1))]
#[case::first_row_terminator(1, (1, 2))]
#[case::second_row_start(2, (2, 1))]
#[case::second_row_interior(3, (2, 2))]
#[case::second_row_terminator(4, (2, 3))]
#[case::empty_row(5, (3, 1))]
#[case::last_row_start(6, (4, 1))]
#[case::last_row_interior(8, (4, 3))]
fn location_for_offset(#[case] offset: usize, #[case] expected: (usize, usize)) {
assert_eq!(Some(expected), location_at(&state(), offset));
}
/// Lookups are cached across calls, so a sequence of them must produce the same locations as
/// the same lookups made in isolation, regardless of the order they arrive in. Readers query
/// offsets in increasing order, but nothing in the API requires it. Each case is a sequence of
/// `(offset, expected location)` pairs applied to a single state instance; each pair is also
/// checked against a state whose cache is cold.
#[rstest]
#[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))])]
#[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))])]
#[case::repeated_same_row([(6, (4, 1)), (8, (4, 3)), (6, (4, 1)), (8, (4, 3)), (6, (4, 1))])]
#[case::alternating_distant_rows([(0, (1, 1)), (8, (4, 3)), (1, (1, 2)), (6, (4, 1)), (2, (2, 1)), (5, (3, 1))])]
// Each lookup below must reject the cached row and resolve an earlier one.
#[case::backward_cache_misses([(8, (4, 3)), (2, (2, 1)), (0, (1, 1))])]
fn lookups_are_order_independent<const N: usize>(
#[case] lookups: [(usize, (usize, usize)); N],
) {
let warm_state = state();
for (offset, expected) in lookups {
assert_eq!(
Some(expected),
location_at(&warm_state, offset),
"offset {offset} was wrong when the cache was warm"
);
assert_eq!(
Some(expected),
location_at(&state(), offset),
"offset {offset} was wrong when the cache was cold"
);
}
}
/// Rows can be appended after lookups have already populated the cache.
#[test]
fn location_after_appending_source() {
let mut state = state();
assert_eq!(Some((4, 3)), location_at(&state, 8));
state.update_from_source(9, b"\ndddd");
assert_eq!(Some((4, 3)), location_at(&state, 8));
assert_eq!(Some((5, 2)), location_at(&state, 11));
// Resolving an offset that precedes the cached row forces the binary search fallback to run
// over the grown offset table.
assert_eq!(Some((2, 2)), location_at(&state, 3));
}
}