mod notes;
mod rows;
mod text;
mod value;
use anyhow::{Context, Result, bail};
use std::ops::Range;
use text::{dedent, indent_by};
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 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),
}])
}
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(())
}
pub fn replace_row_value(
&mut self,
section: &str,
index: usize,
key: &str,
value: &Value,
) -> Result<()> {
self.replace_value(&route!(section, index, key), key, value)
}
fn replace_value(&mut self, route: &Route, key: &str, value: &Value) -> Result<()> {
let span = if matches!(value, Value::Sequence(_)) {
let parsed = self.parsed()?;
let feature = parsed
.query_exact(route)?
.context("the value to replace is missing")?;
let (from, to) = feature.location.byte_span;
Some(from..to)
} else {
None
};
if key == "notes"
&& let Some(span) = span
{
let rendered = crate::document::notes::sequence_like(&self.source, &span, value)?;
self.source.replace_range(span, &rendered);
return Ok(());
}
if let Some(span) = span {
let written = value::sequence_like(&self.source, &span, value)?;
self.source.replace_range(span, &written);
return Ok(());
}
self.patch(&[Patch {
route: route.clone(),
operation: Op::Replace(value.clone()),
}])
}
pub fn remove_row_key(&mut self, section: &str, index: usize, key: &str) -> Result<()> {
self.patch(&[Patch {
route: route!(section, index, key),
operation: Op::Remove,
}])
}
pub fn row_key_shape(
&self,
section: &str,
index: usize,
key: &str,
) -> Result<Option<KeyShape>> {
let parsed = self.parsed()?;
let Ok(Some(feature)) = parsed.query_exact(&route!(section, index, key)) else {
return Ok(None);
};
let text = parsed.extract(&feature).trim();
if text.starts_with('[') || text.starts_with('-') {
return Ok(Some(KeyShape::Sequence));
}
Ok(Some(KeyShape::Scalar))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KeyShape {
Scalar,
Sequence,
}
fn revise(row: &str, drop: &[&str], add: &[(&str, Value)]) -> Result<String> {
let mut changes = Vec::with_capacity(drop.len() + add.len());
changes.extend(drop.iter().map(|key| (*key, None)));
changes.extend(add.iter().map(|(key, value)| (*key, Some(value.clone()))));
revise_fields(row, &changes)
}
pub fn revise_fields(row: &str, changes: &[(&str, Option<Value>)]) -> Result<String> {
let mut kept = row.to_owned();
for (key, value) in changes {
let document = Parsed::new(kept.clone()).context("read the row on its own")?;
let exists = document.query_exists(&route!(0, *key));
match (exists, value) {
(true, None) => {
let mut document = Document::new(kept);
document.patch(&[Patch {
route: route!(0, *key),
operation: Op::Remove,
}])?;
kept = document.into_source();
}
(true, Some(value)) => {
let mut document = Document::new(kept);
document.replace_value(&route!(0, *key), key, value)?;
kept = document.into_source();
}
(false, Some(value)) => {
kept = format!(
"{}\n{}",
kept.trim_end(),
indent_by(&value::mapping_entry(key, value)?, 2)
);
}
(false, None) => {}
}
}
Ok(kept.trim_end().to_owned())
}
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(())
}