Skip to content

Commit 8951953

Browse files
committed
feat(parser): improve diagnostic messages for merge conflicts (#15443)
Adds detection and improved error messages for version control merge conflicts encountered in the parser. This is based upon rustc's implementation.
1 parent 73f9e29 commit 8951953

File tree

10 files changed

+391
-27
lines changed

10 files changed

+391
-27
lines changed

crates/oxc_parser/src/diagnostics.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,48 @@ pub fn unexpected_token(span: Span) -> OxcDiagnostic {
5858
OxcDiagnostic::error("Unexpected token").with_label(span)
5959
}
6060

61+
#[cold]
62+
pub fn merge_conflict_marker(
63+
start_span: Span,
64+
middle_span: Option<Span>,
65+
end_span: Option<Span>,
66+
) -> OxcDiagnostic {
67+
let mut diagnostic = OxcDiagnostic::error("Encountered diff marker")
68+
.and_label(
69+
start_span.primary_label(
70+
"between this marker and `=======` is the code that we're merging into",
71+
),
72+
)
73+
.with_help(
74+
"Conflict markers indicate that a merge was started but could not be completed due to \
75+
merge conflicts.\n\
76+
To resolve a conflict, keep only the code you want and then delete the lines containing \
77+
conflict markers.\n\
78+
If you're having merge conflicts after pulling new code, the top section is the code you \
79+
already had and the bottom section is the remote code.\n\
80+
If you're in the middle of a rebase, the top section is the code being rebased onto and \
81+
the bottom section is the code coming from the current commit being rebased.\n\
82+
If you have nested conflicts, resolve the outermost conflict first.",
83+
);
84+
85+
if let Some(middle) = middle_span {
86+
diagnostic = diagnostic
87+
.and_label(middle.label("between this marker and `>>>>>>>` is the incoming code"));
88+
} else {
89+
// Incomplete conflict - missing middle or end markers
90+
diagnostic = diagnostic.with_help(
91+
"This conflict marker appears to be incomplete (missing `=======` or `>>>>>>>`).\n\
92+
Check if the conflict markers were accidentally modified or partially deleted.",
93+
);
94+
}
95+
96+
if let Some(end) = end_span {
97+
diagnostic = diagnostic.and_label(end.label("this marker concludes the conflict region"));
98+
}
99+
100+
diagnostic
101+
}
102+
61103
#[cold]
62104
pub fn expect_token(x0: &str, x1: &str, span: Span) -> OxcDiagnostic {
63105
OxcDiagnostic::error(format!("Expected `{x0}` but found `{x1}`"))

crates/oxc_parser/src/error_handler.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use oxc_allocator::Dummy;
44
use oxc_diagnostics::OxcDiagnostic;
5+
use oxc_span::Span;
56

67
use crate::{ParserImpl, diagnostics, lexer::Kind};
78

@@ -25,6 +26,15 @@ impl<'a> ParserImpl<'a> {
2526
self.set_fatal_error(error);
2627
return;
2728
}
29+
30+
// Check if this looks like a merge conflict marker
31+
if let Some(start_span) = self.is_merge_conflict_marker() {
32+
let (middle_span, end_span) = self.find_merge_conflict_markers();
33+
let error = diagnostics::merge_conflict_marker(start_span, middle_span, end_span);
34+
self.set_fatal_error(error);
35+
return;
36+
}
37+
2838
let error = diagnostics::unexpected_token(self.cur_token().span());
2939
self.set_fatal_error(error);
3040
}
@@ -71,3 +81,117 @@ impl<'a> ParserImpl<'a> {
7181
matches!(self.cur_kind(), Kind::Eof | Kind::Undetermined) || self.fatal_error.is_some()
7282
}
7383
}
84+
85+
// ==================== Merge Conflict Marker Detection ====================
86+
//
87+
// Git merge conflict markers detection and error recovery.
88+
//
89+
// This provides enhanced diagnostics when the parser encounters Git merge conflict markers
90+
// (e.g., `<<<<<<<`, `=======`, `>>>>>>>`). Instead of showing a generic "Unexpected token"
91+
// error, we detect these patterns and provide helpful guidance on how to resolve the conflict.
92+
//
93+
// Inspired by rust-lang/rust#106242
94+
impl ParserImpl<'_> {
95+
/// Check if the current position looks like a merge conflict marker.
96+
///
97+
/// Detects the following Git conflict markers:
98+
/// - `<<<<<<<` - Start marker (ours)
99+
/// - `=======` - Middle separator
100+
/// - `>>>>>>>` - End marker (theirs)
101+
/// - `|||||||` - Diff3 format (common ancestor)
102+
///
103+
/// Returns the span of the marker if detected, None otherwise.
104+
///
105+
/// # False Positive Prevention
106+
///
107+
/// Git conflict markers always appear at the start of a line. To prevent false positives
108+
/// from operator sequences in valid code (e.g., `a << << b`), we verify that the first
109+
/// token is on a new line using the `is_on_new_line()` flag from the lexer.
110+
///
111+
/// The special case `span.start == 0` handles the beginning of the file, where
112+
/// `is_on_new_line()` may be false but a conflict marker is still valid.
113+
fn is_merge_conflict_marker(&self) -> Option<Span> {
114+
let token = self.cur_token();
115+
let span = token.span();
116+
117+
// Git conflict markers always appear at start of line.
118+
// This prevents false positives from operator sequences like `a << << b`.
119+
// At the start of the file (span.start == 0), we allow the check to proceed
120+
// even if is_on_new_line() is false, since there's no preceding line.
121+
if !token.is_on_new_line() && span.start != 0 {
122+
return None;
123+
}
124+
125+
// Get the remaining source text from the current position
126+
let remaining = &self.source_text[span.start as usize..];
127+
128+
// Check for each conflict marker pattern (all are exactly 7 ASCII characters)
129+
// Git conflict markers are always ASCII, so we can safely use byte slicing
130+
if remaining.starts_with("<<<<<<<")
131+
|| remaining.starts_with("=======")
132+
|| remaining.starts_with(">>>>>>>")
133+
|| remaining.starts_with("|||||||")
134+
{
135+
// Marker length is 7 bytes (all ASCII characters)
136+
return Some(Span::new(span.start, span.start + 7));
137+
}
138+
139+
None
140+
}
141+
142+
/// Scans forward to find the middle and end markers of a merge conflict.
143+
///
144+
/// After detecting the start marker (`<<<<<<<`), this function scans forward to find:
145+
/// - The middle marker (`=======`)
146+
/// - The end marker (`>>>>>>>`)
147+
///
148+
/// The diff3 marker (`|||||||`) is recognized but not returned, as it appears between
149+
/// the start and middle markers and doesn't need separate labeling in the diagnostic.
150+
///
151+
/// Returns `(middle_span, end_span)` where:
152+
/// - `middle_span` is the location of `=======` (if found)
153+
/// - `end_span` is the location of `>>>>>>>` (if found)
154+
///
155+
/// Uses a checkpoint to rewind the parser state after scanning, leaving the parser
156+
/// positioned at the start marker.
157+
///
158+
/// # Nested Conflicts
159+
///
160+
/// If nested conflict markers are encountered (e.g., a conflict within a conflict),
161+
/// this function returns the first complete set of markers found. The parser will
162+
/// stop with a fatal error at the first conflict, so nested conflicts won't be
163+
/// fully analyzed until the outer conflict is resolved.
164+
///
165+
/// The diagnostic message includes a note about nested conflicts to guide users
166+
/// to resolve the outermost conflict first.
167+
fn find_merge_conflict_markers(&mut self) -> (Option<Span>, Option<Span>) {
168+
let checkpoint = self.checkpoint();
169+
let mut middle_span = None;
170+
171+
loop {
172+
self.bump_any();
173+
174+
if self.cur_kind() == Kind::Eof {
175+
self.rewind(checkpoint);
176+
return (middle_span, None);
177+
}
178+
179+
// Check if we've hit a conflict marker
180+
if let Some(marker_span) = self.is_merge_conflict_marker() {
181+
let span = self.cur_token().span();
182+
let remaining = &self.source_text[span.start as usize..];
183+
184+
if remaining.starts_with("=======") && middle_span.is_none() {
185+
// Found middle marker
186+
middle_span = Some(marker_span);
187+
} else if remaining.starts_with(">>>>>>>") {
188+
// Found end marker
189+
let result = (middle_span, Some(marker_span));
190+
self.rewind(checkpoint);
191+
return result;
192+
}
193+
// Skip other markers (like diff3 `|||||||` or nested start markers `<<<<<<<`)
194+
}
195+
}
196+
}
197+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Test case for git conflict markers detection
2+
// Based on https://github.com/rust-lang/rust/pull/106242
3+
//
4+
// NOTE: Parser stops at the first conflict marker encountered (fatal error),
5+
// so only the first conflict in this file will be reported.
6+
// Subsequent conflicts are included for completeness but won't be tested
7+
// until the earlier conflicts are removed.
8+
9+
function test() {
10+
<<<<<<< HEAD
11+
const x = 1;
12+
=======
13+
const y = 2;
14+
>>>>>>> branch
15+
return x;
16+
}
17+
18+
// Test with diff3 format
19+
function test2() {
20+
<<<<<<< HEAD
21+
const a = 1;
22+
||||||| parent
23+
const b = 2;
24+
=======
25+
const c = 3;
26+
>>>>>>> branch
27+
return a;
28+
}
29+
30+
// Test in enum/object-like structure
31+
const obj = {
32+
<<<<<<< HEAD
33+
x: 1,
34+
=======
35+
y: 2;
36+
>>>>>>> branch
37+
};
38+
39+
// Test incomplete conflict (only start marker)
40+
function test3() {
41+
<<<<<<< HEAD
42+
const incomplete = true;
43+
return incomplete;
44+
}
45+
46+
// Test nested conflicts (only outermost conflict will be detected)
47+
function nested() {
48+
<<<<<<< OUTER
49+
const outer = 1;
50+
<<<<<<< INNER
51+
const inner = 2;
52+
=======
53+
const innerAlt = 3;
54+
>>>>>>> INNER
55+
=======
56+
const outerAlt = 4;
57+
>>>>>>> OUTER
58+
}
59+
60+
// Test different lexer contexts for >>>>>>>
61+
// Context 1: After expression (may lex as ShiftRight3 + ShiftRight3 + RAngle)
62+
const expr = a
63+
>>>>>>> branch
64+
65+
// Context 2: At statement start (may lex as individual RAngle tokens)
66+
>>>>>>> branch
67+
68+
// Context 3: After binary operator (may lex as ShiftRight + ShiftRight + ShiftRight + RAngle)
69+
const x = 1 >>
70+
>>>>>>> branch
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Test that conflict markers in strings and comments don't trigger false positives
2+
// These should all parse successfully as they are valid JavaScript code
3+
4+
// Conflict markers in strings (should NOT trigger)
5+
const validString = "<<<<<<< HEAD";
6+
const anotherString = "=======";
7+
const endMarker = ">>>>>>> branch";
8+
const diff3Marker = "|||||||";
9+
10+
// Conflict markers in template literals (should NOT trigger)
11+
const validTemplate = `
12+
<<<<<<< not a marker
13+
this is fine
14+
=======
15+
still fine
16+
>>>>>>> also fine
17+
`;
18+
19+
// Conflict markers in comments (should NOT trigger)
20+
// <<<<<<< HEAD - this is a comment about conflicts
21+
// ======= separator
22+
// >>>>>>> branch
23+
24+
/*
25+
Multi-line comment with markers:
26+
<<<<<<< HEAD
27+
=======
28+
>>>>>>> branch
29+
These are all just text in a comment
30+
*/
31+
32+
// Edge case: markers that look similar but aren't exactly 7 characters
33+
// These should NOT trigger because they're not conflict markers
34+
const tooShort = "<<<<<<";
35+
const tooLong = "<<<<<<<<";
36+
37+
// All of the above should parse successfully
38+
export { validString, validTemplate };
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
codegen_misc Summary:
2-
AST Parsed : 48/48 (100.00%)
3-
Positive Passed: 48/48 (100.00%)
2+
AST Parsed : 49/49 (100.00%)
3+
Positive Passed: 49/49 (100.00%)
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
formatter_misc Summary:
2-
AST Parsed : 48/48 (100.00%)
3-
Positive Passed: 48/48 (100.00%)
2+
AST Parsed : 49/49 (100.00%)
3+
Positive Passed: 49/49 (100.00%)

