Skip to main content

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}