Strings are opaque. Your editor knows that const query = \…`` is a JavaScript template literal, but it has no idea that what’s inside is SQL. The syntax highlighting stops at the opening backtick. Everything after that is one flat color.
This bothered me enough to write a plugin.
The problem
If you write backend code, you’ve seen this pattern a thousand times:
const query = `
SELECT u.name, u.email
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.total > 100
`;Your editor renders that entire SQL block as a string literal. No keyword highlighting, no structure, no visual separation between SELECT and u.name. It’s all the same color.
This isn’t just an aesthetics problem. When you’re scanning code quickly, syntax highlighting is how your brain parses structure. Without it, embedded queries are a wall of text you have to actually read instead of glance at.
How Tree-sitter injection works
Tree-sitter, the parser that powers syntax highlighting in modern Neovim, has a concept called language injection. You can tell it: “this region of the syntax tree should be parsed as a different language.”
The mechanism is .scm query files — pattern-matching rules written in an S-expression syntax. Here’s a simplified example:
((template_string
(string_fragment) @injection.content)
(#match? @injection.content "^\\s*--\\s*[sS][qQ][lL]")
(#set! injection.language "sql"))This says: if a template string’s content starts with --sql (case-insensitive), treat the content as SQL. The #match? predicate does the pattern matching, and #set! injection.language tells Tree-sitter which parser to use.
The result: real SQL highlighting inside your JavaScript string.
Two annotation styles
The plugin supports two ways to mark embedded languages:
Inline comment — a comment marker inside the string itself:
const query = `
--sql
SELECT * FROM users WHERE id = $1
`;Above-line comment — a regular code comment on the line before:
// sql
const query = `
SELECT * FROM users WHERE id = $1
`;Both produce the same result. The inline style is more portable — it stays with the string if you move it. The above-line style is cleaner if you don’t want the marker inside your actual query.
The unconventional approach
Most Neovim plugins register things at runtime through the Lua API. This plugin does something different: it generates .scm query files and writes them to after/queries/<lang>/injections.scm on disk.
This was deliberate. Tree-sitter loads injection queries from these files automatically. By writing the files with the ;extends directive, the plugin’s queries add to whatever injection queries already exist for that language — including ones from the parser itself or other plugins.
The tradeoff is that setup() touches the filesystem. But it means the queries are loaded exactly like any other Tree-sitter query, through the same code path Neovim already uses. No monkey-patching, no runtime hooks, no surprises.
Extensibility as the point
Built-in injection queries exist now for some parser combinations. If you’re writing SQL in TypeScript template literals, your Tree-sitter parser might already handle that specific case. But built-in queries are rigid — they cover the cases the parser author anticipated and nothing else.
This plugin is a template engine for injection queries. You pass in a configuration that defines which host languages, which embedded languages, and which annotation patterns to support. The plugin generates the corresponding .scm files.
Want to add injection for GLSL inside Rust raw strings? Write the query template, pass it to setup(). The plugin handles the case-insensitive matching (every character becomes a [gG][lL][sS][lL] regex class), the file generation, and the ;extends directive.
That’s the difference. Built-in solutions solve the cases someone predicted. This plugin lets you solve any case you actually have.
The LSP gotcha
The most interesting issue from the community — and the one that generated the most discussion — was about LSP semantic highlighting. When a language server attaches, it can override Tree-sitter highlights with its own semantic tokens. The LSP says “this region is a string,” and that classification wins, painting over the carefully injected syntax highlighting.
The fix is to clear the relevant semantic token highlight:
vim.api.nvim_create_autocmd('LspAttach', {
callback = function()
vim.api.nvim_set_hl(0, '@lsp.type.string', {})
end,
})This tells Neovim to stop letting the LSP override string highlighting, which lets Tree-sitter’s injection do its job. It’s a one-liner, but it took a community discussion to surface it — the kind of thing you only discover when real users hit real configurations.
What I’d change
If I were starting over, I’d consider using Neovim’s vim.treesitter.query.set() API instead of writing files to disk. The API has matured since 2023, and it would avoid the filesystem side effect. But the current approach works, it’s been stable for three years, and 45 people depend on it doing exactly what it does today.
Sometimes the right call is to leave working code alone.
The plugin is at github.com/DariusCorvus/tree-sitter-language-injection.nvim — 250 lines of Lua, zero dependencies beyond nvim-treesitter, and every issue closed.