I recently discovered Rad, a programming language for writing command-line tools with a Python-like syntax. It doesn’t have Neovim support yet, so I set it up myself only using Neovim’s built-in APIs—no plugin required.

This post shows how to:

  • Add filetype detection
  • Enable syntax highlighting by Tree-sitter
  • Configure LSP support

Prerequisites

  • Neovim 0.12. As Neovim evolves, it “absorbs” features that previously required plugins. This means older configuration patterns become obsolete.

  • Tree-sitter CLI 0.26.8. Tree-sitter parsers must be compiled. Using a recent version ensures compatibility with Neovim’s internal runtime.

  • Rad 0.9.2. I’m using rad as an example, but this process works for any language. For rad, the language creator provides a language server, and a Tree-sitter grammar, which is everything I need.

Hello Rad

To have a real example to work with, here’s a small CLI that highlights a few features of Rad: Argument parsing, usage documentation, argument types and constraints, out of the box.

#!/usr/bin/env rad
---
Greet someone.
---

args:
  name str # Someone to greet
  greeting str = "hello" # How to greet
  greeting enum ["hello", "hi"]


fn capitalize(string: str) -> str:
  return string[0].upper() + string[1:]

print("{greeting.capitalize()} {name.capitalize()}")

If you run this CLI, you’ll get this output:

Greet someone.

Usage:
  hello.rad <name> [greeting] [OPTIONS]

Script args:
      --name str       Someone to greet
      --greeting str   How to greet. Valid values: [hello, hi] (default hello)

Syntax highlighting in Neovim

Neovim supports two mechanisms for syntax highlighting. The older mechanism, inherited from Vim, is based on pattern matching.

For example:

syn match myComment "^#.*$"
syn keyword myBoolean true false

hi def link myComment Comment
hi def link myBoolean Boolean

This highlights all lines starting with a # character as comments, and every occurrence of true or false as Booleans.

The simplicity is hard to beat, especially since it has zero dependencies. Neovim 0.12 ships with 778 such syntax files, and syntax highlighting is on by default.

A newer approach uses Tree-sitter, a parser generation tool. Tree-sitter parses your code into a syntax tree. Once you have a syntax tree, you can use it for many purposes: syntax highlighting, indenting, folding, and navigation.

Since Tree-sitter knows the structure of your code, it can provide more accurate syntax highlighting. This is especially useful in mixed-language documents, for example, if you have code blocks in Markdown documents,

Tree-sitter support is experimental in Neovim 0.12.

Syntax highlighting with Tree-sitter works with queries that match nodes from the syntax tree to highlight groups, such as:

(comment) @comment
(boolean_literal) @boolean

Make Neovim recognize a new filetype

First, Neovim needs to recognize Rad as a filetype. To check if any filetype is currently associated with hello.rad, open the file and run the command set ft?. It shows radiance, which is apparently a scene description language for rendering. Interesting, but not what I want.

To change this, I create a new file in my Neovim config with the following code:

-- ~/.config/nvim/ftdetect/rad.lua
vim.filetype.add({
  extension = { rad = "rad" },
  pattern = {
    ["[^.]*"] = {
      priority = -math.huge, -- run last; only fires if no other rule matched
      function(_, bufnr)
        local line = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] or ""
        if line:match("^#!.*/rad%s*$") or line:match("^#!.*/env%s+rad%s*$") then
          return "rad"
        end
      end,
    },
  },
})

This recognizes hello.rad by its extension, but also checks if the first line is #!/usr/bin/env rad. This lets me write Rad CLIs without using extensions. Feels more CLI-like that way.

When I open hello.rad again and type :set ft?, it shows rad now. Rad!

Add a custom Tree-sitter parser

Next, I need to add a Tree-sitter parser for rad. The parser is the program that converts the text from hello.rad into a tree structure. Neovim ships with only a small set of built-in parsers (for example Lua, Vimscript, or C).

Luckily for Rad, the language creator provides a Tree-sitter grammar in a tree-sitter-rad repository.

After cloning the repository, I ran the following command to build the parser:

cd tree-sitter-rad
tree-sitter build -o rad.so

Neovim looks for parsers in its runtime directories, so placing it in the ~/.config/nvim/parser/ directory makes it available:

cp rad.so ~/.config/nvim/parser/rad.so

