4 Commits
dev ... react

Author SHA1 Message Date
629bdf27b4 Renderer.mount: add tests
Some checks failed
NeoVim tests / code-quality (push) Failing after 1m6s
2025-10-11 16:40:07 -06:00
88b9e5f965 fix CI
Some checks failed
NeoVim tests / code-quality (push) Failing after 1m6s
2025-10-11 15:37:09 -06:00
fd7b53ab05 Switch to emmylua for type-checking
Some checks failed
NeoVim tests / code-quality (push) Failing after 1m6s
2025-10-11 15:12:18 -06:00
9fcc803805 retained-state immediate-mode renderer
Some checks failed
NeoVim tests / code-quality (push) Failing after 1m17s
2025-10-11 14:43:02 -06:00
26 changed files with 631 additions and 416 deletions

View File

@@ -1,22 +1,12 @@
{ {
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json", "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
"diagnostics": {
"disable": [
"access-invisible",
"redefined-local"
]
},
"runtime": { "runtime": {
"version": "LuaJIT" "version": "LuaJIT"
}, },
"workspace": { "workspace": {
"ignoreDir": [
".prefix"
],
"library": [ "library": [
"$VIMRUNTIME", "$VIMRUNTIME",
"library/busted", "library/busted"
"library/luv"
] ]
} }
} }

View File

@@ -1,60 +1,29 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: ci name: NeoVim tests
on: [push]
on:
workflow_dispatch:
pull_request:
push:
tags: ["*"]
branches: ["*"]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MISE_EXPERIMENTAL: true
jobs: jobs:
ci: code-quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 env:
XDG_CONFIG_HOME: ${{ github.workspace }}/.config/
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with: with:
submodules: true nix_path: nixpkgs=channel:nixos-unstable
- name: Install apt dependencies - name: Populate Nix store
run: | run:
sudo apt-get update nix-shell --pure --run 'true'
sudo apt-get install -y libreadline-dev
- name: Setup environment - name: Type-check
run: | run:
if [ -n "${{secrets.TOKEN}}" ]; then nix-shell --pure --run 'make lint'
export GITHUB_TOKEN="${{secrets.TOKEN}}"
fi
export MISE_GITHUB_TOKEN="$GITHUB_TOKEN"
echo "$GITHUB_TOKEN" >> $GITHUB_ENV
echo "$MISE_GITHUB_TOKEN" >> $GITHUB_ENV
- name: Install mise - name: Check formatting with stylua
run: | run:
curl https://mise.run | sh nix-shell --pure --run 'make fmt-check'
echo "$HOME/.local/bin" >> $GITHUB_PATH
echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
- name: Install mise dependencies - name: Run busted tests
run: | run:
mise install nix-shell --pure --run 'make test'
mise list --local
- name: Check Lua formatting
run: mise run fmt:check
- name: Check for type-errors
run: mise run lint
- name: Run tests
run: mise run test:all

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
.prefix/
*.src.rock *.src.rock
*.aider* *.aider*
luacov.*.out luacov.*.out

8
.gitmodules vendored
View File

@@ -2,11 +2,3 @@
path = library/busted path = library/busted
url = https://github.com/LuaCATS/busted url = https://github.com/LuaCATS/busted
branch = main branch = main
[submodule "library/luv"]
path = library/luv
url = https://github.com/LuaCATS/luv
branch = main
[submodule "nvimv"]
path = nvimv
url = https://github.com/jrop/nvimv
branch = main

View File

@@ -1,2 +0,0 @@
-- :vim set ft=lua
globals = { "vim" }

View File

@@ -1,16 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/refs/heads/master/setting/schema.json",
"runtime": {
"version": "LuaJIT"
},
"workspace": {
"ignoreDir": [
".prefix"
],
"library": [
"$VIMRUNTIME",
"library/busted",
"library/luv"
]
}
}

View File

@@ -1 +0,0 @@
library/

26
Makefile Normal file
View File

@@ -0,0 +1,26 @@
export VIMRUNTIME := $(shell nvim -u NORC --headless +'echo $$VIMRUNTIME' +'quitall' 2>&1)
all: lint fmt-check test
lint:
@echo "## Typechecking"
@emmylua_check .
fmt-check:
@echo "## Checking code format"
@stylua --check .
fmt:
@echo "## Formatting code"
@stylua .
test:
@rm -f luacov.*.out
@echo "## Running tests"
@busted --coverage --verbose
@echo "## Generating coverage report"
@luacov
@awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out
watch:
@watchexec -c -e lua make

View File

@@ -10,12 +10,8 @@
-- change on the underlying filesystem. -- change on the underlying filesystem.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @class u.examples.FsDir --- @alias u.examples.FsDir { kind: 'dir', path: string, expanded: boolean, children: u.examples.FsNode[] }
--- @field kind 'dir' --- @alias u.examples.FsFile { kind: 'file', path: string }
--- @field path string
--- @field expanded boolean
--- @field children u.examples.FsNode[]
--- @alias u.examples.FsFile table
--- @alias u.examples.FsNode u.examples.FsDir | u.examples.FsFile --- @alias u.examples.FsNode u.examples.FsDir | u.examples.FsFile
--- @alias u.examples.ShowOpts { root_path?: string, width?: number, focus_path?: string } --- @alias u.examples.ShowOpts { root_path?: string, width?: number, focus_path?: string }
@@ -116,11 +112,12 @@ function H.populate_dir_children(tree, path_to_node)
end) end)
end end
--- @class u.examples.RenderOpts --- @param opts {
--- @field bufnr number --- bufnr: number,
--- @field prev_winnr number --- prev_winnr: number,
--- @field root_path string --- root_path: string,
--- @field focus_path? string --- focus_path?: string
--- }
--- ---
--- @return { expand: fun(path: string), collapse: fun(path: string) } --- @return { expand: fun(path: string), collapse: fun(path: string) }
local function _render_in_buffer(opts) local function _render_in_buffer(opts)
@@ -409,10 +406,11 @@ end
-- Public API functions: -- Public API functions:
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @class u.examples.CurrentInf --- @type {
--- @field bufnr number --- bufnr: number,
--- @field winnr number --- winnr: number,
--- @field controller table --- controller: { expand: fun(path: string), collapse: fun(path: string) }
--- } | nil
local current_inf = nil local current_inf = nil
--- Show the filetree: --- Show the filetree:

