From fb6e80cb63e5931891b735777240c9187879132f Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Wed, 19 Feb 2025 23:16:26 -0700 Subject: [PATCH] experimental: renderer --- lua/u/buffer.lua | 20 ++- lua/u/range.lua | 6 +- lua/u/renderer.lua | 352 +++++++++++++++++++++++++++++++++++++++++ lua/u/tracker.lua | 166 +++++++++++++++++++ lua/u/utils.lua | 119 +++++++++++--- lua/u/utils/string.lua | 29 ++++ spec/renderer_spec.lua | 59 +++++++ spec/utils_spec.lua | 70 ++++++++ 8 files changed, 796 insertions(+), 25 deletions(-) create mode 100644 lua/u/renderer.lua create mode 100644 lua/u/tracker.lua create mode 100644 lua/u/utils/string.lua create mode 100644 spec/renderer_spec.lua create mode 100644 spec/utils_spec.lua diff --git a/lua/u/buffer.lua b/lua/u/buffer.lua index 06424f4..a9e499d 100644 --- a/lua/u/buffer.lua +++ b/lua/u/buffer.lua @@ -1,16 +1,21 @@ local Range = require 'u.range' +local Renderer = require 'u.renderer' ---@class Buffer ---@field buf number +---@field private renderer Renderer local Buffer = {} ---@param buf? number ---@return Buffer function Buffer.from_nr(buf) if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end - local b = { buf = buf } - setmetatable(b, { __index = Buffer }) - return b + + local renderer = Renderer.new(buf) + return setmetatable({ + buf = buf, + renderer = renderer, + }, { __index = Buffer }) end ---@return Buffer @@ -69,4 +74,13 @@ function Buffer:text_object(txt_obj, opts) return Range.from_text_object(txt_obj, opts) end +--- @param event string|string[] +--- @param opts vim.api.keyset.create_autocmd +function Buffer:autocmd(event, opts) + vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.buf })) +end + +--- @param markup string +function Buffer:render(markup) return self.renderer:render(markup) end + return Buffer diff --git a/lua/u/range.lua b/lua/u/range.lua index b7d4d34..4ac45d3 100644 --- a/lua/u/range.lua +++ b/lua/u/range.lua @@ -1,9 +1,9 @@ local Pos = require 'u.pos' local State = require 'u.state' -local orig_on_yank = vim.highlight.on_yank +local orig_on_yank = (vim.hl or vim.highlight).on_yank local on_yank_enabled = true; -(vim.highlight --[[@as any]]).on_yank = function(opts) +((vim.hl or vim.highlight) --[[@as any]]).on_yank = function(opts) if not on_yank_enabled then return end return orig_on_yank(opts) end @@ -493,7 +493,7 @@ function Range:highlight(group, opts) State.run(self.start.buf, function(s) if not in_macro then s:track_winview() end - vim.highlight.range( + (vim.hl or vim.highlight).range( self.start.buf, ns, group, diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua new file mode 100644 index 0000000..fec7694 --- /dev/null +++ b/lua/u/renderer.lua @@ -0,0 +1,352 @@ +local utils = require 'u.utils' +local str = require 'u.utils.string' + +--- @alias RendererParsedTag { kind: 'tag'; name: string; attributes: table } + +-------------------------------------------------------------------------------- +-- Renderer class +-------------------------------------------------------------------------------- +--- @alias RendererHighlight { group: string; start: [number, number]; stop: [number, number ] } + +--- @class Renderer +--- @field bufnr number +--- @field ns number +--- @field changedtick number +--- @field old { lines: string[]; hls: RendererHighlight[] } +--- @field curr { lines: string[]; hls: RendererHighlight[] } +local Renderer = {} +Renderer.__index = Renderer + +--- @param bufnr number|nil +function Renderer.new(bufnr) + if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end + + local self = setmetatable({ + bufnr = bufnr, + ns = vim.api.nvim_create_namespace '', + changedtick = 0, + old = { lines = {}, hls = {} }, + curr = { lines = {}, hls = {} }, + }, Renderer) + return self +end + +--- @param opts { +--- markup: string; +--- format_tag: fun(tag: RendererParsedTag): string; +--- on_tag?: fun(tag: RendererParsedTag, start0: [number, number], stop0: [number, number]): any; +--- } +function Renderer.markup_to_lines(opts) + local nodes = Renderer._parse_markup(opts.markup) + + --- @type string[] + local lines = {} + + local curr_line1 = 1 + local curr_col1 = 1 -- exclusive: sits one position **beyond** the last inserted text + --- @param s string + local function put(s) + lines[curr_line1] = (lines[curr_line1] or '') .. s + curr_col1 = #lines[curr_line1] + 1 + end + local function put_line() + table.insert(lines, '') + curr_line1 = curr_line1 + 1 + curr_col1 = 1 + end + + for _, node in ipairs(nodes) do + if node.kind == 'text' then + local node_lines = vim.split(node.value, '\n') + for lnum, s in ipairs(node_lines) do + if lnum > 1 then put_line() end + put(s) + end + elseif node.kind == 'tag' then + local value = opts.format_tag(node) + local start0 = { curr_line1 - 1, curr_col1 - 1 } + local value_lines = vim.split(value, '\n') + for lnum, value_line in ipairs(value_lines) do + if lnum > 1 then put_line() end + put(value_line) + end + local stop0 = { curr_line1 - 1, curr_col1 - 1 } + if opts.on_tag then opts.on_tag(node, start0, stop0) end + end + end + + return lines +end + +--- @param opts { +--- markup: string; +--- format_tag?: fun(tag: RendererParsedTag): string; +--- } +function Renderer.markup_to_string(opts) + if not opts.format_tag then + opts.format_tag = function(tag) + if tag.name == 't' then + local value = tag.attributes.value + if type(value) == 'string' then return value end + end + + return '' + end + end + return table.concat(Renderer.markup_to_lines(opts), '\n') +end + +--- @param markup string +function Renderer:render(markup) + 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), hls = {} } + self.changedtick = changedtick + end + + --- @type RendererHighlight[] + local hls = {} + + --- @type string[] + local lines = Renderer.markup_to_lines { + markup = markup, + + format_tag = function(tag) + if tag.name == 't' then + local value = tag.attributes.value + if type(value) == 'string' then return value end + end + + return '' + end, + + on_tag = function(tag, start0, stop0) + local function attr_num(nm) + if not tag.attributes[nm] then return end + return tonumber(tag.attributes[nm]) + end + local function attr_bool(nm) + if not tag.attributes[nm] then return end + return tag.attributes[nm] and true or false + end + + if tag.name == 't' then + local group = tag.attributes.hl + if type(group) == 'string' then + table.insert(hls, { group = group, start = start0, stop = stop0 }) + + local local_exists = #vim.tbl_keys(vim.api.nvim_get_hl(self.ns, { name = group })) > 0 + local global_exists = #vim.tbl_keys(vim.api.nvim_get_hl(0, { name = group })) + local exists = local_exists or global_exists + + if not exists or attr_bool 'hl:force' then + vim.api.nvim_set_hl_ns(self.ns) + vim.api.nvim_set_hl(self.ns, group, { + fg = tag.attributes['hl:fg'], + bg = tag.attributes['hl:bg'], + sp = tag.attributes['hl:sp'], + blend = attr_num 'hl:blend', + bold = attr_bool 'hl:bold', + standout = attr_bool 'hl:standout', + underline = attr_bool 'hl:underline', + undercurl = attr_bool 'hl:undercurl', + underdouble = attr_bool 'hl:underdouble', + underdotted = attr_bool 'hl:underdotted', + underdashed = attr_bool 'hl:underdashed', + strikethrough = attr_bool 'hl:strikethrough', + italic = attr_bool 'hl:italic', + reverse = attr_bool 'hl:reverse', + nocombine = attr_bool 'hl:nocombine', + link = tag.attributes['hl:link'], + default = attr_bool 'hl:default', + ctermfg = attr_num 'hl:ctermfg', + ctermbg = attr_num 'hl:ctermbg', + cterm = tag.attributes['hl:cterm'], + force = attr_bool 'hl:force', + }) + end + end + end + end, + } + + self.old = self.curr + self.curr = { lines = lines, hls = hls } + self:_reconcile() +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 } + 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 } + 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) + self.old = self.curr + + 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 }) + 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, '')) + + 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 }) + elseif col_change.kind == 'change' then + self:_set_text('change-char', lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to }) + elseif col_change.kind == 'delete' then + self:_set_text('del-char', 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, {}) + else + -- No change + end + end + + -- vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, self.curr.lines) + vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) + for _, hl in ipairs(self.curr.hls) do + (vim.hl or vim.highlight).range(self.bufnr, self.ns, hl.group, hl.start, hl.stop, { + inclusive = false, + priority = (vim.hl or vim.highlight).priorities.user, + regtype = 'v', + }) + end + + self.changedtick = vim.b[self.bufnr].changedtick +end + +--- @private +--- @param markup string e.g., [[Something ]] +function Renderer._parse_markup(markup) + --- @type ({ kind: 'text'; value: string } | RendererParsedTag)[] + local nodes = {} + + --- @type 'text' | 'tag' + local mode = 'text' + local pos = 1 + local watchdog = 0 + + local function skip_whitespace() + local _, new_pos = str.eat_while(markup, pos, str.is_whitespace) + pos = new_pos + end + local function check_infinite_loop() + watchdog = watchdog + 1 + if watchdog > #markup then + vim.print('ERROR', { + num_nodes = #nodes, + last_node = nodes[#nodes], + pos = pos, + len = #markup, + }) + error 'infinite loop' + end + end + + while pos <= #markup do + check_infinite_loop() + + if mode == 'text' then + -- + -- Parse contiguous regions of text + -- + local eaten, new_pos = str.eat_while(markup, pos, function(c) return c ~= '<' end) + if #eaten > 0 then table.insert(nodes, { kind = 'text', value = eaten:gsub('<', '<') }) end + pos = new_pos + + if markup:sub(pos, pos) == '<' then mode = 'tag' end + elseif mode == 'tag' then + -- + -- Parse self-closing tags + -- + if markup:sub(pos, pos) == '<' then pos = pos + 1 end + local tag_name, new_pos = str.eat_while(markup, pos, function(c) return not str.is_whitespace(c) end) + pos = new_pos + + if tag_name == '/>' then + -- empty tag + table.insert(nodes, { kind = 'tag', name = '', attributes = {} }) + else + local node = { kind = 'tag', name = tag_name, attributes = {} } + skip_whitespace() + + while markup:sub(pos, pos + 1) ~= '/>' do + check_infinite_loop() + if pos > #markup then error 'unexpected end of markup' end + + local attr_name + attr_name, new_pos = str.eat_while(markup, pos, function(c) return c ~= '=' and not str.is_whitespace(c) end) + pos = new_pos + + local attr_value = nil + if markup:sub(pos, pos) == '=' then + pos = pos + 1 + if markup:sub(pos, pos) == '"' then + pos = pos + 1 + attr_value, new_pos = str.eat_while(markup, pos, function(c, i, s) + local prev_c = s:sub(i - 1, i - 1) + return c ~= '"' or (prev_c == '\\' and c == '"') + end) + pos = new_pos + 1 -- skip the closing '"' + else + attr_value, new_pos = str.eat_while(markup, pos, function(c) return not str.is_whitespace(c) end) + pos = new_pos + end + end + + node.attributes[attr_name] = (attr_value and attr_value:gsub('\\"', '"')) or true + skip_whitespace() + end + pos = pos + 2 -- skip the '/>' + + table.insert(nodes, node) + end + + mode = 'text' + end + end + + return nodes +end + +return Renderer diff --git a/lua/u/tracker.lua b/lua/u/tracker.lua new file mode 100644 index 0000000..f0caa28 --- /dev/null +++ b/lua/u/tracker.lua @@ -0,0 +1,166 @@ +local M = {} + +M.debug = false + +--- @class Signal +--- @field name? string +--- @field private changing boolean +--- @field value any +--- @field subscribers table +local Signal = {} +M.Signal = Signal +Signal.__index = Signal + +--- @param value any +--- @param name? string +--- @return Signal +function Signal:new(value, name) + local obj = setmetatable({ + name = name, + changing = false, + value = value, + subscribers = {}, + }, self) + return obj +end + +--- @param value any +function Signal:set(value) + self.value = value + + -- We don't handle cyclic updates: + if self.changing then return end + + local prev_changing = self.changing + self.changing = true + for _, cb in ipairs(self.subscribers) do + pcall(cb, value) + end + self.changing = prev_changing +end + +--- @return any +function Signal:get() + local ctx = M.ExecutionContext.current() + if ctx then ctx:track(self) end + return self.value +end + +--- @param fn function +function Signal:update(fn) self:set(fn(self.value)) end + +--- @param callback function +function Signal:subscribe(callback) + table.insert(self.subscribers, callback) + return function() self:unsubscribe(callback) end +end + +--- @param callback function +function Signal:unsubscribe(callback) + for i, cb in ipairs(self.subscribers) do + if cb == callback then + table.remove(self.subscribers, i) + break + end + end +end + +function Signal:dispose() self.subscribers = {} end + +CURRENT_CONTEXT = nil + +--- @class ExecutionContext +--- @field signals table +local ExecutionContext = {} +M.ExecutionContext = ExecutionContext +ExecutionContext.__index = ExecutionContext + +--- @return ExecutionContext +function ExecutionContext:new() + return setmetatable({ + signals = {}, + subscribers = {}, + }, ExecutionContext) +end + +function ExecutionContext.current() return CURRENT_CONTEXT end + +--- @param fn function +--- @param ctx ExecutionContext +function ExecutionContext:run(fn, ctx) + local oldCtx = CURRENT_CONTEXT + CURRENT_CONTEXT = ctx + local result + local success, err = pcall(function() result = fn() end) + + CURRENT_CONTEXT = oldCtx + + if not success then error(err) end + + return result +end + +function ExecutionContext:track(signal) self.signals[signal] = true end + +--- @param callback function +function ExecutionContext:subscribe(callback) + local wrapped_callback = function() callback() end + for signal in pairs(self.signals) do + signal:subscribe(wrapped_callback) + end + + return function() + for signal in pairs(self.signals) do + signal:unsubscribe(wrapped_callback) + end + end +end + +function ExecutionContext:dispose() + for signal, _ in pairs(self.signals) do + signal:dispose() + end + self.signals = {} +end + +--- @param value any +--- @param name? string +--- @return Signal +function M.create_signal(value, name) return Signal:new(value, name) end + +--- @param fn function +--- @param name? string +--- @return Signal +function M.create_memo(fn, name) + local result + M.create_effect(function() + local value = fn() + if name and M.debug then vim.notify(name) end + if result then + result:set(value) + else + result = M.create_signal(value, name and ('m.s:' .. name) or nil) + end + end, name) + return result +end + +--- @param fn function +--- @param name? string +function M.create_effect(fn, name) + local ctx = M.ExecutionContext:new() + M.ExecutionContext:run(fn, ctx) + return ctx:subscribe(function() + if name and M.debug then + local deps = vim + .iter(vim.tbl_keys(ctx.signals)) + :map(function(s) return s.name end) + :filter(function(nm) return nm ~= nil end) + :join ',' + vim.notify(name .. ':=>' .. deps) + end + fn() + end) +end + +return M diff --git a/lua/u/utils.lua b/lua/u/utils.lua index 228a9d2..ffe1d88 100644 --- a/lua/u/utils.lua +++ b/lua/u/utils.lua @@ -8,6 +8,18 @@ local M = {} ---@alias KeyMaps table } ---@alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: Range|nil } +--- @generic T +--- @param x `T` +--- @param message? string +--- @return T +function M.dbg(x, message) + local t = {} + if message ~= nil then table.insert(t, message) end + table.insert(t, x) + vim.print(t) + return x +end + --- A utility for creating user commands that also pre-computes useful information --- and attaches it to the arguments. --- @@ -107,28 +119,97 @@ function M.repeatablemap(mode, lhs, rhs, opts) end, vim.tbl_extend('force', opts or {}, { expr = true })) end -function M.get_editor_dimensions() - local w = 0 - local h = 0 - local tabnr = vim.api.nvim_get_current_tabpage() - for _, winid in ipairs(vim.api.nvim_list_wins()) do - local tabpage = vim.api.nvim_win_get_tabpage(winid) - if tabpage == tabnr then - local pos = vim.api.nvim_win_get_position(winid) - local r, c = pos[1], pos[2] - local win_w = vim.api.nvim_win_get_width(winid) - local win_h = vim.api.nvim_win_get_height(winid) - local right = c + win_w - local bottom = r + win_h - if right > w then w = right end - if bottom > h then h = bottom 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 - if w == 0 or h == 0 then - w = vim.api.nvim_win_get_width(0) - h = vim.api.nvim_win_get_height(0) + + -- Fill the base cases + for i = 0, m do + dp[i][0] = i end - return { width = w, height = h } + 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/string.lua b/lua/u/utils/string.lua new file mode 100644 index 0000000..947b89b --- /dev/null +++ b/lua/u/utils/string.lua @@ -0,0 +1,29 @@ +local M = {} + +-------------------------------------------------------------------------------- +--- eat_while +-------------------------------------------------------------------------------- + +--- @param s string +--- @param pos number +--- @param predicate fun(c: string, i: number, s: string): boolean +function M.eat_while(s, pos, predicate) + local eaten = '' + local curr = pos + local watchdog = 0 + while curr <= #s do + watchdog = watchdog + 1 + if watchdog > #s then error 'infinite loop' end + + local c = s:sub(curr, curr) + if not predicate(c, curr, s) then break end + eaten = eaten .. c + curr = curr + 1 + end + return eaten, curr +end + +--- @param c string +function M.is_whitespace(c) return c == ' ' or c == '\t' or c == '\n' end + +return M diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua new file mode 100644 index 0000000..74e7769 --- /dev/null +++ b/spec/renderer_spec.lua @@ -0,0 +1,59 @@ +local Renderer = require 'u.renderer' + +--- @param markup string +local function parse(markup) + -- call private method: + return (Renderer --[[@as any]])._parse_markup(markup) +end + +describe('Renderer', function() + it('_parse_markup: empty string', function() + local nodes = parse [[]] + assert.are.same({}, nodes) + end) + + it('_parse_markup: only string', function() + local nodes = parse [[The quick brown fox jumps over the lazy dog.]] + assert.are.same({ + { kind = 'text', value = 'The quick brown fox jumps over the lazy dog.' }, + }, nodes) + end) + + it('_parse_markup: <', function() + local nodes = parse [[<t value="bleh" />]] + assert.are.same({ + { kind = 'text', value = '' }, + }, nodes) + end) + + it('_parse_markup: empty tag', function() + local nodes = parse [[]] + assert.are.same({ { kind = 'tag', name = '', attributes = {} } }, nodes) + end) + + it('_parse_markup: tag', function() + local nodes = parse [[]] + assert.are.same({ + { + kind = 'tag', + name = 't', + attributes = { + value = 'Hello', + }, + }, + }, nodes) + end) + + it('_parse_markup: attributes with quotes', function() + local nodes = parse [[]] + assert.are.same({ + { + kind = 'tag', + name = 't', + attributes = { + value = 'Hello "there"', + }, + }, + }, nodes) + end) +end) diff --git a/spec/utils_spec.lua b/spec/utils_spec.lua new file mode 100644 index 0000000..75bfdf9 --- /dev/null +++ b/spec/utils_spec.lua @@ -0,0 +1,70 @@ +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)