mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
1 parent
73f9e29a14
commit
8951953008
10 files changed
+390
-26
No files matched your search
@@ -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}`"))
|
||||
|
||||
@@ -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 `<<<<<<<`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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%)
|
||||
@@ -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%)
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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%)
|
||||
Reference in new issue
Block a user