diff --git a/crates/oxc_parser/src/diagnostics.rs b/crates/oxc_parser/src/diagnostics.rs index 0df8e0c527793..4ccfe18f2e789 100644 --- a/crates/oxc_parser/src/diagnostics.rs +++ b/crates/oxc_parser/src/diagnostics.rs @@ -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, + end_span: Option, +) -> 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}`")) diff --git a/crates/oxc_parser/src/error_handler.rs b/crates/oxc_parser/src/error_handler.rs index 0357c628ff689..b520be2266e9d 100644 --- a/crates/oxc_parser/src/error_handler.rs +++ b/crates/oxc_parser/src/error_handler.rs @@ -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 { + 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, Option) { + 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 `<<<<<<<`) + } + } + } +} diff --git a/tasks/coverage/misc/fail/diff-markers.js b/tasks/coverage/misc/fail/diff-markers.js new file mode 100644 index 0000000000000..0859c1340ecb7 --- /dev/null +++ b/tasks/coverage/misc/fail/diff-markers.js @@ -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 diff --git a/tasks/coverage/misc/pass/diff-markers-in-strings.js b/tasks/coverage/misc/pass/diff-markers-in-strings.js new file mode 100644 index 0000000000000..78d2838479ff2 --- /dev/null +++ b/tasks/coverage/misc/pass/diff-markers-in-strings.js @@ -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 }; diff --git a/tasks/coverage/snapshots/codegen_misc.snap b/tasks/coverage/snapshots/codegen_misc.snap index 3459728d0bf56..039ebf3251ae3 100644 --- a/tasks/coverage/snapshots/codegen_misc.snap +++ b/tasks/coverage/snapshots/codegen_misc.snap @@ -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%) diff --git a/tasks/coverage/snapshots/formatter_misc.snap b/tasks/coverage/snapshots/formatter_misc.snap index fec3e10a45001..4f48848ce08e9 100644 --- a/tasks/coverage/snapshots/formatter_misc.snap +++ b/tasks/coverage/snapshots/formatter_misc.snap @@ -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%) diff --git a/tasks/coverage/snapshots/parser_misc.snap b/tasks/coverage/snapshots/parser_misc.snap index ae4a200fcea5d..3892e95c909bd 100644 --- a/tasks/coverage/snapshots/parser_misc.snap +++ b/tasks/coverage/snapshots/parser_misc.snap @@ -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 diff --git a/tasks/coverage/snapshots/parser_typescript.snap b/tasks/coverage/snapshots/parser_typescript.snap index 39c0b16aaa6b7..f32982612d61a 100644 --- a/tasks/coverage/snapshots/parser_typescript.snap +++ b/tasks/coverage/snapshots/parser_typescript.snap @@ -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 │ } ╰──── - - × Unexpected token - ╭─[typescript/tests/cases/compiler/conflictMarkerTrivia2.ts:3:1] - 2 │ foo() { - 3 │ <<<<<<< B - · ── - 4 │ a(); - ╰──── + 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. + + × 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 =
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] diff --git a/tasks/coverage/snapshots/semantic_misc.snap b/tasks/coverage/snapshots/semantic_misc.snap index 7b20c14306284..04914f1a5349f 100644 --- a/tasks/coverage/snapshots/semantic_misc.snap +++ b/tasks/coverage/snapshots/semantic_misc.snap @@ -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)] diff --git a/tasks/coverage/snapshots/transformer_misc.snap b/tasks/coverage/snapshots/transformer_misc.snap index d64c7c9443577..796fc8b9977ec 100644 --- a/tasks/coverage/snapshots/transformer_misc.snap +++ b/tasks/coverage/snapshots/transformer_misc.snap @@ -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%)