Skip to main content

rlsp_yaml_parser/
schema.rs

1// SPDX-License-Identifier: MIT
2
3//! YAML 1.2.2 §10 schema tag resolution.
4//!
5//! Three schemas are provided, in increasing generality:
6//!
7//! - [`Schema::Failsafe`] — all scalars resolve to `!!str`, all sequences to
8//!   `!!seq`, all mappings to `!!map`.
9//! - [`Schema::Json`] — narrow pattern set; unmatched plain scalars are an
10//!   error ([`UnresolvedScalar`]).
11//! - [`Schema::Core`] — superset of JSON; unmatched plain scalars fall back to
12//!   `!!str`.
13//!
14//! Use [`resolve_scalar`] and [`resolve_collection`] to apply a schema to a
15//! node.  When the node already carries an explicit source tag, both functions
16//! return `None` / `Ok(None)` — the caller's tag takes precedence.
17
18use crate::event::ScalarStyle;
19
20// ---------------------------------------------------------------------------
21// Public types
22// ---------------------------------------------------------------------------
23
24/// YAML 1.2.2 §10 recommended schema selection.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Schema {
27    /// Failsafe schema (§10.1): scalars → `str`, sequences → `seq`,
28    /// mappings → `map`.
29    Failsafe,
30    /// JSON schema (§10.2): narrow pattern set; unmatched plain scalars
31    /// produce [`UnresolvedScalar`].
32    Json,
33    /// Core schema (§10.3): superset of JSON; unmatched plain scalars fall
34    /// back to `str`.
35    Core,
36}
37
38/// The resolved YAML tag for a node.
39///
40/// Each variant carries the URI constant for that tag family.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ResolvedTag {
43    /// `tag:yaml.org,2002:str`
44    Str,
45    /// `tag:yaml.org,2002:int`
46    Int,
47    /// `tag:yaml.org,2002:float`
48    Float,
49    /// `tag:yaml.org,2002:bool`
50    Bool,
51    /// `tag:yaml.org,2002:null`
52    Null,
53    /// `tag:yaml.org,2002:seq`
54    Seq,
55    /// `tag:yaml.org,2002:map`
56    Map,
57}
58
59impl ResolvedTag {
60    /// Returns the `tag:yaml.org,2002:*` URI for this tag.
61    #[must_use]
62    pub const fn as_str(self) -> &'static str {
63        match self {
64            Self::Str => "tag:yaml.org,2002:str",
65            Self::Int => "tag:yaml.org,2002:int",
66            Self::Float => "tag:yaml.org,2002:float",
67            Self::Bool => "tag:yaml.org,2002:bool",
68            Self::Null => "tag:yaml.org,2002:null",
69            Self::Seq => "tag:yaml.org,2002:seq",
70            Self::Map => "tag:yaml.org,2002:map",
71        }
72    }
73}
74
75/// Error returned by [`resolve_scalar`] when the JSON schema cannot match a
76/// plain scalar value.
77///
78/// The JSON schema has no fallback — every untagged plain scalar must match one
79/// of its patterns (null, bool, int, float).  If none match, the scalar is
80/// unresolvable under JSON schema rules.
81#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
82#[error("unresolved scalar: no JSON schema pattern matched the plain scalar value")]
83pub struct UnresolvedScalar;
84
85/// Collection kind, used as a parameter to [`resolve_collection`].
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum CollectionKind {
88    /// A YAML sequence (`!!seq`).
89    Sequence,
90    /// A YAML mapping (`!!map`).
91    Mapping,
92}
93
94// ---------------------------------------------------------------------------
95// Resolution functions
96// ---------------------------------------------------------------------------
97
98/// Resolve the tag for a scalar node under the given schema.
99///
100/// # Return value
101///
102/// - `Ok(None)` — `source_tag` is `Some`; the existing explicit tag wins, no
103///   schema resolution applied.
104/// - `Ok(Some(tag))` — resolution succeeded; `tag` is the resolved YAML tag.
105///
106/// # Errors
107///
108/// Returns [`Err(UnresolvedScalar)`](UnresolvedScalar) only with
109/// [`Schema::Json`] when the scalar style is [`ScalarStyle::Plain`] and no
110/// JSON pattern matched.
111///
112/// # Style semantics
113///
114/// Only [`ScalarStyle::Plain`] scalars participate in pattern matching.  All
115/// other styles (single-quoted, double-quoted, literal block, folded block)
116/// resolve unconditionally to `!!str` — the content of a quoted or block scalar
117/// is always a string regardless of what the characters spell.
118pub fn resolve_scalar(
119    schema: Schema,
120    style: ScalarStyle,
121    value: &str,
122    source_tag: Option<&str>,
123) -> Result<Option<ResolvedTag>, UnresolvedScalar> {
124    // Explicit source tag takes priority over schema resolution.
125    if source_tag.is_some() {
126        return Ok(None);
127    }
128
129    match schema {
130        Schema::Failsafe => Ok(Some(ResolvedTag::Str)),
131
132        Schema::Core => {
133            let tag = match style {
134                ScalarStyle::Plain => resolve_core_plain(value),
135                // All non-plain styles are unconditionally !!str.
136                ScalarStyle::SingleQuoted
137                | ScalarStyle::DoubleQuoted
138                | ScalarStyle::Literal(_)
139                | ScalarStyle::Folded(_) => ResolvedTag::Str,
140            };
141            Ok(Some(tag))
142        }
143
144        Schema::Json => {
145            let tag = match style {
146                ScalarStyle::Plain => resolve_json_plain(value)?,
147                // Non-plain styles are !!str in JSON schema too.
148                ScalarStyle::SingleQuoted
149                | ScalarStyle::DoubleQuoted
150                | ScalarStyle::Literal(_)
151                | ScalarStyle::Folded(_) => ResolvedTag::Str,
152            };
153            Ok(Some(tag))
154        }
155    }
156}
157
158/// Resolve the tag for a collection node under the given schema.
159///
160/// # Return value
161///
162/// - `None` — `source_tag` is `Some`; the existing explicit tag wins.
163/// - `Some(tag)` — resolved tag (`Seq` or `Map`) according to `kind`.
164///
165/// All three schemas resolve sequences to `!!seq` and mappings to `!!map`.
166#[must_use]
167pub const fn resolve_collection(
168    schema: Schema,
169    kind: CollectionKind,
170    source_tag: Option<&str>,
171) -> Option<ResolvedTag> {
172    // Explicit source tag wins.
173    if source_tag.is_some() {
174        return None;
175    }
176    // All three schemas map sequences → !!seq and mappings → !!map.
177    let _ = schema;
178    Some(match kind {
179        CollectionKind::Sequence => ResolvedTag::Seq,
180        CollectionKind::Mapping => ResolvedTag::Map,
181    })
182}
183
184// ---------------------------------------------------------------------------
185// Core schema plain-scalar dispatch (§10.3)
186// ---------------------------------------------------------------------------
187
188/// Resolve a plain scalar under the Core schema.
189///
190/// Dispatch order: null → bool → int → float → str (fallback).
191fn resolve_core_plain(value: &str) -> ResolvedTag {
192    if is_core_null(value) {
193        ResolvedTag::Null
194    } else if is_core_bool(value) {
195        ResolvedTag::Bool
196    } else if is_core_int(value) {
197        ResolvedTag::Int
198    } else if is_core_float(value) {
199        ResolvedTag::Float
200    } else {
201        ResolvedTag::Str
202    }
203}
204
205// ---------------------------------------------------------------------------
206// JSON schema plain-scalar dispatch (§10.2)
207// ---------------------------------------------------------------------------
208
209/// Resolve a plain scalar under the JSON schema.
210///
211/// Dispatch order: null → bool → int → float.  No fallback — unmatched
212/// scalars return `Err(UnresolvedScalar)`.
213///
214/// Note on `-0`: JSON int is `0 | -?[1-9][0-9]*`, so `-0` is not a JSON int
215/// (the single-`0` branch is bare, with no sign).  JSON float is
216/// `-?(0|[1-9][0-9]*)(\.[0-9]*)?([eE][-+]?[0-9]+)?`, so `-0` matches
217/// (sign `-`, integer part `0`, no fractional or exponent).  Therefore `-0`
218/// resolves to `Float` under the JSON schema.
219fn resolve_json_plain(value: &str) -> Result<ResolvedTag, UnresolvedScalar> {
220    if is_json_null(value) {
221        Ok(ResolvedTag::Null)
222    } else if is_json_bool(value) {
223        Ok(ResolvedTag::Bool)
224    } else if is_json_int(value) {
225        Ok(ResolvedTag::Int)
226    } else if is_json_float(value) {
227        Ok(ResolvedTag::Float)
228    } else {
229        Err(UnresolvedScalar)
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Core schema matchers (§10.3.2 tag resolution table)
235// ---------------------------------------------------------------------------
236
237/// `null | Null | NULL | ~ | ""` (YAML 1.2.2 §10.3.2 null row).
238#[must_use]
239pub fn is_core_null(value: &str) -> bool {
240    matches!(value, "null" | "Null" | "NULL" | "~" | "")
241}
242
243/// `true | True | TRUE | false | False | FALSE` (§10.3.2 bool row).
244#[must_use]
245pub fn is_core_bool(value: &str) -> bool {
246    matches!(
247        value,
248        "true" | "True" | "TRUE" | "false" | "False" | "FALSE"
249    )
250}
251
252/// Decimal `[-+]?[0-9]+`, octal `0o[0-7]+`, hex `0x[0-9a-fA-F]+` (§10.3.2
253/// int rows).  Leading zeros in decimal (e.g. `007`) are rejected.
254#[must_use]
255pub fn is_core_int(value: &str) -> bool {
256    // Strip optional leading sign; the sign itself is never valid.
257    let rest = value
258        .strip_prefix('-')
259        .or_else(|| value.strip_prefix('+'))
260        .unwrap_or(value);
261
262    if rest.is_empty() {
263        return false;
264    }
265
266    if let Some(oct) = rest.strip_prefix("0o") {
267        // Octal: must have at least one digit after prefix.
268        !oct.is_empty() && oct.bytes().all(|b| matches!(b, b'0'..=b'7'))
269    } else if let Some(hex) = rest.strip_prefix("0x") {
270        // Hex: must have at least one digit after prefix.
271        !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())
272    } else {
273        // Decimal: no leading zeros unless the number is exactly "0".
274        if rest.len() > 1 && rest.starts_with('0') {
275            return false;
276        }
277        rest.bytes().all(|b| b.is_ascii_digit())
278    }
279}
280
281/// Core float: decimal (`[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?`),
282/// infinity (`[-+]?\.inf|\.Inf|\.INF`), not-a-number (`.nan|.NaN|.NAN`)
283/// (§10.3.2 float rows).
284#[must_use]
285pub fn is_core_float(value: &str) -> bool {
286    // Special values.
287    if matches!(value, ".nan" | ".NaN" | ".NAN") {
288        return true;
289    }
290
291    // Strip optional leading sign for inf and decimal.
292    let unsigned = value
293        .strip_prefix('-')
294        .or_else(|| value.strip_prefix('+'))
295        .unwrap_or(value);
296
297    // Infinity: [+-]?.inf | .Inf | .INF
298    if matches!(unsigned, ".inf" | ".Inf" | ".INF") {
299        return true;
300    }
301
302    // Decimal float: (\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?
303    is_core_decimal_float(unsigned)
304}
305
306/// Check whether `s` (already sign-stripped) matches the Core decimal float
307/// pattern: `(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?`.
308fn is_core_decimal_float(s: &str) -> bool {
309    // Split off optional exponent first.
310    let (mantissa, exp_part) = split_exponent(s);
311
312    // Validate exponent if present.
313    if exp_part.is_some_and(|exp| !is_valid_exponent_digits(exp)) {
314        return false;
315    }
316
317    // Mantissa must be either:
318    //   a) \.[0-9]+  — leading-dot form
319    //   b) [0-9]+(\.[0-9]*)?  — digit(s) with optional fractional part
320    if let Some(after_dot) = mantissa.strip_prefix('.') {
321        // Leading-dot form: must have at least one digit after the dot.
322        !after_dot.is_empty() && after_dot.bytes().all(|b| b.is_ascii_digit())
323    } else {
324        // Digit-first form.
325        let (int_part, frac) = mantissa.find('.').map_or((mantissa, None), |pos| {
326            (&mantissa[..pos], Some(&mantissa[pos + 1..]))
327        });
328        if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
329            return false;
330        }
331        // If there's a fractional part it may be empty (e.g. `1.`) or digits.
332        if let Some(frac_digits) = frac {
333            if !frac_digits.bytes().all(|b| b.is_ascii_digit()) {
334                return false;
335            }
336        } else {
337            // No dot at all — only valid if there's an exponent (e.g. `1e10`).
338            // Without an exponent this is just an integer.
339            if exp_part.is_none() {
340                return false;
341            }
342        }
343        true
344    }
345}
346
347/// Split `s` at the first `e` or `E`, returning `(mantissa, Some(exponent_digits))`.
348/// The exponent sign (`+`/`-`) is included in the returned exponent slice.
349fn split_exponent(s: &str) -> (&str, Option<&str>) {
350    s.find(['e', 'E'])
351        .map_or((s, None), |pos| (&s[..pos], Some(&s[pos + 1..])))
352}
353
354/// Validate exponent digits: optional `+`/`-` followed by at least one ASCII digit.
355fn is_valid_exponent_digits(exp: &str) -> bool {
356    let digits = exp.strip_prefix(['-', '+']).unwrap_or(exp);
357    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
358}
359
360// ---------------------------------------------------------------------------
361// JSON schema matchers (§10.2.2 tag resolution table)
362// ---------------------------------------------------------------------------
363
364/// JSON null: exactly `"null"` (§10.2.2).
365#[must_use]
366pub fn is_json_null(value: &str) -> bool {
367    value == "null"
368}
369
370/// JSON bool: `"true"` or `"false"` only (§10.2.2).
371#[must_use]
372pub fn is_json_bool(value: &str) -> bool {
373    matches!(value, "true" | "false")
374}
375
376/// JSON int: `0 | -?[1-9][0-9]*` (§10.2.2).
377///
378/// No `+` sign, no octal, no hex, no leading zeros.
379#[must_use]
380pub fn is_json_int(value: &str) -> bool {
381    if value == "0" {
382        return true;
383    }
384    // -?[1-9][0-9]*
385    let rest = value.strip_prefix('-').unwrap_or(value);
386    let mut bytes = rest.bytes();
387    match bytes.next() {
388        // First digit must be 1–9.
389        Some(b'1'..=b'9') => {}
390        _ => return false,
391    }
392    bytes.all(|b| b.is_ascii_digit())
393}
394
395/// JSON float: `-?(0|[1-9][0-9]*)(\.[0-9]*)?([eE][-+]?[0-9]+)?` (§10.2.2).
396///
397/// No `+` sign, no leading-dot form, no `.inf`, no `.nan`.
398#[must_use]
399pub fn is_json_float(value: &str) -> bool {
400    // Strip optional leading minus (no + allowed).
401    let unsigned = value.strip_prefix('-').unwrap_or(value);
402
403    // Integer part: `0` or `[1-9][0-9]*`.
404    let after_int = if let Some(rest) = unsigned.strip_prefix('0') {
405        rest
406    } else {
407        let mut bytes = unsigned.bytes();
408        match bytes.next() {
409            Some(b'1'..=b'9') => {}
410            _ => return false,
411        }
412        let consumed = 1 + bytes.take_while(u8::is_ascii_digit).count();
413        &unsigned[consumed..]
414    };
415
416    // Optional fractional part: `\.[0-9]*`
417    let after_frac = after_int.strip_prefix('.').map_or(after_int, |rest| {
418        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
419        &rest[digits..]
420    });
421
422    // Optional exponent: `[eE][-+]?[0-9]+`
423    let after_exp = if let Some(exp_rest) = after_frac
424        .strip_prefix('e')
425        .or_else(|| after_frac.strip_prefix('E'))
426    {
427        let digits_start = exp_rest.strip_prefix(['-', '+']).unwrap_or(exp_rest);
428        if digits_start.is_empty() || !digits_start.bytes().all(|b| b.is_ascii_digit()) {
429            return false;
430        }
431        ""
432    } else {
433        after_frac
434    };
435
436    // Must have consumed the entire string.
437    after_exp.is_empty()
438}
439
440// ---------------------------------------------------------------------------
441// Tests
442// ---------------------------------------------------------------------------
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::event::Chomp;
448    use rstest::rstest;
449
450    // ── 1. ResolvedTag::as_str() ───────────────────────────────────────────
451
452    #[rstest]
453    #[case::str_tag(ResolvedTag::Str, "tag:yaml.org,2002:str")]
454    #[case::int_tag(ResolvedTag::Int, "tag:yaml.org,2002:int")]
455    #[case::float_tag(ResolvedTag::Float, "tag:yaml.org,2002:float")]
456    #[case::bool_tag(ResolvedTag::Bool, "tag:yaml.org,2002:bool")]
457    #[case::null_tag(ResolvedTag::Null, "tag:yaml.org,2002:null")]
458    #[case::seq_tag(ResolvedTag::Seq, "tag:yaml.org,2002:seq")]
459    #[case::map_tag(ResolvedTag::Map, "tag:yaml.org,2002:map")]
460    fn resolved_tag_as_str_returns_uri(#[case] tag: ResolvedTag, #[case] expected: &str) {
461        assert_eq!(tag.as_str(), expected);
462    }
463
464    // ── 2. Core regex matchers ─────────────────────────────────────────────
465
466    // is_core_null — true
467
468    #[rstest]
469    #[case::null_lowercase("null")]
470    #[case::null_titlecase("Null")]
471    #[case::null_uppercase("NULL")]
472    #[case::tilde("~")]
473    #[case::empty("")]
474    fn is_core_null_returns_true(#[case] input: &str) {
475        assert!(is_core_null(input));
476    }
477
478    // is_core_null — false
479
480    #[rstest]
481    #[case::none_string("none")]
482    #[case::nil_string("nil")]
483    #[case::mixed_case_null("nUll")]
484    #[case::single_space(" ")]
485    #[case::json_null_inside_word("nullX")]
486    fn is_core_null_returns_false(#[case] input: &str) {
487        assert!(!is_core_null(input));
488    }
489
490    // is_core_bool — true
491
492    #[rstest]
493    #[case::true_lowercase("true")]
494    #[case::true_titlecase("True")]
495    #[case::true_uppercase("TRUE")]
496    #[case::false_lowercase("false")]
497    #[case::false_titlecase("False")]
498    #[case::false_uppercase("FALSE")]
499    fn is_core_bool_returns_true(#[case] input: &str) {
500        assert!(is_core_bool(input));
501    }
502
503    // is_core_bool — false
504
505    #[rstest]
506    #[case::yaml11_yes("yes")]
507    #[case::yaml11_no("no")]
508    #[case::yaml11_on("on")]
509    #[case::yaml11_off("off")]
510    #[case::mixed_case_true("tRue")]
511    #[case::integer_one("1")]
512    #[case::integer_zero("0")]
513    fn is_core_bool_returns_false(#[case] input: &str) {
514        assert!(!is_core_bool(input));
515    }
516
517    // is_core_int — true
518
519    #[rstest]
520    #[case::decimal_zero("0")]
521    #[case::decimal_positive("42")]
522    #[case::decimal_negative("-1")]
523    #[case::decimal_plus_prefix("+100")]
524    #[case::octal("0o17")]
525    #[case::octal_negative("-0o10")]
526    #[case::hex_lower("0xff")]
527    #[case::hex_upper("0xFF")]
528    #[case::hex_negative("-0x1A")]
529    fn is_core_int_returns_true(#[case] input: &str) {
530        assert!(is_core_int(input));
531    }
532
533    // is_core_int — false
534
535    #[rstest]
536    #[case::leading_zeros("007")]
537    #[case::empty("")]
538    #[case::sign_only_plus("+")]
539    #[case::sign_only_minus("-")]
540    #[case::float_with_dot("3.14")]
541    #[case::float_exp("1e5")]
542    #[case::octal_prefix_only("0o")]
543    #[case::hex_prefix_only("0x")]
544    #[case::alpha_string("abc")]
545    fn is_core_int_returns_false(#[case] input: &str) {
546        assert!(!is_core_int(input));
547    }
548
549    // is_core_float — true
550
551    #[rstest]
552    #[case::decimal_dot("3.14")]
553    #[case::decimal_no_integer_part(".5")]
554    #[case::exponent_only("1e10")]
555    #[case::exponent_negative("1.5E-3")]
556    #[case::positive_signed_float("+1.0")]
557    #[case::negative_float("-0.5")]
558    #[case::inf_lowercase(".inf")]
559    #[case::inf_titlecase(".Inf")]
560    #[case::inf_uppercase(".INF")]
561    #[case::neg_inf_lowercase("-.inf")]
562    #[case::neg_inf_titlecase("-.Inf")]
563    #[case::neg_inf_uppercase("-.INF")]
564    #[case::pos_inf("+.inf")]
565    #[case::nan_lowercase(".nan")]
566    #[case::nan_titlecase(".NaN")]
567    #[case::nan_uppercase(".NAN")]
568    fn is_core_float_returns_true(#[case] input: &str) {
569        assert!(is_core_float(input));
570    }
571
572    // is_core_float — false
573
574    #[rstest]
575    #[case::bare_integer("42")]
576    #[case::empty("")]
577    #[case::bare_inf_no_dot("inf")]
578    #[case::bare_nan_no_dot("nan")]
579    #[case::sign_only("+")]
580    #[case::dot_only(".")]
581    fn is_core_float_returns_false(#[case] input: &str) {
582        assert!(!is_core_float(input));
583    }
584
585    // ── 3. JSON regex matchers ─────────────────────────────────────────────
586
587    // is_json_null
588
589    #[test]
590    fn is_json_null_returns_true() {
591        assert!(is_json_null("null"));
592    }
593
594    #[rstest]
595    #[case::null_titlecase("Null")]
596    #[case::null_uppercase("NULL")]
597    #[case::tilde("~")]
598    #[case::empty("")]
599    fn is_json_null_returns_false(#[case] input: &str) {
600        assert!(!is_json_null(input));
601    }
602
603    // is_json_bool
604
605    #[rstest]
606    #[case::true_lowercase("true")]
607    #[case::false_lowercase("false")]
608    fn is_json_bool_returns_true(#[case] input: &str) {
609        assert!(is_json_bool(input));
610    }
611
612    #[rstest]
613    #[case::true_titlecase("True")]
614    #[case::true_uppercase("TRUE")]
615    #[case::false_titlecase("False")]
616    #[case::false_uppercase("FALSE")]
617    fn is_json_bool_returns_false(#[case] input: &str) {
618        assert!(!is_json_bool(input));
619    }
620
621    // is_json_int
622
623    #[rstest]
624    #[case::zero("0")]
625    #[case::positive_decimal("42")]
626    #[case::negative_decimal("-1")]
627    #[case::negative_multi("-100")]
628    #[case::large_negative("-9999")]
629    fn is_json_int_returns_true(#[case] input: &str) {
630        assert!(is_json_int(input));
631    }
632
633    #[rstest]
634    #[case::plus_prefix("+42")]
635    #[case::plus_zero("+0")]
636    #[case::minus_zero("-0")]
637    #[case::leading_zeros("007")]
638    #[case::octal("0o17")]
639    #[case::hex("0xFF")]
640    #[case::empty("")]
641    #[case::sign_only_plus("+")]
642    #[case::sign_only_minus("-")]
643    fn is_json_int_returns_false(#[case] input: &str) {
644        assert!(!is_json_int(input));
645    }
646
647    // is_json_float
648
649    #[rstest]
650    #[case::zero_float_simple("0.5")]
651    #[case::negative_with_decimal("-1.5")]
652    #[case::with_exponent("1e10")]
653    #[case::with_negative_exponent("-1.5e-3")]
654    // `-0` matches `-?(0)` with no fractional/exponent — valid JSON float.
655    #[case::minus_zero("-0")]
656    // bare `0` matches the integer part with no fractional or exponent.
657    #[case::zero_alone("0")]
658    fn is_json_float_returns_true(#[case] input: &str) {
659        assert!(is_json_float(input));
660    }
661
662    #[rstest]
663    #[case::plus_prefix("+1.5")]
664    #[case::inf_dot(".inf")]
665    #[case::nan_dot(".nan")]
666    #[case::leading_dot(".5")]
667    #[case::empty("")]
668    #[case::sign_only("-")]
669    fn is_json_float_returns_false(#[case] input: &str) {
670        assert!(!is_json_float(input));
671    }
672
673    // ── 4. resolve_scalar ─────────────────────────────────────────────────
674
675    // 4a. Failsafe schema
676
677    #[rstest]
678    #[case::plain_null(ScalarStyle::Plain, "null", None)]
679    #[case::single_quoted_true(ScalarStyle::SingleQuoted, "true", None)]
680    #[case::double_quoted_int(ScalarStyle::DoubleQuoted, "42", None)]
681    #[case::literal_block(ScalarStyle::Literal(Chomp::Clip), "hello", None)]
682    #[case::folded_block(ScalarStyle::Folded(Chomp::Strip), "world", None)]
683    fn resolve_scalar_failsafe_always_str(
684        #[case] style: ScalarStyle,
685        #[case] value: &str,
686        #[case] source_tag: Option<&str>,
687    ) {
688        assert_eq!(
689            resolve_scalar(Schema::Failsafe, style, value, source_tag),
690            Ok(Some(ResolvedTag::Str))
691        );
692    }
693
694    #[test]
695    fn resolve_scalar_failsafe_explicit_tag_passthrough() {
696        let result = resolve_scalar(
697            Schema::Failsafe,
698            ScalarStyle::Plain,
699            "null",
700            Some("tag:yaml.org,2002:str"),
701        );
702        assert_eq!(result, Ok(None));
703    }
704
705    // 4b. Core schema
706
707    #[rstest]
708    #[case::plain_null_lowercase(ScalarStyle::Plain, "null", None, ResolvedTag::Null)]
709    #[case::plain_null_tilde(ScalarStyle::Plain, "~", None, ResolvedTag::Null)]
710    #[case::plain_null_empty(ScalarStyle::Plain, "", None, ResolvedTag::Null)]
711    #[case::plain_bool_true_lower(ScalarStyle::Plain, "true", None, ResolvedTag::Bool)]
712    #[case::plain_bool_false_upper(ScalarStyle::Plain, "FALSE", None, ResolvedTag::Bool)]
713    #[case::plain_int_decimal(ScalarStyle::Plain, "42", None, ResolvedTag::Int)]
714    #[case::plain_int_octal(ScalarStyle::Plain, "0o17", None, ResolvedTag::Int)]
715    #[case::plain_int_hex(ScalarStyle::Plain, "0xFF", None, ResolvedTag::Int)]
716    #[case::plain_float_decimal(ScalarStyle::Plain, "3.14", None, ResolvedTag::Float)]
717    #[case::plain_float_inf(ScalarStyle::Plain, ".inf", None, ResolvedTag::Float)]
718    #[case::plain_float_nan(ScalarStyle::Plain, ".nan", None, ResolvedTag::Float)]
719    #[case::plain_unmatched_str(ScalarStyle::Plain, "hello", None, ResolvedTag::Str)]
720    #[case::plain_leading_zeros(ScalarStyle::Plain, "007", None, ResolvedTag::Str)]
721    #[case::single_quoted_null(ScalarStyle::SingleQuoted, "null", None, ResolvedTag::Str)]
722    #[case::double_quoted_true(ScalarStyle::DoubleQuoted, "true", None, ResolvedTag::Str)]
723    #[case::literal_any(ScalarStyle::Literal(Chomp::Clip), "42", None, ResolvedTag::Str)]
724    #[case::folded_any(ScalarStyle::Folded(Chomp::Keep), "null", None, ResolvedTag::Str)]
725    fn resolve_scalar_core(
726        #[case] style: ScalarStyle,
727        #[case] value: &str,
728        #[case] source_tag: Option<&str>,
729        #[case] expected: ResolvedTag,
730    ) {
731        assert_eq!(
732            resolve_scalar(Schema::Core, style, value, source_tag),
733            Ok(Some(expected))
734        );
735    }
736
737    #[test]
738    fn resolve_scalar_core_explicit_tag_passthrough() {
739        let result = resolve_scalar(
740            Schema::Core,
741            ScalarStyle::Plain,
742            "null",
743            Some("tag:yaml.org,2002:int"),
744        );
745        assert_eq!(result, Ok(None));
746    }
747
748    // 4c. JSON schema
749
750    #[rstest]
751    // null
752    #[case::plain_null_lowercase(ScalarStyle::Plain, "null", None, Ok(Some(ResolvedTag::Null)))]
753    // JSON rejects Core-only null forms
754    #[case::plain_null_tilde_rejected(ScalarStyle::Plain, "~", None, Err(UnresolvedScalar))]
755    #[case::plain_empty_rejected(ScalarStyle::Plain, "", None, Err(UnresolvedScalar))]
756    // bool
757    #[case::plain_bool_true_lower(ScalarStyle::Plain, "true", None, Ok(Some(ResolvedTag::Bool)))]
758    #[case::plain_bool_true_upper_rejected(ScalarStyle::Plain, "TRUE", None, Err(UnresolvedScalar))]
759    // int
760    #[case::plain_int_decimal(ScalarStyle::Plain, "42", None, Ok(Some(ResolvedTag::Int)))]
761    #[case::plain_int_zero(ScalarStyle::Plain, "0", None, Ok(Some(ResolvedTag::Int)))]
762    #[case::plain_int_negative(ScalarStyle::Plain, "-1", None, Ok(Some(ResolvedTag::Int)))]
763    #[case::plain_int_plus_rejected(ScalarStyle::Plain, "+42", None, Err(UnresolvedScalar))]
764    // -0: not a JSON int; dispatched to float (matches `-?(0)` with no fractional/exp)
765    #[case::plain_minus_zero_is_float(ScalarStyle::Plain, "-0", None, Ok(Some(ResolvedTag::Float)))]
766    #[case::plain_octal_rejected(ScalarStyle::Plain, "0o17", None, Err(UnresolvedScalar))]
767    #[case::plain_hex_rejected(ScalarStyle::Plain, "0xFF", None, Err(UnresolvedScalar))]
768    // float
769    #[case::plain_float_decimal(ScalarStyle::Plain, "1.5", None, Ok(Some(ResolvedTag::Float)))]
770    #[case::plain_float_inf_rejected(ScalarStyle::Plain, ".inf", None, Err(UnresolvedScalar))]
771    #[case::plain_float_nan_rejected(ScalarStyle::Plain, ".nan", None, Err(UnresolvedScalar))]
772    #[case::plain_float_plus_rejected(ScalarStyle::Plain, "+1.5", None, Err(UnresolvedScalar))]
773    // unmatched
774    #[case::plain_unmatched_rejected(ScalarStyle::Plain, "hello", None, Err(UnresolvedScalar))]
775    // non-plain styles → Str (no pattern matching)
776    #[case::single_quoted_becomes_str(
777        ScalarStyle::SingleQuoted,
778        "null",
779        None,
780        Ok(Some(ResolvedTag::Str))
781    )]
782    #[case::double_quoted_becomes_str(
783        ScalarStyle::DoubleQuoted,
784        "true",
785        None,
786        Ok(Some(ResolvedTag::Str))
787    )]
788    #[case::literal_becomes_str(
789        ScalarStyle::Literal(Chomp::Clip),
790        "42",
791        None,
792        Ok(Some(ResolvedTag::Str))
793    )]
794    #[case::folded_becomes_str(
795        ScalarStyle::Folded(Chomp::Strip),
796        "null",
797        None,
798        Ok(Some(ResolvedTag::Str))
799    )]
800    fn resolve_scalar_json(
801        #[case] style: ScalarStyle,
802        #[case] value: &str,
803        #[case] source_tag: Option<&str>,
804        #[case] expected: Result<Option<ResolvedTag>, UnresolvedScalar>,
805    ) {
806        assert_eq!(
807            resolve_scalar(Schema::Json, style, value, source_tag),
808            expected
809        );
810    }
811
812    #[test]
813    fn resolve_scalar_json_explicit_tag_passthrough() {
814        let result = resolve_scalar(Schema::Json, ScalarStyle::Plain, "null", Some("!custom"));
815        assert_eq!(result, Ok(None));
816    }
817
818    // 4d. source_tag passthrough — cross-schema
819
820    #[test]
821    fn resolve_scalar_explicit_tag_returns_none_failsafe() {
822        assert_eq!(
823            resolve_scalar(
824                Schema::Failsafe,
825                ScalarStyle::Plain,
826                "null",
827                Some("anything")
828            ),
829            Ok(None)
830        );
831    }
832
833    #[test]
834    fn resolve_scalar_explicit_tag_returns_none_json() {
835        assert_eq!(
836            resolve_scalar(Schema::Json, ScalarStyle::Plain, "null", Some("anything")),
837            Ok(None)
838        );
839    }
840
841    #[test]
842    fn resolve_scalar_explicit_tag_returns_none_core() {
843        assert_eq!(
844            resolve_scalar(Schema::Core, ScalarStyle::Plain, "null", Some("anything")),
845            Ok(None)
846        );
847    }
848
849    // ── 5. resolve_collection ─────────────────────────────────────────────
850
851    #[rstest]
852    #[case::failsafe_sequence_no_tag(
853        Schema::Failsafe,
854        CollectionKind::Sequence,
855        None,
856        Some(ResolvedTag::Seq)
857    )]
858    #[case::failsafe_mapping_no_tag(
859        Schema::Failsafe,
860        CollectionKind::Mapping,
861        None,
862        Some(ResolvedTag::Map)
863    )]
864    #[case::json_sequence_no_tag(
865        Schema::Json,
866        CollectionKind::Sequence,
867        None,
868        Some(ResolvedTag::Seq)
869    )]
870    #[case::json_mapping_no_tag(
871        Schema::Json,
872        CollectionKind::Mapping,
873        None,
874        Some(ResolvedTag::Map)
875    )]
876    #[case::core_sequence_no_tag(
877        Schema::Core,
878        CollectionKind::Sequence,
879        None,
880        Some(ResolvedTag::Seq)
881    )]
882    #[case::core_mapping_no_tag(
883        Schema::Core,
884        CollectionKind::Mapping,
885        None,
886        Some(ResolvedTag::Map)
887    )]
888    #[case::failsafe_sequence_explicit_tag(
889        Schema::Failsafe,
890        CollectionKind::Sequence,
891        Some("!custom"),
892        None
893    )]
894    #[case::failsafe_mapping_explicit_tag(
895        Schema::Failsafe,
896        CollectionKind::Mapping,
897        Some("tag:yaml.org,2002:map"),
898        None
899    )]
900    #[case::core_sequence_explicit_tag(Schema::Core, CollectionKind::Sequence, Some("!seq"), None)]
901    #[case::json_mapping_explicit_tag(Schema::Json, CollectionKind::Mapping, Some("!map"), None)]
902    fn resolve_collection_dispatch(
903        #[case] schema: Schema,
904        #[case] kind: CollectionKind,
905        #[case] source_tag: Option<&str>,
906        #[case] expected: Option<ResolvedTag>,
907    ) {
908        assert_eq!(resolve_collection(schema, kind, source_tag), expected);
909    }
910}