feat: initial release

This commit is contained in:
Barrett Ruth 2026-02-20 16:55:55 -05:00
commit a2fe68b248
26 changed files with 960 additions and 0 deletions

9
.busted Normal file
View file

@ -0,0 +1,9 @@
return {
_all = {
lua = 'nvim -l',
ROOT = { './spec/' },
},
default = {
verbose = true,
},
}

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
insert_final_newline = true
charset = utf-8
[*.lua]
indent_style = space
indent_size = 2

17
.github/DISCUSSION_TEMPLATE/q-a.yaml vendored Normal file
View file

@ -0,0 +1,17 @@
title: 'Q&A'
labels: []
body:
- type: markdown
attributes:
value: |
Use this space for questions, ideas, and general discussion about blink-cmp-tmux.
For bug reports, please [open an issue](https://github.com/barrettruth/blink-cmp-tmux/issues/new/choose) instead.
- type: textarea
attributes:
label: Question or topic
validations:
required: true
- type: textarea
attributes:
label: Context
description: Any relevant details (Neovim version, config, screenshots)

85
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View file

@ -0,0 +1,85 @@
name: Bug Report
description: Report a bug
title: 'bug: '
labels: [bug]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/blink-cmp-tmux/issues)
required: true
- label: I have updated to the latest version
required: true
- type: textarea
attributes:
label: 'Neovim version'
description: 'Output of `nvim --version`'
render: text
validations:
required: true
- type: input
attributes:
label: 'Operating system'
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
validations:
required: true
- type: textarea
attributes:
label: Description
description: What happened? What did you expect?
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug
value: |
1.
2.
3.
validations:
required: true
- type: textarea
attributes:
label: Minimal reproduction
description: |
Save the script below as `repro.lua`, edit if needed, and run:
```
nvim -u repro.lua
```
Confirm the bug reproduces with this config before submitting.
render: lua
value: |
vim.env.LAZY_STDPATH = '.repro'
load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))()
require('lazy.nvim').setup({
spec = {
{
'saghen/blink.cmp',
dependencies = {
'barrettruth/blink-cmp-tmux',
},
opts = {
sources = {
default = { 'tmux' },
providers = {
tmux = {
name = 'Tmux',
module = 'blink-cmp-tmux',
},
},
},
},
},
},
})
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yaml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/barrettruth/blink-cmp-tmux/discussions
about: Ask questions and discuss ideas

View file

@ -0,0 +1,30 @@
name: Feature Request
description: Suggest a feature
title: 'feat: '
labels: [enhancement]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/blink-cmp-tmux/issues)
required: true
- type: textarea
attributes:
label: Problem
description: What problem does this solve?
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered

21
.github/workflows/luarocks.yaml vendored Normal file
View file

@ -0,0 +1,21 @@
name: luarocks
on:
push:
tags:
- 'v*'
jobs:
quality:
uses: ./.github/workflows/quality.yaml
publish:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nvim-neorocks/luarocks-tag-release@v7
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}

89
.github/workflows/quality.yaml vendored Normal file
View file

