Skip to main content

quarto_source_map/
provenance_builder.rs

1//! Shared run-tiling builder for decoders that walk a decoded string
2//! lockstep against the source bytes it came from.
3//!
4//! `ProvenanceBuilder` is the piece of machinery three decoders share
5//! (quarto-yaml's scalar walker, pampa's div-attribute unescaper,
6//! comrak's CommonMark text path): feed it a sequence of
7//! `verbatim`/`replacement` calls describing how content bytes were
8//! produced from source bytes, and `finish()` emits a `SourceInfo` that
9//! is contiguous when it safely can be, and a `Concat` otherwise.
10//!
11//! See `claude-notes/plans/2026-08-20-provenance-1-foundations.md`,
12//! § "The shared builder", for the design this module implements.
13
14use std::ops::Range;
15use std::sync::Arc;
16
17use crate::source_info::SourceInfo;
18use crate::types::FileId;
19
20/// Where a builder's pieces are rooted: directly in a file, or as
21/// substrings of a parent `SourceInfo` (which may itself be a `Concat`).
22enum Root {
23    File(FileId),
24    Parent(Arc<SourceInfo>),
25}
26
27/// One accumulated piece, before it is turned into a `SourcePiece`.
28///
29/// `verbatim` is kept only internally — it does not survive into the
30/// emitted `SourceInfo` — but it is exactly what `push` needs to decide
31/// whether to coalesce, and what `finish` needs to decide whether to
32/// collapse. It is the caller's assertion (via which method it called),
33/// never re-derived from `src_range`/`content_len`.
34struct Piece {
35    src_range: Range<usize>,
36    content_len: usize,
37    verbatim: bool,
38}
39
40/// Builds a `SourceInfo` by tiling a decoded string's content against
41/// the source bytes it came from.
42///
43/// Construct with [`ProvenanceBuilder::in_file`] or
44/// [`ProvenanceBuilder::in_parent`], describe the decode with
45/// [`verbatim`](Self::verbatim) and [`replacement`](Self::replacement)
46/// calls in content order, then call [`finish`](Self::finish).
47pub struct ProvenanceBuilder {
48    root: Root,
49    anchor: usize,
50    pieces: Vec<Piece>,
51}
52
53impl ProvenanceBuilder {
54    /// Start a builder whose pieces are `Original` ranges directly in
55    /// `file_id`.
56    ///
57    /// `anchor` is the scalar's span start; `finish()` uses it when the
58    /// piece list ends up empty, since an empty piece list has no source
59    /// range to infer a position from.
60    pub fn in_file(file_id: FileId, anchor: usize) -> Self {
61        Self {
62            root: Root::File(file_id),
63            anchor,
64            pieces: Vec::new(),
65        }
66    }
67
68    /// Start a builder whose pieces are `Substring` ranges over `parent`.
69    ///
70    /// `parent` may itself be a `Concat` (e.g. q2's cell-options path
71    /// hands `quarto_yaml::parse_with_parent` a `SourceInfo::concat(..)`).
72    /// The builder never resolves absolute positions out of `parent` —
73    /// see [`finish`](Self::finish) — so this is safe regardless of what
74    /// shape `parent` is.
75    ///
76    /// `anchor` is the scalar's span start, in `parent`'s coordinate
77    /// space; see [`in_file`](Self::in_file) for why it is needed.
78    pub fn in_parent(parent: SourceInfo, anchor: usize) -> Self {
79        Self {
80            root: Root::Parent(Arc::new(parent)),
81            anchor,
82            pieces: Vec::new(),
83        }
84    }
85
86    /// Record `src_range.len()` source bytes decoding to that many
87    /// content bytes, unchanged.
88    ///
89    /// This is a caller assertion: the builder takes byte-identity on
90    /// trust and never re-derives it from lengths. Adjacent `verbatim`
91    /// calls whose source ranges abut are merged; a `replacement` never
92    /// coalesces with anything, however convenient its length.
93    pub fn verbatim(&mut self, src_range: Range<usize>) {
94        let content_len = src_range.len();
95        self.push(src_range, content_len, true);
96    }
97
98    /// Record `src_range.len()` source bytes decoding to `out_len`
99    /// content bytes, where source and content are not asserted to be
100    /// byte-identical.
101    ///
102    /// `out_len == 0` is a deletion. An empty `src_range` with
103    /// `out_len > 0` is synthesis: content with no corresponding source
104    /// byte (e.g. a chomped block scalar's trailing newline at EOF).
105    pub fn replacement(&mut self, src_range: Range<usize>, out_len: usize) {
106        self.push(src_range, out_len, false);
107    }
108
109    fn push(&mut self, src_range: Range<usize>, content_len: usize, verbatim: bool) {
110        if verbatim
111            && let Some(last) = self.pieces.last_mut()
112            && last.verbatim
113            && last.src_range.end == src_range.start
114        {
115            last.src_range.end = src_range.end;
116            last.content_len += content_len;
117            return;
118        }
119        self.pieces.push(Piece {
120            src_range,
121            content_len,
122            verbatim,
123        });
124    }
125
126    /// Emit the tiled `SourceInfo`: `Original`/`Substring` if the piece
127    /// list collapsed to exactly one verbatim piece (or is empty), a
128    /// `Concat` otherwise.
129    ///
130    /// This never calls `resolve_byte_range` — on the parent or on
131    /// anything derived from it — because `in_parent`'s parent may be a
132    /// `Concat`, for which that accessor returns `None`. Positions are
133    /// always built from the piece's own `src_range`, never resolved.
134    pub fn finish(self) -> SourceInfo {
135        #[cfg(debug_assertions)]
136        {
137            for pair in self.pieces.windows(2) {
138                debug_assert_eq!(
139                    pair[0].src_range.end, pair[1].src_range.start,
140                    "ProvenanceBuilder::finish: pieces do not tile their source \
141                     contiguously (gap or overlap between adjacent pieces)"
142                );
143            }
144        }
145
146        if self.pieces.is_empty() {
147            return self.leaf(self.anchor, self.anchor);
148        }
149
150        // Collapse iff there is exactly one piece and it is verbatim.
151        // Nothing weaker is sound: a 2->1 replacement collapsing would
152        // violate the length invariant, and a 1->1 fold (equal lengths,
153        // different bytes) collapsing would license Verbatim-copying the
154        // fold's source bytes for its (different) content bytes.
155        if self.pieces.len() == 1 && self.pieces[0].verbatim {
156            let p = &self.pieces[0];
157            return self.leaf(p.src_range.start, p.src_range.end);
158        }
159
160        let concat_pieces: Vec<(SourceInfo, usize)> = self
161            .pieces
162            .iter()
163            .map(|p| (self.leaf(p.src_range.start, p.src_range.end), p.content_len))
164            .collect();
165        SourceInfo::concat(concat_pieces)
166    }
167
168    /// Build a leaf `SourceInfo` (`Original` or `Substring`) over
169    /// `[start, end)` in the root's coordinate space.
170    fn leaf(&self, start: usize, end: usize) -> SourceInfo {
171        match &self.root {
172            Root::File(file_id) => SourceInfo::Original {
173                file_id: *file_id,
174                start_offset: start,
175                end_offset: end,
176            },
177            Root::Parent(parent) => SourceInfo::Substring {
178                parent: Arc::clone(parent),
179                start_offset: start,
180                end_offset: end,
181            },
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::types::FileId;
190
191    // -------------------------------------------------------------------
192    // Frozen test: all-verbatim -> a contiguous SourceInfo, not a
193    // 1-piece Concat.
194    // -------------------------------------------------------------------
195    #[test]
196    fn test_all_verbatim_collapses_to_contiguous() {
197        let mut b = ProvenanceBuilder::in_file(FileId(0), 100);
198        b.verbatim(0..3);
199        b.verbatim(3..7);
200        b.verbatim(7..10);
201        let si = b.finish();
202        match si {
203            SourceInfo::Original {
204                file_id,
205                start_offset,
206                end_offset,
207            } => {
208                assert_eq!(file_id, FileId(0));
209                assert_eq!(start_offset, 0);
210                assert_eq!(end_offset, 10);
211            }
212            other => panic!("expected a contiguous Original, got {other:?}"),
213        }
214    }
215
216    // -------------------------------------------------------------------
217    // Frozen test: the fold shape (verbatim / 1->1 replacement /
218    // verbatim) stays a 3-piece Concat and must not collapse.
219    // -------------------------------------------------------------------
220    #[test]
221    fn test_fold_shape_stays_uncollapsed_concat() {
222        // root plain, col-0 continuation: "aaa\nbbb" -> "aaa bbb"
223        // verbatim 0..3, replacement 3..4 (1 source byte -> 1 content
224        // byte, but NOT byte-identical: \n -> ' '), verbatim 4..7.
225        let mut b = ProvenanceBuilder::in_file(FileId(0), 0);
226        b.verbatim(0..3);
227        b.replacement(3..4, 1);
228        b.verbatim(4..7);
229        let si = b.finish();
230        match si {
231            SourceInfo::Concat { pieces } => {
232                assert_eq!(pieces.len(), 3, "fold shape must not collapse");
233                assert_eq!(si_length(&pieces[0].source_info), 3);
234                assert_eq!(pieces[0].length, 3);
235                assert_eq!(si_length(&pieces[1].source_info), 1);
236                assert_eq!(pieces[1].length, 1);
237                assert_eq!(si_length(&pieces[2].source_info), 3);
238                assert_eq!(pieces[2].length, 3);
239            }
240            other => panic!("expected a 3-piece Concat, got {other:?}"),
241        }
242    }
243
244    // -------------------------------------------------------------------
245    // Frozen test: zero pieces -> a zero-length SourceInfo at the
246    // anchor.
247    // -------------------------------------------------------------------
248    #[test]
249    fn test_zero_pieces_yields_zero_length_at_anchor() {
250        let b = ProvenanceBuilder::in_file(FileId(3), 42);
251        let si = b.finish();
252        match si {
253            SourceInfo::Original {
254                file_id,
255                start_offset,
256                end_offset,
257            } => {
258                assert_eq!(file_id, FileId(3));
259                assert_eq!(start_offset, 42);
260                assert_eq!(end_offset, 42);
261            }
262            other => panic!("expected a zero-length Original at the anchor, got {other:?}"),
263        }
264    }
265
266    // -------------------------------------------------------------------
267    // Frozen test: an out_len == 0 piece (a deletion) is STORED, and
268    // the source tiling stays gap-free.
269    // -------------------------------------------------------------------
270    #[test]
271    fn test_deletion_piece_is_stored_and_tiling_is_gap_free() {
272        // verbatim 4..7, deleted 7..11, verbatim 11..14 (the
273        // escaped-break shape from the fixtures note).
274        let mut b = ProvenanceBuilder::in_file(FileId(0), 0);
275        b.verbatim(4..7);
276        b.replacement(7..11, 0);
277        b.verbatim(11..14);
278        let si = b.finish();
279        match si {
280            SourceInfo::Concat { pieces } => {
281                assert_eq!(pieces.len(), 3, "the deletion piece must not be dropped");
282                assert_eq!(si_length(&pieces[1].source_info), 4);
283                assert_eq!(pieces[1].length, 0, "deletion produces zero content bytes");
284                // Tiling: pieces' source ranges must abut with no gap.
285                assert_eq!(source_range(&pieces[0].source_info), 4..7);
286                assert_eq!(source_range(&pieces[1].source_info), 7..11);
287                assert_eq!(source_range(&pieces[2].source_info), 11..14);
288            }
289            other => panic!("expected a 3-piece Concat, got {other:?}"),
290        }
291    }
292
293    // -------------------------------------------------------------------
294    // Frozen test: in_parent over a real Concat parent yields
295    // parent-relative pieces (never resolves absolute positions).
296    // -------------------------------------------------------------------
297    #[test]
298    fn test_in_parent_over_concat_parent_yields_parent_relative_pieces() {
299        let parent = SourceInfo::concat(vec![
300            (SourceInfo::original(FileId(1), 0, 5), 5),
301            (SourceInfo::original(FileId(1), 10, 15), 5),
302        ]);
303        assert!(
304            parent.resolve_byte_range().is_none(),
305            "sanity: Concat has no resolvable range"
306        );
307
308        // Case A: a single verbatim piece collapses to a Substring
309        // directly over the Concat parent — no resolve_byte_range call
310        // is needed or made.
311        let mut single = ProvenanceBuilder::in_parent(parent.clone(), 0);
312        single.verbatim(2..6);
313        match single.finish() {
314            SourceInfo::Substring {
315                parent: p,
316                start_offset,
317                end_offset,
318            } => {
319                assert!(matches!(*p, SourceInfo::Concat { .. }));
320                assert_eq!(start_offset, 2);
321                assert_eq!(end_offset, 6);
322            }
323            other => panic!("expected a Substring over the Concat parent, got {other:?}"),
324        }
325
326        // Case B: verbatim + replacement (does not collapse) yields a
327        // Concat whose pieces are each Substrings over the same parent.
328        let mut multi = ProvenanceBuilder::in_parent(parent.clone(), 0);
329        multi.verbatim(0..4);
330        multi.replacement(4..6, 1);
331        match multi.finish() {
332            SourceInfo::Concat { pieces } => {
333                assert_eq!(pieces.len(), 2);
334                for piece in &pieces {
335                    match &piece.source_info {
336                        SourceInfo::Substring { parent: p, .. } => {
337                            assert!(matches!(**p, SourceInfo::Concat { .. }));
338                        }
339                        other => panic!("expected a Substring piece, got {other:?}"),
340                    }
341                }
342                match &pieces[0].source_info {
343                    SourceInfo::Substring {
344                        start_offset,
345                        end_offset,
346                        ..
347                    } => {
348                        assert_eq!(*start_offset, 0);
349                        assert_eq!(*end_offset, 4);
350                    }
351                    _ => unreachable!(),
352                }
353                match &pieces[1].source_info {
354                    SourceInfo::Substring {
355                        start_offset,
356                        end_offset,
357                        ..
358                    } => {
359                        assert_eq!(*start_offset, 4);
360                        assert_eq!(*end_offset, 6);
361                    }
362                    _ => unreachable!(),
363                }
364            }
365            other => panic!("expected a 2-piece Concat, got {other:?}"),
366        }
367    }
368
369    // -------------------------------------------------------------------
370    // Additional cases named alongside the frozen tests: one
371    // replacement, synthesis, adjacent replacements, a replacement at
372    // offset 0, and a replacement at the end.
373    // -------------------------------------------------------------------
374
375    #[test]
376    fn test_single_replacement_does_not_collapse() {
377        // k: '''' -> value "'" : a single 2->1 replacement. Must stay a
378        // 1-piece Concat, not collapse to Original{4,6} (which would
379        // violate the length invariant: length() 2 != decoded.len() 1).
380        let mut b = ProvenanceBuilder::in_file(FileId(0), 4);
381        b.replacement(4..6, 1);
382        let si = b.finish();
383        match si {
384            SourceInfo::Concat { pieces } => {
385                assert_eq!(pieces.len(), 1);
386                assert_eq!(pieces[0].length, 1);
387                assert_eq!(si_length(&pieces[0].source_info), 2);
388            }
389            other => panic!("expected a 1-piece Concat, got {other:?}"),
390        }
391    }
392
393    #[test]
394    fn test_synthesis_empty_src_range_with_positive_out_len() {
395        // block | no final newline: verbatim 7..10, then a synthesized
396        // trailing newline with no source byte at all: replacement(10..10, 1).
397        let mut b = ProvenanceBuilder::in_file(FileId(0), 7);
398        b.verbatim(7..10);
399        b.replacement(10..10, 1);
400        let si = b.finish();
401        match si {
402            SourceInfo::Concat { pieces } => {
403                assert_eq!(pieces.len(), 2);
404                assert_eq!(pieces[1].length, 1);
405                assert_eq!(source_range(&pieces[1].source_info), 10..10);
406            }
407            other => panic!("expected a 2-piece Concat, got {other:?}"),
408        }
409    }
410
411    #[test]
412    fn test_adjacent_replacements_do_not_coalesce() {
413        let mut b = ProvenanceBuilder::in_file(FileId(0), 0);
414        b.replacement(0..2, 1);
415        b.replacement(2..4, 1);
416        let si = b.finish();
417        match si {
418            SourceInfo::Concat { pieces } => {
419                assert_eq!(
420                    pieces.len(),
421                    2,
422                    "replacements never coalesce, even when adjacent"
423                );
424                assert_eq!(source_range(&pieces[0].source_info), 0..2);
425                assert_eq!(source_range(&pieces[1].source_info), 2..4);
426            }
427            other => panic!("expected a 2-piece Concat, got {other:?}"),
428        }
429    }
430
431    #[test]
432    fn test_replacement_at_offset_zero() {
433        let mut b = ProvenanceBuilder::in_file(FileId(0), 0);
434        b.replacement(0..2, 1);
435        b.verbatim(2..5);
436        let si = b.finish();
437        match si {
438            SourceInfo::Concat { pieces } => {
439                assert_eq!(pieces.len(), 2);
440                assert_eq!(source_range(&pieces[0].source_info), 0..2);
441            }
442            other => panic!("expected a 2-piece Concat, got {other:?}"),
443        }
444    }
445
446    #[test]
447    fn test_replacement_at_the_end() {
448        let mut b = ProvenanceBuilder::in_file(FileId(0), 0);
449        b.verbatim(0..3);
450        b.replacement(3..5, 1);
451        let si = b.finish();
452        match si {
453            SourceInfo::Concat { pieces } => {
454                assert_eq!(pieces.len(), 2);
455                assert_eq!(source_range(&pieces[1].source_info), 3..5);
456                assert_eq!(pieces[1].length, 1);
457            }
458            other => panic!("expected a 2-piece Concat, got {other:?}"),
459        }
460    }
461
462    // -------------------------------------------------------------------
463    // Test helpers
464    // -------------------------------------------------------------------
465
466    fn si_length(si: &SourceInfo) -> usize {
467        si.length()
468    }
469
470    fn source_range(si: &SourceInfo) -> Range<usize> {
471        match si {
472            SourceInfo::Original {
473                start_offset,
474                end_offset,
475                ..
476            } => *start_offset..*end_offset,
477            SourceInfo::Substring {
478                start_offset,
479                end_offset,
480                ..
481            } => *start_offset..*end_offset,
482            other => panic!("source_range: unexpected variant {other:?}"),
483        }
484    }
485}