diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index f0961dab..576bdb85 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -296,7 +296,7 @@ function M.actions.clear_files() end function M.actions.jump_to_file() - require('opencode.ui.navigation').jump_to_target_at_cursor() + require('opencode.ui.navigation').jump_to_file_at_cursor() end function M.actions.jump_to_target_at_cursor() diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 1d873257..29eda642 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -59,7 +59,6 @@ function M.setup(opts) require('opencode.event_manager').setup() require('opencode.context').setup() require('opencode.ui.context_bar').setup() - require('opencode.ui.reference_picker').setup() end return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7c01caec..68ca2d5e 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -545,6 +545,48 @@ ---@field display_line number Line number to display the action ---@field range? { from: number, to: number } Optional range for the action +---@class CodeReferenceTextRange +---@field start_offset integer Raw part text offset, 1-based inclusive +---@field end_offset integer Raw part text offset, 1-based inclusive + +---@class CodeReference +---@field session_id string +---@field message_id string +---@field part_id string +---@field path string +---@field line? integer +---@field col? integer +---@field source_kind 'assistant_text'|'tool_file_path' +---@field raw_range? CodeReferenceTextRange Required for assistant_text references +---@field order integer Smaller values appear earlier in the session message/part/text order. + +---@class SymbolSnapshotCycle + +---@class FormatterContext +---@field interactive boolean +---@field get_child_parts? fun(session_id: string): OpencodeMessagePart[]? +---@field current_refs? CodeReference[] +---@field current_files? string[] +---@field symbol_cycle? SymbolSnapshotCycle + +---@class OutputTargetRange +---@field line integer Output-local line, 1-based +---@field start_col integer Output-local column, 0-based inclusive +---@field end_col integer Output-local column, 0-based exclusive + +---@class OutputTarget +---@field kind 'file'|'diff'|'symbol' +---@field range OutputTargetRange +---@field path? string +---@field line? integer +---@field col? integer +---@field token? string +---@field candidate_files? string[] + +---@class RenderedTarget: OutputTarget +---@field part_id string +---@field message_id string + ---@alias OutputExtmarkType vim.api.keyset.set_extmark & {start_col:0} ---@alias OutputExtmark OutputExtmarkType|fun():OutputExtmarkType diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 6bceadf5..133ce972 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -39,6 +39,17 @@ function M.setup_autocmds(windows) end, }) + vim.api.nvim_create_autocmd({ 'BufWritePost', 'BufFilePost', 'BufDelete', 'BufWipeout', 'FileChangedShellPost' }, { + group = group, + pattern = '*', + callback = function(args) + if args.file == '' then + return + end + require('opencode.ui.renderer.events').invalidate_reference_targets_for_file_change() + end, + }) + vim.api.nvim_create_autocmd('WinEnter', { group = group, pattern = '*', diff --git a/lua/opencode/ui/event_scope.lua b/lua/opencode/ui/event_scope.lua index 221335ad..a93f9482 100644 --- a/lua/opencode/ui/event_scope.lua +++ b/lua/opencode/ui/event_scope.lua @@ -87,6 +87,9 @@ local policies = { ['file.edited'] = function() return true end, + ['file.watcher.updated'] = function() + return true + end, ['custom.restore_point.created'] = function() return true end, diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 1a20f821..d47d99db 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -7,6 +7,7 @@ local config = require('opencode.config') local snapshot = require('opencode.snapshot') local mention = require('opencode.ui.mention') local permission_window = require('opencode.ui.permission_window') +local symbol_tokens = require('opencode.ui.symbol_tokens') local tool_formatters = require('opencode.ui.formatter.tools') local format_utils = require('opencode.ui.formatter.utils') @@ -601,49 +602,339 @@ function M._format_context_file(output, path) return output:add_line(string.format('[`%s`](%s)', path, path)) end ----@param output Output Output object to write to ----@param text string ----@param message_id string|nil Optional message ID for reference parsing -function M._format_assistant_message(output, text, message_id) - local reference_picker = require('opencode.ui.reference_picker') - local references = reference_picker.parse_references(text, message_id) +local function ranges_overlap(start_a, end_a, start_b, end_b) + return start_a <= end_b and end_a >= start_b +end - -- If no references, just add the text as-is - if #references == 0 then - output:add_lines(vim.split(text, '\n')) - return +local function in_ranges(ranges, start_pos, end_pos) + for _, range in ipairs(ranges) do + if ranges_overlap(start_pos, end_pos, range.start_offset, range.end_offset) then + return true + end + end + return false +end + +local function available_file_set(context) + local files = {} + for _, path in ipairs((context and context.current_files) or {}) do + if type(path) == 'string' and path ~= '' then + files[path] = true + end + end + return files +end + +local function resolve_available_path(path, available_files) + if type(path) ~= 'string' or path == '' then + return nil + end + if path:sub(1, 1) == '/' then + return available_files[path] and path or nil + end + local absolute = (vim.fn.getcwd and vim.fn.getcwd() or '') .. '/' .. path + if available_files[absolute] then + return absolute + end +end + +local function add_candidate_file(candidates, seen, available_files, path) + local absolute = resolve_available_path(path, available_files) + if absolute and not seen[absolute] then + seen[absolute] = true + candidates[#candidates + 1] = absolute + end +end + +local function output_range_for_absolute_range(rendered, first_output_line, start_offset, end_offset) + local line_start = 1 + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local line_end = line_start + #line - 1 + if ranges_overlap(line_start, line_end, start_offset, end_offset) then + return { + line = first_output_line + line_idx, + start_col = math.max(start_offset, line_start) - line_start, + end_col = math.min(end_offset, line_end) - line_start + 1, + } + end + line_start = line_start + #line + 1 + end +end + +local function current_part_index(message, part) + if not (message and message.parts and part and part.id) then + return nil + end + for index, candidate in ipairs(message.parts) do + if candidate.id == part.id then + return index + end + end +end + +local function previous_part_candidate_files(message, part, context, available_files) + local index = current_part_index(message, part) + if not index then + return {} + end + + local part_index_by_id = {} + for part_index, message_part in ipairs(message.parts or {}) do + if message_part.id then + part_index_by_id[message_part.id] = part_index + end + end + + local candidates = {} + local seen = {} + local message_id = message.info and message.info.id + for _, ref in ipairs((context and context.current_refs) or {}) do + local ref_part_index = part_index_by_id[ref.part_id] + if ref.message_id == message_id and ref_part_index and ref_part_index < index then + add_candidate_file(candidates, seen, available_files, ref.path) + end + end + + return candidates +end + +local function part_text_trim_offset(part, text) + local raw_text = part and part.text + if type(raw_text) ~= 'string' or raw_text == text then + return 0 + end + + local visible_start = raw_text:find(text, 1, true) + return visible_start and (visible_start - 1) or 0 +end + +local function current_part_text_references(part, message, text, context) + if not (part and part.id and message and message.info and message.info.id and context and context.current_refs) then + return {} + end + + local trim_offset = part_text_trim_offset(part, text) + local refs = {} + for _, ref in ipairs(context.current_refs) do + local raw_range = ref.raw_range + if + ref.source_kind == 'assistant_text' + and ref.message_id == message.info.id + and ref.part_id == part.id + and raw_range + then + local match_start = raw_range.start_offset - trim_offset + local match_end = raw_range.end_offset - trim_offset + if match_start >= 1 and match_end <= #text and match_start <= match_end then + refs[#refs + 1] = { + file_path = ref.path, + line = ref.line, + col = ref.col, + match_start = match_start, + match_end = match_end, + } + end + end end + return refs +end - -- Sort references by match_start position (ascending) +local function rendered_text_with_reference_ranges(text, references, available_files) table.sort(references, function(a, b) return a.match_start < b.match_start end) - -- Build a new text with icons inserted before each reference - local result = '' + local rendered = '' + local executable_reference_ranges = {} + local rendered_mention_ranges = {} local last_pos = 1 local ref_icon = icons.get('reference') for _, ref in ipairs(references) do - -- Add text before this reference - result = result .. text:sub(last_pos, ref.match_start - 1) - -- Add the icon and the reference - result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end) + rendered = rendered .. text:sub(last_pos, ref.match_start - 1) + + local ref_text = text:sub(ref.match_start, ref.match_end) + local absolute = resolve_available_path(ref.file_path, available_files) + local rendered_ref_start = #rendered + (absolute and #ref_icon or 0) + 1 + rendered = rendered .. (absolute and ref_icon or '') .. ref_text + local range = { + start_offset = rendered_ref_start, + end_offset = rendered_ref_start + #ref_text - 1, + path = ref.file_path, + absolute_path = absolute, + line = ref.line, + col = ref.col, + } + rendered_mention_ranges[#rendered_mention_ranges + 1] = range + if absolute then + executable_reference_ranges[#executable_reference_ranges + 1] = range + end + last_pos = ref.match_end + 1 end - -- Add any remaining text after the last reference if last_pos <= #text then - result = result .. text:sub(last_pos) + rendered = rendered .. text:sub(last_pos) end - output:add_lines(vim.split(result, '\n')) + return rendered, executable_reference_ranges, rendered_mention_ranges +end + +local function add_symbol_reference_highlights(output, rendered, rendered_mention_ranges, symbol_refs, first_line_idx) + local line_start = 1 + + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local scan_from = 1 + while scan_from <= #line do + local start_pos, end_pos, token = symbol_tokens.find(line, scan_from) + if not start_pos then + break + end + + 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 + output:add_extmark(first_line_idx + line_idx - 1, { + start_col = start_pos - 1, + end_col = end_pos, + hl_group = 'OpencodeSymbolReference', + priority = 900, + }) + end + + scan_from = end_pos + 1 + end + + line_start = line_start + #line + 1 + end +end + +local function add_file_reference_targets(output, rendered, rendered_reference_ranges, first_line_idx) + for _, range in ipairs(rendered_reference_ranges) do + local output_range = output_range_for_absolute_range(rendered, first_line_idx, range.start_offset, range.end_offset) + if output_range then + output:add_target({ + kind = 'file', + path = range.absolute_path, + line = range.line, + col = range.col, + range = output_range, + }) + end + end +end + +local function add_symbol_reference_targets( + output, + rendered, + rendered_mention_ranges, + first_line_idx, + part, + message, + context +) + if not (context and context.interactive and context.symbol_cycle) then + return {} + end + + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local available_files = available_file_set(context) + local prior_part_candidates = previous_part_candidate_files(message, part, context, available_files) + local line_start = 1 + local targeted_tokens = {} + + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local scan_from = 1 + while scan_from <= #line do + local start_pos, end_pos, token = symbol_tokens.find(line, scan_from) + if not start_pos then + break + end + + 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) then + local candidates = {} + local seen = {} + for _, range in ipairs(rendered_mention_ranges) do + if range.end_offset < abs_start then + add_candidate_file(candidates, seen, available_files, range.path) + end + end + if #candidates == 0 then + candidates = prior_part_candidates + end + + if #candidates > 0 and #symbol_snapshot.targets_for_token(context.symbol_cycle, token, candidates) > 0 then + output:add_target({ + kind = 'symbol', + token = token, + candidate_files = vim.deepcopy(candidates), + range = { + line = first_line_idx + line_idx, + start_col = start_pos - 1, + end_col = end_pos, + }, + }) + targeted_tokens[token] = true + end + end + + scan_from = end_pos + 1 + end + + line_start = line_start + #line + 1 + end + + return targeted_tokens +end + +local function add_file_reference_highlights(output, rendered, rendered_reference_ranges, first_line_idx) + local line_start = 1 + + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local line_end = line_start + #line - 1 + for _, range in ipairs(rendered_reference_ranges) do + if ranges_overlap(line_start, line_end, range.start_offset, range.end_offset) then + output:add_extmark(first_line_idx + line_idx - 1, { + start_col = math.max(range.start_offset, line_start) - line_start, + end_col = math.min(range.end_offset, line_end) - line_start + 1, + hl_group = 'OpencodeReference', + priority = 1000, + }) + end + end + line_start = line_start + #line + 1 + end +end + +---@param output Output Output object to write to +---@param text string +---@param part? OpencodeMessagePart +---@param message? OpencodeMessage +---@param context? FormatterContext +function M._format_assistant_message(output, text, part, message, context) + local references = current_part_text_references(part, message, text, context) + local rendered, rendered_reference_ranges, rendered_mention_ranges = + rendered_text_with_reference_ranges(text, references, available_file_set(context)) + local first_line_idx = output:get_line_count() + + output:add_lines(vim.split(rendered, '\n')) + if context and context.interactive then + add_file_reference_targets(output, rendered, rendered_reference_ranges, first_line_idx) + end + add_file_reference_highlights(output, rendered, rendered_reference_ranges, first_line_idx) + local targeted_tokens = + add_symbol_reference_targets(output, rendered, rendered_mention_ranges, first_line_idx, part, message, context) + add_symbol_reference_highlights(output, rendered, rendered_mention_ranges, targeted_tokens, first_line_idx) end ---@param output Output Output object to write to ---@param part OpencodeMessagePart ----@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]? -function M.format_tool(output, part, get_child_parts) +---@param context FormatterContext +function M.format_tool(output, part, context) local tool = part.tool if not tool or not part.state then return @@ -653,7 +944,7 @@ function M.format_tool(output, part, get_child_parts) local formatter = tool_formatters[tool] or (tool:match('_') and tool_formatters.mcp) or tool_formatters.tool local fold_count = #output.fold_ranges - formatter.format(output, part, get_child_parts) + formatter.format(output, part, context) if not format_utils.should_fold_tool(tool) then for idx = #output.fold_ranges, fold_count + 1, -1 do @@ -702,9 +993,9 @@ end ---@param part OpencodeMessagePart The part to format ---@param message? OpencodeMessage Optional message object to extract role and mentions from ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one ----@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]? +---@param context FormatterContext ---@return Output -function M.format_part(part, message, is_last_part, get_child_parts) +function M.format_part(part, message, is_last_part, context) local output = Output.new() if not message or not message.info or not message.info.role then @@ -754,13 +1045,13 @@ function M.format_part(part, message, is_last_part, get_child_parts) end elseif role == 'assistant' then if part.type == 'text' and part.text then - M._format_assistant_message(output, vim.trim(part.text), part.messageID) + M._format_assistant_message(output, vim.trim(part.text), part, message, context) content_added = true elseif part.type == 'reasoning' then M._format_reasoning(output, part) content_added = true elseif part.type == 'tool' then - M.format_tool(output, part, get_child_parts) + M.format_tool(output, part, context) content_added = true elseif part.type == 'patch' and part.hash then M._format_patch(output, part) diff --git a/lua/opencode/ui/formatter/tools/apply_patch.lua b/lua/opencode/ui/formatter/tools/apply_patch.lua index 6e202bee..8c2158fd 100644 --- a/lua/opencode/ui/formatter/tools/apply_patch.lua +++ b/lua/opencode/ui/formatter/tools/apply_patch.lua @@ -41,7 +41,7 @@ function M.format(output, part) if (config.ui.output.tools.show_output or config.ui.output.tools.use_folds) and patch then local start_line = output:get_line_count() + 1 local file_type = file and util.get_markdown_filetype(file.filePath) or '' - formatter_utils.format_diff(output, patch, file_type) + formatter_utils.format_diff(output, patch, file_type, file.filePath) output:add_fold_with_threshold(start_line, config.ui.output.tools.show_output, config.ui.output.tools.use_folds) end end diff --git a/lua/opencode/ui/formatter/tools/file.lua b/lua/opencode/ui/formatter/tools/file.lua index a4401cb0..b61bfc4e 100644 --- a/lua/opencode/ui/formatter/tools/file.lua +++ b/lua/opencode/ui/formatter/tools/file.lua @@ -62,8 +62,22 @@ function M.format(output, part) local utils = require('opencode.ui.formatter.utils') local config = require('opencode.config') - local icons = require('opencode.ui.icons') - utils.format_action(output, icons.get(tool_type), tool_type, file_name, utils.get_duration_text(part)) + local icon_text = icons.get(tool_type) + utils.format_action(output, icon_text, tool_type, file_name, utils.get_duration_text(part)) + + if file_name ~= '' and input.filePath then + local action_line = output:get_line_count() + local line_content = output:get_line(action_line) + output:add_target({ + kind = 'file', + path = input.filePath, + range = { + line = action_line, + start_col = 0, + end_col = line_content and #line_content or 0, + }, + }) + end local start_line = output:get_line_count() + 1 if not (config.ui.output.tools.show_output or config.ui.output.tools.use_folds) then @@ -71,7 +85,7 @@ function M.format(output, part) end if tool_type == 'edit' and metadata.diff then - utils.format_diff(output, metadata.diff, file_type) + utils.format_diff(output, metadata.diff, file_type, input.filePath) elseif tool_type == 'write' and input.content then utils.format_code(output, vim.split(input.content, '\n'), file_type) end diff --git a/lua/opencode/ui/formatter/tools/task.lua b/lua/opencode/ui/formatter/tools/task.lua index 8bb8c8d0..b639b2ac 100644 --- a/lua/opencode/ui/formatter/tools/task.lua +++ b/lua/opencode/ui/formatter/tools/task.lua @@ -23,8 +23,8 @@ end ---@param output Output ---@param part OpencodeMessagePart ----@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]? -function M.format(output, part, get_child_parts) +---@param context? FormatterContext +function M.format(output, part, context) if part.tool ~= 'task' then return end @@ -49,7 +49,10 @@ function M.format(output, part, get_child_parts) local output_start_line = output:get_line_count() + 1 if config.ui.output.tools.show_output or config.ui.output.tools.use_folds then local child_session_id = metadata.sessionId - local child_parts = child_session_id and get_child_parts and get_child_parts(child_session_id) + local child_parts = child_session_id + and context + and context.get_child_parts + and context.get_child_parts(child_session_id) if child_parts and #child_parts > 0 then output:add_empty_line() @@ -73,7 +76,11 @@ function M.format(output, part, get_child_parts) end end - output:add_fold_with_threshold(output_start_line, config.ui.output.tools.show_output, config.ui.output.tools.use_folds) + output:add_fold_with_threshold( + output_start_line, + config.ui.output.tools.show_output, + config.ui.output.tools.use_folds + ) end local end_line = output:get_line_count() diff --git a/lua/opencode/ui/formatter/utils.lua b/lua/opencode/ui/formatter/utils.lua index 35cdf509..7c858132 100644 --- a/lua/opencode/ui/formatter/utils.lua +++ b/lua/opencode/ui/formatter/utils.lua @@ -119,7 +119,7 @@ local function build_diff_gutter(line_numbers, width) return string.format('%' .. width .. 's', line_number and tostring(line_number) or '') end -local function add_diff_line(output, line, line_numbers, width) +local function add_diff_line(output, line, line_numbers, width, source_path) local first_char = line:sub(1, 1) local line_hl = first_char == '+' and 'OpencodeDiffAdd' or first_char == '-' and 'OpencodeDiffDelete' or nil local gutter_hl = first_char == '+' and 'OpencodeDiffAddGutter' @@ -129,9 +129,23 @@ local function add_diff_line(output, line, line_numbers, width) local gutter = build_diff_gutter(line_numbers, width) local gutter_width = #gutter + 2 - output:add_line(string.rep(' ', gutter_width) .. line:sub(2)) + local rendered_line = string.rep(' ', gutter_width) .. line:sub(2) + output:add_line(rendered_line) local line_idx = output:get_line_count() + if source_path and line_numbers.new then + output:add_target({ + kind = 'diff', + path = source_path, + line = line_numbers.new, + range = { + line = line_idx, + start_col = 0, + end_col = #rendered_line, + }, + }) + end + local extmark = { end_col = 0, end_row = line_idx, @@ -159,7 +173,8 @@ end ---@param output Output ---@param code string ---@param file_type string -function M.format_diff(output, code, file_type) +---@param source_path? string +function M.format_diff(output, code, file_type, source_path) output:add_empty_line() --- NOTE: use longer code fence because code could contain ``` @@ -172,7 +187,7 @@ function M.format_diff(output, code, file_type) for idx, line in ipairs(lines) do local source_idx = first_visible_line + idx - 1 if numbered_lines[source_idx] then - add_diff_line(output, line, numbered_lines[source_idx], line_number_width) + add_diff_line(output, line, numbered_lines[source_idx], line_number_width, source_path) else output:add_line(line) end diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index b92269cc..e72cdfb3 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -41,7 +41,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) - vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#1976D2', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#5F7896', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSymbolReference', { link = 'Identifier', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#E3F2FD', fg = '#1976D2', default = true }) @@ -89,7 +90,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) - vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#7AA2F7', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#8AA6C8', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSymbolReference', { link = 'Identifier', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#2B3A5A', fg = '#61AFEF', default = true }) diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index fe8e6894..3e0feac3 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -3,7 +3,6 @@ local M = {} local state = require('opencode.state') local config = require('opencode.config') local renderer = require('opencode.ui.renderer') -local output_window = require('opencode.ui.output_window') function M.goto_message_by_id(message_id) require('opencode.ui.ui').focus_output() @@ -118,168 +117,32 @@ local function resolve_path(raw) if vim.uv.fs_stat(absolute) then return absolute end - local found = vim.fn.findfile(raw, '.;') - if found ~= '' then - return found - end -end - -local function parse_path_location(raw) - if raw:match('^%a[%w+.-]*://') and not raw:match('^file://') then - return nil - end - - local path = raw:gsub('^file://', '') - local line, col - local p, l, c = path:match('^(.-):(%d+):(%d+)$') - if p then - path, line, col = p, tonumber(l), tonumber(c) - else - p, l = path:match('^(.-):(%d+)$') - if p then - path, line = p, tonumber(l) - end - end - - if path == '' then - return nil - end - - return { path = path, line = line, col = col } -end - -local function contains_col(start_pos, end_pos, col) - return col >= start_pos - 1 and col <= end_pos - 1 -end - -local function add_file_candidate(candidates, line, pattern, path_capture_index) - path_capture_index = path_capture_index or 2 - local captures = { line:match(pattern) } - while #captures > 0 do - local start_pos = captures[1] - local end_pos = captures[#captures] - 1 - local raw = captures[path_capture_index] - table.insert(candidates, { - start_pos = start_pos, - end_pos = end_pos, - target = parse_path_location(raw), - }) - local next_start = end_pos + 2 - captures = { line:match(pattern, next_start) } - end -end - -local function file_target_at_col(line, col) - local candidates = {} - - add_file_candidate(candidates, line, '()%[%`([^`]+)%`%]%([^%)]+%)()') - add_file_candidate(candidates, line, '()%`([^`\n]+%.%w+:?%d*:?%d*)%`()') - add_file_candidate(candidates, line, '()file://([%S]+%.%w+:?%d*:?%d*)()') - add_file_candidate(candidates, line, '()%*%*.-%*%*%s+%`([^`]+)%`()') - add_file_candidate(candidates, line, '()([%w_./%-]+/[%w_./%-]*%.%w+:?%d*:?%d*)()') - add_file_candidate(candidates, line, '()([%w_%-]+%.%w+:?%d*:?%d*)()') - - for _, candidate in ipairs(candidates) do - if candidate.target and contains_col(candidate.start_pos, candidate.end_pos, col) and resolve_path(candidate.target.path) then - return candidate.target - end - end -end - -local function first_file_target(line) - local max_col = math.max(#line - 1, 0) - for col = 0, max_col do - local target = file_target_at_col(line, col) - if target then - return target - end - end -end - -local function diff_line_number(buf, line_num) - local ns = output_window.namespace - local extmarks = vim.api.nvim_buf_get_extmarks(buf, ns, { line_num - 1, 0 }, { line_num - 1, -1 }, { details = true }) - for _, extmark in ipairs(extmarks) do - local details = extmark[4] - local virt_text = details and details.virt_text - if virt_text then - local gutter = virt_text[1] and virt_text[1][1] - local sign = virt_text[2] and virt_text[2][1] - if sign == '-' then - return nil - end - if sign == '+' or sign == ' ' then - return tonumber(vim.trim(gutter or '')) - end - end - end -end - ----Resolve file and line number at cursor position in the output buffer. ----@return { path: string, line: number? }? -function M.resolve_file_at_cursor() - local windows = state.windows or {} - local win = windows.output_win - local buf = windows.output_buf - - if not win or not buf or not vim.api.nvim_win_is_valid(win) then - return nil - end - - local cursor = vim.api.nvim_win_get_cursor(win) - local line_num = cursor[1] - local col = cursor[2] - local line = vim.api.nvim_buf_get_lines(buf, line_num - 1, line_num, false)[1] - - if not line then - return nil - end - - local target = file_target_at_col(line, col) - if target then - return target - end - - local file_path = nil - for i = line_num, 1, -1 do - local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] - if l then - local found = first_file_target(l) - if found then - file_path = found.path - break - end - end - end - - if not file_path then - return nil - end - - local ln = diff_line_number(buf, line_num) - if not ln then - return nil - end - - return { path = file_path, line = ln } end ---Open a file in the current window without triggering BufRead/BufNew autocmds. ---Falls back to :edit if the file isn't loaded in any buffer yet. ---@param path string +---@return boolean local function open_silent(path) local escaped = vim.fn.fnameescape(path) - if not pcall(vim.cmd, 'buffer ' .. escaped) then - pcall(vim.cmd, 'edit ' .. escaped) - end + if not pcall(function() + vim.cmd('buffer ' .. escaped) + end) then + return pcall(function() + vim.cmd('edit ' .. escaped) + end) + end + return true end local function open_at(win, path, line, col) if not win or not vim.api.nvim_win_is_valid(win) then - return + return false end vim.api.nvim_set_current_win(win) - open_silent(path) + if not open_silent(path) then + return false + end if line then local buf = vim.api.nvim_win_get_buf(win) local line_count = vim.api.nvim_buf_line_count(buf) @@ -293,6 +156,7 @@ local function open_at(win, path, line, col) pcall(vim.api.nvim_win_set_cursor, win, { target_line, target_col }) vim.cmd('normal! zz') end + return true end local function best_target_win() @@ -309,33 +173,124 @@ end ---@param path string ---@param line? number ---@param col? number +---@return boolean function M.navigate_to_location(path, line, col) local resolved_path = resolve_path(path) if not resolved_path then - return + return false end local target_win = best_target_win() local windows = state.windows if config.ui.position == 'current' and windows and target_win == windows.output_win then require('opencode.ui.ui').hide_visible_windows(windows) end - open_at(target_win, resolved_path, line, col) + return open_at(target_win, resolved_path, line, col) end function M.resolve_target_at_cursor() - return M.resolve_file_at_cursor() + local windows = state.windows or {} + local win = windows.output_win + if not win or not vim.api.nvim_win_is_valid(win) then + return nil + end + + local cursor = vim.api.nvim_win_get_cursor(win) + return renderer.get_target_at_position(cursor[1], cursor[2]) end -function M.jump_to_target_at_cursor() - local resolved = M.resolve_target_at_cursor() - if not resolved then +local function format_symbol_target(target, width) + local location = target.path + if target.line then + location = location .. ':' .. target.line + if target.col then + location = location .. ':' .. target.col + end + end + local kind = target.kind and (' [' .. target.kind .. ']') or '' + return require('opencode.ui.base_picker').create_time_picker_item( + target.token .. kind .. ' ' .. location, + nil, + nil, + width + ) +end + +local function pick_symbol_target(targets) + return require('opencode.ui.base_picker').pick({ + items = targets, + format_fn = format_symbol_target, + actions = {}, + callback = function(selected) + if selected then + M.navigate_to_location(selected.path, selected.line, selected.col) + end + end, + title = 'Symbol References (' .. #targets .. ')', + width = config.ui.picker_width, + preview = 'file', + layout_opts = config.ui.picker, + }) +end + +local function target_at_cursor(filter) + local windows = state.windows or {} + local win = windows.output_win + if not win or not vim.api.nvim_win_is_valid(win) then + return nil + end + + local cursor = vim.api.nvim_win_get_cursor(win) + return renderer.get_target_at_position(cursor[1], cursor[2], filter) +end + +local function jump_to_symbol_target(target) + if not target.token then return end - M.navigate_to_location(resolved.path, resolved.line, resolved.col) + + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local targets = + symbol_snapshot.targets_for_token(symbol_snapshot.new_cycle(), target.token, target.candidate_files or {}) + if #targets == 0 then + vim.notify('No symbol target found: ' .. target.token, vim.log.levels.INFO) + return + end + + if #targets == 1 then + local resolved = targets[1] + M.navigate_to_location(resolved.path, resolved.line, resolved.col) + return + end + + pick_symbol_target(targets) +end + +local function jump_to_rendered_target(target) + if target.kind == 'file' or target.kind == 'diff' then + M.navigate_to_location(target.path, target.line, target.col) + return + end + + if target.kind == 'symbol' then + jump_to_symbol_target(target) + end +end + +function M.jump_to_target_at_cursor() + local target = target_at_cursor() + if target then + jump_to_rendered_target(target) + end end function M.jump_to_file_at_cursor() - M.jump_to_target_at_cursor() + local target = target_at_cursor(function(candidate) + return candidate.kind == 'file' or candidate.kind == 'diff' + end) + if not target then + return + end + jump_to_rendered_target(target) end return M diff --git a/lua/opencode/ui/output.lua b/lua/opencode/ui/output.lua index dbfc6c3b..a1c31048 100644 --- a/lua/opencode/ui/output.lua +++ b/lua/opencode/ui/output.lua @@ -7,6 +7,7 @@ Output.__index = Output ---@field lines string[] ---@field extmarks table ---@field actions OutputAction[] +---@field targets OutputTarget[] ---@field add_line fun(self: Output, line: string, fit?: boolean): number ---@field get_line fun(self: Output, idx: number): string? ---@field merge_line fun(self: Output, idx: number, text: string) @@ -20,12 +21,15 @@ Output.__index = Output ---@field add_actions fun(self: Output, actions: OutputAction[]) ---@field add_action fun(self: Output, action: OutputAction) ---@field get_actions_for_line fun(self: Output, line: number): OutputAction[]? +---@field add_target fun(self: Output, target: OutputTarget) +---@field add_targets fun(self: Output, targets: OutputTarget[]) ---@return self Output function Output.new() local self = setmetatable({}, Output) self.lines = {} self.extmarks = {} self.actions = {} + self.targets = {} self.fold_ranges = {} return self end @@ -79,11 +83,12 @@ function Output:add_empty_line() return nil end ----Clear all lines, extmarks, and actions +---Clear all lines, extmarks, actions, and targets function Output:clear() self.lines = {} self.extmarks = {} self.actions = {} + self.targets = {} end ---Add a fold range @@ -162,6 +167,18 @@ function Output:add_action(action) table.insert(self.actions, action) end +---@param target OutputTarget +function Output:add_target(target) + table.insert(self.targets, target) +end + +---@param targets OutputTarget[] +function Output:add_targets(targets) + for _, target in ipairs(targets) do + self:add_target(target) + end +end + ---Get actions for a line matching a range ---@param line number The line index to check ---@return OutputAction[]|nil diff --git a/lua/opencode/ui/reference_facts.lua b/lua/opencode/ui/reference_facts.lua new file mode 100644 index 00000000..2a395bd2 --- /dev/null +++ b/lua/opencode/ui/reference_facts.lua @@ -0,0 +1,308 @@ +local M = {} + +local reference_parser = require('opencode.ui.reference_parser') + +local current_session_id = nil +local messages_by_id = {} +local next_message_order = 1 +local current_files = {} + +local function relative_path(path) + if path:sub(1, 1) ~= '/' then + return path + end + return vim.fn.fnamemodify(path, ':~:.') +end + +local function absolute_path(path) + if path:sub(1, 1) == '/' then + return path + end + return vim.fn.getcwd() .. '/' .. path +end + +local function file_is_available(path) + local absolute = absolute_path(path) + if vim.fn.filereadable(absolute) == 1 then + return true, absolute + end + return false, absolute +end + +local function is_current_session_assistant_message(session_id, message) + return current_session_id == session_id + and message + and message.info + and message.info.sessionID == session_id + and message.info.role == 'assistant' + and not (message.info.id and message.info.id:match('^__opencode_')) +end + +local function collect_part_refs(session_id, message, part, message_order, part_order) + if not is_current_session_assistant_message(session_id, message) or not part or part.synthetic or not part.id then + return {} + end + + local refs = {} + local message_id = message.info.id + + if part.type == 'text' and part.text then + for ref_order, parsed in ipairs(reference_parser.parse_references(part.text, part.id)) do + table.insert(refs, { + session_id = session_id, + message_id = message_id, + part_id = part.id, + path = parsed.file_path, + line = parsed.line, + col = parsed.col, + source_kind = 'assistant_text', + raw_range = { + start_offset = parsed.match_start, + end_offset = parsed.match_end, + }, + order = message_order * 1000000 + part_order * 1000 + ref_order, + }) + end + elseif part.type == 'tool' then + local file_path = vim.tbl_get(part, 'state', 'input', 'filePath') + if file_path and file_path ~= '' then + table.insert(refs, { + session_id = session_id, + message_id = message_id, + part_id = part.id, + path = relative_path(file_path), + source_kind = 'tool_file_path', + order = message_order * 1000000 + part_order * 1000 + 1, + }) + end + end + + return refs +end + +local function refs_equal(a, b) + if #(a or {}) ~= #(b or {}) then + return false + end + for i = 1, #a do + local left = a[i] + local right = b[i] + if + left.path ~= right.path + or left.line ~= right.line + or left.col ~= right.col + or left.source_kind ~= right.source_kind + then + return false + end + end + return true +end + +local function all_refs() + local entries = {} + for _, entry in pairs(messages_by_id) do + entries[#entries + 1] = entry + end + table.sort(entries, function(a, b) + return a.order < b.order + end) + + local refs = {} + for _, entry in ipairs(entries) do + local parts = {} + for _, part_entry in pairs(entry.parts) do + parts[#parts + 1] = part_entry + end + table.sort(parts, function(a, b) + return a.order < b.order + end) + + for _, part_entry in ipairs(parts) do + for _, ref in ipairs(part_entry.refs) do + refs[#refs + 1] = ref + end + end + end + + return refs +end + +local function rebuild_current_files() + current_files = {} + local seen = {} + for _, ref in ipairs(all_refs()) do + local available, absolute = file_is_available(ref.path) + if available and not seen[absolute] then + seen[absolute] = true + current_files[#current_files + 1] = absolute + end + end +end + +local function ensure_message_entry(message) + local message_id = message and message.info and message.info.id + if not message_id then + return nil + end + + local entry = messages_by_id[message_id] + if not entry then + entry = { + message = message, + order = next_message_order, + parts = {}, + } + next_message_order = next_message_order + 1 + messages_by_id[message_id] = entry + end + entry.message = message + return entry +end + +local function replace_part_entry(session_id, message, part) + local message_id = message and message.info and message.info.id + local part_id = part and part.id + if not message_id or not part_id then + return false + end + + if not is_current_session_assistant_message(session_id, message) then + local entry = messages_by_id[message_id] + if entry and entry.parts[part_id] then + entry.parts[part_id] = nil + return true + end + return false + end + + local entry = ensure_message_entry(message) + local part_order = 1 + for index, candidate in ipairs(message.parts or {}) do + if candidate.id == part_id then + part_order = index + break + end + end + + local old_refs = entry.parts[part_id] and entry.parts[part_id].refs or {} + local refs = collect_part_refs(session_id, message, part, entry.order, part_order) + if #refs > 0 then + entry.parts[part_id] = { order = part_order, refs = refs } + else + entry.parts[part_id] = nil + end + return not refs_equal(old_refs, refs) +end + +function M.clear() + current_session_id = nil + messages_by_id = {} + next_message_order = 1 + current_files = {} + reference_parser.clear_all() +end + +---@param session_id string +---@param messages OpencodeMessage[] +function M.rebuild(session_id, messages) + current_session_id = session_id + messages_by_id = {} + next_message_order = 1 + reference_parser.clear_all() + + for message_order, message in ipairs(messages or {}) do + if is_current_session_assistant_message(session_id, message) then + local entry = { + message = message, + order = message_order, + parts = {}, + } + messages_by_id[message.info.id] = entry + next_message_order = math.max(next_message_order, message_order + 1) + + for part_order, part in ipairs(message.parts or {}) do + if part.id then + local refs = collect_part_refs(session_id, message, part, message_order, part_order) + if #refs > 0 then + entry.parts[part.id] = { order = part_order, refs = refs } + end + end + end + end + end + + rebuild_current_files() +end + +---@param session_id string +---@param message OpencodeMessage +---@param part OpencodeMessagePart +---@return boolean refs_changed +function M.replace_part(session_id, message, part) + if not current_session_id then + current_session_id = session_id + end + local changed = replace_part_entry(session_id, message, part) + if changed then + rebuild_current_files() + end + return changed +end + +---@param message_id string +---@param part_id string +---@return boolean refs_changed +function M.remove_part(message_id, part_id) + reference_parser.clear(part_id) + local entry = messages_by_id[message_id] + local had_refs = entry and entry.parts[part_id] and #(entry.parts[part_id].refs or {}) > 0 + if entry then + entry.parts[part_id] = nil + end + if had_refs then + rebuild_current_files() + end + return had_refs == true +end + +---@param message_id string +---@return boolean refs_changed +function M.remove_message(message_id) + local entry = messages_by_id[message_id] + local had_refs = false + if entry then + for part_id, part_entry in pairs(entry.parts) do + reference_parser.clear(part_id) + if #(part_entry.refs or {}) > 0 then + had_refs = true + end + end + end + messages_by_id[message_id] = nil + if had_refs then + rebuild_current_files() + end + return had_refs +end + +---@return CodeReference[] +function M.current_refs() + local refs = {} + for _, ref in ipairs(all_refs()) do + refs[#refs + 1] = vim.deepcopy(ref) + end + + return refs +end + +---@return string[] +function M.current_files() + return vim.deepcopy(current_files) +end + +function M.refresh_current_files() + rebuild_current_files() +end + +return M diff --git a/lua/opencode/ui/reference_parser.lua b/lua/opencode/ui/reference_parser.lua new file mode 100644 index 00000000..7ba59010 --- /dev/null +++ b/lua/opencode/ui/reference_parser.lua @@ -0,0 +1,193 @@ +---@class ParsedCodeReference +---@field file_path string +---@field line number|nil +---@field col number|nil +---@field match_start number +---@field match_end number + +local M = {} + +local PATTERNS = { + { pat = '`([^`\n]+%.(%w+)):?(%d*):?(%d*)`' }, + { pat = 'file://([%S]+%.(%w+)):?(%d*):?(%d*)' }, + { pat = '([%w_./%-]+/[%w_./%-]*%.(%w+)):?(%d*):?(%d*)' }, + { pat = '([%w_%-]+%.(%w+)):?(%d*):?(%d*)' }, +} + +local OVERLAP = 128 +local cache = {} + +local function is_valid_ext(ext) + return #ext >= 1 and #ext <= 5 and ext:match('^%a+$') ~= nil +end + +local function is_url_path(path, chunk, ms) + local context = chunk:sub(math.max(1, ms - 64), ms - 1) + return context:match('https?://[%S]*$') + or context:match('www%.[%S]*$') + or path:match('^//') + or path:match('^www%.') + or path:match('^[%w%-]+%.[%w%-]+/') +end + +local function current_line_start_before(text, offset) + return text:sub(1, offset - 1):match('.*\n()') or 1 +end + +local function unclosed_inline_backtick_before(text, offset) + local line_start = current_line_start_before(text, offset) + local line_prefix = text:sub(line_start, offset - 1):gsub('```', ' ') + local _, backticks_before_offset = line_prefix:gsub('`', '') + if backticks_before_offset % 2 == 0 then + return nil + end + local relative_offset = line_prefix:match('^.*()`') + return relative_offset and (line_start + relative_offset - 1) or nil +end + +local function is_inside_unclosed_inline_backticks(text, offset) + if not unclosed_inline_backtick_before(text, offset) then + return false + end + local line_end = text:find('\n', offset, true) or (#text + 1) + local closing_backtick = text:find('`', offset, true) + return not (closing_backtick and closing_backtick < line_end) +end + +local function fenced_code_ranges(text) + local ranges = {} + local fence_start = nil + local line_start = 1 + + while line_start <= #text do + local newline = text:find('\n', line_start, true) + local line_end = newline and (newline - 1) or #text + local line = text:sub(line_start, line_end) + + if line:match('^%s*```') then + if fence_start then + ranges[#ranges + 1] = { fence_start, newline or line_end } + fence_start = nil + else + fence_start = line_start + end + end + + if not newline then + break + end + line_start = newline + 1 + end + + if fence_start then + ranges[#ranges + 1] = { fence_start, #text } + end + + return ranges +end + +local function inside_range(ranges, offset) + for _, range in ipairs(ranges) do + if offset >= range[1] and offset <= range[2] then + return true + end + end + return false +end + +local function overlaps(ranges, abs_ms, abs_me) + for _, r in ipairs(ranges) do + if abs_ms <= r[2] and abs_me >= r[1] then + return true + end + end + return false +end + +local function make_ref(path, line_str, col_str, abs_start, abs_end) + return { + file_path = path, + line = line_str ~= '' and tonumber(line_str) or nil, + col = col_str ~= '' and tonumber(col_str) or nil, + match_start = abs_start, + match_end = abs_end, + } +end + +local function parse_references_into(text, c, scan_from) + local chunk = text:sub(scan_from) + local abs_offset = scan_from - 1 + local fenced_ranges = fenced_code_ranges(text) + + for pattern_index, entry in ipairs(PATTERNS) do + local pos = 1 + while pos <= #chunk do + local ms, me, path, ext, l, col = chunk:find(entry.pat, pos) + if not ms then + break + end + + if is_valid_ext(ext) then + local abs_ms = ms + abs_offset + local abs_me = me + abs_offset + if + not inside_range(fenced_ranges, abs_ms) + and not is_url_path(path, chunk, ms) + and (pattern_index == 1 or not is_inside_unclosed_inline_backticks(text, abs_ms)) + and not overlaps(c.ranges, abs_ms, abs_me) + then + table.insert(c.ranges, { abs_ms, abs_me }) + table.insert(c.refs, make_ref(path, l or '', col or '', abs_ms, abs_me)) + end + end + + pos = me + 1 + end + end +end + +local function append_scan_from(text, parsed_upto) + local scan_from = math.max(1, parsed_upto - OVERLAP + 1) + return unclosed_inline_backtick_before(text, scan_from) or scan_from +end + +---@param text string +---@param key string +---@return ParsedCodeReference[] +function M.parse_references(text, key) + local c = cache[key] + if c and text == c.text then + return c.refs + end + + local scan_from = 1 + if c and vim.startswith(text, c.text) then + scan_from = append_scan_from(text, c.parsed_upto) + else + c = { + text = '', + parsed_upto = 0, + refs = {}, + ranges = {}, + } + cache[key] = c + end + + local len = #text + parse_references_into(text, c, scan_from) + + c.text = text + c.parsed_upto = len + return c.refs +end + +---@param key string +function M.clear(key) + cache[key] = nil +end + +function M.clear_all() + cache = {} +end + +return M diff --git a/lua/opencode/ui/reference_picker.lua b/lua/opencode/ui/reference_picker.lua index 46d71b63..aa1bc803 100644 --- a/lua/opencode/ui/reference_picker.lua +++ b/lua/opencode/ui/reference_picker.lua @@ -1,28 +1,9 @@ -local state = require('opencode.state') local config = require('opencode.config') local base_picker = require('opencode.ui.base_picker') local icons = require('opencode.ui.icons') ----@class CodeReference ----@field file_path string ----@field line number|nil ----@field col number|nil ----@field match_start number ----@field match_end number - local M = {} -local PATTERNS = { - { pat = '`([^`\n]+%.(%w+)):?(%d*):?(%d*)`', check_exists = false }, - { pat = 'file://([%S]+%.(%w+)):?(%d*):?(%d*)', check_exists = false }, - { pat = '([%w_./%-]+/[%w_./%-]*%.(%w+)):?(%d*):?(%d*)', check_exists = false }, - { pat = '([%w_%-]+%.(%w+)):?(%d*):?(%d*)', check_exists = true }, -} - -local OVERLAP = 128 -local cache = {} -local exists_cache = {} - local function make_absolute_path(path) if not vim.startswith(path, '/') then return vim.fn.getcwd() .. '/' .. path @@ -30,119 +11,9 @@ local function make_absolute_path(path) return path end -local function file_exists(path) - local abs = make_absolute_path(path) - if exists_cache[abs] == nil then - exists_cache[abs] = vim.fn.filereadable(abs) == 1 - end - return exists_cache[abs] -end - -local function is_valid_ext(ext) - return #ext >= 1 and #ext <= 5 and ext:match('^%a+$') ~= nil -end - -local function is_url_path(path, chunk, ms) - local context = chunk:sub(math.max(1, ms - 64), ms - 1) - return context:match('https?://[%S]*$') - or context:match('www%.[%S]*$') - or path:match('^//') - or path:match('^www%.') - or path:match('^[%w%-]+%.[%w%-]+/') -end - -local function overlaps(ranges, abs_ms, abs_me) - for _, r in ipairs(ranges) do - if abs_ms <= r[2] and abs_me >= r[1] then - return true - end - end - return false -end - -local function make_ref(path, line_str, col_str, abs_start, abs_end) - return { - file_path = path, - line = line_str ~= '' and tonumber(line_str) or nil, - col = col_str ~= '' and tonumber(col_str) or nil, - match_start = abs_start, - match_end = abs_end, - } -end - -local function picker_ref_key(path, line) - return make_absolute_path(path) .. ':' .. (line or 0) -end - ----@param text string ----@param message_id string ----@return CodeReference[] -function M.parse_references(text, message_id) - local c = cache[message_id] - if not c then - c = { - parsed_upto = 0, - refs = {}, - ranges = {}, - seen_paths = {}, - } - cache[message_id] = c - end - - local len = #text - if len <= c.parsed_upto then - return c.refs - end - - local scan_from = math.max(1, c.parsed_upto - OVERLAP + 1) - local chunk = text:sub(scan_from) - local abs_offset = scan_from - 1 - - for _, entry in ipairs(PATTERNS) do - local pos = 1 - while pos <= #chunk do - local ms, me, path, ext, l, col = chunk:find(entry.pat, pos) - if not ms then - break - end - - if is_valid_ext(ext) then - local abs_ms = ms + abs_offset - local abs_me = me + abs_offset - local path_key = path .. ':' .. (l or '') .. ':' .. (col or '') - - if - not is_url_path(path, chunk, ms) - and not c.seen_paths[path_key] - and not overlaps(c.ranges, abs_ms, abs_me) - and (not entry.check_exists or file_exists(path)) - then - c.seen_paths[path_key] = true - table.insert(c.ranges, { abs_ms, abs_me }) - table.insert(c.refs, make_ref(path, l or '', col or '', abs_ms, abs_me)) - end - end - - pos = me + 1 - end - end - - c.parsed_upto = len - return c.refs -end - -function M.clear(message_id) - cache[message_id] = nil -end - -function M.clear_all() - cache = {} - exists_cache = {} -end - local function format_reference_item(ref, width) local icon = icons.get('file') - local location = ref.file_path + local location = ref.path if ref.line then location = location .. ':' .. ref.line if ref.col then @@ -152,53 +23,21 @@ local function format_reference_item(ref, width) return base_picker.create_time_picker_item(icon .. ' ' .. location, nil, nil, width) end -local function collect_picker_refs() - if not state.messages then - return {} - end - +local function display_refs(refs) + local items = {} local seen = {} - local refs = {} - - for i = #state.messages, 1, -1 do - local msg = state.messages[i] - if msg.info and msg.info.role == 'assistant' then - local message_id = msg.info.id - - local c = cache[message_id] - if c then - for _, ref in ipairs(c.refs) do - local key = picker_ref_key(ref.file_path, ref.line) - if not seen[key] then - seen[key] = true - table.insert(refs, ref) - end - end - end - - if msg.parts then - for _, part in ipairs(msg.parts) do - if part.type == 'tool' then - local file_path = vim.tbl_get(part, 'state', 'input', 'filePath') - if file_path and vim.fn.filereadable(file_path) == 1 then - local rel = vim.fn.fnamemodify(file_path, ':~:.') - local key = picker_ref_key(rel, nil) - if not seen[key] then - seen[key] = true - table.insert(refs, make_ref(rel, '', '', 0, 0)) - end - end - end - end - end + for _, ref in ipairs(refs or {}) do + local key = make_absolute_path(ref.path) .. ':' .. (ref.line or 0) + if not seen[key] then + seen[key] = true + items[#items + 1] = ref end end - - return refs + return items end function M.pick() - local refs = collect_picker_refs() + local refs = display_refs(require('opencode.ui.reference_facts').current_refs()) if #refs == 0 then vim.notify('No code references found in the conversation', vim.log.levels.INFO) return @@ -221,7 +60,7 @@ function M.pick() end function M.navigate_to(ref) - local file_path = make_absolute_path(ref.file_path) + local file_path = make_absolute_path(ref.path) if vim.fn.filereadable(file_path) ~= 1 then vim.notify('File not found: ' .. file_path, vim.log.levels.WARN) return @@ -237,12 +76,4 @@ function M.navigate_to(ref) end end ----Setup reference picker event subscriptions ----Should be called once during plugin initialization -function M.setup() - state.store.subscribe('messages', function() - M.clear_all() - end) -end - return M diff --git a/lua/opencode/ui/render_state.lua b/lua/opencode/ui/render_state.lua index b98814bd..cacb8478 100644 --- a/lua/opencode/ui/render_state.lua +++ b/lua/opencode/ui/render_state.lua @@ -9,6 +9,7 @@ ---@field line_start integer? Line where part starts ---@field line_end integer? Line where part ends ---@field actions table[] Actions associated with this part +---@field targets RenderedTarget[] Targets associated with this part ---@field has_extmarks boolean? Whether the part currently has extmarks applied ---@class RenderState @@ -366,6 +367,86 @@ function RenderState:get_actions_at_line(line) return actions end +---@param target RenderedTarget +---@param line integer 1-indexed +---@param col integer 0-indexed +---@return boolean +local function target_contains_position(target, line, col) + local range = target.range + return range.line == line and col >= range.start_col and col < range.end_col +end + +---@param target RenderedTarget +---@return integer +local function target_priority(target) + if target.kind == 'file' or target.kind == 'diff' then + return 1 + end + if target.kind == 'symbol' then + return 2 + end + return 3 +end + +---@param target RenderedTarget +---@return integer +local function target_width(target) + return target.range.end_col - target.range.start_col +end + +---@param part_id string +---@param targets OutputTarget[] +---@param offset? integer Line offset to apply to target line numbers +function RenderState:add_targets(part_id, targets, offset) + local part_data = self._parts[part_id] + if not part_data then + return + end + offset = offset or 0 + + for _, target in ipairs(targets) do + local rendered_target = vim.deepcopy(target) + rendered_target.range.line = rendered_target.range.line + offset + rendered_target.part_id = part_id + rendered_target.message_id = part_data.message_id + part_data.targets[#part_data.targets + 1] = rendered_target + end +end + +---@param part_id string +function RenderState:clear_targets(part_id) + local part_data = self._parts[part_id] + if part_data then + part_data.targets = {} + end +end + +---@param line integer 1-indexed +---@param col integer 0-indexed +---@param filter? fun(target: RenderedTarget): boolean +---@return RenderedTarget? +function RenderState:get_target_at_position(line, col, filter) + local best = nil + local best_priority = nil + local best_width = nil + + for _, part_data in pairs(self._parts) do + for _, target in ipairs(part_data.targets or {}) do + if target_contains_position(target, line, col) and (not filter or filter(target)) then + local priority = target_priority(target) + local width = target_width(target) + if not best or priority < best_priority or (priority == best_priority and width < best_width) then + best = target + best_priority = priority + best_width = width + end + end + end + end + + return best and vim.deepcopy(best) or nil +end + ---@param part_id string ---@param actions table[] ---@param offset? integer Line offset to apply to action line numbers @@ -410,6 +491,14 @@ function RenderState:get_all_actions() return all_actions end +---@param targets RenderedTarget[] +---@param delta integer +local function shift_targets(targets, delta) + for _, target in ipairs(targets or {}) do + target.range.line = target.range.line + delta + end +end + ---@param message OpencodeMessage ---@param line_start integer? ---@param line_end integer? @@ -462,6 +551,7 @@ function RenderState:set_part(part, line_start, line_end) line_start = line_start, line_end = line_end, actions = {}, + targets = {}, has_extmarks = false, } else @@ -469,6 +559,9 @@ function RenderState:set_part(part, line_start, line_end) if message_id then existing.message_id = message_id end + if line_start and existing.line_start and existing.line_start ~= line_start then + shift_targets(existing.targets, line_start - existing.line_start) + end if line_start then existing.line_start = line_start end @@ -506,12 +599,16 @@ function RenderState:update_part_lines(part_id, new_line_start, new_line_end) end local old_line_end = part_data.line_end + local old_line_start = part_data.line_start local old_line_count = old_line_end - part_data.line_start + 1 local new_line_count = new_line_end - new_line_start + 1 local delta = new_line_count - old_line_count part_data.line_start = new_line_start part_data.line_end = new_line_end + if old_line_start ~= new_line_start then + shift_targets(part_data.targets, new_line_start - old_line_start) + end self._ranges_valid = false if self._max_line_end_valid then @@ -669,6 +766,7 @@ function RenderState:shift_all(from_line, delta) for _, action in ipairs(part_data.actions) do shift_action(action, delta) end + shift_targets(part_data.targets, delta) end end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 986f66e1..852b239c 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -2,6 +2,7 @@ local state = require('opencode.state') local config = require('opencode.config') local output_window = require('opencode.ui.output_window') local permission_window = require('opencode.ui.permission_window') +local reference_facts = require('opencode.ui.reference_facts') local Promise = require('opencode.promise') local ctx = require('opencode.ui.renderer.ctx') local events = require('opencode.ui.renderer.events') @@ -276,6 +277,7 @@ function M.event_subscriptions() { 'question.replied', events.on_question_replied }, { 'question.rejected', events.on_question_replied }, { 'file.edited', events.on_file_edited }, + { 'file.watcher.updated', events.on_file_watcher_updated }, { 'custom.restore_point.created', events.on_restore_points }, { 'custom.emit_events.finished', M.on_emit_events_finished }, } @@ -284,6 +286,7 @@ end ---Reset all renderer state and clear the output buffer function M.reset() ctx:reset() + reference_facts.clear() output_window.clear() permission_window.clear_all() state.renderer.reset() @@ -350,6 +353,8 @@ function M._render_full_session_data(session_data, opts) return end + reference_facts.rebuild(state.active_session.id, state.messages) + local visible_messages, hidden_count = get_visible_session_messages(state.messages) local revert_index = get_revert_index(state.messages) @@ -588,6 +593,20 @@ function M.get_actions_for_line(line) return ctx.render_state:get_actions_at_line(line) end +---@param line integer 1-indexed +---@param col integer 0-indexed +---@param filter? fun(target: RenderedTarget): boolean +---@return RenderedTarget|nil +function M.get_target_at_position(line, col, filter) + return ctx.render_state:get_target_at_position(line, col, filter) +end + +---@param part_id string +---@param message_id string +function M.mark_part_dirty(part_id, message_id) + flush.mark_part_dirty(part_id, message_id) +end + ---Return the rendered message record for a given message ID ---@param message_id string ---@return RenderedMessage|nil diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index 67f40618..3ff9fa04 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -432,7 +432,7 @@ end ---@param part_id string ---@param formatted_data Output ---@param line_start integer -local function apply_part_actions(part_id, formatted_data, line_start) +local function apply_part_render_data(part_id, formatted_data, line_start) if has_actions(formatted_data.actions) then ctx.render_state:clear_actions(part_id) ctx.render_state:add_actions(part_id, vim.deepcopy(formatted_data.actions), line_start) @@ -440,15 +440,9 @@ local function apply_part_actions(part_id, formatted_data, line_start) ctx.render_state:clear_actions(part_id) end - local part_data = ctx.render_state:get_part(part_id) - if part_data then - part_data.has_extmarks = has_extmarks(formatted_data.extmarks) - end -end + ctx.render_state:clear_targets(part_id) + ctx.render_state:add_targets(part_id, vim.deepcopy(formatted_data.targets or {}), line_start) ----@param part_id string ----@param formatted_data Output -local function set_part_extmark_state(part_id, formatted_data) local part_data = ctx.render_state:get_part(part_id) if part_data then part_data.has_extmarks = has_extmarks(formatted_data.extmarks) @@ -588,7 +582,7 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt local part_data = ctx.render_state:get_part(part_id) if part_data then ctx.render_state:set_part(part_data.part, line_start, line_end) - apply_part_actions(part_id, formatted_data, line_start) + apply_part_render_data(part_id, formatted_data, line_start) end return true @@ -613,13 +607,12 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt highlight_written_lines(write_start, lines_to_write) local new_line_end = cached.line_start + #formatted_data.lines - 1 - apply_part_actions(part_id, formatted_data, cached.line_start) + apply_part_render_data(part_id, formatted_data, cached.line_start) if new_line_end ~= cached.line_end then ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) end apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, true) - set_part_extmark_state(part_id, formatted_data) if formatted_data.fold_ranges and #formatted_data.fold_ranges > 0 then M.update_part_folds(part_id) @@ -639,11 +632,10 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt ctx.render_state:shift_all(insert_at, #formatted_data.lines) output_window.shift_folds(insert_at, #formatted_data.lines) ctx.render_state:set_part(part_data.part, range.line_start, range.line_end) - apply_part_actions(part_id, formatted_data, range.line_start) + apply_part_render_data(part_id, formatted_data, range.line_start) if has_extmarks(formatted_data.extmarks) then output_window.set_extmarks(formatted_data.extmarks, range.line_start) end - set_part_extmark_state(part_id, formatted_data) if formatted_data.fold_ranges and #formatted_data.fold_ranges > 0 then M.set_all_folds() @@ -755,9 +747,8 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_format local formatted_data = ctx.formatted_parts[part_id] if formatted_data then - apply_part_actions(part_id, formatted_data, cached.line_start) + apply_part_render_data(part_id, formatted_data, cached.line_start) apply_appended_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end) - set_part_extmark_state(part_id, formatted_data) if formatted_data.fold_ranges then M.update_part_folds(part_id) end diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index 46e7bd0d..e250d3c0 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -3,6 +3,7 @@ local config = require('opencode.config') local ctx = require('opencode.ui.renderer.ctx') local permission_window = require('opencode.ui.permission_window') local flush = require('opencode.ui.renderer.flush') +local reference_facts = require('opencode.ui.reference_facts') ---@param message OpencodeMessage|nil ---@return string|nil @@ -49,6 +50,61 @@ local function find_message_in_state(message_id) return nil end +local function is_assistant_message(message) + return message and message.info and message.info.role == 'assistant' +end + +local function find_part_index(message, part_id) + if not message or not message.parts or not part_id then + return nil + end + for index, part in ipairs(message.parts) do + if part.id == part_id then + return index + end + end + return nil +end + +local function mark_following_assistant_text_parts_dirty(message, changed_part_index) + if not is_assistant_message(message) or not changed_part_index then + return + end + + local message_id = message.info and message.info.id + for index = changed_part_index + 1, #(message.parts or {}) do + local part = message.parts[index] + if part.type == 'text' and part.text and part.id then + flush.mark_part_dirty(part.id, message_id) + end + end +end + +local function mark_rendered_assistant_text_parts_dirty() + local active_session_id = state.active_session and state.active_session.id + if not active_session_id then + return + end + + for part_id, part_data in pairs(ctx.render_state._parts or {}) do + local part = part_data.part + if + part + and part.type == 'text' + and part.text + and not part.synthetic + and part_data.line_start + and part_data.line_end + then + local message_data = ctx.render_state:get_message(part_data.message_id) + local message = message_data and message_data.message or find_message_in_state(part_data.message_id) + if is_assistant_message(message) and message.info.sessionID == active_session_id then + flush.mark_part_dirty(part_id, part_data.message_id) + end + end + end +end + -- Lazy require to avoid circular dependency: renderer.lua <-> events.lua ---@param force? boolean local function scroll(force) @@ -57,6 +113,11 @@ end local M = {} +function M.invalidate_reference_targets_for_file_change() + reference_facts.refresh_current_files() + mark_rendered_assistant_text_parts_dirty() +end + ---@param message_id string ---@param revert_index? integer local function replay_orphan_parts(message_id, revert_index) @@ -267,6 +328,7 @@ function M.on_message_removed(properties) end end + reference_facts.remove_message(message_id) flush.queue_message_removal(message_id) for i, msg in ipairs(state.messages or {}) do @@ -384,6 +446,11 @@ function M.on_part_updated(properties, revert_index) return end + local ref_scope_changed = reference_facts.replace_part(state.active_session.id, message, part) + if ref_scope_changed then + mark_following_assistant_text_parts_dirty(message, find_part_index(message, part.id)) + end + if is_new_part then ctx.render_state:set_part(part) else @@ -458,13 +525,19 @@ function M.on_part_removed(properties) -- Remove the part from the in-memory message too local cached = ctx.render_state:get_part(part_id) - local message_id = cached and cached.message_id + local message_id = (cached and cached.message_id) or properties.messageID if message_id then local rendered_message = ctx.render_state:get_message(message_id) - if rendered_message and rendered_message.message and rendered_message.message.parts then - for i, part in ipairs(rendered_message.message.parts) do + local message = rendered_message and rendered_message.message or find_message_in_state(message_id) + local removed_index = find_part_index(message, part_id) + local ref_scope_changed = reference_facts.remove_part(message_id, part_id) + if message and message.parts then + if ref_scope_changed then + mark_following_assistant_text_parts_dirty(message, removed_index) + end + for i, part in ipairs(message.parts) do if part.id == part_id then - table.remove(rendered_message.message.parts, i) + table.remove(message.parts, i) break end end @@ -598,11 +671,17 @@ end ---@param properties {file: string} function M.on_file_edited(properties) vim.cmd('checktime') + M.invalidate_reference_targets_for_file_change() if config.hooks and config.hooks.on_file_edited then pcall(config.hooks.on_file_edited, properties.file) end end +---@param properties {file: string, event: "add"|"change"|"unlink"} +function M.on_file_watcher_updated(properties) + M.invalidate_reference_targets_for_file_change() +end + ---Handle custom.restore_point.created ---@param properties RestorePointCreatedEvent function M.on_restore_points(properties) diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index d8f22092..f5fb2bfc 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -1,6 +1,8 @@ local state = require('opencode.state') local config = require('opencode.config') local formatter = require('opencode.ui.formatter') +local reference_facts = require('opencode.ui.reference_facts') +local symbol_snapshot = require('opencode.ui.symbol_snapshot') local output_window = require('opencode.ui.output_window') local ctx = require('opencode.ui.renderer.ctx') local scroll = require('opencode.ui.renderer.scroll') @@ -296,6 +298,19 @@ local function snapshot_pending() return pending end +---@return FormatterContext +local function new_formatter_context() + return { + interactive = true, + get_child_parts = function(session_id) + return ctx.render_state:get_child_session_parts(session_id) + end, + current_refs = reference_facts.current_refs(), + current_files = reference_facts.current_files(), + symbol_cycle = symbol_snapshot.new_cycle(), + } +end + ---@param message_id string ---@return Output|nil local function format_message(message_id) @@ -319,9 +334,10 @@ local function format_message(message_id) end ---@param part_id string +---@param render_context FormatterContext ---@return Output|nil formatted ---@return string|nil message_id -local function format_part(part_id) +local function format_part(part_id, render_context) local rendered_part = ctx.render_state:get_part(part_id) if not rendered_part or not rendered_part.part then return nil @@ -334,15 +350,7 @@ local function format_part(part_id) end local is_last_part = (buffer.get_last_part_for_message(message) == part_id) - local ok, formatted_or_err = pcall( - formatter.format_part, - rendered_part.part, - message, - is_last_part, - function(session_id) - return ctx.render_state:get_child_session_parts(session_id) - end - ) + local ok, formatted_or_err = pcall(formatter.format_part, rendered_part.part, message, is_last_part, render_context) if not ok then warn_part_render_error_once(part_id, rendered_part.message_id, formatted_or_err) return nil, rendered_part.message_id @@ -363,10 +371,11 @@ end ---@param part_id string ---@param message_id string|nil -local function apply_part(part_id, message_id) +---@param render_context FormatterContext +local function apply_part(part_id, message_id, render_context) local previous = ctx.formatted_parts[part_id] local formatted = nil - formatted, message_id = format_part(part_id) + formatted, message_id = format_part(part_id, render_context) if not formatted or not message_id then return end @@ -395,8 +404,9 @@ local function apply_part(part_id, message_id) end ---@param pending RendererCtx['pending'] +---@param render_context FormatterContext ---@return boolean -local function apply_pending(pending) +local function apply_pending(pending, render_context) local buf = state.windows and state.windows.output_buf if not buf or not vim.api.nvim_buf_is_valid(buf) then return false @@ -465,7 +475,7 @@ local function apply_pending(pending) local parts = message and message.message and message.message.parts or {} for _, part in ipairs(parts or {}) do if part.id and dirty_parts[part.id] then - apply_part(part.id, message_id) + apply_part(part.id, message_id, render_context) dirty_parts[part.id] = nil pending.dirty_parts[part.id] = nil end @@ -476,7 +486,7 @@ local function apply_pending(pending) for _, part_id in ipairs(pending.dirty_part_order) do local message_id = pending.dirty_parts[part_id] if message_id then - apply_part(part_id, message_id) + apply_part(part_id, message_id, render_context) end end end) @@ -587,7 +597,7 @@ end ---Flush all pending renderer changes to the output buffer. function M.flush() local pending = snapshot_pending() - local applied = apply_pending(pending) + local applied = apply_pending(pending, new_formatter_context()) if applied and not ctx.bulk_mode then M.request_on_data_rendered() end diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index d6888759..37ef0006 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -147,7 +147,10 @@ local function format_messages(messages, omitted_count) local parts = msg.parts or {} for part_idx, part in ipairs(parts) do local is_last = part_idx == #parts - local ok, part_output = pcall(formatter.format_part, part, msg, is_last) + local ok, part_output = pcall(formatter.format_part, part, msg, is_last, { + interactive = false, + get_child_parts = nil, + }) if ok and part_output then vim.list_extend(all_lines, part_output.lines) append_extmarks(all_extmarks, part_output.extmarks, line_offset) diff --git a/lua/opencode/ui/symbol_snapshot.lua b/lua/opencode/ui/symbol_snapshot.lua new file mode 100644 index 00000000..b01a8034 --- /dev/null +++ b/lua/opencode/ui/symbol_snapshot.lua @@ -0,0 +1,223 @@ +local M = {} + +local MIN_DEFINITION_TOKEN_LENGTH = 2 + +local function absolute_path(path) + if path:sub(1, 1) == '/' then + return path + end + return vim.fn.getcwd() .. '/' .. path +end + +local function definition_token(token) + return type(token) == 'string' + and #token >= MIN_DEFINITION_TOKEN_LENGTH + and not token:match('^%d+$') + and not token:match('%s') +end + +local function current_source_root(path, lang) + local source + local parser + local bufnr = vim.fn.bufnr and vim.fn.bufnr(path) or -1 + + if bufnr and bufnr > 0 and vim.api.nvim_buf_is_loaded and vim.api.nvim_buf_is_loaded(bufnr) then + source = bufnr + local parser_ok, buffer_parser = pcall(function() + if vim.treesitter and vim.treesitter.get_parser then + return vim.treesitter.get_parser(bufnr, lang) + end + end) + if parser_ok then + parser = buffer_parser + end + else + local read_ok, lines = pcall(vim.fn.readfile, path) + if read_ok and type(lines) == 'table' then + local content = table.concat(lines, '\n') + source = content + local parser_ok, string_parser = pcall(function() + if vim.treesitter and vim.treesitter.get_string_parser then + return vim.treesitter.get_string_parser(content, lang) + end + end) + if parser_ok then + parser = string_parser + end + end + end + + if not parser then + return nil, nil + end + + local parse_ok, trees = pcall(function() + return parser:parse() + end) + local tree = parse_ok and trees and trees[1] or nil + local root_ok, root = pcall(function() + return tree and tree:root() or nil + end) + if not root_ok then + return nil, nil + end + + return source, root +end + +function M.token_variants(token) + if type(token) ~= 'string' then + return {} + end + + local variants = {} + local seen = {} + + local function add_variant(value) + if value ~= '' and not seen[value] then + seen[value] = true + table.insert(variants, value) + end + end + + add_variant(token) + + local index = 1 + while index <= #token do + local dot_start, dot_end = token:find('%.', index) + local colon_start, colon_end = token:find('::', index, true) + local lua_colon_start, lua_colon_end = token:find(':', index, true) + if lua_colon_start and token:sub(lua_colon_start, lua_colon_start + 1) == '::' then + lua_colon_start, lua_colon_end = nil, nil + end + + local delimiter_start, delimiter_end = dot_start, dot_end + if colon_start and (not delimiter_start or colon_start < delimiter_start) then + delimiter_start, delimiter_end = colon_start, colon_end + end + if lua_colon_start and (not delimiter_start or lua_colon_start < delimiter_start) then + delimiter_start, delimiter_end = lua_colon_start, lua_colon_end + end + if not delimiter_start then + break + end + + add_variant(token:sub(delimiter_end + 1)) + index = delimiter_end + 1 + end + + return variants +end + +local function collect_path(path) + local by_token = {} + local filetype = vim.filetype and vim.filetype.match and vim.filetype.match({ filename = path }) or nil + if not filetype then + return by_token + end + + local lang = filetype + local lang_ok, parser_lang = pcall(function() + if vim.treesitter and vim.treesitter.language and vim.treesitter.language.get_lang then + return vim.treesitter.language.get_lang(filetype) + end + end) + if lang_ok and parser_lang then + lang = parser_lang + end + + local query_ok, query = pcall(function() + if vim.treesitter and vim.treesitter.query and vim.treesitter.query.get then + return vim.treesitter.query.get(lang, 'locals') + end + end) + if not query_ok or not query then + return by_token + end + + local source, root = current_source_root(path, lang) + if not (source and root) then + return by_token + end + + local iter_ok, iter, iter_state, iter_initial = pcall(function() + return query:iter_captures(root, source, 0, -1) + end) + if not iter_ok or not iter then + return by_token + end + + for capture_id, node in iter, iter_state, iter_initial do + local capture = query.captures and query.captures[capture_id] + local kind = capture and capture:match('^local%.definition%.(.+)$') + local text_ok, token = pcall(function() + return vim.treesitter.get_node_text(node, source) + end) + if kind and kind ~= 'associated' and text_ok and definition_token(token) then + local row, col = node:range() + local targets = by_token[token] + if not targets then + targets = {} + by_token[token] = targets + end + table.insert(targets, { + token = token, + path = path, + line = row + 1, + col = col + 1, + kind = kind, + }) + end + end + + return by_token +end + +local function is_cycle(value) + return type(value) == 'table' and value._symbol_snapshot_cycle == true +end + +function M.new_cycle() + return { + _symbol_snapshot_cycle = true, + by_path = {}, + } +end + +local function collect_cycle_path(cycle, path) + local absolute = absolute_path(path) + if cycle.by_path[absolute] == nil then + cycle.by_path[absolute] = collect_path(absolute) + end + return cycle.by_path[absolute] +end + +function M.targets_for_token(cycle, token, candidate_files) + if not is_cycle(cycle) then + return {} + end + + if type(token) ~= 'string' or type(candidate_files) ~= 'table' or #candidate_files == 0 then + return {} + end + + local targets = {} + local seen = {} + + for _, path in ipairs(candidate_files) do + local path_snapshot = collect_cycle_path(cycle, path) + for _, variant in ipairs(M.token_variants(token)) do + for _, target in ipairs(path_snapshot[variant] or {}) do + local key = table.concat({ target.path or '', target.line or 0, target.col or 0, target.token or '' }, ':') + if not seen[key] then + seen[key] = true + table.insert(targets, vim.deepcopy(target)) + end + end + end + end + + return targets +end + +return M diff --git a/lua/opencode/ui/symbol_tokens.lua b/lua/opencode/ui/symbol_tokens.lua new file mode 100644 index 00000000..28a89f3d --- /dev/null +++ b/lua/opencode/ui/symbol_tokens.lua @@ -0,0 +1,67 @@ +local M = {} + +local function is_candidate(token) + return token:find('[%a_]') ~= nil +end + +local function is_path_segment(line, start_pos, end_pos) + -- Path fragments belong to file/reference handling. Emitting their segments as + -- symbols would make directories like `tests/data` look jumpable. + local before = start_pos > 1 and line:sub(start_pos - 1, start_pos - 1) or '' + local after = end_pos < #line and line:sub(end_pos + 1, end_pos + 1) or '' + return before:match('[/\\%-]') ~= nil + or after:match('[/\\%-]') ~= nil + or (before == '.' and (start_pos == 2 or not line:sub(start_pos - 2, start_pos - 2):match('[%w_]'))) +end + +function M.find(line, scan_from) + local start_pos, end_pos = line:find('[%w_][%w_]*', scan_from) + if not start_pos then + return nil + end + + -- Qualified spans may contain `.`, `::`, or Lua's method `:`. + -- A lone `:` followed by prose punctuation stays outside the symbol. + while end_pos < #line do + local delimiter = line:sub(end_pos + 1, end_pos + 2) + local delimiter_len + if delimiter == '::' then + delimiter_len = 2 + elseif line:sub(end_pos + 1, end_pos + 1) == ':' then + delimiter_len = 1 + elseif line:sub(end_pos + 1, end_pos + 1) == '.' then + delimiter_len = 1 + else + break + end + + local tail_start = end_pos + delimiter_len + 1 + local tail_start_pos, tail_end_pos = line:find('[%w_][%w_]*', tail_start) + if tail_start_pos ~= tail_start then + break + end + end_pos = tail_end_pos + end + + local token = line:sub(start_pos, end_pos) + if not is_candidate(token) or is_path_segment(line, start_pos, end_pos) then + return start_pos, end_pos, nil + end + return start_pos, end_pos, token +end + +function M.at_col(line, col) + local scan_from = 1 + while scan_from <= #line do + local start_pos, end_pos, token = M.find(line, scan_from) + if not start_pos then + return nil + end + if col >= start_pos - 1 and col <= end_pos - 1 then + return token + end + scan_from = end_pos + 1 + end +end + +return M diff --git a/tests/data/api-abort.expected.json b/tests/data/api-abort.expected.json index 27fda823..44cdec8e 100644 --- a/tests/data/api-abort.expected.json +++ b/tests/data/api-abort.expected.json @@ -207,7 +207,7 @@ "----", "", "", - "You asked if I can generate 10 numbers, and you referenced reading an empty file ( `a-empty.txt`). However, I'm currently in \"plan mode,\" which means I cannot write or modify any files—I'm only allowed to read, observe,", + "You asked if I can generate 10 numbers, and you referenced reading an empty file (`a-empty.txt`). However, I'm currently in \"plan mode,\" which means I cannot write or modify any files—I'm only allowed to read, observe,", "", "> [!ERROR] The operation was aborted.", "", diff --git a/tests/data/cursor_data.expected.json b/tests/data/cursor_data.expected.json index ccc95204..46429f66 100644 --- a/tests/data/cursor_data.expected.json +++ b/tests/data/cursor_data.expected.json @@ -334,7 +334,7 @@ "", "**Explanation**", "", - "- The line `local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')` (in  `lua/opencode/context.lua:58`) uses `vim.tbl_get` to safely read a nested field from a table.", + "- The line `local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')` (in `lua/opencode/context.lua:58`) uses `vim.tbl_get` to safely read a nested field from a table.", "- Concretely it attempts to read `config.context[context_key].enabled` but without throwing an error if `config.context` or `config.context[context_key]` is nil. If any intermediate key is missing it returns `nil`.", "- In the surrounding function `M.is_context_enabled`, that value is the default config value for the given context key. The function then checks the state override:", " - If `state.current_context_config[context_key].enabled` is not `nil`, that state value (true/false) is returned.", diff --git a/tests/data/diagnostics.expected.json b/tests/data/diagnostics.expected.json index 04afd6a3..d07cabcb 100644 --- a/tests/data/diagnostics.expected.json +++ b/tests/data/diagnostics.expected.json @@ -11231,10 +11231,10 @@ "----", "", "", - "Let's address the three EmmyLua diagnostics at line 130 in  `lua/opencode/core.lua`:", + "Let's address the three EmmyLua diagnostics at line 130 in `lua/opencode/core.lua`:", "", "### 1. `param-type-not-match`: expected `string` but found `string?`", - "- **Location:**  `state.active_session.id` (line 130, col 20)", + "- **Location:** `state.active_session.id` (line 130, col 20)", "- **Cause:** `state.active_session` may be `nil`, so `state.active_session.id` could error or be `nil`. The function expects a `string`, not a nullable string.", "- **Fix:** Add a nil check for `state.active_session` before accessing `.id`.", "", diff --git a/tests/data/explore.expected.json b/tests/data/explore.expected.json index 711a0449..82dcbf3f 100644 --- a/tests/data/explore.expected.json +++ b/tests/data/explore.expected.json @@ -1709,7 +1709,7 @@ "----", "", "", - "The task tool is rendered primarily in  `lua/opencode/ui/formatter.lua`:", + "The task tool is rendered primarily in `lua/opencode/ui/formatter.lua`:", "", "- **Lines 665–735** — `M._format_tool()`: the central dispatch function that routes tool names to their specific formatters. The `'task'` branch is at **lines 700–707**.", "- **Lines 737–801** — `M._format_task_tool()`: the full task tool renderer, which:", @@ -1718,8 +1718,8 @@ " - Attaches a `select_child_session` contextual action (lines 793–800)", "", "Supporting files:", - "- ` lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", - "- ` lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", + "- `lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", + "- `lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", "", "" ], diff --git a/tests/data/markdown-codefence.expected.json b/tests/data/markdown-codefence.expected.json index 0d4fc020..7649add7 100644 --- a/tests/data/markdown-codefence.expected.json +++ b/tests/data/markdown-codefence.expected.json @@ -1112,14 +1112,14 @@ " - Keymap: `` remains the same (unless you want to change it)", "", "### 2. Update Codebase", - "- In  `lua/opencode/api.lua`:", + "- In `lua/opencode/api.lua`:", " - Rename the function `M.stop()` to `M.cancel()`", " - Update all references to `stop` (command registration, legacy command map, subcommand routing, etc.) to use `cancel`", " - Ensure legacy command `OpencodeStop` still works (with deprecation warning), but routes to `cancel`", "- In any other files (keymap config, tests, etc.) update references to `stop` to `cancel` as needed", "", "### 3. Update Tests", - "- In  `tests/unit/api_spec.lua`:", + "- In `tests/unit/api_spec.lua`:", " - Update any tests that check for `stop` to check for `cancel`", "", "### 4. Update Slash Commands (if applicable)", diff --git a/tests/data/output-target-navigation.json b/tests/data/output-target-navigation.json index 24427296..40b9a991 100644 --- a/tests/data/output-target-navigation.json +++ b/tests/data/output-target-navigation.json @@ -36,7 +36,7 @@ "messageID": "msg_output_target_navigation", "sessionID": "ses_output_target_navigation", "type": "text", - "text": "Open `lua/opencode/ui/navigation.lua:12:3` from replayed output." + "text": "Open `lua/opencode/ui/navigation.lua:1:3` from replayed output." } } } diff --git a/tests/data/perf.expected.json b/tests/data/perf.expected.json index 5b062622..674ecde1 100644 --- a/tests/data/perf.expected.json +++ b/tests/data/perf.expected.json @@ -210,10 +210,10 @@ "Here's a long markdown-rich session with many fenced code blocks to help test rendering. I include the file you provided plus a variety of smaller snippets in different languages and formats.", "", "**File Contents**", - "-  `lua/opencode/ui/output_window.lua:1`", + "- `lua/opencode/ui/output_window.lua:1`", "", "```lua", - "--  lua/opencode/ui/output_window.lua", + "-- lua/opencode/ui/output_window.lua", "local state = require('opencode.state')", "local config = require('opencode.config')", "", @@ -452,7 +452,7 @@ "**Unified Diff example**", "", "```diff", - "***  before/lua/opencode/ui/output_window.lua", + "*** before/lua/opencode/ui/output_window.lua", "@@", "- vim.api.nvim_set_option_value('winhighlight', config.ui.window_highlight, { win = windows.output_win })", "+ vim.api.nvim_set_option_value('winhighlight', config.ui.window_highlight or '', { win = windows.output_win })", @@ -498,11 +498,11 @@ "", "```bash", "export OPENCODE_ENV=development", - "nvim --headless -u  tests/minimal/init.lua -c \"lua require('plenary.test_harness').test_directory('./tests/unit', {minimal_init = ' ./tests/minimal/init.lua'})\"", + "nvim --headless -u tests/minimal/init.lua -c \"lua require('plenary.test_harness').test_directory('./tests/unit', {minimal_init = './tests/minimal/init.lua'})\"", "```", "", "**Mixed inline code examples**", - "- Use backticks for commands:  `./run_tests.sh`", + "- Use backticks for commands: `./run_tests.sh`", "- File path with start line: `lua/opencode/ui/output_window.lua:1`", "- API call: `vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)`", "", @@ -515,7 +515,6 @@ "", "" ], - "timestamp": 1773947644, "window": { "cursor": [ 316, diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json index 81bf4a2b..7f6fe2e7 100644 --- a/tests/data/permission-ask-new-approve.expected.json +++ b/tests/data/permission-ask-new-approve.expected.json @@ -858,13 +858,13 @@ "Here are the files that have been changed according to git status:", "", "- Modified (but not staged):", - " -  `lua/opencode/event_manager.lua`", - " -  `lua/opencode/ui/formatter.lua`", - " -  `lua/opencode/ui/renderer.lua`", + " - `lua/opencode/event_manager.lua`", + " - `lua/opencode/ui/formatter.lua`", + " - `lua/opencode/ui/renderer.lua`", "", "- Untracked files:", - " -  `test.lua`", - " -  `tests/data/permission_ask_new.json`", + " - `test.lua`", + " - `tests/data/permission_ask_new.json`", "", "No files are currently staged for commit. Let me know if you want more details (like the diff), want to stage/commit, or need help with anything else!", "", diff --git a/tests/data/redo-all.expected.json b/tests/data/redo-all.expected.json index b07d8795..70cbd73e 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -1830,7 +1830,7 @@ "----", "", "", - "I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.", + "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1847,7 +1847,7 @@ "----", "", "", - "**Done:** added the word `again` to  `test.txt`.", + "**Done:** added the word `again` to `test.txt`.", "", "----", "", @@ -1857,7 +1857,7 @@ "----", "", "", - "I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", + "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1881,7 +1881,7 @@ "----", "", "", - "**Done:** appended the word `again2` to  `test.txt`.", + "**Done:** appended the word `again2` to `test.txt`.", "", "----", "", @@ -1891,14 +1891,14 @@ "----", "", "", - "I'll read  `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.", + "I'll read `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.", "", "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "----", "", "", - "I'll append the word \"again3\" to the first line of  `test.txt` with an exact in-place edit. Applying the change now.", + "I'll append the word \"again3\" to the first line of `test.txt` with an exact in-place edit. Applying the change now.", "", "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1915,7 +1915,7 @@ "----", "", "", - "**Done:** appended the word `again3` to  `test.txt`.", + "**Done:** appended the word `again3` to `test.txt`.", "", "" ], diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index aab1051d..f63cd5f9 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -1259,7 +1259,7 @@ "----", "", "", - "I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.", + "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1276,7 +1276,7 @@ "----", "", "", - "**Done:** added the word `again` to  `test.txt`.", + "**Done:** added the word `again` to `test.txt`.", "", "----", "", @@ -1286,7 +1286,7 @@ "----", "", "", - "I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", + "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1310,7 +1310,7 @@ "----", "", "", - "**Done:** appended the word `again2` to  `test.txt`.", + "**Done:** appended the word `again2` to `test.txt`.", "", "----", "", diff --git a/tests/data/selection.expected.json b/tests/data/selection.expected.json index 9a15d207..4f9128f7 100644 --- a/tests/data/selection.expected.json +++ b/tests/data/selection.expected.json @@ -415,7 +415,7 @@ "----", "", "", - "I can see the file  `/Users/cam/tmp/a/diff-test.txt` contains \"this is a string\" on line 1.", + "I can see the file `/Users/cam/tmp/a/diff-test.txt` contains \"this is a string\" on line 1.", "", "The two selection contexts you provided show:", "1. Current content: \"this is a string\"", diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index 7d11115f..cee6af7b 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -1360,7 +1360,7 @@ "- **Add** a blank line after writing new content (in `_write_formatted_data`)", "- **Remove** the trailing blank line before writing new content (also in `_write_formatted_data`)", "", - "### Changes needed in  `renderer.lua`:", + "### Changes needed in `renderer.lua`:", "", "1. **Add state tracking** (after line 14):", " ```lua", diff --git a/tests/data/symbol-reference-navigation.json b/tests/data/symbol-reference-navigation.json new file mode 100644 index 00000000..735b1ef5 --- /dev/null +++ b/tests/data/symbol-reference-navigation.json @@ -0,0 +1,66 @@ +[ + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_symbol_reference_navigation", + "directory": "/mock/project/path", + "time": { + "created": 1760000000000, + "updated": 1760000000000 + }, + "version": "0.15.0", + "title": "Symbol Reference Navigation Test", + "projectID": "test-symbol-reference-navigation" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_symbol_reference_navigation", + "sessionID": "ses_symbol_reference_navigation", + "time": { + "created": 1760000001000 + }, + "role": "assistant" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_symbol_reference_navigation_tool", + "messageID": "msg_symbol_reference_navigation", + "sessionID": "ses_symbol_reference_navigation", + "type": "tool", + "tool": "read", + "state": { + "status": "completed", + "input": { + "filePath": "lua/opencode/ui/symbol_snapshot.lua" + }, + "output": "lua/opencode/ui/symbol_snapshot.lua\nfunction M.collect(refs)", + "time": { + "start": 1760000001000, + "end": 1760000001001 + } + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_symbol_reference_navigation_text", + "messageID": "msg_symbol_reference_navigation", + "sessionID": "ses_symbol_reference_navigation", + "type": "text", + "text": "collect should be highlighted from the referenced file." + } + } + } +] diff --git a/tests/data/updating-text.expected.json b/tests/data/updating-text.expected.json index bab3074f..ec3f2848 100644 --- a/tests/data/updating-text.expected.json +++ b/tests/data/updating-text.expected.json @@ -223,7 +223,7 @@ "", "**Minimal example:**", "", - " `plugin/example.lua`:", + "`plugin/example.lua`:", "```lua", "if vim.g.loaded_example then", " return", @@ -235,7 +235,7 @@ "end, {})", "```", "", - " `lua/example/init.lua`:", + "`lua/example/init.lua`:", "```lua", "local M = {}", "", @@ -252,7 +252,7 @@ "```", "", "Key components:", - "- Use  `vim.api` for Neovim API calls", + "- Use `vim.api` for Neovim API calls", "- Provide a `setup()` function for configuration", "- Create user commands with `nvim_create_user_command`", "- Use autocommands with `nvim_create_autocmd`", diff --git a/tests/helpers.lua b/tests/helpers.lua index 7eb181b6..fa18e371 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -13,7 +13,7 @@ function M.replay_setup() local renderer = require('opencode.ui.renderer') local permission_window = require('opencode.ui.permission_window') local question_window = require('opencode.ui.question_window') - local reference_picker = require('opencode.ui.reference_picker') + local reference_parser = require('opencode.ui.reference_parser') local empty_promise = require('opencode.promise').new():resolve(nil) config_file.config_promise = empty_promise @@ -33,7 +33,7 @@ function M.replay_setup() question_window._current_question_index = 1 question_window._collected_answers = {} question_window._answering = false - reference_picker.clear_all() + reference_parser.clear_all() ---@diagnostic disable-next-line: duplicate-set-field require('opencode.session').project_id = function() diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 0bdd0fa5..a5f1b434 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -201,6 +201,14 @@ describe('renderer unit tests', function() end end) + it('subscribes to file watcher updates for reference target invalidation', function() + assert(vim.tbl_contains(event_subscriptions(), 'file.watcher.updated')) + assert.is_true(require('opencode.ui.event_scope').should_handle('file.watcher.updated', { + file = 'src/ok.lua', + event = 'unlink', + })) + end) + it('unsubsribes from events correctly', function() local renderer = require('opencode.ui.renderer') local event_manager = state.event_manager @@ -325,6 +333,39 @@ describe('renderer unit tests', function() render_stub:revert() end) + it('render_output and render_lines do not write targets into RenderState', function() + local renderer = require('opencode.ui.renderer') + local ctx = require('opencode.ui.renderer.ctx') + local Output = require('opencode.ui.output') + + helpers.replay_setup() + local add_targets_stub = stub(ctx.render_state, 'add_targets') + local clear_targets_stub = stub(ctx.render_state, 'clear_targets') + + local output = Output.new() + output:add_line('open README.md') + output:add_extmark(0, { hl_group = 'OpencodeReference', start_col = 5, end_col = 14 }) + output:add_fold(1, 1) + output:add_target({ + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 5, end_col = 14 }, + }) + + renderer.render_output(output) + renderer.render_lines({ 'display only' }) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + + add_targets_stub:revert() + clear_targets_stub:revert() + ui.close_windows(state.windows) + + assert.are.same({ 'display only' }, lines) + assert.stub(add_targets_stub).was_not_called() + assert.stub(clear_targets_stub).was_not_called() + end) + it('inserts a single synthetic revert message during full session render', function() local renderer = require('opencode.ui.renderer') @@ -372,34 +413,107 @@ describe('renderer unit tests', function() state.ui.set_last_code_window(code_win) local path = 'lua/opencode/ui/navigation.lua' + local test_root = vim.fn.tempname() + local absolute_path = test_root .. '/' .. path + vim.fn.mkdir(vim.fn.fnamemodify(absolute_path, ':h'), 'p') + local file = assert(io.open(absolute_path, 'w')) + file:write('abc') + file:close() + + local original_getcwd = vim.fn.getcwd + vim.fn.getcwd = function() + return test_root + end + vim.api.nvim_buf_set_name(code_buf, absolute_path) + vim.api.nvim_buf_set_lines(code_buf, 0, -1, false, { 'abc' }) local events = helpers.load_test_data('tests/data/output-target-navigation.json') state.session.set_active(helpers.get_session_from_events(events, true)) local session_data = helpers.load_session_from_events(events) - renderer._render_full_session_data(session_data) - - local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) - local target_line, target_col - for idx, line in ipairs(lines) do - local col = line:find(path, 1, true) - if col then - target_line = idx - target_col = col - 1 - break + local ok, err = pcall(function() + renderer._render_full_session_data(session_data) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + local target_line, target_col + for idx, line in ipairs(lines) do + local col = line:find(path, 1, true) + if col then + target_line = idx + target_col = col - 1 + break + end end - end - assert.is_not_nil(target_line, 'replayed output did not contain file reference') - vim.api.nvim_set_current_win(state.windows.output_win) - vim.api.nvim_win_set_cursor(state.windows.output_win, { target_line, target_col }) + assert.is_not_nil(target_line, 'replayed output did not contain file reference') + vim.api.nvim_set_current_win(state.windows.output_win) + vim.api.nvim_win_set_cursor(state.windows.output_win, { target_line, target_col }) - navigation.jump_to_target_at_cursor() + navigation.jump_to_target_at_cursor() - assert.equals(code_win, vim.api.nvim_get_current_win()) - assert.matches(path .. '$', vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(code_win))) - assert.same({ 12, 2 }, vim.api.nvim_win_get_cursor(code_win)) + assert.equals(code_win, vim.api.nvim_get_current_win()) + assert.matches(path .. '$', vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(code_win))) + assert.same({ 1, 2 }, vim.api.nvim_win_get_cursor(code_win)) + end) + vim.fn.getcwd = original_getcwd pcall(vim.api.nvim_win_close, code_win, true) pcall(vim.api.nvim_buf_delete, code_buf, { force = true }) + pcall(vim.fn.delete, test_root, 'rf') + if not ok then + error(err) + end + end) + + it('renders reference-scoped symbol highlights through full session replay', function() + local renderer = require('opencode.ui.renderer') + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local events = helpers.load_test_data('tests/data/symbol-reference-navigation.json') + local referenced_file = 'lua/opencode/ui/symbol_snapshot.lua' + local cycle = { id = 'cycle' } + local new_cycle_stub = stub(symbol_snapshot, 'new_cycle').returns(cycle) + local targets_for_token_stub = stub(symbol_snapshot, 'targets_for_token').invokes( + function(received_cycle, token, candidate_files) + assert.are.equal(cycle, received_cycle) + if token ~= 'collect' then + return {} + end + assert.are.equal(1, #candidate_files) + assert.matches(referenced_file .. '$', candidate_files[1]) + return { + { + path = candidate_files[1], + line = 1, + col = 10, + token = token, + }, + } + end + ) + + helpers.replay_setup() + local original_filereadable = vim.fn.filereadable + vim.fn.filereadable = function(path) + if path:match(referenced_file .. '$') then + return 1 + end + return original_filereadable(path) + end + state.session.set_active(helpers.get_session_from_events(events, true)) + renderer._render_full_session_data(helpers.load_session_from_events(events)) + + local actual = helpers.capture_output(state.windows.output_buf, output_window.namespace) + local symbol_mark + for _, mark in ipairs(actual.extmarks) do + if mark[4] and mark[4].hl_group == 'OpencodeSymbolReference' then + symbol_mark = mark + break + end + end + + new_cycle_stub:revert() + targets_for_token_stub:revert() + vim.fn.filereadable = original_filereadable + + assert.is_not_nil(symbol_mark) end) it('limits rendered messages and inserts a hidden-messages notice', function() diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 5a7620e6..ca4b0d58 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -122,12 +122,15 @@ describe('formatter', function() }, } - local output = formatter.format_part(part, message, true, function(session_id) - if session_id == 'ses_child' then - return child_parts - end - return nil - end) + local output = formatter.format_part(part, message, true, { + interactive = true, + get_child_parts = function(session_id) + if session_id == 'ses_child' then + return child_parts + end + return nil + end, + }) assert.are.equal(' ** tool** ', output.lines[3]) end) @@ -184,12 +187,15 @@ describe('formatter', function() }, } - local output = formatter.format_part(part, message, true, function(session_id) - if session_id == 'ses_child' then - return child_parts - end - return nil - end) + local output = formatter.format_part(part, message, true, { + interactive = true, + get_child_parts = function(session_id) + if session_id == 'ses_child' then + return child_parts + end + return nil + end, + }) local found = false for _, line in ipairs(output.lines) do @@ -269,7 +275,7 @@ describe('formatter', function() assert.are.equal('** read** `/tmp/project/` 1s', output.lines[1]) end) - it('renders diff line numbers as extmarks', function() + it('renders diff line numbers as extmarks and targets', function() local output = Output.new() local formatter_utils = require('opencode.ui.formatter.utils') @@ -285,7 +291,8 @@ describe('formatter', function() ' gamma', '+beta', }, '\n'), - 'lua' + 'lua', + '/test/project/lua/foo.lua' ) assert.are.equal(' alpha', output.lines[3]) @@ -305,6 +312,375 @@ describe('formatter', function() assert.are.equal('11', add_mark.virt_text[1][1]) assert.are.equal('+', add_mark.virt_text[2][1]) assert.are.equal('OpencodeDiffAddGutter', add_mark.virt_text[1][2]) + + assert.are.same({ + { + kind = 'diff', + path = '/test/project/lua/foo.lua', + line = 10, + range = { line = 4, start_col = 0, end_col = 9 }, + }, + { + kind = 'diff', + path = '/test/project/lua/foo.lua', + line = 11, + range = { line = 5, start_col = 0, end_col = 8 }, + }, + }, output.targets) + end) + + it('projects supplied reference facts instead of deriving them during assistant render', function() + local reference_parser = require('opencode.ui.reference_parser') + local original_parse_references = reference_parser.parse_references + reference_parser.parse_references = function() + error('assistant render must consume reference facts, not parse assistant text') + end + + local original_messages = state.messages + state.renderer.set_messages(setmetatable({}, { + __pairs = function() + error('assistant render must not scan state.messages') + end, + __ipairs = function() + error('assistant render must not scan state.messages') + end, + })) + + local text = 'See `src/foo.lua` now' + local part = { + id = 'part_render_boundary', + type = 'text', + text = text, + messageID = 'msg_render_boundary', + sessionID = 'ses_1', + } + local message = { + info = { id = 'msg_render_boundary', role = 'assistant', sessionID = 'ses_1' }, + parts = { part }, + } + + local ok, err = pcall(function() + local output = formatter.format_part(part, message, true, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + current_refs = {}, + }) + + assert.are.equal(text, output.lines[1]) + assert.are.same({}, output.targets) + end) + + reference_parser.parse_references = original_parse_references + state.renderer.set_messages(original_messages) + + assert.is_true(ok, err) + end) + + it('maps supplied reference facts to executable rendered file targets after trim', function() + local reference_facts = require('opencode.ui.reference_facts') + local icons = require('opencode.ui.icons') + local raw_text = ' See `src/foo.lua:12:3` now ' + local part = { + id = 'part_trimmed_ref', + type = 'text', + text = raw_text, + messageID = 'msg_trimmed_ref', + sessionID = 'ses_1', + } + local message = { + info = { id = 'msg_trimmed_ref', role = 'assistant', sessionID = 'ses_1' }, + parts = { part }, + } + + reference_facts.clear() + reference_facts.rebuild('ses_1', { message }) + + local refs = reference_facts.current_refs() + local output = formatter.format_part(part, message, true, { + interactive = true, + current_refs = refs, + current_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + }) + local rendered_ref_start = output.lines[1]:find('`src/foo.lua:12:3`', 1, true) - 1 + local raw_ref_start, raw_ref_end = raw_text:find('`src/foo.lua:12:3`', 1, true) + + reference_facts.clear() + + assert.are.same({ start_offset = raw_ref_start, end_offset = raw_ref_end }, refs[1].raw_range) + assert.are.equal('See ' .. icons.get('reference') .. '`src/foo.lua:12:3` now', output.lines[1]) + assert.are.same({ + kind = 'file', + path = vim.fn.getcwd() .. '/src/foo.lua', + line = 12, + col = 3, + range = { + line = 1, + start_col = rendered_ref_start, + end_col = rendered_ref_start + #'`src/foo.lua:12:3`', + }, + }, output.targets[1]) + end) + + it('leaves unavailable file mentions inert', function() + local text = 'See `src/missing.lua` now' + local ref_start, ref_end = text:find('`src/missing.lua`', 1, true) + local part = { id = 'part_missing_ref', text = text } + local message = { info = { id = 'msg_missing_ref' }, parts = { part } } + + local output = Output.new() + formatter._format_assistant_message(output, text, part, message, { + interactive = true, + current_files = {}, + current_refs = { + { + message_id = 'msg_missing_ref', + part_id = 'part_missing_ref', + path = 'src/missing.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = ref_start, end_offset = ref_end }, + }, + }, + }) + + assert.are.equal(text, output.lines[1]) + assert.are.same({}, output.targets) + assert.is_nil(output.extmarks[0]) + end) + + it('creates symbol targets from same-part file references before the token', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local text = 'See `src/foo.lua` foo' + local ref_start, ref_end = text:find('`src/foo.lua`', 1, true) + local part = { id = 'part_file_ref', text = text } + local message = { info = { id = 'msg_file_ref' }, parts = { part } } + package.loaded['opencode.ui.symbol_snapshot'] = { + targets_for_token = function(_, token, candidate_files) + assert.are.same({ vim.fn.getcwd() .. '/src/foo.lua' }, candidate_files) + if token == 'foo' then + return { { token = 'foo', path = vim.fn.getcwd() .. '/src/foo.lua', line = 1, col = 1 } } + end + return {} + end, + } + + local output = Output.new() + formatter._format_assistant_message(output, text, part, message, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + current_refs = { + { + message_id = 'msg_file_ref', + part_id = 'part_file_ref', + path = vim.fn.getcwd() .. '/src/foo.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = ref_start, end_offset = ref_end }, + }, + }, + symbol_cycle = {}, + }) + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + local symbol_mark + local reference_mark + for _, mark in ipairs(output.extmarks[0]) do + if mark.hl_group == 'OpencodeSymbolReference' then + symbol_mark = mark + elseif mark.hl_group == 'OpencodeReference' then + reference_mark = mark + end + end + local trailing_foo_start = output.lines[1]:find('foo$', 1, false) + assert.are.equal(2, #output.extmarks[0]) + assert.is_not_nil(reference_mark) + assert.is_not_nil(symbol_mark) + assert.are.equal(trailing_foo_start - 1, symbol_mark.start_col) + assert.are.equal(trailing_foo_start + 2, symbol_mark.end_col) + assert.are.same({ + { + kind = 'file', + path = vim.fn.getcwd() .. '/src/foo.lua', + range = output.targets[1].range, + }, + { + kind = 'symbol', + token = 'foo', + candidate_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + range = { line = 1, start_col = trailing_foo_start - 1, end_col = trailing_foo_start + 2 }, + }, + }, { + { + kind = output.targets[1].kind, + path = output.targets[1].path, + range = output.targets[1].range, + }, + output.targets[2], + }) + end) + + it('does not create symbol targets without local candidate files', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + + package.loaded['opencode.ui.symbol_snapshot'] = { + targets_for_token = function() + error('symbol lookup requires local candidate files') + end, + } + + local output = Output.new() + formatter._format_assistant_message(output, 'foo bar', { id = 'part_no_candidates' }, 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.equal('foo bar', output.lines[1]) + assert.are.same({}, output.targets) + assert.is_nil(output.extmarks[0]) + end) + + it('uses same-message previous file refs as symbol candidates', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + + 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 = 1, col = 1 } } + or {} + end, + } + + local previous_part = { id = 'tool_1', type = 'tool' } + local current_part = { id = 'text_1', type = 'text', text = 'foo' } + local message = { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { previous_part, current_part }, + } + + local output = Output.new() + formatter._format_assistant_message(output, 'foo', current_part, message, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/main.lua' }, + current_refs = { + { + message_id = 'msg_1', + part_id = 'tool_1', + path = 'src/main.lua', + source_kind = 'tool_file_path', + }, + }, + symbol_cycle = {}, + }) + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.are.same({ + { + kind = 'symbol', + token = 'foo', + candidate_files = { vim.fn.getcwd() .. '/src/main.lua' }, + range = { line = 1, start_col = 0, end_col = 3 }, + }, + }, output.targets) + assert.are.equal('OpencodeSymbolReference', output.extmarks[0][1].hl_group) + end) + + it('does not highlight symbol-looking segments inside paths', function() + local output = Output.new() + formatter._format_assistant_message(output, 'See tests/data/symbol-reference-navigation.json and .cache/') + + assert.is_nil(output.extmarks[0]) + end) + + it('uses part identity to select assistant text reference facts', function() + local message = { + info = { id = 'msg_same', role = 'assistant', sessionID = 'ses_1' }, + parts = {}, + } + local part_a = { + id = 'part_a', + type = 'text', + text = 'See `a.lua`', + messageID = 'msg_same', + sessionID = 'ses_1', + } + local part_b = { + id = 'part_b', + type = 'text', + text = 'See `b.lua`', + messageID = 'msg_same', + sessionID = 'ses_1', + } + local a_start, a_end = part_a.text:find('`a.lua`', 1, true) + local b_start, b_end = part_b.text:find('`b.lua`', 1, true) + local context = { + current_refs = { + { + message_id = 'msg_same', + part_id = 'part_a', + path = 'a.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = a_start, end_offset = a_end }, + }, + { + message_id = 'msg_same', + part_id = 'part_b', + path = 'b.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = b_start, end_offset = b_end }, + }, + }, + } + local first = formatter.format_part(part_a, message, false, context) + local second = formatter.format_part(part_b, message, true, context) + + assert.is_truthy(first.lines[1]:find('a.lua', 1, true)) + assert.is_nil(first.lines[1]:find('b.lua', 1, true)) + assert.is_truthy(second.lines[1]:find('b.lua', 1, true)) + assert.is_nil(second.lines[1]:find('a.lua', 1, true)) + end) + + 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 ref_start, ref_end = text:find('`src/main.lua`', 1, true) + local part = { id = 'part_colon', text = text } + local message = { info = { id = 'msg_colon' }, parts = { part } } + 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 } } + or {} + end, + } + + local output = Output.new() + formatter._format_assistant_message(output, text, part, message, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/main.lua' }, + current_refs = { + { + message_id = 'msg_colon', + part_id = 'part_colon', + path = 'src/main.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = ref_start, end_offset = ref_end }, + }, + }, + symbol_cycle = {}, + }) + + 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) + 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) end) it('formats grep tools when streamed input contains vim.NIL placeholders', function() @@ -611,12 +987,15 @@ describe('formatter', function() }, } - local output = formatter.format_part(part, message, true, function(session_id) - if session_id == 'ses_child' then - return child_parts - end - return nil - end) + local output = formatter.format_part(part, message, true, { + interactive = true, + get_child_parts = function(session_id) + if session_id == 'ses_child' then + return child_parts + end + return nil + end, + }) assert.are.same({ text = '[S] Open this Session', @@ -626,6 +1005,7 @@ describe('formatter', function() display_line = 1, range = { from = 2, to = 5 }, }, output.actions[1]) + assert.is_truthy(table.concat(output.lines, '\n'):find('read', 1, true)) end) describe('fold_exclude', function() diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 2df42ffe..88db2edf 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -1,4 +1,5 @@ local renderer = require('opencode.ui.renderer') +local stub = require('luassert.stub') local config = require('opencode.config') local state = require('opencode.state') local session_runtime = require('opencode.services.session_runtime') @@ -6,7 +7,6 @@ local events = require('opencode.ui.renderer.events') local helpers = require('tests.helpers') local ui = require('opencode.ui.ui') - local function expect_nil_hook_no_error(run) assert.has_no.errors(run) end @@ -232,3 +232,52 @@ describe('hooks', function() end) end) end) + +describe('reference target local file lifecycle autocmds', function() + local autocmds = require('opencode.ui.autocmds') + + it('invalidates rendered reference targets on local file writes, renames, unloads, and shell changes', function() + local original_create_augroup = vim.api.nvim_create_augroup + local original_create_autocmd = vim.api.nvim_create_autocmd + local created = {} + + local invalidate_stub = stub(events, 'invalidate_reference_targets_for_file_change') + local ok, err = pcall(function() + vim.api.nvim_create_augroup = function() + return 42 + end + vim.api.nvim_create_autocmd = function(event, opts) + created[#created + 1] = { event = event, opts = opts } + return #created + end + + autocmds.setup_autocmds({ input_win = 1, output_win = 2, footer_win = 3, input_buf = 4, output_buf = 5 }) + + local file_lifecycle_autocmd + for _, entry in ipairs(created) do + if type(entry.event) == 'table' and vim.tbl_contains(entry.event, 'BufWritePost') then + file_lifecycle_autocmd = entry + break + end + end + + assert.is_not_nil(file_lifecycle_autocmd) + assert.are.same( + { 'BufWritePost', 'BufFilePost', 'BufDelete', 'BufWipeout', 'FileChangedShellPost' }, + file_lifecycle_autocmd.event + ) + + file_lifecycle_autocmd.opts.callback({ file = '/repo/tests/unit/formatter_spec.lua' }) + file_lifecycle_autocmd.opts.callback({ file = '' }) + + assert.stub(invalidate_stub).was_called(1) + end) + + vim.api.nvim_create_augroup = original_create_augroup + vim.api.nvim_create_autocmd = original_create_autocmd + invalidate_stub:revert() + if not ok then + error(err) + end + end) +end) diff --git a/tests/unit/navigation_spec.lua b/tests/unit/navigation_spec.lua index ec7bfd70..ad295eed 100644 --- a/tests/unit/navigation_spec.lua +++ b/tests/unit/navigation_spec.lua @@ -4,28 +4,11 @@ local stub = require('luassert.stub') local navigation = require('opencode.ui.navigation') local config = require('opencode.config') local ui = require('opencode.ui.ui') -local output_window = require('opencode.ui.output_window') local renderer = require('opencode.ui.renderer') local state = require('opencode.state') local existing_path = 'lua/opencode/ui/navigation.lua' -local function set_cursor_on(win, line_num, line, needle) - local start_pos = assert(line:find(needle, 1, true)) - vim.api.nvim_win_set_cursor(win, { line_num, start_pos - 1 }) -end - -local function add_diff_extmark(buf, line_idx, gutter, sign) - vim.api.nvim_buf_set_extmark(buf, output_window.namespace, line_idx, 0, { - virt_text = { - { gutter, 'LineNr' }, - { sign, 'DiffAdd' }, - { ' ', 'Normal' }, - }, - virt_text_pos = 'inline', - }) -end - describe('output token navigation', function() local output_buf, output_win, input_buf, input_win, code_buf, code_win local original_windows, original_code_win, original_code_buf, original_config @@ -79,103 +62,392 @@ describe('output token navigation', function() config.values = original_config end) - it('resolves an ordinary visible path token at the cursor', function() - local line = 'open ' .. existing_path - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, existing_path) + it('resolves the rendered target under the cursor', function() + local target = { kind = 'file', path = existing_path, line = 12, col = 3 } + local target_stub = stub(renderer, 'get_target_at_position').returns(target) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'open target' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 4 }) - local target = navigation.resolve_target_at_cursor() + assert.same(target, navigation.resolve_target_at_cursor()) + assert.stub(target_stub).was_called_with(1, 4) - assert.same({ path = existing_path }, target) + target_stub:revert() end) - it('resolves path line and column from the cursor token', function() - local token = existing_path .. ':12:3' - local line = 'open ' .. token - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, token) + it('executes file and diff rendered targets on ', function() + local original_navigate_to_location = navigation.navigate_to_location + local navigated = {} + local targets = { + { kind = 'file', path = existing_path, line = 7, col = 2 }, + { kind = 'diff', path = existing_path, line = 42 }, + } + local index = 0 + local target_stub = stub(renderer, 'get_target_at_position').invokes(function() + index = index + 1 + return targets[index] + end) + navigation.navigate_to_location = function(path, line, col) + navigated[#navigated + 1] = { path = path, line = line, col = col } + return true + end + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'file', 'diff' }) + + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + navigation.jump_to_target_at_cursor() - local target = navigation.resolve_target_at_cursor() + navigation.navigate_to_location = original_navigate_to_location + target_stub:revert() - assert.same({ path = existing_path, line = 12, col = 3 }, target) + assert.same({ + { path = existing_path, line = 7, col = 2 }, + { path = existing_path, line = 42, col = nil }, + }, navigated) end) - it('resolves backtick, file uri, markdown, and tool-path forms', function() - local cases = { - { '`' .. existing_path .. ':4`', existing_path, 4 }, - { 'file://' .. existing_path .. ':5', existing_path, 5 }, - { '[`' .. existing_path .. '`](file)', existing_path, nil }, - { '**tool** `' .. existing_path .. '`', existing_path, nil }, - } + it('leaves target lifecycle to render invalidation when a rendered file target fails', function() + local original_navigate_to_location = navigation.navigate_to_location + local dirty_stub = stub(renderer, 'mark_part_dirty') + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'file', + path = 'missing.lua', + part_id = 'part_1', + message_id = 'msg_1', + }) + navigation.navigate_to_location = function() + return false + end - for _, case in ipairs(cases) do - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { case[1] }) - set_cursor_on(output_win, 1, case[1], existing_path) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'missing.lua' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() - local target = navigation.resolve_target_at_cursor() + navigation.navigate_to_location = original_navigate_to_location + target_stub:revert() - assert.same({ path = case[2], line = case[3] }, target) - end + assert.stub(dirty_stub).was_not_called() + dirty_stub:revert() end) - it('uses only the path token under the cursor on multi-token lines', function() - local first = 'lua/opencode/api.lua' - local second = existing_path .. ':7' - local line = first .. ' then ' .. second - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + it('does not dirty the source part after a rendered diff target opens', function() + local original_navigate_to_location = navigation.navigate_to_location + local dirty_stub = stub(renderer, 'mark_part_dirty') + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'diff', + path = existing_path, + part_id = 'part_1', + message_id = 'msg_1', + }) + navigation.navigate_to_location = function() + return true + end + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { existing_path }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() - set_cursor_on(output_win, 1, line, second) - assert.same({ path = existing_path, line = 7 }, navigation.resolve_target_at_cursor()) + navigation.navigate_to_location = original_navigate_to_location + target_stub:revert() - vim.api.nvim_win_set_cursor(output_win, { 1, #first + 2 }) - assert.is_nil(navigation.resolve_target_at_cursor()) + assert.stub(dirty_stub).was_not_called() + dirty_stub:revert() end) - it('uses diff extmark new-file line for add and context rows', function() - local header = '[`' .. existing_path .. '`](file)' - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { header, '+ added', ' context' }) + it('keypress consumes rendered targets instead of deriving targets from screen text', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated = {} + local target_stub = stub(renderer, 'get_target_at_position').returns(nil) + + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + error('symbol target resolution must not run without a rendered target') + end, + targets_for_token = function() + error('symbol target resolution must not run without a rendered target') + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated[#navigated + 1] = { path = path, line = line, col = col } + return true + end + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { '`' .. existing_path .. '`', 'foo' }) + local ok, err = pcall(function() + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + vim.api.nvim_win_set_cursor(output_win, { 2, 1 }) + navigation.jump_to_file_at_cursor() + end) + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() + + assert.is_true(ok, err) + assert.are.same({}, navigated) + assert.stub(target_stub).was_called(2) + assert.are.same(1, target_stub.calls[1].refs[1]) + assert.are.same(0, target_stub.calls[1].refs[2]) + assert.are.same(2, target_stub.calls[2].refs[1]) + assert.are.same(1, target_stub.calls[2].refs[2]) + assert.are.same('function', type(target_stub.calls[2].refs[3])) + end) - add_diff_extmark(output_buf, 1, ' 42 ', '+') - vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) - assert.same({ path = existing_path, line = 42 }, navigation.resolve_target_at_cursor()) + it('keeps gf file-and-diff only', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated = {} + local targets = { + { kind = 'diff', path = existing_path, line = 9 }, + { + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }, + } + local index = 0 + local target_stub = stub(renderer, 'get_target_at_position').invokes(function(_, _, filter) + index = index + 1 + local target = targets[index] + if filter and not filter(target) then + return nil + end + return target + end) + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + error('gf must not resolve symbol targets') + end, + targets_for_token = function() + error('gf must not resolve symbol targets') + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated[#navigated + 1] = { path = path, line = line, col = col } + return true + end - add_diff_extmark(output_buf, 2, ' 43 ', ' ') - vim.api.nvim_win_set_cursor(output_win, { 3, 0 }) - assert.same({ path = existing_path, line = 43 }, navigation.resolve_target_at_cursor()) - end) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'diff', 'foo' }) + local ok, err = pcall(function() + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_file_at_cursor() + vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + navigation.jump_to_file_at_cursor() + end) - it('does not jump deleted diff rows to old-file lines', function() - local header = '[`' .. existing_path .. '`](file)' - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { header, '- deleted' }) - add_diff_extmark(output_buf, 1, ' 9 ', '-') - vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() - assert.is_nil(navigation.resolve_target_at_cursor()) + assert.is_true(ok, err) + assert.same({ { path = existing_path, line = 9, col = nil } }, navigated) end) - it('keeps window and cursor unchanged on missing path or plain text', function() + it('executes a symbol rendered target with current file contents', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }) + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + return { cycle = 'fresh' } + end, + targets_for_token = function(cycle, token, candidate_files) + assert.same({ cycle = 'fresh' }, cycle) + assert.equal('foo', token) + assert.same({ existing_path }, candidate_files) + return { { token = 'foo', path = existing_path, line = 3, col = 1 } } + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated = { path = path, line = line, col = col } + return true + end + state.renderer.set_messages(setmetatable({}, { + __pairs = function() + error('symbol target navigation must not scan state.messages') + end, + __ipairs = function() + error('symbol target navigation must not scan state.messages') + end, + })) + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) + local ok, err = pcall(function() + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + end) + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + state.renderer.set_messages({}) + target_stub:revert() + + assert.is_true(ok, err) + assert.same({ path = existing_path, line = 3, col = 1 }, navigated) + end) + + it('reports symbol misses without mutating target lifecycle', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] local notify_stub = stub(vim, 'notify') - local load_stub = stub(renderer, 'load_all_messages') - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { '`missing/not_here.lua`', 'plain text' }) - set_cursor_on(output_win, 1, '`missing/not_here.lua`', 'missing/not_here.lua') - local before_win = vim.api.nvim_get_current_win() - local before_cursor = vim.api.nvim_win_get_cursor(output_win) + local dirty_stub = stub(renderer, 'mark_part_dirty') + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }) + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + return {} + end, + targets_for_token = function() + return {} + end, + } + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) navigation.jump_to_target_at_cursor() - assert.equals(before_win, vim.api.nvim_get_current_win()) - assert.same(before_cursor, vim.api.nvim_win_get_cursor(output_win)) - assert.stub(notify_stub).was_not_called() - assert.stub(load_stub).was_not_called() + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() - vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) - navigation.jump_to_target_at_cursor() - assert.equals(before_win, vim.api.nvim_get_current_win()) - assert.stub(notify_stub).was_not_called() - assert.stub(load_stub).was_not_called() + assert.stub(notify_stub).was_called_with('No symbol target found: foo', vim.log.levels.INFO) + assert.stub(dirty_stub).was_not_called() notify_stub:revert() - load_stub:revert() + dirty_stub:revert() + end) + + it('offers multiple symbol rendered targets through the base picker', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_base_picker = package.loaded['opencode.ui.base_picker'] + local original_navigate_to_location = navigation.navigate_to_location + local picked_opts + local navigated + local targets = { + { token = 'foo', path = existing_path, line = 1, col = 1, kind = 'function' }, + { token = 'foo', path = existing_path, line = 2, col = 1 }, + } + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }) + + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + return {} + end, + targets_for_token = function() + return targets + end, + } + package.loaded['opencode.ui.base_picker'] = { + create_time_picker_item = function(text) + return { + text = text, + to_string = function(self) + return self.text + end, + } + end, + pick = function(opts) + picked_opts = opts + opts.callback(opts.items[2]) + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated = { path = path, line = line, col = col } + return true + end + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + package.loaded['opencode.ui.base_picker'] = original_base_picker + target_stub:revert() + + assert.same(targets, picked_opts.items) + assert.equal('file', picked_opts.preview) + assert.equal('Symbol References (2)', picked_opts.title) + assert.equal( + 'foo [function] ' .. existing_path .. ':1:1', + picked_opts.format_fn(targets[1], 80):to_string():match('^%s*(.-)%s*$') + ) + assert.same({ path = existing_path, line = 2, col = 1 }, navigated) + end) + + it('does not mutate target lifecycle when a picked symbol target fails to open', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_base_picker = package.loaded['opencode.ui.base_picker'] + local original_navigate_to_location = navigation.navigate_to_location + local dirty_stub = stub(renderer, 'mark_part_dirty') + local source_target = { + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + } + local targets = { + { token = 'foo', path = existing_path, line = 1, col = 1 }, + { token = 'foo', path = existing_path, line = 2, col = 1 }, + } + local target_stub = stub(renderer, 'get_target_at_position').returns(source_target) + + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + return {} + end, + targets_for_token = function() + return targets + end, + } + package.loaded['opencode.ui.base_picker'] = { + create_time_picker_item = function(text) + return { + text = text, + to_string = function(self) + return self.text + end, + } + end, + pick = function(opts) + opts.callback(opts.items[2]) + end, + } + navigation.navigate_to_location = function() + return false + end + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + package.loaded['opencode.ui.base_picker'] = original_base_picker + target_stub:revert() + + assert.stub(dirty_stub).was_not_called() + dirty_stub:revert() end) it('opens explicit locations with 1-based col converted and clamped', function() @@ -189,6 +461,40 @@ describe('output token navigation', function() assert.equals(math.max(#line - 1, 0), cursor[2]) end) + it('keeps file-first without running the symbol resolver', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'file', + path = existing_path, + line = 7, + col = 2, + }) + + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + error('symbol resolver should not run when a file target exists') + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated = { path = path, line = line, col = col } + return true + end + + local line = 'open ' .. existing_path .. ':7:2' + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() + + assert.same({ path = existing_path, line = 7, col = 2 }, navigated) + end) + it('hides current-mode output before opening a target so output view can be restored', function() config.values.ui.position = 'current' config.values.ui.persist_state = true diff --git a/tests/unit/output_spec.lua b/tests/unit/output_spec.lua new file mode 100644 index 00000000..2844333e --- /dev/null +++ b/tests/unit/output_spec.lua @@ -0,0 +1,44 @@ +local Output = require('opencode.ui.output') + +describe('Output targets', function() + it('initializes and appends targets', function() + local output = Output.new() + + output:add_target({ + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 0, end_col = 9 }, + }) + output:add_targets({ + { + kind = 'symbol', + token = 'setup', + candidate_files = { 'README.md' }, + range = { line = 1, start_col = 10, end_col = 15 }, + }, + }) + + assert.equals(2, #output.targets) + assert.equals('README.md', output.targets[1].path) + assert.equals('setup', output.targets[2].token) + end) + + it('clears targets with the rest of the carrier data', function() + local output = Output.new() + output:add_line('README.md') + output:add_extmark(1, { hl_group = 'OpencodeFile' }) + output:add_action({ text = 'Open', type = 'diff_open', key = 'o' }) + output:add_target({ + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 0, end_col = 9 }, + }) + + output:clear() + + assert.equals(0, #output.lines) + assert.is_true(vim.tbl_isempty(output.extmarks)) + assert.equals(0, #output.actions) + assert.equals(0, #output.targets) + end) +end) diff --git a/tests/unit/reference_facts_spec.lua b/tests/unit/reference_facts_spec.lua new file mode 100644 index 00000000..52c0705d --- /dev/null +++ b/tests/unit/reference_facts_spec.lua @@ -0,0 +1,325 @@ +local assert = require('luassert') +local stub = require('luassert.stub') + +describe('opencode.ui.reference_facts', function() + local reference_facts + local original_fn + local original_api + + local function assistant_message(id, session_id, parts) + return { + info = { id = id, role = 'assistant', sessionID = session_id }, + parts = parts or {}, + } + end + + before_each(function() + original_fn = vim.fn + original_api = vim.api + + vim.fn = vim.tbl_extend('force', vim.fn or {}, { + getcwd = function() + return '/repo' + end, + filereadable = function(path) + return (path == '/repo/src/ok.lua' or path == '/repo/src/tool.lua') and 1 or 0 + end, + fnamemodify = function(path, modifier) + if modifier == ':~:.' then + return path:gsub('^/repo/', '') + end + return path + end, + }) + + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.reference_parser'] = nil + reference_facts = require('opencode.ui.reference_facts') + end) + + after_each(function() + reference_facts.clear() + vim.fn = original_fn + vim.api = original_api + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.reference_parser'] = nil + end) + + it('owns session facts without loading the picker UI', function() + package.loaded['opencode.ui.reference_picker'] = false + + assert.has_no.errors(function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }), + }) + end) + + package.loaded['opencode.ui.reference_picker'] = nil + assert.equal('src/ok.lua', reference_facts.current_refs()[1].path) + end) + + it('rebuilds current session assistant reference facts only', function() + reference_facts.rebuild('ses_1', { + { + info = { id = 'user_1', role = 'user', sessionID = 'ses_1' }, + parts = { { id = 'user_part', type = 'text', text = 'Ignore `src/user.lua`.' } }, + }, + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua:12:3`.' }, + { id = 'part_2', type = 'tool', state = { input = { filePath = '/repo/src/tool.lua' } } }, + }), + assistant_message('msg_2', 'ses_other', { + { id = 'part_other', type = 'text', text = 'Ignore `src/other.lua`.' }, + }), + }) + + local refs = reference_facts.current_refs() + + assert.equal(2, #refs) + assert.equal('src/ok.lua', refs[1].path) + assert.equal(12, refs[1].line) + assert.equal(3, refs[1].col) + assert.equal('assistant_text', refs[1].source_kind) + assert.are.same({ start_offset = 5, end_offset = 21 }, refs[1].raw_range) + assert.equal('src/tool.lua', refs[2].path) + assert.equal('tool_file_path', refs[2].source_kind) + end) + + it('replace_part replaces old refs for the same part', function() + local message = assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }) + reference_facts.rebuild('ses_1', { message }) + + message.parts[1] = { id = 'part_1', type = 'text', text = 'See `src/loaded.lua`.' } + local changed = reference_facts.replace_part('ses_1', message, message.parts[1]) + local refs = reference_facts.current_refs() + + assert.is_true(changed) + assert.equal(1, #refs) + assert.equal('src/loaded.lua', refs[1].path) + end) + + it('replace_part keeps same-key append facts and adds new refs', function() + local message = assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }) + reference_facts.rebuild('ses_1', { message }) + local first_range = reference_facts.current_refs()[1].raw_range + + message.parts[1] = { id = 'part_1', type = 'text', text = 'See `src/ok.lua`. Also `src/loaded.lua`.' } + local changed = reference_facts.replace_part('ses_1', message, message.parts[1]) + local refs = reference_facts.current_refs() + + assert.is_true(changed) + assert.equal(2, #refs) + assert.equal('src/ok.lua', refs[1].path) + assert.are.same(first_range, refs[1].raw_range) + assert.equal('src/loaded.lua', refs[2].path) + end) + + it('keeps duplicate path and line facts from different source parts and messages in session order', function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'First `src/ok.lua:12`.' }, + { id = 'part_2', type = 'text', text = 'Second `src/ok.lua:12`.' }, + }), + assistant_message('msg_2', 'ses_1', { + { id = 'part_3', type = 'text', text = 'Third `src/ok.lua:12`.' }, + }), + }) + + local refs = reference_facts.current_refs() + + assert.equal(3, #refs) + assert.equal('msg_1', refs[1].message_id) + assert.equal('part_1', refs[1].part_id) + assert.equal('msg_1', refs[2].message_id) + assert.equal('part_2', refs[2].part_id) + assert.equal('msg_2', refs[3].message_id) + assert.equal('part_3', refs[3].part_id) + assert.is_true(refs[1].order < refs[2].order) + assert.is_true(refs[2].order < refs[3].order) + end) + + it('remove_part and remove_message shrink current refs', function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + { id = 'part_2', type = 'text', text = 'See `src/loaded.lua`.' }, + }), + }) + + assert.is_true(reference_facts.remove_part('msg_1', 'part_1')) + assert.equal('src/loaded.lua', reference_facts.current_refs()[1].path) + + assert.is_true(reference_facts.remove_message('msg_1')) + assert.are.same({}, reference_facts.current_refs()) + end) + + it('maintains current_files from readable files', function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`, `src/loaded.lua`, and `src/missing.lua`.' }, + { id = 'part_2', type = 'text', text = 'See `src/ok.lua` again.' }, + }), + }) + + assert.are.same({ '/repo/src/ok.lua' }, reference_facts.current_files()) + end) + + it('refreshes current_files when filesystem availability changes', function() + local ok_exists = true + vim.fn.filereadable = function(path) + return (ok_exists and path == '/repo/src/ok.lua') and 1 or 0 + end + + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }), + }) + + assert.are.same({ '/repo/src/ok.lua' }, reference_facts.current_files()) + + ok_exists = false + reference_facts.refresh_current_files() + + assert.are.same({}, reference_facts.current_files()) + end) +end) + +describe('reference facts renderer dirty propagation', function() + local state = require('opencode.state') + local ctx = require('opencode.ui.renderer.ctx') + local flush = require('opencode.ui.renderer.flush') + local events + local reference_facts + local schedule_stub + + local function message_with_refs() + return { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_ref', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'See `src/ok.lua`.' }, + { id = 'part_later', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'Call foo after refs.' }, + }, + } + end + + local function render_message_parts(message) + state.renderer.set_messages({ message }) + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + end + + before_each(function() + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.renderer.events'] = nil + reference_facts = require('opencode.ui.reference_facts') + events = require('opencode.ui.renderer.events') + ctx:reset() + reference_facts.clear() + state.session.set_active({ id = 'ses_1' }) + schedule_stub = stub(flush, 'schedule') + end) + + after_each(function() + schedule_stub:revert() + ctx:reset() + reference_facts.clear() + package.loaded['opencode.ui.renderer.events'] = nil + package.loaded['opencode.ui.reference_facts'] = nil + state.session.clear_active() + state.renderer.set_messages({}) + end) + + it('dirties following assistant text parts when a ref-bearing part changes', function() + local message = message_with_refs() + state.renderer.set_messages({ message }) + reference_facts.rebuild('ses_1', { message }) + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + + events.on_part_updated({ + part = { + id = 'part_ref', + messageID = 'msg_1', + sessionID = 'ses_1', + type = 'text', + text = 'Reference removed.', + }, + }) + + assert.equal('msg_1', ctx.pending.dirty_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + end) + + it('dirties following assistant text parts when a ref-bearing part is removed', function() + local message = message_with_refs() + state.renderer.set_messages({ message }) + reference_facts.rebuild('ses_1', { message }) + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + + events.on_part_removed({ sessionID = 'ses_1', messageID = 'msg_1', partID = 'part_ref' }) + + assert.is_true(ctx.pending.removed_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + end) + + it('dirties rendered assistant text parts when files are edited', function() + local message = message_with_refs() + message.parts[#message.parts + 1] = { + id = 'part_hidden', + messageID = 'msg_1', + sessionID = 'ses_1', + type = 'text', + text = 'Unrendered text should wait for its normal render path.', + } + render_message_parts(message) + + local original_cmd = vim.cmd + local refresh_stub = stub(reference_facts, 'refresh_current_files') + local ok, err = pcall(function() + vim.cmd = function(command) + assert.equal('checktime', command) + end + + events.on_file_edited({ file = 'src/ok.lua' }) + + assert.stub(refresh_stub).was_called(1) + assert.equal('msg_1', ctx.pending.dirty_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + assert.is_nil(ctx.pending.dirty_parts.part_hidden) + end) + vim.cmd = original_cmd + refresh_stub:revert() + if not ok then + error(err) + end + end) + + it('dirties rendered assistant text parts when watched files change', function() + local message = message_with_refs() + render_message_parts(message) + + local refresh_stub = stub(reference_facts, 'refresh_current_files') + local ok, err = pcall(function() + events.on_file_watcher_updated({ file = 'src/ok.lua', event = 'unlink' }) + + assert.stub(refresh_stub).was_called(1) + assert.equal('msg_1', ctx.pending.dirty_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + end) + refresh_stub:revert() + if not ok then + error(err) + end + end) +end) diff --git a/tests/unit/reference_parser_spec.lua b/tests/unit/reference_parser_spec.lua new file mode 100644 index 00000000..a6c167bf --- /dev/null +++ b/tests/unit/reference_parser_spec.lua @@ -0,0 +1,176 @@ +local assert = require('luassert') + +describe('opencode.ui.reference_parser', function() + local reference_parser + local original_startswith + + before_each(function() + original_startswith = vim.startswith + + vim.startswith = function(str, prefix) + return str:sub(1, #prefix) == prefix + end + + package.loaded['opencode.ui.reference_parser'] = nil + reference_parser = require('opencode.ui.reference_parser') + reference_parser.clear_all() + end) + + after_each(function() + reference_parser.clear_all() + vim.startswith = original_startswith + package.loaded['opencode.ui.reference_parser'] = nil + end) + + it('parses backtick file references with line and column', function() + local refs = reference_parser.parse_references('Error at `src/handler.lua:10:5`.', 'part1') + + assert.equal(1, #refs) + assert.equal('src/handler.lua', refs[1].file_path) + assert.equal(10, refs[1].line) + assert.equal(5, refs[1].col) + assert.is_number(refs[1].match_start) + assert.is_number(refs[1].match_end) + end) + + it('parses file URIs, nested paths, and top-level file mentions', function() + local refs = + reference_parser.parse_references('Open file://src/config.lua, src/module/helper.lua:25, and README.md.', 'part1') + + assert.equal(3, #refs) + assert.equal('src/config.lua', refs[1].file_path) + assert.equal('src/module/helper.lua', refs[2].file_path) + assert.equal(25, refs[2].line) + assert.equal('README.md', refs[3].file_path) + end) + + it('rejects URL paths and extensionless paths', function() + local refs = reference_parser.parse_references('Visit https://example.com/file.lua and see `README`.', 'part1') + + assert.equal(0, #refs) + end) + + it('parses missing top-level file mentions without checking filesystem', function() + local refs = reference_parser.parse_references('Read missing.xyz.', 'part1') + + assert.equal(1, #refs) + assert.equal('missing.xyz', refs[1].file_path) + end) + + it('includes explicit references regardless of file availability', function() + local refs = reference_parser.parse_references('Create `newfile.xyz`.', 'part1') + + assert.equal(1, #refs) + assert.equal('newfile.xyz', refs[1].file_path) + end) + + it('ignores path-shaped text inside fenced code blocks', function() + local refs = reference_parser.parse_references('```bash\n./run_tests.sh\nsrc/main.lua\n```', 'part1') + + assert.equal(0, #refs) + end) + + it('parses prose and inline references outside fenced code blocks', function() + local refs = reference_parser.parse_references( + '```bash\n./run_tests.sh\nsrc/ignored.lua\n```\nRun `./run_tests.sh` and inspect src/main.lua.', + 'part1' + ) + + assert.equal(2, #refs) + assert.equal('./run_tests.sh', refs[1].file_path) + assert.equal('src/main.lua', refs[2].file_path) + end) + + it('keeps one reference per text range while allowing repeated paths', function() + local refs = + reference_parser.parse_references('Check file://src/main.lua, then `src/main.lua:10`, then main.lua:42.', 'part1') + + assert.equal(3, #refs) + table.sort(refs, function(a, b) + return a.match_start < b.match_start + end) + assert.equal('src/main.lua', refs[1].file_path) + assert.equal('src/main.lua', refs[2].file_path) + assert.equal(10, refs[2].line) + assert.equal('main.lua', refs[3].file_path) + assert.equal(42, refs[3].line) + end) + + it('keeps repeated mentions of the same path at distinct text positions', function() + local refs = reference_parser.parse_references( + 'Open lua/opencode/ui/formatter.lua first, then mention lua/opencode/ui/formatter.lua before format_part.', + 'part1' + ) + + assert.equal(2, #refs) + assert.equal('lua/opencode/ui/formatter.lua', refs[1].file_path) + assert.equal('lua/opencode/ui/formatter.lua', refs[2].file_path) + assert.is_true(refs[1].match_start < refs[2].match_start) + end) + + it('extends append-only updates without recreating existing refs', function() + local refs1 = reference_parser.parse_references('Check `src/main.lua`.', 'part1') + local count_before_append = #refs1 + local first_ref = refs1[1] + local first_range = vim.deepcopy(first_ref) + local refs2 = reference_parser.parse_references('Check `src/main.lua`. Also `lib/util.lua`.', 'part1') + + assert.equal(1, count_before_append) + assert.equal(2, #refs2) + assert.is_true(rawequal(first_ref, refs2[1])) + assert.are.same(first_range, refs2[1]) + assert.equal('lib/util.lua', refs2[2].file_path) + end) + + it('waits for closing backticks before caching a path inside a code span', function() + local partial_refs = reference_parser.parse_references('- `lua/opencode/event_manager.lua', 'part1') + local count_before_closing_backtick = #partial_refs + local refs = reference_parser.parse_references('- `lua/opencode/event_manager.lua`', 'part1') + + assert.equal(0, count_before_closing_backtick) + assert.equal(1, #refs) + assert.equal('lua/opencode/event_manager.lua', refs[1].file_path) + assert.are.same( + { match_start = 3, match_end = 34 }, + { match_start = refs[1].match_start, match_end = refs[1].match_end } + ) + end) + + it('extends append-only references whose opening backtick is outside the overlap window', function() + local prefix = string.rep('a', 160) + local partial_text = prefix .. ' `src/' .. string.rep('deep/', 40) + reference_parser.parse_references(partial_text, 'part1') + + local refs = reference_parser.parse_references(partial_text .. 'main.lua`', 'part1') + + assert.equal(1, #refs) + assert.equal(prefix:len() + 2, refs[1].match_start) + assert.equal('src/' .. string.rep('deep/', 40) .. 'main.lua', refs[1].file_path) + end) + + it('resets same-key cache when text becomes shorter', function() + reference_parser.parse_references('Check `src/main.lua`. Also `lib/util.lua`.', 'part1') + + local refs = reference_parser.parse_references('No refs.', 'part1') + + assert.equal(0, #refs) + end) + + it('resets same-key cache when existing text changes in place', function() + local old_refs = reference_parser.parse_references('Check `src/main.lua`.', 'part1') + + local refs = reference_parser.parse_references('Check `src/other.lua`.', 'part1') + + reference_parser.clear('part1') + local refs_after_clear = reference_parser.parse_references('No refs.', 'part1') + + assert.equal(1, #old_refs) + assert.equal(1, #refs) + assert.equal('src/other.lua', refs[1].file_path) + assert.are.same( + { match_start = 7, match_end = 21 }, + { match_start = refs[1].match_start, match_end = refs[1].match_end } + ) + assert.equal(0, #refs_after_clear) + end) +end) diff --git a/tests/unit/reference_picker_spec.lua b/tests/unit/reference_picker_spec.lua index 5b8df616..a8a40c78 100644 --- a/tests/unit/reference_picker_spec.lua +++ b/tests/unit/reference_picker_spec.lua @@ -2,10 +2,10 @@ local assert = require('luassert') describe('opencode.ui.reference_picker', function() local reference_picker - local mock_state local mock_config local mock_base_picker local mock_icons + local reference_facts local original_fn local original_startswith local original_cmd @@ -51,17 +51,6 @@ describe('opencode.ui.reference_picker', function() end, }) - mock_state = { - messages = {}, - event_manager = { - subscribe = function() end, - }, - store = { - subscribe = function() end, - }, - } - package.loaded['opencode.state'] = mock_state - mock_config = { ui = { picker_width = 100, @@ -91,6 +80,9 @@ describe('opencode.ui.reference_picker', function() package.loaded['opencode.ui.icons'] = mock_icons reference_picker = require('opencode.ui.reference_picker') + package.loaded['opencode.ui.reference_parser'] = nil + reference_facts = require('opencode.ui.reference_facts') + reference_facts.clear() end) after_each(function() @@ -100,213 +92,11 @@ describe('opencode.ui.reference_picker', function() vim.api = original_api package.loaded['opencode.ui.reference_picker'] = nil - package.loaded['opencode.state'] = nil package.loaded['opencode.config'] = nil package.loaded['opencode.ui.base_picker'] = nil package.loaded['opencode.ui.icons'] = nil - end) - - describe('parse_references', function() - it('parses backtick-wrapped file references', function() - local text = 'Check the implementation in `src/main.lua` for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/main.lua', refs[1].file_path) - assert.is_nil(refs[1].line) - assert.is_nil(refs[1].col) - end) - - it('parses backtick-wrapped file references with line numbers', function() - local text = 'See function at `src/utils.lua:42` for implementation.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/utils.lua', refs[1].file_path) - assert.equal(42, refs[1].line) - assert.is_nil(refs[1].col) - end) - - it('parses backtick-wrapped file references with line and column', function() - local text = 'Error at `src/handler.lua:10:5` needs fixing.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/handler.lua', refs[1].file_path) - assert.equal(10, refs[1].line) - assert.equal(5, refs[1].col) - end) - - it('parses backtick-wrapped file references with line ranges (only start line captured)', function() - local text = 'Review lines `src/test.lua:10-20` for context.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/test.lua', refs[1].file_path) - assert.equal(10, refs[1].line) - -- end of range is not represented in the ref struct - end) - - it('parses file:// URI references', function() - local text = 'Open file://src/config.lua for settings.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/config.lua', refs[1].file_path) - end) - - it('parses file:// URI references with line numbers', function() - local text = 'Check file://src/init.lua:99 for the issue.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/init.lua', refs[1].file_path) - assert.equal(99, refs[1].line) - end) - - it('parses plain path references with forward slashes', function() - local text = 'The function is in src/module/helper.lua:25' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/module/helper.lua', refs[1].file_path) - assert.equal(25, refs[1].line) - end) - - it('parses top-level file references when file exists', function() - local text = 'Check README.md for documentation.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('README.md', refs[1].file_path) - end) - - it('parses multiple references in one text', function() - local text = [[ - The main logic is in `src/main.lua:50` and helper - functions are in `lib/utils.lua:10`. Also see - the configuration in config.txt. - ]] - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(3, #refs) - assert.equal('src/main.lua', refs[1].file_path) - assert.equal(50, refs[1].line) - assert.equal('lib/utils.lua', refs[2].file_path) - assert.equal(10, refs[2].line) - assert.equal('config.txt', refs[3].file_path) - end) - - it('rejects URLs in context', function() - local text = 'Visit https://example.com/file.lua for more info.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('rejects www URLs in context', function() - local text = 'See www.example.com/docs.txt for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('rejects files without extensions', function() - local text = 'Check `README` for info.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('rejects top-level files that do not exist (check_exists pattern)', function() - -- Unquoted top-level filenames require the file to be readable - local text = 'See nonexistent.xyz for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('includes backtick-wrapped files regardless of existence', function() - -- Backtick pattern has check_exists=false; useful for referencing new files - local text = 'Create `newfile.xyz` with the following content.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('newfile.xyz', refs[1].file_path) - end) - - it('deduplicates overlapping matches across patterns', function() - local text = 'Check file://src/main.lua for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - -- file:// and plain-slash patterns both see this, but only the first match wins - assert.is_true(#refs >= 1) - assert.equal('src/main.lua', refs[1].file_path) - end) - - it('keeps later top-level references with the same basename', function() - local text = 'See `src/main.lua:10` first, then check main.lua:42 too.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(2, #refs) - assert.equal('src/main.lua', refs[1].file_path) - assert.equal(10, refs[1].line) - assert.equal('main.lua', refs[2].file_path) - assert.equal(42, refs[2].line) - end) - - it('ref struct contains file_path, line, col, match_start, match_end', function() - local text = 'See `src/test.lua:5:3` for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - local ref = refs[1] - assert.equal('src/test.lua', ref.file_path) - assert.equal(5, ref.line) - assert.equal(3, ref.col) - assert.is_number(ref.match_start) - assert.is_number(ref.match_end) - end) - - it('returns same cached refs when called again with identical text', function() - local text = 'Check `src/main.lua` for details.' - local refs1 = reference_picker.parse_references(text, 'msg1') - local refs2 = reference_picker.parse_references(text, 'msg1') - - assert.equal(#refs1, #refs2) - assert.equal(refs1[1].file_path, refs2[1].file_path) - end) - - it('extends refs incrementally as text grows', function() - local text1 = 'Check `src/main.lua`.' - local text2 = text1 .. ' Also `lib/util.lua`.' - - -- Capture count before second call: parse_references returns the live c.refs - -- table, so refs1 would mutate if stored and then text2 is parsed - local count1 = #reference_picker.parse_references(text1, 'msg1') - local count2 = #reference_picker.parse_references(text2, 'msg1') - - assert.equal(1, count1) - assert.equal(2, count2) - end) - - it('handles files with hyphens and underscores', function() - local text = 'Check `my-cool_file.lua:5` for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('my-cool_file.lua', refs[1].file_path) - assert.equal(5, refs[1].line) - end) - - it('handles nested directory paths', function() - local text = 'See `src/module/sub/deep/file.lua:100` for implementation.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/module/sub/deep/file.lua', refs[1].file_path) - assert.equal(100, refs[1].line) - end) + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.reference_parser'] = nil end) describe('navigate_to', function() @@ -316,7 +106,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cmd_calls, cmd) end - local ref = { file_path = 'src/main.lua' } + local ref = { path = 'src/main.lua' } reference_picker.navigate_to(ref) assert.equal(1, #cmd_calls) @@ -329,7 +119,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cursor_calls, { win = win, pos = pos }) end - local ref = { file_path = 'src/main.lua', line = 42 } + local ref = { path = 'src/main.lua', line = 42 } reference_picker.navigate_to(ref) assert.equal(1, #cursor_calls) @@ -343,7 +133,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cursor_calls, { win = win, pos = pos }) end - local ref = { file_path = 'src/main.lua', line = 42, col = 10 } + local ref = { path = 'src/main.lua', line = 42, col = 10 } reference_picker.navigate_to(ref) assert.equal(1, #cursor_calls) @@ -360,7 +150,7 @@ describe('opencode.ui.reference_picker', function() return 50 end - local ref = { file_path = 'src/main.lua', line = 999 } + local ref = { path = 'src/main.lua', line = 999 } reference_picker.navigate_to(ref) assert.equal(1, #cursor_calls) @@ -373,7 +163,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cmd_calls, cmd) end - local ref = { file_path = 'src/my file.lua' } + local ref = { path = 'src/my file.lua' } reference_picker.navigate_to(ref) assert.equal(1, #cmd_calls) @@ -387,7 +177,7 @@ describe('opencode.ui.reference_picker', function() table.insert(notify_calls, { msg = msg, level = level }) end - local ref = { file_path = 'nonexistent.xyz' } + local ref = { path = 'nonexistent.xyz' } reference_picker.navigate_to(ref) assert.equal(1, #notify_calls) @@ -407,7 +197,7 @@ describe('opencode.ui.reference_picker', function() return 0 end - local ref = { file_path = '/absolute/path/file.lua' } + local ref = { path = '/absolute/path/file.lua' } reference_picker.navigate_to(ref) assert.equal(1, #cmd_calls) @@ -415,28 +205,8 @@ describe('opencode.ui.reference_picker', function() end) end) - -- Helper: populate parse cache then expose items via pick() - local function pick_items(messages_and_texts) - -- messages_and_texts: list of { id, role, text, parts } - local state_msgs = {} - for _, m in ipairs(messages_and_texts) do - if m.text then - reference_picker.parse_references(m.text, m.id) - end - table.insert(state_msgs, { - info = { role = m.role or 'assistant', id = m.id }, - parts = m.parts, - }) - end - mock_state.messages = state_msgs - - local captured - mock_base_picker.pick = function(opts) - captured = opts - return {} - end - reference_picker.pick() - return captured and captured.items or nil + local function rebuild_facts(messages) + reference_facts.rebuild('ses_1', messages) end describe('pick', function() @@ -447,7 +217,7 @@ describe('opencode.ui.reference_picker', function() table.insert(notify_calls, { msg = msg, level = level }) end - mock_state.messages = {} + reference_facts.clear() reference_picker.pick() assert.equal(1, #notify_calls) @@ -463,8 +233,14 @@ describe('opencode.ui.reference_picker', function() return {} end - reference_picker.parse_references('Check `src/main.lua:10`.', 'msg1') - mock_state.messages = { { info = { role = 'assistant', id = 'msg1' } } } + rebuild_facts({ + { + info = { role = 'assistant', id = 'msg1', sessionID = 'ses_1' }, + parts = { + { type = 'text', id = 'part1', text = 'Check `src/main.lua:10`.' }, + }, + }, + }) reference_picker.pick() assert.equal(1, #pick_calls) @@ -476,191 +252,51 @@ describe('opencode.ui.reference_picker', function() assert.equal('file', pick_calls[1].preview) end) - it('collects references from cached assistant message text', function() - local items = pick_items({ - { id = 'msg1', text = 'Check `src/main.lua:10` for details.' }, - }) - - assert.is_not_nil(items) - assert.equal(1, #items) - assert.equal('src/main.lua', items[1].file_path) - assert.equal(10, items[1].line) - end) - - it('ignores user messages when collecting refs', function() - reference_picker.parse_references('Check `src/main.lua:10`.', 'msg1') - mock_state.messages = { { info = { role = 'user', id = 'msg1' } } } - - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) - end - - reference_picker.pick() - - assert.equal(1, #notify_calls) -- "No code references found" - vim.notify = original_notify - end) - - it('returns refs in reverse message order (most recent first)', function() - local items = pick_items({ - { id = 'msg1', text = 'Check `old.lua:1`.' }, - { id = 'msg2', text = 'See `new.lua:2`.' }, - }) - - assert.is_not_nil(items) - assert.equal(2, #items) - assert.equal('new.lua', items[1].file_path) - assert.equal('old.lua', items[2].file_path) - end) - - it('deduplicates refs with same file and line across messages', function() - local items = pick_items({ - { id = 'msg1', text = 'Check `src/main.lua:10`.' }, - { id = 'msg2', text = 'Also check `src/main.lua:10`.' }, - }) - - assert.is_not_nil(items) - assert.equal(1, #items) - end) - - it('keeps most recent ref when deduplicating (reverse order wins)', function() - -- msg2 is processed first (reverse order), so it wins deduplication - local items = pick_items({ - { id = 'msg1', text = 'Check `src/main.lua:10`.' }, - { id = 'msg2', text = 'Also check `src/main.lua:10`.' }, - }) - - assert.is_not_nil(items) - assert.equal(1, #items) - -- The kept ref came from the cache for msg2 - assert.equal('src/main.lua', items[1].file_path) - end) - - it('collects file paths from tool parts', function() - local items = pick_items({ + it('uses references from reference_facts', function() + rebuild_facts({ { - id = 'msg1', + info = { role = 'assistant', id = 'msg1', sessionID = 'ses_1' }, parts = { - { - type = 'tool', - state = { input = { filePath = '/test/project/src/file.lua' } }, - }, + { type = 'text', id = 'part1', text = 'Check `src/main.lua:10` for details.' }, }, }, }) + local captured + mock_base_picker.pick = function(opts) + captured = opts + return {} + end - assert.is_not_nil(items) - assert.equal(1, #items) - assert.equal('src/file.lua', items[1].file_path) - end) - - it('deduplicates matching text refs and tool-part refs', function() - local items = pick_items({ - { - id = 'msg1', - text = 'Check `src/file.lua` for details.', - parts = { - { - type = 'tool', - state = { input = { filePath = '/test/project/src/file.lua' } }, - }, - }, - }, - }) + reference_picker.pick() + local items = captured and captured.items assert.is_not_nil(items) assert.equal(1, #items) - assert.equal('src/file.lua', items[1].file_path) + assert.equal('src/main.lua', items[1].path) + assert.equal(10, items[1].line) end) - it('ignores non-existent files in tool parts', function() - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) - end - - pick_items({ + it('deduplicates picker display items by path and line without changing facts', function() + rebuild_facts({ { - id = 'msg1', + info = { role = 'assistant', id = 'msg1', sessionID = 'ses_1' }, parts = { - { - type = 'tool', - state = { input = { filePath = '/test/project/nonexistent.xyz' } }, - }, + { type = 'text', id = 'part1', text = 'First `src/main.lua:10`.' }, + { type = 'text', id = 'part2', text = 'Second `src/main.lua:10`.' }, }, }, }) - - assert.equal(1, #notify_calls) -- "No code references found" - vim.notify = original_notify - end) - - it('collects refs from nil messages gracefully', function() - mock_state.messages = nil - - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) - end - - reference_picker.pick() - - assert.equal(1, #notify_calls) - assert.equal('No code references found in the conversation', notify_calls[1].msg) - vim.notify = original_notify - end) - end) - - describe('setup', function() - it('can be called without errors', function() - assert.has_no.errors(function() - reference_picker.setup() - end) - end) - - it('subscribes to messages state changes', function() - local subscriptions = {} - mock_state.store.subscribe = function(key, handler) - table.insert(subscriptions, { key = key, handler = handler }) - end - - reference_picker.setup() - - assert.equal(1, #subscriptions) - assert.equal('messages', subscriptions[1].key) - assert.is_function(subscriptions[1].handler) - end) - - it('clears the parse cache when messages state changes', function() - reference_picker.parse_references('See `src/main.lua`.', 'msg1') - - local handler - mock_state.store.subscribe = function(key, h) - handler = h - end - reference_picker.setup() - - -- Simulate a messages state change - handler() - - -- Cache is now cleared; pick() finds no refs for msg1 - mock_state.messages = { { info = { role = 'assistant', id = 'msg1' } } } - - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) + local captured + mock_base_picker.pick = function(opts) + captured = opts + return {} end reference_picker.pick() - assert.equal(1, #notify_calls) - assert.equal('No code references found in the conversation', notify_calls[1].msg) - vim.notify = original_notify + assert.equal(1, #captured.items) + assert.equal('part1', captured.items[1].part_id) + assert.equal('Code References (1)', captured.title) end) end) end) diff --git a/tests/unit/render_state_spec.lua b/tests/unit/render_state_spec.lua index 8b70898b..14277021 100644 --- a/tests/unit/render_state_spec.lua +++ b/tests/unit/render_state_spec.lua @@ -264,6 +264,133 @@ describe('RenderState', function() end) end) + describe('targets', function() + local function target(kind, line, start_col, end_col, extra) + local result = vim.tbl_extend('force', { + kind = kind, + range = { + line = line, + start_col = start_col, + end_col = end_col, + }, + }, extra or {}) + return result + end + + before_each(function() + render_state:set_part({ id = 'part1', messageID = 'msg1' }, 0, 2) + end) + + it('adds and gets targets by line and column', function() + render_state:add_targets('part1', { + target('file', 1, 3, 12, { path = 'lua/opencode/init.lua' }), + }) + + local result = render_state:get_target_at_position(1, 3) + + assert.is_not_nil(result) + assert.equals('file', result.kind) + assert.equals('part1', result.part_id) + assert.equals('msg1', result.message_id) + assert.equals('lua/opencode/init.lua', result.path) + assert.is_nil(render_state:get_target_at_position(1, 12)) + end) + + it('applies output line offset when adding targets', function() + render_state:add_targets('part1', { + target('file', 2, 0, 4, { path = 'README.md' }), + }, 10) + + assert.is_nil(render_state:get_target_at_position(2, 1)) + + local result = render_state:get_target_at_position(12, 1) + assert.is_not_nil(result) + assert.equals('README.md', result.path) + end) + + it('clears all targets for a part', function() + render_state:add_targets('part1', { + target('file', 1, 0, 4, { path = 'README.md' }), + target('symbol', 1, 5, 9, { token = 'setup', candidate_files = { 'README.md' } }), + }) + + render_state:clear_targets('part1') + + assert.is_nil(render_state:get_target_at_position(1, 1)) + assert.is_nil(render_state:get_target_at_position(1, 6)) + end) + + it('leaves no target after clear followed by empty add', function() + render_state:add_targets('part1', { + target('file', 1, 0, 4, { path = 'README.md' }), + }) + + render_state:clear_targets('part1') + render_state:add_targets('part1', {}, 10) + + assert.is_nil(render_state:get_target_at_position(1, 1)) + end) + + it('filters targets without changing file and diff priority over symbols', function() + render_state:add_targets('part1', { + target('symbol', 1, 0, 10, { token = 'setup', candidate_files = { 'README.md' } }), + target('diff', 1, 0, 10, { path = 'README.md', line = 3 }), + }) + + local result = render_state:get_target_at_position(1, 4) + assert.equals('diff', result.kind) + + local symbol = render_state:get_target_at_position(1, 4, function(candidate) + return candidate.kind == 'symbol' + end) + assert.equals('symbol', symbol.kind) + end) + + it('moves targets with shifted parts', function() + render_state:set_part({ id = 'part2', messageID = 'msg1' }, 3, 4) + render_state:add_targets('part2', { + target('file', 4, 0, 6, { path = 'later.lua' }), + }) + + render_state:shift_all(3, 5) + + assert.is_nil(render_state:get_target_at_position(4, 1)) + local shifted = render_state:get_target_at_position(9, 1) + assert.is_not_nil(shifted) + assert.equals('part2', shifted.part_id) + end) + + it('moves targets when a part line range is updated', function() + render_state:add_targets('part1', { + target('file', 1, 0, 6, { path = 'moved.lua' }), + }) + + render_state:update_part_lines('part1', 5, 7) + + assert.is_nil(render_state:get_target_at_position(1, 1)) + local shifted = render_state:get_target_at_position(6, 1) + assert.is_not_nil(shifted) + assert.equals('moved.lua', shifted.path) + end) + + it('removes targets with the removed part and shifts remaining part targets', function() + render_state:set_part({ id = 'part2', messageID = 'msg1' }, 3, 4) + render_state:add_targets('part1', { + target('file', 1, 8, 14, { path = 'removed.lua' }), + }) + render_state:add_targets('part2', { + target('file', 4, 0, 6, { path = 'kept.lua' }), + }) + + render_state:remove_part('part1') + + assert.is_nil(render_state:get_target_at_position(1, 9)) + local shifted = render_state:get_target_at_position(1, 1) + assert.is_not_nil(shifted) + assert.equals('kept.lua', shifted.path) + end) + end) + describe('update_part_lines', function() before_each(function() state.renderer.set_messages({ diff --git a/tests/unit/renderer_buffer_spec.lua b/tests/unit/renderer_buffer_spec.lua index 6dcebbb3..3625a296 100644 --- a/tests/unit/renderer_buffer_spec.lua +++ b/tests/unit/renderer_buffer_spec.lua @@ -158,6 +158,43 @@ describe('renderer.buffer extmarks', function() }, }, 12) end) + + it('replaces rendered targets with line offset when updating a part', function() + ctx.render_state:set_part({ id = 'part_1', messageID = 'msg_1', type = 'text' }, 10, 10) + ctx.render_state:add_targets('part_1', { + { + kind = 'file', + path = 'old.lua', + range = { line = 11, start_col = 0, end_col = 7 }, + }, + }) + + buffer.upsert_part_now('part_1', 'msg_1', { + lines = { 'new.lua' }, + extmarks = {}, + actions = {}, + targets = { + { + kind = 'file', + path = 'new.lua', + range = { line = 1, start_col = 0, end_col = 7 }, + }, + }, + }, { + lines = { 'old.lua' }, + extmarks = {}, + actions = {}, + targets = {}, + }) + + assert.is_nil(ctx.render_state:get_target_at_position(11, 1, function(target) + return target.path == 'old.lua' + end)) + + local result = ctx.render_state:get_target_at_position(11, 1) + assert.is_not_nil(result) + assert.equals('new.lua', result.path) + end) end) describe('update_part_folds', function() diff --git a/tests/unit/renderer_targets_spec.lua b/tests/unit/renderer_targets_spec.lua new file mode 100644 index 00000000..99328903 --- /dev/null +++ b/tests/unit/renderer_targets_spec.lua @@ -0,0 +1,123 @@ +local ctx = require('opencode.ui.renderer.ctx') +local renderer = require('opencode.ui.renderer') +local flush = require('opencode.ui.renderer.flush') +local stub = require('luassert.stub') +local helpers = require('tests.helpers') +local state = require('opencode.state') + +describe('renderer target API', function() + local schedule_stub + + before_each(function() + ctx:reset() + schedule_stub = stub(flush, 'schedule') + end) + + after_each(function() + schedule_stub:revert() + ctx:reset() + end) + + it('returns rendered targets with source ids', function() + ctx.render_state:set_part({ id = 'part1', messageID = 'msg1' }, 0, 0) + ctx.render_state:add_targets('part1', { + { + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 0, end_col = 9 }, + }, + }) + + local result = renderer.get_target_at_position(1, 4) + + assert.is_not_nil(result) + assert.equals('README.md', result.path) + assert.equals('part1', result.part_id) + assert.equals('msg1', result.message_id) + end) + + it('marks a part dirty using part_id then message_id', function() + renderer.mark_part_dirty('part1', 'msg1') + + assert.equals('msg1', ctx.pending.dirty_parts.part1) + assert.equals('part1', ctx.pending.dirty_part_order[1]) + assert.is_true(ctx.pending.dirty_part_by_message.msg1.part1) + end) +end) + +describe('renderer flush formatter context', function() + local formatter + local reference_facts + local symbol_snapshot + local format_stub + local refs_stub + local files_stub + local cycle_stub + + before_each(function() + helpers.replay_setup() + ctx:reset() + formatter = require('opencode.ui.formatter') + reference_facts = require('opencode.ui.reference_facts') + symbol_snapshot = require('opencode.ui.symbol_snapshot') + end) + + after_each(function() + if format_stub then + format_stub:revert() + end + if refs_stub then + refs_stub:revert() + end + if files_stub then + files_stub:revert() + end + if cycle_stub then + cycle_stub:revert() + end + ctx:reset() + if state.windows then + require('opencode.ui.ui').close_windows(state.windows) + end + end) + + it('creates one symbol cycle and shares it across formatted parts', function() + local Output = require('opencode.ui.output') + local cycle = { id = 'cycle_1' } + local contexts = {} + + refs_stub = stub(reference_facts, 'current_refs').returns({}) + files_stub = stub(reference_facts, 'current_files').returns({ '/repo/src/ok.lua' }) + cycle_stub = stub(symbol_snapshot, 'new_cycle').returns(cycle) + format_stub = stub(formatter, 'format_part').invokes(function(_, _, _, context) + contexts[#contexts + 1] = context + local output = Output.new() + output:add_line('formatted') + return output + end) + + local message = { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'one' }, + { id = 'part_2', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'two' }, + }, + } + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + ctx.render_state:upsert_child_session_part('child_1', { id = 'child_part', type = 'tool' }) + ctx.pending.dirty_part_order = { 'part_1', 'part_2' } + ctx.pending.dirty_parts = { part_1 = 'msg_1', part_2 = 'msg_1' } + + flush.flush() + + assert.stub(cycle_stub).was_called(1) + assert.equal(2, #contexts) + assert.is_true(contexts[1].interactive) + assert.is_function(contexts[1].get_child_parts) + assert.are.same(ctx.render_state:get_child_session_parts('child_1'), contexts[1].get_child_parts('child_1')) + assert.are.equal(cycle, contexts[1].symbol_cycle) + assert.are.equal(contexts[1].symbol_cycle, contexts[2].symbol_cycle) + end) +end) diff --git a/tests/unit/session_picker_spec.lua b/tests/unit/session_picker_spec.lua index f3c2eb3a..d37ca333 100644 --- a/tests/unit/session_picker_spec.lua +++ b/tests/unit/session_picker_spec.lua @@ -40,7 +40,13 @@ describe('opencode.ui.session_picker', function() it('returns false when only a sibling is deleted', function() local sibling = { id = 'sibling', parentID = 'root' } - assert.is_false(session_picker._is_session_or_ancestor_deleted('child', { sibling = true }, { root, child, sibling, grandchild })) + assert.is_false( + session_picker._is_session_or_ancestor_deleted( + 'child', + { sibling = true }, + { root, child, sibling, grandchild } + ) + ) end) it('returns false for a root session when an unrelated root is deleted', function() @@ -106,6 +112,126 @@ describe('opencode.ui.session_picker', function() assert.are.same({ 'Loading...' }, writes[1]) assert.are.same({ 'No messages or failed to load' }, writes[2]) end) + + it('formats preview parts with non-interactive formatter context', function() + local base_picker = require('opencode.ui.base_picker') + local formatter = require('opencode.ui.formatter') + local Output = require('opencode.ui.output') + local captured_opts + local contexts = {} + local format_stub = stub(formatter, 'format_part').invokes(function(_, _, _, context) + contexts[#contexts + 1] = context + local output = Output.new() + output:add_line('preview part') + return output + end) + + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + state.jobs.set_api_client({ + list_messages = function() + return Promise.new():resolve({ + { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_1', type = 'text', text = 'See `src/main.lua`.' }, + }, + }, + }) + end, + }) + + session_picker.pick({ { id = 's1', title = 'Session', time = { updated = 'now' } } }, function() end) + + local target = { + get_bufnr = function() + return nil + end, + is_valid = function() + return true + end, + set_lines = function() end, + with_window = function() end, + } + + captured_opts.preview_fn({ id = 's1' }, target) + vim.wait(100, function() + return #contexts == 1 + end) + + format_stub:revert() + + assert.equal(1, #contexts) + assert.is_false(contexts[1].interactive) + assert.is_nil(contexts[1].get_child_parts) + assert.is_nil(contexts[1].symbol_cycle) + end) + + it('does not resolve rendered targets while formatting preview parts', function() + local base_picker = require('opencode.ui.base_picker') + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local captured_opts + local writes = {} + local bufnr = vim.api.nvim_create_buf(false, true) + + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + error('preview formatting must not create a symbol cycle') + end, + targets_for_token = function() + error('preview formatting must not resolve symbol targets') + end, + } + + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + state.jobs.set_api_client({ + list_messages = function() + return Promise.new():resolve({ + { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_1', type = 'text', text = 'See `src/main.lua` then call foo.' }, + }, + }, + }) + end, + }) + + session_picker.pick({ { id = 's1', title = 'Session', time = { updated = 'now' } } }, function() end) + + local target = { + get_bufnr = function() + return bufnr + end, + is_valid = function() + return true + end, + set_lines = function(_, lines) + writes[#writes + 1] = lines + end, + with_window = function(_, fn) + fn() + end, + } + + captured_opts.preview_fn({ id = 's1' }, target) + vim.wait(100, function() + return #writes >= 2 + end) + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + + assert.is_truthy(table.concat(writes[#writes], '\n'):find('src/main.lua', 1, true)) + assert.is_nil(table.concat(writes[#writes], '\n'):find('%[render error%]')) + end) end) -- ----------------------------------------------------------------------- @@ -119,12 +245,15 @@ describe('opencode.ui.session_picker', function() local root_session = { id = 'root', parentID = nil, title = 'Root', time = { updated = '2024-01-01' } } local other_root = { id = 'other-root', parentID = nil, title = 'Other', time = { updated = '2024-01-01' } } local child_session = { id = 'child', parentID = 'root', title = 'Child', time = { updated = '2024-01-01' } } - local grandchild_session = { id = 'grandchild', parentID = 'child', title = 'Grandchild', time = { updated = '2024-01-01' } } + local grandchild_session = + { id = 'grandchild', parentID = 'child', title = 'Grandchild', time = { updated = '2024-01-01' } } before_each(function() original = support.snapshot_state() - vim.schedule = function(fn) fn() end + vim.schedule = function(fn) + fn() + end support.mock_api_client() diff --git a/tests/unit/symbol_snapshot_spec.lua b/tests/unit/symbol_snapshot_spec.lua new file mode 100644 index 00000000..5ad84495 --- /dev/null +++ b/tests/unit/symbol_snapshot_spec.lua @@ -0,0 +1,401 @@ +local assert = require('luassert') + +describe('opencode.ui.symbol_snapshot', function() + local symbol_snapshot + local original_fn + local original_api + local original_filetype + local original_treesitter + local original_notify + local files + local buffers + local captures_by_content + local read_counts + local parse_counts + local query_available + local parser_available + local notify_calls + + local function fake_node(text, row, col) + return { + text = text, + range = function() + return row, col, row, col + #text + end, + } + end + + local function set_file(path, lines, captures) + files[path] = lines + captures_by_content[table.concat(lines, '\n')] = captures or {} + end + + before_each(function() + original_fn = vim.fn + original_api = vim.api + original_filetype = vim.filetype + original_treesitter = vim.treesitter + original_notify = vim.notify + files = {} + buffers = {} + captures_by_content = {} + read_counts = {} + parse_counts = {} + query_available = true + parser_available = true + notify_calls = {} + + vim.fn = vim.tbl_extend('force', vim.fn or {}, { + getcwd = function() + return '/test/project' + end, + filereadable = function(path) + return files[path] and 1 or 0 + end, + readfile = function(path) + if not files[path] then + error('missing file') + end + read_counts[path] = (read_counts[path] or 0) + 1 + return files[path] + end, + bufnr = function(path) + return buffers[path] and buffers[path].bufnr or -1 + end, + }) + + vim.api = vim.tbl_extend('force', vim.api or {}, { + nvim_buf_is_loaded = function(bufnr) + for _, buffer in pairs(buffers) do + if buffer.bufnr == bufnr then + return true + end + end + return false + end, + nvim_buf_get_lines = function(bufnr) + for _, buffer in pairs(buffers) do + if buffer.bufnr == bufnr then + return buffer.lines + end + end + return {} + end, + }) + + vim.filetype = { + match = function(opts) + if opts.filename:match('%.lua$') then + return 'lua' + end + end, + } + + vim.treesitter = { + language = { + get_lang = function(filetype) + return filetype + end, + }, + query = { + get = function(_, name) + if not query_available or name ~= 'locals' then + return nil + end + + return { + captures = { + 'local.definition.function', + 'local.definition.var', + 'local.reference', + 'local.definition.associated', + }, + iter_captures = function(_, _, source) + local content = source + if type(source) == 'number' then + for _, buffer in pairs(buffers) do + if buffer.bufnr == source then + content = table.concat(buffer.lines, '\n') + end + end + end + local captures = captures_by_content[content] or {} + local index = 0 + return function() + index = index + 1 + local capture = captures[index] + if capture then + return capture.id, capture.node + end + end + end, + } + end, + }, + get_string_parser = function(content) + if not parser_available then + error('parser unavailable') + end + return { + parse = function() + parse_counts[content] = (parse_counts[content] or 0) + 1 + return { + { + root = function() + return { content = content } + end, + }, + } + end, + } + end, + get_parser = function(bufnr) + if not parser_available then + error('parser unavailable') + end + return { + parse = function() + parse_counts[bufnr] = (parse_counts[bufnr] or 0) + 1 + return { + { + root = function() + return { bufnr = bufnr } + end, + }, + } + end, + } + end, + get_node_text = function(node) + return node.text + end, + } + + vim.notify = function(msg, level) + table.insert(notify_calls, { msg = msg, level = level }) + end + + package.loaded['opencode.ui.symbol_snapshot'] = nil + symbol_snapshot = require('opencode.ui.symbol_snapshot') + end) + + after_each(function() + vim.fn = original_fn + vim.api = original_api + vim.filetype = original_filetype + vim.treesitter = original_treesitter + vim.notify = original_notify + package.loaded['opencode.ui.symbol_snapshot'] = nil + end) + + it('exports only the frozen public API', function() + local keys = {} + for key in pairs(symbol_snapshot) do + table.insert(keys, key) + end + table.sort(keys) + + assert.same({ 'new_cycle', 'targets_for_token', 'token_variants' }, keys) + end) + + it('bounds token lookup to candidate files', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + set_file('/test/project/src/other.lua', { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/main.lua' })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/other.lua' })) + end) + + it('parses each candidate file once per cycle', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + + set_file('/test/project/src/main.lua', { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/main.lua' })) + end) + + it('reads and parses the same candidate file once per cycle', function() + local path = '/test/project/src/main.lua' + local content = 'local function foo() end\nlocal function bar() end' + set_file(path, { 'local function foo() end', 'local function bar() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + { id = 1, node = fake_node('bar', 1, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { path })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'bar', { path })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { path, path })) + + assert.equal(1, read_counts[path]) + assert.equal(1, parse_counts[content]) + end) + + it('collects definition tokens from referenced readable Lua files', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + { id = 3, node = fake_node('ignored', 0, 0) }, + }) + + local cycle = symbol_snapshot.new_cycle() + local targets = symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' }) + + assert.equal(1, #targets) + assert.equal('/test/project/src/main.lua', targets[1].path) + assert.equal(1, targets[1].line) + assert.equal(16, targets[1].col) + assert.equal('function', targets[1].kind) + end) + + it('does not include tokens from files absent from refs', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + set_file('/test/project/src/other.lua', { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/main.lua' })) + end) + + it('reflects file changes on each collect call', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + local first = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(first, 'foo', { '/test/project/src/main.lua' })) + + set_file('/test/project/src/main.lua', { '', '', 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 2, 15) }, + }) + local second = symbol_snapshot.new_cycle() + + assert.equal(1, #symbol_snapshot.targets_for_token(first, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(second, 'foo', { '/test/project/src/main.lua' })) + local targets = symbol_snapshot.targets_for_token(second, 'bar', { '/test/project/src/main.lua' }) + assert.equal(1, #targets) + assert.equal(3, targets[1].line) + end) + + it('uses loaded buffer content before disk content', function() + set_file('/test/project/src/main.lua', { 'local function disk_name() end' }, { + { id = 1, node = fake_node('disk_name', 0, 15) }, + }) + buffers['/test/project/src/main.lua'] = { + bufnr = 7, + lines = { 'local function buffer_name() end' }, + } + captures_by_content['local function buffer_name() end'] = { + { id = 1, node = fake_node('buffer_name', 0, 15) }, + } + + local cycle = symbol_snapshot.new_cycle() + + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'buffer_name', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'disk_name', { '/test/project/src/main.lua' })) + end) + + it('filters empty, short, numeric, and whitespace definition tokens', function() + set_file('/test/project/src/main.lua', { 'symbols' }, { + { id = 1, node = fake_node('', 0, 0) }, + { id = 1, node = fake_node('x', 0, 0) }, + { id = 1, node = fake_node('123', 0, 0) }, + { id = 1, node = fake_node('two words', 0, 0) }, + { id = 1, node = fake_node('ok', 0, 0) }, + }) + + local cycle = symbol_snapshot.new_cycle() + + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'x', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, '123', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'two words', { '/test/project/src/main.lua' })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'ok', { '/test/project/src/main.lua' })) + end) + + it('skips Lua associated owner captures', function() + set_file('/test/project/src/client.lua', { 'function OpencodeApiClient:_call() end' }, { + { id = 4, node = fake_node('OpencodeApiClient', 0, 9) }, + { id = 1, node = fake_node('_call', 0, 27) }, + }) + + local cycle = symbol_snapshot.new_cycle() + + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'OpencodeApiClient', { '/test/project/src/client.lua' })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, '_call', { '/test/project/src/client.lua' })) + end) + + it('silently skips parser and query failures', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + parser_available = false + local no_parser = symbol_snapshot.new_cycle() + assert.equal(0, #symbol_snapshot.targets_for_token(no_parser, 'foo', { '/test/project/src/main.lua' })) + parser_available = true + query_available = false + local no_query = symbol_snapshot.new_cycle() + + assert.equal(0, #symbol_snapshot.targets_for_token(no_query, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #notify_calls) + end) + + it('returns a new targets array', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + local targets = symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' }) + table.remove(targets, 1) + + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + end) + + it('keeps token variants stable, deduplicated, and whole-token first', function() + assert.same({ 'foo' }, symbol_snapshot.token_variants('foo')) + assert.same( + { 'M.actions.jump_to_file', 'actions.jump_to_file', 'jump_to_file' }, + symbol_snapshot.token_variants('M.actions.jump_to_file') + ) + assert.same( + { 'package::Type::method', 'Type::method', 'method' }, + symbol_snapshot.token_variants('package::Type::method') + ) + assert.same({ 'OpencodeApiClient:_call', '_call' }, symbol_snapshot.token_variants('OpencodeApiClient:_call')) + end) + + it('keeps token lookup exact', function() + set_file('/test/project/src/main.lua', { 'local function jump_to_file() end' }, { + { id = 1, node = fake_node('jump_to_file', 0, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + local exact_targets = symbol_snapshot.targets_for_token(cycle, 'jump_to_file', { '/test/project/src/main.lua' }) + local qualified_targets = + symbol_snapshot.targets_for_token(cycle, 'M.actions.jump_to_file', { '/test/project/src/main.lua' }) + + assert.equal(1, #exact_targets) + assert.equal('jump_to_file', exact_targets[1].token) + assert.equal(1, #qualified_targets) + assert.equal('jump_to_file', qualified_targets[1].token) + end) +end) diff --git a/tests/unit/symbol_tokens_spec.lua b/tests/unit/symbol_tokens_spec.lua new file mode 100644 index 00000000..baf1e6f4 --- /dev/null +++ b/tests/unit/symbol_tokens_spec.lua @@ -0,0 +1,63 @@ +local assert = require('luassert') + +local symbol_tokens = require('opencode.ui.symbol_tokens') + +describe('opencode.ui.symbol_tokens', function() + it('finds plain and qualified symbol spans', function() + local cases = { + { line = 'foo', token = 'foo', start_pos = 1, end_pos = 3 }, + { line = 'foo: call', token = 'foo', start_pos = 1, end_pos = 3 }, + { line = 'foo:call', token = 'foo:call', start_pos = 1, end_pos = 8 }, + { line = 'A::b', token = 'A::b', start_pos = 1, end_pos = 4 }, + { line = 'OpencodeApiClient:_call', token = 'OpencodeApiClient:_call', start_pos = 1, end_pos = 23 }, + { line = 'M.actions.jump_to_file', token = 'M.actions.jump_to_file', start_pos = 1, end_pos = 22 }, + { line = 'foo.', token = 'foo', start_pos = 1, end_pos = 3 }, + { line = 'foo::', token = 'foo', start_pos = 1, end_pos = 3 }, + } + + for _, case in ipairs(cases) do + local start_pos, end_pos, token = symbol_tokens.find(case.line, 1) + assert.equal(case.start_pos, start_pos, case.line) + assert.equal(case.end_pos, end_pos, case.line) + assert.equal(case.token, token, case.line) + end + end) + + it('returns nil token for numeric spans', function() + local start_pos, end_pos, token = symbol_tokens.find('123', 1) + + assert.equal(1, start_pos) + assert.equal(3, end_pos) + assert.is_nil(token) + end) + + it('returns nil token for path segments', function() + local cases = { + { line = 'tests/data/symbol-reference-navigation.json', scan_from = 1, start_pos = 1, end_pos = 5 }, + { line = 'tests/data/symbol-reference-navigation.json', scan_from = 7, start_pos = 7, end_pos = 10 }, + { line = 'tests/data/symbol-reference-navigation.json', scan_from = 12, start_pos = 12, end_pos = 17 }, + { line = '.cache/', scan_from = 1, start_pos = 2, end_pos = 6 }, + { line = 'docs/plans/', scan_from = 1, start_pos = 1, end_pos = 4 }, + } + + for _, case in ipairs(cases) do + local start_pos, end_pos, token = symbol_tokens.find(case.line, case.scan_from) + assert.equal(case.start_pos, start_pos, case.line) + assert.equal(case.end_pos, end_pos, case.line) + assert.is_nil(token, case.line) + end + end) + + it('resolves tokens by zero-based cursor column', function() + assert.equal('foo', symbol_tokens.at_col('foo: call', 0)) + assert.is_nil(symbol_tokens.at_col('foo: call', 3)) + assert.equal('foo:call', symbol_tokens.at_col('foo:call', 3)) + assert.equal('OpencodeApiClient:_call', symbol_tokens.at_col('OpencodeApiClient:_call', 18)) + assert.equal('A::b', symbol_tokens.at_col('A::b', 1)) + assert.equal('A::b', symbol_tokens.at_col('A::b', 2)) + assert.equal('A::b', symbol_tokens.at_col('A::b', 3)) + assert.equal('M.actions.jump_to_file', symbol_tokens.at_col('M.actions.jump_to_file', 10)) + assert.is_nil(symbol_tokens.at_col('tests/data/symbol-reference-navigation.json', 8)) + assert.is_nil(symbol_tokens.at_col('.cache/', 2)) + end) +end) diff --git a/tests/unit/timer_spec.lua b/tests/unit/timer_spec.lua index 0873242c..f8654565 100644 --- a/tests/unit/timer_spec.lua +++ b/tests/unit/timer_spec.lua @@ -335,18 +335,18 @@ describe('Timer', function() -- Start, wait for ticks, then stop timer:start() - vim.wait(30, function() + assert.is_true(vim.wait(1000, function() return tick_count >= 2 - end) + end)) timer:stop() local count_after_stop = tick_count -- Restart and verify it works again timer:start() - vim.wait(30, function() + assert.is_true(vim.wait(1000, function() return tick_count > count_after_stop + 1 - end) + end)) assert.is_true(tick_count > count_after_stop + 1) assert.is_true(timer:is_running())