add shout.vim Neovim plugin

This commit is contained in:
Chris Wanstrath 2026-03-14 13:11:08 -07:00
parent 00b844cafc
commit ddd69e375f
5 changed files with 254 additions and 0 deletions

44
vim/README.md Normal file
View File

@ -0,0 +1,44 @@
# shout.vim
Neovim plugin for `.shout` shell integration test files.
## Features
- **Syntax highlighting** for commands, expected output, directives, wildcards, exit codes, and comments
- **`:ShoutRun`** — run the current `.shout` file, results in quickfix
- **`:ShoutUpdate`** — run with `--update` to capture actual output into the file
- **`:ShoutRunAll`** — run all test files
## Install
### lazy.nvim
```lua
{
dir = "~/path/to/shout/vim",
ft = "shout",
}
```
### Manual / symlink
Symlink or copy the `vim/` directory into your nvim runtime path:
```sh
mkdir -p ~/.local/share/nvim/site/pack/shout/start
ln -s /path/to/shout/vim ~/.local/share/nvim/site/pack/shout/start/shout
```
## Syntax elements
| Pattern | Highlight |
|---|---|
| `$ command` | Statement (prompt as Special) |
| `$# comment` | Comment |
| `@env KEY=VALUE` | PreProc / Identifier / String |
| `@setup`, `@teardown` | PreProc |
| Expected output | String |
| `...` (wildcard) | WarningMsg |
| `[N]`, `[*]` (exit code) | Constant |
| `\$ ...` (escaped dollar) | SpecialChar + String |
| `# inline comment` | Comment |

5
vim/ftdetect/shout.lua Normal file
View File

@ -0,0 +1,5 @@
vim.filetype.add({
extension = {
shout = "shout",
},
})

18
vim/ftplugin/shout.lua Normal file
View File

@ -0,0 +1,18 @@
-- Buffer-local settings for .shout files
vim.bo.commentstring = "$# %s"
vim.bo.shiftwidth = 0
vim.bo.tabstop = 2
vim.bo.expandtab = true
-- Commands
vim.api.nvim_buf_create_user_command(0, "ShoutRun", function()
require("shout").run()
end, { desc = "Run current .shout file" })
vim.api.nvim_buf_create_user_command(0, "ShoutUpdate", function()
require("shout").update()
end, { desc = "Run with --update to capture actual output" })
vim.api.nvim_buf_create_user_command(0, "ShoutRunAll", function()
require("shout").run_all()
end, { desc = "Run all .shout files in test/" })

118
vim/lua/shout/init.lua Normal file
View File

@ -0,0 +1,118 @@
local M = {}
-- Find the shout binary: prefer local node_modules, then PATH
local function shout_cmd()
local local_bin = vim.fn.findfile("node_modules/.bin/shout", vim.fn.getcwd() .. ";")
if local_bin ~= "" then
return vim.fn.fnamemodify(local_bin, ":p")
end
-- Try bun run
if vim.fn.executable("bun") == 1 then
return "bun run shout"
end
return "shout"
end
-- Parse shout test output for quickfix entries
local function parse_output(lines, file)
local items = {}
for _, line in ipairs(lines) do
-- Match "FAIL path" lines
local fail_path = line:match("^FAIL%s+(.+)$")
if fail_path then
table.insert(items, {
filename = fail_path,
lnum = 1,
text = "FAIL",
type = "E",
})
end
-- Match " $ command" lines after a FAIL (command that failed)
local cmd = line:match("^%s+%$%s+(.+)$")
if cmd then
table.insert(items, {
filename = file or "",
lnum = 0,
text = "$ " .. cmd,
type = "I",
})
end
end
return items
end
local function run_shout(args, on_exit)
local cmd = shout_cmd() .. " test " .. args
local output_lines = {}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
stderr_buffered = true,
on_stdout = function(_, data)
vim.list_extend(output_lines, data)
end,
on_stderr = function(_, data)
vim.list_extend(output_lines, data)
end,
on_exit = function(_, code)
vim.schedule(function()
if on_exit then
on_exit(code, output_lines)
end
end)
end,
})
end
local ns = vim.api.nvim_create_namespace("shout")
local function show_results(code, lines, file)
-- Clear previous diagnostics
if file then
local bufnr = vim.fn.bufnr(file)
if bufnr ~= -1 then
vim.diagnostic.reset(ns, bufnr)
end
end
-- Show in quickfix
local items = parse_output(lines, file)
vim.fn.setqflist(items, "r")
if code == 0 then
vim.notify("shout: all tests passed", vim.log.levels.INFO)
else
vim.notify("shout: tests failed (see :copen)", vim.log.levels.ERROR)
vim.cmd("copen")
end
end
function M.run()
local file = vim.fn.expand("%:p")
vim.notify("shout: running " .. vim.fn.expand("%:t") .. "...", vim.log.levels.INFO)
run_shout(vim.fn.shellescape(file), function(code, lines)
show_results(code, lines, file)
end)
end
function M.update()
local file = vim.fn.expand("%:p")
vim.notify("shout: updating " .. vim.fn.expand("%:t") .. "...", vim.log.levels.INFO)
run_shout("--update " .. vim.fn.shellescape(file), function(code, lines)
if code == 0 then
vim.cmd("edit") -- reload the buffer
vim.notify("shout: updated " .. vim.fn.expand("%:t"), vim.log.levels.INFO)
else
show_results(code, lines, file)
end
end)
end
function M.run_all()
vim.notify("shout: running all tests...", vim.log.levels.INFO)
run_shout("", function(code, lines)
show_results(code, lines, nil)
end)
end
return M

