|
| 1 | +--- |
| 2 | +name: Custom Syntax Highlighting in (Neo)Vim |
| 3 | +slug: custom-highlighting |
| 4 | +published: 2026-01-17 |
| 5 | +labels: |
| 6 | +- vim |
| 7 | +- rust |
| 8 | +previewLines: 22 |
| 9 | +--- |
| 10 | + |
| 11 | +*Hopefully this post can be found by someone trying to answer the same questions I was, and maybe save some time.* |
| 12 | + |
| 13 | +Recently while working on closer integration between [Rust and WGSL shader code](/generating-shader-code-1), I looked into what it would take to get syntax highlighting working seamlessly. With a snippet like this: |
| 14 | + |
| 15 | +```rust |
| 16 | +fn do_something() -> Option<()> { |
| 17 | + // something |
| 18 | + None |
| 19 | +} |
| 20 | + |
| 21 | +const SHADER_SOURCE: &'static Shader = wgsl!(r#" |
| 22 | + struct VertexOutput { |
| 23 | + @builtin(position) clip_position: vec4<f32>, |
| 24 | + } |
| 25 | +
|
| 26 | + @vertex |
| 27 | + fn vs_main( |
| 28 | + vertex: ModelVertexData, |
| 29 | + instance: InstanceDataWithNormalMatrix, |
| 30 | + ) -> VertexOutput { |
| 31 | + // ... |
| 32 | + } |
| 33 | +"); |
| 34 | +``` |
| 35 | +
|
| 36 | +...we want to be able to edit the "embedded" shader code as seamlessly as possible - it should look like this: |
| 37 | +
|
| 38 | +```wgsl |
| 39 | +struct VertexOutput { |
| 40 | + @builtin(position) clip_position: vec4<f32>, |
| 41 | +} |
| 42 | +
|
| 43 | +@vertex |
| 44 | +fn vs_main( |
| 45 | + vertex: ModelVertexData, |
| 46 | + instance: InstanceDataWithNormalMatrix, |
| 47 | +) -> VertexOutput { |
| 48 | + // ... |
| 49 | +} |
| 50 | +``` |
| 51 | +
|
| 52 | +You may have seen this (multiple syntax highlighting languages used in the same file) for things like JSX or HTML with `<style>` tags. One way to accomplish this in modern editors is with [tree-sitter](https://github.com/tree-sitter/tree-sitter), a parser generator library built-in to Neovim and usable in others like VSCode via extensions. Tree-sitter parses the syntax tree of the code in the document, providing a structure for syntax highlighting rules (and others) to operate on. |
| 53 | +
|
| 54 | +In this case we want to query for a macro invocation with a particular name (`wgsl`), capturing the string literal argument's contents, and marking them as WGSL for the sake of syntax highlighting. Tree-sitter in Neovim exposes a way to add these custom rules, "injections" ([`:help treesitter-language-injections`](https://neovim.io/doc/user/treesitter.html#_treesitter-language-injections)). There are a number of examples online of setting these up, but I couldn't seem to get the expected results from them and needed to combine details from each: |
| 55 | +
|
| 56 | +This [Youtube video from TJ DeVries](https://www.youtube.com/watch?v=v3o9YaHBM4Q) explains the concept well, shows using the tree-sitter playground, and how to operate on Rust - but it's a little outdated now. On my current version of Neovim (v0.11.5), the usage of `:TSPlaygroundToggle` has been replaced with `:InspectTree`. I tested a query in the playground and got the results I wanted, but couldn't get it to load from an injection file `$NVIM_CONFIG_DIR/after/queries/rust/injections.scm` until I added a special comment to the top of the file: |
| 57 | +
|
| 58 | +```scheme |
| 59 | +;extends |
| 60 | +
|
| 61 | +((macro_invocation |
| 62 | + macro: [ |
| 63 | + (scoped_identifier name: (_) @_macro_name) |
| 64 | + (identifier) @_macro_name |
| 65 | + ] |
| 66 | + (#eq? @_macro_name "wgsl") |
| 67 | + (token_tree (raw_string_literal (string_content) @injection.content)) |
| 68 | + (#set! injection.language "wgsl")) |
| 69 | +) |
| 70 | +``` |
| 71 | +
|
| 72 | +*~/.config/nvim/after/queries/rust/injections.scm* |
| 73 | +
|
| 74 | +[This blog post](https://www.josean.com/posts/nvim-treesitter-and-textobjects) has a lot of great information, and mentions the special `; extends` comment. At this point, I have the intended effect when first editing a file that has the matching syntax! ...until it re-parses and is overwritten for some reason, reverting to being treated as a normal Rust string literal. Many examples show setting a "priority" property for injections which seems like exactly what we need, but I couldn't get it to do anything meaningful - nor could I find many people talking about it. |
| 75 | +
|
| 76 | +Finally I stumbled on [this Reddit comment](https://www.reddit.com/r/neovim/comments/1iiqwb1/comment/mbg6ivp/) on a post describing my exact problem! As mentioned in the parent comment, `:Inspect` shows two rules matching the node marking it as a string type, which seems to be the issue. The reverting behavior goes away when I add to my Neovim configuration (in this case `after/ftplugin/rust.lua`): |
| 77 | +
|
| 78 | +```lua |
| 79 | +vim.api.nvim_set_hl(0, '@lsp.type.string.rust', {}) |
| 80 | +``` |
| 81 | +
|
| 82 | +Why? It's unsatisfying, but I don't know at this point. I'm just happy to see the expected result, now no longer fleeting. |
| 83 | +
|
| 84 | + |
| 85 | +The fruits of our "labor". |
| 86 | +
|
| 87 | +As a cool side effect, this behavior also takes effect when highlighting Rust in other contexts - like the Markdown source for this post. |
| 88 | +
|
| 89 | + |
0 commit comments