use crate::error::Result;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::collections::VecDeque;
use indexmap::IndexMap;
#[derive(Debug, Clone)]
pub struct Document {
source: String,
trailing_newline: bool,
items: Vec<Item>,
}
#[derive(Debug, Clone)]
enum Item {
Blank,
Comment(String),
Entry(EntryNode),
}
#[derive(Debug, Clone)]
struct EntryNode {
key: String,
header: String,
child_lines: Vec<String>,
children: Option<Vec<Item>>,
}
pub fn load_document(input: &str) -> Result<Document> {
let trailing_newline = input.ends_with('\n');
let lines: Vec<&str> = input.lines().collect();
let level = base_indent(&lines).unwrap_or(0);
let items = parse_block(&lines, level);
Ok(Document {
source: input.to_string(),
trailing_newline,
items,
})
}
impl Document {
pub fn deserialize<T: DeserializeOwned>(&self) -> Result<T> {
crate::from_str(&self.source)
}
pub fn reserialize<T: Serialize>(&self, value: &T) -> Result<String> {
let new_text = crate::to_string(value)?;
let new_lines: Vec<&str> = new_text.lines().collect();
let new_level = base_indent(&new_lines).unwrap_or(0);
let new_items = parse_block(&new_lines, new_level);
let merged = merge_block(&self.items, &new_items);
let rendered = render_items(&merged);
let mut out = rendered.join("\n");
if self.trailing_newline && !out.is_empty() {
out.push('\n');
}
Ok(out)
}
pub fn render(&self) -> String {
let mut out = render_items(&self.items).join("\n");
if self.trailing_newline && !out.is_empty() {
out.push('\n');
}
out
}
}
pub fn update_str<T: Serialize>(original: &str, value: &T) -> Result<String> {
load_document(original)?.reserialize(value)
}
pub fn edit_str<T, F>(original: &str, edit: F) -> Result<String>
where
T: DeserializeOwned + Serialize,
F: FnOnce(&mut T),
{
let doc = load_document(original)?;
let mut value: T = doc.deserialize()?;
edit(&mut value);
doc.reserialize(&value)
}
fn indent_of(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn base_indent(lines: &[&str]) -> Option<usize> {
lines
.iter()
.find(|l| !l.trim().is_empty())
.map(|l| indent_of(l))
}
fn next_nonblank_deeper(lines: &[&str], start: usize, level: usize) -> bool {
lines[start..]
.iter()
.find(|l| !l.trim().is_empty())
.is_some_and(|l| indent_of(l) > level)
}
fn split_key(header_trimmed: &str) -> String {
match header_trimmed.find('=') {
Some(p) => header_trimmed[..p].trim().to_string(),
None => header_trimmed.trim().to_string(),
}
}
fn inline_value(header: &str) -> &str {
match header.find('=') {
Some(p) => header[p + 1..].trim(),
None => "",
}
}
fn parse_block(lines: &[&str], level: usize) -> Vec<Item> {
let mut items = Vec::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.is_empty() {
items.push(Item::Blank);
i += 1;
continue;
}
if trimmed.starts_with("/=") {
items.push(Item::Comment(line.to_string()));
i += 1;
continue;
}
let mut j = i + 1;
while j < lines.len() {
let l2 = lines[j];
if l2.trim().is_empty() {
if next_nonblank_deeper(lines, j + 1, level) {
j += 1;
continue;
}
break;
}
if indent_of(l2) > level {
j += 1;
} else {
break;
}
}
let child_lines: Vec<String> = lines[i + 1..j].iter().map(|s| s.to_string()).collect();
let children = parse_children(&child_lines);
items.push(Item::Entry(EntryNode {
key: split_key(trimmed),
header: line.to_string(),
child_lines,
children,
}));
i = j;
}
items
}
fn parse_children(child_lines: &[String]) -> Option<Vec<Item>> {
let refs: Vec<&str> = child_lines.iter().map(String::as_str).collect();
let level = base_indent(&refs)?;
let items = parse_block(&refs, level);
let is_block = items
.iter()
.any(|it| matches!(it, Item::Entry(e) if e.header.contains('=')));
is_block.then_some(items)
}
fn merge_block(base: &[Item], new: &[Item]) -> Vec<Item> {
let mut queues: IndexMap<String, VecDeque<usize>> = IndexMap::new();
for (idx, it) in new.iter().enumerate() {
if let Item::Entry(e) = it {
queues.entry(e.key.clone()).or_default().push_back(idx);
}
}
let mut consumed = vec![false; new.len()];
let mut result: Vec<Item> = Vec::new();
let mut trivia: Vec<Item> = Vec::new();
for item in base {
match item {
Item::Blank | Item::Comment(_) => trivia.push(item.clone()),
Item::Entry(b) => {
let matched = queues.get_mut(&b.key).and_then(VecDeque::pop_front);
match matched {
Some(ni) => {
consumed[ni] = true;
result.append(&mut trivia);
let n = match &new[ni] {
Item::Entry(e) => e,
_ => unreachable!("queues only index entries"),
};
result.push(Item::Entry(merge_entry(b, n)));
}
None => {
let mut kept = trivia_on_removal(&trivia);
result.append(&mut kept);
trivia.clear();
}
}
}
}
}
result.append(&mut trivia);
for (idx, it) in new.iter().enumerate() {
if !consumed[idx] {
if let Item::Entry(e) = it {
if inline_value(&e.header).is_empty() && e.child_lines.is_empty() {
continue;
}
result.push(it.clone());
}
}
}
result
}
fn merge_entry(b: &EntryNode, n: &EntryNode) -> EntryNode {
let b_block = b.children.is_some() && b.header.contains('=');
let n_block = n.children.is_some();
if b_block && n_block {
let merged = merge_block(b.children.as_ref().unwrap(), n.children.as_ref().unwrap());
let child_lines = render_items(&merged);
return EntryNode {
key: b.key.clone(),
header: b.header.clone(),
child_lines,
children: Some(merged),
};
}
if entries_value_equal(b, n) {
b.clone()
} else {
n.clone()
}
}
fn entries_value_equal(b: &EntryNode, n: &EntryNode) -> bool {
inline_value(&b.header) == inline_value(&n.header)
&& b.child_lines.len() == n.child_lines.len()
&& b.child_lines
.iter()
.zip(&n.child_lines)
.all(|(x, y)| x.trim_end() == y.trim_end())
}
fn trivia_on_removal(trivia: &[Item]) -> Vec<Item> {
match trivia.iter().rposition(|t| matches!(t, Item::Blank)) {
Some(idx) => trivia[..=idx].to_vec(),
None => Vec::new(),
}
}
fn render_items(items: &[Item]) -> Vec<String> {
let mut out = Vec::new();
for it in items {
match it {
Item::Blank => out.push(String::new()),
Item::Comment(line) => out.push(line.clone()),
Item::Entry(e) => {
out.push(e.header.clone());
out.extend(e.child_lines.iter().cloned());
}
}
}
out
}
impl std::fmt::Display for Document {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render())
}
}