use anyhow::{Context, Result, bail};
use std::ops::Range;
use yaml_serde::Value;
use yamlpatch::{Op, Patch, apply_yaml_patches};
use yamlpath::{Document as Parsed, Route, route};
const DEFAULT_INDENT: usize = 2;
pub struct Document {
source: String,
}
impl Document {
#[must_use]
pub fn new(source: String) -> Self {
Self { source }
}
#[must_use]
pub fn into_source(self) -> String {
self.source
}
fn parsed(&self) -> Result<Parsed> {
Parsed::new(self.source.clone()).context("this ledger is not YAML the parser can follow")
}
pub fn ids(&self, section: &str) -> Result<Vec<String>> {
let parsed = self.parsed()?;
let mut ids = Vec::new();
let mut index = 0;
while let Ok(Some(feature)) = parsed.query_exact(&route!(section, index)) {
if let Some(id) = id_of(parsed.extract(&feature)) {
ids.push(id.to_owned());
}
index += 1;
}
Ok(ids)
}
pub fn move_to_front(&mut self, section: &str, id: &str) -> Result<()> {
if self.ids(section)?.first().is_some_and(|first| first == id) {
return Ok(());
}
let row = self.cut(section, id)?;
self.paste_front(section, &row)
}
pub fn move_between(
&mut self,
from: &str,
to: &str,
id: &str,
drop: &[&str],
add: &[(&str, Value)],
) -> Result<()> {
let row = self.cut(from, id)?;
let row = revise(&row, drop, add)?;
self.close_if_empty(from)?;
self.paste_front(to, &row)
}
pub fn append(&mut self, section: &str, value: &Value) -> Result<()> {
let rendered = yaml_serde::to_string(value).context("render the new row")?;
self.paste_back(section, &as_item(&rendered))
}
pub fn reorder_rows(&mut self, section: &str, ids: &[String]) -> Result<()> {
if self.ids(section)? == ids {
return Ok(());
}
let mut rows = Vec::with_capacity(ids.len());
for id in ids {
rows.push(self.cut(section, id)?);
}
for row in rows {
self.paste_back(section, &row)?;
}
Ok(())
}
pub fn reorder_sections(&mut self, order: &[&str]) -> Result<()> {
let mut blocks = Vec::with_capacity(order.len());
for section in order {
if let Some(span) = self.block_of(section)? {
blocks.push((*section, span));
}
}
if blocks.len() < 2 {
return Ok(());
}
let mut found: Vec<Range<usize>> = blocks.iter().map(|(_, span)| span.clone()).collect();
found.sort_by_key(|span| span.start);
let wanted: Vec<&str> = blocks.iter().map(|(section, _)| *section).collect();
let already: Vec<&str> = {
let mut named: Vec<(usize, &str)> = blocks
.iter()
.map(|(section, span)| (span.start, *section))
.collect();
named.sort_by_key(|(start, _)| *start);
named.into_iter().map(|(_, section)| section).collect()
};
if already == wanted {
return Ok(());
}
for pair in found.windows(2) {
if pair[0].end > pair[1].start {
bail!("these lists overlap, which is not a file this can reorder");
}
if !self.source[pair[0].end..pair[1].start].trim().is_empty() {
bail!(
"something between these lists belongs to neither; move it above a key or out of the way"
);
}
}
let gaps: Vec<String> = found
.windows(2)
.map(|pair| self.source[pair[0].end..pair[1].start].to_owned())
.collect();
let mut rebuilt = String::new();
for (at, (_, span)) in blocks.iter().enumerate() {
rebuilt.push_str(self.source[span.clone()].trim_end());
if let Some(gap) = gaps.get(at) {
rebuilt.push_str(gap);
}
}
let (from, to) = (found[0].start, found[found.len() - 1].end);
self.source.replace_range(from..to, &rebuilt);
Ok(())
}
pub fn set_indent(&mut self, section: &str, indent: usize) -> Result<()> {
loop {
let Some(row) = self
.rows(section)?
.into_iter()
.find(|row| self.depth_of(row.start) != indent)
else {
return Ok(());
};
let depth = self.depth_of(row.start);
let text = self.source[row.clone()].to_owned();
let (body, tail) = text.split_at(text.trim_end().len());
let moved = indent_by(&dedent(body, depth), indent);
self.source
.replace_range(row.start..row.end, &format!("{moved}{tail}"));
}
}
fn block_of(&self, section: &str) -> Result<Option<Range<usize>>> {
if self.parsed()?.query_key_only(&route!(section)).is_err() {
return Ok(None);
}
let (key_start, key_line_end) = self.key_line(section)?;
let from = self.line_holding(key_start);
let end = self
.rows(section)?
.last()
.map_or(key_line_end, |last| last.end);
Ok(Some(from..end.max(key_line_end)))
}
fn depth_of(&self, offset: usize) -> usize {
let line = &self.source[offset..];
line.len() - line.trim_start().len()
}
pub fn set(&mut self, key: &str, value: Value) -> Result<()> {
self.patch(&[Patch {
route: route!(key),
operation: Op::Replace(value),
}])
}
pub fn rewrite_blockers(&mut self, section: &str, row: &str, kept: &[String]) -> Result<()> {
let index = self.position_of(section, row)?;
let route: Route = route!(section, index, "blocked_by");
let span = {
let parsed = self.parsed()?;
let feature = parsed
.query_exact(&route)?
.with_context(|| format!("{row} has no blocked_by to rewrite"))?;
let (from, to) = feature.location.byte_span;
from..to
};
let written = self.rewrite_list(&span, kept);
self.source.replace_range(span, &written);
Ok(())
}
fn rewrite_list(&self, span: &Range<usize>, kept: &[String]) -> String {
let was = &self.source[span.clone()];
if !was.trim_start().starts_with('-') {
return format!("[{}]", kept.join(", "));
}
if kept.is_empty() {
return "[]".to_owned();
}
let indent = self.source[..span.start]
.rfind('\n')
.map_or(0, |line| span.start - line - 1);
let pad = " ".repeat(indent);
kept.iter()
.enumerate()
.map(|(at, blocker)| {
if at == 0 {
format!("- {blocker}")
} else {
format!("\n{pad}- {blocker}")
}
})
.collect()
}
fn patch(&mut self, patches: &[Patch]) -> Result<()> {
let parsed = self.parsed()?;
let edited = apply_yaml_patches(&parsed, patches).context("edit this ledger")?;
edited.source().clone_into(&mut self.source);
Ok(())
}
fn cut(&mut self, section: &str, id: &str) -> Result<String> {
let index = self.position_of(section, id)?;
let rows = self.rows(section)?;
let span = rows[index].clone();
let indent = self.indent_of(section)?;
let row = dedent(self.source[span.clone()].trim_end(), indent);
let last = index + 1 == rows.len();
let from = if last {
self.start_of_blank_run(span.start)
} else {
span.start
};
let to = if last && self.source[span.end..].starts_with('\n') {
span.end + 1
} else {
span.end
};
self.source.replace_range(from..to, "");
Ok(row)
}
fn paste_front(&mut self, section: &str, row: &str) -> Result<()> {
self.open(section)?;
let crowded = !self.ids(section)?.is_empty();
let indent = self.indent_of(section)?;
let at = self.front_of(section)?;
let block = if crowded {
format!("{}\n\n", indent_by(row, indent))
} else {
format!("{}\n", indent_by(row, indent))
};
self.source.insert_str(at, &block);
Ok(())
}
fn paste_back(&mut self, section: &str, row: &str) -> Result<()> {
self.open(section)?;
let indent = self.indent_of(section)?;
let rows = self.rows(section)?;
let at = match rows.last() {
Some(last) => self.source[last.end..]
.find('\n')
.map_or(self.source.len(), |at| last.end + at + 1),
None => self.front_of(section)?,
};
let block = if rows.is_empty() {
format!("{}\n", indent_by(row, indent))
} else {
format!("\n{}\n", indent_by(row, indent))
};
self.source.insert_str(at, &block);
Ok(())
}
fn rows(&self, section: &str) -> Result<Vec<Range<usize>>> {
let parsed = self.parsed()?;
let mut bounds = Vec::new();
let mut index = 0;
while let Ok(Some(feature)) = parsed.query_exact(&route!(section, index)) {
let (content, end) = feature.location.byte_span;
bounds.push((self.line_holding(content), end));
index += 1;
}
let last = bounds
.last()
.map(|(_, end)| self.without_trailing_remarks(*end));
Ok(bounds
.iter()
.enumerate()
.map(|(at, (start, _))| {
let end = bounds
.get(at + 1)
.map_or(last.unwrap_or(*start), |(next, _)| *next);
*start..end
})
.collect())
}
fn position_of(&self, section: &str, id: &str) -> Result<usize> {
self.ids(section)?
.iter()
.position(|carried| carried == id)
.with_context(|| format!("{id} is not in {section}"))
}
fn line_holding(&self, offset: usize) -> usize {
let mut start = self.source[..offset].rfind('\n').map_or(0, |at| at + 1);
while let Some(above) = self.source[..start.saturating_sub(1)].rfind('\n') {
if !self.source[above + 1..start].trim_start().starts_with('#') {
break;
}
start = above + 1;
}
start
}
fn start_of_blank_run(&self, offset: usize) -> usize {
let mut start = offset;
while start > 0 {
let line_start = self.source[..start - 1].rfind('\n').map_or(0, |at| at + 1);
if !self.source[line_start..start - 1].trim().is_empty() {
return start;
}
start = line_start;
}
start
}
fn without_trailing_remarks(&self, end: usize) -> usize {
let mut end = end;
loop {
let line_start = self.source[..end].rfind('\n').map_or(0, |at| at + 1);
let line = self.source[line_start..end].trim_start();
if line_start == 0 || !(line.is_empty() || line.starts_with('#')) {
return end;
}
end = line_start.saturating_sub(1);
}
}
fn indent_of(&self, section: &str) -> Result<usize> {
let Some(first) = self.rows(section)?.first().cloned() else {
return Ok(DEFAULT_INDENT);
};
let line = &self.source[first.start..];
Ok(line.len() - line.trim_start().len())
}
fn front_of(&self, section: &str) -> Result<usize> {
let after = self.key_span(section)?.1;
Ok(self.source[after..]
.find('\n')
.map_or(self.source.len(), |at| after + at + 1))
}
fn open(&mut self, section: &str) -> Result<()> {
let (from, line_end) = self.key_line(section)?;
if !self.source[from..line_end].trim_end().ends_with("[]") {
return Ok(());
}
self.source
.replace_range(from..line_end, &format!("{section}:"));
Ok(())
}
fn close_if_empty(&mut self, section: &str) -> Result<()> {
if !self.ids(section)?.is_empty() {
return Ok(());
}
let (from, line_end) = self.key_line(section)?;
self.source
.replace_range(from..line_end, &format!("{section}: []"));
Ok(())
}
fn key_span(&self, section: &str) -> Result<(usize, usize)> {
let parsed = self.parsed()?;
let key = parsed
.query_key_only(&route!(section))
.with_context(|| format!("{section}: is missing"))?;
Ok(key.location.byte_span)
}
fn key_line(&self, section: &str) -> Result<(usize, usize)> {
let (from, key_end) = self.key_span(section)?;
let line_end = self.source[key_end..]
.find('\n')
.map_or(self.source.len(), |at| key_end + at);
Ok((from, line_end))
}
}
fn revise(row: &str, drop: &[&str], add: &[(&str, Value)]) -> Result<String> {
let document = Parsed::new(row.to_owned()).context("read the row on its own")?;
let patches: Vec<Patch> = drop
.iter()
.filter(|key| document.query_exists(&route!(0, **key)))
.map(|key| Patch {
route: route!(0, *key),
operation: Op::Remove,
})
.collect();
let mut kept = if patches.is_empty() {
row.to_owned()
} else {
apply_yaml_patches(&document, &patches)
.context("revise the row")?
.source()
.to_owned()
};
for (key, value) in add {
kept = format!(
"{}\n{}",
kept.trim_end(),
indent_by(&render(key, value)?, 2)
);
}
Ok(kept.trim_end().to_owned())
}
fn render(key: &str, value: &Value) -> Result<String> {
let mut mapping = yaml_serde::Mapping::new();
mapping.insert(Value::from(key), value.clone());
let rendered =
yaml_serde::to_string(&Value::Mapping(mapping)).with_context(|| format!("render {key}"))?;
Ok(as_the_ledgers_write_lists(&rendered))
}
fn as_item(mapping: &str) -> String {
let written = as_the_ledgers_write_lists(mapping);
let mut lines = written.lines();
let Some(first) = lines.next() else {
return String::new();
};
let rest = lines.fold(String::new(), |mut item, line| {
item.push_str("\n ");
item.push_str(line);
item
});
format!("- {first}{rest}")
}
fn as_the_ledgers_write_lists(rendered: &str) -> String {
rendered
.trim_end()
.lines()
.map(|line| {
if line.trim_start().starts_with("- ") {
format!(" {line}")
} else {
line.to_owned()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn id_of(text: &str) -> Option<&str> {
text.lines().find_map(|line| {
line.trim_start()
.trim_start_matches("- ")
.strip_prefix("id:")
.map(|value| value.trim().trim_matches(['"', '\'']))
})
}
fn dedent(text: &str, spaces: usize) -> String {
text.lines()
.map(|line| line.get(spaces..).unwrap_or(line.trim_start()))
.collect::<Vec<_>>()
.join("\n")
}
fn indent_by(text: &str, spaces: usize) -> String {
let pad = " ".repeat(spaces);
text.lines()
.map(|line| {
if line.trim().is_empty() {
String::new()
} else {
format!("{pad}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn must_still_parse(source: &str) -> Result<()> {
if serde_yml::from_str::<serde_yml::Value>(source).is_err() {
bail!("this edit would leave a file that is not valid YAML");
}
Ok(())
}