No description
Find a file
Barrett Ruth d310a9ee6e
Merge pull request #4 from barrettruth/feat/ts-highlights
treesitter syntax highlighting
2026-02-01 19:06:03 -05:00
.github/workflows fix: correct ci 2026-02-01 19:03:52 -05:00
doc feat: add some config options 2026-02-01 18:52:00 -05:00
lua/fugitive-ts fix: correct ci 2026-02-01 19:03:52 -05:00
plugin feat: add the entire plugin 2026-02-01 17:38:32 -05:00
.editorconfig feat: initial setup files 2026-02-01 16:30:24 -05:00
.gitignore feat: initial setup files 2026-02-01 16:30:24 -05:00
.luarc.json feat: initial setup files 2026-02-01 16:30:24 -05:00
.pre-commit-config.yaml fix: correct ci 2026-02-01 19:03:52 -05:00
.prettierignore feat: initial setup files 2026-02-01 16:30:24 -05:00
.prettierrc feat: initial setup files 2026-02-01 16:30:24 -05:00
fugitive-ts.nvim-scm-1.rockspec feat: add rockspec 2026-02-01 17:02:07 -05:00
LICENSE feat: initial setup files 2026-02-01 16:30:24 -05:00
README.md feat: add the entire plugin 2026-02-01 17:38:32 -05:00
selene.toml feat: initial setup files 2026-02-01 16:30:24 -05:00
stylua.toml feat: initial setup files 2026-02-01 16:30:24 -05:00
vim.toml feat: initial setup files 2026-02-01 16:30:24 -05:00

fugitive-ts.nvim

Treesitter syntax highlighting for vim-fugitive diff views.

Problem

vim-fugitive uses regex-based syntax/diff.vim for highlighting expanded diffs in the status buffer. This means code inside diffs has no language-aware highlighting:

Unstaged (1)
M lua/mymodule.lua
@@ -10,3 +10,4 @@
 local M = {}           ← no lua highlighting
+local new_thing = true ← just diff green, no syntax
 return M

Solution

Hook into fugitive's buffer, detect diff hunks, extract the language from filenames, and apply treesitter highlights as extmarks on top of fugitive's existing highlighting.

Unstaged (1)
M lua/mymodule.lua
@@ -10,3 +10,4 @@
 local M = {}           ← treesitter lua highlights overlaid
+local new_thing = true ← diff green + lua keyword/boolean highlights
 return M

Technical Approach

1. Hook Point

vim.api.nvim_create_autocmd("FileType", {
  pattern = "fugitive",
  callback = function(args)
    -- Set up buffer-local autocmd to re-highlight on changes
    -- (user expanding/collapsing diffs with = key)
  end
})

Also consider User FugitiveIndex event for more specific timing.

2. Parse Fugitive Buffer Structure

The fugitive status buffer has this structure:

Head: branch-name
Merge: origin/branch
Help: g?

Unstaged (N)
M path/to/file.lua        ← filename line (extract extension here)
@@ -10,3 +10,4 @@           ← hunk header
 context line              ← code lines start here
+added line
-removed line
 context line              ← code lines end at next blank/header

Staged (N)
...

Pattern to detect:

  • Filename: ^[MADRC?] .+%.(%w+)$ → captures extension
  • Hunk header: ^@@ .+ @@
  • Code lines: after hunk header, lines starting with , +, or -
  • End of hunk: blank line, next filename, or next section header

3. Map Extension to Treesitter Language

local ext_to_lang = {
  lua = "lua",
  py = "python",
  js = "javascript",
  ts = "typescript",
  tsx = "tsx",
  rs = "rust",
  go = "go",
  rb = "ruby",
  -- etc.
}

-- Or use vim.filetype.match() for robustness:
local ft = vim.filetype.match({ filename = filename })
local lang = vim.treesitter.language.get_lang(ft)

4. Check Parser Availability

local function has_parser(lang)
  local ok = pcall(vim.treesitter.language.inspect, lang)
  return ok
end

If no parser, skip (keep fugitive's default highlighting).

5. Apply Treesitter Highlights

Core algorithm:

local ns = vim.api.nvim_create_namespace("fugitive_ts")

local function highlight_hunk(bufnr, start_line, lines, lang)
  -- Strip the leading +/- /space from each line for parsing
  local code_lines = {}
  local prefix_chars = {}
  for i, line in ipairs(lines) do
    prefix_chars[i] = line:sub(1, 1)
    code_lines[i] = line:sub(2)  -- remove diff prefix
  end

  local code = table.concat(code_lines, "\n")

  -- Parse with treesitter
  local parser = vim.treesitter.get_string_parser(code, lang)
  local tree = parser:parse()[1]
  local root = tree:root()

  -- Get highlight query
  local query = vim.treesitter.query.get(lang, "highlights")
  if not query then return end

  -- Apply highlights
  for id, node, metadata in query:iter_captures(root, code) do
    local capture = "@" .. query.captures[id]
    local sr, sc, er, ec = node:range()

    -- Translate to buffer coordinates
    -- sr/er are 0-indexed rows within the code snippet
    -- Need to add start_line offset and +1 for the prefix char
    local buf_sr = start_line + sr
    local buf_er = start_line + er
    local buf_sc = sc + 1  -- +1 for the +/-/space prefix
    local buf_ec = ec + 1

    vim.api.nvim_buf_set_extmark(bufnr, ns, buf_sr, buf_sc, {
      end_row = buf_er,
      end_col = buf_ec,
      hl_group = capture,
      priority = 200,  -- higher than fugitive's syntax
    })
  end
end

6. Re-highlight on Buffer Change

Fugitive modifies the buffer when user expands/collapses diffs. Need to re-parse:

vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI"}, {
  buffer = bufnr,
  callback = function()
    -- Clear old highlights
    vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
    -- Re-scan and highlight
    highlight_fugitive_buffer(bufnr)
  end
})

Consider debouncing for performance.

File Structure

fugitive-ts.nvim/
├── lua/
│   └── fugitive-ts/
│       ├── init.lua      -- setup() and main logic
│       ├── parser.lua    -- parse fugitive buffer structure
│       └── highlight.lua -- treesitter highlight application
└── plugin/
    └── fugitive-ts.lua   -- autocommand setup (lazy load)

API

require("fugitive-ts").setup({
  -- Enable/disable (default: true)
  enabled = true,

  -- Custom extension -> language mappings
  languages = {
    -- extension = "treesitter-lang"
  },

  -- Fallback to vim syntax if no treesitter parser (default: false)
  -- (More complex to implement - would need to create scratch buffer)
  syntax_fallback = false,
})

Edge Cases

  1. No parser installed: Skip, keep default highlighting
  2. Unknown extension: Use vim.filetype.match() then get_lang()
  3. Binary files: Fugitive shows "Binary file differs" - no code lines
  4. Very large diffs: Consider limiting to visible lines only
  5. Multi-byte characters: Treesitter ranges are byte-based, should work

Dependencies

  • Neovim 0.9+ (treesitter APIs)
  • vim-fugitive
  • Treesitter parsers for languages you want highlighted

Performance Considerations

  • Only parse visible hunks (check against vim.fn.line('w0') / line('w$'))
  • Debounce TextChanged events (50-100ms)
  • Cache parsed trees if buffer hasn't changed
  • Use priority = 200 on extmarks to layer over fugitive syntax

References