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