diff --git a/README.md b/README.md index adad924..61bda38 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,70 @@ # fugitive-ts.nvim -Treesitter syntax highlighting for vim-fugitive diff views. +**Treesitter syntax highlighting for vim-fugitive diff views** -## Problem +Transform fugitive's regex-based diff highlighting into language-aware, +treesitter-powered syntax 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: +## Features -``` -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 -``` +- **Language-aware highlighting**: Full treesitter syntax highlighting for code + in diff hunks +- **Automatic language detection**: Detects language from filenames using + Neovim's filetype detection +- **Header context highlighting**: Highlights function signatures in hunk + headers (`@@ ... @@ function foo()`) +- **Performance optimized**: Debounced updates, configurable max lines per hunk +- **Zero configuration**: Works out of the box with sensible defaults -## Solution +## Requirements -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 - -```lua -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 - -```lua -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 - -```lua -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: - -```lua -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: - -```lua -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 - -```lua -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 +- Neovim 0.9.0+ +- [vim-fugitive](https://github.com/tpope/vim-fugitive) - Treesitter parsers for languages you want highlighted -## Performance Considerations +## Installation -- 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 +Using [lazy.nvim](https://github.com/folke/lazy.nvim): -## References +```lua +{ + 'barrettruth/fugitive-ts.nvim', + dependencies = { 'tpope/vim-fugitive' }, + opts = {}, +} +``` -- [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]() -- [vim.treesitter.get_string_parser]() +## Configuration + +```lua +require('fugitive-ts').setup({ + enabled = true, + debug = false, + languages = {}, + disabled_languages = {}, + highlight_headers = true, + debounce_ms = 50, + max_lines_per_hunk = 500, +}) +``` + +| Option | Default | Description | +| -------------------- | ------- | --------------------------------------------- | +| `enabled` | `true` | Enable/disable highlighting | +| `debug` | `false` | Log debug messages to `:messages` | +| `languages` | `{}` | Custom filename → language mappings | +| `disabled_languages` | `{}` | Languages to skip (e.g., `{"markdown"}`) | +| `highlight_headers` | `true` | Highlight context in `@@ ... @@` hunk headers | +| `debounce_ms` | `50` | Debounce delay for re-highlighting | +| `max_lines_per_hunk` | `500` | Skip treesitter for large hunks | + +## Documentation + +```vim +:help fugitive-ts.nvim +``` + +## Similar Projects + +- [codediff.nvim](https://github.com/esmuellert/codediff.nvim) +- [diffview.nvim](https://github.com/sindrets/diffview.nvim) diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt index f91fb09..cc900b1 100644 --- a/doc/fugitive-ts.nvim.txt +++ b/doc/fugitive-ts.nvim.txt @@ -32,6 +32,14 @@ SETUP *fugitive-ts-setup* -- Custom filename -> language mappings (optional) languages = {}, + -- Languages to skip treesitter highlighting for (default: {}) + -- Uses treesitter language names, e.g. {"markdown", "vimdoc"} + disabled_languages = {}, + + -- Highlight context in hunk headers (default: true) + -- e.g. "@@ -10,3 +10,4 @@ function foo()" -> "function foo()" gets highlighted + highlight_headers = true, + -- Debounce delay in ms (default: 50) debounce_ms = 50, diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua index ea292d0..6286f10 100644 --- a/lua/fugitive-ts/highlight.lua +++ b/lua/fugitive-ts/highlight.lua @@ -10,9 +10,58 @@ end ---@param bufnr integer ---@param ns integer ---@param hunk fugitive-ts.Hunk ----@param max_lines integer +---@param col_offset integer +---@param text string +---@param lang string ---@param debug? boolean -function M.highlight_hunk(bufnr, ns, hunk, max_lines, debug) +---@return integer +local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, debug) + local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang) + if not ok or not parser_obj then + return 0 + end + + local trees = parser_obj:parse() + if not trees or #trees == 0 then + return 0 + end + + local query = vim.treesitter.query.get(lang, 'highlights') + if not query then + return 0 + end + + local extmark_count = 0 + local header_line = hunk.start_line - 1 + + for id, node, _ in query:iter_captures(trees[1]:root(), text) do + local capture_name = '@' .. query.captures[id] + local sr, sc, er, ec = node:range() + + local buf_sr = header_line + sr + local buf_er = header_line + er + local buf_sc = col_offset + sc + local buf_ec = col_offset + ec + + 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 + + return extmark_count +end + +---@param bufnr integer +---@param ns integer +---@param hunk fugitive-ts.Hunk +---@param max_lines integer +---@param highlight_headers boolean +---@param debug? boolean +function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) local lang = hunk.lang if not lang then return @@ -66,6 +115,20 @@ function M.highlight_hunk(bufnr, ns, hunk, max_lines, debug) return end + if highlight_headers and hunk.header_context and hunk.header_context_col then + local header_line = hunk.start_line - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { + end_col = hunk.header_context_col + #hunk.header_context, + hl_group = 'Normal', + priority = 199, + }) + local header_extmarks = + highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, lang, debug) + if debug and header_extmarks > 0 then + dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) + end + end + for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua index 4247062..209a19c 100644 --- a/lua/fugitive-ts/init.lua +++ b/lua/fugitive-ts/init.lua @@ -2,6 +2,8 @@ ---@field enabled boolean ---@field debug boolean ---@field languages table +---@field disabled_languages string[] +---@field highlight_headers boolean ---@field debounce_ms integer ---@field max_lines_per_hunk integer @@ -21,6 +23,8 @@ local default_config = { enabled = true, debug = false, languages = {}, + disabled_languages = {}, + highlight_headers = true, debounce_ms = 50, max_lines_per_hunk = 500, } @@ -53,10 +57,18 @@ local function highlight_buffer(bufnr) vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - local hunks = parser.parse_buffer(bufnr, config.languages, config.debug) + local hunks = + parser.parse_buffer(bufnr, config.languages, config.disabled_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) + highlight.highlight_hunk( + bufnr, + ns, + hunk, + config.max_lines_per_hunk, + config.highlight_headers, + config.debug + ) end end diff --git a/lua/fugitive-ts/parser.lua b/lua/fugitive-ts/parser.lua index fec0dda..67c84ee 100644 --- a/lua/fugitive-ts/parser.lua +++ b/lua/fugitive-ts/parser.lua @@ -2,6 +2,8 @@ ---@field filename string ---@field lang string ---@field start_line integer +---@field header_context string? +---@field header_context_col integer? ---@field lines string[] local M = {} @@ -15,9 +17,10 @@ end ---@param filename string ---@param custom_langs? table +---@param disabled_langs? string[] ---@param debug? boolean ---@return string? -local function get_lang_from_filename(filename, custom_langs, debug) +local function get_lang_from_filename(filename, custom_langs, disabled_langs, debug) if custom_langs and custom_langs[filename] then return custom_langs[filename] end @@ -32,6 +35,12 @@ local function get_lang_from_filename(filename, custom_langs, debug) local lang = vim.treesitter.language.get_lang(ft) if lang then + if disabled_langs and vim.tbl_contains(disabled_langs, lang) then + if debug then + dbg('lang disabled: %s', lang) + end + return nil + end local ok = pcall(vim.treesitter.language.inspect, lang) if ok then return lang @@ -48,9 +57,10 @@ end ---@param bufnr integer ---@param custom_langs? table +---@param disabled_langs? string[] ---@param debug? boolean ---@return fugitive-ts.Hunk[] -function M.parse_buffer(bufnr, custom_langs, debug) +function M.parse_buffer(bufnr, custom_langs, disabled_langs, debug) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) ---@type fugitive-ts.Hunk[] local hunks = {} @@ -61,6 +71,10 @@ function M.parse_buffer(bufnr, custom_langs, debug) local current_lang = nil ---@type integer? local hunk_start = nil + ---@type string? + local hunk_header_context = nil + ---@type integer? + local hunk_header_context_col = nil ---@type string[] local hunk_lines = {} @@ -70,10 +84,14 @@ function M.parse_buffer(bufnr, custom_langs, debug) filename = current_filename, lang = current_lang, start_line = hunk_start, + header_context = hunk_header_context, + header_context_col = hunk_header_context_col, lines = hunk_lines, }) end hunk_start = nil + hunk_header_context = nil + hunk_header_context_col = nil hunk_lines = {} end @@ -82,13 +100,18 @@ function M.parse_buffer(bufnr, custom_langs, debug) if filename then flush_hunk() current_filename = filename - current_lang = get_lang_from_filename(filename, custom_langs, debug) + current_lang = get_lang_from_filename(filename, custom_langs, disabled_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 + local prefix, context = line:match('^(@@.-@@%s*)(.*)') + if context and context ~= '' then + hunk_header_context = context + hunk_header_context_col = #prefix + end elseif hunk_start then local prefix = line:sub(1, 1) if prefix == ' ' or prefix == '+' or prefix == '-' then diff --git a/scripts/test-env.sh b/scripts/test-env.sh new file mode 100755 index 0000000..f0d967a --- /dev/null +++ b/scripts/test-env.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -e + +PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMP_DIR=$(mktemp -d) + +echo "Creating test environment in $TEMP_DIR" + +cd "$TEMP_DIR" +git init -q + +cat > test.lua << 'EOF' +local M = {} + +function M.hello() + local msg = "hello world" + print(msg) + return true +end + +return M +EOF + +cat > test.py << 'EOF' +def hello(): + msg = "hello world" + print(msg) + return True + +if __name__ == "__main__": + hello() +EOF + +cat > test.js << 'EOF' +function hello() { + const msg = "hello world"; + console.log(msg); + return true; +} + +module.exports = { hello }; +EOF + +git add -A +git commit -q -m "initial commit" + +cat >> test.lua << 'EOF' + +function M.goodbye() + local msg = "goodbye world" + print(msg) + return false +end +EOF + +cat >> test.py << 'EOF' + +def goodbye(): + msg = "goodbye world" + print(msg) + return False +EOF + +cat >> test.js << 'EOF' + +function goodbye() { + const msg = "goodbye world"; + console.log(msg); + return false; +} +EOF + +git add test.lua + +cat > init.lua << EOF +vim.opt.rtp:prepend('$PLUGIN_DIR') +vim.opt.rtp:prepend(vim.fn.stdpath('data') .. '/lazy/vim-fugitive') + +require('fugitive-ts').setup({ + debug = true, +}) + +vim.cmd('Git') +EOF + +echo "Test repo created with:" +echo " - test.lua (staged changes)" +echo " - test.py (unstaged changes)" +echo " - test.js (unstaged changes)" +echo "" +echo "Opening neovim with fugitive..." + +nvim -u init.lua