Skip to main content

rust_yaml/
resolver.rs

1//! YAML resolver for tag resolution and implicit typing
2
3use crate::version::YamlVersion;
4use crate::{Error, Position};
5use std::collections::HashMap;
6
7/// Build the error returned when the resolver detects the YAML 1.1
8/// `tag:yaml.org,2002:value` indicator (`=`).
9///
10/// Centralized so all composers report the same message — matches the
11/// `ConstructorError` raised by `ruamel.yaml` typ="safe" / typ="unsafe".
12/// See YAML 1.1 §10.3.4 — <https://yaml.org/spec/1.1/#id903992>.
13#[must_use]
14pub fn value_tag_error(position: Position) -> Error {
15    Error::construction(
16        position,
17        "the YAML 1.1 `=` indicator (tag:yaml.org,2002:value) has no \
18         constructor in rust-yaml; drop the `%YAML 1.1` directive or \
19         quote the value (`'='`) to keep it as a string",
20    )
21}
22
23/// Result of resolving a plain (unquoted) scalar to a YAML type.
24///
25/// This is used by every composer variant to share implicit-resolution
26/// logic. Each composer maps the variants to its own value type.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum PlainScalarType {
29    /// Null (`null`, `Null`, `NULL`, `~`).
30    Null,
31    /// Boolean — under YAML 1.2 only `true`/`false` (any case); under
32    /// YAML 1.1 also `yes`/`no`/`on`/`off`.
33    Bool(bool),
34    /// 64-bit signed integer (decimal).
35    Int(i64),
36    /// 64-bit float.
37    Float(f64),
38    /// Falls through to a string — the caller keeps the original input.
39    Str,
40    /// YAML 1.1 `tag:yaml.org,2002:value` (the bare `=` indicator,
41    /// §10.3.4 of the 1.1 spec). Dropped from the 1.2 Core Schema, so
42    /// the resolver only emits this under a `%YAML 1.1` directive.
43    /// Composers should reject it — there is no `Value` variant in the
44    /// user-facing tree to construct it into. This mirrors
45    /// `ruamel.yaml typ="safe"` / `typ="unsafe"`, both of which raise
46    /// `ConstructorError`.
47    Value,
48}
49
50/// Resolve a plain scalar to a [`PlainScalarType`] under the given
51/// YAML version.
52///
53/// This is the single source of truth for implicit scalar typing.
54/// Composers call it instead of duplicating the resolution sequence.
55///
56/// Empty plain scalars currently fall through to `Str` to preserve
57/// existing rust-yaml behavior; the YAML 1.2 spec treats them as `Null`,
58/// which is tracked as a separate Core Schema gap.
59#[must_use]
60pub fn resolve_plain_scalar(value: &str, version: YamlVersion) -> PlainScalarType {
61    // §10.2 (Core Schema) and §10.3 failsafe table: an empty plain
62    // scalar resolves to `tag:yaml.org,2002:null` — i.e. \`Null\`.
63    if value.is_empty() {
64        return PlainScalarType::Null;
65    }
66
67    if let Ok(i) = value.parse::<i64>() {
68        return PlainScalarType::Int(i);
69    }
70
71    if let Ok(f) = value.parse::<f64>() {
72        return PlainScalarType::Float(f);
73    }
74
75    // YAML 1.1 §10.3.4: bare `=` is the `tag:yaml.org,2002:value`
76    // indicator. Case-sensitive (only literal `=`), and the full scalar
77    // must be exactly `=` — `a=b` / `==` / etc. stay as plain strings.
78    if version == YamlVersion::V1_1 && value == "=" {
79        return PlainScalarType::Value;
80    }
81
82    let lower = value.to_lowercase();
83    match lower.as_str() {
84        "true" => PlainScalarType::Bool(true),
85        "false" => PlainScalarType::Bool(false),
86        "null" | "~" => PlainScalarType::Null,
87        "yes" | "on" if version == YamlVersion::V1_1 => PlainScalarType::Bool(true),
88        "no" | "off" if version == YamlVersion::V1_1 => PlainScalarType::Bool(false),
89        _ => PlainScalarType::Str,
90    }
91}
92
93/// Trait for YAML resolvers that handle tag resolution
94pub trait Resolver {
95    /// Resolve a tag for implicit typing
96    fn resolve_tag(&self, value: &str, implicit: bool) -> Option<String>;
97
98    /// Add an implicit resolver pattern
99    fn add_implicit_resolver(&mut self, tag: String, pattern: String);
100
101    /// Reset the resolver state
102    fn reset(&mut self);
103}
104
105/// Basic resolver with standard YAML 1.2 implicit typing
106#[derive(Debug)]
107pub struct BasicResolver {
108    implicit_resolvers: HashMap<String, String>,
109}
110
111impl BasicResolver {
112    /// Create a new resolver with standard YAML 1.2 resolvers
113    pub fn new() -> Self {
114        let mut resolver = Self {
115            implicit_resolvers: HashMap::new(),
116        };
117
118        // Add standard YAML 1.2 implicit resolvers
119        resolver.add_standard_resolvers();
120        resolver
121    }
122
123    fn add_standard_resolvers(&mut self) {
124        // Boolean values
125        self.implicit_resolvers
126            .insert("true".to_string(), "tag:yaml.org,2002:bool".to_string());
127        self.implicit_resolvers
128            .insert("True".to_string(), "tag:yaml.org,2002:bool".to_string());
129        self.implicit_resolvers
130            .insert("TRUE".to_string(), "tag:yaml.org,2002:bool".to_string());
131        self.implicit_resolvers
132            .insert("false".to_string(), "tag:yaml.org,2002:bool".to_string());
133        self.implicit_resolvers
134            .insert("False".to_string(), "tag:yaml.org,2002:bool".to_string());
135        self.implicit_resolvers
136            .insert("FALSE".to_string(), "tag:yaml.org,2002:bool".to_string());
137
138        // Null values
139        self.implicit_resolvers
140            .insert("null".to_string(), "tag:yaml.org,2002:null".to_string());
141        self.implicit_resolvers
142            .insert("Null".to_string(), "tag:yaml.org,2002:null".to_string());
143        self.implicit_resolvers
144            .insert("NULL".to_string(), "tag:yaml.org,2002:null".to_string());
145        self.implicit_resolvers
146            .insert("~".to_string(), "tag:yaml.org,2002:null".to_string());
147    }
148
149    /// Check if a string represents an integer
150    pub fn is_int(&self, value: &str) -> bool {
151        value.parse::<i64>().is_ok()
152    }
153
154    /// Check if a string represents a float
155    pub fn is_float(&self, value: &str) -> bool {
156        value.parse::<f64>().is_ok()
157    }
158}
159
160impl Default for BasicResolver {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166impl Resolver for BasicResolver {
167    fn resolve_tag(&self, value: &str, implicit: bool) -> Option<String> {
168        if !implicit {
169            return None;
170        }
171
172        // Check explicit mappings first
173        if let Some(tag) = self.implicit_resolvers.get(value) {
174            return Some(tag.clone());
175        }
176
177        // Check numeric types
178        if self.is_int(value) {
179            return Some("tag:yaml.org,2002:int".to_string());
180        }
181
182        if self.is_float(value) {
183            return Some("tag:yaml.org,2002:float".to_string());
184        }
185
186        // Default to string
187        Some("tag:yaml.org,2002:str".to_string())
188    }
189
190    fn add_implicit_resolver(&mut self, tag: String, pattern: String) {
191        self.implicit_resolvers.insert(pattern, tag);
192    }
193
194    fn reset(&mut self) {
195        // Keep the standard resolvers, don't clear them
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn plain_scalar_decimal_int() {
205        assert_eq!(
206            resolve_plain_scalar("42", YamlVersion::V1_2),
207            PlainScalarType::Int(42)
208        );
209        assert_eq!(
210            resolve_plain_scalar("-7", YamlVersion::V1_2),
211            PlainScalarType::Int(-7)
212        );
213    }
214
215    #[test]
216    fn plain_scalar_float() {
217        assert_eq!(
218            resolve_plain_scalar("3.14", YamlVersion::V1_2),
219            PlainScalarType::Float(3.14)
220        );
221    }
222
223    #[test]
224    fn plain_scalar_bool_1_2_only_true_false() {
225        assert_eq!(
226            resolve_plain_scalar("true", YamlVersion::V1_2),
227            PlainScalarType::Bool(true)
228        );
229        assert_eq!(
230            resolve_plain_scalar("TRUE", YamlVersion::V1_2),
231            PlainScalarType::Bool(true)
232        );
233        assert_eq!(
234            resolve_plain_scalar("False", YamlVersion::V1_2),
235            PlainScalarType::Bool(false)
236        );
237    }
238
239    #[test]
240    fn plain_scalar_bool_1_2_rejects_yes_no_on_off() {
241        for s in ["yes", "no", "on", "off", "Yes", "NO", "On", "OFF"] {
242            assert_eq!(
243                resolve_plain_scalar(s, YamlVersion::V1_2),
244                PlainScalarType::Str,
245                "{s:?} should fall through to Str under 1.2"
246            );
247        }
248    }
249
250    #[test]
251    fn plain_scalar_bool_1_1_accepts_yes_no_on_off() {
252        assert_eq!(
253            resolve_plain_scalar("yes", YamlVersion::V1_1),
254            PlainScalarType::Bool(true)
255        );
256        assert_eq!(
257            resolve_plain_scalar("no", YamlVersion::V1_1),
258            PlainScalarType::Bool(false)
259        );
260        assert_eq!(
261            resolve_plain_scalar("on", YamlVersion::V1_1),
262            PlainScalarType::Bool(true)
263        );
264        assert_eq!(
265            resolve_plain_scalar("off", YamlVersion::V1_1),
266            PlainScalarType::Bool(false)
267        );
268    }
269
270    #[test]
271    fn plain_scalar_null_any_version() {
272        for v in [YamlVersion::V1_1, YamlVersion::V1_2] {
273            assert_eq!(resolve_plain_scalar("null", v), PlainScalarType::Null);
274            assert_eq!(resolve_plain_scalar("Null", v), PlainScalarType::Null);
275            assert_eq!(resolve_plain_scalar("NULL", v), PlainScalarType::Null);
276            assert_eq!(resolve_plain_scalar("~", v), PlainScalarType::Null);
277        }
278    }
279
280    #[test]
281    fn plain_scalar_string_fallback() {
282        assert_eq!(
283            resolve_plain_scalar("hello", YamlVersion::V1_2),
284            PlainScalarType::Str
285        );
286        // YAML 1.2 dropped the `!!value` tag — `=` is a plain string.
287        assert_eq!(
288            resolve_plain_scalar("=", YamlVersion::V1_2),
289            PlainScalarType::Str
290        );
291    }
292
293    /// YAML 1.1 §10.3.4 — `=` is the indicator for the
294    /// `tag:yaml.org,2002:value` (Value) tag. The resolver surfaces
295    /// it as a distinct variant so composers can refuse it the way
296    /// `ruamel.yaml` typ="safe" / typ="unsafe" do — see
297    /// <https://yaml.org/spec/1.1/#id903992>.
298    #[test]
299    fn plain_scalar_value_tag_1_1() {
300        assert_eq!(
301            resolve_plain_scalar("=", YamlVersion::V1_1),
302            PlainScalarType::Value
303        );
304    }
305
306    /// The `=` indicator must be the entire scalar — strings that merely
307    /// contain `=` (`a=b`, `==`, `= `, ` =`) stay as plain strings even
308    /// under 1.1.
309    #[test]
310    fn plain_scalar_value_tag_1_1_only_bare_equals() {
311        for s in ["==", "a=b", "= ", " =", " = "] {
312            assert_eq!(
313                resolve_plain_scalar(s, YamlVersion::V1_1),
314                PlainScalarType::Str,
315                "{s:?} should fall through to Str — only bare `=` is the value tag"
316            );
317        }
318    }
319
320    #[test]
321    fn test_resolver_creation() {
322        let resolver = BasicResolver::new();
323        assert!(!resolver.implicit_resolvers.is_empty());
324    }
325
326    #[test]
327    fn test_boolean_resolution() {
328        let resolver = BasicResolver::new();
329
330        assert_eq!(
331            resolver.resolve_tag("true", true),
332            Some("tag:yaml.org,2002:bool".to_string())
333        );
334        assert_eq!(
335            resolver.resolve_tag("false", true),
336            Some("tag:yaml.org,2002:bool".to_string())
337        );
338    }
339
340    #[test]
341    fn test_null_resolution() {
342        let resolver = BasicResolver::new();
343
344        assert_eq!(
345            resolver.resolve_tag("null", true),
346            Some("tag:yaml.org,2002:null".to_string())
347        );
348        assert_eq!(
349            resolver.resolve_tag("~", true),
350            Some("tag:yaml.org,2002:null".to_string())
351        );
352    }
353
354    #[test]
355    fn test_numeric_resolution() {
356        let resolver = BasicResolver::new();
357
358        assert_eq!(
359            resolver.resolve_tag("42", true),
360            Some("tag:yaml.org,2002:int".to_string())
361        );
362        assert_eq!(
363            resolver.resolve_tag("3.14", true),
364            Some("tag:yaml.org,2002:float".to_string())
365        );
366    }
367
368    #[test]
369    fn test_string_resolution() {
370        let resolver = BasicResolver::new();
371
372        assert_eq!(
373            resolver.resolve_tag("hello", true),
374            Some("tag:yaml.org,2002:str".to_string())
375        );
376    }
377
378    #[test]
379    fn test_explicit_tag_resolution() {
380        let resolver = BasicResolver::new();
381
382        // When not implicit, should return None
383        assert_eq!(resolver.resolve_tag("true", false), None);
384    }
385
386    #[test]
387    fn test_custom_resolver() {
388        let mut resolver = BasicResolver::new();
389
390        resolver.add_implicit_resolver(
391            "tag:example.com,2002:custom".to_string(),
392            "CUSTOM".to_string(),
393        );
394
395        assert_eq!(
396            resolver.resolve_tag("CUSTOM", true),
397            Some("tag:example.com,2002:custom".to_string())
398        );
399    }
400}