Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- The diff view highlights code embedded in another language with that language's parser, not only the file's own grammar

## [0.2.0] — 2026-10-09

### Added

- `dw` in the diff or the panel shows a partly staged file's changes since staging, works as a toggle
- `ds` in the panel or the diff previews the commit: only the staged changes, each diffed against `HEAD`, also works as a toggle
- `s` and `u` act on a hunk the index holds only part of, and the diff winbar tallies staged and partial hunks
Expand Down
159 changes: 125 additions & 34 deletions lua/differ/syntax/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ local M = {}
local ns = vim.api.nvim_create_namespace("differ.syntax")

-- layering: syntax foreground sits *under* the diff line background (paint
-- uses 100) and word-level spans (200), so the diff state always reads on top
-- uses 100) and word-level spans (200), so the diff state always reads on top.
-- an injected layer sits one above its host, capped short of the diff layer, so
-- embedded code wins where the host also captures the region it was lifted from
local PRIORITY = 90
local MAX_DEPTH_BUMP = 9

-- ceiling for parsing injections. the primary tree is still parsed above it, so a
-- generated file keeps its tag/keyword level highlighting without paying for
-- injection discovery over the whole tree
local INJECTION_MAX_BYTES = 512 * 1024

-- resolve a treesitter language for `path`, or nil when there's no filetype, no
-- mapped language, or the parser isn't installed, in which case the pass is
Expand All @@ -39,10 +47,88 @@ local function resolve_lang(path)
return lang
end

-- parse `text` and collect highlight captures in source coordinates. mirrors the
-- core highlighter: hl group is `@<capture>.<lang>`, captures named `_…` are
-- internal and skipped. multi-line captures are clipped to one entry per line;
-- injections (embedded languages) are deferred for v1, only the primary tree
-- treesitter handles are typed `any`: there's no nvim type library on the
-- typecheck path, so naming TSTree/TSNode/Query would only be an undefined alias
---@class differ.SyntaxLayer
---@field tree any -- TSTree
---@field lang string
---@field query any -- vim.treesitter.Query
---@field priority integer

-- flatten a language tree into paint layers, host before its injections so
-- embedded code is applied over the region the host lifted it from. one injected
-- language holds one layer per region, not one per language, so a file with three
-- `<script>` blocks yields three trees under a single child
---@param parser any
---@return differ.SyntaxLayer[]
local function layers(parser)
local out = {}
local function walk(ltree, depth)
local lang = ltree:lang()
local query = vim.treesitter.query.get(lang, "highlights")
if query then
for _, tree in pairs(ltree:trees()) do
out[#out + 1] = {
tree = tree,
lang = lang,
query = query,
priority = PRIORITY + math.min(depth, MAX_DEPTH_BUMP),
}
end
end
for _, child in pairs(ltree:children()) do
walk(child, depth + 1)
end
end
walk(parser, 0)
return out
end

