Skip to content

Commit 2f12c13

Browse files
committed
performance improvements
1 parent a1e13d0 commit 2f12c13

2 files changed

Lines changed: 101 additions & 28 deletions

File tree

lua/morph.lua

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ local function tree_identity_key(node, index)
148148
error 'unreachable'
149149
end
150150

151+
-- Pre-computed keymap mode tables and attribute names.
152+
-- Avoids allocating `{ 'i', 'n', 'v', 'x', 'o' }` and concatenating
153+
-- `mode .. 'map'` on every on_tag callback invocation.
154+
local KEYMAP_MODES = { 'i', 'n', 'v', 'x', 'o' }
155+
local KEYMAP_ATTRS = { 'imap', 'nmap', 'vmap', 'xmap', 'omap' }
156+
151157
--------------------------------------------------------------------------------
152158
-- Levenshtein Diff Algorithm
153159
--
@@ -425,7 +431,11 @@ local h = setmetatable({}, {
425431
-- h.Comment(attrs, children) - shorthand for h('text', { hl = 'Comment', ...attrs }, children)
426432
__index = function(self, highlight_group)
427433
return function(attributes, children)
428-
local merged_attrs = vim.tbl_deep_extend('force', { hl = highlight_group }, attributes or {})
434+
attributes = attributes or {}
435+
local merged_attrs = { hl = highlight_group }
436+
for k, v in pairs(attributes) do
437+
merged_attrs[k] = v
438+
end
429439
return self('text', merged_attrs, children or {})
430440
end
431441
end,
@@ -496,12 +506,15 @@ Extmark.__index = Extmark
496506
--- @param opts vim.api.keyset.set_extmark
497507
--- @return morph.Extmark
498508
function Extmark.new(bufnr, ns, start, stop, opts)
499-
local extmark_opts = vim.tbl_extend('force', {
509+
local extmark_opts = {
500510
end_row = stop[1],
501511
end_col = stop[2],
502512
right_gravity = false,
503513
end_right_gravity = true,
504-
}, opts)
514+
}
515+
for k, v in next, opts do
516+
extmark_opts[k] = v
517+
end
505518

506519
local id = vim.api.nvim_buf_set_extmark(bufnr, ns, start[1], start[2], extmark_opts)
507520
return setmetatable(
@@ -798,6 +811,7 @@ Morph.__index = Morph
798811
--- @return string[]
799812
function Morph.markup_to_lines(opts)
800813
local lines = {} --- @type string[]
814+
local line_buffers = {} --- @type string[][]
801815
local curr_line1, curr_col1 = 1, 1 -- 1-based position tracking
802816

803817
-- Stack of text accumulators - each tag tracks its own text content
@@ -806,16 +820,20 @@ function Morph.markup_to_lines(opts)
806820

807821
--- @param s string
808822
local function emit_text(s)
809-
lines[curr_line1] = (lines[curr_line1] or '') .. s
810-
curr_col1 = #lines[curr_line1] + 1
823+
local buf = line_buffers[curr_line1]
824+
if not buf then
825+
buf = {}
826+
line_buffers[curr_line1] = buf
827+
end
828+
table.insert(buf, s)
829+
curr_col1 = curr_col1 + #s
811830
-- Append to all active accumulators (for nested tags)
812831
for _, acc in ipairs(text_accumulators) do
813832
table.insert(acc.text, s)
814833
end
815834
end
816835

817836
local function emit_newline()
818-
table.insert(lines, '')
819837
curr_line1 = curr_line1 + 1
820838
curr_col1 = 1
821839
for _, acc in ipairs(text_accumulators) do
@@ -874,6 +892,14 @@ function Morph.markup_to_lines(opts)
874892
end
875893

876894
visit(opts.tree)
895+
896+
-- Finalize: concatenate line buffers into final lines table.
897+
-- table.concat is O(n) and single-allocation in LuaJIT.
898+
for i = 1, curr_line1 do
899+
local buf = line_buffers[i]
900+
lines[i] = buf and table.concat(buf) or ''
901+
end
902+
877903
return lines
878904
end
879905

@@ -883,35 +909,67 @@ end
883909
function Morph.markup_to_string(opts) return table.concat(Morph.markup_to_lines(opts), '\n') end
884910

885911
--- Apply minimal edits to transform buffer content from old_lines to new_lines.
886-
--- Uses Levenshtein distance to find the shortest edit sequence.
887-
--- Falls back to full buffer replacement when >30% of lines change.
912+
--- Uses a two-stage strategy:
913+
--- 1. (O(1)) If line count delta >30% on a large buffer (>500 lines), skip
914+
--- diffing entirely and do a full buffer replace.
915+
--- 2. (O(n)) Trim common prefix/suffix lines, then run Levenshtein on the
916+
--- (much smaller) middle section for character-precise edits.
888917
--- @param bufnr integer
889918
--- @param old_lines string[]?
890919
--- @param new_lines string[]
891920
function Morph.patch_lines(bufnr, old_lines, new_lines)
892921
old_lines = old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
893922

894-
-- Quick check: if >30% of lines differ, skip expensive diffing
895923
local max_lines = math.max(#old_lines, #new_lines)
896-
if max_lines > vim.o.lines * 5 then
897-
local diff_count = 0
898-
local threshold = math.floor(max_lines * 0.3)
899-
for i = 1, max_lines do
900-
if old_lines[i] ~= new_lines[i] then
901-
diff_count = diff_count + 1
902-
if diff_count > threshold then
903-
-- Too many changes, just replace the whole buffer
904-
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines)
905-
return
906-
end
907-
end
924+
925+
-- Stage 1 (O(1)): check if the line count changed enough that
926+
-- Levenshtein would be wasteful - if so, do a full buffer replace.
927+
if max_lines > 500 then
928+
local len_delta = math.abs(#old_lines - #new_lines) / max_lines
929+
if len_delta > 0.3 then
930+
local view = vim.fn.winsaveview()
931+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines)
932+
vim.fn.winrestview(view)
933+
return
908934
end
909935
end
910936

911-
local line_changes = levenshtein { from = old_lines, to = new_lines }
937+
-- Stage 2 (O(n)): trim common prefix/suffix lines so Levenshtein
938+
-- only sees the changed middle. A single-line insertion deep in a
939+
-- 1000-line list balloons Levenshtein's O(n^2) cost; most tree-view
940+
-- edits are localised, so the trimmed input is orders of magnitude
941+
-- smaller than the raw line count suggests.
942+
local prefix = 0
943+
while
944+
prefix < #old_lines
945+
and prefix < #new_lines
946+
and old_lines[prefix + 1] == new_lines[prefix + 1]
947+
do
948+
prefix = prefix + 1
949+
end
950+
951+
local suffix = 0
952+
while
953+
suffix < #old_lines - prefix
954+
and suffix < #new_lines - prefix
955+
and old_lines[#old_lines - suffix] == new_lines[#new_lines - suffix]
956+
do
957+
suffix = suffix + 1
958+
end
959+
960+
local trimmed_old = {}
961+
for i = prefix + 1, #old_lines - suffix do
962+
trimmed_old[#trimmed_old + 1] = old_lines[i]
963+
end
964+
local trimmed_new = {}
965+
for i = prefix + 1, #new_lines - suffix do
966+
trimmed_new[#trimmed_new + 1] = new_lines[i]
967+
end
968+
969+
local line_changes = levenshtein { from = trimmed_old, to = trimmed_new }
912970

913971
for _, change in ipairs(line_changes) do
914-
local line0 = change.index - 1
972+
local line0 = prefix + change.index - 1
915973

916974
if change.kind == 'add' then
917975
vim.api.nvim_buf_set_lines(bufnr, line0, line0, true, { change.item })
@@ -972,7 +1030,7 @@ function Morph.new(bufnr)
9721030
}, Morph)
9731031

9741032
-- Snapshot all buffer-local keymaps so we can restore them before each render
975-
for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
1033+
for _, mode in ipairs(KEYMAP_MODES) do
9761034
self.original_keymaps[mode] = {}
9771035
--- @diagnostic disable-next-line: param-type-mismatch
9781036
for _, map in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode)) do
@@ -1057,7 +1115,7 @@ function Morph:render(tree)
10571115
local pending_extmarks = {} --- @type { tag: morph.Tag, start: morph.Pos00, stop: morph.Pos00, opts: any }[]
10581116

10591117
-- Clear all buffer-local keymaps, then restore originals
1060-
for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
1118+
for _, mode in ipairs(KEYMAP_MODES) do
10611119
for _, map in ipairs(vim.api.nvim_buf_get_keymap(self.bufnr, mode)) do
10621120
--- @diagnostic disable-next-line: param-type-mismatch
10631121
pcall(vim.keymap.del, mode, map.lhs, { buffer = self.bufnr })
@@ -1089,8 +1147,9 @@ function Morph:render(tree)
10891147
})
10901148

