Skip to content

Commit 0ccf0ac

Browse files
authored
refactor(completions): work with auto-pairs (#2400)
Co-authored-by: Oli Morris <[email protected]>
1 parent ceaca96 commit 0ccf0ac

File tree

2 files changed

+121
-43
lines changed

2 files changed

+121
-43
lines changed

lua/codecompanion/strategies/inline/completion.lua

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ local function extract_next_line(text)
6868
return string.sub(text, 1, newline_pos)
6969
end
7070

71-
---Validate that the completion is not stale (cursor matches range end)
71+
---Validate that cursor is within the completion range
7272
---@param item table The completion item
7373
---@param cursor_row number The current cursor row (0-indexed)
7474
---@param cursor_col number The current cursor column (0-indexed)
@@ -78,11 +78,19 @@ local function validate_completion(item, cursor_row, cursor_col)
7878
return true
7979
end
8080

81+
local start_row = item.range.start.row
82+
local start_col = item.range.start.col
8183
local end_row = item.range.end_.row
8284
local end_col = item.range.end_.col
8385

84-
if end_row ~= cursor_row or end_col ~= cursor_col then
85-
log:debug("Ignoring stale completion (cursor: %d,%d; range end: %d,%d)", cursor_row, cursor_col, end_row, end_col)
86+
-- Allow cursor anywhere within range to support auto-pairs
87+
if cursor_row < start_row or cursor_row > end_row then
88+
return false
89+
end
90+
if cursor_row == start_row and cursor_col < start_col then
91+
return false
92+
end
93+
if cursor_row == end_row and cursor_col > end_col then
8694
return false
8795
end
8896

@@ -110,34 +118,34 @@ end
110118
---Get the new text by removing the existing buffer text prefix
111119
---@param item table The completion item
112120
---@param text string The full completion text
121+
---@param cursor_row number The current cursor row (0-indexed)
122+
---@param cursor_col number The current cursor column (0-indexed)
113123
---@return string new_text The text after removing existing prefix
114-
local function get_new_text(item, text)
124+
local function get_new_text(item, text, cursor_row, cursor_col)
115125
local bufnr = api.nvim_get_current_buf()
116126
local start_row = item.range.start.row
117127
local start_col = item.range.start.col
118-
local end_row = item.range.end_.row
119-
local end_col = item.range.end_.col
120128

121-
local existing_lines = api.nvim_buf_get_text(bufnr, start_row, start_col, end_row, end_col, {})
129+
-- Use cursor position instead of range.end_ to handle auto-pairs
130+
local existing_lines = api.nvim_buf_get_text(bufnr, start_row, start_col, cursor_row, cursor_col, {})
122131
local existing_text = table.concat(existing_lines, "\n")
123132

124-
-- Remove existing text prefix to get only new text
125133
if vim.startswith(text, existing_text) then
126134
return text:sub(#existing_text + 1)
127135
else
128-
log:warn("Suggestion doesn't start with existing text")
129136
return text
130137
end
131138
end
132139

133-
---Insert text at cursor and move cursor to the end
140+
---Insert text at cursor (replacing to range end) and move cursor to the end
134141
---@param text string The text to insert
135142
---@param cursor_row number The current cursor row (0-indexed)
136143
---@param cursor_col number The current cursor column (0-indexed)
137-
local function insert_and_move_cursor(text, cursor_row, cursor_col)
138-
-- Split the text if it contains newlines
144+
---@param end_row number The range end row (0-indexed)
145+
---@param end_col number The range end column (0-indexed)
146+
local function insert_and_move_cursor(text, cursor_row, cursor_col, end_row, end_col)
139147
local lines = vim.split(text, "\n", { plain = true })
140-
api.nvim_buf_set_text(0, cursor_row, cursor_col, cursor_row, cursor_col, lines)
148+
api.nvim_buf_set_text(0, cursor_row, cursor_col, end_row, end_col, lines)
141149

142150
-- Move cursor to the end of the inserted text
143151
local new_row, new_col
@@ -176,15 +184,17 @@ local function accept_partial(extract_fn)
176184
return
177185
end
178186

179-
local new_text = get_new_text(item, text)
187+
local new_text = get_new_text(item, text, cursor_row, cursor_col)
180188

181189
local extracted = extract_fn(new_text)
182190
if not extracted then
183191
log:debug("No text could be extracted from completion")
184192
return
185193
end
186194

187-
insert_and_move_cursor(extracted, cursor_row, cursor_col)
195+
local end_row = item.range.end_.row
196+
local end_col = item.range.end_.col
197+
insert_and_move_cursor(extracted, cursor_row, cursor_col, end_row, end_col)
188198
accepted = true
189199
end,
190200
})

