Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions crates/oxc_parser/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`"))
Expand Down
124 changes: 124 additions & 0 deletions crates/oxc_parser/src/error_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use oxc_allocator::Dummy;
use oxc_diagnostics::OxcDiagnostic;
use oxc_span::Span;

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

Expand All @@ -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);
}
Expand Down Expand Up @@ -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 changes: 70 additions & 0 deletions tasks/coverage/misc/fail/diff-markers.js
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions tasks/coverage/misc/pass/diff-markers-in-strings.js
Original file line number Diff line number Diff line change
@@ -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 };
4 changes: 2 additions & 2 deletions tasks/coverage/snapshots/codegen_misc.snap
Original file line number Diff line number Diff line change
@@ -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%)
4 changes: 2 additions & 2 deletions tasks/coverage/snapshots/formatter_misc.snap
Original file line number Diff line number Diff line change
@@ -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%)
28 changes: 25 additions & 3 deletions tasks/coverage/snapshots/parser_misc.snap
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading