feat: add the entire plugin

This commit is contained in:
Barrett Ruth 2026-02-01 17:38:32 -05:00
parent 7eade50d05
commit 21b8cfb470
7 changed files with 382 additions and 5 deletions

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()>)

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

@ -0,0 +1,69 @@
*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,
-- Custom filename -> language mappings (optional)
languages = {},
-- Debounce delay in ms (default: 50)
debounce_ms = 50,
})
<
==============================================================================
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,41 @@
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
local common_langs = { 'lua', 'python', 'javascript', 'typescript', 'rust', 'go', 'c', 'cpp' }
local available = {}
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,52 @@
local M = {}
function M.highlight_hunk(bufnr, ns, hunk)
local lang = hunk.lang
if not lang then
return
end
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
return
end
local trees = parser_obj:parse()
if not trees or #trees == 0 then
return
end
local query = vim.treesitter.query.get(lang, 'highlights')
if not query then
return
end
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,
})
end
end
return M

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

@ -0,0 +1,109 @@
---@class fugitive-ts.Config
---@field enabled boolean
---@field languages table<string, string>
---@field debounce_ms 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,
languages = {},
debounce_ms = 50,
}
---@type fugitive-ts.Config
local config = vim.deepcopy(default_config)
---@type table<integer, boolean>
local attached_buffers = {}
---@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)
for _, hunk in ipairs(hunks) do
highlight.highlight_hunk(bufnr, ns, hunk)
end
end
---@param bufnr integer
---@return fun()
local function create_debounced_highlight(bufnr)
---@type uv_timer_t?
local timer = nil
return function()
if timer then
timer:stop()
timer:close()
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
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('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

View file

@ -0,0 +1,89 @@
---@class fugitive-ts.Hunk
---@field filename string
---@field lang string
---@field start_line integer
---@field lines string[]
local M = {}
---@param filename string
---@param custom_langs? table<string, string>
---@return string?
local function get_lang_from_filename(filename, custom_langs)
if custom_langs and custom_langs[filename] then
return custom_langs[filename]
end
local ft = vim.filetype.match({ filename = filename })
if not ft then
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
end
return nil
end
---@param bufnr integer
---@param custom_langs? table<string, string>
---@return fugitive-ts.Hunk[]
function M.parse_buffer(bufnr, custom_langs)
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)
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,
})