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:
parent
01d8b4eb5e
commit
ad6c683052
28 changed files with 1158 additions and 0 deletions
9
.busted
Normal file
9
.busted
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
return {
|
||||
_all = {
|
||||
lua = 'nvim -l',
|
||||
ROOT = { './spec/' },
|
||||
},
|
||||
default = {
|
||||
verbose = true,
|
||||
},
|
||||
}
|
||||
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
17
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
Normal 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
85
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal 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
5
.github/ISSUE_TEMPLATE/config.yaml
vendored
Normal 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
|
||||
30
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
30
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal 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
21
.github/workflows/luarocks.yaml
vendored
Normal 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
89
.github/workflows/quality.yaml
vendored
Normal 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
22
.github/workflows/test.yaml
vendored
Normal 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
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
*.log
|
||||
|
||||
.*cache*
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
node_modules/
|
||||
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
.envrc
|
||||
8
.luarc.json
Normal file
8
.luarc.json
Normal 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
17
.pre-commit-config.yaml
Normal 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
1
.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
9
.prettierrc
Normal file
9
.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"proseWrap": "always",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
49
README.md
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
30
blink-cmp-ssh-scm-1.rockspec
Normal file
30
blink-cmp-ssh-scm-1.rockspec
Normal 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
43
flake.lock
generated
Normal 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
35
flake.nix
Normal 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
281
lua/blink-cmp-ssh.lua
Normal 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
|
||||
36
lua/blink-cmp-ssh/health.lua
Normal file
36
lua/blink-cmp-ssh/health.lua
Normal 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
|
||||
16
lua/blink-cmp-ssh/types.lua
Normal file
16
lua/blink-cmp-ssh/types.lua
Normal 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
1
selene.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
std = 'vim'
|
||||
54
spec/helpers.lua
Normal file
54
spec/helpers.lua
Normal 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
34
spec/minimal_init.lua
Normal 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
183
spec/ssh_spec.lua
Normal 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
8
stylua.toml
Normal 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
33
vim.toml
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue