Files
ethersync-p2p/nvim-plugin/lua/ethersync/changetracker.lua
aileot 201101b11c refactor(nvim): insert "ethersync" to each module
not to pollute lua package.loaded namespaces with general terms.
2025-07-30 17:54:22 +02:00

199 lines
9.3 KiB
Lua

-- SPDX-FileCopyrightText: 2024 blinry <mail@blinry.org>
-- SPDX-FileCopyrightText: 2024 zormit <nt4u@kpvn.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
local sync = require("vim.lsp.sync")
local utils = require("ethersync.utils")
local debug = require("ethersync.logging").debug
local M = {}
-- Used to note that changes to the buffer should be ignored, and not be sent out as deltas.
local ignore_edits = false
local function get_all_lines_respecting_eol(buffer)
local lines = vim.api.nvim_buf_get_lines(buffer, 0, -1, true)
-- If eol is on, that's like a virtual empty line after the current lines.
if vim.bo[buffer].eol then
table.insert(lines, "")
end
return lines
end
local function is_empty(diff)
return diff.text == ""
and diff.range["start"].line == diff.range["end"].line
and diff.range["start"].character == diff.range["end"].character
end
-- Subscribes the callback to changes for a given buffer id and reports with a delta.
--
-- The delta can be expected to be in the format as specified in the daemon-editor protocol.
function M.track_changes(buffer, callback)
-- Used to remember the previous content of the buffer, so that we can
-- calculate the difference between the previous and the current content.
local prev_lines = get_all_lines_respecting_eol(buffer)
vim.api.nvim_buf_attach(buffer, false, {
on_lines = function(
_the_literal_string_lines --[[@diagnostic disable-line]],
_buffer_handle --[[@diagnostic disable-line]],
_changedtick, --[[@diagnostic disable-line]]
first_line,
last_line,
new_last_line
)
-- First, clear the "modified" option, so that the buffer is not displayed as dirty.
-- Being modified doesn't have meaning for ethersync-ed files.
vim.api.nvim_buf_set_option(buffer, "modified", false)
-- Line counts that we get called with are zero-based.
-- last_line and new_last_line are exclusive
-- TODO: optimize with a cache
local curr_lines = get_all_lines_respecting_eol(buffer)
-- Special case: When deleting the entire content, when 'eol' is on, there
-- will still be a "virtual line" after the current empty line: The file content will be "\n".
-- So new_last_line should not be 0, but 1!
if vim.bo[buffer].eol and #curr_lines == 2 and curr_lines[1] == "" and last_line == 1 then
new_last_line = 1
end
debug({ first_line = first_line, last_line = last_line, new_last_line = new_last_line })
-- Are we currently ignoring edits?
if ignore_edits then
prev_lines = curr_lines
return
end
debug({ curr_lines = curr_lines, prev_lines = prev_lines })
local diff = sync.compute_diff(prev_lines, curr_lines, first_line, last_line, new_last_line, "utf-32", "\n")
-- line/character indices in diff are zero-based.
debug({ diff = diff })
-- Special case: If the entire content is deleted, undo the special treatment introduced in
-- https://github.com/neovim/neovim/pull/29904. We think it's incorrect. :P
if #curr_lines == 1 and curr_lines[1] == "" then
diff.range["start"].line = 0
end
-- TODO: Simplify the solution?
-- For example, pull tests into good variable names like "ends_with_newline".
-- TODO: Update the following comment to describe the problem and the solution more clearly.
-- Sometimes, Neovim deletes full lines by deleting the last line, plus an imaginary newline at the end. For example, to delete the second line, Neovim would delete from (line: 1, column: 0) to (line: 2, column 0).
-- But, in the case of deleting the last line, what we expect in the rest of Ethersync is to delete the newline *before* the line.
-- So let's change the deleted range to (line: 0, column: [last character of the first line]) to (line: 1, column: [last character of the second line]).
if diff.range["end"].line == #prev_lines then
-- Range spans to a line one after the visible buffer lines.
if diff.range["start"].line == 0 then
-- The range starts on the first line, so we can't "shift the range backwards".
-- Instead, we just shorten the range by one character.
diff.range["end"].line = diff.range["end"].line - 1
diff.range["end"].character = vim.fn.strchars(prev_lines[#prev_lines])
if string.sub(diff.text, -1) == "\n" then
-- The replacement ends with a newline.
-- Drop it, because we shortened the range not to include the newline.
diff.text = string.sub(diff.text, 1, -2)
end
else
-- The range doesn't start on the first line.
if diff.range["end"].character == 0 then
-- The range ends at the beginning of the line after the visible lines.
if diff.range["start"].character == 0 then
-- Operation applies to beginning of lines, that means it's possible to shift it back.
-- Modify edit, s.t. not the last \n, but the one before is replaced.
diff.range["start"].line = diff.range["start"].line - 1
diff.range["end"].line = diff.range["end"].line - 1
diff.range["start"].character = vim.fn.strchars(prev_lines[diff.range["start"].line + 1])
diff.range["end"].character = vim.fn.strchars(prev_lines[diff.range["end"].line + 1])
elseif string.sub(diff.text, -1) == "\n" then
-- The replacement ends with a newline.
-- Drop it, and shorten the range by one character.
diff.text = string.sub(diff.text, 1, -2)
diff.range["end"].line = diff.range["end"].line - 1
diff.range["end"].character = vim.fn.strchars(prev_lines[diff.range["end"].line + 1])
else
vim.api.nvim_err_writeln(
"[ethersync] We don't know how to handle this case for a deletion after the last visible line. Please file a bug."
)
end
else
vim.api.nvim_err_writeln(
"[ethersync] We think a delta ending inside the line after the visible ones cannot happen. Please file a bug."
)
end
end
else
-- The range does not extend past the visible buffer lines (diff.range["end"].line < #prev_lines).
-- We might still want to make the delta prettier.
-- TODO: Integrate these cases in the above if branches somehow?
if
diff.range["end"].character == 0
and string.sub(diff.text, 1, 1) == "\n"
and diff.range["start"].character == vim.fn.strchars(prev_lines[diff.range["start"].line + 1])
and diff.range["start"].line < diff.range["end"].line
then
-- Range starts at the end of a line, and spans the newline after it, but also begins with a newline.
-- This newline is redundant, and leads to less-pretty diffs. Remove it.
diff.text = string.sub(diff.text, 2, -1)
diff.range["start"].line = diff.range["start"].line + 1
diff.range["start"].character = 0
end
if
diff.range["end"].character == 0
and string.sub(diff.text, -1) == "\n"
and diff.range["start"].line < diff.range["end"].line
then
-- Range ends on the beginning of a line, but the replacement ends with a newline.
-- This newline is redundant, and leads to less-pretty diffs. Remove it.
diff.text = string.sub(diff.text, 1, -2)
diff.range["end"].line = diff.range["end"].line - 1
diff.range["end"].character = vim.fn.strchars(prev_lines[diff.range["end"].line + 1])
end
end
prev_lines = curr_lines
if is_empty(diff) then
return
end
local delta = {
{
range = diff.range,
replacement = diff.text,
},
}
debug({ final_delta = delta })
callback(delta)
end,
})
end
function M.apply_delta(buffer, delta)
local text_edits = {}
for _, replacement in ipairs(delta) do
local text_edit = {
range = replacement.range,
newText = replacement.replacement,
}
table.insert(text_edits, text_edit)
end
ignore_edits = true
utils.apply_text_edits(text_edits, buffer, "utf-32")
ignore_edits = false
end
return M