Skip to content

Commit e35f01b

Browse files
authored
Support yaml's FP sentinels (#295)
1 parent 35cc5e9 commit e35f01b

2 files changed

Lines changed: 108 additions & 3 deletions

File tree

crates/quarto-yaml-validation/src/validator.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ fn validate_number(
230230
) -> ValidationResult<()> {
231231
let num = match &value.yaml {
232232
Yaml::Integer(n) => *n as f64,
233-
Yaml::Real(s) => s.parse::<f64>().unwrap_or(f64::NAN),
233+
Yaml::Real(_) => value.yaml.as_f64().unwrap_or(f64::NAN),
234234
_ => {
235235
context.add_error(
236236
ValidationErrorKind::TypeMismatch {
@@ -884,6 +884,48 @@ mod tests {
884884
assert!(validate(&yaml, &schema, &registry, &source_ctx).is_ok());
885885
}
886886

887+
#[test]
888+
fn test_validate_number_yaml_infinity_and_nan() {
889+
// End-to-end: parse real YAML text and validate against a `number`
890+
// schema. The YAML core-schema float spellings (`.inf`, `-.inf`, …)
891+
// must validate as numbers. Regression for tidyverse/data-dict#47,
892+
// where `range: [-.inf, 8.3]` was rejected with "got string".
893+
let registry = SchemaRegistry::new();
894+
let source_ctx = SourceContext::new();
895+
let number_schema = || {
896+
Schema::Number(NumberSchema {
897+
annotations: SchemaAnnotations::default(),
898+
minimum: None,
899+
maximum: None,
900+
exclusive_minimum: None,
901+
exclusive_maximum: None,
902+
multiple_of: None,
903+
})
904+
};
905+
906+
for text in [".inf", "+.inf", "-.inf", ".Inf", ".INF", ".nan", ".NaN", ".NAN"] {
907+
let yaml = quarto_yaml::parse(text).unwrap();
908+
assert!(
909+
validate(&yaml, &number_schema(), &registry, &source_ctx).is_ok(),
910+
"{text:?} should validate as a number"
911+
);
912+
}
913+
914+
// And the data-dict shape: an array of numbers including -.inf.
915+
let array_schema = Schema::Array(ArraySchema {
916+
annotations: SchemaAnnotations::default(),
917+
items: Some(Box::new(number_schema())),
918+
min_items: None,
919+
max_items: None,
920+
unique_items: None,
921+
});
922+
let yaml = quarto_yaml::parse("[-.inf, 8.3]").unwrap();
923+
assert!(
924+
validate(&yaml, &array_schema, &registry, &source_ctx).is_ok(),
925+
"[-.inf, 8.3] should validate as an array of numbers"
926+
);
927+
}
928+
887929
#[test]
888930
fn test_validate_number_wrong_type() {
889931
let registry = SchemaRegistry::new();

crates/quarto-yaml/src/parser.rs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,8 +558,10 @@ fn parse_scalar_value(value: &str) -> Yaml {
558558
return Yaml::Integer(i);
559559
}
560560

561-
// Try to parse as float
562-
if let Ok(_f) = value.parse::<f64>() {
561+
// Try to parse as float, including the YAML 1.2 core-schema float
562+
// spellings (`.inf`, `-.inf`, `+.inf`, `.nan`, and case variants) that
563+
// Rust's `f64::from_str` does not accept.
564+
if is_yaml_float(value) {
563565
return Yaml::Real(value.to_string());
564566
}
565567

@@ -581,6 +583,16 @@ fn parse_scalar_value(value: &str) -> Yaml {
581583
Yaml::String(value.to_string())
582584
}
583585

586+
/// Returns `true` if `value` is a YAML 1.2 core-schema float.
587+
fn is_yaml_float(value: &str) -> bool {
588+
match value {
589+
".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => true,
590+
"-.inf" | "-.Inf" | "-.INF" => true,
591+
".nan" | ".NaN" | ".NAN" => true,
592+
_ => value.bytes().any(|b| b.is_ascii_digit()) && value.parse::<f64>().is_ok(),
593+
}
594+
}
595+
584596
#[cfg(test)]
585597
mod tests {
586598
use super::*;
@@ -599,6 +611,57 @@ mod tests {
599611
assert_eq!(yaml.yaml.as_i64(), Some(42));
600612
}
601613

614+
#[test]
615+
fn test_parse_yaml_float_special_forms() {
616+
// YAML 1.2 core-schema float spellings must resolve to Yaml::Real,
617+
// not Yaml::String. See tidyverse/data-dict#47.
618+
for (text, expected) in [
619+
(".inf", f64::INFINITY),
620+
("+.inf", f64::INFINITY),
621+
(".Inf", f64::INFINITY),
622+
(".INF", f64::INFINITY),
623+
("+.INF", f64::INFINITY),
624+
("-.inf", f64::NEG_INFINITY),
625+
("-.Inf", f64::NEG_INFINITY),
626+
("-.INF", f64::NEG_INFINITY),
627+
] {
628+
let yaml = parse(text).unwrap();
629+
assert!(
630+
matches!(yaml.yaml, Yaml::Real(_)),
631+
"{text:?} should parse as Yaml::Real, got {:?}",
632+
yaml.yaml
633+
);
634+
assert_eq!(
635+
yaml.yaml.as_f64(),
636+
Some(expected),
637+
"{text:?} should evaluate to {expected}"
638+
);
639+
}
640+
641+
for text in [".nan", ".NaN", ".NAN"] {
642+
let yaml = parse(text).unwrap();
643+
assert!(
644+
matches!(yaml.yaml, Yaml::Real(_)),
645+
"{text:?} should parse as Yaml::Real, got {:?}",
646+
yaml.yaml
647+
);
648+
assert!(
649+
yaml.yaml.as_f64().unwrap().is_nan(),
650+
"{text:?} should evaluate to NaN"
651+
);
652+
}
653+
654+
// Bare `inf` / `nan` (no leading dot) are NOT YAML floats — they stay strings.
655+
for text in ["inf", "nan", "infinity"] {
656+
let yaml = parse(text).unwrap();
657+
assert!(
658+
matches!(yaml.yaml, Yaml::String(_)),
659+
"{text:?} should stay a Yaml::String, got {:?}",
660+
yaml.yaml
661+
);
662+
}
663+
}
664+
602665
#[test]
603666
fn test_parse_boolean() {
604667
let yaml = parse("true").unwrap();

0 commit comments

Comments
 (0)