Skip to main content

quarto_source_map/
source_info.rs

1//! Source information with transformation tracking
2
3use crate::types::{FileId, Range};
4use serde::{Deserialize, Serialize};
5use smallvec::SmallVec;
6use std::sync::Arc;
7
8/// Source information tracking a location and its transformation history
9///
10/// This enum stores only byte offsets. Row and column information is computed
11/// on-demand via `map_offset()` using the FileInformation line break index.
12///
13/// Design notes:
14/// - Original: Points directly to a file with byte offsets
15/// - Substring: Points to a range within a parent SourceInfo (offsets are relative to parent)
16/// - Concat: Combines multiple SourceInfo pieces (preserves provenance when coalescing text)
17/// - Generated: Produced by a pipeline transform. `by` records the producer; `from`
18///   records source-side anchors (empty for pure synthesis, `Invocation` for
19///   shortcode-style resolutions).
20///
21/// The Transformed variant was removed because it's not used in production code.
22/// Text transformations (smart quotes, em-dashes) use Original SourceInfo pointing
23/// to the pre-transformation text, accepting that the byte offsets are approximate.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub enum SourceInfo {
26    /// Direct position in an original file
27    ///
28    /// Stores only byte offsets. Use `map_offset()` to get row/column information.
29    Original {
30        file_id: FileId,
31        start_offset: usize,
32        end_offset: usize,
33    },
34    /// Substring extraction from a parent source
35    ///
36    /// Offsets are relative to the parent's text.
37    /// The chain of Substrings always resolves to an Original.
38    Substring {
39        parent: Arc<SourceInfo>,
40        start_offset: usize,
41        end_offset: usize,
42    },
43    /// Concatenation of multiple sources
44    ///
45    /// Used when coalescing adjacent text nodes while preserving
46    /// the fact that they came from different source locations.
47    Concat { pieces: Vec<SourcePiece> },
48    /// Node produced by a pipeline transform
49    ///
50    /// `by` records the producer ("which transform made me"); `from` is a
51    /// list of typed, role-labeled source-info pointers ("which source
52    /// bytes contributed to me"). Empty `from` means pure synthesis
53    /// (sectionize wrappers, filter constructions, title-block h1).
54    /// An `Invocation` anchor present means there is a source-side
55    /// preimage (every shortcode resolution).
56    Generated {
57        by: By,
58        #[serde(default, skip_serializing_if = "SmallVec::is_empty")]
59        from: SmallVec<[Anchor; 2]>,
60    },
61}
62
63/// Producer identity for a [`SourceInfo::Generated`] node.
64///
65/// `kind` is a short, kebab-case identifier describing which transform
66/// produced the node ("filter", "shortcode", "sectionize", ...). Third
67/// parties should namespace as `ext/<extension>/<kind>`.
68///
69/// `data` is per-kind configuration that is **not** a source-info pointer.
70/// Source-side anchors live in the parent `Generated.from` list, not here.
71/// `Null` for kinds that don't carry per-instance data.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct By {
74    /// Short kind tag, kebab-case. Examples: "filter", "shortcode",
75    /// "sectionize", "user-edit", "title-block".
76    /// Third-party kinds should namespace: "ext/my-extension/foo".
77    pub kind: String,
78
79    /// Per-kind configuration that is NOT a source-info pointer.
80    /// Anchors live in `Generated.from`, not here.
81    /// `Null` for kinds that don't carry per-instance data.
82    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
83    pub data: serde_json::Value,
84}
85
86/// Role describing what kind of source-side contribution an anchor records.
87///
88/// The known roles are load-bearing — `Invocation` is what the writer's
89/// preimage walk and attribution consult; `ValueSource` is diagnostic-only.
90/// `Other(String)` is an open escape hatch for extension-defined roles.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub enum AnchorRole {
93    /// The user-written construct that triggered this node's creation
94    /// (e.g. the `{{< meta foo >}}` token in the active document).
95    /// Load-bearing: the writer's `preimage_in` and attribution's
96    /// `resolve_byte_range` consult the first anchor with this role.
97    /// At most one per node by convention.
98    Invocation,
99
100    /// Where the VALUE this node carries was defined, when distinct
101    /// from the invocation site (e.g. `footer:` in `_metadata.yml` for
102    /// a `{{< meta footer >}}` resolution). Diagnostic-only — does not
103    /// affect the writer or attribution decisions in v1.
104    ValueSource,
105
106    /// Extension-defined or future role we haven't enumerated.
107    /// String is kebab-case, namespaced (`ext/<name>/<role>`).
108    ///
109    /// **`preimage_in` does not walk this role.** Future anchor roles
110    /// default to non-walked unless explicitly added to
111    /// [`SourceInfo::preimage_in`]'s `Generated` arm. Extensions adding
112    /// `Other("…")` should treat this as a feature: attribution data
113    /// attached via `Other` is not accidentally consulted by the writer's
114    /// byte-copying path. If a role *does* contribute to body-text
115    /// preimage in `target`, it must be explicitly enumerated in
116    /// `preimage_in`.
117    Other(String),
118}
119
120/// A single typed, role-labeled source-info pointer attached to a
121/// [`SourceInfo::Generated`] node.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct Anchor {
124    pub role: AnchorRole,
125    pub source_info: Arc<SourceInfo>,
126}
127
128/// A piece of a concatenated source
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct SourcePiece {
131    /// Source information for this piece
132    pub source_info: SourceInfo,
133    /// Where this piece starts in the concatenated string
134    pub offset_in_concat: usize,
135    /// Length of this piece
136    pub length: usize,
137}
138
139impl Default for SourceInfo {
140    fn default() -> Self {
141        SourceInfo::Original {
142            file_id: FileId(0),
143            start_offset: 0,
144            end_offset: 0,
145        }
146    }
147}
148
149impl SourceInfo {
150    /// Deprecated: use `SourceInfo::for_test()` in tests or an explicit
151    /// `Generated{by: <kind>}` in production. See provenance-contract.md.
152    ///
153    /// This inherent method shadows `Default::default()` so that callers
154    /// writing `SourceInfo::default()` see a deprecation error under
155    /// `deny(deprecated)`. The trait impl is retained (and called by this
156    /// method) so that `unwrap_or_default()` and `#[derive(Default)]` still
157    /// compile; those are caught by separate grep tooling.
158    #[deprecated(
159        since = "0.1.0",
160        note = "Use SourceInfo::for_test() in tests, or the appropriate Generated{by: <kind>} in production. See provenance-contract.md."
161    )]
162    #[doc(hidden)]
163    // Intentionally shadows `Default::default` (see the doc comment above): this
164    // deprecated inherent method is the provenance-contract tripwire, kept so
165    // `unwrap_or_default()`/`#[derive(Default)]` still compile while flagging
166    // direct calls. The name must match the trait method, so the lint is moot.
167    #[allow(clippy::should_implement_trait)]
168    pub fn default() -> Self {
169        <Self as Default>::default()
170    }
171
172    /// Create source info for a position in an original file (from offsets)
173    pub fn original(file_id: FileId, start_offset: usize, end_offset: usize) -> Self {
174        SourceInfo::Original {
175            file_id,
176            start_offset,
177            end_offset,
178        }
179    }
180
181    /// Create source info for a position in an original file (from Range)
182    ///
183    /// This is a compatibility helper for code that still uses Range.
184    /// The row and column information in the Range is ignored; only offsets are stored.
185    pub fn from_range(file_id: FileId, range: Range) -> Self {
186        SourceInfo::Original {
187            file_id,
188            start_offset: range.start.offset,
189            end_offset: range.end.offset,
190        }
191    }
192
193    /// Create source info for a substring extraction
194    pub fn substring(parent: SourceInfo, start: usize, end: usize) -> Self {
195        SourceInfo::Substring {
196            parent: Arc::new(parent),
197            start_offset: start,
198            end_offset: end,
199        }
200    }
201
202    /// Create source info for concatenated sources
203    pub fn concat(pieces: Vec<(SourceInfo, usize)>) -> Self {
204        let source_pieces: Vec<SourcePiece> = pieces
205            .into_iter()
206            .map(|(source_info, length)| SourcePiece {
207                source_info,
208                offset_in_concat: 0, // Will be calculated based on cumulative lengths
209                length,
210            })
211            .collect();
212
213        // Calculate cumulative offsets
214        let mut cumulative_offset = 0;
215        let pieces_with_offsets: Vec<SourcePiece> = source_pieces
216            .into_iter()
217            .map(|mut piece| {
218                piece.offset_in_concat = cumulative_offset;
219                cumulative_offset += piece.length;
220                piece
221            })
222            .collect();
223
224        SourceInfo::Concat {
225            pieces: pieces_with_offsets,
226        }
227    }
228
229    /// Create a [`SourceInfo::Generated`] with an empty anchor list.
230    ///
231    /// Use [`SourceInfo::append_anchor`] to add anchors after construction.
232    /// For Generated nodes that need to carry anchors at construction
233    /// time, build the variant directly: `SourceInfo::Generated { by, from }`.
234    pub fn generated(by: By) -> Self {
235        SourceInfo::Generated {
236            by,
237            from: SmallVec::new(),
238        }
239    }
240
241    /// Convenience for tests: produce a non-atomic `Generated` source_info
242    /// with `By::test_scaffold()` and no anchors. Use this in test code
243    /// where a constructor requires a `SourceInfo` but there's no real
244    /// provenance to record. Replaces the historical
245    /// `SourceInfo::default()` pattern in tests.
246    pub fn for_test() -> Self {
247        SourceInfo::Generated {
248            by: By::test_scaffold(),
249            from: SmallVec::new(),
250        }
251    }
252
253    /// If this is a [`SourceInfo::Generated`], return the first anchor whose
254    /// role is [`AnchorRole::Invocation`].
255    ///
256    /// Returns `None` otherwise (including for non-`Generated` variants).
257    /// By convention there is at most one `Invocation` anchor per node.
258    pub fn invocation_anchor(&self) -> Option<&Arc<SourceInfo>> {
259        match self {
260            SourceInfo::Generated { from, .. } => from
261                .iter()
262                .find(|a| matches!(a.role, AnchorRole::Invocation))
263                .map(|a| &a.source_info),
264            _ => None,
265        }
266    }
267
268    /// If this is a [`SourceInfo::Generated`], return the first anchor whose
269    /// role is [`AnchorRole::ValueSource`].
270    ///
271    /// Returns `None` otherwise. By convention there is at most one
272    /// `ValueSource` anchor per node.
273    pub fn value_source_anchor(&self) -> Option<&Arc<SourceInfo>> {
274        match self {
275            SourceInfo::Generated { from, .. } => from
276                .iter()
277                .find(|a| matches!(a.role, AnchorRole::ValueSource))
278                .map(|a| &a.source_info),
279            _ => None,
280        }
281    }
282
283    /// Iterate over every anchor in this [`SourceInfo::Generated`] whose role
284    /// equals `role`.
285    ///
286    /// Returns an empty iterator for non-`Generated` variants. Iteration order
287    /// is the append order.
288    pub fn anchors_with_role<'a>(
289        &'a self,
290        role: &'a AnchorRole,
291    ) -> Box<dyn Iterator<Item = &'a Arc<SourceInfo>> + 'a> {
292        match self {
293            SourceInfo::Generated { from, .. } => Box::new(
294                from.iter()
295                    .filter(move |a| &a.role == role)
296                    .map(|a| &a.source_info),
297            ),
298            _ => Box::new(std::iter::empty()),
299        }
300    }
301
302    /// Append `(role, source_info)` to this [`SourceInfo::Generated`]'s
303    /// anchor list.
304    ///
305    /// Panics if `self` is not [`SourceInfo::Generated`]. By convention there
306    /// is at most one anchor per known role; appending a second anchor with
307    /// the same role does not replace the first — accessors that find by
308    /// role return the earliest match.
309    pub fn append_anchor(&mut self, role: AnchorRole, source_info: Arc<SourceInfo>) {
310        match self {
311            SourceInfo::Generated { from, .. } => {
312                from.push(Anchor { role, source_info });
313            }
314            _ => panic!("append_anchor called on non-Generated SourceInfo"),
315        }
316    }
317
318    /// Combine two SourceInfo objects representing adjacent text
319    ///
320    /// This creates a Concat mapping that preserves both sources.
321    /// The resulting SourceInfo spans from the start of self to the end of other.
322    pub fn combine(&self, other: &SourceInfo) -> Self {
323        let self_length = self.length();
324        let other_length = other.length();
325
326        SourceInfo::concat(vec![
327            (self.clone(), self_length),
328            (other.clone(), other_length),
329        ])
330    }
331
332    /// Get the length (in bytes) represented by this SourceInfo
333    pub fn length(&self) -> usize {
334        match self {
335            SourceInfo::Original {
336                start_offset,
337                end_offset,
338                ..
339            } => end_offset - start_offset,
340            SourceInfo::Substring {
341                start_offset,
342                end_offset,
343                ..
344            } => end_offset - start_offset,
345            SourceInfo::Concat { pieces } => pieces.iter().map(|p| p.length).sum(),
346            SourceInfo::Generated { .. } => 0,
347        }
348    }
349
350    /// Get the start offset for this SourceInfo
351    ///
352    /// For Original and Substring, returns the start_offset field.
353    /// For Concat, returns 0 (the concat represents a new text starting at 0).
354    /// For Generated, returns 0.
355    pub fn start_offset(&self) -> usize {
356        match self {
357            SourceInfo::Original { start_offset, .. } => *start_offset,
358            SourceInfo::Substring { start_offset, .. } => *start_offset,
359            SourceInfo::Concat { .. } => 0,
360            SourceInfo::Generated { .. } => 0,
361        }
362    }
363
364    /// Get the end offset for this SourceInfo
365    ///
366    /// For Original and Substring, returns the end_offset field.
367    /// For Concat, returns the total length.
368    /// For Generated, returns 0.
369    pub fn end_offset(&self) -> usize {
370        match self {
371            SourceInfo::Original { end_offset, .. } => *end_offset,
372            SourceInfo::Substring { end_offset, .. } => *end_offset,
373            SourceInfo::Concat { .. } => self.length(),
374            SourceInfo::Generated { .. } => 0,
375        }
376    }
377
378    /// Chain-resolve to `(file_id, start_offset, end_offset)` in the
379    /// root source file.
380    ///
381    /// Returns `None` for `Concat` — Concat doesn't map cleanly to a
382    /// single contiguous byte range. For `Generated`, delegates to the
383    /// first `Invocation` anchor and recurses (`None` when no
384    /// `Invocation` anchor is present). The attribution v1 sidecar
385    /// relies on this contract; project-scoped (v2) features that need
386    /// the full chain resolver should use `map_offset` against a
387    /// `SourceContext` instead.
388    pub fn resolve_byte_range(&self) -> Option<(usize, usize, usize)> {
389        match self {
390            SourceInfo::Original {
391                file_id,
392                start_offset,
393                end_offset,
394            } => Some((file_id.0, *start_offset, *end_offset)),
395            SourceInfo::Substring {
396                parent,
397                start_offset,
398                end_offset,
399            } => {
400                let (fid, parent_start, _) = parent.resolve_byte_range()?;
401                Some((fid, parent_start + start_offset, parent_start + end_offset))
402            }
403            SourceInfo::Concat { .. } => None,
404            SourceInfo::Generated { .. } => self
405                .invocation_anchor()
406                .and_then(|si| si.resolve_byte_range()),
407        }
408    }
409
410    /// Byte range in `target` that this `SourceInfo`'s preimage covers, if any.
411    ///
412    /// A `Some(hull)` licenses **locating** a position in `target` — it does
413    /// not license **copying** bytes from it. For an `Original` or a
414    /// `Substring` chain that bottoms out in one, the hull happens to be
415    /// byte-identical to this node's content, so locating and copying
416    /// coincide. For a `Concat`, the hull is an **offset claim only**: a
417    /// piece's source run and its content run can have equal length and
418    /// different bytes (a 1→1 fold, e.g. a decoded escape that happens to
419    /// decode to the same length it was encoded in) — no length or
420    /// contiguity check can detect this, and `SourceInfo` carries no
421    /// verbatim/replacement tag once constructed. A caller that needs to
422    /// copy bytes (the writer's "can I Verbatim-copy for this node"
423    /// decision) needs byte-identity, which this function cannot supply for
424    /// a `Concat`; it can only supply it for `Original`/`Substring`.
425    ///
426    /// Semantics by variant:
427    /// - `Original` → `Some(start..end)` iff the file matches `target`, else `None`.
428    /// - `Substring` → if the parent is a `Concat`, `None` — see above; the
429    ///   affine composition `parent_range.start + offset` is only valid when
430    ///   the parent is byte-identical to its content, which a `Concat`
431    ///   parent is not. Otherwise, recurse the parent; offsets compose
432    ///   additively.
433    /// - `Concat` → every piece must resolve into `target` AND the resolved
434    ///   ranges must be byte-contiguous (no gaps, no overlaps). A gappy Concat
435    ///   returns `None`. The resulting hull is an offset claim, not a
436    ///   byte-identity claim (see above).
437    /// - `Generated` → walk the `Invocation` anchor only via
438    ///   [`invocation_anchor`](Self::invocation_anchor). **No other anchor
439    ///   role is consulted** — not `ValueSource` (Plan 9), not future
440    ///   `Dispatch` (Plan 10), not `AnchorRole::Other`. See the
441    ///   role-asymmetry section below.
442    ///
443    /// # Role asymmetry
444    ///
445    /// `preimage_in` only walks `AnchorRole::Invocation`. This is load-bearing:
446    /// copying bytes from a `ValueSource` source range would emit raw YAML
447    /// metadata (or whatever the value lived in) into the body — a hard
448    /// correctness bug. The same applies to `Dispatch` (which points at Lua
449    /// source) and to any extension-defined `Other` role.
450    ///
451    /// **Future anchor roles default to non-walked.** Extensions introducing
452    /// `AnchorRole::Other("…")` should treat this as a feature: their
453    /// attribution metadata is not accidentally consulted by the writer's
454    /// byte-copying path. If a role *does* contribute to body-text preimage,
455    /// it must be explicitly added to this function's `Generated` arm.
456    pub fn preimage_in(&self, target: FileId) -> Option<std::ops::Range<usize>> {
457        match self {
458            SourceInfo::Original {
459                file_id,
460                start_offset,
461                end_offset,
462            } if *file_id == target => Some(*start_offset..*end_offset),
463            SourceInfo::Original { .. } => None,
464            SourceInfo::Substring { parent, .. }
465                if matches!(**parent, SourceInfo::Concat { .. }) =>
466            {
467                // A Concat parent is not byte-identical to its content (a
468                // piece's source run and content run can differ in length
469                // and bytes — the 1→1 fold), so composing this Substring's
470                // offsets affinely over the parent's hull would produce a
471                // wrong-but-plausible-looking range. Refuse instead: this
472                // matches resolve_byte_range's Concat arm, which also
473                // returns None rather than guess.
474                None
475            }
476            SourceInfo::Substring {
477                parent,
478                start_offset,
479                end_offset,
480            } => {
481                let parent_range = parent.preimage_in(target)?;
482                Some(parent_range.start + start_offset..parent_range.start + end_offset)
483            }
484            SourceInfo::Concat { pieces } => {
485                let ranges: Vec<std::ops::Range<usize>> = pieces
486                    .iter()
487                    .map(|p| p.source_info.preimage_in(target))
488                    .collect::<Option<Vec<_>>>()?;
489                if ranges.is_empty() {
490                    return None;
491                }
492                if ranges.windows(2).all(|w| w[0].end == w[1].start) {
493                    let first = ranges.first().unwrap().start;
494                    let last = ranges.last().unwrap().end;
495                    Some(first..last)
496                } else {
497                    None
498                }
499            }
500            SourceInfo::Generated { .. } => self
501                .invocation_anchor()
502                .and_then(|si| si.preimage_in(target)),
503        }
504    }
505
506    /// Remap every `FileId` referenced by this `SourceInfo` (including those
507    /// inside `Substring` parents and `Concat` pieces) using the provided
508    /// mapping function.
509    ///
510    /// Used when merging ASTs that were parsed against different files into a
511    /// single `ASTContext` with a shared filename table — callers shift each
512    /// AST's `FileId`s to their slot in the merged table before combining.
513    pub fn remap_file_ids<F>(&mut self, map: &F)
514    where
515        F: Fn(FileId) -> FileId,
516    {
517        match self {
518            SourceInfo::Original { file_id, .. } => {
519                *file_id = map(*file_id);
520            }
521            SourceInfo::Substring { parent, .. } => {
522                // Arc::make_mut clones if there are other references.
523                let parent = Arc::make_mut(parent);
524                parent.remap_file_ids(map);
525            }
526            SourceInfo::Concat { pieces } => {
527                for piece in pieces {
528                    piece.source_info.remap_file_ids(map);
529                }
530            }
531            SourceInfo::Generated { from, .. } => {
532                for anchor in from {
533                    // Arc::make_mut clones if there are other references.
534                    let inner = Arc::make_mut(&mut anchor.source_info);
535                    inner.remap_file_ids(map);
536                }
537            }
538        }
539    }
540
541    /// First `FileId` reachable from this `SourceInfo`'s root.
542    ///
543    /// - `Original` → `Some(file_id)`.
544    /// - `Substring` → recurse parent.
545    /// - `Concat` → `pieces.iter().find_map(|p| p.source_info.root_file_id())`
546    ///   (`find_map` semantics — skips Generated holes and empty pieces).
547    /// - `Generated` → `invocation_anchor().and_then(|si| si.root_file_id())`;
548    ///   `None` when no `Invocation` anchor is present.
549    pub fn root_file_id(&self) -> Option<FileId> {
550        match self {
551            SourceInfo::Original { file_id, .. } => Some(*file_id),
552            SourceInfo::Substring { parent, .. } => parent.root_file_id(),
553            SourceInfo::Concat { pieces } => {
554                pieces.iter().find_map(|p| p.source_info.root_file_id())
555            }
556            SourceInfo::Generated { .. } => {
557                self.invocation_anchor().and_then(|si| si.root_file_id())
558            }
559        }
560    }
561
562    /// Insert every `FileId` reachable from this `SourceInfo` into `out`.
563    ///
564    /// Walks every `Original`, every `Substring` parent, every `Concat`
565    /// piece, and every `Generated` anchor (all roles — `Invocation`,
566    /// `ValueSource`, `Other`).
567    pub fn collect_file_ids(&self, out: &mut std::collections::HashSet<FileId>) {
568        match self {
569            SourceInfo::Original { file_id, .. } => {
570                out.insert(*file_id);
571            }
572            SourceInfo::Substring { parent, .. } => parent.collect_file_ids(out),
573            SourceInfo::Concat { pieces } => {
574                for piece in pieces {
575                    piece.source_info.collect_file_ids(out);
576                }
577            }
578            SourceInfo::Generated { from, .. } => {
579                for anchor in from {
580                    anchor.source_info.collect_file_ids(out);
581                }
582            }
583        }
584    }
585}
586
587impl By {
588    /// Producer kind for a node constructed by a Lua filter
589    /// (e.g. `pandoc.Str("decoration")` inside a filter callback).
590    ///
591    /// `filter_path` is the path the Lua engine reported via
592    /// `debug.getinfo(...).source` (with the leading "@" stripped);
593    /// `line` is the line number inside that file where the constructor
594    /// ran. Until Lua-file-registration lands (bd-36fr9), `(filter_path,
595    /// line)` lives in `by.data`; afterwards it migrates to a `Dispatch`
596    /// anchor and `by.data` shrinks to `{}`.
597    pub fn filter(filter_path: impl Into<String>, line: usize) -> Self {
598        Self {
599            kind: "filter".to_string(),
600            data: serde_json::json!({
601                "filter_path": filter_path.into(),
602                "line": line,
603            }),
604        }
605    }
606
607    /// Producer kind for the `SectionizeTransform`'s synthesized section
608    /// Divs. Children remain editable; the wrapper itself is structural.
609    pub fn sectionize() -> Self {
610        Self {
611            kind: "sectionize".to_string(),
612            data: serde_json::Value::Null,
613        }
614    }
615
616    /// Producer kind for React-constructed (user-typed) content reaching
617    /// the AST through the q2-preview client.
618    pub fn user_edit() -> Self {
619        Self {
620            kind: "user-edit".to_string(),
621            data: serde_json::Value::Null,
622        }
623    }
624
625    /// Producer kind for shortcode resolutions.
626    ///
627    /// **Invariant.** Every `Generated { by: shortcode(...), .. }` must
628    /// carry at least one `Invocation` anchor in `from` pointing at the
629    /// source token's byte range. Use only inside a `Generated` whose
630    /// anchor list is populated; constructing the bare shape with empty
631    /// `from` is rejected by Plan 6's audit-completion test and trips
632    /// Plan 7's writer `debug_assert!`.
633    pub fn shortcode(name: impl Into<String>) -> Self {
634        Self {
635            kind: "shortcode".to_string(),
636            data: serde_json::json!({ "name": name.into() }),
637        }
638    }
639
640    /// Producer kind for `IncludeStage`'s expansion wrapper. Note that
641    /// most include-related synthesized content keeps its `Original`
642    /// `source_info` (inherited from the include-line Paragraph) — this
643    /// kind is only used where a `Generated` is explicitly required.
644    pub fn include() -> Self {
645        Self {
646            kind: "include".to_string(),
647            data: serde_json::Value::Null,
648        }
649    }
650
651    /// Producer kind for the title-block stage's synthesized title `h1`.
652    pub fn title_block() -> Self {
653        Self {
654            kind: "title-block".to_string(),
655            data: serde_json::Value::Null,
656        }
657    }
658
659    /// Producer kind for the footnotes stage's container Div.
660    pub fn footnotes() -> Self {
661        Self {
662            kind: "footnotes".to_string(),
663            data: serde_json::Value::Null,
664        }
665    }
666
667    /// Producer kind for `RevealSlidesTransform`'s synthesized slide
668    /// structure — title-slide Div, section wrappers, speaker-notes Div,
669    /// and any other chrome built from the slide-level heading tree.
670    /// Non-atomic: the slide container is structural chrome; the content
671    /// inside (headings, paragraphs) retains its own source_info.
672    pub fn revealjs() -> Self {
673        Self {
674            kind: "revealjs".to_string(),
675            data: serde_json::Value::Null,
676        }
677    }
678
679    /// Producer kind for the appendix-structure stage's wrapper Div.
680    pub fn appendix() -> Self {
681        Self {
682            kind: "appendix".to_string(),
683            data: serde_json::Value::Null,
684        }
685    }
686
687    /// Producer kind for parser-side synthetic Spaces inserted by the
688    /// tree-sitter post-processing pass.
689    pub fn tree_sitter_postprocess() -> Self {
690        Self {
691            kind: "tree-sitter-postprocess".to_string(),
692            data: serde_json::Value::Null,
693        }
694    }
695
696    /// "We don't know" placeholder used by `json::read_completing_source_info`
697    /// when a node arrives without an `s:` field from outside the q2
698    /// source-tracking world (qmd-syntax-helper Pandoc subprocess, CLI
699    /// `--from json`, external filter binaries, Lua AST handoff).
700    ///
701    /// Non-atomic by design — nodes carrying `By::unknown()` remain
702    /// editable in the preview; user edits re-stamp them as `user_edit`
703    /// on save. See Plan 7f Phase 4's per-caller table for placement
704    /// guidance.
705    pub fn unknown() -> Self {
706        Self {
707            kind: "unknown".to_string(),
708            data: serde_json::Value::Null,
709        }
710    }
711
712    /// Producer kind for test scaffolding. Non-atomic; appears only in
713    /// test code where `source_info` is required by a constructor but
714    /// has no real provenance to record. Paired with
715    /// [`SourceInfo::for_test`].
716    pub fn test_scaffold() -> Self {
717        Self {
718            kind: "test-scaffold".to_string(),
719            data: serde_json::Value::Null,
720        }
721    }
722
723    /// Producer kind for citeproc-rendered content (citation Str
724    /// replacements, bibliography `Div`s, `#refs` wrappers). The bytes
725    /// come from CSL processing of bibliographic metadata, not from
726    /// user-written source.
727    ///
728    /// Atomic — citeproc output is generated content the user can't
729    /// edit through the preview; changes go through the CSL pipeline,
730    /// not through inline editing.
731    pub fn citeproc() -> Self {
732        Self {
733            kind: "citeproc".to_string(),
734            data: serde_json::Value::Null,
735        }
736    }
737
738    /// Producer kind for content synthesized from execution-engine
739    /// output (Jupyter cell stdout / stderr, rich-display MIME bundles,
740    /// kernel error tracebacks). The bytes come from kernel execution,
741    /// not from user-written source.
742    ///
743    /// Atomic — execution outputs are regenerated on every re-run;
744    /// editing them through the preview would be a UX bug.
745    pub fn jupyter_output() -> Self {
746        Self {
747            kind: "jupyter-output".to_string(),
748            data: serde_json::Value::Null,
749        }
750    }
751
752    /// Producer kind for callout-decoration synthesis:
753    /// default-title injection (`Note`, `Warning`, etc. when the user
754    /// omits a title and `appearance="default"`) and the
755    /// screen-reader-only type announcement span.
756    ///
757    /// Non-atomic — the wrapper Div is structural, and its children
758    /// (the user's actual callout body) remain editable through the
759    /// preview. The synthesized title text itself has no preimage but
760    /// regenerates from the callout type when the user changes it,
761    /// so atomicity at the wrapper level would be incorrect.
762    pub fn callout() -> Self {
763        Self {
764            kind: "callout".to_string(),
765            data: serde_json::Value::Null,
766        }
767    }
768
769    /// Empty-Map sentinel `ConfigValue` used during metadata merging
770    /// when no value is present. Non-atomic. The bytes don't exist —
771    /// the node is structural. See [`By::is_programmatic_sentinel`].
772    pub fn config_default() -> Self {
773        Self {
774            kind: "config-default".to_string(),
775            data: serde_json::Value::Null,
776        }
777    }
778
779    /// Programmatic construction of `ConfigValue` (e.g.
780    /// `ConfigValue::from_path`, intermediate maps created during
781    /// `insert_path`). No source bytes exist for these nodes.
782    /// See [`By::is_programmatic_sentinel`].
783    pub fn programmatic_config() -> Self {
784        Self {
785            kind: "programmatic-config".to_string(),
786            data: serde_json::Value::Null,
787        }
788    }
789
790    /// True for kinds whose source bytes don't exist — `config-default`,
791    /// `programmatic-config`, `unknown`. Used by code that needs to
792    /// distinguish "no real source" sentinels from a genuine
793    /// `Original{FileId(0), …}` pointing at a real document.
794    pub fn is_programmatic_sentinel(&self) -> bool {
795        matches!(
796            self.kind.as_str(),
797            "config-default" | "programmatic-config" | "unknown"
798        )
799    }
800
801    /// Escape-hatch constructor for any `kind` string — including built-in
802    /// names and extension-defined kinds (`ext/<extension>/<kind>`).
803    ///
804    /// Forgery (an extension calling `By::raw("shortcode", …)` without the
805    /// required `Invocation` anchor) is caught downstream by Plan 6's
806    /// audit-completion test and Plan 7's `debug_assert!`. The convention
807    /// for third-party kinds is `ext/<extension>/<kind>`.
808    pub fn raw(kind: impl Into<String>, data: serde_json::Value) -> Self {
809        Self {
810            kind: kind.into(),
811            data,
812        }
813    }
814
815    /// True if a `Generated { by: <self>, .. }` node should be treated
816    /// as atomic by the incremental writer.
817    ///
818    /// Atomic nodes are produced by the pipeline and represent content
819    /// the user shouldn't edit through React (filter constructions,
820    /// shortcode resolutions, synthesized title h1, tree-sitter-inserted
821    /// spaces). Atomicity is determined by `kind` alone — orthogonal to
822    /// anchor-presence.
823    ///
824    /// Extensions that contribute new `by.kind` values are not atomic by
825    /// default in v1.
826    pub fn is_atomic_kind(&self) -> bool {
827        matches!(
828            self.kind.as_str(),
829            "filter"
830                | "shortcode"
831                | "title-block"
832                | "tree-sitter-postprocess"
833                | "citeproc"
834                | "jupyter-output"
835        )
836    }
837
838    /// True if this `By`'s `kind` equals `kind`.
839    pub fn is_kind(&self, kind: &str) -> bool {
840        self.kind == kind
841    }
842
843    /// If `self.kind == "filter"`, return `(filter_path, line)`.
844    ///
845    /// Returns `None` for any other kind, or when the data payload is
846    /// malformed (missing or non-string `filter_path`, missing or
847    /// non-integer `line`).
848    pub fn as_filter(&self) -> Option<(&str, usize)> {
849        if self.kind != "filter" {
850            return None;
851        }
852        let path = self.data.get("filter_path")?.as_str()?;
853        let line = self.data.get("line")?.as_u64()? as usize;
854        Some((path, line))
855    }
856}
857
858impl Anchor {
859    /// Construct an [`AnchorRole::Invocation`] anchor.
860    pub fn invocation(source_info: Arc<SourceInfo>) -> Self {
861        Self {
862            role: AnchorRole::Invocation,
863            source_info,
864        }
865    }
866
867    /// Construct an [`AnchorRole::ValueSource`] anchor.
868    pub fn value_source(source_info: Arc<SourceInfo>) -> Self {
869        Self {
870            role: AnchorRole::ValueSource,
871            source_info,
872        }
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use crate::types::{FileId, Location, Range};
880
881    #[test]
882    fn test_original_source_info() {
883        let file_id = FileId(0);
884        let range = Range {
885            start: Location {
886                offset: 0,
887                row: 0,
888                column: 0,
889            },
890            end: Location {
891                offset: 10,
892                row: 0,
893                column: 10,
894            },
895        };
896
897        let info = SourceInfo::from_range(file_id, range.clone());
898
899        assert_eq!(info.start_offset(), 0);
900        assert_eq!(info.end_offset(), 10);
901        assert_eq!(info.length(), 10);
902        match info {
903            SourceInfo::Original {
904                file_id: mapped_id, ..
905            } => {
906                assert_eq!(mapped_id, file_id);
907            }
908            _ => panic!("Expected Original mapping"),
909        }
910    }
911
912    #[test]
913    fn test_remap_file_ids_original() {
914        let mut info = SourceInfo::original(FileId(0), 0, 10);
915        info.remap_file_ids(&|id| FileId(id.0 + 1));
916        match info {
917            SourceInfo::Original { file_id, .. } => assert_eq!(file_id, FileId(1)),
918            _ => panic!("Expected Original"),
919        }
920    }
921
922    #[test]
923    fn test_remap_file_ids_substring() {
924        let parent = SourceInfo::original(FileId(0), 0, 100);
925        let mut info = SourceInfo::substring(parent, 5, 20);
926        info.remap_file_ids(&|id| FileId(id.0 + 7));
927        match info {
928            SourceInfo::Substring { parent, .. } => match &*parent {
929                SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(7)),
930                _ => panic!("Expected Original parent"),
931            },
932            _ => panic!("Expected Substring"),
933        }
934    }
935
936    #[test]
937    fn test_remap_file_ids_concat() {
938        let a = SourceInfo::original(FileId(0), 0, 5);
939        let b = SourceInfo::original(FileId(3), 5, 10);
940        let mut info = SourceInfo::concat(vec![(a, 5), (b, 5)]);
941        info.remap_file_ids(&|id| FileId(id.0 + 10));
942        match info {
943            SourceInfo::Concat { pieces } => {
944                match &pieces[0].source_info {
945                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(10)),
946                    _ => panic!("Expected Original"),
947                }
948                match &pieces[1].source_info {
949                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(13)),
950                    _ => panic!("Expected Original"),
951                }
952            }
953            _ => panic!("Expected Concat"),
954        }
955    }
956
957    #[test]
958    fn test_remap_file_ids_generated_empty_from_is_noop() {
959        let mut info = SourceInfo::generated(By::filter("foo.lua", 42));
960        info.remap_file_ids(&|_| FileId(99));
961        match info {
962            SourceInfo::Generated { by, from } => {
963                assert!(from.is_empty());
964                let (path, line) = by.as_filter().unwrap();
965                assert_eq!(path, "foo.lua");
966                assert_eq!(line, 42);
967            }
968            _ => panic!("Expected Generated"),
969        }
970    }
971
972    // -------------------------------------------------------------------------
973    // Plan 4 — By / Anchor / Generated coverage
974    // -------------------------------------------------------------------------
975
976    #[test]
977    fn test_by_filter_builder() {
978        let by = By::filter("a.lua", 7);
979        assert_eq!(by.kind, "filter");
980        assert_eq!(by.as_filter(), Some(("a.lua", 7)));
981    }
982
983    #[test]
984    fn test_by_sectionize_builder() {
985        let by = By::sectionize();
986        assert_eq!(by.kind, "sectionize");
987        assert!(by.data.is_null());
988    }
989
990    #[test]
991    fn test_by_user_edit_builder() {
992        assert_eq!(By::user_edit().kind, "user-edit");
993    }
994
995    #[test]
996    fn test_by_shortcode_builder_records_name() {
997        let by = By::shortcode("meta");
998        assert_eq!(by.kind, "shortcode");
999        assert_eq!(by.data.get("name").and_then(|v| v.as_str()), Some("meta"));
1000    }
1001
1002    #[test]
1003    fn test_by_include_title_footnotes_appendix_tree_sitter_builders() {
1004        assert_eq!(By::include().kind, "include");
1005        assert_eq!(By::title_block().kind, "title-block");
1006        assert_eq!(By::footnotes().kind, "footnotes");
1007        assert_eq!(By::appendix().kind, "appendix");
1008        assert_eq!(
1009            By::tree_sitter_postprocess().kind,
1010            "tree-sitter-postprocess"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_by_raw_builder_accepts_any_kind() {
1016        let by = By::raw("ext/my-plugin/foo", serde_json::json!({"k": 1}));
1017        assert_eq!(by.kind, "ext/my-plugin/foo");
1018        assert_eq!(by.data.get("k").and_then(|v| v.as_u64()), Some(1));
1019    }
1020
1021    #[test]
1022    fn test_by_is_atomic_kind() {
1023        assert!(By::filter("x.lua", 1).is_atomic_kind());
1024        assert!(By::shortcode("meta").is_atomic_kind());
1025        assert!(By::title_block().is_atomic_kind());
1026        assert!(By::tree_sitter_postprocess().is_atomic_kind());
1027        assert!(By::citeproc().is_atomic_kind());
1028        assert!(By::jupyter_output().is_atomic_kind());
1029
1030        assert!(!By::callout().is_atomic_kind());
1031
1032        assert!(!By::sectionize().is_atomic_kind());
1033        assert!(!By::user_edit().is_atomic_kind());
1034        assert!(!By::include().is_atomic_kind());
1035        assert!(!By::footnotes().is_atomic_kind());
1036        assert!(!By::appendix().is_atomic_kind());
1037        assert!(!By::unknown().is_atomic_kind());
1038        assert!(!By::test_scaffold().is_atomic_kind());
1039        assert!(!By::config_default().is_atomic_kind());
1040        assert!(!By::programmatic_config().is_atomic_kind());
1041        assert!(!By::raw("ext/anywhere/foo", serde_json::Value::Null).is_atomic_kind());
1042    }
1043
1044    #[test]
1045    fn test_by_unknown_constructor() {
1046        let by = By::unknown();
1047        assert_eq!(by.kind, "unknown");
1048        assert!(by.data.is_null());
1049        // Non-atomic — nodes carrying By::unknown() remain editable; the
1050        // strict reader rejects missing `s:`, the completing reader stamps
1051        // them with this kind only at the explicit call site.
1052        assert!(!by.is_atomic_kind());
1053    }
1054
1055    #[test]
1056    fn test_by_test_scaffold_constructor() {
1057        let by = By::test_scaffold();
1058        assert_eq!(by.kind, "test-scaffold");
1059        assert!(by.data.is_null());
1060        assert!(!by.is_atomic_kind());
1061        // Not a "no real source" sentinel — it's test scaffolding.
1062        assert!(!by.is_programmatic_sentinel());
1063    }
1064
1065    #[test]
1066    fn test_by_config_default_constructor() {
1067        let by = By::config_default();
1068        assert_eq!(by.kind, "config-default");
1069        assert!(by.data.is_null());
1070        assert!(!by.is_atomic_kind());
1071    }
1072
1073    #[test]
1074    fn test_by_programmatic_config_constructor() {
1075        let by = By::programmatic_config();
1076        assert_eq!(by.kind, "programmatic-config");
1077        assert!(by.data.is_null());
1078        assert!(!by.is_atomic_kind());
1079    }
1080
1081    #[test]
1082    fn test_by_citeproc_constructor() {
1083        let by = By::citeproc();
1084        assert_eq!(by.kind, "citeproc");
1085        assert!(by.data.is_null());
1086        // Atomic — citeproc output is non-editable in the preview.
1087        assert!(by.is_atomic_kind());
1088        // Not a "no real source" sentinel; the bytes come from CSL output.
1089        assert!(!by.is_programmatic_sentinel());
1090    }
1091
1092    #[test]
1093    fn test_by_jupyter_output_constructor() {
1094        let by = By::jupyter_output();
1095        assert_eq!(by.kind, "jupyter-output");
1096        assert!(by.data.is_null());
1097        // Atomic — execution outputs regenerate on every re-run.
1098        assert!(by.is_atomic_kind());
1099        assert!(!by.is_programmatic_sentinel());
1100    }
1101
1102    #[test]
1103    fn test_by_callout_constructor() {
1104        let by = By::callout();
1105        assert_eq!(by.kind, "callout");
1106        assert!(by.data.is_null());
1107        // Non-atomic — callout wrapper is structural; children stay editable.
1108        assert!(!by.is_atomic_kind());
1109        assert!(!by.is_programmatic_sentinel());
1110    }
1111
1112    #[test]
1113    fn test_by_is_programmatic_sentinel() {
1114        assert!(By::config_default().is_programmatic_sentinel());
1115        assert!(By::programmatic_config().is_programmatic_sentinel());
1116        assert!(By::unknown().is_programmatic_sentinel());
1117
1118        assert!(!By::user_edit().is_programmatic_sentinel());
1119        assert!(!By::filter("x.lua", 1).is_programmatic_sentinel());
1120        assert!(!By::shortcode("meta").is_programmatic_sentinel());
1121        assert!(!By::test_scaffold().is_programmatic_sentinel());
1122        assert!(!By::sectionize().is_programmatic_sentinel());
1123    }
1124
1125    #[test]
1126    fn test_source_info_for_test() {
1127        let si = SourceInfo::for_test();
1128        match si {
1129            SourceInfo::Generated { by, from } => {
1130                assert_eq!(by.kind, "test-scaffold");
1131                assert!(from.is_empty());
1132            }
1133            _ => panic!("for_test() must return Generated"),
1134        }
1135    }
1136
1137    #[test]
1138    fn test_by_is_kind() {
1139        let by = By::shortcode("meta");
1140        assert!(by.is_kind("shortcode"));
1141        assert!(!by.is_kind("filter"));
1142    }
1143
1144    #[test]
1145    fn test_by_as_filter_rejects_non_filter() {
1146        assert!(By::sectionize().as_filter().is_none());
1147        // Malformed filter (missing line) → None.
1148        let by = By {
1149            kind: "filter".to_string(),
1150            data: serde_json::json!({ "filter_path": "x.lua" }),
1151        };
1152        assert!(by.as_filter().is_none());
1153    }
1154
1155    #[test]
1156    fn test_anchor_invocation_value_source_constructors() {
1157        let original = Arc::new(SourceInfo::original(FileId(1), 0, 5));
1158        let inv = Anchor::invocation(Arc::clone(&original));
1159        let vs = Anchor::value_source(Arc::clone(&original));
1160        assert!(matches!(inv.role, AnchorRole::Invocation));
1161        assert!(matches!(vs.role, AnchorRole::ValueSource));
1162    }
1163
1164    #[test]
1165    fn test_by_json_round_trip() {
1166        let by = By::shortcode("meta");
1167        let json = serde_json::to_string(&by).unwrap();
1168        let back: By = serde_json::from_str(&json).unwrap();
1169        assert_eq!(by, back);
1170    }
1171
1172    #[test]
1173    fn test_anchor_json_round_trip() {
1174        let anchor = Anchor::invocation(Arc::new(SourceInfo::original(FileId(2), 10, 20)));
1175        let json = serde_json::to_string(&anchor).unwrap();
1176        let back: Anchor = serde_json::from_str(&json).unwrap();
1177        assert_eq!(anchor, back);
1178    }
1179
1180    #[test]
1181    fn test_generated_json_round_trip_empty_from() {
1182        let info = SourceInfo::generated(By::sectionize());
1183        let json = serde_json::to_string(&info).unwrap();
1184        let back: SourceInfo = serde_json::from_str(&json).unwrap();
1185        assert_eq!(info, back);
1186    }
1187
1188    #[test]
1189    fn test_generated_json_round_trip_with_invocation_anchor() {
1190        let mut info = SourceInfo::generated(By::shortcode("meta"));
1191        info.append_anchor(
1192            AnchorRole::Invocation,
1193            Arc::new(SourceInfo::original(FileId(5), 100, 110)),
1194        );
1195        let json = serde_json::to_string(&info).unwrap();
1196        let back: SourceInfo = serde_json::from_str(&json).unwrap();
1197        assert_eq!(info, back);
1198    }
1199
1200    #[test]
1201    fn test_generated_json_round_trip_multi_anchor() {
1202        let mut info = SourceInfo::generated(By::shortcode("meta"));
1203        info.append_anchor(
1204            AnchorRole::Invocation,
1205            Arc::new(SourceInfo::original(FileId(5), 100, 110)),
1206        );
1207        info.append_anchor(
1208            AnchorRole::ValueSource,
1209            Arc::new(SourceInfo::original(FileId(7), 200, 220)),
1210        );
1211        let json = serde_json::to_string(&info).unwrap();
1212        let back: SourceInfo = serde_json::from_str(&json).unwrap();
1213        assert_eq!(info, back);
1214    }
1215
1216    #[test]
1217    fn test_generated_length_start_end_are_zero() {
1218        let info = SourceInfo::generated(By::sectionize());
1219        assert_eq!(info.length(), 0);
1220        assert_eq!(info.start_offset(), 0);
1221        assert_eq!(info.end_offset(), 0);
1222    }
1223
1224    #[test]
1225    fn test_generated_resolve_byte_range_recurses_through_substring() {
1226        let parent = SourceInfo::original(FileId(42), 100, 200);
1227        let sub = SourceInfo::substring(parent, 10, 20);
1228        let mut info = SourceInfo::generated(By::shortcode("meta"));
1229        info.append_anchor(AnchorRole::Invocation, Arc::new(sub));
1230        assert_eq!(info.resolve_byte_range(), Some((42, 110, 120)));
1231    }
1232
1233    #[test]
1234    fn test_generated_resolve_byte_range_empty_returns_none() {
1235        let info = SourceInfo::generated(By::sectionize());
1236        assert!(info.resolve_byte_range().is_none());
1237    }
1238
1239    #[test]
1240    fn test_generated_resolve_byte_range_value_source_only_returns_none() {
1241        let mut info = SourceInfo::generated(By::shortcode("meta"));
1242        info.append_anchor(
1243            AnchorRole::ValueSource,
1244            Arc::new(SourceInfo::original(FileId(5), 100, 110)),
1245        );
1246        assert!(info.resolve_byte_range().is_none());
1247    }
1248
1249    #[test]
1250    fn test_generated_remap_file_ids_walks_anchors() {
1251        let mut info = SourceInfo::generated(By::shortcode("meta"));
1252        info.append_anchor(
1253            AnchorRole::Invocation,
1254            Arc::new(SourceInfo::original(FileId(0), 0, 5)),
1255        );
1256        info.append_anchor(
1257            AnchorRole::ValueSource,
1258            Arc::new(SourceInfo::original(FileId(3), 10, 20)),
1259        );
1260        info.remap_file_ids(&|id| FileId(id.0 + 10));
1261        match &info {
1262            SourceInfo::Generated { from, .. } => {
1263                assert_eq!(from.len(), 2);
1264                match from[0].source_info.as_ref() {
1265                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(10)),
1266                    _ => panic!("Expected Original anchor 0"),
1267                }
1268                match from[1].source_info.as_ref() {
1269                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(13)),
1270                    _ => panic!("Expected Original anchor 1"),
1271                }
1272            }
1273            _ => panic!("Expected Generated"),
1274        }
1275    }
1276
1277    #[test]
1278    fn test_root_file_id_per_variant() {
1279        // Original
1280        let original = SourceInfo::original(FileId(7), 0, 5);
1281        assert_eq!(original.root_file_id(), Some(FileId(7)));
1282
1283        // Substring → recurse parent
1284        let sub = SourceInfo::substring(original.clone(), 0, 5);
1285        assert_eq!(sub.root_file_id(), Some(FileId(7)));
1286
1287        // Concat find_map skips Generated holes
1288        let empty_gen = SourceInfo::generated(By::sectionize());
1289        let real = SourceInfo::original(FileId(42), 0, 5);
1290        let concat = SourceInfo::concat(vec![(empty_gen, 0), (real, 5)]);
1291        assert_eq!(concat.root_file_id(), Some(FileId(42)));
1292
1293        // Generated with Invocation
1294        let mut g = SourceInfo::generated(By::shortcode("meta"));
1295        g.append_anchor(
1296            AnchorRole::Invocation,
1297            Arc::new(SourceInfo::original(FileId(9), 0, 1)),
1298        );
1299        assert_eq!(g.root_file_id(), Some(FileId(9)));
1300
1301        // Generated with no Invocation
1302        let mut g2 = SourceInfo::generated(By::shortcode("meta"));
1303        g2.append_anchor(
1304            AnchorRole::ValueSource,
1305            Arc::new(SourceInfo::original(FileId(9), 0, 1)),
1306        );
1307        assert_eq!(g2.root_file_id(), None);
1308
1309        // Generated empty
1310        let g3 = SourceInfo::generated(By::sectionize());
1311        assert_eq!(g3.root_file_id(), None);
1312    }
1313
1314    #[test]
1315    fn test_collect_file_ids_walks_every_anchor_role() {
1316        let mut info = SourceInfo::generated(By::shortcode("meta"));
1317        info.append_anchor(
1318            AnchorRole::Invocation,
1319            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1320        );
1321        info.append_anchor(
1322            AnchorRole::ValueSource,
1323            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1324        );
1325        info.append_anchor(
1326            AnchorRole::Other("dispatch".to_string()),
1327            Arc::new(SourceInfo::original(FileId(3), 0, 1)),
1328        );
1329        let mut out = std::collections::HashSet::new();
1330        info.collect_file_ids(&mut out);
1331        assert!(out.contains(&FileId(1)));
1332        assert!(out.contains(&FileId(2)));
1333        assert!(out.contains(&FileId(3)));
1334        assert_eq!(out.len(), 3);
1335    }
1336
1337    #[test]
1338    fn test_collect_file_ids_walks_concat_and_substring() {
1339        let inner = SourceInfo::original(FileId(5), 0, 100);
1340        let sub = SourceInfo::substring(inner, 10, 20);
1341        let other = SourceInfo::original(FileId(11), 0, 5);
1342        let concat = SourceInfo::concat(vec![(sub, 10), (other, 5)]);
1343        let mut out = std::collections::HashSet::new();
1344        concat.collect_file_ids(&mut out);
1345        assert!(out.contains(&FileId(5)));
1346        assert!(out.contains(&FileId(11)));
1347        assert_eq!(out.len(), 2);
1348    }
1349
1350    #[test]
1351    fn test_invocation_anchor_accessor() {
1352        let mut info = SourceInfo::generated(By::shortcode("meta"));
1353        assert!(info.invocation_anchor().is_none());
1354        info.append_anchor(
1355            AnchorRole::ValueSource,
1356            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1357        );
1358        assert!(info.invocation_anchor().is_none());
1359        info.append_anchor(
1360            AnchorRole::Invocation,
1361            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1362        );
1363        assert!(info.invocation_anchor().is_some());
1364        // Non-Generated returns None.
1365        assert!(
1366            SourceInfo::original(FileId(0), 0, 0)
1367                .invocation_anchor()
1368                .is_none()
1369        );
1370    }
1371
1372    #[test]
1373    fn test_value_source_anchor_accessor() {
1374        let mut info = SourceInfo::generated(By::shortcode("meta"));
1375        assert!(info.value_source_anchor().is_none());
1376        info.append_anchor(
1377            AnchorRole::Invocation,
1378            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1379        );
1380        assert!(info.value_source_anchor().is_none());
1381        info.append_anchor(
1382            AnchorRole::ValueSource,
1383            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1384        );
1385        assert!(info.value_source_anchor().is_some());
1386    }
1387
1388    #[test]
1389    fn test_anchors_with_role() {
1390        let mut info = SourceInfo::generated(By::shortcode("meta"));
1391        info.append_anchor(
1392            AnchorRole::Invocation,
1393            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1394        );
1395        info.append_anchor(
1396            AnchorRole::ValueSource,
1397            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1398        );
1399        info.append_anchor(
1400            AnchorRole::Other("ext/foo".to_string()),
1401            Arc::new(SourceInfo::original(FileId(3), 0, 1)),
1402        );
1403        assert_eq!(info.anchors_with_role(&AnchorRole::Invocation).count(), 1);
1404        assert_eq!(info.anchors_with_role(&AnchorRole::ValueSource).count(), 1);
1405        assert_eq!(
1406            info.anchors_with_role(&AnchorRole::Other("ext/foo".to_string()))
1407                .count(),
1408            1
1409        );
1410        assert_eq!(
1411            info.anchors_with_role(&AnchorRole::Other("missing".to_string()))
1412                .count(),
1413            0
1414        );
1415    }
1416
1417    #[test]
1418    fn test_append_anchor_preserves_order() {
1419        let mut info = SourceInfo::generated(By::shortcode("meta"));
1420        info.append_anchor(
1421            AnchorRole::Invocation,
1422            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1423        );
1424        info.append_anchor(
1425            AnchorRole::ValueSource,
1426            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1427        );
1428        match info {
1429            SourceInfo::Generated { from, .. } => {
1430                assert_eq!(from.len(), 2);
1431                assert!(matches!(from[0].role, AnchorRole::Invocation));
1432                assert!(matches!(from[1].role, AnchorRole::ValueSource));
1433            }
1434            _ => panic!("Expected Generated"),
1435        }
1436    }
1437
1438    #[test]
1439    fn test_combine_with_generated_is_zero_length_piece() {
1440        let original = SourceInfo::original(FileId(0), 10, 20);
1441        let generated = SourceInfo::generated(By::sectionize());
1442        let combined = original.combine(&generated);
1443        match &combined {
1444            SourceInfo::Concat { pieces } => {
1445                assert_eq!(pieces.len(), 2);
1446                assert_eq!(pieces[1].length, 0);
1447            }
1448            _ => panic!("Expected Concat"),
1449        }
1450        // Length of the combined value equals only the Original side.
1451        assert_eq!(combined.length(), 10);
1452    }
1453
1454    #[test]
1455    fn test_source_info_serialization() {
1456        let file_id = FileId(0);
1457        let range = Range {
1458            start: Location {
1459                offset: 0,
1460                row: 0,
1461                column: 0,
1462            },
1463            end: Location {
1464                offset: 10,
1465                row: 0,
1466                column: 10,
1467            },
1468        };
1469
1470        let info = SourceInfo::from_range(file_id, range);
1471        let json = serde_json::to_string(&info).unwrap();
1472        let deserialized: SourceInfo = serde_json::from_str(&json).unwrap();
1473
1474        assert_eq!(info, deserialized);
1475    }
1476
1477    #[test]
1478    fn test_substring_source_info() {
1479        let file_id = FileId(0);
1480        let parent_range = Range {
1481            start: Location {
1482                offset: 0,
1483                row: 0,
1484                column: 0,
1485            },
1486            end: Location {
1487                offset: 100,
1488                row: 0,
1489                column: 100,
1490            },
1491        };
1492        let parent = SourceInfo::from_range(file_id, parent_range);
1493
1494        let substring = SourceInfo::substring(parent, 10, 20);
1495
1496        assert_eq!(substring.start_offset(), 10);
1497        assert_eq!(substring.end_offset(), 20);
1498        assert_eq!(substring.length(), 10);
1499
1500        match substring {
1501            SourceInfo::Substring {
1502                start_offset,
1503                end_offset,
1504                ..
1505            } => {
1506                assert_eq!(start_offset, 10);
1507                assert_eq!(end_offset, 20);
1508            }
1509            _ => panic!("Expected Substring mapping"),
1510        }
1511    }
1512
1513    #[test]
1514    fn test_concat_source_info() {
1515        let file_id1 = FileId(0);
1516        let file_id2 = FileId(1);
1517
1518        let info1 = SourceInfo::from_range(
1519            file_id1,
1520            Range {
1521                start: Location {
1522                    offset: 0,
1523                    row: 0,
1524                    column: 0,
1525                },
1526                end: Location {
1527                    offset: 10,
1528                    row: 0,
1529                    column: 10,
1530                },
1531            },
1532        );
1533
1534        let info2 = SourceInfo::from_range(
1535            file_id2,
1536            Range {
1537                start: Location {
1538                    offset: 0,
1539                    row: 0,
1540                    column: 0,
1541                },
1542                end: Location {
1543                    offset: 15,
1544                    row: 0,
1545                    column: 15,
1546                },
1547            },
1548        );
1549
1550        let concat = SourceInfo::concat(vec![(info1, 10), (info2, 15)]);
1551
1552        assert_eq!(concat.start_offset(), 0);
1553        assert_eq!(concat.end_offset(), 25); // 10 + 15
1554        assert_eq!(concat.length(), 25);
1555
1556        match concat {
1557            SourceInfo::Concat { pieces } => {
1558                assert_eq!(pieces.len(), 2);
1559                assert_eq!(pieces[0].offset_in_concat, 0);
1560                assert_eq!(pieces[0].length, 10);
1561                assert_eq!(pieces[1].offset_in_concat, 10);
1562                assert_eq!(pieces[1].length, 15);
1563            }
1564            _ => panic!("Expected Concat mapping"),
1565        }
1566    }
1567
1568    #[test]
1569    fn test_combine_two_sources() {
1570        let file_id = FileId(0);
1571
1572        // Create two separate source info objects
1573        let info1 = SourceInfo::from_range(
1574            file_id,
1575            Range {
1576                start: Location {
1577                    offset: 0,
1578                    row: 0,
1579                    column: 0,
1580                },
1581                end: Location {
1582                    offset: 10,
1583                    row: 0,
1584                    column: 10,
1585                },
1586            },
1587        );
1588
1589        let info2 = SourceInfo::from_range(
1590            file_id,
1591            Range {
1592                start: Location {
1593                    offset: 15,
1594                    row: 0,
1595                    column: 15,
1596                },
1597                end: Location {
1598                    offset: 25,
1599                    row: 0,
1600                    column: 25,
1601                },
1602            },
1603        );
1604
1605        // Combine them
1606        let combined = info1.combine(&info2);
1607
1608        // Should create a Concat with total length = 10 + 10 = 20
1609        assert_eq!(combined.start_offset(), 0);
1610        assert_eq!(combined.end_offset(), 20);
1611        assert_eq!(combined.length(), 20);
1612
1613        match combined {
1614            SourceInfo::Concat { pieces } => {
1615                assert_eq!(pieces.len(), 2);
1616                assert_eq!(pieces[0].length, 10);
1617                assert_eq!(pieces[0].offset_in_concat, 0);
1618                assert_eq!(pieces[1].length, 10);
1619                assert_eq!(pieces[1].offset_in_concat, 10);
1620            }
1621            _ => panic!("Expected Concat mapping"),
1622        }
1623    }
1624
1625    #[test]
1626    fn test_combine_preserves_source_tracking() {
1627        // Combine sources from different files
1628        let file_id1 = FileId(5);
1629        let file_id2 = FileId(10);
1630
1631        let info1 = SourceInfo::from_range(
1632            file_id1,
1633            Range {
1634                start: Location {
1635                    offset: 100,
1636                    row: 5,
1637                    column: 0,
1638                },
1639                end: Location {
1640                    offset: 105,
1641                    row: 5,
1642                    column: 5,
1643                },
1644            },
1645        );
1646
1647        let info2 = SourceInfo::from_range(
1648            file_id2,
1649            Range {
1650                start: Location {
1651                    offset: 200,
1652                    row: 10,
1653                    column: 0,
1654                },
1655                end: Location {
1656                    offset: 207,
1657                    row: 10,
1658                    column: 7,
1659                },
1660            },
1661        );
1662
1663        let combined = info1.combine(&info2);
1664
1665        // Verify both sources are preserved in the Concat
1666        match combined {
1667            SourceInfo::Concat { pieces } => {
1668                assert_eq!(pieces.len(), 2);
1669
1670                // First piece should come from file_id1
1671                match &pieces[0].source_info {
1672                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, file_id1),
1673                    _ => panic!("Expected Original mapping for first piece"),
1674                }
1675
1676                // Second piece should come from file_id2
1677                match &pieces[1].source_info {
1678                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, file_id2),
1679                    _ => panic!("Expected Original mapping for second piece"),
1680                }
1681            }
1682            _ => panic!("Expected Concat mapping"),
1683        }
1684    }
1685
1686    /// Test JSON serialization of Original mapping
1687    #[test]
1688    fn test_json_serialization_original() {
1689        let file_id = FileId(0);
1690        let range = Range {
1691            start: Location {
1692                offset: 10,
1693                row: 1,
1694                column: 5,
1695            },
1696            end: Location {
1697                offset: 50,
1698                row: 3,
1699                column: 10,
1700            },
1701        };
1702
1703        let info = SourceInfo::from_range(file_id, range);
1704        let json = serde_json::to_value(&info).unwrap();
1705
1706        // Verify JSON structure
1707        assert_eq!(json["Original"]["file_id"], 0);
1708        assert_eq!(json["Original"]["start_offset"], 10);
1709        assert_eq!(json["Original"]["end_offset"], 50);
1710
1711        // Verify round-trip
1712        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1713        assert_eq!(info, deserialized);
1714    }
1715
1716    /// Test JSON serialization of Substring mapping
1717    #[test]
1718    fn test_json_serialization_substring() {
1719        let file_id = FileId(0);
1720        let parent_range = Range {
1721            start: Location {
1722                offset: 0,
1723                row: 0,
1724                column: 0,
1725            },
1726            end: Location {
1727                offset: 100,
1728                row: 5,
1729                column: 20,
1730            },
1731        };
1732        let parent = SourceInfo::from_range(file_id, parent_range);
1733
1734        let substring = SourceInfo::substring(parent, 10, 30);
1735        let json = serde_json::to_value(&substring).unwrap();
1736
1737        // Verify JSON structure
1738        assert_eq!(json["Substring"]["start_offset"], 10);
1739        assert_eq!(json["Substring"]["end_offset"], 30);
1740
1741        // Verify parent is serialized (with Rc, it's a full copy in JSON)
1742        assert!(json["Substring"]["parent"].is_object());
1743        assert_eq!(json["Substring"]["parent"]["Original"]["file_id"], 0);
1744
1745        // Verify round-trip
1746        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1747        assert_eq!(substring, deserialized);
1748    }
1749
1750    /// Test JSON serialization of nested Substring mappings (simulates .qmd frontmatter)
1751    #[test]
1752    fn test_json_serialization_nested_substring() {
1753        let file_id = FileId(0);
1754
1755        // Level 1: Original file
1756        let file_range = Range {
1757            start: Location {
1758                offset: 0,
1759                row: 0,
1760                column: 0,
1761            },
1762            end: Location {
1763                offset: 200,
1764                row: 10,
1765                column: 0,
1766            },
1767        };
1768        let file_info = SourceInfo::from_range(file_id, file_range);
1769
1770        // Level 2: YAML frontmatter (substring of file)
1771        let yaml_info = SourceInfo::substring(file_info, 4, 150);
1772
1773        // Level 3: YAML value (substring of frontmatter)
1774        let value_info = SourceInfo::substring(yaml_info, 20, 35);
1775
1776        let json = serde_json::to_value(&value_info).unwrap();
1777
1778        // Verify nested structure
1779        assert_eq!(json["Substring"]["start_offset"], 20);
1780        assert_eq!(json["Substring"]["end_offset"], 35);
1781        assert_eq!(json["Substring"]["parent"]["Substring"]["start_offset"], 4);
1782        assert_eq!(
1783            json["Substring"]["parent"]["Substring"]["parent"]["Original"]["file_id"],
1784            0
1785        );
1786
1787        // Verify round-trip
1788        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1789        assert_eq!(value_info, deserialized);
1790    }
1791
1792    /// Test JSON serialization of Concat mapping
1793    #[test]
1794    fn test_json_serialization_concat() {
1795        let file_id1 = FileId(0);
1796        let file_id2 = FileId(1);
1797
1798        let info1 = SourceInfo::from_range(
1799            file_id1,
1800            Range {
1801                start: Location {
1802                    offset: 0,
1803                    row: 0,
1804                    column: 0,
1805                },
1806                end: Location {
1807                    offset: 10,
1808                    row: 0,
1809                    column: 10,
1810                },
1811            },
1812        );
1813
1814        let info2 = SourceInfo::from_range(
1815            file_id2,
1816            Range {
1817                start: Location {
1818                    offset: 20,
1819                    row: 2,
1820                    column: 0,
1821                },
1822                end: Location {
1823                    offset: 30,
1824                    row: 2,
1825                    column: 10,
1826                },
1827            },
1828        );
1829
1830        let combined = info1.combine(&info2);
1831        let json = serde_json::to_value(&combined).unwrap();
1832
1833        // Verify JSON structure
1834        assert!(json["Concat"]["pieces"].is_array());
1835        let pieces = json["Concat"]["pieces"].as_array().unwrap();
1836        assert_eq!(pieces.len(), 2);
1837
1838        // First piece
1839        assert_eq!(pieces[0]["offset_in_concat"], 0);
1840        assert_eq!(pieces[0]["length"], 10);
1841        assert_eq!(pieces[0]["source_info"]["Original"]["file_id"], 0);
1842
1843        // Second piece
1844        assert_eq!(pieces[1]["offset_in_concat"], 10);
1845        assert_eq!(pieces[1]["length"], 10);
1846        assert_eq!(pieces[1]["source_info"]["Original"]["file_id"], 1);
1847
1848        // Verify round-trip
1849        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1850        assert_eq!(combined, deserialized);
1851    }
1852
1853    /// Test JSON serialization of complex nested structure (real-world example)
1854    #[test]
1855    fn test_json_serialization_complex_nested() {
1856        let file_id = FileId(0);
1857
1858        // Simulate a .qmd file structure
1859        let qmd_file = SourceInfo::from_range(
1860            file_id,
1861            Range {
1862                start: Location {
1863                    offset: 0,
1864                    row: 0,
1865                    column: 0,
1866                },
1867                end: Location {
1868                    offset: 500,
1869                    row: 20,
1870                    column: 0,
1871                },
1872            },
1873        );
1874
1875        // YAML frontmatter is a substring
1876        let yaml_frontmatter = SourceInfo::substring(qmd_file.clone(), 4, 200);
1877
1878        // A YAML key is a substring of frontmatter
1879        let yaml_key = SourceInfo::substring(yaml_frontmatter.clone(), 10, 20);
1880
1881        // A YAML value is another substring of frontmatter
1882        let yaml_value = SourceInfo::substring(yaml_frontmatter, 25, 50);
1883
1884        // Combine key and value (simulating metadata entry)
1885        let combined = yaml_key.combine(&yaml_value);
1886
1887        let json = serde_json::to_value(&combined).unwrap();
1888
1889        // Verify this complex structure serializes
1890        assert!(json.is_object());
1891        assert!(json["Concat"].is_object());
1892
1893        // Verify round-trip
1894        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1895        assert_eq!(combined, deserialized);
1896    }
1897
1898    // -------------------------------------------------------------------------
1899    // Plan 7 — preimage_in accessor
1900    // -------------------------------------------------------------------------
1901
1902    #[test]
1903    fn test_preimage_in_original_same_file() {
1904        let info = SourceInfo::original(FileId(0), 10, 25);
1905        assert_eq!(info.preimage_in(FileId(0)), Some(10..25));
1906    }
1907
1908    #[test]
1909    fn test_preimage_in_original_different_file_returns_none() {
1910        let info = SourceInfo::original(FileId(0), 10, 25);
1911        assert_eq!(info.preimage_in(FileId(1)), None);
1912    }
1913
1914    #[test]
1915    fn test_preimage_in_substring_composes_offsets() {
1916        // Parent points at bytes 100..200 in file 0.
1917        // Substring takes bytes 5..15 *relative to parent*.
1918        // Preimage in file 0 should be 105..115.
1919        let parent = SourceInfo::original(FileId(0), 100, 200);
1920        let info = SourceInfo::substring(parent, 5, 15);
1921        assert_eq!(info.preimage_in(FileId(0)), Some(105..115));
1922    }
1923
1924    #[test]
1925    fn test_preimage_in_substring_different_file_returns_none() {
1926        let parent = SourceInfo::original(FileId(0), 100, 200);
1927        let info = SourceInfo::substring(parent, 5, 15);
1928        assert_eq!(info.preimage_in(FileId(7)), None);
1929    }
1930
1931    #[test]
1932    fn test_preimage_in_substring_chain() {
1933        // Original 1000..2000 in file 0; Substring 100..500 relative; Substring 10..50 relative.
1934        // Expected preimage in file 0: 1100 + 10 .. 1100 + 50 = 1110..1150.
1935        let root = SourceInfo::original(FileId(0), 1000, 2000);
1936        let mid = SourceInfo::substring(root, 100, 500);
1937        let leaf = SourceInfo::substring(mid, 10, 50);
1938        assert_eq!(leaf.preimage_in(FileId(0)), Some(1110..1150));
1939    }
1940
1941    #[test]
1942    fn test_preimage_in_concat_contiguous() {
1943        // Two adjacent pieces of file 0: 10..15 and 15..25 → contiguous → 10..25.
1944        let a = SourceInfo::original(FileId(0), 10, 15);
1945        let b = SourceInfo::original(FileId(0), 15, 25);
1946        let info = SourceInfo::concat(vec![(a, 5), (b, 10)]);
1947        assert_eq!(info.preimage_in(FileId(0)), Some(10..25));
1948    }
1949
1950    #[test]
1951    fn test_preimage_in_concat_gappy_returns_none() {
1952        // 10..15 then 20..25 → gap between 15 and 20 → None.
1953        let a = SourceInfo::original(FileId(0), 10, 15);
1954        let b = SourceInfo::original(FileId(0), 20, 25);
1955        let info = SourceInfo::concat(vec![(a, 5), (b, 5)]);
1956        assert_eq!(info.preimage_in(FileId(0)), None);
1957    }
1958
1959    #[test]
1960    fn test_preimage_in_concat_overlapping_returns_none() {
1961        // 10..20 then 15..25 → overlap → not byte-contiguous → None.
1962        let a = SourceInfo::original(FileId(0), 10, 20);
1963        let b = SourceInfo::original(FileId(0), 15, 25);
1964        let info = SourceInfo::concat(vec![(a, 10), (b, 10)]);
1965        assert_eq!(info.preimage_in(FileId(0)), None);
1966    }
1967
1968    #[test]
1969    fn test_preimage_in_concat_mixed_files_returns_none() {
1970        // One piece in file 0, another in file 1 → resolving in file 0 fails
1971        // because the file-1 piece can't be resolved.
1972        let a = SourceInfo::original(FileId(0), 10, 15);
1973        let b = SourceInfo::original(FileId(1), 15, 25);
1974        let info = SourceInfo::concat(vec![(a, 5), (b, 10)]);
1975        assert_eq!(info.preimage_in(FileId(0)), None);
1976    }
1977
1978    // -------------------------------------------------------------------------
1979    // Phase 1 (quarto-source-map 0.1.2) — preimage_in must not compose
1980    // affinely over a Concat parent through the Substring arm.
1981    // See extract-design-concat-preimage.md, "preimage_in composes
1982    // affinely over a Concat parent, and must not".
1983    // -------------------------------------------------------------------------
1984
1985    #[test]
1986    fn test_preimage_in_substring_over_concat_parent_returns_none() {
1987        // Gap-free Concat modelling YAML 'it''s': verbatim "it" (source
1988        // 1..3), a collapsed-escape replacement "'" (source 3..5, 1
1989        // content byte), verbatim "s" (source 5..6). Content is 4 bytes;
1990        // source extent 1..6.
1991        let it = SourceInfo::original(FileId(0), 1, 3);
1992        let escaped_quote = SourceInfo::original(FileId(0), 3, 5);
1993        let s = SourceInfo::original(FileId(0), 5, 6);
1994        let concat = SourceInfo::concat(vec![(it, 2), (escaped_quote, 1), (s, 1)]);
1995
1996        // Substring{parent: Concat} composes affinely before the fix and
1997        // returns the wrong hull Some(1..5) — under by exactly the
1998        // collapsed escape byte. After the fix it must return None.
1999        let sub = SourceInfo::substring(concat, 0, 4);
2000        assert_eq!(sub.preimage_in(FileId(0)), None);
2001    }
2002
2003    #[test]
2004    fn test_preimage_in_bare_concat_over_gap_free_pieces_is_gating() {
2005        // GATING: same 'it''s' fixture as above, queried directly on the
2006        // Concat (no Substring). Unchanged by the fix — pins that only the
2007        // Substring composition changed, not the Concat arm itself.
2008        let it = SourceInfo::original(FileId(0), 1, 3);
2009        let escaped_quote = SourceInfo::original(FileId(0), 3, 5);
2010        let s = SourceInfo::original(FileId(0), 5, 6);
2011        let concat = SourceInfo::concat(vec![(it, 2), (escaped_quote, 1), (s, 1)]);
2012
2013        assert_eq!(concat.preimage_in(FileId(0)), Some(1..6));
2014    }
2015
2016    #[test]
2017    fn test_preimage_in_cell_options_multi_option_shape_is_gating() {
2018        // GATING: hand-modelled Concat for a multi-option cell. Each
2019        // option line's piece excludes the `#| ` prefix, so consecutive
2020        // option lines leave a source gap where the next prefix sits.
2021        // Already None both bare and through Substring, before and after
2022        // the fix — gappy Concats always refuse.
2023        let opt_a = SourceInfo::original(FileId(0), 3, 4);
2024        let opt_b = SourceInfo::original(FileId(0), 10, 11);
2025        let concat = SourceInfo::concat(vec![(opt_a, 1), (opt_b, 1)]);
2026
2027        assert_eq!(concat.preimage_in(FileId(0)), None);
2028        let sub = SourceInfo::substring(concat, 0, 2);
2029        assert_eq!(sub.preimage_in(FileId(0)), None);
2030    }
2031
2032    #[test]
2033    fn test_preimage_in_cell_options_single_option_shape_through_substring_returns_none() {
2034        // Hand-modelled Concat for a single-option cell: one gap-free
2035        // piece (no prefix-induced gap). The bare Concat still yields a
2036        // correct affine hull — untouched by this fix, since a single
2037        // piece is trivially contiguous. Only the Substring composition
2038        // changes: before the fix it returns the same Some hull; after
2039        // the fix it must return None, since the parent is a Concat. This
2040        // is the one row that binds the documented behavior change.
2041        let opt = SourceInfo::original(FileId(0), 5, 8);
2042        let concat = SourceInfo::concat(vec![(opt, 3)]);
2043
2044        assert_eq!(concat.preimage_in(FileId(0)), Some(5..8));
2045
2046        let sub = SourceInfo::substring(concat, 0, 3);
2047        assert_eq!(sub.preimage_in(FileId(0)), None);
2048    }
2049
2050    #[test]
2051    fn test_preimage_in_concat_contiguous_hull_with_zero_content_piece() {
2052        // Escaped-break shape: verbatim 4..7, a stored zero-content piece
2053        // 7..11 (the collapsed escape bytes contribute no decoded content
2054        // but must stay in the source tiling so it remains gap-free), and
2055        // verbatim 11..14. Guards against a future "simplification" that
2056        // special-cases zero-content pieces and drops them from the
2057        // contiguity walk.
2058        let a = SourceInfo::original(FileId(0), 4, 7);
2059        let zero_content = SourceInfo::original(FileId(0), 7, 11);
2060        let b = SourceInfo::original(FileId(0), 11, 14);
2061        let concat = SourceInfo::concat(vec![(a, 3), (zero_content, 0), (b, 3)]);
2062        assert_eq!(concat.preimage_in(FileId(0)), Some(4..14));
2063
2064        // Omitting the zero-content piece leaves a source gap (7..11
2065        // missing) between the two verbatim pieces -> None.
2066        let a2 = SourceInfo::original(FileId(0), 4, 7);
2067        let b2 = SourceInfo::original(FileId(0), 11, 14);
2068        let concat_missing_piece = SourceInfo::concat(vec![(a2, 3), (b2, 3)]);
2069        assert_eq!(concat_missing_piece.preimage_in(FileId(0)), None);
2070    }
2071
2072    #[test]
2073    fn test_preimage_in_generated_no_anchors_returns_none() {
2074        // Sectionize-style wrapper, footnotes-container, etc.: Generated with
2075        // empty `from`. No Invocation anchor → no preimage.
2076        let info = SourceInfo::generated(By::sectionize());
2077        assert_eq!(info.preimage_in(FileId(0)), None);
2078    }
2079
2080    #[test]
2081    fn test_preimage_in_generated_with_invocation_in_target() {
2082        // Shortcode resolution: Generated with an Invocation anchor pointing
2083        // at the {{< meta foo >}} token bytes.
2084        let token = SourceInfo::original(FileId(0), 50, 70);
2085        let mut info = SourceInfo::generated(By::shortcode("meta"));
2086        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
2087        assert_eq!(info.preimage_in(FileId(0)), Some(50..70));
2088    }
2089
2090    #[test]
2091    fn test_preimage_in_generated_with_invocation_outside_target() {
2092        // Invocation anchor points at file 0; query asks about file 1 → None.
2093        let token = SourceInfo::original(FileId(0), 50, 70);
2094        let mut info = SourceInfo::generated(By::shortcode("meta"));
2095        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
2096        assert_eq!(info.preimage_in(FileId(1)), None);
2097    }
2098
2099    #[test]
2100    fn test_preimage_in_generated_walks_through_substring_in_invocation() {
2101        // Invocation anchor is itself a Substring chain. preimage_in must
2102        // walk through it correctly.
2103        let root = SourceInfo::original(FileId(0), 100, 200);
2104        let token = SourceInfo::substring(root, 10, 30);
2105        let mut info = SourceInfo::generated(By::shortcode("meta"));
2106        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
2107        assert_eq!(info.preimage_in(FileId(0)), Some(110..130));
2108    }
2109
2110    // -------------------------------------------------------------------------
2111    // Plan 7 — preimage_in role-asymmetry: only Invocation is walked.
2112    // -------------------------------------------------------------------------
2113
2114    #[test]
2115    fn test_preimage_in_generated_value_source_only_returns_none() {
2116        // Plan 9-shape: Generated whose only anchor is ValueSource (points at
2117        // YAML metadata bytes). The writer must NOT copy those bytes into the
2118        // body — preimage_in returns None.
2119        let meta_si = SourceInfo::original(FileId(0), 10, 25);
2120        let mut info = SourceInfo::generated(By::appendix());
2121        info.append_anchor(AnchorRole::ValueSource, Arc::new(meta_si));
2122        assert_eq!(info.preimage_in(FileId(0)), None);
2123    }
2124
2125    #[test]
2126    fn test_preimage_in_generated_other_only_returns_none() {
2127        // Extension-defined Other role. preimage_in must not walk it.
2128        let lua_si = SourceInfo::original(FileId(0), 10, 25);
2129        let mut info = SourceInfo::generated(By::filter("upper.lua", 14));
2130        info.append_anchor(
2131            AnchorRole::Other("ext/my-ext/dispatch".to_string()),
2132            Arc::new(lua_si),
2133        );
2134        assert_eq!(info.preimage_in(FileId(0)), None);
2135    }
2136
2137    #[test]
2138    fn test_preimage_in_generated_invocation_plus_value_source_walks_invocation_only() {
2139        // Plan 2/Plan 9 mixed shape: Invocation in file 0 + ValueSource in
2140        // file 1. Query file 0 → Invocation resolves → Some(token range).
2141        // Query file 1 → Invocation resolves to file 0 (not 1) → None.
2142        // (The writer must not see the value-source range when asked about
2143        // any file, even the file the ValueSource points into.)
2144        let token = SourceInfo::original(FileId(0), 50, 70);
2145        let value = SourceInfo::original(FileId(1), 200, 215);
2146        let mut info = SourceInfo::generated(By::shortcode("meta"));
2147        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
2148        info.append_anchor(AnchorRole::ValueSource, Arc::new(value));
2149
2150        assert_eq!(info.preimage_in(FileId(0)), Some(50..70));
2151        assert_eq!(info.preimage_in(FileId(1)), None);
2152    }
2153}