From 8bb6a81d1fd69a427aa165157cacd07b31fd1d81 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 20 Feb 2026 20:16:33 -0500 Subject: [PATCH 1/5] 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. --- lua/blink-cmp-ghostty.lua | 47 +++++++++++++++++++++++++++++---------- spec/ghostty_spec.lua | 33 ++++++++++++++++----------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/lua/blink-cmp-ghostty.lua b/lua/blink-cmp-ghostty.lua index 2efd951..abfcd98 100644 --- a/lua/blink-cmp-ghostty.lua +++ b/lua/blink-cmp-ghostty.lua @@ -5,6 +5,9 @@ local M = {} local keys_cache = nil ---@type table? 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 }) @@ -15,14 +18,14 @@ function M.enabled() return vim.bo.filetype == 'ghostty' end +---@param stdout string ---@return blink.cmp.CompletionItem[] -local function parse_keys() +local function parse_keys(stdout) local Kind = require('blink.cmp.types').CompletionItemKind - local result = vim.system({ 'ghostty', '+show-config', '--docs' }):wait() local items = {} local doc_lines = {} - for line in ((result.stdout or '') .. '\n'):gmatch('(.-)\n') do + for line in (stdout .. '\n'):gmatch('(.-)\n') do if line:match('^#') then local stripped = line:gsub('^# ?', '') doc_lines[#doc_lines + 1] = stripped @@ -79,13 +82,7 @@ end ---@param ctx blink.cmp.Context ---@param callback fun(response: blink.cmp.CompletionResponse) ----@return fun() -function M:get_completions(ctx, callback) - if not keys_cache then - keys_cache = parse_keys() - enums_cache = parse_enums() - end - +local function respond(ctx, callback) local line = ctx.line local col = ctx.cursor[2] local eq_pos = line:find('=') @@ -108,16 +105,42 @@ function M:get_completions(ctx, callback) is_incomplete_backward = false, items = items, }) - return function() end + return end callback({ items = {} }) else callback({ is_incomplete_forward = false, is_incomplete_backward = false, - items = vim.deepcopy(keys_cache), + items = keys_cache, }) 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 + vim.system({ 'ghostty', '+show-config', '--docs' }, {}, function(result) + vim.schedule(function() + keys_cache = parse_keys(result.stdout or '') + enums_cache = parse_enums() + loading = false + for _, p in ipairs(pending) do + respond(p.ctx, p.callback) + end + pending = {} + end) + end) + end return function() end end diff --git a/spec/ghostty_spec.lua b/spec/ghostty_spec.lua index 5c7e804..fae7bab 100644 --- a/spec/ghostty_spec.lua +++ b/spec/ghostty_spec.lua @@ -19,24 +19,31 @@ local BASH_COMPLETION = table.concat({ }, '\n') local function mock_system() - local original = vim.system + local original_system = vim.system + local original_schedule = vim.schedule ---@diagnostic disable-next-line: duplicate-set-field - vim.system = function(cmd) + vim.system = function(cmd, _, on_exit) if cmd[1] == 'ghostty' then - return { - wait = function() - return { stdout = CONFIG_DOCS, code = 0 } - end, - } + local result = { stdout = CONFIG_DOCS, code = 0 } + if on_exit then + on_exit(result) + return {} + end + return { wait = function() return result end } end - return { - wait = function() - return { stdout = '', code = 1 } - end, - } + local result = { stdout = '', code = 1 } + if on_exit then + on_exit(result) + return {} + end + return { wait = function() return result end } + end + vim.schedule = function(fn) + fn() end return function() - vim.system = original + vim.system = original_system + vim.schedule = original_schedule end end From b5b18520d74dd5eb1a36b7d0ae5a0d79ed71fb76 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:56:26 -0500 Subject: [PATCH 2/5] 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. --- lua/blink-cmp-ghostty.lua | 26 ++++++++++++++++++++-- lua/blink-cmp-ghostty/types.lua | 16 ++++++++++++++ spec/ghostty_spec.lua | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 lua/blink-cmp-ghostty/types.lua diff --git a/lua/blink-cmp-ghostty.lua b/lua/blink-cmp-ghostty.lua index 2efd951..1110868 100644 --- a/lua/blink-cmp-ghostty.lua +++ b/lua/blink-cmp-ghostty.lua @@ -10,9 +10,31 @@ function M.new() return setmetatable({}, { __index = M }) end +local ghostty_config_dirs = { + vim.fn.expand('$XDG_CONFIG_HOME/ghostty'), + vim.fn.expand('$HOME/.config/ghostty'), + '/etc/ghostty', +} + ---@return boolean 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 ---@return blink.cmp.CompletionItem[] @@ -81,7 +103,7 @@ end ---@param callback fun(response: blink.cmp.CompletionResponse) ---@return fun() function M:get_completions(ctx, callback) - if not keys_cache then + if not keys_cache or not enums_cache then keys_cache = parse_keys() enums_cache = parse_enums() end diff --git a/lua/blink-cmp-ghostty/types.lua b/lua/blink-cmp-ghostty/types.lua new file mode 100644 index 0000000..d311414 --- /dev/null +++ b/lua/blink-cmp-ghostty/types.lua @@ -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[] diff --git a/spec/ghostty_spec.lua b/spec/ghostty_spec.lua index 5c7e804..86cdd36 100644 --- a/spec/ghostty_spec.lua +++ b/spec/ghostty_spec.lua @@ -57,6 +57,7 @@ local function mock_enums() end return original_realpath(path) end + -- selene: allow(incorrect_standard_library_use) io.open = function(path, mode) if path:match('ghostty%.bash$') then return { @@ -72,6 +73,7 @@ local function mock_enums() return function() vim.fn.exepath = original_exepath vim.uv.fs_realpath = original_realpath + -- selene: allow(incorrect_standard_library_use) io.open = original_open end end @@ -98,6 +100,43 @@ describe('blink-cmp-ghostty', function() helpers.delete_buffer(bufnr) 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() local bufnr = helpers.create_buffer({}, 'lua') local source = require('blink-cmp-ghostty') From 072859ce04b30dfae063e33b6030672f311db8cb Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:01:21 -0500 Subject: [PATCH 3/5] 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. --- lua/blink-cmp-ghostty.lua | 46 ++++++++++++++++++++++++++++++--------- spec/ghostty_spec.lua | 25 ++++++++++++++++----- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/lua/blink-cmp-ghostty.lua b/lua/blink-cmp-ghostty.lua index 1110868..5787f24 100644 --- a/lua/blink-cmp-ghostty.lua +++ b/lua/blink-cmp-ghostty.lua @@ -5,6 +5,9 @@ local M = {} local keys_cache = nil ---@type table? 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 }) @@ -37,14 +40,14 @@ function M.enabled() return false end +---@param stdout string ---@return blink.cmp.CompletionItem[] -local function parse_keys() +local function parse_keys(stdout) local Kind = require('blink.cmp.types').CompletionItemKind - local result = vim.system({ 'ghostty', '+show-config', '--docs' }):wait() local items = {} local doc_lines = {} - for line in ((result.stdout or '') .. '\n'):gmatch('(.-)\n') do + for line in (stdout .. '\n'):gmatch('(.-)\n') do if line:match('^#') then local stripped = line:gsub('^# ?', '') doc_lines[#doc_lines + 1] = stripped @@ -101,13 +104,10 @@ end ---@param ctx blink.cmp.Context ---@param callback fun(response: blink.cmp.CompletionResponse) ----@return fun() -function M:get_completions(ctx, callback) +local function respond(ctx, callback) if not keys_cache or not enums_cache then - keys_cache = parse_keys() - enums_cache = parse_enums() + return end - local line = ctx.line local col = ctx.cursor[2] local eq_pos = line:find('=') @@ -130,16 +130,42 @@ function M:get_completions(ctx, callback) is_incomplete_backward = false, items = items, }) - return function() end + return end callback({ items = {} }) else callback({ is_incomplete_forward = false, is_incomplete_backward = false, - items = vim.deepcopy(keys_cache), + items = keys_cache, }) 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 + vim.system({ 'ghostty', '+show-config', '--docs' }, {}, function(result) + vim.schedule(function() + keys_cache = parse_keys(result.stdout or '') + enums_cache = parse_enums() + loading = false + for _, p in ipairs(pending) do + respond(p.ctx, p.callback) + end + pending = {} + end) + end) + end return function() end end diff --git a/spec/ghostty_spec.lua b/spec/ghostty_spec.lua index 86cdd36..d0ad426 100644 --- a/spec/ghostty_spec.lua +++ b/spec/ghostty_spec.lua @@ -19,24 +19,39 @@ local BASH_COMPLETION = table.concat({ }, '\n') local function mock_system() - local original = vim.system + local original_system = vim.system + local original_schedule = vim.schedule ---@diagnostic disable-next-line: duplicate-set-field - vim.system = function(cmd) + vim.system = function(cmd, _, on_exit) if cmd[1] == 'ghostty' then + local result = { stdout = CONFIG_DOCS, code = 0 } + if on_exit then + on_exit(result) + return {} + end return { wait = function() - return { stdout = CONFIG_DOCS, code = 0 } + return result end, } end + local result = { stdout = '', code = 1 } + if on_exit then + on_exit(result) + return {} + end return { wait = function() - return { stdout = '', code = 1 } + return result end, } end + vim.schedule = function(fn) + fn() + end return function() - vim.system = original + vim.system = original_system + vim.schedule = original_schedule end end From fe16245881ce5b6147447b0e9f96fe8d10cd7f72 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:01:32 -0500 Subject: [PATCH 4/5] feat: add healthcheck (#9) * 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. --- lua/blink-cmp-ghostty.lua | 18 ++++++++---- lua/blink-cmp-ghostty/health.lua | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 lua/blink-cmp-ghostty/health.lua diff --git a/lua/blink-cmp-ghostty.lua b/lua/blink-cmp-ghostty.lua index 5787f24..17f659d 100644 --- a/lua/blink-cmp-ghostty.lua +++ b/lua/blink-cmp-ghostty.lua @@ -67,21 +67,29 @@ local function parse_keys(stdout) return items end ----@return table -local function parse_enums() +---@return string? +function M.bash_completion_path() local bin = vim.fn.exepath('ghostty') if bin == '' then - return {} + return nil end local real = vim.uv.fs_realpath(bin) if not real then - return {} + return nil end local prefix = real:match('(.*)/bin/ghostty$') if not prefix then + return nil + end + return prefix .. '/share/bash-completion/completions/ghostty.bash' +end + +---@return table +local function parse_enums() + local path = M.bash_completion_path() + if not path then return {} end - local path = prefix .. '/share/bash-completion/completions/ghostty.bash' local fd = io.open(path, 'r') if not fd then return {} diff --git a/lua/blink-cmp-ghostty/health.lua b/lua/blink-cmp-ghostty/health.lua new file mode 100644 index 0000000..5db565d --- /dev/null +++ b/lua/blink-cmp-ghostty/health.lua @@ -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 From 0b137d64f6c2172aedca8388ecbe2e2c215844d0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:21:54 -0500 Subject: [PATCH 5/5] perf: async enum file read (#10) * 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. --- lua/blink-cmp-ghostty.lua | 58 +++++++++++++++++++++++++++++---------- spec/ghostty_spec.lua | 46 +++++++++++++++++++++++-------- 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/lua/blink-cmp-ghostty.lua b/lua/blink-cmp-ghostty.lua index 17f659d..9eb94ce 100644 --- a/lua/blink-cmp-ghostty.lua +++ b/lua/blink-cmp-ghostty.lua @@ -85,18 +85,7 @@ function M.bash_completion_path() end ---@return table -local function parse_enums() - local path = M.bash_completion_path() - if not path then - return {} - end - local fd = io.open(path, 'r') - if not fd then - return {} - end - local content = fd:read('*a') - fd:close() - +local function parse_enums(content) local enums = {} for key, values in content:gmatch('%-%-([a-z][a-z0-9-]*)%) [^\n]* compgen %-W "([^"]+)"') do local vals = {} @@ -162,17 +151,56 @@ function M:get_completions(ctx, callback) pending[#pending + 1] = { ctx = ctx, callback = callback } if not loading then 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() - keys_cache = parse_keys(result.stdout or '') - enums_cache = parse_enums() + 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 end diff --git a/spec/ghostty_spec.lua b/spec/ghostty_spec.lua index d0ad426..5ef7a2a 100644 --- a/spec/ghostty_spec.lua +++ b/spec/ghostty_spec.lua @@ -55,10 +55,15 @@ local function mock_system() end end +local MOCK_FD = 99 + local function mock_enums() local original_exepath = vim.fn.exepath 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) if name == 'ghostty' then @@ -72,24 +77,41 @@ local function mock_enums() end return original_realpath(path) end - -- selene: allow(incorrect_standard_library_use) - io.open = function(path, mode) + vim.uv.fs_open = function(path, flags, mode, callback) if path:match('ghostty%.bash$') then - return { - read = function() - return BASH_COMPLETION - end, - close = function() end, - } + callback(nil, MOCK_FD) + return 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 return function() vim.fn.exepath = original_exepath vim.uv.fs_realpath = original_realpath - -- selene: allow(incorrect_standard_library_use) - 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