use super::output::{TxDocMutation, TxExecResult, TxLintResult, TxReadResult, TxSearchResult};
use super::validate::op_label;
use crate::ops::doc::{
FileFormat, MutationResult, apply_doc_mutation, detect_format, parse_doc,
serialize_value_preserving,
};
use crate::plan::{Operation, Plan};
use crate::tx::context::EngineContext;
use crate::write::apply_policy;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
mod file_ops;
mod policy;
#[cfg(test)]
mod tests;
pub(crate) use file_ops::execute_file_op;
pub(crate) use policy::build_write_policy;
pub(crate) fn read_file_content<'a>(
pending: &'a mut HashMap<PathBuf, (String, String)>,
existed_before: &mut HashSet<PathBuf>,
path: &Path,
) -> anyhow::Result<&'a str> {
match pending.entry(path.to_path_buf()) {
Entry::Occupied(entry) => Ok(&entry.into_mut().1),
Entry::Vacant(entry) => {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?;
existed_before.insert(path.to_path_buf());
Ok(&entry.insert((content.clone(), content)).1)
}
}
}
pub(crate) fn read_and_probe(
pending: &mut HashMap<PathBuf, (String, String)>,
existed_before: &mut HashSet<PathBuf>,
path: &Path,
) -> anyhow::Result<bool> {
if pending.contains_key(path) {
return Ok(true); }
let bytes = std::fs::read(path)
.map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?;
if crate::files::is_binary(&bytes) {
return Ok(false);
}
let content = match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return Ok(false),
};
existed_before.insert(path.to_path_buf());
pending.insert(path.to_path_buf(), (content.clone(), content));
Ok(true)
}
pub(crate) fn update_file_content(
pending: &mut HashMap<PathBuf, (String, String)>,
deletions: &mut HashSet<PathBuf>,
write_targets: &mut HashSet<PathBuf>,
path: &Path,
new_content: String,
) {
deletions.remove(path);
write_targets.insert(path.to_path_buf());
if let Some((_, current)) = pending.get_mut(path) {
*current = new_content;
} else {
pending.insert(path.to_path_buf(), (String::new(), new_content));
}
}
pub(crate) fn mark_write_target(write_targets: &mut HashSet<PathBuf>, path: &Path) {
write_targets.insert(path.to_path_buf());
}
pub(crate) fn flush_doc_cache_entry(
pending: &mut HashMap<PathBuf, (String, String)>,
deletions: &mut HashSet<PathBuf>,
write_targets: &mut HashSet<PathBuf>,
path: PathBuf,
cached: CachedDoc,
) -> anyhow::Result<()> {
let new_content = serialize_value_preserving(
&cached.original_text,
&cached.old_value,
&cached.value,
&cached.format,
)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
update_file_content(pending, deletions, write_targets, &path, new_content);
Ok(())
}
pub(crate) fn flush_doc_cache(
pending: &mut HashMap<PathBuf, (String, String)>,
deletions: &mut HashSet<PathBuf>,
write_targets: &mut HashSet<PathBuf>,
doc_cache: &mut HashMap<PathBuf, CachedDoc>,
) -> anyhow::Result<()> {
for (path, cached) in doc_cache.drain() {
flush_doc_cache_entry(pending, deletions, write_targets, path, cached)?;
}
Ok(())
}
pub(crate) fn path_err<E: std::fmt::Display>(path: &str) -> impl FnOnce(E) -> anyhow::Error + '_ {
move |e| anyhow::anyhow!("{path}: {e}")
}
pub(crate) fn get_doc_root<'a>(
pending: &mut HashMap<PathBuf, (String, String)>,
existed_before: &mut HashSet<PathBuf>,
doc_cache: &'a mut HashMap<PathBuf, CachedDoc>,
path: &str,
cwd: &Path,
) -> anyhow::Result<&'a mut serde_json::Value> {
let file_path = cwd.join(path);
if !doc_cache.contains_key(&file_path) {
let content = read_file_content(pending, existed_before, &file_path)?;
let format = detect_format(path).map_err(path_err(path))?;
let root = parse_doc(content, &format).map_err(path_err(path))?;
let old_value = root.clone();
let original_text = content.to_owned();
doc_cache.insert(
file_path.clone(),
CachedDoc {
value: root,
format,
original_text,
old_value,
},
);
}
Ok(&mut doc_cache
.get_mut(&file_path)
.expect("just inserted into doc_cache")
.value)
}
pub(crate) struct CachedDoc {
pub(crate) value: serde_json::Value,
pub(crate) format: FileFormat,
pub(crate) original_text: String,
pub(crate) old_value: serde_json::Value,
}
pub(crate) struct TxState<'a> {
pub(crate) pending: &'a mut HashMap<PathBuf, (String, String)>,
pub(crate) deletions: &'a mut HashSet<PathBuf>,
pub(crate) existed_before: &'a mut HashSet<PathBuf>,
pub(crate) doc_cache: &'a mut HashMap<PathBuf, CachedDoc>,
pub(crate) tx_reads: &'a mut Vec<TxReadResult>,
pub(crate) tx_searches: &'a mut Vec<TxSearchResult>,
pub(crate) tx_lints: &'a mut Vec<TxLintResult>,
pub(crate) tx_mutations: &'a mut Vec<TxDocMutation>,
pub(crate) write_targets: &'a mut HashSet<PathBuf>,
pub(crate) replace_hint: Option<String>,
pub(crate) cwd: &'a Path,
pub(crate) quiet: bool,
pub(crate) structured: bool,
pub(crate) guard: Option<&'a crate::containment::PathGuard>,
}
#[cfg(test)]
pub(crate) struct TxStateFixture {
pub pending: HashMap<PathBuf, (String, String)>,
pub deletions: HashSet<PathBuf>,
pub existed_before: HashSet<PathBuf>,
pub doc_cache: HashMap<PathBuf, CachedDoc>,
pub reads: Vec<TxReadResult>,
pub searches: Vec<TxSearchResult>,
pub lints: Vec<TxLintResult>,
pub mutations: Vec<TxDocMutation>,
pub write_targets: HashSet<PathBuf>,
}
#[cfg(test)]
impl TxStateFixture {
pub fn new() -> Self {
Self {
pending: HashMap::new(),
deletions: HashSet::new(),
existed_before: HashSet::new(),
doc_cache: HashMap::new(),
reads: Vec::new(),
searches: Vec::new(),
lints: Vec::new(),
mutations: Vec::new(),
write_targets: HashSet::new(),
}
}
pub fn state<'a>(&'a mut self, cwd: &'a Path) -> TxState<'a> {
TxState {
pending: &mut self.pending,
deletions: &mut self.deletions,
existed_before: &mut self.existed_before,
doc_cache: &mut self.doc_cache,
tx_reads: &mut self.reads,
tx_searches: &mut self.searches,
tx_lints: &mut self.lints,
tx_mutations: &mut self.mutations,
write_targets: &mut self.write_targets,
replace_hint: None,
cwd,
quiet: true,
structured: false,
guard: None,
}
}
}
pub(crate) use super::replace_op::execute_replace_op;
pub(crate) fn execute_read_op(
path: &str,
lines: &Option<String>,
tx: &mut TxState<'_>,
) -> anyhow::Result<()> {
let file_path = tx.cwd.join(path);
read_file_content(tx.pending, tx.existed_before, &file_path)?;
let content = &tx.pending[&file_path].1;
if lines.is_none() {
let total_lines = content.lines().count();
let start_line = if total_lines == 0 { 0 } else { 1 };
tx.tx_reads.push(TxReadResult {
path: path.to_string(),
content: content.clone(),
start_line,
end_line: total_lines,
total_lines,
});
return Ok(());
}
let selected = {
let spec = lines.as_ref().expect("checked is_none above");
let range = crate::ops::read::parse_line_range(spec)?;
crate::ops::read::select_lines(content, range)
};
tx.tx_reads.push(TxReadResult {
path: path.to_string(),
content: selected.content,
start_line: selected.start_line,
end_line: selected.end_line,
total_lines: selected.total_lines,
});
Ok(())
}
pub(crate) use super::search_op::execute_search_op;
fn op_needs_doc_flush(op: &Operation) -> bool {
op.needs_doc_flush()
}
pub(crate) fn execute_doc_op(op: &Operation, tx: &mut TxState<'_>) -> anyhow::Result<()> {
let (path, mutation) = crate::plan::op_to_doc_mutation(op)
.ok_or_else(|| anyhow::anyhow!("execute_doc_op called with non-doc operation"))?;
let root = get_doc_root(tx.pending, tx.existed_before, tx.doc_cache, path, tx.cwd)
.map_err(path_err(path))?;
let strict_no_match = matches!(op, Operation::DocUpdate { .. });
let is_delete = matches!(
op,
Operation::DocDelete { .. } | Operation::DocDeleteWhere { .. }
);
let op_name = match op {
Operation::DocDelete { .. } => "doc.delete",
Operation::DocDeleteWhere { .. } => "doc.delete_where",
_ => "",
};
match apply_doc_mutation(root, mutation).map_err(path_err(path))? {
MutationResult::Applied | MutationResult::AlreadyExists => Ok(()),
MutationResult::Removed(count) => {
if is_delete {
tx.tx_mutations.push(TxDocMutation {
path: path.to_string(),
op: op_name.to_string(),
changed: true,
removed: count,
});
}
Ok(())
}
MutationResult::NoMatch if strict_no_match => {
let label = op_label(op);
Err(crate::exit::NoMatchError {
msg: format!("{path}: {label} matched nothing"),
})?
}
MutationResult::NoMatch => {
if is_delete {
tx.tx_mutations.push(TxDocMutation {
path: path.to_string(),
op: op_name.to_string(),
changed: false,
removed: 0,
});
}
Ok(())
}
MutationResult::TypeError(msg) => {
anyhow::bail!("{path}: {msg}");
}
}
}
pub(crate) fn execute_operation(op: &Operation, tx: &mut TxState<'_>) -> anyhow::Result<usize> {
match op {
Operation::Replace { .. } => {
return execute_replace_op(op, tx);
}
Operation::DocSet { .. }
| Operation::DocDelete { .. }
| Operation::DocMerge { .. }
| Operation::DocAppend { .. }
| Operation::DocPrepend { .. }
| Operation::DocUpdate { .. }
| Operation::DocMove { .. }
| Operation::DocEnsure { .. }
| Operation::DocDeleteWhere { .. } => {
execute_doc_op(op, tx)?;
}
Operation::MdReplaceSection { .. }
| Operation::MdInsertAfterHeading { .. }
| Operation::MdInsertBeforeHeading { .. }
| Operation::MdUpsertBullet { .. }
| Operation::MdTableAppend { .. }
| Operation::MdMoveSection { .. }
| Operation::MdDedupeHeadings { .. }
| Operation::MdLintAgents { .. } => {
return super::md_op::execute_md_op(op, tx);
}
Operation::TidyFix { .. } => {
return super::tidy_op::execute_tidy_op(op, tx);
}
Operation::FileAppend { .. }
| Operation::FilePrepend { .. }
| Operation::FileCreate { .. }
| Operation::FileDelete { .. }
| Operation::FileRename { .. } => {
return execute_file_op(op, tx);
}
Operation::PatchApply { .. } => {
return super::patch_op::execute_patch_op(op, tx);
}
Operation::Read { path, lines } => {
execute_read_op(path, lines, tx)?;
}
Operation::Search { .. } => {
execute_search_op(op, tx)?;
}
#[cfg(feature = "ast")]
Operation::AstRename { .. }
| Operation::AstReplace { .. }
| Operation::AstRewriteSignature { .. }
| Operation::AstInsert { .. }
| Operation::AstWrap { .. }
| Operation::AstImports { .. }
| Operation::AstReorder { .. }
| Operation::AstGroup { .. }
| Operation::AstMove { .. }
| Operation::AstExtractToFile { .. }
| Operation::AstSplit { .. } => {
return super::ast_op::execute_ast_op(op, tx);
}
}
Ok(0)
}
pub(crate) fn execute_and_collect(
plan: &Plan,
ctx: &EngineContext,
quiet: bool,
structured: bool,
guard: Option<&crate::containment::PathGuard>,
) -> anyhow::Result<TxExecResult> {
let cwd = ctx.cwd();
let mut pending: HashMap<PathBuf, (String, String)> = HashMap::new();
let mut deletions: HashSet<PathBuf> = HashSet::new();
let mut existed_before: HashSet<PathBuf> = HashSet::new();
let mut write_targets: HashSet<PathBuf> = HashSet::new();
let mut has_non_idempotent_replace = false;
let mut total_replace_matches = 0usize;
let mut tx_reads: Vec<TxReadResult> = Vec::new();
let mut tx_searches: Vec<TxSearchResult> = Vec::new();
let mut tx_lints: Vec<TxLintResult> = Vec::new();
let mut tx_mutations: Vec<TxDocMutation> = Vec::new();
let mut doc_cache: HashMap<PathBuf, CachedDoc> = HashMap::new();
let mut replace_hint: Option<String> = None;
if let Some(g) = guard {
for op in &plan.operations {
for p in op.declared_paths() {
g.check_path(&p)
.map_err(|e| anyhow::anyhow!("path rejected by workspace guard: {e}"))?;
}
}
}
crate::verbose!(
"tx: executing plan with {} operations",
plan.operations.len()
);
for (i, op) in plan.operations.iter().enumerate() {
crate::verbose!(
"tx: operation {}/{}: {}",
i + 1,
plan.operations.len(),
op_label(op)
);
if let Operation::Replace { if_exists, .. } = op
&& !if_exists
{
has_non_idempotent_replace = true;
}
if op_needs_doc_flush(op) {
flush_doc_cache(
&mut pending,
&mut deletions,
&mut write_targets,
&mut doc_cache,
)?;
}
let mut tx = TxState {
pending: &mut pending,
deletions: &mut deletions,
existed_before: &mut existed_before,
doc_cache: &mut doc_cache,
tx_reads: &mut tx_reads,
tx_searches: &mut tx_searches,
tx_lints: &mut tx_lints,
tx_mutations: &mut tx_mutations,
write_targets: &mut write_targets,
replace_hint: None,
cwd,
quiet,
structured,
guard,
};
match execute_operation(op, &mut tx) {
Ok(count) => {
crate::verbose!(
"tx: operation {} succeeded (replace_matches: {count})",
i + 1
);
if matches!(op, Operation::Replace { .. }) {
total_replace_matches += count;
}
if replace_hint.is_none() {
replace_hint = tx.replace_hint.take();
}
}
Err(e) => {
crate::verbose!("tx: operation {} failed: {e}", i + 1);
if let Some(detail) = e.chain().find_map(|c| {
c.downcast_ref::<crate::exit::NoMatchError>()
.map(|n| n.msg.clone())
}) {
return Err(crate::exit::NoMatchError {
msg: format!("operation {} ({}) failed: {detail}", i + 1, op_label(op)),
}
.into());
}
anyhow::bail!("operation {} ({}) failed: {e}", i + 1, op_label(op));
}
}
}
flush_doc_cache(
&mut pending,
&mut deletions,
&mut write_targets,
&mut doc_cache,
)?;
let mut changes: Vec<(PathBuf, String, String)> = Vec::new();
for (path, (original, current)) in &pending {
if !write_targets.contains(path) {
continue;
}
let write_policy = build_write_policy(plan, ctx, path)?;
let final_content = apply_policy(current, &write_policy);
let is_new_file = !existed_before.contains(path);
if *original != *final_content || is_new_file {
changes.push((path.clone(), original.clone(), final_content.into_owned()));
}
}
changes.sort_by(|a, b| a.0.cmp(&b.0));
let pending_deletions = deletions
.iter()
.filter(|p| !changes.iter().any(|(c, _, _)| c == *p))
.count();
let no_effective_changes = changes.is_empty() && pending_deletions == 0;
let replace_no_matches =
has_non_idempotent_replace && total_replace_matches == 0 && no_effective_changes;
Ok(TxExecResult {
changes,
deletions,
existed_before,
pending,
tx_reads,
tx_searches,
tx_lints,
tx_mutations,
no_effective_changes,
replace_no_matches,
replace_hint,
})
}