Compare commits

..

4 commits

Author SHA1 Message Date
Barrett Ruth
0b137d64f6
perf: async enum file read (#10)
Some checks failed
quality / changes (push) Has been cancelled
test / Test (Neovim nightly) (push) Has been cancelled
test / Test (Neovim stable) (push) Has been cancelled
luarocks / quality (push) Has been cancelled
quality / Lua Format Check (push) Has been cancelled
quality / Lua Lint Check (push) Has been cancelled
quality / Lua Type Check (push) Has been cancelled
quality / Markdown Format Check (push) Has been cancelled
luarocks / publish (push) Has been cancelled
* perf: async enum file read

Problem: parse_enums read the bash-completion file with blocking
io.open/fd:read on the main thread, stalling the event loop.

Solution: read the file via vim.uv.fs_open/fstat/read with callbacks,
running in parallel with the ghostty config system call using the
same remaining-counter pattern as the other blink-cmp plugins.

* fix: lint warning and test mocks for async file read

Problem: selene flagged unused err3 variable, and test mock_enums
still mocked io.open instead of the new vim.uv.fs_* calls.

Solution: rename err3 to _, replace io.open mock with synchronous
vim.uv.fs_open/fs_fstat/fs_read/fs_close mocks using a sentinel fd.
2026-02-22 23:21:54 -05:00
Barrett Ruth
fe16245881
feat: add healthcheck (#9)
Some checks failed
luarocks / quality (push) Has been cancelled
luarocks / publish (push) Has been cancelled
* 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.

* fix: revert blanket diagnostics.disable and selene comments

Problem: .luarc.json blanket-disabled four diagnostic categories
project-wide, and selene inline directives were added to suppress
warnings on io.open monkey-patching in tests.

Solution: revert .luarc.json to match main and remove selene comments.

* refactor: reuse main module's bash completion path resolution in healthcheck

Problem: health.lua duplicated the entire bash completion file
resolution chain (exepath -> realpath -> prefix match -> path
construction) from the main module, risking drift if the logic changes.

Solution: extract M.bash_completion_path() from parse_enums() and call
it from both parse_enums and the healthcheck.
2026-02-20 21:01:32 -05:00
Barrett Ruth
072859ce04
perf: async cache initialization and remove deepcopy (#8)
* perf: async cache initialization and remove deepcopy

Problem: first completion request blocked the UI with a synchronous
vim.system():wait() call, and every subsequent key completion
unnecessarily deep-copied the entire cache.

Solution: use vim.system with an async callback to initialize the cache
without blocking. Queue pending completion requests during loading and
serve them once parsing finishes. Return cached keys directly instead
of deep-copying.

* fix: revert blanket diagnostics.disable and selene comments

Problem: .luarc.json blanket-disabled four diagnostic categories
project-wide, and selene inline directives were added to suppress
warnings on io.open monkey-patching in tests.

Solution: revert .luarc.json to match main and remove selene comments.
2026-02-20 21:01:21 -05:00
Barrett Ruth
b5b18520d7
feat: detect ghostty config files by canonical path (#7)
* feat: detect ghostty config files by canonical path

Problem: enabled() only checked for the 'ghostty' filetype, but many
users have ghostty config files detected as 'config' or with no
filetype set. These users got no completions.

Solution: check canonical ghostty config directories
($XDG_CONFIG_HOME/ghostty, ~/.config/ghostty, /etc/ghostty) when the
filetype is 'config' or empty, resolving symlinks to handle indirect
paths.

* fix: revert blanket diagnostics.disable and selene comments

Problem: .luarc.json blanket-disabled four diagnostic categories
project-wide, and selene inline directives were added to suppress
warnings on io.open monkey-patching in tests.

Solution: revert .luarc.json to match main and remove selene comments.

* fix(ci): resolve selene and lua-ls CI failures

Problem: selene flags io.open assignments in test mocks as
incorrect_standard_library_use, and lua-ls reports undefined blink.cmp
types and a need-check-nil on enums_cache. These are pre-existing
issues on main that surface when Lua files change.

Solution: add targeted selene allow comments on the two io.open
mock lines, add minimal blink.cmp type stubs for lua-ls, and
include enums_cache in the nil guard.
2026-02-20 20:56:26 -05:00
4 changed files with 221 additions and 28 deletions

View file

@ -13,9 +13,31 @@ 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()
return vim.bo.filetype == 'ghostty' if vim.bo.filetype == 'ghostty' then
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 ---@param stdout string
@ -45,28 +67,25 @@ local function parse_keys(stdout)
return items return items
end end
---@return table<string, string[]> ---@return string?
local function parse_enums() function M.bash_completion_path()
local bin = vim.fn.exepath('ghostty') local bin = vim.fn.exepath('ghostty')
if bin == '' then if bin == '' then
return {} return nil
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 {} return nil
end end
local prefix = real:match('(.*)/bin/ghostty$') local prefix = real:match('(.*)/bin/ghostty$')
if not prefix then if not prefix then
return {} return nil
end end
local path = prefix .. '/share/bash-completion/completions/ghostty.bash' return prefix .. '/share/bash-completion/completions/ghostty.bash'
local fd = io.open(path, 'r') end
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 = {}
@ -83,6 +102,9 @@ 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) local function respond(ctx, callback)
if not keys_cache or not enums_cache then
return
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('=')
@ -129,17 +151,56 @@ function M:get_completions(ctx, callback)
pending[#pending + 1] = { ctx = ctx, callback = callback } pending[#pending + 1] = { ctx = ctx, callback = callback }
if not loading then if not loading then
loading = true loading = true
vim.system({ 'ghostty', '+show-config', '--docs' }, {}, function(result) 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() vim.schedule(function()
keys_cache = parse_keys(result.stdout or '') keys_cache = parse_keys(config_out)
enums_cache = parse_enums() enums_cache = parse_enums(enums_content)
loading = false loading = false
for _, p in ipairs(pending) do for _, p in ipairs(pending) do
respond(p.ctx, p.callback) respond(p.ctx, p.callback)
end end
pending = {} pending = {}
end) end)
end
vim.system({ 'ghostty', '+show-config', '--docs' }, {}, function(result)
config_out = result.stdout or ''
on_all_done()
end) 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 end
return function() end return function() end
end end

View file

@ -0,0 +1,47 @@
local M = {}
function M.check()
vim.health.start('blink-cmp-ghostty')
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('ghostty')
if bin ~= '' then
vim.health.ok('ghostty executable found: ' .. bin)
else
vim.health.error('ghostty executable not found')
return
end
local result = vim.system({ 'ghostty', '+show-config', '--docs' }):wait()
if result.code == 0 and result.stdout and result.stdout ~= '' then
vim.health.ok('ghostty +show-config --docs produces output')
else
vim.health.warn(
'ghostty +show-config --docs failed (config key documentation will be unavailable)'
)
end
local source = require('blink-cmp-ghostty')
local path = source.bash_completion_path()
if not path then
vim.health.warn('could not resolve bash completion path (enum completions will be unavailable)')
return
end
local fd = io.open(path, 'r')
if fd then
fd:close()
vim.health.ok('bash completion file found: ' .. path)
else
vim.health.warn(
'bash completion file not found at ' .. path .. ' (enum completions will be unavailable)'
)
end
end
return M

View 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[]

View file

@ -29,14 +29,22 @@ local function mock_system()
on_exit(result) on_exit(result)
return {} return {}
end end
return { wait = function() return result end } return {
wait = function()
return result
end,
}
end end
local result = { stdout = '', code = 1 } local result = { stdout = '', code = 1 }
if on_exit then if on_exit then
on_exit(result) on_exit(result)
return {} return {}
end end
return { wait = function() return result end } return {
wait = function()
return result
end,
}
end end
vim.schedule = function(fn) vim.schedule = function(fn)
fn() fn()
@ -47,10 +55,15 @@ local function mock_system()
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_open = io.open local original_fs_open = vim.uv.fs_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
@ -64,22 +77,41 @@ local function mock_enums()
end end
return original_realpath(path) return original_realpath(path)
end end
io.open = function(path, mode) vim.uv.fs_open = function(path, flags, mode, callback)
if path:match('ghostty%.bash$') then if path:match('ghostty%.bash$') then
return { callback(nil, MOCK_FD)
read = function() return
return BASH_COMPLETION
end,
close = function() end,
}
end end
return original_open(path, mode) return original_fs_open(path, flags, mode, callback)
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
io.open = original_open vim.uv.fs_open = original_fs_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
@ -105,6 +137,43 @@ 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')