From b458587c67386b31d204ff5b20883940b1d6cbd8 Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Wed, 19 Mar 2025 23:09:29 -0600 Subject: [PATCH] cleanup and add more tests --- lua/u/logger.lua | 12 ++- lua/u/renderer.lua | 190 ++++++++++++++++++++++++++++++----------- lua/u/utils.lua | 91 -------------------- spec/renderer_spec.lua | 131 ++++++++++++++++++++++++++++ spec/utils_spec.lua | 70 --------------- 5 files changed, 279 insertions(+), 215 deletions(-) create mode 100644 spec/renderer_spec.lua delete mode 100644 spec/utils_spec.lua diff --git a/lua/u/logger.lua b/lua/u/logger.lua index 7f5879b..c76bf15 100644 --- a/lua/u/logger.lua +++ b/lua/u/logger.lua @@ -1,7 +1,7 @@ local M = {} --- @params name string -function M.file_for_name(name) return vim.fs.joinpath(vim.fn.stdpath 'cache', 'my.log', name .. '.log.jsonl') end +function M.file_for_name(name) return vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log', name .. '.log.jsonl') end -------------------------------------------------------------------------------- -- Logger class @@ -49,7 +49,15 @@ function M.setup() local log_file_path = M.file_for_name(args.fargs[1]) vim.fn.mkdir(vim.fs.dirname(log_file_path), 'p') vim.system({ 'touch', log_file_path }):wait() - vim.cmd.Term('tail -f "' .. log_file_path .. '"') + + vim.cmd.new() + + local winnr = vim.api.nvim_get_current_win() + vim.wo[winnr][0].number = false + vim.wo[winnr][0].relativenumber = false + + vim.cmd.terminal('tail -f "' .. log_file_path .. '"') + vim.cmd.startinsert() end, { nargs = '*' }) end diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 3442c72..3f71a8f 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -1,11 +1,9 @@ -local utils = require 'u.utils' - local M = {} +local H = {} --- @alias Tag { kind: 'tag'; name: string, attributes: table, children: Tree } --- @alias Node nil | boolean | string | Tag --- @alias Tree Node | Node[] -local TagMetaTable = {} --- @param name string --- @param attributes? table @@ -20,9 +18,7 @@ function M.h(name, attributes, children) } end --------------------------------------------------------------------------------- --- Renderer class --------------------------------------------------------------------------------- +-- Renderer {{{ --- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any } --- @class Renderer @@ -35,10 +31,12 @@ local Renderer = {} Renderer.__index = Renderer M.Renderer = Renderer +--- @private --- @param x any --- @return boolean function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end +--- @private --- @param x any --- @return boolean function Renderer.is_tag_arr(x) @@ -46,7 +44,7 @@ function Renderer.is_tag_arr(x) return #x == 0 or not Renderer.is_tag(x) end --- @param bufnr number|nil -function Renderer.new(bufnr) +function Renderer.new(bufnr) -- {{{ if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end if vim.b[bufnr]._renderer_ns == nil then @@ -61,13 +59,13 @@ function Renderer.new(bufnr) curr = { lines = {}, extmarks = {} }, }, Renderer) return self -end +end -- }}} --- @param opts { --- tree: Tree; --- on_tag?: fun(tag: Tag, start0: [number, number], stop0: [number, number]): any; --- } -function Renderer.markup_to_lines(opts) +function Renderer.markup_to_lines(opts) -- {{{ --- @type string[] local lines = {} @@ -85,7 +83,7 @@ function Renderer.markup_to_lines(opts) end --- @param node Node - local function visit(node) + local function visit(node) -- {{{ if node == nil or type(node) == 'boolean' then return end if type(node) == 'string' then @@ -99,7 +97,9 @@ function Renderer.markup_to_lines(opts) -- visit the children: if Renderer.is_tag_arr(node.children) then - for _, child in ipairs(node.children) do + for _, child in + ipairs(node.children --[[@as Node[] ]]) + do -- newlines are not controlled by array entries, do NOT output a line here: visit(child) end @@ -115,11 +115,11 @@ function Renderer.markup_to_lines(opts) visit(child) end end - end + end -- }}} visit(opts.tree) return lines -end +end -- }}} --- @param opts { --- tree: string; @@ -128,7 +128,7 @@ end function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end --- @param tree Tree -function Renderer:render(tree) +function Renderer:render(tree) -- {{{ local changedtick = vim.b[self.bufnr].changedtick if changedtick ~= self.changedtick then self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) } @@ -142,7 +142,7 @@ function Renderer:render(tree) local lines = Renderer.markup_to_lines { tree = tree, - on_tag = function(tag, start0, stop0) + on_tag = function(tag, start0, stop0) -- {{{ if tag.name == 'text' then local hl = tag.attributes.hl if type(hl) == 'string' then @@ -161,7 +161,7 @@ function Renderer:render(tree) vim.keymap.set( 'n', lhs, - function() return self:_on_expr_map('n', lhs) end, + function() return self:_expr_map_callback('n', lhs) end, { buffer = self.bufnr, expr = true, replace_keycodes = true } ) end @@ -176,79 +176,64 @@ function Renderer:render(tree) }) end end - end, + end, -- }}} } self.old = self.curr self.curr = { lines = lines, extmarks = extmarks } self:_reconcile() -end +end -- }}} --- @private ---- @param info string --- @param start integer --- @param end_ integer --- @param strict_indexing boolean --- @param replacement string[] -function Renderer:_set_lines(info, start, end_, strict_indexing, replacement) - self:_log { 'set_lines', self.bufnr, start, end_, strict_indexing, replacement } +function Renderer:_set_lines(start, end_, strict_indexing, replacement) vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement) - self:_log { 'after(' .. info .. ')', vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) } end --- @private ---- @param info string --- @param start_row integer --- @param start_col integer --- @param end_row integer --- @param end_col integer --- @param replacement string[] -function Renderer:_set_text(info, start_row, start_col, end_row, end_col, replacement) - self:_log { 'set_text', self.bufnr, start_row, start_col, end_row, end_col, replacement } +function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement) vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement) - self:_log { 'after(' .. info .. ')', vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) } end --- @private -function Renderer:_log(...) - -- - -- vim.print(...) -end - ---- @private -function Renderer:_reconcile() - local line_changes = utils.levenshtein(self.old.lines, self.curr.lines) +function Renderer:_reconcile() -- {{{ + local line_changes = H.levenshtein(self.old.lines, self.curr.lines) self.old = self.curr -- -- Step 1: morph the text to the desired state: -- - self:_log { line_changes = line_changes } for _, line_change in ipairs(line_changes) do local lnum0 = line_change.index - 1 if line_change.kind == 'add' then - self:_set_lines('add-line', lnum0, lnum0, true, { line_change.item }) + self:_set_lines(lnum0, lnum0, true, { line_change.item }) elseif line_change.kind == 'change' then -- Compute inter-line diff, and apply: - self:_log '--------------------------------------------------------------------------------' - local col_changes = utils.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, '')) + local col_changes = H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, '')) for _, col_change in ipairs(col_changes) do local cnum0 = col_change.index - 1 - self:_log { line_change = col_change, cnum = cnum0, lnum = lnum0 } if col_change.kind == 'add' then - self:_set_text('add-char', lnum0, cnum0, lnum0, cnum0, { col_change.item }) + self:_set_text(lnum0, cnum0, lnum0, cnum0, { col_change.item }) elseif col_change.kind == 'change' then - self:_set_text('change-char', lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to }) + self:_set_text(lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to }) elseif col_change.kind == 'delete' then - self:_set_text('del-char', lnum0, cnum0, lnum0, cnum0 + 1, {}) + self:_set_text(lnum0, cnum0, lnum0, cnum0 + 1, {}) else -- No change end end elseif line_change.kind == 'delete' then - self:_set_lines('del-line', lnum0, lnum0 + 1, true, {}) + self:_set_lines(lnum0, lnum0 + 1, true, {}) else -- No change end @@ -274,12 +259,12 @@ function Renderer:_reconcile() }, extmark.opts) ) end -end +end -- }}} --- @private --- @param mode string --- @param lhs string -function Renderer:_on_expr_map(mode, lhs) +function Renderer:_expr_map_callback(mode, lhs) -- {{{ -- find the tag with the smallest intersection that contains the cursor: local pos0 = vim.api.nvim_win_get_cursor(0) pos0[1] = pos0[1] - 1 -- make it actually 0-based @@ -308,7 +293,7 @@ function Renderer:_on_expr_map(mode, lhs) -- Resort to default behavior: return cancel and '' or lhs -end +end -- }}} --- Returns pairs of extmarks and tags associate with said extmarks. The --- returned tags/extmarks are sorted smallest (innermost) to largest @@ -317,7 +302,7 @@ end --- @private (private for now) --- @param pos0 [number; number] --- @return { extmark: RendererExtmark; tag: Tag; }[] -function Renderer:get_pos_infos(pos0) +function Renderer:get_pos_infos(pos0) -- {{{ local cursor_line0, cursor_col0 = pos0[1], pos0[2] -- The cursor (block) occupies **two** extmark spaces: one for it's left @@ -388,12 +373,10 @@ function Renderer:get_pos_infos(pos0) :totable() return matching_tags -end - --------------------------------------------------------------------------------- --- TreeBuilder class --------------------------------------------------------------------------------- +end -- }}} +-- }}} +-- TreeBuilder {{{ --- @class TreeBuilder --- @field private nodes Node[] local TreeBuilder = {} @@ -433,5 +416,108 @@ end --- @return Tree function TreeBuilder:tree() return self.nodes end +-- }}} + +-- Levenshtein utility {{{ +--- @alias LevenshteinChange ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; }) +--- @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; } +--- @return LevenshteinChange[] +function H.levenshtein(x, y, cost) + -- 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 + -- that is tailored to working better with how NeoVim manipulates text. I've + -- done no further investigation in this area, however, so it's impossible to + -- tell if such tuning would produce real benefit. For now, I'm leaving this + -- 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 + + local m, n = #x, #y + -- Initialize the distance matrix + local dp = {} + for i = 0, m do + dp[i] = {} + for j = 0, n do + dp[i][j] = 0 + end + end + + -- Fill the base cases + for i = 0, m do + dp[i][0] = i + end + for j = 0, n do + dp[0][j] = j + end + + -- Compute the Levenshtein distance dynamically + for i = 1, m do + for j = 1, n do + if x[i] == y[j] then + dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same + else + local costDelete = dp[i - 1][j] + cost_of_delete_f(x[i]) + local costAdd = dp[i][j - 1] + cost_of_add_f(y[j]) + local costChange = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j]) + dp[i][j] = math.min(costDelete, costAdd, costChange) + end + end + end + + -- Backtrack to find the changes + local i = m + local j = n + --- @type LevenshteinChange[] + local changes = {} + + while i > 0 or j > 0 do + local default_cost = dp[i][j] + local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost + local cost_of_add = j > 0 and dp[i][j - 1] or default_cost + local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost + + --- @param u number + --- @param v number + --- @param w number + local function is_first_min(u, v, w) return u <= v and u <= w end + + if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then + -- potential change + if x[i] ~= y[j] then + --- @type LevenshteinChange + local change = { kind = 'change', from = x[i], index = i, to = y[j] } + table.insert(changes, change) + end + i = i - 1 + j = j - 1 + elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then + -- addition + --- @type LevenshteinChange + local change = { kind = 'add', item = y[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 LevenshteinChange + local change = { kind = 'delete', item = x[i], index = i } + table.insert(changes, change) + i = i - 1 + else + error 'unreachable' + end + end + + return changes +end +-- }}} return M diff --git a/lua/u/utils.lua b/lua/u/utils.lua index 593e08b..f0f4242 100644 --- a/lua/u/utils.lua +++ b/lua/u/utils.lua @@ -121,95 +121,4 @@ end function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end ---- @alias LevenshteinChange ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; }) ---- @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; } ---- @return LevenshteinChange[] -function M.levenshtein(x, y, cost) - 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 - - local m, n = #x, #y - -- Initialize the distance matrix - local dp = {} - for i = 0, m do - dp[i] = {} - for j = 0, n do - dp[i][j] = 0 - end - end - - -- Fill the base cases - for i = 0, m do - dp[i][0] = i - end - for j = 0, n do - dp[0][j] = j - end - - -- Compute the Levenshtein distance dynamically - for i = 1, m do - for j = 1, n do - if x[i] == y[j] then - dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same - else - local costDelete = dp[i - 1][j] + cost_of_delete_f(x[i]) - local costAdd = dp[i][j - 1] + cost_of_add_f(y[j]) - local costChange = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j]) - dp[i][j] = math.min(costDelete, costAdd, costChange) - end - end - end - - -- Backtrack to find the changes - local i = m - local j = n - --- @type LevenshteinChange[] - local changes = {} - - while i > 0 or j > 0 do - local default_cost = dp[i][j] - local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost - local cost_of_add = j > 0 and dp[i][j - 1] or default_cost - local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost - - --- @param u number - --- @param v number - --- @param w number - local function is_first_min(u, v, w) return u <= v and u <= w end - - if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then - -- potential change - if x[i] ~= y[j] then - --- @type LevenshteinChange - local change = { kind = 'change', from = x[i], index = i, to = y[j] } - table.insert(changes, change) - end - i = i - 1 - j = j - 1 - elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then - -- addition - --- @type LevenshteinChange - local change = { kind = 'add', item = y[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 LevenshteinChange - local change = { kind = 'delete', item = x[i], index = i } - table.insert(changes, change) - i = i - 1 - else - error 'unreachable' - end - end - - return changes -end - return M diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua new file mode 100644 index 0000000..174b79a --- /dev/null +++ b/spec/renderer_spec.lua @@ -0,0 +1,131 @@ +local R = require 'u.renderer' +local withbuf = loadfile './spec/withbuf.lua'() + +local function getlines() return vim.api.nvim_buf_get_lines(0, 0, -1, true) end + +describe('Renderer', function() + it('should render text in an empty buffer', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { 'hello', ' ', 'world' } + assert.are.same(getlines(), { 'hello world' }) + end) + end) + + it('should result in the correct text after repeated renders', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { 'hello', ' ', 'world' } + assert.are.same(getlines(), { 'hello world' }) + + r:render { 'goodbye', ' ', 'world' } + assert.are.same(getlines(), { 'goodbye world' }) + + r:render { 'hello', ' ', 'universe' } + assert.are.same(getlines(), { 'hello universe' }) + end) + end) + + it('should handle tags correctly', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { + R.h('text', { hl = 'HighlightGroup' }, 'hello '), + R.h('text', { hl = 'HighlightGroup' }, 'world'), + } + assert.are.same(getlines(), { 'hello world' }) + end) + end) + + it('should reconcile added lines', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { 'line 1', '\n', 'line 2' } + assert.are.same(getlines(), { 'line 1', 'line 2' }) + + -- Add a new line: + r:render { 'line 1', '\n', 'line 2\n', 'line 3' } + assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' }) + end) + end) + + it('should reconcile deleted lines', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { 'line 1', '\nline 2', '\nline 3' } + assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' }) + + -- Remove a line: + r:render { 'line 1', '\nline 3' } + assert.are.same(getlines(), { 'line 1', 'line 3' }) + end) + end) + + it('should handle multiple nested elements', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { + R.h('text', {}, { + 'first line', + }), + '\n', + R.h('text', {}, 'second line'), + } + assert.are.same(getlines(), { 'first line', 'second line' }) + + r:render { + R.h('text', {}, 'updated first line'), + '\n', + R.h('text', {}, 'third line'), + } + assert.are.same(getlines(), { 'updated first line', 'third line' }) + end) + end) + + -- + -- get_pos_infos + -- + + it('should return no extmarks for an empty buffer', function() + withbuf({}, function() + local r = R.Renderer.new(0) + local pos_infos = r:get_pos_infos { 0, 0 } + assert.are.same(pos_infos, {}) + end) + end) + + it('should return correct extmark for a given position', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { + R.h('text', { hl = 'HighlightGroup1' }, 'Hello'), + R.h('text', { hl = 'HighlightGroup2' }, ' World'), + } + + local pos_infos = r:get_pos_infos { 0, 2 } + + assert.are.same(#pos_infos, 1) + assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') + assert.are.same(pos_infos[1].extmark.start, { 0, 0 }) + assert.are.same(pos_infos[1].extmark.stop, { 0, 5 }) + end) + end) + + it('should return multiple extmarks for overlapping text', function() + withbuf({}, function() + local r = R.Renderer.new(0) + r:render { + R.h('text', { hl = 'HighlightGroup1' }, { + 'Hello', + R.h('text', { hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } }, ' World'), + }), + } + + local pos_infos = r:get_pos_infos { 0, 5 } + + assert.are.same(#pos_infos, 2) + assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2') + assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1') + end) + end) +end) diff --git a/spec/utils_spec.lua b/spec/utils_spec.lua deleted file mode 100644 index 75bfdf9..0000000 --- a/spec/utils_spec.lua +++ /dev/null @@ -1,70 +0,0 @@ -local utils = require 'u.utils' - ---- @param s string -local function split(s) return vim.split(s, '') end - ---- @param original string ---- @param changes LevenshteinChange[] -local function morph(original, changes) - local t = split(original) - for _, change in ipairs(changes) do - if change.kind == 'add' then - table.insert(t, change.index, change.item) - elseif change.kind == 'delete' then - table.remove(t, change.index) - elseif change.kind == 'change' then - t[change.index] = change.to - end - end - return vim.iter(t):join '' -end - -describe('utils', function() - it('levenshtein', function() - local original = 'abc' - local result = 'absece' - local changes = utils.levenshtein(split(original), split(result)) - assert.are.same(changes, { - { - item = 'e', - kind = 'add', - index = 4, - }, - { - item = 'e', - kind = 'add', - index = 3, - }, - { - item = 's', - kind = 'add', - index = 3, - }, - }) - assert.are.same(morph(original, changes), result) - - original = 'jonathan' - result = 'ajoanthan' - changes = utils.levenshtein(split(original), split(result)) - assert.are.same(changes, { - { - from = 'a', - index = 4, - kind = 'change', - to = 'n', - }, - { - from = 'n', - index = 3, - kind = 'change', - to = 'a', - }, - { - index = 1, - item = 'a', - kind = 'add', - }, - }) - assert.are.same(morph(original, changes), result) - end) -end)