Skip to content

Commit 4d17c30

Browse files
authored
feat(lsp): adjust completion textEdit range to replace typed word (#303)
refactor(lsp): pass word to to_lsp_item for accurate replacement test(lsp): add test for correct textEdit range in completion items
1 parent b590f96 commit 4d17c30

2 files changed

Lines changed: 161 additions & 87 deletions

File tree

lua/opencode/lsp/opencode_ls.lua

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,20 @@ end
9191
---Convert opencode CompletionItem to LSP CompletionItem
9292
---@param item CompletionItem
9393
---@param index integer
94+
---@param params lsp.CompletionParams
95+
---@param word string Text after the trigger character to be completed
9496
---@return OpencodeLspItem
95-
local function to_lsp_item(item, index, params)
97+
local function to_lsp_item(item, index, params, word)
9698
local source = require('opencode.ui.completion').get_source_by_name(item.source_name)
97-
local kind = (source and source.custom_kind) or vim.lsp.protocol.CompletionItemKind.Function ---@type lsp.CompletionItemKind
99+
local kind = (source and source.custom_kind) or
100+
vim.lsp.protocol.CompletionItemKind.Function ---@type lsp.CompletionItemKind
98101
local priority = source and source.priority or 999
99102
local line = params.position.line
100103
local col = params.position.character
101104

105+
local start_char = col - #word
106+
local end_char = col
107+
102108
---@type OpencodeLspItem
103109
local lsp_item = {
104110
label = (M.supports_kind_icons() and '' or item.kind_icon .. ' ') .. item.label,
@@ -115,8 +121,8 @@ local function to_lsp_item(item, index, params)
115121
sortText = string.format('%02d_%02d_%02d_%s', priority, item.priority or 999, index, item.label),
116122
textEdit = {
117123
range = {
118-
start = { line = line, character = col },
119-
['end'] = { line = line, character = col },
124+
start = { line = line, character = start_char },
125+
['end'] = { line = line, character = end_char },
120126
},
121127
newText = item.insert_text,
122128
},
@@ -161,29 +167,29 @@ handlers[ms.textDocument_completion] = function(params, callback)
161167
end
162168

163169
Promise.all(promises)
164-
:and_then(function(results)
165-
---@type OpencodeLspItem[]
166-
local all_items = {}
167-
local is_incomplete = false
168-
169-
for _, items in ipairs(results) do
170-
for j, item in ipairs(items or {}) do
171-
local source = completion.get_source_by_name(item.source_name)
172-
if source and source.is_incomplete then
173-
is_incomplete = true
170+
:and_then(function(results)
171+
---@type OpencodeLspItem[]
172+
local all_items = {}
173+
local is_incomplete = false
174+
175+
for _, items in ipairs(results) do
176+
for j, item in ipairs(items or {}) do
177+
local source = completion.get_source_by_name(item.source_name)
178+
if source and source.is_incomplete then
179+
is_incomplete = true
180+
end
181+
182+
table.insert(all_items, to_lsp_item(item, j, params, word))
174183
end
175-
176-
table.insert(all_items, to_lsp_item(item, j, params))
177184
end
178-
end
179185

180-
callback(nil, { isIncomplete = is_incomplete, items = all_items })
181-
end)
182-
:catch(function(err)
183-
local log = require('opencode.log')
184-
log.error('Error in completion handler: ' .. tostring(err))
185-
callback(nil, { isIncomplete = false, items = {} })
186-
end)
186+
callback(nil, { isIncomplete = is_incomplete, items = all_items })
187+
end)
188+
:catch(function(err)
189+
local log = require('opencode.log')
190+
log.error('Error in completion handler: ' .. tostring(err))
191+
callback(nil, { isIncomplete = false, items = {} })
192+
end)
187193
end
188194

189195
---Create the LSP server configuration
@@ -231,7 +237,7 @@ function M.start(bufnr)
231237
local completed_item = vim.v.completed_item
232238
if completed_item and completed_item.user_data then
233239
local data = vim.tbl_get(completed_item, 'user_data', 'nvim', 'lsp', 'completion_item', 'data')
234-
or vim.tbl_get(completed_item, 'user_data', 'lsp', 'item', 'data')
240+
or vim.tbl_get(completed_item, 'user_data', 'lsp', 'item', 'data')
235241

236242
local item = data and data._opencode_item
237243

tests/unit/completion_lsp_spec.lua

Lines changed: 130 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,68 +1019,136 @@ describe('opencode LSP completion', function()
10191019
end)
10201020

10211021
it('embeds the original item in data._opencode_item', function()
1022-
package.loaded['blink.cmp'] = nil
1023-
package.loaded['opencode.lsp.opencode_ls'] = nil
1024-
ls = require('opencode.lsp.opencode_ls')
1025-
1026-
package.loaded['opencode.ui.completion'] = nil
1027-
local completion = require('opencode.ui.completion')
1028-
completion._sources = {}
1029-
1030-
local original_item = {
1031-
label = 'OriginalItem',
1032-
kind = 'file',
1033-
kind_icon = '',
1034-
insert_text = 'OriginalItem',
1035-
source_name = 'data_test_source',
1036-
data = { custom = 'value' },
1037-
}
1038-
1039-
completion.register_source({
1040-
name = 'data_test_source',
1041-
priority = 1,
1042-
complete = function()
1043-
return Promise.new():resolve({ original_item })
1044-
end,
1045-
get_trigger_character = function()
1046-
return '@'
1047-
end,
1048-
})
1049-
1050-
vim.api.nvim_buf_get_lines = function()
1051-
return { '@test' }
1052-
end
1053-
vim.api.nvim_get_current_line = function()
1054-
return '@test'
1055-
end
1056-
vim.api.nvim_win_get_cursor = function()
1057-
return { 1, 5 }
1058-
end
1059-
1060-
local config_obj = ls.create_config()
1061-
local server = config_obj.cmd({}, {})
1062-
1063-
local done = false
1064-
local callback_result = nil
1065-
server.request('textDocument/completion', {
1066-
position = { line = 0, character = 5 },
1067-
}, function(err, result)
1068-
callback_result = result
1069-
done = true
1070-
end)
1071-
1072-
vim.wait(200, function()
1073-
return done
1074-
end)
1075-
1076-
assert.is_not_nil(callback_result)
1077-
assert.are.equal(1, #callback_result.items)
1078-
local lsp_item = callback_result.items[1]
1079-
assert.is_not_nil(lsp_item.data)
1080-
assert.is_not_nil(lsp_item.data._opencode_item)
1081-
assert.are.equal(original_item.label, lsp_item.data._opencode_item.label)
1082-
assert.are.equal('value', lsp_item.data._opencode_item.data.custom)
1083-
end)
1022+
package.loaded['blink.cmp'] = nil
1023+
package.loaded['opencode.lsp.opencode_ls'] = nil
1024+
ls = require('opencode.lsp.opencode_ls')
1025+
1026+
package.loaded['opencode.ui.completion'] = nil
1027+
local completion = require('opencode.ui.completion')
1028+
completion._sources = {}
1029+
1030+
local original_item = {
1031+
label = 'OriginalItem',
1032+
kind = 'file',
1033+
kind_icon = '',
1034+
insert_text = 'OriginalItem',
1035+
source_name = 'data_test_source',
1036+
data = { custom = 'value' },
1037+
}
1038+
1039+
completion.register_source({
1040+
name = 'data_test_source',
1041+
priority = 1,
1042+
complete = function()
1043+
return Promise.new():resolve({ original_item })
1044+
end,
1045+
get_trigger_character = function()
1046+
return '@'
1047+
end,
1048+
})
1049+
1050+
vim.api.nvim_buf_get_lines = function()
1051+
return { '@test' }
1052+
end
1053+
vim.api.nvim_get_current_line = function()
1054+
return '@test'
1055+
end
1056+
vim.api.nvim_win_get_cursor = function()
1057+
return { 1, 5 }
1058+
end
1059+
1060+
local config_obj = ls.create_config()
1061+
local server = config_obj.cmd({}, {})
1062+
1063+
local done = false
1064+
local callback_result = nil
1065+
server.request('textDocument/completion', {
1066+
position = { line = 0, character = 5 },
1067+
}, function(err, result)
1068+
callback_result = result
1069+
done = true
1070+
end)
1071+
1072+
vim.wait(200, function()
1073+
return done
1074+
end)
1075+
1076+
assert.is_not_nil(callback_result)
1077+
assert.are.equal(1, #callback_result.items)
1078+
local lsp_item = callback_result.items[1]
1079+
assert.is_not_nil(lsp_item.data)
1080+
assert.is_not_nil(lsp_item.data._opencode_item)
1081+
assert.are.equal(original_item.label, lsp_item.data._opencode_item.label)
1082+
assert.are.equal('value', lsp_item.data._opencode_item.data.custom)
1083+
end)
1084+
1085+
it('calculates correct textEdit range to replace typed word', function()
1086+
package.loaded['blink.cmp'] = nil
1087+
package.loaded['opencode.lsp.opencode_ls'] = nil
1088+
ls = require('opencode.lsp.opencode_ls')
1089+
1090+
package.loaded['opencode.ui.completion'] = nil
1091+
local completion = require('opencode.ui.completion')
1092+
completion._sources = {}
1093+
1094+
local item_for_range_test = {
1095+
label = 'tests',
1096+
kind = 'file',
1097+
kind_icon = '',
1098+
insert_text = 'tests',
1099+
source_name = 'range_test_source',
1100+
data = {},
1101+
}
1102+
1103+
completion.register_source({
1104+
name = 'range_test_source',
1105+
priority = 1,
1106+
complete = function()
1107+
return Promise.new():resolve({ item_for_range_test })
1108+
end,
1109+
get_trigger_character = function()
1110+
return '@'
1111+
end,
1112+
})
1113+
1114+
vim.api.nvim_buf_get_lines = function()
1115+
return { '@te' }
1116+
end
1117+
vim.api.nvim_get_current_line = function()
1118+
return '@te'
1119+
end
1120+
vim.api.nvim_win_get_cursor = function()
1121+
return { 1, 3 }
1122+
end
1123+
1124+
local config_obj = ls.create_config()
1125+
local server = config_obj.cmd({}, {})
1126+
1127+
local done = false
1128+
local callback_result = nil
1129+
server.request('textDocument/completion', {
1130+
position = { line = 0, character = 3 },
1131+
}, function(err, result)
1132+
callback_result = result
1133+
done = true
1134+
end)
1135+
1136+
vim.wait(200, function()
1137+
return done
1138+
end)
1139+
1140+
assert.is_not_nil(callback_result)
1141+
assert.are.equal(1, #callback_result.items)
1142+
local lsp_item = callback_result.items[1]
1143+
assert.is_not_nil(lsp_item.textEdit)
1144+
assert.is_not_nil(lsp_item.textEdit.range)
1145+
1146+
local range = lsp_item.textEdit.range
1147+
assert.are.equal(1, range.start.character, 'start should be at trigger char position + 1')
1148+
assert.are.equal(3, range['end'].character, 'end should be at cursor position')
1149+
assert.are.equal(0, range.start.line, 'line should be 0')
1150+
assert.are.equal(0, range['end'].line, 'line should be 0')
1151+
end)
10841152
end)
10851153
end)
10861154
end)

0 commit comments

Comments
 (0)