From e12b0eb5fcd377c305877285f0683019595b6be8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 19:37:44 -0500 Subject: [PATCH] feat(doc): readme --- README.md | 282 +++++++++----------------------------------- scripts/test-env.sh | 93 +++++++++++++++ 2 files changed, 151 insertions(+), 224 deletions(-) create mode 100755 scripts/test-env.sh 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/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