@@ -148,6 +148,12 @@ local function tree_identity_key(node, index)
148148 error ' unreachable'
149149end
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
498508function 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[]
799812function 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
878904end
879905
@@ -883,35 +909,67 @@ end
883909function 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[]
891920function 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 )
0 commit comments