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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ highlighting driven by treesitter.
- Word and chatacer-level diff highlighting
- `:Diff` for [pierre-style](https://diffs.com) unified, stacked, or split diffs against any revision
- `:Diff review` full-repo review diff with qflist/loclist navigation
- `:Diff files {a} {b}` to diff two arbitrary files
- `:Diff files` to diff two arbitrary files, or the buffer against its last save
- Inline and 3-way merge conflict detection, highlighting, and resolution
- Email quoting/patch syntax support (`> diff ...`)
- Vim syntax fallback
Expand Down
23 changes: 16 additions & 7 deletions doc/diffs.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -476,16 +476,23 @@ Diff objects: ~ *diffs.nvim-diff-objects*

Diff files: ~ *diffs.nvim-diff-files*

`:Diff files {left} [{right}]` opens a read-only generated diff of two
`:Diff files [{left}] [{right}]` opens a read-only generated diff of two
arbitrary files. Neither file has to be tracked by git, and the command
works outside a repository. It is the file-path counterpart to a plain
|:Diff| object: the first path names the old (left) side, and the second
names the new (right) side. With a single path the new side defaults to the
current buffer's file, mirroring |:diffsplit| and `:Diff {rev}`.
names the new (right) side.

A named path is always read from disk. An omitted side falls back to the
current buffer: the new side is the buffer's live content, and the old
side is that buffer's file on disk. Bare `:Diff files` therefore compares
the buffer to its last save, like `:Diff {rev}`, which also diffs against
unsaved buffer content. A buffer-backed side is shown as `buffer:{name}`,
and falls back to disk if that buffer is gone when the view reloads.

Examples: >vim
:Diff files " last save (old) vs buffer (new)
:Diff files old.lua new.lua " old.lua (old) vs new.lua (new)
:Diff files other.lua " other.lua (old) vs current file (new)
:Diff files other.lua " other.lua (old) vs buffer (new)
:Diff files ++layout=stacked a.txt b.txt
<
The `++layout=unified` and `++layout=stacked` layouts and the |:vertical|
Expand All @@ -495,10 +502,12 @@ Diff files: ~ *diffs.nvim-diff-files*
(`do`/`dp`) are not available.

Errors: ~
- With no path, or more than two, the command reports the expected count.
- More than two paths reports the expected count.
- An omitted side needs a named current buffer.
- A path that is a directory, missing, unreadable, or binary is refused and
named; directories are intentionally unsupported.
- Two identical files report no changes without opening a buffer.
named; directories are intentionally unsupported. A buffer-backed side
does not have to exist on disk.
- Two identical sides report no changes without opening a buffer.

Stacked generated layout: ~ *diffs.nvim-stacked-layout*

Expand Down
86 changes: 55 additions & 31 deletions lua/diffs/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,27 @@ local function render_section_source(repo_root, section)
return replace_combined_diffs(diff_lines, repo_root)
end

---@param name string
---@param buffered boolean
---@return string
local function files_side_display(name, buffered)
return buffered and ('buffer:' .. name) or name
end

---@param source diffs.GeneratedBufferSource
---@return diffs.ContentLines?, boolean
local function files_source_new_lines(source)
local bufnr = source.right_buf
if
bufnr
and vim.api.nvim_buf_is_loaded(bufnr)
and vim.api.nvim_buf_get_name(bufnr) == source.right_path
then
return content.from_buffer(bufnr), true
end
return git.get_working_content(source.right_path), false
end

---@param source diffs.GeneratedBufferSource
---@return string[]?, diffs.DiffSpec?, string?, table?
local function render_source(source)
Expand All @@ -606,13 +627,13 @@ local function render_source(source)
if not old_lines then
return nil, nil, source.left_name .. ': file not readable', nil
end
local new_lines = git.get_working_content(source.right_path)
local new_lines, buffered = files_source_new_lines(source)
if not new_lines then
return nil, nil, source.right_name .. ': file not readable', nil
end
return render.unified_lines(old_lines, new_lines, source.left_name, source.right_name),
nil,
'files:' .. source.left_name .. ' -> ' .. source.right_name,
'files:' .. source.left_name .. ' -> ' .. files_side_display(source.right_name, buffered),
nil
end

Expand Down Expand Up @@ -998,81 +1019,84 @@ end
---@field layout "unified"|"stacked"|"split"
---@field vertical? boolean

---@param left string
---@param path string
---@return string, string
local function files_side_names(path)
local abs = vim.fn.fnamemodify(vim.fn.expand(path), ':p')
local name = vim.fn.fnamemodify(abs, ':~:.')
return abs, name ~= '' and name or abs
end

---@param left string?
---@param right string?
---@param opts? diffs.DiffFilesViewOpts
---@return integer?
function M.diff_files(left, right, opts)
opts = opts or {}

local right_input = right
if not right_input then
local current = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf())
if current == '' then
notify('cannot diff unnamed buffer', vim.log.levels.ERROR)
return nil
end
right_input = current
local current_buf = vim.api.nvim_get_current_buf()
local current_name = vim.api.nvim_buf_get_name(current_buf)
local right_buf = right == nil and current_buf or nil
if (left == nil or right == nil) and current_name == '' then
notify('cannot diff unnamed buffer', vim.log.levels.ERROR)
return nil
end

