use super::{
model::{Anchor, Cursor, Edit, FileOp, InsertMode, ParseWarning, ParsedRange},
tokenizer::{BlockTarget, Token, TokenKind, Tokenizer},
};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ParseOutput {
pub(crate) edits: Vec<Edit>,
pub(crate) file_op: Option<FileOp>,
pub(crate) warnings: Vec<ParseWarning>,
}
#[derive(Debug, Clone)]
struct PayloadRow {
text: String,
bare: bool,
}
#[derive(Debug, Clone)]
struct Pending {
target: BlockTarget,
line_num: usize,
payloads: Vec<PayloadRow>,
deferred_blanks: Vec<PayloadRow>,
}
#[derive(Default)]
pub(crate) struct Executor {
edits: Vec<Edit>,
warnings: Vec<ParseWarning>,
edit_index: usize,
pending: Option<Pending>,
file_op: Option<FileOp>,
terminated: bool,
skippable_comments: Vec<(usize, String)>,
}
impl Executor {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn feed(&mut self, mut token: Token) -> Result<(), String> {
if self.terminated {
return Ok(());
}
for warning in &mut token.warnings {
if let ParseWarning::StrayDotSkipped { line_num } = warning {
*line_num = token.line_num;
}
}
self.warnings.extend(token.warnings);
match token.kind {
TokenKind::EnvelopeBegin => self.consume_pending_skippable_comments(),
TokenKind::EnvelopeEnd | TokenKind::Abort => {
self.consume_pending_skippable_comments()?;
self.terminated = true;
Ok(())
}
TokenKind::Header { .. } => {
self.consume_pending_skippable_comments()?;
self.flush_pending()
}
TokenKind::Blank => {
self.consume_pending_skippable_comments()?;
self.handle_blank(String::new(), token.line_num);
Ok(())
}
TokenKind::PayloadLiteral { text } => {
self.consume_pending_skippable_comments()?;
self.handle_literal_payload(text, token.line_num)
}
TokenKind::Comment { text } => {
if self.pending.is_none() {
self.skippable_comments.push((token.line_num, text));
return Ok(());
}
self.consume_pending_skippable_comments()?;
self.handle_raw(text, token.line_num)
}
TokenKind::Raw { text } => {
if self.pending.is_none() && is_skippable_comment_line(&text) {
self.skippable_comments.push((token.line_num, text));
return Ok(());
}
self.consume_pending_skippable_comments()?;
self.handle_raw(text, token.line_num)
}
TokenKind::OpBlock { target } => {
self.discard_pending_skippable_comments();
if let BlockTarget::Replace { range } | BlockTarget::Delete { range } = &target {
validate_range_order(range, token.line_num)?;
}
match target {
BlockTarget::Remove => {
self.flush_pending()?;
self.set_file_op(FileOp::Remove, token.line_num)
}
BlockTarget::Move { dest } => {
self.flush_pending()?;
self.set_file_op(FileOp::Move { dest }, token.line_num)
}
target => {
self.flush_pending()?;
self.pending = Some(Pending {
target,
line_num: token.line_num,
payloads: Vec::new(),
deferred_blanks: Vec::new(),
});
Ok(())
}
}
}
}
}
pub(crate) fn end(mut self) -> Result<ParseOutput, String> {
self.consume_pending_skippable_comments()?;
self.flush_pending()?;
self.validate_file_op()?;
self.validate_no_overlapping_deletes()?;
Ok(ParseOutput {
edits: self.edits,
file_op: self.file_op,
warnings: self.warnings,
})
}
fn discard_pending_skippable_comments(&mut self) {
self.skippable_comments.clear();
}
fn consume_pending_skippable_comments(&mut self) -> Result<(), String> {
let comments = std::mem::take(&mut self.skippable_comments);
for (line_num, text) in comments {
self.handle_raw(text, line_num)?;
}
Ok(())
}
fn set_file_op(&mut self, file_op: FileOp, line_num: usize) -> Result<(), String> {
if self.file_op.is_some() {
return Err(format!(
"line {line_num}: only one file-level op (`REM` or `MV`) per section. Merge them under one header."
));
}
if matches!(file_op, FileOp::Remove) && !self.edits.is_empty() {
return Err(format!(
"line {line_num}: `REM` deletes the whole file and cannot be combined with line ops."
));
}
self.file_op = Some(file_op);
Ok(())
}
fn validate_file_op(&self) -> Result<(), String> {
if matches!(self.file_op, Some(FileOp::Remove)) && !self.edits.is_empty() {
return Err(
"`REM` deletes the whole file and cannot be combined with line ops.".to_string(),
);
}
Ok(())
}
fn validate_no_overlapping_deletes(&self) -> Result<(), String> {
let mut source_lines_by_anchor: HashMap<usize, HashSet<usize>> = HashMap::new();
for edit in &self.edits {
if let Edit::Delete {
anchor, line_num, ..
} = edit
{
let source_lines = source_lines_by_anchor.entry(anchor.line).or_default();
source_lines.insert(*line_num);
}
}
for (anchor_line, source_lines) in source_lines_by_anchor {
if source_lines.len() < 2 {
continue;
}
let mut lines = source_lines.into_iter().collect::<Vec<_>>();
lines.sort_unstable();
return Err(format!(
"line {}: anchor line {anchor_line} is already targeted by another hunk on line {}. Issue ONE hunk per range; payload is only the final desired content, never a before/after pair.",
lines[1], lines[0]
));
}
Ok(())
}
fn handle_literal_payload(&mut self, text: String, line_num: usize) -> Result<(), String> {
let pending = self.pending.as_mut().ok_or_else(|| {
if self.file_op.is_some() {
format!("line {line_num}: `MV` and `REM` take no body rows.")
} else {
format!(
"line {line_num}: payload line has no preceding hunk header. Got {:?}.",
format!("+{text}")
)
}
})?;
if matches!(pending.target, BlockTarget::Delete { .. }) {
return Err(format!(
"line {line_num}: `DEL` takes no body rows. Remove the colon and body rows."
));
}
if matches!(pending.target, BlockTarget::DeleteBlock { .. }) {
return Err(format!("line {line_num}: `DEL.BLK` takes no body rows."));
}
commit_deferred_blanks(&mut self.warnings, pending);
pending.payloads.push(PayloadRow { text, bare: false });
Ok(())
}
fn handle_raw(&mut self, text: String, line_num: usize) -> Result<(), String> {
if let Some(message) = detect_apply_patch_contamination(&text) {
return Err(format!("line {line_num}: {message}"));
}
if self.file_op.is_some() {
return Err(format!(
"line {line_num}: `MV` and `REM` take no body rows."
));
}
if let Some(pending) = self.pending.as_mut() {
if text.trim().is_empty() {
self.handle_blank(text, line_num);
return Ok(());
}
if matches!(pending.target, BlockTarget::Delete { .. }) {
return Err(format!(
"line {line_num}: `DEL` takes no body rows. Remove the colon and body rows."
));
}
if matches!(pending.target, BlockTarget::DeleteBlock { .. }) {
return Err(format!("line {line_num}: `DEL.BLK` takes no body rows."));
}
if text.trim_start().starts_with('-') {
return Err(format!(
"line {line_num}: body rows use `+`, not `-`. For literal minus content, write `+-...`."
));
}
push_warning_once(&mut self.warnings, ParseWarning::BareBodyAutoPiped);
commit_deferred_blanks(&mut self.warnings, pending);
pending.payloads.push(PayloadRow { text, bare: true });
return Ok(());
}
if text.trim().is_empty() {
return Ok(());
}
Err(format!(
"line {line_num}: payload line has no preceding hunk header. Use `SWAP N.=M:`, `DEL N.=M`, or `INS.PRE|POST|HEAD|TAIL:` above the body. Got {text:?}."
))
}
fn handle_blank(&mut self, text: String, _line_num: usize) {
let Some(pending) = self.pending.as_mut() else {
return;
};
if matches!(
pending.target,
BlockTarget::Delete { .. } | BlockTarget::DeleteBlock { .. }
) {
return;
}
if pending.payloads.is_empty() {
return;
}
pending
.deferred_blanks
.push(PayloadRow { text, bare: true });
}
fn flush_pending(&mut self) -> Result<(), String> {
let Some(mut pending) = self.pending.take() else {
return Ok(());
};
strip_bare_prefixes_if_uniform(&mut pending.payloads);
match pending.target {
BlockTarget::Delete { range } => {
for anchor in expand_range(&range) {
self.push_delete(anchor, pending.line_num);
}
}
BlockTarget::DeleteBlock { anchor } => {
self.push_block(anchor, &[], pending.line_num, None);
}
BlockTarget::Block { anchor } => {
if pending.payloads.is_empty() {
return Err(format!(
"line {}: `SWAP.BLK` needs at least one body row.",
pending.line_num
));
}
self.push_block(anchor, &pending.payloads, pending.line_num, None);
}
BlockTarget::InsertAfterBlock { anchor } => {
if pending.payloads.is_empty() {
return Err(format!(
"line {}: insert hunk needs at least one body row.",
pending.line_num
));
}
self.push_block(
anchor,
&pending.payloads,
pending.line_num,
Some(super::model::BlockMode::InsertAfter),
);
}
BlockTarget::Replace { range } => {
if pending.payloads.is_empty() {
for anchor in expand_range(&range) {
self.push_delete(anchor, pending.line_num);
}
} else {
let cursor = Cursor::BeforeAnchor {
anchor: range.start.clone(),
};
self.emit_payload_rows(
cursor,
&pending.payloads,
pending.line_num,
Some(InsertMode::Replacement),
);
for anchor in expand_range(&range) {
self.push_delete(anchor, pending.line_num);
}
}
}
BlockTarget::InsertBefore { ref anchor } => {
self.require_payloads(&pending)?;
self.emit_payload_rows(
Cursor::BeforeAnchor {
anchor: anchor.clone(),
},
&pending.payloads,
pending.line_num,
None,
);
}
BlockTarget::InsertAfter { ref anchor } => {
self.require_payloads(&pending)?;
self.emit_payload_rows(
Cursor::AfterAnchor {
anchor: anchor.clone(),
},
&pending.payloads,
pending.line_num,
None,
);
}
BlockTarget::Bof => {
self.require_payloads(&pending)?;
self.emit_payload_rows(Cursor::Bof, &pending.payloads, pending.line_num, None);
}
BlockTarget::Eof => {
self.require_payloads(&pending)?;
self.emit_payload_rows(Cursor::Eof, &pending.payloads, pending.line_num, None);
}
BlockTarget::Remove | BlockTarget::Move { .. } => {
unreachable!("file ops are not pending hunks")
}
}
Ok(())
}
fn require_payloads(&self, pending: &Pending) -> Result<(), String> {
if pending.payloads.is_empty() {
Err(format!(
"line {}: insert hunk needs at least one body row.",
pending.line_num
))
} else {
Ok(())
}
}
fn emit_payload_rows(
&mut self,
cursor: Cursor,
payloads: &[PayloadRow],
line_num: usize,
mode: Option<InsertMode>,
) {
for payload in payloads {
self.push_insert(cursor.clone(), payload.text.clone(), line_num, mode);
}
}
fn push_insert(
&mut self,
cursor: Cursor,
text: String,
line_num: usize,
mode: Option<InsertMode>,
) {
self.edits.push(Edit::Insert {
cursor,
text,
line_num,
index: self.edit_index,
mode,
block_start: None,
});
self.edit_index += 1;
}
fn push_delete(&mut self, anchor: Anchor, line_num: usize) {
self.edits.push(Edit::Delete {
anchor,
line_num,
index: self.edit_index,
old_assertion: None,
});
self.edit_index += 1;
}
fn push_block(
&mut self,
anchor: Anchor,
payloads: &[PayloadRow],
line_num: usize,
mode: Option<super::model::BlockMode>,
) {
self.edits.push(Edit::Block {
anchor,
payloads: payloads
.iter()
.map(|payload| payload.text.clone())
.collect(),
mode,
line_num,
index: self.edit_index,
});
self.edit_index += 1;
}
}
pub(crate) fn parse_patch(diff: &str) -> Result<ParseOutput, String> {
let mut tokenizer = Tokenizer::new();
let mut executor = Executor::new();
for token in tokenizer.feed(diff) {
executor.feed(token)?;
}
for token in tokenizer.end() {
executor.feed(token)?;
}
executor.end()
}
fn validate_range_order(range: &ParsedRange, line_num: usize) -> Result<(), String> {
if range.end.line < range.start.line {
return Err(format!(
"line {line_num}: range {}.={} ends before it starts.",
range.start.line, range.end.line
));
}
Ok(())
}
fn expand_range(range: &ParsedRange) -> Vec<Anchor> {
(range.start.line..=range.end.line)
.map(|line| Anchor { line })
.collect()
}
fn is_skippable_comment_line(line: &str) -> bool {
line.trim_start().starts_with('#')
}
fn detect_apply_patch_contamination(text: &str) -> Option<String> {
let trimmed = text.trim_start();
if trimmed.is_empty() {
return None;
}
if [
"*** Update File:",
"*** Add File:",
"*** Delete File:",
"*** Move to:",
]
.iter()
.any(|prefix| trimmed.starts_with(prefix))
{
return Some("apply_patch sentinel is not valid in hashline. File sections start with `[path#HASH]`.".to_string());
}
if trimmed.starts_with("@@") {
return Some("unified-diff hunk headers are not valid in hashline.".to_string());
}
if starts_with_del_colon(trimmed) {
return Some(
"`DEL N.=M` has no colon and no body. Remove the colon and body rows.".to_string(),
);
}
if trimmed.chars().all(|ch| ch.is_ascii_digit()) {
return Some(format!(
"hunk headers need a verb. Use `SWAP {trimmed}.={trimmed}:` or `DEL {trimmed}`."
));
}
None
}
fn starts_with_del_colon(trimmed: &str) -> bool {
if !trimmed.starts_with("DEL ") {
return false;
}
trimmed.contains(':')
}
fn commit_deferred_blanks(warnings: &mut Vec<ParseWarning>, pending: &mut Pending) {
if pending.deferred_blanks.is_empty() {
return;
}
push_warning_once(warnings, ParseWarning::BareBodyAutoPiped);
pending.payloads.append(&mut pending.deferred_blanks);
}
fn push_warning_once(warnings: &mut Vec<ParseWarning>, warning: ParseWarning) {
if !warnings.contains(&warning) {
warnings.push(warning);
}
}
fn strip_bare_prefixes_if_uniform(payloads: &mut [PayloadRow]) {
let mut saw_bare = false;
let mut all_literal_values = true;
for row in payloads.iter() {
if !row.bare || row.text.trim().is_empty() {
continue;
}
saw_bare = true;
let stripped = strip_one_leading_hashline_prefix(&row.text);
if stripped == row.text {
return;
}
all_literal_values &= bare_literal_value(stripped);
}
if !saw_bare || all_literal_values {
return;
}
for row in payloads.iter_mut() {
if row.bare && !row.text.trim().is_empty() {
row.text = strip_one_leading_hashline_prefix(&row.text).to_string();
}
}
}
fn strip_one_leading_hashline_prefix(text: &str) -> &str {
let trimmed = text.trim_start();
let stripped_ws = text.len() - trimmed.len();
let Some(colon) = trimmed.find(':') else {
return text;
};
if colon == 0
|| !trimmed[..colon].chars().all(|ch| ch.is_ascii_digit())
|| trimmed.starts_with('0')
{
return text;
}
&text[stripped_ws + colon + 1..]
}
fn bare_literal_value(text: &str) -> bool {
let trimmed = text.trim();
let value = trimmed.strip_suffix(',').unwrap_or(trimmed).trim();
if value.len() >= 2 {
let bytes = value.as_bytes();
if (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
|| (bytes[0] == b'\"' && bytes[value.len() - 1] == b'\"')
{
return true;
}
}
value.parse::<f64>().is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::hash_edit::model::BlockMode;
#[test]
fn parses_all_ops() {
let cases = [
"SWAP 2.=3:\n+x",
"SWAP.BLK 2:\n+x",
"DEL 2.=3",
"DEL.BLK 2",
"INS.PRE 2:\n+x",
"INS.POST 2:\n+x",
"INS.BLK.POST 2:\n+x",
"INS.HEAD:\n+x",
"INS.TAIL:\n+x",
"MV dest.txt",
"REM",
];
for case in cases {
parse_patch(case).unwrap_or_else(|err| panic!("{case}: {err}"));
}
}
#[test]
fn swap_range_lowers_to_replacement_inserts_and_deletes() {
let parsed = parse_patch("SWAP 2.=3:\n+a\n+b").unwrap();
assert_eq!(parsed.edits.len(), 4);
assert!(matches!(
parsed.edits[0],
Edit::Insert {
mode: Some(InsertMode::Replacement),
..
}
));
assert!(matches!(
parsed.edits[2],
Edit::Delete {
anchor: Anchor { line: 2 },
..
}
));
assert!(matches!(
parsed.edits[3],
Edit::Delete {
anchor: Anchor { line: 3 },
..
}
));
}
#[test]
fn block_and_insert_after_block_lowers_to_block_edits() {
let swap = parse_patch("SWAP.BLK 5:\n+x").unwrap();
assert!(matches!(
swap.edits[0],
Edit::Block {
anchor: Anchor { line: 5 },
mode: None,
..
}
));
let insert = parse_patch("INS.BLK.POST 5:\n+x").unwrap();
assert!(matches!(
insert.edits[0],
Edit::Block {
anchor: Anchor { line: 5 },
mode: Some(BlockMode::InsertAfter),
..
}
));
}
#[test]
fn tolerate_stray_dot_bare_rows_and_deferred_blanks() {
let parsed = parse_patch("SWAP 2.=3.:\n2:first\n\n3:third\n").unwrap();
assert!(parsed.warnings.contains(&ParseWarning::BareBodyAutoPiped));
assert!(
parsed
.warnings
.contains(&ParseWarning::StrayDotSkipped { line_num: 1 })
);
let texts: Vec<_> = parsed
.edits
.iter()
.filter_map(|edit| match edit {
Edit::Insert { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(texts, vec!["first", "", "third"]);
}
#[test]
fn preserves_literal_plus_and_minus_payload() {
let parsed = parse_patch("INS.HEAD:\n++plus\n+-minus").unwrap();
let texts: Vec<_> = parsed
.edits
.iter()
.filter_map(|edit| match edit {
Edit::Insert { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(texts, vec!["+plus", "-minus"]);
}
#[test]
fn rejects_invalid_body_and_overlap_cases() {
assert!(parse_patch("DEL 2:\n+x").unwrap_err().contains("no colon"));
assert!(
parse_patch("DEL 2\n+x")
.unwrap_err()
.contains("takes no body")
);
assert!(parse_patch("REM\n+x").unwrap_err().contains("take no body"));
assert!(
parse_patch("DEL 2\nDEL 2")
.unwrap_err()
.contains("already targeted")
);
}
}