Skip to main content

aft/patch/
parser.rs

1//! Parser for the opencode `*** Begin Patch` envelope.
2//!
3//! This ports the pure parsing half of `packages/opencode-plugin/src/patch-parser.ts`.
4
5use regex::Regex;
6use std::sync::OnceLock;
7
8/// Maximum patch text size in bytes to prevent memory exhaustion.
9pub const MAX_PATCH_SIZE: usize = 1024 * 1024;
10/// Maximum number of file operations per patch.
11pub const MAX_HUNKS: usize = 500;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Hunk {
15    Add {
16        path: String,
17        contents: String,
18    },
19    Delete {
20        path: String,
21    },
22    Update {
23        path: String,
24        move_path: Option<String>,
25        chunks: Vec<UpdateFileChunk>,
26    },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct UpdateFileChunk {
31    pub old_lines: Vec<String>,
32    pub new_lines: Vec<String>,
33    pub change_context: Option<String>,
34    pub is_end_of_file: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PatchHeader {
39    pub file_path: String,
40    pub move_path: Option<String>,
41    pub next_idx: usize,
42}
43
44/// Strip a whole-input heredoc wrapper; mirrors `patch-parser.ts:33-36`.
45///
46/// The TypeScript regex uses a backreference for the closing delimiter. Rust's
47/// `regex` crate intentionally omits backreferences, so this uses `regex` for
48/// the opening heredoc syntax and then verifies the matching closing delimiter
49/// manually to preserve the same anchored wrapper behavior.
50pub fn strip_heredoc(input: &str) -> String {
51    static OPEN_RE: OnceLock<Regex> = OnceLock::new();
52    let open_re = OPEN_RE.get_or_init(|| {
53        Regex::new(r#"^(?:cat\s+)?<<['"]?([A-Za-z0-9_]+)['"]?\s*\n"#)
54            .expect("heredoc opening regex should compile")
55    });
56
57    let Some(captures) = open_re.captures(input) else {
58        return input.to_owned();
59    };
60    let Some(opening) = captures.get(0) else {
61        return input.to_owned();
62    };
63    let delimiter = captures
64        .get(1)
65        .expect("heredoc regex has a delimiter capture")
66        .as_str();
67    let rest = &input[opening.end()..];
68
69    for (offset, _) in rest.match_indices('\n') {
70        let after_newline = &rest[offset + 1..];
71        let Some(after_delimiter) = after_newline.strip_prefix(delimiter) else {
72            continue;
73        };
74        if after_delimiter.chars().all(char::is_whitespace) {
75            return rest[..offset].to_owned();
76        }
77    }
78
79    input.to_owned()
80}
81
82/// Parse a file-operation header line and return its path, optional move destination, and next line index.
83pub fn parse_patch_header(lines: &[&str], start_idx: usize) -> Option<PatchHeader> {
84    let line = *lines.get(start_idx)?;
85
86    if let Some(path) = line.strip_prefix("*** Add File:") {
87        let file_path = path.trim();
88        return (!file_path.is_empty()).then(|| PatchHeader {
89            file_path: file_path.to_owned(),
90            move_path: None,
91            next_idx: start_idx + 1,
92        });
93    }
94
95    if let Some(path) = line.strip_prefix("*** Delete File:") {
96        let file_path = path.trim();
97        return (!file_path.is_empty()).then(|| PatchHeader {
98            file_path: file_path.to_owned(),
99            move_path: None,
100            next_idx: start_idx + 1,
101        });
102    }
103
104    if let Some(path) = line.strip_prefix("*** Update File:") {
105        let file_path = path.trim();
106        if file_path.is_empty() {
107            return None;
108        }
109
110        let mut move_path = None;
111        let mut next_idx = start_idx + 1;
112        if let Some(next_line) = lines.get(next_idx) {
113            if let Some(path) = next_line.strip_prefix("*** Move to:") {
114                move_path = Some(path.trim().to_owned());
115                next_idx += 1;
116            }
117        }
118
119        return Some(PatchHeader {
120            file_path: file_path.to_owned(),
121            move_path,
122            next_idx,
123        });
124    }
125
126    None
127}
128
129/// Parse added file content by collecting every `+` line until the next patch marker.
130pub fn parse_add_file_content(lines: &[&str], start_idx: usize) -> (String, usize) {
131    let mut content = String::new();
132    let mut i = start_idx;
133
134    while i < lines.len() && !lines[i].starts_with("***") {
135        if let Some(line) = lines[i].strip_prefix('+') {
136            content.push_str(line);
137            content.push('\n');
138        }
139        i += 1;
140    }
141
142    if content.ends_with('\n') {
143        content.pop();
144    }
145
146    (content, i)
147}
148
149/// Parse `@@` update chunks into old/new line vectors and optional context anchors.
150pub fn parse_update_file_chunks(lines: &[&str], start_idx: usize) -> (Vec<UpdateFileChunk>, usize) {
151    let mut chunks = Vec::new();
152    let mut i = start_idx;
153
154    while i < lines.len() && !lines[i].starts_with("***") {
155        if lines[i].starts_with("@@") {
156            let context_line = lines[i]["@@".len()..].trim();
157            i += 1;
158
159            let mut old_lines = Vec::new();
160            let mut new_lines = Vec::new();
161            let mut is_end_of_file = false;
162
163            while i < lines.len() && !lines[i].starts_with("@@") {
164                let change_line = lines[i];
165
166                if change_line == "*** End of File" {
167                    is_end_of_file = true;
168                    i += 1;
169                    break;
170                }
171                if change_line.starts_with("***") {
172                    break;
173                }
174
175                if let Some(content) = change_line.strip_prefix(' ') {
176                    old_lines.push(content.to_owned());
177                    new_lines.push(content.to_owned());
178                } else if let Some(content) = change_line.strip_prefix('-') {
179                    old_lines.push(content.to_owned());
180                } else if let Some(content) = change_line.strip_prefix('+') {
181                    new_lines.push(content.to_owned());
182                }
183
184                i += 1;
185            }
186
187            chunks.push(UpdateFileChunk {
188                old_lines,
189                new_lines,
190                change_context: (!context_line.is_empty()).then(|| context_line.to_owned()),
191                is_end_of_file,
192            });
193        } else {
194            i += 1;
195        }
196    }
197
198    (chunks, i)
199}
200
201/// Parse an opencode apply_patch envelope; mirrors `patch-parser.ts:148-201`.
202///
203/// The size guard uses `patch_text.len()` bytes. TypeScript uses string length,
204/// but the port intentionally guards bytes so Rust bounds actual allocation size.
205pub fn parse_patch(patch_text: &str) -> Result<Vec<Hunk>, String> {
206    if patch_text.len() > MAX_PATCH_SIZE {
207        return Err(format!(
208            "Patch too large: {} bytes exceeds limit of {} bytes",
209            patch_text.len(),
210            MAX_PATCH_SIZE
211        ));
212    }
213
214    let trimmed = patch_text.trim();
215    let cleaned = strip_heredoc(trimmed);
216    // Splitting on `\n` leaves CRLF's `\r` attached to patch payload lines. Remove only
217    // that terminator byte so carriage returns inside added or replacement content survive.
218    let lines: Vec<&str> = cleaned
219        .split('\n')
220        .map(|line| line.strip_suffix('\r').unwrap_or(line))
221        .collect();
222    let mut hunks = Vec::new();
223
224    let begin_idx = lines
225        .iter()
226        .position(|line| line.trim() == "*** Begin Patch");
227    let end_idx = lines.iter().position(|line| line.trim() == "*** End Patch");
228
229    let (Some(begin_idx), Some(end_idx)) = (begin_idx, end_idx) else {
230        return Err(
231            "Invalid patch format: missing *** Begin Patch / *** End Patch markers".to_owned(),
232        );
233    };
234    if begin_idx >= end_idx {
235        return Err(
236            "Invalid patch format: missing *** Begin Patch / *** End Patch markers".to_owned(),
237        );
238    }
239
240    let mut i = begin_idx + 1;
241    while i < end_idx {
242        let Some(header) = parse_patch_header(&lines, i) else {
243            i += 1;
244            continue;
245        };
246
247        if hunks.len() >= MAX_HUNKS {
248            return Err(format!(
249                "Patch exceeds maximum of {} file operations",
250                MAX_HUNKS
251            ));
252        }
253
254        if lines[i].starts_with("*** Add File:") {
255            let (contents, next_idx) = parse_add_file_content(&lines, header.next_idx);
256            hunks.push(Hunk::Add {
257                path: header.file_path,
258                contents,
259            });
260            i = next_idx;
261        } else if lines[i].starts_with("*** Delete File:") {
262            hunks.push(Hunk::Delete {
263                path: header.file_path,
264            });
265            i = header.next_idx;
266        } else if lines[i].starts_with("*** Update File:") {
267            let (chunks, next_idx) = parse_update_file_chunks(&lines, header.next_idx);
268            hunks.push(Hunk::Update {
269                path: header.file_path,
270                move_path: header.move_path,
271                chunks,
272            });
273            i = next_idx;
274        } else {
275            i += 1;
276        }
277    }
278
279    Ok(hunks)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    fn assert_parse_error(patch: &str, expected: &str) {
287        assert_eq!(parse_patch(patch).unwrap_err(), expected);
288    }
289
290    #[test]
291    fn parse_patch_missing_markers_matches_patch_parser_test_4_9() {
292        assert_parse_error(
293            "*** Add File: hello.txt\n+hello",
294            "Invalid patch format: missing *** Begin Patch / *** End Patch markers",
295        );
296    }
297
298    #[test]
299    fn parse_patch_empty_body_matches_patch_parser_test_11_13() {
300        assert_eq!(
301            parse_patch("*** Begin Patch\n*** End Patch").unwrap(),
302            vec![]
303        );
304    }
305
306    #[test]
307    fn parse_patch_ignores_empty_add_header_matches_patch_parser_test_15_17() {
308        assert_eq!(
309            parse_patch("*** Begin Patch\n*** Add File:\n+hello\n*** End Patch").unwrap(),
310            vec![]
311        );
312    }
313
314    #[test]
315    fn parse_patch_size_limit_matches_patch_parser_test_19_25() {
316        let oversized_patch = "x".repeat(MAX_PATCH_SIZE + 1);
317        assert_parse_error(
318            &oversized_patch,
319            "Patch too large: 1048577 bytes exceeds limit of 1048576 bytes",
320        );
321    }
322
323    #[test]
324    fn parse_patch_hunk_limit_matches_patch_parser_test_27_38() {
325        let mut patch = vec!["*** Begin Patch".to_owned()];
326        for index in 0..=MAX_HUNKS {
327            patch.push(format!("*** Add File: file-{index}.txt"));
328            patch.push(format!("+line {index}"));
329        }
330        patch.push("*** End Patch".to_owned());
331
332        assert_parse_error(
333            &patch.join("\n"),
334            "Patch exceeds maximum of 500 file operations",
335        );
336    }
337
338    #[test]
339    fn parse_patch_invalid_heredoc_matches_patch_parser_test_40_56() {
340        let wrapped_patch = [
341            "<<EOF",
342            "*** Begin Patch",
343            "*** Add File: hello.txt",
344            "+hello world",
345            "*** End Patch",
346            "NOT_EOF",
347        ]
348        .join("\n");
349
350        let expected = vec![Hunk::Add {
351            path: "hello.txt".to_owned(),
352            contents: "hello world".to_owned(),
353        }];
354        assert_eq!(parse_patch(&wrapped_patch).unwrap(), expected);
355        assert_eq!(
356            parse_patch(&format!("prefix\n{wrapped_patch}")).unwrap(),
357            expected
358        );
359    }
360
361    #[test]
362    fn strip_heredoc_accepts_whole_input_wrapper_from_patch_parser_source_33_36() {
363        let wrapped_patch = [
364            "cat <<'PATCH'",
365            "*** Begin Patch",
366            "*** Add File: hello.txt",
367            "+hello world",
368            "*** End Patch",
369            "PATCH",
370        ]
371        .join("\n");
372
373        assert_eq!(
374            parse_patch(&wrapped_patch).unwrap(),
375            vec![Hunk::Add {
376                path: "hello.txt".to_owned(),
377                contents: "hello world".to_owned(),
378            }]
379        );
380    }
381
382    #[test]
383    fn parse_patch_round_trips_add_delete_update_move_from_parser_source_38_141() {
384        let patch = [
385            "*** Begin Patch",
386            "*** Add File: src/new.txt",
387            "+hello",
388            "+world",
389            "*** Delete File: src/old.txt",
390            "*** Update File: src/edit.txt",
391            "@@ function demo()",
392            " const keep = true;",
393            "-const value = 1;",
394            "+const value = 2;",
395            "*** Update File: src/from.txt",
396            "*** Move to: src/to.txt",
397            "@@",
398            "-old",
399            "+new",
400            "*** End of File",
401            "*** End Patch",
402        ]
403        .join("\n");
404
405        assert_eq!(
406            parse_patch(&patch).unwrap(),
407            vec![
408                Hunk::Add {
409                    path: "src/new.txt".to_owned(),
410                    contents: "hello\nworld".to_owned(),
411                },
412                Hunk::Delete {
413                    path: "src/old.txt".to_owned(),
414                },
415                Hunk::Update {
416                    path: "src/edit.txt".to_owned(),
417                    move_path: None,
418                    chunks: vec![UpdateFileChunk {
419                        old_lines: vec![
420                            "const keep = true;".to_owned(),
421                            "const value = 1;".to_owned()
422                        ],
423                        new_lines: vec![
424                            "const keep = true;".to_owned(),
425                            "const value = 2;".to_owned()
426                        ],
427                        change_context: Some("function demo()".to_owned()),
428                        is_end_of_file: false,
429                    }],
430                },
431                Hunk::Update {
432                    path: "src/from.txt".to_owned(),
433                    move_path: Some("src/to.txt".to_owned()),
434                    chunks: vec![UpdateFileChunk {
435                        old_lines: vec!["old".to_owned()],
436                        new_lines: vec!["new".to_owned()],
437                        change_context: None,
438                        is_end_of_file: true,
439                    }],
440                },
441            ]
442        );
443    }
444
445    #[test]
446    fn parse_patch_supports_multiple_chunks_in_one_update_from_parser_source_91_141() {
447        let patch = [
448            "*** Begin Patch",
449            "*** Update File: src/multi.txt",
450            "@@ first",
451            "-one",
452            "+two",
453            "@@ second",
454            " three",
455            "-four",
456            "+five",
457            "*** End Patch",
458        ]
459        .join("\n");
460
461        assert_eq!(
462            parse_patch(&patch).unwrap(),
463            vec![Hunk::Update {
464                path: "src/multi.txt".to_owned(),
465                move_path: None,
466                chunks: vec![
467                    UpdateFileChunk {
468                        old_lines: vec!["one".to_owned()],
469                        new_lines: vec!["two".to_owned()],
470                        change_context: Some("first".to_owned()),
471                        is_end_of_file: false,
472                    },
473                    UpdateFileChunk {
474                        old_lines: vec!["three".to_owned(), "four".to_owned()],
475                        new_lines: vec!["three".to_owned(), "five".to_owned()],
476                        change_context: Some("second".to_owned()),
477                        is_end_of_file: false,
478                    },
479                ],
480            }]
481        );
482    }
483
484    #[test]
485    fn crlf_envelope_does_not_leak_carriage_returns_into_patch_content() {
486        let patch = [
487            "*** Begin Patch",
488            "*** Add File: created.txt",
489            "+created",
490            "*** Update File: existing.txt",
491            "@@",
492            "-old\rinside",
493            "+new\rinside",
494            "*** End Patch",
495        ]
496        .join("\r\n");
497
498        assert_eq!(
499            parse_patch(&patch).unwrap(),
500            vec![
501                Hunk::Add {
502                    path: "created.txt".to_owned(),
503                    contents: "created".to_owned(),
504                },
505                Hunk::Update {
506                    path: "existing.txt".to_owned(),
507                    move_path: None,
508                    chunks: vec![UpdateFileChunk {
509                        old_lines: vec!["old\rinside".to_owned()],
510                        new_lines: vec!["new\rinside".to_owned()],
511                        change_context: None,
512                        is_end_of_file: false,
513                    }],
514                },
515            ]
516        );
517    }
518}