tests/strategies/inline/test_completion.lua

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,45 @@ local T = new_set({
5353
},
5454
})
5555

56-
-- Helper to enter insert mode and set cursor
57-
local function set_cursor_insert(row, col)
58-
child.cmd("startinsert!")
59-
child.api.nvim_win_set_cursor(0, { row, col })
56+
---Helper to set buffer with cursor marker and enter insert mode
57+
---Accepts string "text|" or table {"line1", "line|2"} where | marks cursor position
58+
---@param text string|table
59+
local function set_buffer_text(text)
60+
if type(text) == "string" then
61+
local cursor_pos = text:find("|", 1, true)
62+
if not cursor_pos then
63+
error("No cursor marker '|' found in text")
64+
end
65+
66+
local line = text:sub(1, cursor_pos - 1) .. text:sub(cursor_pos + 1)
67+
child.api.nvim_buf_set_lines(0, 0, -1, true, { line })
68+
child.cmd("startinsert!")
69+
child.api.nvim_win_set_cursor(0, { 1, cursor_pos - 1 })
70+
elseif type(text) == "table" then
71+
local cursor_row, cursor_col
72+
local lines = {}
73+
74+
for i, line in ipairs(text) do
75+
local pos = line:find("|", 1, true)
76+
if pos then
77+
cursor_row = i
78+
cursor_col = pos - 1
79+
table.insert(lines, line:sub(1, pos - 1) .. line:sub(pos + 1))
80+
else
81+
table.insert(lines, line)
82+
end
83+
end
84+
85+
if not cursor_row then
86+
error("No cursor marker '|' found in text")
87+
end
88+
89+
child.api.nvim_buf_set_lines(0, 0, -1, true, lines)
90+
child.cmd("startinsert!")
91+
child.api.nvim_win_set_cursor(0, { cursor_row, cursor_col })
92+
else
93+
error("Expected string or table")
94+
end
6095
end
6196

6297
T["accept_word()"] = new_set()
@@ -66,9 +101,7 @@ T["accept_word()"]["works with simple word completion"] = function()
66101
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
67102
end
68103

69-
-- Set up: buffer has "-- Create a fib" and cursor is at end
70-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "-- Create a fib" })
71-
set_cursor_insert(1, 15) -- After "-- Create a fib"
104+
set_buffer_text("-- Create a fib|")
72105

