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    /// This is the writer's "can I Verbatim-copy bytes from `target` for the
413    /// node carrying this source_info?" check.
414    ///
415    /// Semantics by variant:
416    /// - `Original` → `Some(start..end)` iff the file matches `target`, else `None`.
417    /// - `Substring` → recurse the parent; offsets compose additively.
418    /// - `Concat` → every piece must resolve into `target` AND the resolved
419    ///   ranges must be byte-contiguous (no gaps, no overlaps). A gappy Concat
420    ///   returns `None` — the writer can't Verbatim-copy a non-contiguous span.
421    /// - `Generated` → walk the `Invocation` anchor only via
422    ///   [`invocation_anchor`](Self::invocation_anchor). **No other anchor
423    ///   role is consulted** — not `ValueSource` (Plan 9), not future
424    ///   `Dispatch` (Plan 10), not `AnchorRole::Other`. See the
425    ///   role-asymmetry section below.
426    ///
427    /// # Role asymmetry
428    ///
429    /// `preimage_in` only walks `AnchorRole::Invocation`. This is load-bearing:
430    /// copying bytes from a `ValueSource` source range would emit raw YAML
431    /// metadata (or whatever the value lived in) into the body — a hard
432    /// correctness bug. The same applies to `Dispatch` (which points at Lua
433    /// source) and to any extension-defined `Other` role.
434    ///
435    /// **Future anchor roles default to non-walked.** Extensions introducing
436    /// `AnchorRole::Other("…")` should treat this as a feature: their
437    /// attribution metadata is not accidentally consulted by the writer's
438    /// byte-copying path. If a role *does* contribute to body-text preimage,
439    /// it must be explicitly added to this function's `Generated` arm.
440    pub fn preimage_in(&self, target: FileId) -> Option<std::ops::Range<usize>> {
441        match self {
442            SourceInfo::Original {
443                file_id,
444                start_offset,
445                end_offset,
446            } if *file_id == target => Some(*start_offset..*end_offset),
447            SourceInfo::Original { .. } => None,
448            SourceInfo::Substring {
449                parent,
450                start_offset,
451                end_offset,
452            } => {
453                let parent_range = parent.preimage_in(target)?;
454                Some(parent_range.start + start_offset..parent_range.start + end_offset)
455            }
456            SourceInfo::Concat { pieces } => {
457                let ranges: Vec<std::ops::Range<usize>> = pieces
458                    .iter()
459                    .map(|p| p.source_info.preimage_in(target))
460                    .collect::<Option<Vec<_>>>()?;
461                if ranges.is_empty() {
462                    return None;
463                }
464                if ranges.windows(2).all(|w| w[0].end == w[1].start) {
465                    let first = ranges.first().unwrap().start;
466                    let last = ranges.last().unwrap().end;
467                    Some(first..last)
468                } else {
469                    None
470                }
471            }
472            SourceInfo::Generated { .. } => self
473                .invocation_anchor()
474                .and_then(|si| si.preimage_in(target)),
475        }
476    }
477
478    /// Remap every `FileId` referenced by this `SourceInfo` (including those
479    /// inside `Substring` parents and `Concat` pieces) using the provided
480    /// mapping function.
481    ///
482    /// Used when merging ASTs that were parsed against different files into a
483    /// single `ASTContext` with a shared filename table — callers shift each
484    /// AST's `FileId`s to their slot in the merged table before combining.
485    pub fn remap_file_ids<F>(&mut self, map: &F)
486    where
487        F: Fn(FileId) -> FileId,
488    {
489        match self {
490            SourceInfo::Original { file_id, .. } => {
491                *file_id = map(*file_id);
492            }
493            SourceInfo::Substring { parent, .. } => {
494                // Arc::make_mut clones if there are other references.
495                let parent = Arc::make_mut(parent);
496                parent.remap_file_ids(map);
497            }
498            SourceInfo::Concat { pieces } => {
499                for piece in pieces {
500                    piece.source_info.remap_file_ids(map);
501                }
502            }
503            SourceInfo::Generated { from, .. } => {
504                for anchor in from {
505                    // Arc::make_mut clones if there are other references.
506                    let inner = Arc::make_mut(&mut anchor.source_info);
507                    inner.remap_file_ids(map);
508                }
509            }
510        }
511    }
512
513    /// First `FileId` reachable from this `SourceInfo`'s root.
514    ///
515    /// - `Original` → `Some(file_id)`.
516    /// - `Substring` → recurse parent.
517    /// - `Concat` → `pieces.iter().find_map(|p| p.source_info.root_file_id())`
518    ///   (`find_map` semantics — skips Generated holes and empty pieces).
519    /// - `Generated` → `invocation_anchor().and_then(|si| si.root_file_id())`;
520    ///   `None` when no `Invocation` anchor is present.
521    pub fn root_file_id(&self) -> Option<FileId> {
522        match self {
523            SourceInfo::Original { file_id, .. } => Some(*file_id),
524            SourceInfo::Substring { parent, .. } => parent.root_file_id(),
525            SourceInfo::Concat { pieces } => {
526                pieces.iter().find_map(|p| p.source_info.root_file_id())
527            }
528            SourceInfo::Generated { .. } => {
529                self.invocation_anchor().and_then(|si| si.root_file_id())
530            }
531        }
532    }
533
534    /// Insert every `FileId` reachable from this `SourceInfo` into `out`.
535    ///
536    /// Walks every `Original`, every `Substring` parent, every `Concat`
537    /// piece, and every `Generated` anchor (all roles — `Invocation`,
538    /// `ValueSource`, `Other`).
539    pub fn collect_file_ids(&self, out: &mut std::collections::HashSet<FileId>) {
540        match self {
541            SourceInfo::Original { file_id, .. } => {
542                out.insert(*file_id);
543            }
544            SourceInfo::Substring { parent, .. } => parent.collect_file_ids(out),
545            SourceInfo::Concat { pieces } => {
546                for piece in pieces {
547                    piece.source_info.collect_file_ids(out);
548                }
549            }
550            SourceInfo::Generated { from, .. } => {
551                for anchor in from {
552                    anchor.source_info.collect_file_ids(out);
553                }
554            }
555        }
556    }
557}
558
559impl By {
560    /// Producer kind for a node constructed by a Lua filter
561    /// (e.g. `pandoc.Str("decoration")` inside a filter callback).
562    ///
563    /// `filter_path` is the path the Lua engine reported via
564    /// `debug.getinfo(...).source` (with the leading "@" stripped);
565    /// `line` is the line number inside that file where the constructor
566    /// ran. Until Lua-file-registration lands (bd-36fr9), `(filter_path,
567    /// line)` lives in `by.data`; afterwards it migrates to a `Dispatch`
568    /// anchor and `by.data` shrinks to `{}`.
569    pub fn filter(filter_path: impl Into<String>, line: usize) -> Self {
570        Self {
571            kind: "filter".to_string(),
572            data: serde_json::json!({
573                "filter_path": filter_path.into(),
574                "line": line,
575            }),
576        }
577    }
578
579    /// Producer kind for the `SectionizeTransform`'s synthesized section
580    /// Divs. Children remain editable; the wrapper itself is structural.
581    pub fn sectionize() -> Self {
582        Self {
583            kind: "sectionize".to_string(),
584            data: serde_json::Value::Null,
585        }
586    }
587
588    /// Producer kind for React-constructed (user-typed) content reaching
589    /// the AST through the q2-preview client.
590    pub fn user_edit() -> Self {
591        Self {
592            kind: "user-edit".to_string(),
593            data: serde_json::Value::Null,
594        }
595    }
596
597    /// Producer kind for shortcode resolutions.
598    ///
599    /// **Invariant.** Every `Generated { by: shortcode(...), .. }` must
600    /// carry at least one `Invocation` anchor in `from` pointing at the
601    /// source token's byte range. Use only inside a `Generated` whose
602    /// anchor list is populated; constructing the bare shape with empty
603    /// `from` is rejected by Plan 6's audit-completion test and trips
604    /// Plan 7's writer `debug_assert!`.
605    pub fn shortcode(name: impl Into<String>) -> Self {
606        Self {
607            kind: "shortcode".to_string(),
608            data: serde_json::json!({ "name": name.into() }),
609        }
610    }
611
612    /// Producer kind for `IncludeStage`'s expansion wrapper. Note that
613    /// most include-related synthesized content keeps its `Original`
614    /// `source_info` (inherited from the include-line Paragraph) — this
615    /// kind is only used where a `Generated` is explicitly required.
616    pub fn include() -> Self {
617        Self {
618            kind: "include".to_string(),
619            data: serde_json::Value::Null,
620        }
621    }
622
623    /// Producer kind for the title-block stage's synthesized title `h1`.
624    pub fn title_block() -> Self {
625        Self {
626            kind: "title-block".to_string(),
627            data: serde_json::Value::Null,
628        }
629    }
630
631    /// Producer kind for the footnotes stage's container Div.
632    pub fn footnotes() -> Self {
633        Self {
634            kind: "footnotes".to_string(),
635            data: serde_json::Value::Null,
636        }
637    }
638
639    /// Producer kind for `RevealSlidesTransform`'s synthesized slide
640    /// structure — title-slide Div, section wrappers, speaker-notes Div,
641    /// and any other chrome built from the slide-level heading tree.
642    /// Non-atomic: the slide container is structural chrome; the content
643    /// inside (headings, paragraphs) retains its own source_info.
644    pub fn revealjs() -> Self {
645        Self {
646            kind: "revealjs".to_string(),
647            data: serde_json::Value::Null,
648        }
649    }
650
651    /// Producer kind for the appendix-structure stage's wrapper Div.
652    pub fn appendix() -> Self {
653        Self {
654            kind: "appendix".to_string(),
655            data: serde_json::Value::Null,
656        }
657    }
658
659    /// Producer kind for parser-side synthetic Spaces inserted by the
660    /// tree-sitter post-processing pass.
661    pub fn tree_sitter_postprocess() -> Self {
662        Self {
663            kind: "tree-sitter-postprocess".to_string(),
664            data: serde_json::Value::Null,
665        }
666    }
667
668    /// "We don't know" placeholder used by `json::read_completing_source_info`
669    /// when a node arrives without an `s:` field from outside the q2
670    /// source-tracking world (qmd-syntax-helper Pandoc subprocess, CLI
671    /// `--from json`, external filter binaries, Lua AST handoff).
672    ///
673    /// Non-atomic by design — nodes carrying `By::unknown()` remain
674    /// editable in the preview; user edits re-stamp them as `user_edit`
675    /// on save. See Plan 7f Phase 4's per-caller table for placement
676    /// guidance.
677    pub fn unknown() -> Self {
678        Self {
679            kind: "unknown".to_string(),
680            data: serde_json::Value::Null,
681        }
682    }
683
684    /// Producer kind for test scaffolding. Non-atomic; appears only in
685    /// test code where `source_info` is required by a constructor but
686    /// has no real provenance to record. Paired with
687    /// [`SourceInfo::for_test`].
688    pub fn test_scaffold() -> Self {
689        Self {
690            kind: "test-scaffold".to_string(),
691            data: serde_json::Value::Null,
692        }
693    }
694
695    /// Producer kind for citeproc-rendered content (citation Str
696    /// replacements, bibliography `Div`s, `#refs` wrappers). The bytes
697    /// come from CSL processing of bibliographic metadata, not from
698    /// user-written source.
699    ///
700    /// Atomic — citeproc output is generated content the user can't
701    /// edit through the preview; changes go through the CSL pipeline,
702    /// not through inline editing.
703    pub fn citeproc() -> Self {
704        Self {
705            kind: "citeproc".to_string(),
706            data: serde_json::Value::Null,
707        }
708    }
709
710    /// Producer kind for content synthesized from execution-engine
711    /// output (Jupyter cell stdout / stderr, rich-display MIME bundles,
712    /// kernel error tracebacks). The bytes come from kernel execution,
713    /// not from user-written source.
714    ///
715    /// Atomic — execution outputs are regenerated on every re-run;
716    /// editing them through the preview would be a UX bug.
717    pub fn jupyter_output() -> Self {
718        Self {
719            kind: "jupyter-output".to_string(),
720            data: serde_json::Value::Null,
721        }
722    }
723
724    /// Producer kind for callout-decoration synthesis:
725    /// default-title injection (`Note`, `Warning`, etc. when the user
726    /// omits a title and `appearance="default"`) and the
727    /// screen-reader-only type announcement span.
728    ///
729    /// Non-atomic — the wrapper Div is structural, and its children
730    /// (the user's actual callout body) remain editable through the
731    /// preview. The synthesized title text itself has no preimage but
732    /// regenerates from the callout type when the user changes it,
733    /// so atomicity at the wrapper level would be incorrect.
734    pub fn callout() -> Self {
735        Self {
736            kind: "callout".to_string(),
737            data: serde_json::Value::Null,
738        }
739    }
740
741    /// Empty-Map sentinel `ConfigValue` used during metadata merging
742    /// when no value is present. Non-atomic. The bytes don't exist —
743    /// the node is structural. See [`By::is_programmatic_sentinel`].
744    pub fn config_default() -> Self {
745        Self {
746            kind: "config-default".to_string(),
747            data: serde_json::Value::Null,
748        }
749    }
750
751    /// Programmatic construction of `ConfigValue` (e.g.
752    /// `ConfigValue::from_path`, intermediate maps created during
753    /// `insert_path`). No source bytes exist for these nodes.
754    /// See [`By::is_programmatic_sentinel`].
755    pub fn programmatic_config() -> Self {
756        Self {
757            kind: "programmatic-config".to_string(),
758            data: serde_json::Value::Null,
759        }
760    }
761
762    /// True for kinds whose source bytes don't exist — `config-default`,
763    /// `programmatic-config`, `unknown`. Used by code that needs to
764    /// distinguish "no real source" sentinels from a genuine
765    /// `Original{FileId(0), …}` pointing at a real document.
766    pub fn is_programmatic_sentinel(&self) -> bool {
767        matches!(
768            self.kind.as_str(),
769            "config-default" | "programmatic-config" | "unknown"
770        )
771    }
772
773    /// Escape-hatch constructor for any `kind` string — including built-in
774    /// names and extension-defined kinds (`ext/<extension>/<kind>`).
775    ///
776    /// Forgery (an extension calling `By::raw("shortcode", …)` without the
777    /// required `Invocation` anchor) is caught downstream by Plan 6's
778    /// audit-completion test and Plan 7's `debug_assert!`. The convention
779    /// for third-party kinds is `ext/<extension>/<kind>`.
780    pub fn raw(kind: impl Into<String>, data: serde_json::Value) -> Self {
781        Self {
782            kind: kind.into(),
783            data,
784        }
785    }
786
787    /// True if a `Generated { by: <self>, .. }` node should be treated
788    /// as atomic by the incremental writer.
789    ///
790    /// Atomic nodes are produced by the pipeline and represent content
791    /// the user shouldn't edit through React (filter constructions,
792    /// shortcode resolutions, synthesized title h1, tree-sitter-inserted
793    /// spaces). Atomicity is determined by `kind` alone — orthogonal to
794    /// anchor-presence.
795    ///
796    /// Extensions that contribute new `by.kind` values are not atomic by
797    /// default in v1.
798    pub fn is_atomic_kind(&self) -> bool {
799        matches!(
800            self.kind.as_str(),
801            "filter"
802                | "shortcode"
803                | "title-block"
804                | "tree-sitter-postprocess"
805                | "citeproc"
806                | "jupyter-output"
807        )
808    }
809
810    /// True if this `By`'s `kind` equals `kind`.
811    pub fn is_kind(&self, kind: &str) -> bool {
812        self.kind == kind
813    }
814
815    /// If `self.kind == "filter"`, return `(filter_path, line)`.
816    ///
817    /// Returns `None` for any other kind, or when the data payload is
818    /// malformed (missing or non-string `filter_path`, missing or
819    /// non-integer `line`).
820    pub fn as_filter(&self) -> Option<(&str, usize)> {
821        if self.kind != "filter" {
822            return None;
823        }
824        let path = self.data.get("filter_path")?.as_str()?;
825        let line = self.data.get("line")?.as_u64()? as usize;
826        Some((path, line))
827    }
828}
829
830impl Anchor {
831    /// Construct an [`AnchorRole::Invocation`] anchor.
832    pub fn invocation(source_info: Arc<SourceInfo>) -> Self {
833        Self {
834            role: AnchorRole::Invocation,
835            source_info,
836        }
837    }
838
839    /// Construct an [`AnchorRole::ValueSource`] anchor.
840    pub fn value_source(source_info: Arc<SourceInfo>) -> Self {
841        Self {
842            role: AnchorRole::ValueSource,
843            source_info,
844        }
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use crate::types::{FileId, Location, Range};
852
853    #[test]
854    fn test_original_source_info() {
855        let file_id = FileId(0);
856        let range = Range {
857            start: Location {
858                offset: 0,
859                row: 0,
860                column: 0,
861            },
862            end: Location {
863                offset: 10,
864                row: 0,
865                column: 10,
866            },
867        };
868
869        let info = SourceInfo::from_range(file_id, range.clone());
870
871        assert_eq!(info.start_offset(), 0);
872        assert_eq!(info.end_offset(), 10);
873        assert_eq!(info.length(), 10);
874        match info {
875            SourceInfo::Original {
876                file_id: mapped_id, ..
877            } => {
878                assert_eq!(mapped_id, file_id);
879            }
880            _ => panic!("Expected Original mapping"),
881        }
882    }
883
884    #[test]
885    fn test_remap_file_ids_original() {
886        let mut info = SourceInfo::original(FileId(0), 0, 10);
887        info.remap_file_ids(&|id| FileId(id.0 + 1));
888        match info {
889            SourceInfo::Original { file_id, .. } => assert_eq!(file_id, FileId(1)),
890            _ => panic!("Expected Original"),
891        }
892    }
893
894    #[test]
895    fn test_remap_file_ids_substring() {
896        let parent = SourceInfo::original(FileId(0), 0, 100);
897        let mut info = SourceInfo::substring(parent, 5, 20);
898        info.remap_file_ids(&|id| FileId(id.0 + 7));
899        match info {
900            SourceInfo::Substring { parent, .. } => match &*parent {
901                SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(7)),
902                _ => panic!("Expected Original parent"),
903            },
904            _ => panic!("Expected Substring"),
905        }
906    }
907
908    #[test]
909    fn test_remap_file_ids_concat() {
910        let a = SourceInfo::original(FileId(0), 0, 5);
911        let b = SourceInfo::original(FileId(3), 5, 10);
912        let mut info = SourceInfo::concat(vec![(a, 5), (b, 5)]);
913        info.remap_file_ids(&|id| FileId(id.0 + 10));
914        match info {
915            SourceInfo::Concat { pieces } => {
916                match &pieces[0].source_info {
917                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(10)),
918                    _ => panic!("Expected Original"),
919                }
920                match &pieces[1].source_info {
921                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(13)),
922                    _ => panic!("Expected Original"),
923                }
924            }
925            _ => panic!("Expected Concat"),
926        }
927    }
928
929    #[test]
930    fn test_remap_file_ids_generated_empty_from_is_noop() {
931        let mut info = SourceInfo::generated(By::filter("foo.lua", 42));
932        info.remap_file_ids(&|_| FileId(99));
933        match info {
934            SourceInfo::Generated { by, from } => {
935                assert!(from.is_empty());
936                let (path, line) = by.as_filter().unwrap();
937                assert_eq!(path, "foo.lua");
938                assert_eq!(line, 42);
939            }
940            _ => panic!("Expected Generated"),
941        }
942    }
943
944    // -------------------------------------------------------------------------
945    // Plan 4 — By / Anchor / Generated coverage
946    // -------------------------------------------------------------------------
947
948    #[test]
949    fn test_by_filter_builder() {
950        let by = By::filter("a.lua", 7);
951        assert_eq!(by.kind, "filter");
952        assert_eq!(by.as_filter(), Some(("a.lua", 7)));
953    }
954
955    #[test]
956    fn test_by_sectionize_builder() {
957        let by = By::sectionize();
958        assert_eq!(by.kind, "sectionize");
959        assert!(by.data.is_null());
960    }
961
962    #[test]
963    fn test_by_user_edit_builder() {
964        assert_eq!(By::user_edit().kind, "user-edit");
965    }
966
967    #[test]
968    fn test_by_shortcode_builder_records_name() {
969        let by = By::shortcode("meta");
970        assert_eq!(by.kind, "shortcode");
971        assert_eq!(by.data.get("name").and_then(|v| v.as_str()), Some("meta"));
972    }
973
974    #[test]
975    fn test_by_include_title_footnotes_appendix_tree_sitter_builders() {
976        assert_eq!(By::include().kind, "include");
977        assert_eq!(By::title_block().kind, "title-block");
978        assert_eq!(By::footnotes().kind, "footnotes");
979        assert_eq!(By::appendix().kind, "appendix");
980        assert_eq!(
981            By::tree_sitter_postprocess().kind,
982            "tree-sitter-postprocess"
983        );
984    }
985
986    #[test]
987    fn test_by_raw_builder_accepts_any_kind() {
988        let by = By::raw("ext/my-plugin/foo", serde_json::json!({"k": 1}));
989        assert_eq!(by.kind, "ext/my-plugin/foo");
990        assert_eq!(by.data.get("k").and_then(|v| v.as_u64()), Some(1));
991    }
992
993    #[test]
994    fn test_by_is_atomic_kind() {
995        assert!(By::filter("x.lua", 1).is_atomic_kind());
996        assert!(By::shortcode("meta").is_atomic_kind());
997        assert!(By::title_block().is_atomic_kind());
998        assert!(By::tree_sitter_postprocess().is_atomic_kind());
999        assert!(By::citeproc().is_atomic_kind());
1000        assert!(By::jupyter_output().is_atomic_kind());
1001
1002        assert!(!By::callout().is_atomic_kind());
1003
1004        assert!(!By::sectionize().is_atomic_kind());
1005        assert!(!By::user_edit().is_atomic_kind());
1006        assert!(!By::include().is_atomic_kind());
1007        assert!(!By::footnotes().is_atomic_kind());
1008        assert!(!By::appendix().is_atomic_kind());
1009        assert!(!By::unknown().is_atomic_kind());
1010        assert!(!By::test_scaffold().is_atomic_kind());
1011        assert!(!By::config_default().is_atomic_kind());
1012        assert!(!By::programmatic_config().is_atomic_kind());
1013        assert!(!By::raw("ext/anywhere/foo", serde_json::Value::Null).is_atomic_kind());
1014    }
1015
1016    #[test]
1017    fn test_by_unknown_constructor() {
1018        let by = By::unknown();
1019        assert_eq!(by.kind, "unknown");
1020        assert!(by.data.is_null());
1021        // Non-atomic — nodes carrying By::unknown() remain editable; the
1022        // strict reader rejects missing `s:`, the completing reader stamps
1023        // them with this kind only at the explicit call site.
1024        assert!(!by.is_atomic_kind());
1025    }
1026
1027    #[test]
1028    fn test_by_test_scaffold_constructor() {
1029        let by = By::test_scaffold();
1030        assert_eq!(by.kind, "test-scaffold");
1031        assert!(by.data.is_null());
1032        assert!(!by.is_atomic_kind());
1033        // Not a "no real source" sentinel — it's test scaffolding.
1034        assert!(!by.is_programmatic_sentinel());
1035    }
1036
1037    #[test]
1038    fn test_by_config_default_constructor() {
1039        let by = By::config_default();
1040        assert_eq!(by.kind, "config-default");
1041        assert!(by.data.is_null());
1042        assert!(!by.is_atomic_kind());
1043    }
1044
1045    #[test]
1046    fn test_by_programmatic_config_constructor() {
1047        let by = By::programmatic_config();
1048        assert_eq!(by.kind, "programmatic-config");
1049        assert!(by.data.is_null());
1050        assert!(!by.is_atomic_kind());
1051    }
1052
1053    #[test]
1054    fn test_by_citeproc_constructor() {
1055        let by = By::citeproc();
1056        assert_eq!(by.kind, "citeproc");
1057        assert!(by.data.is_null());
1058        // Atomic — citeproc output is non-editable in the preview.
1059        assert!(by.is_atomic_kind());
1060        // Not a "no real source" sentinel; the bytes come from CSL output.
1061        assert!(!by.is_programmatic_sentinel());
1062    }
1063
1064    #[test]
1065    fn test_by_jupyter_output_constructor() {
1066        let by = By::jupyter_output();
1067        assert_eq!(by.kind, "jupyter-output");
1068        assert!(by.data.is_null());
1069        // Atomic — execution outputs regenerate on every re-run.
1070        assert!(by.is_atomic_kind());
1071        assert!(!by.is_programmatic_sentinel());
1072    }
1073
1074    #[test]
1075    fn test_by_callout_constructor() {
1076        let by = By::callout();
1077        assert_eq!(by.kind, "callout");
1078        assert!(by.data.is_null());
1079        // Non-atomic — callout wrapper is structural; children stay editable.
1080        assert!(!by.is_atomic_kind());
1081        assert!(!by.is_programmatic_sentinel());
1082    }
1083
1084    #[test]
1085    fn test_by_is_programmatic_sentinel() {
1086        assert!(By::config_default().is_programmatic_sentinel());
1087        assert!(By::programmatic_config().is_programmatic_sentinel());
1088        assert!(By::unknown().is_programmatic_sentinel());
1089
1090        assert!(!By::user_edit().is_programmatic_sentinel());
1091        assert!(!By::filter("x.lua", 1).is_programmatic_sentinel());
1092        assert!(!By::shortcode("meta").is_programmatic_sentinel());
1093        assert!(!By::test_scaffold().is_programmatic_sentinel());
1094        assert!(!By::sectionize().is_programmatic_sentinel());
1095    }
1096
1097    #[test]
1098    fn test_source_info_for_test() {
1099        let si = SourceInfo::for_test();
1100        match si {
1101            SourceInfo::Generated { by, from } => {
1102                assert_eq!(by.kind, "test-scaffold");
1103                assert!(from.is_empty());
1104            }
1105            _ => panic!("for_test() must return Generated"),
1106        }
1107    }
1108
1109    #[test]
1110    fn test_by_is_kind() {
1111        let by = By::shortcode("meta");
1112        assert!(by.is_kind("shortcode"));
1113        assert!(!by.is_kind("filter"));
1114    }
1115
1116    #[test]
1117    fn test_by_as_filter_rejects_non_filter() {
1118        assert!(By::sectionize().as_filter().is_none());
1119        // Malformed filter (missing line) → None.
1120        let by = By {
1121            kind: "filter".to_string(),
1122            data: serde_json::json!({ "filter_path": "x.lua" }),
1123        };
1124        assert!(by.as_filter().is_none());
1125    }
1126
1127    #[test]
1128    fn test_anchor_invocation_value_source_constructors() {
1129        let original = Arc::new(SourceInfo::original(FileId(1), 0, 5));
1130        let inv = Anchor::invocation(Arc::clone(&original));
1131        let vs = Anchor::value_source(Arc::clone(&original));
1132        assert!(matches!(inv.role, AnchorRole::Invocation));
1133        assert!(matches!(vs.role, AnchorRole::ValueSource));
1134    }
1135
1136    #[test]
1137    fn test_by_json_round_trip() {
1138        let by = By::shortcode("meta");
1139        let json = serde_json::to_string(&by).unwrap();
1140        let back: By = serde_json::from_str(&json).unwrap();
1141        assert_eq!(by, back);
1142    }
1143
1144    #[test]
1145    fn test_anchor_json_round_trip() {
1146        let anchor = Anchor::invocation(Arc::new(SourceInfo::original(FileId(2), 10, 20)));
1147        let json = serde_json::to_string(&anchor).unwrap();
1148        let back: Anchor = serde_json::from_str(&json).unwrap();
1149        assert_eq!(anchor, back);
1150    }
1151
1152    #[test]
1153    fn test_generated_json_round_trip_empty_from() {
1154        let info = SourceInfo::generated(By::sectionize());
1155        let json = serde_json::to_string(&info).unwrap();
1156        let back: SourceInfo = serde_json::from_str(&json).unwrap();
1157        assert_eq!(info, back);
1158    }
1159
1160    #[test]
1161    fn test_generated_json_round_trip_with_invocation_anchor() {
1162        let mut info = SourceInfo::generated(By::shortcode("meta"));
1163        info.append_anchor(
1164            AnchorRole::Invocation,
1165            Arc::new(SourceInfo::original(FileId(5), 100, 110)),
1166        );
1167        let json = serde_json::to_string(&info).unwrap();
1168        let back: SourceInfo = serde_json::from_str(&json).unwrap();
1169        assert_eq!(info, back);
1170    }
1171
1172    #[test]
1173    fn test_generated_json_round_trip_multi_anchor() {
1174        let mut info = SourceInfo::generated(By::shortcode("meta"));
1175        info.append_anchor(
1176            AnchorRole::Invocation,
1177            Arc::new(SourceInfo::original(FileId(5), 100, 110)),
1178        );
1179        info.append_anchor(
1180            AnchorRole::ValueSource,
1181            Arc::new(SourceInfo::original(FileId(7), 200, 220)),
1182        );
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_length_start_end_are_zero() {
1190        let info = SourceInfo::generated(By::sectionize());
1191        assert_eq!(info.length(), 0);
1192        assert_eq!(info.start_offset(), 0);
1193        assert_eq!(info.end_offset(), 0);
1194    }
1195
1196    #[test]
1197    fn test_generated_resolve_byte_range_recurses_through_substring() {
1198        let parent = SourceInfo::original(FileId(42), 100, 200);
1199        let sub = SourceInfo::substring(parent, 10, 20);
1200        let mut info = SourceInfo::generated(By::shortcode("meta"));
1201        info.append_anchor(AnchorRole::Invocation, Arc::new(sub));
1202        assert_eq!(info.resolve_byte_range(), Some((42, 110, 120)));
1203    }
1204
1205    #[test]
1206    fn test_generated_resolve_byte_range_empty_returns_none() {
1207        let info = SourceInfo::generated(By::sectionize());
1208        assert!(info.resolve_byte_range().is_none());
1209    }
1210
1211    #[test]
1212    fn test_generated_resolve_byte_range_value_source_only_returns_none() {
1213        let mut info = SourceInfo::generated(By::shortcode("meta"));
1214        info.append_anchor(
1215            AnchorRole::ValueSource,
1216            Arc::new(SourceInfo::original(FileId(5), 100, 110)),
1217        );
1218        assert!(info.resolve_byte_range().is_none());
1219    }
1220
1221    #[test]
1222    fn test_generated_remap_file_ids_walks_anchors() {
1223        let mut info = SourceInfo::generated(By::shortcode("meta"));
1224        info.append_anchor(
1225            AnchorRole::Invocation,
1226            Arc::new(SourceInfo::original(FileId(0), 0, 5)),
1227        );
1228        info.append_anchor(
1229            AnchorRole::ValueSource,
1230            Arc::new(SourceInfo::original(FileId(3), 10, 20)),
1231        );
1232        info.remap_file_ids(&|id| FileId(id.0 + 10));
1233        match &info {
1234            SourceInfo::Generated { from, .. } => {
1235                assert_eq!(from.len(), 2);
1236                match from[0].source_info.as_ref() {
1237                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(10)),
1238                    _ => panic!("Expected Original anchor 0"),
1239                }
1240                match from[1].source_info.as_ref() {
1241                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, FileId(13)),
1242                    _ => panic!("Expected Original anchor 1"),
1243                }
1244            }
1245            _ => panic!("Expected Generated"),
1246        }
1247    }
1248
1249    #[test]
1250    fn test_root_file_id_per_variant() {
1251        // Original
1252        let original = SourceInfo::original(FileId(7), 0, 5);
1253        assert_eq!(original.root_file_id(), Some(FileId(7)));
1254
1255        // Substring → recurse parent
1256        let sub = SourceInfo::substring(original.clone(), 0, 5);
1257        assert_eq!(sub.root_file_id(), Some(FileId(7)));
1258
1259        // Concat find_map skips Generated holes
1260        let empty_gen = SourceInfo::generated(By::sectionize());
1261        let real = SourceInfo::original(FileId(42), 0, 5);
1262        let concat = SourceInfo::concat(vec![(empty_gen, 0), (real, 5)]);
1263        assert_eq!(concat.root_file_id(), Some(FileId(42)));
1264
1265        // Generated with Invocation
1266        let mut g = SourceInfo::generated(By::shortcode("meta"));
1267        g.append_anchor(
1268            AnchorRole::Invocation,
1269            Arc::new(SourceInfo::original(FileId(9), 0, 1)),
1270        );
1271        assert_eq!(g.root_file_id(), Some(FileId(9)));
1272
1273        // Generated with no Invocation
1274        let mut g2 = SourceInfo::generated(By::shortcode("meta"));
1275        g2.append_anchor(
1276            AnchorRole::ValueSource,
1277            Arc::new(SourceInfo::original(FileId(9), 0, 1)),
1278        );
1279        assert_eq!(g2.root_file_id(), None);
1280
1281        // Generated empty
1282        let g3 = SourceInfo::generated(By::sectionize());
1283        assert_eq!(g3.root_file_id(), None);
1284    }
1285
1286    #[test]
1287    fn test_collect_file_ids_walks_every_anchor_role() {
1288        let mut info = SourceInfo::generated(By::shortcode("meta"));
1289        info.append_anchor(
1290            AnchorRole::Invocation,
1291            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1292        );
1293        info.append_anchor(
1294            AnchorRole::ValueSource,
1295            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1296        );
1297        info.append_anchor(
1298            AnchorRole::Other("dispatch".to_string()),
1299            Arc::new(SourceInfo::original(FileId(3), 0, 1)),
1300        );
1301        let mut out = std::collections::HashSet::new();
1302        info.collect_file_ids(&mut out);
1303        assert!(out.contains(&FileId(1)));
1304        assert!(out.contains(&FileId(2)));
1305        assert!(out.contains(&FileId(3)));
1306        assert_eq!(out.len(), 3);
1307    }
1308
1309    #[test]
1310    fn test_collect_file_ids_walks_concat_and_substring() {
1311        let inner = SourceInfo::original(FileId(5), 0, 100);
1312        let sub = SourceInfo::substring(inner, 10, 20);
1313        let other = SourceInfo::original(FileId(11), 0, 5);
1314        let concat = SourceInfo::concat(vec![(sub, 10), (other, 5)]);
1315        let mut out = std::collections::HashSet::new();
1316        concat.collect_file_ids(&mut out);
1317        assert!(out.contains(&FileId(5)));
1318        assert!(out.contains(&FileId(11)));
1319        assert_eq!(out.len(), 2);
1320    }
1321
1322    #[test]
1323    fn test_invocation_anchor_accessor() {
1324        let mut info = SourceInfo::generated(By::shortcode("meta"));
1325        assert!(info.invocation_anchor().is_none());
1326        info.append_anchor(
1327            AnchorRole::ValueSource,
1328            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1329        );
1330        assert!(info.invocation_anchor().is_none());
1331        info.append_anchor(
1332            AnchorRole::Invocation,
1333            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1334        );
1335        assert!(info.invocation_anchor().is_some());
1336        // Non-Generated returns None.
1337        assert!(
1338            SourceInfo::original(FileId(0), 0, 0)
1339                .invocation_anchor()
1340                .is_none()
1341        );
1342    }
1343
1344    #[test]
1345    fn test_value_source_anchor_accessor() {
1346        let mut info = SourceInfo::generated(By::shortcode("meta"));
1347        assert!(info.value_source_anchor().is_none());
1348        info.append_anchor(
1349            AnchorRole::Invocation,
1350            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1351        );
1352        assert!(info.value_source_anchor().is_none());
1353        info.append_anchor(
1354            AnchorRole::ValueSource,
1355            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1356        );
1357        assert!(info.value_source_anchor().is_some());
1358    }
1359
1360    #[test]
1361    fn test_anchors_with_role() {
1362        let mut info = SourceInfo::generated(By::shortcode("meta"));
1363        info.append_anchor(
1364            AnchorRole::Invocation,
1365            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1366        );
1367        info.append_anchor(
1368            AnchorRole::ValueSource,
1369            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1370        );
1371        info.append_anchor(
1372            AnchorRole::Other("ext/foo".to_string()),
1373            Arc::new(SourceInfo::original(FileId(3), 0, 1)),
1374        );
1375        assert_eq!(info.anchors_with_role(&AnchorRole::Invocation).count(), 1);
1376        assert_eq!(info.anchors_with_role(&AnchorRole::ValueSource).count(), 1);
1377        assert_eq!(
1378            info.anchors_with_role(&AnchorRole::Other("ext/foo".to_string()))
1379                .count(),
1380            1
1381        );
1382        assert_eq!(
1383            info.anchors_with_role(&AnchorRole::Other("missing".to_string()))
1384                .count(),
1385            0
1386        );
1387    }
1388
1389    #[test]
1390    fn test_append_anchor_preserves_order() {
1391        let mut info = SourceInfo::generated(By::shortcode("meta"));
1392        info.append_anchor(
1393            AnchorRole::Invocation,
1394            Arc::new(SourceInfo::original(FileId(1), 0, 1)),
1395        );
1396        info.append_anchor(
1397            AnchorRole::ValueSource,
1398            Arc::new(SourceInfo::original(FileId(2), 0, 1)),
1399        );
1400        match info {
1401            SourceInfo::Generated { from, .. } => {
1402                assert_eq!(from.len(), 2);
1403                assert!(matches!(from[0].role, AnchorRole::Invocation));
1404                assert!(matches!(from[1].role, AnchorRole::ValueSource));
1405            }
1406            _ => panic!("Expected Generated"),
1407        }
1408    }
1409
1410    #[test]
1411    fn test_combine_with_generated_is_zero_length_piece() {
1412        let original = SourceInfo::original(FileId(0), 10, 20);
1413        let generated = SourceInfo::generated(By::sectionize());
1414        let combined = original.combine(&generated);
1415        match &combined {
1416            SourceInfo::Concat { pieces } => {
1417                assert_eq!(pieces.len(), 2);
1418                assert_eq!(pieces[1].length, 0);
1419            }
1420            _ => panic!("Expected Concat"),
1421        }
1422        // Length of the combined value equals only the Original side.
1423        assert_eq!(combined.length(), 10);
1424    }
1425
1426    #[test]
1427    fn test_source_info_serialization() {
1428        let file_id = FileId(0);
1429        let range = Range {
1430            start: Location {
1431                offset: 0,
1432                row: 0,
1433                column: 0,
1434            },
1435            end: Location {
1436                offset: 10,
1437                row: 0,
1438                column: 10,
1439            },
1440        };
1441
1442        let info = SourceInfo::from_range(file_id, range);
1443        let json = serde_json::to_string(&info).unwrap();
1444        let deserialized: SourceInfo = serde_json::from_str(&json).unwrap();
1445
1446        assert_eq!(info, deserialized);
1447    }
1448
1449    #[test]
1450    fn test_substring_source_info() {
1451        let file_id = FileId(0);
1452        let parent_range = Range {
1453            start: Location {
1454                offset: 0,
1455                row: 0,
1456                column: 0,
1457            },
1458            end: Location {
1459                offset: 100,
1460                row: 0,
1461                column: 100,
1462            },
1463        };
1464        let parent = SourceInfo::from_range(file_id, parent_range);
1465
1466        let substring = SourceInfo::substring(parent, 10, 20);
1467
1468        assert_eq!(substring.start_offset(), 10);
1469        assert_eq!(substring.end_offset(), 20);
1470        assert_eq!(substring.length(), 10);
1471
1472        match substring {
1473            SourceInfo::Substring {
1474                start_offset,
1475                end_offset,
1476                ..
1477            } => {
1478                assert_eq!(start_offset, 10);
1479                assert_eq!(end_offset, 20);
1480            }
1481            _ => panic!("Expected Substring mapping"),
1482        }
1483    }
1484
1485    #[test]
1486    fn test_concat_source_info() {
1487        let file_id1 = FileId(0);
1488        let file_id2 = FileId(1);
1489
1490        let info1 = SourceInfo::from_range(
1491            file_id1,
1492            Range {
1493                start: Location {
1494                    offset: 0,
1495                    row: 0,
1496                    column: 0,
1497                },
1498                end: Location {
1499                    offset: 10,
1500                    row: 0,
1501                    column: 10,
1502                },
1503            },
1504        );
1505
1506        let info2 = SourceInfo::from_range(
1507            file_id2,
1508            Range {
1509                start: Location {
1510                    offset: 0,
1511                    row: 0,
1512                    column: 0,
1513                },
1514                end: Location {
1515                    offset: 15,
1516                    row: 0,
1517                    column: 15,
1518                },
1519            },
1520        );
1521
1522        let concat = SourceInfo::concat(vec![(info1, 10), (info2, 15)]);
1523
1524        assert_eq!(concat.start_offset(), 0);
1525        assert_eq!(concat.end_offset(), 25); // 10 + 15
1526        assert_eq!(concat.length(), 25);
1527
1528        match concat {
1529            SourceInfo::Concat { pieces } => {
1530                assert_eq!(pieces.len(), 2);
1531                assert_eq!(pieces[0].offset_in_concat, 0);
1532                assert_eq!(pieces[0].length, 10);
1533                assert_eq!(pieces[1].offset_in_concat, 10);
1534                assert_eq!(pieces[1].length, 15);
1535            }
1536            _ => panic!("Expected Concat mapping"),
1537        }
1538    }
1539
1540    #[test]
1541    fn test_combine_two_sources() {
1542        let file_id = FileId(0);
1543
1544        // Create two separate source info objects
1545        let info1 = SourceInfo::from_range(
1546            file_id,
1547            Range {
1548                start: Location {
1549                    offset: 0,
1550                    row: 0,
1551                    column: 0,
1552                },
1553                end: Location {
1554                    offset: 10,
1555                    row: 0,
1556                    column: 10,
1557                },
1558            },
1559        );
1560
1561        let info2 = SourceInfo::from_range(
1562            file_id,
1563            Range {
1564                start: Location {
1565                    offset: 15,
1566                    row: 0,
1567                    column: 15,
1568                },
1569                end: Location {
1570                    offset: 25,
1571                    row: 0,
1572                    column: 25,
1573                },
1574            },
1575        );
1576
1577        // Combine them
1578        let combined = info1.combine(&info2);
1579
1580        // Should create a Concat with total length = 10 + 10 = 20
1581        assert_eq!(combined.start_offset(), 0);
1582        assert_eq!(combined.end_offset(), 20);
1583        assert_eq!(combined.length(), 20);
1584
1585        match combined {
1586            SourceInfo::Concat { pieces } => {
1587                assert_eq!(pieces.len(), 2);
1588                assert_eq!(pieces[0].length, 10);
1589                assert_eq!(pieces[0].offset_in_concat, 0);
1590                assert_eq!(pieces[1].length, 10);
1591                assert_eq!(pieces[1].offset_in_concat, 10);
1592            }
1593            _ => panic!("Expected Concat mapping"),
1594        }
1595    }
1596
1597    #[test]
1598    fn test_combine_preserves_source_tracking() {
1599        // Combine sources from different files
1600        let file_id1 = FileId(5);
1601        let file_id2 = FileId(10);
1602
1603        let info1 = SourceInfo::from_range(
1604            file_id1,
1605            Range {
1606                start: Location {
1607                    offset: 100,
1608                    row: 5,
1609                    column: 0,
1610                },
1611                end: Location {
1612                    offset: 105,
1613                    row: 5,
1614                    column: 5,
1615                },
1616            },
1617        );
1618
1619        let info2 = SourceInfo::from_range(
1620            file_id2,
1621            Range {
1622                start: Location {
1623                    offset: 200,
1624                    row: 10,
1625                    column: 0,
1626                },
1627                end: Location {
1628                    offset: 207,
1629                    row: 10,
1630                    column: 7,
1631                },
1632            },
1633        );
1634
1635        let combined = info1.combine(&info2);
1636
1637        // Verify both sources are preserved in the Concat
1638        match combined {
1639            SourceInfo::Concat { pieces } => {
1640                assert_eq!(pieces.len(), 2);
1641
1642                // First piece should come from file_id1
1643                match &pieces[0].source_info {
1644                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, file_id1),
1645                    _ => panic!("Expected Original mapping for first piece"),
1646                }
1647
1648                // Second piece should come from file_id2
1649                match &pieces[1].source_info {
1650                    SourceInfo::Original { file_id, .. } => assert_eq!(*file_id, file_id2),
1651                    _ => panic!("Expected Original mapping for second piece"),
1652                }
1653            }
1654            _ => panic!("Expected Concat mapping"),
1655        }
1656    }
1657
1658    /// Test JSON serialization of Original mapping
1659    #[test]
1660    fn test_json_serialization_original() {
1661        let file_id = FileId(0);
1662        let range = Range {
1663            start: Location {
1664                offset: 10,
1665                row: 1,
1666                column: 5,
1667            },
1668            end: Location {
1669                offset: 50,
1670                row: 3,
1671                column: 10,
1672            },
1673        };
1674
1675        let info = SourceInfo::from_range(file_id, range);
1676        let json = serde_json::to_value(&info).unwrap();
1677
1678        // Verify JSON structure
1679        assert_eq!(json["Original"]["file_id"], 0);
1680        assert_eq!(json["Original"]["start_offset"], 10);
1681        assert_eq!(json["Original"]["end_offset"], 50);
1682
1683        // Verify round-trip
1684        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1685        assert_eq!(info, deserialized);
1686    }
1687
1688    /// Test JSON serialization of Substring mapping
1689    #[test]
1690    fn test_json_serialization_substring() {
1691        let file_id = FileId(0);
1692        let parent_range = Range {
1693            start: Location {
1694                offset: 0,
1695                row: 0,
1696                column: 0,
1697            },
1698            end: Location {
1699                offset: 100,
1700                row: 5,
1701                column: 20,
1702            },
1703        };
1704        let parent = SourceInfo::from_range(file_id, parent_range);
1705
1706        let substring = SourceInfo::substring(parent, 10, 30);
1707        let json = serde_json::to_value(&substring).unwrap();
1708
1709        // Verify JSON structure
1710        assert_eq!(json["Substring"]["start_offset"], 10);
1711        assert_eq!(json["Substring"]["end_offset"], 30);
1712
1713        // Verify parent is serialized (with Rc, it's a full copy in JSON)
1714        assert!(json["Substring"]["parent"].is_object());
1715        assert_eq!(json["Substring"]["parent"]["Original"]["file_id"], 0);
1716
1717        // Verify round-trip
1718        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1719        assert_eq!(substring, deserialized);
1720    }
1721
1722    /// Test JSON serialization of nested Substring mappings (simulates .qmd frontmatter)
1723    #[test]
1724    fn test_json_serialization_nested_substring() {
1725        let file_id = FileId(0);
1726
1727        // Level 1: Original file
1728        let file_range = Range {
1729            start: Location {
1730                offset: 0,
1731                row: 0,
1732                column: 0,
1733            },
1734            end: Location {
1735                offset: 200,
1736                row: 10,
1737                column: 0,
1738            },
1739        };
1740        let file_info = SourceInfo::from_range(file_id, file_range);
1741
1742        // Level 2: YAML frontmatter (substring of file)
1743        let yaml_info = SourceInfo::substring(file_info, 4, 150);
1744
1745        // Level 3: YAML value (substring of frontmatter)
1746        let value_info = SourceInfo::substring(yaml_info, 20, 35);
1747
1748        let json = serde_json::to_value(&value_info).unwrap();
1749
1750        // Verify nested structure
1751        assert_eq!(json["Substring"]["start_offset"], 20);
1752        assert_eq!(json["Substring"]["end_offset"], 35);
1753        assert_eq!(json["Substring"]["parent"]["Substring"]["start_offset"], 4);
1754        assert_eq!(
1755            json["Substring"]["parent"]["Substring"]["parent"]["Original"]["file_id"],
1756            0
1757        );
1758
1759        // Verify round-trip
1760        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1761        assert_eq!(value_info, deserialized);
1762    }
1763
1764    /// Test JSON serialization of Concat mapping
1765    #[test]
1766    fn test_json_serialization_concat() {
1767        let file_id1 = FileId(0);
1768        let file_id2 = FileId(1);
1769
1770        let info1 = SourceInfo::from_range(
1771            file_id1,
1772            Range {
1773                start: Location {
1774                    offset: 0,
1775                    row: 0,
1776                    column: 0,
1777                },
1778                end: Location {
1779                    offset: 10,
1780                    row: 0,
1781                    column: 10,
1782                },
1783            },
1784        );
1785
1786        let info2 = SourceInfo::from_range(
1787            file_id2,
1788            Range {
1789                start: Location {
1790                    offset: 20,
1791                    row: 2,
1792                    column: 0,
1793                },
1794                end: Location {
1795                    offset: 30,
1796                    row: 2,
1797                    column: 10,
1798                },
1799            },
1800        );
1801
1802        let combined = info1.combine(&info2);
1803        let json = serde_json::to_value(&combined).unwrap();
1804
1805        // Verify JSON structure
1806        assert!(json["Concat"]["pieces"].is_array());
1807        let pieces = json["Concat"]["pieces"].as_array().unwrap();
1808        assert_eq!(pieces.len(), 2);
1809
1810        // First piece
1811        assert_eq!(pieces[0]["offset_in_concat"], 0);
1812        assert_eq!(pieces[0]["length"], 10);
1813        assert_eq!(pieces[0]["source_info"]["Original"]["file_id"], 0);
1814
1815        // Second piece
1816        assert_eq!(pieces[1]["offset_in_concat"], 10);
1817        assert_eq!(pieces[1]["length"], 10);
1818        assert_eq!(pieces[1]["source_info"]["Original"]["file_id"], 1);
1819
1820        // Verify round-trip
1821        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1822        assert_eq!(combined, deserialized);
1823    }
1824
1825    /// Test JSON serialization of complex nested structure (real-world example)
1826    #[test]
1827    fn test_json_serialization_complex_nested() {
1828        let file_id = FileId(0);
1829
1830        // Simulate a .qmd file structure
1831        let qmd_file = SourceInfo::from_range(
1832            file_id,
1833            Range {
1834                start: Location {
1835                    offset: 0,
1836                    row: 0,
1837                    column: 0,
1838                },
1839                end: Location {
1840                    offset: 500,
1841                    row: 20,
1842                    column: 0,
1843                },
1844            },
1845        );
1846
1847        // YAML frontmatter is a substring
1848        let yaml_frontmatter = SourceInfo::substring(qmd_file.clone(), 4, 200);
1849
1850        // A YAML key is a substring of frontmatter
1851        let yaml_key = SourceInfo::substring(yaml_frontmatter.clone(), 10, 20);
1852
1853        // A YAML value is another substring of frontmatter
1854        let yaml_value = SourceInfo::substring(yaml_frontmatter, 25, 50);
1855
1856        // Combine key and value (simulating metadata entry)
1857        let combined = yaml_key.combine(&yaml_value);
1858
1859        let json = serde_json::to_value(&combined).unwrap();
1860
1861        // Verify this complex structure serializes
1862        assert!(json.is_object());
1863        assert!(json["Concat"].is_object());
1864
1865        // Verify round-trip
1866        let deserialized: SourceInfo = serde_json::from_value(json).unwrap();
1867        assert_eq!(combined, deserialized);
1868    }
1869
1870    // -------------------------------------------------------------------------
1871    // Plan 7 — preimage_in accessor
1872    // -------------------------------------------------------------------------
1873
1874    #[test]
1875    fn test_preimage_in_original_same_file() {
1876        let info = SourceInfo::original(FileId(0), 10, 25);
1877        assert_eq!(info.preimage_in(FileId(0)), Some(10..25));
1878    }
1879
1880    #[test]
1881    fn test_preimage_in_original_different_file_returns_none() {
1882        let info = SourceInfo::original(FileId(0), 10, 25);
1883        assert_eq!(info.preimage_in(FileId(1)), None);
1884    }
1885
1886    #[test]
1887    fn test_preimage_in_substring_composes_offsets() {
1888        // Parent points at bytes 100..200 in file 0.
1889        // Substring takes bytes 5..15 *relative to parent*.
1890        // Preimage in file 0 should be 105..115.
1891        let parent = SourceInfo::original(FileId(0), 100, 200);
1892        let info = SourceInfo::substring(parent, 5, 15);
1893        assert_eq!(info.preimage_in(FileId(0)), Some(105..115));
1894    }
1895
1896    #[test]
1897    fn test_preimage_in_substring_different_file_returns_none() {
1898        let parent = SourceInfo::original(FileId(0), 100, 200);
1899        let info = SourceInfo::substring(parent, 5, 15);
1900        assert_eq!(info.preimage_in(FileId(7)), None);
1901    }
1902
1903    #[test]
1904    fn test_preimage_in_substring_chain() {
1905        // Original 1000..2000 in file 0; Substring 100..500 relative; Substring 10..50 relative.
1906        // Expected preimage in file 0: 1100 + 10 .. 1100 + 50 = 1110..1150.
1907        let root = SourceInfo::original(FileId(0), 1000, 2000);
1908        let mid = SourceInfo::substring(root, 100, 500);
1909        let leaf = SourceInfo::substring(mid, 10, 50);
1910        assert_eq!(leaf.preimage_in(FileId(0)), Some(1110..1150));
1911    }
1912
1913    #[test]
1914    fn test_preimage_in_concat_contiguous() {
1915        // Two adjacent pieces of file 0: 10..15 and 15..25 → contiguous → 10..25.
1916        let a = SourceInfo::original(FileId(0), 10, 15);
1917        let b = SourceInfo::original(FileId(0), 15, 25);
1918        let info = SourceInfo::concat(vec![(a, 5), (b, 10)]);
1919        assert_eq!(info.preimage_in(FileId(0)), Some(10..25));
1920    }
1921
1922    #[test]
1923    fn test_preimage_in_concat_gappy_returns_none() {
1924        // 10..15 then 20..25 → gap between 15 and 20 → None.
1925        let a = SourceInfo::original(FileId(0), 10, 15);
1926        let b = SourceInfo::original(FileId(0), 20, 25);
1927        let info = SourceInfo::concat(vec![(a, 5), (b, 5)]);
1928        assert_eq!(info.preimage_in(FileId(0)), None);
1929    }
1930
1931    #[test]
1932    fn test_preimage_in_concat_overlapping_returns_none() {
1933        // 10..20 then 15..25 → overlap → not byte-contiguous → None.
1934        let a = SourceInfo::original(FileId(0), 10, 20);
1935        let b = SourceInfo::original(FileId(0), 15, 25);
1936        let info = SourceInfo::concat(vec![(a, 10), (b, 10)]);
1937        assert_eq!(info.preimage_in(FileId(0)), None);
1938    }
1939
1940    #[test]
1941    fn test_preimage_in_concat_mixed_files_returns_none() {
1942        // One piece in file 0, another in file 1 → resolving in file 0 fails
1943        // because the file-1 piece can't be resolved.
1944        let a = SourceInfo::original(FileId(0), 10, 15);
1945        let b = SourceInfo::original(FileId(1), 15, 25);
1946        let info = SourceInfo::concat(vec![(a, 5), (b, 10)]);
1947        assert_eq!(info.preimage_in(FileId(0)), None);
1948    }
1949
1950    #[test]
1951    fn test_preimage_in_generated_no_anchors_returns_none() {
1952        // Sectionize-style wrapper, footnotes-container, etc.: Generated with
1953        // empty `from`. No Invocation anchor → no preimage.
1954        let info = SourceInfo::generated(By::sectionize());
1955        assert_eq!(info.preimage_in(FileId(0)), None);
1956    }
1957
1958    #[test]
1959    fn test_preimage_in_generated_with_invocation_in_target() {
1960        // Shortcode resolution: Generated with an Invocation anchor pointing
1961        // at the {{< meta foo >}} token bytes.
1962        let token = SourceInfo::original(FileId(0), 50, 70);
1963        let mut info = SourceInfo::generated(By::shortcode("meta"));
1964        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
1965        assert_eq!(info.preimage_in(FileId(0)), Some(50..70));
1966    }
1967
1968    #[test]
1969    fn test_preimage_in_generated_with_invocation_outside_target() {
1970        // Invocation anchor points at file 0; query asks about file 1 → None.
1971        let token = SourceInfo::original(FileId(0), 50, 70);
1972        let mut info = SourceInfo::generated(By::shortcode("meta"));
1973        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
1974        assert_eq!(info.preimage_in(FileId(1)), None);
1975    }
1976
1977    #[test]
1978    fn test_preimage_in_generated_walks_through_substring_in_invocation() {
1979        // Invocation anchor is itself a Substring chain. preimage_in must
1980        // walk through it correctly.
1981        let root = SourceInfo::original(FileId(0), 100, 200);
1982        let token = SourceInfo::substring(root, 10, 30);
1983        let mut info = SourceInfo::generated(By::shortcode("meta"));
1984        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
1985        assert_eq!(info.preimage_in(FileId(0)), Some(110..130));
1986    }
1987
1988    // -------------------------------------------------------------------------
1989    // Plan 7 — preimage_in role-asymmetry: only Invocation is walked.
1990    // -------------------------------------------------------------------------
1991
1992    #[test]
1993    fn test_preimage_in_generated_value_source_only_returns_none() {
1994        // Plan 9-shape: Generated whose only anchor is ValueSource (points at
1995        // YAML metadata bytes). The writer must NOT copy those bytes into the
1996        // body — preimage_in returns None.
1997        let meta_si = SourceInfo::original(FileId(0), 10, 25);
1998        let mut info = SourceInfo::generated(By::appendix());
1999        info.append_anchor(AnchorRole::ValueSource, Arc::new(meta_si));
2000        assert_eq!(info.preimage_in(FileId(0)), None);
2001    }
2002
2003    #[test]
2004    fn test_preimage_in_generated_other_only_returns_none() {
2005        // Extension-defined Other role. preimage_in must not walk it.
2006        let lua_si = SourceInfo::original(FileId(0), 10, 25);
2007        let mut info = SourceInfo::generated(By::filter("upper.lua", 14));
2008        info.append_anchor(
2009            AnchorRole::Other("ext/my-ext/dispatch".to_string()),
2010            Arc::new(lua_si),
2011        );
2012        assert_eq!(info.preimage_in(FileId(0)), None);
2013    }
2014
2015    #[test]
2016    fn test_preimage_in_generated_invocation_plus_value_source_walks_invocation_only() {
2017        // Plan 2/Plan 9 mixed shape: Invocation in file 0 + ValueSource in
2018        // file 1. Query file 0 → Invocation resolves → Some(token range).
2019        // Query file 1 → Invocation resolves to file 0 (not 1) → None.
2020        // (The writer must not see the value-source range when asked about
2021        // any file, even the file the ValueSource points into.)
2022        let token = SourceInfo::original(FileId(0), 50, 70);
2023        let value = SourceInfo::original(FileId(1), 200, 215);
2024        let mut info = SourceInfo::generated(By::shortcode("meta"));
2025        info.append_anchor(AnchorRole::Invocation, Arc::new(token));
2026        info.append_anchor(AnchorRole::ValueSource, Arc::new(value));
2027
2028        assert_eq!(info.preimage_in(FileId(0)), Some(50..70));
2029        assert_eq!(info.preimage_in(FileId(1)), None);
2030    }
2031}