Compare commits

..

1 commit

Author SHA1 Message Date
7af8029d53
feat: add healthcheck
Problem: users had no way to diagnose why completions were missing or
incomplete beyond checking for the ghostty executable.

Solution: add a :checkhealth module that verifies blink.cmp is
installed, ghostty is on PATH, +show-config --docs produces output,
and the bash completion file exists for enum values.
2026-02-20 20:17:06 -05:00
4 changed files with 50 additions and 225 deletions

View file

@ -5,49 +5,24 @@ local M = {}
local keys_cache = nil local keys_cache = nil
---@type table<string, string[]>? ---@type table<string, string[]>?
local enums_cache = nil local enums_cache = nil
local loading = false
---@type {ctx: blink.cmp.Context, callback: fun(response: blink.cmp.CompletionResponse)}[]
local pending = {}
function M.new() function M.new()
return setmetatable({}, { __index = M }) return setmetatable({}, { __index = M })
end end
local ghostty_config_dirs = {
vim.fn.expand('$XDG_CONFIG_HOME/ghostty'),
vim.fn.expand('$HOME/.config/ghostty'),
'/etc/ghostty',
}
---@return boolean ---@return boolean
function M.enabled() function M.enabled()
if vim.bo.filetype == 'ghostty' then return vim.bo.filetype == 'ghostty'
return true
end
if vim.bo.filetype ~= 'config' and vim.bo.filetype ~= '' then
return false
end
local path = vim.api.nvim_buf_get_name(0)
if path == '' then
return false
end
local real = vim.uv.fs_realpath(path) or path
for _, dir in ipairs(ghostty_config_dirs) do
if real:find(dir, 1, true) == 1 then
return true
end
end
return false
end end
---@param stdout string
---@return blink.cmp.CompletionItem[] ---@return blink.cmp.CompletionItem[]
local function parse_keys(stdout) local function parse_keys()
local Kind = require('blink.cmp.types').CompletionItemKind local Kind = require('blink.cmp.types').CompletionItemKind
local result = vim.system({ 'ghostty', '+show-config', '--docs' }):wait()
local items = {} local items = {}
local doc_lines = {} local doc_lines = {}
for line in (stdout .. '\n'):gmatch('(.-)\n') do for line in ((result.stdout or '') .. '\n'):gmatch('(.-)\n') do
if line:match('^#') then if line:match('^#') then
local stripped = line:gsub('^# ?', '') local stripped = line:gsub('^# ?', '')
doc_lines[#doc_lines + 1] = stripped doc_lines[#doc_lines + 1] = stripped
@ -67,25 +42,28 @@ local function parse_keys(stdout)
return items return items
end end
---@return string? ---@return table<string, string[]>
function M.bash_completion_path() local function parse_enums()
local bin = vim.fn.exepath('ghostty') local bin = vim.fn.exepath('ghostty')
if bin == '' then if bin == '' then
return nil return {}
end end
local real = vim.uv.fs_realpath(bin) local real = vim.uv.fs_realpath(bin)
if not real then if not real then
return nil return {}
end end
local prefix = real:match('(.*)/bin/ghostty$') local prefix = real:match('(.*)/bin/ghostty$')
if not prefix then if not prefix then
return nil return {}
end end
return prefix .. '/share/bash-completion/completions/ghostty.bash' local path = prefix .. '/share/bash-completion/completions/ghostty.bash'
end local fd = io.open(path, 'r')
if not fd then
return {}
end
local content = fd:read('*a')
fd:close()
---@return table<string, string[]>
local function parse_enums(content)
local enums = {} local enums = {}
for key, values in content:gmatch('%-%-([a-z][a-z0-9-]*)%) [^\n]* compgen %-W "([^"]+)"') do for key, values in content:gmatch('%-%-([a-z][a-z0-9-]*)%) [^\n]* compgen %-W "([^"]+)"') do
local vals = {} local vals = {}
@ -101,10 +79,13 @@ end
---@param ctx blink.cmp.Context ---@param ctx blink.cmp.Context
---@param callback fun(response: blink.cmp.CompletionResponse) ---@param callback fun(response: blink.cmp.CompletionResponse)
local function respond(ctx, callback) ---@return fun()
if not keys_cache or not enums_cache then function M:get_completions(ctx, callback)
return if not keys_cache then
keys_cache = parse_keys()
enums_cache = parse_enums()
end end
local line = ctx.line local line = ctx.line
local col = ctx.cursor[2] local col = ctx.cursor[2]
local eq_pos = line:find('=') local eq_pos = line:find('=')
@ -127,81 +108,16 @@ local function respond(ctx, callback)
is_incomplete_backward = false, is_incomplete_backward = false,
items = items, items = items,
}) })
return return function() end
end end
callback({ items = {} }) callback({ items = {} })
else else
callback({ callback({
is_incomplete_forward = false, is_incomplete_forward = false,
is_incomplete_backward = false, is_incomplete_backward = false,
items = keys_cache, items = vim.deepcopy(keys_cache),
}) })
end end
end
---@param ctx blink.cmp.Context
---@param callback fun(response: blink.cmp.CompletionResponse)
---@return fun()
function M:get_completions(ctx, callback)
if keys_cache then
respond(ctx, callback)
return function() end
end
pending[#pending + 1] = { ctx = ctx, callback = callback }
if not loading then
loading = true
local config_out, enums_content
local remaining = 2
local function on_all_done()
remaining = remaining - 1
if remaining > 0 then
return
end
vim.schedule(function()
keys_cache = parse_keys(config_out)
enums_cache = parse_enums(enums_content)
loading = false
for _, p in ipairs(pending) do
respond(p.ctx, p.callback)
end
pending = {}
end)
end
vim.system({ 'ghostty', '+show-config', '--docs' }, {}, function(result)
config_out = result.stdout or ''
on_all_done()
end)
local path = M.bash_completion_path()
if not path then
enums_content = ''
on_all_done()
else
vim.uv.fs_open(path, 'r', 438, function(err, fd)
if err or not fd then
enums_content = ''
on_all_done()
return
end
vim.uv.fs_fstat(fd, function(err2, stat)
if err2 or not stat then
vim.uv.fs_close(fd)
enums_content = ''
on_all_done()
return
end
vim.uv.fs_read(fd, stat.size, 0, function(_, data)
vim.uv.fs_close(fd)
enums_content = data or ''
on_all_done()
end)
end)
end)
end
end
return function() end return function() end
end end

View file

@ -22,25 +22,26 @@ function M.check()
if result.code == 0 and result.stdout and result.stdout ~= '' then if result.code == 0 and result.stdout and result.stdout ~= '' then
vim.health.ok('ghostty +show-config --docs produces output') vim.health.ok('ghostty +show-config --docs produces output')
else else
vim.health.warn( vim.health.warn('ghostty +show-config --docs failed (config key documentation will be unavailable)')
'ghostty +show-config --docs failed (config key documentation will be unavailable)'
)
end end
local source = require('blink-cmp-ghostty') local real = vim.uv.fs_realpath(bin)
local path = source.bash_completion_path() if not real then
if not path then vim.health.warn('could not resolve ghostty symlink (enum completions will be unavailable)')
vim.health.warn('could not resolve bash completion path (enum completions will be unavailable)')
return return
end end
local prefix = real:match('(.*)/bin/ghostty$')
if not prefix then
vim.health.warn('ghostty binary is not in a standard bin/ directory (enum completions will be unavailable)')
return
end
local path = prefix .. '/share/bash-completion/completions/ghostty.bash'
local fd = io.open(path, 'r') local fd = io.open(path, 'r')
if fd then if fd then
fd:close() fd:close()
vim.health.ok('bash completion file found: ' .. path) vim.health.ok('bash completion file found: ' .. path)
else else
vim.health.warn( vim.health.warn('bash completion file not found at ' .. path .. ' (enum completions will be unavailable)')
'bash completion file not found at ' .. path .. ' (enum completions will be unavailable)'
)
end end
end end

View file

@ -1,16 +0,0 @@
---@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[]

View file

@ -19,51 +19,31 @@ local BASH_COMPLETION = table.concat({
}, '\n') }, '\n')
local function mock_system() local function mock_system()
local original_system = vim.system local original = vim.system
local original_schedule = vim.schedule
---@diagnostic disable-next-line: duplicate-set-field ---@diagnostic disable-next-line: duplicate-set-field
vim.system = function(cmd, _, on_exit) vim.system = function(cmd)
if cmd[1] == 'ghostty' then if cmd[1] == 'ghostty' then
local result = { stdout = CONFIG_DOCS, code = 0 }
if on_exit then
on_exit(result)
return {}
end
return { return {
wait = function() wait = function()
return result return { stdout = CONFIG_DOCS, code = 0 }
end, end,
} }
end end
local result = { stdout = '', code = 1 }
if on_exit then
on_exit(result)
return {}
end
return { return {
wait = function() wait = function()
return result return { stdout = '', code = 1 }
end, end,
} }
end end
vim.schedule = function(fn)
fn()
end
return function() return function()
vim.system = original_system vim.system = original
vim.schedule = original_schedule
end end
end end
local MOCK_FD = 99
local function mock_enums() local function mock_enums()
local original_exepath = vim.fn.exepath local original_exepath = vim.fn.exepath
local original_realpath = vim.uv.fs_realpath local original_realpath = vim.uv.fs_realpath
local original_fs_open = vim.uv.fs_open local original_open = io.open
local original_fs_fstat = vim.uv.fs_fstat
local original_fs_read = vim.uv.fs_read
local original_fs_close = vim.uv.fs_close
vim.fn.exepath = function(name) vim.fn.exepath = function(name)
if name == 'ghostty' then if name == 'ghostty' then
@ -77,41 +57,22 @@ local function mock_enums()
end end
return original_realpath(path) return original_realpath(path)
end end
vim.uv.fs_open = function(path, flags, mode, callback) io.open = function(path, mode)
if path:match('ghostty%.bash$') then if path:match('ghostty%.bash$') then
callback(nil, MOCK_FD) return {
return read = function()
return BASH_COMPLETION
end,
close = function() end,
}
end end
return original_fs_open(path, flags, mode, callback) return original_open(path, mode)
end
vim.uv.fs_fstat = function(fd, callback)
if fd == MOCK_FD then
callback(nil, { size = #BASH_COMPLETION })
return
end
return original_fs_fstat(fd, callback)
end
vim.uv.fs_read = function(fd, size, offset, callback)
if fd == MOCK_FD then
callback(nil, BASH_COMPLETION)
return
end
return original_fs_read(fd, size, offset, callback)
end
vim.uv.fs_close = function(fd, ...)
if fd == MOCK_FD then
return true
end
return original_fs_close(fd, ...)
end end
return function() return function()
vim.fn.exepath = original_exepath vim.fn.exepath = original_exepath
vim.uv.fs_realpath = original_realpath vim.uv.fs_realpath = original_realpath
vim.uv.fs_open = original_fs_open io.open = original_open
vim.uv.fs_fstat = original_fs_fstat
vim.uv.fs_read = original_fs_read
vim.uv.fs_close = original_fs_close
end end
end end
@ -137,43 +98,6 @@ describe('blink-cmp-ghostty', function()
helpers.delete_buffer(bufnr) helpers.delete_buffer(bufnr)
end) end)
it('returns true for config filetype in ghostty config dir', function()
local source = require('blink-cmp-ghostty')
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_set_option_value('filetype', 'config', { buf = bufnr })
local config_path = vim.fn.expand('$HOME/.config/ghostty/config')
vim.api.nvim_buf_set_name(bufnr, config_path)
local original_realpath = vim.uv.fs_realpath
vim.uv.fs_realpath = function(p)
if p == config_path then
return config_path
end
return original_realpath(p)
end
assert.is_true(source.enabled())
vim.uv.fs_realpath = original_realpath
helpers.delete_buffer(bufnr)
end)
it('returns false for config filetype outside ghostty dir', function()
local source = require('blink-cmp-ghostty')
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_set_option_value('filetype', 'config', { buf = bufnr })
vim.api.nvim_buf_set_name(bufnr, '/tmp/some-other/config')
local original_realpath = vim.uv.fs_realpath
vim.uv.fs_realpath = function(p)
if p == '/tmp/some-other/config' then
return '/tmp/some-other/config'
end
return original_realpath(p)
end
assert.is_false(source.enabled())
vim.uv.fs_realpath = original_realpath
helpers.delete_buffer(bufnr)
end)
it('returns false for other filetypes', function() it('returns false for other filetypes', function()
local bufnr = helpers.create_buffer({}, 'lua') local bufnr = helpers.create_buffer({}, 'lua')
local source = require('blink-cmp-ghostty') local source = require('blink-cmp-ghostty')