Skip to content

Commit cdc0d36

Browse files
authored
Merge pull request #22 from tomgeorge/feat/completion
Add nvim-cmp completion
2 parents beed598 + 950ec08 commit cdc0d36

File tree

5 files changed

+273
-0
lines changed

5 files changed

+273
-0
lines changed

after/plugin/eca-nvim.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---@diagnostic disable-next-line: param-type-mismatch
2+
local has_cmp, cmp = pcall(require, "cmp")
3+
if has_cmp then
4+
cmp.register_source("eca_commands", require("eca.completion.cmp.commands").new())
5+
cmp.register_source("eca_contexts", require("eca.completion.cmp.context").new())
6+
cmp.setup.filetype("eca-input", {
7+
sources = {
8+
{ name = "eca_contexts" },
9+
{ name = "eca_commands" },
10+
},
11+
})
12+
end
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---@param commands eca.ChatCommand
2+
---@return lsp.CompletionItem[]
3+
local function create_completion_items(commands)
4+
---@type lsp.CompletionItem[]
5+
local items = {}
6+
7+
if commands then
8+
for _, command in ipairs(commands) do
9+
---@type lsp.CompletionItem
10+
local item = {
11+
label = command.name or command.command,
12+
kind = vim.lsp.protocol.CompletionItemKind.Function,
13+
detail = command.description or ("ECA command: " .. (command.name or command.command)),
14+
documentation = command.help and {
15+
kind = "markdown",
16+
value = command.help,
17+
} or nil,
18+
insertText = command.name or command.command,
19+
}
20+
table.insert(items, item)
21+
end
22+
end
23+
24+
return items
25+
end
26+
27+
---@param s string
28+
---@return string
29+
local function get_query(s)
30+
local match = s:match("^>?%s*/(.*)$")
31+
return match
32+
end
33+
34+
-- Query server for available commands
35+
local function query_server_commands(query, callback)
36+
local eca = require("eca")
37+
if not eca.server or not eca.server:is_running() then
38+
callback({})
39+
return
40+
end
41+
42+
eca.server:send_request("chat/queryCommands", { query = query }, function(err, result)
43+
if err then
44+
callback({})
45+
return
46+
end
47+
48+
local items = create_completion_items(result.commands)
49+
callback(items)
50+
end)
51+
end
52+
53+
local source = {}
54+
55+
source.new = function()
56+
return setmetatable({ cache = {} }, { __index = source })
57+
end
58+
59+
source.get_trigger_characters = function()
60+
return { "/" }
61+
end
62+
63+
source.is_available = function()
64+
return vim.bo.filetype == "eca-input"
65+
end
66+
67+
---@diagnostic disable-next-line: unused-local
68+
source.complete = function(self, _, callback)
69+
-- Only complete if we're typing a command (starts with /)
70+
local line = vim.api.nvim_get_current_line()
71+
local query = get_query(line)
72+
73+
-- Only provide command completions when we have / followed by word characters or at word boundary
74+
if query then
75+
local bufnr = vim.api.nvim_get_current_buf()
76+
77+
if self.cache[bufnr] and self.cache[bufnr][query] then
78+
callback({ items = self.cache[bufnr][query], isIncomplete = false })
79+
else
80+
query_server_commands(query, function(items)
81+
callback({
82+
items = items,
83+
isIncomplete = false,
84+
})
85+
self.cache[bufnr] = {}
86+
self.cache[bufnr][query] = items
87+
end)
88+
end
89+
else
90+
callback({
91+
items = {},
92+
isIncomplete = false,
93+
})
94+
end
95+
end
96+
return source

