Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lua/eca/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ M._defaults = {
behaviour = {
auto_set_keymaps = true,
auto_focus_sidebar = true,
auto_start_server = true, -- Automatically start server on setup
auto_start_server = false, -- Automatically start server on setup
auto_download = true, -- Automatically download server if not found
show_status_updates = true, -- Show status updates in notifications
},
Expand Down
10 changes: 6 additions & 4 deletions lua/eca/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,12 @@ function M.setup(opts)
M.state = require("eca.state").new()
M.server = Server.new()
M.mediator = require("eca.mediator").new(M.server, M.state)
-- Start server automatically in background
vim.defer_fn(function()
M.server:start()
end, 100) -- Small delay to ensure everything is loaded

if Config.behaviour and Config.behaviour.auto_start_server then
vim.defer_fn(function()
M.server:start()
end, 100) -- Small delay to ensure everything is loaded
end

M.did_setup = true
end
Expand Down
34 changes: 18 additions & 16 deletions lua/eca/path_finder.lua
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function M:_write_version_file(version)
file:write(version)
file:close()
else
Logger.notify("Could not write version file: " .. self._version_file, vim.log.levels.WARN)
Logger.warn("Could not write version file: " .. self._version_file)
end
end

Expand Down Expand Up @@ -136,7 +136,7 @@ function M:_download_latest_server(server_path, version)

local download_path = self._cache_dir .. "/" .. artifact_name

Logger.notify("Downloading latest ECA server version from: " .. download_url, vim.log.levels.INFO)
Logger.debug("Downloading latest ECA server version from: " .. download_url)

-- Ensure cache directory exists
vim.fn.mkdir(self._cache_dir, "p")
Expand All @@ -150,8 +150,7 @@ function M:_download_latest_server(server_path, version)

local download_result = os.execute(download_cmd)
if download_result ~= 0 then
Logger.notify("Failed to download ECA server from: " .. download_url, vim.log.levels.ERROR)
return false
error("Failed to download ECA server from: " .. download_url)
end

-- Extract if it's a zip file
Expand All @@ -161,35 +160,33 @@ function M:_download_latest_server(server_path, version)

local extract_result = os.execute(extract_cmd)
if extract_result ~= 0 then
Logger.notify("Failed to extract ECA server", vim.log.levels.ERROR)
return false
error("Failed to extract ECA server")
end

-- Remove the zip file after extraction
os.remove(download_path)
end

-- Make executable (if not Windows)
if not vim.loop.os_uname().sysname:lower():match("windows") then
if not uv.os_uname().sysname:lower():match("windows") then

Copilot AI Oct 6, 2025

Copy link

Choose a reason for hiding this comment

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

Direct use of 'uv' global may not be available in all Neovim versions. Consider using 'vim.uv' or 'vim.loop' for better compatibility.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

uv is defined as local uv = vim.uv or vim.loop in the first line of the file

os.execute("chmod +x " .. vim.fn.shellescape(server_path))
end

if not Utils.file_exists(server_path) then
Logger.notify("ECA server binary not found after download and extraction", vim.log.levels.ERROR)
return false
error("ECA server binary not found after download and extraction")
end

-- Write version file
self:_write_version_file(version)

Logger.notify("ECA server downloaded successfully", vim.log.levels.INFO)
Logger.debug("ECA server downloaded successfully")

return true
end

---@return string
function M:find()
function M:find(custom_path)
-- Check for custom server path first
local custom_path = Config.server_path
if custom_path and custom_path:gsub("%s+", "") ~= "" then
if Utils.file_exists(custom_path) then
Logger.debug("Using custom server path: " .. custom_path)
Expand All @@ -215,13 +212,18 @@ function M:find()
-- Download if server doesn't exist or version is outdated
if not server_exists or (latest_version and current_version ~= latest_version) then
if not latest_version then
Logger.notify("Could not check for latest version, using existing server", vim.log.levels.WARN)
Logger.warn("Could not check for latest version, using existing server")
return server_path
end

local success = self:_download_latest_server(server_path, latest_version)
if not success then
error("Failed to download ECA server")
local success

local ok, err = pcall(function()
success = self:_download_latest_server(server_path, latest_version)
end)

if not ok or not success then
error((err and tostring(err)) or "Failed to download ECA server")
end
end

Expand Down
134 changes: 75 additions & 59 deletions lua/eca/server.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
local Utils = require("eca.utils")
local Config = require("eca.config")
local PathFinder = require("eca.path_finder")
local Logger = require("eca.logger")

---@class eca.Server
Expand All @@ -13,16 +12,17 @@ local Logger = require("eca.logger")
---@field on_stop function Callback when the server stops
---Called when a notification is received(message without an ID)
---@field on_notification fun(server: eca.Server, message: table)
---@field private path_finder eca.PathFinder Server path finder
---@field pending_requests {id: fun(err, data)} -- outgoing requests with callbacks
---@field cwd string Current working directory for the server process
---@field workspace_folders {name: string, uri: string}[] Workspace folders to send on initialize
local M = {}

---@param opts? table
---@return eca.Server
function M.new(opts)
opts = vim.tbl_extend("keep", opts or {}, {
on_start = function(pid)
require("eca.logger").notify("Started server with pid " .. pid, vim.log.levels.INFO)
require("eca.logger").debug("Started server with pid " .. pid)
end,
on_initialize = function()
require("eca.logger").notify("Server ready to receive messages", vim.log.levels.INFO)
Expand All @@ -37,7 +37,13 @@ function M.new(opts)
require("eca.observer").notify(message)
end)
end,
path_finder = PathFinder:new(),
cwd = vim.fn.getcwd(),
workspace_folders = {
{
name = vim.fn.fnamemodify(Utils.get_project_root(), ":t"),
uri = "file://" .. Utils.get_project_root(),
},
},
})

