diff --git a/docs/performance-audit.md b/docs/performance-audit.md deleted file mode 100644 index dd8284d7..00000000 --- a/docs/performance-audit.md +++ /dev/null @@ -1,82 +0,0 @@ -# Runtime and maintainability audit - -This audit reviewed transport, event batching and subscriptions, timers, file completion, -promise settlement, history persistence, snapshot/review commands, and renderer update -primitives. It includes targeted fixes and regression tests. It is not a guarantee that -every runtime or platform combination is free of defects. - -## Fixed - -| Priority | Finding | Change | -| --- | --- | --- | -| High | File completion synchronously waited for server responses and shell searches. Server errors prevented fallback; Lua also treats `executable()` returning `0` as true. | Yield through promises, catch failed searches, skip unavailable executables, and enforce the configured result limit. | -| High | Delayed server-ready callbacks could reconnect after stop; an old server's shutdown could close the replacement subscription. Buffered and late events could cross subscription boundaries. | Guard callbacks with lifecycle/subscription identities, check the current server, and clear pending batches and cached parts at subscription cleanup. | -| High | The server test suite passed fake PIDs to real process enumeration and signal functions. The first full run terminated with exit 143. | Stub both OS operations for server tests, including restoration after each test. The termination's precise cause was not independently proven. | -| Medium | Collapsing repeated part updates rescanned all intervening events, producing quadratic work. | Track the most recent permission-event index, preserving the existing permission ordering rule in constant time per update. | -| Medium | Streaming line extraction copied the remaining buffer for each line. | Scan by offset and retain the remaining suffix once per chunk. Extremely long, incomplete lines can still cause repeated concatenation across chunks. | -| Medium | HTTP parsing stopped at a proxy CONNECT or informational response, potentially reporting the wrong status and treating final headers as body text. | Consume preliminary header blocks and parse the final response; preserve bodies that happen to resemble HTTP headers. | -| Medium | A cleared throttle callback could consume a new batch before its own deadline. | Invalidate cancelled callbacks using a generation counter. | -| Medium | Queued timer ticks could run after stop/restart; an old tick could stop a replacement timer created inside the callback. | Check timer identity before invocation and before stopping. | -| Medium | A listener unsubscribing itself during event emission could cause the next listener to be skipped. | Iterate a snapshot of the listener list. | - -## Measurements and verification - -`./run_tests.sh`: **1,224 passed, zero failed**, including minimal, unit, and replay tests. -The sandboxed run could not create Neovim swap files; the successful complete run used -approved filesystem access. Targeted tests cover pending completion responses, fallback, -subscription replacement, timer restart, cancellation, HTTP headers, and stream boundaries. -Changed Lua files were formatted with the repository's StyLua configuration; `git diff --check` passed. - -A synthetic benchmark compared the original and updated event manager in headless Neovim. -Each batch contained repeated full updates to one text part; event delivery was disabled -to isolate normalization and collapsing. Results are the median of five runs, with GC -before each run, on the audit machine: - -| Updates per batch | Before | After | -| --- | ---: | ---: | -| 1,000 | 1.49 ms | 0.67 ms | -| 4,000 | 18.05 ms | 2.33 ms | -| 8,000 | 78.79 ms | 4.80 ms | - -This demonstrates the removed quadratic scan. It is not an end-to-end rendering or typing -latency measurement. Live server, Windows shell, and large-repository interactive testing -remain useful follow-up validation. - -## Remaining findings, in recommended order - -1. **Addressed: history encoding and deletion.** New writes use JSON lines in `history.jsonl`, - with legacy `history.txt` reading and migration on the first write. Existing ambiguous - legacy records retain their previous interpretation; the legacy file remains intact. - Deletion deduplicates indices and commits rewrites atomically without mutating the cache - on failure. Regression tests cover round trips, migration, clear, duplicate indices and - failed writes. -2. **Addressed: snapshot/review and API startup waits.** Snapshot APIs now return promises; - revert operations resolve to `{ id, deleted_files }`. Callers await Git processes and - picker choices, and command handlers return the complete promise chain. Operations capture - their session/directory and serialize access to each snapshot index. Stale review results - are ignored. API startup and version detection yield, with cancellation available before - an event subscription starts. Real temporary Git repositories exercise revert and recovery; - mocked delayed processes exercise concurrency and workspace switches. Checkout errors no - longer imply that a file should be deleted, and diff previews preserve original bytes. -3. **Addressed: promise retention and falsy rejections.** Settlement clears both callback - queues and the waiting coroutine list after scheduling consumers. Rejection has its own - state flag, preserving `false` and `nil` rejection reasons consistently through chaining, - `finally`, synchronous waiting and coroutine awaiting. Both early and late consumers have - regression coverage. -4. **Architecture remains tightly coupled.** The required topology scanner reports **5 cycles**, - a **largest strongly connected component of 41 modules**, **18 policy violations**, and - **11 ungrouped modules**. The HEAD-to-worktree diff adds/removes zero dependency edges and - introduces zero violations. Of the violations, 12 are capability-to-entry dependencies, - 4 entry-to-infrastructure, and 2 capability-to-dispatch. Prioritize renderer/formatter - dependencies on permission/question windows and the `ui.ui` orchestration boundary. - Expand scanner group coverage before using its totals as a comprehensive architecture gate. - -The remaining findings above are based on source inspection, not newly added reproductions. -They are intentionally recorded separately from the tested fixes. - -Scanner commands (install `scripts/dependency-topology/requirements.txt` first): - -```sh -python3 scripts/dependency-topology/scan_topology.py scan --json -python3 scripts/dependency-topology/scan_topology.py diff --from HEAD --to worktree --json -``` diff --git a/lua/opencode/lru_cache.lua b/lua/opencode/lru_cache.lua new file mode 100644 index 00000000..1fa06ee6 --- /dev/null +++ b/lua/opencode/lru_cache.lua @@ -0,0 +1,59 @@ +---@generic T +---@class LruCache +---@field private capacity integer +---@field private entries table +---@field private size integer +---@field private clock integer +local LruCache = {} +LruCache.__index = LruCache + +---@param capacity integer +---@return LruCache +function LruCache.new(capacity) + assert(capacity > 0, 'cache capacity must be positive') + return setmetatable({ + capacity = capacity, + entries = {}, + size = 0, + clock = 0, + }, LruCache) +end + +---@param key any +---@return T +function LruCache:get(key) + local entry = self.entries[key] + if not entry then + return nil + end + + self.clock = self.clock + 1 + entry.used = self.clock + return entry.value +end + +---@param key any +---@param value T +function LruCache:set(key, value) + local entry = self.entries[key] + if not entry and self.size >= self.capacity then + local oldest_key + local oldest_use = math.huge + for cached_key, cached_entry in pairs(self.entries) do + if cached_entry.used < oldest_use then + oldest_key = cached_key + oldest_use = cached_entry.used + end + end + self.entries[oldest_key] = nil + self.size = self.size - 1 + end + + if not entry then + self.size = self.size + 1 + end + self.clock = self.clock + 1 + self.entries[key] = { value = value, used = self.clock } +end + +return LruCache diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 6925cec6..3169a6ce 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -566,9 +566,11 @@ ---@field order integer Smaller values appear earlier in the session message/part/text order. ---@class SymbolSnapshotCycle +---@field warm_path fun(self: SymbolSnapshotCycle, path: string) ---@class FormatterContext ---@field interactive boolean +---@field resolve_symbol_targets? boolean ---@field get_child_parts? fun(session_id: string): OpencodeMessagePart[]? ---@field current_refs? CodeReference[] ---@field current_files? string[] diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index a00352c8..f9169e9e 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -795,7 +795,7 @@ local function add_file_reference_targets(output, rendered, rendered_reference_r end local function add_symbol_reference_targets(output, rendered, rendered_mention_ranges, first_line_idx, context) - if not (context and context.interactive and context.symbol_cycle) then + if not (context and context.interactive and context.resolve_symbol_targets ~= false and context.symbol_cycle) then return {} end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index c28dfaa8..55463a33 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -283,7 +283,6 @@ function M.event_subscriptions() { '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 }, } end @@ -422,10 +421,10 @@ function M._render_full_session_data(session_data, opts) events.on_part_updated({ part = revert_message.parts[1] }) end - local t_format_end = vim.uv.hrtime() flush.flush() flush.end_bulk_mode() - local t_flush_end = vim.uv.hrtime() + + events.refresh_rendered_symbol_targets() if opts.restore_model_from_messages then require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true }) @@ -600,11 +599,6 @@ end M.reconcile_rendered_message_limit = reconcile_rendered_message_limit M.is_message_visible = is_message_visible ----Scroll to bottom after all queued events have been processed -function M.on_emit_events_finished() - M.scroll_to_bottom() -end - ---Return all actions available at a given (0-indexed) line ---@param line integer ---@return table[] diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 59f8ea83..716f215f 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -45,6 +45,9 @@ local ctx = { }, flush_scheduled = false, ---@type boolean markdown_render_scheduled = false, ---@type boolean + symbol_refresh_pending = false, ---@type boolean + symbol_refresh_token = 0, ---@type integer + symbol_refresh_cycle = nil, ---@type table? bulk_mode = false, ---@type boolean bulk_buffer_lines = {}, bulk_extmarks_by_line = {}, @@ -77,6 +80,9 @@ function ctx:reset() } self.flush_scheduled = false self.markdown_render_scheduled = false + self.symbol_refresh_pending = false + self.symbol_refresh_token = self.symbol_refresh_token + 1 + self.symbol_refresh_cycle = nil self.global_folds = {} self.part_folds = {} self:bulk_reset() @@ -96,6 +102,7 @@ function ctx:has_pending_work(pending) pending = pending or self.pending return self.flush_scheduled + or self.symbol_refresh_pending or self.bulk_mode or #pending.dirty_message_order > 0 or #pending.dirty_part_order > 0 diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index b6560405..f9aab59b 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -4,6 +4,7 @@ local ctx = require('opencode.ui.renderer.ctx') local prompts = ctx.prompt_controllers local flush = require('opencode.ui.renderer.flush') local reference_facts = require('opencode.ui.reference_facts') +local symbol_refresh = require('opencode.ui.renderer.symbol_refresh') ---@param message OpencodeMessage|nil ---@return string|nil @@ -85,31 +86,6 @@ local function mark_following_assistant_text_parts_dirty(message, changed_part_i 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) @@ -118,9 +94,12 @@ end local M = {} +function M.refresh_rendered_symbol_targets() + symbol_refresh.refresh() +end + function M.invalidate_reference_targets_for_file_change() - reference_facts.refresh_current_files() - mark_rendered_assistant_text_parts_dirty() + symbol_refresh.invalidate() end ---@param message_id string diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index d1a93576..77da79e8 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -14,9 +14,7 @@ local warned_part_render_error = false local function output_window_is_in_background_tab() local output_win = state.windows and state.windows.output_win - return output_win - and vim.api.nvim_win_is_valid(output_win) - and not state.ui.is_window_in_current_tab(output_win) + return output_win and vim.api.nvim_win_is_valid(output_win) and not state.ui.is_window_in_current_tab(output_win) end ---@param part_id string @@ -251,12 +249,13 @@ end local function new_formatter_context() return { interactive = true, + resolve_symbol_targets = not ctx.bulk_mode, 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.available_files(), - symbol_cycle = symbol_snapshot.new_cycle(), + symbol_cycle = ctx.symbol_refresh_cycle or symbol_snapshot.new_cycle(), } end @@ -543,6 +542,7 @@ function M.resume_deferred_rendering() M.flush() if ctx.bulk_mode then M.end_bulk_mode() + require('opencode.ui.renderer.events').refresh_rendered_symbol_targets() end M.flush_pending_on_data_rendered() end diff --git a/lua/opencode/ui/renderer/symbol_refresh.lua b/lua/opencode/ui/renderer/symbol_refresh.lua new file mode 100644 index 00000000..02ca6ca8 --- /dev/null +++ b/lua/opencode/ui/renderer/symbol_refresh.lua @@ -0,0 +1,152 @@ +local state = require('opencode.state') +local ctx = require('opencode.ui.renderer.ctx') +local flush = require('opencode.ui.renderer.flush') +local symbol_snapshot = require('opencode.ui.symbol_snapshot') + +local M = {} +local REFRESH_INTERVAL_MS = 1 + +local function find_message_in_state(message_id) + for _, message in ipairs(state.messages or {}) do + if message.info and message.info.id == message_id then + return message + end + end + return nil +end + +local function is_assistant_message(message) + return message and message.info and message.info.role == 'assistant' +end + +local function is_rendered_assistant_text_part(part_id, active_session_id) + local part_data = ctx.render_state:get_part(part_id) + local part = part_data and part_data.part + if + not part + or part.type ~= 'text' + or not part.text + or part.synthetic + or not part_data.line_start + or not part_data.line_end + then + return false + end + + 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) + return is_assistant_message(message) and message.info.sessionID == active_session_id +end + +local function rendered_assistant_text_part_ids(active_session_id) + local part_ids = {} + for part_id in pairs(ctx.render_state._parts or {}) do + if is_rendered_assistant_text_part(part_id, active_session_id) then + part_ids[#part_ids + 1] = part_id + end + end + return part_ids +end + +local function mark_part_dirty(part_id, active_session_id) + if not is_rendered_assistant_text_part(part_id, active_session_id) then + return + end + + local part_data = ctx.render_state:get_part(part_id) + ctx.formatted_parts[part_id] = nil + flush.mark_part_dirty(part_id, part_data.message_id) +end + +local function mark_all_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 in pairs(ctx.render_state._parts or {}) do + mark_part_dirty(part_id, active_session_id) + end +end + +local function finish_refresh(refresh_token) + ctx.symbol_refresh_pending = false + vim.schedule(function() + if ctx.symbol_refresh_token == refresh_token then + ctx.symbol_refresh_cycle = nil + end + end) +end + +function M.invalidate() + ctx.symbol_refresh_pending = false + ctx.symbol_refresh_token = ctx.symbol_refresh_token + 1 + ctx.symbol_refresh_cycle = nil + require('opencode.ui.reference_facts').refresh_current_files() + mark_all_parts_dirty() +end + +function M.refresh() + local active_session_id = state.active_session and state.active_session.id + if not active_session_id then + return + end + + local reference_facts = require('opencode.ui.reference_facts') + reference_facts.refresh_current_files() + local candidate_files = reference_facts.available_files() + local part_ids = rendered_assistant_text_part_ids(active_session_id) + local refresh_token = ctx.symbol_refresh_token + 1 + ctx.symbol_refresh_token = refresh_token + ctx.symbol_refresh_pending = true + ctx.symbol_refresh_cycle = symbol_snapshot.new_cycle() + + local next_candidate = 1 + local next_part = 1 + local function is_current_refresh() + if ctx.symbol_refresh_token ~= refresh_token then + return false + end + if not state.active_session or state.active_session.id ~= active_session_id then + finish_refresh(refresh_token) + return false + end + return true + end + + local function refresh_next_part() + if not is_current_refresh() then + return + end + local part_id = part_ids[next_part] + if part_id then + mark_part_dirty(part_id, active_session_id) + next_part = next_part + 1 + vim.defer_fn(refresh_next_part, REFRESH_INTERVAL_MS) + else + finish_refresh(refresh_token) + end + end + + local function warm_next_candidate() + if not is_current_refresh() then + return + end + + local path = candidate_files[next_candidate] + if path then + local cycle = ctx.symbol_refresh_cycle + if cycle and type(cycle.warm_path) == 'function' then + pcall(cycle.warm_path, cycle, path) + end + next_candidate = next_candidate + 1 + vim.defer_fn(warm_next_candidate, REFRESH_INTERVAL_MS) + else + vim.defer_fn(refresh_next_part, REFRESH_INTERVAL_MS) + end + end + + vim.defer_fn(warm_next_candidate, REFRESH_INTERVAL_MS) +end + +return M diff --git a/lua/opencode/ui/symbol_snapshot.lua b/lua/opencode/ui/symbol_snapshot.lua index b01a8034..721d4248 100644 --- a/lua/opencode/ui/symbol_snapshot.lua +++ b/lua/opencode/ui/symbol_snapshot.lua @@ -1,6 +1,35 @@ local M = {} local MIN_DEFINITION_TOKEN_LENGTH = 2 +local path_cache = require('opencode.lru_cache').new(256) + +local function timestamp_key(timestamp) + if type(timestamp) == 'table' then + return string.format('%s:%s', timestamp.sec or '', timestamp.nsec or '') + end + return tostring(timestamp or '') +end + +local function source_version(path) + 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 + local ok, changedtick = pcall(vim.api.nvim_buf_get_changedtick, bufnr) + return ok and 'buffer:' .. bufnr .. ':' .. changedtick or nil + end + + local stat = (vim.uv or vim.loop).fs_stat(path) + if not stat then + return nil + end + return table.concat({ + 'disk', + stat.dev or '', + stat.ino or '', + timestamp_key(stat.mtime), + timestamp_key(stat.ctime), + stat.size, + }, ':') +end local function absolute_path(path) if path:sub(1, 1) == '/' then @@ -126,6 +155,13 @@ local function collect_path(path) lang = parser_lang end + local version = source_version(path) + local cache_key = version and lang .. ':' .. version + local cached = path_cache:get(path) + if cached and cached.version == cache_key then + return cached.by_token + 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') @@ -170,6 +206,10 @@ local function collect_path(path) end end + if cache_key then + path_cache:set(path, { version = cache_key, by_token = by_token }) + end + return by_token end @@ -177,13 +217,6 @@ 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 @@ -192,6 +225,20 @@ local function collect_cycle_path(cycle, path) return cycle.by_path[absolute] end +function M.new_cycle() + local cycle = { + _symbol_snapshot_cycle = true, + by_path = {}, + } + function cycle:warm_path(path) + if type(path) == 'string' then + collect_cycle_path(self, path) + end + end + + return cycle +end + function M.targets_for_token(cycle, token, candidate_files) if not is_cycle(cycle) then return {} @@ -203,10 +250,11 @@ function M.targets_for_token(cycle, token, candidate_files) local targets = {} local seen = {} + local variants = M.token_variants(token) 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 _, variant in ipairs(variants) 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 diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index f7b48051..486f5395 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -214,6 +214,10 @@ describe('renderer unit tests', function() })) end) + it('leaves post-flush scrolling to the renderer flush', function() + assert.is_false(vim.tbl_contains(event_subscriptions(), 'custom.emit_events.finished')) + end) + it('unsubsribes from events correctly', function() local renderer = require('opencode.ui.renderer') local event_manager = state.event_manager @@ -503,7 +507,15 @@ describe('renderer unit tests', function() return original_filereadable(path) end state.session.set_active(helpers.get_session_from_events(events, true)) + vim.wait(0) renderer._render_full_session_data(helpers.load_session_from_events(events)) + local ctx = require('opencode.ui.renderer.ctx') + assert.is_true( + vim.wait(1000, function() + return not ctx:has_pending_work() + end), + 'Timed out waiting for deferred symbol targets' + ) local actual = helpers.capture_output(state.windows.output_buf, output_window.namespace) local symbol_mark diff --git a/tests/unit/lru_cache_spec.lua b/tests/unit/lru_cache_spec.lua new file mode 100644 index 00000000..c6fb64dc --- /dev/null +++ b/tests/unit/lru_cache_spec.lua @@ -0,0 +1,27 @@ +local LruCache = require('opencode.lru_cache') + +describe('LRU cache', function() + it('evicts the least recently used entry', function() + local cache = LruCache.new(2) + cache:set('first', 1) + cache:set('second', 2) + assert.equal(1, cache:get('first')) + + cache:set('third', 3) + + assert.is_nil(cache:get('second')) + assert.equal(1, cache:get('first')) + assert.equal(3, cache:get('third')) + end) + + it('updates existing entries without evicting another entry', function() + local cache = LruCache.new(2) + cache:set('first', 1) + cache:set('second', 2) + + cache:set('first', 3) + + assert.equal(3, cache:get('first')) + assert.equal(2, cache:get('second')) + end) +end) diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index fa589c76..5302e5ba 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -112,6 +112,7 @@ describe('persist_state', function() vim.fn.writefile(lines or { 'line 1', 'line 2', 'line 3', 'line 4', 'line 5' }, tmpfile) code_buf = vim.fn.bufadd(tmpfile) + vim.bo[code_buf].swapfile = false vim.fn.bufload(code_buf) vim.bo[code_buf].buflisted = true diff --git a/tests/unit/symbol_refresh_spec.lua b/tests/unit/symbol_refresh_spec.lua new file mode 100644 index 00000000..f7aa19b0 --- /dev/null +++ b/tests/unit/symbol_refresh_spec.lua @@ -0,0 +1,76 @@ +local stub = require('luassert.stub') +local state = require('opencode.state') +local ctx = require('opencode.ui.renderer.ctx') +local reference_facts = require('opencode.ui.reference_facts') +local symbol_snapshot = require('opencode.ui.symbol_snapshot') +local symbol_refresh = require('opencode.ui.renderer.symbol_refresh') + +describe('renderer symbol refresh', function() + local original_defer_fn + local original_schedule + + before_each(function() + ctx:reset() + state.session.set_active({ id = 'ses_test', title = 'Test Session' }) + original_defer_fn = vim.defer_fn + original_schedule = vim.schedule + end) + + after_each(function() + vim.defer_fn = original_defer_fn + vim.schedule = original_schedule + ctx:reset() + end) + + it('cancels an active refresh when symbol data is invalidated', function() + local refresh_stub = stub(reference_facts, 'refresh_current_files') + local cycle = {} + ctx.symbol_refresh_pending = true + ctx.symbol_refresh_cycle = cycle + local refresh_token = ctx.symbol_refresh_token + + symbol_refresh.invalidate() + + assert.equal(refresh_token + 1, ctx.symbol_refresh_token) + assert.is_false(ctx.symbol_refresh_pending) + assert.is_nil(ctx.symbol_refresh_cycle) + assert.stub(refresh_stub).was_called(1) + refresh_stub:revert() + end) + + it('finishes a refresh when warming a candidate throws', function() + local callbacks = {} + vim.defer_fn = function(callback) + callbacks[#callbacks + 1] = callback + end + vim.schedule = function(callback) + callback() + end + + local refresh_stub = stub(reference_facts, 'refresh_current_files') + local files_stub = stub(reference_facts, 'available_files').returns({ 'broken.lua', 'valid.lua' }) + local warmed = {} + local cycle = { + warm_path = function(_, path) + warmed[#warmed + 1] = path + if path == 'broken.lua' then + error('failed to warm snapshot') + end + end, + } + local cycle_stub = stub(symbol_snapshot, 'new_cycle').returns(cycle) + + symbol_refresh.refresh() + while #callbacks > 0 do + table.remove(callbacks, 1)() + end + + assert.same({ 'broken.lua', 'valid.lua' }, warmed) + assert.is_false(ctx.symbol_refresh_pending) + assert.is_nil(ctx.symbol_refresh_cycle) + + cycle_stub:revert() + files_stub:revert() + refresh_stub:revert() + end) +end) diff --git a/tests/unit/symbol_snapshot_spec.lua b/tests/unit/symbol_snapshot_spec.lua index 5ad84495..bef37862 100644 --- a/tests/unit/symbol_snapshot_spec.lua +++ b/tests/unit/symbol_snapshot_spec.lua @@ -7,7 +7,11 @@ describe('opencode.ui.symbol_snapshot', function() local original_filetype local original_treesitter local original_notify + local original_uv + local original_loop + local original_lru_cache local files + local file_versions local buffers local captures_by_content local read_counts @@ -15,6 +19,7 @@ describe('opencode.ui.symbol_snapshot', function() local query_available local parser_available local notify_calls + local cached_paths local function fake_node(text, row, col) return { @@ -27,6 +32,7 @@ describe('opencode.ui.symbol_snapshot', function() local function set_file(path, lines, captures) files[path] = lines + file_versions[path] = (file_versions[path] or 0) + 1 captures_by_content[table.concat(lines, '\n')] = captures or {} end @@ -36,7 +42,11 @@ describe('opencode.ui.symbol_snapshot', function() original_filetype = vim.filetype original_treesitter = vim.treesitter original_notify = vim.notify + original_uv = vim.uv + original_loop = vim.loop + original_lru_cache = package.loaded['opencode.lru_cache'] files = {} + file_versions = {} buffers = {} captures_by_content = {} read_counts = {} @@ -44,6 +54,7 @@ describe('opencode.ui.symbol_snapshot', function() query_available = true parser_available = true notify_calls = {} + cached_paths = {} vim.fn = vim.tbl_extend('force', vim.fn or {}, { getcwd = function() @@ -175,6 +186,27 @@ describe('opencode.ui.symbol_snapshot', function() table.insert(notify_calls, { msg = msg, level = level }) end + local uv = { + fs_stat = function(path) + local version = file_versions[path] + return version and { mtime = { sec = version, nsec = 0 }, size = #table.concat(files[path], '\n') } or nil + end, + } + vim.uv = uv + vim.loop = uv + + package.loaded['opencode.lru_cache'] = { + new = function() + return { + get = function(_, path) + return cached_paths[path] + end, + set = function(_, path, value) + cached_paths[path] = value + end, + } + end, + } package.loaded['opencode.ui.symbol_snapshot'] = nil symbol_snapshot = require('opencode.ui.symbol_snapshot') end) @@ -185,7 +217,10 @@ describe('opencode.ui.symbol_snapshot', function() vim.filetype = original_filetype vim.treesitter = original_treesitter vim.notify = original_notify + vim.uv = original_uv + vim.loop = original_loop package.loaded['opencode.ui.symbol_snapshot'] = nil + package.loaded['opencode.lru_cache'] = original_lru_cache end) it('exports only the frozen public API', function() @@ -246,6 +281,28 @@ describe('opencode.ui.symbol_snapshot', function() assert.equal(1, parse_counts[content]) end) + it('reuses parsed candidate files across cycles until they change', function() + local path = '/test/project/src/main.lua' + local content = 'local function foo() end' + set_file(path, { content }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + local first = symbol_snapshot.new_cycle() + local second = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(first, 'foo', { path })) + assert.equal(1, #symbol_snapshot.targets_for_token(second, 'foo', { path })) + assert.equal(1, read_counts[path]) + assert.equal(1, parse_counts[content]) + + set_file(path, { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + local changed = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(changed, 'bar', { path })) + assert.equal(2, read_counts[path]) + 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) },