diff --git a/README.md b/README.md index 45f4c9f..615a087 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # swank.nvim [![CI](https://github.com/corigne/swank.nvim/actions/workflows/ci.yml/badge.svg)](https://github.com/corigne/swank.nvim/actions/workflows/ci.yml) -[![Coverage](https://img.shields.io/badge/coverage-82%25-green?style=flat-square&logo=lua)](https://github.com/corigne/swank.nvim/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/coverage-91%25-green?style=flat-square&logo=lua)](https://github.com/corigne/swank.nvim/actions/workflows/ci.yml) A modern, pure-Lua Common Lisp development environment for Neovim, built on the [Swank](https://github.com/slime/slime/blob/master/swank/backend.lisp) protocol. diff --git a/TODOs.md b/TODOs.md index 46651a1..fca2d4a 100644 --- a/TODOs.md +++ b/TODOs.md @@ -3,63 +3,16 @@ Items here are intentionally deferred past 1.0. They are good ideas but out of scope for the initial release. ---- - -## Arg completion hints (snippet-style) - -When a completion item is accepted, insert a snippet or virtual-text hint -showing the lambda list for the selected symbol — similar to what LSP -servers provide for function signatures. - -**Desired UX:** accepting `(mapcar` expands to `(mapcar function list)` with -the arguments as tab-stops (if a snippet engine is available) or as -dismissible virtual text (as a fallback). - -**Approach:** -- On `CompletionItemAccepted` (or equivalent blink/nvim-cmp callback), - fire `swank:operator-arglist` for the accepted symbol. -- If a snippet engine is active (LuaSnip, nvim-snippy, blink native), - build a snippet string from the arglist and expand it. -- If no snippet engine is present, render the arglist as extmark virtual - text to the right of the cursor; clear it on the next insert or ``. -- The `item.labelDetails.description` field can carry a short arglist - preview in the completion menu itself (no engine required). - -**Blockers / considerations:** -- `swank:operator-arglist` is async; need to handle the race between - acceptance and the RPC round-trip gracefully. -- Snippet engine detection should be soft (`pcall`) — never a hard - dependency. -- blink.cmp and nvim-cmp have different post-accept hook APIs; needs - separate integration paths. - ---- - -## Live diagnostics - -Show compiler errors/warnings inline (like LSP diagnostics) without -requiring an explicit eval. - -**Note:** Swank has no push-based lint protocol. SBCL only reports -conditions when code is compiled or evaluated. True live diagnostics would -require periodic background compilation of the buffer, which is expensive -and changes semantics. This is a hard problem; consider only after -evaluating whether background `compile-string` is acceptable. - ---- - -## omnifunc fallback - -Implement `vim.bo.omnifunc` using `swank:completions` so that any -completion plugin honouring `omnifunc` gets basic symbol completion -without requiring a dedicated source. - -**Note:** `omnifunc` is synchronous; the RPC must complete before the -callback returns. Use `vim.wait` or a coroutine-based approach. Document -clearly that this blocks the event loop briefly. +## Tests to add (coverage) +- Add unit tests covering transport._feed error branches, transport send/read error paths, and client._on_connect follow-ups to raise coverage >80 --- - -## Tests to add (coverage) -- Add unit tests covering transport._feed error branches, transport send/read error paths, and client._on_connect follow-ups to raise coverage >80 \ No newline at end of file +## Investigate Sextant LSP as an alternative backend over SWANK. +- Discuss whether it make sense to create an alternative or even supplementary backend to SWANK using standard LSP implementations, targeting sextant, see: https://github.com/parenworks/sextant +- - Consider the implications for existing SWANK-specific features parity with SLIME, and what our plugin must still cover to bridge the gap. +- - Evaluate the performance and feature parity of Sextant compared to SWANK, especially in areas like completions, arg hints, and diagnostics. +- - Consider the limits of Sextant's nvim plugin compared to swank.nvim. +- - Make sure we can even connect to the running SWANK server in the sextant sbcl process, and if so, whether we can use it to support features that are not provided by the LSP. +- - Investigate the nvim plugin for Sextant as a possible reference implementation for the LSP backend, see if any code can be shared between the two implementations. See: https://github.com/parenworks/sextant.nvim +- - If all is reasonable, adopt sextant, create a plan to refactor the plugin such that the currently mocked LSP capabilities are maintained as a fallback when LSP is unavailable, but otherwise treat LSP capabilities as first-class; nvim-lspconfig should be respected. diff --git a/luacov.report.out b/luacov.report.out index 0cdff45..37f9495 100644 --- a/luacov.report.out +++ b/luacov.report.out @@ -4,37 +4,37 @@ -- swank.nvim — high-level Swank client -- Manages connection lifecycle, emacs-rex call/response, and event routing. - 3 local M = {} + 15 local M = {} - 3 local transport_mod = require("swank.transport") - 3 local protocol = require("swank.protocol") + 15 local transport_mod = require("swank.transport") + 15 local protocol = require("swank.protocol") ---@type SwankTransport|nil - 3 local transport = nil + 15 local transport = nil ---@type "disconnected"|"connecting"|"connected" - 3 local connection_state = "disconnected" + 15 local connection_state = "disconnected" ---@type integer|nil jobstart job id for the CL implementation process - 3 local impl_job_id = nil + 15 local impl_job_id = nil ---@type string[] stderr lines collected during implementation startup; shown only on error exit - 3 local stderr_log = {} + 15 local stderr_log = {} ---@type integer monotonically increasing message ID - 3 local msg_id = 0 + 15 local msg_id = 0 ---@type table pending RPC callbacks - 3 local callbacks = {} + 15 local callbacks = {} ---@type integer count of in-flight "silent" rex calls; suppresses :write-string while > 0 - 3 local silent_count = 0 + 15 local silent_count = 0 ---@type string current package context - 3 local current_package = "COMMON-LISP-USER" + 15 local current_package = "COMMON-LISP-USER" local function next_id() - 40 msg_id = msg_id + 1 - 40 return msg_id + 45 msg_id = msg_id + 1 + 45 return msg_id end -- --------------------------------------------------------------------------- @@ -44,38 +44,38 @@ --- Connect to a running Swank server ---@param host string|nil defaults to config ---@param port integer|nil defaults to config - 3 function M.connect(host, port) - 3 if connection_state ~= "disconnected" then + 15 function M.connect(host, port) + 5 if connection_state ~= "disconnected" then 1 vim.notify("swank.nvim: already connected or connecting", vim.log.levels.WARN) 1 return end - 2 local cfg = require("swank").config - 2 host = host or cfg.server.host - 2 port = port or cfg.server.port - 2 connection_state = "connecting" + 4 local cfg = require("swank").config + 4 host = host or cfg.server.host + 4 port = port or cfg.server.port + 4 connection_state = "connecting" - 4 transport = transport_mod.Transport.new( + 8 transport = transport_mod.Transport.new( function(raw) -- on_message -****0 local msg = protocol.parse(raw) -****0 if msg then protocol.dispatch(msg) end + 2 local msg = protocol.parse(raw) + 2 if msg then protocol.dispatch(msg) end end, function() -- on_disconnect -****0 transport = nil -****0 connection_state = "disconnected" -****0 vim.notify("swank.nvim: disconnected", vim.log.levels.WARN) + 1 transport = nil + 1 connection_state = "disconnected" + 1 vim.notify("swank.nvim: disconnected", vim.log.levels.WARN) end - 2 ) + 4 ) - 4 transport:connect(host, port, function(err) - 2 if err then + 8 transport:connect(host, port, function(err) + 4 if err then 1 vim.notify("swank.nvim: connection failed — " .. err, vim.log.levels.ERROR) 1 transport = nil 1 connection_state = "disconnected" 1 return end - 1 connection_state = "connected" - 1 vim.notify("swank.nvim: connected to " .. host .. ":" .. port, vim.log.levels.INFO) - 1 M._on_connect() + 3 connection_state = "connected" + 3 vim.notify("swank.nvim: connected to " .. host .. ":" .. port, vim.log.levels.INFO) + 3 M._on_connect() end) end @@ -83,25 +83,25 @@ --- The flags suppress banners/interactivity and load a file. --- Unknown implementations fall back to SBCL-style flags. ---@type table - 3 local impl_cli_flags = { - 3 sbcl = { "--noinform", "--non-interactive", "--load" }, - 3 ccl = { "--quiet", "--batch", "--load" }, - 3 ecl = { "--norc", "--load" }, - 3 abcl = { "--batch", "--load" }, + 15 local impl_cli_flags = { + 15 sbcl = { "--noinform", "--non-interactive", "--load" }, + 15 ccl = { "--quiet", "--batch", "--load" }, + 15 ecl = { "--norc", "--load" }, + 15 abcl = { "--batch", "--load" }, } --- Spawn the configured CL implementation with Swank, detect port from file, then connect - 3 function M.start_and_connect() - 9 if connection_state ~= "disconnected" then return end + 15 function M.start_and_connect() + 14 if connection_state ~= "disconnected" then return end - 16 local cache_dir = vim.fn.stdpath("cache") .. "/swank.nvim" - 9 vim.fn.mkdir(cache_dir, "p") - 8 local port_file = cache_dir .. "/swank-port" - 8 local script_file = cache_dir .. "/start-swank.lisp" - 9 vim.fn.delete(port_file) + 26 local cache_dir = vim.fn.stdpath("cache") .. "/swank.nvim" + 19 vim.fn.mkdir(cache_dir, "p") + 13 local port_file = cache_dir .. "/swank-port" + 13 local script_file = cache_dir .. "/start-swank.lisp" + 19 vim.fn.delete(port_file) - 8 local escaped = port_file:gsub("\\", "\\\\"):gsub('"', '\\"') - 16 local script = string.format([[ + 13 local escaped = port_file:gsub("\\", "\\\\"):gsub('"', '\\"') + 26 local script = string.format([[ (require :asdf) #-quicklisp (let ((qs (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))) @@ -117,30 +117,30 @@ (with-open-file (s (pathname "%s") :direction :output :if-exists :supersede) (format s "~d" port)) (loop (sleep 60))) - 8 ]], escaped) + 13 ]], escaped) - 8 local f = io.open(script_file, "w") - 8 if not f then + 13 local f = io.open(script_file, "w") + 13 if not f then ****0 vim.notify("swank.nvim: cannot write startup script", vim.log.levels.ERROR) ****0 return end - 8 f:write(script) - 8 f:close() + 13 f:write(script) + 13 f:close() - 8 local impl = require("swank").config.autostart.implementation + 13 local impl = require("swank").config.autostart.implementation -- Build argv: binary + quiet/batch flags + "--load" + script - 16 local impl_name = vim.fn.fnamemodify(impl, ":t"):lower() - 8 local flags = impl_cli_flags[impl_name] or impl_cli_flags.sbcl + 26 local impl_name = vim.fn.fnamemodify(impl, ":t"):lower() + 13 local flags = impl_cli_flags[impl_name] or impl_cli_flags.sbcl -- flags ends with "--load"; append the script path - 8 local argv = { impl } - 30 for _, flag in ipairs(flags) do table.insert(argv, flag) end - 8 table.insert(argv, script_file) + 13 local argv = { impl } + 50 for _, flag in ipairs(flags) do table.insert(argv, flag) end + 13 table.insert(argv, script_file) - 8 connection_state = "connecting" - 8 vim.notify("swank.nvim: starting " .. impl .. "…", vim.log.levels.INFO) + 13 connection_state = "connecting" + 13 vim.notify("swank.nvim: starting " .. impl .. "…", vim.log.levels.INFO) - 16 impl_job_id = vim.fn.jobstart( - 8 argv, + 26 impl_job_id = vim.fn.jobstart( + 13 argv, { on_stderr = function(_, data) ****0 for _, line in ipairs(data) do @@ -162,62 +162,62 @@ ****0 stderr_log = {} end, } - 7 ) + 12 ) - 7 if impl_job_id <= 0 then - 7 connection_state = "disconnected" - 7 vim.notify("swank.nvim: failed to start " .. impl, vim.log.levels.ERROR) - 7 return + 12 if impl_job_id <= 0 then + 8 connection_state = "disconnected" + 8 vim.notify("swank.nvim: failed to start " .. impl, vim.log.levels.ERROR) + 8 return end -- Poll for port file (500ms × 60 = 30s timeout) -****0 local attempts = 0 -****0 local timer = vim.uv.new_timer() -****0 timer:start(500, 500, vim.schedule_wrap(function() -****0 attempts = attempts + 1 -****0 local pf = io.open(port_file, "r") -****0 if pf then -****0 local port_str = pf:read("*l") -****0 pf:close() -****0 timer:stop() -****0 timer:close() -****0 local port = tonumber(port_str) -****0 if port then -****0 connection_state = "disconnected" -- let connect() proceed -****0 M.connect("127.0.0.1", port) + 4 local attempts = 0 + 4 local timer = vim.uv.new_timer() + 12 timer:start(500, 500, vim.schedule_wrap(function() + 63 attempts = attempts + 1 + 63 local pf = io.open(port_file, "r") + 63 if pf then + 2 local port_str = pf:read("*l") + 2 pf:close() + 2 timer:stop() + 2 timer:close() + 2 local port = tonumber(port_str) + 2 if port then + 1 connection_state = "disconnected" -- let connect() proceed + 2 M.connect("127.0.0.1", port) else -****0 connection_state = "disconnected" -****0 vim.notify("swank.nvim: malformed port file", vim.log.levels.ERROR) + 1 connection_state = "disconnected" + 1 vim.notify("swank.nvim: malformed port file", vim.log.levels.ERROR) end -****0 elseif attempts >= 60 then -****0 timer:stop() -****0 timer:close() -****0 connection_state = "disconnected" -****0 vim.notify("swank.nvim: timed out waiting for Swank server", vim.log.levels.ERROR) + 61 elseif attempts >= 60 then + 2 timer:stop() + 2 timer:close() + 2 connection_state = "disconnected" + 2 vim.notify("swank.nvim: timed out waiting for Swank server", vim.log.levels.ERROR) end end)) end --- Disconnect and optionally stop the CL implementation process - 3 function M.disconnect() - 1 if transport then + 15 function M.disconnect() + 2 if transport then 1 transport:disconnect() 1 transport = nil end - 1 connection_state = "disconnected" - 1 if impl_job_id then -****0 vim.fn.jobstop(impl_job_id) -****0 impl_job_id = nil + 2 connection_state = "disconnected" + 2 if impl_job_id then + 1 vim.fn.jobstop(impl_job_id) + 1 impl_job_id = nil end - 1 vim.notify("swank.nvim: disconnected", vim.log.levels.INFO) + 2 vim.notify("swank.nvim: disconnected", vim.log.levels.INFO) end ---@return boolean - 3 function M.is_connected() - 9 return connection_state == "connected" + 15 function M.is_connected() + 12 return connection_state == "connected" end - 3 function M.get_package() + 15 function M.get_package() ****0 return current_package end @@ -230,21 +230,21 @@ ---@param cb fun(result: any) ---@param pkg string|nil package context ---@param thread any|nil thread id from :debug (nil → true, meaning Swank picks) - 3 function M.rex(form, cb, pkg, thread) - 41 if not transport then - 1 vim.notify("swank.nvim: not connected", vim.log.levels.ERROR) - 1 return + 15 function M.rex(form, cb, pkg, thread) + 47 if not transport then + 2 vim.notify("swank.nvim: not connected", vim.log.levels.ERROR) + 2 return end - 40 local id = next_id() - 40 callbacks[id] = cb - 80 local payload = protocol.serialize({ + 45 local id = next_id() + 45 callbacks[id] = cb + 90 local payload = protocol.serialize({ ":emacs-rex", - 40 form, - 40 pkg or current_package, - 40 thread ~= nil and thread or true, - 40 id, + 45 form, + 45 pkg or current_package, + 45 thread ~= nil and thread or true, + 45 id, }) - 40 transport:send(payload) + 45 transport:send(payload) end --- Like rex, but suppresses any :write-string output produced as a side effect. @@ -254,19 +254,19 @@ ---@param cb fun(result: any) ---@param pkg string|nil ---@param thread any|nil - 3 function M.silent_rex(form, cb, pkg, thread) - 3 silent_count = silent_count + 1 - 6 M.rex(form, function(result) + 15 function M.silent_rex(form, cb, pkg, thread) + 5 silent_count = silent_count + 1 + 10 M.rex(form, function(result) 2 silent_count = math.max(0, silent_count - 1) 2 cb(result) - 5 end, pkg, thread) + 7 end, pkg, thread) end -- --------------------------------------------------------------------------- -- Event handlers -- --------------------------------------------------------------------------- - 6 protocol.on(":return", function(msg) + 30 protocol.on(":return", function(msg) -- msg = (:return (:ok result) id) or (:return (:abort condition) id) 21 local id = msg[3] 21 local cb = callbacks[id] @@ -276,49 +276,49 @@ end end) - 6 protocol.on(":write-string", function(msg) - 1 if silent_count > 0 then return end - 1 require("swank.ui.repl").append(msg[2] or "") + 30 protocol.on(":write-string", function(msg) + 2 if silent_count > 0 then return end + 2 require("swank.ui.repl").append(msg[2] or "") end) - 6 protocol.on(":debug", function(msg) + 30 protocol.on(":debug", function(msg) -- Suppress debugger activation caused by background 'silent' rex calls - 1 if silent_count > 0 then -****0 local ok_swank, swank_mod = pcall(require, "swank") -****0 if ok_swank and swank_mod.config and swank_mod.config.debug then -****0 pcall(function() -****0 local fp = io.open("/tmp/swank_silent_debug_events.log", "a") -****0 if fp then -****0 fp:write(os.date("%FT%T") .. " suppressed :debug — payload=" .. tostring(msg[2]) .. "\n") -****0 fp:close() + 2 if silent_count > 0 then + 1 local ok_swank, swank_mod = pcall(require, "swank") + 1 if ok_swank and swank_mod.config and swank_mod.config.debug then + 2 pcall(function() + 1 local fp = io.open("/tmp/swank_silent_debug_events.log", "a") + 1 if fp then + 1 fp:write(os.date("%FT%T") .. " suppressed :debug — payload=" .. tostring(msg[2]) .. "\n") + 1 fp:close() end end) end -****0 return + 1 return end 1 require("swank.ui.sldb").open(msg) end) - 3 protocol.on(":debug-activate", function(_) end) + 15 protocol.on(":debug-activate", function(_) end) - 6 protocol.on(":debug-return", function(_) + 30 protocol.on(":debug-return", function(_) 1 require("swank.ui.sldb").close() end) - 3 protocol.on(":new-features", function(_) end) + 15 protocol.on(":new-features", function(_) end) - 3 protocol.on(":indentation-update", function(_) end) + 15 protocol.on(":indentation-update", function(_) end) - 6 protocol.on(":ping", function(msg) + 30 protocol.on(":ping", function(msg) -- Swank keepalive; respond with :emacs-pong - 1 if transport then - 1 local payload = protocol.serialize({ ":emacs-pong", msg[2], msg[3] }) - 1 transport:send(payload) + 2 if transport then + 2 local payload = protocol.serialize({ ":emacs-pong", msg[2], msg[3] }) + 2 transport:send(payload) end end) -- SWANK-TRACE-DIALOG events - 6 protocol.on(":trace-dialog-update", function(msg) + 30 protocol.on(":trace-dialog-update", function(msg) -- msg = (:trace-dialog-update specs entries) -- specs is a list of traced spec names; entries is a list of trace records 1 local specs = type(msg[2]) == "table" and msg[2] or {} @@ -333,7 +333,7 @@ -- --------------------------------------------------------------------------- --- Eval the top-level form under the cursor - 3 function M.eval_toplevel() + 15 function M.eval_toplevel() 1 local form = M._form_at_cursor() 1 if not form or form == "" then return end 1 require("swank.ui.repl").show_input(form) @@ -343,7 +343,7 @@ end --- Eval visually selected region - 3 function M.eval_region() + 15 function M.eval_region() 1 local lines = M._get_visual_selection() 1 if not lines then return end 1 require("swank.ui.repl").show_input(lines) @@ -353,73 +353,73 @@ end --- Eval with interactive input via vim.ui.input - 3 function M.eval_interactive() -****0 vim.ui.input({ prompt = "Eval: " }, function(input) -****0 if not input or input == "" then return end -****0 require("swank.ui.repl").show_input(input) -****0 M.rex({ "swank:eval-and-grab-output", input }, function(result) -****0 require("swank.ui.repl").show_result(result) + 15 function M.eval_interactive() + 2 vim.ui.input({ prompt = "Eval: " }, function(input) + 1 if not input or input == "" then return end + 1 require("swank.ui.repl").show_input(input) + 2 M.rex({ "swank:eval-and-grab-output", input }, function(result) + 1 require("swank.ui.repl").show_result(result) end) end) end --- Describe a symbol by name ---@param sym string - 3 function M.describe(sym) + 15 function M.describe(sym) -- Sanitize common reader prefixes and whitespace; validate symbol-like input. - 2 if not sym then return end - 2 local raw_token = tostring(sym) - 2 local s = raw_token + 4 if not sym then return end + 4 local raw_token = tostring(sym) + 4 local s = raw_token -- Strip #', leading quotes/backticks/commas produced by some editors/completions - 2 s = s:gsub("^#'", ""):gsub("^['`%,]+", "") - 2 s = s:match("^%s*(.-)%s*$") or s + 4 s = s:gsub("^#'", ""):gsub("^['`%,]+", "") + 4 s = s:match("^%s*(.-)%s*$") or s -- Debug log raw->sanitized (only when config.debug) - 2 local ok_swank, swank_mod = pcall(require, "swank") - 2 if ok_swank and swank_mod.config and swank_mod.config.debug then -****0 local ok, f = pcall(io.open, "/tmp/swank_sanitize_debug.log", "a") -****0 if ok and f then -****0 pcall(function() -****0 f:write(os.date("%FT%T") .. " raw=" .. tostring(raw_token) .. " sanitized=" .. tostring(s) .. "\n") -****0 f:close() + 4 local ok_swank, swank_mod = pcall(require, "swank") + 4 if ok_swank and swank_mod.config and swank_mod.config.debug then + 1 local ok, f = pcall(io.open, "/tmp/swank_sanitize_debug.log", "a") + 1 if ok and f then + 2 pcall(function() + 1 f:write(os.date("%FT%T") .. " raw=" .. tostring(raw_token) .. " sanitized=" .. tostring(s) .. "\n") + 1 f:close() end) end end - 4 if not M._is_symbol_like(s) then return end + 8 if not M._is_symbol_like(s) then return end -- Use silent_rex so any side-effect :write-string output (e.g. "Unknown symbol:") is suppressed - 4 M.silent_rex({ "swank:describe-symbol", s }, function(result) - 2 if type(result) ~= "table" or result[1] ~= ":ok" then return end - 1 local text = tostring(result[2] or ""):gsub("\r", "") - 1 local lines = vim.split(text, "\n", { plain = true }) - 1 while #lines > 0 and lines[#lines] == "" do table.remove(lines) end - 1 if #lines == 0 then return end - 1 local width = 0 - 2 for _, l in ipairs(lines) do width = math.max(width, #l) end - 2 width = math.min(math.max(width, 40), math.floor(vim.o.columns * 0.7)) - 2 local height = math.min(#lines, math.floor(vim.o.lines * 0.5)) - 1 local buf = vim.api.nvim_create_buf(false, true) - 2 vim.bo[buf].filetype = "swank-describe" - 2 vim.bo[buf].buftype = "nofile" - 1 vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - 2 vim.bo[buf].modifiable = false - 1 local c = require("swank").config - 1 local fcfg = (c and c.ui and c.ui.floating) or {} - 2 local win = vim.api.nvim_open_win(buf, false, { + 8 M.silent_rex({ "swank:describe-symbol", s }, function(result) + 3 if type(result) ~= "table" or result[1] ~= ":ok" then return end + 2 local text = tostring(result[2] or ""):gsub("\r", "") + 2 local lines = vim.split(text, "\n", { plain = true }) + 6 while #lines > 0 and lines[#lines] == "" do table.remove(lines) end + 2 if #lines == 0 then return end + 2 local width = 0 + 5 for _, l in ipairs(lines) do width = math.max(width, #l) end + 4 width = math.min(math.max(width, 40), math.floor(vim.o.columns * 0.7)) + 4 local height = math.min(#lines, math.floor(vim.o.lines * 0.5)) + 2 local buf = vim.api.nvim_create_buf(false, true) + 4 vim.bo[buf].filetype = "swank-describe" + 3 vim.bo[buf].buftype = "nofile" + 2 vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + 3 vim.bo[buf].modifiable = false + 2 local c = require("swank").config + 2 local fcfg = (c and c.ui and c.ui.floating) or {} + 4 local win = vim.api.nvim_open_win(buf, false, { relative = "cursor", row = 1, col = 0, - 1 width = width, height = height, + 2 width = width, height = height, style = "minimal", - 1 border = fcfg.border or "rounded", - 1 title = " " .. s .. " ", + 2 border = fcfg.border or "rounded", + 2 title = " " .. s .. " ", title_pos = "center", }) - 1 if vim.api.nvim_win_is_valid(win) then -****0 vim.wo[win].wrap = true + 3 if vim.api.nvim_win_is_valid(win) then + 2 vim.wo[win].wrap = true end -- close on any cursor movement or buffer leave - 2 vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "BufLeave" }, { + 4 vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "BufLeave" }, { once = true, callback = function() 1 if vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end @@ -430,7 +430,7 @@ --- Run an apropos query and display formatted results in the REPL ---@param query string - 3 function M.apropos(query) + 15 function M.apropos(query) 10 M.rex({ "swank:apropos-list-for-emacs", query, false, false, nil }, function(result) 5 if type(result) ~= "table" or result[1] ~= ":ok" then return end 4 local entries = result[2] @@ -459,7 +459,7 @@ --- Inspect a value by evaluating an expression string ---@param expr string expression to evaluate and inspect (e.g. "*", "SOME-VAR") - 3 function M.inspect_value(expr) + 15 function M.inspect_value(expr) 2 M.rex({ "swank:init-inspector", expr }, function(result) 1 require("swank.ui.inspector").open(result) end) @@ -467,14 +467,14 @@ --- Navigate to the Nth part inside the current inspector view ---@param n integer 0-based index of the part to follow - 3 function M.inspect_nth_part(n) + 15 function M.inspect_nth_part(n) 2 M.rex({ "swank:inspect-nth-part", n }, function(result) ****0 require("swank.ui.inspector").open(result) end) end --- Go back to the previous inspector view - 3 function M.inspector_pop() + 15 function M.inspector_pop() 4 M.rex({ "swank:inspector-pop" }, function(result) 2 if type(result) == "table" and result[1] == ":ok" and result[2] then 2 require("swank.ui.inspector").open(result) @@ -485,14 +485,14 @@ end --- Refresh the current inspector view - 3 function M.inspector_reinspect() + 15 function M.inspector_reinspect() 2 M.rex({ "swank:inspector-reinspect" }, function(result) ****0 require("swank.ui.inspector").open(result) end) end --- Quit the inspector - 3 function M.quit_inspector() + 15 function M.quit_inspector() 1 M.rex({ "swank:quit-inspector" }, function(_) end) 1 require("swank.ui.inspector").close() end @@ -503,7 +503,7 @@ --- Toggle tracing of a function by name ---@param sym string function name, e.g. "MY-FUNC" or "my-package:my-func" - 3 function M.trace_toggle(sym) + 15 function M.trace_toggle(sym) 2 M.rex({ "swank-trace-dialog:dialog-toggle-trace", sym }, function(result) 1 local trace = require("swank.ui.trace") 1 if type(result) == "table" and result[1] == ":ok" then @@ -517,7 +517,7 @@ end --- Untrace all traced functions - 3 function M.untrace_all() + 15 function M.untrace_all() 2 M.rex({ "swank-trace-dialog:dialog-untrace-all" }, function(result) 1 if type(result) == "table" and result[1] == ":ok" then 1 require("swank.ui.trace").set_specs({}) @@ -527,14 +527,14 @@ end --- Clear accumulated trace entries - 3 function M.clear_traces() + 15 function M.clear_traces() 2 M.rex({ "swank-trace-dialog:clear-trace-tree" }, function(_) 1 require("swank.ui.trace").clear() end) end --- Pull the latest trace entries from Swank and update the dialog - 3 function M.refresh_traces() + 15 function M.refresh_traces() -- report-specs gives the list of traced names 2 M.rex({ "swank-trace-dialog:report-specs" }, function(result) 1 if type(result) == "table" and result[1] == ":ok" then @@ -552,7 +552,7 @@ end --- Load current file - 3 function M.load_file() + 15 function M.load_file() 2 local path = vim.api.nvim_buf_get_name(0) 2 if path == "" then 1 vim.notify("swank.nvim: buffer has no file path", vim.log.levels.WARN) @@ -564,7 +564,7 @@ end --- Compile current file - 3 function M.compile_file() + 15 function M.compile_file() 2 local path = vim.api.nvim_buf_get_name(0) 2 if path == "" then 1 vim.notify("swank.nvim: buffer has no file path", vim.log.levels.WARN) @@ -576,7 +576,7 @@ end --- Compile form at cursor - 3 function M.compile_form() + 15 function M.compile_form() 1 local form = M._form_at_cursor() 1 if not form or form == "" then return end 1 local bufname = vim.api.nvim_buf_get_name(0) @@ -590,7 +590,7 @@ end --- Switch package interactively - 3 function M.set_package_interactive() + 15 function M.set_package_interactive() 4 vim.ui.input({ prompt = "Package: ", default = current_package }, function(pkg) 2 if not pkg or pkg == "" then return end 3 M.rex({ "swank:set-package", pkg:upper() }, function(result) @@ -608,7 +608,7 @@ --- Show arglist for the innermost operator at cursor in the echo area -- Accept an optional 'force' argument: when true, run even in insert mode. - 3 function M.autodoc(force) + 15 function M.autodoc(force) -- Don't auto-show in insert mode to avoid spamming the echo area. 3 if not force and vim.api.nvim_get_mode().mode == "i" then return end @@ -649,17 +649,17 @@ -- XRef ---@param sym string - 3 function M.xref_calls(sym) + 15 function M.xref_calls(sym) 1 M.rex({ "swank:xref", ":calls", sym }, function(r) require("swank.ui.xref").show(r, "calls") end) end ---@param sym string - 3 function M.xref_references(sym) + 15 function M.xref_references(sym) 1 M.rex({ "swank:xref", ":references", sym }, function(r) require("swank.ui.xref").show(r, "references") end) end ---@param sym string - 3 function M.find_definition(sym) + 15 function M.find_definition(sym) 1 M.rex({ "swank:find-definitions-for-emacs", sym }, function(r) require("swank.ui.xref").show(r, "definition") end) end @@ -667,31 +667,31 @@ -- Post-connect initialisation -- --------------------------------------------------------------------------- - 3 function M._on_connect() - 3 local cfg = require("swank").config + 15 function M._on_connect() + 7 local cfg = require("swank").config -- 1. Get connection info (implementation name + version) - 6 M.rex({ "swank:connection-info" }, function(result) - 1 if type(result) == "table" and result[1] == ":ok" and type(result[2]) == "table" then - 1 local info = M._plist(result[2]) - 1 local impl = M._plist(info[":lisp-implementation"] or {}) - 1 local name = impl[":name"] or "Unknown Lisp" - 1 local version = impl[":version"] or "" - 1 vim.notify("swank.nvim: " .. name .. " " .. version, vim.log.levels.INFO) + 14 M.rex({ "swank:connection-info" }, function(result) + 3 if type(result) == "table" and result[1] == ":ok" and type(result[2]) == "table" then + 2 local info = M._plist(result[2]) + 2 local impl = M._plist(info[":lisp-implementation"] or {}) + 2 local name = impl[":name"] or "Unknown Lisp" + 2 local version = impl[":version"] or "" + 2 vim.notify("swank.nvim: " .. name .. " " .. version, vim.log.levels.INFO) end end) -- 2. Load contribs (quoted list of keyword symbols, e.g. :swank-repl) - 3 local contribs = type(cfg.contribs) == "table" and #cfg.contribs > 0 and cfg.contribs - 3 if contribs then - 2 M.rex({ - 1 "swank:swank-require", - 1 { "QUOTE", contribs }, + 7 local contribs = type(cfg.contribs) == "table" and #cfg.contribs > 0 and cfg.contribs + 7 if contribs then + 4 M.rex({ + 2 "swank:swank-require", + 2 { "QUOTE", contribs }, }, function(_) - 1 M.rex({ "swank:set-package", current_package }, function(_) end) + 3 M.rex({ "swank:set-package", current_package }, function(_) end) end) else - 2 M.rex({ "swank:set-package", current_package }, function(_) end) + 6 M.rex({ "swank:set-package", current_package }, function(_) end) end end @@ -700,7 +700,7 @@ -- --------------------------------------------------------------------------- --- Get the top-level form containing the cursor (treesitter → paren fallback) - 3 function M._form_at_cursor() + 15 function M._form_at_cursor() 3 local bufnr = vim.api.nvim_get_current_buf() 4 local ok, parser = pcall(vim.treesitter.get_parser, bufnr, "commonlisp") 3 if ok and parser then @@ -722,7 +722,7 @@ end --- Bracket-aware top-level form scanner (used when treesitter unavailable) - 3 function M._form_at_cursor_paren() + 15 function M._form_at_cursor_paren() 8 local bufnr = vim.api.nvim_get_current_buf() 8 local cursor_row = vim.api.nvim_win_get_cursor(0)[1] 8 local all_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) @@ -761,7 +761,7 @@ ****0 return table.concat(collected, "\n") end - 3 function M._get_visual_selection() + 15 function M._get_visual_selection() 4 local s = vim.fn.getpos("'<") 3 local e = vim.fn.getpos("'>") 3 local lines = vim.api.nvim_buf_get_lines(0, s[2] - 1, e[2], false) @@ -771,7 +771,7 @@ --- Find the operator (first symbol) of the innermost list at cursor ---@return string|nil - 3 function M._innermost_operator() + 15 function M._innermost_operator() 5 local line = vim.api.nvim_get_current_line() 5 local col = vim.api.nvim_win_get_cursor(0)[2] + 1 5 local depth = 0 @@ -793,85 +793,85 @@ --- Used to guard describe/apropos so they don't fire on garbage selections. ---@param text string|nil ---@return boolean - 3 function M._is_symbol_like(text) - 25 if not text or text == "" then return false end - 23 local trimmed = text:match("^%s*(.-)%s*$") - 23 if trimmed == "" or trimmed:find("%s") then return false end + 15 function M._is_symbol_like(text) + 27 if not text or text == "" then return false end + 25 local trimmed = text:match("^%s*(.-)%s*$") + 25 if trimmed == "" or trimmed:find("%s") then return false end -- Reject tokens that end with ':' (package-only like 'cl-user:') - 17 if trimmed:match(":$") then return false end + 19 if trimmed:match(":$") then return false end -- Reject bare numbers (integers and floats) — not valid symbol names - 17 if trimmed:match("^%-?%d+%.?%d*$") then return false end + 19 if trimmed:match("^%-?%d+%.?%d*$") then return false end -- Must contain only valid CL symbol chars: letters, digits, and punctuation -- allow colon for package-qualified names (but not trailing colon) - 15 if trimmed:match("^[%a%d%+%-%*%/%@%$%%%%^%&%_%=%<%>%~%.%!%?%|:#]+$") == nil then return false end - 15 return true + 17 if trimmed:match("^[%a%d%+%-%*%/%@%$%%%%^%&%_%=%<%>%~%.%!%?%|:#]+$") == nil then return false end + 17 return true end --- Convert a flat plist to a Lua table keyed by lowercased keyword ---@param lst table ---@return table - 3 function M._plist(lst) - 11 local t = {} - 11 if type(lst) ~= "table" then return t end - 9 local i = 1 - 25 while i < #lst do - 32 t[tostring(lst[i] or ""):lower()] = lst[i + 1] - 16 i = i + 2 + 15 function M._plist(lst) + 13 local t = {} + 13 if type(lst) ~= "table" then return t end + 11 local i = 1 + 30 while i < #lst do + 38 t[tostring(lst[i] or ""):lower()] = lst[i + 1] + 19 i = i + 2 end - 9 return t + 11 return t end -- Test-only injection hooks (do not call from production code) - 3 function M._test_inject(fake_transport) - 48 transport = fake_transport - 48 connection_state = "connected" + 15 function M._test_inject(fake_transport) + 49 transport = fake_transport + 49 connection_state = "connected" end - 3 function M._test_reset() - 66 transport = nil - 66 connection_state = "disconnected" - 66 current_package = "COMMON-LISP-USER" - 66 callbacks = {} - 66 msg_id = 0 - 66 stderr_log = {} - 66 impl_job_id = nil + 15 function M._test_reset() + 94 transport = nil + 94 connection_state = "disconnected" + 94 current_package = "COMMON-LISP-USER" + 94 callbacks = {} + 94 msg_id = 0 + 94 stderr_log = {} + 94 impl_job_id = nil end - 3 return M + 15 return M ============================================================================== /home/runner/work/swank.nvim/swank.nvim/lua/swank/init.lua ============================================================================== - 4 local M = {} + 15 local M = {} - 4 local default_config = { + 15 local default_config = { -- Key prefix for all swank.nvim keymaps leader = "", -- Swank server connection defaults - 4 server = { + 15 server = { host = "127.0.0.1", port = 4005, - 4 }, + 15 }, -- Automatically start a CL implementation + Swank server on first attach - 4 autostart = { + 15 autostart = { enabled = true, implementation = "sbcl", - 4 }, + 15 }, -- UI settings - 4 ui = { - 4 repl = { + 15 ui = { + 15 repl = { -- "auto" picks the best layout based on terminal size: -- right split if REPL gets >=80 cols, bottom if >=12 rows, else float position = "auto", -- fraction of editor width/height (0 < size <= 1) or fixed columns/rows size = 0.45, - 4 }, - 4 floating = { + 15 }, + 15 floating = { border = "rounded", - 4 }, - 4 }, + 15 }, + 15 }, -- Swank contribs to load on connect (keyword symbol format) - 4 contribs = { + 15 contribs = { ":swank-asdf", ":swank-repl", ":swank-fuzzy", @@ -880,12 +880,12 @@ ":swank-trace-dialog", ":swank-c-p-c", ":swank-package-fu", - 4 }, + 15 }, -- Debug logging: when true, write debug logs to /tmp/* debug = false, } - 4 M.config = {} + 15 M.config = {} --- Register JSON schema with neoconf.nvim if it is available. --- Enables schema-based completions and validation in .neoconf.json and similar files. @@ -904,14 +904,14 @@ --- Setup swank.nvim with user config ---@param opts table|nil - 4 function M.setup(opts) + 15 function M.setup(opts) 8 M.config = vim.tbl_deep_extend("force", default_config, opts or {}) 4 register_neoconf_schema() end --- Called on FileType lisp/cl — attach keymaps and optionally connect ---@param bufnr integer - 4 function M.attach(bufnr) + 15 function M.attach(bufnr) 3 if not next(M.config) then 1 M.config = default_config end @@ -923,7 +923,7 @@ end end - 4 return M + 15 return M ============================================================================== /home/runner/work/swank.nvim/swank.nvim/lua/swank/protocol.lua @@ -931,7 +931,7 @@ -- swank.nvim — Swank protocol: s-expression parser + serializer + event dispatch -- Parses only the subset of s-expressions used in Swank messages. - 4 local M = {} + 16 local M = {} -- --------------------------------------------------------------------------- -- S-expression reader (minimal, Swank-message subset) @@ -943,53 +943,53 @@ ---@return any, integer (value, next_pos) local function read(src, pos) -- skip whitespace - 813 while pos <= #src and src:sub(pos, pos):match("%s") do pos = pos + 1 end - 407 if pos > #src then error("unexpected end of input") end + 835 while pos <= #src and src:sub(pos, pos):match("%s") do pos = pos + 1 end + 418 if pos > #src then error("unexpected end of input") end - 406 local ch = src:sub(pos, pos) + 417 local ch = src:sub(pos, pos) -- list - 406 if ch == "(" then - 106 local result = {} - 106 pos = pos + 1 + 417 if ch == "(" then + 109 local result = {} + 109 pos = pos + 1 while true do - 1365 while pos <= #src and src:sub(pos, pos):match("%s") do pos = pos + 1 end - 894 if src:sub(pos, pos) == ")" then return result, pos + 1 end + 1397 while pos <= #src and src:sub(pos, pos):match("%s") do pos = pos + 1 end + 916 if src:sub(pos, pos) == ")" then return result, pos + 1 end local val - 683 val, pos = read(src, pos) - 341 table.insert(result, val) + 699 val, pos = read(src, pos) + 349 table.insert(result, val) end -- string - 300 elseif ch == '"' then - 81 local s = "" - 81 pos = pos + 1 - 1018 while pos <= #src do - 1018 local c = src:sub(pos, pos) - 1018 if c == "\\" then + 308 elseif ch == '"' then + 82 local s = "" + 82 pos = pos + 1 + 1025 while pos <= #src do + 1025 local c = src:sub(pos, pos) + 1025 if c == "\\" then 3 pos = pos + 1 6 s = s .. src:sub(pos, pos) - 1015 elseif c == '"' then - 81 return s, pos + 1 + 1022 elseif c == '"' then + 82 return s, pos + 1 else - 934 s = s .. c + 940 s = s .. c end - 937 pos = pos + 1 + 943 pos = pos + 1 end ****0 error("unterminated string") -- keyword or symbol - 219 elseif ch:match("[%a%d:%-_#%%%.%+%*%?%!%@%$%^%&%=%<%>%/%%|~`]") then - 218 local s = "" - 3618 while pos <= #src and not src:sub(pos, pos):match("[%s%(%)\"']") do - 3194 s = s .. src:sub(pos, pos) - 1597 pos = pos + 1 + 226 elseif ch:match("[%a%d:%-_#%%%.%+%*%?%!%@%$%^%&%=%<%>%/%%|~`]") then + 225 local s = "" + 3702 while pos <= #src and not src:sub(pos, pos):match("[%s%(%)\"']") do + 3264 s = s .. src:sub(pos, pos) + 1632 pos = pos + 1 end - 218 local upper = s:upper() - 218 if upper == "NIL" then return nil, pos - 201 elseif upper == "T" then return true, pos - 161 elseif s:match("^%-?%d+$") then return tonumber(s), pos - 100 else return s, pos -- symbol or keyword as plain string + 225 local upper = s:upper() + 225 if upper == "NIL" then return nil, pos + 208 elseif upper == "T" then return true, pos + 168 elseif s:match("^%-?%d+$") then return tonumber(s), pos + 103 else return s, pos -- symbol or keyword as plain string end -- quote shorthand @@ -1006,16 +1006,16 @@ --- Parse a Swank s-expression message string into a Lua value ---@param src string ---@return any - 4 function M.parse(src) - 128 local ok, val = pcall(function() - 64 local v, _ = read(src, 1) - 63 return v + 16 function M.parse(src) + 134 local ok, val = pcall(function() + 67 local v, _ = read(src, 1) + 66 return v end) - 64 if not ok then + 67 if not ok then 1 vim.notify("swank.nvim: parse error: " .. tostring(val), vim.log.levels.ERROR) 1 return nil end - 63 return val + 66 return val end -- --------------------------------------------------------------------------- @@ -1024,34 +1024,34 @@ ---@param val any ---@return string - 4 function M.serialize(val) - 452 local t = type(val) - 452 if val == nil then + 16 function M.serialize(val) + 494 local t = type(val) + 494 if val == nil then 1 return "nil" - 451 elseif t == "boolean" then - 59 return val and "t" or "nil" - 392 elseif t == "number" then - 68 return tostring(math.floor(val)) - 324 elseif t == "string" then + 493 elseif t == "boolean" then + 64 return val and "t" or "nil" + 429 elseif t == "number" then + 75 return tostring(math.floor(val)) + 354 elseif t == "string" then -- Only emit as an unquoted symbol when the string is: -- 1. a keyword — starts with ':' e.g. :ok, :swank-repl -- 2. package-qualified — contains ':' e.g. swank:eval-and-grab-output -- 3. an explicit CL operator we embed literally (QUOTE) -- Everything else (package names, symbol strings passed as args) is quoted. - 204 local is_symbol = val:match("^:[%a%d%-_%.]+$") -- keyword - 204 or val:match("^[^:]+:[%a%d%-_%+%*%?%!%@%$%^%&%=%<%>%/%%|~%.#][%a%d%-_%+%*%?%!%@%$%^%&%=%<%>%/%%|~%.#:]*$") -- pkg:sym - 143 or val == "QUOTE" - 204 if is_symbol then - 110 return val + 223 local is_symbol = val:match("^:[%a%d%-_%.]+$") -- keyword + 223 or val:match("^[^:]+:[%a%d%-_%+%*%?%!%@%$%^%&%=%<%>%/%%|~%.#][%a%d%-_%+%*%?%!%@%$%^%&%=%<%>%/%%|~%.#:]*$") -- pkg:sym + 156 or val == "QUOTE" + 223 if is_symbol then + 121 return val else - 94 return '"' .. val:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + 102 return '"' .. val:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' end - 120 elseif t == "table" then - 120 local parts = {} - 502 for _, v in ipairs(val) do - 699 table.insert(parts, M.serialize(v)) + 131 elseif t == "table" then + 131 local parts = {} + 549 for _, v in ipairs(val) do + 764 table.insert(parts, M.serialize(v)) end - 120 return "(" .. table.concat(parts, " ") .. ")" + 131 return "(" .. table.concat(parts, " ") .. ")" else ****0 error("cannot serialize type: " .. t) end @@ -1062,29 +1062,29 @@ -- --------------------------------------------------------------------------- ---@type table - 4 local handlers = {} + 16 local handlers = {} --- Register a handler for a Swank event type ---@param event string e.g. ":return", ":debug", ":write-string" ---@param fn fun(payload: any) - 4 function M.on(event, fn) - 62 handlers[event:upper()] = fn + 16 function M.on(event, fn) + 278 handlers[event:upper()] = fn end --- Dispatch a parsed Swank message to the appropriate handler ---@param msg any parsed s-expression (a list) - 4 function M.dispatch(msg) - 34 if type(msg) ~= "table" or not msg[1] then return end - 30 local event = tostring(msg[1]):upper() - 30 local handler = handlers[event] - 30 if handler then - 58 handler(msg) + 16 function M.dispatch(msg) + 37 if type(msg) ~= "table" or not msg[1] then return end + 33 local event = tostring(msg[1]):upper() + 33 local handler = handlers[event] + 33 if handler then + 64 handler(msg) else 1 vim.notify("swank.nvim: unhandled event: " .. event, vim.log.levels.DEBUG) end end - 4 return M + 16 return M ============================================================================== /home/runner/work/swank.nvim/swank.nvim/lua/swank/transport.lua @@ -1093,52 +1093,52 @@ -- Uses vim.uv (libuv) for non-blocking socket I/O. -- Swank message framing: 6-hex-digit length prefix + s-expression payload. - 5 local M = {} + 20 local M = {} - 5 local uv = vim.uv + 20 local uv = vim.uv ---@class SwankTransport ---@field handle uv_tcp_t|nil ---@field buffer string ---@field on_message fun(msg: string) ---@field on_disconnect fun() - 5 local Transport = {} - 5 Transport.__index = Transport + 20 local Transport = {} + 20 Transport.__index = Transport --- Create a new transport instance ---@param on_message fun(msg: string) called for each complete message received ---@param on_disconnect fun() called on socket close/error ---@return SwankTransport - 5 function Transport.new(on_message, on_disconnect) - 15 return setmetatable({ + 20 function Transport.new(on_message, on_disconnect) + 18 return setmetatable({ handle = nil, buffer = "", - 15 on_message = on_message, - 15 on_disconnect = on_disconnect, - 15 }, Transport) + 18 on_message = on_message, + 18 on_disconnect = on_disconnect, + 18 }, Transport) end --- Connect to a Swank server ---@param host string ---@param port integer ---@param on_connect fun(err: string|nil) - 5 function Transport:connect(host, port, on_connect) - 2 local handle = uv.new_tcp() - 4 handle:connect(host, port, function(err) -****0 if err then -****0 handle:close() -****0 on_connect(err) -****0 return + 20 function Transport:connect(host, port, on_connect) + 5 local handle = uv.new_tcp() + 10 handle:connect(host, port, function(err) + 3 if err then + 1 handle:close() + 1 on_connect(err) + 1 return end -****0 self.handle = handle -****0 on_connect(nil) -****0 handle:read_start(function(read_err, data) -****0 vim.schedule(function() -****0 if read_err or not data then -****0 self:_on_close() -****0 return + 2 self.handle = handle + 2 on_connect(nil) + 4 handle:read_start(function(read_err, data) + 4 vim.schedule(function() + 2 if read_err or not data then + 1 self:_on_close() + 1 return end -****0 self:_feed(data) + 1 self:_feed(data) end) end) end) @@ -1146,7 +1146,7 @@ --- Send a raw message string (will be length-prefixed) ---@param payload string - 5 function Transport:send(payload) + 20 function Transport:send(payload) 2 if not self.handle then 1 vim.notify("swank.nvim: not connected", vim.log.levels.ERROR) 1 return @@ -1156,7 +1156,7 @@ end --- Disconnect the socket - 5 function Transport:disconnect() + 20 function Transport:disconnect() 2 if self.handle then 1 self.handle:close() 1 self.handle = nil @@ -1165,31 +1165,31 @@ --- Internal: handle raw data from socket ---@param data string - 5 function Transport:_feed(data) - 55 self.buffer = self.buffer .. data + 20 function Transport:_feed(data) + 56 self.buffer = self.buffer .. data while true do - 69 if #self.buffer < 6 then break end - 62 local len = tonumber(self.buffer:sub(1, 6), 16) - 31 if not len then + 71 if #self.buffer < 6 then break end + 64 local len = tonumber(self.buffer:sub(1, 6), 16) + 32 if not len then 1 vim.notify("swank.nvim: bad message frame", vim.log.levels.ERROR) 1 self.buffer = "" 1 break end - 30 if #self.buffer < 6 + len then break end - 14 local msg = self.buffer:sub(7, 6 + len) - 28 self.buffer = self.buffer:sub(7 + len) - 28 self.on_message(msg) + 31 if #self.buffer < 6 + len then break end + 15 local msg = self.buffer:sub(7, 6 + len) + 30 self.buffer = self.buffer:sub(7 + len) + 30 self.on_message(msg) end end --- Internal: handle socket close - 5 function Transport:_on_close() - 1 self.handle = nil - 1 self.on_disconnect() + 20 function Transport:_on_close() + 2 self.handle = nil + 2 self.on_disconnect() end - 5 M.Transport = Transport - 5 return M + 20 M.Transport = Transport + 20 return M ============================================================================== Summary @@ -1197,9 +1197,9 @@ Summary File Hits Missed Coverage ------------------------------------------------------------------------------------ -/home/runner/work/swank.nvim/swank.nvim/lua/swank/client.lua 362 94 79.39% +/home/runner/work/swank.nvim/swank.nvim/lua/swank/client.lua 409 47 89.69% /home/runner/work/swank.nvim/swank.nvim/lua/swank/init.lua 27 5 84.38% /home/runner/work/swank.nvim/swank.nvim/lua/swank/protocol.lua 75 3 96.15% -/home/runner/work/swank.nvim/swank.nvim/lua/swank/transport.lua 39 12 76.47% +/home/runner/work/swank.nvim/swank.nvim/lua/swank/transport.lua 51 0 100.00% ------------------------------------------------------------------------------------ -Total 503 114 81.52% +Total 562 55 91.09% diff --git a/tests/unit/client_connect_on_message_spec.lua b/tests/unit/client_connect_on_message_spec.lua new file mode 100644 index 0000000..6d9c1a0 --- /dev/null +++ b/tests/unit/client_connect_on_message_spec.lua @@ -0,0 +1,107 @@ +-- tests/unit/client_connect_on_message_spec.lua +-- Tests for client.connect on_message dispatch and on_disconnect handling using a mocked transport factory + +local client = require("swank.client") +local transport_mod = require("swank.transport") +local protocol = require("swank.protocol") + +local function silence_notify() + _G.__orig_notify = vim.notify + vim.notify = function() end +end +local function restore_notify() + if _G.__orig_notify then vim.notify = _G.__orig_notify; _G.__orig_notify = nil end +end + +local function mock_ui() + require("swank.ui.repl").show_result = function(_) end + require("swank.ui.repl").show_input = function(_) end + require("swank.ui.repl").append = function(_) end + require("swank.ui.inspector").open = function(_) end + require("swank.ui.inspector").close = function() end + require("swank.ui.sldb").open = function(_) end + require("swank.ui.sldb").close = function() end + require("swank.ui.xref").show = function(_) end + require("swank.ui.notes").show = function(_) end + require("swank.ui.trace").set_specs = function(_) end + require("swank.ui.trace").push_entries = function(_) end + require("swank.ui.trace").clear = function() end +end + +describe("client.connect on_message/on_disconnect", function() + local orig_transport + before_each(function() + silence_notify() + mock_ui() + client._test_reset() + orig_transport = transport_mod.Transport + end) + after_each(function() + transport_mod.Transport = orig_transport + client._test_reset() + restore_notify() + end) + + it(":write-string from on_message dispatches to repl.append and on_disconnect clears state", function() + local captured_on_message, captured_on_disconnect + transport_mod.Transport = { + new = function(on_message, on_disconnect) + captured_on_message = on_message + captured_on_disconnect = on_disconnect + return { + connect = function(self, host, port, cb) cb(nil) end, + disconnect = function(self) end, + send = function(self, _payload) end, + } + end, + } + + -- ensure config exists so connect can be called with nils if needed + require("swank").config = require("swank").config or {} + + client._test_reset() + client.connect("127.0.0.1", 4005) + assert.is_true(client.is_connected()) + + local appended + require("swank.ui.repl").append = function(s) appended = s end + + -- Deliver a :write-string event (protocol.parse expects an s-expression string) + assert.is_function(captured_on_message) + captured_on_message('(:write-string "hello\n")') + assert.equals("hello\n", appended) + + -- Simulate a disconnect from the transport + assert.is_function(captured_on_disconnect) + captured_on_disconnect() + -- transport cleared, connection_state → disconnected + assert.is_false(client.is_connected()) + end) + + it(":ping causes a :emacs-pong to be sent via transport:send", function() + local captured_on_message + local last_sent + transport_mod.Transport = { + new = function(on_message, on_disconnect) + captured_on_message = on_message + return { + connect = function(self, host, port, cb) cb(nil) end, + disconnect = function(self) end, + send = function(self, payload) last_sent = payload end, + } + end, + } + + client._test_reset() + client.connect("127.0.0.1", 4005) + assert.is_true(client.is_connected()) + + -- Fire a :ping event + captured_on_message('(:ping 1 42)') + assert.is_not_nil(last_sent) + local parsed = protocol.parse(last_sent) + assert.equals(":emacs-pong", parsed[1]) + assert.equals(1, parsed[2]) + assert.equals(42, parsed[3]) + end) +end) diff --git a/tests/unit/client_debug_silent_spec.lua b/tests/unit/client_debug_silent_spec.lua new file mode 100644 index 0000000..06ba6b3 --- /dev/null +++ b/tests/unit/client_debug_silent_spec.lua @@ -0,0 +1,73 @@ +-- tests/unit/client_debug_silent_spec.lua +-- Ensure protocol :debug events are suppressed during M.silent_rex + +local client = require("swank.client") +local protocol = require("swank.protocol") + +local function make_mock_transport() + local sent = {} + local t = { + send = function(self, payload) table.insert(sent, payload) end, + disconnect = function(self) self._closed = true end, + _closed = false, + } + return t, sent +end + +local function silence_notify() + _G.__orig_notify = vim.notify + vim.notify = function() end +end +local function restore_notify() + if _G.__orig_notify then vim.notify = _G.__orig_notify; _G.__orig_notify = nil end +end + +local function mock_ui() + require("swank.ui.repl").show_result = function(_) end + require("swank.ui.repl").show_input = function(_) end + require("swank.ui.repl").append = function(_) end + require("swank.ui.sldb").open = function(_) end + require("swank.ui.sldb").close = function() end +end + +describe("protocol :debug suppression with silent_rex", function() + local mock, sent + + before_each(function() + silence_notify() + mock_ui() + mock, sent = make_mock_transport() + client._test_reset() + client._test_inject(mock) + require("swank").config = {} + end) + + after_each(function() + client._test_reset() + require("swank").config = {} + restore_notify() + end) + + it("suppresses :debug when silent_rex in flight and swank.config.debug = true", function() + -- Enable debug logging in config so the suppression branch takes the pcall path + require("swank").config = { debug = true } + + -- Stub io.open to avoid touching filesystem during test (pcall wraps this) + local orig_io_open = io.open + io.open = function(...) return { write = function() end, close = function() end } end + + -- Make sldb.open fatal if invoked (it should NOT be called while suppressed) + require("swank.ui.sldb").open = function() error("sldb.open should not be called") end + + -- Start a silent rex; this increments silent_count and leaves it >0 until callback + client.silent_rex({ "swank:eval-and-grab-output", "(+ 1 2)" }, function() end) + + -- Dispatch a :debug event while silent_rex is in flight. Should be suppressed. + assert.has_no.errors(function() + protocol.dispatch({ ":debug", { "payload" }, 1 }) + end) + + -- Restore io.open + io.open = orig_io_open + end) +end) diff --git a/tests/unit/client_describe_debug_log_spec.lua b/tests/unit/client_describe_debug_log_spec.lua new file mode 100644 index 0000000..cc6a23c --- /dev/null +++ b/tests/unit/client_describe_debug_log_spec.lua @@ -0,0 +1,47 @@ +-- tests/unit/client_describe_debug_log_spec.lua +-- Ensure client.describe writes debug sanitize log when swank.config.debug = true + +local client = require("swank.client") + +local function silence_notify() + _G.__orig_notify = vim.notify + vim.notify = function() end +end +local function restore_notify() + if _G.__orig_notify then vim.notify = _G.__orig_notify; _G.__orig_notify = nil end +end + +describe("client.describe() debug logging", function() + local orig_io_open + before_each(function() + silence_notify() + client._test_reset() + orig_io_open = io.open + end) + after_each(function() + io.open = orig_io_open + client._test_reset() + restore_notify() + require("swank").config = {} + end) + + it("writes sanitize debug info when config.debug is true", function() + require("swank").config = { debug = true } + + local wrote = false + local wrote_data = nil + io.open = function(path, mode) + -- simulate successful open for append + return { + write = function(_, data) wrote = true; wrote_data = data end, + close = function() end, + } + end + + -- call describe; it will try to open the debug log before validating symbol + client.describe("mapcar") + assert.is_true(wrote) + assert.truthy(type(wrote_data) == "string") + assert.truthy(wrote_data:find("raw=") or wrote_data:find("sanitized=")) + end) +end) diff --git a/tests/unit/client_describe_ui_spec.lua b/tests/unit/client_describe_ui_spec.lua new file mode 100644 index 0000000..cb74c29 --- /dev/null +++ b/tests/unit/client_describe_ui_spec.lua @@ -0,0 +1,104 @@ +-- tests/unit/client_describe_ui_spec.lua +-- Test client.describe UI: ensure a floating buffer/window is created and trailing blank lines are trimmed + +local client = require("swank.client") + +describe("client.describe UI", function() + local orig_silent_rex, orig_create_buf, orig_buf_set_lines, orig_open_win + local orig_win_is_valid, orig_win_close, orig_create_autocmd, orig_bo, orig_wo + local orig_config, orig_notify, orig_cols, orig_lines + + before_each(function() + client._test_reset() + orig_silent_rex = client.silent_rex + orig_create_buf = vim.api.nvim_create_buf + orig_buf_set_lines = vim.api.nvim_buf_set_lines + orig_open_win = vim.api.nvim_open_win + orig_win_is_valid = vim.api.nvim_win_is_valid + orig_win_close = vim.api.nvim_win_close + orig_create_autocmd = vim.api.nvim_create_autocmd + orig_bo = vim.bo + orig_wo = vim.wo + orig_config = require("swank").config + orig_notify = vim.notify + orig_cols = vim.o.columns + orig_lines = vim.o.lines + + require("swank").config = { ui = { floating = { border = "rounded" } } } + vim.o.columns = 120 + vim.o.lines = 60 + + fake_buf = 101 + fake_win = 202 + captured_lines = nil + opened_opts = nil + created_autocmd = nil + + vim.api.nvim_create_buf = function(listed, scratch) return fake_buf end + -- Provide a buffer-local opts proxy for numeric buffer ids so production + -- code can set vim.bo[buf].filetype etc. without touching the real API. + local _orig_bo = orig_bo + vim.bo = setmetatable({}, { + __index = function(t, k) + if type(k) == "number" then + local tbl = {} + rawset(t, k, tbl) + return tbl + end + return (_orig_bo and _orig_bo[k]) or nil + end, + __newindex = function(t, k, v) rawset(t, k, v) end, + }) + vim.api.nvim_buf_set_lines = function(buf, start, _end, strict, lines) captured_lines = lines end + -- Provide a window-local opts proxy for numeric window ids + local _orig_wo = orig_wo + vim.wo = setmetatable({}, { + __index = function(t, k) + if type(k) == "number" then + local tbl = {} + rawset(t, k, tbl) + return tbl + end + return (_orig_wo and _orig_wo[k]) or nil + end, + __newindex = function(t, k, v) rawset(t, k, v) end, + }) + vim.api.nvim_open_win = function(buf, enter, opts) opened_opts = opts; return fake_win end + vim.api.nvim_win_is_valid = function(win) return true end + win_closed = false + vim.api.nvim_win_close = function(win, _) win_closed = true end + vim.api.nvim_create_autocmd = function(ev, opts) created_autocmd = {ev=ev, opts=opts} end + end) + + after_each(function() + client.silent_rex = orig_silent_rex + vim.api.nvim_create_buf = orig_create_buf + vim.api.nvim_buf_set_lines = orig_buf_set_lines + vim.api.nvim_open_win = orig_open_win + vim.api.nvim_win_is_valid = orig_win_is_valid + vim.api.nvim_win_close = orig_win_close + vim.api.nvim_create_autocmd = orig_create_autocmd + vim.bo = orig_bo + vim.wo = orig_wo + require("swank").config = orig_config + vim.notify = orig_notify + vim.o.columns = orig_cols + vim.o.lines = orig_lines + client._test_reset() + end) + + it("creates a floating window with sanitized description", function() + client.silent_rex = function(form, cb) + cb({ ":ok", "Alpha\nBeta\n\n" }) + end + + client.describe("Alpha") + + assert.equals(2, #captured_lines) + assert.equals("Alpha", captured_lines[1]) + assert.equals("Beta", captured_lines[2]) + assert.is_true(opened_opts ~= nil) + assert.equals(" Alpha ", opened_opts.title) + assert.is_true(created_autocmd ~= nil) + end) +end) diff --git a/tests/unit/client_disconnect_jobstop_spec.lua b/tests/unit/client_disconnect_jobstop_spec.lua new file mode 100644 index 0000000..b4437f5 --- /dev/null +++ b/tests/unit/client_disconnect_jobstop_spec.lua @@ -0,0 +1,68 @@ +-- tests/unit/client_disconnect_jobstop_spec.lua +-- Ensure client.disconnect stops impl job when impl_job_id is set + +local client = require("swank.client") + +local function silence_notify() + _G.__orig_notify = vim.notify + vim.notify = function() end +end +local function restore_notify() + if _G.__orig_notify then vim.notify = _G.__orig_notify; _G.__orig_notify = nil end +end + +describe("client.disconnect() stops impl job when present", function() + local orig_jobstart, orig_jobstop, orig_uv_new_timer, orig_io_open + before_each(function() + silence_notify() + client._test_reset() + orig_jobstart = vim.fn.jobstart + orig_jobstop = vim.fn.jobstop + orig_uv_new_timer = vim.uv.new_timer + orig_io_open = io.open + end) + after_each(function() + vim.fn.jobstart = orig_jobstart + vim.fn.jobstop = orig_jobstop + vim.uv.new_timer = orig_uv_new_timer + io.open = orig_io_open + client._test_reset() + restore_notify() + end) + + it("calls jobstop on impl_job_id when disconnecting", function() + local swank = require("swank") + swank.config = { + autostart = { enabled = true, implementation = "dummy-bin" }, + server = { host = "127.0.0.1", port = 4005 }, + contribs = {}, + } + + -- jobstart returns a positive id + local returned_job_id = 4242 + vim.fn.jobstart = function(argv, opts) + -- emulate starting; return the job id + return returned_job_id + end + + local jobstop_called_with + vim.fn.jobstop = function(id) jobstop_called_with = id end + + -- stub timer so it doesn't attempt to poll or call callbacks + vim.uv.new_timer = function() + return { start = function() end, stop = function() end, close = function() end } + end + + -- stub io.open when writing script + io.open = function(path, mode) + return { write = function() end, close = function() end } + end + + -- Run start_and_connect so impl_job_id is set + client.start_and_connect() + + -- Now disconnect should call jobstop with the same id + client.disconnect() + assert.equals(returned_job_id, jobstop_called_with) + end) +end) diff --git a/tests/unit/client_eval_interactive_spec.lua b/tests/unit/client_eval_interactive_spec.lua new file mode 100644 index 0000000..e3b08ab --- /dev/null +++ b/tests/unit/client_eval_interactive_spec.lua @@ -0,0 +1,42 @@ +-- tests/unit/client_eval_interactive_spec.lua +-- Test client.eval_interactive prompting and rex flow + +local client = require("swank.client") + +describe("client.eval_interactive", function() + local orig_ui_input, orig_rex, orig_show_input, orig_show_result + before_each(function() + client._test_reset() + orig_ui_input = vim.ui.input + orig_rex = client.rex + orig_show_input = require("swank.ui.repl").show_input + orig_show_result = require("swank.ui.repl").show_result + end) + after_each(function() + vim.ui.input = orig_ui_input + client.rex = orig_rex + require("swank.ui.repl").show_input = orig_show_input + require("swank.ui.repl").show_result = orig_show_result + client._test_reset() + end) + + it("prompts and sends rex when user inputs text", function() + local seen_input + require("swank.ui.repl").show_input = function(s) seen_input = s end + local seen_result + require("swank.ui.repl").show_result = function(result) seen_result = result end + + vim.ui.input = function(opts, cb) cb("(+ 1 2)") end + + client.rex = function(form, cb) + assert.equals("swank:eval-and-grab-output", form[1]) + assert.equals("(+ 1 2)", form[2]) + cb({ ":ok", "42" }) + end + + client.eval_interactive() + + assert.equals("(+ 1 2)", seen_input) + assert.equals(":ok", seen_result[1]) + end) +end) diff --git a/tests/unit/client_on_connect_contribs_spec.lua b/tests/unit/client_on_connect_contribs_spec.lua new file mode 100644 index 0000000..ee75dbe --- /dev/null +++ b/tests/unit/client_on_connect_contribs_spec.lua @@ -0,0 +1,44 @@ +-- tests/unit/client_on_connect_contribs_spec.lua +-- Ensure M._on_connect loads contribs and calls swank:swank-require then set-package + +local client = require("swank.client") + +describe("client._on_connect with contribs", function() + local orig_rex, orig_notify, orig_config + before_each(function() + client._test_reset() + orig_rex = client.rex + orig_notify = vim.notify + orig_config = require("swank").config + end) + after_each(function() + client.rex = orig_rex + vim.notify = orig_notify + require("swank").config = orig_config + client._test_reset() + end) + + it("calls swank:swank-require and then set-package when contribs present", function() + local swank = require("swank") + swank.config = { contribs = { ":swank-repl" } } + + local calls = {} + client.rex = function(form, cb) + table.insert(calls, form[1]) + -- simulate success immediately + cb({ ":ok", true }) + end + + client._on_connect() + + local saw_require, saw_set = false, false + for _, c in ipairs(calls) do + local s = tostring(c) + if s == "swank:swank-require" then saw_require = true end + if s == "swank:set-package" then saw_set = true end + end + + assert.is_true(saw_require) + assert.is_true(saw_set) + end) +end) diff --git a/tests/unit/client_on_connect_spec.lua b/tests/unit/client_on_connect_spec.lua new file mode 100644 index 0000000..63cfdf0 --- /dev/null +++ b/tests/unit/client_on_connect_spec.lua @@ -0,0 +1,41 @@ +-- tests/unit/client_on_connect_spec.lua +-- Test client._on_connect post-connect initialisation behaviour + +local client = require("swank.client") + +describe("client._on_connect", function() + local orig_rex, orig_notify, orig_config + before_each(function() + client._test_reset() + orig_rex = client.rex + orig_notify = vim.notify + orig_config = require("swank").config + end) + after_each(function() + client.rex = orig_rex + vim.notify = orig_notify + require("swank").config = orig_config + client._test_reset() + end) + + it("notifies with implementation name and version from connection-info", function() + local swank = require("swank") + swank.config = { contribs = {} } + + client.rex = function(form, cb) + local cmd = form[1] + if cmd == "swank:connection-info" then + cb({ ":ok", { ":lisp-implementation", { ":name", "SBCL", ":version", "1.2.3" } } }) + else + cb({ ":ok", true }) + end + end + + local captured + vim.notify = function(msg, level) captured = msg end + + client._on_connect() + + assert.equals("swank.nvim: SBCL 1.2.3", captured) + end) +end) diff --git a/tests/unit/client_start_and_connect_jobstart_fail_spec.lua b/tests/unit/client_start_and_connect_jobstart_fail_spec.lua new file mode 100644 index 0000000..63d22d3 --- /dev/null +++ b/tests/unit/client_start_and_connect_jobstart_fail_spec.lua @@ -0,0 +1,49 @@ +-- tests/unit/client_start_and_connect_jobstart_fail_spec.lua +-- Test client.start_and_connect when jobstart returns non-positive id (failed to start) + +local client = require("swank.client") + +describe("client.start_and_connect() jobstart failure", function() + local orig_jobstart, orig_uv_new_timer, orig_schedule_wrap, orig_io, orig_notify + before_each(function() + client._test_reset() + orig_jobstart = vim.fn.jobstart + orig_uv_new_timer = vim.uv.new_timer + orig_schedule_wrap = vim.schedule_wrap + orig_io = io.open + orig_notify = vim.notify + end) + after_each(function() + vim.fn.jobstart = orig_jobstart + vim.uv.new_timer = orig_uv_new_timer + vim.schedule_wrap = orig_schedule_wrap + io.open = orig_io + vim.notify = orig_notify + client._test_reset() + end) + + it("notifies when jobstart returns non-positive id", function() + local swank = require("swank") + swank.config = { + autostart = { enabled = true, implementation = "dummy-binary" }, + server = { host = "127.0.0.1", port = 4005 }, + contribs = {}, + } + + -- jobstart returns non-positive to simulate failure + vim.fn.jobstart = function(argv, _opts) return 0 end + + -- stub writer so script write succeeds + io.open = function(path, mode) + if mode == "w" then return { write = function() end, close = function() end } end + return nil + end + + local captured_msg + vim.notify = function(msg, level) captured_msg = msg end + + client.start_and_connect() + + assert.equals("swank.nvim: failed to start dummy-binary", captured_msg) + end) +end) diff --git a/tests/unit/client_start_and_connect_malformed_portfile_spec.lua b/tests/unit/client_start_and_connect_malformed_portfile_spec.lua new file mode 100644 index 0000000..566d8b3 --- /dev/null +++ b/tests/unit/client_start_and_connect_malformed_portfile_spec.lua @@ -0,0 +1,63 @@ +-- tests/unit/client_start_and_connect_malformed_portfile_spec.lua +-- Test client.start_and_connect malformed port-file handling + +local client = require("swank.client") + +describe("client.start_and_connect() malformed port file", function() + local orig_jobstart, orig_uv_new_timer, orig_schedule_wrap, orig_io, orig_notify + before_each(function() + client._test_reset() + orig_jobstart = vim.fn.jobstart + orig_uv_new_timer = vim.uv.new_timer + orig_schedule_wrap = vim.schedule_wrap + orig_io = io.open + orig_notify = vim.notify + end) + after_each(function() + vim.fn.jobstart = orig_jobstart + vim.uv.new_timer = orig_uv_new_timer + vim.schedule_wrap = orig_schedule_wrap + io.open = orig_io + vim.notify = orig_notify + client._test_reset() + end) + + it("notifies on malformed port file content", function() + local swank = require("swank") + swank.config = { + autostart = { enabled = true, implementation = "dummy-binary" }, + server = { host = "127.0.0.1", port = 4005 }, + contribs = {}, + } + + vim.fn.jobstart = function(argv, _opts) return 123 end + + vim.uv.new_timer = function() + return { + start = function(self, _a, _b, cb) + -- simulate immediate detection of a port file containing non-numeric data + cb() + end, + stop = function() end, + close = function() end, + } + end + + vim.schedule_wrap = function(fn) return fn end + + io.open = function(path, mode) + if mode == "r" then + return { read = function(_, _p) return "not-a-number" end, close = function() end } + else + return { write = function() end, close = function() end } + end + end + + local captured_msg + vim.notify = function(msg, level) captured_msg = msg end + + client.start_and_connect() + + assert.equals("swank.nvim: malformed port file", captured_msg) + end) +end) diff --git a/tests/unit/client_start_and_connect_timeout_spec.lua b/tests/unit/client_start_and_connect_timeout_spec.lua new file mode 100644 index 0000000..576e127 --- /dev/null +++ b/tests/unit/client_start_and_connect_timeout_spec.lua @@ -0,0 +1,62 @@ +-- tests/unit/client_start_and_connect_timeout_spec.lua +-- Test client.start_and_connect timeout path (attempts >= 60) + +local client = require("swank.client") + +describe("client.start_and_connect() timeout", function() + local orig_jobstart, orig_uv_new_timer, orig_schedule_wrap, orig_io, orig_notify + before_each(function() + client._test_reset() + orig_jobstart = vim.fn.jobstart + orig_uv_new_timer = vim.uv.new_timer + orig_schedule_wrap = vim.schedule_wrap + orig_io = io.open + orig_notify = vim.notify + end) + after_each(function() + vim.fn.jobstart = orig_jobstart + vim.uv.new_timer = orig_uv_new_timer + vim.schedule_wrap = orig_schedule_wrap + io.open = orig_io + vim.notify = orig_notify + client._test_reset() + end) + + it("times out when port file not created within attempts", function() + local swank = require("swank") + swank.config = { + autostart = { enabled = true, implementation = "dummy-binary" }, + server = { host = "127.0.0.1", port = 4005 }, + contribs = {}, + } + + -- stub jobstart to return positive pid + vim.fn.jobstart = function(argv, _opts) return 123 end + + -- stub timer to call the poll callback 61 times to trigger timeout + vim.uv.new_timer = function() + return { + start = function(self, _a, _b, cb) + for i = 1, 61 do cb() end + end, + stop = function() end, + close = function() end, + } + end + + vim.schedule_wrap = function(fn) return fn end + + -- always fail to open the port file for reads; provide writer for script_file + io.open = function(path, mode) + if mode == "r" then return nil end + return { write = function() end, close = function() end } + end + + local captured_msg + vim.notify = function(msg, level) captured_msg = msg end + + client.start_and_connect() + + assert.equals("swank.nvim: timed out waiting for Swank server", captured_msg) + end) +end) diff --git a/tests/unit/client_start_and_connect_timer_spec.lua b/tests/unit/client_start_and_connect_timer_spec.lua new file mode 100644 index 0000000..688098f --- /dev/null +++ b/tests/unit/client_start_and_connect_timer_spec.lua @@ -0,0 +1,81 @@ +-- tests/unit/client_start_and_connect_timer_spec.lua +-- Test client.start_and_connect timer/polling behaviour (port-file detection) + +local client = require("swank.client") +local orig_io_open = io.open + +local function silence_notify() + _G.__orig_notify = vim.notify + vim.notify = function() end +end +local function restore_notify() + if _G.__orig_notify then vim.notify = _G.__orig_notify; _G.__orig_notify = nil end +end + +describe("client.start_and_connect() timer/port-file polling", function() + local orig_jobstart, orig_uv_new_timer, orig_schedule_wrap, orig_io + before_each(function() + silence_notify() + client._test_reset() + orig_jobstart = vim.fn.jobstart + orig_uv_new_timer = vim.uv.new_timer + orig_schedule_wrap = vim.schedule_wrap + orig_io = io.open + end) + after_each(function() + vim.fn.jobstart = orig_jobstart + vim.uv.new_timer = orig_uv_new_timer + vim.schedule_wrap = orig_schedule_wrap + io.open = orig_io + client._test_reset() + restore_notify() + end) + + it("reads port file and invokes M.connect with discovered port", function() + local swank = require("swank") + swank.config = { + autostart = { enabled = true, implementation = "dummy-binary" }, + server = { host = "127.0.0.1", port = 4005 }, + contribs = {}, + } + + -- stub jobstart to return positive pid + vim.fn.jobstart = function(argv, _opts) return 123 end + + -- stub timer to immediately call the callback + vim.uv.new_timer = function() + return { + start = function(self, _a, _b, cb) cb() end, + stop = function() end, + close = function() end, + } + end + + -- schedule_wrap should return identity so the timer receives the inner fn + vim.schedule_wrap = function(fn) return fn end + + -- stub io.open: when opened for reading the port file, return an object with read + io.open = function(path, mode) + if mode == "r" then + return { read = function(_, _p) return tostring(4005) end, close = function() end } + else + -- writing the script: provide a stub with write/close + return { write = function() end, close = function() end } + end + end + + -- Capture calls to client.connect + local called_host, called_port + local orig_connect = client.connect + client.connect = function(host, port) called_host = host; called_port = port end + + -- Run start_and_connect; timer:start will immediately invoke the poll callback + client.start_and_connect() + + -- Expect that client.connect was invoked with the port read from the file + assert.equals(4005, called_port) + + -- restore connect + client.connect = orig_connect + end) +end) diff --git a/tests/unit/transport_connect_errors_spec.lua b/tests/unit/transport_connect_errors_spec.lua new file mode 100644 index 0000000..d4a80ad --- /dev/null +++ b/tests/unit/transport_connect_errors_spec.lua @@ -0,0 +1,151 @@ +-- tests/unit/transport_connect_errors_spec.lua +-- Unit tests for transport:connect error and read error handling using mocked vim.uv + +local function make_mock_uv_connect_error() + local last_handle + local uv = {} + uv.new_tcp = function() + local handle = {} + handle.closed = false + function handle:connect(host, port, cb) + -- simulate immediate connect error + cb("connect-failed") + end + function handle:close() + self.closed = true + end + function handle:read_start(cb) end + function handle:write(_) end + last_handle = handle + return handle + end + return uv, function() return last_handle end +end + +local function make_mock_uv_connect_success() + local last_handle + local uv = {} + uv.new_tcp = function() + local handle = {} + handle.closed = false + function handle:connect(host, port, cb) + -- simulate immediate successful connect + cb(nil) + end + function handle:read_start(cb) + -- store callback for later invocation by test + self._read_cb = cb + end + function handle:write(_) end + function handle:close() + self.closed = true + end + last_handle = handle + return handle + end + return uv, function() return last_handle end +end + +local function silence_notify() + _G.__orig_notify = vim.notify + vim.notify = function() end +end +local function restore_notify() + if _G.__orig_notify then vim.notify = _G.__orig_notify; _G.__orig_notify = nil end +end + +describe("Transport.connect error/read handling", function() + local orig_uv, orig_transport_mod + + after_each(function() + -- restore globals + vim.uv = orig_uv + package.loaded["swank.transport"] = orig_transport_mod + restore_notify() + end) + + it("connect() passes error and closes handle on connect failure", function() + silence_notify() + orig_uv = vim.uv + orig_transport_mod = package.loaded["swank.transport"] + + local mock_uv, get_last_handle = make_mock_uv_connect_error() + vim.uv = mock_uv + package.loaded["swank.transport"] = nil + local transport_mod = require("swank.transport") + + local got_err = nil + local t = transport_mod.Transport.new(function() end, function() end) + t:connect("127.0.0.1", 4005, function(err) got_err = err end) + + assert.equals("connect-failed", got_err) + local last_handle = get_last_handle() + assert.is_true(last_handle.closed, "expected handle:close() to be called on connect error") + end) + + it("read error triggers on_disconnect and clears handle", function() + silence_notify() + orig_uv = vim.uv + orig_transport_mod = package.loaded["swank.transport"] + + local mock_uv, get_last_handle = make_mock_uv_connect_success() + vim.uv = mock_uv + package.loaded["swank.transport"] = nil + local transport_mod = require("swank.transport") + + -- make vim.schedule synchronous for tests + local orig_schedule = vim.schedule + vim.schedule = function(fn) fn() end + + local disconnected = false + local received = {} + local t = transport_mod.Transport.new(function(msg) table.insert(received, msg) end, function() disconnected = true end) + local conn_err = nil + t:connect("127.0.0.1", 4005, function(err) conn_err = err end) + + assert.is_nil(conn_err) + local last_handle = get_last_handle() + -- simulate read error from uv + assert.is_function(last_handle._read_cb) + last_handle._read_cb("read-err", nil) + + assert.is_true(disconnected, "expected on_disconnect called on read error") + assert.is_nil(t.handle, "expected transport.handle to be cleared") + + -- restore schedule + vim.schedule = orig_schedule + end) + + it("read data feeds messages to on_message via _feed", function() + silence_notify() + orig_uv = vim.uv + orig_transport_mod = package.loaded["swank.transport"] + + local mock_uv, get_last_handle = make_mock_uv_connect_success() + vim.uv = mock_uv + package.loaded["swank.transport"] = nil + local transport_mod = require("swank.transport") + + -- make vim.schedule synchronous for tests + local orig_schedule = vim.schedule + vim.schedule = function(fn) fn() end + + local received = {} + local disconnected = false + local t = transport_mod.Transport.new(function(msg) table.insert(received, msg) end, function() disconnected = true end) + t:connect("127.0.0.1", 4005, function(_) end) + + local last_handle = get_last_handle() + -- craft a framed message + local body = "(hello)" + local frame = string.format("%06x", #body) .. body + assert.is_function(last_handle._read_cb) + last_handle._read_cb(nil, frame) + + assert.equals(1, #received) + assert.equals("(hello)", received[1]) + + -- restore schedule + vim.schedule = orig_schedule + end) +end)