Merge pull request #4 from barrettruth/feat/ts-highlights

treesitter syntax highlighting
This commit is contained in:
Barrett Ruth 2026-02-01 19:06:03 -05:00 committed by GitHub
commit d310a9ee6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 492 additions and 7 deletions

View file

@ -3,7 +3,7 @@ name: luarocks
on:
push:
tags:
- "v*"
- 'v*'
jobs:
quality:

View file

@ -14,4 +14,4 @@ repos:
hooks:
- id: prettier
name: prettier
files: \.(md|,toml,yaml,sh)$
files: \.(md|toml|yaml|sh)$

View file

@ -4,7 +4,9 @@ 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:
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)
@ -17,7 +19,9 @@ M lua/mymodule.lua
## 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.
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)
@ -66,6 +70,7 @@ Staged (N)
```
Pattern to detect:
- Filename: `^[MADRC?] .+%.(%w+)$` → captures extension
- Hunk header: `^@@ .+ @@`
- Code lines: after hunk header, lines starting with ` `, `+`, or `-`
@ -154,7 +159,8 @@ end
### 6. Re-highlight on Buffer Change
Fugitive modifies the buffer when user expands/collapses diffs. Need to re-parse:
Fugitive modifies the buffer when user expands/collapses diffs. Need to
re-parse:
```lua
vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI"}, {
@ -226,5 +232,5 @@ require("fugitive-ts").setup({
- [Neovim Treesitter API](https://neovim.io/doc/user/treesitter.html)
- [vim-fugitive User events](https://github.com/tpope/vim-fugitive/blob/master/doc/fugitive.txt)
- [nvim_buf_set_extmark](https://neovim.io/doc/user/api.html#nvim_buf_set_extmark())
- [vim.treesitter.get_string_parser](https://neovim.io/doc/user/treesitter.html#vim.treesitter.get_string_parser())
- [nvim_buf_set_extmark](<https://neovim.io/doc/user/api.html#nvim_buf_set_extmark()>)
- [vim.treesitter.get_string_parser](<https://neovim.io/doc/user/treesitter.html#vim.treesitter.get_string_parser()>)

77
doc/fugitive-ts.nvim.txt Normal file
View file

@ -0,0 +1,77 @@
*fugitive-ts.nvim.txt* Treesitter highlighting for vim-fugitive diffs
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: MIT
==============================================================================
INTRODUCTION *fugitive-ts.nvim*
fugitive-ts.nvim adds treesitter-based syntax highlighting to vim-fugitive
diff views. It overlays language-aware highlights on top of fugitive's
default regex-based diff highlighting.
==============================================================================
REQUIREMENTS *fugitive-ts-requirements*
- Neovim 0.9.0+
- vim-fugitive
- Treesitter parsers for languages you want highlighted
==============================================================================
SETUP *fugitive-ts-setup*
>lua
require('fugitive-ts').setup({
-- Enable/disable highlighting (default: true)
enabled = true,
-- Enable debug logging (default: false)
-- Outputs to :messages with [fugitive-ts] prefix
debug = false,
-- Custom filename -> language mappings (optional)
languages = {},
-- Debounce delay in ms (default: 50)
debounce_ms = 50,
-- Max lines per hunk before skipping treesitter (default: 500)
-- Prevents lag on large diffs
max_lines_per_hunk = 500,
})
<
==============================================================================
COMMANDS *fugitive-ts-commands*
This plugin works automatically when you open a fugitive buffer. No commands
are required.
==============================================================================
API *fugitive-ts-api*
*fugitive-ts.setup()*
setup({opts})
Configure the plugin. See |fugitive-ts-setup| for options.
*fugitive-ts.attach()*
attach({bufnr})
Manually attach highlighting to a buffer. Called automatically for
fugitive buffers.
*fugitive-ts.refresh()*
refresh({bufnr})
Manually refresh highlighting for a buffer.
==============================================================================
ROADMAP *fugitive-ts-roadmap*
Planned features and improvements:
- Vim syntax fallback: For languages without treesitter parsers, fall back
to vim's built-in syntax highlighting via scratch buffers. This would
provide highlighting coverage for more languages at the cost of
implementation complexity.
==============================================================================
vim:tw=78:ts=8:ft=help:norl:

View file

@ -0,0 +1,44 @@
local M = {}
function M.check()
vim.health.start('fugitive-ts.nvim')
if vim.fn.has('nvim-0.9.0') == 1 then
vim.health.ok('Neovim 0.9.0+ detected')
else
vim.health.error('fugitive-ts.nvim requires Neovim 0.9.0+')
end
local fugitive_loaded = vim.fn.exists(':Git') == 2
if fugitive_loaded then
vim.health.ok('vim-fugitive detected')
else
vim.health.warn('vim-fugitive not detected (required for this plugin to be useful)')
end
---@type string[]
local common_langs = { 'lua', 'python', 'javascript', 'typescript', 'rust', 'go', 'c', 'cpp' }
---@type string[]
local available = {}
---@type string[]
local missing = {}
for _, lang in ipairs(common_langs) do
local ok = pcall(vim.treesitter.language.inspect, lang)
if ok then
table.insert(available, lang)
else
table.insert(missing, lang)
end
end
if #available > 0 then
vim.health.ok('Treesitter parsers available: ' .. table.concat(available, ', '))
end
if #missing > 0 then
vim.health.info('Treesitter parsers not installed: ' .. table.concat(missing, ', '))
end
end
return M

View file

@ -0,0 +1,105 @@
local M = {}
---@param msg string
---@param ... any
local function dbg(msg, ...)
local formatted = string.format(msg, ...)
vim.notify('[fugitive-ts] ' .. formatted, vim.log.levels.DEBUG)
end
---@param bufnr integer
---@param ns integer
---@param hunk fugitive-ts.Hunk
---@param max_lines integer
---@param debug? boolean
function M.highlight_hunk(bufnr, ns, hunk, max_lines, debug)
local lang = hunk.lang
if not lang then
return
end
if #hunk.lines > max_lines then
if debug then
dbg(
'skipping hunk %s:%d (%d lines > %d max)',
hunk.filename,
hunk.start_line,
#hunk.lines,
max_lines
)
end
return
end
---@type string[]
local code_lines = {}
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2))
end
local code = table.concat(code_lines, '\n')
if code == '' then
return
end
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, code, lang)
if not ok or not parser_obj then
if debug then
dbg('failed to create parser for lang: %s', lang)
end
return
end
local trees = parser_obj:parse()
if not trees or #trees == 0 then
if debug then
dbg('parse returned no trees for lang: %s', lang)
end
return
end
local query = vim.treesitter.query.get(lang, 'highlights')
if not query then
if debug then
dbg('no highlights query for lang: %s', lang)
end
return
end
for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1
local line_len = #line
if line_len > 1 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
end_col = line_len,
hl_group = 'Normal',
priority = 199,
})
end
end
local extmark_count = 0
for id, node, _ in query:iter_captures(trees[1]:root(), code) do
local capture_name = '@' .. query.captures[id]
local sr, sc, er, ec = node:range()
local buf_sr = hunk.start_line + sr
local buf_er = hunk.start_line + er
local buf_sc = sc + 1
local buf_ec = ec + 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
end_col = buf_ec,
hl_group = capture_name,
priority = 200,
})
extmark_count = extmark_count + 1
end
if debug then
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)
end
end
return M

