use std::fmt::Write as _;
use crate::error::{EmitError, EmitResult};
use crate::value::Value;
use memchr::memmem;
use saphyr::{ScalarOwned, YamlEmitter};
use saphyr_parser::ScalarStyle;
#[derive(Debug, Clone)]
pub struct EmitterConfig {
pub indent: usize,
pub width: usize,
pub default_flow_style: Option<bool>,
pub explicit_start: bool,
pub compact: bool,
pub multiline_strings: bool,
}
impl Default for EmitterConfig {
fn default() -> Self {
Self {
indent: 2,
width: 80,
default_flow_style: None,
explicit_start: false,
compact: true,
multiline_strings: false,
}
}
}
impl EmitterConfig {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_indent(mut self, indent: usize) -> Self {
self.indent = indent.clamp(1, 9);
self
}
#[must_use]
pub fn with_width(mut self, width: usize) -> Self {
self.width = width.clamp(20, 1000);
self
}
#[must_use]
pub const fn with_default_flow_style(mut self, flow_style: Option<bool>) -> Self {
self.default_flow_style = flow_style;
self
}
#[must_use]
pub const fn with_explicit_start(mut self, explicit_start: bool) -> Self {
self.explicit_start = explicit_start;
self
}
#[must_use]
pub const fn with_compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
}
#[must_use]
pub const fn with_multiline_strings(mut self, multiline_strings: bool) -> Self {
self.multiline_strings = multiline_strings;
self
}
}
#[derive(Debug)]
pub struct Emitter;
impl Emitter {
pub fn emit_str_with_config(value: &Value, config: &EmitterConfig) -> EmitResult<String> {
if config.default_flow_style == Some(true) {
let raw = Self::emit_flow(value)?;
let mut output = Self::apply_formatting(raw, config);
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
return Ok(output);
}
let estimated_size = Self::estimate_output_size(value);
let mut output = String::with_capacity(estimated_size);
{
let mut emitter = YamlEmitter::new(&mut output);
emitter.compact(config.compact);
emitter.multiline_strings(config.multiline_strings);
let yaml_borrowed: saphyr::Yaml = value.into();
emitter
.dump(&yaml_borrowed)
.map_err(|e| EmitError::Emit(e.to_string()))?;
}
output = Self::apply_formatting(output, config);
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
Ok(output)
}
fn estimate_output_size(value: &Value) -> usize {
Self::estimate_value_size(value)
}
fn estimate_value_size(value: &Value) -> usize {
match value {
Value::Value(scalar) => Self::estimate_scalar_size(scalar),
Value::Sequence(seq) => {
seq.iter().map(|v| 3 + Self::estimate_value_size(v)).sum()
}
Value::Mapping(map) => {
map.iter()
.map(|(k, v)| 11 + Self::estimate_value_size(k) + Self::estimate_value_size(v))
.sum()
}
Value::Representation(s, _, _) => s.len() + 2,
Value::Tagged(_, inner) => 10 + Self::estimate_value_size(inner),
Value::Alias(_) => 10,
Value::BadValue => 4,
}
}
fn estimate_scalar_size(scalar: &ScalarOwned) -> usize {
match scalar {
ScalarOwned::Null => 4, ScalarOwned::Boolean(_) => 5, ScalarOwned::Integer(i) => {
if *i == 0 {
1
} else {
i.unsigned_abs()
.checked_ilog10()
.map_or(1, |d| d as usize + 1)
+ 1
}
}
ScalarOwned::FloatingPoint(_) => 20, ScalarOwned::String(s) => s.len() + 2, }
}
pub fn emit_str(value: &Value) -> EmitResult<String> {
Self::emit_str_with_config(value, &EmitterConfig::default())
}
pub fn emit_all_with_config(values: &[Value], config: &EmitterConfig) -> EmitResult<String> {
let total_size: usize =
values.iter().map(Self::estimate_output_size).sum::<usize>() + values.len() * 5;
let mut output = String::with_capacity(total_size);
let inner_config = EmitterConfig {
explicit_start: false,
..*config
};
for (i, value) in values.iter().enumerate() {
if i > 0 || config.explicit_start {
output.push_str("---\n");
}
let doc = Self::emit_str_with_config(value, &inner_config)?;
output.push_str(&doc);
if !output.ends_with('\n') {
output.push('\n');
}
}
Ok(output)
}
pub fn emit_all(values: &[Value]) -> EmitResult<String> {
Self::emit_all_with_config(values, &EmitterConfig::default())
}
fn apply_formatting(mut output: String, config: &EmitterConfig) -> String {
if config.explicit_start {
if !output.starts_with("---") {
output.insert_str(0, "---\n");
}
} else if output.starts_with("---\n") {
output.drain(..4);
} else if output.starts_with("---") {
let skip = 3 + output[3..].chars().take_while(|c| *c == '\n').count();
output.drain(..skip);
}
output = Self::fix_special_floats(&output);
if config.indent != 2 {
output = Self::reindent(&output, config.indent);
}
output
}
fn fix_special_floats(output: &str) -> String {
if !Self::might_contain_special_floats(output) {
return output.to_string();
}
Self::fix_special_floats_slow(output)
}
#[inline]
fn might_contain_special_floats(output: &str) -> bool {
let bytes = output.as_bytes();
memmem::find(bytes, b"inf").is_some() || memmem::find(bytes, b"NaN").is_some()
}
fn fix_special_floats_slow(output: &str) -> String {
let mut result = String::with_capacity(output.len());
for (i, line) in output.lines().enumerate() {
if i > 0 {
result.push('\n');
}
let trimmed = line.trim_end();
if let Some(prefix) = trimmed.strip_suffix("inf") {
if let Some(before_minus) = prefix.strip_suffix('-') {
if Self::is_value_position(before_minus) {
result.push_str(before_minus);
result.push_str("-.inf");
continue;
}
} else if Self::is_value_position(prefix) {
result.push_str(prefix);
result.push_str(".inf");
continue;
}
} else if let Some(prefix) = trimmed.strip_suffix("NaN")
&& Self::is_value_position(prefix)
{
result.push_str(prefix);
result.push_str(".nan");
continue;
}
result.push_str(line);
}
result
}
fn is_value_position(prefix: &str) -> bool {
prefix.is_empty()
|| prefix.ends_with(": ")
|| prefix.ends_with("- ")
|| prefix.ends_with('\n')
}
fn extract_directives(input: &str) -> String {
let mut directives = String::new();
for line in input.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("---") || trimmed.starts_with("...") {
break;
}
if trimmed.starts_with("%YAML") || trimmed.starts_with("%TAG") {
directives.push_str(line);
directives.push('\n');
}
}
directives
}
pub fn format_with_config(input: &str, config: &EmitterConfig) -> EmitResult<String> {
let directives = Self::extract_directives(input);
#[cfg(all(feature = "streaming", feature = "arena"))]
{
let formatted = crate::streaming::format_streaming_arena(input, config)?;
Ok(Self::prepend_directives(&directives, formatted))
}
#[cfg(all(feature = "streaming", not(feature = "arena")))]
{
let formatted = crate::streaming::format_streaming(input, config)?;
Ok(Self::prepend_directives(&directives, formatted))
}
#[cfg(not(feature = "streaming"))]
{
let docs = crate::Parser::parse_all_preserving_styles(input)
.map_err(|e| EmitError::Emit(e.to_string()))?;
if docs.is_empty() {
return Ok(String::new());
}
let inner_config = EmitterConfig {
explicit_start: false,
..*config
};
let mut output = String::new();
for (i, doc) in docs.iter().enumerate() {
if i > 0 || config.explicit_start {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str("---\n");
}
let emitted = Self::emit_str_preserving_styles(doc, &inner_config, 0)?;
output.push_str(&emitted);
}
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
Ok(Self::prepend_directives(&directives, output))
}
}
fn prepend_directives(directives: &str, formatted: String) -> String {
if directives.is_empty() {
return formatted;
}
if formatted.starts_with("---") {
format!("{directives}{formatted}")
} else {
format!("{directives}---\n{formatted}")
}
}
fn emit_str_preserving_styles(
value: &Value,
config: &EmitterConfig,
indent_level: usize,
) -> EmitResult<String> {
if !Self::has_block_scalar(value) {
return Self::emit_str_with_config(value, config);
}
let raw = Self::emit_value(value, config, indent_level)?;
Ok(Self::apply_formatting(raw, config))
}
fn has_block_scalar(value: &Value) -> bool {
match value {
Value::Representation(_, ScalarStyle::Literal | ScalarStyle::Folded, _) => true,
Value::Sequence(seq) => seq.iter().any(Self::has_block_scalar),
Value::Mapping(map) => map
.iter()
.any(|(k, v)| Self::has_block_scalar(k) || Self::has_block_scalar(v)),
Value::Tagged(_, inner) => Self::has_block_scalar(inner),
_ => false,
}
}
fn emit_value(
value: &Value,
config: &EmitterConfig,
indent_level: usize,
) -> EmitResult<String> {
match value {
Value::Representation(content, ScalarStyle::Literal, _) => Ok(
Self::format_block_scalar(content, '|', config.indent, indent_level),
),
Value::Representation(content, ScalarStyle::Folded, _) => Ok(
Self::format_block_scalar(content, '>', config.indent, indent_level),
),
Value::Mapping(map) => {
let indent = " ".repeat(indent_level * config.indent);
let mut out = String::new();
for (k, v) in map {
let key_str = Self::emit_scalar_inline(k)?;
match v {
Value::Representation(_, ScalarStyle::Literal | ScalarStyle::Folded, _) => {
let val_str = Self::emit_value(v, config, indent_level + 1)?;
write!(out, "{indent}{key_str}: {val_str}")
.map_err(|e| EmitError::Emit(e.to_string()))?;
}
Value::Mapping(_) | Value::Sequence(_) => {
writeln!(out, "{indent}{key_str}:")
.map_err(|e| EmitError::Emit(e.to_string()))?;
let val_str = Self::emit_value(v, config, indent_level + 1)?;
out.push_str(&val_str);
}
_ => {
let val_str = Self::emit_value_inline(v)?;
writeln!(out, "{indent}{key_str}: {val_str}")
.map_err(|e| EmitError::Emit(e.to_string()))?;
}
}
}
Ok(out)
}
Value::Sequence(seq) => {
let indent = " ".repeat(indent_level * config.indent);
let mut out = String::new();
for item in seq {
match item {
Value::Representation(_, ScalarStyle::Literal | ScalarStyle::Folded, _) => {
let item_str = Self::emit_value(item, config, indent_level + 1)?;
write!(out, "{indent}- {item_str}")
.map_err(|e| EmitError::Emit(e.to_string()))?;
}
Value::Mapping(_) | Value::Sequence(_) => {
writeln!(out, "{indent}-")
.map_err(|e| EmitError::Emit(e.to_string()))?;
let item_str = Self::emit_value(item, config, indent_level + 1)?;
out.push_str(&item_str);
}
_ => {
let item_str = Self::emit_value_inline(item)?;
writeln!(out, "{indent}- {item_str}")
.map_err(|e| EmitError::Emit(e.to_string()))?;
}
}
}
Ok(out)
}
_ => Self::emit_value_inline(value).map(|s| format!("{s}\n")),
}
}
fn emit_scalar_inline(value: &Value) -> EmitResult<String> {
match value {
Value::Representation(s, ScalarStyle::SingleQuoted, _) => Ok(format!("'{s}'")),
Value::Representation(s, ScalarStyle::DoubleQuoted, _) => Ok(format!("\"{s}\"")),
Value::Representation(s, _, _) => Ok(s.clone()),
Value::Value(scalar) => match scalar {
ScalarOwned::Null => Ok("null".to_string()),
ScalarOwned::Boolean(b) => Ok(if *b { "true" } else { "false" }.to_string()),
ScalarOwned::Integer(i) => Ok(i.to_string()),
ScalarOwned::FloatingPoint(f) => {
let s = f.to_string();
if s.contains('.')
|| s.contains('e')
|| s.contains('E')
|| s.eq_ignore_ascii_case("inf")
|| s.eq_ignore_ascii_case("-inf")
|| s.eq_ignore_ascii_case("nan")
{
Ok(s)
} else {
Ok(format!("{s}.0"))
}
}
ScalarOwned::String(s) => {
if s.contains(':') || s.contains('#') || s.is_empty() {
Ok(format!("\"{s}\""))
} else {
Ok(s.clone())
}
}
},
_ => Err(EmitError::UnsupportedType(
"complex key not supported".to_string(),
)),
}
}
fn emit_value_inline(value: &Value) -> EmitResult<String> {
let mut out = String::new();
{
let mut emitter = YamlEmitter::new(&mut out);
emitter.compact(true);
let yaml: saphyr::Yaml = value.into();
emitter
.dump(&yaml)
.map_err(|e| EmitError::Emit(e.to_string()))?;
}
let trimmed = out
.strip_prefix("---\n")
.unwrap_or(&out)
.trim_end_matches('\n');
Ok(trimmed.to_string())
}
fn format_block_scalar(
content: &str,
indicator: char,
indent_width: usize,
indent_level: usize,
) -> String {
let child_indent = " ".repeat(indent_level * indent_width);
let chomping = if content.ends_with("\n\n") {
"+"
} else if !content.ends_with('\n') {
"-"
} else {
""
};
let mut out = format!("{indicator}{chomping}\n");
for line in content.lines() {
if line.is_empty() {
out.push('\n');
} else {
let _ = writeln!(out, "{child_indent}{line}");
}
}
out
}
fn emit_flow(value: &Value) -> EmitResult<String> {
match value {
Value::Mapping(map) => {
let mut out = String::from("{");
for (i, (k, v)) in map.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
let key_str = Self::emit_scalar_inline(k)?;
let val_str = Self::emit_flow(v)?;
write!(out, "{key_str}: {val_str}")
.map_err(|e| EmitError::Emit(e.to_string()))?;
}
out.push('}');
Ok(out)
}
Value::Sequence(seq) => {
let mut out = String::from("[");
for (i, item) in seq.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(&Self::emit_flow(item)?);
}
out.push(']');
Ok(out)
}
_ => Self::emit_value_inline(value),
}
}
fn reindent(output: &str, target: usize) -> String {
let mut result = String::with_capacity(output.len());
let mut in_block_scalar = false;
let mut block_scalar_base_indent: usize = 0;
for (i, line) in output.lines().enumerate() {
if i > 0 {
result.push('\n');
}
let trimmed = line.trim_start();
if trimmed.starts_with("---")
|| trimmed.starts_with("...")
|| trimmed.starts_with("%YAML")
|| trimmed.starts_with("%TAG")
{
in_block_scalar = false;
result.push_str(line);
continue;
}
let leading = line.len() - trimmed.len();
let level = leading / 2;
if in_block_scalar {
if leading > block_scalar_base_indent {
let base_level = block_scalar_base_indent / 2;
let extra = leading - block_scalar_base_indent;
let new_leading = base_level * target + extra;
let spaces = " ".repeat(new_leading);
result.push_str(&spaces);
result.push_str(trimmed);
continue;
}
in_block_scalar = false;
}
let value_part = trimmed.trim_end_matches(|c: char| c.is_whitespace());
let last_nonws = value_part.trim_start_matches(|c: char| c != '|' && c != '>');
if last_nonws.starts_with('|') || last_nonws.starts_with('>') {
in_block_scalar = true;
block_scalar_base_indent = leading;
}
let new_leading = level * target;
let spaces = " ".repeat(new_leading);
result.push_str(&spaces);
result.push_str(trimmed);
}
if output.ends_with('\n') {
result.push('\n');
}
result
}
pub fn format(input: &str) -> EmitResult<String> {
Self::format_with_config(input, &EmitterConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ordered_float::OrderedFloat;
use saphyr::ScalarOwned;
#[test]
fn test_emit_str_string() {
let value = Value::Value(ScalarOwned::String("test".to_string()));
let result = Emitter::emit_str(&value).unwrap();
assert!(result.contains("test"));
}
#[test]
fn test_emit_str_integer() {
let value = Value::Value(ScalarOwned::Integer(42));
let result = Emitter::emit_str(&value).unwrap();
assert!(result.contains("42"));
}
#[test]
fn test_emit_all_multiple() {
let values = vec![
Value::Value(ScalarOwned::String("first".to_string())),
Value::Value(ScalarOwned::String("second".to_string())),
];
let result = Emitter::emit_all(&values).unwrap();
assert!(result.contains("first"));
assert!(result.contains("second"));
assert!(result.contains("---"));
}
#[test]
fn test_emit_all_single() {
let values = vec![Value::Value(ScalarOwned::String("only".to_string()))];
let result = Emitter::emit_all(&values).unwrap();
assert!(result.contains("only"));
assert!(!result.starts_with("---"));
}
#[test]
fn test_emitter_config_default() {
let config = EmitterConfig::default();
assert_eq!(config.indent, 2);
assert_eq!(config.width, 80);
assert_eq!(config.default_flow_style, None);
assert!(!config.explicit_start);
assert!(config.compact);
assert!(!config.multiline_strings);
}
#[test]
fn test_emitter_config_builder() {
let config = EmitterConfig::new()
.with_indent(4)
.with_width(120)
.with_explicit_start(true)
.with_compact(false);
assert_eq!(config.indent, 4);
assert_eq!(config.width, 120);
assert!(config.explicit_start);
assert!(!config.compact);
}
#[test]
fn test_emitter_config_clamp_indent() {
let config = EmitterConfig::new().with_indent(100);
assert_eq!(config.indent, 9);
let config = EmitterConfig::new().with_indent(0);
assert_eq!(config.indent, 1);
}
#[test]
fn test_emitter_config_clamp_width() {
let config = EmitterConfig::new().with_width(10);
assert_eq!(config.width, 20);
let config = EmitterConfig::new().with_width(2000);
assert_eq!(config.width, 1000);
}
#[test]
fn test_emit_with_explicit_start() {
let value = Value::Value(ScalarOwned::String("test".to_string()));
let config = EmitterConfig::new().with_explicit_start(true);
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(result.starts_with("---"));
}
#[test]
fn test_emit_without_explicit_start() {
let value = Value::Value(ScalarOwned::String("test".to_string()));
let config = EmitterConfig::new().with_explicit_start(false);
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(!result.starts_with("---"));
}
#[test]
fn test_emit_all_with_explicit_start() {
let values = vec![
Value::Value(ScalarOwned::String("first".to_string())),
Value::Value(ScalarOwned::String("second".to_string())),
];
let config = EmitterConfig::new().with_explicit_start(true);
let result = Emitter::emit_all_with_config(&values, &config).unwrap();
assert!(result.starts_with("---"));
assert_eq!(result.matches("---").count(), 2);
}
#[test]
fn test_emit_with_compact_false() {
let value = Value::Sequence(vec![
Value::Value(ScalarOwned::Integer(1)),
Value::Value(ScalarOwned::Integer(2)),
]);
let config = EmitterConfig::new().with_compact(false);
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(result.contains('1') && result.contains('2'));
}
#[test]
fn test_emit_with_multiline_strings() {
let value = Value::Value(ScalarOwned::String("line1\nline2".to_string()));
let config = EmitterConfig::new().with_multiline_strings(true);
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(result.contains("line1") && result.contains("line2"));
}
#[test]
fn test_estimate_scalar_size_all_types() {
let null_size = Emitter::estimate_scalar_size(&ScalarOwned::Null);
assert_eq!(null_size, 4);
let bool_size = Emitter::estimate_scalar_size(&ScalarOwned::Boolean(true));
assert_eq!(bool_size, 5);
let zero_size = Emitter::estimate_scalar_size(&ScalarOwned::Integer(0));
assert_eq!(zero_size, 1);
let single_digit = Emitter::estimate_scalar_size(&ScalarOwned::Integer(5));
assert!(single_digit >= 1);
let multi_digit = Emitter::estimate_scalar_size(&ScalarOwned::Integer(12345));
assert!(multi_digit >= 5);
let negative = Emitter::estimate_scalar_size(&ScalarOwned::Integer(-42));
assert!(negative >= 2);
let float_size =
Emitter::estimate_scalar_size(&ScalarOwned::FloatingPoint(OrderedFloat(1.23456)));
assert_eq!(float_size, 20);
let string_size = Emitter::estimate_scalar_size(&ScalarOwned::String("hello".to_string()));
assert_eq!(string_size, 7); }
#[test]
fn test_estimate_value_size_mapping() {
use saphyr::MappingOwned;
let mut map = MappingOwned::new();
map.insert(
Value::Value(ScalarOwned::String("key1".to_string())),
Value::Value(ScalarOwned::Integer(100)),
);
map.insert(
Value::Value(ScalarOwned::String("key2".to_string())),
Value::Value(ScalarOwned::Integer(200)),
);
let mapping = Value::Mapping(map);
let size = Emitter::estimate_value_size(&mapping);
assert!(
size > 20,
"Mapping estimate should be significant: got {size}"
);
let mut nested_map = MappingOwned::new();
nested_map.insert(
Value::Value(ScalarOwned::String("outer".to_string())),
mapping,
);
let nested_size = Emitter::estimate_value_size(&Value::Mapping(nested_map));
assert!(
nested_size > size,
"Nested mapping should have larger estimate"
);
}
#[test]
fn test_might_contain_special_floats_positive() {
assert!(Emitter::might_contain_special_floats("inf"));
assert!(Emitter::might_contain_special_floats("key: inf"));
assert!(Emitter::might_contain_special_floats("-inf"));
assert!(Emitter::might_contain_special_floats("key: -inf"));
assert!(Emitter::might_contain_special_floats("- inf\n- -inf"));
assert!(Emitter::might_contain_special_floats("NaN"));
assert!(Emitter::might_contain_special_floats("key: NaN"));
assert!(Emitter::might_contain_special_floats(
"values:\n - NaN\n - inf"
));
assert!(Emitter::might_contain_special_floats(
"---\npi: 3.14\nspecial: inf\n"
));
}
#[test]
fn test_might_contain_special_floats_false_positives() {
assert!(
Emitter::might_contain_special_floats("information"),
"'information' contains 'inf' substring"
);
assert!(
Emitter::might_contain_special_floats("infinity"),
"'infinity' contains 'inf' substring"
);
assert!(
Emitter::might_contain_special_floats("infinite"),
"'infinite' contains 'inf' substring"
);
assert!(
Emitter::might_contain_special_floats("reinforce"),
"'reinforce' contains 'inf' substring"
);
assert!(!Emitter::might_contain_special_floats("hello world"));
assert!(!Emitter::might_contain_special_floats("key: value"));
assert!(!Emitter::might_contain_special_floats("number: 42"));
assert!(!Emitter::might_contain_special_floats("pi: 3.14159"));
assert!(!Emitter::might_contain_special_floats("config")); assert!(!Emitter::might_contain_special_floats("nan")); assert!(!Emitter::might_contain_special_floats("INF")); }
#[test]
fn test_fix_special_floats_inf() {
let result = Emitter::fix_special_floats("inf");
assert_eq!(result, ".inf");
let result = Emitter::fix_special_floats("key: inf");
assert_eq!(result, "key: .inf");
let result = Emitter::fix_special_floats("-inf");
assert_eq!(result, "-.inf");
let result = Emitter::fix_special_floats("key: -inf");
assert_eq!(result, "key: -.inf");
let result = Emitter::fix_special_floats("- inf");
assert_eq!(result, "- .inf");
let result = Emitter::fix_special_floats("- -inf");
assert_eq!(result, "- -.inf");
let input = "positive: inf\nnegative: -inf\nlist:\n - inf\n - -inf";
let result = Emitter::fix_special_floats(input);
assert!(result.contains("positive: .inf"));
assert!(result.contains("negative: -.inf"));
assert!(result.contains("- .inf"));
assert!(result.contains("- -.inf"));
}
#[test]
fn test_fix_special_floats_nan() {
let result = Emitter::fix_special_floats("NaN");
assert_eq!(result, ".nan");
let result = Emitter::fix_special_floats("value: NaN");
assert_eq!(result, "value: .nan");
let result = Emitter::fix_special_floats("- NaN");
assert_eq!(result, "- .nan");
let input = "nan_value: NaN\nlist:\n - NaN";
let result = Emitter::fix_special_floats(input);
assert!(result.contains("nan_value: .nan"));
assert!(result.contains("- .nan"));
let result = Emitter::fix_special_floats("name: BaNaNa");
assert_eq!(result, "name: BaNaNa", "BaNaNa should not be modified");
let input = "inf_val: inf\nnan_val: NaN\nneg_inf: -inf";
let result = Emitter::fix_special_floats(input);
assert!(result.contains("inf_val: .inf"));
assert!(result.contains("nan_val: .nan"));
assert!(result.contains("neg_inf: -.inf"));
}
#[test]
fn test_estimate_value_size_sequence() {
let seq = Value::Sequence(vec![
Value::Value(ScalarOwned::Integer(1)),
Value::Value(ScalarOwned::Integer(2)),
Value::Value(ScalarOwned::String("hello".to_string())),
]);
let size = Emitter::estimate_value_size(&seq);
assert!(
size >= 10,
"Sequence estimate should be significant: got {size}"
);
}
#[test]
fn test_estimate_value_size_all_variants() {
use saphyr_parser::{ScalarStyle, Tag};
let repr = Value::Representation("custom".to_string(), ScalarStyle::Plain, None);
let repr_size = Emitter::estimate_value_size(&repr);
assert_eq!(repr_size, 8);
let tag = Tag {
handle: "!".to_string(),
suffix: "custom".to_string(),
};
let tagged = Value::Tagged(tag, Box::new(Value::Value(ScalarOwned::Integer(42))));
let tagged_size = Emitter::estimate_value_size(&tagged);
assert!(tagged_size >= 10, "Tagged value should have tag overhead");
let alias = Value::Alias(1);
let alias_size = Emitter::estimate_value_size(&alias);
assert_eq!(alias_size, 10);
let bad = Value::BadValue;
let bad_size = Emitter::estimate_value_size(&bad);
assert_eq!(bad_size, 4);
}
#[test]
fn test_emit_all_empty_slice() {
let empty: Vec<Value> = vec![];
let config = EmitterConfig::default();
let result = Emitter::emit_all_with_config(&empty, &config).unwrap();
assert!(result.is_empty(), "Empty input should produce empty output");
}
#[test]
fn test_emit_all_buffer_preallocation() {
let docs: Vec<Value> = (0..10)
.map(|i| Value::Value(ScalarOwned::String(format!("document_{i}"))))
.collect();
let config = EmitterConfig::default();
let result = Emitter::emit_all_with_config(&docs, &config).unwrap();
for i in 0..10 {
assert!(
result.contains(&format!("document_{i}")),
"Should contain document_{i}"
);
}
assert_eq!(
result.matches("---").count(),
9,
"Should have 9 document separators"
);
}
#[test]
fn test_estimate_scalar_size_large_integer() {
let max_int = Emitter::estimate_scalar_size(&ScalarOwned::Integer(i64::MAX));
assert!(max_int >= 19, "Max i64 should have at least 19 chars");
let min_int = Emitter::estimate_scalar_size(&ScalarOwned::Integer(i64::MIN));
assert!(min_int >= 19, "Min i64 should have at least 19 chars");
let thousand = Emitter::estimate_scalar_size(&ScalarOwned::Integer(1000));
assert!(thousand >= 4, "1000 should have at least 4 chars");
let million = Emitter::estimate_scalar_size(&ScalarOwned::Integer(1_000_000));
assert!(million >= 7, "1000000 should have at least 7 chars");
}
#[test]
fn test_might_contain_special_floats_empty() {
assert!(!Emitter::might_contain_special_floats(""));
}
#[test]
fn test_fix_special_floats_no_changes() {
let input = "key: value\nlist:\n - item1\n - item2\nnumber: 42\n";
let result = Emitter::fix_special_floats(input);
assert_eq!(result, input, "No changes should be made for normal YAML");
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_yaml11_bool_key_on() {
let result = Emitter::format("on: push").unwrap();
assert!(
!result.contains("\"on\""),
"key `on` must not be quoted, got: {result}"
);
assert!(result.contains("on:"), "key `on` must appear unquoted");
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_yaml11_bool_key_off() {
let result = Emitter::format("off: value").unwrap();
assert!(
!result.contains("\"off\""),
"key `off` must not be quoted, got: {result}"
);
assert!(result.contains("off:"), "key `off` must appear unquoted");
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_yaml11_bool_key_yes() {
let result = Emitter::format("yes: value").unwrap();
assert!(
!result.contains("\"yes\""),
"key `yes` must not be quoted, got: {result}"
);
assert!(result.contains("yes:"), "key `yes` must appear unquoted");
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_yaml11_bool_key_no() {
let result = Emitter::format("no: value").unwrap();
assert!(
!result.contains("\"no\""),
"key `no` must not be quoted, got: {result}"
);
assert!(result.contains("no:"), "key `no` must appear unquoted");
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_github_actions_workflow() {
let yaml = "on:\n push:\n branches:\n - main\n";
let result = Emitter::format(yaml).unwrap();
assert!(
!result.contains("\"on\""),
"GitHub Actions `on:` trigger must not be quoted, got: {result}"
);
assert!(result.contains("on:"), "on: key must appear unquoted");
assert!(result.contains("push:"), "push: key must appear");
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_yaml12_bools_unaffected() {
let yaml = "enabled: true\ndisabled: false\n";
let result = Emitter::format(yaml).unwrap();
assert!(result.contains("true"), "true value must be preserved");
assert!(result.contains("false"), "false value must be preserved");
}
#[test]
#[cfg(feature = "streaming")]
fn test_format_preserves_float_types() {
let result = Emitter::format("version: 1.0").unwrap();
assert!(
result.contains("1.0"),
"version: 1.0 must emit as float, got: {result}"
);
assert!(
!result.contains(": 1\n"),
"version: 1.0 must not become integer 1, got: {result}"
);
let result = Emitter::format("count: 1.23e10").unwrap();
assert!(
result.contains("1.23e10") || result.contains("1.23e+10"),
"Scientific notation must be preserved, got: {result}"
);
assert!(
!result.contains("12300000000"),
"Scientific notation must not expand to integer, got: {result}"
);
let result = Emitter::format("pi: 3.14").unwrap();
assert!(
result.contains("3.14"),
"3.14 must be preserved, got: {result}"
);
}
#[test]
fn test_format_preserves_literal_block_scalar() {
let input = "literal: |\n line one\n line two\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("literal: |"),
"literal block style should be preserved, got: {result}"
);
assert!(result.contains("line one"));
assert!(result.contains("line two"));
}
#[test]
fn test_format_preserves_folded_block_scalar() {
let input = "folded: >\n word1\n word2\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("folded: >"),
"folded block style should be preserved, got: {result}"
);
}
#[test]
fn test_format_nested_literal_block_scalar() {
let input = "outer:\n inner: |\n line one\n line two\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("inner: |"),
"nested literal block should be preserved, got: {result}"
);
assert!(result.contains("line one"));
assert!(result.contains("line two"));
}
#[test]
fn test_format_mixed_block_and_plain_values() {
let input = "plain: value\nliteral: |\n block content\nnumber: 42\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(result.contains("plain: value"));
assert!(result.contains("literal: |"));
assert!(result.contains("block content"));
assert!(result.contains("number: 42") || result.contains("number: '42'"));
}
#[test]
fn test_format_block_scalar_not_double_quoted() {
let input = "key: |\n multiline\n content\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
!result.contains("\"multiline"),
"block scalar should not be double-quoted, got: {result}"
);
assert!(
result.contains("key: |"),
"literal indicator must be present, got: {result}"
);
}
#[test]
fn test_format_multidoc_preserves_both_documents() {
let input =
"---\n- Mark McGwire\n- Sammy Sosa\n\n---\n- Chicago Cubs\n- St Louis Cardinals";
let result = Emitter::format(input).unwrap();
assert!(result.contains("Mark McGwire"), "First doc must be present");
assert!(
result.contains("Chicago Cubs"),
"Second doc must be present"
);
}
#[test]
fn test_format_multidoc_separator_present() {
let input = "---\nfoo: 1\n---\nbar: 2";
let result = Emitter::format(input).unwrap();
assert!(result.contains("---"), "Separator must appear in output");
assert!(result.contains("foo"), "First doc key must be present");
assert!(result.contains("bar"), "Second doc key must be present");
}
#[test]
fn test_format_multidoc_issue65_fixture() {
let input =
"---\n- Mark McGwire\n- Sammy Sosa\n\n---\n- Chicago Cubs\n- St Louis Cardinals";
let result = Emitter::format(input).unwrap();
assert!(result.contains("Mark McGwire"));
assert!(result.contains("Sammy Sosa"));
assert!(result.contains("Chicago Cubs"));
assert!(result.contains("St Louis Cardinals"));
assert!(result.contains("---"));
}
#[test]
fn test_format_multidoc_three_documents() {
let input = "---\na: 1\n---\nb: 2\n---\nc: 3";
let result = Emitter::format(input).unwrap();
assert!(result.contains('a'), "First doc must be present");
assert!(result.contains('b'), "Second doc must be present");
assert!(result.contains('c'), "Third doc must be present");
assert!(
result.matches("---").count() >= 2,
"At least two separators must appear between three documents"
);
}
#[test]
fn test_format_multidoc_explicit_start() {
let input = "---\nfoo: 1\n---\nbar: 2";
let config = EmitterConfig::new().with_explicit_start(true);
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(result.contains("foo"), "First doc must be present");
assert!(result.contains("bar"), "Second doc must be present");
assert!(
!result.contains("---\n---"),
"Double separator must not appear: {result}"
);
assert_eq!(
result.matches("---").count(),
2,
"Exactly two separators for two docs"
);
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_nested_mapping_no_trailing_space() {
let result = Emitter::format("parent:\n child: value\n").unwrap();
assert!(
!result.contains("parent: \n"),
"trailing space after key with nested value, got: {result:?}"
);
assert!(
result.contains("parent:\n"),
"parent key must be followed by newline without space, got: {result:?}"
);
assert!(
result.contains("child: value"),
"child key-value must be preserved, got: {result:?}"
);
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_sequence_of_mappings_indent() {
let yaml = "steps:\n - uses: actions/checkout@v4\n - name: Install Rust\n uses: dtolnay/rust-toolchain@stable\n";
let result = Emitter::format(yaml).unwrap();
assert!(
!result.contains("- "),
"sequence item keys must not be double-indented, got: {result:?}"
);
assert!(
result.contains("- uses:"),
"first item key must directly follow dash, got: {result:?}"
);
assert!(
result.contains("uses: actions/checkout@v4"),
"got: {result:?}"
);
assert!(
result.contains("uses: dtolnay/rust-toolchain@stable"),
"got: {result:?}"
);
}
#[cfg(feature = "streaming")]
#[test]
fn test_format_sequence_of_mappings_valid_yaml() {
let yaml = "steps:\n - uses: actions/checkout@v4\n - name: Install Rust\n uses: dtolnay/rust-toolchain@stable\n";
let result = Emitter::format(yaml).unwrap();
let reparsed = crate::Parser::parse_str(&result);
assert!(
reparsed.is_ok(),
"formatted output is invalid YAML: {result:?}"
);
}
#[test]
fn test_format_preserves_clip_chomp() {
let input = "desc: |\n line one\n line two\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("desc: |\n"),
"clip chomp `|` must not be changed to `|-`, got: {result}"
);
}
#[test]
fn test_format_preserves_strip_chomp() {
let input = "desc: |-\n line one\n line two\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("desc: |-\n"),
"strip chomp `|-` must be preserved, got: {result}"
);
}
#[test]
fn test_format_preserves_keep_chomp() {
let input = "desc: |+\n line one\n line two\n\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("desc: |+\n"),
"keep chomp `|+` must be preserved, got: {result}"
);
}
#[test]
fn test_format_preserves_folded_clip_chomp() {
let input = "desc: >\n line one\n line two\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("desc: >\n"),
"folded clip `>` must not be changed to `>-`, got: {result}"
);
}
#[test]
fn test_emit_str_ends_with_newline() {
let value = Value::Value(ScalarOwned::String("hello".to_string()));
let result = Emitter::emit_str(&value).unwrap();
assert!(
result.ends_with('\n'),
"emit_str output must end with newline, got: {result:?}"
);
}
#[test]
fn test_emit_str_with_config_ends_with_newline_default() {
let value = Value::Value(ScalarOwned::String("hello".to_string()));
let config = EmitterConfig::default();
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(
result.ends_with('\n'),
"emit_str_with_config output must end with newline (default config), got: {result:?}"
);
}
#[test]
fn test_emit_str_with_config_ends_with_newline_explicit_start() {
let value = Value::Value(ScalarOwned::String("hello".to_string()));
let config = EmitterConfig::new().with_explicit_start(true);
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(
result.ends_with('\n'),
"emit_str_with_config output must end with newline (explicit_start=true), got: {result:?}"
);
}
#[test]
fn test_format_preserves_yaml_directive() {
let input = "%YAML 1.2\n---\nkey: value\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("%YAML 1.2"),
"format_with_config must preserve %YAML directive, got: {result:?}"
);
}
#[test]
fn test_format_preserves_tag_directive() {
let input = "%TAG ! tag:example.com,2000:app/\n---\nkey: value\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("%TAG ! tag:example.com,2000:app/"),
"format_with_config must preserve %TAG directive, got: {result:?}"
);
}
#[test]
fn test_format_without_directives_works_normally() {
let input = "key: value\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("key: value"),
"format_with_config without directives must work normally, got: {result:?}"
);
assert!(
result.ends_with('\n'),
"format_with_config output must end with newline, got: {result:?}"
);
}
#[test]
fn test_format_yaml_directive_precedes_document_start() {
let input = "%YAML 1.2\n---\nkey: value\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
let yaml_pos = result
.find("%YAML 1.2")
.expect("%YAML directive must be present");
let doc_start_pos = result.find("---").expect("--- must be present");
assert!(
yaml_pos < doc_start_pos,
"%YAML directive must appear before ---, got: {result:?}"
);
}
#[test]
fn test_format_yaml_and_tag_directives_together() {
let input = "%YAML 1.2\n%TAG ! tag:example.com,2000:app/\n---\nkey: value\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("%YAML 1.2"),
"%YAML directive must be preserved, got: {result:?}"
);
assert!(
result.contains("%TAG ! tag:example.com,2000:app/"),
"%TAG directive must be preserved, got: {result:?}"
);
let yaml_pos = result.find("%YAML 1.2").unwrap();
let tag_pos = result.find("%TAG").unwrap();
let doc_pos = result.find("---").unwrap();
assert!(
yaml_pos < doc_pos,
"%YAML must precede ---, got: {result:?}"
);
assert!(tag_pos < doc_pos, "%TAG must precede ---, got: {result:?}");
}
#[test]
fn test_extract_directives_without_explicit_doc_start() {
let input = "%YAML 1.2\nkey: value\n";
let directives = Emitter::extract_directives(input);
assert_eq!(
directives, "%YAML 1.2\n",
"extract_directives must return directive line even without ---, got: {directives:?}"
);
}
#[test]
fn test_format_directive_only_on_first_document_in_multidoc_stream() {
let input = "%YAML 1.2\n---\nfirst: doc\n---\nsecond: doc\n";
let config = EmitterConfig::default();
let result = Emitter::format_with_config(input, &config).unwrap();
assert!(
result.contains("%YAML 1.2"),
"%YAML directive must be present in output, got: {result:?}"
);
assert_eq!(
result.matches("%YAML 1.2").count(),
1,
"Directive must appear exactly once, got: {result:?}"
);
assert!(
result.contains("first: doc"),
"first document must be present, got: {result:?}"
);
assert!(
result.contains("second: doc"),
"second document must be present, got: {result:?}"
);
}
#[test]
fn test_emit_with_indent_4() {
use saphyr::MappingOwned;
let mut map = MappingOwned::new();
map.insert(
Value::Value(ScalarOwned::String("key".to_string())),
Value::Sequence(vec![
Value::Value(ScalarOwned::Integer(1)),
Value::Value(ScalarOwned::Integer(2)),
]),
);
let value = Value::Mapping(map);
let config = EmitterConfig::new().with_indent(4);
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(
result.contains(" - 1") || result.contains(" -"),
"indent=4 should produce 4-space indentation, got: {result:?}"
);
}
#[test]
fn test_emit_default_flow_style_true_mapping() {
use saphyr::MappingOwned;
let mut map = MappingOwned::new();
map.insert(
Value::Value(ScalarOwned::String("a".to_string())),
Value::Value(ScalarOwned::Integer(1)),
);
map.insert(
Value::Value(ScalarOwned::String("b".to_string())),
Value::Value(ScalarOwned::Integer(2)),
);
let value = Value::Mapping(map);
let config = EmitterConfig::new().with_default_flow_style(Some(true));
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(
result.contains('{') && result.contains('}'),
"default_flow_style=true should produce flow mapping {{...}}, got: {result:?}"
);
assert!(result.contains("a: 1"), "mapping key a must be present");
assert!(result.contains("b: 2"), "mapping key b must be present");
}
#[test]
fn test_emit_default_flow_style_true_sequence() {
let value = Value::Sequence(vec![
Value::Value(ScalarOwned::Integer(1)),
Value::Value(ScalarOwned::Integer(2)),
Value::Value(ScalarOwned::Integer(3)),
]);
let config = EmitterConfig::new().with_default_flow_style(Some(true));
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(
result.contains('[') && result.contains(']'),
"default_flow_style=true should produce flow sequence [...], got: {result:?}"
);
assert!(result.contains("1, 2, 3"), "sequence items must be inline");
}
#[test]
fn test_emit_default_flow_style_none_is_block() {
let value = Value::Sequence(vec![
Value::Value(ScalarOwned::Integer(1)),
Value::Value(ScalarOwned::Integer(2)),
]);
let config = EmitterConfig::new().with_default_flow_style(None);
let result = Emitter::emit_str_with_config(&value, &config).unwrap();
assert!(
result.contains("- 1"),
"default_flow_style=None should produce block sequence, got: {result:?}"
);
}
#[test]
fn test_reindent_basic() {
let input = "key:\n nested: value\n";
let result = Emitter::reindent(input, 4);
assert!(
result.contains(" nested: value"),
"reindent(4) should produce 4 spaces, got: {result:?}"
);
}
#[test]
fn test_reindent_preserves_markers() {
let input = "---\nkey: value\n";
let result = Emitter::reindent(input, 4);
assert!(result.contains("---"), "--- marker must be preserved");
assert!(result.contains("key: value"));
}
}