local left_abs = vim.fn.fnamemodify(vim.fn.expand(left), ':p')
local right_abs = vim.fn.fnamemodify(vim.fn.expand(right_input), ':p')
local left_name = vim.fn.fnamemodify(left_abs, ':~:.')
if left_name == '' then
left_name = left_abs
end
local right_name = vim.fn.fnamemodify(right_abs, ':~:.')
if right_name == '' then
right_name = right_abs
end
local left_abs, left_name = files_side_names(left or current_name)
local right_abs, right_name = files_side_names(right or current_name)
local right_display = files_side_display(right_name, right_buf ~= nil)

for _, side in ipairs({
{ abs = left_abs, name = left_name },
{ abs = right_abs, name = right_name },
{ abs = left_abs, name = left_name, buffered = false },
{ abs = right_abs, name = right_name, buffered = right_buf ~= nil },
}) do
if vim.fn.isdirectory(side.abs) == 1 then
notify(side.name .. ' is a directory; :Diff files compares two files', vim.log.levels.ERROR)
return nil
end
if vim.fn.filereadable(side.abs) ~= 1 then
if not side.buffered and vim.fn.filereadable(side.abs) ~= 1 then
notify(side.name .. ': file not readable', vim.log.levels.ERROR)
return nil
end
end

local old_lines = git.get_working_content(left_abs) or {}
local new_lines = git.get_working_content(right_abs) or {}
local new_lines = right_buf and content.from_buffer(right_buf)
or git.get_working_content(right_abs)
or {}
if render.has_binary_lines(old_lines) or render.has_binary_lines(new_lines) then
notify('diff does not support binary files', vim.log.levels.ERROR)
return nil
end

local diff_lines = render.unified_lines(old_lines, new_lines, left_name, right_name)
if #diff_lines == 0 then
notify('no changes between ' .. left_name .. ' and ' .. right_name, vim.log.levels.INFO)
notify('no changes between ' .. left_name .. ' and ' .. right_display, vim.log.levels.INFO)
return nil
end

local diff_buf = create_generated_diff_buffer({
name = 'diffs://files:' .. left_name .. ' -> ' .. right_name,
name = 'diffs://files:' .. left_name .. ' -> ' .. right_display,
lines = diff_lines,
source = generated.files_source(left_abs, right_abs, left_name, right_name),
source = generated.files_source(left_abs, right_abs, left_name, right_name, right_buf),
rail_style = rail_style_for_layout(opts.layout),
})
show_generated_diff_buffer(diff_buf, opts.vertical)
lists.set_for_unified_buffer(diff_buf, diff_lines, {
title = 'diff: files ' .. left_name .. ' -> ' .. right_name,
title = 'diff: files ' .. left_name .. ' -> ' .. right_display,
})
attach_generated_diff_buffer(diff_buf)

if difftastic.available() then
if right_buf then
apply_difft_unified(diff_buf, diff_lines, nil, old_lines, new_lines, right_name)
elseif difftastic.available() then
local lhs, rhs = difftastic.span_maps_for_paths(left_abs, right_abs)
if lhs and rhs then
paint_difft_unified(diff_buf, diff_lines, nil, lhs, rhs)
end
end

dbg('opened files diff buffer %d (%s -> %s)', diff_buf, left_name, right_name)
dbg('opened files diff buffer %d (%s -> %s)', diff_buf, left_name, right_display)
return diff_buf
end

Expand Down
5 changes: 1 addition & 4 deletions lua/diffs/diffargs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ function M.parse(args, context)
end

---@class diffs.DiffFilesParseResult
---@field left string # old/left side path, as typed
---@field left? string # old/left side path, as typed; nil means the current buffer's file on disk
---@field right? string # new/right side path, as typed; nil means the current buffer
---@field layout "unified"|"stacked"|"split"

Expand All @@ -235,9 +235,6 @@ function M.parse_files(args)
return nil, layout_err
end

if #tokens == 0 then
return nil, ':Diff files expects one or two file paths'
end
if #tokens > 2 then
return nil, 'expected at most two file paths'
end
Expand Down
8 changes: 7 additions & 1 deletion lua/diffs/generated/source.lua
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ end
---@field right_path? string
---@field left_name? string
---@field right_name? string
---@field right_buf? integer

---@param source table
---@return diffs.GeneratedBufferSource?
Expand Down Expand Up @@ -214,6 +215,9 @@ function M.normalize_source(source)
if type(source.right_path) ~= 'string' or source.right_path == '' then
error('expected files right_path')
end
if source.right_buf ~= nil and type(source.right_buf) ~= 'number' then
error('expected files right_buf')
end
else
error('unknown source kind')
end
Expand Down Expand Up @@ -301,15 +305,17 @@ end
---@param right_path string # absolute path of the new/right side
---@param left_name string # display name for the old/left side
---@param right_name string # display name for the new/right side
---@param right_buf? integer # buffer whose content is the new/right side; nil reads the path
---@return diffs.GeneratedBufferSource
function M.files_source(left_path, right_path, left_name, right_name)
function M.files_source(left_path, right_path, left_name, right_name, right_buf)
return {
version = 1,
kind = 'files',
left_path = left_path,
right_path = right_path,
left_name = left_name,
right_name = right_name,
right_buf = right_buf,
}
end