10911149
-- Register keymaps for any mode handlers (nmap, imap, vmap, xmap, omap)
1092-
for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
1093-
local handlers = tag.attributes[mode .. 'map']
1150+
for i = 1, 5 do
1151+
local handlers = tag.attributes[KEYMAP_ATTRS[i]]
1152+
local mode = KEYMAP_MODES[i]
10941153
for lhs, _ in pairs(handlers or {}) do
10951154
vim.keymap.set(mode, lhs, function()
10961155
local result = self:_dispatch_keypress(mode, lhs)

spec/morph_spec.lua

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ describe('Morph', function()
271271
with_buf({}, function()
272272
local r = Morph.new(0)
273273
r:render {
274-
h.Comment({}, {
274+
h.Comment({ id = 'merged-attrs' }, {
275275
'comment start ',
276276
h.String({}, 'string'),
277277
' comment end',
@@ -336,6 +336,20 @@ describe('Morph', function()
336336
assert.are.same({ 'line 1', 'line 2', 'line 3', 'line 4' }, get_lines())
337337
end)
338338
end)
339+
340+
it('uses full buffer replace when line count delta exceeds threshold', function()
341+
with_buf({}, function()
342+
local old = {}
343+
for i = 1, 600 do table.insert(old, 'old line ' .. i) end
344+
local new = {}
345+
for i = 1, 1000 do table.insert(new, 'new line ' .. i) end
346+
Morph.patch_lines(0, old, new)
347+
local lines = get_lines()
348+
assert.are.same(1000, #lines)
349+
assert.are.same('new line 1', lines[1])
350+
assert.are.same('new line 1000', lines[1000])
351+
end)
352+
end)
339353
end)
340354

341355
------------------------------------------------------------------------------

0 commit comments

Comments
 (0)