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/renderer.lua b/lua/u/renderer.lua new file mode 100644 index 0000000..7903317 --- /dev/null +++ b/lua/u/renderer.lua @@ -0,0 +1,306 @@ +local utils = require 'u.utils' +local str = require 'u.utils.string' + +-------------------------------------------------------------------------------- +-- 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 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 + + local nodes = self._parse_markup(markup) + + --- @type string[] + local lines = {} + --- @type RendererHighlight[] + local hls = {} + + 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 function attr_num(nm) + if not node.attributes[nm] then return end + return tonumber(node.attributes[nm]) + end + local function attr_bool(nm) + if not node.attributes[nm] then return end + return node.attributes[nm] and true or false + end + + if node.name == 't' then + local value = node.attributes.value + if type(value) == 'string' then + 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 } + + local group = node.attributes.hl + if type(group) == 'string' then + 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 = node.attributes['hl:fg'], + bg = node.attributes['hl:bg'], + sp = node.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 = node.attributes['hl:link'], + default = attr_bool 'hl:default', + ctermfg = attr_num 'hl:ctermfg', + ctermbg = attr_num 'hl:ctermbg', + cterm = node.attributes['hl:cterm'], + force = attr_bool 'hl:force', + }) + end + end + if type(group) == 'string' then table.insert(hls, { group = group, start = start0, stop = stop0 }) end + 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.highlight.range(self.bufnr, self.ns, hl.group, hl.start, hl.stop, { + inclusive = false, + priority = vim.highlight.priorities.user, + regtype = 'charwise', + }) + 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 } | { kind: 'tag'; name: string; attributes: table })[] + 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/signal.lua b/lua/u/signal.lua new file mode 100644 index 0000000..a236405 --- /dev/null +++ b/lua/u/signal.lua @@ -0,0 +1,143 @@ +local M = {} + +--- @class Signal +--- @field value any +--- @field subscribers table +local Signal = {} +M.Signal = Signal +Signal.__index = Signal + +--- @param value any +--- @return Signal +function Signal:new(value) + local obj = setmetatable({ + value = value, + subscribers = {}, + }, self) + return obj +end + +--- @param value any +function Signal:set(value) + self.value = value + self:_notify() +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 + +--- @private +function Signal:_notify() + for _, cb in ipairs(self.subscribers) do + cb(self.value) + end +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 +--- @field subscribers 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) + self.subscribers[callback] = true + for signal in pairs(self.signals) do + signal:subscribe(function() callback() end) + end + return function() self:unsubscribe(callback) end +end + +--- @param callback function +function ExecutionContext:unsubscribe(callback) self.subscribers[callback] = nil end + +function ExecutionContext:dispose() + for signal, _ in pairs(self.signals) do + signal:dispose() + end + self.signals = {} + self.subscribers = {} +end + +--- @param value any +--- @return Signal +function M.create_signal(value) return Signal:new(value) end + +--- @param fn function +--- @return Signal +function M.create_memo(fn) + local result + M.create_effect(function() + local value = fn() + if result then + result:set(value) + else + result = M.create_signal(value) + end + end) + return result +end + +--- @param fn function +function M.create_effect(fn) + local ctx = M.ExecutionContext:new() + M.ExecutionContext:run(fn, ctx) + return ctx:subscribe(fn) +end + +return M diff --git a/lua/u/utils.lua b/lua/u/utils.lua index 228a9d2..7c6b6ef 100644 --- a/lua/u/utils.lua +++ b/lua/u/utils.lua @@ -131,4 +131,95 @@ function M.get_editor_dimensions() return { width = w, height = h } 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/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)