Skip to main content

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};