Skip to content

Commit

Permalink
fix: correctly computes minimal changes besides EOL character
Browse files Browse the repository at this point in the history
  • Loading branch information
neubaner committed Nov 3, 2024
1 parent 9bc6681 commit 73f0812
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 188 deletions.
19 changes: 5 additions & 14 deletions lua/rzls/utils/format.lua
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
local lcs = require("rzls.utils.lcs")
local M = {}

---@param source string
---@param target string
---@param line_start? integer
---@param character_start? integer
local function compute_minimal_diff(source, target, line_start, character_start)
local edits = lcs.diff(source, target)
local collapsed_edits = lcs.collapse(edits)

return lcs.convert_to_text_edits(collapsed_edits, line_start, character_start)
end

---@param lines string[]
---@param range lsp.Range
local function extract_lines_from_range(lines, range)
Expand Down Expand Up @@ -60,23 +49,25 @@ function M.compute_minimal_edits(source_buf, target_edit)

---@type lsp.TextEdit[]
local edits = {}

for _, idx in ipairs(indices) do
local source_line_start, source_line_count, target_line_start, target_line_count = unpack(idx)
local source_line_end = source_line_start + source_line_count - 1
local target_line_end = target_line_start + target_line_count - 1

local source = table.concat(source_lines, "\n", source_line_start, source_line_end)
local target = table.concat(target_lines, "\n", target_line_start, target_line_end)
local text_edits = compute_minimal_diff(
source,
target,

local text_edits = lcs.to_lsp_edits(
lcs.diff(source, target),
source_line_start + target_edit.range.start.line - 1,
target_edit.range.start.character
)

vim.list_extend(edits, text_edits)
end

-- return vim.print(edits)
return edits
end

Expand Down
172 changes: 47 additions & 125 deletions lua/rzls/utils/lcs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@ local M = {}
---@class rzls.lcs.Edit
---@field kind rzls.lcs.EditKind
---@field text string
---
---@class rzls.lcs.CollapsedEdit
---@field kind rzls.lcs.EditKind
---@field text string
---@field line integer

---@enum rzls.lcs.EditKind
M.edit_kind = {
Expand Down Expand Up @@ -110,142 +105,69 @@ function M.diff(source, target)
return reverse_table(edits)
end

--- Collapses a sequence of edits of the same kind that are on the same line
---@param edits rzls.lcs.Edit[]
---@return rzls.lcs.CollapsedEdit[]
function M.collapse(edits)
---@type rzls.lcs.Edit[]
local results = {}
---@param line integer
---@param character integer
---@return lsp.TextEdit[]
function M.to_lsp_edits(edits, line, character)
local function advance_cursor(edit)
if edit.text == "\n" then
line = line + 1
character = 0
else
character = character + 1
end
end

---@type lsp.TextEdit[]
local lsp_edits = {}
local i = 1
local line = 1
while i < #edits do
local current_text = ""
local current_kind = edits[i].kind
local current_line = line

for j = i, #edits do
if edits[j].kind ~= current_kind then
break
end
-- Skip all unchanged edits and advance cursor
while i < #edits and edits[i].kind == M.edit_kind.unchanged do
advance_cursor(edits[i])
i = i + 1

if edits[j].text ~= "\n" then
current_text = current_text .. edits[j].text
end

-- Keep the new line in this edit but don't accept anymore edits
if edits[j].text == "\n" then
line = line + 1
break
end
end

table.insert(results, {
text = current_text,
kind = current_kind,
line = current_line,
})
end

return results
end
-- No more edits to compute
if i >= #edits then
break
end

--- Group edits that belong to the same line
---@param edits rzls.lcs.CollapsedEdit[]
---@return rzls.lcs.CollapsedEdit[][]
function M.group_edits_by_line(edits)
---@type rzls.lcs.CollapsedEdit[][]
local line_edits = {}
local line = 1
local i = 1
while i < #edits do
line_edits[line] = {
edits[i],
}
local new_text = ""
local start_line, start_character = line, character

for j = i + 1, #edits do
if edits[j].line ~= edits[i].line then
break
-- Collect consecutive additions and removals
while i < #edits and edits[i].kind ~= M.edit_kind.unchanged do
if edits[i].kind == M.edit_kind.addition then
new_text = new_text .. edits[i].text
elseif edits[i].kind == M.edit_kind.removal then
advance_cursor(edits[i])
else
error("unexcepted edit kind " .. edits[i].kind)
end
table.insert(line_edits[line], edits[j])
i = i + 1
end
line = line + 1
i = i + 1
end

return line_edits
end

---@param edits rzls.lcs.CollapsedEdit[]
---@param line_start? integer
---@param character_start? integer
---@return lsp.TextEdit[]
function M.convert_to_text_edits(edits, line_start, character_start)
local line_edits = M.group_edits_by_line(edits)
line_start = line_start or 0
character_start = character_start or 0

local character = character_start

---@type lsp.TextEdit[]
local text_edits = {}
for line, line_edit in ipairs(line_edits) do
-- LSP lines are 0 based
line = line + line_start - 1
for edit_index, edit in ipairs(line_edit) do
local next_edit = line_edit[edit_index + 1]

-- if next_edit is nil, it means we are at the last line
local is_eol = next_edit == nil or edit.line ~= next_edit.line
-- if we are the last edit on a line, we must place an edit that ends
-- on the begging of the next line
local ending_line = is_eol and line + 1 or line
local ending_character = is_eol and 0 or character

---@type lsp.TextEdit
local text_edit
if edit.kind == "removal" then
text_edit = {
newText = "",
range = {
start = {
line = line,
character = character,
},
["end"] = {
line = ending_line,
character = ending_character + edit.text:len(),
},
},
}
elseif edit.kind == "addition" then
text_edit = {
newText = edit.text,
range = {
start = {
line = line,
character = character,
},
["end"] = {
line = ending_line,
character = ending_character,
},
},
}
end
-- NOTE: unchanged edits should only skip characters
---@type lsp.TextEdit
local lsp_edit = {
newText = new_text,
range = {
start = {
line = start_line,
character = start_character,
},
["end"] = {
line = line,
character = character,
},
},
}

character = character + edit.text:len()
if text_edit ~= nil then
table.insert(text_edits, text_edit)
end
end
character = 0
table.insert(lsp_edits, lsp_edit)
end

return text_edits
return lsp_edits
end

return M
84 changes: 35 additions & 49 deletions tests/rzls/utils/lcs_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,41 +22,6 @@ describe("lcs", function()
eq(expected, edits)
end)

it("collapses sequences of edits of the same kind", function()
local edits = lcs.diff("sunday", "saturday")
edits = lcs.collapse(edits)

---@type rzls.lcs.CollapsedEdit[]
local expected = {
{ text = "s", kind = kind.unchanged, line = 1 },
{ text = "at", kind = kind.addition, line = 1 },
{ text = "u", kind = kind.unchanged, line = 1 },
{ text = "n", kind = kind.removal, line = 1 },
{ text = "r", kind = kind.addition, line = 1 },
{ text = "day", kind = kind.unchanged, line = 1 },
}
eq(expected, edits)
end)

it("diffs new lines", function()
local source = '<div\n\nclass="container d-flex flex-column gap-3 py-3">'
local target = '<div class="container d-flex flex-column gap-3 py-3">'

local edits = lcs.diff(source, target)
edits = lcs.collapse(edits)

---@type rzls.lcs.CollapsedEdit[]
local expected = {
{ text = "<div", kind = kind.unchanged, line = 1 },
-- new lines should not be included in the colapsed changes
{ text = "", kind = kind.removal, line = 1 },
{ text = "", kind = kind.removal, line = 2 },
{ text = " ", kind = kind.addition, line = 3 },
{ text = 'class="container d-flex flex-column gap-3 py-3">', kind = kind.unchanged, line = 3 },
}
eq(expected, edits)
end)

---@return lsp.TextEdit
local function lsp_edit(new_text, start_line, start_char, end_line, end_char)
return {
Expand All @@ -79,24 +44,45 @@ describe("lcs", function()
local target = '<div class="bar">'

local edits = lcs.diff(source, target)
edits = lcs.collapse(edits)

local text_edits = lcs.convert_to_text_edits(edits)
local text_edits = lcs.to_lsp_edits(edits, 0, 0)

---@type lsp.TextEdit[]
local expected = {
-- Delete first \n
lsp_edit("", 0, 4, 1, 0),
-- Delete second \n
lsp_edit("", 1, 0, 2, 0),
-- Add space between div and class
lsp_edit(" ", 2, 0, 2, 0),
-- Delete foo
lsp_edit("", 2, 8, 2, 11),
-- Add bar
lsp_edit("bar", 2, 11, 2, 11),
-- Replaces "\n\n" with " "
lsp_edit(" ", 0, 4, 2, 0),
-- Replaces "foo" with "bar"
lsp_edit("bar", 2, 7, 2, 10),
}

eq(expected, text_edits)
end)

it("applies converted lsp.TextEdit's to buffer", function()
local source = '<div class="bar">'
local target = '<div\n\nclass="foo">'

local edits = lcs.diff(source, target)
local text_edits = lcs.to_lsp_edits(edits, 0, 0)

local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, true, vim.split(source, "\n"))
vim.lsp.util.apply_text_edits(text_edits, buf, "utf-8")

local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, true)
eq(target, table.concat(lines, "\n"))
end)

it("applies converted lsp.TextEdit's to buffer with CRLF line endings", function()
local source = '<div class="bar">'
local target = '<div\r\n\r\nclass="foo">'

local edits = lcs.diff(source, target)
local text_edits = lcs.to_lsp_edits(edits, 0, 0)

local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, true, vim.split(source, "\r\n"))
vim.lsp.util.apply_text_edits(text_edits, buf, "utf-8")

local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, true)
eq(target, table.concat(lines, "\r\n"))
end)
end)

0 comments on commit 73f0812

Please sign in to comment.