Skip to content

Add Sweep AI autocomplete plugin analysis#2

Closed
Kabilan108 wants to merge 18 commits intonixosfrom
claude/neovim-sweep-autocomplete-IY2GC
Closed

Add Sweep AI autocomplete plugin analysis#2
Kabilan108 wants to merge 18 commits intonixosfrom
claude/neovim-sweep-autocomplete-IY2GC

Conversation

@Kabilan108
Copy link
Owner

@Kabilan108 Kabilan108 commented Jan 24, 2026

Research notes from analyzing the JetBrains Sweep AI plugin architecture,
including context building strategies, prompt formats, and LSP integration
patterns for designing a Neovim equivalent.

Summary by CodeRabbit

  • New Features
    • Added sweep.nvim AI autocomplete with debounce, configurable keybindings, ghost-text UI, commands (enable/disable/toggle/status/debug), and edit/ring-based cross-file context.
  • Configuration
    • New options for server/timeout, prediction parameters, caching, debounce, keymaps, filetype exclusions, and suggestion highlight.
  • Documentation
    • Added comprehensive README and an in-depth technical analysis of the plugin and integration.
  • Tests
    • Extensive unit and integration tests for completion, caching, context, UI, keymaps, HTTP, and helpers.

✏️ Tip: You can customize this high-level summary in your review settings.

claude added 16 commits January 24, 2026 01:29
Research notes from analyzing the JetBrains Sweep AI plugin architecture,
including context building strategies, prompt formats, and LSP integration
patterns for designing a Neovim equivalent.
- Plugin entry point with commands (enable/disable/toggle/status/debug)
- Configuration module with typed defaults for keymaps, context, server, UI
- Test harness setup with plenary.test
- Initial config_spec tests

Targeting Neovim 0.10+ with plenary.nvim dependency.
Implement context module for sweep.nvim that extracts rich context:
- LSP integration for definitions and type info via textDocument/definition and hover
- Treesitter integration for scope detection (function, class, method)
- Import/require statement extraction for multiple languages
- Formatted output with prioritized sections for prompts
- Async API with callbacks for LSP operations
- Graceful handling of missing LSP/treesitter
- Content truncation to configurable limits
This is the core orchestration module for sweep.nvim that ties together
the FIM, HTTP, parser, and UI modules. Implements:

- Debounced trigger mechanism using vim.loop timers
- Request cancellation for in-flight HTTP requests
- accept_full/accept_line/accept_word for partial completion acceptance
- dismiss for clearing without insertion
- Filetype exclusion support
- Enable/disable/toggle functionality

Tests cover debouncing, request management, text insertion, and error handling.
Implements keymaps for sweep.nvim that allow users to:
- Trigger completion manually (insert mode)
- Accept full, line, or word completions (insert mode, only when visible)
- Dismiss completions (insert mode, only when visible)