133
lua/fugitive-ts/init.lua Normal file
View file

@ -0,0 +1,133 @@
---@class fugitive-ts.Config
---@field enabled boolean
---@field debug boolean
---@field languages table<string, string>
---@field debounce_ms integer
---@field max_lines_per_hunk integer
---@class fugitive-ts
---@field attach fun(bufnr?: integer)
---@field refresh fun(bufnr?: integer)
---@field setup fun(opts?: fugitive-ts.Config)
local M = {}
local highlight = require('fugitive-ts.highlight')
local parser = require('fugitive-ts.parser')
local ns = vim.api.nvim_create_namespace('fugitive_ts')
---@type fugitive-ts.Config
local default_config = {
enabled = true,
debug = false,
languages = {},
debounce_ms = 50,
max_lines_per_hunk = 500,
}
---@type fugitive-ts.Config
local config = vim.deepcopy(default_config)
---@type table<integer, boolean>
local attached_buffers = {}
---@param msg string
---@param ... any
local function dbg(msg, ...)
if not config.debug then
return
end
local formatted = string.format(msg, ...)
vim.notify('[fugitive-ts] ' .. formatted, vim.log.levels.DEBUG)
end
---@param bufnr integer
local function highlight_buffer(bufnr)
if not config.enabled then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
local hunks = parser.parse_buffer(bufnr, config.languages, config.debug)
dbg('found %d hunks in buffer %d', #hunks, bufnr)
for _, hunk in ipairs(hunks) do
highlight.highlight_hunk(bufnr, ns, hunk, config.max_lines_per_hunk, config.debug)
end
end
---@param bufnr integer
---@return fun()
local function create_debounced_highlight(bufnr)
local timer = nil
return function()
if timer then
timer:stop() ---@diagnostic disable-line: undefined-field
timer:close() ---@diagnostic disable-line: undefined-field
end
timer = vim.uv.new_timer()
timer:start(
config.debounce_ms,
0,
vim.schedule_wrap(function()
timer:close()
timer = nil
highlight_buffer(bufnr)
end)
)
end
end
---@param bufnr? integer
function M.attach(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if attached_buffers[bufnr] then
return
end
attached_buffers[bufnr] = true
dbg('attaching to buffer %d', bufnr)
local debounced = create_debounced_highlight(bufnr)
highlight_buffer(bufnr)
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
buffer = bufnr,
callback = debounced,
})
vim.api.nvim_create_autocmd('Syntax', {
buffer = bufnr,
callback = function()
dbg('syntax event, re-highlighting buffer %d', bufnr)
highlight_buffer(bufnr)
end,
})
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
callback = function()
attached_buffers[bufnr] = nil
end,
})
end
---@param bufnr? integer
function M.refresh(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
highlight_buffer(bufnr)
end
---@param opts? fugitive-ts.Config
function M.setup(opts)
opts = opts or {}
config = vim.tbl_deep_extend('force', default_config, opts)
end
return M

109
lua/fugitive-ts/parser.lua Normal file
View file

@ -0,0 +1,109 @@
---@class fugitive-ts.Hunk
---@field filename string
---@field lang string
---@field start_line integer
---@field lines string[]
local M = {}
---@param msg string
---@param ... any
local function dbg(msg, ...)
local formatted = string.format(msg, ...)
vim.notify('[fugitive-ts] ' .. formatted, vim.log.levels.DEBUG)
end
---@param filename string
---@param custom_langs? table<string, string>
---@param debug? boolean
---@return string?
local function get_lang_from_filename(filename, custom_langs, debug)
if custom_langs and custom_langs[filename] then
return custom_langs[filename]
end
local ft = vim.filetype.match({ filename = filename })
if not ft then
if debug then
dbg('no filetype for: %s', filename)
end
return nil
end
local lang = vim.treesitter.language.get_lang(ft)
if lang then
local ok = pcall(vim.treesitter.language.inspect, lang)
if ok then
return lang
end
if debug then
dbg('no parser for lang: %s (ft: %s)', lang, ft)
end
elseif debug then
dbg('no ts lang for filetype: %s', ft)
end
return nil
end
---@param bufnr integer
---@param custom_langs? table<string, string>
---@param debug? boolean
---@return fugitive-ts.Hunk[]
function M.parse_buffer(bufnr, custom_langs, debug)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
---@type fugitive-ts.Hunk[]
local hunks = {}
---@type string?
local current_filename = nil
---@type string?
local current_lang = nil
---@type integer?
local hunk_start = nil
---@type string[]
local hunk_lines = {}
local function flush_hunk()
if hunk_start and #hunk_lines > 0 and current_lang then
table.insert(hunks, {
filename = current_filename,
lang = current_lang,
start_line = hunk_start,
lines = hunk_lines,
})
end
hunk_start = nil
hunk_lines = {}
end
for i, line in ipairs(lines) do
local filename = line:match('^[MADRC%?!]%s+(.+)$')
if filename then
flush_hunk()
current_filename = filename
current_lang = get_lang_from_filename(filename, custom_langs, debug)
if debug and current_lang then
dbg('file: %s -> lang: %s', filename, current_lang)
end
elseif line:match('^@@.-@@') then
flush_hunk()
hunk_start = i
elseif hunk_start then
local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then
table.insert(hunk_lines, line)
elseif line == '' or line:match('^[MADRC%?!]%s+') or line:match('^%a') then
flush_hunk()
current_filename = nil
current_lang = nil
end
end
end
flush_hunk()
return hunks
end
return M

11
plugin/fugitive-ts.lua Normal file
View file

@ -0,0 +1,11 @@
if vim.g.loaded_fugitive_ts then
return
end
vim.g.loaded_fugitive_ts = 1
vim.api.nvim_create_autocmd('FileType', {
pattern = 'fugitive',
callback = function(args)
require('fugitive-ts').attach(args.buf)
end,
})