return setmetatable({
Expand All @@ -46,11 +52,12 @@ function M.new(opts)
on_initialize = opts.on_initialize,
on_stop = opts.on_stop,
on_notification = opts.on_notification,
path_finder = opts.path_finder,
messages = {},
pending_requests = {},
initialized = false,
next_id = 0,
cwd = opts.cwd,
workspace_folders = opts.workspace_folders,
}, { __index = M })
end

Expand Down Expand Up @@ -93,70 +100,79 @@ end
function M:start(opts)
opts = opts or { initialize = true }

local server_path
local ok, path_finder_error = pcall(function()
server_path = self.path_finder:find()
end)
local this_file = debug.getinfo(1, "S").source:sub(2)
local proj_root = vim.fn.fnamemodify(this_file, ":p:h:h:h")
local script_path = proj_root .. "/scripts/server_path.lua"

if not ok or not server_path then
Logger.notify("Could not find or download ECA server" .. tostring(path_finder_error), vim.log.levels.ERROR)
return
local nvim_exe = vim.fn.exepath("nvim")

if not nvim_exe or nvim_exe == "" then
nvim_exe = "nvim"
end

Logger.debug("Starting ECA server: " .. server_path)
local lua_cmd = string.format("lua ServerPath.run(%s)", Config.server_path or "")

Copilot AI Oct 6, 2025

Copy link

Choose a reason for hiding this comment

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

The Config.server_path value is not properly quoted for shell execution. If the path contains spaces or special characters, this will cause command execution failures.

Suggested change
local lua_cmd = string.format("lua ServerPath.run(%s)", Config.server_path or "")
local function lua_quote(str)
-- Escape backslashes and double quotes for Lua string literal
return '"' .. tostring(str):gsub('\\', '\\\\'):gsub('"', '\\"') .. '"'
end
local lua_cmd = string.format("lua ServerPath.run(%s)", lua_quote(Config.server_path or ""))

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This makes sense, I added this to utils.lua

local args = { server_path, "server" }
if Config.server_args and Config.server_args ~= "" then
vim.list_extend(args, vim.split(Config.server_args, " "))
end
local cmd = { nvim_exe, "--headless", "--noplugin", "-u", script_path, "-c", lua_cmd }

