blink-cmp-ssh/lua/blink-cmp-ssh.lua
Barrett Ruth 679a4bf804
Some checks are pending
quality / changes (push) Waiting to run
quality / Lua Format Check (push) Blocked by required conditions
quality / Lua Lint Check (push) Blocked by required conditions
quality / Lua Type Check (push) Blocked by required conditions
quality / Markdown Format Check (push) Blocked by required conditions
test / Test (Neovim nightly) (push) Waiting to run
test / Test (Neovim stable) (push) Waiting to run
fix: capture inline description for keywords like Host and User
Problem: keywords with description text on the same line (e.g.
"Host    Restricts the following...") had their first line of
documentation silently dropped because the parser started collecting
at the line after the keyword.

Solution: extract trailing text from the keyword line and prepend it
to the description block.
2026-02-22 21:29:07 -05:00

285 lines
7.6 KiB
Lua

---@class blink-cmp-ssh : blink.cmp.Source
local M = {}
---@type blink.cmp.CompletionItem[]?
local keywords_cache = nil
---@type table<string, string[]>?
local enums_cache = nil
local loading = false
---@type {ctx: blink.cmp.Context, callback: fun(response: blink.cmp.CompletionResponse)}[]
local pending = {}
function M.new()
return setmetatable({}, { __index = M })
end
---@return boolean
function M.enabled()
return vim.bo.filetype == 'sshconfig'
end
---@type table<string, string[]>
local static_enums = {
AddKeysToAgent = { 'yes', 'no', 'ask', 'confirm' },
AddressFamily = { 'any', 'inet', 'inet6' },
BatchMode = { 'yes', 'no' },
CanonicalizeHostname = { 'yes', 'no', 'always' },
CanonicalizeFallbackLocal = { 'yes', 'no' },
CheckHostIP = { 'yes', 'no' },
ClearAllForwardings = { 'yes', 'no' },
Compression = { 'yes', 'no' },
ControlMaster = { 'yes', 'no', 'ask', 'auto', 'autoask' },
EnableEscapeCommandline = { 'yes', 'no' },
EnableSSHKeysign = { 'yes', 'no' },
ExitOnForwardFailure = { 'yes', 'no' },
FingerprintHash = { 'md5', 'sha256' },
ForkAfterAuthentication = { 'yes', 'no' },
ForwardAgent = { 'yes', 'no' },
ForwardX11 = { 'yes', 'no' },
ForwardX11Trusted = { 'yes', 'no' },
GatewayPorts = { 'yes', 'no' },
GSSAPIAuthentication = { 'yes', 'no' },
GSSAPIDelegateCredentials = { 'yes', 'no' },
HashKnownHosts = { 'yes', 'no' },
HostbasedAuthentication = { 'yes', 'no' },
IdentitiesOnly = { 'yes', 'no' },
KbdInteractiveAuthentication = { 'yes', 'no' },
LogLevel = {
'QUIET',
'FATAL',
'ERROR',
'INFO',
'VERBOSE',
'DEBUG',
'DEBUG1',
'DEBUG2',
'DEBUG3',
},
NoHostAuthenticationForLocalhost = { 'yes', 'no' },
PasswordAuthentication = { 'yes', 'no' },
PermitLocalCommand = { 'yes', 'no' },
PermitRemoteOpen = { 'any', 'none' },
ProxyUseFdpass = { 'yes', 'no' },
PubkeyAuthentication = { 'yes', 'no', 'unbound', 'host-bound' },
RequestTTY = { 'yes', 'no', 'force', 'auto' },
SessionType = { 'none', 'subsystem', 'default' },
StdinNull = { 'yes', 'no' },
StreamLocalBindUnlink = { 'yes', 'no' },
StrictHostKeyChecking = { 'yes', 'no', 'ask', 'accept-new', 'off' },
TCPKeepAlive = { 'yes', 'no' },
Tunnel = { 'yes', 'no', 'point-to-point', 'ethernet' },
UpdateHostKeys = { 'yes', 'no', 'ask' },
VerifyHostKeyDNS = { 'yes', 'no', 'ask' },
VisualHostKey = { 'yes', 'no' },
}
---@type table<string, string[]>
local query_to_keywords = {
cipher = { 'Ciphers' },
['cipher-auth'] = { 'Ciphers' },
mac = { 'MACs' },
kex = { 'KexAlgorithms' },
key = { 'HostKeyAlgorithms', 'PubkeyAcceptedAlgorithms' },
['key-sig'] = { 'CASignatureAlgorithms' },
}
---@param stdout string
---@return blink.cmp.CompletionItem[]
local function parse_keywords(stdout)
local Kind = require('blink.cmp.types').CompletionItemKind
local lines = {}
for line in (stdout .. '\n'):gmatch('(.-)\n') do
lines[#lines + 1] = line
end
local defs = {}
for i, line in ipairs(lines) do
local kw = line:match('^ (%u%a+)%s*$') or line:match('^ (%u%a+) ')
if kw then
local inline = line:match('^ %u%a+%s%s%s+(.+)')
defs[#defs + 1] = { line = i, keyword = kw, inline = inline }
end
end
local items = {}
for idx, def in ipairs(defs) do
local block_end = (defs[idx + 1] and defs[idx + 1].line or #lines) - 1
local desc_lines = {}
if def.inline then
desc_lines[#desc_lines + 1] = ' ' .. def.inline
end
for k = def.line + 1, block_end do
desc_lines[#desc_lines + 1] = lines[k]
end
local paragraphs = { {} }
for _, dl in ipairs(desc_lines) do
local stripped = vim.trim(dl)
if stripped == '' then
if #paragraphs[#paragraphs] > 0 then
paragraphs[#paragraphs + 1] = {}
end
else
local para = paragraphs[#paragraphs]
para[#para + 1] = stripped
end
end
local parts = {}
for _, para in ipairs(paragraphs) do
if #para > 0 then
parts[#parts + 1] = table.concat(para, ' ')
end
end
local desc = table.concat(parts, '\n\n')
desc = desc:gsub(string.char(0xe2, 0x80, 0x90) .. ' ', '')
desc = desc:gsub(' +', ' ')
items[#items + 1] = {
label = def.keyword,
kind = Kind.Property,
documentation = desc ~= '' and { kind = 'markdown', value = desc } or nil,
}
end
return items
end
---@param stdout string
---@return table<string, string[]>
local function parse_enums(stdout)
local enums = {}
for k, v in pairs(static_enums) do
enums[k:lower()] = v
end
local current_query = nil
for line in (stdout .. '\n'):gmatch('(.-)\n') do
local query = line:match('^##(.+)')
if query then
current_query = query
elseif current_query and line ~= '' then
local keywords = query_to_keywords[current_query]
if keywords then
for _, kw in ipairs(keywords) do
local key = kw:lower()
if not enums[key] then
enums[key] = {}
end
local seen = {}
for _, existing in ipairs(enums[key]) do
seen[existing] = true
end
if not seen[line] then
enums[key][#enums[key] + 1] = line
end
end
end
end
end
return enums
end
---@param ctx blink.cmp.Context
---@param callback fun(response: blink.cmp.CompletionResponse)
local function respond(ctx, callback)
if not keywords_cache or not enums_cache then
return
end
local before = ctx.line:sub(1, ctx.cursor[2])
if before:match('^%s*%a*$') then
callback({
is_incomplete_forward = false,
is_incomplete_backward = false,
items = keywords_cache,
})
return
end
local keyword = before:match('^%s*(%S+)')
if keyword then
local vals = enums_cache[keyword:lower()]
if vals then
local Kind = require('blink.cmp.types').CompletionItemKind
local items = {}
for _, v in ipairs(vals) do
items[#items + 1] = {
label = v,
kind = Kind.EnumMember,
filterText = v,
}
end
callback({
is_incomplete_forward = false,
is_incomplete_backward = false,
items = items,
})
return
end
end
callback({ items = {} })
end
---@param ctx blink.cmp.Context
---@param callback fun(response: blink.cmp.CompletionResponse)
---@return fun()
function M:get_completions(ctx, callback)
if keywords_cache then
respond(ctx, callback)
return function() end
end
pending[#pending + 1] = { ctx = ctx, callback = callback }
if not loading then
loading = true
local man_out, enums_out
local remaining = 2
local function on_all_done()
remaining = remaining - 1
if remaining > 0 then
return
end
vim.schedule(function()
local ok_kw, kw = pcall(parse_keywords, man_out)
if not ok_kw then
kw = {}
end
keywords_cache = kw
local ok_en, en = pcall(parse_enums, enums_out)
if not ok_en then
en = {}
end
enums_cache = en
loading = false
for _, p in ipairs(pending) do
respond(p.ctx, p.callback)
end
pending = {}
end)
end
vim.system(
{ 'bash', '-c', 'MANWIDTH=80 man -P cat ssh_config 2>/dev/null' },
{},
function(result)
man_out = result.stdout or ''
on_all_done()
end
)
vim.system({
'bash',
'-c',
'for q in cipher cipher-auth mac kex key key-cert key-plain key-sig protocol-version compression sig; do echo "##$q"; ssh -Q "$q" 2>/dev/null; done',
}, {}, function(result)
enums_out = result.stdout or ''
on_all_done()
end)
end
return function() end
end
return M