diff --git a/.busted b/.busted new file mode 100644 index 0000000..53513b8 --- /dev/null +++ b/.busted @@ -0,0 +1,9 @@ +return { + _all = { + lua = 'nvim -l', + ROOT = { './spec/' }, + }, + default = { + verbose = true, + }, +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9de190 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = true +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml new file mode 100644 index 0000000..73eb031 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -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) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..1e9595e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..e961557 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..7ef0d31 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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 diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml new file mode 100644 index 0000000..9b6664e --- /dev/null +++ b/.github/workflows/luarocks.yaml @@ -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 }} diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..77049fd --- /dev/null +++ b/.github/workflows/quality.yaml @@ -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 . diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6910389 --- /dev/null +++ b/.github/workflows/test.yaml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cf5e89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.log + +.*cache* +CLAUDE.md +.claude/ + +node_modules/ + +result +result-* +.direnv/ +.envrc diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..b438cce --- /dev/null +++ b/.luarc.json @@ -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" +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5d1f13f --- /dev/null +++ b/.pre-commit-config.yaml @@ -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)$ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0663621 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "proseWrap": "always", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "none", + "semi": false, + "singleQuote": true +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3aba69a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed6f0b8 --- /dev/null +++ b/README.md @@ -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', + }, + }, + }, + }, +} +``` diff --git a/blink-cmp-ssh-scm-1.rockspec b/blink-cmp-ssh-scm-1.rockspec new file mode 100644 index 0000000..ed813f0 --- /dev/null +++ b/blink-cmp-ssh-scm-1.rockspec @@ -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', +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..7501bfa --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4463493 --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; + }); + }; +} diff --git a/lua/blink-cmp-ssh.lua b/lua/blink-cmp-ssh.lua new file mode 100644 index 0000000..9297dd7 --- /dev/null +++ b/lua/blink-cmp-ssh.lua @@ -0,0 +1,281 @@ +---@class blink-cmp-ssh : blink.cmp.Source +local M = {} + +---@type blink.cmp.CompletionItem[]? +local keywords_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 }) +end + +---@return boolean +function M.enabled() + return vim.bo.filetype == 'sshconfig' +end + +---@type table +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 +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 +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 diff --git a/lua/blink-cmp-ssh/health.lua b/lua/blink-cmp-ssh/health.lua new file mode 100644 index 0000000..2102730 --- /dev/null +++ b/lua/blink-cmp-ssh/health.lua @@ -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 diff --git a/lua/blink-cmp-ssh/types.lua b/lua/blink-cmp-ssh/types.lua new file mode 100644 index 0000000..d311414 --- /dev/null +++ b/lua/blink-cmp-ssh/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/selene.toml b/selene.toml new file mode 100644 index 0000000..96cf5ab --- /dev/null +++ b/selene.toml @@ -0,0 +1 @@ +std = 'vim' diff --git a/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..1635e3c --- /dev/null +++ b/spec/helpers.lua @@ -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 diff --git a/spec/minimal_init.lua b/spec/minimal_init.lua new file mode 100644 index 0000000..6fea9dd --- /dev/null +++ b/spec/minimal_init.lua @@ -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, + }, +} diff --git a/spec/ssh_spec.lua b/spec/ssh_spec.lua new file mode 100644 index 0000000..9a88a2a --- /dev/null +++ b/spec/ssh_spec.lua @@ -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) diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..01ded03 --- /dev/null +++ b/stylua.toml @@ -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 diff --git a/vim.toml b/vim.toml new file mode 100644 index 0000000..3a84ac2 --- /dev/null +++ b/vim.toml @@ -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