Skip to main content

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}