# nvim-ts-autotag pairs tags through Tree-sitter node patterns

> nvim-ts-autotag closes a tag when you type the closing bracket and renames both halves of a pair from the Tree-sitter parse tree, but it needs a parser for the filetype and spreads its behaviour across three keys in one setup call.

**windwp/nvim-ts-autotag** — Use treesitter to auto close and auto rename html tag

- Repository: https://github.com/windwp/nvim-ts-autotag
- Stars: 2,128 · Forks: 111
- Language: Lua
- License: MIT
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/windwp-nvim-ts-autotag

## Typing a bracket, not a keyword, drives the closing tag

Editor plugins that close tags usually watch insert mode for a keyword or a trigger character typed on its own. This one reads the Tree-sitter parse tree, and the documentation describes its behaviour in a table with only two rows. Typing the bracket after `<div` produces a closed pair, shown as `<div></div>`.

That second row is the whole rename mechanism in miniature: the `ciwspan<esc>` sequence replaces the word between the two tags, and the closing tag follows the opening one. Rename is not a separate command with a separate trigger; it falls out of the same pair tracking that produced the closing tag in the first place. The row rewrites the pair into `<span></span>`.

Every decision comes from node names in the parse tree, so the filetype needs a parser before any of this happens. That constraint, rather than the language list, decides what you actually get.

## The parser floor is Nvim 0.9.5 and a missing grammar

The floor is `Nvim 0.9.5` and up. On top of that sits a hard requirement: without Tree-sitter parsers installed for a given filetype, nothing works, and the docs point you at nvim-treesitter for installing them rather than bundling a grammar installer of their own.

The list of supported filetypes is wider than html. It names astro, dot, glimmer, handlebars, html, javascript, jsx, liquid, vento, markdown, php, rescript, svelte, tsx, twig, typescript, vue and xml, then adds and more without stopping. Two entries stretch the idea past markup: Graphviz graphs run through the dot filetype, and plain prose runs through markdown.

In neither case is a closing angle bracket a tag delimiter in the usual sense, which tells you the plugin works off node structure rather than punctuation. Those two lists are independent, and mixing them up is the usual first surprise. Having a filetype named in that paragraph does not mean a parser is installed for it, and installing a parser does not add a tag config for it. A language outside the list can still get behaviour through the alias mechanism, which the next section covers.

## The setup call takes opts, per_filetype and aliases as siblings

No installation command appears anywhere in the documentation. The single entry point is a call to `setup`, and its shape deserves a second look, because the first example shows 2 of the 3 top-level keys and a later example shows the third.

```lua
require('nvim-ts-autotag').setup({
  opts = {
    -- Defaults
    enable_close = true, -- Auto close tags
    enable_rename = true, -- Auto rename pairs of tags
    enable_close_on_slash = false -- Auto close on trailing </
  },
  -- Also override individual filetype configs, these take priority.
  -- Empty by default, useful if one of the "opts" global settings
  -- doesn't work well in a specific filetype
  per_filetype = {
    ["html"] = {
      enable_close = false
    }
  }
})
```

The `opts` table holds the defaults, and the comments above them are the only description of what each switch means. The sibling table with the filetype overrides takes priority and sits beside `opts` rather than inside it, as the documentation says. The alias example passes `aliases` as another sibling of the same call, which is easy to miss when you copy the first snippet and go looking for the key.

Two more notes come with the call. Lazy loading is called unnecessary, since the plugin is described as efficient at choosing when it needs to load, and 2 events are named for those who still want it: `BufReadPre`, `BufNewFile`. A standing caution adds that the `nvim-treesitter.configs` route is deprecated. It will be removed in `1.0.0`.

## TagConfigs:update replaces patterns the upstream tree renamed

When a parser changes its node names, the shipped queries stop matching and tags stop closing. What the docs offer instead is a config object you can edit at runtime. It lives in `nvim-ts-autotag.config.init`, and the recipe reads a config and calls `override` on it with new patterns before writing the result back with `update`.

```lua
local TagConfigs = require("nvim-ts-autotag.config.init")
TagConfigs:update(TagConfigs:get("xml"):override("xml", {
    start_tag_pattern = { "STag" },
    end_tag_pattern = { "ETag" },
}))
```

The example is xml, with both patterns replaced. The new values are `STag`, `ETag`. The docs also note that this is very nearly what happens during the plugin's own internal initialization, which says the shipped configs are built through the same call rather than by some separate mechanism.

You cannot guess your way past a broken parser. The repair needs the new node names, and finding them means reading the grammar's own output. That is the honest boundary of the override: a repair tool for people willing to inspect a tree, not a compatibility switch you can flip blind. The last push was on 2026-04-15, which is the date to compare against when an unrelated parser update quietly stops your tags from closing.

## enable_close_on_slash is the one option left off by default

3 switches live in the global table, 2 of them on out of the box: `enable_close`, `enable_rename`. The third is `enable_close_on_slash`, set false, and documented as closing on a trailing `</`. So you get autoclose and rename by default, and nothing happens when you type a closing angle bracket by hand.

The `per_filetype` table exists for that kind of exception. The documented example sets `enable_close` to false in html, says nothing about the other two keys, and leaves open whether rename then stays on for html or falls back to the global value. A configuration that turns autoclose off in html therefore leaves one behaviour undefined in writing, and setting the key yourself is the only way to find out.

