Skip to content

Commit 2efc2e1

Browse files
committed
feat(search_replace): support empty SEARCH block as insert operation
- `parse_blocks` and `apply` now treat empty SEARCH sections as "insert at cursor" operations - Added insert logic, warnings, and error cases for missing cursor row - Updates to quick_chat instructions for insert at cursor - tests for insert/empty SEARCH now included in search_replace_spec
1 parent 5e29101 commit 2efc2e1

File tree

3 files changed

+157
-29
lines changed

3 files changed

+157
-29
lines changed

lua/opencode/quick_chat.lua

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ local function process_response(session_info, messages)
9191
return false
9292
end
9393

94-
local success, errors, applied_count = search_replace.apply(session_info.buf, replacements)
94+
local success, errors, applied_count = search_replace.apply(session_info.buf, replacements, session_info.row)
9595

9696
-- Provide detailed feedback
9797
if applied_count > 0 then
@@ -286,8 +286,18 @@ local function generate_search_replace_instructions(context_instance)
286286
'>>>>>>> REPLACE',
287287
'```',
288288
'',
289+
'**Insert new code at cursor (empty SEARCH):**',
290+
'When the cursor is on an empty line or you need to insert without replacing, use an empty SEARCH section:',
291+
'```',
292+
'<<<<<<< SEARCH',
293+
'=======',
294+
'local new_variable = "inserted at cursor"',
295+
'>>>>>>> REPLACE',
296+
'```',
297+
'',
289298
'# FINAL REMINDER',
290299
'Output ONLY the SEARCH/REPLACE blocks. The SEARCH section must match the original code exactly.',
300+
'Use an empty SEARCH section to insert new code at the cursor position.',
291301
}
292302

293303
for _, line in ipairs(examples) do

lua/opencode/quick_chat/search_replace.lua

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ local M = {}
44
--- Supports both raw and code-fenced formats:
55
--- <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE
66
--- ```\n<<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE\n```
7+
--- Empty SEARCH sections are valid and indicate "insert at cursor position"
78
---@param response_text string Response text containing SEARCH/REPLACE blocks
8-
---@return table[] replacements Array of {search=string, replace=string, block_number=number}
9+
---@return table[] replacements Array of {search=string, replace=string, block_number=number, is_insert=boolean}
910
---@return string[] warnings Array of warning messages for malformed blocks
1011
function M.parse_blocks(response_text)
1112
local replacements = {}
@@ -48,17 +49,14 @@ function M.parse_blocks(response_text)
4849
-- Extract replace content (everything between separator and end marker)
4950
local replace_content = text:sub(replace_start, end_marker_start - 1)
5051

51-
if search_content:match('^%s*$') then
52-
table.insert(warnings, string.format('Block %d: Empty SEARCH section', block_number))
53-
pos = end_marker_end + 1
54-
else
55-
table.insert(replacements, {
56-
search = search_content,
57-
replace = replace_content,
58-
block_number = block_number,
59-
})
60-
pos = end_marker_end + 1
61-
end
52+
local is_insert = search_content:match('^%s*$') ~= nil
53+
table.insert(replacements, {
54+
search = is_insert and '' or search_content,
55+
replace = replace_content,
56+
block_number = block_number,
57+
is_insert = is_insert,
58+
})
59+
pos = end_marker_end + 1
6260
end
6361
end
6462
end
@@ -67,12 +65,14 @@ function M.parse_blocks(response_text)
6765
end
6866

6967
--- Applies SEARCH/REPLACE blocks to buffer content using exact matching
68+
--- Empty SEARCH sections (is_insert=true) will insert at the specified cursor row
7069
---@param buf integer Buffer handle
71-
---@param replacements table[] Array of {search=string, replace=string, block_number=number}
70+
---@param replacements table[] Array of {search=string, replace=string, block_number=number, is_insert=boolean}
71+
---@param cursor_row? integer Optional cursor row (0-indexed) for insert operations
7272
---@return boolean success Whether any replacements were applied
7373
---@return string[] errors List of error messages for failed replacements
7474
---@return number applied_count Number of successfully applied replacements
75-
function M.apply(buf, replacements)
75+
function M.apply(buf, replacements, cursor_row)
7676
if not vim.api.nvim_buf_is_valid(buf) then
7777
return false, { 'Buffer is not valid' }, 0
7878
end
@@ -87,22 +87,39 @@ function M.apply(buf, replacements)
8787
local search = replacement.search
8888
local replace = replacement.replace
8989
local block_num = replacement.block_number or '?'
90+
local is_insert = replacement.is_insert
9091

91-
-- Exact match only
92-
local start_pos, end_pos = content:find(search, 1, true)
93-
94-
if start_pos and end_pos then
95-
content = content:sub(1, start_pos - 1) .. replace .. content:sub(end_pos + 1)
96-
applied_count = applied_count + 1
92+
if is_insert then
93+
-- Empty SEARCH: insert at cursor row
94+
if not cursor_row then
95+
table.insert(errors, string.format('Block %d: Insert operation requires cursor position', block_num))
96+
else
97+
-- Split replace content into lines and insert at cursor row
98+
local replace_lines = vim.split(replace, '\n', { plain = true })
99+
vim.api.nvim_buf_set_lines(buf, cursor_row, cursor_row, false, replace_lines)
100+
applied_count = applied_count + 1
101+
-- Refresh lines and content after direct buffer modification
102+
lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
103+
content = table.concat(lines, '\n')
104+
end
97105
else
98-
local search_preview = search:sub(1, 60):gsub('\n', '\\n')
99-
if #search > 60 then
100-
search_preview = search_preview .. '...'
106+
-- Exact match only
107+
local start_pos, end_pos = content:find(search, 1, true)
108+
109+
if start_pos and end_pos then
110+
content = content:sub(1, start_pos - 1) .. replace .. content:sub(end_pos + 1)
111+
applied_count = applied_count + 1
112+
else
113+
local search_preview = search:sub(1, 60):gsub('\n', '\\n')
114+
if #search > 60 then
115+
search_preview = search_preview .. '...'
116+
end
117+
table.insert(errors, string.format('Block %d: No exact match for: "%s"', block_num, search_preview))
101118
end
102-
table.insert(errors, string.format('Block %d: No exact match for: "%s"', block_num, search_preview))
103119
end
104120
end
105121

