Skip to content

Commit 92af03e

Browse files
committed
feat(lcs): add LCS & minimal edit support
Introduce new support for LCS-based diff generation and minimal text edits. • Add lcs.lua to compute diffs based on longest common subsequence. • Add mimimal_edits.lua to reduce and optimize text edits. • Update nes/init.lua to apply minimal edits when a single edit is returned.
1 parent a45b3d9 commit 92af03e

File tree

3 files changed

+287
-0
lines changed

3 files changed

+287
-0
lines changed

lua/copilot-lsp/lcs.lua

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
local M = {}
2+
3+
---@class copilotlsp.lcs.Edit
4+
---@field kind copilotlsp.lcs.EditKind
5+
---@field text string
6+
7+
---@enum copilotlsp.lcs.EditKind
8+
M.edit_kind = {
9+
addition = "addition",
10+
removal = "removal",
11+
unchanged = "unchanged",
12+
}
13+
14+
--- Computes the Long Common Subequence table.
15+
--- Reference: [https://en.wikipedia.org/wiki/Longest_common_subsequence#Computing_the_length_of_the_LCS]
16+
---@param source string
17+
---@param target string
18+
function M.generate_table(source, target)
19+
local n = #source + 1
20+
local m = #target + 1
21+
22+
---@type integer[][]
23+
local lcs = {}
24+
for i = 1, n do
25+
lcs[i] = {}
26+
for j = 1, m do
27+
lcs[i][j] = 0
28+
end
29+
end
30+
31+
for i = 2, n do
32+
for j = 2, m do
33+
if source:byte(i - 1) == target:byte(j - 1) then
34+
lcs[i][j] = 1 + lcs[i - 1][j - 1]
35+
else
36+
lcs[i][j] = math.max(lcs[i - 1][j], lcs[i][j - 1])
37+
end
38+
end
39+
end
40+
return lcs
41+
end
42+
43+
---@generic T
44+
---@param tbl T[]
45+
---@return T[]
46+
local function reverse_table(tbl)
47+
local ret = {}
48+
for i = #tbl, 1, -1 do
49+
table.insert(ret, tbl[i])
50+
end
51+
return ret
52+
end
53+
54+
--- Calculates a diff between two strings using LCS
55+
---@param source string
56+
---@param target string
57+
---@return copilotlsp.lcs.Edit[]
58+
function M.diff(source, target)
59+
local src_idx, trt_idx = #source + 1, #target + 1
60+
local lcs
61+
62+
---@type copilotlsp.lcs.Edit[]
63+
local edits = {}
64+
local edit_idx = 1
65+
66+
local edit_kind = M.edit_kind
67+
68+
while src_idx > 1 or trt_idx > 1 do
69+
if src_idx == 1 then
70+
trt_idx = trt_idx - 1
71+
edits[edit_idx] = {
72+
kind = edit_kind.addition,
73+
text = string.char(string.byte(target, trt_idx)),
74+
}
75+
elseif trt_idx == 1 then
76+
src_idx = src_idx - 1
77+
edits[edit_idx] = {
78+
kind = edit_kind.removal,
79+
text = string.char(string.byte(source, src_idx)),
80+
}
81+
else
82+
local src_char = string.byte(source, src_idx - 1)
83+
local trt_char = string.byte(target, trt_idx - 1)
84+
85+
if src_char == trt_char then
86+
src_idx, trt_idx = src_idx - 1, trt_idx - 1
87+
edits[edit_idx] = {
88+
kind = edit_kind.unchanged,
89+
text = string.char(src_char),
90+
}
91+
else
92+
lcs = lcs or M.generate_table(source, target)
93+
if lcs[src_idx - 1][trt_idx] <= lcs[src_idx][trt_idx - 1] then
94+
trt_idx = trt_idx - 1
95+
edits[edit_idx] = {
96+
kind = edit_kind.addition,
97+
text = string.char(trt_char),
98+
}
99+
else
100+
src_idx = src_idx - 1
101+
edits[edit_idx] = {
102+
kind = edit_kind.removal,
103+
text = string.char(src_char),
104+
}
105+
end
106+
end
107+
end
108+
edit_idx = edit_idx + 1
109+
end
110+
111+
return reverse_table(edits)
112+
end
113+
114+
---@param edits copilotlsp.lcs.Edit[]
115+
---@param line integer
116+
---@param character integer
117+
---@return lsp.TextEdit[]
118+
function M.to_lsp_edits(edits, line, character)
119+
local function advance_cursor(edit)
120+
if edit.text == "\n" then
121+
line = line + 1
122+
character = 0
123+
else
124+
character = character + 1
125+
end
126+
end
127+
128+
---@type lsp.TextEdit[]
129+
local lsp_edits = {}
130+
local i = 1
131+
while i < #edits do
132+
-- Skip all unchanged edits and advance cursor
133+
while i < #edits and edits[i].kind == M.edit_kind.unchanged do
134+
advance_cursor(edits[i])
135+
i = i + 1
136+
end
137+
138+
-- No more edits to compute
139+
if i >= #edits then
140+
break
141+
end
142+
143+
local new_text = ""
144+
local start_line, start_character = line, character
145+
146+
-- Collect consecutive additions and removals
147+
while i < #edits and edits[i].kind ~= M.edit_kind.unchanged do
148+
if edits[i].kind == M.edit_kind.addition then
149+
new_text = new_text .. edits[i].text
150+
elseif edits[i].kind == M.edit_kind.removal then
151+
advance_cursor(edits[i])
152+
else
153+
error("unexcepted edit kind " .. edits[i].kind)
154+
end
155+
i = i + 1
156+
end
157+
158+
---@type lsp.TextEdit
159+
local lsp_edit = {
160+
newText = new_text,
161+
range = {
162+
start = {
163+
line = start_line,
164+
character = start_character,
165+
},
166+
["end"] = {
167+
line = line,
168+
character = character,
169+
},
170+
},
171+
}
172+
173+
table.insert(lsp_edits, lsp_edit)
174+
end
175+
176+
return lsp_edits
177+
end
178+
179+
return M

lua/copilot-lsp/mimimal_edits.lua

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
local lcs = require("copilot-lsp.lcs")
2+
local M = {}
3+
4+
---@param lines string[]
5+
---@param range lsp.Range
6+
local function extract_lines_from_range(lines, range)
7+
local start_row = range.start.line + 1
8+
local start_col = range.start.character + 1
9+
local end_row = range["end"].line + 1
10+
local end_col = range["end"].character + 1
11+
12+
local source_lines = {}
13+
-- Loop through the zero-indexed range [source_start_row, source_end_row)
14+
for i = start_row, end_row do
15+
local line = lines[i]
16+
17+
if i == start_row then
18+
line = line:sub(start_col, -1)
19+
elseif i == end_row then
20+
line = line:sub(1, end_col - 1)
21+
end
22+
23+
-- strip CR characters when neovim fails to identify the correct file format
24+
if vim.endswith(line, "\r") then
25+
table.insert(source_lines, line:sub(1, -2))
26+
else
27+
table.insert(source_lines, line)
28+
end
29+
end
30+
31+
return source_lines
32+
end
33+
34+
---@param source_buf string[]
35+
---@param target_edit lsp.TextEdit
36+
---@return lsp.TextEdit[]
37+
function M.compute_minimal_edits(source_buf, target_edit)
38+
local source_lines = extract_lines_from_range(source_buf, target_edit.range)
39+
local target_lines = vim.split(target_edit.newText, "\r?\n")
40+
41+
local source_text = table.concat(source_lines, "\n")
42+
local target_text = table.concat(target_lines, "\n")
43+
44+
local indices = vim.diff(source_text, target_text, {
45+
algorithm = "histogram",
46+
result_type = "indices",
47+
})
48+
assert(type(indices) == "table")
49+
50+
---@type lsp.TextEdit[]
51+
local edits = {}
52+
53+
for _, idx in ipairs(indices) do
54+
local source_line_start, source_line_count, target_line_start, target_line_count = unpack(idx)
55+
local source_line_end = source_line_start + source_line_count - 1
56+
local target_line_end = target_line_start + target_line_count - 1
57+
58+
local source = table.concat(source_lines, "\n", source_line_start, source_line_end)
59+
local target = table.concat(target_lines, "\n", target_line_start, target_line_end)
60+
61+
local text_edits = lcs.to_lsp_edits(
62+
lcs.diff(source, target),
63+
source_line_start + target_edit.range.start.line - 1,
64+
target_edit.range.start.character
65+
)
66+
67+
vim.list_extend(edits, text_edits)
68+
end
69+
70+
local contains_non_whitespace_edit = vim.iter(edits):any(function(edit)
71+
return edit.newText:find("%S") ~= nil
72+
end)
73+
74+
-- Diff the whole text if we encounter a non whitespace character in the edit.
75+
-- This might happen when the formatted document deletes many lines
76+
-- and `vim.diff` split those deletions into multiple hunks.
77+
if contains_non_whitespace_edit then
78+
edits = lcs.to_lsp_edits(
79+
lcs.diff(source_text, target_text),
80+
target_edit.range.start.line,
81+
target_edit.range.start.character
82+
)
83+
end
84+
85+
return edits
86+
end
87+
88+
return M

lua/copilot-lsp/nes/init.lua

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local errs = require("copilot-lsp.errors")
22
local nes_ui = require("copilot-lsp.nes.ui")
33
local utils = require("copilot-lsp.util")
4+
local me = require("copilot-lsp.mimimal_edits")
45

56
local M = {}
67

@@ -18,6 +19,25 @@ local function handle_nes_response(err, result, ctx)
1819
--- Convert to textEdit fields
1920
edit.newText = edit.text
2021
end
22+
if #result.edits == 1 then
23+
---@type copilotlsp.InlineEdit[]
24+
local min_edits = {}
25+
26+
-- minimise the edits
27+
local source_lines = vim.api.nvim_buf_get_lines(ctx.bufnr, 0, -1, false)
28+
local min_te = me.compute_minimal_edits(source_lines, result.edits[1])
29+
for _, edit in ipairs(min_te) do
30+
local new_edit = {
31+
range = edit.range,
32+
newText = edit.newText,
33+
command = result.edits[1].command,
34+
textDocument = result.edits[1].textDocument,
35+
}
36+
table.insert(min_edits, new_edit)
37+
end
38+
39+
result.edits = min_edits
40+
end
2141
nes_ui._display_next_suggestion(ctx.bufnr, nes_ns, result.edits)
2242
end
2343

0 commit comments

Comments
 (0)