That table starts empty and is described as the place for a global setting that does not suit a particular filetype. Every entry is therefore an exception rather than a layer, so nothing there needs updating when you add one.

## A global diagnostics handler swap for the rename delay

One failure has a filed number behind it: tags going stale while you type, tracked as issue 19. The documented fix sits outside this plugin's own config and replaces the LSP handler for diagnostics.

```lua
vim.lsp.handlers['textDocument/publishDiagnostics'] = vim.lsp.with(
    vim.lsp.diagnostic.on_publish_diagnostics,
    {
        underline = true,
        virtual_text = {
            spacing = 5,
            severity_limit = 'Warning',
        },
        update_in_insert = true,
    }
)
```

`vim.lsp.with` wraps the existing `on_publish_diagnostics` handler, adds `underline`, and adds a `virtual_text` table with a spacing of 5 and a severity limit of `Warning`. It sets `update_in_insert` to true, and that last flag does the work: it tells Neovim to refresh diagnostics while the cursor is in insert mode.

Read the scope before pasting it. The assignment targets `vim.lsp.handlers['textDocument/publishDiagnostics']` for the whole session, so every buffer now renders underline and virtual text at that spacing, in every filetype, whether or not tags were involved. It also overwrites a handler you may already have configured. If your own config already sets `update_in_insert`, merge the two rather than replacing yours.

## Sample fixtures reach templ and erb, the language list does not

The top level of the repository is small: `lua/`, `plugin/`, `sample/`, `tests/` sit at the root, plus a Makefile, a README.md, .editorconfig, .gitignore, .luarc.json and .github/. The `sample/` directory is the part worth reading.

It holds index.hbs, index.html, index.html.erb, index.js, index.php, index.res, index.svelte, index.templ, index.ts, index.tsx, index.vue and index.xml. Set those against the language list and most map onto filetypes the docs name. Two do not: `.erb`, `.templ` appear nowhere in it. The docs do not say what those two fixtures exercise, or whether ERB and templ tags close through a shipped config, an alias, or nothing at all. Treat the language list as incomplete for those two and test them in your own buffer before depending on them.

The fixtures double as the cheapest regression check you have. Open sample/index.xml next to the override from the previous section and you can see immediately whether a parser upgrade changed the node names the shipped `xml` config expects.

## The Makefile deletes test deps that no target installs

Nothing else in the repository says how to run its tests; only the `Makefile` does, and it has a hole in it.

```makefile
clean:
	nvim --headless --clean -n -c "lua vim.fn.delete('./tests/.deps', 'rf')" +q
test:
	nvim --headless --clean -u tests/test.lua "$(FILE)"
lint:
	stylua --check lua/ tests/
format:
	stylua lua/ tests/
```

The `clean` target deletes the test dependency directory through Neovim itself, and that directory is `./tests/.deps`, which no target in the file creates. The `test` target runs headless against tests/test.lua, with a single path handed to it as `$(FILE)`, which the repository never explains.

Two more targets handle formatting through stylua, one checking and one rewriting. Stylua is named in those two targets and nowhere else in the documentation, and nothing in the repository says how a fresh clone ends up with the dependency directory that `clean` removes.

## Conclusion

Adopt nvim-ts-autotag if you write html, vue, svelte or jsx and already run Tree-sitter, because it removes the keystroke you would otherwise spend on every closing tag. Leave it out if your Neovim sits below 0.9.5 or no parser is installed for your filetype, since nothing fires without both. Before switching, type a tag in each language you care about and watch the closing tag follow a rename, then check whether your diagnostics handler already sets update_in_insert, since the documented fix replaces that handler for every buffer.

## FAQ

### Will nvim-ts-autotag work without Tree-sitter parsers installed?

No. The documentation states it will not work unless you have treesitter parsers installed for a given filetype, and it points at nvim-treesitter for installing parsers. The stated minimum version is Nvim 0.9.5 and up.

### How do I stop nvim-ts-autotag from closing tags in one filetype only?

Set the switch in the `per_filetype` table, which takes priority over the global settings. The documented example sets `enable_close` to false for html and leaves the other two global settings alone.

### How do I add tag support for a language nvim-ts-autotag does not ship?

Map the filetype onto an existing config through `aliases` in the setup call. You can also call `TagConfigs:add_alias` with the filetype and the config to copy. If the parser renamed its nodes instead, override the tag patterns through `TagConfigs:update`.

### Why does nvim-ts-autotag stop renaming tags while I am typing?

That symptom is tracked as issue 19. The documented fix wraps the publishDiagnostics handler with `vim.lsp.with` and turns on `update_in_insert`, which also switches on `underline` and virtual text for every buffer.

### How do I run the nvim-ts-autotag test suite?

The Makefile test target runs Neovim headless against tests/test.lua with one path passed as `$(FILE)`. No target in that file creates `./tests/.deps`, the directory the clean target deletes, so a fresh clone has to populate it first.

## Sources

- [Issues](https://github.com/windwp/nvim-ts-autotag/issues)
- [License: MIT](https://github.com/windwp/nvim-ts-autotag/blob/main/LICENSE)
- [README](https://github.com/windwp/nvim-ts-autotag/blob/main/README.md)
- [windwp/nvim-ts-autotag on GitHub](https://github.com/windwp/nvim-ts-autotag)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/windwp-nvim-ts-autotag