Uses expr mappings for conditional behavior that passes through the key
when no completion is visible. Includes comprehensive TDD tests.
Configure sweep.nvim with keybindings matching previous llama.vim setup:
- <C-c> trigger, <C-s> accept full, <C-l> accept line, <C-w> accept word
- Same highlight color (#f2cdcd) for ghost text
- Uses LLAMA_SERVER_URL env var with localhost:8012 default
@coderabbitai
Copy link

coderabbitai bot commented Jan 24, 2026

📝 Walkthrough

Walkthrough

Adds sweep.nvim: a new Neovim AI-autocomplete plugin (replacing a commented-out llama.vim entry) with config, lifecycle, completion pipeline, context extraction, caching, HTTP client for llama.cpp, UI, autocmds/keymaps, debug pane, and extensive unit tests.

Changes

Cohort / File(s) Summary
Configuration & Entrypoint
config/nvim/lua/plugins.lua, sweep.nvim/lua/sweep/config.lua, sweep.nvim/lua/sweep/init.lua, sweep.nvim/plugin/sweep.lua
Add plugin entry for sweep.nvim, configuration schema/defaults, highlight setup, lifecycle APIs (setup/enable/disable/toggle/status), and plugin commands.
Core Completion & Protocol
sweep.nvim/lua/sweep/completion.lua, sweep.nvim/lua/sweep/fim.lua, sweep.nvim/lua/sweep/http.lua, sweep.nvim/lua/sweep/parser.lua
Implement debounced completion flow, FIM request builder, async HTTP client with cancellation for llama.cpp, response parsing (stop tokens, lines, timings), caching hooks, and public completion APIs (trigger, manual_trigger, cancel, accept_*, dismiss, enable/disable).
Context, State & Caching
sweep.nvim/lua/sweep/context.lua, sweep.nvim/lua/sweep/cache.lua, sweep.nvim/lua/sweep/ring.lua, sweep.nvim/lua/sweep/edits.lua
Add LSP/Treesitter context extraction (sync/async), LRU cache with TTL and stats, circular ring buffer for cross-file context with dedupe/trim, and edit-history tracking with attach/detach and formatted edit context.
Neovim Integration & UI
sweep.nvim/lua/sweep/autocmds.lua, sweep.nvim/lua/sweep/keymaps.lua, sweep.nvim/lua/sweep/ui.lua
Register autocmds (CursorMovedI, BufEnter/Leave, InsertLeave, TextYankPost, BufWritePost), per-buffer conditional keymaps mapping to completion actions, and ghost-text UI via extmarks (show/clear/is_visible/get_current).
Debugging & Tests
sweep.nvim/lua/sweep/debug.lua, sweep.nvim/README.md, sweep-ai-autocomplete-analysis.md, sweep.nvim/tests/*, sweep.nvim/tests/minimal_init.lua
Add runtime debug pane/logger, README and deep analysis doc, minimal test init, and extensive unit tests covering nearly all new modules (autocmds, cache, completion, config, context, debug, edits, fim, http, keymaps, parser, ring, ui).

Sequence Diagram(s)

sequenceDiagram
    participant Editor as Neovim Editor
    participant Autocmd as Autocmds
    participant Completion as Completion
    participant FIM as FIM Builder
    participant Context as Context/Ring/Edits
    participant HTTP as HTTP Client
    participant Server as llama.cpp Server
    participant Parser as Parser
    participant UI as UI Module

    Editor->>Autocmd: CursorMovedI / Keymap event
    Autocmd->>Completion: trigger()
    activate Completion
    Completion->>Completion: debounce timer
    Completion->>FIM: build_request(buf context)
    FIM-->>Completion: fim request
    Completion->>Context: gather context (LSP/treesitter/ring/edits)
    Context-->>Completion: context payload
    Completion->>HTTP: request(prompt + params)
    deactivate Completion

    activate HTTP
    HTTP->>Server: POST /completion
    Server->>Server: inference
    Server-->>HTTP: response (json)
    HTTP->>Parser: parse(response)
    deactivate HTTP

    activate Parser
    Parser-->>Completion: parsed result (lines, tokens, timings)
    deactivate Parser

    activate Completion
    Completion->>Cache: store(result)
    Completion->>UI: show(lines, info)
    deactivate Completion

    UI->>Editor: set extmarks (virt_text/virt_lines)
    Editor->>Completion: accept_full/accept_line/accept_word
    Completion->>Editor: insert text / clear UI
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

A whisker-twitching hop with neon light,
I gather context, edits, and prompt just right,
Ghost letters dance where the cursor will roam,
From ring to server, the completion finds home,
— a rabbit's cheer for Sweep's new code tonight! 🐰✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Add Sweep AI autocomplete plugin analysis' clearly describes the primary change: adding comprehensive technical analysis and implementation of a Sweep AI autocomplete plugin for Neovim (sweep.nvim).
Docstring Coverage ✅ Passed Docstring coverage is 98.57% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- Fix SCOPE_NODE_TYPES duplicate keys bug (Lua table overwrites)
- Remove unnecessary JSON encode/decode round-trip in completion
- Make parser.parse() accept both string and table input
- Remove dead code: unreachable cancellation check in http callback
- Remove dead code: unused get_treesitter_parser function
- Remove dead code: unused in_import_section variable
- Remove dead code: empty vim.schedule wrapper in error handler
- Simplify redundant nil check in parser
- Add entry counter to cache for O(1) count operations
- Reuse parser.first_word() in completion accept_word
- Clean up unused variable captures in treesitter range extraction
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Fix all issues with AI agents
In `@sweep-ai-autocomplete-analysis.md`:
- Line 15: Add language identifiers to all fenced code blocks flagged by
markdownlint MD040; for each triple-backtick block (e.g., the "JetBrains IDE"
sample and the other fenced examples) replace ``` with a language tag like
```text, ```python, ```lua, or ```diff as appropriate so the blocks are
annotated; ensure each code fence in the document is updated consistently to
remove the MD040 warnings.

In `@sweep.nvim/lua/sweep/cache.lua`:
- Around line 97-135: The set() path can loop infinitely when config.max_entries
<= 0; update M.setup to ensure config.max_entries is clamped to a sensible
minimum (e.g., 1) or set a flag to disable caching, and modify M.set to
short‑circuit when config.max_entries <= 0 (or caching disabled) so it doesn't
enter the while entry_count() >= config.max_entries loop; reference M.setup to
clamp config.max_entries and M.set (and functions entry_count and evict_lru) to
guard/return early when max_entries is non‑positive.

In `@sweep.nvim/lua/sweep/completion.lua`:
- Around line 59-67: The cancel_debounce_timer function currently stops and nils
state.debounce_timer but doesn't close the libuv handle; update
cancel_debounce_timer to call state.debounce_timer:stop() (if active) and then
state.debounce_timer:close() before setting state.debounce_timer = nil so the
underlying vim.loop timer handle is released; ensure you check that
state.debounce_timer has a :close method (i.e., non-nil) and call close in all
paths where the timer is discarded.
- Around line 250-251: The code sets state.current_handle via http.request by
concatenating cfg.server.endpoint .. '/infill', which can produce a double slash
if cfg.server.endpoint ends with '/' (e.g., "http://localhost:8012/"); update
the URL construction used in the http.request call to normalize the endpoint
first (trim any trailing slash from cfg.server.endpoint or use a join that
ensures a single separator) before appending '/infill' so the request URL is
always valid; adjust the logic around state.current_handle assignment and the
http.request invocation accordingly.

In `@sweep.nvim/lua/sweep/config.lua`:
- Around line 32-46: The SweepConfig annotation and M.defaults disagree about
where show_info lives; either add a top-level show_info field to the SweepConfig
typedef or move/remove the top-level default and keep show_info under
SweepUIConfig (and update M.defaults accordingly). Locate the SweepConfig
annotation and the M.defaults table (symbols: SweepConfig, M.defaults) and
ensure the schema and defaults match: if you intend a top-level flag, add
"@@field show_info boolean" to SweepConfig and keep the key in M.defaults; if
you intend it under ui, remove the top-level show_info from M.defaults and
ensure SweepUIConfig and its "@field show_info boolean" exist and are documented
(also update the duplicate instance at the second occurrence around the other
defaults noted in the comment).

In `@sweep.nvim/lua/sweep/context.lua`:
- Around line 185-236: The get_scope function currently hardcodes column 0 when
calling vim.treesitter.get_node which can return an outer scope on indented
lines; update the signature of M.get_scope(bufnr, row, col, opts) to accept an
optional col (default 0) and use pos = { row, col } when calling
vim.treesitter.get_node, then update both callers M.get(...) and
M.get_async(...) to pass their col argument through to M.get_scope so the
correct node at the cursor column is used for scope detection.
- Around line 387-440: The hover handling in M.get_type_info doesn't handle
array-formatted HoverContents (MarkedString[]) and only inspects strings/tables,
so update the callback passed to vim.lsp.buf_request to detect when
result.contents is a list: if type(contents) == 'table' and
vim.tbl_islist(contents) then pick the first element (or join multiple) and
extract its string or .value/.kind similarly; otherwise keep existing
string/table handling—assign the extracted value to type_info and call
callback(type_info) once. Ensure you reference M.get_type_info, the local
variables contents and type_info, and the vim.lsp.buf_request response handler
when making this change.

In `@sweep.nvim/lua/sweep/http.lua`:
- Around line 25-156: The bug in M.request is that active_requests[request_id]
is removed before any vim.schedule callbacks run, so a cancel() after response
but before the scheduled on_success/on_error cannot suppress callbacks; change
the lifecycle so the request entry remains until scheduled callbacks finish by
adding and using a completed flag (e.g., active_requests[request_id] = { job =
job, cancelled = false, completed = false }), only mark completed = true and
then remove the entry inside the vim.schedule wrapper(s) after double-checking
cancelled (in both on_success and on_error paths), and ensure cancel() sets
cancelled = true but does not remove the entry until completed is true.

In `@sweep.nvim/lua/sweep/keymaps.lua`:
- Around line 30-121: The conditional callback currently returns termcodes via
vim.api.nvim_replace_termcodes (in make_conditional_callback), which disables
user remapped mappings on passthrough; change make_conditional_callback to
return the raw key string (do not call nvim_replace_termcodes) when completion
is not visible, and update the vim.keymap.set calls in setup_buffer_keymaps for
the conditional mappings (those using km.accept_full, km.accept_line,
km.accept_word, km.dismiss) to include remap = true in their options so returned
keys honor existing user mappings and snippets.

In `@sweep.nvim/lua/sweep/ring.lua`:
- Around line 72-79: M.setup currently assigns config.max_chunks and
config.chunk_size directly from opts which can allow zero or negative values
causing modulo-by-zero in M.add/M.get; clamp config.max_chunks to a safe minimum
of 1 (and similarly clamp config.chunk_size to at least 1) inside M.setup after
reading opts, e.g. enforce config.max_chunks = math.max(1, opts.max_chunks or
16) and config.chunk_size = math.max(1, opts.chunk_size or 64), then call
M.clear(); ensure any dependent logic in M.add and M.get can rely on these
invariants.

In `@sweep.nvim/README.md`:
- Line 136: Update the README entry for the edits module: replace the inaccurate
description "Applies accepted completions to buffer" with a concise, accurate
description such as "Tracks edit history and provides context for next-edit
prediction" (mentioning that actual completion application is handled by modules
like completion or ui); target the `edits` module line in the README and ensure
the wording distinguishes its role from `completion` / `ui`.

In `@sweep.nvim/tests/sweep/http_spec.lua`:
- Around line 342-434: The test's mock_curl.post creates a local job but never
assigns it to the global mock_job, so job1/job2 become nil and calling
_trigger_callback fails; modify the mock_curl.post in the second spec to capture
the created job (e.g., local job = create_mock_job() or create_mock_job({...}) )
and either assign mock_job = job or return that same job and set local job1/job2
= mock_job after calling http.request; specifically update the mock_curl.post
closure used in the "should not call any callbacks after cancel_all" test to set
mock_job (or return and store the created job) before wiring stored_callback so
job1._trigger_callback and job2._trigger_callback are valid.
🧹 Nitpick comments (12)
sweep.nvim/lua/sweep/edits.lua (1)

241-248: detach doesn't actually detach from nvim_buf_attach.

This function clears local state but doesn't actually stop the on_lines callback from firing. To properly detach, you'd need to return true from on_lines or on_bytes in a subsequent callback. Consider documenting this limitation or implementing proper detachment by storing a flag that causes on_lines to return true on next invocation.

♻️ Optional: Implement proper detachment
 ---@type table<number, boolean> Track which buffers are attached
 local attached_buffers = {}
+
+---@type table<number, boolean> Track buffers pending detach
+local pending_detach = {}

 -- In on_lines callback:
+        if pending_detach[buf] then
+          pending_detach[buf] = nil
+          return true  -- Returning true detaches the callback
+        end

 --- Stop tracking a buffer
 ---@param bufnr number
 function M.detach(bufnr)
   attached_buffers[bufnr] = nil
   buffer_content[bufnr] = nil
+  pending_detach[bufnr] = true
   -- Note: nvim_buf_attach with on_detach handles cleanup automatically
   -- when buffer is deleted. Manual detach is for explicit stop tracking.
 end
sweep.nvim/tests/minimal_init.lua (1)

7-24: Minor redundancy in plenary path fallbacks.

Line 14 (~/.local/share/nvim/lazy/plenary.nvim) typically resolves to the same path as line 8 (vim.fn.stdpath('data') .. '/lazy/plenary.nvim'). This is harmless but could be removed for clarity.

♻️ Remove redundant path
   local alt_paths = {
-    vim.fn.expand('~/.local/share/nvim/lazy/plenary.nvim'),
     vim.fn.expand('~/.local/share/nvim/site/pack/packer/start/plenary.nvim'),
     '/usr/share/nvim/site/pack/packer/start/plenary.nvim',
   }
sweep.nvim/tests/sweep/ring_spec.lua (1)

65-66: Simplify the assertion for better readability.

The assertion on line 65 uses a convoluted pattern. Consider using the simpler and more direct approach that's already used on line 66.

♻️ Simplify assertion
-      assert.is_nil(vim.tbl_contains(contents, 'chunk1') and 'chunk1' or nil)
+      assert.is_false(vim.tbl_contains(contents, 'chunk1'))
       assert.is_true(vim.tbl_contains(contents, 'chunk5'))
sweep.nvim/tests/sweep/edits_spec.lua (1)

406-411: Consider tightening the attach success assertion.

The assertion result == true or result == nil or result is very permissive. Since attach uses pcall internally and the function is documented to return boolean success, consider asserting strictly assert.is_true(result) for clearer test intent.

♻️ Tighten assertion
     it('should return true when attach is called', function()
       -- attach returns success indicator
       local result = edits.attach(1)
-      assert.is_true(result == true or result == nil or result)
+      assert.is_true(result)
     end)
sweep.nvim/tests/sweep/cache_spec.lua (1)

146-205: Consider stubbing time instead of vim.wait in TTL tests.
Real sleeps can slow the suite and be flaky on busy CI. If the cache can accept a time provider (or expose a now_ms override), you can advance time deterministically without waiting.

sweep.nvim/lua/sweep/keymaps.lua (1)

160-167: Avoid mutating buffer_keymaps during iteration.
Clearing entries inside the pairs() loop can skip buffers. Iterate over a snapshot of keys for predictable teardown.

♻️ Safer iteration during teardown
-  for bufnr, _ in pairs(buffer_keymaps) do
-    teardown_buffer_keymaps(bufnr)
-  end
+  for _, bufnr in ipairs(vim.tbl_keys(buffer_keymaps)) do
+    teardown_buffer_keymaps(bufnr)
+  end
sweep.nvim/lua/sweep/fim.lua (1)

68-187: Avoid reading the entire buffer for each request.
nvim_buf_get_lines(bufnr, 0, -1, ...) pulls the whole file on every completion. For large buffers this is expensive. Use nvim_buf_line_count plus targeted get_lines for just the prefix/suffix ranges.

♻️ Example refactor
-  -- Get all buffer lines
-  local total_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
-  local line_count = `#total_lines`
+  -- Get line count without loading entire buffer
+  local line_count = vim.api.nvim_buf_line_count(bufnr)
@@
-  local current_line = total_lines[row + 1] or ''
+  local current_line = (vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1]) or ''
@@
-  for i = prefix_start, row - 1 do
-    local line = total_lines[i + 1]
-    if line then
-      table.insert(prefix_parts, line)
-    end
-  end
+  local prefix_lines_buf = vim.api.nvim_buf_get_lines(bufnr, prefix_start, row, false)
+  for _, line in ipairs(prefix_lines_buf) do
+    table.insert(prefix_parts, line)
+  end
@@
-  for i = row + 1, suffix_end do
-    local line = total_lines[i + 1]
-    if line then
-      table.insert(suffix_parts, line)
-    end
-  end
+  local suffix_lines_buf = vim.api.nvim_buf_get_lines(bufnr, row + 1, suffix_end + 1, false)
+  for _, line in ipairs(suffix_lines_buf) do
+    table.insert(suffix_parts, line)
+  end
sweep.nvim/lua/sweep/completion.lua (3)

149-153: Remove no-op vim.schedule call.

This vim.schedule wraps an empty function body (the notify is commented out). Either remove it entirely or enable debug logging conditionally.

Proposed fix
   -- Update pending state
   state.pending = false

-  -- Log the error (optional)
-  vim.schedule(function()
-    -- Uncomment for debugging:
-    -- vim.notify('Sweep completion error: ' .. error_msg, vim.log.levels.DEBUG)
-  end)
+  -- Log error in debug mode
+  local cfg = config.get()
+  if cfg.debug then
+    vim.schedule(function()
+      vim.notify('Sweep completion error: ' .. error_msg, vim.log.levels.DEBUG)
+    end)
+  end

405-412: Word pattern may be too restrictive for code completion.

The pattern [%w_]+ only matches alphanumerics and underscores. Common code tokens like foo-bar (CSS), foo.bar (method chains), or foo::bar (C++/Rust) would only capture the first segment. Consider if this is intentional or if a more permissive pattern would be preferable.


97-100: Remove unnecessary JSON re-encoding in parser call.

The response parameter is already a parsed table (as documented in the function signature). Re-encoding it to JSON with vim.json.encode() only for parser.parse() to immediately decode it again with vim.json.decode() is wasteful. Either have parser.parse() accept tables directly, or pass the raw JSON string from the HTTP layer instead.

sweep.nvim/lua/sweep/init.lua (2)

24-29: Cache configuration is hardcoded.

The ring buffer uses configurable values with fallback defaults, but the cache uses hardcoded values (max_entries = 100, ttl_ms = 60000). Consider exposing these in the config for consistency.

Proposed fix
   -- Initialize cache
   local cache = require('sweep.cache')
   cache.setup({
-    max_entries = 100,
-    ttl_ms = 60000, -- 1 minute TTL
+    max_entries = cfg.cache and cfg.cache.max_entries or 100,
+    ttl_ms = cfg.cache and cfg.cache.ttl_ms or 60000,
   })

79-88: Minor redundancy: UI cleared twice.

completion.disable() already calls M.cancel() which invokes ui.clear(). The explicit ui.clear() on line 88 is redundant, though harmless.

Proposed fix
   -- Disable completion module (cancels pending requests)
   local completion = require('sweep.completion')
   completion.disable()

   -- Tear down autocmds and keymaps
   require('sweep.autocmds').teardown()
   require('sweep.keymaps').teardown()

-  -- Clear UI
-  require('sweep.ui').clear()
-


Sweep AI uses a **custom inference engine** rather than calling external APIs like OpenAI/Anthropic:

```
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifiers to fenced code blocks (MD040).
markdownlint flags multiple fenced blocks without a language tag; please annotate them (e.g., text, python, lua, diff).

✅ Example (apply similarly to other blocks)
-```
+```text
┌─────────────────────────────────────────────────────────────────┐
│                      JetBrains IDE                               │
...
-```
+```

Also applies to: 64-64, 104-104, 147-147, 158-158, 196-196, 222-222, 277-277, 290-290, 353-353

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

15-15: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@sweep-ai-autocomplete-analysis.md` at line 15, Add language identifiers to
all fenced code blocks flagged by markdownlint MD040; for each triple-backtick
block (e.g., the "JetBrains IDE" sample and the other fenced examples) replace
``` with a language tag like ```text, ```python, ```lua, or ```diff as
appropriate so the blocks are annotated; ensure each code fence in the document
is updated consistently to remove the MD040 warnings.

Comment on lines 97 to 135
--- Initialize the cache with options
---@param opts? table
function M.setup(opts)
opts = opts or {}
config = vim.tbl_deep_extend('force', defaults, opts)

-- Clear cache on setup
entries = {}
access_order = {}
stats = {
hits = 0,
misses = 0,
evictions = 0,
}
end

--- Store a value in the cache
---@param key string
---@param value any
function M.set(key, value)
if value == nil then
M.remove(key)
return
end

local existing = entries[key]
local current_time = now_ms()

if existing then
-- Update existing entry
existing.value = value
existing.created_at = current_time
existing.accessed_at = current_time
touch(key)
else
-- Check if we need to evict
while entry_count() >= config.max_entries do
evict_lru()
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent infinite loop when max_entries <= 0.
set() can spin forever if max_entries is 0 or negative. Clamp to a valid minimum or short‑circuit caching in that case.

🔧 Suggested fix
 function M.setup(opts)
   opts = opts or {}
   config = vim.tbl_deep_extend('force', defaults, opts)
+  if config.max_entries < 1 then
+    config.max_entries = 0
+  end
   -- Clear cache on setup
   entries = {}
   access_order = {}
   stats = {
@@
 function M.set(key, value)
+  if config.max_entries == 0 then
+    return
+  end
   if value == nil then
     M.remove(key)
     return
   end
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/cache.lua` around lines 97 - 135, The set() path can
loop infinitely when config.max_entries <= 0; update M.setup to ensure
config.max_entries is clamped to a sensible minimum (e.g., 1) or set a flag to
disable caching, and modify M.set to short‑circuit when config.max_entries <= 0
(or caching disabled) so it doesn't enter the while entry_count() >=
config.max_entries loop; reference M.setup to clamp config.max_entries and M.set
(and functions entry_count and evict_lru) to guard/return early when max_entries
is non‑positive.

Comment on lines +59 to +67
--- Cancel the debounce timer if active
local function cancel_debounce_timer()
if state.debounce_timer then
if state.debounce_timer:is_active() then
state.debounce_timer:stop()
end
state.debounce_timer = nil
end
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Timer handle not closed – potential resource leak.

In libuv (via vim.loop), timers should be closed with :close() after stopping to release the underlying handle. Repeatedly creating timers without closing them can leak resources over time.

Proposed fix
 local function cancel_debounce_timer()
   if state.debounce_timer then
     if state.debounce_timer:is_active() then
       state.debounce_timer:stop()
     end
+    state.debounce_timer:close()
     state.debounce_timer = nil
   end
 end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
--- Cancel the debounce timer if active
local function cancel_debounce_timer()
if state.debounce_timer then
if state.debounce_timer:is_active() then
state.debounce_timer:stop()
end
state.debounce_timer = nil
end
end
--- Cancel the debounce timer if active
local function cancel_debounce_timer()
if state.debounce_timer then
if state.debounce_timer:is_active() then
state.debounce_timer:stop()
end
state.debounce_timer:close()
state.debounce_timer = nil
end
end
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/completion.lua` around lines 59 - 67, The
cancel_debounce_timer function currently stops and nils state.debounce_timer but
doesn't close the libuv handle; update cancel_debounce_timer to call
state.debounce_timer:stop() (if active) and then state.debounce_timer:close()
before setting state.debounce_timer = nil so the underlying vim.loop timer
handle is released; ensure you check that state.debounce_timer has a :close
method (i.e., non-nil) and call close in all paths where the timer is discarded.

Comment on lines 250 to 251
state.current_handle = http.request({
endpoint = cfg.server.endpoint .. '/infill',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential double-slash in endpoint URL.

If cfg.server.endpoint ends with a trailing slash (e.g., http://localhost:8012/), concatenating /infill produces http://localhost:8012//infill. Consider normalizing the endpoint.

Proposed fix
+  -- Normalize endpoint (remove trailing slash if present)
+  local base_endpoint = cfg.server.endpoint:gsub('/$', '')
+
   -- Make HTTP request
   state.current_handle = http.request({
-    endpoint = cfg.server.endpoint .. '/infill',
+    endpoint = base_endpoint .. '/infill',
     body = request_body,
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/completion.lua` around lines 250 - 251, The code sets
state.current_handle via http.request by concatenating cfg.server.endpoint ..
'/infill', which can produce a double slash if cfg.server.endpoint ends with '/'
(e.g., "http://localhost:8012/"); update the URL construction used in the
http.request call to normalize the endpoint first (trim any trailing slash from
cfg.server.endpoint or use a join that ensures a single separator) before
appending '/infill' so the request URL is always valid; adjust the logic around
state.current_handle assignment and the http.request invocation accordingly.

Comment on lines +32 to +46
---@class SweepConfig
---@field auto_enable boolean Auto-enable on setup
---@field debounce_ms number Debounce delay in milliseconds
---@field keymaps SweepKeymaps
---@field context SweepContextConfig
---@field server SweepServerConfig
---@field ui SweepUIConfig
---@field filetypes_exclude string[] Filetypes to exclude

---@type SweepConfig
M.defaults = {
auto_enable = true,
debounce_ms = 100,
show_info = true,

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align SweepConfig schema with show_info usage.
There’s a top‑level show_info default but the SweepConfig annotation doesn’t include it, and ui.show_info exists too. Please consolidate or document the intended source of truth to avoid ambiguity.

💡 Minimal fix to keep annotations accurate
 ---@class SweepConfig
 ---@field auto_enable boolean Auto-enable on setup
 ---@field debounce_ms number Debounce delay in milliseconds
+---@field show_info boolean Show completion info (global)
 ---@field keymaps SweepKeymaps
 ---@field context SweepContextConfig
 ---@field server SweepServerConfig
 ---@field ui SweepUIConfig
 ---@field filetypes_exclude string[] Filetypes to exclude

Also applies to: 72-76

🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/config.lua` around lines 32 - 46, The SweepConfig
annotation and M.defaults disagree about where show_info lives; either add a
top-level show_info field to the SweepConfig typedef or move/remove the
top-level default and keep show_info under SweepUIConfig (and update M.defaults
accordingly). Locate the SweepConfig annotation and the M.defaults table
(symbols: SweepConfig, M.defaults) and ensure the schema and defaults match: if
you intend a top-level flag, add "@@field show_info boolean" to SweepConfig and
keep the key in M.defaults; if you intend it under ui, remove the top-level
show_info from M.defaults and ensure SweepUIConfig and its "@field show_info
boolean" exist and are documented (also update the duplicate instance at the
second occurrence around the other defaults noted in the comment).

Comment on lines 25 to 156
--- Make a completion request to llama.cpp server
---@param opts table Request options
---@param opts.endpoint string Server endpoint URL
---@param opts.body table Request body to be JSON encoded
---@param opts.timeout? number Request timeout in milliseconds (default: 5000)
---@param opts.on_success function Callback for successful response: function(response)
---@param opts.on_error function Callback for error: function(error_message)
---@return table|nil handle Request handle for cancellation, or nil on error
function M.request(opts)
if not opts or not opts.endpoint or not opts.body then
if opts and opts.on_error then
opts.on_error('Missing required options: endpoint and body')
end
return nil
end

local request_id = generate_request_id()
local timeout = opts.timeout or DEFAULT_TIMEOUT

-- Encode body as JSON
local body_json
local ok, result = pcall(vim.json.encode, opts.body)
if not ok then
if opts.on_error then
opts.on_error('Failed to encode request body as JSON: ' .. tostring(result))
end
return nil
end
body_json = result

-- Create the request entry before making the call
active_requests[request_id] = {
job = nil,
cancelled = false,
}

-- Make the async HTTP request
local job = curl.post({
url = opts.endpoint,
body = body_json,
headers = {
['Content-Type'] = 'application/json',
['Accept'] = 'application/json',
},
timeout = timeout,
callback = function(response)
-- Check if request was cancelled
local request = active_requests[request_id]
if not request or request.cancelled then
-- Request was cancelled, don't call callbacks
active_requests[request_id] = nil
return
end

-- Remove from active requests
active_requests[request_id] = nil

-- Handle response
if not response then
if opts.on_error then
vim.schedule(function()
opts.on_error('No response received from server')
end)
end
return
end

-- Check for connection errors (status 0 usually indicates connection failure)
if response.status == 0 then
local error_msg = 'Connection failed'
if response.exit then
if response.exit == 7 then
error_msg = 'Connection refused - is the server running?'
elseif response.exit == 28 then
error_msg = 'Request timed out'
else
error_msg = string.format('Connection error (exit code: %d)', response.exit)
end
end
if opts.on_error then
vim.schedule(function()
opts.on_error(error_msg)
end)
end
return
end

-- Check for HTTP error status
if response.status < 200 or response.status >= 300 then
if opts.on_error then
vim.schedule(function()
opts.on_error(string.format('HTTP error %d: %s', response.status, response.body or 'Unknown error'))
end)
end
return
end

-- Parse JSON response
local parse_ok, parsed = pcall(vim.json.decode, response.body)
if not parse_ok then
if opts.on_error then
vim.schedule(function()
opts.on_error('Failed to parse JSON response: ' .. tostring(parsed))
end)
end
return
end

-- Success!
if opts.on_success then
vim.schedule(function()
-- Double-check cancellation in case it happened during schedule
local req = active_requests[request_id]
if req and req.cancelled then
return
end
opts.on_success(parsed)
end)
end
end,
})

-- Store the job reference
if active_requests[request_id] then
active_requests[request_id].job = job
end

-- Return handle for cancellation
return {
id = request_id,
}
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Late cancellation can’t prevent scheduled callbacks.
The request entry is removed before vim.schedule, so a cancel() after the response but before the scheduled callback can’t suppress stale on_success. Keep the entry until the scheduled callback completes (or track completed and only finalize afterward).

🛠️ Proposed fix
-      -- Remove from active requests
-      active_requests[request_id] = nil
+      -- Keep entry until callbacks finish (allow late cancellation)
+      local function finalize()
+        active_requests[request_id] = nil
+      end
@@
-      if not response then
-        if opts.on_error then
-          vim.schedule(function()
-            opts.on_error('No response received from server')
-          end)
-        end
-        return
-      end
+      if not response then
+        if opts.on_error then
+          vim.schedule(function()
+            if request.cancelled then return finalize() end
+            opts.on_error('No response received from server')
+            finalize()
+          end)
+        else
+          finalize()
+        end
+        return
+      end
@@
-      if opts.on_success then
-        vim.schedule(function()
-          -- Double-check cancellation in case it happened during schedule
-          local req = active_requests[request_id]
-          if req and req.cancelled then
-            return
-          end
-          opts.on_success(parsed)
-        end)
-      end
+      if opts.on_success then
+        vim.schedule(function()
+          if request.cancelled then return finalize() end
+          opts.on_success(parsed)
+          finalize()
+        end)
+      else
+        finalize()
+      end
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/http.lua` around lines 25 - 156, The bug in M.request is
that active_requests[request_id] is removed before any vim.schedule callbacks
run, so a cancel() after response but before the scheduled on_success/on_error
cannot suppress callbacks; change the lifecycle so the request entry remains
until scheduled callbacks finish by adding and using a completed flag (e.g.,
active_requests[request_id] = { job = job, cancelled = false, completed = false
}), only mark completed = true and then remove the entry inside the vim.schedule
wrapper(s) after double-checking cancelled (in both on_success and on_error
paths), and ensure cancel() sets cancelled = true but does not remove the entry
until completed is true.

Comment on lines +30 to +121
--- Create an expr mapping callback for conditional keymaps
--- Returns empty string if action taken, otherwise returns the key for passthrough
---@param key string The key to pass through if completion not visible
---@param action function The action to take when completion is visible
---@return function
local function make_conditional_callback(key, action)
return function()
if get_ui().is_visible() then
action()
return ''
else
-- Pass through the original key
return vim.api.nvim_replace_termcodes(key, true, false, true)
end
end
end

--- Create a simple callback (no visibility check)
---@param action function The action to take
---@return function
local function make_callback(action)
return function()
action()
return ''
end
end

--- Set up keymaps on the current buffer
---@param bufnr? number Buffer number (defaults to current buffer)
local function setup_buffer_keymaps(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()

local cfg = get_config()
local km = cfg.keymaps

-- Track keymaps for this buffer
buffer_keymaps[bufnr] = buffer_keymaps[bufnr] or {}

-- Trigger keymap (no visibility check needed)
vim.keymap.set('i', km.trigger, make_callback(function()
get_completion().manual_trigger()
end), {
buffer = bufnr,
expr = true,
desc = 'Sweep: trigger completion',
silent = true,
})
table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.trigger })

-- Accept full keymap (only when visible)
vim.keymap.set('i', km.accept_full, make_conditional_callback(km.accept_full, function()
get_completion().accept_full()
end), {
buffer = bufnr,
expr = true,
desc = 'Sweep: accept full completion',
silent = true,
})
table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.accept_full })

-- Accept line keymap (only when visible)
vim.keymap.set('i', km.accept_line, make_conditional_callback(km.accept_line, function()
get_completion().accept_line()
end), {
buffer = bufnr,
expr = true,
desc = 'Sweep: accept first line',
silent = true,
})
table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.accept_line })

-- Accept word keymap (only when visible)
vim.keymap.set('i', km.accept_word, make_conditional_callback(km.accept_word, function()
get_completion().accept_word()
end), {
buffer = bufnr,
expr = true,
desc = 'Sweep: accept first word',
silent = true,
})
table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.accept_word })

-- Dismiss keymap (only when visible)
vim.keymap.set('i', km.dismiss, make_conditional_callback(km.dismiss, function()
get_completion().dismiss()
end), {
buffer = bufnr,
expr = true,
desc = 'Sweep: dismiss completion',
silent = true,
})
table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.dismiss })
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Neovim nvim_replace_termcodes expr mapping noremap behavior return value

💡 Result:

  • vim.api.nvim_replace_termcodes(str, from_part, do_lt, special) only converts key-notation like <CR>, <Esc>, etc. into Neovim’s internal keycode bytes and returns a string. It does not execute/“type” anything by itself. Use it only when you need the converted bytes (or use vim.keycode() instead). [1]
  • In an expr mapping, Neovim uses the return value of your Lua callback / Vimscript expression as the keys to be fed as if typed (so you typically just return '<C-u>zz'). vim.keymap.set(..., { expr = true }) does this behavior. [2]
  • For expr mappings created via Lua, vim.keymap.set() makes this easier because { replace_keycodes = true } is the default when expr = true, meaning returned strings like '<CR>' / '<C-u>' are automatically keycode-replaced (so you usually do not need nvim_replace_termcodes() yourself). [3]
  • noremap vs remap behavior: by default vim.keymap.set() is non-recursive (remap = false, i.e. like :noremap), so the produced {rhs} / returned keys are not further remapped. If you want the result to be remapped through other mappings, set { remap = true }. [2]
  • If you call nvim_replace_termcodes() inside a Lua mapping but don’t return it (for expr) and don’t send it with an input API, nothing will happen—conversion alone doesn’t feed keys. (That’s why return ... matters for expr, or otherwise you’d use an input/feed API.) [1]

Sources: Neovim API docs for nvim_replace_termcodes() return value/behavior [1]; Neovim Lua guide on expr mappings and remap default [2]; vim.keymap.set() docs on replace_keycodes default with expr [3].


🌐 Web query:

Neovim expr mapping remap vs noremap keycodes passthrough

💡 Result:

1) What an <expr> mapping actually does

With an <expr> mapping, Neovim evaluates an expression/callback and uses its resulting string as the keys to execute. Returning '' does nothing; returning "\<Ignore>" is “do nothing, but break out to the main loop”. Also, <expr> mappings are commonly used to “read one more key” (e.g. with getchar() / getcharstr()) and then return it. [1]

2) remap vs noremap (recursive vs non-recursive) for the returned keys

After your <expr> callback returns a string, Neovim feeds those keys back into the typeahead stream. Whether that returned key sequence is allowed to trigger other mappings depends on recursion:

  • Non-recursive (:noremap, or noremap=true) ⇒ returned keys won’t be remapped.
  • Recursive (:map, or remap=true) ⇒ returned keys can trigger mappings.

For Lua mappings:

  • vim.keymap.set(..., { remap = false }) is the default and is the inverse of noremap. [3]
  • For the lower-level API, nvim_set_keymap(..., { noremap = true }) disables recursion. [2]

3) “keycodes passthrough” = replace_keycodes

This is the piece that decides whether strings like "<CR>", "<Tab>", "<C-w>", etc. inside the returned value are treated as literal characters or are converted to real keycodes (via nvim_replace_termcodes()):

  • replace_keycodes = true"<CR>" becomes an actual Enter keycode.
  • replace_keycodes = false"<CR>" stays four characters: <, C, R, >.

Neovim documents this option specifically for expr maps. [2]
And vim.keymap.set() defaults replace_keycodes to true when expr = true. [3]

Practical combos

  • Return special keys like <CR> / <Esc> / <C-w> from an <expr> map:
    Use expr=true and keep the default replace_keycodes=true. [2][3]
  • Return a string that must stay literal (e.g. you’re generating text containing <...>):
    Use expr=true, replace_keycodes=false. [2][3]
  • Passthrough but still allow other mappings to apply to what you return:
    Use remap=true. [3]
  • Passthrough and guarantee “raw” behavior (don’t trigger other mappings):
    Use non-recursive (remap=false / noremap=true). [2][3]

Sources: Neovim :help map-expression (expr behavior, <Ignore>, getchar usage) [1], :help nvim_set_keymap() (defines noremap, replace_keycodes) [2], :help vim.keymap.set() (Lua remap and replace_keycodes defaults) [3].


Pass-through keymaps bypass existing mappings.

When completion is not visible, the callback returns termcodes with the default remap = false, which prevents the returned keys from triggering other mappings (e.g., snippets on <Tab>). To allow user mappings to take effect on passthrough, return the raw key and enable remap = true on the conditional mappings.

🐛 Proposed fix to preserve existing mappings
 local function make_conditional_callback(key, action)
   return function()
     if get_ui().is_visible() then
       action()
       return ''
     else
-      -- Pass through the original key
-      return vim.api.nvim_replace_termcodes(key, true, false, true)
+      -- Pass through the original key (allow remap)
+      return key
     end
   end
 end
@@
   vim.keymap.set('i', km.accept_full, make_conditional_callback(km.accept_full, function()
     get_completion().accept_full()
   end), {
     buffer = bufnr,
     expr = true,
+    remap = true,
     desc = 'Sweep: accept full completion',
     silent = true,
   })
@@
   vim.keymap.set('i', km.accept_line, make_conditional_callback(km.accept_line, function()
     get_completion().accept_line()
   end), {
     buffer = bufnr,
     expr = true,
+    remap = true,
     desc = 'Sweep: accept first line',
     silent = true,
   })
@@
   vim.keymap.set('i', km.accept_word, make_conditional_callback(km.accept_word, function()
     get_completion().accept_word()
   end), {
     buffer = bufnr,
     expr = true,
+    remap = true,
     desc = 'Sweep: accept first word',
     silent = true,
   })
@@
   vim.keymap.set('i', km.dismiss, make_conditional_callback(km.dismiss, function()
     get_completion().dismiss()
   end), {
     buffer = bufnr,
     expr = true,
+    remap = true,
     desc = 'Sweep: dismiss completion',
     silent = true,
   })
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/keymaps.lua` around lines 30 - 121, The conditional
callback currently returns termcodes via vim.api.nvim_replace_termcodes (in
make_conditional_callback), which disables user remapped mappings on
passthrough; change make_conditional_callback to return the raw key string (do
not call nvim_replace_termcodes) when completion is not visible, and update the
vim.keymap.set calls in setup_buffer_keymaps for the conditional mappings (those
using km.accept_full, km.accept_line, km.accept_word, km.dismiss) to include
remap = true in their options so returned keys honor existing user mappings and
snippets.

Comment on lines +72 to +79
--- Initialize the ring buffer with configuration
---@param opts? RingConfig
function M.setup(opts)
opts = opts or {}
config.max_chunks = opts.max_chunks or 16
config.chunk_size = opts.chunk_size or 64
M.clear()
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against max_chunks <= 0 (modulo-by-zero).
If a user sets max_chunks to 0 or negative, add/get will crash on modulo. Clamp to a safe minimum (and optionally do the same for chunk_size).

🔧 Suggested fix
 function M.setup(opts)
   opts = opts or {}
-  config.max_chunks = opts.max_chunks or 16
-  config.chunk_size = opts.chunk_size or 64
+  config.max_chunks = math.max(1, opts.max_chunks or 16)
+  config.chunk_size = math.max(1, opts.chunk_size or 64)
   M.clear()
 end
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/ring.lua` around lines 72 - 79, M.setup currently
assigns config.max_chunks and config.chunk_size directly from opts which can
allow zero or negative values causing modulo-by-zero in M.add/M.get; clamp
config.max_chunks to a safe minimum of 1 (and similarly clamp config.chunk_size
to at least 1) inside M.setup after reading opts, e.g. enforce config.max_chunks
= math.max(1, opts.max_chunks or 16) and config.chunk_size = math.max(1,
opts.chunk_size or 64), then call M.clear(); ensure any dependent logic in M.add
and M.get can rely on these invariants.

| `ui` | Ghost text rendering and info display |
| `keymaps` | Keymap setup and handlers |
| `autocmds` | Autocommand management |
| `edits` | Applies accepted completions to buffer |
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix inaccurate module description.

The edits module description says "Applies accepted completions to buffer", but based on the implementation, it actually tracks edit history for next-edit prediction context. The actual completion application likely happens in completion or ui modules.

📝 Proposed fix
-| `edits`      | Applies accepted completions to buffer       |
+| `edits`      | Tracks edit history for next-edit prediction |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| `edits` | Applies accepted completions to buffer |
| `edits` | Tracks edit history for next-edit prediction |
🤖 Prompt for AI Agents
In `@sweep.nvim/README.md` at line 136, Update the README entry for the edits
module: replace the inaccurate description "Applies accepted completions to
buffer" with a concise, accurate description such as "Tracks edit history and
provides context for next-edit prediction" (mentioning that actual completion
application is handled by modules like completion or ui); target the `edits`
module line in the README and ensure the wording distinguishes its role from
`completion` / `ui`.

Comment on lines +342 to +434
describe('cancel_all', function()
it('should cancel all pending requests', function()
local jobs = {}

mock_curl.post = function(opts)
mock_curl._last_opts = opts
local job = create_mock_job({ pid = #jobs + 1 })
table.insert(jobs, job)
return job
end

-- Make multiple requests
http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test1' },
on_success = function() end,
on_error = function() end,
})

http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test2' },
on_success = function() end,
on_error = function() end,
})

http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test3' },
on_success = function() end,
on_error = function() end,
})

assert.are.equal(3, #jobs)

-- Cancel all
http.cancel_all()

-- All jobs should be cancelled
for _, job in ipairs(jobs) do
assert.is_true(job._cancelled)
end
end)

it('should not call any callbacks after cancel_all', function()
local callbacks_triggered = 0

mock_curl.post = function(opts)
mock_curl._last_opts = opts
local job = create_mock_job()
local stored_callback = opts.callback
job._trigger_callback = function()
if stored_callback then
stored_callback({
status = 200,
body = vim.json.encode({ content = 'test' }),
})
end
end
return job
end

local handle1 = http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test1' },
on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
})

local job1 = mock_job

local handle2 = http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test2' },
on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
})

local job2 = mock_job

-- Cancel all
http.cancel_all()

-- Try to trigger callbacks
vim.schedule(function()
job1._trigger_callback()
job2._trigger_callback()
end)

vim.wait(100, function() return callbacks_triggered > 0 end, 10)

assert.are.equal(0, callbacks_triggered)
end)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix nil job handles in cancel_all callback test.

The overridden mock_curl.post never assigns mock_job, so job1/job2 can be nil and _trigger_callback() will error. Capture the created job (or set mock_job) before storing it.

🐛 Proposed fix
-      mock_curl.post = function(opts)
-        mock_curl._last_opts = opts
-        local job = create_mock_job()
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        local job = create_mock_job()
+        mock_job = job
         local stored_callback = opts.callback
         job._trigger_callback = function()
           if stored_callback then
             stored_callback({
               status = 200,
               body = vim.json.encode({ content = 'test' }),
             })
           end
         end
         return job
       end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('cancel_all', function()
it('should cancel all pending requests', function()
local jobs = {}
mock_curl.post = function(opts)
mock_curl._last_opts = opts
local job = create_mock_job({ pid = #jobs + 1 })
table.insert(jobs, job)
return job
end
-- Make multiple requests
http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test1' },
on_success = function() end,
on_error = function() end,
})
http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test2' },
on_success = function() end,
on_error = function() end,
})
http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test3' },
on_success = function() end,
on_error = function() end,
})
assert.are.equal(3, #jobs)
-- Cancel all
http.cancel_all()
-- All jobs should be cancelled
for _, job in ipairs(jobs) do
assert.is_true(job._cancelled)
end
end)
it('should not call any callbacks after cancel_all', function()
local callbacks_triggered = 0
mock_curl.post = function(opts)
mock_curl._last_opts = opts
local job = create_mock_job()
local stored_callback = opts.callback
job._trigger_callback = function()
if stored_callback then
stored_callback({
status = 200,
body = vim.json.encode({ content = 'test' }),
})
end
end
return job
end
local handle1 = http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test1' },
on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
})
local job1 = mock_job
local handle2 = http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test2' },
on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
})
local job2 = mock_job
-- Cancel all
http.cancel_all()
-- Try to trigger callbacks
vim.schedule(function()
job1._trigger_callback()
job2._trigger_callback()
end)
vim.wait(100, function() return callbacks_triggered > 0 end, 10)
assert.are.equal(0, callbacks_triggered)
end)
describe('cancel_all', function()
it('should cancel all pending requests', function()
local jobs = {}
mock_curl.post = function(opts)
mock_curl._last_opts = opts
local job = create_mock_job({ pid = `#jobs` + 1 })
table.insert(jobs, job)
return job
end
-- Make multiple requests
http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test1' },
on_success = function() end,
on_error = function() end,
})
http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test2' },
on_success = function() end,
on_error = function() end,
})
http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test3' },
on_success = function() end,
on_error = function() end,
})
assert.are.equal(3, `#jobs`)
-- Cancel all
http.cancel_all()
-- All jobs should be cancelled
for _, job in ipairs(jobs) do
assert.is_true(job._cancelled)
end
end)
it('should not call any callbacks after cancel_all', function()
local callbacks_triggered = 0
mock_curl.post = function(opts)
mock_curl._last_opts = opts
local job = create_mock_job()
mock_job = job
local stored_callback = opts.callback
job._trigger_callback = function()
if stored_callback then
stored_callback({
status = 200,
body = vim.json.encode({ content = 'test' }),
})
end
end
return job
end
local handle1 = http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test1' },
on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
})
local job1 = mock_job
local handle2 = http.request({
endpoint = 'http://localhost:8080/completion',
body = { prompt = 'test2' },
on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
})
local job2 = mock_job
-- Cancel all
http.cancel_all()
-- Try to trigger callbacks
vim.schedule(function()
job1._trigger_callback()
job2._trigger_callback()
end)
vim.wait(100, function() return callbacks_triggered > 0 end, 10)
assert.are.equal(0, callbacks_triggered)
end)
🤖 Prompt for AI Agents
In `@sweep.nvim/tests/sweep/http_spec.lua` around lines 342 - 434, The test's
mock_curl.post creates a local job but never assigns it to the global mock_job,
so job1/job2 become nil and calling _trigger_callback fails; modify the
mock_curl.post in the second spec to capture the created job (e.g., local job =
create_mock_job() or create_mock_job({...}) ) and either assign mock_job = job
or return that same job and set local job1/job2 = mock_job after calling
http.request; specifically update the mock_curl.post closure used in the "should
not call any callbacks after cancel_all" test to set mock_job (or return and
store the created job) before wiring stored_callback so job1._trigger_callback
and job2._trigger_callback are valid.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@sweep.nvim/lua/sweep/completion.lua`:
- Around line 248-258: The callbacks passed to the HTTP request are being
wrapped in vim.schedule twice, causing extra latency; remove the outer
vim.schedule wrappers in the on_success and on_error handlers so they call
on_completion_success(response, cache_key) and on_completion_error(err) directly
(leave the existing on_completion_success, on_completion_error and cache_key
references intact) because http.lua already schedules those callbacks.
♻️ Duplicate comments (5)
sweep.nvim/lua/sweep/http.lua (1)

79-80: Race condition: request entry removed before callbacks complete.

This is the same issue flagged in a previous review. The request entry is removed at line 80 before vim.schedule callbacks execute, so a cancel() call after the response arrives but before the scheduled callback runs cannot suppress stale on_success/on_error invocations.

sweep.nvim/lua/sweep/completion.lua (2)

59-67: Timer handle not closed — potential resource leak.

This is the same issue flagged in a previous review. In libuv, timers should be closed with :close() after stopping to release the underlying handle.


244-245: Potential double-slash in endpoint URL.

This is the same issue flagged in a previous review. If cfg.server.endpoint ends with a trailing slash, the concatenation produces a double-slash URL.

sweep.nvim/lua/sweep/context.lua (2)

175-183: Scope detection hardcodes column 0.

This is the same issue flagged in a previous review. The function signature doesn't accept a col parameter and hardcodes pos = { row, 0 }. On indented lines, column 0 may resolve to an outer scope node rather than the actual scope at the cursor.


404-416: Hover contents array handling missing.

This is the same issue flagged in a previous review. Per the LSP specification, textDocument/hover response contents can be MarkedString[] (an array/list), but the current code only handles single strings and tables with .value properties.

🧹 Nitpick comments (3)
sweep.nvim/lua/sweep/parser.lua (1)

110-117: Consider using vim.list_slice for cleaner line limiting.

The manual loop for limiting lines can be simplified using Neovim's built-in utility.

Optional simplification
   -- Apply max_lines limit
   if max_lines and max_lines > 0 and `#lines` > max_lines then
-    local limited_lines = {}
-    for i = 1, max_lines do
-      table.insert(limited_lines, lines[i])
-    end
-    lines = limited_lines
+    lines = vim.list_slice(lines, 1, max_lines)
     content = table.concat(lines, '\n')
   end
sweep.nvim/lua/sweep/http.lua (1)

199-205: Consider O(1) active count tracking.

The current implementation iterates over all entries. If request volume is low this is fine, but for consistency with the commit message mentioning "entry counter to cache for O(1) count operations," you could maintain an active_count variable incremented/decremented on add/remove.

sweep.nvim/lua/sweep/context.lua (1)

442-442: Line counting pattern is clever but may confuse readers.

select(2, str:gsub('\n', '\n')) returns the replacement count (number of newlines). Consider extracting this to a named helper for clarity.

Optional: Extract line count helper
-- Add near top of file
local function count_lines(str)
  local _, newlines = str:gsub('\n', '\n')
  return newlines + 1
end

-- Then use: local scope_lines = count_lines(ctx.scope.content)

Comment on lines 248 to 258
on_success = function(response)
vim.schedule(function()
on_completion_success(response, cache_key)
end)
end,
on_error = function(err)
vim.schedule(function()
on_completion_error(err)
end)
end,
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Double vim.schedule wrapping adds unnecessary latency.

The http.lua module already wraps on_success and on_error callbacks in vim.schedule (see lines 85, 105, 115, 126, 135-137 in http.lua). Wrapping them again here is redundant and delays callback execution by an extra event loop cycle.

Proposed fix
     on_success = function(response)
-      vim.schedule(function()
-        on_completion_success(response, cache_key)
-      end)
+      on_completion_success(response, cache_key)
     end,
     on_error = function(err)
-      vim.schedule(function()
-        on_completion_error(err)
-      end)
+      on_completion_error(err)
     end,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
on_success = function(response)
vim.schedule(function()
on_completion_success(response, cache_key)
end)
end,
on_error = function(err)
vim.schedule(function()
on_completion_error(err)
end)
end,
})
on_success = function(response)
on_completion_success(response, cache_key)
end,
on_error = function(err)
on_completion_error(err)
end,
})
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/completion.lua` around lines 248 - 258, The callbacks
passed to the HTTP request are being wrapped in vim.schedule twice, causing
extra latency; remove the outer vim.schedule wrappers in the on_success and
on_error handlers so they call on_completion_success(response, cache_key) and
on_completion_error(err) directly (leave the existing on_completion_success,
on_completion_error and cache_key references intact) because http.lua already
schedules those callbacks.

Comment on lines +8 to +23
local function split_lines(str)
if str == '' then
return { '' }
end
local lines = {}
for line in str:gmatch('([^\n]*)\n?') do
if line ~= '' or #lines == 0 then
table.insert(lines, line)
end
end
-- Remove trailing empty string from gmatch if present
if #lines > 1 and lines[#lines] == '' then
table.remove(lines)
end
return lines
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

split_lines may drop legitimate empty lines.

The condition on line 14 skips empty lines after the first, which means multi-line content like "foo\n\nbar" would lose the middle blank line, producing {"foo", "bar"} instead of {"foo", "", "bar"}. If preserving blank lines matters for completion display, this logic needs adjustment.

Proposed fix to preserve interior empty lines
 local function split_lines(str)
   if str == '' then
     return { '' }
   end
   local lines = {}
   for line in str:gmatch('([^\n]*)\n?') do
-    if line ~= '' or `#lines` == 0 then
-      table.insert(lines, line)
-    end
+    table.insert(lines, line)
   end
   -- Remove trailing empty string from gmatch if present
   if `#lines` > 1 and lines[`#lines`] == '' then
     table.remove(lines)
   end
   return lines
 end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
local function split_lines(str)
if str == '' then
return { '' }
end
local lines = {}
for line in str:gmatch('([^\n]*)\n?') do
if line ~= '' or #lines == 0 then
table.insert(lines, line)
end
end
-- Remove trailing empty string from gmatch if present
if #lines > 1 and lines[#lines] == '' then
table.remove(lines)
end
return lines
end
local function split_lines(str)
if str == '' then
return { '' }
end
local lines = {}
for line in str:gmatch('([^\n]*)\n?') do
table.insert(lines, line)
end
-- Remove trailing empty string from gmatch if present
if `#lines` > 1 and lines[`#lines`] == '' then
table.remove(lines)
end
return lines
end

- Fix 4: Attach edit tracking to buffers on BufEnter (autocmds.lua)
  - Skip special buffers and excluded filetypes
  - edits.attach() now called for normal file buffers

- Fix 1: Wire edit tracking into completion flow (completion.lua)
  - Add edits module to lazy-loaded dependencies
  - Include edits.get_context() in prompt (first, as most relevant)

- Fix 3: Implement completion deduplication (completion.lua)
  - Add deduplicate_completion() helper
  - Removes text that already exists after cursor
  - Prevents suggesting duplicate content

- Fix 2: Fix LSP context async/sync mismatch (completion.lua)
  - Refactor make_request() to use context.get_async() when LSP enabled
  - Extract send_completion_request() helper for cleaner flow
  - Extract add_sync_context() for ring buffer and edit context
  - LSP definitions and type_info now properly included in prompts
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@sweep.nvim/lua/sweep/autocmds.lua`:
- Around line 36-57: The add_visible_to_ring function can capture the wrong
window's visible range on BufLeave; modify add_visible_to_ring to accept the
target bufnr (and optional winid), avoid using the implicit current window, and
compute the visible range for a window actually showing that buffer (use
vim.fn.win_findbuf(bufnr) to get windows and call vim.fn.line('w0', winid) /
vim.fn.line('w$', winid) for that window); then use vim.api.nvim_buf_get_lines
with the computed first/last and call ring.add(...) as before; ensure the
BufLeave autocmd passes the buffer (and preferably the winid) or invoke
add_visible_to_ring earlier (before the window switches) so the captured lines
come from the correct window/buffer.
- Around line 80-92: on_buf_leave currently takes bufnr but calls
add_visible_to_ring which uses nvim_get_current_buf() and can capture the wrong
buffer; change add_visible_to_ring to accept an optional bufnr parameter (use
the provided bufnr when present, otherwise fall back to nvim_get_current_buf()),
then update the caller on_buf_leave to call add_visible_to_ring(bufnr). Make
sure the add_visible_to_ring implementation and any other callers handle a nil
bufnr gracefully and continue to default to the current buffer.

In `@sweep.nvim/lua/sweep/completion.lua`:
- Around line 399-404: The debounce timer created in state.debounce_timer is
never closed when it fires; update the logic so the timer is closed in both
cancellation and successful fire. Modify cancel_debounce_timer() to stop and
then close state.debounce_timer (ensuring nil assignment after closing), and in
the vim.schedule_wrap callback (where make_request() is called) call
state.debounce_timer:close() (or reuse cancel_debounce_timer() after ensuring it
closes rather than only stopping) so the handle is always closed and cleaned up
after firing; reference symbols: state.debounce_timer, cancel_debounce_timer(),
cfg.debounce_ms, make_request().
- Around line 171-182: The async callback uses a captured bufnr and cursor
(row/col) without validating the buffer is still valid/current; before calling
deduplicate_completion(result.lines, bufnr, row, col) or ui.clear(), check
vim.api.nvim_buf_is_valid(bufnr) and that vim.api.nvim_get_current_buf() ==
bufnr (or otherwise bail), and if invalid/changed, call ui.clear() and return to
avoid operating on a stale buffer.
🧹 Nitpick comments (3)
sweep.nvim/lua/sweep/completion.lua (3)

102-128: Deduplication only handles exact prefix matches.

The current logic only removes duplicates when the completion starts exactly with text_after_cursor. It won't handle partial overlaps (e.g., completion "function" when "func" exists after cursor). This is a minor limitation that may result in redundant suggestions in edge cases.


264-275: Comment contradicts code ordering.

The comment on line 271 states edit context "goes first in the prompt" but the code prepends ring_context first (line 267), then prepends edit_context (line 274). Since prepending reverses order, edit context does end up first—but the logic is confusing. Consider reordering for clarity.

Proposed fix for clarity
 local function add_sync_context(prefix)
   local full_prefix = prefix
 
-  -- Add ring buffer context
-  local ring_context = ring.get_context()
-  if ring_context and ring_context ~= '' then
-    full_prefix = ring_context .. '\n\n' .. full_prefix
-  end
-
   -- Add edit tracking context (Sweep's next-edit format)
   -- This goes first in the prompt as it's most relevant for predicting next edits
   local edit_context = edits.get_context()
   if edit_context and edit_context ~= '' then
     full_prefix = edit_context .. '\n\n' .. full_prefix
   end
 
+  -- Add ring buffer context (after edit context)
+  local ring_context = ring.get_context()
+  if ring_context and ring_context ~= '' then
+    full_prefix = ring_context .. '\n\n' .. full_prefix
+  end
+
   return full_prefix
 end

341-360: Async LSP callback may proceed with stale context.

The callback at line 341 only checks state.enabled but doesn't verify that the buffer or cursor position hasn't changed while waiting for LSP. If the user continues typing, this request may complete with outdated base_prefix, input_suffix, and cache_key, potentially showing irrelevant completions.

Consider capturing the cursor position/buffer at request time and comparing in the callback, or incrementing a request generation counter to detect staleness.

Comment on lines +36 to +57
local function add_visible_to_ring(source)
local ring = require('sweep.ring')
local bufnr = vim.api.nvim_get_current_buf()
local filename = vim.api.nvim_buf_get_name(bufnr)
local filetype = vim.bo[bufnr].filetype

-- Get visible line range
local first = vim.fn.line('w0')
local last = vim.fn.line('w$')

-- Get lines (nvim_buf_get_lines is 0-indexed)
local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, first - 1, last, false)
if not ok or #lines == 0 then
return
end

ring.add({
content = table.concat(lines, '\n'),
filename = filename,
filetype = filetype,
source = source,
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Window/buffer mismatch possible on BufLeave.

vim.fn.line('w0') and vim.fn.line('w$') return the visible range of the current window, not necessarily the window showing the buffer being left. On BufLeave, the window may have already switched context, causing add_visible_to_ring to capture content from the wrong buffer or incorrect line ranges.

Consider passing bufnr to the function and using buffer-local methods, or calling this function before the buffer switch completes.

🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/autocmds.lua` around lines 36 - 57, The
add_visible_to_ring function can capture the wrong window's visible range on
BufLeave; modify add_visible_to_ring to accept the target bufnr (and optional
winid), avoid using the implicit current window, and compute the visible range
for a window actually showing that buffer (use vim.fn.win_findbuf(bufnr) to get
windows and call vim.fn.line('w0', winid) / vim.fn.line('w$', winid) for that
window); then use vim.api.nvim_buf_get_lines with the computed first/last and
call ring.add(...) as before; ensure the BufLeave autocmd passes the buffer (and
preferably the winid) or invoke add_visible_to_ring earlier (before the window
switches) so the captured lines come from the correct window/buffer.

Comment on lines +80 to +92
--- Handle BufLeave event - cancel completion and add to ring
---@param bufnr number
local function on_buf_leave(bufnr)
local completion = require('sweep.completion')
local ui = require('sweep.ui')

-- Cancel any pending completion
completion.cancel()
ui.clear()

-- Add visible content to ring buffer
add_visible_to_ring('buffer_leave')
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused bufnr parameter — may capture wrong buffer content.

on_buf_leave receives bufnr (the buffer being left) but add_visible_to_ring calls nvim_get_current_buf() internally, which might already be the new buffer. This could cause the wrong buffer's content to be added to the ring.

Proposed fix

Modify add_visible_to_ring to accept an optional bufnr parameter:

-local function add_visible_to_ring(source)
-  local ring = require('sweep.ring')
-  local bufnr = vim.api.nvim_get_current_buf()
+local function add_visible_to_ring(source, bufnr)
+  local ring = require('sweep.ring')
+  bufnr = bufnr or vim.api.nvim_get_current_buf()

Then pass it from the caller:

-  add_visible_to_ring('buffer_leave')
+  add_visible_to_ring('buffer_leave', bufnr)
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/autocmds.lua` around lines 80 - 92, on_buf_leave
currently takes bufnr but calls add_visible_to_ring which uses
nvim_get_current_buf() and can capture the wrong buffer; change
add_visible_to_ring to accept an optional bufnr parameter (use the provided
bufnr when present, otherwise fall back to nvim_get_current_buf()), then update
the caller on_buf_leave to call add_visible_to_ring(bufnr). Make sure the
add_visible_to_ring implementation and any other callers handle a nil bufnr
gracefully and continue to default to the current buffer.

Comment on lines +171 to +182
-- Get current cursor position
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1] - 1 -- Convert to 0-indexed
local col = cursor[2]
local bufnr = vim.api.nvim_get_current_buf()

-- Deduplicate: remove text that already exists after cursor
local display_lines = deduplicate_completion(result.lines, bufnr, row, col)
if not display_lines or #display_lines == 0 then
ui.clear()
return
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate buffer still exists before using it in async callback.

The callback fires asynchronously after the HTTP request completes. By that time, the buffer may have been closed or the user may have switched to a different buffer. Using a stale bufnr could cause errors or display completions in the wrong buffer.

Proposed fix
   -- Get current cursor position
   local cursor = vim.api.nvim_win_get_cursor(0)
   local row = cursor[1] - 1 -- Convert to 0-indexed
   local col = cursor[2]
   local bufnr = vim.api.nvim_get_current_buf()
 
+  -- Validate buffer is still valid
+  if not vim.api.nvim_buf_is_valid(bufnr) then
+    return
+  end
+
   -- Deduplicate: remove text that already exists after cursor
🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/completion.lua` around lines 171 - 182, The async
callback uses a captured bufnr and cursor (row/col) without validating the
buffer is still valid/current; before calling
deduplicate_completion(result.lines, bufnr, row, col) or ui.clear(), check
vim.api.nvim_buf_is_valid(bufnr) and that vim.api.nvim_get_current_buf() ==
bufnr (or otherwise bail), and if invalid/changed, call ui.clear() and return to
avoid operating on a stale buffer.

Comment on lines +399 to +404
-- Create new debounce timer
state.debounce_timer = vim.loop.new_timer()
state.debounce_timer:start(cfg.debounce_ms, 0, vim.schedule_wrap(function()
cancel_debounce_timer()
make_request()
end))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Timer not closed after successful fire.

When the debounce timer fires and executes the callback, cancel_debounce_timer() is called but the timer is no longer active, so only close() would be needed. However, as noted in lines 61-69, close() is missing entirely. Ensure the timer is properly closed both when cancelled and when it fires successfully.

🤖 Prompt for AI Agents
In `@sweep.nvim/lua/sweep/completion.lua` around lines 399 - 404, The debounce
timer created in state.debounce_timer is never closed when it fires; update the
logic so the timer is closed in both cancellation and successful fire. Modify
cancel_debounce_timer() to stop and then close state.debounce_timer (ensuring
nil assignment after closing), and in the vim.schedule_wrap callback (where
make_request() is called) call state.debounce_timer:close() (or reuse
cancel_debounce_timer() after ensuring it closes rather than only stopping) so
the handle is always closed and cleaned up after firing; reference symbols:
state.debounce_timer, cancel_debounce_timer(), cfg.debounce_ms, make_request().

@Kabilan108 Kabilan108 closed this Jan 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants