diff --git a/doc/diffview.txt b/doc/diffview.txt index 6b17884c..04a35c7c 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -146,17 +146,13 @@ COMMANDS *diffview-commands* For more info on these actions, see |diffview-actions-conflict_choose|. - NOTE: Jujutsu's default `conflict-marker-style = "diff"` emits - `+++++++` / `%%%%%%%` sections that the parser doesn't recognize, - so the marker-based actions (`c[oOtTbBaA]`, `[x`, `]x`, - `d[xX]`) no-op in a Jujutsu repo. Set >toml - [ui] - conflict-marker-style = "git" -< - in `~/.config/jj/config.toml` and Jujutsu will re-materialize the - markers on the next working-copy snapshot. The whole-buffer - variants (`cs*`) read from the side panes directly and - work regardless of marker style. + NOTE: All three of Jujutsu's `ui.conflict-marker-style` values + (`"diff"`, `"snapshot"`, `"git"`) work with the marker-based + actions for 2-sided conflicts. Conflicts with 3 or more sides + can't be represented in the ours/base/theirs model those actions + assume, so they are skipped; use the whole-buffer variants + (`cs*`), which read from the side panes and work + regardless of marker style or sidedness. NOTE: The horizontal 3-way diff is only the default layout for the merge-tool, but there are multiple variations on the 3-way diff layout diff --git a/lua/diffview/tests/functional/parse_conflicts_spec.lua b/lua/diffview/tests/functional/parse_conflicts_spec.lua new file mode 100644 index 00000000..5e5bceae --- /dev/null +++ b/lua/diffview/tests/functional/parse_conflicts_spec.lua @@ -0,0 +1,268 @@ +local helpers = require("diffview.tests.helpers") +local vcs_utils = require("diffview.vcs.utils") + +local eq = helpers.eq + +---Split a heredoc-style string into lines the way `nvim_buf_get_lines` +---returns them (no trailing empty element for a final newline). +---@param text string +---@return string[] +local function lines_of(text) + local out = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + out[#out + 1] = line + end + -- Drop the trailing empty element produced by the appended `\n`. + if out[#out] == "" then + out[#out] = nil + end + return out +end + +describe("diffview.vcs.utils.parse_conflicts", function() + describe("git diff3 markers", function() + it("extracts ours, base, and theirs contents", function() + local input = lines_of([[ +line 1 +<<<<<<< HEAD +ours line +||||||| base +base line +======= +theirs line +>>>>>>> branch +line 3]]) + + local conflicts = vcs_utils.parse_conflicts(input) + + eq(1, #conflicts) + local c = conflicts[1] + eq(2, c.first) + eq(8, c.last) + eq({ "ours line" }, c.ours.content) + eq({ "base line" }, c.base.content) + eq({ "theirs line" }, c.theirs.content) + end) + + it("parses a conflict whose git branch label starts with 'conflict'", function() + -- The label matches the jj header pattern (`<<<<<<< conflict-*`); + -- without a peek gate on jj sub-markers the jj parser would eat + -- the region and drop it silently. + local input = lines_of([[ +<<<<<<< conflict-fix +ours line +======= +theirs line +>>>>>>> conflict-fix]]) + + local conflicts = vcs_utils.parse_conflicts(input) + + eq(1, #conflicts) + eq({ "ours line" }, conflicts[1].ours.content) + eq({ "theirs line" }, conflicts[1].theirs.content) + end) + end) + + describe("jj diff-style markers", function() + it("reconstructs base and theirs from the diff and snapshot", function() + -- Left side is diff-formatted (against the base "b"), right is a + -- snapshot. This matches `jj`'s emitted layout for `ui.conflict- + -- marker-style = "diff"` (the default). + local input = lines_of([[ +a +<<<<<<< conflict 1 of 1 +%%%%%%% diff from: xxx aaaaaaaa "initial" +\\\\\\\ to: yyy bbbbbbbb "left" +-b ++b_left ++++++++ zzz cccccccc "right" +b_right +>>>>>>> conflict 1 of 1 ends +c]]) + + local conflicts = vcs_utils.parse_conflicts(input) + + eq(1, #conflicts) + local c = conflicts[1] + eq(2, c.first) + eq(9, c.last) + eq({ "b_left" }, c.ours.content) + eq({ "b" }, c.base.content) + eq({ "b_right" }, c.theirs.content) + end) + + it("reconstructs base once when both sides are diffs against it", function() + -- With `ui.conflict-marker-style = "diff"`, `jj` emits a `%%%%%%%` + -- block for every side that can be represented as a diff against the + -- base, so a 2-sided conflict often has two diff blocks. The base + -- must be recovered from just one of them, not concatenated. + local input = lines_of([[ +<<<<<<< conflict 1 of 1 +%%%%%%% diff from: xxx aaaaaaaa "base" +\\\\\\\ to: yyy bbbbbbbb "left" +-b ++b_left +%%%%%%% diff from: xxx aaaaaaaa "base" +\\\\\\\ to: zzz cccccccc "right" +-b ++b_right +>>>>>>> conflict 1 of 1 ends]]) + + local c = vcs_utils.parse_conflicts(input)[1] + + eq({ "b_left" }, c.ours.content) + eq({ "b" }, c.base.content) + eq({ "b_right" }, c.theirs.content) + end) + + it("preserves diff context lines in both sides", function() + local input = lines_of([[ +<<<<<<< conflict 1 of 1 +%%%%%%% diff from: xxx aaaaaaaa "initial" +\\\\\\\ to: yyy bbbbbbbb "left" + keep +-drop ++add + tail ++++++++ zzz cccccccc "right" +right only +>>>>>>> conflict 1 of 1 ends]]) + + local c = vcs_utils.parse_conflicts(input)[1] + + eq({ "keep", "add", "tail" }, c.ours.content) + eq({ "keep", "drop", "tail" }, c.base.content) + eq({ "right only" }, c.theirs.content) + end) + end) + + describe("jj snapshot-style markers", function() + it("extracts ours, base, and theirs from three snapshot blocks", function() + local input = lines_of([[ +a +<<<<<<< conflict 1 of 1 ++++++++ yyy bbbbbbbb "left" +b_left +------- xxx aaaaaaaa "initial" +b ++++++++ zzz cccccccc "right" +b_right +>>>>>>> conflict 1 of 1 ends +c]]) + + local c = vcs_utils.parse_conflicts(input)[1] + + eq({ "b_left" }, c.ours.content) + eq({ "b" }, c.base.content) + eq({ "b_right" }, c.theirs.content) + end) + + it("emits an empty base when no `-------` block is present", function() + -- Fallback shape (no explicit base): both sides are snapshots. + local input = lines_of([[ +<<<<<<< conflict 1 of 1 ++++++++ yyy bbbbbbbb "left" +left content ++++++++ zzz cccccccc "right" +right content +>>>>>>> conflict 1 of 1 ends]]) + + local c = vcs_utils.parse_conflicts(input)[1] + + eq({ "left content" }, c.ours.content) + eq({}, c.base.content) + eq({ "right content" }, c.theirs.content) + end) + end) + + describe("jj N-sided conflict", function() + it("skips regions with 3+ sides", function() + -- 3-sided conflicts can't be projected onto ours/base/theirs, so the + -- region is dropped from the returned list; the parser advances past + -- the trailer so downstream regions are still picked up. + local input = lines_of([[ +<<<<<<< conflict 1 of 1 ++++++++ a +one ++++++++ b +two ++++++++ c +three +>>>>>>> conflict 1 of 1 ends +<<<<<<< conflict 1 of 1 ++++++++ y "ours" +two_ours ++++++++ z "theirs" +two_theirs +>>>>>>> conflict 1 of 1 ends]]) + + local conflicts = vcs_utils.parse_conflicts(input) + + eq(1, #conflicts) + eq({ "two_ours" }, conflicts[1].ours.content) + eq({ "two_theirs" }, conflicts[1].theirs.content) + end) + + it("skips a 3-sided region emitted as two diffs and a snapshot", function() + -- Real `jj` output for a 3-parent merge with + -- `ui.conflict-marker-style = "diff"`: two `%%%%%%%` blocks against + -- the same base plus a `+++++++` snapshot side. The region is + -- 3-sided, so it is dropped; the trailing 2-sided region must still + -- parse. + local input = lines_of([[ +<<<<<<< conflict 1 of 1 +%%%%%%% diff from: n "base" +\\\\\\\ to: s "a" +-25 ++A +%%%%%%% diff from: n "base" +\\\\\\\ to: v "b" +-25 ++B ++++++++ m "c" +C +>>>>>>> conflict 1 of 1 ends +<<<<<<< conflict 1 of 1 ++++++++ y "ours" +tail_ours ++++++++ z "theirs" +tail_theirs +>>>>>>> conflict 1 of 1 ends]]) + + local conflicts = vcs_utils.parse_conflicts(input) + + eq(1, #conflicts) + eq({ "tail_ours" }, conflicts[1].ours.content) + eq({ "tail_theirs" }, conflicts[1].theirs.content) + end) + end) + + describe("multiple regions", function() + it("handles back-to-back jj regions of mixed styles", function() + local input = lines_of([[ +<<<<<<< conflict 1 of 2 ++++++++ y "ours" +one_ours ++++++++ z "theirs" +one_theirs +>>>>>>> conflict 1 of 2 ends +<<<<<<< conflict 2 of 2 +%%%%%%% diff from: x "base" +\\\\\\\ to: y "ours" +-old ++two_ours ++++++++ z "theirs" +two_theirs +>>>>>>> conflict 2 of 2 ends]]) + + local conflicts = vcs_utils.parse_conflicts(input) + + eq(2, #conflicts) + eq({ "one_ours" }, conflicts[1].ours.content) + eq({ "one_theirs" }, conflicts[1].theirs.content) + eq({ "two_ours" }, conflicts[2].ours.content) + eq({ "old" }, conflicts[2].base.content) + eq({ "two_theirs" }, conflicts[2].theirs.content) + end) + end) +end) diff --git a/lua/diffview/vcs/utils.lua b/lua/diffview/vcs/utils.lua index 4d3bd974..251bb02a 100644 --- a/lua/diffview/vcs/utils.lua +++ b/lua/diffview/vcs/utils.lua @@ -579,6 +579,48 @@ local CONFLICT_BASE = [[^||||||| ]] local CONFLICT_SEP = [[^=======$]] local CONFLICT_END = [[^>>>>>>> ]] +-- Jujutsu conflict markers. Both `ui.conflict-marker-style = "diff"` (the +-- default) and `"snapshot"` share this outer frame: +-- <<<<<<< conflict N of M +-- ...one sub-block per side (and optionally the base)... +-- >>>>>>> conflict N of M ends +-- The lowercase "conflict" keyword distinguishes them from git's diff3 +-- markers above (`<<<<<<< HEAD`, `>>>>>>> branch`). Sub-blocks are: +-- +++++++ full snapshot of a side +-- ------- full snapshot of the base +-- %%%%%%% diff from: +-- \\\\\\\ to: two-line header for a unified diff whose +-- `-`/`+`/` ` prefixed body lines describe a +-- side relative to base. +-- See jj's `ui.conflict-marker-style` docs. +local JJ_CONFLICT_START = [[^<<<<<<<%s+[Cc]onflict]] +local JJ_CONFLICT_END = [[^>>>>>>>%s+[Cc]onflict]] +local JJ_SNAPSHOT_SIDE = [[^%+%+%+%+%+%+%+%s]] +local JJ_SNAPSHOT_BASE = [[^%-%-%-%-%-%-%-%s]] +local JJ_DIFF_FROM = [[^%%%%%%%%%%%%%%%s+diff from:]] +local JJ_DIFF_TO = [[^\\\\\\\]] + +---Peek forward from a `<<<<<<< conflict ...` header to distinguish a jj +---conflict region from a git conflict whose branch label just happens to +---start with "conflict" (e.g., `<<<<<<< conflict-fix`). Returns true iff +---a jj sub-block marker (`+++++++`, `-------`, or `%%%%%%%`) appears +---before the region's trailer, so callers can fall through to git +---parsing when the region is git-shaped despite the ambiguous header. +---@param lines string[] +---@param start_idx integer Index of the `<<<<<<< conflict ...` header. +---@return boolean +local function looks_like_jj_region(lines, start_idx) + for j = start_idx + 1, #lines do + local line = lines[j] + if line:match(JJ_SNAPSHOT_SIDE) or line:match(JJ_SNAPSHOT_BASE) or line:match(JJ_DIFF_FROM) then + return true + elseif line:match(JJ_CONFLICT_END) or line:match(JJ_CONFLICT_START) then + return false + end + end + return false +end + ---@class ConflictRegion ---@field first integer ---@field last integer @@ -586,6 +628,110 @@ local CONFLICT_END = [[^>>>>>>> ]] ---@field base { first: integer, last: integer, content?: string[] } ---@field theirs { first: integer, last: integer, content?: string[] } +---Parse one Jujutsu conflict region beginning at `start_idx` (1-based, on +---the `<<<<<<< conflict ...` line). Returns the assembled ConflictRegion +---and the last line consumed (inclusive), or `nil` plus an advance point +---when the region is unrepresentable in the 2-sided ours/base/theirs model +---(3+ sides, no matching trailer, etc.). +---@param lines string[] +---@param start_idx integer +---@return ConflictRegion? region +---@return integer consumed_upto Last line index consumed (advance past this). +local function parse_jj_region(lines, start_idx) + local sides = {} -- Ordered list of side contents (first = ours, second = theirs). + local base_content -- From an explicit `-------` snapshot block. + local base_from_diff -- Reconstructed from `%%%%%%%` diff blocks' `-` lines. + + -- At most one of these accumulators is active at a time; their presence + -- also serves as the parser's "current block kind" state. `cur_diff_base` + -- collects the current `%%%%%%%` block's `-`/` ` lines separately so + -- multiple diff blocks against the same base don't concatenate. + local cur_snapshot -- Accumulator for the active `+++++++` or `-------` block. + local cur_diff_side -- Accumulator for `+` lines of the active `%%%%%%%` block. + local cur_diff_base -- Accumulator for `-`/` ` lines of the active `%%%%%%%` block. + + local function flush() + if cur_diff_side then + sides[#sides + 1] = cur_diff_side + -- Every diff block reconstructs the same base, so keep the first. + base_from_diff = base_from_diff or cur_diff_base + end + cur_snapshot, cur_diff_side, cur_diff_base = nil, nil, nil + end + + local i = start_idx + 1 + local end_idx + while i <= #lines do + local line = lines[i] + if line:match(JJ_CONFLICT_END) then + flush() + end_idx = i + break + elseif line:match(JJ_SNAPSHOT_SIDE) then + flush() + cur_snapshot = {} + sides[#sides + 1] = cur_snapshot + elseif line:match(JJ_SNAPSHOT_BASE) then + flush() + base_content = base_content or {} + cur_snapshot = base_content + elseif line:match(JJ_DIFF_FROM) then + flush() + cur_diff_side = {} + cur_diff_base = {} + -- Consume the `\\\\\\\ ... to:` continuation, guarding in case a + -- future jj format drops it: without the guard we'd swallow the + -- first diff-body line. + if i + 1 <= #lines and lines[i + 1]:match(JJ_DIFF_TO) then + i = i + 1 + end + elseif line:match(JJ_CONFLICT_START) then + -- Nested header without a trailer for the outer region: bail out + -- and let the outer loop restart parsing here. + flush() + return nil, i - 1 + elseif cur_snapshot then + cur_snapshot[#cur_snapshot + 1] = line + elseif cur_diff_side then + local marker = line:sub(1, 1) + local rest = line:sub(2) + if marker == "-" then + cur_diff_base[#cur_diff_base + 1] = rest + elseif marker == "+" then + cur_diff_side[#cur_diff_side + 1] = rest + elseif marker == " " then + cur_diff_base[#cur_diff_base + 1] = rest + cur_diff_side[#cur_diff_side + 1] = rest + end + end + i = i + 1 + end + + if not end_idx then + -- No matching trailer; leave the header for the caller to reconsider + -- (it may still match the git state machine downstream). + return nil, start_idx + end + + if #sides ~= 2 then + -- N-sided (or malformed) conflict: `ConflictRegion` can't represent it. + return nil, end_idx + end + + local base = base_content or base_from_diff or {} + local region = { + first = start_idx, + last = end_idx, + -- Sub-range first/last are only read by the git branch's auto-slicer, + -- which we bypass here (`content` is set directly). Anchor them at + -- the outer bounds so any incidental consumer sees a valid range. + ours = { first = start_idx, last = end_idx, content = sides[1] }, + base = { first = start_idx, last = end_idx, content = base }, + theirs = { first = start_idx, last = end_idx, content = sides[2] }, + } + return region, end_idx +end + ---@param lines string[] ---@param winid? integer ---@return ConflictRegion[] conflicts @@ -600,6 +746,18 @@ function M.parse_conflicts(lines, winid) cursor = api.nvim_win_get_cursor(winid) end + local function register(data) + if cursor then + if not cur_conflict and cursor[1] >= data.first and cursor[1] <= data.last then + cur_conflict = data + cur_idx = #ret + 1 + elseif cursor[1] > data.last then + cur_idx = (cur_idx or 0) + 1 + end + end + ret[#ret + 1] = data + end + local function handle(data) local first = math.min( data.ours.first or math.huge, @@ -629,18 +787,9 @@ function M.parse_conflicts(lines, winid) data.theirs.content = utils.vec_slice(lines, data.theirs.first + 1, data.theirs.last - 1) end - if cursor then - if not cur_conflict and cursor[1] >= first and cursor[1] <= last then - cur_conflict = data - cur_idx = #ret + 1 - elseif cursor[1] > last then - cur_idx = (cur_idx or 0) + 1 - end - end - data.first = first data.last = last - ret[#ret + 1] = data + register(data) end local function new_cur() @@ -653,8 +802,31 @@ function M.parse_conflicts(lines, winid) cur = new_cur() + -- `parse_jj_region` consumes multiple lines per hit, so we can't rely on + -- the loop's natural +1 step; `skip_until` records the last consumed + -- line so subsequent iterations no-op until we clear the region. + local skip_until = 0 + for i, line in ipairs(lines) do - if line:match(CONFLICT_START) then + if i <= skip_until then + goto continue + end + -- Try jj markers first: `JJ_CONFLICT_START` is a strict subset of the + -- git `CONFLICT_START` pattern (both start with `<<<<<<< `), so the + -- git branch would otherwise swallow a jj header. Gate on a peek for + -- jj sub-markers so a git branch named `conflict-*` still gets parsed + -- by the git state machine below instead of being silently dropped. + if line:match(JJ_CONFLICT_START) and looks_like_jj_region(lines, i) then + if has_start then + handle(cur) + cur, has_start, has_base, has_sep = new_cur(), false, false, false + end + local region, consumed = parse_jj_region(lines, i) + if region then + register(region) + end + skip_until = consumed + elseif line:match(CONFLICT_START) then if has_start then handle(cur) cur, has_start, has_base, has_sep = new_cur(), false, false, false @@ -701,6 +873,7 @@ function M.parse_conflicts(lines, winid) handle(cur) cur, has_start, has_base, has_sep = new_cur(), false, false, false end + ::continue:: end handle(cur)