View File

@@ -22,11 +22,12 @@ vim.api.nvim_create_autocmd('VimResized', {
end, end,
}) })
--- @class u.examples.Notification --- @alias u.examples.Notification {
--- @field kind number --- kind: number,
--- @field id number --- id: number,
--- @field text string --- text: string,
--- @field timer uv.uv_timer_t --- timer: uv.uv_timer_t
--- }
local M = {} local M = {}

View File

@@ -44,21 +44,23 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
-- shortest portion of this function. -- shortest portion of this function.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @class u.examples.SelectController<T> --- @alias u.examples.SelectController<T> {
--- @field get_items fun(): T[] --- get_items: fun(): T[],
--- @field set_items fun(items: T[]) --- set_items: fun(items: T[]),
--- @field set_filter_text fun(filter_text: string) --- set_filter_text: fun(filter_text: string),
--- @field get_selected_indices fun(): number[] --- get_selected_indices: fun(): number[],
--- @field get_selected_items fun(): T[] --- get_selected_items: fun(): T[],
--- @field set_selected_indices fun(indices: number[], ephemeral?: boolean) --- set_selected_indices: fun(indicies: number[], ephemeral?: boolean),
--- @field close fun() --- close: fun()
--- @class u.examples.SelectOpts<T> --- }
--- @field items T[] --- @alias u.examples.SelectOpts<T> {
--- @field multi? boolean --- items: `T`[],
--- @field format_item? fun(item: T): u.renderer.Tree --- multi?: boolean,
--- @field on_finish? fun(items: T[], indices: number[]) --- format_item?: (fun(item: T): u.renderer.Tree),
--- @field on_selection_changed? fun(items: T[], indices: number[]) --- on_finish?: (fun(items: T[], indicies: number[])),
--- @field mappings? table<string, fun(select: u.examples.SelectController)> --- on_selection_changed?: fun(items: T[], indicies: number[]),
--- mappings?: table<string, fun(select: u.examples.SelectController)>
--- }
--- @generic T --- @generic T
--- @param opts u.examples.SelectOpts<T> --- @param opts u.examples.SelectOpts<T>

Submodule library/luv deleted from 3615eb12c9

View File

@@ -3,7 +3,8 @@ local Range = require 'u.range'
--- @type fun(range: u.Range): nil|(fun():any) --- @type fun(range: u.Range): nil|(fun():any)
local __U__OpKeymapOpFunc_rhs = nil local __U__OpKeymapOpFunc_rhs = nil
--- This is the global utility function used for operatorfunc in opkeymap --- This is the global utility function used for operatorfunc
--- in opkeymap
--- @param ty 'line'|'char'|'block' --- @param ty 'line'|'char'|'block'
-- selene: allow(unused_variable) -- selene: allow(unused_variable)
function _G.__U__OpKeymapOpFunc(ty) function _G.__U__OpKeymapOpFunc(ty)

View File

@@ -513,7 +513,7 @@ function Range:text() return vim.fn.join(self:lines(), '\n') end
--- @param l number --- @param l number
-- luacheck: ignore -- luacheck: ignore
--- @return { line: string, idx0: { start: number, stop: number }, lnum: number, range: fun():u.Range, text: fun():string }|nil --- @return { line: string, idx0: { start: number, stop: number }, lnum: number, range: (fun():u.Range), text: fun():string }|nil
function Range:line(l) function Range:line(l)
if l < 0 then l = self:line_count() + l + 1 end if l < 0 then l = self:line_count() + l + 1 end
if l > self:line_count() then return end if l > self:line_count() then return end

View File