@ -0,0 +1,89 @@
name: quality
on:
workflow_call:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
lua: ${{ steps.changes.outputs.lua }}
markdown: ${{ steps.changes.outputs.markdown }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
lua:
- 'lua/**'
- 'plugin/**'
- '*.lua'
- '.luarc.json'
- '*.toml'
markdown:
- '*.md'
lua-format:
name: Lua Format Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: 2.1.0
args: --check .
lua-lint:
name: Lua Lint Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Lint with Selene
uses: NTBBloodbath/selene-action@v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --display-style quiet .
lua-typecheck:
name: Lua Type Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Run Lua LS Type Check
uses: mrcjkb/lua-typecheck-action@v0
with:
checklevel: Warning
directories: lua
configpath: .luarc.json
markdown-format:
name: Markdown Format Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.markdown == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install prettier
run: pnpm add -g prettier@3.1.0
- name: Check markdown formatting with prettier
run: prettier --check .

22
.github/workflows/test.yaml vendored Normal file
View file

@ -0,0 +1,22 @@
name: test
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
nvim: [stable, nightly]
name: Test (Neovim ${{ matrix.nvim }})
steps:
- uses: actions/checkout@v4
- uses: nvim-neorocks/nvim-busted-action@v1
with:
nvim_version: ${{ matrix.nvim }}

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
*.log
.*cache*
CLAUDE.md
.claude/
node_modules/
result
result-*
.direnv/

8
.luarc.json Normal file
View file

@ -0,0 +1,8 @@
{
"runtime.version": "Lua 5.1",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false,
"completion.callSnippet": "Replace"
}

17
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,17 @@
minimum_pre_commit_version: '3.5.0'
repos:
- repo: https://github.com/JohnnyMorganz/StyLua
rev: v2.3.1
hooks:
- id: stylua-github
name: stylua (Lua formatter)
files: \.lua$
pass_filenames: true
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
name: prettier
files: \.(md|toml|yaml|yml|sh)$

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
node_modules/

9
.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"proseWrap": "always",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"semi": false,
"singleQuote": true
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Raphael
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

47
README.md Normal file
View file

@ -0,0 +1,47 @@
# blink-cmp-tmux
Tmux command completion source for
[blink.cmp](https://github.com/saghen/blink.cmp).
## Features
- Completes tmux commands with full usage signatures
- Includes alias information for commands
- Shows man page descriptions in documentation
## Requirements
- Neovim 0.10.0+
- [blink.cmp](https://github.com/saghen/blink.cmp)
- tmux
## Installation
Install via
[luarocks](https://luarocks.org/modules/barrettruth/blink-cmp-tmux):
```
luarocks install blink-cmp-tmux
```
Or with lazy.nvim:
```lua
{
'saghen/blink.cmp',
dependencies = {
'barrettruth/blink-cmp-tmux',
},
opts = {
sources = {
default = { 'tmux' },
providers = {
tmux = {
name = 'Tmux',
module = 'blink-cmp-tmux',
},
},
},
},
}
```

View file

@ -0,0 +1,30 @@
rockspec_format = '3.0'
package = 'blink-cmp-tmux'
version = 'scm-1'
source = {
url = 'git+https://github.com/barrettruth/blink-cmp-tmux.git',
}
description = {
summary = 'Tmux command completion source for blink.cmp',
homepage = 'https://github.com/barrettruth/blink-cmp-tmux',
license = 'MIT',
}
dependencies = {
'lua >= 5.1',
}
test_dependencies = {
'nlua',
'busted >= 2.1.1',
}
test = {
type = 'busted',
}
build = {
type = 'builtin',
}

43
flake.lock generated Normal file
View file

@ -0,0 +1,43 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771207753,
"narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d1c15b7d5806069da59e819999d70e1cec0760bf",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

35
flake.nix Normal file
View file

@ -0,0 +1,35 @@
{
description = "blink-cmp-tmux tmux command completion source for blink.cmp";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
systems.url = "github:nix-systems/default";
};
outputs =
{
nixpkgs,
systems,
...
}:
let
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = [
(pkgs.luajit.withPackages (
ps: with ps; [
busted
nlua
]
))
pkgs.prettier
pkgs.stylua
pkgs.selene
];
};
});
};
}

155
lua/blink-cmp-tmux.lua Normal file
View file

