Skip to main content

aft/patch/
apply.rs

1//! Apply parsed update chunks to file content.
2//!
3//! This ports `applyUpdateChunks` and its diagnostics from
4//! `packages/opencode-plugin/src/patch-parser.ts`.
5
6use crate::patch::matcher::{
7    find_nearest_miss, seek_sequence_tiered, NearestMiss, NearestMissSearch, SequenceMatch,
8    NEAREST_MISS_MAX_FILE_BYTES,
9};
10use crate::patch::parser::UpdateFileChunk;
11
12const NEAREST_MISS_RENDER_LINES: usize = 20;
13
14/// Return only the matched line index for diagnostics that do not need the full tiered match details.
15pub fn seek_sequence(
16    lines: &[&str],
17    pattern: &[&str],
18    start_index: usize,
19    eof: bool,
20) -> Option<usize> {
21    seek_sequence_tiered(lines, pattern, start_index, eof)
22        .map(|sequence_match| sequence_match.found)
23}
24
25fn line_refs(lines: &[String]) -> Vec<&str> {
26    lines.iter().map(String::as_str).collect()
27}
28
29fn seek_sequence_tiered_strings(
30    lines: &[String],
31    pattern: &[String],
32    start_index: usize,
33    eof: bool,
34) -> Option<SequenceMatch> {
35    let line_refs = line_refs(lines);
36    let pattern_refs: Vec<&str> = pattern.iter().map(String::as_str).collect();
37    seek_sequence_tiered(&line_refs, &pattern_refs, start_index, eof)
38}
39
40fn seek_sequence_strings(
41    lines: &[String],
42    pattern: &[String],
43    start_index: usize,
44    eof: bool,
45) -> Option<usize> {
46    let line_refs = line_refs(lines);
47    let pattern_refs: Vec<&str> = pattern.iter().map(String::as_str).collect();
48    seek_sequence(&line_refs, &pattern_refs, start_index, eof)
49}
50
51fn inline_code(value: &str) -> String {
52    format!("`{}`", value.replace('`', "\\`"))
53}
54
55fn render_found_nearest_miss(lines: &[&str], pattern: &[&str], nearest: NearestMiss) -> String {
56    let start_line = nearest.start + 1;
57    let end_line = nearest.end;
58    let mut rendered = format!(
59        "Nearest miss at lines {start_line}-{end_line} (matched {}/{} context lines):",
60        nearest.matched_lines,
61        pattern.len()
62    );
63    let line_number_width = end_line.to_string().len();
64    let available_lines = nearest.end.saturating_sub(nearest.start);
65    for (offset, line) in lines[nearest.start..nearest.end]
66        .iter()
67        .take(NEAREST_MISS_RENDER_LINES)
68        .enumerate()
69    {
70        let line_number = nearest.start + offset + 1;
71        rendered.push_str(&format!(
72            "\n  {line_number:>line_number_width$} | {line}",
73            line_number_width = line_number_width
74        ));
75    }
76    if available_lines > NEAREST_MISS_RENDER_LINES {
77        rendered.push_str(&format!(
78            "\n  ... ({} more candidate lines truncated)",
79            available_lines - NEAREST_MISS_RENDER_LINES
80        ));
81    }
82
83    if nearest.first_divergence < pattern.len() {
84        let wanted_line = pattern[nearest.first_divergence];
85        let file_line_number = nearest.start + nearest.first_divergence + 1;
86        let actual_line = lines
87            .get(nearest.start + nearest.first_divergence)
88            .copied()
89            .unwrap_or("<EOF>");
90        rendered.push_str(&format!(
91            "\nFirst divergence: wanted line {} {} vs file line {file_line_number} {}",
92            nearest.first_divergence + 1,
93            inline_code(wanted_line),
94            inline_code(actual_line)
95        ));
96    } else {
97        rendered.push_str(
98            "\nFirst divergence: none within the candidate window; the hunk placement constraint did not match.",
99        );
100    }
101
102    rendered
103}
104
105fn render_nearest_miss(lines: &[&str], pattern: &[&str], file_size_bytes: usize) -> String {
106    match find_nearest_miss(lines, pattern, file_size_bytes) {
107        NearestMissSearch::Found(nearest) => render_found_nearest_miss(lines, pattern, nearest),
108        NearestMissSearch::NoSimilarRegion => "Nearest miss: no similar region found.".to_owned(),
109        NearestMissSearch::SkippedLargeFile => format!(
110            "Nearest miss skipped: file is {file_size_bytes} bytes, above the {} MiB diagnostic limit.",
111            NEAREST_MISS_MAX_FILE_BYTES / (1024 * 1024)
112        ),
113    }
114}
115
116/// Apply parsed update chunks to original file content, returning the patched text or an error string.
117pub fn apply_update_chunks(
118    original_content: &str,
119    file_path: &str,
120    chunks: &[UpdateFileChunk],
121) -> Result<String, String> {
122    let mut original_lines: Vec<String> = original_content
123        .split('\n')
124        .map(ToOwned::to_owned)
125        .collect();
126
127    if original_lines.last().is_some_and(String::is_empty) {
128        original_lines.pop();
129    }
130
131    let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
132    let mut line_index = 0;
133
134    for chunk in chunks {
135        let change_context = chunk
136            .change_context
137            .as_ref()
138            .filter(|context| !context.is_empty());
139        if let Some(context) = change_context {
140            let line_refs = line_refs(&original_lines);
141            let context_pattern = [context.as_str()];
142            let Some(context_match) =
143                seek_sequence_tiered(&line_refs, &context_pattern, line_index, false)
144            else {
145                return Err(format!("Failed to find context '{context}' in {file_path}"));
146            };
147            line_index = context_match.found + context_match.line_count;
148        }
149
150        if chunk.old_lines.is_empty() {
151            let insertion_idx = if change_context.is_some() {
152                line_index
153            } else if original_lines.last().is_some_and(String::is_empty) {
154                original_lines.len() - 1
155            } else {
156                original_lines.len()
157            };
158            replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
159            continue;
160        }
161
162        let mut pattern = chunk.old_lines.clone();
163        let mut new_slice = chunk.new_lines.clone();
164        let mut matched = seek_sequence_tiered_strings(
165            &original_lines,
166            &pattern,
167            line_index,
168            chunk.is_end_of_file,
169        );
170
171        if matched.is_none() && pattern.last().is_some_and(String::is_empty) {
172            pattern.pop();
173            if new_slice.last().is_some_and(String::is_empty) {
174                new_slice.pop();
175            }
176            matched = seek_sequence_tiered_strings(
177                &original_lines,
178                &pattern,
179                line_index,
180                chunk.is_end_of_file,
181            );
182        }
183
184        if let Some(sequence_match) = matched {
185            replacements.push((sequence_match.found, sequence_match.line_count, new_slice));
186            line_index = sequence_match.found + sequence_match.line_count;
187        } else {
188            let new_slice_trimmed: Vec<String> = new_slice
189                .iter()
190                .filter(|line| !line.trim().is_empty())
191                .cloned()
192                .collect();
193            let already_applied = !new_slice_trimmed.is_empty()
194                && seek_sequence_strings(
195                    &original_lines,
196                    &new_slice_trimmed,
197                    0,
198                    chunk.is_end_of_file,
199                )
200                .is_some();
201
202            let line_refs = line_refs(&original_lines);
203            let pattern_refs: Vec<&str> = pattern.iter().map(String::as_str).collect();
204            let nearest_miss =
205                render_nearest_miss(&line_refs, &pattern_refs, original_content.len());
206            let tried_tiers =
207                "exact, trimEnd, trim, indent (tab/space), unicode, reflow (whitespace-normalized)";
208            let already_applied_hint = if already_applied {
209                "\n\nHint: the replacement content for this hunk already appears in the file. \
210                 The patch may have been partially applied in a prior turn — re-read the file \
211                 to confirm which hunks still need to apply."
212            } else {
213                ""
214            };
215
216            return Err(format!(
217                "Failed to find expected lines in {file_path}:\n{}\n\n\
218                 Tried match tiers: {tried_tiers}.\n\n{nearest_miss}{already_applied_hint}",
219                chunk.old_lines.join("\n")
220            ));
221        }
222    }
223
224    replacements.sort_by(|left, right| left.0.cmp(&right.0));
225
226    let mut result = original_lines;
227    for (start_idx, old_len, new_segment) in replacements.into_iter().rev() {
228        result.splice(start_idx..start_idx + old_len, new_segment);
229    }
230
231    if result.last().is_none_or(|line| !line.is_empty()) {
232        result.push(String::new());
233    }
234
235    Ok(result.join("\n"))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn chunk(old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
243        UpdateFileChunk {
244            old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
245            new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
246            change_context: None,
247            is_end_of_file: false,
248        }
249    }
250
251    fn context_chunk(context: &str, old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
252        UpdateFileChunk {
253            old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
254            new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
255            change_context: Some(context.to_owned()),
256            is_end_of_file: false,
257        }
258    }
259
260    fn eof_chunk(old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
261        UpdateFileChunk {
262            old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
263            new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
264            change_context: None,
265            is_end_of_file: true,
266        }
267    }
268
269    fn assert_apply_error(original: &str, file_path: &str, chunks: &[UpdateFileChunk]) -> String {
270        apply_update_chunks(original, file_path, chunks).unwrap_err()
271    }
272
273    #[test]
274    fn missing_change_context_matches_patch_parser_test_60_72() {
275        let chunks = [context_chunk("missing line", &["beta"], &["updated beta"])];
276        assert_eq!(
277            assert_apply_error("alpha\nbeta\n", "src/example.ts", &chunks),
278            "Failed to find context 'missing line' in src/example.ts"
279        );
280    }
281
282    #[test]
283    fn missing_old_lines_error_format_matches_patch_parser_test_74_85() {
284        let chunks = [chunk(&["missing line"], &["replacement line"])];
285        assert_eq!(
286            assert_apply_error("alpha\nbeta\n", "src/example.ts", &chunks),
287            "Failed to find expected lines in src/example.ts:\nmissing line\n\n\
288             Tried match tiers: exact, trimEnd, trim, indent (tab/space), unicode, reflow (whitespace-normalized).\n\n\
289             Nearest miss: no similar region found."
290        );
291    }
292
293    #[test]
294    fn already_applied_hint_matches_patch_parser_test_87_103() {
295        let chunks = [chunk(
296            &["const mainQuota = await getFreshMainQuota(auth.access, storage)"],
297            &["const mainQuota = await getMainQuotaForRouting(auth.access, storage)"],
298        )];
299        let file_with_rewrite_already_applied =
300            "alpha\nconst mainQuota = await getMainQuotaForRouting(auth.access, storage)\nbeta\n";
301
302        assert!(
303            assert_apply_error(file_with_rewrite_already_applied, "src/example.ts", &chunks)
304                .contains("already appears in the file")
305        );
306    }
307
308    #[test]
309    fn absent_old_and_new_lines_have_no_already_applied_hint_matches_patch_parser_test_105_122() {
310        let chunks = [chunk(&["missing old line"], &["missing new line"])];
311        let message = assert_apply_error("unrelated content\n", "src/example.ts", &chunks);
312        assert!(message.contains("Failed to find expected lines"));
313        assert!(!message.contains("already appears in the file"));
314    }
315
316    #[test]
317    fn spaces_patch_matches_tab_file_matches_patch_parser_test_124_147() {
318        let file = "function foo() {\n\treturn 42;\n}\n";
319        let chunks = [chunk(&["    return 42;"], &["    return 43;"])];
320
321        assert_eq!(
322            apply_update_chunks(file, "src/foo.ts", &chunks).unwrap(),
323            "function foo() {\n    return 43;\n}\n"
324        );
325    }
326
327    #[test]
328    fn tab_patch_matches_spaces_file_matches_patch_parser_test_149_162() {
329        let file = "function foo() {\n    return 42;\n}\n";
330        let chunks = [chunk(&["\treturn 42;"], &["\treturn 43;"])];
331
332        assert_eq!(
333            apply_update_chunks(file, "src/foo.ts", &chunks).unwrap(),
334            "function foo() {\n\treturn 43;\n}\n"
335        );
336    }
337
338    #[test]
339    fn closest_match_diagnostic_matches_patch_parser_test_164_195() {
340        let file =
341            "function foo() {\n  const x = 1;\n  const y = 2;\n  const z = 3;\n  return x + y + z;\n}\n";
342        let chunks = [chunk(
343            &["  const x = 1;", "  const y = 2;", "  const Q = 99;"],
344            &["  const x = 1;", "  const y = 2;", "  const Q = 100;"],
345        )];
346
347        let message = assert_apply_error(file, "src/foo.ts", &chunks);
348        assert!(message.contains("Nearest miss at lines 2-4 (matched 2/3 context lines):"));
349        assert!(message.contains("  2 |   const x = 1;"));
350        assert!(message.contains("  3 |   const y = 2;"));
351        assert!(message.contains("  4 |   const z = 3;"));
352        assert!(message.contains(
353            "First divergence: wanted line 3 `  const Q = 99;` vs file line 4 `  const z = 3;`"
354        ));
355    }
356
357    #[test]
358    fn tried_tiers_diagnostic_matches_patch_parser_test_197_221() {
359        let chunks = [chunk(&["completely unrelated line"], &["replacement"])];
360        let message = assert_apply_error("alpha\nbeta\ngamma\n", "src/foo.ts", &chunks);
361
362        assert!(message.contains("Tried match tiers:"));
363        assert!(message.contains("exact"));
364        assert!(message.contains("trim"));
365        assert!(message.contains("indent"));
366        assert!(message.contains("unicode"));
367    }
368
369    #[test]
370    fn oversized_file_failure_explains_that_nearest_miss_was_skipped() {
371        let synthetic_file = "x".repeat(NEAREST_MISS_MAX_FILE_BYTES + 1);
372        let chunks = [chunk(&["wanted content"], &["replacement"])];
373
374        let message = assert_apply_error(&synthetic_file, "src/large.txt", &chunks);
375        assert!(message.contains("Nearest miss skipped: file is"));
376        assert!(message.contains("above the 2 MiB diagnostic limit"));
377    }
378
379    #[test]
380    fn reflow_one_line_to_three_line_split_matches_patch_parser_test_225_240() {
381        let original =
382            "function demo() {\n  const value = alpha +\n    beta +\n    gamma;\n  return value;\n}\n";
383        let chunks = [chunk(
384            &["  const value = alpha + beta + gamma;"],
385            &["  const value = alpha + beta + delta;"],
386        )];
387
388        assert_eq!(
389            apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
390            "function demo() {\n  const value = alpha + beta + delta;\n  return value;\n}\n"
391        );
392    }
393
394    #[test]
395    fn reflow_three_line_to_one_line_join_matches_patch_parser_test_242_254() {
396        let original = "function demo() {\n  const value = alpha + beta + gamma;\n}\n";
397        let chunks = [chunk(
398            &["  const value = alpha +", "    beta +", "    gamma;"],
399            &["  const value = alpha +", "    beta +", "    delta;"],
400        )];
401
402        assert_eq!(
403            apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
404            "function demo() {\n  const value = alpha +\n    beta +\n    delta;\n}\n"
405        );
406    }
407
408    #[test]
409    fn ambiguous_reflow_rejects_matches_patch_parser_test_256_269() {
410        let original =
411            "const value = alpha +\n  beta +\n  gamma;\n\nconst value = alpha +\n  beta +\n  gamma;\n";
412        let chunks = [chunk(
413            &["const value = alpha + beta + gamma;"],
414            &["const value = alpha + beta + delta;"],
415        )];
416
417        assert!(assert_apply_error(original, "src/demo.ts", &chunks)
418            .contains("Failed to find expected lines in src/demo.ts"));
419    }
420
421    #[test]
422    fn reflow_near_miss_rejects_matches_patch_parser_test_271_282() {
423        let chunks = [chunk(
424            &["const value = alpha + beta + delta;"],
425            &["const value = alpha + beta + epsilon;"],
426        )];
427
428        assert!(assert_apply_error(
429            "const value = alpha +\n  beta +\n  gamma;\n",
430            "src/demo.ts",
431            &chunks,
432        )
433        .contains("Failed to find expected lines in src/demo.ts"));
434    }
435
436    #[test]
437    fn contiguous_match_wins_before_reflow_matches_patch_parser_test_284_299() {
438        let original =
439            "const value = alpha +\n  beta +\n  gamma;\nconst value = alpha + beta + gamma;\n";
440        let chunks = [chunk(
441            &["const value = alpha + beta + gamma;"],
442            &["const value = alpha + beta + delta;"],
443        )];
444
445        assert_eq!(
446            apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
447            "const value = alpha +\n  beta +\n  gamma;\nconst value = alpha + beta + delta;\n"
448        );
449    }
450
451    #[test]
452    fn strict_tiers_stay_ahead_of_reflow_matches_patch_parser_test_301_342() {
453        let cases = [
454            (
455                "src/rstrip.ts",
456                "const value = alpha +\n  beta +\n  gamma;\nconst value = alpha + beta + gamma;   \n",
457                vec!["const value = alpha + beta + gamma;"],
458                "const value = alpha +\n  beta +\n  gamma;\nconst value = alpha + beta + delta;\n",
459            ),
460            (
461                "src/trim.ts",
462                "const value = alpha +\n  beta +\n  gamma;\n  const value = alpha + beta + gamma;\n",
463                vec!["const value = alpha + beta + gamma;"],
464                "const value = alpha +\n  beta +\n  gamma;\nconst value = alpha + beta + delta;\n",
465            ),
466            (
467                "src/unicode.ts",
468                "const label =\n  \"alpha\";\nconst label = “alpha”;\n",
469                vec!["const label = \"alpha\";"],
470                "const label =\n  \"alpha\";\nconst value = alpha + beta + delta;\n",
471            ),
472        ];
473
474        for (file_path, original, old_lines, expected) in cases {
475            let chunks = [chunk(&old_lines, &["const value = alpha + beta + delta;"])];
476            assert_eq!(
477                apply_update_chunks(original, file_path, &chunks).unwrap(),
478                expected
479            );
480        }
481    }
482
483    #[test]
484    fn pure_insertion_with_context_matches_patch_parser_test_345_367() {
485        let original = "function foo() {\n  return 1;\n}\n\nfunction bar() {\n  return 2;\n}\n";
486        let chunks = [context_chunk("function foo() {", &[], &["  const x = 42;"])];
487
488        let result = apply_update_chunks(original, "src/example.ts", &chunks).unwrap();
489        assert_eq!(
490            result,
491            "function foo() {\n  const x = 42;\n  return 1;\n}\n\nfunction bar() {\n  return 2;\n}\n"
492        );
493        assert!(!result.contains("  return 2;\n  const x = 42;"));
494    }
495
496    #[test]
497    fn pure_insertion_without_context_matches_patch_parser_test_369_380() {
498        let original = "alpha\nbeta\n";
499        let chunks = [chunk(&[], &["gamma"])];
500
501        assert_eq!(
502            apply_update_chunks(original, "src/example.ts", &chunks).unwrap(),
503            "alpha\nbeta\ngamma\n"
504        );
505    }
506
507    #[test]
508    fn pure_insertion_does_not_short_circuit_matches_patch_parser_test_382_397() {
509        let original = "import a;\nimport b;\n\nconst x = 1;\n";
510        let chunks = [context_chunk("import a;", &[], &["import inserted;"])];
511
512        assert_eq!(
513            apply_update_chunks(original, "src/example.ts", &chunks).unwrap(),
514            "import a;\nimport inserted;\nimport b;\n\nconst x = 1;\n"
515        );
516    }
517
518    #[test]
519    fn eof_hunk_applies_final_occurrence_matches_patch_parser_test_400_414() {
520        let original = "header\nmarker\nold\nmiddle\nmarker\nold\n";
521        let chunks = [eof_chunk(&["marker", "old"], &["marker", "new"])];
522
523        assert_eq!(
524            apply_update_chunks(original, "src/eof.ts", &chunks).unwrap(),
525            "header\nmarker\nold\nmiddle\nmarker\nnew\n"
526        );
527    }
528
529    #[test]
530    fn eof_hunk_rejects_forward_scan_matches_patch_parser_test_416_429() {
531        let original = "header\nmarker\nold\nmiddle\nmarker\nchanged\n";
532        let chunks = [eof_chunk(&["marker", "old"], &["marker", "new"])];
533
534        assert!(assert_apply_error(original, "src/eof.ts", &chunks)
535            .contains("Failed to find expected lines in src/eof.ts"));
536    }
537
538    #[test]
539    fn trailing_empty_line_retry_matches_patch_parser_source_514_519() {
540        let chunks = [chunk(&["alpha", ""], &["beta", ""])];
541
542        assert_eq!(
543            apply_update_chunks("alpha\n", "src/trailing.ts", &chunks).unwrap(),
544            "beta\n"
545        );
546    }
547}