noyalib/include.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! `!include` directive support — compose YAML documents from
5//! multiple files via the `!include path/to/file.yaml` tag.
6//!
7//! Two layers:
8//!
9//! - **`include` feature** (this module's free-standing types) —
10//! defines `IncludeResolver`, `IncludeRequest`, and
11//! `InputSource`. The resolver is a `Send + Sync` closure
12//! stored on [`crate::ParserConfig`]; users wire it up via
13//! [`crate::ParserConfig::include_resolver`].
14//!
15//! - **`include_fs` feature** (`SafeFileResolver`) — a Unix
16//! capability-rooted filesystem implementation, with a Windows
17//! canonical-root fallback, symlink-policy enforcement
18//! (`SymlinkPolicy`), and max-depth cycle protection.
19//!
20//! Fragment anchors (`!include file.yaml#name`) resolve the named
21//! YAML anchor inside the included document and substitute its
22//! value rather than the whole document. Plain `!include
23//! file.yaml` substitutes the document root.
24//!
25//! Cyclic includes (A includes B includes A) are rejected via a
26//! per-resolution visited set; the depth ceiling
27//! [`crate::ParserConfig::max_include_depth`] (default 24)
28//! bounds the recursion.
29
30use crate::error::Result;
31use crate::prelude::*;
32
33#[cfg(feature = "include_fs")]
34mod fs;
35#[cfg(feature = "include_fs")]
36#[cfg_attr(docsrs, doc(cfg(feature = "include_fs")))]
37pub use fs::{SafeFileResolver, SymlinkPolicy};
38
39/// Describes one `!include` request the loader hands to the
40/// resolver.
41///
42/// The `spec` is the YAML scalar text after `!include` —
43/// typically a file path, possibly with a `#anchor` fragment.
44/// Resolvers are free to interpret the spec however they like
45/// (file path, URL, key in a virtual filesystem); the
46/// [`SafeFileResolver`] interprets it as a filesystem path.
47#[derive(Debug, Clone)]
48#[non_exhaustive]
49pub struct IncludeRequest<'a> {
50 /// The path / URL / identifier the user wrote after
51 /// `!include`. Includes the optional `#anchor` fragment.
52 pub spec: &'a str,
53 /// The source identifier of the document making this
54 /// request. The top-level document is `0`; nested includes
55 /// receive a fresh id from the parser.
56 pub from_id: usize,
57 /// Inclusion depth (0 = top-level document, 1 = first
58 /// nested include, …). Resolvers can refuse to resolve
59 /// beyond a certain depth or use this for diagnostics.
60 pub depth: usize,
61 /// The most bytes this source may hold: the smaller of
62 /// [`crate::ParserConfig::max_document_length`] and what remains
63 /// of [`crate::ParserConfig::max_total_include_bytes`]. The loader
64 /// refuses a longer source after the resolver returns, so a
65 /// resolver should stop reading one byte past this limit (as
66 /// [`SafeFileResolver`] does) rather than buffer the whole input.
67 pub max_bytes: usize,
68}
69
70/// What a resolver returns: the YAML text plus a stable
71/// identifier that downstream layers use for cycle detection
72/// and span-source attribution.
73///
74/// `name` is shown in diagnostic output — typically the
75/// canonicalised file path. `bytes` is the YAML text the loader
76/// will parse.
77#[derive(Debug, Clone)]
78#[non_exhaustive]
79pub struct InputSource {
80 /// Display name (file path, URL, …).
81 pub name: String,
82 /// The YAML text to parse.
83 pub bytes: String,
84}
85
86impl InputSource {
87 /// Construct a new [`InputSource`].
88 ///
89 /// # Examples
90 ///
91 /// ```
92 /// use noyalib::include::InputSource;
93 /// let s = InputSource::new("config.yaml", "k: 1\n");
94 /// assert_eq!(s.name, "config.yaml");
95 /// assert_eq!(s.bytes, "k: 1\n");
96 /// ```
97 #[must_use]
98 pub fn new(name: impl Into<String>, bytes: impl Into<String>) -> Self {
99 Self {
100 name: name.into(),
101 bytes: bytes.into(),
102 }
103 }
104}
105
106/// Resolver closure stored on [`crate::ParserConfig`].
107///
108/// Wraps an `Arc<dyn Fn>` so the type is `Clone + Debug` (the
109/// underlying `dyn Fn` is not). Construct with
110/// [`IncludeResolver::new`].
111///
112/// `Arc` (not `Box`) keeps configs cheap to clone. The closure
113/// is `Send + Sync` so the resolver can be invoked from any
114/// thread of a parallel parse.
115#[derive(Clone)]
116pub struct IncludeResolver(Arc<dyn Fn(IncludeRequest<'_>) -> Result<InputSource> + Send + Sync>);
117
118impl IncludeResolver {
119 /// Wrap a closure as an [`IncludeResolver`].
120 ///
121 /// # Examples
122 ///
123 /// ```
124 /// use noyalib::include::{IncludeRequest, IncludeResolver, InputSource};
125 /// use noyalib::Result;
126 /// let r = IncludeResolver::new(|req: IncludeRequest<'_>| -> Result<InputSource> {
127 /// Ok(InputSource::new(req.spec, "v: 1\n"))
128 /// });
129 /// let _ = r;
130 /// ```
131 #[must_use]
132 pub fn new<F>(f: F) -> Self
133 where
134 F: Fn(IncludeRequest<'_>) -> Result<InputSource> + Send + Sync + 'static,
135 {
136 Self(Arc::new(f))
137 }
138
139 /// Invoke the wrapped closure.
140 ///
141 /// # Errors
142 ///
143 /// Surfaces whatever the underlying resolver returned.
144 pub fn resolve(&self, req: IncludeRequest<'_>) -> Result<InputSource> {
145 (self.0)(req)
146 }
147}
148
149impl fmt::Debug for IncludeResolver {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 f.debug_struct("IncludeResolver")
152 .field("ptr", &Arc::as_ptr(&self.0))
153 .finish()
154 }
155}
156
157/// Split `path#fragment` into `(path, Some(fragment))` /
158/// `(path, None)`. Used by both the resolver and the post-parse
159/// walk so they agree on which characters are path-bytes.
160///
161/// # Examples
162///
163/// ```
164/// use noyalib::include::split_fragment;
165/// assert_eq!(split_fragment("a.yaml#anchor"), ("a.yaml", Some("anchor")));
166/// assert_eq!(split_fragment("a.yaml"), ("a.yaml", None));
167/// ```
168#[must_use]
169pub fn split_fragment(spec: &str) -> (&str, Option<&str>) {
170 match spec.split_once('#') {
171 Some((p, f)) => (p, Some(f)),
172 None => (spec, None),
173 }
174}