Skip to main content

merge_engine/
patterns.rs

1//! Pattern-based DSL resolution rules.
2//!
3//! Implements a domain-specific language for common merge conflict resolution
4//! patterns, inspired by the program synthesis approach from Svyatkovskiy et al.
5//! (ICSE 2021) which found that ~28% of merge conflicts follow repetitive patterns
6//! and ~41% of 1-2 line changes can be resolved by a DSL.
7//!
8//! Instead of learning patterns via synthesis, we encode the most common resolution
9//! patterns as declarative rules. Each rule has:
10//! - A **predicate** that checks if the conflict matches the pattern
11//! - A **transform** that produces the resolution
12//!
13//! Common patterns from the literature:
14//! 1. Both sides add imports/includes → keep both (union)
15//! 2. Both sides modify the same value → prefer longer/more specific
16//! 3. One side adds, other modifies → combine additions
17//! 4. Adjacent-line edits (false conflict) → concatenate
18//! 5. Whitespace/formatting only differences → pick either
19//! 6. Both sides add to a list → interleave or concatenate
20//! 7. Identical deletions → accept deletion
21
22use crate::types::{Confidence, MergeScenario, ResolutionCandidate, ResolutionStrategy};
23
24/// A pattern rule that can match and resolve a conflict.
25pub trait PatternRule: Send + Sync {
26    /// Human-readable name for the rule.
27    fn name(&self) -> &str;
28
29    /// Check if this rule matches the given conflict scenario.
30    fn matches(&self, scenario: &MergeScenario<&str>) -> bool;
31
32    /// Produce a resolution. Only called if `matches` returned true.
33    fn resolve(&self, scenario: &MergeScenario<&str>) -> String;
34
35    /// Confidence level of this rule's resolution.
36    fn confidence(&self) -> Confidence;
37}
38
39/// Registry of all pattern rules.
40pub struct PatternRegistry {
41    rules: Vec<Box<dyn PatternRule>>,
42}
43
44impl PatternRegistry {
45    /// Create a registry with all built-in rules.
46    pub fn new() -> Self {
47        Self {
48            rules: vec![
49                Box::new(WhitespaceOnlyRule),
50                Box::new(IdenticalChangeRule),
51                Box::new(BothAddLinesRule),
52                Box::new(OneEmptyRule),
53                Box::new(PrefixSuffixRule),
54                Box::new(ImportUnionRule),
55                Box::new(AdjacentEditRule),
56            ],
57        }
58    }
59
60    /// Try all rules against a conflict, returning the first match.
61    pub fn try_resolve(&self, scenario: &MergeScenario<&str>) -> Option<ResolutionCandidate> {
62        for rule in &self.rules {
63            if rule.matches(scenario) {
64                return Some(ResolutionCandidate {
65                    content: rule.resolve(scenario),
66                    confidence: rule.confidence(),
67                    strategy: ResolutionStrategy::PatternRule,
68                });
69            }
70        }
71        None
72    }
73
74    /// Try all rules and return ALL matching resolutions, not just the first.
75    pub fn try_resolve_all(&self, scenario: &MergeScenario<&str>) -> Vec<ResolutionCandidate> {
76        self.rules
77            .iter()
78            .filter(|rule| rule.matches(scenario))
79            .map(|rule| ResolutionCandidate {
80                content: rule.resolve(scenario),
81                confidence: rule.confidence(),
82                strategy: ResolutionStrategy::PatternRule,
83            })
84            .collect()
85    }
86}
87
88impl Default for PatternRegistry {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94// ──────────────────────────────────────────────────────────────
95// Rule 1: Whitespace-only differences
96// ──────────────────────────────────────────────────────────────
97
98/// If the conflict is only whitespace/formatting differences, pick left.
99struct WhitespaceOnlyRule;
100
101impl PatternRule for WhitespaceOnlyRule {
102    fn name(&self) -> &str {
103        "whitespace-only"
104    }
105
106    fn matches(&self, scenario: &MergeScenario<&str>) -> bool {
107        let base_norm = normalize_whitespace(scenario.base);
108        let left_norm = normalize_whitespace(scenario.left);
109        let right_norm = normalize_whitespace(scenario.right);
110        // If all three are the same after whitespace normalization, it's a false conflict
111        base_norm == left_norm && base_norm == right_norm || left_norm == right_norm
112    }
113
114    fn resolve(&self, scenario: &MergeScenario<&str>) -> String {
115        // Prefer the version with more intentional formatting (left by convention)
116        scenario.left.to_string()
117    }
118
119    fn confidence(&self) -> Confidence {
120        Confidence::High
121    }
122}
123
124// ──────────────────────────────────────────────────────────────
125// Rule 2: Identical changes from both sides
126// ──────────────────────────────────────────────────────────────
127
128/// Both sides made the exact same change — just accept it.
129struct IdenticalChangeRule;
130
131impl PatternRule for IdenticalChangeRule {
132    fn name(&self) -> &str {
133        "identical-change"
134    }
135
136    fn matches(&self, scenario: &MergeScenario<&str>) -> bool {
137        scenario.left == scenario.right && scenario.left != scenario.base
138    }
139
140    fn resolve(&self, scenario: &MergeScenario<&str>) -> String {
141        scenario.left.to_string()
142    }
143
144    fn confidence(&self) -> Confidence {
145        Confidence::High
146    }
147}
148
149// ──────────────────────────────────────────────────────────────
150// Rule 3: Both sides add new lines (no modification to base)
151// ──────────────────────────────────────────────────────────────
152
153/// Both sides added lines while the base is empty or both additions start
154/// after the base content. Concatenate both additions.
155struct BothAddLinesRule;
156
157impl PatternRule for BothAddLinesRule {
158    fn name(&self) -> &str {
159        "both-add-lines"
160    }
161
162    fn matches(&self, scenario: &MergeScenario<&str>) -> bool {
163        let base = scenario.base.trim();
164        if !base.is_empty() {
165            return false;
166        }
167        // Both sides are purely additions
168        !scenario.left.trim().is_empty() && !scenario.right.trim().is_empty()
169    }
170
171    fn resolve(&self, scenario: &MergeScenario<&str>) -> String {
172        let mut result = scenario.left.to_string();
173        if !result.ends_with('\n') {
174            result.push('\n');
175        }
176        result.push_str(scenario.right);
177        result
178    }
179
180    fn confidence(&self) -> Confidence {
181        Confidence::Medium
182    }
183}
184
185// ──────────────────────────────────────────────────────────────
186// Rule 4: One side is empty (deletion vs. modification)
187// ──────────────────────────────────────────────────────────────
188
189/// One side deleted the content while the other modified it.
190/// Accept the modification (prefer data preservation).
191struct OneEmptyRule;
192
193impl PatternRule for OneEmptyRule {
194    fn name(&self) -> &str {
195        "one-empty"
196    }
197
198    fn matches(&self, scenario: &MergeScenario<&str>) -> bool {
199        let left_empty = scenario.left.trim().is_empty();
200        let right_empty = scenario.right.trim().is_empty();
201        (left_empty && !right_empty) || (!left_empty && right_empty)
202    }
203
204    fn resolve(&self, scenario: &MergeScenario<&str>) -> String {
205        if scenario.left.trim().is_empty() {
206            scenario.right.to_string()
207        } else {
208            scenario.left.to_string()
209        }
210    }
211
212    fn confidence(&self) -> Confidence {
213        Confidence::Medium
214    }
215}
216
217// ──────────────────────────────────────────────────────────────
218// Rule 5: One side is a prefix/suffix of the other
219// ──────────────────────────────────────────────────────────────
220
221/// If one side's change is a prefix or suffix of the other, take the longer one.
222/// This captures the common pattern where both sides extend the same code
223/// but one went further.
224struct PrefixSuffixRule;
225
226impl PatternRule for PrefixSuffixRule {
227    fn name(&self) -> &str {
228        "prefix-suffix"
229    }
230
231    fn matches(&self, scenario: &MergeScenario<&str>) -> bool {
232        let left = scenario.left.trim();
233        let right = scenario.right.trim();
234        if left == right {
235            return false;
236        }
237        left.starts_with(right)
238            || right.starts_with(left)
239            || left.ends_with(right)
240            || right.ends_with(left)
241    }
242
243    fn resolve(&self, scenario: &MergeScenario<&str>) -> String {
244        let left = scenario.left.trim();
245        let right = scenario.right.trim();
246        // Take the longer (more complete) version
247        if left.len() >= right.len() {
248            scenario.left.to_string()
249        } else {
250            scenario.right.to_string()
251        }
252    }
253
254    fn confidence(&self) -> Confidence {
255        Confidence::Medium
256    }
257}
258
259// ──────────────────────────────────────────────────────────────
260// Rule 6: Import/include union
261// ──────────────────────────────────────────────────────────────
262
263/// Both sides added different import/include/use statements.
264/// Take the union (deduplicated, sorted).
265struct ImportUnionRule;
266
267impl PatternRule for ImportUnionRule {
268    fn name(&self) -> &str {
269        "import-union"
270    }
271
272    fn matches(&self, scenario: &MergeScenario<&str>) -> bool {
273        // Check if all non-empty lines look like import/use/include statements
274        let all_imports = |text: &str| {
275            text.lines()
276                .filter(|l| !l.trim().is_empty())
277                .all(is_import_line)
278        };
279        all_imports(scenario.base) && all_imports(scenario.left) && all_imports(scenario.right)
280    }
281
282    fn resolve(&self, scenario: &MergeScenario<&str>) -> String {
283        let mut imports: Vec<String> = Vec::new();
284
285        // Collect all unique imports from both sides
286        for line in scenario.left.lines().chain(scenario.right.lines()) {
287            let trimmed = line.trim().to_string();
288            if !trimmed.is_empty() && !imports.contains(&trimmed) {
289                imports.push(trimmed);
290            }
291        }
292
293        imports.sort();
294        imports.join("\n")
295    }
296
297    fn confidence(&self) -> Confidence {
298        Confidence::High
299    }
300}
301
302// ──────────────────────────────────────────────────────────────
303// Rule 7: Adjacent edits (different lines modified)
304// ──────────────────────────────────────────────────────────────
305
306/// Both sides edited different lines within the conflict region.
307/// If we can identify which lines each side actually changed, interleave cleanly.
308struct AdjacentEditRule;
309
310impl PatternRule for AdjacentEditRule {
311    fn name(&self) -> &str {
312        "adjacent-edit"
313    }
314
315    fn matches(&self, scenario: &MergeScenario<&str>) -> bool {
316        let base_lines: Vec<&str> = scenario.base.lines().collect();
317        let left_lines: Vec<&str> = scenario.left.lines().collect();
318        let right_lines: Vec<&str> = scenario.right.lines().collect();
319
320        // Must have the same number of lines
321        if base_lines.len() != left_lines.len() || base_lines.len() != right_lines.len() {
322            return false;
323        }
324
325        // Each line should be changed by at most one side
326        for i in 0..base_lines.len() {
327            let left_changed = base_lines[i] != left_lines[i];
328            let right_changed = base_lines[i] != right_lines[i];
329            if left_changed && right_changed {
330                return false;
331            }
332        }
333
334        // At least one line changed on each side
335        let left_has_changes = base_lines
336            .iter()
337            .zip(left_lines.iter())
338            .any(|(b, l)| b != l);
339        let right_has_changes = base_lines
340            .iter()
341            .zip(right_lines.iter())
342            .any(|(b, r)| b != r);
343        left_has_changes && right_has_changes
344    }
345
346    fn resolve(&self, scenario: &MergeScenario<&str>) -> String {
347        let base_lines: Vec<&str> = scenario.base.lines().collect();
348        let left_lines: Vec<&str> = scenario.left.lines().collect();
349        let right_lines: Vec<&str> = scenario.right.lines().collect();
350
351        let mut result = Vec::new();
352        for i in 0..base_lines.len() {
353            if base_lines[i] != left_lines[i] {
354                result.push(left_lines[i]);
355            } else if base_lines[i] != right_lines[i] {
356                result.push(right_lines[i]);
357            } else {
358                result.push(base_lines[i]);
359            }
360        }
361
362        result.join("\n")
363    }
364
365    fn confidence(&self) -> Confidence {
366        Confidence::High
367    }
368}
369
370// ──────────────────────────────────────────────────────────────
371// Helpers
372// ──────────────────────────────────────────────────────────────
373
374fn normalize_whitespace(s: &str) -> String {
375    s.split_whitespace().collect::<Vec<_>>().join(" ")
376}
377
378fn is_import_line(line: &str) -> bool {
379    let trimmed = line.trim();
380    trimmed.starts_with("import ")
381        || trimmed.starts_with("from ")
382        || trimmed.starts_with("use ")
383        || trimmed.starts_with("#include")
384        || trimmed.starts_with("require(")
385        || trimmed.starts_with("const ")
386            && (trimmed.contains("require(") || trimmed.contains("import("))
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_whitespace_only() {
395        let scenario = MergeScenario::new(
396            "int x = 1;",
397            "int  x = 1;", // extra space
398            "int x  = 1;", // different extra space
399        );
400        let registry = PatternRegistry::new();
401        let result = registry.try_resolve(&scenario);
402        assert!(result.is_some());
403        assert_eq!(result.unwrap().confidence, Confidence::High);
404    }
405
406    #[test]
407    fn test_identical_change() {
408        let scenario = MergeScenario::new("old", "new", "new");
409        let registry = PatternRegistry::new();
410        let result = registry.try_resolve(&scenario);
411        assert!(result.is_some());
412        assert_eq!(result.unwrap().content, "new");
413    }
414
415    #[test]
416    fn test_both_add_lines() {
417        let scenario = MergeScenario::new("", "line_a", "line_b");
418        let registry = PatternRegistry::new();
419        let result = registry.try_resolve(&scenario);
420        assert!(result.is_some());
421        assert!(result.unwrap().content.contains("line_a"));
422    }
423
424    #[test]
425    fn test_import_union() {
426        let scenario = MergeScenario::new(
427            "import a\nimport b",
428            "import a\nimport b\nimport c",
429            "import a\nimport b\nimport d",
430        );
431        let registry = PatternRegistry::new();
432        let result = registry.try_resolve(&scenario);
433        assert!(result.is_some());
434        let content = result.unwrap().content;
435        assert!(content.contains("import c"));
436        assert!(content.contains("import d"));
437    }
438
439    #[test]
440    fn test_adjacent_edit() {
441        let scenario = MergeScenario::new(
442            "line1\nline2\nline3",
443            "modified1\nline2\nline3",
444            "line1\nline2\nmodified3",
445        );
446        let registry = PatternRegistry::new();
447        let result = registry.try_resolve(&scenario);
448        assert!(result.is_some());
449        let content = result.unwrap().content;
450        assert!(content.contains("modified1"));
451        assert!(content.contains("modified3"));
452    }
453
454    #[test]
455    fn test_prefix_suffix() {
456        let scenario = MergeScenario::new("base", "extended_base", "extended_base_more");
457        let registry = PatternRegistry::new();
458        let result = registry.try_resolve(&scenario);
459        assert!(result.is_some());
460        assert!(result.unwrap().content.contains("extended_base_more"));
461    }
462}