semantic/merge_driver/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Function-level three-way merge driver.
3//!
4//! Decomposes a parseable source file into AST-defined items, merges each item
5//! independently, and falls back to `heddle-merge::text_hunk_merge` on items
6//! that can't be resolved structurally — and on the entire file when the
7//! parser declines.
8//!
9//! See `docs/design/semantic-merge-function-level.md` for the contract.
10
11use std::path::Path;
12
13use merge::{ConflictMarkers, MergeOutcome, text_hunk_merge_with_markers};
14
15use crate::{
16 cache::SemanticParseCache,
17 parser::{Language, ParsedFile},
18};
19
20mod items;
21mod language_rules;
22mod reconstruct;
23
24#[cfg(test)]
25mod tests;
26
27use items::segment_file;
28use reconstruct::reconstruct_merged_file;
29
30/// Three-way merge of `base`, `ours`, `theirs` using AST-defined item boundaries
31/// when the parser accepts all three sides, falling back to
32/// [`text_hunk_merge_with_markers`] otherwise.
33///
34/// The `path` is used for language detection only; it does NOT need to exist
35/// on disk.
36pub fn semantic_three_way_merge(
37 base: &[u8],
38 ours: &[u8],
39 theirs: &[u8],
40 path: &Path,
41 markers: ConflictMarkers<'_>,
42) -> MergeOutcome {
43 if base == ours && base == theirs {
44 return MergeOutcome::Clean(base.to_vec());
45 }
46 if base == ours {
47 return MergeOutcome::Clean(theirs.to_vec());
48 }
49 if base == theirs {
50 return MergeOutcome::Clean(ours.to_vec());
51 }
52 if ours == theirs {
53 return MergeOutcome::Clean(ours.to_vec());
54 }
55
56 let language = Language::from_path(path);
57 if matches!(language, Language::Unknown) {
58 return text_hunk_merge_with_markers(base, ours, theirs, markers);
59 }
60
61 let (Ok(base_text), Ok(ours_text), Ok(theirs_text)) = (
62 std::str::from_utf8(base),
63 std::str::from_utf8(ours),
64 std::str::from_utf8(theirs),
65 ) else {
66 return text_hunk_merge_with_markers(base, ours, theirs, markers);
67 };
68
69 // Share the process-wide parse cache with semantic diff so base/ours/theirs
70 // are not thrice-cold on every merge when the same blobs were just parsed
71 // for classification or a prior merge attempt.
72 let cache = SemanticParseCache::shared();
73 let (Some(base_parsed), Some(ours_parsed), Some(theirs_parsed)) = (
74 cache.parse(base_text, language),
75 cache.parse(ours_text, language),
76 cache.parse(theirs_text, language),
77 ) else {
78 return text_hunk_merge_with_markers(base, ours, theirs, markers);
79 };
80
81 let mut base_segments = segment_file(&base_parsed);
82 let mut ours_segments = segment_file(&ours_parsed);
83 let mut theirs_segments = segment_file(&theirs_parsed);
84
85 // Rekey `use` items so declarations whose expanded leaf sets intersect
86 // on ANY path collide for cross-side matching (heddle#468; Codex r2 on
87 // PR #477). Must run before the empty-base add/add guard below and
88 // before reconstruction, both of which key off `Item`/`ItemKey`.
89 items::canonicalize_use_keys(&mut base_segments, &mut ours_segments, &mut theirs_segments);
90
91 // When a side has zero parseable items but the others do, the
92 // per-item alignment has nothing to anchor on for that side and
93 // its contiguous content can't be cleanly split across the other
94 // sides' per-item segments — the surrounding preamble/postamble
95 // merges either drop the side's edits (Codex r2 P1 #3) or
96 // double-emit its bridging content. text_hunk_merge handles the
97 // full-file alignment without those artifacts, so route this
98 // shape through it.
99 //
100 // EXCEPTION: empty base with both sides adding items that share
101 // keys (add/add). text_hunk_merge concatenates both insertions
102 // at the same anchor and silently produces duplicate definitions;
103 // `resolve_item`'s add/add arm is the only path that surfaces this
104 // as a conflict. Drop through to the reconstruct path in that case
105 // so the conflict is reported (Codex r3 P1 #1).
106 let counts = [
107 base_segments.items.len(),
108 ours_segments.items.len(),
109 theirs_segments.items.len(),
110 ];
111 if counts.contains(&0) && counts.iter().any(|&c| c > 0) {
112 let addadd_in_empty_base = base_segments.items.is_empty() && {
113 let ours_keys: std::collections::BTreeSet<_> =
114 ours_segments.items.iter().map(|i| &i.key).collect();
115 theirs_segments
116 .items
117 .iter()
118 .any(|i| ours_keys.contains(&i.key))
119 };
120 if !addadd_in_empty_base {
121 return text_hunk_merge_with_markers(base, ours, theirs, markers);
122 }
123 }
124
125 let outcome = reconstruct_merged_file(
126 base_text,
127 ours_text,
128 theirs_text,
129 &base_segments,
130 &ours_segments,
131 &theirs_segments,
132 markers,
133 );
134
135 // Input-grounded safety net (heddle#490 P3 floor). The tree model makes
136 // silent structural collapse impossible *by construction*, but a cheap
137 // conservation check against the INPUTS — not the merge's own resolved
138 // metadata — is kept as defense-in-depth: if a CLEAN merge ever fails to
139 // re-parse or invents an item/nesting no input had, route to the textual
140 // path instead of emitting the corruption.
141 //
142 // The floor also guards CONFLICT outputs (heddle#490 r6). The clean-only
143 // check could not see a malformed body that ships *alongside* a real
144 // conflict: a divergent container header plus an empty-base both-sides-add
145 // emitted a duplicated opening `{` (so `{`/`}` no longer balance) while the
146 // outcome was `Conflicts`, and a conflict skipped the clean floor — the
147 // malformed markers shipped silently. A conflict the user resolves must
148 // still be well-formed: resolving the markers to EITHER side must yield a
149 // file that re-parses. If a resolution is unparseable (an unbalanced /
150 // duplicated delimiter), route to the textual fallback, whose markers are
151 // well-formed by construction. Part 1 (structural body delimiter) makes
152 // this hold by construction; this guard keeps a future weave regression
153 // from re-shipping the class silently.
154 match &outcome {
155 MergeOutcome::Clean(output) => {
156 if !conserves_inputs(output, language, &base_parsed, &ours_parsed, &theirs_parsed) {
157 return text_hunk_merge_with_markers(base, ours, theirs, markers);
158 }
159 }
160 MergeOutcome::Conflicts {
161 merged_bytes_with_markers,
162 ..
163 } => {
164 if !conflict_well_formed(merged_bytes_with_markers, language) {
165 return text_hunk_merge_with_markers(base, ours, theirs, markers);
166 }
167 }
168 MergeOutcome::Binary | MergeOutcome::DeleteVsModify => {}
169 }
170 outcome
171}
172
173/// Whether a conflict output is structurally well-formed: resolving its markers
174/// to EITHER side independently yields a file that re-parses.
175///
176/// Reuses the clean floor's re-parse signal ([`ParsedFile::parse`] returns
177/// `None` on a tree with errors) so both floors close the SAME class with the
178/// same mechanism. The duplicate-delimiter corruption (heddle#490 r6) leaves a
179/// resolved side with an unbalanced `{`/`}`, which fails to parse and is caught
180/// here. If the markers themselves are malformed (unbalanced
181/// `<<<<<<< / ======= / >>>>>>>` nesting) the resolver returns `None`, which is
182/// likewise treated as not-well-formed.
183fn conflict_well_formed(output: &[u8], language: Language) -> bool {
184 let Ok(text) = std::str::from_utf8(output) else {
185 return false;
186 };
187 let Some((ours, theirs)) = resolve_conflict_sides(text) else {
188 return false;
189 };
190 ParsedFile::parse(ours.as_str(), language).is_some()
191 && ParsedFile::parse(theirs.as_str(), language).is_some()
192}
193
194/// Resolve conflict-marked `text` into its two independent sides: the
195/// take-ours resolution (drop the `theirs` hunks + markers) and the take-theirs
196/// resolution (drop the `ours` hunks + markers). Mirrors the marker shape
197/// emitted by [`merge::markers`] / `reconstruct::emit_addadd_conflict`:
198/// `<<<<<<< <label>` … `=======` … `>>>>>>> <label>`.
199///
200/// Returns `None` when the markers are malformed — a `=======` outside an open
201/// `<<<<<<<`, a `>>>>>>>` outside an open `=======`, a nested `<<<<<<<`, or an
202/// unterminated block at end of input — so a structurally broken conflict is
203/// itself surfaced as not-well-formed.
204fn resolve_conflict_sides(text: &str) -> Option<(String, String)> {
205 enum State {
206 Normal,
207 Ours,
208 Theirs,
209 }
210 let mut ours = String::new();
211 let mut theirs = String::new();
212 let mut state = State::Normal;
213 for line in text.split_inclusive('\n') {
214 let marker = conflict_marker(line);
215 if matches!(marker, Some(ConflictMarker::Start)) {
216 match state {
217 State::Normal => state = State::Ours,
218 _ => return None,
219 }
220 } else if matches!(marker, Some(ConflictMarker::Separator)) {
221 match state {
222 State::Ours => state = State::Theirs,
223 _ => return None,
224 }
225 } else if matches!(marker, Some(ConflictMarker::End)) {
226 match state {
227 State::Theirs => state = State::Normal,
228 _ => return None,
229 }
230 } else {
231 match state {
232 State::Normal => {
233 ours.push_str(line);
234 theirs.push_str(line);
235 }
236 State::Ours => ours.push_str(line),
237 State::Theirs => theirs.push_str(line),
238 }
239 }
240 }
241 match state {
242 State::Normal => Some((ours, theirs)),
243 _ => None,
244 }
245}
246
247#[derive(Clone, Copy, Debug, Eq, PartialEq)]
248enum ConflictMarker {
249 Start,
250 Separator,
251 End,
252}
253
254fn conflict_marker(line: &str) -> Option<ConflictMarker> {
255 let body = line.strip_suffix('\n').unwrap_or(line);
256 let body = body.strip_suffix('\r').unwrap_or(body).trim_start();
257
258 if marker_body_matches(body, "<<<<<<<") {
259 Some(ConflictMarker::Start)
260 } else if marker_body_matches(body, "=======") {
261 Some(ConflictMarker::Separator)
262 } else if marker_body_matches(body, ">>>>>>>") {
263 Some(ConflictMarker::End)
264 } else {
265 None
266 }
267}
268
269fn marker_body_matches(body: &str, marker: &str) -> bool {
270 let Some(rest) = body.strip_prefix(marker) else {
271 return false;
272 };
273 rest.is_empty() || rest.starts_with(' ')
274}
275
276/// Whether a clean `output` conserves the structure of its inputs. Re-parses
277/// the output and checks, against the three inputs (re-segmented raw so `use`
278/// keys compare on the same footing as the output's):
279///
280/// 1. **Re-parse** — a clean merge that yields an unparseable file is a
281/// corruption (catches a collapse's unbalanced delimiters).
282/// 2. **Item-identity subset** — every `(scope, kind, name)` in the output
283/// must appear in some input; the merge may not invent an item or move one
284/// into a scope no contributing side gave it (catches mis-nesting / a child
285/// escaping its container).
286///
287/// Both checks are deletion-safe (a subset relation, not equality), so a
288/// legitimate clean merge with deletions passes; and edit-safe (they key on
289/// item identity, not line text), so a within-line edit that recombines bytes
290/// passes. The line-duplication class the harness pins (Bug 1's doubled
291/// postamble) is excluded by construction in the tree model and covered by the
292/// ported conformance tests, so it needs no production line-budget check.
293fn conserves_inputs(
294 output: &[u8],
295 language: Language,
296 base_parsed: &ParsedFile,
297 ours_parsed: &ParsedFile,
298 theirs_parsed: &ParsedFile,
299) -> bool {
300 use std::collections::BTreeSet;
301
302 let Ok(out_text) = std::str::from_utf8(output) else {
303 return false;
304 };
305 let Some(out_parsed) = ParsedFile::parse(out_text, language) else {
306 return false;
307 };
308
309 type Identity = (Vec<String>, items::ItemKind, String);
310 let collect = |seg: &items::FileSegments, set: &mut BTreeSet<Identity>| {
311 items::visit_items(&seg.items, &mut |i| {
312 set.insert((i.key.scope.clone(), i.key.kind, i.key.name.clone()));
313 });
314 };
315
316 let mut allowed: BTreeSet<Identity> = BTreeSet::new();
317 for parsed in [base_parsed, ours_parsed, theirs_parsed] {
318 collect(&segment_file(parsed), &mut allowed);
319 }
320 let mut got: BTreeSet<Identity> = BTreeSet::new();
321 collect(&segment_file(&out_parsed), &mut got);
322 got.is_subset(&allowed)
323}
324
325/// Strategy a merge call should use for content reconciliation.
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327pub enum MergeStrategy {
328 /// Always use `heddle-merge::text_hunk_merge` on the whole file.
329 HunkOnly,
330 /// Try AST-defined item decomposition first; fall through to
331 /// `text_hunk_merge` for unparseable / unknown-language files.
332 Semantic,
333}
334
335/// Single entry point that dispatches on [`MergeStrategy`]. Provided so call
336/// sites that already thread a strategy enum don't have to branch themselves.
337pub fn three_way_merge(
338 base: &[u8],
339 ours: &[u8],
340 theirs: &[u8],
341 path: &Path,
342 markers: ConflictMarkers<'_>,
343 strategy: MergeStrategy,
344) -> MergeOutcome {
345 match strategy {
346 MergeStrategy::HunkOnly => text_hunk_merge_with_markers(base, ours, theirs, markers),
347 MergeStrategy::Semantic => semantic_three_way_merge(base, ours, theirs, path, markers),
348 }
349}
350
351pub use merge::{ConflictMarkers as MergeConflictMarkers, MergeOutcome as MergeDriverOutcome};