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