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.
This commit is contained in:
camc314 committed 2025-11-08 06:53:50 +00:00
1 parent 73f9e29a14
commit 8951953008
10 files changed
+390 -26

No files matched your search

+42
View File
@@ -58,6 +58,48 @@ pub fn unexpected_token(span: Span) -> OxcDiagnostic {
OxcDiagnostic::error("Unexpected token").with_label(span)
}
#[cold]
pub fn merge_conflict_marker(
start_span: Span,
middle_span: Option<Span>,
end_span: Option<Span>,
) -> OxcDiagnostic {
let mut diagnostic = OxcDiagnostic::error("Encountered diff marker")
.and_label(
start_span.primary_label(
"between this marker and `=======` is the code that we're merging into",
),
)
.with_help(
"Conflict markers indicate that a merge was started but could not be completed due to \
merge conflicts.\n\
To resolve a conflict, keep only the code you want and then delete the lines containing \
conflict markers.\n\
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.\n\
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.\n\
If you have nested conflicts, resolve the outermost conflict first.",
);
if let Some(middle) = middle_span {
diagnostic = diagnostic
.and_label(middle.label("between this marker and `>>>>>>>` is the incoming code"));
} else {
// Incomplete conflict - missing middle or end markers
diagnostic = diagnostic.with_help(
"This conflict marker appears to be incomplete (missing `=======` or `>>>>>>>`).\n\
Check if the conflict markers were accidentally modified or partially deleted.",
);
}
if let Some(end) = end_span {
diagnostic = diagnostic.and_label(end.label("this marker concludes the conflict region"));
}
diagnostic
}
#[cold]
pub fn expect_token(x0: &str, x1: &str, span: Span) -> OxcDiagnostic {
OxcDiagnostic::error(format!("Expected `{x0}` but found `{x1}`"))
+124
View File
@@ -2,6 +2,7 @@
use oxc_allocator::Dummy;
use oxc_diagnostics::OxcDiagnostic;
use oxc_span::Span;
use crate::{ParserImpl, diagnostics, lexer::Kind};
@@ -25,6 +26,15 @@ impl<'a> ParserImpl<'a> {
self.set_fatal_error(error);
return;
}
// Check if this looks like a merge conflict marker
if let Some(start_span) = self.is_merge_conflict_marker() {
let (middle_span, end_span) = self.find_merge_conflict_markers();
let error = diagnostics::merge_conflict_marker(start_span, middle_span, end_span);
self.set_fatal_error(error);
return;
}
let error = diagnostics::unexpected_token(self.cur_token().span());
self.set_fatal_error(error);
}
@@ -71,3 +81,117 @@ impl<'a> ParserImpl<'a> {
matches!(self.cur_kind(), Kind::Eof | Kind::Undetermined) || self.fatal_error.is_some()
}
}
// ==================== Merge Conflict Marker Detection ====================
//
// Git merge conflict markers detection and error recovery.
//
// This provides enhanced diagnostics when the parser encounters Git merge conflict markers
// (e.g., `<<<<<<<`, `=======`, `>>>>>>>`). Instead of showing a generic "Unexpected token"
// error, we detect these patterns and provide helpful guidance on how to resolve the conflict.
//
// Inspired by rust-lang/rust#106242
impl ParserImpl<'_> {
/// Check if the current position looks like a merge conflict marker.
///
/// Detects the following Git conflict markers:
/// - `<<<<<<<` - Start marker (ours)
/// - `=======` - Middle separator
/// - `>>>>>>>` - End marker (theirs)
/// - `|||||||` - Diff3 format (common ancestor)
///
/// Returns the span of the marker if detected, None otherwise.
///
/// # False Positive Prevention
///
/// Git conflict markers always appear at the start of a line. To prevent false positives
/// from operator sequences in valid code (e.g., `a << << b`), we verify that the first
/// token is on a new line using the `is_on_new_line()` flag from the lexer.
///
/// The special case `span.start == 0` handles the beginning of the file, where
/// `is_on_new_line()` may be false but a conflict marker is still valid.
fn is_merge_conflict_marker(&self) -> Option<Span> {
let token = self.cur_token();
let span = token.span();
// Git conflict markers always appear at start of line.
// This prevents false positives from operator sequences like `a << << b`.
// At the start of the file (span.start == 0), we allow the check to proceed
// even if is_on_new_line() is false, since there's no preceding line.
if !token.is_on_new_line() && span.start != 0 {
return None;
}
// Get the remaining source text from the current position
let remaining = &self.source_text[span.start as usize..];
// Check for each conflict marker pattern (all are exactly 7 ASCII characters)
// Git conflict markers are always ASCII, so we can safely use byte slicing
if remaining.starts_with("<<<<<<<")
|| remaining.starts_with("=======")
|| remaining.starts_with(">>>>>>>")
|| remaining.starts_with("|||||||")
{
// Marker length is 7 bytes (all ASCII characters)
return Some(Span::new(span.start, span.start + 7));
}
None
}
/// Scans forward to find the middle and end markers of a merge conflict.
///
/// After detecting the start marker (`<<<<<<<`), this function scans forward to find:
/// - The middle marker (`=======`)
/// - The end marker (`>>>>>>>`)
///
/// The diff3 marker (`|||||||`) is recognized but not returned, as it appears between
/// the start and middle markers and doesn't need separate labeling in the diagnostic.
///
/// Returns `(middle_span, end_span)` where:
/// - `middle_span` is the location of `=======` (if found)
/// - `end_span` is the location of `>>>>>>>` (if found)
///
/// Uses a checkpoint to rewind the parser state after scanning, leaving the parser
/// positioned at the start marker.
///
/// # Nested Conflicts
///
/// If nested conflict markers are encountered (e.g., a conflict within a conflict),
/// this function returns the first complete set of markers found. The parser will
/// stop with a fatal error at the first conflict, so nested conflicts won't be
/// fully analyzed until the outer conflict is resolved.
///
/// The diagnostic message includes a note about nested conflicts to guide users
/// to resolve the outermost conflict first.
fn find_merge_conflict_markers(&mut self) -> (Option<Span>, Option<Span>) {
let checkpoint = self.checkpoint();
let mut middle_span = None;
loop {
self.bump_any();
if self.cur_kind() == Kind::Eof {
self.rewind(checkpoint);
return (middle_span, None);
}
// Check if we've hit a conflict marker
if let Some(marker_span) = self.is_merge_conflict_marker() {
let span = self.cur_token().span();
let remaining = &self.source_text[span.start as usize..];
if remaining.starts_with("=======") && middle_span.is_none() {
// Found middle marker
middle_span = Some(marker_span);
} else if remaining.starts_with(">>>>>>>") {
// Found end marker
let result = (middle_span, Some(marker_span));
self.rewind(checkpoint);
return result;
}
// Skip other markers (like diff3 `|||||||` or nested start markers `<<<<<<<`)
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
// Test case for git conflict markers detection
// Based on https://github.com/rust-lang/rust/pull/106242
//
// NOTE: Parser stops at the first conflict marker encountered (fatal error),
// so only the first conflict in this file will be reported.
// Subsequent conflicts are included for completeness but won't be tested
// until the earlier conflicts are removed.
function test() {
<<<<<<< HEAD
const x = 1;
=======
const y = 2;
>>>>>>> branch
return x;
}
// Test with diff3 format
function test2() {
<<<<<<< HEAD
const a = 1;
||||||| parent
const b = 2;
=======
const c = 3;
>>>>>>> branch
return a;
}
// Test in enum/object-like structure
const obj = {
<<<<<<< HEAD
x: 1,
=======
y: 2;
>>>>>>> branch
};
// Test incomplete conflict (only start marker)
function test3() {
<<<<<<< HEAD
const incomplete = true;
return incomplete;
}
// Test nested conflicts (only outermost conflict will be detected)
function nested() {
<<<<<<< OUTER
const outer = 1;
<<<<<<< INNER
const inner = 2;
=======
const innerAlt = 3;
>>>>>>> INNER
=======
const outerAlt = 4;
>>>>>>> OUTER
}
// Test different lexer contexts for >>>>>>>
// Context 1: After expression (may lex as ShiftRight3 + ShiftRight3 + RAngle)
const expr = a
>>>>>>> branch
// Context 2: At statement start (may lex as individual RAngle tokens)
>>>>>>> branch
// Context 3: After binary operator (may lex as ShiftRight + ShiftRight + ShiftRight + RAngle)
const x = 1 >>
>>>>>>> branch
@@ -0,0 +1,38 @@
// Test that conflict markers in strings and comments don't trigger false positives
// These should all parse successfully as they are valid JavaScript code
// Conflict markers in strings (should NOT trigger)
const validString = "<<<<<<< HEAD";
const anotherString = "=======";
const endMarker = ">>>>>>> branch";
const diff3Marker = "|||||||";
// Conflict markers in template literals (should NOT trigger)
const validTemplate = `
<<<<<<< not a marker
this is fine
=======
still fine
>>>>>>> also fine
`;
// Conflict markers in comments (should NOT trigger)
// <<<<<<< HEAD - this is a comment about conflicts
// ======= separator
// >>>>>>> branch
/*
Multi-line comment with markers:
<<<<<<< HEAD
=======
>>>>>>> branch
These are all just text in a comment
*/
// Edge case: markers that look similar but aren't exactly 7 characters
// These should NOT trigger because they're not conflict markers
const tooShort = "<<<<<<";
const tooLong = "<<<<<<<<";
// All of the above should parse successfully
export { validString, validTemplate };
+2 -2
View File
@@ -1,3 +1,3 @@
codegen_misc Summary:
AST Parsed : 48/48 (100.00%)
Positive Passed: 48/48 (100.00%)
AST Parsed : 49/49 (100.00%)
Positive Passed: 49/49 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
formatter_misc Summary:
AST Parsed : 48/48 (100.00%)
Positive Passed: 48/48 (100.00%)
AST Parsed : 49/49 (100.00%)
Positive Passed: 49/49 (100.00%)
+25 -3
View File
@@ -1,7 +1,7 @@
parser_misc Summary:
AST Parsed : 48/48 (100.00%)
Positive Passed: 48/48 (100.00%)
Negative Passed: 107/107 (100.00%)
AST Parsed : 49/49 (100.00%)
Positive Passed: 49/49 (100.00%)
Negative Passed: 108/108 (100.00%)
× Cannot assign to 'arguments' in strict mode
╭─[misc/fail/arguments-eval.ts:1:10]
@@ -50,6 +50,28 @@ Negative Passed: 107/107 (100.00%)
8 │
╰────
× Encountered diff marker
╭─[misc/fail/diff-markers.js:10:1]
9 │ function test() {
10 │ <<<<<<< HEAD
· ───┬───
· ╰── between this marker and `=======` is the code that we're merging into
11 │ const x = 1;
12 │ =======
· ───┬───
· ╰── between this marker and `>>>>>>>` is the incoming code
13 │ const y = 2;
14 │ >>>>>>> branch
· ───┬───
· ╰── this marker concludes the conflict region
15 │ return x;
╰────
help: Conflict markers indicate that a merge was started but could not be completed due to merge conflicts.
To resolve a conflict, keep only the code you want and then delete the lines containing conflict markers.
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.
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.
If you have nested conflicts, resolve the outermost conflict first.
× Expected `,` or `]` but found `const`
╭─[misc/fail/imbalanced-array-expr.js:2:1]
1 │ const foo = [0, 1
+83 -15
View File
@@ -6337,37 +6337,102 @@ Expect to Parse: tasks/coverage/typescript/tests/cases/conformance/parser/ecmasc
╰────
help: Remove these parameters here
× Unexpected token
× Encountered diff marker
╭─[typescript/tests/cases/compiler/conflictMarkerDiff3Trivia1.ts:2:1]
1 │ class C {
2 │ <<<<<<< HEAD
· ──
· ───┬───
· ╰── between this marker and `=======` is the code that we're merging into
3 │ v = 1;
╰────
╭─[typescript/tests/cases/compiler/conflictMarkerDiff3Trivia1.ts:6:1]
5 │ v = 3;
6 │ =======
· ───┬───
· ╰── between this marker and `>>>>>>>` is the incoming code
7 │ v = 2;
8 │ >>>>>>> Branch-a
· ───┬───
· ╰── this marker concludes the conflict region
9 │ }
╰────
help: Conflict markers indicate that a merge was started but could not be completed due to merge conflicts.
To resolve a conflict, keep only the code you want and then delete the lines containing conflict markers.
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.
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.
If you have nested conflicts, resolve the outermost conflict first.
× Unexpected token
× Encountered diff marker
╭─[typescript/tests/cases/compiler/conflictMarkerDiff3Trivia2.ts:3:1]
2 │ foo() {
3 │ <<<<<<< B
· ──
· ───┬───
· ╰── between this marker and `=======` is the code that we're merging into
4 │ a();
╰────
╭─[typescript/tests/cases/compiler/conflictMarkerDiff3Trivia2.ts:9:1]
8 │ }
9 │ =======
· ───┬───
· ╰── between this marker and `>>>>>>>` is the incoming code
10 │ b();
11 │ }
12 │ >>>>>>> A
· ───┬───
· ╰── this marker concludes the conflict region
13 │
╰────
help: Conflict markers indicate that a merge was started but could not be completed due to merge conflicts.
To resolve a conflict, keep only the code you want and then delete the lines containing conflict markers.
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.
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.
If you have nested conflicts, resolve the outermost conflict first.
× Unexpected token
× Encountered diff marker
╭─[typescript/tests/cases/compiler/conflictMarkerTrivia1.ts:2:1]
1 │ class C {
2 │ <<<<<<< HEAD
· ──
· ───┬───
· ╰── between this marker and `=======` is the code that we're merging into
3 │ v = 1;
4 │ =======
· ───┬───
· ╰── between this marker and `>>>>>>>` is the incoming code
5 │ v = 2;
6 │ >>>>>>> Branch-a
· ───┬───
· ╰── this marker concludes the conflict region
7 │ }
╰────
help: Conflict markers indicate that a merge was started but could not be completed due to merge conflicts.
To resolve a conflict, keep only the code you want and then delete the lines containing conflict markers.
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.
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.
If you have nested conflicts, resolve the outermost conflict first.
× Unexpected token
╭─[typescript/tests/cases/compiler/conflictMarkerTrivia2.ts:3:1]
2 │ foo() {
3 │ <<<<<<< B
· ──
4 │ a();
╰────
× Encountered diff marker
╭─[typescript/tests/cases/compiler/conflictMarkerTrivia2.ts:3:1]
2 │ foo() {
3 │ <<<<<<< B
· ───┬───
· ╰── between this marker and `=======` is the code that we're merging into
4 │ a();
5 │ }
6 │ =======
· ───┬───
· ╰── between this marker and `>>>>>>>` is the incoming code
7 │ b();
8 │ }
9 │ >>>>>>> A
· ───┬───
· ╰── this marker concludes the conflict region
10 │
╰────
help: Conflict markers indicate that a merge was started but could not be completed due to merge conflicts.
To resolve a conflict, keep only the code you want and then delete the lines containing conflict markers.
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.
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.
If you have nested conflicts, resolve the outermost conflict first.
× Unexpected token
╭─[typescript/tests/cases/compiler/conflictMarkerTrivia3.tsx:2:1]
@@ -6376,12 +6441,15 @@ Expect to Parse: tasks/coverage/typescript/tests/cases/conformance/parser/ecmasc
· ──
╰────
× Unexpected token
× Encountered diff marker
╭─[typescript/tests/cases/compiler/conflictMarkerTrivia4.ts:2:1]
1 │ const x = <div>
2 │ <<<<<<< HEAD
· ──
· ───┬───
· ╰── between this marker and `=======` is the code that we're merging into
╰────
help: This conflict marker appears to be incomplete (missing `=======` or `>>>>>>>`).
Check if the conflict markers were accidentally modified or partially deleted.
× Identifier `foo` has already been declared
╭─[typescript/tests/cases/compiler/conflictingTypeAnnotatedVar.ts:1:5]
+2 -2
View File
@@ -1,6 +1,6 @@
semantic_misc Summary:
AST Parsed : 48/48 (100.00%)
Positive Passed: 30/48 (62.50%)
AST Parsed : 49/49 (100.00%)
Positive Passed: 31/49 (63.27%)
semantic Error: tasks/coverage/misc/pass/oxc-11593.ts
Scope children mismatch:
after transform: ScopeId(0): [ScopeId(1)]
@@ -1,3 +1,3 @@
transformer_misc Summary:
AST Parsed : 48/48 (100.00%)
Positive Passed: 48/48 (100.00%)
AST Parsed : 49/49 (100.00%)
Positive Passed: 49/49 (100.00%)