diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 4a2f643b..16b766d7 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -764,7 +764,12 @@ local function add_symbol_reference_highlights(output, rendered, rendered_mentio local abs_start = line_start + start_pos - 1 local abs_end = line_start + end_pos - 1 - if token and not in_ranges(rendered_mention_ranges, abs_start, abs_end) and symbol_refs[token] then + if + token + and symbol_refs[token] + and symbol_tokens.is_code_like(line, start_pos, end_pos, token) + and not in_ranges(rendered_mention_ranges, abs_start, abs_end) + then output:add_extmark(first_line_idx + line_idx - 1, { start_col = start_pos - 1, end_col = end_pos, @@ -818,7 +823,12 @@ local function add_symbol_reference_targets(output, rendered, rendered_mention_r -- Empty-candidate guard keeps the zero-lookup contract testable here; -- targets_for_token re-checks internally for other callers. - if token and not in_ranges(rendered_mention_ranges, abs_start, abs_end) and #candidates > 0 then + if + token + and #candidates > 0 + and symbol_tokens.is_code_like(line, start_pos, end_pos, token) + and not in_ranges(rendered_mention_ranges, abs_start, abs_end) + then local targets = symbol_snapshot.targets_for_token(context.symbol_cycle, token, candidates) if #targets > 0 then output:add_target({ diff --git a/lua/opencode/ui/symbol_tokens.lua b/lua/opencode/ui/symbol_tokens.lua index 28a89f3d..1e7dd477 100644 --- a/lua/opencode/ui/symbol_tokens.lua +++ b/lua/opencode/ui/symbol_tokens.lua @@ -50,6 +50,19 @@ function M.find(line, scan_from) return start_pos, end_pos, token end +-- Plain prose words collide with locals in candidate files, so only accept +-- identifier-shaped tokens or ones quoted as inline code. +---@param line string +---@param start_pos integer +---@param end_pos integer +---@param token string +---@return boolean +function M.is_code_like(line, start_pos, end_pos, token) + return token:find('[_.:]') ~= nil + or token:find('%l%u') ~= nil + or (line:sub(start_pos - 1, start_pos - 1) == '`' and line:sub(end_pos + 1, end_pos + 1) == '`') +end + function M.at_col(line, col) local scan_from = 1 while scan_from <= #line do diff --git a/tests/data/symbol-reference-navigation.json b/tests/data/symbol-reference-navigation.json index 735b1ef5..ca8bd8e4 100644 --- a/tests/data/symbol-reference-navigation.json +++ b/tests/data/symbol-reference-navigation.json @@ -59,7 +59,7 @@ "messageID": "msg_symbol_reference_navigation", "sessionID": "ses_symbol_reference_navigation", "type": "text", - "text": "collect should be highlighted from the referenced file." + "text": "`collect` should be highlighted from the referenced file." } } } diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 080ef2b0..958af782 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -808,7 +808,7 @@ describe('formatter', function() } local output = Output.new() - formatter._format_assistant_message(output, 'foo', { id = 'part_symbol_only' }, { + formatter._format_assistant_message(output, '`foo`', { id = 'part_symbol_only' }, { id = 'msg_symbol_only', kind = 'assistant', session_id = 'ses_1', @@ -826,12 +826,47 @@ describe('formatter', function() { kind = 'symbol', token = 'foo', - range = { line = 1, start_col = 0, end_col = 3 }, + range = { line = 1, start_col = 1, end_col = 4 }, }, }, output.targets) assert.are.equal('OpencodeSymbolReference', output.extmarks[0][1].hl_group) end) + it('limits symbol targets to code-like or backticked tokens', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + package.loaded['opencode.ui.symbol_snapshot'] = { + targets_for_token = function(_, token) + return { { token = token, path = '/test/project/src/foo.lua', line = 1, col = 1 } } + end, + } + + local output = Output.new() + formatter._format_assistant_message( + output, + 'Move to the first line, `first` switch_to_mode M.apply fooBar', + { + id = 'part_prose', + }, + nil, + { + interactive = true, + current_files = { '/test/project/src/foo.lua' }, + current_refs = {}, + symbol_cycle = {}, + } + ) + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.are.same( + { 'first', 'switch_to_mode', 'M.apply', 'fooBar' }, + vim.tbl_map(function(target) + return target.token + end, output.targets) + ) + assert.are.equal(4, #output.extmarks[0]) + end) + it('does not create symbol targets without local candidate files', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] @@ -930,14 +965,15 @@ describe('formatter', function() it('highlights a symbol before trailing prose colon', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] - local text = 'See `src/main.lua` foo: call this' + local text = 'See `src/main.lua` foo_bar: call this' local ref_start, ref_end = text:find('`src/main.lua`', 1, true) local part = { id = 'part_colon', kind = 'text', text = text } local message = assistant({ part }, { id = 'msg_colon' }) package.loaded['opencode.ui.symbol_snapshot'] = { targets_for_token = function(_, token, candidate_files) assert.are.same({ vim.fn.getcwd() .. '/src/main.lua' }, candidate_files) - return token == 'foo' and { { token = 'foo', path = vim.fn.getcwd() .. '/src/main.lua', line = 3, col = 1 } } + return token == 'foo_bar' + and { { token = 'foo_bar', path = vim.fn.getcwd() .. '/src/main.lua', line = 3, col = 1 } } or {} end, } @@ -961,11 +997,11 @@ describe('formatter', function() package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot local symbol_mark = output.extmarks[0][2] - local foo_start = output.lines[1]:find('foo:', 1, true) + local foo_start = output.lines[1]:find('foo_bar:', 1, true) assert.are.equal(text:gsub('See ', 'See ' .. require('opencode.ui.icons').get('reference'), 1), output.lines[1]) assert.are.equal(foo_start - 1, symbol_mark.start_col) - assert.are.equal(foo_start + 2, symbol_mark.end_col) - assert.are.equal('foo', output.targets[2].token) + assert.are.equal(foo_start + 6, symbol_mark.end_col) + assert.are.equal('foo_bar', output.targets[2].token) end) it('formats grep tools when streamed input contains vim.NIL placeholders', function() diff --git a/tests/unit/symbol_tokens_spec.lua b/tests/unit/symbol_tokens_spec.lua index baf1e6f4..e23df699 100644 --- a/tests/unit/symbol_tokens_spec.lua +++ b/tests/unit/symbol_tokens_spec.lua @@ -60,4 +60,14 @@ describe('opencode.ui.symbol_tokens', function() assert.is_nil(symbol_tokens.at_col('tests/data/symbol-reference-navigation.json', 8)) assert.is_nil(symbol_tokens.at_col('.cache/', 2)) end) + + it('accepts only code-like tokens', function() + for _, token in ipairs({ 'switch_to_mode', 'M.apply', 'A::b', 'fooBar' }) do + assert.is_true(symbol_tokens.is_code_like(token, 1, #token, token), token) + end + for _, token in ipairs({ 'first', 'Update', 'JSON' }) do + assert.is_false(symbol_tokens.is_code_like(token, 1, #token, token), token) + end + assert.is_true(symbol_tokens.is_code_like('see `first`', 6, 10, 'first')) + end) end)