69
vim/syntax/shout.vim Normal file
View File

@ -0,0 +1,69 @@
" Vim syntax file for .shout files
" Language: shout (shell integration tests)
if exists("b:current_syntax")
finish
endif
" Directives — must appear before first command
syn match shoutDirectiveKey /^@env\s/ contained
syn match shoutDirectiveKey /^@setup\s/ contained
syn match shoutDirectiveKey /^@teardown\s/ contained
syn match shoutEnvDirective /^@env\s\+\S\+=.*$/ contains=shoutDirectiveKey,shoutEnvName,shoutEnvValue
syn match shoutEnvName /\S\+\ze=/ contained nextgroup=shoutEnvEquals
syn match shoutEnvEquals /=/ contained nextgroup=shoutEnvValue
syn match shoutEnvValue /.*$/ contained
syn match shoutSetupDirective /^@setup\s\+.*$/ contains=shoutDirectiveKey
syn match shoutTeardownDirective /^@teardown\s\+.*$/ contains=shoutDirectiveKey
" Comment commands: $# ... or $ # ...
syn match shoutCommentCommand /^\$#.*$/
syn match shoutCommentCommand /^\$\s\+#.*$/
" Command lines: $ command
syn match shoutPrompt /^\$\s/ contained
syn match shoutCommand /^\$\s.\+/ contains=shoutPrompt,shoutInlineComment
syn match shoutInlineComment /\s\+#[^"']*$/ contained
" Escaped dollar in expected output
syn match shoutEscapedDollar /^\\\$/ contained
syn match shoutEscapedLine /^\\\$.*$/ contains=shoutEscapedDollar
" Wildcards
syn match shoutWildcardLine /^\.\.\.$/
syn match shoutWildcardInline /\.\.\./ contained
" Exit code assertions
syn match shoutExitCode /^\[\d\+\]$/
syn match shoutExitCodeWild /^\[\*\]$/
" Expected output (anything not matched above)
syn match shoutExpectedOutput /^[^$@\[\\].*$/ contains=shoutWildcardInline
" Highlighting
hi def link shoutDirectiveKey Keyword
hi def link shoutEnvDirective PreProc
hi def link shoutEnvName Identifier
hi def link shoutEnvEquals Operator
hi def link shoutEnvValue String
hi def link shoutSetupDirective PreProc
hi def link shoutTeardownDirective PreProc
hi def link shoutCommentCommand Comment
hi def link shoutPrompt Special
hi def link shoutCommand Statement
hi def link shoutInlineComment Comment
hi def link shoutEscapedDollar SpecialChar
hi def link shoutEscapedLine String
hi def link shoutWildcardLine WarningMsg
hi def link shoutWildcardInline WarningMsg
hi def link shoutExitCode Constant
hi def link shoutExitCodeWild Constant
hi def link shoutExpectedOutput String
let b:current_syntax = "shout"