opts = vim.tbl_deep_extend("keep", opts, {
cmd = args,
text = true,
cwd = vim.fn.getcwd(),
stdin = true,
stdout = on_stdout(self),
stderr = on_stderr,
---@param out vim.SystemCompleted
on_exit = function(out)
if out.code ~= 0 then
require("eca.logger").notify(string.format("Server exited with status code %d", out.code), vim.log.levels.ERROR)
end
end,
})
vim.system(cmd, { text = true }, function(out)
if out.code ~= 0 then
Logger.notify(out.stderr, vim.log.levels.ERROR)
return
end

local started, process_or_err = pcall(vim.system, opts.cmd, {
cwd = opts.cwd,
text = opts.text,
stdin = opts.stdin,
stdout = opts.stdout,
stderr = opts.stderr,
}, opts.on_exit)

if not started then
self.process = nil
Logger.notify(vim.inspect(process_or_err), vim.log.levels.ERROR)
return
end
local stdout_lines = Utils.split_lines(out.stdout)
local server_path = stdout_lines[#stdout_lines]

self.process = process_or_err
if self.on_start then
self.on_start(process_or_err.pid)
end
Logger.debug("Starting ECA server: " .. server_path)

if opts.initialize then
self:initialize()
end
local args = { server_path, "server" }

if Config.server_args and Config.server_args ~= "" then
vim.list_extend(args, vim.split(Config.server_args, " "))
end

opts = vim.tbl_deep_extend("keep", opts, {
cmd = args,
text = true,
cwd = self.cwd,
stdin = true,
stdout = on_stdout(self),
stderr = on_stderr,
---@param output vim.SystemCompleted
on_exit = function(output)
if output.code ~= 0 then
require("eca.logger").notify(string.format("Server exited with status code %d", output.code), vim.log.levels
.ERROR)
end
end,
})

local started, process_or_err = pcall(vim.system, opts.cmd, {
cwd = opts.cwd,
text = opts.text,
stdin = opts.stdin,
stdout = opts.stdout,
stderr = opts.stderr,
}, opts.on_exit)

if not started then
self.process = nil
Logger.notify(vim.inspect(process_or_err), vim.log.levels.ERROR)
return
end

self.process = process_or_err
if self.on_start then
self.on_start(process_or_err.pid)
end

if opts.initialize then
self:initialize()
end
end)
end

function M:initialize()
local workspace_folders = {
{
name = vim.fn.fnamemodify(Utils.get_project_root(), ":t"),
uri = "file://" .. Utils.get_project_root(),
},
}

self:send_request("initialize", {
processId = vim.fn.getpid(),
clientInfo = {
Expand All @@ -168,7 +184,7 @@ function M:initialize()
chat = true,
},
},
workspaceFolders = workspace_folders,
workspaceFolders = vim.deepcopy(self.workspace_folders),
}, function(err, _)
if err then
Logger.notify("Could not initialize server: " .. err, vim.log.levels.ERROR)
Expand Down
23 changes: 23 additions & 0 deletions scripts/server_path.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
local ServerPath = {}

-- Export module
_G.ServerPath = ServerPath

Copilot AI Oct 6, 2025

Copy link

Choose a reason for hiding this comment

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

Setting globals in a script can lead to namespace pollution. Consider using a local approach or checking if the global already exists before setting it.

Suggested change
_G.ServerPath = ServerPath

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It was moved to a condition that consider the --headless execution of neovim, it should make sense now.

ServerPath.run = function(custom_path)
local path_finder = require("eca.path_finder"):new()
local path

local ok, err = pcall(function()
path = path_finder:find(custom_path)
end)

if not ok then
io.stderr:write(tostring(err))
os.exit(1)
end

io.stdout:write(tostring(path))
os.exit(0)
end

return ServerPath
16 changes: 15 additions & 1 deletion tests/test_server_integration.lua
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ T["server"] = MiniTest.new_set()
T["server"]["start"] = function()
child.lua("_G.server.start({cmd = {'fake'}})")
eq(child.lua_get("_G.server.process"), vim.NIL)
child.lua("_G.server:start()")
child.lua([[
_G.server:start()
_G.server_started = vim.wait(5000, function()
return _G.server and _G.server:is_running()
end, 50)
]])
eq(child.lua_get("_G.server_started"), true)
eq(child.lua_get("_G.server:is_running()"), true)
end

Expand Down Expand Up @@ -80,6 +86,14 @@ T["server"]["initialize"] = function()
end
child.lua("_G.method = " .. vim.inspect(test_case.method))
child.lua("_G.server:start({initialize = false})")
-- Wait for async server start
child.lua([[
_G.server_started = vim.wait(5000, function()
return _G.server and _G.server:is_running()
end, 50)
]])
eq(child.lua_get("_G.server_started"), true)

child.lua_func(function()
_G.server:send_request(_G.method, {}, function(err, result)
_G.err = err
Expand Down
Loading