diff --git a/vim/README.md b/vim/README.md new file mode 100644 index 0000000..1c1f52e --- /dev/null +++ b/vim/README.md @@ -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 | diff --git a/vim/ftdetect/shout.lua b/vim/ftdetect/shout.lua new file mode 100644 index 0000000..4796170 --- /dev/null +++ b/vim/ftdetect/shout.lua @@ -0,0 +1,5 @@ +vim.filetype.add({ + extension = { + shout = "shout", + }, +}) diff --git a/vim/ftplugin/shout.lua b/vim/ftplugin/shout.lua new file mode 100644 index 0000000..f0e9d4f --- /dev/null +++ b/vim/ftplugin/shout.lua @@ -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/" }) diff --git a/vim/lua/shout/init.lua b/vim/lua/shout/init.lua new file mode 100644 index 0000000..c4b7215 --- /dev/null +++ b/vim/lua/shout/init.lua @@ -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 diff --git a/vim/syntax/shout.vim b/vim/syntax/shout.vim new file mode 100644 index 0000000..4cd9a59 --- /dev/null +++ b/vim/syntax/shout.vim @@ -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"