From 5273fd743a2138d70d839486e48312a8f1294331 Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Sat, 11 Oct 2025 14:09:45 -0600 Subject: [PATCH] LEVENSHTEIN FREAKING WORKS FOR TREE DIFFING --- lua/u/renderer.lua | 240 ++++++++++++++++++++++++++------------------- 1 file changed, 137 insertions(+), 103 deletions(-) diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 662333f..df02418 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -272,8 +272,10 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ end -- Morph the text to the desired state: - local line_changes = - H.levenshtein(old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), new_lines) + local line_changes = H.levenshtein { + from = old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), + to = new_lines, + } for _, line_change in ipairs(line_changes) do local lnum0 = line_change.index - 1 @@ -282,7 +284,7 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ elseif line_change.kind == 'change' then -- Compute inter-line diff, and apply: local col_changes = - H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, '')) + H.levenshtein { from = vim.split(line_change.from, ''), to = vim.split(line_change.to, '') } for _, col_change in ipairs(col_changes) do local cnum0 = col_change.index - 1 @@ -644,58 +646,65 @@ function Renderer:get_tags_at(pos0, mode) -- {{{ --- @type u.renderer.RendererExtmark[] local mapped_extmarks = vim .iter(raw_overlapping_extmarks) - --- @return u.renderer.RendererExtmark - :map(function(ext) - --- @type number, number, number, { end_row?: number; end_col?: number }|nil - local id, line0, col0, details = unpack(ext) - local start = { line0, col0 } - local stop = { line0, col0 } - if details and details.end_row ~= nil and details.end_col ~= nil then - stop = { details.end_row, details.end_col } + :map( + --- @return u.renderer.RendererExtmark + function(ext) + --- @type integer, integer, integer, { end_row?: number, end_col?: number }|nil + local id, line0, col0, details = unpack(ext) + local start = { line0, col0 } + local stop = { line0, col0 } + if details and details.end_row ~= nil and details.end_col ~= nil then + stop = { + details.end_row --[[@as integer]], + details.end_col --[[@as integer]], + } + end + return { id = id, start = start, stop = stop, opts = details } end - return { id = id, start = start, stop = stop, opts = details } - end) + ) :totable() local intersecting_extmarks = vim .iter(mapped_extmarks) - --- @param ext u.renderer.RendererExtmark - :filter(function(ext) - if ext.stop[1] ~= nil and ext.stop[2] ~= nil then - -- If we've "ciw" and "collapsed" an extmark onto the cursor, - -- the cursor pos will equal the exmark's start AND end. In this - -- case, we want to include the extmark. - if - cursor_line0 == ext.start[1] - and cursor_col0 == ext.start[2] - and cursor_line0 == ext.stop[1] - and cursor_col0 == ext.stop[2] - then + :filter( + --- @param ext u.renderer.RendererExtmark + function(ext) + if ext.stop[1] ~= nil and ext.stop[2] ~= nil then + -- If we've "ciw" and "collapsed" an extmark onto the cursor, + -- the cursor pos will equal the exmark's start AND end. In this + -- case, we want to include the extmark. + if + cursor_line0 == ext.start[1] + and cursor_col0 == ext.start[2] + and cursor_line0 == ext.stop[1] + and cursor_col0 == ext.stop[2] + then + return true + end + + return + -- START: line check + cursor_line0 >= ext.start[1] + -- START: column check + and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2]) + -- STOP: line check + and cursor_line0 <= ext.stop[1] + -- STOP: column check + and ( + cursor_line0 ~= ext.stop[1] + or ( + mode == 'i' + -- In insert mode, the cursor is "thin", so <= to compensate: + and cursor_col0 <= ext.stop[2] + -- In normal mode, the cursor is "wide", so < to compensate: + or cursor_col0 < ext.stop[2] + ) + ) + else return true end - - return - -- START: line check - cursor_line0 >= ext.start[1] - -- START: column check - and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2]) - -- STOP: line check - and cursor_line0 <= ext.stop[1] - -- STOP: column check - and ( - cursor_line0 ~= ext.stop[1] - or ( - mode == 'i' - -- In insert mode, the cursor is "thin", so <= to compensate: - and cursor_col0 <= ext.stop[2] - -- In normal mode, the cursor is "wide", so < to compensate: - or cursor_col0 < ext.stop[2] - ) - ) - else - return true end - end) + ) :totable() -- Sort the tags into smallest (inner) to largest (outer): @@ -727,12 +736,14 @@ function Renderer:get_tags_at(pos0, mode) -- {{{ --- @type { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[] local matching_tags = vim .iter(intersecting_extmarks) - --- @param ext u.renderer.RendererExtmark - :map(function(ext) - for _, extmark_cache in ipairs(self.text_content.curr.extmarks) do - if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end + :map( + --- @param ext u.renderer.RendererExtmark + function(ext) + for _, extmark_cache in ipairs(self.text_content.curr.extmarks) do + if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end + end end - end) + ) :totable() return matching_tags @@ -823,7 +834,10 @@ function Renderer:mount(tree) -- {{{ component = function(NewC, new_tag) --- @type { tag: u.renderer.Tag, ctx?: u.renderer.FnComponentContext } | nil - local old_component_info = Renderer.tree_match(old_tree, { component = function(_, t) return { tag = t, ctx = t.ctx } end }) + local old_component_info = Renderer.tree_match( + old_tree, + { component = function(_, t) return { tag = t, ctx = t.ctx } end } + ) local old_tag = old_component_info and old_component_info.tag or nil local ctx = old_component_info and old_component_info.ctx or nil @@ -862,52 +876,64 @@ function Renderer:mount(tree) -- {{{ --- @param new_arr u.renderer.Node[] --- @return u.renderer.Node[] function H2.visit_array(old_arr, new_arr) - --- @type table - local old_by_key = {} - - --- @param tree u.renderer.Tree - --- @param idx integer - local function get_or_set_key(tree, idx) - return Renderer.tree_match(tree, { - tag = function(t) - if not t.attributes.key then t.attributes.key = idx end - return t.attributes.key + -- We are going to hijack levenshtein in order to compute the + -- difference between elements/components. In this model, we need to + -- "update" all the nodes, so no nodes are equal. We will rely on + -- levenshtein to find the "shortest path" to conforming old => new via + -- the cost.of_change function. That will provide the meat of modeling + -- what effort it will take to morph one element into the new form. + -- What levenshtein gives us for free in this model is also informing + -- us what needs to be added (i.e., "mounted"), what needs to be + -- deleted ("unmounted") and what needs to be changed ("updated"). + local changes = H.levenshtein { + from = old_arr or {}, + to = new_arr or {}, + are_equal = function() return false end, + cost = { + of_change = function(tree1, tree2, tree1_idx, tree2_idx) + --- @return string + local function verbose_tree_kind(tree, idx) + return Renderer.tree_match(tree, { + nil_ = function() return 'nil' end, + string = function() return 'string' end, + boolean = function() return 'boolean' end, + array = function() return 'array' end, + tag = function(tag) + return ('tag-%s-%s'):format(tag.name, tostring(tag.attributes.key or idx)) + end, + component = function(C, tag) + return ('component-%s-%s'):format(C, tostring(tag.attributes.key or idx)) + end, + }) + end + local tree1_inf = verbose_tree_kind(tree1, tree1_idx) + local tree2_inf = verbose_tree_kind(tree2, tree2_idx) + return tree1_inf == tree2_inf and 1 or 2 end, - component = function(_, t) - if not t.attributes.key then t.attributes.key = idx end - return t.attributes.key - end, - }) - end - - for idx, old in ipairs(old_arr or {}) do - local key = get_or_set_key(old, idx) - if key then old_by_key[key] = old end - end + }, + } --- @type u.renderer.Node[] local resulting_nodes = {} - for idx, new in ipairs(new_arr) do - local key = get_or_set_key(new, idx) - local old = nil - if key then - old = old_by_key[key] - if old then old_by_key[key] = nil end + for _, change in ipairs(changes) do + local resulting_node + if change.kind == 'add' then + resulting_node = H2.visit_tree(nil, change.item) + elseif change.kind == 'delete' then + H2.visit_tree(change.item, nil) + elseif change.kind == 'change' then + resulting_node = H2.visit_tree(change.from, change.to) end - table.insert(resulting_nodes, H2.visit_tree(old, new)) + if resulting_node then table.insert(resulting_nodes, 1, resulting_node) end end log('Renderer.mount.visit_array', { old_arr = old_arr, new_arr = new_arr, + changes = changes, resulting_nodes = resulting_nodes, }) - -- Unmount whatever is left in the "old" map: - for _, n in pairs(old_by_key) do - H2.unmount(n) - end - return resulting_nodes end @@ -990,11 +1016,18 @@ function TreeBuilder:tree() return self.nodes end --- @alias u.renderer.LevenshteinChange ({ kind: 'add', item: T, index: integer } | { kind: 'delete', item: T, index: integer } | { kind: 'change', from: T, to: T, index: integer }) --- @private --- @generic T ---- @param x `T`[] ---- @param y T[] ---- @param cost? { of_delete?: (fun(x: T): number), of_add?: (fun(x: T): number), of_change?: (fun(x: T, y: T): number) } +--- @param opts { +--- from: `T`[], +--- to: T[], +--- are_equal?: (fun(x: T, y: T, xidx: integer, yidx: integer): boolean), +--- cost?: { +--- of_delete?: (fun(x: T, idx: integer): number), +--- of_add?: (fun(x: T, idx: integer): number), +--- of_change?: (fun(x: T, y: T, xidx: integer, yidx: integer): number) +--- } +--- } --- @return u.renderer.LevenshteinChange[] The changes, from last (greatest index) to first (smallest index). -function H.levenshtein(x, y, cost) +function H.levenshtein(opts) -- At the moment, this whole `cost` plumbing is not used. Deletes have the -- same cost as Adds or Changes. I can imagine a future, however, where -- fudging with the costs of operations produces a more optimized change-set @@ -1004,12 +1037,13 @@ function H.levenshtein(x, y, cost) -- in here even though it's not actively used. Hopefully having this -- callback-based plumbing does not cause too much of a performance hit to -- the renderer. - cost = cost or {} - local cost_of_delete_f = cost.of_delete or function() return 1 end - local cost_of_add_f = cost.of_add or function() return 1 end - local cost_of_change_f = cost.of_change or function() return 1 end + if not opts.are_equal then opts.are_equal = function(x, y) return x == y end end + if not opts.cost then opts.cost = {} end + if not opts.cost.of_add then opts.cost.of_add = function() return 1 end end + if not opts.cost.of_change then opts.cost.of_change = function() return 1 end end + if not opts.cost.of_delete then opts.cost.of_delete = function() return 1 end end - local m, n = #x, #y + local m, n = #opts.from, #opts.to -- Initialize the distance matrix local dp = {} for i = 0, m do @@ -1030,12 +1064,12 @@ function H.levenshtein(x, y, cost) -- Compute the Levenshtein distance dynamically for i = 1, m do for j = 1, n do - if x[i] == y[j] then + if opts.are_equal(opts.from[i], opts.to[j], i, j) then dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same else - local cost_delete = dp[i - 1][j] + cost_of_delete_f(x[i]) - local cost_add = dp[i][j - 1] + cost_of_add_f(y[j]) - local cost_change = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j]) + local cost_delete = dp[i - 1][j] + opts.cost.of_delete(opts.from[i], i) + local cost_add = dp[i][j - 1] + opts.cost.of_add(opts.to[j], j) + local cost_change = dp[i - 1][j - 1] + opts.cost.of_change(opts.from[i], opts.to[j], i, j) dp[i][j] = math.min(cost_delete, cost_add, cost_change) end end @@ -1060,9 +1094,9 @@ function H.levenshtein(x, y, cost) if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then -- potential change - if x[i] ~= y[j] then + if not opts.are_equal(opts.from[i], opts.to[j]) then --- @type u.renderer.LevenshteinChange - local change = { kind = 'change', from = x[i], index = i, to = y[j] } + local change = { kind = 'change', from = opts.from[i], index = i, to = opts.to[j] } table.insert(changes, change) end i = i - 1 @@ -1070,13 +1104,13 @@ function H.levenshtein(x, y, cost) elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then -- addition --- @type u.renderer.LevenshteinChange - local change = { kind = 'add', item = y[j], index = i + 1 } + local change = { kind = 'add', item = opts.to[j], index = i + 1 } table.insert(changes, change) j = j - 1 elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then -- deletion --- @type u.renderer.LevenshteinChange - local change = { kind = 'delete', item = x[i], index = i } + local change = { kind = 'delete', item = opts.from[i], index = i } table.insert(changes, change) i = i - 1 else