feat: initial blink-cmp-ssh implementation
Problem: the existing blink-cmp-sshconfig plugin uses a synchronous, build-time Python scraping approach that requires uv and make to generate a static Lua file. Solution: implement a runtime, async blink.cmp source that parses ssh_config keywords from man ssh_config and enum values from ssh -Q queries, matching the architecture of blink-cmp-tmux and blink-cmp-ghostty.
This commit is contained in:
parent
01d8b4eb5e
commit
ad6c683052
28 changed files with 1158 additions and 0 deletions
281
lua/blink-cmp-ssh.lua
Normal file
281
lua/blink-cmp-ssh.lua
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
---@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
|
||||
defs[#defs + 1] = { line = i, keyword = kw }
|
||||
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 = {}
|
||||
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
|
||||
36
lua/blink-cmp-ssh/health.lua
Normal file
36
lua/blink-cmp-ssh/health.lua
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
local M = {}
|
||||
|
||||
function M.check()
|
||||
vim.health.start('blink-cmp-ssh')
|
||||
|
||||
local ok = pcall(require, 'blink.cmp')
|
||||
if ok then
|
||||
vim.health.ok('blink.cmp is installed')
|
||||
else
|
||||
vim.health.error('blink.cmp is not installed')
|
||||
end
|
||||
|
||||
local bin = vim.fn.exepath('ssh')
|
||||
if bin ~= '' then
|
||||
vim.health.ok('ssh executable found: ' .. bin)
|
||||
else
|
||||
vim.health.error('ssh executable not found')
|
||||
return
|
||||
end
|
||||
|
||||
local man_bin = vim.fn.exepath('man')
|
||||
if man_bin ~= '' then
|
||||
vim.health.ok('man executable found: ' .. man_bin)
|
||||
else
|
||||
vim.health.warn('man executable not found (keyword descriptions will be unavailable)')
|
||||
end
|
||||
|
||||
local result = vim.system({ 'ssh', '-Q', 'cipher' }):wait()
|
||||
if result.code == 0 and result.stdout and result.stdout ~= '' then
|
||||
vim.health.ok('ssh -Q cipher produces output')
|
||||
else
|
||||
vim.health.warn('ssh -Q cipher failed (enum completions will be unavailable)')
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
16
lua/blink-cmp-ssh/types.lua
Normal file
16
lua/blink-cmp-ssh/types.lua
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---@class blink.cmp.Source
|
||||
|
||||
---@class blink.cmp.CompletionItem
|
||||
---@field label string
|
||||
---@field kind? integer
|
||||
---@field documentation? {kind: string, value: string}
|
||||
---@field filterText? string
|
||||
|
||||
---@class blink.cmp.Context
|
||||
---@field line string
|
||||
---@field cursor integer[]
|
||||
|
||||
---@class blink.cmp.CompletionResponse
|
||||
---@field is_incomplete_forward? boolean
|
||||
---@field is_incomplete_backward? boolean
|
||||
---@field items blink.cmp.CompletionItem[]
|
||||
Loading…
Add table
Add a link
Reference in a new issue