use std::fmt::Write as _;
use oneyaml_core::{
Compat, Diagnostic, Emit, Label, Layout, QuoteStyle, Schema, Settings, Span, code,
};
use crate::error::Error;
use crate::resolve::{ResolveError, Resolved, Resolver, Style as ResolveStyle, TagInput};
use crate::value::{MAX_CONVERTED_DIGITS, Number, TagStr};
const MAX_IMPLICIT_KEY: usize = 1024;
const SERDE_YAML_MAX_SIMPLE_KEY: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Style {
Plain,
Single,
Double,
Literal,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct BlockIndent {
pub(crate) content: usize,
pub(crate) indicator: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Flavor {
Default,
SerdeYaml,
}
#[derive(Clone, Debug)]
pub(crate) struct Rules {
flavor: Flavor,
double: bool,
indent: usize,
indent_sequences: bool,
core: Resolver,
json: Resolver,
yaml11: Resolver,
serde_yaml: Resolver,
active: Option<Resolver>,
honor_yaml11: bool,
merge_keys: bool,
tagged_scalar_variants: bool,
max_tag_len: usize,
}
impl Rules {
pub(crate) fn new(emit: &Emit, reading: &Settings) -> Result<Self, Error> {
let indent = emit.get_indent();
if indent == 0 || indent > Emit::MAX_INDENT {
let d = Diagnostic::error(
code::INCORRECT_SETTINGS,
format!(
"Emit::indent({indent}) cannot produce correct output: the indentation must be 1..={}",
Emit::MAX_INDENT
),
Label::new(Span::empty(0), ""),
)
.with_help("use an indentation of 1 to Emit::MAX_INDENT spaces (the default is 2)");
return Err(Error::from_diagnostic(d));
}
let flavor = match emit.get_layout() {
Layout::Default => Flavor::Default,
Layout::SerdeYaml => Flavor::SerdeYaml,
other => {
debug_assert!(false, "no emitter for {other:?}");
return Err(Error::internal(&format!("no emitter for {other:?}"), None));
}
};
let double = match emit.get_quote_style() {
QuoteStyle::Single => false,
QuoteStyle::Double => true,
other => {
debug_assert!(false, "no emitter for {other:?}");
return Err(Error::internal(&format!("no emitter for {other:?}"), None));
}
};
Ok(Self {
flavor,
double,
indent,
indent_sequences: emit.get_indent_sequences(),
core: Resolver::new(&Settings::new()),
json: Resolver::new(&Settings::new().schema(Schema::Json)),
yaml11: Resolver::new(&Compat::yaml_1_1()),
serde_yaml: Resolver::new(&Settings::new().yaml11_int_forms(true)),
active: Some(Resolver::new(&reading.clone().schema(Schema::Core)))
.filter(|r| !r.plain_is_core()),
honor_yaml11: reading.get_honor_version_directive(),
merge_keys: reading.get_merge_keys() || reading.get_honor_version_directive(),
tagged_scalar_variants: flavor == Flavor::SerdeYaml
&& reading.get_resolve_tagged_variant_content(),
max_tag_len: reading.get_limits().get_max_scalar_len(),
})
}
pub(crate) fn tag_fits(&self, len: usize) -> bool {
len <= self.max_tag_len
}
pub(crate) fn tag_too_long(&self, len: usize) -> Error {
Error::new(
code::SCALAR_TOO_LONG,
format!(
"a tag of {len} bytes is longer than Limits::max_scalar_len ({}), which the output \
is read back with",
self.max_tag_len
),
None,
)
.with_help("raise Limits::max_scalar_len, or write a shorter tag")
}
pub(crate) fn indent(&self) -> usize {
self.indent
}
pub(crate) fn indent_sequences(&self) -> bool {
self.indent_sequences
}
pub(crate) fn serde_yaml_layout(&self) -> bool {
self.flavor == Flavor::SerdeYaml
}
pub(crate) fn tagged_scalar_variants(&self) -> bool {
self.tagged_scalar_variants
}
pub(crate) fn string_style(&self, text: &str, block: BlockIndent) -> Style {
match self.flavor {
Flavor::Default => self.default_style(text, block),
Flavor::SerdeYaml => self.serde_yaml_style(text, block),
}
}
fn quoted(&self) -> Style {
if self.double {
Style::Double
} else {
Style::Single
}
}
fn default_style(&self, text: &str, block: BlockIndent) -> Style {
if text.contains('\n') {
return if literal_ok(text, block, false) {
Style::Literal
} else {
Style::Double
};
}
if has_escape(text) {
return Style::Double;
}
if self.plain_ok(text) {
Style::Plain
} else {
self.quoted()
}
}
fn plain_ok(&self, text: &str) -> bool {
let edge = |c: Option<char>| c.is_some_and(char::is_whitespace);
!edge(text.chars().next())
&& !edge(text.chars().next_back())
&& plain_syntax_ok(text, true)
&& reads_as_string(&self.core, text)
&& reads_as_string(&self.json, text)
&& reads_as_string(&self.yaml11, text)
&& self.reading_extras_ok(text)
&& !foreign_yaml11(text)
}
fn reads_back_as_string(&self, text: &str) -> bool {
reads_as_string(&self.core, text)
&& self.reading_extras_ok(text)
&& (!self.honor_yaml11 || reads_as_string(&self.yaml11, text))
}
fn reading_extras_ok(&self, text: &str) -> bool {
self.active
.as_ref()
.is_none_or(|r| reads_as_string(r, text))
&& !(self.merge_keys && text == "<<")
}
fn serde_yaml_style(&self, text: &str, block: BlockIndent) -> Style {
let a = Analysis::of(text);
if text.contains('\n') {
return if a.block && literal_ok(text, block, true) {
Style::Literal
} else {
Style::Double
};
}
let typed = !reads_as_string(&self.serde_yaml, text)
|| serde_yaml_signed_octal(text)
|| matches!(text, "<<" | "=");
let quoted = if a.single && !self.double {
Style::Single
} else {
Style::Double
};
if typed || !a.plain || !plain_syntax_ok(text, false) || !self.reads_back_as_string(text) {
quoted
} else {
Style::Plain
}
}
pub(crate) fn write_string(
&self,
out: &mut String,
text: &str,
style: Style,
block: BlockIndent,
) {
match style {
Style::Plain => out.push_str(text),
Style::Single => write_single(out, text),
Style::Double => self.write_double(out, text),
Style::Literal => self.write_literal(out, text, block),
}
}
pub(crate) fn write_implicit_key(
&self,
out: &mut String,
text: &str,
tag: Option<TagStr<'_>>,
) -> bool {
let block = BlockIndent {
content: self.indent,
indicator: None,
};
let style = self.string_style(text, block);
if style == Style::Literal {
return false;
}
let start = out.len();
let mut tag_len = 0usize;
if let Some(t) = tag {
if !self.tag_fits(t.len()) || write_tag(out, t).is_err() {
out.truncate(start);
return false;
}
out.push(' ');
tag_len = out.len().saturating_sub(start);
}
self.write_string(out, text, style, block);
let fits = match self.flavor {
Flavor::Default => out.len().saturating_sub(start) <= MAX_IMPLICIT_KEY,
Flavor::SerdeYaml => {
text.len().saturating_add(tag_len.saturating_sub(1)) <= SERDE_YAML_MAX_SIMPLE_KEY
&& !text.contains(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'])
}
};
if !fits {
out.truncate(start);
}
fits
}
pub(crate) fn short_key(&self, len: usize) -> bool {
match self.flavor {
Flavor::Default => len <= MAX_IMPLICIT_KEY,
Flavor::SerdeYaml => len <= SERDE_YAML_MAX_SIMPLE_KEY,
}
}
pub(crate) fn write_number(&self, out: &mut String, n: &Number) -> Result<(), Error> {
match n.as_float() {
Some(f) => {
self.write_float(out, f);
Ok(())
}
None => n.write_core(out, MAX_CONVERTED_DIGITS).map_err(|()| {
Error::new(
code::UNREPRESENTABLE_VALUE,
format!(
"a negative octal or hexadecimal integer, or a sexagesimal (base-60) \
integer of either sign, wider than {MAX_CONVERTED_DIGITS} digits has \
no linear YAML 1.2 Core form"
),
None,
)
.with_help(
"Core reads `-0x…` and base 60 as strings, and converting to decimal is \
quadratic past this bound; write the integer in decimal, or as a string",
)
}),
}
}
pub(crate) fn write_float(&self, out: &mut String, f: f64) {
if f.is_nan() {
out.push_str(".nan");
} else if f.is_infinite() {
out.push_str(if f.is_sign_negative() {
"-.inf"
} else {
".inf"
});
} else {
match self.flavor {
Flavor::Default => every_reader_float(out, f),
Flavor::SerdeYaml => out.push_str(&ryu_layout(f)),
}
}
}
#[cfg(test)]
pub(crate) fn float_text(&self, f: f64) -> String {
let mut out = String::new();
self.write_float(&mut out, f);
out
}
fn write_double(&self, out: &mut String, text: &str) {
out.push('"');
for c in text.chars() {
let escape = match self.flavor {
Flavor::Default => needs_escape(c),
Flavor::SerdeYaml => needs_escape(c) || !libyaml_printable(c),
};
if c == '"' || c == '\\' || escape {
push_escape(out, c);
} else {
out.push(c);
}
}
out.push('"');
}
fn write_literal(&self, out: &mut String, text: &str, block: BlockIndent) {
out.push('|');
let indicator = match self.flavor {
Flavor::Default => first_content_line_indented(text),
Flavor::SerdeYaml => text.starts_with([' ', '\n']),
};
if let (true, Some(n)) = (indicator, block.indicator) {
let _ = write!(out, "{n}");
}
let content = text.trim_end_matches('\n');
let trailing = text.len().saturating_sub(content.len());
out.push_str(match trailing {
0 => "-",
1 if !content.is_empty() => "",
_ => "+",
});
let body = text.strip_suffix('\n').unwrap_or(text);
for line in body.split('\n') {
out.push('\n');
if !line.is_empty() {
push_spaces(out, block.content);
out.push_str(line);
}
}
}
}
pub(crate) fn write_null(out: &mut String) {
out.push_str("null");
}
pub(crate) fn write_bool(out: &mut String, b: bool) {
out.push_str(if b { "true" } else { "false" });
}
const CORE_TAGS: [&str; 7] = ["null", "bool", "int", "float", "str", "seq", "map"];
const YAML_PREFIX: &str = "tag:yaml.org,2002:";
#[allow(clippy::result_unit_err, reason = "the caller builds the diagnostic")]
pub(crate) fn write_tag<'t>(out: &mut String, tag: impl Into<TagStr<'t>>) -> Result<(), ()> {
let tag = tag.into();
if tag.len() == 0 || tag.is("!") {
return Err(());
}
if let Some(name) = tag.strip_prefix(YAML_PREFIX) {
if CORE_TAGS.iter().any(|c| name.is(c)) {
return Err(());
}
if name.len() > 0 {
out.push_str("!!");
percent_encode(out, name.chars(), tag_char);
return Ok(());
}
}
match tag.strip_prefix("!") {
Some(local) if local.len() > 0 => write_local_tag(out, local)?,
Some(_) => return Err(()),
None if !has_uri_scheme(tag) => return Err(()),
None => {
out.push_str("!<");
percent_encode(out, tag.chars(), uri_char);
out.push('>');
}
}
Ok(())
}
#[allow(clippy::result_unit_err, reason = "the caller builds the diagnostic")]
pub(crate) fn write_local_tag<'t>(out: &mut String, name: impl Into<TagStr<'t>>) -> Result<(), ()> {
let name = name.into();
if name.len() == 0 {
return Err(());
}
out.push('!');
percent_encode(out, name.chars(), tag_char);
Ok(())
}
fn has_uri_scheme(tag: TagStr<'_>) -> bool {
let mut b = tag.bytes();
if !b.next().is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
for c in b {
match c {
b':' => return true,
c if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') => {}
_ => return false,
}
}
false
}
fn uri_char(c: char) -> bool {
c.is_ascii_alphanumeric() || "-#;/?:@&=+$,_.!~*'()[]".contains(c)
}
pub(crate) fn tag_char(c: char) -> bool {
uri_char(c) && !"!,[]{}".contains(c)
}
fn percent_encode(out: &mut String, text: impl Iterator<Item = char>, keep: fn(char) -> bool) {
let mut buf = [0u8; 4];
for c in text {
if keep(c) {
out.push(c);
} else {
for b in c.encode_utf8(&mut buf).bytes() {
let _ = write!(out, "%{b:02X}");
}
}
}
}
pub(crate) fn push_spaces(out: &mut String, n: usize) {
const SPACES: &str = " ";
let mut left = n;
while left > 0 {
let take = left.min(SPACES.len());
out.push_str(SPACES.get(..take).unwrap_or(""));
left = left.saturating_sub(take);
}
}
fn has_escape(text: &str) -> bool {
let mut s = text.as_bytes();
loop {
match s {
[] => return false,
[0x00..=0x1f | 0x7f, ..]
| [0xc2, 0x80..=0x9f, ..]
| [0xe2, 0x80, 0xa8 | 0xa9, ..]
| [0xef, 0xbb, 0xbf, ..]
| [0xef, 0xbf, 0xbe | 0xbf, ..] => return true,
[_, rest @ ..] => s = rest,
}
}
}
fn serde_yaml_signed_octal(text: &str) -> bool {
text.strip_prefix(['-', '+'])
.and_then(|t| t.strip_prefix("0o"))
.is_some_and(|d| !d.is_empty() && d.bytes().all(|c| matches!(c, b'0'..=b'7')))
}
fn reads_as_string(r: &Resolver, text: &str) -> bool {
matches!(
r.resolve_scalar(TagInput::Absent, ResolveStyle::Plain, text),
Ok(Resolved::Str) | Err(ResolveError::JsonUnmatched)
)
}
fn needs_escape(c: char) -> bool {
matches!(c, '\0'..='\x1f' | '\x7f'..='\u{9f}' | '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}')
}
fn push_escape(out: &mut String, c: char) {
let short = match c {
'\0' => "\\0",
'\x07' => "\\a",
'\x08' => "\\b",
'\t' => "\\t",
'\n' => "\\n",
'\x0b' => "\\v",
'\x0c' => "\\f",
'\r' => "\\r",
'\x1b' => "\\e",
'"' => "\\\"",
'\\' => "\\\\",
'\u{85}' => "\\N",
'\u{2028}' => "\\L",
'\u{2029}' => "\\P",
_ => "",
};
if !short.is_empty() {
out.push_str(short);
return;
}
let n = u32::from(c);
let _ = if n <= 0xff {
write!(out, "\\x{n:02X}")
} else if n <= 0xffff {
write!(out, "\\u{n:04X}")
} else {
write!(out, "\\U{n:08X}")
};
}
fn write_single(out: &mut String, text: &str) {
out.push('\'');
for c in text.chars() {
if c == '\'' {
out.push('\'');
}
out.push(c);
}
out.push('\'');
}
fn plain_syntax_ok(text: &str, conservative: bool) -> bool {
let b = text.as_bytes();
let Some(&first) = b.first() else {
return false;
};
let spec_plain_first =
matches!(first, b'-' | b'?' | b':') && b.get(1).is_some_and(|c| !matches!(c, b' ' | b'\t'));
let indicator = matches!(
first,
b'-' | b'?'
| b':'
| b','
| b'['
| b']'
| b'{'
| b'}'
| b'#'
| b'&'
| b'*'
| b'!'
| b'|'
| b'>'
| b'\''
| b'"'
| b'%'
| b'@'
| b'`'
) && (conservative || !spec_plain_first);
if indicator
|| matches!(first, b' ' | b'\t')
|| matches!(b.last(), Some(b' ' | b'\t' | b':'))
|| b.starts_with(b"---")
|| b.starts_with(b"...")
{
return false;
}
let mut prev = 0u8;
for &c in b {
if (prev == b':' && c == b' ') || (prev == b' ' && c == b'#') {
return false;
}
prev = c;
}
!has_escape(text)
}
fn foreign_yaml11(text: &str) -> bool {
if let Some(&c) = text.as_bytes().first() {
if c.is_ascii_alphabetic() && !matches!(c, b'e' | b'E') {
return psych_word(text);
}
}
matches!(text, "<<" | "=")
|| timestamp_like(text)
|| yaml11_float(text)
|| underscore_int(text)
|| prefixed_int_any_case(text)
|| psych_number(text)
|| go_number(text)
}
fn psych_number(text: &str) -> bool {
if psych_word(text) {
return true;
}
let s = text.strip_prefix(['-', '+']).unwrap_or(text);
if s.eq_ignore_ascii_case(".inf") || s.eq_ignore_ascii_case(".nan") {
return true;
}
let numeric =
|c: u8| c.is_ascii_digit() || matches!(c, b',' | b'_' | b'.' | b'e' | b'E' | b'+' | b'-');
if s.contains(',') && s.bytes().any(|c| c.is_ascii_digit()) && s.bytes().all(numeric) {
return true;
}
psych_base60(s)
}
fn psych_word(text: &str) -> bool {
const WORDS: [&str; 7] = ["null", "yes", "true", "on", "no", "false", "off"];
(2..=5).contains(&text.len()) && WORDS.iter().any(|w| text.eq_ignore_ascii_case(w))
}
fn psych_base60(s: &str) -> bool {
let b = s.as_bytes();
if !b.first().is_some_and(u8::is_ascii_digit) {
return false;
}
let mut i = b
.iter()
.take_while(|c| c.is_ascii_digit() || **c == b'_')
.count();
let mut groups = 0usize;
while b.get(i) == Some(&b':') {
i = i.saturating_add(1);
let n = b
.iter()
.skip(i)
.take(2)
.take_while(|c| c.is_ascii_digit())
.count();
if n == 0 {
return false;
}
i = i.saturating_add(n);
groups = groups.saturating_add(1);
}
if groups == 0 {
return false;
}
if b.get(i) == Some(&b'.') {
i = i.saturating_add(1);
i = i.saturating_add(
b.iter()
.skip(i)
.take_while(|c| c.is_ascii_digit() || **c == b'_')
.count(),
);
}
i == b.len()
}
fn go_number(text: &str) -> bool {
if !text.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '-' | '+' | '.')) {
return false;
}
if !text.contains('_') {
return go_int(text.as_bytes()) || go_float(text.as_bytes());
}
let plain: Vec<u8> = text.bytes().filter(|c| *c != b'_').collect();
go_int(&plain) || go_float(&plain)
}
fn go_int(s: &[u8]) -> bool {
let s = s
.strip_prefix(b"-")
.or_else(|| s.strip_prefix(b"+"))
.unwrap_or(s);
let digits = |d: &[u8], ok: fn(&u8) -> bool| !d.is_empty() && d.iter().all(ok);
match s {
[b'0', b'x' | b'X', rest @ ..] => digits(rest, u8::is_ascii_hexdigit),
[b'0', b'b' | b'B', rest @ ..] => digits(rest, |c| matches!(c, b'0' | b'1')),
[b'0', b'o' | b'O', rest @ ..] => digits(rest, |c| matches!(c, b'0'..=b'7')),
_ => digits(s, u8::is_ascii_digit),
}
}
fn go_float(s: &[u8]) -> bool {
let s = s
.strip_prefix(b"-")
.or_else(|| s.strip_prefix(b"+"))
.unwrap_or(s);
let run = |s: &[u8], at: usize| s.iter().skip(at).take_while(|c| c.is_ascii_digit()).count();
let mut i;
if s.first() == Some(&b'.') {
let n = run(s, 1);
if n == 0 {
return false;
}
i = n.saturating_add(1);
} else {
i = run(s, 0);
if i == 0 {
return false;
}
if s.get(i) == Some(&b'.') {
i = i.saturating_add(1);
i = i.saturating_add(run(s, i));
}
}
if matches!(s.get(i), Some(b'e' | b'E')) {
i = i.saturating_add(1);
if matches!(s.get(i), Some(b'-' | b'+')) {
i = i.saturating_add(1);
}
let n = run(s, i);
if n == 0 {
return false;
}
i = i.saturating_add(n);
}
i == s.len()
}
fn prefixed_int_any_case(text: &str) -> bool {
let s = text.strip_prefix(['-', '+']).unwrap_or(text);
let mut b = s.bytes();
b.next() == Some(b'0')
&& matches!(b.next(), Some(b'x' | b'X' | b'o' | b'O' | b'b' | b'B'))
&& s.len() > 2
&& b.all(|c| c.is_ascii_hexdigit() || matches!(c, b'_' | b','))
}
fn timestamp_like(text: &str) -> bool {
let b = text.as_bytes();
let digits = |from: usize, min: usize, max: usize| -> Option<usize> {
let n = b
.iter()
.skip(from)
.take(max)
.take_while(|c| c.is_ascii_digit())
.count();
(n >= min).then(|| from.saturating_add(n))
};
let dash = |at: usize| (b.get(at) == Some(&b'-')).then(|| at.saturating_add(1));
let end = digits(0, 4, 4)
.and_then(dash)
.and_then(|i| digits(i, 1, 2))
.and_then(dash)
.and_then(|i| digits(i, 1, 2));
match end {
Some(i) => matches!(b.get(i), None | Some(b'T' | b't' | b' ' | b'\t')),
None => false,
}
}
fn yaml11_float(text: &str) -> bool {
let s = text.strip_prefix(['-', '+']).unwrap_or(text);
let b = s.as_bytes();
let mut i = 0usize;
if b.first().is_some_and(u8::is_ascii_digit) {
i = b
.iter()
.take_while(|c| c.is_ascii_digit() || **c == b'_')
.count();
}
if b.get(i) != Some(&b'.') {
return false;
}
i = i.saturating_add(1);
i = i.saturating_add(
b.iter()
.skip(i)
.take_while(|c| c.is_ascii_digit() || **c == b'.')
.count(),
);
if matches!(b.get(i), Some(b'e' | b'E')) {
i = i.saturating_add(1);
if !matches!(b.get(i), Some(b'-' | b'+')) {
return false;
}
i = i.saturating_add(1);
let n = b.iter().skip(i).take_while(|c| c.is_ascii_digit()).count();
if n == 0 {
return false;
}
i = i.saturating_add(n);
}
i == b.len()
}
fn underscore_int(text: &str) -> bool {
let s = text.strip_prefix(['-', '+']).unwrap_or(text);
s.strip_prefix("0x")
.or_else(|| s.strip_prefix("0b"))
.is_some_and(|d| !d.is_empty() && d.bytes().all(|c| c == b'_'))
}
fn first_content_line_indented(text: &str) -> bool {
text.split('\n')
.find(|l| !l.is_empty())
.is_some_and(|l| l.starts_with(' '))
}
fn literal_ok(text: &str, block: BlockIndent, serde_yaml: bool) -> bool {
let chars_ok = serde_yaml
|| (text.chars().any(|c| c != '\n')
&& !text
.chars()
.any(|c| c != '\n' && c != '\t' && needs_escape(c))
&& !text
.split('\n')
.find(|l| !l.is_empty())
.is_some_and(|l| l.starts_with('\t')));
let indicator = if serde_yaml {
text.starts_with([' ', '\n'])
} else {
first_content_line_indented(text)
};
chars_ok && (!indicator || block.indicator.is_some_and(|n| (1..=9).contains(&n)))
}
fn libyaml_printable(c: char) -> bool {
matches!(c, '\n' | ' '..='~' | '\u{a0}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..)
&& c != '\u{feff}'
}
#[derive(Clone, Copy, Debug)]
struct Analysis {
plain: bool,
single: bool,
block: bool,
}
impl Analysis {
fn of(text: &str) -> Self {
if text.is_empty() {
return Self {
plain: false,
single: true,
block: false,
};
}
let is_break = |c: char| matches!(c, '\r' | '\n' | '\u{85}' | '\u{2028}' | '\u{2029}');
let is_blankz = |c: Option<char>| c.is_none_or(|c| matches!(c, ' ' | '\t') || is_break(c));
let mut block_indicators = text.starts_with("---") || text.starts_with("...");
let (mut special, mut line_breaks) = (false, false);
let (mut leading_space, mut leading_break) = (false, false);
let (mut trailing_space, mut trailing_break) = (false, false);
let (mut break_space, mut space_break) = (false, false);
let (mut previous_space, mut previous_break) = (false, false);
let mut preceded_by_ws = true;
let mut chars = text.chars().peekable();
let mut first = true;
while let Some(c) = chars.next() {
let next = chars.peek().copied();
let is_last = next.is_none();
let followed_by_ws = is_blankz(next);
if first {
if "#,[]{}&*!|>'\"%@`".contains(c) || (c == '-' && followed_by_ws) {
block_indicators = true;
}
if (c == '?' || c == ':') && followed_by_ws {
block_indicators = true;
}
} else if (c == ':' && followed_by_ws) || (c == '#' && preceded_by_ws) {
block_indicators = true;
}
if !libyaml_printable(c) {
special = true;
}
if is_break(c) {
line_breaks = true;
}
if c == ' ' {
leading_space |= first;
trailing_space |= is_last;
break_space |= previous_break;
previous_space = true;
previous_break = false;
} else if is_break(c) {
leading_break |= first;
trailing_break |= is_last;
space_break |= previous_space;
previous_break = true;
previous_space = false;
} else {
previous_space = false;
previous_break = false;
}
preceded_by_ws = is_blankz(Some(c));
first = false;
}
let mut a = Self {
plain: true,
single: true,
block: true,
};
if leading_space || leading_break || trailing_space || trailing_break {
a.plain = false;
}
if trailing_space {
a.block = false;
}
if break_space || space_break || special {
a.plain = false;
a.single = false;
}
if space_break || special {
a.block = false;
}
if line_breaks || block_indicators {
a.plain = false;
}
if text.contains(['\u{2028}', '\u{2029}']) {
a.single = false;
}
a
}
}
fn sci_parts(f: f64) -> (String, i32) {
let s = format!("{:e}", f.abs());
let (mantissa, exp) = s.split_once('e').unwrap_or((&s, "0"));
let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
(digits, exp.parse().unwrap_or(0))
}
fn every_reader_float(out: &mut String, f: f64) {
let start = out.len();
let _ = write!(out, "{f:?}");
let Some(e) = out
.get(start..)
.and_then(|s| s.find('e'))
.map(|i| start.saturating_add(i))
else {
return;
};
let after = e.saturating_add(1);
if !out.get(after..).is_some_and(|x| x.starts_with('-')) {
out.insert(after, '+');
}
if !out.get(start..e).is_some_and(|m| m.contains('.')) {
out.insert_str(e, ".0");
}
}
fn ryu_layout(f: f64) -> String {
let mut out = String::new();
if f.is_sign_negative() {
out.push('-');
}
if f == 0.0 {
out.push_str("0.0");
return out;
}
let (digits, e) = sci_parts(f);
let length = i64::try_from(digits.len()).unwrap_or(i64::MAX);
let kk = i64::from(e).saturating_add(1);
let k = kk.saturating_sub(length);
if (0..=16).contains(&kk) && k >= 0 {
out.push_str(&digits);
out.extend(std::iter::repeat_n('0', usize::try_from(k).unwrap_or(0)));
out.push_str(".0");
} else if 0 < kk && kk <= 16 {
let at = usize::try_from(kk).unwrap_or(0);
let (a, b) = digits.split_at(at.min(digits.len()));
out.push_str(a);
out.push('.');
out.push_str(b);
} else if -5 < kk && kk <= 0 {
out.push_str("0.");
out.extend(std::iter::repeat_n(
'0',
usize::try_from(kk.saturating_neg()).unwrap_or(0),
));
out.push_str(&digits);
} else {
let mut chars = digits.chars();
if let Some(d) = chars.next() {
out.push(d);
}
let rest: String = chars.collect();
if !rest.is_empty() {
out.push('.');
out.push_str(&rest);
}
let _ = write!(out, "e{}", kk.saturating_sub(1));
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
use super::*;
fn rules(emit: Emit) -> Rules {
Rules::new(&emit, &Settings::new()).unwrap()
}
const NODE: BlockIndent = BlockIndent {
content: 2,
indicator: Some(2),
};
fn written(r: &Rules, s: &str) -> String {
let mut out = String::new();
let style = r.string_style(s, NODE);
r.write_string(&mut out, s, style, NODE);
out
}
#[test]
fn every_reader_safe_quoting() {
let r = rules(Emit::new());
for s in [
"",
" a",
"a ",
"#a",
"a #b",
"a: b",
"a:",
"-",
"-a",
"?a",
":a",
"no",
"yes",
"on",
"off",
"y",
"n",
"Y",
"~",
"0777",
"1_000",
"12:30",
".inf",
"null",
"123",
"0x1F",
"0b1",
"1e3",
"1.2.3",
".",
"2001-12-14",
"2001-1-2t",
"<<",
"=",
"---",
"...",
"true",
"NULL",
"+1",
"0o7",
"0x_",
"-0b_",
"@a",
"`a",
"%a",
"!a",
"&a",
"*a",
"|a",
">a",
"[a",
"]a",
"{a",
"}a",
",a",
"'a",
"\"a",
] {
let w = written(&r, s);
assert!(w.starts_with('\''), "{s:?} => {w:?}");
}
for s in ["\u{a0}", "a\u{2003}", "\u{3000}a"] {
assert_eq!(written(&r, s), format!("'{s}'"), "{s:?}");
}
for s in [
"a", "a b", "a,b", "a[b", "é", "a'b", "a\"b", "2001-12", "x.y", "1.2.x", "a\u{a0}b",
] {
assert_eq!(written(&r, s), s, "{s:?}");
}
assert_eq!(written(&r, "a'b "), "'a''b '");
assert_eq!(written(&r, "a\tb"), "\"a\\tb\"");
assert_eq!(written(&r, "a\rb"), "\"a\\rb\"");
assert_eq!(
written(&r, "\u{1}\u{7f}\u{85}\u{feff}\u{2028}\u{ffff}\u{a0}"),
"\"\\x01\\x7F\\N\\uFEFF\\L\\uFFFF\u{a0}\""
);
assert_eq!(written(&r, "\\ \""), "\\ \"");
let d = rules(Emit::new().quote_style(QuoteStyle::Double));
assert_eq!(written(&d, "yes"), "\"yes\"");
assert_eq!(written(&d, "a"), "a");
}
#[test]
fn literal_blocks_and_chomping() {
let r = rules(Emit::new());
assert_eq!(written(&r, "a\nb"), "|-\n a\n b");
assert_eq!(written(&r, "a\nb\n"), "|\n a\n b");
assert_eq!(written(&r, "a\nb\n\n"), "|+\n a\n b\n");
assert_eq!(written(&r, "\na"), "|-\n\n a");
assert_eq!(written(&r, " a\nb"), "|2-\n a\n b");
assert_eq!(written(&r, "\n a"), "|2-\n\n a");
assert_eq!(written(&r, "a\n\tb"), "|-\n a\n \tb");
assert_eq!(written(&r, "\ta\nb"), "\"\\ta\\nb\"");
assert_eq!(written(&r, "\n\ta"), "\"\\n\\ta\"");
assert_eq!(written(&r, "\n"), "\"\\n\"");
assert_eq!(written(&r, "\n\n"), "\"\\n\\n\"");
assert_eq!(written(&r, "a\r\nb"), "\"a\\r\\nb\"");
let wide = BlockIndent {
content: 10,
indicator: Some(10),
};
assert_eq!(r.string_style(" a\nb", wide), Style::Double);
assert_eq!(r.string_style("a\nb", wide), Style::Literal);
let root = BlockIndent {
content: 2,
indicator: None,
};
assert_eq!(r.string_style(" a\nb", root), Style::Double);
assert_eq!(r.string_style("a\nb", root), Style::Literal);
}
#[test]
fn floats() {
let r = rules(Emit::new());
let s = rules(Emit::new().layout(Layout::SerdeYaml));
for (f, ours, theirs) in [
(1.0, "1.0", "1.0"),
(0.1, "0.1", "0.1"),
(1e300, "1.0e+300", "1e300"),
(1e16, "1.0e+16", "1e16"),
(1e15, "1000000000000000.0", "1000000000000000.0"),
(1e-5, "1.0e-5", "0.00001"),
(1e-7, "1.0e-7", "1e-7"),
(1.5e-7, "1.5e-7", "1.5e-7"),
(-0.0, "-0.0", "-0.0"),
(123_456_789.125, "123456789.125", "123456789.125"),
(1.25e21, "1.25e+21", "1.25e21"),
(-2.5e-300, "-2.5e-300", "-2.5e-300"),
(f64::INFINITY, ".inf", ".inf"),
(f64::NEG_INFINITY, "-.inf", "-.inf"),
(f64::NAN, ".nan", ".nan"),
] {
assert_eq!(r.float_text(f), ours, "{f:?}");
assert_eq!(s.float_text(f), theirs, "{f:?}");
if f.is_finite() {
assert_eq!(ours.parse::<f64>().unwrap().to_bits(), f.to_bits());
}
}
}
#[test]
fn tags() {
let t = |tag: &str| {
let mut out = String::new();
write_tag(&mut out, tag).map(|()| out)
};
assert_eq!(t("!color").unwrap(), "!color");
assert_eq!(t("!a!b c").unwrap(), "!a%21b%20c");
assert_eq!(t("!é").unwrap(), "!%C3%A9");
assert!(t("!").is_err());
assert!(t("$:?").is_err());
for bad in ["x", "a b", ":x", "1a:b", "a_b:c", "é:x"] {
assert!(t(bad).is_err(), "{bad}");
}
assert_eq!(t("urn:x").unwrap(), "!<urn:x>");
assert_eq!(t("a+b.c-d:x y").unwrap(), "!<a+b.c-d:x%20y>");
assert_eq!(t("!!").unwrap(), "!%21");
assert_eq!(t("tag:yaml.org,2002:binary").unwrap(), "!!binary");
assert_eq!(
t("tag:example.com,2000:a/b").unwrap(),
"!<tag:example.com,2000:a/b>"
);
assert_eq!(t("tag:yaml.org,2002:").unwrap(), "!<tag:yaml.org,2002:>");
assert!(t("").is_err());
assert!(t("tag:yaml.org,2002:str").is_err());
}
#[test]
fn psych_and_go_readings_are_quoted() {
let r = rules(Emit::new());
for s in [
"1,000", "1,2", "+1,000", "-1,000", "0,7", "1,000.5", "1,000e+3", ",1", "1,",
"09:15", "00:30", "0:30", "1:2", "-0:30", "0:30.5", "1_0:30",
".iNf", "+.INf", "-.InF", ".nAn", ".NAn",
"tRUE", "nULL", "yEs", "oFF", "FaLsE", "nO", "oN", "NuLl", "TrUe",
"0b1,0", "0x1,F", "0x,1F", "-0b,1", "+0x1,f",
"+_1", "1e_5", "0_x1F", "1_e_5", "-_1", "1__0", "0_b1", "0_o7", ".5_", "1._5",
] {
assert!(foreign_yaml11(s), "{s} is foreign");
let w = written(&r, s);
assert_eq!(w, format!("'{s}'"), "{s:?}");
}
for s in ["a,b", "x_1", "1:a", "infinity_x", "1.2.x"] {
assert_eq!(written(&r, s), s, "{s:?}");
}
let c = rules(Emit::new().layout(Layout::SerdeYaml));
assert_eq!(written(&c, "1,000"), "1,000");
}
#[test]
fn implicit_keys() {
let key = |r: &Rules, text: &str, tag: Option<&str>| {
let mut out = String::from("- ");
let ok = r.write_implicit_key(&mut out, text, tag.map(TagStr::from));
assert_eq!(ok, out.len() > 2, "{text:?}");
ok.then(|| out.split_off(2))
};
let r = rules(Emit::new());
assert_eq!(key(&r, "a", None).unwrap(), "a");
assert_eq!(key(&r, "yes", Some("!t")).unwrap(), "!t 'yes'");
assert_eq!(key(&r, "a\nb", None), None);
assert_eq!(key(&r, "a", Some("!")), None);
assert!(key(&r, &"x".repeat(1024), None).is_some());
assert_eq!(key(&r, &"x".repeat(1025), None), None);
assert_eq!(key(&r, &"x".repeat(1020), Some("!tag")), None);
let s = rules(Emit::new().layout(Layout::SerdeYaml));
assert!(key(&s, &"x".repeat(128), None).is_some());
assert_eq!(key(&s, &"x".repeat(129), None), None);
assert!(r.short_key(1024) && !r.short_key(1025));
assert!(s.short_key(128) && !s.short_key(129));
}
#[test]
fn reading_floor_shortcut_is_exact() {
let old_floor = |reading: &Settings, text: &str| {
let core = Resolver::new(&Settings::new());
let active = Resolver::new(&reading.clone().schema(Schema::Core));
let yaml11 = Resolver::new(&Compat::yaml_1_1());
let honor = reading.get_honor_version_directive();
let merge = reading.get_merge_keys() || honor;
reads_as_string(&core, text)
&& reads_as_string(&active, text)
&& (!honor || reads_as_string(&yaml11, text))
&& !(merge && text == "<<")
};
let readings = [
Settings::new(),
Settings::new().merge_keys(true),
Settings::new().honor_version_directive(true),
Settings::new().yaml11_booleans(true),
Settings::new().legacy_octal(true),
Settings::new().sexagesimal(true),
Settings::new().digit_separators(true),
Settings::new().yaml11_int_forms(true),
Compat::serde_yaml(),
Compat::yaml_1_1(),
Settings::new().schema(Schema::Json),
];
let texts = [
"a", "yes", "No", "on", "y", "0777", "08", "1_000", "12:30", "0b101", "-0x1F", "+0x1",
"-0o17", "<<", "=", "null", "~", "1.5", ".inf", "true", "x_1", "a b",
];
for reading in &readings {
let plain_core = Resolver::new(&reading.clone().schema(Schema::Core)).plain_is_core();
let expect_core = !(reading.get_yaml11_booleans()
|| reading.get_legacy_octal()
|| reading.get_sexagesimal()
|| reading.get_digit_separators()
|| reading.get_yaml11_int_forms());
assert_eq!(plain_core, expect_core, "{reading:?}");
for layout in [Layout::Default, Layout::SerdeYaml] {
let r = Rules::new(&Emit::new().layout(layout), reading).unwrap();
assert_eq!(r.active.is_none(), plain_core);
for t in texts {
assert_eq!(r.reads_back_as_string(t), old_floor(reading, t), "{t:?}");
let old_plain = !t.starts_with(char::is_whitespace)
&& !t.ends_with(char::is_whitespace)
&& plain_syntax_ok(t, true)
&& reads_as_string(&r.core, t)
&& reads_as_string(&r.json, t)
&& reads_as_string(&r.yaml11, t)
&& old_floor(reading, t)
&& !foreign_yaml11(t);
assert_eq!(r.plain_ok(t), old_plain, "{t:?} {reading:?}");
}
}
}
assert!(Resolver::new(&Settings::new()).plain_is_core());
assert!(!Resolver::new(&Settings::new().schema(Schema::Json)).plain_is_core());
assert!(!Resolver::new(&Settings::new().schema(Schema::Failsafe)).plain_is_core());
}
fn plain_syntax_reference(text: &str, conservative: bool) -> bool {
let mut chars = text.chars();
let Some(first) = chars.next() else {
return false;
};
let spec_plain_first =
"-?:".contains(first) && chars.next().is_some_and(|c| !matches!(c, ' ' | '\t'));
let indicator =
"-?:,[]{}#&*!|>'\"%@`".contains(first) && (conservative || !spec_plain_first);
!(text.starts_with([' ', '\t'])
|| text.ends_with([' ', '\t'])
|| indicator
|| text.contains(": ")
|| text.contains(" #")
|| text.ends_with(':')
|| text.starts_with("---")
|| text.starts_with("...")
|| text.chars().any(needs_escape))
}
fn foreign_reference(text: &str) -> bool {
const WORDS: [&str; 7] = ["null", "yes", "true", "on", "no", "false", "off"];
matches!(text, "<<" | "=")
|| timestamp_like(text)
|| yaml11_float(text)
|| underscore_int(text)
|| prefixed_int_any_case(text)
|| WORDS.iter().any(|w| text.eq_ignore_ascii_case(w))
|| psych_number(text)
|| go_number(text)
}
fn samples() -> Vec<String> {
let alphabet = [
"a",
"e",
"E",
"n",
"N",
"o",
"O",
"f",
"t",
"T",
"y",
"x",
"b",
"0",
"1",
"9",
".",
",",
"_",
":",
"-",
"+",
"?",
" ",
"#",
"<",
"=",
"\t",
"'",
"é",
"\u{85}",
"\u{a0}",
"\u{2028}",
"\u{feff}",
"\u{ffff}",
"\u{fffd}",
"\u{1f600}",
"\u{7f}",
];
let mut out = vec![String::new()];
for a in alphabet {
out.push(a.to_owned());
for b in alphabet {
out.push(format!("{a}{b}"));
for c in alphabet {
out.push(format!("{a}{b}{c}"));
}
}
}
for s in [
"null",
"NuLl",
"false",
"FALSE",
"off",
"On",
"yes",
"true",
"e,1",
"E1,0",
"e",
"e_1",
"eE,+",
"nulls",
"no-",
"1,000",
"2001-12-14",
"0x1F",
"1:30",
".inf",
"a: b",
"a #b",
"---a",
"...a",
"a:",
"-a",
"?a",
":a",
"- a",
"é: x",
] {
out.push(s.to_owned());
}
out
}
#[test]
fn byte_fast_paths_decide_as_before() {
for s in samples() {
for conservative in [true, false] {
assert_eq!(
plain_syntax_ok(&s, conservative),
plain_syntax_reference(&s, conservative),
"{s:?} {conservative}"
);
}
assert_eq!(foreign_yaml11(&s), foreign_reference(&s), "{s:?}");
assert_eq!(has_escape(&s), s.chars().any(needs_escape), "{s:?}");
}
let mut buf = [0u8; 4];
for c in (0..=0x10_ffffu32).filter_map(char::from_u32) {
let one = c.encode_utf8(&mut buf);
assert_eq!(has_escape(one), needs_escape(c), "{c:?}");
let mid = format!("a{one}é");
assert_eq!(has_escape(&mid), needs_escape(c), "{c:?}");
}
let mut out = String::new();
for n in [0, 1, 63, 64, 65, 200] {
out.clear();
push_spaces(&mut out, n);
assert_eq!(out, " ".repeat(n));
}
assert!(write_local_tag(&mut out, "").is_err());
}
#[test]
fn byte_fast_paths_decide_as_before_random() {
let alphabet: Vec<char> =
"nulyestrofaNULYESTROFAixbIXB0123456789.,_:+-eE~<= #éüß\u{85}\u{a0}\u{feff}\u{2028}☃"
.chars()
.collect();
let mut state: u64 = 0x9e37_79b9_7f4a_7c15;
let mut next = move || {
state ^= state >> 12;
state ^= state << 25;
state ^= state >> 27;
state.wrapping_mul(0x2545_f491_4f6c_dd1d)
};
let words = [
"null", "yes", "true", "on", "no", "false", "off", "inf", "nan",
];
let mut text = String::new();
let mut hits = 0usize;
for round in 0..400_000u32 {
text.clear();
if round % 4 == 0 {
let w = words[(next() % words.len() as u64) as usize];
for c in w.chars() {
let upper = next() % 2 == 0;
text.push(if upper { c.to_ascii_uppercase() } else { c });
}
}
let len = (next() % 13) as usize;
for _ in 0..len {
text.push(alphabet[(next() % alphabet.len() as u64) as usize]);
}
for conservative in [true, false] {
assert_eq!(
plain_syntax_ok(&text, conservative),
plain_syntax_reference(&text, conservative),
"{text:?} {conservative}"
);
}
let foreign = foreign_yaml11(&text);
assert_eq!(foreign, foreign_reference(&text), "{text:?}");
hits += usize::from(foreign);
}
assert!(hits > 10_000, "{hits}");
}
#[test]
fn foreign_yaml11_forms() {
for s in [
"0X1F",
"-0B1",
"+0O7_7",
"<<",
"=",
"2001-12-14",
"2001-12-14T21:59:43.10-05:00",
"2001-1-1 1:2:3",
"1.2.3",
".",
"-.5.",
"1.e+3",
"0x_",
"+0b__",
"1.e3",
] {
assert!(foreign_yaml11(s), "{s}");
}
for s in [
"2001-12",
"20011-12-14",
"2001-12-14x",
"a.b",
"0x",
"0XZ",
"0y1",
"a,b",
"1,a",
",",
"1:a",
"1:",
"2001:db8::1",
"_",
"a_1",
"1_a",
".in",
"inf",
] {
assert!(!foreign_yaml11(s), "{s}");
}
}
}