1use thiserror::Error;
2
3const END_PATCH: &str = "*** End Patch";
4const END_FILE: &str = "*** End of File";
5const UPDATE_FILE: &str = "*** Update File:";
6const DELETE_FILE: &str = "*** Delete File:";
7const ADD_FILE: &str = "*** Add File:";
8
9const END_SECTION_MARKERS: [&str; 5] = [END_PATCH, UPDATE_FILE, DELETE_FILE, ADD_FILE, END_FILE];
10const SECTION_TERMINATORS: [&str; 4] = [END_PATCH, UPDATE_FILE, DELETE_FILE, ADD_FILE];
11
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
17pub enum ApplyDiffMode {
18 #[default]
20 Update,
21 Create,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Error)]
32#[error("{message}")]
33pub struct ApplyDiffError {
34 message: String,
35}
36
37impl ApplyDiffError {
38 pub(crate) fn new(message: impl Into<String>) -> Self {
39 Self {
40 message: message.into(),
41 }
42 }
43
44 pub fn message(&self) -> &str {
46 &self.message
47 }
48}
49
50pub fn apply_diff_to_text(input: &str, diff: &str) -> Result<String, ApplyDiffError> {
75 apply_diff_to_text_with_mode(input, diff, ApplyDiffMode::Update)
76}
77
78pub fn apply_diff_to_text_with_mode(
103 input: &str,
104 diff: &str,
105 mode: ApplyDiffMode,
106) -> Result<String, ApplyDiffError> {
107 let diff_lines = normalize_diff_lines(diff);
108 let uses_crlf = mode == ApplyDiffMode::Update && has_uniform_crlf(input);
109 let normalized_input = uses_crlf.then(|| input.replace("\r\n", "\n"));
110 let input = normalized_input.as_deref().unwrap_or(input);
111 let output = match mode {
112 ApplyDiffMode::Update => {
113 let chunks = parse_update_diff(diff_lines, input)?;
114 apply_chunks(input, &chunks)
115 }
116 ApplyDiffMode::Create => parse_create_diff(diff_lines),
117 }?;
118 Ok(if uses_crlf {
119 output.replace('\n', "\r\n")
120 } else {
121 output
122 })
123}
124
125fn has_uniform_crlf(input: &str) -> bool {
126 let bytes = input.as_bytes();
127 let newline_count = bytes.iter().filter(|byte| **byte == b'\n').count();
128 newline_count > 0 && bytes.windows(2).filter(|pair| pair == b"\r\n").count() == newline_count
129}
130
131#[derive(Debug)]
132struct Chunk {
133 orig_index: usize,
134 del_lines: Vec<String>,
135 ins_lines: Vec<String>,
136}
137
138struct ParserState {
139 lines: Vec<String>,
140 index: usize,
141 fuzz: usize,
142}
143
144fn normalize_diff_lines(diff: &str) -> Vec<String> {
145 let bytes = diff.as_bytes();
146 let mut lines = Vec::new();
147 let mut start = 0;
148
149 for (index, byte) in bytes.iter().enumerate() {
150 if *byte != b'\n' {
151 continue;
152 }
153
154 let mut end = index;
155 if end > start && bytes[end - 1] == b'\r' {
156 end -= 1;
157 }
158
159 let mut line = &diff[start..end];
160 if let Some(without_cr) = line.strip_suffix('\r') {
161 line = without_cr;
162 }
163 lines.push(line.to_owned());
164 start = index + 1;
165 }
166
167 let mut final_line = &diff[start..];
168 if let Some(without_cr) = final_line.strip_suffix('\r') {
169 final_line = without_cr;
170 }
171 lines.push(final_line.to_owned());
172
173 if lines.last().is_some_and(String::is_empty) {
174 lines.pop();
175 }
176 lines
177}
178
179fn is_done(state: &ParserState, prefixes: &[&str]) -> bool {
180 state.index >= state.lines.len()
181 || prefixes
182 .iter()
183 .any(|prefix| state.lines[state.index].starts_with(prefix))
184}
185
186fn read_str(state: &mut ParserState, prefix: &str) -> Option<String> {
187 let suffix = state
188 .lines
189 .get(state.index)
190 .and_then(|line| line.strip_prefix(prefix))?
191 .to_owned();
192 state.index += 1;
193 Some(suffix)
194}
195
196fn parse_create_diff(mut lines: Vec<String>) -> Result<String, ApplyDiffError> {
197 lines.push(END_PATCH.to_owned());
198 let mut parser = ParserState {
199 lines,
200 index: 0,
201 fuzz: 0,
202 };
203 let mut output = Vec::new();
204
205 while !is_done(&parser, &SECTION_TERMINATORS) {
206 let line = &parser.lines[parser.index];
207 parser.index += 1;
208 let Some(content) = line.strip_prefix('+') else {
209 return Err(ApplyDiffError::new(format!(
210 "Invalid Add File Line: {line}"
211 )));
212 };
213 output.push(content);
214 }
215
216 Ok(output.join("\n"))
217}
218
219fn parse_update_diff(mut lines: Vec<String>, input: &str) -> Result<Vec<Chunk>, ApplyDiffError> {
220 lines.push(END_PATCH.to_owned());
221 let mut parser = ParserState {
222 lines,
223 index: 0,
224 fuzz: 0,
225 };
226 let input_lines: Vec<&str> = input.split('\n').collect();
227 let mut chunks = Vec::new();
228 let mut cursor = 0;
229
230 while !is_done(&parser, &END_SECTION_MARKERS) {
231 let (anchors, anchor_count) = read_anchors(&mut parser);
232 if anchor_count == 0 && cursor != 0 {
233 let line = parser
234 .lines
235 .get(parser.index)
236 .map_or("undefined", String::as_str);
237 return Err(ApplyDiffError::new(format!("Invalid Line:\n{line}")));
238 }
239
240 let require_anchor_match = anchor_count > 1;
241 for (index, anchor) in anchors.iter().enumerate() {
242 cursor = advance_cursor_to_anchor(
243 anchor,
244 &input_lines,
245 cursor,
246 &mut parser,
247 require_anchor_match,
248 index > 0,
249 )?;
250 }
251
252 let section = read_section(&parser.lines, parser.index)?;
253 let next_context_text = section.next_context.join("\n");
254 let (new_index, fuzz) =
255 find_context(&input_lines, §ion.next_context, cursor, section.eof);
256 let Some(new_index) = new_index else {
257 let kind = if section.eof {
258 "Invalid EOF Context"
259 } else {
260 "Invalid Context"
261 };
262 return Err(ApplyDiffError::new(format!(
263 "{kind} {cursor}:\n{next_context_text}"
264 )));
265 };
266
267 parser.fuzz += fuzz;
268 chunks.extend(section.chunks.into_iter().map(|mut chunk| {
269 chunk.orig_index += new_index;
270 chunk
271 }));
272 cursor = new_index + section.next_context.len();
273 parser.index = section.end_index;
274 }
275
276 Ok(chunks)
277}
278
279fn read_anchors(parser: &mut ParserState) -> (Vec<String>, usize) {
280 let mut anchors = Vec::new();
281 let mut anchor_count = 0;
282
283 loop {
284 let start_index = parser.index;
285 let anchor = read_str(parser, "@@ ");
286 let mut consumed = parser.index != start_index;
287
288 if !consumed
289 && parser
290 .lines
291 .get(parser.index)
292 .is_some_and(|line| line == "@@")
293 {
294 parser.index += 1;
295 consumed = true;
296 }
297 if !consumed {
298 break;
299 }
300
301 anchor_count += 1;
302 if let Some(anchor) = anchor.filter(|anchor| !trim_diff_whitespace(anchor).is_empty()) {
303 anchors.push(anchor);
304 }
305 }
306
307 (anchors, anchor_count)
308}
309
310fn advance_cursor_to_anchor(
311 anchor: &str,
312 input_lines: &[&str],
313 mut cursor: usize,
314 parser: &mut ParserState,
315 require_match: bool,
316 force_forward_search: bool,
317) -> Result<usize, ApplyDiffError> {
318 let mut found = false;
319
320 if !force_forward_search && input_lines[..cursor].contains(&anchor) {
321 found = true;
322 } else if let Some(index) = input_lines[cursor..]
323 .iter()
324 .position(|line| *line == anchor)
325 {
326 cursor += index + 1;
327 found = true;
328 }
329
330 if !found {
331 if !force_forward_search
332 && input_lines[..cursor]
333 .iter()
334 .any(|line| trim_diff_whitespace(line) == trim_diff_whitespace(anchor))
335 {
336 found = true;
337 } else if let Some(index) = input_lines[cursor..]
338 .iter()
339 .position(|line| trim_diff_whitespace(line) == trim_diff_whitespace(anchor))
340 {
341 cursor += index + 1;
342 parser.fuzz += 1;
343 found = true;
344 }
345 }
346
347 if require_match && !found {
348 return Err(ApplyDiffError::new(format!(
349 "Invalid Anchor {cursor}:\n{anchor}"
350 )));
351 }
352
353 Ok(cursor)
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357enum LineMode {
358 Keep,
359 Add,
360 Delete,
361}
362
363struct Section {
364 next_context: Vec<String>,
365 chunks: Vec<Chunk>,
366 end_index: usize,
367 eof: bool,
368}
369
370fn read_section(lines: &[String], start_index: usize) -> Result<Section, ApplyDiffError> {
371 let mut context = Vec::new();
372 let mut del_lines = Vec::new();
373 let mut ins_lines = Vec::new();
374 let mut chunks = Vec::new();
375 let mut mode = LineMode::Keep;
376 let mut index = start_index;
377 let orig_index = index;
378
379 while index < lines.len() {
380 let raw = &lines[index];
381 if raw.starts_with("@@")
382 || raw.starts_with(END_PATCH)
383 || raw.starts_with(UPDATE_FILE)
384 || raw.starts_with(DELETE_FILE)
385 || raw.starts_with(ADD_FILE)
386 || raw.starts_with(END_FILE)
387 {
388 break;
389 }
390 if raw == "***" {
391 break;
392 }
393 if raw.starts_with("***") {
394 return Err(ApplyDiffError::new(format!("Invalid Line: {raw}")));
395 }
396
397 index += 1;
398 let last_mode = mode;
399 let line = if raw.is_empty() { " " } else { raw.as_str() };
400 let (next_mode, content) = if let Some(content) = line.strip_prefix('+') {
401 (LineMode::Add, content)
402 } else if let Some(content) = line.strip_prefix('-') {
403 (LineMode::Delete, content)
404 } else if let Some(content) = line.strip_prefix(' ') {
405 (LineMode::Keep, content)
406 } else {
407 return Err(ApplyDiffError::new(format!("Invalid Line: {line}")));
408 };
409 mode = next_mode;
410
411 if mode == LineMode::Keep
412 && last_mode != mode
413 && (!ins_lines.is_empty() || !del_lines.is_empty())
414 {
415 chunks.push(Chunk {
416 orig_index: context.len() - del_lines.len(),
417 del_lines: std::mem::take(&mut del_lines),
418 ins_lines: std::mem::take(&mut ins_lines),
419 });
420 }
421
422 match mode {
423 LineMode::Delete => {
424 del_lines.push(content.to_owned());
425 context.push(content.to_owned());
426 }
427 LineMode::Add => ins_lines.push(content.to_owned()),
428 LineMode::Keep => context.push(content.to_owned()),
429 }
430 }
431
432 if !ins_lines.is_empty() || !del_lines.is_empty() {
433 chunks.push(Chunk {
434 orig_index: context.len() - del_lines.len(),
435 del_lines,
436 ins_lines,
437 });
438 }
439
440 if lines.get(index).is_some_and(|line| line == END_FILE) {
441 return Ok(Section {
442 next_context: context,
443 chunks,
444 end_index: index + 1,
445 eof: true,
446 });
447 }
448
449 if index == orig_index {
450 let line = lines.get(index).map_or("undefined", String::as_str);
451 return Err(ApplyDiffError::new(format!(
452 "Nothing in this section - index={index} {line}"
453 )));
454 }
455
456 Ok(Section {
457 next_context: context,
458 chunks,
459 end_index: index,
460 eof: false,
461 })
462}
463
464fn find_context(
465 lines: &[&str],
466 context: &[String],
467 start: usize,
468 eof: bool,
469) -> (Option<usize>, usize) {
470 if eof {
471 let end_start = lines.len().saturating_sub(context.len());
472 let end_match = find_context_core(lines, context, end_start);
473 if end_match.0.is_some() {
474 return end_match;
475 }
476
477 let (new_index, fuzz) = find_context_core(lines, context, start);
478 return (new_index, fuzz + 10_000);
479 }
480
481 find_context_core(lines, context, start)
482}
483
484fn find_context_core(lines: &[&str], context: &[String], start: usize) -> (Option<usize>, usize) {
485 if context.is_empty() {
486 return (Some(start), 0);
487 }
488
489 for index in start..lines.len() {
490 if equals_slice(lines, context, index, |value| value) {
491 return (Some(index), 0);
492 }
493 }
494 for index in start..lines.len() {
495 if equals_slice(lines, context, index, trim_diff_whitespace_end) {
496 return (Some(index), 1);
497 }
498 }
499 for index in start..lines.len() {
500 if equals_slice(lines, context, index, trim_diff_whitespace) {
501 return (Some(index), 100);
502 }
503 }
504
505 (None, 0)
506}
507
508fn equals_slice<'a, F>(source: &'a [&'a str], target: &'a [String], start: usize, map: F) -> bool
509where
510 F: Fn(&'a str) -> &'a str,
511{
512 if start + target.len() > source.len() {
513 return false;
514 }
515
516 source[start..start + target.len()]
517 .iter()
518 .copied()
519 .zip(target.iter().map(String::as_str))
520 .all(|(source, target)| map(source) == map(target))
521}
522
523fn trim_diff_whitespace(value: &str) -> &str {
524 value.trim_matches(is_diff_whitespace)
525}
526
527fn trim_diff_whitespace_end(value: &str) -> &str {
528 value.trim_end_matches(is_diff_whitespace)
529}
530
531fn is_diff_whitespace(character: char) -> bool {
532 matches!(
533 character,
534 '\u{0009}'
535 | '\u{000A}'
536 | '\u{000B}'
537 | '\u{000C}'
538 | '\u{000D}'
539 | '\u{0020}'
540 | '\u{00A0}'
541 | '\u{1680}'
542 | '\u{2000}'
543 ..='\u{200A}'
544 | '\u{2028}'
545 | '\u{2029}'
546 | '\u{202F}'
547 | '\u{205F}'
548 | '\u{3000}'
549 | '\u{FEFF}'
550 )
551}
552
553fn apply_chunks(input: &str, chunks: &[Chunk]) -> Result<String, ApplyDiffError> {
554 let orig_lines: Vec<&str> = input.split('\n').collect();
555 let mut dest_lines: Vec<&str> = Vec::new();
556 let mut orig_index = 0;
557
558 for chunk in chunks {
559 if chunk.orig_index > orig_lines.len() {
560 return Err(ApplyDiffError::new(format!(
561 "applyDiff: chunk.origIndex {} > input length {}",
562 chunk.orig_index,
563 orig_lines.len()
564 )));
565 }
566 if orig_index > chunk.orig_index {
567 return Err(ApplyDiffError::new(format!(
568 "applyDiff: overlapping chunk at {} (cursor {orig_index})",
569 chunk.orig_index
570 )));
571 }
572
573 dest_lines.extend_from_slice(&orig_lines[orig_index..chunk.orig_index]);
574 orig_index = chunk.orig_index;
575 dest_lines.extend(chunk.ins_lines.iter().map(String::as_str));
576 orig_index += chunk.del_lines.len();
577 }
578
579 dest_lines.extend_from_slice(&orig_lines[orig_index..]);
580 Ok(dest_lines.join("\n"))
581}