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}