tasks/coverage/snapshots/parser_misc.snap

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
parser_misc Summary:
2-
AST Parsed : 48/48 (100.00%)
3-
Positive Passed: 48/48 (100.00%)
4-
Negative Passed: 107/107 (100.00%)
2+
AST Parsed : 49/49 (100.00%)
3+
Positive Passed: 49/49 (100.00%)
4+
Negative Passed: 108/108 (100.00%)
55

66
× Cannot assign to 'arguments' in strict mode
77
╭─[misc/fail/arguments-eval.ts:1:10]
@@ -50,6 +50,28 @@ Negative Passed: 107/107 (100.00%)
5050
8
5151
╰────
5252

53+
× Encountered diff marker
54+
╭─[misc/fail/diff-markers.js:10:1]
55+
9function test() {
56+
10<<<<<<< HEAD
57+
· ───┬───
58+
· ╰── between this marker and `=======` is the code that we're merging into
59+
11const x = 1;
60+
12=======
61+
· ───┬───
62+
· ╰── between this marker and `>>>>>>>` is the incoming code
63+
13const y = 2;
64+
14>>>>>>> branch
65+
· ───┬───
66+
· ╰── this marker concludes the conflict region
67+
15return x;
68+
╰────
69+
help: Conflict markers indicate that a merge was started but could not be completed due to merge conflicts.
70+
To resolve a conflict, keep only the code you want and then delete the lines containing conflict markers.
71+
If you're having merge conflicts after pulling new code, the top section is the code you already had and the bottom section is the remote code.
72+
If you're in the middle of a rebase, the top section is the code being rebased onto and the bottom section is the code coming from the current commit being rebased.
73+
If you have nested conflicts, resolve the outermost conflict first.
74+
5375
× Expected `,` or `]` but found `const`
5476
╭─[misc/fail/imbalanced-array-expr.js:2:1]
5577
1const foo = [0, 1

0 commit comments

Comments
 (0)