feat: initial blink-cmp-ssh implementation

Problem: the existing blink-cmp-sshconfig plugin uses a synchronous,
build-time Python scraping approach that requires uv and make to
generate a static Lua file.

Solution: implement a runtime, async blink.cmp source that parses
ssh_config keywords from man ssh_config and enum values from ssh -Q
queries, matching the architecture of blink-cmp-tmux and
blink-cmp-ghostty.
This commit is contained in:
Barrett Ruth 2026-02-22 21:00:34 -05:00
parent 01d8b4eb5e
commit ad6c683052
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
28 changed files with 1158 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-ssh.
For bug reports, please [open an issue](https://github.com/barrettruth/blink-cmp-ssh/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-ssh/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-ssh',
},
opts = {
sources = {
default = { 'ssh' },
providers = {
ssh = {
name = 'SSH',
module = 'blink-cmp-ssh',
},
},
},
},
},
},
})
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-ssh/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-ssh/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 }}

12
.gitignore vendored Normal file
View file

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

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) 2026 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.

49
README.md Normal file
View file

@ -0,0 +1,49 @@
# blink-cmp-ssh
SSH configuration completion source for
[blink.cmp](https://github.com/saghen/blink.cmp).
## Features
- Completes `ssh_config` keywords with man page documentation
- Provides enum values for keywords with known option sets (ciphers,
MACs, key exchange algorithms, etc.)
- Keyword and enum data fetched asynchronously at runtime via
`man ssh_config` and `ssh -Q`
## Requirements
- Neovim 0.10.0+
- [blink.cmp](https://github.com/saghen/blink.cmp)
- `ssh` and `man` executables
## Installation
Install via
[luarocks](https://luarocks.org/modules/barrettruth/blink-cmp-ssh):
```
luarocks install blink-cmp-ssh
```
Or with lazy.nvim:
```lua
{
'saghen/blink.cmp',
dependencies = {
'barrettruth/blink-cmp-ssh',
},
opts = {
sources = {
default = { 'ssh' },
providers = {
ssh = {
name = 'SSH',
module = 'blink-cmp-ssh',
},
},
},
},
}
```

View file

@ -0,0 +1,30 @@
rockspec_format = '3.0'
package = 'blink-cmp-ssh'
version = 'scm-1'
source = {
url = 'git+https://github.com/barrettruth/blink-cmp-ssh.git',
}
description = {
summary = 'SSH configuration completion source for blink.cmp',
homepage = 'https://github.com/barrettruth/blink-cmp-ssh',
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-ssh SSH configuration 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
];
};
});
};
}

281
lua/blink-cmp-ssh.lua Normal file
View file

@ -0,0 +1,281 @@
---@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
---@type table<string, string[]>
local static_enums = {
AddKeysToAgent = { 'yes', 'no', 'ask', 'confirm' },
AddressFamily = { 'any', 'inet', 'inet6' },
BatchMode = { 'yes', 'no' },
CanonicalizeHostname = { 'yes', 'no', 'always' },
CanonicalizeFallbackLocal = { 'yes', 'no' },
CheckHostIP = { 'yes', 'no' },
ClearAllForwardings = { 'yes', 'no' },
Compression = { 'yes', 'no' },
ControlMaster = { 'yes', 'no', 'ask', 'auto', 'autoask' },
EnableEscapeCommandline = { 'yes', 'no' },
EnableSSHKeysign = { 'yes', 'no' },
ExitOnForwardFailure = { 'yes', 'no' },
FingerprintHash = { 'md5', 'sha256' },
ForkAfterAuthentication = { 'yes', 'no' },
ForwardAgent = { 'yes', 'no' },
ForwardX11 = { 'yes', 'no' },
ForwardX11Trusted = { 'yes', 'no' },
GatewayPorts = { 'yes', 'no' },
GSSAPIAuthentication = { 'yes', 'no' },
GSSAPIDelegateCredentials = { 'yes', 'no' },
HashKnownHosts = { 'yes', 'no' },
HostbasedAuthentication = { 'yes', 'no' },
IdentitiesOnly = { 'yes', 'no' },
KbdInteractiveAuthentication = { 'yes', 'no' },
LogLevel = {
'QUIET',
'FATAL',
'ERROR',
'INFO',
'VERBOSE',
'DEBUG',
'DEBUG1',
'DEBUG2',
'DEBUG3',
},
NoHostAuthenticationForLocalhost = { 'yes', 'no' },
PasswordAuthentication = { 'yes', 'no' },
PermitLocalCommand = { 'yes', 'no' },
PermitRemoteOpen = { 'any', 'none' },
ProxyUseFdpass = { 'yes', 'no' },
PubkeyAuthentication = { 'yes', 'no', 'unbound', 'host-bound' },
RequestTTY = { 'yes', 'no', 'force', 'auto' },
SessionType = { 'none', 'subsystem', 'default' },
StdinNull = { 'yes', 'no' },
StreamLocalBindUnlink = { 'yes', 'no' },
StrictHostKeyChecking = { 'yes', 'no', 'ask', 'accept-new', 'off' },
TCPKeepAlive = { 'yes', 'no' },
Tunnel = { 'yes', 'no', 'point-to-point', 'ethernet' },
UpdateHostKeys = { 'yes', 'no', 'ask' },
VerifyHostKeyDNS = { 'yes', 'no', 'ask' },
VisualHostKey = { 'yes', 'no' },
}
---@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+)%s*$') or line:match('^ (%u%a+) ')
if kw then
defs[#defs + 1] = { line = i, keyword = kw }
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 = {}
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
---@return table<string, string[]>
local function parse_enums(stdout)
local enums = {}
for k, v in pairs(static_enums) do
enums[k:lower()] = 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_en, en = pcall(parse_enums, enums_out)
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

View file

@ -0,0 +1,36 @@
local M = {}
function M.check()
vim.health.start('blink-cmp-ssh')
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('ssh')
if bin ~= '' then
vim.health.ok('ssh executable found: ' .. bin)
else
vim.health.error('ssh executable not found')
return
end
local man_bin = vim.fn.exepath('man')
if man_bin ~= '' then
vim.health.ok('man executable found: ' .. man_bin)
else
vim.health.warn('man executable not found (keyword descriptions will be unavailable)')
end
local result = vim.system({ 'ssh', '-Q', 'cipher' }):wait()
if result.code == 0 and result.stdout and result.stdout ~= '' then
vim.health.ok('ssh -Q cipher produces output')
else
vim.health.warn('ssh -Q cipher failed (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[]

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,
},
}