@ -0,0 +1,155 @@
---@class blink-cmp-tmux : blink.cmp.Source
local M = {}
---@type blink.cmp.CompletionItem[]?
local cache = nil
function M.new()
return setmetatable({}, { __index = M })
end
---@return boolean
function M.enabled()
return vim.bo.filetype == 'tmux'
end
---@return table<string, string>
local function parse_descriptions()
local result = vim.system({ 'bash', '-c', 'MANWIDTH=80 man -P cat tmux 2>/dev/null' }):wait()
local stdout = result.stdout or ''
local lines = {}
for line in (stdout .. '\n'):gmatch('(.-)\n') do
lines[#lines + 1] = line
end
local cmd_result = vim.system({ 'tmux', 'list-commands', '-F', '#{command_list_name}' }):wait()
local cmds = {}
for name in (cmd_result.stdout or ''):gmatch('[^\n]+') do
cmds[name] = true
end
local defs = {}
for i, line in ipairs(lines) do
local cmd = line:match('^ ([a-z][a-z-]+)')
if cmd and cmds[cmd] then
local rest = line:sub(8 + #cmd)
if rest == '' or rest:match('^%s+%[') or rest:match('^%s%s+') then
defs[#defs + 1] = { line = i, cmd = cmd }
end
end
end
local descs = {}
for idx, def in ipairs(defs) do
local block_end = (defs[idx + 1] and defs[idx + 1].line or #lines) - 1
local j = def.line + 1
while j <= block_end do
local l = lines[j]
if l:match('^%s+%(alias:') then
j = j + 1
elseif l:match('^ ') then
local stripped = vim.trim(l)
if stripped == '' or stripped:match('[%[%]]') then
j = j + 1
else
break
end
elseif vim.trim(l) == '' then
j = j + 1
else
break
end
end
local desc_lines = {}
for k = j, 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('\xe2\x80\x90 ', '')
desc = desc:gsub(' +', ' ')
if desc ~= '' then
descs[def.cmd] = desc
end
end
return descs
end
---@param output string
---@param descs table<string, string>
---@return blink.cmp.CompletionItem[]
local function parse(output, descs)
local Kind = require('blink.cmp.types').CompletionItemKind
local items = {}
for line in output:gmatch('[^\n]+') do
local name, alias = line:match('^([a-z-]+)%s+%(([a-z-]+)%)')
if not name then
name = line:match('^([a-z-]+)')
end
if name then
local doc_parts = {}
if alias then
doc_parts[#doc_parts + 1] = ('**alias**: `%s`\n'):format(alias)
end
doc_parts[#doc_parts + 1] = '```\n' .. line .. '\n```'
if descs[name] then
doc_parts[#doc_parts + 1] = '\n---\n\n' .. descs[name]
end
items[#items + 1] = {
label = name,
kind = Kind.Keyword,
documentation = {
kind = 'markdown',
value = table.concat(doc_parts),
},
}
end
end
return items
end
---@param ctx blink.cmp.Context
---@param callback fun(response: blink.cmp.CompletionResponse)
---@return fun()
function M:get_completions(ctx, callback)
if not cache then
local ok, descs = pcall(parse_descriptions)
if not ok then
descs = {}
end
local result = vim.system({ 'tmux', 'list-commands' }):wait()
cache = parse(result.stdout or '', descs)
end
local before = ctx.line:sub(1, ctx.cursor[2])
if before:match('^%s*[a-z-]*$') then
callback({
is_incomplete_forward = false,
is_incomplete_backward = false,
items = vim.deepcopy(cache),
})
else
callback({ items = {} })
end
return function() end
end
return M

1
selene.toml Normal file
View file

@ -0,0 +1 @@
std = 'vim'

54
spec/helpers.lua Normal file
View file

@ -0,0 +1,54 @@
local plugin_dir = vim.fn.getcwd()
vim.opt.runtimepath:prepend(plugin_dir)
if not package.loaded['blink.cmp.types'] then
package.loaded['blink.cmp.types'] = {
CompletionItemKind = {
Text = 1,
Method = 2,
Function = 3,
Constructor = 4,
Field = 5,
Variable = 6,
Class = 7,
Interface = 8,
Module = 9,
Property = 10,
Unit = 11,
Value = 12,
Enum = 13,
Keyword = 14,
Snippet = 15,
Color = 16,
File = 17,
Reference = 18,
Folder = 19,
EnumMember = 20,
Constant = 21,
Struct = 22,
Event = 23,
Operator = 24,
TypeParameter = 25,
},
}
end
local M = {}
function M.create_buffer(lines, filetype)
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
if filetype then
vim.api.nvim_set_option_value('filetype', filetype, { buf = bufnr })
end
vim.api.nvim_set_current_buf(bufnr)
return bufnr
end
function M.delete_buffer(bufnr)
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
return M

34
spec/minimal_init.lua Normal file
View file

@ -0,0 +1,34 @@
vim.cmd([[set runtimepath=$VIMRUNTIME]])
vim.opt.runtimepath:append('.')
vim.opt.packpath = {}
vim.opt.loadplugins = false
package.loaded['blink.cmp.types'] = {
CompletionItemKind = {
Text = 1,
Method = 2,
Function = 3,
Constructor = 4,
Field = 5,
Variable = 6,
Class = 7,
Interface = 8,
Module = 9,
Property = 10,
Unit = 11,
Value = 12,
Enum = 13,
Keyword = 14,
Snippet = 15,
Color = 16,
File = 17,
Reference = 18,
Folder = 19,
EnumMember = 20,
Constant = 21,
Struct = 22,
Event = 23,
Operator = 24,
TypeParameter = 25,
},
}

166
spec/tmux_spec.lua Normal file
View file

@ -0,0 +1,166 @@
local helpers = require('spec.helpers')
local TMUX_COMMANDS = table.concat({
'bind-key (bind) [-lnrN:T:] key command [arguments]',
'display-message (display) [-aINpv] [-c target-client] [-d delay] [-t target-pane] [message]',
'set-option (set) [-aFgopqsuUw] [-t target-pane] option [value]',
}, '\n')
local TMUX_NAMES = 'bind-key\ndisplay-message\nset-option\n'
local MAN_PAGE = table.concat({
' bind-key [-lnrN:T:] key command [arguments]',
' (alias: bind)',
'',
' Bind a key to a command.',
'',
' display-message [-aINpv] [-c target-client] [-d delay] [-t target-pane] [message]',
' (alias: display)',
'',
' Display a message.',
'',
' set-option [-aFgopqsuUw] [-t target-pane] option [value]',
' (alias: set)',
'',
' Set a window option.',
}, '\n')
local function mock_system()
local original = vim.system
---@diagnostic disable-next-line: duplicate-set-field
vim.system = function(cmd)
if cmd[1] == 'bash' then
return {
wait = function()
return { stdout = MAN_PAGE, code = 0 }
end,
}
elseif cmd[1] == 'tmux' and cmd[2] == 'list-commands' then
if cmd[3] == '-F' then
return {
wait = function()
return { stdout = TMUX_NAMES, code = 0 }
end,
}
end
return {
wait = function()
return { stdout = TMUX_COMMANDS, code = 0 }
end,
}
end
return {
wait = function()
return { stdout = '', code = 1 }
end,
}
end
return function()
vim.system = original
end
end
describe('blink-cmp-tmux', function()
local restore
before_each(function()
package.loaded['blink-cmp-tmux'] = nil
restore = mock_system()
end)
after_each(function()
if restore then
restore()
end
end)
describe('enabled', function()
it('returns true for tmux filetype', function()
local bufnr = helpers.create_buffer({}, 'tmux')
local source = require('blink-cmp-tmux')
assert.is_true(source.enabled())
helpers.delete_buffer(bufnr)
end)
it('returns false for other filetypes', function()
local bufnr = helpers.create_buffer({}, 'lua')
local source = require('blink-cmp-tmux')
assert.is_false(source.enabled())
helpers.delete_buffer(bufnr)
end)
end)
describe('get_completions', function()
it('returns items with Keyword kind', function()
local source = require('blink-cmp-tmux').new()
local items
source:get_completions({ line = '', cursor = { 1, 0 } }, function(response)
items = response.items
end)
assert.is_not_nil(items)
assert.equals(3, #items)
for _, item in ipairs(items) do
assert.equals(14, item.kind)
end
end)
it('returns items on empty line', function()
local source = require('blink-cmp-tmux').new()
local items
source:get_completions({ line = '', cursor = { 1, 0 } }, function(response)
items = response.items
end)
assert.equals(3, #items)
end)
it('returns items when typing command prefix', function()
local source = require('blink-cmp-tmux').new()
local items
source:get_completions({ line = 'bind', cursor = { 1, 4 } }, function(response)
items = response.items
end)
assert.is_true(#items > 0)
end)
it('returns empty after command arguments', function()
local source = require('blink-cmp-tmux').new()
local items
source:get_completions({ line = 'bind-key -n ', cursor = { 1, 12 } }, function(response)
items = response.items
end)
assert.equals(0, #items)
end)
it('includes documentation with man page description', function()
local source = require('blink-cmp-tmux').new()
local items
source:get_completions({ line = '', cursor = { 1, 0 } }, function(response)
items = response.items
end)
local bind = vim.iter(items):find(function(item)
return item.label == 'bind-key'
end)
assert.is_not_nil(bind)
assert.is_not_nil(bind.documentation)
assert.is_truthy(bind.documentation.value:find('Bind a key'))
end)
it('includes alias in documentation', function()
local source = require('blink-cmp-tmux').new()
local items
source:get_completions({ line = '', cursor = { 1, 0 } }, function(response)
items = response.items
end)
local bind = vim.iter(items):find(function(item)
return item.label == 'bind-key'
end)
assert.is_truthy(bind.documentation.value:find('alias'))
end)
it('returns a cancel function', function()
local source = require('blink-cmp-tmux').new()
local cancel = source:get_completions({ line = '', cursor = { 1, 0 } }, function() end)
assert.is_function(cancel)
end)
end)
end)

8
stylua.toml Normal file
View file

@ -0,0 +1,8 @@
column_width = 100
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"
call_parentheses = "Always"
[sort_requires]
enabled = true

33
vim.toml Normal file
View file

@ -0,0 +1,33 @@
[selene]
base = "lua51"
name = "vim"
[vim]
any = true
[jit]
any = true
[bit]
any = true
[assert]
any = true
[describe]
any = true
[it]
any = true
[before_each]
any = true
[after_each]
any = true
[spy]
any = true
[stub]
any = true