background-shape
Treesitter for Real Refactoring, Structural Edits in Neovim
April 10, 2024 · 7 min read · by Muhammad Amal programming

TL;DR — Treesitter parses code into a real AST. With nvim-treesitter-textobjects, you get function-level, parameter-level, and class-level edits that work the same way across 40+ languages. It’s not a JetBrains-grade refactoring tool, but it covers most of what you’d reach for daily.

The thing that finally pulled me off JetBrains wasn’t speed or RAM. It was realizing that 80% of what I used “Refactor” menus for was just structural editing, deleting a function, swapping arguments, extracting a block. Treesitter does that in Neovim, with consistent keybindings across whatever language I’m in that week.

This isn’t a hype piece. Treesitter doesn’t do rename-across-files (that’s LSP’s job). It won’t extract a function with proper variable capture (that’s still JetBrains territory). What it does is give you AST-aware text objects that compose with Vim’s grammar, and that turns out to be enough for most day-to-day editing.

I’ll walk through the setup, the operations that actually matter, and where Treesitter stops being the right tool.

What Treesitter Actually Is

Treesitter is a parser generator. The Neovim integration ships parsers for a long list of languages and exposes the resulting parse tree to plugins and to your config. That means Neovim knows where functions begin and end, what’s a parameter list, what’s a string literal, and so on, structurally, not by regex.

Three things this enables.

First, accurate syntax highlighting. Old Vim regex highlighting got things like nested template strings wrong. Treesitter doesn’t.

Second, structural folding. You can fold by AST node, so “fold all functions” is one command.

Third, and most importantly for refactoring, structural text objects. vaf selects “around function.” dif deletes “inside function.” >af swaps with next function. These work in Lua, Go, Python, Rust, TypeScript, with the same keystrokes.

The Minimum Useful Setup

Two plugins, one spec file:

-- lua/plugins/treesitter.lua
return {
  { 'nvim-treesitter/nvim-treesitter',
    build = ':TSUpdate',
    event = { 'BufReadPost', 'BufNewFile' },
    dependencies = { 'nvim-treesitter/nvim-treesitter-textobjects' },
    config = function()
      require('nvim-treesitter.configs').setup({
        ensure_installed = {
          'lua', 'vim', 'vimdoc', 'query',
          'go', 'gomod', 'python', 'typescript', 'tsx', 'javascript',
          'json', 'yaml', 'toml', 'bash', 'markdown', 'markdown_inline',
        },
        highlight   = { enable = true },
        indent      = { enable = true },
        incremental_selection = {
          enable = true,
          keymaps = {
            init_selection    = '<C-space>',
            node_incremental  = '<C-space>',
            scope_incremental = false,
            node_decremental  = '<bs>',
          },
        },
        textobjects = {
          select = {
            enable    = true,
            lookahead = true,
            keymaps = {
              ['af'] = '@function.outer',
              ['if'] = '@function.inner',
              ['ac'] = '@class.outer',
              ['ic'] = '@class.inner',
              ['aa'] = '@parameter.outer',
              ['ia'] = '@parameter.inner',
            },
          },
          move = {
            enable = true,
            set_jumps = true,
            goto_next_start     = { [']f'] = '@function.outer', [']c'] = '@class.outer' },
            goto_previous_start = { ['[f'] = '@function.outer', ['[c'] = '@class.outer' },
          },
          swap = {
            enable = true,
            swap_next     = { ['<leader>a'] = '@parameter.inner' },
            swap_previous = { ['<leader>A'] = '@parameter.inner' },
          },
        },
      })
    end },
}

That’s the whole thing. After :TSUpdate runs, you have AST-aware editing in every listed language.

Some specifics worth understanding. lookahead = true means if your cursor isn’t on a function, vif will jump forward to the next one and select it. Without this, you have to be physically inside the function first, which is annoying. set_jumps = true adds Treesitter jumps to the jumplist, so <C-o> and <C-i> work through them.

The Operations That Pay Off

Here’s the actual workflow that changes when you have these text objects.

Delete a function. Cursor anywhere in the function. daf. Done. Works in Go, Python, TypeScript, identically.

Yank a function to paste elsewhere. yaf, then p wherever you want it. Sounds trivial. Then remember how you used to do this in VS Code, select with the mouse from the function signature to the closing brace, hoping you didn’t miss a line.