183
spec/ssh_spec.lua Normal file
View file

@ -0,0 +1,183 @@
local helpers = require('spec.helpers')
local MAN_PAGE = table.concat({
'SSH_CONFIG(5) File Formats Manual SSH_CONFIG(5)',
'',
' The possible keywords and their meanings are as follows:',
'',
' Host Restricts the following declarations (up to the next Host or',
' Match keyword) to be only for those hosts that match one of the',
' patterns given after the keyword.',
'',
' StrictHostKeyChecking',
' If this flag is set to yes, ssh(1) will never automatically add',
' host keys to the ~/.ssh/known_hosts file, and refuses to connect',
' to hosts whose host key has changed.',
'',
' Hostname',
' Specifies the real host name to log into.',
'',
}, '\n')
local SSH_Q_OUTPUT = table.concat({
'##cipher',
'aes128-ctr',
'aes256-ctr',
'chacha20-poly1305@openssh.com',
'##cipher-auth',
'chacha20-poly1305@openssh.com',
'##mac',
'hmac-sha2-256',
'##kex',
'curve25519-sha256',
'##key',
'ssh-ed25519',
'##key-cert',
'##key-plain',
'##key-sig',
'ssh-ed25519',
'##protocol-version',
'2',
'##compression',
'none',
'zlib@openssh.com',
'##sig',
}, '\n')
local function mock_system()
local original_system = vim.system
local original_schedule = vim.schedule
---@diagnostic disable-next-line: duplicate-set-field
vim.system = function(cmd, _, on_exit)
local stdout = ''
if cmd[1] == 'bash' and cmd[3] and cmd[3]:find('man %-P cat ssh_config') then
stdout = MAN_PAGE
elseif cmd[1] == 'bash' and cmd[3] and cmd[3]:find('ssh %-Q') then
stdout = SSH_Q_OUTPUT
end
local result = { stdout = stdout, code = 0 }
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_system
vim.schedule = original_schedule
end
end
describe('blink-cmp-ssh', function()
local restores = {}
before_each(function()
package.loaded['blink-cmp-ssh'] = nil
end)
after_each(function()
for _, fn in ipairs(restores) do
fn()
end
restores = {}
end)
describe('enabled', function()
it('returns true for sshconfig filetype', function()
local bufnr = helpers.create_buffer({}, 'sshconfig')
local source = require('blink-cmp-ssh')
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-ssh')
assert.is_false(source.enabled())
helpers.delete_buffer(bufnr)
end)
end)
describe('get_completions', function()
it('returns keyword items with Property kind on empty line', function()
restores[#restores + 1] = mock_system()
local source = require('blink-cmp-ssh').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(10, item.kind)
end
end)
it('returns keyword items on partial keyword', function()
restores[#restores + 1] = mock_system()
local source = require('blink-cmp-ssh').new()
local items
source:get_completions({ line = 'Str', cursor = { 1, 3 } }, function(response)
items = response.items
end)
assert.is_not_nil(items)
assert.equals(3, #items)
end)
it('includes man page documentation in items', function()
restores[#restores + 1] = mock_system()
local source = require('blink-cmp-ssh').new()
local items
source:get_completions({ line = '', cursor = { 1, 0 } }, function(response)
items = response.items
end)
local strict = vim.iter(items):find(function(item)
return item.label == 'StrictHostKeyChecking'
end)
assert.is_not_nil(strict)
assert.is_not_nil(strict.documentation)
assert.is_truthy(strict.documentation.value:find('known_hosts'))
end)
it('returns enum values after a known keyword', function()
restores[#restores + 1] = mock_system()
local source = require('blink-cmp-ssh').new()
local items
source:get_completions(
{ line = 'StrictHostKeyChecking ', cursor = { 1, 22 } },
function(response)
items = response.items
end
)
assert.is_not_nil(items)
assert.is_true(#items > 0)
for _, item in ipairs(items) do
assert.equals(20, item.kind)
end
end)
it('returns empty after a non-enum keyword', function()
restores[#restores + 1] = mock_system()
local source = require('blink-cmp-ssh').new()
local items
source:get_completions({ line = 'Hostname ', cursor = { 1, 9 } }, function(response)
items = response.items
end)
assert.equals(0, #items)
end)
it('returns a cancel function', function()
restores[#restores + 1] = mock_system()
local source = require('blink-cmp-ssh').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