blink-cmp-ssh/lua/blink-cmp-ssh.lua
Barrett Ruth e9bb8de70f
refactor: derive enum values from man page instead of hardcoding
Problem: static_enums was a manually maintained table of ~40 keywords
that could go stale when OpenSSH adds or changes config options.

Solution: parse enum values directly from the ssh_config man page using
two strategies: definitive list sentences ("The argument must be X, Y,
or Z") and fallback extraction from "set to X" patterns. Also fixes
keyword detection to handle digits (ForwardX11) and inline definitions
(Tunnel, SetEnv). Covers 36/41 old keywords plus discovers 4 new ones
(IPQoS, SyslogFacility, ObscureKeystrokeTiming, ControlPersist).
2026-02-22 23:34:47 -05:00

359 lines
9.4 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
---@param fragment string
---@return string[]?
local function parse_value_list(fragment)
fragment = fragment:gsub('%b()', '')
fragment = fragment:gsub('"', '')
fragment = fragment:gsub(' or ', ', ')
fragment = fragment:gsub(' and ', ', ')
local vals = {}
local seen = {}
for piece in (fragment .. ','):gmatch('%s*(.-),%s*') do
piece = vim.trim(piece)
if piece ~= '' and not piece:find('%s') then
local val = piece:match('^([%a][%a%d-]+)$')
if val then
if not seen[val] then
seen[val] = true
vals[#vals + 1] = val
end
end
end
end
return #vals >= 2 and vals or nil
end
---@param man_stdout string
---@return table<string, string[]>
local function extract_enums_from_man(man_stdout)
local lines = {}
for line in (man_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%d]+)%s*$') or line:match('^ (%u[%a%d]+) ')
if kw then
defs[#defs + 1] = { line = i, keyword = kw }
end
end
local enums = {}
for idx, def in ipairs(defs) do
local block_end = (defs[idx + 1] and defs[idx + 1].line or #lines) - 1
local parts = {}
for k = def.line + 1, block_end do
parts[#parts + 1] = lines[k]
end
local text = table.concat(parts, ' ')
text = text:gsub(string.char(0xe2, 0x80, 0x90) .. '%s+', '')
text = text:gsub('%s+', ' ')
local list = text:match('[Tt]he argument must be (.-)%.')
or text:match('[Tt]he argument to this keyword must be (.-)%.')
or text:match('[Tt]he argument may be one of:? (.-)%.')
or text:match('[Tt]he argument may be (.-)%.')
or text:match('[Tt]he possible values are:? (.-)%.')
or text:match('[Vv]alid arguments are (.-)%.')
or text:match('[Vv]alid options are:? (.-)%.')
or text:match('[Aa]ccepted values are (.-)%.')
local vals = list and parse_value_list(list)
if not vals then
local fvals = {}
local fseen = {}
local function add(v)
if v and #v >= 2 and not fseen[v] then
fseen[v] = true
fvals[#fvals + 1] = v
end
end
for v1, v2 in text:gmatch(' is set to "?([%a][%a%d-]+)"? or "?([%a][%a%d-]+)"?') do
add(v1)
add(v2)
end
for v in text:gmatch(' is set to "?([%a][%a%d-]+)"?') do
add(v)
end
for v in text:gmatch('%u[%a%d]+ set to "?([%a][%a%d-]+)"?') do
add(v)
end
for v in text:gmatch('When set to "?([%a][%a%d-]+)"?') do
add(v)
end
for v in text:gmatch('[Ss]etting %S+ to "?([%a][%a%d-]+)"?') do
add(v)
end
for v in text:gmatch('value %S+ be set to "?([%a][%a%d-]+)"?') do
add(v)
end
local these = text:match('[Tt]hese options are:? (.-)%.')
if these then
local tv = parse_value_list(these)
if tv then
for _, v in ipairs(tv) do
add(v)
end
end
end
for v in text:gmatch('[Tt]he default is "?([%a][%a%d-]+)"?') do
if v ~= 'to' then
add(v)
end
end
for v in text:gmatch('[Tt]he default, "?([%a][%a%d-]+)"?') do
add(v)
end
for v in text:gmatch('[Aa]n argument of "?([%a][%a%d-]+)"?') do
add(v)
end
for v in text:gmatch('[Aa] value of "?([%a][%a%d-]+)"?') do
add(v)
end
if #fvals >= 2 then
vals = fvals
end
end
if vals then
enums[def.keyword:lower()] = vals
end
end
return enums
end
---@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%d]+)%s*$') or line:match('^ (%u[%a%d]+) ')
if kw then
local inline = line:match('^ %u[%a%d]+%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
---@param man_enums table<string, string[]>
---@return table<string, string[]>
local function parse_enums(stdout, man_enums)
local enums = {}
for k, v in pairs(man_enums) do
enums[k] = 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_me, me = pcall(extract_enums_from_man, man_out)
if not ok_me then
me = {}
end
local ok_en, en = pcall(parse_enums, enums_out, me)
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