use alloc::string::String;
use crate::error::{Error, Role};
use crate::model::{Comment, Meta, Resource};
#[derive(Clone, Copy, Debug)]
pub struct Style {
pub wrap_over: Option<usize>,
pub wrap_at: usize,
pub indent: &'static str,
}
impl Default for Style {
fn default() -> Self {
Style {
wrap_over: None,
wrap_at: 76,
indent: " ",
}
}
}
impl Style {
pub fn wrapped(over: usize, at: usize) -> Self {
Style {
wrap_over: Some(over),
wrap_at: at,
..Style::default()
}
}
}
pub fn serialize<V: AsRef<str>>(resource: &Resource<'_, V>) -> Result<String, Error> {
serialize_with(resource, &Style::default())
}
pub fn serialize_with<V: AsRef<str>>(
resource: &Resource<'_, V>,
style: &Style,
) -> Result<String, Error> {
let mut out = String::new();
let mut gap = false;
if resource.comment.is_some() || !resource.meta.is_empty() {
write_comment(&mut out, resource.comment.as_ref())?;
for meta in &resource.meta {
write_meta(&mut out, meta, style)?;
}
out.push_str("---\n");
gap = true;
}
for section in &resource.sections {
if let Some(head) = §ion.head {
blank_line(&mut out);
write_comment(&mut out, head.comment.as_ref())?;
for meta in &head.meta {
write_meta(&mut out, meta, style)?;
}
out.push('[');
head.id.write_canonical(&mut out)?;
out.push_str("]\n");
gap = false;
}
for (i, entry) in section.entries.iter().enumerate() {
for detached in section.detached.iter().filter(|d| d.before == i) {
blank_line(&mut out);
write_comment(&mut out, Some(&detached.comment))?;
out.push('\n');
}
let value = entry.value.as_ref();
let block = starts_on_its_own_line(value);
if gap || block || entry.comment.is_some() {
blank_line(&mut out);
}
gap = block;
write_comment(&mut out, entry.comment.as_ref())?;
for meta in &entry.meta {
write_meta(&mut out, meta, style)?;
}
entry.id.write_canonical(&mut out)?;
if value.is_empty() {
out.push_str(" =\n");
} else if block {
out.push_str(" =\n");
out.push_str(style.indent);
write_value(&mut out, value, style)?;
out.push('\n');
} else {
out.push_str(" = ");
write_value(&mut out, value, style)?;
out.push('\n');
}
}
let past = section.entries.len();
for detached in section.detached.iter().filter(|d| d.before >= past) {
blank_line(&mut out);
write_comment(&mut out, Some(&detached.comment))?;
out.push('\n');
}
}
Ok(out)
}
fn starts_on_its_own_line(value: &str) -> bool {
value.contains('\n') && !value.starts_with('\n')
}
fn blank_line(out: &mut String) {
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
}
fn write_comment(out: &mut String, comment: Option<&Comment<'_>>) -> Result<(), Error> {
let Some(comment) = comment else {
return Ok(());
};
for line in comment.text.split('\n') {
if let Some(c) = line
.chars()
.find(|c| c.is_control() || *c == '\u{2028}' || *c == '\u{2029}')
{
return Err(Error::Unrepresentable(Role::Comment, c));
}
if line.is_empty() {
out.push_str("#\n");
} else {
out.push_str("# ");
out.push_str(line);
out.push('\n');
}
}
Ok(())
}
fn write_meta(out: &mut String, meta: &Meta<'_>, style: &Style) -> Result<(), Error> {
if meta.name.is_empty() {
return Err(Error::Empty(Role::PropertyName));
}
if let Some(c) = meta
.name
.chars()
.find(|c| !crate::model::is_id_char(*c) && *c != '.')
{
return Err(Error::Unrepresentable(Role::PropertyName, c));
}
out.push('@');
out.push_str(&meta.name);
match &meta.value {
Some(value) if !value.is_empty() => {
out.push(' ');
write_value(out, value, style)?;
}
_ => {}
}
out.push('\n');
Ok(())
}
fn write_value(out: &mut String, value: &str, style: &Style) -> Result<(), Error> {
let wrap = style
.wrap_over
.filter(|over| value.len() > *over && !value.contains('\n'))
.map(|_| style.wrap_at);
let mut lines = Lines {
col: out.len() - out.rfind('\n').map_or(0, |i| i + 1),
line_has_content: false,
indent: style.indent,
wrap,
out,
};
let mut word = String::new();
let mut depth = 0usize;
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
let at_line_start = !lines.line_has_content && word.is_empty();
match c {
'\n' => {
if at_line_start || matches!(chars.peek(), None | Some('\n')) {
word.push_str("\\n");
} else {
lines.word(&word);
word.clear();
lines.hard_break();
}
}
'\\' => {
let Some(next) = chars.next() else {
return Err(Error::TrailingBackslash);
};
if !matches!(next, '\\' | '{' | '|' | '}') {
return Err(Error::LoneBackslash(next));
}
word.push('\\');
word.push(next);
}
' ' if at_line_start => word.push_str("\\ "),
'\t' if at_line_start => word.push_str("\\t"),
'\t' => word.push_str("\\t"),
'\r' => word.push_str("\\r"),
'\u{2028}' | '\u{2029}' => push_hex(&mut word, c),
c if c.is_control() => push_hex(&mut word, c),
c => {
if c == '{' {
depth += 1;
} else if c == '}' {
depth = depth.saturating_sub(1);
}
word.push(c);
if c == ' ' && depth == 0 && !matches!(chars.peek(), None | Some(' ')) {
lines.word(&word);
word.clear();
}
}
}
}
lines.word(&word);
Ok(())
}
struct Lines<'o> {
out: &'o mut String,
col: usize,
line_has_content: bool,
indent: &'static str,
wrap: Option<usize>,
}
impl Lines<'_> {
fn word(&mut self, word: &str) {
if word.is_empty() {
return;
}
if let Some(wrap) = self.wrap
&& self.line_has_content
&& self.col + word.len() > wrap
{
self.out.push_str("\\\n");
self.out.push_str(self.indent);
self.col = self.indent.len();
}
self.out.push_str(word);
self.col += word.len();
self.line_has_content = true;
}
fn hard_break(&mut self) {
self.out.push('\n');
self.out.push_str(self.indent);
self.col = self.indent.len();
self.line_has_content = false;
}
}
fn push_hex(out: &mut String, c: char) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let u = c as u32;
let (prefix, digits) = if u < 0x100 { ("\\x", 2) } else { ("\\u", 4) };
out.push_str(prefix);
for i in (0..digits).rev() {
let nibble = (u >> (4 * i)) & 0xF;
out.push(char::from(HEX[nibble as usize]));
}
}