73106
-- Set up the mock completion
74107
child.lua([[
@@ -94,8 +127,7 @@ T["accept_word()"]["works with punctuation in completion"] = function()
94127
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
95128
end
96129

97-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "local x" })
98-
set_cursor_insert(1, 7)
130+
set_buffer_text("local x|")
99131

100132
child.lua([[
101133
_G.mock_completion_item = {
@@ -118,8 +150,7 @@ T["accept_word()"]["works with newline in word"] = function()
118150
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
119151
end
120152

121-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "function test()" })
122-
set_cursor_insert(1, 15)
153+
set_buffer_text("function test()|")
123154

124155
child.lua([[
125156
_G.mock_completion_item = {
@@ -143,9 +174,7 @@ T["accept_word()"]["ignores stale completion (cursor before range end)"] = funct
143174
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
144175
end
145176

146-
-- Make a longer buffer so cursor doesn't get clamped
147-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "test some long text here" })
148-
set_cursor_insert(1, 10) -- Cursor at position 10
177+
set_buffer_text("test some |long text here")
149178

150179
child.lua([[
151180
_G.mock_completion_item = {
@@ -168,8 +197,7 @@ T["accept_word()"]["handles empty buffer"] = function()
168197
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
169198
end
170199

171-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "" })
172-
set_cursor_insert(1, 0)
200+
set_buffer_text("|")
173201

174202
child.lua([[
175203
_G.mock_completion_item = {
@@ -187,13 +215,59 @@ T["accept_word()"]["handles empty buffer"] = function()
187215
h.eq(child.api.nvim_win_get_cursor(0), { 1, 6 })
188216
end
189217

218+
T["accept_word()"]["handles auto-pairs with cursor inside brackets"] = function()
219+
if vim.fn.has("nvim-0.12") == 0 then
220+
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
221+
end
222+
223+
set_buffer_text("local function hello_world(|)")
224+
225+
child.lua([[
226+
_G.mock_completion_item = {
227+
insert_text = "local function hello_world(arg)",
228+
range = {
229+
start = { row = 0, col = 0 },
230+
end_ = { row = 0, col = 27 }
231+
}
232+
}
233+
234+
_G.completion.accept_word()
235+
]])
236+
237+
-- Should insert "arg" without being confused by the trailing ")"
238+
h.eq(child.api.nvim_buf_get_lines(0, 0, -1, true), { "local function hello_world(arg)" })
239+
h.eq(child.api.nvim_win_get_cursor(0), { 1, 30 })
240+
end
241+
242+
T["accept_word()"]["handles auto-pairs with cursor inside quotes"] = function()
243+
if vim.fn.has("nvim-0.12") == 0 then
244+
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
245+
end
246+
247+
set_buffer_text('print("|")') -- print("|")
248+
249+
child.lua([[
250+
_G.mock_completion_item = {
251+
insert_text = "print(\"hello world\")",
252+
range = {
253+
start = { row = 0, col = 0 },
254+
end_ = { row = 0, col = 7 }
255+
}
256+
}
257+
258+
_G.completion.accept_word()
259+
]])
260+
261+
h.eq(child.api.nvim_buf_get_lines(0, 0, -1, true), { 'print("hello ")' })
262+
h.eq(child.api.nvim_win_get_cursor(0), { 1, 13 })
263+
end
264+
190265
T["accept_word()"]["handles completion that doesn't start with existing text"] = function()
191266
if vim.fn.has("nvim-0.12") == 0 then
192267
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
193268
end
194269

195-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "foo bar" })
196-
set_cursor_insert(1, 7)
270+
set_buffer_text("foo bar|")
197271

198272
child.lua([[
199273
_G.mock_completion_item = {
@@ -218,8 +292,7 @@ T["accept_line()"]["works with single line completion"] = function()
218292
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
219293
end
220294

221-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "-- Comment" })
222-
set_cursor_insert(1, 10)
295+
set_buffer_text("-- Comment|")
223296

224297
child.lua([[
225298
_G.mock_completion_item = {
@@ -242,8 +315,7 @@ T["accept_line()"]["works with multi-line completion"] = function()
242315
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
243316
end
244317

245-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "function test()" })
246-
set_cursor_insert(1, 15)
318+
set_buffer_text("function test()|")
247319

248320
child.lua([[
249321
_G.mock_completion_item = {
@@ -257,7 +329,6 @@ T["accept_line()"]["works with multi-line completion"] = function()
257329
_G.completion.accept_line()
258330
]])
259331

260-
-- Should insert newline + next line content + newline (avoiding the double-press issue)
261332
h.eq(child.api.nvim_buf_get_lines(0, 0, -1, true), { "function test()", " return 42", "" })
262333
h.eq(child.api.nvim_win_get_cursor(0), { 3, 0 })
263334
end
@@ -267,9 +338,7 @@ T["accept_line()"]["ignores stale completion"] = function()
267338
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
268339
end
269340

270-
-- Make a longer buffer so cursor doesn't get clamped
271-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "test some long text here" })
272-
set_cursor_insert(1, 10)
341+
set_buffer_text("test some |long text here")
273342

274343
child.lua([[
275344
_G.mock_completion_item = {
@@ -292,8 +361,7 @@ T["accept_line()"]["handles completion without newline"] = function()
292361
MiniTest.skip("Requires Neovim 0.12+ for vim.lsp.inline_completion")
293362
end
294363

295-
child.api.nvim_buf_set_lines(0, 0, -1, true, { "hello" })
296-
set_cursor_insert(1, 5)
364+
set_buffer_text("hello|")
297365

298366
child.lua([[
299367
_G.mock_completion_item = {

0 commit comments

Comments
 (0)