Expand Down
99 changes: 97 additions & 2 deletions spec/diff_files_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('diffs.commands.diff_files', function()
before_each(function()
dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
dir = vim.fn.resolve(dir)
a = dir .. '/a.txt'
b = dir .. '/b.txt'
vim.fn.writefile({ 'alpha', 'beta', 'gamma' }, a)
Expand All @@ -69,7 +70,8 @@ describe('diffs.commands.diff_files', function()
return false
end)
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_name(buf):match('^diffs://') then
local name = vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_name(buf) or ''
if name:match('^diffs://') or name:find(dir, 1, true) then
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end
end
Expand All @@ -91,14 +93,107 @@ describe('diffs.commands.diff_files', function()
assert.are.equal(b, source.right_path)
end)

it('defaults the new side to the current buffer with a single path', function()
it('defaults the new side to live buffer content with a single path', function()
vim.cmd('edit ' .. vim.fn.fnameescape(a))
local src = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' })

local bufnr = commands.diff_files(b, nil, { layout = 'unified' })

assert.is_number(bufnr)
assert.is_true(has_line(bufnr, '^.*%+UNSAVED'))

local source = generated.source(bufnr)
assert.are.equal(b, source.left_path)
assert.are.equal(a, source.right_path)
assert.are.equal(src, source.right_buf)
end)

it('compares the current buffer against its last save with no paths', function()
vim.cmd('edit ' .. vim.fn.fnameescape(a))
local src = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(src, 2, 3, false, { 'GAMMA' })

local bufnr = commands.diff_files(nil, nil, { layout = 'unified' })

assert.is_number(bufnr)
assert.is_true(has_line(bufnr, '^.*%-gamma'))
assert.is_true(has_line(bufnr, '^.*%+GAMMA'))

local source = generated.source(bufnr)
assert.are.equal(a, source.left_path)
assert.are.equal(a, source.right_path)
assert.are.equal(src, source.right_buf)
assert.is_truthy(vim.api.nvim_buf_get_name(bufnr):find('-> buffer:', 1, true))
end)

it('reports no changes for an unmodified buffer with no paths', function()
vim.cmd('edit ' .. vim.fn.fnameescape(a))

local bufnr = commands.diff_files(nil, nil, { layout = 'unified' })

assert.is_nil(bufnr)
local name = vim.fn.fnamemodify(a, ':~:.')
assert.is_true(last_message_has('no changes between ' .. name .. ' and buffer:' .. name))
end)

it('reads both sides from disk when the new side is named', function()
vim.cmd('edit ' .. vim.fn.fnameescape(b))
vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'UNSAVED' })

local bufnr = commands.diff_files(a, b, { layout = 'unified' })

assert.is_number(bufnr)
assert.is_false(has_line(bufnr, 'UNSAVED'))
assert.is_nil(generated.source(bufnr).right_buf)
end)

it('allows a buffer-backed side that is not on disk', function()
vim.cmd('edit ' .. vim.fn.fnameescape(dir .. '/new.txt'))
vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'alpha', 'beta', 'gamma', 'delta' })

local bufnr = commands.diff_files(a, nil, { layout = 'unified' })

assert.is_number(bufnr)
assert.is_true(has_line(bufnr, '^.*%+delta'))
end)

it('refuses a defaulted side in an unnamed buffer', function()
vim.cmd('enew')

assert.is_nil(commands.diff_files(a, nil, { layout = 'unified' }))
assert.is_true(last_message_has('cannot diff unnamed buffer'))
end)

it('re-reads the buffer when the view reloads', function()
vim.cmd('edit ' .. vim.fn.fnameescape(a))
local src = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'first' })

local bufnr = commands.diff_files(b, nil, { layout = 'unified' })
assert.is_number(bufnr)

vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'second' })
commands.read_buffer(bufnr)

assert.is_true(has_line(bufnr, '^.*%+second'))
assert.is_false(has_line(bufnr, '^.*%+first'))
end)

it('falls back to disk when the buffer is gone at reload', function()
vim.cmd('edit ' .. vim.fn.fnameescape(a))
local src = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' })

local bufnr = commands.diff_files(b, nil, { layout = 'unified' })
assert.is_number(bufnr)
assert.is_true(has_line(bufnr, '^.*%+UNSAVED'))

vim.api.nvim_buf_delete(src, { force = true })
commands.read_buffer(bufnr)

assert.is_false(has_line(bufnr, 'UNSAVED'))
assert.is_true(has_line(bufnr, '^.*%+beta'))
end)

it('reloads from disk through the files source', function()
Expand Down
Loading
Loading