To make Neovim actually use the parser for Rad files, I create a new file in my Neovim configuration with the following code:

-- ~/.config/nvim/ftplugin/rad.lua
vim.treesitter.start()

That’s it. The next time I open hello.rad, I can run the command InspectTree to show the parsed syntax tree. The first lines should look like this:

(source_file ; [0, 0] - [15, 0]
  (shebang) ; [0, 0] - [0, 18]
  (file_header ; [1, 0] - [5, 0]
    contents: (file_header_contents)) ; [2, 0] - [3, 0]
  (arg_block ; [5, 0] - [8, 32]
    declaration: (arg_declaration ; [6, 2] - [6, 34]
      type: (string_type) ; [6, 7] - [6, 10]
      comment: (comment_text)) ; [6, 14] - [6, 34]

This means, the parser is working. The checkhealth vim.treesitter command should also list rad now.

At this point:

  • Neovim recognizes .rad files
  • The Tree-sitter parser is installed
  • Tree-sitter parsing is active

What’s still missing is syntax highlighting.

The nvim-treesitter plugin abstracts all of this. It also adds Neovim-specific queries for highlighting, folding, indenting, and more. The plugin’s README includes instructions for registering a custom parser as well.

Add queries for syntax highlighting

To support syntax highlighting with Tree-sitter, Neovim needs queries to match nodes to highlight groups. The tree-sitter-rad repository doesn’t include queries, so I have to write my own.

I could have probably started from scratch or from a similar language, like Python, but it’s 2026, so I fed the grammar to my AI tool of choice and have it generate the query file for me.

Neovim also looks for queries in its runtime path, so I created a new file in my Neovim configuration directory with the following content:

highlights.scm
; ~/.config/nvim/queries/rad/highlights.scm
;
; https://github.com/amterp-dev/tree-sitter-rad
;
; NOTE: rad's `identifier` is an anonymous node (alias to a string in grammar.js),
; so it must be queried as "identifier" (anonymous syntax) not (identifier) (named syntax).

;; ─────────────────────────────────────────────────────────────────────────────
;; Comments
;; ─────────────────────────────────────────────────────────────────────────────

(comment) @comment

; Inline arg declaration comments (# text after an arg declaration)
(arg_declaration comment: (comment_text) @comment.doc)

; File header front-matter contents (between --- markers)
(file_header_contents) @comment.doc

; Command description block contents (between --- markers inside a cmd block)
(cmd_description_contents) @comment.doc

; Shebang line
(shebang) @keyword.directive

;; ─────────────────────────────────────────────────────────────────────────────
;; Literals
;; ─────────────────────────────────────────────────────────────────────────────

(bool) @boolean
(null) @constant.builtin

(int) @number
(float) @number.float
(scientific_number) @number.float

; Strings are split across three nodes by the external scanner
(string_start) @string
(string_content) @string
(string_end) @string

; Escape sequences inside strings
(esc_single_quote)  @string.escape
(esc_double_quote)  @string.escape
(esc_backtick)      @string.escape
(esc_newline)       @string.escape
(esc_tab)           @string.escape
(esc_backslash)     @string.escape
(esc_open_bracket)  @string.escape

; String interpolation delimiters — the inner expr is handled by general expr rules
(interpolation "{" @punctuation.special)
(interpolation "}" @punctuation.special)

;; ─────────────────────────────────────────────────────────────────────────────
;; Types
;; ─────────────────────────────────────────────────────────────────────────────

; Primitive scalar / list types (used in arg_block and fn signatures)
(string_type)      @type.builtin
(int_type)         @type.builtin
(float_type)       @type.builtin
(bool_type)        @type.builtin
(string_list_type) @type.builtin
(int_list_type)    @type.builtin
(float_list_type)  @type.builtin
(bool_list_type)   @type.builtin

; Higher-kinded / special types (used in fn_leaf_type)
(void_type)  @type.builtin
(any_type)   @type.builtin
(error_type) @type.builtin

; Structural type keywords in fn_leaf_type / list_type / map_type
"list" @type.builtin
; NOTE: "map" doubles as a rad field modifier — both uses get @type.builtin.
; Add (rad_field_mod_map "map" @keyword) above this line to override for that context.
"map" @type.builtin

; Union-type pipe separator inside fn_param_or_return_type
"|" @punctuation.delimiter

; List-suffix token in fn_leaf_type (e.g. int[]?)
"[]" @punctuation.special

;; ─────────────────────────────────────────────────────────────────────────────
;; Keywords — control flow
;; ─────────────────────────────────────────────────────────────────────────────

"if"      @keyword.conditional
"else"    @keyword.conditional
"switch"  @keyword.conditional
"case"    @keyword.conditional
"default" @keyword.conditional

"for"   @keyword.repeat
"while" @keyword.repeat

; "in" is operator-like syntactically but reads as a keyword
"in" @keyword.operator

;; ─────────────────────────────────────────────────────────────────────────────
;; Keywords — jump / return
;; ─────────────────────────────────────────────────────────────────────────────

; break / continue / pass are leaf named nodes (no children, just the token)
(break_stmt)    @keyword.return
(continue_stmt) @keyword.return
(pass_stmt)     @keyword

"return" @keyword.return
"yield"  @keyword.return

;; ─────────────────────────────────────────────────────────────────────────────
;; Keywords — functions & scoping
;; ─────────────────────────────────────────────────────────────────────────────

"fn" @keyword.function

; defer / errdefer share exception-handling semantics
"defer"    @keyword.exception
"errdefer" @keyword.exception

; catch is the error-handling construct (catch_block / catch_expr)
"catch" @keyword.exception

"del"  @keyword
"with" @keyword  ; for-loop context binding

;; ─────────────────────────────────────────────────────────────────────────────
;; Keywords — top-level structure (args / command blocks)
;; ─────────────────────────────────────────────────────────────────────────────

"args"    @keyword
"command" @keyword
"calls"   @keyword

; Constraint keywords inside arg_block
"enum"     @keyword
"range"    @keyword
"regex"    @keyword
"requires" @keyword
"excludes" @keyword
"mutually" @keyword.modifier

;; ─────────────────────────────────────────────────────────────────────────────
;; Keywords — rad blocks
;; ─────────────────────────────────────────────────────────────────────────────

; rad_keyword is a named node covering "rad" | "request" | "display"
(rad_keyword) @keyword.special

; Inner rad-block keywords
"fields" @keyword
"sort"   @keyword
"color"  @keyword
"filter" @keyword
; "map" already covered above as @type.builtin

; Sort direction modifiers
"asc"  @keyword.modifier
"desc" @keyword.modifier

; rad_option_keyword is a named node covering "insecure" | "quiet" | "noprint"
(rad_option_keyword) @keyword.modifier

;; ─────────────────────────────────────────────────────────────────────────────
;; Keywords — shell execution
;; ─────────────────────────────────────────────────────────────────────────────

; Shell execution modifiers (appear before $ in shell_cmd)
"quiet"   @keyword.modifier
"confirm" @keyword.modifier

; JSON path opener keyword
"json" @keyword.special

;; ─────────────────────────────────────────────────────────────────────────────
;; Operators
;; ─────────────────────────────────────────────────────────────────────────────

; Logical — spelled out as keywords but function as operators
"and"   @keyword.operator
"or"    @keyword.operator
"not"   @keyword.operator
; "not in" — combined named node
(not_in) @keyword.operator

; Arithmetic
"+" @operator
"-" @operator
"*" @operator
"/" @operator
"%" @operator

; Comparison
"==" @operator
"!=" @operator
"<"  @operator
"<=" @operator
">"  @operator
">=" @operator

; Assignment & compound assignment
"="  @operator
"+=" @operator
"-=" @operator
"*=" @operator
"/=" @operator
"%=" @operator

; Increment / decrement
"++" @operator
"--" @operator

; Misc
"??"  @operator  ; fallback / nil-coalescing
"->"  @operator  ; switch case arrow, fn return type
"?"   @operator  ; ternary / optional marker
"$"   @operator  ; shell execution prefix

;; ─────────────────────────────────────────────────────────────────────────────
;; Functions
;;
;; All "identifier" references below use anonymous node syntax ("identifier")
;; because rad aliases identifierRegex to the string "identifier" in grammar.js.
;; ─────────────────────────────────────────────────────────────────────────────

; Named function definition
(fn_named name: "identifier" @function)

; Function call
(call func: "identifier" @function.call)

; Command block name acts like a top-level function entry point
(cmd_block name: "identifier" @function)

; Callback identifier passed to `calls`
(cmd_calls callback_identifier: "identifier" @function)

; Identifier callbacks passed to rad field modifiers (non-lambda form)
(rad_field_mod_map    lambda: "identifier" @function)
(rad_field_mod_filter lambda: "identifier" @function)

;; ─────────────────────────────────────────────────────────────────────────────
;; Parameters & arguments
;; ─────────────────────────────────────────────────────────────────────────────

; fn / lambda parameters
(normal_param name: "identifier" @variable.parameter)
(vararg_param name: "identifier" @variable.parameter)
; Variadic marker on the param itself (not the type-level [] suffix)
(vararg_param vararg_marker: "*" @punctuation.special)

; Named arguments at call sites
(call_named_arg name: "identifier" @variable.parameter)

; arg_block declarations
(arg_declaration arg_name: "identifier" @variable.parameter)

; Shorthand flag (single-letter alias, e.g. -v)
(shorthand_flag) @variable.parameter

; Argument names referenced in constraint rules
(arg_enum_constraint     arg_name: "identifier" @variable.parameter)
(arg_regex_constraint    arg_name: "identifier" @variable.parameter)
(arg_range_constraint    arg_name: "identifier" @variable.parameter)
(arg_requires_constraint arg_name: "identifier" @variable.parameter)
(arg_excludes_constraint arg_name: "identifier" @variable.parameter)

; Identifiers listed in requires / excludes bodies
(arg_requires_constraint required: "identifier" @variable)
(arg_excludes_constraint excluded: "identifier" @variable)

;; ─────────────────────────────────────────────────────────────────────────────
;; Variables
;; ─────────────────────────────────────────────────────────────────────────────

; Variable paths on both sides of assignment and in expressions
(var_path root: "identifier" @variable)

; For-loop iteration variables
(for_lefts left: "identifier" @variable)

; For-loop `with` context binding
(for_loop context: "identifier" @variable)

; List comprehension iteration variables (shares for_lefts)
(list_comprehension lefts: (for_lefts left: "identifier" @variable))

;; ─────────────────────────────────────────────────────────────────────────────
;; Properties (field / key names)
;; ─────────────────────────────────────────────────────────────────────────────

; Fields listed in a rad `fields` statement
(rad_field_stmt identifier: "identifier" @property)

; Fields referenced in a rad field modifier block
(rad_field_modifier_stmt identifier: "identifier" @property)

; Sort field names (immediate_identifier — a special immediate token)
(rad_sort_specifier first: (immediate_identifier) @property)

; JSON path segment keys
(json_segment key: "identifier" @property)

;; ─────────────────────────────────────────────────────────────────────────────
;; Punctuation
;; ─────────────────────────────────────────────────────────────────────────────

"(" @punctuation.bracket
")" @punctuation.bracket
"[" @punctuation.bracket
"]" @punctuation.bracket
"{" @punctuation.bracket
"}" @punctuation.bracket

"," @punctuation.delimiter
"." @punctuation.delimiter
":" @punctuation.delimiter

; File header / cmd description fences
"---" @punctuation.special

Now, reopening hello.rad should show the Rad syntax highlighted!

Add LSP support

Setting up new language servers has become much easier since Neovim 0.11, thanks to the built-in LSP configuration API, which removes the need for external plugins. Rad comes with a language server radls, all I need to ensure is that Neovim can find the executable.

To define a configuration for radls, I create a new file in my Neovim configuration directory and add the following code:

-- ~/.config/nvim/lsp/radls.lua
vim.lsp.config("radls", {
  cmd = { "radls" },
  filetypes = { "rad" },
})

That alone isn’t enough though, the language server must also be enabled. To do that, I add the following line to my Neovim configuration, for example, in init.lua:

-- ~/.config/nvim/init.lua
vim.lsp.enable({
  -- other language servers
  "radls",
})

Running checkhealth vim.lsp with hello.rad open should show radls as attached. For now, that means inline diagnostics—Rad is young, and the language server is too.

Wrap up

Rad now has decent native support in my Neovim setup: filetype detection, Tree-sitter highlighting, and LSP support, all without plugins.

The same approach should work for other languages too, as long as they already have a Tree-sitter grammar and a language server. Writing the queries was the fiddliest part, but AI helped a lot there.