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}