diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index 9f934a5..9b6664e 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -3,7 +3,7 @@ name: luarocks on: push: tags: - - "v*" + - 'v*' jobs: quality: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9f6659..2edf043 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,4 +14,4 @@ repos: hooks: - id: prettier name: prettier - files: \.(md|,toml,yaml,sh)$ + files: \.(md|toml|yaml|sh)$ diff --git a/README.md b/README.md index 871ab74..adad924 100644 --- a/README.md +++ b/README.md @@ -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]() +- [vim.treesitter.get_string_parser]() diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt new file mode 100644 index 0000000..f91fb09 --- /dev/null +++ b/doc/fugitive-ts.nvim.txt @@ -0,0 +1,77 @@ +*fugitive-ts.nvim.txt* Treesitter highlighting for vim-fugitive diffs + +Author: Barrett Ruth +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: diff --git a/lua/fugitive-ts/health.lua b/lua/fugitive-ts/health.lua new file mode 100644 index 0000000..48f946b --- /dev/null +++ b/lua/fugitive-ts/health.lua @@ -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 diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua new file mode 100644 index 0000000..ea292d0 --- /dev/null +++ b/lua/fugitive-ts/highlight.lua @@ -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 diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua new file mode 100644 index 0000000..4247062 --- /dev/null +++ b/lua/fugitive-ts/init.lua @@ -0,0 +1,133 @@ +---@class fugitive-ts.Config +---@field enabled boolean +---@field debug boolean +---@field languages table +---@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 +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 diff --git a/lua/fugitive-ts/parser.lua b/lua/fugitive-ts/parser.lua new file mode 100644 index 0000000..fec0dda --- /dev/null +++ b/lua/fugitive-ts/parser.lua @@ -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 +---@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 +---@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 diff --git a/plugin/fugitive-ts.lua b/plugin/fugitive-ts.lua new file mode 100644 index 0000000..cfe7a03 --- /dev/null +++ b/plugin/fugitive-ts.lua @@ -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, +})