use super::output::{TxExecResult, TxLintResult, TxReadResult, TxSearchResult};
use super::validate::op_label;
use crate::cli::global::{EolMode, GlobalFlags};
use crate::ops::doc::{
FileFormat, MutationResult, apply_doc_mutation, detect_format, parse_doc,
serialize_value_preserving,
};
use crate::ops::md::{
dedupe_headings_in, insert_after_heading_in, insert_before_heading_in, move_section_in,
replace_section_in, table_append_for_tx, upsert_bullet_in,
};
use crate::ops::patch::{ApplyHunksOptions, ApplyHunksStatus, apply_patch_with_loader};
use crate::plan::{Operation, Plan};
use crate::write::{WritePolicy, apply_policy};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
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>,
path: &Path,
new_content: String,
) {
deletions.remove(path);
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 flush_doc_cache_entry(
pending: &mut HashMap<PathBuf, (String, String)>,
deletions: &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, &path, new_content);
Ok(())
}
pub(crate) fn flush_doc_cache(
pending: &mut HashMap<PathBuf, (String, String)>,
deletions: &mut HashSet<PathBuf>,
doc_cache: &mut HashMap<PathBuf, CachedDoc>,
) -> anyhow::Result<()> {
for (path, cached) in doc_cache.drain() {
flush_doc_cache_entry(pending, deletions, path, cached)?;
}
Ok(())
}
pub(crate) fn apply_md_heading_op(
tx: &mut TxState<'_>,
path: &str,
heading: &str,
extra: &str,
op: impl FnOnce(&str, &str, &str) -> Option<String>,
err_label: &str,
) -> anyhow::Result<()> {
let file_path = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &file_path)?;
let new_content = op(file_content, heading, extra)
.ok_or_else(|| anyhow::anyhow!("{err_label} not found: {heading}"))?;
update_file_content(tx.pending, tx.deletions, &file_path, new_content);
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 = match format {
FileFormat::Json => serde_json::Value::Null,
_ => 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) replace_hint: Option<String>,
pub(crate) cwd: &'a Path,
pub(crate) quiet: bool,
pub(crate) structured: bool,
}
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 {
matches!(
op,
Operation::Replace { .. }
| Operation::MdReplaceSection { .. }
| Operation::MdInsertAfterHeading { .. }
| Operation::MdInsertBeforeHeading { .. }
| Operation::MdUpsertBullet { .. }
| Operation::MdTableAppend { .. }
| Operation::MdMoveSection { .. }
| Operation::MdDedupeHeadings { .. }
| Operation::PatchApply { .. }
| Operation::FileAppend { .. }
| Operation::Read { .. }
| Operation::Search { .. }
| Operation::MdLintAgents { .. }
| Operation::TidyFix { .. }
) || {
#[cfg(feature = "ast")]
{
matches!(
op,
Operation::AstRename { .. } | Operation::AstReplace { .. }
)
}
#[cfg(not(feature = "ast"))]
{
false
}
}
}
pub(crate) fn execute_doc_op(op: &Operation, tx: &mut TxState<'_>) -> anyhow::Result<()> {
let (path, mutation) =
crate::plan::op_to_doc_mutation(op).expect("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 { .. });
match apply_doc_mutation(root, mutation).map_err(path_err(path))? {
MutationResult::Applied | MutationResult::AlreadyExists => Ok(()),
MutationResult::NoMatch if strict_no_match => {
let label = op_label(op);
anyhow::bail!("{path}: {label} matched nothing");
}
MutationResult::NoMatch => Ok(()),
MutationResult::TypeError(msg) => {
anyhow::bail!("{path}: {msg}");
}
}
}
pub(crate) fn execute_file_op(op: &Operation, tx: &mut TxState<'_>) -> anyhow::Result<usize> {
match op {
Operation::FileAppend { path, content } => {
let file_path = tx.cwd.join(path);
if !tx.deletions.contains(&file_path)
&& !file_path.exists()
&& !tx.pending.contains_key(&file_path)
{
anyhow::bail!("file does not exist: {path}");
}
let existing = read_file_content(tx.pending, tx.existed_before, &file_path)?;
let combined = crate::ops::file::append_content(existing, content);
update_file_content(tx.pending, tx.deletions, &file_path, combined);
}
Operation::FileCreate {
path,
content,
force,
} => {
let file_path = tx.cwd.join(path);
if file_path.exists() && !file_path.is_file() {
anyhow::bail!("target is not a file: {path}");
}
if force.unwrap_or(false) {
if tx.pending.contains_key(&file_path) || file_path.exists() {
let _ = read_file_content(tx.pending, tx.existed_before, &file_path)?;
}
update_file_content(tx.pending, tx.deletions, &file_path, content.clone());
} else {
let exists_in_tx =
tx.pending.contains_key(&file_path) && !tx.deletions.contains(&file_path);
if exists_in_tx || (!tx.deletions.contains(&file_path) && file_path.exists()) {
anyhow::bail!("file already exists: {path}");
}
update_file_content(tx.pending, tx.deletions, &file_path, content.clone());
}
}
Operation::FileDelete { path } => {
let file_path = tx.cwd.join(path);
if file_path.exists() && !file_path.is_file() {
anyhow::bail!("target is not a file: {path}");
}
let created_in_tx = match tx.pending.get(&file_path) {
Some((original, _)) => original.is_empty() && !file_path.exists(),
None => {
let _ = read_file_content(tx.pending, tx.existed_before, &file_path)?;
false
}
};
if created_in_tx {
tx.pending.remove(&file_path);
tx.deletions.remove(&file_path);
} else {
update_file_content(tx.pending, tx.deletions, &file_path, String::new());
tx.deletions.insert(file_path);
}
}
Operation::FileRename { from, to, force } => {
let src_path = tx.cwd.join(from);
let dst_path = tx.cwd.join(to);
if src_path.exists() && !src_path.is_file() {
anyhow::bail!("source is not a file: {from}");
}
if dst_path.exists() && !dst_path.is_file() {
anyhow::bail!("destination is not a file: {to}");
}
if src_path == dst_path
|| matches!(
(src_path.canonicalize(), dst_path.canonicalize()),
(Ok(ref s), Ok(ref d)) if s == d
)
{
return Ok(0);
}
let content = read_file_content(tx.pending, tx.existed_before, &src_path)?.to_string();
if !force {
let dst_exists = (tx.pending.contains_key(&dst_path)
&& !tx.deletions.contains(&dst_path))
|| (!tx.deletions.contains(&dst_path) && dst_path.exists());
if dst_exists {
anyhow::bail!("destination already exists: {to}");
}
}
if *force && !tx.pending.contains_key(&dst_path) && dst_path.exists() {
let _ = read_file_content(tx.pending, tx.existed_before, &dst_path)?;
}
update_file_content(tx.pending, tx.deletions, &dst_path, content);
let created_in_tx = match tx.pending.get(&src_path) {
Some((original, _)) => original.is_empty() && !src_path.exists(),
None => false,
};
if created_in_tx {
tx.pending.remove(&src_path);
tx.deletions.remove(&src_path);
} else {
update_file_content(tx.pending, tx.deletions, &src_path, String::new());
tx.deletions.insert(src_path);
}
}
_ => unreachable!("execute_file_op called with non-file operation"),
}
Ok(0)
}
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 {
path,
heading,
content,
} => {
apply_md_heading_op(tx, path, heading, content, replace_section_in, "heading")?;
}
Operation::MdInsertAfterHeading {
path,
heading,
content,
} => {
apply_md_heading_op(
tx,
path,
heading,
content,
insert_after_heading_in,
"heading",
)?;
}
Operation::MdInsertBeforeHeading {
path,
heading,
content,
} => {
apply_md_heading_op(
tx,
path,
heading,
content,
insert_before_heading_in,
"heading",
)?;
}
Operation::MdUpsertBullet {
path,
heading,
bullet,
} => {
apply_md_heading_op(tx, path, heading, bullet, upsert_bullet_in, "heading")?;
}
Operation::MdTableAppend { path, heading, row } => {
apply_md_heading_op(tx, path, heading, row, table_append_for_tx, "heading/table")?;
}
Operation::MdMoveSection {
path,
heading,
to,
before,
after,
} => {
let position = match (before.as_deref(), after.as_deref()) {
(Some(b), None) => ("before", b),
(None, Some(a)) => ("after", a),
_ => anyhow::bail!("md.move_section requires exactly one of 'before' or 'after'"),
};
let dest_path_str = to.as_deref().unwrap_or(path.as_str());
let source_path = tx.cwd.join(path);
let dest_path = tx.cwd.join(dest_path_str);
let same_file = to.is_none()
|| matches!(
(source_path.canonicalize(), dest_path.canonicalize()),
(Ok(ref s), Ok(ref d)) if s == d
);
let source_content =
read_file_content(tx.pending, tx.existed_before, &source_path)?.to_owned();
let dest_content = if same_file {
source_content.clone()
} else {
read_file_content(tx.pending, tx.existed_before, &dest_path)?.to_owned()
};
let (new_source, new_dest) =
move_section_in(&source_content, heading, &dest_content, position, same_file)
.ok_or_else(|| {
anyhow::anyhow!("md.move_section: heading or target not found")
})?;
update_file_content(tx.pending, tx.deletions, &source_path, new_source);
if !same_file {
update_file_content(tx.pending, tx.deletions, &dest_path, new_dest);
}
}
Operation::MdDedupeHeadings { path } => {
let file_path = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &file_path)?;
let (new_content, _removed) = dedupe_headings_in(file_content);
update_file_content(tx.pending, tx.deletions, &file_path, new_content);
}
Operation::TidyFix {
path,
ensure_final_newline,
trim_trailing_whitespace,
normalize_eol,
} => {
let file_path = tx.cwd.join(path);
let content = read_file_content(tx.pending, tx.existed_before, &file_path)?.to_owned();
let policy = WritePolicy {
ensure_final_newline: ensure_final_newline.unwrap_or(true),
trim_trailing_whitespace: trim_trailing_whitespace.unwrap_or(false),
normalize_eol: if let Some(eol) = normalize_eol {
crate::write::parse_eol_mode(eol)?
} else {
EolMode::Keep
},
collapse_blanks: false,
indent_style: None,
indent_size: None,
};
let new = crate::write::apply_policy(&content, &policy);
if content != *new {
update_file_content(tx.pending, tx.deletions, &file_path, new.into_owned());
}
}
Operation::FileAppend { .. }
| Operation::FileCreate { .. }
| Operation::FileDelete { .. }
| Operation::FileRename { .. } => {
return execute_file_op(op, tx);
}
Operation::PatchApply {
diff,
on_stale,
allow_conflicts,
} => {
let options = ApplyHunksOptions {
on_stale: *on_stale,
allow_conflicts: *allow_conflicts,
};
let patched_files = apply_patch_with_loader(
diff,
|path| {
let file_path = tx.cwd.join(path);
Ok(read_file_content(tx.pending, tx.existed_before, &file_path)?.to_string())
},
options,
)?;
for result in patched_files {
if result.status == ApplyHunksStatus::Conflict && !allow_conflicts {
anyhow::bail!(
"patch apply: {} -- merge produced {} conflict(s); set allow_conflicts to write conflict markers",
result.path,
result.conflicts.len()
);
}
let file_path = tx.cwd.join(&result.path);
update_file_content(tx.pending, tx.deletions, &file_path, result.content);
}
}
Operation::Read { path, lines } => {
execute_read_op(path, lines, tx)?;
}
Operation::Search { .. } => {
execute_search_op(op, tx)?;
}
Operation::MdLintAgents { path } => {
let file_path = tx.cwd.join(path);
let content = read_file_content(tx.pending, tx.existed_before, &file_path)?;
let issues = crate::ops::md::lint_agents_content(content);
tx.tx_lints.push(TxLintResult {
path: path.clone(),
issue_count: issues.len(),
issues,
});
}
#[cfg(feature = "ast")]
Operation::AstRename {
path,
old_name,
new_name,
lang,
} => {
let abs = tx.cwd.join(path);
let content = read_file_content(tx.pending, tx.existed_before, &abs)?;
let lang_val = lang
.as_deref()
.map(crate::ast::Language::from_extension)
.unwrap_or_else(|| crate::ast::Language::from_path(&abs));
let result =
crate::ast::rename::rename_in_source(content, old_name, new_name, lang_val);
match result {
Some(r) if r.replacements > 0 => {
update_file_content(tx.pending, tx.deletions, &abs, r.content);
return Ok(r.replacements);
}
_ => {
let re = crate::ops::replace::compile_replace_regex(
old_name, false, false, false, true,
)?;
if let Some(re) = re {
let new_content = re.replace_all(content, new_name.as_str()).to_string();
let count = re.find_iter(content).count();
if count > 0 {
update_file_content(tx.pending, tx.deletions, &abs, new_content);
return Ok(count);
}
}
anyhow::bail!("no matches for '{}' in {}", old_name, path);
}
}
}
#[cfg(feature = "ast")]
Operation::AstReplace {
path,
symbol,
from,
to,
regex,
lang,
} => {
let abs = tx.cwd.join(path);
let content = read_file_content(tx.pending, tx.existed_before, &abs)?;
let lang_val = lang
.as_deref()
.map(crate::ast::Language::from_extension)
.unwrap_or_else(|| crate::ast::Language::from_path(&abs));
let result = crate::ast::replace::replace_in_symbol(
content, symbol, from, to, *regex, lang_val,
)?;
match result {
Some(r) if r.replacements > 0 => {
update_file_content(tx.pending, tx.deletions, &abs, r.content);
return Ok(r.replacements);
}
Some(_) => anyhow::bail!(
"no matches for '{}' in symbol '{}' in {}",
from,
symbol,
path
),
None => anyhow::bail!("symbol '{}' not found in {}", symbol, path),
}
}
}
Ok(0)
}
fn build_write_policy(
plan: &Plan,
global: &GlobalFlags,
path: &Path,
) -> anyhow::Result<WritePolicy> {
let mut write_policy = crate::write::policy_from_flags(global, Some(path));
if let Some(ov) = &plan.write_policy {
write_policy.apply_override(ov)?;
}
Ok(write_policy)
}
pub(crate) fn execute_and_collect(
plan: &Plan,
cwd: &Path,
global: &GlobalFlags,
quiet: bool,
structured: bool,
) -> anyhow::Result<TxExecResult> {
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 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 doc_cache: HashMap<PathBuf, CachedDoc> = HashMap::new();
let mut replace_hint: Option<String> = None;
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 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,
replace_hint: None,
cwd,
quiet,
structured,
};
match execute_operation(op, &mut tx) {
Ok(count) => {
crate::verbose!(
"tx: operation {} succeeded (replace_matches: {count})",
i + 1
);
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);
anyhow::bail!("operation {} ({}) failed: {e}", i + 1, op_label(op));
}
}
}
flush_doc_cache(&mut pending, &mut deletions, &mut doc_cache)?;
let mut changes: Vec<(PathBuf, String, String)> = Vec::new();
for (path, (original, current)) in &pending {
let write_policy = build_write_policy(plan, global, path)?;
let final_content = apply_policy(current, &write_policy);
if *original != *final_content {
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,
no_effective_changes,
replace_no_matches,
replace_hint,
})
}