122+
-- Apply remaining content changes (for non-insert replacements)
106123
if applied_count > 0 then
107124
local new_lines = vim.split(content, '\n', { plain = true })
108125
vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines)

tests/unit/search_replace_spec.lua

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,25 @@ local x = 2
108108
assert.matches('Missing end marker', warnings[1])
109109
end)
110110

111-
it('warns on empty SEARCH section', function()
111+
it('parses empty SEARCH section as insert operation', function()
112+
-- Empty search means "insert at cursor position"
113+
local input = [[
114+
<<<<<<< SEARCH
115+
116+
=======
117+
local x = 2
118+
>>>>>>> REPLACE
119+
]]
120+
local replacements, warnings = search_replace.parse_blocks(input)
121+
122+
assert.equals(1, #replacements)
123+
assert.equals(0, #warnings)
124+
assert.equals('', replacements[1].search)
125+
assert.equals('local x = 2', replacements[1].replace)
126+
assert.is_true(replacements[1].is_insert)
127+
end)
128+
129+
it('parses whitespace-only SEARCH section as insert operation', function()
112130
-- Note: Empty search with content on same line as separator
113131
-- The parser requires \n======= so whitespace-only search still needs proper structure
114132
local input = [[
@@ -120,9 +138,10 @@ local x = 2
120138
]]
121139
local replacements, warnings = search_replace.parse_blocks(input)
122140

123-
assert.equals(0, #replacements)
124-
assert.equals(1, #warnings)
125-
assert.matches('Empty SEARCH section', warnings[1])
141+
assert.equals(1, #replacements)
142+
assert.equals(0, #warnings)
143+
assert.equals('', replacements[1].search)
144+
assert.is_true(replacements[1].is_insert)
126145
end)
127146

128147
it('handles empty REPLACE section (deletion)', function()
@@ -277,4 +296,86 @@ describe('search_replace.apply', function()
277296

278297
vim.api.nvim_buf_delete(buf, { force = true })
279298
end)
299+
300+
it('inserts at cursor row when is_insert is true', function()
301+
local buf = vim.api.nvim_create_buf(false, true)
302+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', 'line 3' })
303+
304+
local replacements = {
305+
{ search = '', replace = 'inserted text', block_number = 1, is_insert = true },
306+
}
307+
308+
-- Insert before row 1 (0-indexed), so inserts before "line 2"
309+
local success, errors, count = search_replace.apply(buf, replacements, 1)
310+
311+
assert.is_true(success)
312+
assert.equals(0, #errors)
313+
assert.equals(1, count)
314+
315+
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
316+
assert.are.same({ 'line 1', 'inserted text', 'line 2', 'line 3' }, lines)
317+
318+
vim.api.nvim_buf_delete(buf, { force = true })
319+
end)
320+
321+
it('inserts at empty line cursor position', function()
322+
local buf = vim.api.nvim_create_buf(false, true)
323+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', '', 'line 3' })
324+
325+
local replacements = {
326+
{ search = '', replace = 'new content', block_number = 1, is_insert = true },
327+
}
328+
329+
-- Insert before row 1 (0-indexed), the empty line
330+
local success, errors, count = search_replace.apply(buf, replacements, 1)
331+
332+
assert.is_true(success)
333+
assert.equals(0, #errors)
334+
assert.equals(1, count)
335+
336+
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
337+
assert.are.same({ 'line 1', 'new content', '', 'line 3' }, lines)
338+
339+
vim.api.nvim_buf_delete(buf, { force = true })
340+
end)
341+
342+
it('inserts multiline content at cursor row', function()
343+
local buf = vim.api.nvim_create_buf(false, true)
344+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', '', 'line 3' })
345+
346+
local replacements = {
347+
{ search = '', replace = 'first\nsecond\nthird', block_number = 1, is_insert = true },
348+
}
349+
350+
-- Insert before row 1 (0-indexed)
351+
local success, errors, count = search_replace.apply(buf, replacements, 1)
352+
353+
assert.is_true(success)
354+
assert.equals(0, #errors)
355+
assert.equals(1, count)
356+
357+
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
358+
assert.are.same({ 'line 1', 'first', 'second', 'third', '', 'line 3' }, lines)
359+
360+
vim.api.nvim_buf_delete(buf, { force = true })
361+
end)
362+
363+
it('returns error for insert without cursor_row', function()
364+
local buf = vim.api.nvim_create_buf(false, true)
365+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1' })
366+
367+
local replacements = {
368+
{ search = '', replace = 'inserted text', block_number = 1, is_insert = true },
369+
}
370+
371+
-- No cursor_row provided
372+
local success, errors, count = search_replace.apply(buf, replacements)
373+
374+
assert.is_false(success)
375+
assert.equals(1, #errors)
376+
assert.matches('Insert operation requires cursor position', errors[1])
377+
assert.equals(0, count)
378+
379+
vim.api.nvim_buf_delete(buf, { force = true })
380+
end)
280381
end)

0 commit comments

Comments
 (0)