Skip to main content

noyalib/
spanned.rs

1//! Source location tracking for deserialized values.
2//!
3//! `Spanned<T>` wraps a deserialized value and records the start/end
4//! [`Location`](crate::Location) in the original YAML source.
5
6// SPDX-License-Identifier: MIT OR Apache-2.0
7// Copyright (c) 2026 Noyalib. All rights reserved.
8
9use crate::prelude::*;
10use core::ops::Deref;
11
12use crate::error::Location;
13
14/// Sentinel struct name used by the deserializer to detect `Spanned<T>`.
15pub(crate) const SPANNED_TYPE_NAME: &str = "$__noyalib_private_Spanned";
16
17/// Field names for the virtual Spanned struct.
18pub(crate) const SPANNED_FIELD_START_LINE: &str = "$__noyalib_start_line";
19pub(crate) const SPANNED_FIELD_START_COLUMN: &str = "$__noyalib_start_column";
20pub(crate) const SPANNED_FIELD_START_INDEX: &str = "$__noyalib_start_index";
21pub(crate) const SPANNED_FIELD_END_LINE: &str = "$__noyalib_end_line";
22pub(crate) const SPANNED_FIELD_END_COLUMN: &str = "$__noyalib_end_column";
23pub(crate) const SPANNED_FIELD_END_INDEX: &str = "$__noyalib_end_index";
24pub(crate) const SPANNED_FIELD_VALUE: &str = "$__noyalib_value";
25
26pub(crate) const SPANNED_FIELDS: &[&str] = &[
27    SPANNED_FIELD_START_LINE,
28    SPANNED_FIELD_START_COLUMN,
29    SPANNED_FIELD_START_INDEX,
30    SPANNED_FIELD_END_LINE,
31    SPANNED_FIELD_END_COLUMN,
32    SPANNED_FIELD_END_INDEX,
33    SPANNED_FIELD_VALUE,
34];
35
36/// A value annotated with source span information.
37///
38/// During serialization, `Spanned<T>` serializes transparently as `T`.
39/// During deserialization from a YAML string via [`from_str`](crate::from_str)
40/// or [`from_str_with_config`](crate::from_str_with_config), real source
41/// locations are populated. When deserializing via
42/// [`from_value`](crate::from_value), locations default to zero.
43///
44/// # Examples
45///
46/// ```rust
47/// use noyalib::Spanned;
48///
49/// #[derive(serde::Serialize, serde::Deserialize, Debug)]
50/// struct Config {
51///     port: Spanned<u16>,
52/// }
53/// ```
54///
55/// # Limitation: `#[serde(flatten)]`
56///
57/// `Spanned<T>` cannot be the target of a `#[serde(flatten)]` field. The
58/// limitation is in serde itself: `FlatMapDeserializer::deserialize_struct`
59/// uses `FlatStructAccess`, which filters the residue by the FIELDS list
60/// passed to `deserialize_struct`. Because `Spanned` advertises internal
61/// magic field names (`$__noyalib_value` and friends) that never appear in
62/// real input, every residue entry is filtered out before our visitor
63/// runs.
64///
65/// The supported pattern is to flatten a bare [`crate::Value`] (which works
66/// today) and look up source spans through the [`crate::cst::Document`]
67/// API separately:
68///
69/// ```rust,ignore
70/// // Works.
71/// #[derive(serde::Deserialize)]
72/// struct Config {
73///     name: String,
74///     #[serde(flatten)]
75///     extra: noyalib::Value,
76/// }
77///
78/// // Errors with a clear message at deserialize time.
79/// #[derive(serde::Deserialize)]
80/// struct AlsoConfig {
81///     name: String,
82///     #[serde(flatten)]
83///     extra: noyalib::Spanned<noyalib::Value>,  // not supported
84/// }
85/// ```
86///
87/// If the use case is "agent edits YAML, also wants source position of
88/// each unflattened field", parse the source via
89/// [`crate::cst::parse_document`] and use
90/// [`crate::cst::Document::span_at`] / [`crate::cst::Document::comments_at`]
91/// — those resolve byte ranges by path, work post-edit, and are not
92/// constrained by serde's flatten mechanics.
93#[derive(Clone, PartialEq, Eq, Hash)]
94pub struct Spanned<T> {
95    /// The deserialized value.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use noyalib::Spanned;
101    /// let s = Spanned::new("x".to_string());
102    /// assert_eq!(s.value, "x");
103    /// ```
104    pub value: T,
105    /// Start location in the source.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use noyalib::Spanned;
111    /// let s: Spanned<i32> = Spanned::new(1);
112    /// assert_eq!(s.start.line(), 0);
113    /// ```
114    pub start: Location,
115    /// End location in the source.
116    ///
117    /// # Examples
118    ///
119    /// ```
120    /// use noyalib::Spanned;
121    /// let s: Spanned<i32> = Spanned::new(1);
122    /// assert_eq!(s.end.line(), 0);
123    /// ```
124    pub end: Location,
125}
126
127impl<T: fmt::Debug> fmt::Debug for Spanned<T> {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.debug_struct("Spanned")
130            .field("value", &self.value)
131            .field("start", &self.start)
132            .field("end", &self.end)
133            .finish()
134    }
135}
136
137impl<T> Deref for Spanned<T> {
138    type Target = T;
139    fn deref(&self) -> &T {
140        &self.value
141    }
142}
143
144impl<T> Spanned<T> {
145    /// Create a new spanned value with sentinel zero locations
146    /// (`line = 0`, `column = 0`, `index = 0`).
147    ///
148    /// The sentinel form exists so callers can synthesise a
149    /// [`Spanned<T>`] without owning a parser context; it is
150    /// distinguishable from a parsed location because parsed
151    /// locations always have `line >= 1` and `column >= 1`. Use
152    /// the deserialisation path (`#[derive(serde::Deserialize)]` with a
153    /// `Spanned<T>` field) when authentic source positions are
154    /// required.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use noyalib::Spanned;
160    /// let s = Spanned::new(42_u16);
161    /// assert_eq!(*s, 42);
162    /// // Sentinel — distinguishable from a parsed location.
163    /// assert_eq!(s.start.line(), 0);
164    /// ```
165    pub fn new(value: T) -> Self {
166        Self {
167            value,
168            start: Location::default(),
169            end: Location::default(),
170        }
171    }
172
173    /// Unwrap into the inner value.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use noyalib::Spanned;
179    /// let s = Spanned::new("hello".to_string());
180    /// assert_eq!(s.into_inner(), "hello");
181    /// ```
182    pub fn into_inner(self) -> T {
183        self.value
184    }
185}
186
187impl<T> From<T> for Spanned<T> {
188    fn from(value: T) -> Self {
189        Self::new(value)
190    }
191}
192
193impl<T: serde_core::Serialize> serde_core::Serialize for Spanned<T> {
194    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
195    where
196        S: serde_core::Serializer,
197    {
198        // Transparent: just serialize the inner value
199        self.value.serialize(serializer)
200    }
201}
202
203impl<'de, T: serde_core::Deserialize<'de>> serde_core::Deserialize<'de> for Spanned<T> {
204    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
205    where
206        D: serde_core::Deserializer<'de>,
207    {
208        deserializer.deserialize_struct(
209            SPANNED_TYPE_NAME,
210            SPANNED_FIELDS,
211            SpannedVisitor(core::marker::PhantomData),
212        )
213    }
214}
215
216struct SpannedVisitor<T>(core::marker::PhantomData<T>);
217
218impl<'de, T: serde_core::Deserialize<'de>> serde_core::de::Visitor<'de> for SpannedVisitor<T> {
219    type Value = Spanned<T>;
220
221    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        formatter.write_str("a spanned value")
223    }
224
225    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
226    where
227        A: serde_core::de::MapAccess<'de>,
228    {
229        let mut start_line: Option<usize> = None;
230        let mut start_column: Option<usize> = None;
231        let mut start_index: Option<usize> = None;
232        let mut end_line: Option<usize> = None;
233        let mut end_column: Option<usize> = None;
234        let mut end_index: Option<usize> = None;
235        let mut value: Option<T> = None;
236        // We track whether *any* keys were yielded so we can produce
237        // a more helpful error in the `#[serde(flatten)]` corner —
238        // serde's `FlatStructAccess` filters residue entries by the
239        // FIELDS list passed to `deserialize_struct`, and our magic
240        // SPANNED_FIELDS never match the real residue keys, so the
241        // visitor sees zero entries. The bare `missing_field` error
242        // is unhelpful in that case; we want to point the user at
243        // the workaround.
244        let mut saw_any_key = false;
245
246        while let Some(key) = map.next_key::<&str>()? {
247            saw_any_key = true;
248            match key {
249                SPANNED_FIELD_START_LINE => start_line = Some(map.next_value()?),
250                SPANNED_FIELD_START_COLUMN => start_column = Some(map.next_value()?),
251                SPANNED_FIELD_START_INDEX => start_index = Some(map.next_value()?),
252                SPANNED_FIELD_END_LINE => end_line = Some(map.next_value()?),
253                SPANNED_FIELD_END_COLUMN => end_column = Some(map.next_value()?),
254                SPANNED_FIELD_END_INDEX => end_index = Some(map.next_value()?),
255                SPANNED_FIELD_VALUE => value = Some(map.next_value()?),
256                _ => {
257                    // Unknown field — skip
258                    let _ = map.next_value::<serde_core::de::IgnoredAny>()?;
259                }
260            }
261        }
262
263        let value = value.ok_or_else(|| {
264            if saw_any_key {
265                serde_core::de::Error::missing_field(SPANNED_FIELD_VALUE)
266            } else {
267                serde_core::de::Error::custom(
268                    "Spanned<T> can not be deserialized via `#[serde(flatten)]` — \
269                     serde's FlatStructAccess filters residue entries by the field \
270                     name list, and Spanned uses internal magic field names that \
271                     never match real residue keys. Use a bare `Value` for the \
272                     flatten target and look the span up separately via \
273                     `Document::span_at`/`comments_at`.",
274                )
275            }
276        })?;
277
278        let start = Location::new(
279            start_line.unwrap_or(0),
280            start_column.unwrap_or(0),
281            start_index.unwrap_or(0),
282        );
283        let end = Location::new(
284            end_line.unwrap_or(0),
285            end_column.unwrap_or(0),
286            end_index.unwrap_or(0),
287        );
288
289        Ok(Spanned { value, start, end })
290    }
291}