lua/eca/completion/cmp/context.lua

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
local cmp = require("cmp")
2+
---@param context eca.ChatContext
3+
---@return lsp.CompletionItem
4+
local function as_completion_item(context)
5+
---@type lsp.CompletionItem
6+
---@diagnostic disable-next-line: missing-fields
7+
local item = {}
8+
if context.type == "file" then
9+
item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":."))
10+
item.kind = cmp.lsp.CompletionItemKind.File
11+
item.data = {
12+
context_item = context,
13+
}
14+
elseif context.type == "directory" then
15+
item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":."))
16+
item.kind = cmp.lsp.CompletionItemKind.Folder
17+
elseif context.type == "web" then
18+
item.label = context.url
19+
item.kind = cmp.lsp.CompletionItemKind.File
20+
elseif context.type == "repoMap" then
21+
item.label = "repoMap"
22+
item.kind = cmp.lsp.CompletionItemKind.Module
23+
item.detail = "Summary view of workspace files."
24+
elseif context.type == "mcpResource" then
25+
item.label = string.format("%s:%s", context.server, context.name)
26+
item.kind = cmp.lsp.CompletionItemKind.Struct
27+
item.detail = context.description
28+
end
29+
if not item.label then
30+
return {}
31+
end
32+
return item
33+
end
34+
35+
-- Query server for available contexts
36+
-- @param query string
37+
--
38+
local function query_server_contexts(query, callback)
39+
local eca = require("eca")
40+
if not eca.server or not eca.server:is_running() then
41+
callback({})
42+
return
43+
end
44+
45+
eca.server:send_request("chat/queryContext", { query = query }, function(err, result)
46+
if err then
47+
callback({})
48+
return
49+
end
50+
51+
local items = vim.iter(result.contexts):map(as_completion_item):totable()
52+
callback({ items = items, isIncomplete = true })
53+
end)
54+
end
55+
56+
local source = {}
57+
58+
function source.new()
59+
return setmetatable({}, { __index = source })
60+
end
61+
62+
function source:get_trigger_characters()
63+
return { "@" }
64+
end
65+
66+
function source:get_keyword_pattern()
67+
return [[\k\+]]
68+
end
69+
70+
function source:is_available()
71+
return vim.bo.filetype == "eca-input"
72+
end
73+
74+
---@param cursor_line string
75+
---@param cursor_position lsp.Position|vim.Position
76+
---@return string
77+
local function get_query(cursor_line, cursor_position)
78+
local before_cursor = cursor_line:sub(1, cursor_position.col)
79+
---@type string[]
80+
local matches = {}
81+
local it = before_cursor:gmatch("@([%w%./_\\%-~]*)")
82+
for match in it do
83+
table.insert(matches, match)
84+
end
85+
return matches[#matches]
86+
end
87+
88+
---@param params cmp.SourceCompletionApiParams
89+
---@diagnostic disable-next-line: unused-local
90+
function source:complete(params, callback)
91+
local query = get_query(params.context.cursor_line, params.context.cursor)
92+
if query then
93+
query_server_contexts(query, function(items)
94+
callback(items)
95+
end)
96+
end
97+
end
98+
99+
--- Taken from https://github.com/hrsh7th/cmp-path/blob/9a16c8e5d0be845f1d1b64a0331b155a9fe6db4d/lua/cmp_path/init.lua
100+
--- Show a small preview of file contexft items in the documentation window.
101+
---@param data eca.ChatContext
102+
---@return lsp.MarkupContent
103+
source._get_documentation = function(_, data, count)
104+
if data and data.path then
105+
local filename = data.path
106+
local binary = assert(io.open(data.path, "rb"))
107+
local first_kb = binary:read(1024)
108+
if first_kb and first_kb:find("\0") then
109+
return { kind = vim.lsp.protocol.MarkupKind.PlainText, value = "binary file" }
110+
end
111+
112+
local content = io.lines(data.path)
113+
114+
--- Try to support line ranges, I don't know if this works or not yet
115+
local start = data.lines_range and data.lines_range.start or 1
116+
local last = data.lines_range and data.lines_range["end"] or count
117+
local skip_lines = start - 1
118+
local take_lines = last - start
119+
local contents = vim.iter(content):skip(skip_lines):take(take_lines):totable()
120+
121+
local filetype = vim.filetype.match({ filename = filename })
122+
if not filetype then
123+
return { kind = vim.lsp.protocol.MarkupKind.PlainText, value = table.concat(contents, "\n") }
124+
end
125+
126+
table.insert(contents, 1, "```" .. filetype)
127+
table.insert(contents, "```")
128+
return { kind = vim.lsp.protocol.MarkupKind.Markdown, value = table.concat(contents, "\n") }
129+
end
130+
return {}
131+
end
132+
133+
---@param completion_item lsp.CompletionItem
134+
function source:resolve(completion_item, callback)
135+
if completion_item.data then
136+
local context_item = completion_item.data.context_item
137+
---@cast context_item eca.ChatContext
138+
if context_item.type == "file" then
139+
completion_item.documentation = self:_get_documentation(context_item, 20)
140+
end
141+
callback(completion_item)
142+
end
143+
end
144+
145+
return source

lua/eca/sidebar.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ function M:_create_containers()
367367
size = { height = input_height },
368368
buf_options = vim.tbl_deep_extend("force", base_buf_options, {
369369
modifiable = true,
370+
filetype = "eca-input",
370371
}),
371372
win_options = vim.tbl_deep_extend("force", base_win_options, {
372373
statusline = " ",

lua/eca/types.lua

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---@meta
2+
---@class eca.ChatContext
3+
---@field type string
4+
---@field path? string
5+
---@field lines_range? {start: integer, end: integer}
6+
---@field url? string
7+
---@field uri? string
8+
---@field name? string
9+
---@field description? string
10+
---@field mime_type? string
11+
---@field server string
12+
---
13+
---
14+
---@class eca.ChatCommand
15+
---@field name string
16+
---@field description string
17+
---@field help string
18+
---@field type "mcp-prompt"|"native"
19+
---@field arguments {name: string, description?: string, required: boolean}[]

0 commit comments

Comments
 (0)