Skip to main content

lex_core/lex/builtins/
include.rs

1//! `LexIncludeHandler` — the first built-in [`LexHandler`].
2//!
3//! Wraps the existing [`Loader`] + [`parse_no_attach`] + [`stamp_doc`]
4//! pipeline so that `lex.include` runs through the registry-driven
5//! dispatch fabric the rest of the extension system uses. The
6//! observable behaviour matches the legacy inline path in
7//! [`crate::lex::includes::resolve_from_source`]: same parameter
8//! syntax, same path-resolution rules, same `FsLoader` security
9//! defenses (path traversal, symlink loop, size limit, root escape,
10//! absolute-path rejection).
11//!
12//! # Lifecycle
13//!
14//! In α (this PR — lex-fmt/lex#532), the handler is registrable but
15//! the resolve pass keeps using the inline path. PR 3d
16//! (lex-fmt/lex#533) flips the call site so this handler runs in
17//! production. That gives us a clean separation between *handler is
18//! correct* (proven here) and *resolve pass dispatches via the
19//! registry* (proven there).
20//!
21//! # Error mapping
22//!
23//! Loader errors map onto `HandlerError::Custom` with codes in the
24//! handler-defined `-32000..=-32099` range reserved by the wire spec
25//! §5. `Loader::load` failures and path-resolution failures all
26//! become diagnostics at the labelled node's range when surfaced
27//! through `Registry::dispatch_resolve`.
28
29use std::path::Path;
30use std::sync::Arc;
31
32use lex_extension::{
33    handler::{HandlerError, LexHandler},
34    wire::{LabelCtx, WireNode},
35};
36
37use crate::lex::includes::{
38    parse_no_attach, resolve_file_reference, stamp_doc, IncludeError, LoadError, LoadedFile,
39    Loader, ResolveConfig,
40};
41use crate::lex::wire::to_wire_document;
42
43/// Error code: `lex.include` annotation was missing the required `src`
44/// parameter. Matches the wire spec's handler-defined range.
45pub const CODE_MISSING_SRC: i32 = -32000;
46/// Error code: `Loader::load` returned `NotFound`.
47pub const CODE_NOT_FOUND: i32 = -32001;
48/// Error code: include path canonicalised outside the loader's root,
49/// or the resolver rejected it pre-load as a root escape.
50pub const CODE_OUTSIDE_ROOT: i32 = -32002;
51/// Error code: include target exceeded the loader's size cap.
52pub const CODE_TOO_LARGE: i32 = -32003;
53/// Error code: include path was a platform-absolute path
54/// (`C:\foo`, `/abs` on Unix), which the resolver rejects pre-load.
55pub const CODE_ABSOLUTE_PATH: i32 = -32004;
56/// Error code: underlying I/O error during load.
57pub const CODE_IO: i32 = -32005;
58/// Error code: `parse_no_attach` rejected the loaded source.
59/// Carries `data: { "path": <canonical_path>, "message": <parser msg> }`.
60pub const CODE_PARSE_FAILED: i32 = -32006;
61
62/// Function-pointer type for the parse step. Tests can substitute a
63/// stub via [`LexIncludeHandler::with_parse_fn`] to deterministically
64/// exercise the parse-error mapping without depending on which inputs
65/// the (permissive) lex parser happens to reject.
66pub(crate) type ParseFn = fn(&str) -> Result<crate::lex::ast::Document, String>;
67
68/// Built-in handler for the `lex.include` label.
69pub struct LexIncludeHandler {
70    loader: Arc<dyn Loader + Send + Sync>,
71    config: ResolveConfig,
72    parse_fn: ParseFn,
73}
74
75impl LexIncludeHandler {
76    /// Construct a handler from a loader (typically [`crate::lex::includes::FsLoader`]
77    /// in production, [`crate::lex::includes::MemoryLoader`] in tests)
78    /// and a resolve config bundling the resolution `root` plus depth /
79    /// total-include caps.
80    ///
81    /// Depth and total-include limits are not enforced by the handler
82    /// itself; they belong to the resolve-pass walker that wraps
83    /// dispatches across the document. The handler stores the config
84    /// so that future hooks (validate, render) can read its limits
85    /// without an additional indirection.
86    pub fn new(loader: Arc<dyn Loader + Send + Sync>, config: ResolveConfig) -> Self {
87        Self {
88            loader,
89            config,
90            parse_fn: parse_no_attach,
91        }
92    }
93
94    /// Construct a handler with a custom parse function. Used by
95    /// tests to deterministically exercise the parse-error path; the
96    /// production constructor [`Self::new`] uses
97    /// [`parse_no_attach`].
98    #[cfg(test)]
99    pub(crate) fn with_parse_fn(
100        loader: Arc<dyn Loader + Send + Sync>,
101        config: ResolveConfig,
102        parse_fn: ParseFn,
103    ) -> Self {
104        Self {
105            loader,
106            config,
107            parse_fn,
108        }
109    }
110
111    /// Read-only access to the resolution root the handler was built
112    /// with. Useful for tests and for the resolve pass that wires
113    /// this handler into a registry.
114    pub fn root(&self) -> &Path {
115        &self.config.root
116    }
117}
118
119impl LexHandler for LexIncludeHandler {
120    fn on_resolve(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
121        let src = extract_src(ctx)?;
122
123        // Path resolution against the host file's directory. When the
124        // host file's origin is unknown, resolution falls back to the
125        // configured root (per `resolve_file_reference`).
126        let host_origin = ctx.node.origin.as_deref().map(Path::new);
127        let target_path = resolve_file_reference(&src, host_origin, &self.config.root)
128            .map_err(|e| include_error_to_handler(&e))?;
129
130        // Load through the injected loader. Same security gate as the
131        // inline path: FsLoader canonicalises and bounds-checks against
132        // its canonical root post-symlink resolution.
133        let LoadedFile {
134            source,
135            canonical_path,
136        } = self
137            .loader
138            .load(&target_path)
139            .map_err(|e| load_error_to_handler(&e))?;
140
141        // Parse without annotation attachment — annotations stay
142        // visible as standalone children, matching what
143        // `resolve_from_source` does in the inline path. The parse
144        // function is injectable so tests can deterministically
145        // exercise the parse-error mapping; production uses
146        // `parse_no_attach`.
147        let mut included = (self.parse_fn)(&source).map_err(|message| HandlerError::Custom {
148            code: CODE_PARSE_FAILED,
149            message: format!("parse of `{}` failed: {message}", canonical_path.display()),
150            data: Some(serde_json::json!({
151                "path": canonical_path.display().to_string(),
152                "message": message,
153            })),
154        })?;
155
156        // Stamp every node's `Range.origin_path` with the loaded file's
157        // canonical path so downstream tooling (file-reference
158        // resolution, scoped footnote lookup) sees the right origin.
159        let origin = Arc::new(canonical_path);
160        stamp_doc(&mut included, &origin);
161
162        // Splice-equivalent normalisation: convert the included
163        // document's title and document-level annotations into leading
164        // children of the root session, mirroring the legacy
165        // `prepare_splice_list` semantics so PR 3d's call-site flip
166        // produces an identical observable splice.
167        promote_title_and_doc_annotations(&mut included);
168
169        let wire = to_wire_document(&included);
170        Ok(Some(wire))
171    }
172}
173
174/// Mutate `doc` in place so that its title (if any) and document-level
175/// annotations are prepended to the root session's children — the same
176/// transformation `lex/includes.rs::prepare_splice_list` does, but
177/// applied to a still-typed `Document` so the wire codec can walk it
178/// uniformly.
179///
180/// Order matches the legacy splice list: title first, then
181/// `doc.annotations` in source order, then the original root children.
182fn promote_title_and_doc_annotations(doc: &mut crate::lex::ast::Document) {
183    use crate::lex::ast::elements::content_item::ContentItem;
184    use crate::lex::ast::elements::paragraph::Paragraph;
185
186    let mut prefix: Vec<ContentItem> = Vec::new();
187    if let Some(title) = doc.title.take() {
188        let location = title.location.clone();
189        let para = Paragraph::from_line(title.as_str().to_string()).at(location);
190        prefix.push(ContentItem::Paragraph(para));
191    }
192    for ann in doc.annotations.drain(..) {
193        prefix.push(ContentItem::Annotation(ann));
194    }
195    if !prefix.is_empty() {
196        let original = std::mem::take(doc.root.children.as_mut_vec());
197        let mut combined = prefix;
198        combined.extend(original);
199        *doc.root.children.as_mut_vec() = combined;
200    }
201}
202
203fn extract_src(ctx: &LabelCtx) -> Result<String, HandlerError> {
204    ctx.params
205        .get("src")
206        .and_then(|v| v.as_str())
207        .map(|s| s.to_string())
208        .ok_or_else(|| HandlerError::Custom {
209            code: CODE_MISSING_SRC,
210            message: format!(
211                "lex.include is missing required `src` parameter; got params: {}",
212                ctx.params
213            ),
214            data: None,
215        })
216}
217
218fn load_error_to_handler(err: &LoadError) -> HandlerError {
219    match err {
220        LoadError::NotFound { path } => HandlerError::Custom {
221            code: CODE_NOT_FOUND,
222            message: format!("include not found: {}", path.display()),
223            data: Some(serde_json::json!({ "path": path.display().to_string() })),
224        },
225        LoadError::OutsideRoot { path, root } => HandlerError::Custom {
226            code: CODE_OUTSIDE_ROOT,
227            message: format!(
228                "include path {} resolves outside loader root {}",
229                path.display(),
230                root.display()
231            ),
232            data: Some(serde_json::json!({
233                "path": path.display().to_string(),
234                "root": root.display().to_string(),
235            })),
236        },
237        LoadError::TooLarge { path, size, limit } => HandlerError::Custom {
238            code: CODE_TOO_LARGE,
239            message: format!(
240                "include file {} is {size} bytes, exceeds limit of {limit} bytes",
241                path.display()
242            ),
243            data: Some(serde_json::json!({
244                "path": path.display().to_string(),
245                "size": size,
246                "limit": limit,
247            })),
248        },
249        LoadError::Io { path, message } => HandlerError::Custom {
250            code: CODE_IO,
251            message: format!("io error reading {}: {message}", path.display()),
252            data: Some(serde_json::json!({ "path": path.display().to_string() })),
253        },
254    }
255}
256
257fn include_error_to_handler(err: &IncludeError) -> HandlerError {
258    match err {
259        IncludeError::AbsolutePath { path } => HandlerError::Custom {
260            code: CODE_ABSOLUTE_PATH,
261            message: format!(
262                "lex.include `src` rejected: {} is a platform-absolute path",
263                path.display()
264            ),
265            data: Some(serde_json::json!({ "path": path.display().to_string() })),
266        },
267        IncludeError::RootEscape { path, root } => HandlerError::Custom {
268            code: CODE_OUTSIDE_ROOT,
269            message: format!(
270                "include path {} resolves outside resolution root {}",
271                path.display(),
272                root.display()
273            ),
274            data: Some(serde_json::json!({
275                "path": path.display().to_string(),
276                "root": root.display().to_string(),
277            })),
278        },
279        // `resolve_file_reference` only ever returns `AbsolutePath` or
280        // `RootEscape` — the other `IncludeError` variants come from
281        // the resolve-pass walker, not from path resolution. Treat
282        // them as internal here so a future change in the resolver
283        // doesn't silently produce a misleading custom code.
284        other => HandlerError::internal(format!("path resolution failed: {other}")),
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::lex::includes::{LoadError, LoadedFile, MemoryLoader};
292    use lex_extension::wire::{AnnotationBody, NodeRef, Position, Range};
293    use std::path::PathBuf;
294
295    fn make_ctx(src: &str, host_origin: Option<&str>) -> LabelCtx {
296        LabelCtx {
297            label: "lex.include".into(),
298            params: serde_json::json!({ "src": src }),
299            body: AnnotationBody::None,
300            node: NodeRef {
301                kind: "annotation".into(),
302                range: Range {
303                    start: Position(0, 0),
304                    end: Position(0, 0),
305                },
306                origin: host_origin.map(|s| s.to_string()),
307            },
308        }
309    }
310
311    fn handler_with_loader(loader: MemoryLoader, root: PathBuf) -> LexIncludeHandler {
312        LexIncludeHandler::new(Arc::new(loader), ResolveConfig::with_root(root))
313    }
314
315    #[test]
316    fn happy_path_returns_wire_document() {
317        let mut loader = MemoryLoader::new();
318        loader.insert(
319            PathBuf::from("/root/included.lex"),
320            "Hello from included.\n",
321        );
322        let handler = handler_with_loader(loader, PathBuf::from("/root"));
323
324        let ctx = make_ctx("included.lex", Some("/root/host.lex"));
325        let result = handler.on_resolve(&ctx).expect("on_resolve ok");
326        let wire = result.expect("returned Some(WireNode)");
327
328        // Top-level result is a WireNode::Document.
329        let WireNode::Document {
330            children, origin, ..
331        } = wire
332        else {
333            panic!("expected WireNode::Document, got something else");
334        };
335        // Origin should reflect the *included* file (canonical_path),
336        // because stamp_doc walks the loaded tree and the wire codec
337        // lifts origin_path from the root session's range.
338        assert_eq!(origin.as_deref(), Some("/root/included.lex"));
339        // The single paragraph from the included source must survive
340        // the round trip.
341        assert!(
342            !children.is_empty(),
343            "included document children must reach the wire payload"
344        );
345    }
346
347    #[test]
348    fn missing_src_returns_custom_error() {
349        let loader = MemoryLoader::new();
350        let handler = handler_with_loader(loader, PathBuf::from("/root"));
351        let mut ctx = make_ctx("ignored", None);
352        ctx.params = serde_json::json!({});
353        let err = handler.on_resolve(&ctx).expect_err("must error");
354        match err {
355            HandlerError::Custom { code, .. } => {
356                assert_eq!(code, CODE_MISSING_SRC);
357            }
358            other => panic!("expected Custom code, got {other:?}"),
359        }
360    }
361
362    #[test]
363    fn not_found_maps_to_code_minus_32001() {
364        let loader = MemoryLoader::new();
365        let handler = handler_with_loader(loader, PathBuf::from("/root"));
366        let ctx = make_ctx("missing.lex", Some("/root/host.lex"));
367        let err = handler.on_resolve(&ctx).expect_err("must error");
368        match err {
369            HandlerError::Custom { code, .. } => assert_eq!(code, CODE_NOT_FOUND),
370            other => panic!("expected NotFound→Custom, got {other:?}"),
371        }
372    }
373
374    #[test]
375    fn outside_root_via_resolver_maps_to_code_minus_32002() {
376        let loader = MemoryLoader::new();
377        let handler = handler_with_loader(loader, PathBuf::from("/root"));
378        // ../../../etc/passwd would normalise outside `/root`, so the
379        // resolver returns `RootEscape` before any load attempt.
380        let ctx = make_ctx("../../../etc/passwd", Some("/root/host.lex"));
381        let err = handler.on_resolve(&ctx).expect_err("must error");
382        match err {
383            HandlerError::Custom { code, .. } => assert_eq!(code, CODE_OUTSIDE_ROOT),
384            other => panic!("expected RootEscape→Custom, got {other:?}"),
385        }
386    }
387
388    #[test]
389    fn absolute_path_maps_to_code_minus_32004() {
390        let loader = MemoryLoader::new();
391        let handler = handler_with_loader(loader, PathBuf::from("/root"));
392        // Platform-absolute path on Unix; the resolver rejects up front
393        // before any load. (`/x` would normalise as `root-absolute` per
394        // Lex spec; we use a Windows-style path so the platform-
395        // absolute check fires regardless of OS.)
396        #[cfg(windows)]
397        let absolute = "C:\\Windows\\System32\\drivers\\etc\\hosts";
398        #[cfg(not(windows))]
399        let absolute = "//absolute/elsewhere"; // double-slash → host on UNC; treated as absolute
400        let ctx = make_ctx(absolute, Some("/root/host.lex"));
401        let err = handler.on_resolve(&ctx).expect_err("must error");
402        // On platforms where `Path::is_absolute(absolute)` returns true
403        // we expect AbsolutePath (-32004); otherwise we expect
404        // OutsideRoot (-32002). Both are valid security outcomes.
405        match err {
406            HandlerError::Custom { code, .. } => {
407                assert!(
408                    code == CODE_ABSOLUTE_PATH || code == CODE_OUTSIDE_ROOT,
409                    "expected -32002 or -32004, got {code}"
410                );
411            }
412            other => panic!("expected Custom code, got {other:?}"),
413        }
414    }
415
416    #[test]
417    fn loader_outside_root_maps_to_code_minus_32002() {
418        // A loader that itself returns OutsideRoot (e.g., FsLoader
419        // catching a symlink escape post-canonicalisation). Simulate
420        // this with a custom mock loader.
421        struct MockEscape;
422        impl Loader for MockEscape {
423            fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
424                Err(LoadError::OutsideRoot {
425                    path: path.to_path_buf(),
426                    root: PathBuf::from("/root"),
427                })
428            }
429        }
430        let handler = LexIncludeHandler::new(
431            Arc::new(MockEscape),
432            ResolveConfig::with_root(PathBuf::from("/root")),
433        );
434        let ctx = make_ctx("inner.lex", Some("/root/host.lex"));
435        let err = handler.on_resolve(&ctx).expect_err("must error");
436        match err {
437            HandlerError::Custom { code, .. } => assert_eq!(code, CODE_OUTSIDE_ROOT),
438            other => panic!("expected OutsideRoot→Custom, got {other:?}"),
439        }
440    }
441
442    #[test]
443    fn too_large_maps_to_code_minus_32003() {
444        struct MockTooLarge;
445        impl Loader for MockTooLarge {
446            fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
447                Err(LoadError::TooLarge {
448                    path: path.to_path_buf(),
449                    size: 1_000_000,
450                    limit: 100,
451                })
452            }
453        }
454        let handler = LexIncludeHandler::new(
455            Arc::new(MockTooLarge),
456            ResolveConfig::with_root(PathBuf::from("/root")),
457        );
458        let ctx = make_ctx("big.lex", Some("/root/host.lex"));
459        let err = handler.on_resolve(&ctx).expect_err("must error");
460        match err {
461            HandlerError::Custom { code, data, .. } => {
462                assert_eq!(code, CODE_TOO_LARGE);
463                let data = data.expect("data attached");
464                assert_eq!(data["size"], 1_000_000);
465                assert_eq!(data["limit"], 100);
466            }
467            other => panic!("expected TooLarge→Custom, got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn io_error_maps_to_code_minus_32005() {
473        struct MockIo;
474        impl Loader for MockIo {
475            fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
476                Err(LoadError::Io {
477                    path: path.to_path_buf(),
478                    message: "permission denied".into(),
479                })
480            }
481        }
482        let handler = LexIncludeHandler::new(
483            Arc::new(MockIo),
484            ResolveConfig::with_root(PathBuf::from("/root")),
485        );
486        let ctx = make_ctx("locked.lex", Some("/root/host.lex"));
487        let err = handler.on_resolve(&ctx).expect_err("must error");
488        match err {
489            HandlerError::Custom { code, .. } => assert_eq!(code, CODE_IO),
490            other => panic!("expected Io→Custom, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn parse_failure_maps_to_custom_parse_failed() {
496        // Deterministic test of the parse-failure → HandlerError
497        // mapping. The lex parser is permissive — most malformed
498        // inputs parse to *something* — so finding a fixture that
499        // reliably trips `parse_no_attach` is brittle. Instead we
500        // inject a stub parser that always returns `Err` (via
501        // `LexIncludeHandler::with_parse_fn`) and assert the handler
502        // maps that error onto `HandlerError::Custom` with
503        // `code = CODE_PARSE_FAILED` and a structured `data`
504        // payload carrying the canonical path and underlying parser
505        // message — the resolve pass destructures these fields to
506        // reconstruct `IncludeError::ParseFailed`.
507        fn always_fails(_source: &str) -> Result<crate::lex::ast::Document, String> {
508            Err("synthetic parser failure".into())
509        }
510
511        let mut loader = MemoryLoader::new();
512        loader.insert(PathBuf::from("/root/broken.lex"), "anything\n");
513        let handler = LexIncludeHandler::with_parse_fn(
514            Arc::new(loader),
515            ResolveConfig::with_root(PathBuf::from("/root")),
516            always_fails,
517        );
518        let ctx = make_ctx("broken.lex", Some("/root/host.lex"));
519        let err = handler.on_resolve(&ctx).expect_err("must error");
520        match err {
521            HandlerError::Custom { code, data, .. } => {
522                assert_eq!(code, CODE_PARSE_FAILED);
523                let data = data.expect("parse-failure data must be attached");
524                assert_eq!(
525                    data["path"].as_str().expect("path field"),
526                    "/root/broken.lex",
527                    "data.path must carry the canonical path"
528                );
529                assert_eq!(
530                    data["message"].as_str().expect("message field"),
531                    "synthetic parser failure",
532                    "data.message must carry the underlying parser message"
533                );
534            }
535            other => panic!("expected Custom CODE_PARSE_FAILED, got {other:?}"),
536        }
537    }
538
539    #[test]
540    fn included_document_title_and_annotations_are_promoted_to_leading_children() {
541        // Locks the `prepare_splice_list`-equivalent semantics: a
542        // titled and document-annotated include must produce wire
543        // children whose leading entries are the title (as a
544        // Paragraph) and each document-level annotation. This is the
545        // observable contract PR 3d's call-site flip relies on to
546        // avoid a behaviour change in the existing integration suite.
547        use crate::lex::ast::elements::content_item::ContentItem;
548        use crate::lex::wire::from_wire_node;
549
550        let mut loader = MemoryLoader::new();
551        // Source with a document title, a document-level annotation,
552        // and one body paragraph.
553        loader.insert(
554            PathBuf::from("/root/titled.lex"),
555            ":: meta author=alice ::\n\
556             Document Title\n\
557             \n\
558             Body paragraph.\n",
559        );
560        let handler = handler_with_loader(loader, PathBuf::from("/root"));
561        let ctx = make_ctx("titled.lex", Some("/root/host.lex"));
562        let wire = handler
563            .on_resolve(&ctx)
564            .expect("on_resolve ok")
565            .expect("Some(WireNode)");
566
567        let items = from_wire_node(&wire).expect("from_wire ok");
568        // Find the indices of the first paragraph and the first
569        // annotation in the recovered list.
570        let first_paragraph = items
571            .iter()
572            .position(|i| matches!(i, ContentItem::Paragraph(_)));
573        let first_annotation = items
574            .iter()
575            .position(|i| matches!(i, ContentItem::Annotation(_)));
576        assert!(
577            first_paragraph.is_some(),
578            "title-as-paragraph must survive into the wire payload"
579        );
580        assert!(
581            first_annotation.is_some(),
582            "document-level annotation must survive into the wire payload"
583        );
584        // Verify the title appears as paragraph text. Either the
585        // title-Paragraph or the original body Paragraph satisfies
586        // this — what matters is that *some* recovered paragraph
587        // carries the title's text.
588        let title_present = items.iter().any(|i| match i {
589            ContentItem::Paragraph(p) => p.lines.iter().any(|li| match li {
590                ContentItem::TextLine(line) => line.content.as_string() == "Document Title",
591                _ => false,
592            }),
593            _ => false,
594        });
595        assert!(
596            title_present,
597            "Document.title must round-trip as a leading Paragraph"
598        );
599        // And the meta annotation must come through with its label.
600        let meta_present = items.iter().any(|i| match i {
601            ContentItem::Annotation(a) => a.data.label.value == "meta",
602            _ => false,
603        });
604        assert!(
605            meta_present,
606            "document-level :: meta :: annotation must round-trip"
607        );
608    }
609
610    #[test]
611    fn round_trip_via_from_wire_recovers_typed_ast() {
612        use crate::lex::ast::elements::content_item::ContentItem;
613        use crate::lex::wire::from_wire_node;
614
615        let mut loader = MemoryLoader::new();
616        loader.insert(PathBuf::from("/root/snippet.lex"), "First paragraph.\n");
617        let handler = handler_with_loader(loader, PathBuf::from("/root"));
618        let ctx = make_ctx("snippet.lex", Some("/root/host.lex"));
619        let wire = handler
620            .on_resolve(&ctx)
621            .expect("on_resolve ok")
622            .expect("Some(WireNode)");
623
624        // The wire payload must round-trip through from_wire_node
625        // back into typed lex-core ContentItems — that's the
626        // contract PR 3d will rely on when splicing.
627        let items = from_wire_node(&wire).expect("from_wire ok");
628        assert!(
629            !items.is_empty(),
630            "from_wire on the included document must recover at least one item"
631        );
632        // The first paragraph must come through.
633        let saw_paragraph = items
634            .iter()
635            .any(|item| matches!(item, ContentItem::Paragraph(_)));
636        assert!(saw_paragraph, "included paragraph must survive round-trip");
637    }
638}