Testscript highlighting in Neovim
This is a good article on how to use testscript in Go(lang). It also goes as far as adding a syntax file. But I’m 100% into Neovim, so this needs to be tree-sitter grammar on my system.
Well… this was a journey and I needed claude.ai for a lot of stuff to pull it off. I also wanted to insert Go syntax highlighting for Go blocks, like in this file.
(Note: this is working on Fedora 43, not on Ubuntu with a (custom) Neovim 0.12.1.)
See this post for a screen shot.
# test power.go against a golden file
exec go run power.go
cmp stdout golden
!stdout 'hello world'
-- power.go --
package main
import "fmt"
func main() {
for i := 0; i < 10; i++ {
fmt.Printf("2^%d = %d\n", i, 1<<i)
}
}
-- golden --
2^0 = 1
2^1 = 2
2^2 = 4
2^3 = 8
2^4 = 16
2^5 = 32
2^6 = 64
2^7 = 128
2^8 = 256
2^9 = 512
The grammar I got (and which works) is:
module.exports = grammar({
name: "testscript",
extras: ($) => [/\s/],
rules: {
source_file: ($) => repeat($._line),
_line: ($) => choice($.comment, $.heredoc, $.command_line),
comment: ($) => /#[^\n]*/,
heredoc: ($) => seq($.heredoc_marker, $.heredoc_body),
heredoc_marker: ($) => /-- [^-\n]+ --/,
heredoc_body: ($) => repeat1($.file_content),
file_content: ($) => /[^\t\n ][^\n]*/,
command_line: ($) =>
seq(
optional($.negation),
optional($.condition),
$.command,
repeat(choice($.variable, $.string, $.redirection, $.argument)),
),
negation: ($) => /!\s*/,
condition: ($) => seq("[", /[^\]]*/, "]"),
command: ($) =>
choice(
"cd",
"chmod",
"cmp",
"cmpenv",
"cp",
"env",
"exec",
"exists",
"grep",
"kill",
"mkdir",
"mv",
"rm",
"skip",
"stderr",
"stdin",
"stdout",
"ttyin",
"ttyout",
"stop",
"symlink",
"unquote",
"wait",
),
variable: ($) => choice(/\$\w+/, /\$\{[^}]+\}/),
string: ($) =>
choice(
seq('"', repeat(choice(/[^"\\]/, /\\./)), '"'),
seq("'", /[^']*/, "'"),
),
redirection: ($) => choice(">>", ">"),
argument: ($) => /[^\s#>'"$\n]+/,
},
});
For making this works you need, .../queries/testscript
a highlights.scm with:
; Comments
(comment) @comment
; Negation
(negation) @operator
; Commands
(command) @keyword
; Conditions
(condition) @punctuation.special
; Variables
(variable) @variable
; Strings
(string) @string
; Redirection
(redirection) @operator
; Heredoc markers
(heredoc_marker) @type
; File content between heredoc markers
(file_content) @constant
and an injections.scm to inject Go syntax:
;; extends
((heredoc
(heredoc_marker) @_marker
(heredoc_body) @injection.content)
(#match? @_marker "\.go")
(#set! injection.language "go")
(#set! injection.include-children true))
Then you also need a callback to register the parser with Neovim:
vim.api.nvim_create_autocmd("User", {
pattern = "TSUpdate",
callback = function()
require("nvim-treesitter.parsers").testscript = {
install_info = {
path = vim.fn.expand("~/.dotfiles/nvim/grammar/testscript"),
files = { "src/parser.c" },
generate_requires_npm = false,
requires_generate_from_grammar = true,
},
}
end,
})
If this is all done and done correctly - I’m not sure if a tree-sitter generate in the grammar directory is
actually needed - and the stars are aligned you can run :TSUpdate followed with a :TSInstall testscript.