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    // Decimal integers.
68    if let Ok(i) = value.parse::<i64>() {
69        return PlainScalarType::Int(i);
70    }
71
72    // Hex / octal / binary integers. Mirrors the `!!int` tag constructor
73    // (`tag.rs::construct_int`) so implicit and explicit typing agree (#22).
74    if let Some(i) = resolve_radix_int(value) {
75        return PlainScalarType::Int(i);
76    }
77
78    // §10.2 float infinity / NaN: only the dotted spellings (`.inf`,
79    // `.nan`, with an optional sign on `.inf`) are YAML 1.2 floats (#22).
80    if let Some(f) = resolve_inf_nan(value) {
81        return PlainScalarType::Float(f);
82    }
83
84    // Finite floats. The digit guard stops Rust's `f64` parser from
85    // accepting the bare `inf` / `infinity` / `nan` words, which YAML 1.2
86    // does not recognize — those fall through to a string.
87    if value.bytes().any(|b| b.is_ascii_digit()) {
88        if let Ok(f) = value.parse::<f64>() {
89            return PlainScalarType::Float(f);
90        }
91    }
92
93    // YAML 1.1 §10.3.4: bare `=` is the `tag:yaml.org,2002:value`
94    // indicator. Case-sensitive (only literal `=`), and the full scalar
95    // must be exactly `=` — `a=b` / `==` / etc. stay as plain strings.
96    if version == YamlVersion::V1_1 && value == "=" {
97        return PlainScalarType::Value;
98    }
99
100    let lower = value.to_lowercase();
101    match lower.as_str() {
102        "true" => PlainScalarType::Bool(true),
103        "false" => PlainScalarType::Bool(false),
104        "null" | "~" => PlainScalarType::Null,
105        "yes" | "on" if version == YamlVersion::V1_1 => PlainScalarType::Bool(true),
106        "no" | "off" if version == YamlVersion::V1_1 => PlainScalarType::Bool(false),
107        _ => PlainScalarType::Str,
108    }
109}
110
111/// Parse a hex (`0x`), octal (`0o`), or binary (`0b`) integer literal.
112///
113/// Accepts the upper- and lower-case prefix forms, mirroring the `!!int`
114/// tag constructor. Returns `None` for any other input, or when the digits
115/// are missing or invalid for the radix.
116fn resolve_radix_int(value: &str) -> Option<i64> {
117    let (radix, digits) = if let Some(d) = value
118        .strip_prefix("0x")
119        .or_else(|| value.strip_prefix("0X"))
120    {
121        (16, d)
122    } else if let Some(d) = value
123        .strip_prefix("0o")
124        .or_else(|| value.strip_prefix("0O"))
125    {
126        (8, d)
127    } else if let Some(d) = value
128        .strip_prefix("0b")
129        .or_else(|| value.strip_prefix("0B"))
130    {
131        (2, d)
132    } else {
133        return None;
134    };
135    if digits.is_empty() {
136        return None;
137    }
138    i64::from_str_radix(digits, radix).ok()
139}
140
141/// Resolve the YAML 1.2 §10.2 float infinity / NaN spellings. The leading
142/// dot is mandatory; `.inf` takes an optional sign, `.nan` does not.
143fn resolve_inf_nan(value: &str) -> Option<f64> {
144    match value {
145        ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => Some(f64::INFINITY),
146        "-.inf" | "-.Inf" | "-.INF" => Some(f64::NEG_INFINITY),
147        ".nan" | ".NaN" | ".NAN" => Some(f64::NAN),
148        _ => None,
149    }
150}
151
152/// Trait for YAML resolvers that handle tag resolution
153pub trait Resolver {
154    /// Resolve a tag for implicit typing
155    fn resolve_tag(&self, value: &str, implicit: bool) -> Option<String>;
156
157    /// Add an implicit resolver pattern
158    fn add_implicit_resolver(&mut self, tag: String, pattern: String);
159
160    /// Reset the resolver state
161    fn reset(&mut self);
162}
163
164/// Basic resolver with standard YAML 1.2 implicit typing
165#[derive(Debug)]
166pub struct BasicResolver {
167    implicit_resolvers: HashMap<String, String>,
168}
169
170impl BasicResolver {
171    /// Create a new resolver with standard YAML 1.2 resolvers
172    pub fn new() -> Self {
173        let mut resolver = Self {
174            implicit_resolvers: HashMap::new(),
175        };
176
177        // Add standard YAML 1.2 implicit resolvers
178        resolver.add_standard_resolvers();
179        resolver
180    }
181
182    fn add_standard_resolvers(&mut self) {
183        // Boolean values
184        self.implicit_resolvers
185            .insert("true".to_string(), "tag:yaml.org,2002:bool".to_string());
186        self.implicit_resolvers
187            .insert("True".to_string(), "tag:yaml.org,2002:bool".to_string());
188        self.implicit_resolvers
189            .insert("TRUE".to_string(), "tag:yaml.org,2002:bool".to_string());
190        self.implicit_resolvers
191            .insert("false".to_string(), "tag:yaml.org,2002:bool".to_string());
192        self.implicit_resolvers
193            .insert("False".to_string(), "tag:yaml.org,2002:bool".to_string());
194        self.implicit_resolvers
195            .insert("FALSE".to_string(), "tag:yaml.org,2002:bool".to_string());
196
197        // Null values
198        self.implicit_resolvers
199            .insert("null".to_string(), "tag:yaml.org,2002:null".to_string());
200        self.implicit_resolvers
201            .insert("Null".to_string(), "tag:yaml.org,2002:null".to_string());
202        self.implicit_resolvers
203            .insert("NULL".to_string(), "tag:yaml.org,2002:null".to_string());
204        self.implicit_resolvers
205            .insert("~".to_string(), "tag:yaml.org,2002:null".to_string());
206    }
207
208    /// Check if a string represents an integer
209    pub fn is_int(&self, value: &str) -> bool {
210        value.parse::<i64>().is_ok()
211    }
212
213    /// Check if a string represents a float
214    pub fn is_float(&self, value: &str) -> bool {
215        value.parse::<f64>().is_ok()
216    }
217}
218
219impl Default for BasicResolver {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225impl Resolver for BasicResolver {
226    fn resolve_tag(&self, value: &str, implicit: bool) -> Option<String> {
227        if !implicit {
228            return None;
229        }
230
231        // Check explicit mappings first
232        if let Some(tag) = self.implicit_resolvers.get(value) {
233            return Some(tag.clone());
234        }
235
236        // Check numeric types
237        if self.is_int(value) {
238            return Some("tag:yaml.org,2002:int".to_string());
239        }
240
241        if self.is_float(value) {
242            return Some("tag:yaml.org,2002:float".to_string());
243        }
244
245        // Default to string
246        Some("tag:yaml.org,2002:str".to_string())
247    }
248
249    fn add_implicit_resolver(&mut self, tag: String, pattern: String) {
250        self.implicit_resolvers.insert(pattern, tag);
251    }
252
253    fn reset(&mut self) {
254        // Keep the standard resolvers, don't clear them
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn plain_scalar_decimal_int() {
264        assert_eq!(
265            resolve_plain_scalar("42", YamlVersion::V1_2),
266            PlainScalarType::Int(42)
267        );
268        assert_eq!(
269            resolve_plain_scalar("-7", YamlVersion::V1_2),
270            PlainScalarType::Int(-7)
271        );
272    }
273
274    #[test]
275    fn plain_scalar_float() {
276        assert_eq!(
277            resolve_plain_scalar("3.14", YamlVersion::V1_2),
278            PlainScalarType::Float(3.14)
279        );
280    }
281
282    #[test]
283    fn plain_scalar_bool_1_2_only_true_false() {
284        assert_eq!(
285            resolve_plain_scalar("true", YamlVersion::V1_2),
286            PlainScalarType::Bool(true)
287        );
288        assert_eq!(
289            resolve_plain_scalar("TRUE", YamlVersion::V1_2),
290            PlainScalarType::Bool(true)
291        );
292        assert_eq!(
293            resolve_plain_scalar("False", YamlVersion::V1_2),
294            PlainScalarType::Bool(false)
295        );
296    }
297
298    #[test]
299    fn plain_scalar_bool_1_2_rejects_yes_no_on_off() {
300        for s in ["yes", "no", "on", "off", "Yes", "NO", "On", "OFF"] {
301            assert_eq!(
302                resolve_plain_scalar(s, YamlVersion::V1_2),
303                PlainScalarType::Str,
304                "{s:?} should fall through to Str under 1.2"
305            );
306        }
307    }
308
309    #[test]
310    fn plain_scalar_bool_1_1_accepts_yes_no_on_off() {
311        assert_eq!(
312            resolve_plain_scalar("yes", YamlVersion::V1_1),
313            PlainScalarType::Bool(true)
314        );
315        assert_eq!(
316            resolve_plain_scalar("no", YamlVersion::V1_1),
317            PlainScalarType::Bool(false)
318        );
319        assert_eq!(
320            resolve_plain_scalar("on", YamlVersion::V1_1),
321            PlainScalarType::Bool(true)
322        );
323        assert_eq!(
324            resolve_plain_scalar("off", YamlVersion::V1_1),
325            PlainScalarType::Bool(false)
326        );
327    }
328
329    #[test]
330    fn plain_scalar_null_any_version() {
331        for v in [YamlVersion::V1_1, YamlVersion::V1_2] {
332            assert_eq!(resolve_plain_scalar("null", v), PlainScalarType::Null);
333            assert_eq!(resolve_plain_scalar("Null", v), PlainScalarType::Null);
334            assert_eq!(resolve_plain_scalar("NULL", v), PlainScalarType::Null);
335            assert_eq!(resolve_plain_scalar("~", v), PlainScalarType::Null);
336        }
337    }
338
339    #[test]
340    fn plain_scalar_string_fallback() {
341        assert_eq!(
342            resolve_plain_scalar("hello", YamlVersion::V1_2),
343            PlainScalarType::Str
344        );
345        // YAML 1.2 dropped the `!!value` tag — `=` is a plain string.
346        assert_eq!(
347            resolve_plain_scalar("=", YamlVersion::V1_2),
348            PlainScalarType::Str
349        );
350    }
351
352    /// YAML 1.1 §10.3.4 — `=` is the indicator for the
353    /// `tag:yaml.org,2002:value` (Value) tag. The resolver surfaces
354    /// it as a distinct variant so composers can refuse it the way
355    /// `ruamel.yaml` typ="safe" / typ="unsafe" do — see
356    /// <https://yaml.org/spec/1.1/#id903992>.
357    #[test]
358    fn plain_scalar_value_tag_1_1() {
359        assert_eq!(
360            resolve_plain_scalar("=", YamlVersion::V1_1),
361            PlainScalarType::Value
362        );
363    }
364
365    /// The `=` indicator must be the entire scalar — strings that merely
366    /// contain `=` (`a=b`, `==`, `= `, ` =`) stay as plain strings even
367    /// under 1.1.
368    #[test]
369    fn plain_scalar_value_tag_1_1_only_bare_equals() {
370        for s in ["==", "a=b", "= ", " =", " = "] {
371            assert_eq!(
372                resolve_plain_scalar(s, YamlVersion::V1_1),
373                PlainScalarType::Str,
374                "{s:?} should fall through to Str — only bare `=` is the value tag"
375            );
376        }
377    }
378
379    #[test]
380    fn plain_scalar_hex_octal_binary_int() {
381        // YAML 1.2 Core `0x`/`0o` plus the `0b` binary form (#22).
382        assert_eq!(
383            resolve_plain_scalar("0xFF", YamlVersion::V1_2),
384            PlainScalarType::Int(255)
385        );
386        assert_eq!(
387            resolve_plain_scalar("0xff", YamlVersion::V1_2),
388            PlainScalarType::Int(255)
389        );
390        assert_eq!(
391            resolve_plain_scalar("0o17", YamlVersion::V1_2),
392            PlainScalarType::Int(15)
393        );
394        assert_eq!(
395            resolve_plain_scalar("0b101", YamlVersion::V1_2),
396            PlainScalarType::Int(5)
397        );
398    }
399
400    #[test]
401    fn plain_scalar_malformed_radix_int_is_string() {
402        // A recognized prefix with no / invalid digits is not an integer.
403        for s in ["0x", "0o8", "0b102", "0xZZ"] {
404            assert_eq!(
405                resolve_plain_scalar(s, YamlVersion::V1_2),
406                PlainScalarType::Str,
407                "{s:?} should fall through to Str"
408            );
409        }
410    }
411
412    #[test]
413    fn plain_scalar_dotted_inf_nan_are_floats() {
414        // §10.2: only the dotted spellings are YAML 1.2 floats (#22).
415        assert_eq!(
416            resolve_plain_scalar(".inf", YamlVersion::V1_2),
417            PlainScalarType::Float(f64::INFINITY)
418        );
419        assert_eq!(
420            resolve_plain_scalar("-.inf", YamlVersion::V1_2),
421            PlainScalarType::Float(f64::NEG_INFINITY)
422        );
423        assert!(matches!(
424            resolve_plain_scalar(".nan", YamlVersion::V1_2),
425            PlainScalarType::Float(f) if f.is_nan()
426        ));
427    }
428
429    #[test]
430    fn plain_scalar_bare_inf_nan_are_strings() {
431        // Bare `inf`/`nan` (no leading dot) are NOT YAML 1.2 floats — they
432        // must stay strings, even though Rust's f64 parser would accept
433        // them (#22).
434        for s in ["inf", "nan", "Inf", "NaN", "infinity"] {
435            assert_eq!(
436                resolve_plain_scalar(s, YamlVersion::V1_2),
437                PlainScalarType::Str,
438                "{s:?} should fall through to Str"
439            );
440        }
441    }
442
443    #[test]
444    fn test_resolver_creation() {
445        let resolver = BasicResolver::new();
446        assert!(!resolver.implicit_resolvers.is_empty());
447    }
448
449    #[test]
450    fn test_boolean_resolution() {
451        let resolver = BasicResolver::new();
452
453        assert_eq!(
454            resolver.resolve_tag("true", true),
455            Some("tag:yaml.org,2002:bool".to_string())
456        );
457        assert_eq!(
458            resolver.resolve_tag("false", true),
459            Some("tag:yaml.org,2002:bool".to_string())
460        );
461    }
462
463    #[test]
464    fn test_null_resolution() {
465        let resolver = BasicResolver::new();
466
467        assert_eq!(
468            resolver.resolve_tag("null", true),
469            Some("tag:yaml.org,2002:null".to_string())
470        );
471        assert_eq!(
472            resolver.resolve_tag("~", true),
473            Some("tag:yaml.org,2002:null".to_string())
474        );
475    }
476
477    #[test]
478    fn test_numeric_resolution() {
479        let resolver = BasicResolver::new();
480
481        assert_eq!(
482            resolver.resolve_tag("42", true),
483            Some("tag:yaml.org,2002:int".to_string())
484        );
485        assert_eq!(
486            resolver.resolve_tag("3.14", true),
487            Some("tag:yaml.org,2002:float".to_string())
488        );
489    }
490
491    #[test]
492    fn test_string_resolution() {
493        let resolver = BasicResolver::new();
494
495        assert_eq!(
496            resolver.resolve_tag("hello", true),
497            Some("tag:yaml.org,2002:str".to_string())
498        );
499    }
500
501    #[test]
502    fn test_explicit_tag_resolution() {
503        let resolver = BasicResolver::new();
504
505        // When not implicit, should return None
506        assert_eq!(resolver.resolve_tag("true", false), None);
507    }
508
509    #[test]
510    fn test_custom_resolver() {
511        let mut resolver = BasicResolver::new();
512
513        resolver.add_implicit_resolver(
514            "tag:example.com,2002:custom".to_string(),
515            "CUSTOM".to_string(),
516        );
517
518        assert_eq!(
519            resolver.resolve_tag("CUSTOM", true),
520            Some("tag:example.com,2002:custom".to_string())
521        );
522    }
523}