use crate::error::ParseResult;
use crate::value::Value;
use saphyr::{ScalarOwned, YamlLoader};
use saphyr_parser::{BufferedInput, Parser as SaphyrParser, ScalarStyle, Tag};
#[derive(Debug)]
pub struct Parser;
impl Parser {
pub fn parse_str(input: &str) -> ParseResult<Option<Value>> {
let mut saphyr_parser = SaphyrParser::new(BufferedInput::new(input.chars()));
let mut loader = YamlLoader::<Value>::default();
loader.early_parse(false);
saphyr_parser.load(&mut loader, true)?;
let docs = inject_implicit_null_if_empty(loader.into_documents(), input);
Ok(docs.into_iter().next().map(canonicalize))
}
pub fn parse_all(input: &str) -> ParseResult<Vec<Value>> {
let mut saphyr_parser = SaphyrParser::new(BufferedInput::new(input.chars()));
let mut loader = YamlLoader::<Value>::default();
loader.early_parse(false);
saphyr_parser.load(&mut loader, true)?;
let docs = inject_implicit_null_if_empty(loader.into_documents(), input);
Ok(docs.into_iter().map(canonicalize).collect())
}
pub fn parse_all_preserving_styles(input: &str) -> ParseResult<Vec<Value>> {
let mut saphyr_parser = SaphyrParser::new(BufferedInput::new(input.chars()));
let mut loader = YamlLoader::<Value>::default();
loader.early_parse(false);
saphyr_parser.load(&mut loader, true)?;
Ok(inject_implicit_null_if_empty(
loader.into_documents(),
input,
))
}
}
fn is_non_specific_tag(tag: &Tag) -> bool {
tag.handle.is_empty() && tag.suffix == "!"
}
fn inject_implicit_null_if_empty(docs: Vec<Value>, input: &str) -> Vec<Value> {
if docs.is_empty() && !input.is_empty() {
vec![Value::Value(ScalarOwned::Null)]
} else {
docs
}
}
pub fn canonicalize(value: Value) -> Value {
match value {
Value::Representation(ref s, style, ref tag) => {
coerce_representation(s, style, tag.as_ref())
}
Value::Value(ScalarOwned::String(ref s)) => match s.as_str() {
"True" | "TRUE" => Value::Value(ScalarOwned::Boolean(true)),
"False" | "FALSE" => Value::Value(ScalarOwned::Boolean(false)),
"Null" | "NULL" => Value::Value(ScalarOwned::Null),
_ => value,
},
Value::Tagged(ref tag, ref inner) => coerce_tagged(tag, inner),
Value::Sequence(seq) => Value::Sequence(seq.into_iter().map(canonicalize).collect()),
Value::Mapping(map) => {
let canonicalized: crate::value::Map = map
.into_iter()
.map(|(k, v)| (canonicalize(k), canonicalize(v)))
.collect();
resolve_merge_keys(canonicalized)
}
other => other,
}
}
fn parse_core_schema_int(s: &str) -> Option<i64> {
let (neg, digits) = s.strip_prefix('-').map_or_else(
|| (false, s.strip_prefix('+').unwrap_or(s)),
|rest| (true, rest),
);
let raw: i64 = if let Some(hex) = digits
.strip_prefix("0x")
.or_else(|| digits.strip_prefix("0X"))
{
i64::from_str_radix(hex, 16).ok()?
} else if let Some(oct) = digits
.strip_prefix("0o")
.or_else(|| digits.strip_prefix("0O"))
{
i64::from_str_radix(oct, 8).ok()?
} else {
digits.parse::<i64>().ok()?
};
if neg { raw.checked_neg() } else { Some(raw) }
}
fn is_integer_literal(s: &str) -> bool {
let s = s.strip_prefix(['+', '-']).unwrap_or(s);
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
return !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit());
}
if let Some(oct) = s.strip_prefix("0o").or_else(|| s.strip_prefix("0O")) {
return !oct.is_empty() && oct.bytes().all(|b| matches!(b, b'0'..=b'7'));
}
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn float_str_to_int(s: &str) -> Option<i64> {
parse_core_schema_float(s)
.filter(|f| f.is_finite() && *f >= i64::MIN as f64 && *f <= i64::MAX as f64)
.map(|f| f as i64)
}
fn parse_core_schema_float(s: &str) -> Option<f64> {
match s {
".inf" | ".Inf" | ".INF" => Some(f64::INFINITY),
"-.inf" | "-.Inf" | "-.INF" => Some(f64::NEG_INFINITY),
".nan" | ".NaN" | ".NAN" => Some(f64::NAN),
other => {
let s = other.strip_prefix(['+', '-']).unwrap_or(other);
let has_digit_start = s.starts_with(|c: char| c.is_ascii_digit());
let looks_like_float = has_digit_start
&& s.chars().all(|c| {
c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-'
});
looks_like_float
.then(|| other.parse::<f64>().ok())
.flatten()
}
}
}
fn coerce_representation(s: &str, style: ScalarStyle, tag: Option<&Tag>) -> Value {
if let Some(tag) = tag.filter(|t| t.is_yaml_core_schema()) {
let coerced: Option<ScalarOwned> = match tag.suffix.as_str() {
"int" => parse_core_schema_int(s)
.or_else(|| float_str_to_int(s))
.map(ScalarOwned::Integer),
"float" => parse_core_schema_float(s).map(|f| ScalarOwned::FloatingPoint(f.into())),
"bool" => s.parse::<bool>().ok().map(ScalarOwned::Boolean),
"null" => matches!(s, "~" | "null" | "").then_some(ScalarOwned::Null),
"str" => Some(ScalarOwned::String(s.into())),
_ => None,
};
if let Some(scalar) = coerced {
return Value::Value(scalar);
}
}
if tag.is_some_and(is_non_specific_tag) {
return Value::Value(ScalarOwned::String(s.into()));
}
if style != ScalarStyle::Plain {
return Value::Value(ScalarOwned::String(s.into()));
}
if s.is_empty() {
return Value::Value(ScalarOwned::Null);
}
let scalar = match s {
"~" | "null" | "NULL" | "Null" => ScalarOwned::Null,
"true" | "True" | "TRUE" => ScalarOwned::Boolean(true),
"false" | "False" | "FALSE" => ScalarOwned::Boolean(false),
other => parse_core_schema_int(other).map_or_else(
|| {
if is_integer_literal(other) {
ScalarOwned::String(other.into())
} else {
parse_core_schema_float(other).map_or_else(
|| ScalarOwned::String(other.into()),
|f| ScalarOwned::FloatingPoint(f.into()),
)
}
},
ScalarOwned::Integer,
),
};
Value::Value(scalar)
}
fn coerce_tagged(tag: &Tag, inner: &Value) -> Value {
if tag.is_yaml_core_schema()
&& let Value::Value(ScalarOwned::String(ref s)) = *inner
{
let coerced: Option<ScalarOwned> = match tag.suffix.as_str() {
"int" => parse_core_schema_int(s)
.or_else(|| float_str_to_int(s))
.map(ScalarOwned::Integer),
"float" => parse_core_schema_float(s).map(|f| ScalarOwned::FloatingPoint(f.into())),
"bool" => s.parse::<bool>().ok().map(ScalarOwned::Boolean),
"null" => matches!(s.as_str(), "~" | "null" | "").then_some(ScalarOwned::Null),
"str" => Some(ScalarOwned::String(s.clone())),
_ => None,
};
if let Some(scalar) = coerced {
return Value::Value(scalar);
}
}
canonicalize(inner.clone())
}
fn resolve_merge_keys(map: crate::value::Map) -> Value {
let merge_key = Value::Value(ScalarOwned::String("<<".into()));
if !map.contains_key(&merge_key) {
return Value::Mapping(map);
}
let mut result: crate::value::Map = crate::value::Map::new();
let mut merges: Vec<Value> = Vec::new();
for (k, v) in map {
if k == merge_key {
merges.push(v);
} else {
result.insert(k, v);
}
}
for merge_val in merges {
match merge_val {
Value::Mapping(merge_map) => {
for (mk, mv) in merge_map {
result.entry(mk).or_insert(mv);
}
}
Value::Sequence(seq) => {
for item in seq {
if let Value::Mapping(merge_map) = item {
for (mk, mv) in merge_map {
result.entry(mk).or_insert(mv);
}
}
}
}
_ => {}
}
}
Value::Mapping(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_str_simple() {
let result = Parser::parse_str("name: test\nvalue: 123").unwrap();
assert!(result.is_some());
}
#[test]
fn test_parse_str_empty() {
let result = Parser::parse_str("").unwrap();
assert!(result.is_none());
}
#[test]
fn test_parse_all_multiple_docs() {
let docs = Parser::parse_all("---\nfoo: 1\n---\nbar: 2").unwrap();
assert_eq!(docs.len(), 2);
}
#[test]
fn test_yaml12_bool_true_variants() {
for variant in &["True", "TRUE"] {
let result = Parser::parse_str(&format!("val: {variant}"))
.unwrap()
.unwrap();
if let Value::Mapping(map) = result {
let v = map.values().next().unwrap();
assert!(
matches!(v, Value::Value(ScalarOwned::Boolean(true))),
"{variant} should be Bool(true)"
);
} else {
panic!("expected mapping");
}
}
}
#[test]
fn test_yaml12_bool_false_variants() {
for variant in &["False", "FALSE"] {
let result = Parser::parse_str(&format!("val: {variant}"))
.unwrap()
.unwrap();
if let Value::Mapping(map) = result {
let v = map.values().next().unwrap();
assert!(
matches!(v, Value::Value(ScalarOwned::Boolean(false))),
"{variant} should be Bool(false)"
);
} else {
panic!("expected mapping");
}
}
}
#[test]
fn test_yaml12_null_variant() {
let result = Parser::parse_str("val: Null").unwrap().unwrap();
if let Value::Mapping(map) = result {
let v = map.values().next().unwrap();
assert!(
matches!(v, Value::Value(ScalarOwned::Null)),
"Null should be Null"
);
} else {
panic!("expected mapping");
}
}
#[test]
fn test_parse_str_invalid() {
let result = Parser::parse_str("invalid: [\n missing: bracket");
assert!(result.is_err());
}
#[test]
fn test_parse_nested() {
let yaml = r"
person:
name: John
age: 30
hobbies:
- reading
- coding
";
let result = Parser::parse_str(yaml).unwrap();
assert!(result.is_some());
}
#[test]
fn test_parse_anchors() {
let yaml = r"
defaults: &defaults
adapter: postgres
host: localhost
development:
<<: *defaults
database: dev_db
";
let result = Parser::parse_str(yaml).unwrap();
assert!(result.is_some());
}
fn get_mapping_val(yaml: &str, key: &str) -> Value {
let result = Parser::parse_str(yaml).unwrap().unwrap();
let Value::Mapping(map) = result else {
panic!("expected mapping");
};
let k = Value::Value(ScalarOwned::String(key.into()));
map[&k].clone()
}
#[test]
fn test_explicit_tag_int_quoted() {
let v = get_mapping_val("val: !!int '42'", "val");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(42))),
"got {v:?}"
);
}
#[test]
fn test_explicit_tag_float() {
let v = get_mapping_val("val: !!float '3.14'", "val");
if let Value::Value(ScalarOwned::FloatingPoint(f)) = v {
#[allow(clippy::approx_constant)]
let expected = 3.14_f64;
assert!((f64::from(f) - expected).abs() < 1e-9);
} else {
panic!("expected FloatingPoint, got {v:?}");
}
}
#[test]
fn test_explicit_tag_bool() {
let v = get_mapping_val("val: !!bool 'true'", "val");
assert!(
matches!(v, Value::Value(ScalarOwned::Boolean(true))),
"got {v:?}"
);
}
#[test]
fn test_explicit_tag_null() {
let v = get_mapping_val("val: !!null ''", "val");
assert!(matches!(v, Value::Value(ScalarOwned::Null)), "got {v:?}");
}
#[test]
fn test_explicit_tag_str_int() {
let v = get_mapping_val("val: !!str 42", "val");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s == "42"),
"got {v:?}"
);
}
#[test]
fn test_explicit_tag_int_float_truncation() {
let v = get_mapping_val("val: !!int 3.14", "val");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(3))),
"got {v:?}"
);
}
#[test]
fn test_explicit_tag_int_negative_float() {
let v = get_mapping_val("val: !!int -2.7", "val");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(-2))),
"got {v:?}"
);
}
#[test]
fn test_explicit_tag_int_scientific() {
let v = get_mapping_val("val: !!int 1.0e2", "val");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(100))),
"got {v:?}"
);
}
#[test]
fn test_explicit_tag_int_exact_float() {
let v = get_mapping_val("val: !!int 3.0", "val");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(3))),
"got {v:?}"
);
}
#[test]
fn test_explicit_tag_int_nan_rejected() {
let v = get_mapping_val("val: !!int .nan", "val");
assert!(
!matches!(v, Value::Value(ScalarOwned::Integer(_))),
"!!int .nan should not produce an integer, got {v:?}"
);
}
#[test]
fn test_explicit_tag_int_inf_rejected() {
let v = get_mapping_val("val: !!int .inf", "val");
assert!(
!matches!(v, Value::Value(ScalarOwned::Integer(_))),
"!!int .inf should not produce an integer, got {v:?}"
);
}
#[test]
fn test_explicit_tag_int_overflow_rejected() {
let v = get_mapping_val("val: !!int 1.0e20", "val");
assert!(
!matches!(v, Value::Value(ScalarOwned::Integer(_))),
"!!int 1.0e20 should not produce a saturated integer, got {v:?}"
);
}
#[test]
fn test_merge_key_basic() {
let yaml = r"
defaults: &defaults
adapter: postgres
host: localhost
development:
<<: *defaults
database: dev_db
";
let result = Parser::parse_str(yaml).unwrap().unwrap();
let Value::Mapping(root) = result else {
panic!("expected mapping")
};
let dev_key = Value::Value(ScalarOwned::String("development".into()));
let Value::Mapping(dev) = root[&dev_key].clone() else {
panic!("expected mapping")
};
let adapter_key = Value::Value(ScalarOwned::String("adapter".into()));
let host_key = Value::Value(ScalarOwned::String("host".into()));
let db_key = Value::Value(ScalarOwned::String("database".into()));
assert!(dev.contains_key(&adapter_key), "adapter should be merged");
assert!(dev.contains_key(&host_key), "host should be merged");
assert!(dev.contains_key(&db_key), "database should be present");
assert!(
!dev.contains_key(&Value::Value(ScalarOwned::String("<<".into()))),
"<< should be removed"
);
}
#[test]
fn test_merge_key_explicit_wins() {
let yaml = r"
base: &base
host: localhost
port: 5432
override:
<<: *base
host: remotehost
";
let result = Parser::parse_str(yaml).unwrap().unwrap();
let Value::Mapping(root) = result else {
panic!("expected mapping")
};
let ov_key = Value::Value(ScalarOwned::String("override".into()));
let Value::Mapping(ov) = root[&ov_key].clone() else {
panic!("expected mapping")
};
let host_key = Value::Value(ScalarOwned::String("host".into()));
assert!(
matches!(&ov[&host_key], Value::Value(ScalarOwned::String(s)) if s == "remotehost"),
"explicit host should win over merged"
);
}
#[test]
fn test_merge_key_sequence() {
let yaml = r"
a: &a
x: 1
b: &b
y: 2
merged:
<<: [*a, *b]
z: 3
";
let result = Parser::parse_str(yaml).unwrap().unwrap();
let Value::Mapping(root) = result else {
panic!("expected mapping")
};
let m_key = Value::Value(ScalarOwned::String("merged".into()));
let Value::Mapping(m) = root[&m_key].clone() else {
panic!("expected mapping")
};
let x = Value::Value(ScalarOwned::String("x".into()));
let y = Value::Value(ScalarOwned::String("y".into()));
let z = Value::Value(ScalarOwned::String("z".into()));
assert!(m.contains_key(&x), "x should be merged from *a");
assert!(m.contains_key(&y), "y should be merged from *b");
assert!(m.contains_key(&z), "z should be present");
}
#[test]
fn test_i64_max_boundary() {
let v = get_mapping_val("x: 9223372036854775807", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(i64::MAX))),
"i64::MAX should stay Integer, got {v:?}"
);
let v = get_mapping_val("x: 9223372036854775808", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"i64::MAX+1 should become String, got {v:?}"
);
}
#[test]
fn test_leading_plus_large_integer() {
let v = get_mapping_val("x: +42", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(42))),
"+42 should be Integer(42), got {v:?}"
);
let v = get_mapping_val("x: +99999999999999999999", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"+overflow should be String, got {v:?}"
);
}
#[test]
fn test_large_integer_preserved_as_string() {
let big =
"99999999999999999999999999999999999999999999999999999999999999999999999999999999";
let v = get_mapping_val(&format!("x: {big}"), "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s == big),
"got {v:?}"
);
}
#[test]
fn test_normal_integer_unaffected() {
let v = get_mapping_val("x: 42", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(42))),
"got {v:?}"
);
}
#[test]
fn test_float_unaffected() {
let v = get_mapping_val("x: 1.5e10", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::FloatingPoint(_))),
"got {v:?}"
);
}
#[test]
fn test_negative_large_integer() {
let big = "-99999999999999999999999999999999";
let v = get_mapping_val(&format!("x: {big}"), "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s == big),
"got {v:?}"
);
}
#[test]
fn test_hex_overflow_preserved_as_string() {
let v = get_mapping_val("x: 0x8000000000000000", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"hex overflow should be String, got {v:?}"
);
}
#[test]
fn test_hex_max_i64_preserved_as_integer() {
let v = get_mapping_val("x: 0x7FFFFFFFFFFFFFFF", "x");
assert!(
matches!(
v,
Value::Value(ScalarOwned::Integer(9_223_372_036_854_775_807))
),
"0x7FFFFFFFFFFFFFFF should be Integer(i64::MAX), got {v:?}"
);
}
#[test]
fn test_octal_overflow_preserved_as_string() {
let v = get_mapping_val("x: 0o1000000000000000000000", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"octal overflow should be String, got {v:?}"
);
}
#[test]
fn test_octal_max_fitting_preserved_as_integer() {
let v = get_mapping_val("x: 0o777777777777777777777", "x");
assert!(
matches!(
v,
Value::Value(ScalarOwned::Integer(9_223_372_036_854_775_807))
),
"0o777777777777777777777 should be Integer(i64::MAX), got {v:?}"
);
}
#[test]
fn test_tagged_int_hex_fits_i64() {
let v = get_mapping_val("x: !!int 0xFF", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::Integer(255))),
"!!int 0xFF should be Integer(255), got {v:?}"
);
}
#[test]
fn test_tagged_int_hex_overflow_preserved_as_string() {
let v = get_mapping_val("x: !!int 0x8000000000000000", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"!!int hex overflow should be String, got {v:?}"
);
}
#[test]
fn test_negative_hex_overflow_preserved_as_string() {
let v = get_mapping_val("x: -0x8000000000000001", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"negative hex overflow should be String, got {v:?}"
);
}
#[test]
fn test_tagged_int_octal_overflow_preserved_as_string() {
let v = get_mapping_val("x: !!int 0o1000000000000000000000", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"!!int octal overflow should be String, got {v:?}"
);
}
#[test]
fn test_uppercase_prefix_hex_overflow_preserved_as_string() {
let v = get_mapping_val("x: 0XDEADBEEFDEADBEEF", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"0X uppercase prefix overflow should be String, got {v:?}"
);
}
#[test]
fn test_uppercase_prefix_octal_overflow_preserved_as_string() {
let v = get_mapping_val("x: 0O1000000000000000000000", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(_))),
"0O uppercase prefix overflow should be String, got {v:?}"
);
}
#[test]
fn test_empty_string_yields_empty_vec() {
let docs = Parser::parse_all("").unwrap();
assert!(docs.is_empty(), "empty string must stay []");
}
#[test]
fn test_whitespace_only_yields_null_doc() {
let docs = Parser::parse_all(" ").unwrap();
assert_eq!(docs.len(), 1);
assert!(matches!(docs[0], Value::Value(ScalarOwned::Null)));
}
#[test]
fn test_comment_only_yields_null_doc() {
let docs = Parser::parse_all("# comment").unwrap();
assert_eq!(docs.len(), 1);
assert!(matches!(docs[0], Value::Value(ScalarOwned::Null)));
}
#[test]
fn test_bare_doc_end_yields_null_doc() {
let docs = Parser::parse_all("...").unwrap();
assert_eq!(docs.len(), 1);
assert!(matches!(docs[0], Value::Value(ScalarOwned::Null)));
}
#[test]
fn test_comment_then_doc_end_yields_null_doc() {
let docs = Parser::parse_all("# c\n...").unwrap();
assert_eq!(docs.len(), 1);
assert!(matches!(docs[0], Value::Value(ScalarOwned::Null)));
}
#[test]
fn test_bare_doc_start_yields_null_doc() {
let docs = Parser::parse_all("---").unwrap();
assert_eq!(docs.len(), 1);
assert!(matches!(docs[0], Value::Value(ScalarOwned::Null)));
}
#[test]
fn test_parse_str_comment_only_returns_null() {
let result = Parser::parse_str("# comment").unwrap();
assert!(matches!(result, Some(Value::Value(ScalarOwned::Null))));
}
#[test]
fn test_parse_str_empty_unchanged() {
let result = Parser::parse_str("").unwrap();
assert!(result.is_none(), "empty string must still return None");
}
#[test]
fn test_bom_only_yields_one_doc() {
let docs = Parser::parse_all("\u{FEFF}").unwrap();
assert_eq!(docs.len(), 1, "BOM-only should yield exactly one document");
}
#[test]
fn test_non_specific_tag_plain_integer_is_string() {
let v = get_mapping_val("x: ! 99", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s == "99"),
"! 99 should be String(\"99\"), got {v:?}"
);
}
#[test]
fn test_non_specific_tag_quoted_is_string() {
let v = get_mapping_val("x: ! \"99\"", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s == "99"),
"! \"99\" should be String(\"99\"), got {v:?}"
);
}
#[test]
fn test_non_specific_tag_true_is_string() {
let v = get_mapping_val("x: ! true", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s == "true"),
"! true should be String(\"true\"), got {v:?}"
);
}
#[test]
fn test_non_specific_tag_null_keyword_is_string() {
let v = get_mapping_val("x: ! null", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s == "null"),
"! null should be String(\"null\"), got {v:?}"
);
}
#[test]
fn test_non_specific_tag_empty_is_string_not_null() {
let v = get_mapping_val("x: ! ''", "x");
assert!(
matches!(v, Value::Value(ScalarOwned::String(ref s)) if s.is_empty()),
"! '' should be String(\"\") not Null, got {v:?}"
);
}
#[test]
fn test_non_specific_tag_on_sequence_is_noop() {
let yaml = "x: ! [1, 2]";
let result = Parser::parse_str(yaml).unwrap().unwrap();
let Value::Mapping(map) = result else {
panic!("expected mapping")
};
let k = Value::Value(ScalarOwned::String("x".into()));
let val = &map[&k];
assert!(
matches!(val, Value::Sequence(_)),
"! on sequence must stay Sequence, got {val:?}"
);
}
#[test]
fn test_round_trip_comment_only() {
use crate::emitter::Emitter;
let docs = Parser::parse_all_preserving_styles("# comment").unwrap();
assert_eq!(docs.len(), 1, "should have one null doc");
let formatted = Emitter::emit_all(&docs).unwrap();
assert!(
!formatted.is_empty(),
"formatted output must be non-empty, got: {formatted:?}"
);
assert_ne!(formatted.trim(), "", "null doc must not format to empty");
}
}