-- one capture becomes one entry per line it covers: head to EOL, full middle
-- lines, tail to ecol (an empty tail is skipped)
---@param out differ.SyntaxCapture[]
---@param node any
---@param hl string
---@param lines string[]
---@param priority integer
local function push_capture(out, node, hl, lines, priority)
local srow, scol, erow, ecol = node:range()
local function push(row, col_start, col_end)
out[#out + 1] = {
row = row,
col_start = col_start,
col_end = col_end,
hl = hl,
priority = priority,
}
end
if srow == erow then
push(srow, scol, ecol)
return
end
push(srow, scol, #(lines[srow + 1] or ""))
for r = srow + 1, erow - 1 do
push(r, 0, #(lines[r + 1] or ""))
end
if ecol > 0 then
push(erow, 0, ecol)
end
end

-- captures named `_…` are internal to the query; spell/nospell carry spell
-- metadata rather than a highlight group, which is what the core highlighter
-- reads them as
---@param name string
---@return boolean
local function is_paintable(name)
return not vim.startswith(name, "_") and name ~= "spell" and name ~= "nospell"
end

-- parse `text` and collect highlight captures in source coordinates, injected
-- languages included. mirrors the core highlighter: the hl group is
-- `@<capture>.<lang>` of the tree the capture came from, so embedded typescript
-- in an astro file reads as typescript. injected ranges come back in the outer
-- string's coordinates, so no translation is needed here or in project
---@param text string
---@param lang string
---@return differ.SyntaxCapture[]
Expand All @@ -51,41 +137,45 @@ local function captures_for(text, lang)
if not ok or not parser then
return {}
end
local query = vim.treesitter.query.get(lang, "highlights")
if not query then
return {}
end
local tree = parser:parse()[1]
if not tree then
if not pcall(parser.parse, parser, #text <= INJECTION_MAX_BYTES) then
return {}
end

local lines = vim.split(text, "\n", { plain = true })
local out = {}
for id, node in query:iter_captures(tree:root(), text, 0, -1) do
local name = query.captures[id]
if not vim.startswith(name, "_") then
local hl = "@" .. name .. "." .. lang
local srow, scol, erow, ecol = node:range()
if srow == erow then
out[#out + 1] = { row = srow, col_start = scol, col_end = ecol, hl = hl }
else
-- head to EOL, full middle lines, tail to ecol (skip an empty tail)
out[#out + 1] =
{ row = srow, col_start = scol, col_end = #(lines[srow + 1] or ""), hl = hl }
for r = srow + 1, erow - 1 do
out[#out + 1] =
{ row = r, col_start = 0, col_end = #(lines[r + 1] or ""), hl = hl }
end
if ecol > 0 then
out[#out + 1] = { row = erow, col_start = 0, col_end = ecol, hl = hl }
end
for _, layer in ipairs(layers(parser)) do
for id, node in layer.query:iter_captures(layer.tree:root(), text, 0, -1) do
local name = layer.query.captures[id]
if is_paintable(name) then
push_capture(out, node, "@" .. name .. "." .. layer.lang, lines, layer.priority)
end
end
end
return out
end

-- a model's old_text/new_text are fixed once built, so a rerender (a layout or
-- context toggle) reprojects rather than reparsing both sides. weak keys: an
-- entry falls away with the model it was parsed from
---@type table<differ.DiffModel, table<string, differ.SyntaxCapture[]>>
local cache = setmetatable({}, { __mode = "k" })

---@param model differ.DiffModel
---@param lang string
---@param side "old"|"new"
---@return differ.SyntaxCapture[]
local function side_captures(model, lang, side)
local entry = cache[model]
if not entry then
entry = {}
cache[model] = entry
end
if not entry[side] then
entry[side] = captures_for(side == "old" and model.old_text or model.new_text, lang)
end
return entry[side]
end

-- apply the syntax pass to one column's buffer: parse the real source(s) the
-- column draws from (old, new, or both for a unified/stacked column), project the
-- captures through the map, and paint them as extmarks. no-op when the language
Expand All @@ -105,13 +195,13 @@ function M.apply(bufnr, column, model)
if column.side == "old" or column.side == "unified" then
vim.list_extend(
marks,
project.project(captures_for(model.old_text, lang), column.map.from_old)
project.project(side_captures(model, lang, "old"), column.map.from_old)
)
end
if column.side == "new" or column.side == "unified" then
vim.list_extend(
marks,
project.project(captures_for(model.new_text, lang), column.map.from_new)
project.project(side_captures(model, lang, "new"), column.map.from_new)
)
end

Expand All @@ -121,7 +211,7 @@ function M.apply(bufnr, column, model)
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, m.row, m.col_start, {
end_col = m.col_end,
hl_group = m.hl,
priority = PRIORITY,
priority = m.priority,
})
end
end
Expand All @@ -131,7 +221,8 @@ end
-- recorded buffer rows, shifted right by col_offset (the spine + marker prefix). same
-- derived-buffer rule as apply: never parse the page itself. clears the namespace once,
-- so one call per repaint covers every snippet; a path with no parser is skipped and
-- its hunk stays plain
-- its hunk stays plain. a snippet is a fragment, so an injection whose opening
-- delimiter was cut away simply doesn't fire and that line stays plain
---@param bufnr integer
---@param snippets { path: string, col_offset: integer, lines: { row: integer, text: string }[] }[]|nil
function M.apply_snippets(bufnr, snippets)
Expand All @@ -155,7 +246,7 @@ function M.apply_snippets(bufnr, snippets)
{
end_col = c.col_end + s.col_offset,
hl_group = c.hl,
priority = PRIORITY,
priority = c.priority,
}
)
end
Expand Down
3 changes: 3 additions & 0 deletions lua/differ/syntax/project.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ local M = {}
---@field col_start integer -- byte col, 0-based inclusive
---@field col_end integer -- byte col, 0-based exclusive
---@field hl string -- resolved highlight group
---@field priority integer -- host layer, one higher per injection depth

---@class differ.SyntaxMark
---@field row integer -- 0-based buffer row
---@field col_start integer
---@field col_end integer
---@field hl string
---@field priority integer

-- project source captures onto buffer rows via `from_map` (source lnum 1-based ->
-- buffer lnum 1-based). returns extmark specs in buffer coordinates
Expand All @@ -34,6 +36,7 @@ function M.project(captures, from_map)
col_start = c.col_start,
col_end = c.col_end,
hl = c.hl,
priority = c.priority,
}
end
end
Expand Down
90 changes: 87 additions & 3 deletions test/nvim/syntax_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,40 @@ local function model(old, new, path)
})
end

-- syntax extmarks for a buffer: { {row, col, end_col, hl}, ... }
-- syntax extmarks for a buffer: { {row, col, end_col, hl, priority}, ... }
local function syntax_marks(bufnr)
local out = {}
for _, m in ipairs(vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })) do
out[#out + 1] = { row = m[2], col = m[3], end_col = m[4].end_col, hl = m[4].hl_group }
out[#out + 1] = {
row = m[2],
col = m[3],
end_col = m[4].end_col,
hl = m[4].hl_group,
priority = m[4].priority,
}
end
return out
end

-- the first mark with `hl` on `row` (optionally starting at `col`), or nil
local function find(marks, row, hl, col)
for _, m in ipairs(marks) do
if m.row == row and m.hl == hl and (col == nil or m.col == col) then
return m
end
end
return nil
end

-- is there a mark with `hl` on `row` (optionally starting at `col`)?
local function has(marks, row, hl, col)
return find(marks, row, hl, col) ~= nil
end

-- is there a mark with `hl` anywhere in the buffer?
local function has_hl(marks, hl)
for _, m in ipairs(marks) do
if m.row == row and m.hl == hl and (col == nil or m.col == col) then
if m.hl == hl then
return true
end
end
Expand Down Expand Up @@ -89,6 +110,50 @@ describe("syntax pass (split)", function()
end)
end)

-- injected languages: a fenced lua block in markdown. both parsers ship with nvim, so
-- this holds wherever the suite runs; astro/vue/svelte are the same shape, with the bulk
-- of the file living in injections rather than in the host tree
describe("syntax pass (injections)", function()
-- stacked, whole-file context: "# t"(0), ""(1), "```lua"(2), old(3), new(4), "```"(5)
local function md_view()
return view("# t\n\n```lua\nlocal x = 1\n```\n", "# t\n\n```lua\nlocal y = 2\n```\n", {
path = "x.md",
})
end

it("highlights the embedded language, not just the host", function()
local v = md_view()
v:open()
local marks = syntax_marks(v.columns[1].bufnr)
assert.is_true(has(marks, 3, "@keyword.lua", 0)) -- `local` on the old line
assert.is_true(has(marks, 4, "@keyword.lua", 0)) -- and on the new line
assert.is_true(has(marks, 0, "@markup.heading.1.markdown")) -- host tree still paints
v:close()
end)

it("layers an injected capture over the host's on the same row", function()
local v = md_view()
v:open()
local marks = syntax_marks(v.columns[1].bufnr)
-- markdown captures the whole fence as raw block, lua captures inside it
local host = find(marks, 3, "@markup.raw.block.markdown")
local injected = find(marks, 3, "@keyword.lua")
assert.is_not_nil(host)
assert.is_not_nil(injected)
assert.is_true(injected.priority > host.priority)
v:close()
end)

it("skips spell captures, which carry metadata rather than a highlight", function()
local v = md_view()
v:open()
local marks = syntax_marks(v.columns[1].bufnr)
assert.is_false(has_hl(marks, "@spell.markdown"))
assert.is_false(has_hl(marks, "@nospell.markdown"))
v:close()
end)
end)

-- apply_snippets projects treesitter captures onto arbitrary buffer rows (the overview's
-- boxed hunk lines), parsed from the stripped source text and shifted right by col_offset
-- to clear the box spine + inset. same namespace/helpers as the View syntax pass
Expand Down Expand Up @@ -122,6 +187,25 @@ describe("apply_snippets (overview hunk syntax)", function()
assert.is_true(has(marks, 1, "@function.call.lua")) -- keep() on the next snippet row
end)

it("projects injected captures too, shifted by col_offset", function()
local offset = 3
local pad = string.rep(" ", offset)
local src = { "```lua", "local x = 1", "```" }
local b = scratch({ pad .. src[1], pad .. src[2], pad .. src[3] })
syntax.apply_snippets(b, {
{
path = "x.md",
col_offset = offset,
lines = {
{ text = src[1], row = 0 },
{ text = src[2], row = 1 },
{ text = src[3], row = 2 },
},
},
})
assert.is_true(has(syntax_marks(b), 1, "@keyword.lua", offset))
end)

it("is a no-op for a path with no treesitter language", function()
local b = scratch({ "local x = 1" })
syntax.apply_snippets(b, {
Expand Down
Loading
Loading