@@ -1,13 +1,25 @@
---@diagnostic disable: redefined-local
function _G.URendererOpFuncSwallow() end function _G.URendererOpFuncSwallow() end
local ENABLE_LOG = false --- Comma-separated list of regex patterns for debug logging. Examples:
--- "render,extmark" or ".*" or "get_tags_at,_on_text_changed" or
--- "^_on_.*,reconcile$"
local DEBUG = vim.env.DEBUG or ''
local function log(...) --- @param name string
if not ENABLE_LOG then return end local function log(name, ...)
if DEBUG == '' then return end
local f = assert(io.open(vim.fs.joinpath(vim.fn.stdpath 'log', 'u.renderer.log'), 'a+')) for pattern in DEBUG:gmatch '[^,]+' do
f:write(os.date() .. '\t' .. vim.inspect { ... } .. '\n') if name:match(vim.trim(pattern)) then
local f = assert(
io.open(vim.fs.joinpath((vim.fn.stdpath 'log') --[[@as string]], 'u.renderer.log'), 'a+')
)
f:write(os.date() .. '\t' .. vim.inspect { name, ... } .. '\n')
f:close() f:close()
return
end
end
end end
local M = {} local M = {}
@@ -15,19 +27,59 @@ local H = {}
--- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string --- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string
--- @alias u.renderer.TagAttributes { [string]?: unknown, imap?: table<string, u.renderer.TagEventHandler>, nmap?: table<string, u.renderer.TagEventHandler>, vmap?: table<string, u.renderer.TagEventHandler>, xmap?: table<string, u.renderer.TagEventHandler>, omap?: table<string, u.renderer.TagEventHandler>, on_change?: fun(text: string): unknown } --- @alias u.renderer.TagAttributes { [string]?: unknown, imap?: table<string, u.renderer.TagEventHandler>, nmap?: table<string, u.renderer.TagEventHandler>, vmap?: table<string, u.renderer.TagEventHandler>, xmap?: table<string, u.renderer.TagEventHandler>, omap?: table<string, u.renderer.TagEventHandler>, on_change?: (fun(text: string): unknown), key?: string|number }
--- @generic TProps
--- @generic TState
--- @class u.renderer.FnComponentContext<TProps, TState>
--- @field phase 'mount'|'update'|'unmount'
--- @field props TProps
--- @field state? TState
--- @field children u.renderer.Tree
--- @field private on_change? fun(): any
--- @field private prev_rendered_children? u.renderer.Tree
local FnComponentContext = {}
M.FnComponentContext = FnComponentContext
FnComponentContext.__index = FnComponentContext
--- @param props TProps
--- @param state? TState
--- @param children u.renderer.Tree
function FnComponentContext.new(props, state, children)
return setmetatable({
phase = 'mount',
props = props,
state = state,
children = children,
}, FnComponentContext)
end
--- @param new_state TState
function FnComponentContext:update(new_state)
self.state = new_state
if self.on_change then vim.schedule(function() self.on_change() end) end
end
--- @private
--- @param new_state TState
function FnComponentContext:update_immediate(new_state)
self.state = new_state
if self.on_change then self.on_change() end
end
--- @alias u.renderer.FnComponent<TProps, TState> fun(ctx: u.renderer.FnComponentContext<TProps, TState>): u.renderer.Tree
--- @class u.renderer.Tag --- @class u.renderer.Tag
--- @field kind 'tag' --- @field kind 'tag'
--- @field name string --- @field name string | u.renderer.FnComponent<any, any>
--- @field attributes u.renderer.TagAttributes --- @field attributes u.renderer.TagAttributes
--- @field children u.renderer.Tree --- @field children u.renderer.Tree
--- @field private ctx? u.renderer.FnComponentContext
--- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag --- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[] --- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
-- luacheck: ignore -- luacheck: ignore
--- @type table<string, fun(attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>
M.h = setmetatable({}, { M.h = setmetatable({}, {
__call = function(_, name, attributes, children) __call = function(_, name, attributes, children)
return { return {
@@ -42,39 +94,77 @@ M.h = setmetatable({}, {
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children) return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end end
end, end,
}) }) --[[@as table<string, fun(attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>]]
-- Renderer {{{ -- Renderer {{{
--- @class u.renderer.RendererExtmark --- @class u.renderer.RendererExtmark
--- @field id? number --- @field id? integer
--- @field start [number, number] --- @field start [integer, integer]
--- @field stop [number, number] --- @field stop [integer, integer]
--- @field opts vim.api.keyset.set_extmark --- @field opts vim.api.keyset.set_extmark
--- @field tag u.renderer.Tag --- @field tag u.renderer.Tag
--- @class u.renderer.Renderer --- @class u.renderer.Renderer
--- @field bufnr number --- @field bufnr integer
--- @field ns number --- @field ns integer
--- @field changedtick number --- @field changedtick integer
--- @field old { lines: string[], extmarks: u.renderer.RendererExtmark[] } --- @field text_content { old: { lines: string[], extmarks: u.renderer.RendererExtmark[] }, curr: { lines: string[], extmarks: u.renderer.RendererExtmark[] } }
--- @field curr { lines: string[], extmarks: u.renderer.RendererExtmark[] } --- @field component_tree { old: u.renderer.Tree }
local Renderer = {} local Renderer = {}
Renderer.__index = Renderer Renderer.__index = Renderer
M.Renderer = Renderer M.Renderer = Renderer
--- @private --- @private
--- @param x any --- @param tree u.renderer.Tree
--- @return boolean --- @param visitors {
function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end --- nil_?: (fun(): any),
--- boolean?: (fun(b: boolean): any),
--- string?: (fun(s: string): any),
--- array?: (fun(tags: u.renderer.Node[]): any),
--- tag?: (fun(tag: u.renderer.Tag): any),
--- component?: (fun(component: u.renderer.FnComponent, tag: u.renderer.Tag): any),
--- unknown?: fun(tag: any): any
--- }
function Renderer.tree_match(tree, visitors) -- {{{
local function is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
local function is_tag_arr(x) return type(x) == 'table' and not is_tag(x) end
if tree == nil then
return visitors.nil_ and visitors.nil_() or nil
elseif type(tree) == 'boolean' then
return visitors.boolean and visitors.boolean(tree) or nil
elseif type(tree) == 'string' then
return visitors.string and visitors.string(tree) or nil
elseif is_tag_arr(tree) then
return visitors.array and visitors.array(tree --[[@as any]]) or nil
elseif is_tag(tree) then
local tag = tree --[[@as u.renderer.Tag]]
if type(tag.name) == 'function' then
return visitors.component and visitors.component(tag.name --[[@as function]], tag) or nil
else
return visitors.tag and visitors.tag(tree --[[@as any]]) or nil
end
else
return visitors.unknown and visitors.unknown(tree) or error 'unknown value: not a tag'
end
end -- }}}
--- @private --- @private
--- @param x any --- @param tree u.renderer.Tree
--- @return boolean --- @return 'nil' | 'boolean' | 'string' | 'array' | 'tag' | u.renderer.FnComponent | 'unknown'
function Renderer.is_tag_arr(x) function Renderer.tree_kind(tree) -- {{{
if type(x) ~= 'table' then return false end return Renderer.tree_match(tree, {
return #x == 0 or not Renderer.is_tag(x) nil_ = function() return 'nil' end,
end boolean = function() return 'boolean' end,
--- @param bufnr number|nil string = function() return 'string' end,
array = function() return 'array' end,
tag = function() return 'tag' end,
component = function(c) return c end,
unknown = function() return 'unknown' end,
}) --[[@as any]]
end -- }}}
--- @param bufnr integer|nil
function Renderer.new(bufnr) -- {{{ function Renderer.new(bufnr) -- {{{
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
@@ -86,8 +176,15 @@ function Renderer.new(bufnr) -- {{{
bufnr = bufnr, bufnr = bufnr,
ns = vim.b[bufnr]._renderer_ns, ns = vim.b[bufnr]._renderer_ns,
changedtick = 0, changedtick = 0,
text_content = {
old = { lines = {}, extmarks = {} }, old = { lines = {}, extmarks = {} },
curr = { lines = {}, extmarks = {} }, curr = { lines = {}, extmarks = {} },
},
component_tree = {
old = nil,
curr = nil,
ctx_by_node = {},
},
}, Renderer) }, Renderer)
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, { vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, {
@@ -98,9 +195,10 @@ function Renderer.new(bufnr) -- {{{
return self return self
end -- }}} end -- }}}
--- @class u.renderer.MarkupOpts --- @param opts {
--- @field tree u.renderer.Tree --- tree: u.renderer.Tree,
--- @field on_tag? fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any --- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any
--- }
function Renderer.markup_to_lines(opts) -- {{{ function Renderer.markup_to_lines(opts) -- {{{
--- @type string[] --- @type string[]
local lines = {} local lines = {}
@@ -118,51 +216,44 @@ function Renderer.markup_to_lines(opts) -- {{{
curr_col1 = 1 curr_col1 = 1
end end
--- @param node u.renderer.Node --- @param node u.renderer.Tree
local function visit(node) -- {{{ local function visit(node) -- {{{
if node == nil or type(node) == 'boolean' then return end Renderer.tree_match(node, {
string = function(s_node)
if type(node) == 'string' then local node_lines = vim.split(s_node, '\n')
local node_lines = vim.split(node, '\n')
for lnum, s in ipairs(node_lines) do for lnum, s in ipairs(node_lines) do
if lnum > 1 then put_line() end if lnum > 1 then put_line() end
put(s) put(s)
end end
elseif Renderer.is_tag(node) then end,
array = function(ts)
for _, child in ipairs(ts) do
visit(child)
end
end,
tag = function(t)
assert(type(t.name) ~= 'function', 'markup_to_lines does not render components')
local start0 = { curr_line1 - 1, curr_col1 - 1 } local start0 = { curr_line1 - 1, curr_col1 - 1 }
visit(t.children)
-- visit the children:
if Renderer.is_tag_arr(node.children) then
for _, child in
ipairs(node.children --[[@as u.renderer.Node[] ]])
do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child)
end
else -- luacheck: ignore
visit(node.children)
end
local stop0 = { curr_line1 - 1, curr_col1 - 1 } local stop0 = { curr_line1 - 1, curr_col1 - 1 }
if opts.on_tag then opts.on_tag(node, start0, stop0) end
elseif Renderer.is_tag_arr(node) then if opts.on_tag then opts.on_tag(t, start0, stop0) end
for _, child in ipairs(node) do end,
-- newlines are not controlled by array entries, do NOT output a line here: })
visit(child)
end
end
end -- }}} end -- }}}
visit(opts.tree) visit(opts.tree)
return lines return lines
end -- }}} end -- }}}
--- @class u.renderer.StringOpts --- @param opts {
--- @field tree u.renderer.Tree --- tree: u.renderer.Tree,
--- @field format_tag? fun(tag: u.renderer.Tag): string --- format_tag?: fun(tag: u.renderer.Tag): string
--- }
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
--- @param bufnr number --- @param bufnr integer
--- @param old_lines string[] | nil --- @param old_lines string[] | nil
--- @param new_lines string[] --- @param new_lines string[]
function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
@@ -188,8 +279,10 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
end end
-- Morph the text to the desired state: -- Morph the text to the desired state:
local line_changes = local line_changes = H.levenshtein {
H.levenshtein(old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), new_lines) 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 for _, line_change in ipairs(line_changes) do
local lnum0 = line_change.index - 1 local lnum0 = line_change.index - 1
@@ -198,7 +291,7 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
elseif line_change.kind == 'change' then elseif line_change.kind == 'change' then
-- Compute inter-line diff, and apply: -- Compute inter-line diff, and apply:
local col_changes = 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 for _, col_change in ipairs(col_changes) do
local cnum0 = col_change.index - 1 local cnum0 = col_change.index - 1
@@ -224,7 +317,8 @@ end -- }}}
function Renderer:render(tree) -- {{{ function Renderer:render(tree) -- {{{
local changedtick = vim.b[self.bufnr].changedtick local changedtick = vim.b[self.bufnr].changedtick
if changedtick ~= self.changedtick then if changedtick ~= self.changedtick then
self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) } self.text_content.curr =
{ extmarks = {}, lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
self.changedtick = changedtick self.changedtick = changedtick
end end
@@ -281,8 +375,8 @@ function Renderer:render(tree) -- {{{
end, -- }}} end, -- }}}
} }
self.old = self.curr self.text_content.old = self.text_content.curr
self.curr = { lines = lines, extmarks = extmarks } self.text_content.curr = { lines = lines, extmarks = extmarks }
self:_reconcile() self:_reconcile()
vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } } vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } }
end -- }}} end -- }}}
@@ -292,7 +386,7 @@ function Renderer:_reconcile() -- {{{
-- --
-- Step 1: morph the text to the desired state: -- Step 1: morph the text to the desired state:
-- --
Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines) Renderer.patch_lines(self.bufnr, self.text_content.old.lines, self.text_content.curr.lines)
self.changedtick = vim.b[self.bufnr].changedtick self.changedtick = vim.b[self.bufnr].changedtick
-- --
@@ -307,7 +401,7 @@ function Renderer:_reconcile() -- {{{
vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1)
-- Set current extmarks: -- Set current extmarks:
for _, extmark in ipairs(self.curr.extmarks) do for _, extmark in ipairs(self.text_content.curr.extmarks) do
extmark.id = vim.api.nvim_buf_set_extmark( extmark.id = vim.api.nvim_buf_set_extmark(
self.bufnr, self.bufnr,
self.ns, self.ns,
@@ -328,7 +422,7 @@ function Renderer:_reconcile() -- {{{
) )
end end
self.old = self.curr self.text_content.old = self.text_content.curr
end -- }}} end -- }}}
--- @private --- @private
@@ -338,9 +432,9 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
-- find the tag with the smallest intersection that contains the cursor: -- find the tag with the smallest intersection that contains the cursor:
local pos0 = vim.api.nvim_win_get_cursor(0) local pos0 = vim.api.nvim_win_get_cursor(0)
pos0[1] = pos0[1] - 1 -- make it actually 0-based pos0[1] = pos0[1] - 1 -- make it actually 0-based
log('_expr_map_callback: pos0:', pos0) log('_expr_map_callback', 'pos0:', pos0)
local pos_infos = self:get_tags_at(pos0) local pos_infos = self:get_tags_at(pos0)
log('_expr_map_callback: pos_infos:', pos_infos) log('_expr_map_callback', 'pos_infos:', pos_infos)
if #pos_infos == 0 then return lhs end if #pos_infos == 0 then return lhs end
@@ -390,7 +484,7 @@ function Renderer:_on_text_changed() -- {{{
--- @type integer, integer, vim.api.keyset.extmark_details --- @type integer, integer, vim.api.keyset.extmark_details
local start_row0, start_col0, details = unpack(extmark) local start_row0, start_col0, details = unpack(extmark)
local end_row0, end_col0 = details.end_row, details.end_col local end_row0, end_col0 = details.end_row, details.end_col
log('_on_text_changed: fetched current extmark for pos_info', { log('_on_text_changed', 'fetched current extmark for pos_info', {
pos_info = pos_info, pos_info = pos_info,
curr_extmark = { curr_extmark = {
start_row0 = start_row0, start_row0 = start_row0,
@@ -419,7 +513,7 @@ function Renderer:_on_text_changed() -- {{{
or '' or ''
end_col0 = last_line:len() end_col0 = last_line:len()
end end
log('_on_text_changed: after position correction', { log('_on_text_changed', 'after position correction', {
curr_extmark = { curr_extmark = {
start_row0 = start_row0, start_row0 = start_row0,
start_col0 = start_col0, start_col0 = start_col0,
@@ -437,7 +531,7 @@ function Renderer:_on_text_changed() -- {{{
local pos2 = { self.bufnr, end_row0 + 1, end_col0 } local pos2 = { self.bufnr, end_row0 + 1, end_col0 }
local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' }) local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' })
if not ok then if not ok then
log('_on_text_changed: getregion: invalid-pos ', { log('_on_text_changed', 'getregion: invalid-pos ', {
{ pos1, pos2 }, { pos1, pos2 },
}) })
vim.api.nvim_echo({ vim.api.nvim_echo({
@@ -490,7 +584,7 @@ function Renderer:_debug() -- {{{
), ),
}, },
computed = { computed = {
extmarks = self.curr.extmarks, extmarks = self.text_content.curr.extmarks,
}, },
} }
vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n')) vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n'))
@@ -532,7 +626,7 @@ end -- }}}
--- (outermost). --- (outermost).
--- ---
--- @private (private for now) --- @private (private for now)
--- @param pos0 [number, number] --- @param pos0 [integer, integer]
--- @param mode string? --- @param mode string?
--- @return { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[] --- @return { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[]
function Renderer:get_tags_at(pos0, mode) -- {{{ function Renderer:get_tags_at(pos0, mode) -- {{{
@@ -548,7 +642,8 @@ function Renderer:get_tags_at(pos0, mode) -- {{{
{ details = true, overlap = true } { details = true, overlap = true }
) )
log( log(
'get_tags_at: context:', 'get_tags_at',
'context:',
{ pos0 = pos0, mode = mode, raw_overlapping_extmarks = raw_overlapping_extmarks } { pos0 = pos0, mode = mode, raw_overlapping_extmarks = raw_overlapping_extmarks }
) )
@@ -558,21 +653,29 @@ function Renderer:get_tags_at(pos0, mode) -- {{{
--- @type u.renderer.RendererExtmark[] --- @type u.renderer.RendererExtmark[]
local mapped_extmarks = vim local mapped_extmarks = vim
.iter(raw_overlapping_extmarks) .iter(raw_overlapping_extmarks)
:map(function(ext) :map(
--- @type number, number, number, { end_row?: number, end_col?: number }|nil --- @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 id, line0, col0, details = unpack(ext)
local start = { line0, col0 } local start = { line0, col0 }
local stop = { line0, col0 } local stop = { line0, col0 }
if details and details.end_row ~= nil and details.end_col ~= nil then if details and details.end_row ~= nil and details.end_col ~= nil then
stop = { details.end_row, details.end_col } stop = {
details.end_row --[[@as integer]],
details.end_col --[[@as integer]],
}
end end
return { id = id, start = start, stop = stop, opts = details } return { id = id, start = start, stop = stop, opts = details }
end) end
)
:totable() :totable()
local intersecting_extmarks = vim local intersecting_extmarks = vim
.iter(mapped_extmarks) .iter(mapped_extmarks)
:filter(function(ext) :filter(
--- @param ext u.renderer.RendererExtmark
function(ext)
if ext.stop[1] ~= nil and ext.stop[2] ~= nil then if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
-- If we've "ciw" and "collapsed" an extmark onto the cursor, -- If we've "ciw" and "collapsed" an extmark onto the cursor,
-- the cursor pos will equal the exmark's start AND end. In this -- the cursor pos will equal the exmark's start AND end. In this
@@ -607,7 +710,8 @@ function Renderer:get_tags_at(pos0, mode) -- {{{
else else
return true return true
end end
end) end
)
:totable() :totable()
-- Sort the tags into smallest (inner) to largest (outer): -- Sort the tags into smallest (inner) to largest (outer):
@@ -633,17 +737,20 @@ function Renderer:get_tags_at(pos0, mode) -- {{{
) )
-- When we set the extmarks in the step above, we captured the IDs of the -- When we set the extmarks in the step above, we captured the IDs of the
-- created extmarks in self.curr.extmarks, which also has which tag each -- created extmarks in self.text_content.curr.extmarks, which also has which tag each
-- extmark is associated with. Cross-reference with that list to get a list -- extmark is associated with. Cross-reference with that list to get a list
-- of tags that we need to fire events for: -- of tags that we need to fire events for:
--- @type { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[] --- @type { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[]
local matching_tags = vim local matching_tags = vim
.iter(intersecting_extmarks) .iter(intersecting_extmarks)
:map(function(ext) :map(
for _, extmark_cache in ipairs(self.curr.extmarks) do --- @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 if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end
end end
end) end
)
:totable() :totable()
return matching_tags return matching_tags
@@ -653,7 +760,7 @@ end -- }}}
--- @param tag_or_id string | u.renderer.Tag --- @param tag_or_id string | u.renderer.Tag
--- @return { start: [number, number], stop: [number, number] } | nil --- @return { start: [number, number], stop: [number, number] } | nil
function Renderer:get_tag_bounds(tag_or_id) -- {{{ function Renderer:get_tag_bounds(tag_or_id) -- {{{
for _, x in ipairs(self.curr.extmarks) do for _, x in ipairs(self.text_content.curr.extmarks) do
local pos = { start = x.start, stop = x.stop } local pos = { start = x.start, stop = x.stop }
local does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id local does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id
or x.tag == tag_or_id or x.tag == tag_or_id
@@ -661,6 +768,189 @@ function Renderer:get_tag_bounds(tag_or_id) -- {{{
end end
end -- }}} end -- }}}
--- @param tree u.renderer.Tree
function Renderer:mount(tree) -- {{{
local function rerender()
local H2 = {}
--- @param tree u.renderer.Tree
function H2.unmount(tree)
log('Render.mount.umount', { tree = tree })
--- @param tree u.renderer.Tree
local function visit(tree)
Renderer.tree_match(tree, {
array = function(tags)
log('Render.mount.umount.array', { tags = tags })
for _, tag in ipairs(tags) do
visit(tag)
end
end,
tag = function(tag)
log('Render.mount.umount.tag', { tag = tag })
visit(tag.children)
end,
component = function(Component, tag)
log('Render.mount.umount.component', { Component = Component, tag = tag })
--- @type u.renderer.FnComponentContext
local ctx = assert(tag.ctx, 'could not find context for node')
-- depth-first:
visit(ctx.children)
-- now unmount current:
ctx.phase = 'unmount'
Component(ctx)
ctx.on_change = nil
end,
})
end
visit(tree)
end
--- @param old_tree u.renderer.Tree
--- @param new_tree u.renderer.Tree
--- @return u.renderer.Tree
function H2.visit_tree(old_tree, new_tree)
local old_tree_kind = Renderer.tree_kind(old_tree)
local new_tree_kind = Renderer.tree_kind(new_tree)
log('Renderer.mount.visit_tree', {
old_tree_kind = old_tree_kind,
old_tree = old_tree,
new_tree_kind = new_tree_kind,
new_tree = new_tree,
})
local new_tree_rendered = Renderer.tree_match(new_tree, {
string = function(s) return s end,
boolean = function(b) return b end,
nil_ = function() return nil end,
array = function(new_arr)
return H2.visit_array(old_tree --[[@as any]], new_arr)
end,
tag = function(new_tag)
log('Renderer.mount.visit_tree.tag', { new_tag = new_tag })
local old_children = old_tree_kind == new_tree_kind and old_tree.children or nil
return M.h(
new_tag.name,
new_tag.attributes,
H2.visit_tree(old_children --[[@as any]], new_tag.children --[[@as any]])
)
end,
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_tag = old_component_info and old_component_info.tag or nil
local ctx = old_component_info and old_component_info.ctx or nil
log('Renderer.mount.visit_tree.component', {
NewC = NewC,
new_tag = new_tag,
old_tag = old_tag,
})
if not ctx then
--- @type u.renderer.FnComponentContext
ctx = FnComponentContext.new(new_tag.attributes, nil, new_tag.children)
else
ctx.phase = 'update'
end
ctx.props = new_tag.attributes
ctx.children = new_tag.children
ctx.on_change = rerender
new_tag.ctx = ctx
local NewC_rendered_children = NewC(ctx)
local result = H2.visit_tree(ctx.prev_rendered_children, NewC_rendered_children)
ctx.prev_rendered_children = NewC_rendered_children
return result
end,
})
if old_tree_kind ~= new_tree_kind then H2.unmount(old_tree) end
return new_tree_rendered
end
--- @param old_arr u.renderer.Node[]
--- @param new_arr u.renderer.Node[]
--- @return u.renderer.Node[]
function H2.visit_array(old_arr, new_arr)
-- 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,
},
}
--- @type u.renderer.Node[]
local resulting_nodes = {}
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
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,
})
return resulting_nodes
end
local renderable_tree = H2.visit_tree(self.component_tree.old, tree)
self.component_tree.old = tree
self:render(renderable_tree)
end
-- Kick off initial render:
rerender()
end -- }}}
-- }}} -- }}}
-- TreeBuilder {{{ -- TreeBuilder {{{
@@ -728,14 +1018,21 @@ function TreeBuilder:tree() return self.nodes end
-- Levenshtein utility {{{ -- Levenshtein utility {{{
-- luacheck: ignore -- luacheck: ignore
--- @alias u.renderer.LevenshteinChange<T> ({ kind: 'add', item: T, index: number } | { kind: 'delete', item: T, index: number } | { kind: 'change', from: T, to: T, index: number }) --- @alias u.renderer.LevenshteinChange<T> ({ kind: 'add', item: T, index: integer } | { kind: 'delete', item: T, index: integer } | { kind: 'change', from: T, to: T, index: integer })
--- @private --- @private
--- @generic T --- @generic T
--- @param x `T`[] --- @param opts {
--- @param y T[] --- from: `T`[],
--- @param cost? { of_delete?: (fun(x: T): number), of_add?: (fun(x: T): number), of_change?: (fun(x: T, y: T): number) } --- 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<T>[] The changes, from last (greatest index) to first (smallest index). --- @return u.renderer.LevenshteinChange<T>[] 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 -- 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 -- 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 -- fudging with the costs of operations produces a more optimized change-set
@@ -745,12 +1042,13 @@ function H.levenshtein(x, y, cost)
-- in here even though it's not actively used. Hopefully having 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 -- callback-based plumbing does not cause too much of a performance hit to
-- the renderer. -- the renderer.
cost = cost or {} if not opts.are_equal then opts.are_equal = function(x, y) return x == y end end
local cost_of_delete_f = cost.of_delete or function() return 1 end if not opts.cost then opts.cost = {} end
local cost_of_add_f = cost.of_add or function() return 1 end if not opts.cost.of_add then opts.cost.of_add = function() return 1 end end
local cost_of_change_f = cost.of_change or function() return 1 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 -- Initialize the distance matrix
local dp = {} local dp = {}
for i = 0, m do for i = 0, m do
@@ -771,12 +1069,12 @@ function H.levenshtein(x, y, cost)
-- Compute the Levenshtein distance dynamically -- Compute the Levenshtein distance dynamically
for i = 1, m do for i = 1, m do
for j = 1, n 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 dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
else else
local cost_delete = dp[i - 1][j] + cost_of_delete_f(x[i]) local cost_delete = dp[i - 1][j] + opts.cost.of_delete(opts.from[i], i)
local cost_add = dp[i][j - 1] + cost_of_add_f(y[j]) local cost_add = dp[i][j - 1] + opts.cost.of_add(opts.to[j], j)
local cost_change = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[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) dp[i][j] = math.min(cost_delete, cost_add, cost_change)
end end
end end
@@ -801,9 +1099,9 @@ function H.levenshtein(x, y, cost)
if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change -- potential change
if x[i] ~= y[j] then if not opts.are_equal(opts.from[i], opts.to[j]) then
--- @type u.renderer.LevenshteinChange --- @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) table.insert(changes, change)
end end
i = i - 1 i = i - 1
@@ -811,13 +1109,13 @@ function H.levenshtein(x, y, cost)
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition -- addition
--- @type u.renderer.LevenshteinChange --- @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) table.insert(changes, change)
j = j - 1 j = j - 1
elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
-- deletion -- deletion
--- @type u.renderer.LevenshteinChange --- @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) table.insert(changes, change)
i = i - 1 i = i - 1
else else

107
mise.toml
View File

@@ -1,107 +0,0 @@
################################################################################
## Tool Alias
################################################################################
################################################################################
## Tools
################################################################################
[tools]
# nvimv needs jq:
jq = "1.8.1"
"asdf:mise-plugins/mise-lua" = "5.1.5"
# Since we have busted configured to look for "nvim", have a "default" version
# installed. In the tests, we will override with `eval $(nvimv env VERSION)`,
# but to avoid having to litter a bunch of commands with that environment
# initialization, this just makes things simpler:
neovim = "0.11.5"
stylua = "2.3.1"
"cargo:emmylua_ls" = "0.20.0"
"cargo:emmylua_check" = "0.20.0"
################################################################################
# Env
################################################################################
[env]
ASDF_LUA_LUAROCKS_VERSION = "3.12.2"
_.source = { path = "./scripts/env.sh", tools = true }
################################################################################
# Tasks
################################################################################
[tasks]
lint = "emmylua_check --ignore '.prefix/**/*.*' ."
fmt = "stylua ."
"fmt:check" = "stylua --check ."
"test:prepare" = '''
# Install Lua test dependencies (busted, etc.):
luarocks test --prepare
echo
# Check that the nightly version of Neovim is not more than a day old:
if find .prefix -path '**/nightly/**/nvim' -mtime -1 2>/dev/null | grep -q .; then
echo "Neovim Nightly is up-to-date"
else
if ./nvimv/nvimv ls | grep nightly >/dev/null; then
./nvimv/nvimv upgrade nightly
else
./nvimv/nvimv install nightly
fi
fi
echo
'''
[tasks."test:version:no-prep"]
hide = true
usage = '''
arg "<version>" help="The version of Neovim to test with"
'''
run = '''
echo
echo -----------------------------
echo -----------------------------
echo Neovim version=$usage_version
echo -----------------------------
echo -----------------------------
echo
./nvimv/nvimv install $usage_version
eval $(./nvimv/nvimv env $usage_version)
busted --verbose
'''
[tasks."test:version"]
depends = ["test:prepare"]
usage = '''
arg "<version>" help="The version of Neovim to test with"
'''
run = 'mise test:version:no-prep $usage_version'
[tasks."test"]
run = 'mise test:version 0.11.5'
[tasks."test:all"]
depends = ["test:prepare"]
run = '''
VERSIONS="0.11.5 nightly"
for v in $VERSIONS; do
mise test:version:no-prep $v
done
'''
[tasks."test:coverage"]
depends = ["test:prepare"]
run = '''
rm -f ./luacov.*.*
busted --coverage --verbose
luacov
awk '/^Summary$/{flag=1;next} flag{print}' luacov.report.out
'''
[tasks."ci"]
run = '''
mise run fmt:check
mise run lint
mise run test:all
'''

1
nvimv

Submodule nvimv deleted from bd5c243b96

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
export PREFIX="$(pwd)/.prefix"
export VIMRUNTIME="$(nvim -u NORC --headless +'echo $VIMRUNTIME' +'quitall' 2>&1)"
eval $(luarocks path)

View File

@@ -1,10 +1,10 @@
{ {
pkgs ? pkgs ?
import import
# nixos-25.05 (neovim@0.11.2): # nixos-unstable (neovim@0.11.4):
(fetchTarball { (fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/1c1c9b3f5ec0421eaa0f22746295466ee6a8d48f.tar.gz"; url = "https://github.com/nixos/nixpkgs/archive/0b4defa2584313f3b781240b29d61f6f9f7e0df3.tar.gz";
sha256 = "0szvq1swpzyjmyyw929ngxy1khdnd9ba96qds2bm6l6kg4iq3cq0"; sha256 = "0p3rrd8wwlk0iwgzm7frkw1k98ywrh0avi7fqjjk87i68n3inxrs";
}) })
{ }, { },
}: }:
@@ -12,7 +12,7 @@ pkgs.mkShell {
packages = [ packages = [
pkgs.git pkgs.git
pkgs.gnumake pkgs.gnumake
pkgs.lua-language-server pkgs.emmylua-check
pkgs.lua51Packages.busted pkgs.lua51Packages.busted
pkgs.lua51Packages.luacov pkgs.lua51Packages.luacov
pkgs.lua51Packages.luarocks pkgs.lua51Packages.luarocks

View File

@@ -1,4 +1,3 @@
--- @diagnostic disable: undefined-field, need-check-nil
local Buffer = require 'u.buffer' local Buffer = require 'u.buffer'
local withbuf = loadfile './spec/withbuf.lua'() local withbuf = loadfile './spec/withbuf.lua'()

View File

@@ -1,4 +1,3 @@
--- @diagnostic disable: undefined-field, need-check-nil, need-check-nil
local CodeWriter = require 'u.codewriter' local CodeWriter = require 'u.codewriter'
describe('CodeWriter', function() describe('CodeWriter', function()

View File

@@ -1,4 +1,3 @@
--- @diagnostic disable: undefined-field, need-check-nil
local Pos = require 'u.pos' local Pos = require 'u.pos'
local withbuf = loadfile './spec/withbuf.lua'() local withbuf = loadfile './spec/withbuf.lua'()

View File

@@ -1,4 +1,3 @@
--- @diagnostic disable: undefined-field, need-check-nil
local Pos = require 'u.pos' local Pos = require 'u.pos'
local Range = require 'u.range' local Range = require 'u.range'
local withbuf = loadfile './spec/withbuf.lua'() local withbuf = loadfile './spec/withbuf.lua'()

View File

@@ -1,4 +1,3 @@
--- @diagnostic disable: undefined-field, need-check-nil
local R = require 'u.renderer' local R = require 'u.renderer'
local withbuf = loadfile './spec/withbuf.lua'() local withbuf = loadfile './spec/withbuf.lua'()
@@ -295,4 +294,99 @@ describe('Renderer', function()
assert.are.same(bounds, { start = { 0, 9 }, stop = { 0, 19 } }) assert.are.same(bounds, { start = { 0, 9 }, stop = { 0, 19 } })
end) end)
end) end)
it('should mount and rerender components', function()
withbuf({}, function()
--- @type any
local leaked_state = { app = {}, c1 = {}, c2 = {} }
--- @param ctx u.renderer.FnComponentContext<any, { phase: string, count: integer }>
local function Counter(ctx)
if ctx.phase == 'mount' then ctx.state = { phase = ctx.phase, count = 1 } end
local state = assert(ctx.state)
state.phase = ctx.phase
leaked_state[ctx.props.id].ctx = ctx
return {
{ 'Value: ', R.h.Number({}, tostring(state.count)) },
}
end
--- @param ctx u.renderer.FnComponentContext<any, {
--- toggle1: boolean,
--- show2: boolean
--- }>
function App(ctx)
if ctx.phase == 'mount' then ctx.state = { toggle1 = false, show2 = true } end
local state = assert(ctx.state)
leaked_state.app.ctx = ctx
return {
state.toggle1 and 'Toggle1' or R.h(Counter, { id = 'c1' }, {}),
'\n',
state.show2 and {
'\n',
R.h(Counter, { id = 'c2' }, {}),
},
}
end
local renderer = R.Renderer.new()
renderer:mount(R.h(App, {}, {}))
local Buffer = require 'u.buffer'
local buf = Buffer.current()
assert.are.same(buf:all():lines(), {
'Value: 1',
'',
'Value: 1',
})
assert.are.same(leaked_state.c1.ctx.state.phase, 'mount')
assert.are.same(leaked_state.c2.ctx.state.phase, 'mount')
leaked_state.app.ctx:update_immediate { toggle1 = true, show2 = true }
assert.are.same(buf:all():lines(), {
'Toggle1',
'',
'Value: 1',
})
assert.are.same(leaked_state.c1.ctx.state.phase, 'unmount')
assert.are.same(leaked_state.c2.ctx.state.phase, 'update')
leaked_state.app.ctx:update_immediate { toggle1 = true, show2 = false }
assert.are.same(buf:all():lines(), {
'Toggle1',
'',
})
assert.are.same(leaked_state.c1.ctx.state.phase, 'unmount')
assert.are.same(leaked_state.c2.ctx.state.phase, 'unmount')
leaked_state.app.ctx:update_immediate { toggle1 = false, show2 = true }
assert.are.same(buf:all():lines(), {
'Value: 1',
'',
'Value: 1',
})
assert.are.same(leaked_state.c1.ctx.state.phase, 'mount')
assert.are.same(leaked_state.c2.ctx.state.phase, 'mount')
leaked_state.c1.ctx:update_immediate { count = 2 }
assert.are.same(buf:all():lines(), {
'Value: 2',
'',
'Value: 1',
})
assert.are.same(leaked_state.c1.ctx.state.phase, 'update')
assert.are.same(leaked_state.c2.ctx.state.phase, 'update')
leaked_state.c2.ctx:update_immediate { count = 3 }
assert.are.same(buf:all():lines(), {
'Value: 2',
'',
'Value: 3',
})
assert.are.same(leaked_state.c1.ctx.state.phase, 'update')
assert.are.same(leaked_state.c2.ctx.state.phase, 'update')
end)
end)
end) end)

View File

@@ -1,4 +1,3 @@
--- @diagnostic disable: undefined-field, need-check-nil
local tracker = require 'u.tracker' local tracker = require 'u.tracker'
local Signal = tracker.Signal local Signal = tracker.Signal
local ExecutionContext = tracker.ExecutionContext local ExecutionContext = tracker.ExecutionContext

View File

@@ -1,16 +0,0 @@
rockspec_format = '3.0'
package = 'u'
version = '0.0.0-0'
source = {
url = 'https://github.com/jrop/u.nvim',
}
dependencies = {
'lua = 5.1',
}
test_dependencies = {
'busted == 2.2.0-1',
'luacov == 0.16.0-1',
}