> ## Index
> Fetch the complete index at https://rhymeswith.fyi/llms.txt
> Use this file to discover all available pages before exploring further.

# Neovim: add language support without plugins

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][neovim] 0.12**.
  As Neovim evolves, it "absorbs" features that previously required plugins.
  This means older configuration patterns become obsolete.

- **[Tree-sitter CLI][tree-sitter-cli] 0.26.8**.
  Tree-sitter parsers must be compiled.
  Using a recent version ensures compatibility with Neovim's internal runtime.

- **[Rad][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.

[neovim]: https://neovim.io/
[tree-sitter-cli]: https://github.com/tree-sitter/tree-sitter/tree/master/crates/cli
[rad]: https://amterp.dev/rad

## 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.

```rad
#!/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:

```txt
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:

```vim
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][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.

[tree-sitter]: https://tree-sitter.github.io/tree-sitter/index.html

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:

```scheme
(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:

```lua
-- ~/.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`][tree-sitter-rad] repository.

[tree-sitter-rad]: https://github.com/amterp/tree-sitter-rad

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

```sh
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:

```sh
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:

```lua
-- ~/.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:

```scheme
(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`][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.

[nvim-treesitter]: https://github.com/nvim-treesitter/nvim-treesitter

## 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:

<details>
<summary><code>highlights.scm</code></summary>

```scheme
; ~/.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
```

</details>

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:

```lua
-- ~/.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`:

```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.