Change inside a function body. cif deletes everything between the braces and drops you in insert mode. Useful when you’re rewriting an implementation but keeping the signature.

Swap two parameters. Cursor on the first parameter. <leader>a. They swap, with commas handled correctly. This is the operation that surprised me most, in old-school Vim, swapping parameters is fiddly because you have to handle the comma yourself. Treesitter knows what a parameter list is.

Jump between functions. ]f goes to the next function. [f goes back. In a 1500-line file, this beats scrolling.

Incremental selection. Press <C-space> to select the current AST node. Press it again to expand to the parent (the function containing this expression, then the class containing the function, and so on). <BS> shrinks back down. This is the operation I miss most when I’m forced into a non-Treesitter editor.

-- Quick example: change every parameter in a Go function to use pointers
-- 1. Cursor on first param
-- 2. cia    -- change inner parameter
-- 3. Type the new param
-- 4. <Esc>
-- 5. <leader>a to swap with next, or ]a + . to repeat

Where Treesitter Stops Being the Right Tool

Be honest about limits.

Treesitter does not understand types. If you want “rename this variable everywhere in the project,” you want LSP rename (<leader>rn in most configs), not a Treesitter operation. Treesitter would do a textual swap; LSP does a scope-aware one.

Treesitter does not extract functions with variable capture. The plugins that claim to do this (refactoring.nvim) work for simple cases but fall over on closures, async code, or anything with non-local state. For real extract refactoring on production code, I still drop into manual mode or use language-specific tools.

Treesitter parsers can be wrong. Most are excellent but a few (PHP, older Vue, some embedded DSLs) have rough edges. If your text objects behave weirdly, run :TSPlayground (or in newer versions, :InspectTree) to see how the parser is interpreting your code.

I covered the navigation side of this workflow in Fast code navigation in Neovim, since fuzzy finding plus structural edits are the two-step combo that replaces most “Refactor” menu actions.

Useful Tree-Aware Tricks

A few smaller wins worth knowing.

Fold by Treesitter. Set vim.opt.foldmethod = 'expr' and vim.opt.foldexpr = 'nvim_treesitter#foldexpr()' and zM folds every function in the buffer. Then zR to unfold. Useful for reading a 3000-line file you didn’t write.

Context at the top of the screen. nvim-treesitter-context shows the function or class you’re inside at the top of the buffer, sticky-style. Looks like VS Code’s sticky scroll. Genuinely useful in long functions.

{ 'nvim-treesitter/nvim-treesitter-context', event = 'BufReadPost', opts = { max_lines = 3 } },

Query playground for debugging. :InspectTree opens a split showing the parse tree of the current buffer. When a text object isn’t behaving, this tells you why, usually the AST node names don’t match what you expected.

Common Pitfalls

A few traps.

  • Forgetting :TSUpdate after a plugin update. Treesitter parsers and the runtime can get out of sync. :checkhealth nvim-treesitter will tell you, but it’s easy to miss.
  • Installing every parser. ensure_installed = 'all' works but downloads 200+ parsers, most of which you’ll never use. List the languages you actually work in.
  • Expecting Treesitter to fix LSP problems. They’re independent. Treesitter knows structure, LSP knows semantics. If gd doesn’t go to definition, that’s LSP, not Treesitter.
  • Letting the parser run on huge files. Treesitter is fast but parsing a 50MB log file is silly. Disable it for files over some size threshold:
vim.api.nvim_create_autocmd({ 'BufReadPre' }, {
  callback = function()
    local size = vim.fn.getfsize(vim.fn.expand('%'))
    if size > 1024 * 1024 then  -- 1MB
      vim.cmd('TSBufDisable highlight')
    end
  end,
})
  • Believing the parser is always right. When the file has syntax errors, the AST is broken and text objects misbehave. Fix the syntax error before refactoring.

Wrapping Up

Treesitter doesn’t replace LSP and it doesn’t replace a real refactoring tool. What it does is give you a consistent grammar for “operate on this function” or “operate on this parameter” that works the same way in every language you touch. That consistency is the actual win.

Install the two plugins, learn af/if/aa/ia/ac/ic, and use ]f/[f to jump between functions. That’s the entire ROI. Everything else is a bonus.

The nvim-treesitter documentation lists supported languages and queries, and the Neovim Treesitter docs cover the underlying API if you want to write your own queries.