10 Commits

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
72b6886838 range: extmarks/tsquery; renderer: text-change
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m18s
2025-10-02 20:01:40 -06:00
12945a4cdf (examples/notify.lua) eliminate dependency on non-existent class
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m17s
2025-10-02 19:53:04 -06:00
6f86bfaa42 ensure normal-mode before running g@...
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m21s
2025-06-04 23:00:22 -06:00
81ba1bb96b Use nix to create environment for testing
All checks were successful
NeoVim tests / code-quality (push) Successful in 14m25s
2025-05-19 19:03:24 -06:00
35b6e123ac Fix issue caused by last commit
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 10s
It seems that closures are not interacting well with the global nature
of operatorfunc's. That is just a hunch, and this feels like a hack, but
it fixes the issue.
2025-05-17 07:33:58 -06:00
237bc9ba5e (range) use g@ for Range.from_motion
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 9s
2025-05-09 21:34:44 -06:00
36 changed files with 2093 additions and 500 deletions

13
.busted Normal file
View File

@@ -0,0 +1,13 @@
return {
_all = {
coverage = false,
lpath = "lua/?.lua;lua/?/init.lua",
lua = "nvim -u NONE -i NONE -l",
},
default = {
verbose = true
},
tests = {
verbose = true
},
}

12
.emmyrc.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
"runtime": {
"version": "LuaJIT"
},
"workspace": {
"library": [
"$VIMRUNTIME",
"library/busted"
]
}
}

View File

@@ -2,15 +2,28 @@
name: NeoVim tests name: NeoVim tests
on: [push] on: [push]
jobs: jobs:
plenary-tests: code-quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
XDG_CONFIG_HOME: ${{ github.workspace }}/.config/ XDG_CONFIG_HOME: ${{ github.workspace }}/.config/
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: rhysd/action-setup-vim@v1 - uses: cachix/install-nix-action@v31
with: with:
neovim: true nix_path: nixpkgs=channel:nixos-unstable
version: v0.11.0
arch: 'x86_64' - name: Populate Nix store
- run: make test run:
nix-shell --pure --run 'true'
- name: Type-check
run:
nix-shell --pure --run 'make lint'
- name: Check formatting with stylua
run:
nix-shell --pure --run 'make fmt-check'
- name: Run busted tests
run:
nix-shell --pure --run 'make test'

3
.gitignore vendored
View File

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

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "library/busted"]
path = library/busted
url = https://github.com/LuaCATS/busted
branch = main

View File

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

6
.luacov Normal file
View File

@@ -0,0 +1,6 @@
return {
include = {
'lua/u/',
},
tick = true,
}

10
.woodpecker/ci.yaml Normal file
View File

@@ -0,0 +1,10 @@
when:
- event: push
steps:
- name: build
image: nixos/nix
commands:
- nix-shell --pure --run 'make lint'
- nix-shell --pure --run 'make fmt-check'
- nix-shell --pure --run 'make test'

View File

@@ -1,16 +1,26 @@
PLENARY_DIR=~/.local/share/nvim/site/pack/test/opt/plenary.nvim export VIMRUNTIME := $(shell nvim -u NORC --headless +'echo $$VIMRUNTIME' +'quitall' 2>&1)
all: lint test all: lint fmt-check test
lint: lint:
lua-language-server --check=lua/u/ --checklevel=Error @echo "## Typechecking"
lx check @emmylua_check .
fmt-check:
@echo "## Checking code format"
@stylua --check .
fmt: fmt:
stylua . @echo "## Formatting code"
@stylua .
test: $(PLENARY_DIR) test:
NVIM_APPNAME=noplugstest nvim -u NORC --headless -c 'set packpath+=~/.local/share/nvim/site' -c 'packadd plenary.nvim' -c "PlenaryBustedDirectory spec/" @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
$(PLENARY_DIR): watch:
git clone https://github.com/nvim-lua/plenary.nvim/ $(PLENARY_DIR) @watchexec -c -e lua make

View File

@@ -1,5 +1,5 @@
local tracker = require 'u.tracker'
local Buffer = require 'u.buffer' local Buffer = require 'u.buffer'
local tracker = require 'u.tracker'
local h = require('u.renderer').h local h = require('u.renderer').h
-- Create an buffer for the UI -- Create an buffer for the UI

View File

@@ -10,10 +10,10 @@
-- change on the underlying filesystem. -- change on the underlying filesystem.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @alias FsDir { kind: 'dir'; path: string; expanded: boolean; children: FsNode[] } --- @alias u.examples.FsDir { kind: 'dir', path: string, expanded: boolean, children: u.examples.FsNode[] }
--- @alias FsFile { kind: 'file'; path: string } --- @alias u.examples.FsFile { kind: 'file', path: string }
--- @alias FsNode FsDir | FsFile --- @alias u.examples.FsNode u.examples.FsDir | u.examples.FsFile
--- @alias ShowOpts { root_path?: string, width?: number, focus_path?: string } --- @alias u.examples.ShowOpts { root_path?: string, width?: number, focus_path?: string }
local Buffer = require 'u.buffer' local Buffer = require 'u.buffer'
local Renderer = require('u.renderer').Renderer local Renderer = require('u.renderer').Renderer
@@ -58,13 +58,13 @@ function H.relative(path, base)
end end
--- @param root_path string --- @param root_path string
--- @return { tree: FsDir; path_to_node: table<string, FsNode> } --- @return { tree: u.examples.FsDir, path_to_node: table<string, u.examples.FsNode> }
function H.get_tree_inf(root_path) function H.get_tree_inf(root_path)
logger:info { 'get_tree_inf', root_path } logger:info { 'get_tree_inf', root_path }
--- @type table<string, FsNode> --- @type table<string, u.examples.FsNode>
local path_to_node = {} local path_to_node = {}
--- @type FsDir --- @type u.examples.FsDir
local tree = { local tree = {
kind = 'dir', kind = 'dir',
path = H.normalize(root_path or '.'), path = H.normalize(root_path or '.'),
@@ -77,8 +77,8 @@ function H.get_tree_inf(root_path)
return { tree = tree, path_to_node = path_to_node } return { tree = tree, path_to_node = path_to_node }
end end
--- @param tree FsDir --- @param tree u.examples.FsDir
--- @param path_to_node table<string, FsNode> --- @param path_to_node table<string, u.examples.FsNode>
function H.populate_dir_children(tree, path_to_node) function H.populate_dir_children(tree, path_to_node)
tree.children = {} tree.children = {}
@@ -113,10 +113,10 @@ function H.populate_dir_children(tree, path_to_node)
end end
--- @param opts { --- @param opts {
--- bufnr: number; --- bufnr: number,
--- prev_winnr: number; --- prev_winnr: number,
--- root_path: string; --- root_path: string,
--- focus_path?: string; --- focus_path?: string
--- } --- }
--- ---
--- @return { expand: fun(path: string), collapse: fun(path: string) } --- @return { expand: fun(path: string), collapse: fun(path: string) }
@@ -135,7 +135,7 @@ local function _render_in_buffer(opts)
local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path)) local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path))
local path_to_node = tree_inf.path_to_node local path_to_node = tree_inf.path_to_node
--- @param node FsDir --- @param node u.examples.FsDir
--- @param child_names string[] --- @param child_names string[]
local function expand_to(node, child_names) local function expand_to(node, child_names)
if #child_names == 0 then return end if #child_names == 0 then return end
@@ -310,7 +310,7 @@ local function _render_in_buffer(opts)
-- --
local renderer = Renderer.new(opts.bufnr) local renderer = Renderer.new(opts.bufnr)
tracker.create_effect(function() tracker.create_effect(function()
--- @type { tree: FsDir; path_to_node: table<string, FsNode> } --- @type { tree: u.examples.FsDir, path_to_node: table<string, u.examples.FsNode> }
local tree_inf = s_tree_inf:get() local tree_inf = s_tree_inf:get()
local tree = tree_inf.tree local tree = tree_inf.tree
@@ -329,7 +329,7 @@ local function _render_in_buffer(opts)
--- Since the filesystem is a recursive tree of nodes, we need to --- Since the filesystem is a recursive tree of nodes, we need to
--- recursively render each node. This function does just that: --- recursively render each node. This function does just that:
--- @param node FsNode --- @param node u.examples.FsNode
--- @param level number --- @param level number
local function render_node(node, level) local function render_node(node, level)
local name = vim.fs.basename(node.path) local name = vim.fs.basename(node.path)
@@ -407,14 +407,14 @@ end
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @type { --- @type {
--- bufnr: number; --- bufnr: number,
--- winnr: number; --- winnr: number,
--- controller: { expand: fun(path: string), collapse: fun(path: string) }; --- controller: { expand: fun(path: string), collapse: fun(path: string) }
--- } | nil --- } | nil
local current_inf = nil local current_inf = nil
--- Show the filetree: --- Show the filetree:
--- @param opts? ShowOpts --- @param opts? u.examples.ShowOpts
function M.show(opts) function M.show(opts)
if current_inf ~= nil then return current_inf.controller end if current_inf ~= nil then return current_inf.controller end
opts = opts or {} opts = opts or {}
@@ -456,7 +456,7 @@ function M.hide()
end end
--- Toggle the filetree: --- Toggle the filetree:
--- @param opts? ShowOpts --- @param opts? u.examples.ShowOpts
function M.toggle(opts) function M.toggle(opts)
if current_inf == nil then if current_inf == nil then
M.show(opts) M.show(opts)

117
examples/form.lua Normal file
View File

@@ -0,0 +1,117 @@
-- form.lua:
--
-- This is a runnable example of a form. Open this file in Neovim, and execute
-- `:luafile %` to run it. It will create a new buffer to the side, and render
-- an interactive form. Edit the "inputs" between the `[...]` brackets, and
-- watch the buffer react immediately to your changes.
--
local Renderer = require('u.renderer').Renderer
local h = require('u.renderer').h
local tracker = require 'u.tracker'
-- Create a new, temporary, buffer to the side:
vim.cmd.vnew()
vim.bo.buftype = 'nofile'
vim.bo.bufhidden = 'wipe'
vim.bo.buflisted = false
local renderer = Renderer.new()
-- Create two signals:
local s_name = tracker.create_signal 'whoever-you-are'
local s_age = tracker.create_signal 'ideally-a-number'
-- We can create derived information from the signals above. Say we want to do
-- some validation on the input for `age`: we can do that with a memo:
local s_age_info = tracker.create_memo(function()
local age_raw = s_age:get()
local age_digits = age_raw:match '^%s*(%d+)%s*$'
local age_n = age_digits and tonumber(age_digits) or nil
return {
type = age_n and 'number' or 'string',
raw = age_raw,
n = age_n,
n1 = age_n and age_n + 1 or nil,
}
end)
-- This is the render effect that depends on the signals created above. This
-- will re-run every time one of the signals changes.
tracker.create_effect(function()
local name = s_name:get()
local age = s_age:get()
local age_info = s_age_info:get()
-- Each time the signals change, we re-render the buffer:
renderer:render {
h.Type({}, '# Form Example'),
'\n\n',
-- We can also listen for when specific locations in the buffer change, on
-- a tag-by-tag basis. This gives us two-way data-binding between the
-- buffer and the signals.
{
'Name: ',
h.Structure({
on_change = function(text) s_name:set(text) end,
}, name),
},
{
'\nAge: ',
h.Structure({
on_change = function(text) s_age:set(text) end,
}, age),
},
'\n\n',
-- Show the values of the signals here, too, so that we can see the
-- reactivity in action. If you change the values in the tags above, you
-- can see the changes reflected here immediately.
{ 'Hello, "', name, '"!' },
--
-- A more complex example: we can do much more complex rendering, based on
-- the state. For example, if you type different values into the `age`
-- field, you can see not only the displayed information change, but also
-- the color of the highlights in this section will adapt to the type of
-- information that has been detected.
--
-- If string input is detected, values below are shown in the
-- `String`/`ErrorMsg` highlight groups.
--
-- If number input is detected, values below are shown in the `Number`
-- highlight group.
--
-- If a valid number is entered, then this section also displays how old
-- you willl be next year (`n + 1`).
--
'\n\n',
h.Type({}, '## Computed Information (derived from `age`)'),
'\n\n',
{
'Type: ',
h('text', {
hl = age_info.type == 'number' and 'Number' or 'String',
}, age_info.type),
},
{ '\nRaw input: ', h.String({}, '"' .. age_info.raw .. '"') },
{
'\nCurrent age: ',
age_info.n
-- Show the age:
and h.Number({}, tostring(age_info.n))
-- Show an error-placeholder if the age is invalid:
or h.ErrorMsg({}, '(?)'),
},
-- This part is shown conditionally, i.e., only if the age next year can be
-- computed:
age_info.n1
and {
'\nAge next year: ',
h.Number({}, tostring(age_info.n1)),
},
}
end)

View File

@@ -1,8 +1,7 @@
local Buffer = require 'u.buffer' local Renderer = require('u.renderer').Renderer
local TreeBuilder = require('u.renderer').TreeBuilder local TreeBuilder = require('u.renderer').TreeBuilder
local tracker = require 'u.tracker' local tracker = require 'u.tracker'
local utils = require 'u.utils' local utils = require 'u.utils'
local Window = require 'my.window'
local TIMEOUT = 4000 local TIMEOUT = 4000
local ICONS = { local ICONS = {
@@ -14,15 +13,25 @@ local ICONS = {
} }
local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' } local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' }
--- @alias Notification { local S_EDITOR_DIMENSIONS =
--- kind: number; tracker.create_signal(utils.get_editor_dimensions(), 's:editor_dimensions')
--- id: number; vim.api.nvim_create_autocmd('VimResized', {
--- text: string; callback = function()
local new_dim = utils.get_editor_dimensions()
S_EDITOR_DIMENSIONS:set(new_dim)
end,
})
--- @alias u.examples.Notification {
--- kind: number,
--- id: number,
--- text: string,
--- timer: uv.uv_timer_t
--- } --- }
local M = {} local M = {}
--- @type Window | nil --- @type { win: integer, buf: integer, renderer: u.renderer.Renderer } | nil
local notifs_w local notifs_w
local s_notifications_raw = tracker.create_signal {} local s_notifications_raw = tracker.create_signal {}
@@ -30,44 +39,49 @@ local s_notifications = s_notifications_raw:debounce(50)
-- Render effect: -- Render effect:
tracker.create_effect(function() tracker.create_effect(function()
--- @type Notification[] --- @type u.examples.Notification[]
local notifs = s_notifications:get() local notifs = s_notifications:get()
--- @type { width: integer, height: integer }
local editor_size = S_EDITOR_DIMENSIONS:get()
if #notifs == 0 then if #notifs == 0 then
if notifs_w then if notifs_w then
notifs_w:close(true) if vim.api.nvim_win_is_valid(notifs_w.win) then vim.api.nvim_win_close(notifs_w.win, true) end
notifs_w = nil notifs_w = nil
end end
return return
end end
local avail_width = editor_size.width
local float_width = 40
local float_height = math.min(#notifs, editor_size.height - 3)
local win_config = {
relative = 'editor',
anchor = 'NE',
row = 0,
col = avail_width,
width = float_width,
height = float_height,
border = 'single',
focusable = false,
zindex = 900,
}
vim.schedule(function() vim.schedule(function()
local editor_size = utils.get_editor_dimensions()
local avail_width = editor_size.width
local float_width = 40
local win_config = {
relative = 'editor',
anchor = 'NE',
row = 0,
col = avail_width,
width = float_width,
height = math.min(#notifs, editor_size.height - 3),
border = 'single',
focusable = false,
}
if not notifs_w or not vim.api.nvim_win_is_valid(notifs_w.win) then if not notifs_w or not vim.api.nvim_win_is_valid(notifs_w.win) then
notifs_w = Window.new(Buffer.create(false, true), win_config) local b = vim.api.nvim_create_buf(false, true)
vim.wo[notifs_w.win].cursorline = false local w = vim.api.nvim_open_win(b, false, win_config)
vim.wo[notifs_w.win].list = false vim.wo[w].cursorline = false
vim.wo[notifs_w.win].listchars = '' vim.wo[w].list = false
vim.wo[notifs_w.win].number = false vim.wo[w].listchars = ''
vim.wo[notifs_w.win].relativenumber = false vim.wo[w].number = false
vim.wo[notifs_w.win].wrap = false vim.wo[w].relativenumber = false
vim.wo[w].wrap = false
notifs_w = { win = w, buf = b, renderer = Renderer.new(b) }
else else
notifs_w:set_config(win_config) vim.api.nvim_win_set_config(notifs_w.win, win_config)
end end
notifs_w:render(TreeBuilder.new() notifs_w.renderer:render(TreeBuilder.new()
:nest(function(tb) :nest(function(tb)
for idx, notif in ipairs(notifs) do for idx, notif in ipairs(notifs) do
if idx > 1 then tb:put '\n' end if idx > 1 then tb:put '\n' end
@@ -79,48 +93,81 @@ tracker.create_effect(function()
end) end)
:tree()) :tree())
vim.api.nvim_win_call(notifs_w.win, function() vim.api.nvim_win_call(notifs_w.win, function()
-- scroll to bottom: vim.fn.winrestview {
vim.cmd.normal 'G' -- scroll all the way left:
-- scroll all the way to the left: leftcol = 0,
vim.cmd.normal '9999zh' -- set the bottom line to be at the bottom of the window:
topline = vim.api.nvim_buf_line_count(notifs_w.buf) - win_config.height + 1,
}
end) end)
end) end)
end) end)
--- @param id number
local function _delete_notif(id)
--- @param notifs u.examples.Notification[]
s_notifications_raw:schedule_update(function(notifs)
for i, notif in ipairs(notifs) do
if notif.id == id then
notif.timer:stop()
notif.timer:close()
table.remove(notifs, i)
break
end
end
return notifs
end)
end
local _orig_notify local _orig_notify
--- @param msg string --- @param msg string
--- @param level integer|nil --- @param level integer|nil
--- @param opts table|nil --- @param opts? { id: number }
local function my_notify(msg, level, opts) function M.notify(msg, level, opts)
vim.schedule(function() _orig_notify(msg, level, opts) end)
if level == nil then level = vim.log.levels.INFO end if level == nil then level = vim.log.levels.INFO end
if level < vim.log.levels.INFO then return end
local id = math.random(math.huge) opts = opts or {}
local id = opts.id or math.random(999999999)
--- @param notifs Notification[] --- @type u.examples.Notification?
s_notifications_raw:schedule_update(function(notifs) local notif = vim.iter(s_notifications_raw:get()):find(function(n) return n.id == id end)
table.insert(notifs, { kind = level, id = id, text = msg }) if not notif then
return notifs -- Create a new notification (maybe):
end) if vim.trim(msg) == '' then return id end
if level < vim.log.levels.INFO then return id end
vim.defer_fn(function() local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create timer')
--- @param notifs Notification[] timer:start(TIMEOUT, 0, function() _delete_notif(id) end)
notif = {
id = id,
kind = level,
text = msg,
timer = timer,
}
--- @param notifs u.examples.Notification[]
s_notifications_raw:schedule_update(function(notifs) s_notifications_raw:schedule_update(function(notifs)
for i, notif in ipairs(notifs) do table.insert(notifs, notif)
if notif.id == id then
table.remove(notifs, i)
break
end
end
return notifs return notifs
end) end)
end, TIMEOUT) else
-- Update an existing notification:
s_notifications_raw:schedule_update(function(notifs)
-- We already have a copy-by-reference of the notif we want to modify:
notif.timer:stop()
notif.text = msg
notif.kind = level
notif.timer:start(TIMEOUT, 0, function() _delete_notif(id) end)
return notifs
end)
end
return id
end end
local _once_msgs = {} local _once_msgs = {}
local function my_notify_once(msg, level, opts) function M.notify_once(msg, level, opts)
if vim.tbl_contains(_once_msgs, msg) then return false end if vim.tbl_contains(_once_msgs, msg) then return false end
table.insert(_once_msgs, msg) table.insert(_once_msgs, msg)
vim.notify(msg, level, opts) vim.notify(msg, level, opts)
@@ -130,8 +177,8 @@ end
function M.setup() function M.setup()
if _orig_notify == nil then _orig_notify = vim.notify end if _orig_notify == nil then _orig_notify = vim.notify end
vim.notify = my_notify vim.notify = M.notify
vim.notify_once = my_notify_once vim.notify_once = M.notify_once
end end
return M return M

View File

@@ -1,5 +1,5 @@
local utils = require 'u.utils'
local Buffer = require 'u.buffer' local Buffer = require 'u.buffer'
local utils = require 'u.utils'
local Renderer = require('u.renderer').Renderer local Renderer = require('u.renderer').Renderer
local h = require('u.renderer').h local h = require('u.renderer').h
local TreeBuilder = require('u.renderer').TreeBuilder local TreeBuilder = require('u.renderer').TreeBuilder
@@ -44,26 +44,26 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
-- shortest portion of this function. -- shortest portion of this function.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @alias SelectController { --- @alias u.examples.SelectController<T> {
--- get_items: fun(): T[]; --- get_items: fun(): T[],
--- set_items: fun(items: T[]); --- set_items: fun(items: T[]),
--- set_filter_text: fun(filter_text: string); --- set_filter_text: fun(filter_text: string),
--- get_selected_indices: fun(): number[]; --- get_selected_indices: fun(): number[],
--- get_selected_items: fun(): T[]; --- get_selected_items: fun(): T[],
--- set_selected_indices: fun(indicies: number[], ephemeral?: boolean); --- set_selected_indices: fun(indicies: number[], ephemeral?: boolean),
--- close: fun(); --- close: fun()
--- } --- }
--- @alias SelectOpts<T> { --- @alias u.examples.SelectOpts<T> {
--- items: `T`[]; --- items: `T`[],
--- multi?: boolean; --- multi?: boolean,
--- format_item?: fun(item: T): Tree; --- format_item?: (fun(item: T): u.renderer.Tree),
--- on_finish?: fun(items: T[], indicies: number[]); --- on_finish?: (fun(items: T[], indicies: number[])),
--- on_selection_changed?: fun(items: T[], indicies: number[]); --- on_selection_changed?: fun(items: T[], indicies: number[]),
--- mappings?: table<string, fun(select: SelectController)>; --- mappings?: table<string, fun(select: u.examples.SelectController)>
--- } --- }
--- @generic T --- @generic T
--- @param opts SelectOpts<T> --- @param opts u.examples.SelectOpts<T>
function M.create_picker(opts) -- {{{ function M.create_picker(opts) -- {{{
local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i' local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i'
local stopinsert = not is_in_insert_mode local stopinsert = not is_in_insert_mode
@@ -326,7 +326,7 @@ function M.create_picker(opts) -- {{{
safe_wrap(function() safe_wrap(function()
local items = s_items:get() local items = s_items:get()
local selected_indices = s_selected_indices:get() local selected_indices = s_selected_indices:get()
--- @type { orig_idx: number; item: T }[] --- @type { orig_idx: number, item: T }[]
local filtered_items = s_filtered_items:get() local filtered_items = s_filtered_items:get()
local cursor_index = s_cursor_index:get() local cursor_index = s_cursor_index:get()
local indices = shallow_copy_arr(selected_indices) local indices = shallow_copy_arr(selected_indices)
@@ -477,7 +477,7 @@ function M.create_picker(opts) -- {{{
local selected_indices = s_selected_indices:get() local selected_indices = s_selected_indices:get()
local top_offset = s_top_offset:get() local top_offset = s_top_offset:get()
local cursor_index = s_cursor_index:get() local cursor_index = s_cursor_index:get()
--- @type { filtered_idx: number; orig_idx: number; item: T; formatted: string }[] --- @type { filtered_idx: number, orig_idx: number, item: T, formatted: string }[]
local visible_items = s_visible_items:get() local visible_items = s_visible_items:get()
-- The above has to run in the execution context for the signaling to work, but -- The above has to run in the execution context for the signaling to work, but
@@ -557,7 +557,7 @@ function M.create_picker(opts) -- {{{
return safe_run(function() H.finish(true) end) return safe_run(function() H.finish(true) end)
end end
return controller --[[@as SelectController]] return controller --[[@as u.examples.SelectController]]
end -- }}} end -- }}}
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
@@ -791,14 +791,14 @@ function M.buffers() -- {{{
-- ensure that `cwd` ends with a trailing slash: -- ensure that `cwd` ends with a trailing slash:
if cwd[#cwd] ~= '/' then cwd = cwd .. '/' end if cwd[#cwd] ~= '/' then cwd = cwd .. '/' end
--- @type { name: string; changed: number; bufnr: number }[] --- @type { name: string, changed: number, bufnr: number }[]
local bufs = vim.fn.getbufinfo { buflisted = 1 } local bufs = vim.fn.getbufinfo { buflisted = 1 }
M.create_picker { M.create_picker {
multi = true, multi = true,
items = bufs, items = bufs,
--- @param item { name: string; changed: number; bufnr: number } --- @param item { name: string, changed: number, bufnr: number }
format_item = function(item) format_item = function(item)
local item_name = item.name local item_name = item.name
if item_name == '' then item_name = '[No Name]' end if item_name == '' then item_name = '[No Name]' end

View File

@@ -1,6 +1,6 @@
local vim_repeat = require 'u.repeat'
local CodeWriter = require 'u.codewriter' local CodeWriter = require 'u.codewriter'
local Range = require 'u.range' local Range = require 'u.range'
local vim_repeat = require 'u.repeat'
local M = {} local M = {}

View File

@@ -1,7 +1,7 @@
local vim_repeat = require 'u.repeat'
local Range = require 'u.range'
local Buffer = require 'u.buffer' local Buffer = require 'u.buffer'
local CodeWriter = require 'u.codewriter' local CodeWriter = require 'u.codewriter'
local Range = require 'u.range'
local vim_repeat = require 'u.repeat'
local M = {} local M = {}
@@ -21,10 +21,10 @@ local surrounds = {
['`'] = { left = '`', right = '`' }, ['`'] = { left = '`', right = '`' },
} }
--- @type { left: string; right: string } | nil --- @type { left: string, right: string } | nil
local CACHED_BOUNDS = nil local CACHED_BOUNDS = nil
--- @return { left: string; right: string }|nil --- @return { left: string, right: string }|nil
local function prompt_for_bounds() local function prompt_for_bounds()
if vim_repeat.is_repeating() then if vim_repeat.is_repeating() then
-- If we are repeating, we don't want to prompt for bounds, because -- If we are repeating, we don't want to prompt for bounds, because
@@ -55,7 +55,7 @@ local function prompt_for_bounds()
end end
--- @param range u.Range --- @param range u.Range
--- @param bounds { left: string; right: string } --- @param bounds { left: string, right: string }
local function do_surround(range, bounds) local function do_surround(range, bounds)
local left = bounds.left local left = bounds.left
local right = bounds.right local right = bounds.right

View File

@@ -1,7 +1,7 @@
local txtobj = require 'u.txtobj' local Buffer = require 'u.buffer'
local Pos = require 'u.pos' local Pos = require 'u.pos'
local Range = require 'u.range' local Range = require 'u.range'
local Buffer = require 'u.buffer' local txtobj = require 'u.txtobj'
local M = {} local M = {}

1
library/busted Submodule

Submodule library/busted added at 5ed85d0e01

View File

@@ -2,10 +2,10 @@ local Range = require 'u.range'
local Renderer = require('u.renderer').Renderer local Renderer = require('u.renderer').Renderer
--- @class u.Buffer --- @class u.Buffer
--- @field bufnr number --- @field bufnr integer
--- @field b vim.var_accessor --- @field b vim.var_accessor
--- @field bo vim.bo --- @field bo vim.bo
--- @field private renderer u.Renderer --- @field renderer u.renderer.Renderer
local Buffer = {} local Buffer = {}
Buffer.__index = Buffer Buffer.__index = Buffer
@@ -62,19 +62,22 @@ end
function Buffer:lines(start, stop) return Range.from_lines(self.bufnr, start, stop) end function Buffer:lines(start, stop) return Range.from_lines(self.bufnr, start, stop) end
--- @param motion string --- @param motion string
--- @param opts? { contains_cursor?: boolean; pos?: u.Pos } --- @param opts? { contains_cursor?: boolean, pos?: u.Pos }
function Buffer:motion(motion, opts) function Buffer:motion(motion, opts)
opts = vim.tbl_extend('force', opts or {}, { bufnr = self.bufnr }) opts = vim.tbl_extend('force', opts or {}, { bufnr = self.bufnr })
return Range.from_motion(motion, opts) return Range.from_motion(motion, opts)
end end
--- @param event string|string[] --- @param event vim.api.keyset.events|vim.api.keyset.events[]
--- @diagnostic disable-next-line: undefined-doc-name --- @diagnostic disable-next-line: undefined-doc-name
--- @param opts vim.api.keyset.create_autocmd --- @param opts vim.api.keyset.create_autocmd
function Buffer:autocmd(event, opts) function Buffer:autocmd(event, opts)
vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.bufnr })) vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.bufnr }))
end end
--- @param fn function
function Buffer:call(fn) return vim.api.nvim_buf_call(self.bufnr, fn) end
--- @param tree u.renderer.Tree --- @param tree u.renderer.Tree
function Buffer:render(tree) return self.renderer:render(tree) end function Buffer:render(tree) return self.renderer:render(tree) end

View File

@@ -32,7 +32,7 @@ end
--- @param line string --- @param line string
--- @param bufnr? number --- @param bufnr? number
function CodeWriter.from_line(line, bufnr) function CodeWriter.from_line(line, bufnr)
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local ws = line:match '^%s*' local ws = line:match '^%s*'
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr }) local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr })

71
lua/u/extmark.lua Normal file
View File

@@ -0,0 +1,71 @@
local Pos = require 'u.pos'
---@class u.Extmark
---@field bufnr integer
---@field id integer
---@field nsid integer
local Extmark = {}
Extmark.__index = Extmark
--- @param bufnr integer
--- @param nsid integer
--- @param id integer
function Extmark.new(bufnr, nsid, id)
return setmetatable({
bufnr = bufnr,
nsid = nsid,
id = id,
}, Extmark)
end
--- @param range u.Range
--- @param nsid integer
function Extmark.from_range(range, nsid)
local r = range:to_charwise()
local stop = r.stop or r.start
local end_row = stop.lnum - 1
local end_col = stop.col
if range.mode == 'V' then
end_row = end_row + 1
end_col = 0
end
local id = vim.api.nvim_buf_set_extmark(r.start.bufnr, nsid, r.start.lnum - 1, r.start.col - 1, {
right_gravity = false,
end_right_gravity = true,
end_row = end_row,
end_col = end_col,
})
return Extmark.new(r.start.bufnr, nsid, id)
end
function Extmark:range()
local Range = require 'u.range'
local raw_extmark =
vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.nsid, self.id, { details = true })
local start_row0, start_col0, details = unpack(raw_extmark)
--- @type u.Pos
local start = Pos.from00(self.bufnr, start_row0, start_col0)
--- @type u.Pos?
local stop = details
and details.end_row
and details.end_col
and Pos.from01(self.bufnr, details.end_row, details.end_col)
local n_buf_lines = vim.api.nvim_buf_line_count(self.bufnr)
if stop and stop.lnum > n_buf_lines then
stop.lnum = n_buf_lines
stop = stop:eol()
end
if stop and stop.col == 0 then
stop.col = 1
stop = stop:next(-1)
end
return Range.new(start, stop, 'v')
end
function Extmark:delete() vim.api.nvim_buf_del_extmark(self.bufnr, self.nsid, self.id) end
return Extmark

View File

@@ -1,9 +1,9 @@
local M = {} local M = {}
local LOG_ROOT = vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log')
--- @params name string --- @params name string
function M.file_for_name(name) function M.file_for_name(name) return vim.fs.joinpath(LOG_ROOT, name .. '.log.jsonl') end
return vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log', name .. '.log.jsonl')
end
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- Logger class -- Logger class
@@ -11,7 +11,6 @@ end
--- @class u.Logger --- @class u.Logger
--- @field name string --- @field name string
--- @field private fd number
local Logger = {} local Logger = {}
Logger.__index = Logger Logger.__index = Logger
M.Logger = Logger M.Logger = Logger
@@ -20,10 +19,7 @@ M.Logger = Logger
function Logger.new(name) function Logger.new(name)
local file_path = M.file_for_name(name) local file_path = M.file_for_name(name)
vim.fn.mkdir(vim.fs.dirname(file_path), 'p') vim.fn.mkdir(vim.fs.dirname(file_path), 'p')
local self = setmetatable({ local self = setmetatable({ name = name }, Logger)
name = name,
fd = (vim.uv or vim.loop).fs_open(file_path, 'a', tonumber('644', 8)),
}, Logger)
return self return self
end end
@@ -32,10 +28,12 @@ end
function Logger:write(level, ...) function Logger:write(level, ...)
local data = { ... } local data = { ... }
if #data == 1 then data = data[1] end if #data == 1 then data = data[1] end
(vim.uv or vim.loop).fs_write( local f = assert(io.open(M.file_for_name(self.name), 'a'), 'could not open file')
self.fd, assert(
vim.json.encode { ts = os.date(), level = level, data = data } .. '\n' f:write(vim.json.encode { ts = os.date(), level = level, data = data } .. '\n'),
'could not write to file'
) )
f:close()
end end
function Logger:trace(...) self:write('INFO', ...) end function Logger:trace(...) self:write('INFO', ...) end
@@ -64,6 +62,12 @@ function M.setup()
vim.cmd.terminal('tail -f "' .. log_file_path .. '"') vim.cmd.terminal('tail -f "' .. log_file_path .. '"')
vim.cmd.startinsert() vim.cmd.startinsert()
end, { nargs = '*' }) end, { nargs = '*' })
vim.api.nvim_create_user_command(
'Logroot',
function() vim.api.nvim_echo({ { LOG_ROOT } }, false, {}) end,
{}
)
end end
return M return M

View File

@@ -5,7 +5,6 @@ local __U__OpKeymapOpFunc_rhs = nil
--- This is the global utility function used for operatorfunc --- This is the global utility function used for operatorfunc
--- in opkeymap --- in opkeymap
--- @type nil|fun(range: u.Range): fun():any|nil
--- @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

@@ -7,9 +7,9 @@ local function line_text(bufnr, lnum)
end end
--- @class u.Pos --- @class u.Pos
--- @field bufnr number buffer number --- @field bufnr integer buffer number
--- @field lnum number 1-based line index --- @field lnum integer 1-based line index
--- @field col number 1-based column index --- @field col integer 1-based column index
--- @field off number --- @field off number
local Pos = {} local Pos = {}
Pos.__index = Pos Pos.__index = Pos
@@ -31,16 +31,33 @@ end
function Pos.new(bufnr, lnum, col, off) function Pos.new(bufnr, lnum, col, off)
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
if off == nil then off = 0 end if off == nil then off = 0 end
local pos = { --- @type u.Pos
return setmetatable({
bufnr = bufnr, bufnr = bufnr,
lnum = lnum, lnum = lnum,
col = col, col = col,
off = off, off = off,
} }, Pos)
setmetatable(pos, Pos)
return pos
end end
--- @param bufnr? number
--- @param lnum0 number 1-based
--- @param col0 number 1-based
--- @param off? number
function Pos.from00(bufnr, lnum0, col0, off) return Pos.new(bufnr, lnum0 + 1, col0 + 1, off) end
--- @param bufnr? number
--- @param lnum0 number 1-based
--- @param col1 number 1-based
--- @param off? number
function Pos.from01(bufnr, lnum0, col1, off) return Pos.new(bufnr, lnum0 + 1, col1, off) end
--- @param bufnr? number
--- @param lnum1 number 1-based
--- @param col0 number 1-based
--- @param off? number
function Pos.from10(bufnr, lnum1, col0, off) return Pos.new(bufnr, lnum1, col0 + 1, off) end
function Pos.invalid() return Pos.new(0, 0, 0, 0) end function Pos.invalid() return Pos.new(0, 0, 0, 0) end
function Pos.__lt(a, b) return a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col) end function Pos.__lt(a, b) return a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col) end
@@ -67,6 +84,15 @@ function Pos.__sub(x, y)
return x:next(-y) return x:next(-y)
end end
--- @param bufnr number
--- @param lnum number
function Pos.from_eol(bufnr, lnum)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local pos = Pos.new(bufnr, lnum, 0)
pos.col = pos:line():len()
return pos
end
--- @param name string --- @param name string
--- @return u.Pos --- @return u.Pos
function Pos.from_pos(name) function Pos.from_pos(name)
@@ -96,9 +122,16 @@ end
function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end
function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end
--- @param pos string --- @param pos string
function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end
--- @param winnr? integer
function Pos:save_to_cursor(winnr)
vim.api.nvim_win_set_cursor(winnr or 0, { self.lnum, self.col - 1 })
end
--- @param mark string --- @param mark string
function Pos:save_to_mark(mark) function Pos:save_to_mark(mark)
local p = self:as_real() local p = self:as_real()

View File

@@ -1,14 +1,8 @@
local Extmark = require 'u.extmark'
local Pos = require 'u.pos' local Pos = require 'u.pos'
-- Certain functions in the Range class yank text. In order to prevent unwanted local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
-- highlighting, we intercept and discard some calls to the `on_yank` callback. local NS = vim.api.nvim_create_namespace 'u.range'
local orig_on_yank = vim.hl.on_yank
local on_yank_enabled = true
--- @diagnostic disable-next-line: duplicate-set-field
function vim.hl.on_yank(opts)
if not on_yank_enabled then return end
return orig_on_yank(opts)
end
--- @class u.Range --- @class u.Range
--- @field start u.Pos --- @field start u.Pos
@@ -118,12 +112,12 @@ function Range.from_lines(bufnr, start_line, stop_line)
end end
--- @param motion string --- @param motion string
--- @param opts? { bufnr?: number; contains_cursor?: boolean; pos?: u.Pos, user_defined?: boolean } --- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean }
--- @return u.Range|nil --- @return u.Range|nil
function Range.from_motion(motion, opts) function Range.from_motion(motion, opts)
-- Options handling: -- Options handling:
opts = opts or {} opts = opts or {}
if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end if opts.bufnr == nil or opts.bufnr == 0 then opts.bufnr = vim.api.nvim_get_current_buf() end
if opts.contains_cursor == nil then opts.contains_cursor = false end if opts.contains_cursor == nil then opts.contains_cursor = false end
if opts.user_defined == nil then opts.user_defined = false end if opts.user_defined == nil then opts.user_defined = false end
@@ -133,10 +127,6 @@ function Range.from_motion(motion, opts)
local is_txtobj = scope == 'a' or scope == 'i' local is_txtobj = scope == 'a' or scope == 'i'
local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest) local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest)
--- @type u.Pos
local start
--- @type u.Pos
local stop
-- Capture the original state of the buffer for restoration later. -- Capture the original state of the buffer for restoration later.
local original_state = { local original_state = {
winview = vim.fn.winsaveview(), winview = vim.fn.winsaveview(),
@@ -144,59 +134,88 @@ function Range.from_motion(motion, opts)
cursor = vim.fn.getpos '.', cursor = vim.fn.getpos '.',
pos_lbrack = vim.fn.getpos "'[", pos_lbrack = vim.fn.getpos "'[",
pos_rbrack = vim.fn.getpos "']", pos_rbrack = vim.fn.getpos "']",
opfunc = vim.go.operatorfunc,
prev_captured_range = _G.Range__from_motion_opfunc_captured_range,
prev_mode = vim.fn.mode(),
vinf = Range.from_vtext(),
} }
--- @type u.Range|nil
_G.Range__from_motion_opfunc_captured_range = nil
vim.api.nvim_buf_call(opts.bufnr, function() vim.api.nvim_buf_call(opts.bufnr, function()
if opts.pos ~= nil then opts.pos:save_to_pos '.' end if opts.pos ~= nil then opts.pos:save_to_pos '.' end
Pos.invalid():save_to_pos "'[" _G.Range__from_motion_opfunc = function(ty)
Pos.invalid():save_to_pos "']" _G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
end
local prev_on_yank_enabled = on_yank_enabled local old_eventignore = vim.o.eventignore
on_yank_enabled = false vim.o.eventignore = 'all'
vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc'
vim.cmd { vim.cmd {
cmd = 'normal', cmd = 'normal',
bang = not opts.user_defined, bang = not opts.user_defined,
args = { '""y' .. motion }, args = { ESC .. 'g@' .. motion },
mods = { silent = true }, mods = { silent = true },
} }
on_yank_enabled = prev_on_yank_enabled vim.o.eventignore = old_eventignore
start = Pos.from_pos "'["
stop = Pos.from_pos "']"
end) end)
local captured_range = _G.Range__from_motion_opfunc_captured_range
-- Restore original state: -- Restore original state:
vim.fn.winrestview(original_state.winview) vim.fn.winrestview(original_state.winview)
vim.fn.setreg('"', original_state.regquote) vim.fn.setreg('"', original_state.regquote)
vim.fn.setpos('.', original_state.cursor) vim.fn.setpos('.', original_state.cursor)
vim.fn.setpos("'[", original_state.pos_lbrack) vim.fn.setpos("'[", original_state.pos_lbrack)
vim.fn.setpos("']", original_state.pos_rbrack) vim.fn.setpos("']", original_state.pos_rbrack)
if original_state.prev_mode ~= 'n' then original_state.vinf:set_visual_selection() end
vim.go.operatorfunc = original_state.opfunc
_G.Range__from_motion_opfunc_captured_range = original_state.prev_captured_range
if start == stop and start:is_invalid() then return nil end if not captured_range then return nil end
-- Fixup the bounds: -- Fixup the bounds:
if if
-- I have no idea why, but when yanking `i"`, the stop-mark is -- I have no idea why, but when yanking `i"`, the stop-mark is
-- placed on the ending quote. For other text-objects, the stop- -- placed on the ending quote. For other text-objects, the stop-
-- mark is placed before the closing character. -- mark is placed before the closing character.
(is_quote_txtobj and scope == 'i' and stop:char() == motion_rest) (is_quote_txtobj and scope == 'i' and captured_range.stop:char() == motion_rest)
-- *Sigh*, this also sometimes happens for `it` as well. -- *Sigh*, this also sometimes happens for `it` as well.
or (motion == 'it' and stop:char() == '<') or (motion == 'it' and captured_range.stop:char() == '<')
then then
stop = stop:next(-1) or stop captured_range.stop = captured_range.stop:next(-1) or captured_range.stop
end end
if is_quote_txtobj and scope == 'a' then if is_quote_txtobj and scope == 'a' then
start = start:find_next(1, motion_rest) or start captured_range.start = captured_range.start:find_next(1, motion_rest) or captured_range.start
stop = stop:find_next(-1, motion_rest) or stop captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop
end end
if if
opts.contains_cursor opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor)))
and not Range.new(start, stop):contains(Pos.new(unpack(original_state.cursor)))
then then
return nil return nil
end end
return Range.new(start, stop) return captured_range
end
--- @param opts? { contains_cursor?: boolean }
function Range.from_tsquery_caps(bufnr, query, opts)
opts = opts or { contains_cursor = true }
local ranges = Range.from_buf_text(bufnr):tsquery(query)
if not ranges then return end
if not opts.contains_cursor then return ranges end
local cursor = Pos.from_pos '.'
return vim.tbl_map(function(cap_ranges)
return vim
.iter(cap_ranges)
:filter(
--- @param r u.Range
function(r) return r:contains(cursor) end
)
:totable()
end, ranges)
end end
--- Get range information from the currently selected visual text. --- Get range information from the currently selected visual text.
@@ -226,19 +245,22 @@ end
--- @param args unknown --- @param args unknown
--- @return u.Range|nil --- @return u.Range|nil
function Range.from_cmd_args(args) function Range.from_cmd_args(args)
--- @type 'v'|'V' if args.range == 0 then return nil end
local mode
--- @type nil|u.Pos local bufnr = vim.api.nvim_get_current_buf()
local start if args.range == 1 then
local stop return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, Pos.MAX_COL), 'V')
if args.range == 0 then end
return nil
else local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]]
start = Pos.from_pos "'<" --- @type 'v'|'V'
stop = Pos.from_pos "'>" local mode = is_visual and vim.fn.visualmode() or 'V'
mode = stop:is_col_max() and 'V' or 'v'
if is_visual then
return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode)
else
return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, Pos.MAX_COL), mode)
end end
return Range.new(start, stop, mode)
end end
function Range.find_nearest_brackets() function Range.find_nearest_brackets()
@@ -278,6 +300,13 @@ function Range:to_linewise()
return r return r
end end
function Range:to_charwise()
local r = self:clone()
r.mode = 'v'
if r.stop:is_col_max() then r.stop = r.stop:as_real() end
return r
end
--- @param x u.Pos | u.Range --- @param x u.Pos | u.Range
function Range:contains(x) function Range:contains(x)
if getmetatable(x) == Pos then if getmetatable(x) == Pos then
@@ -319,27 +348,19 @@ end
--- @param left string --- @param left string
--- @param right string --- @param right string
function Range:save_to_pos(left, right) function Range:save_to_pos(left, right)
if self:is_empty() then self.start:save_to_pos(left);
self.start:save_to_pos(left) (self:is_empty() and self.start or self.stop):save_to_pos(right)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
end end
--- @param left string --- @param left string
--- @param right string --- @param right string
function Range:save_to_marks(left, right) function Range:save_to_marks(left, right)
if self:is_empty() then self.start:save_to_mark(left);
self.start:save_to_mark(left) (self:is_empty() and self.start or self.stop):save_to_mark(right)
self.start:save_to_mark(right)
else
self.start:save_to_mark(left)
self.stop:save_to_mark(right)
end
end end
function Range:save_to_extmark() return Extmark.from_range(self, NS) end
function Range:set_visual_selection() function Range:set_visual_selection()
if self:is_empty() then return end if self:is_empty() then return end
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
@@ -354,14 +375,46 @@ function Range:set_visual_selection()
self.stop:save_to_pos '.' self.stop:save_to_pos '.'
end end
--------------------------------------------------------------------------------
-- Range.from_* functions:
--------------------------------------------------------------------------------
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- Text access/manipulation utilities: -- Text access/manipulation utilities:
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @param query string
function Range:tsquery(query)
local bufnr = self.start.bufnr
local lang = vim.treesitter.language.get_lang(vim.bo[bufnr].filetype)
if lang == nil then return end
local parser = vim.treesitter.get_parser(bufnr, lang)
if parser == nil then return end
local tree = parser:parse()[1]
if tree == nil then return end
local root = tree:root()
local q = vim.treesitter.query.parse(lang, query)
--- @type table<string, u.Range[]>
local ranges = {}
for id, match, _meta in
q:iter_captures(root, bufnr, self.start.lnum - 1, (self.stop or self.start).lnum)
do
local start_row0, start_col0, stop_row0, stop_col0 = match:range()
local range = Range.new(
Pos.new(bufnr, start_row0 + 1, start_col0 + 1),
Pos.new(bufnr, stop_row0 + 1, stop_col0),
'v'
)
if range.stop.lnum > vim.api.nvim_buf_line_count(bufnr) then
range.stop = range.stop:must_next(-1)
end
local capture_name = q.captures[id]
if not ranges[capture_name] then ranges[capture_name] = {} end
if self:contains(range) then table.insert(ranges[capture_name], range) end
end
return ranges
end
function Range:length() function Range:length()
if self:is_empty() then return 0 end if self:is_empty() then return 0 end
@@ -460,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

File diff suppressed because it is too large Load Diff

View File

@@ -6,19 +6,19 @@ M.debug = false
-- class Signal -- class Signal
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @class u.Signal --- @class u.Signal<T>
--- @field name? string --- @field name? string
--- @field private changing boolean --- @field private changing boolean
--- @field private value any --- @field private value T
--- @field private subscribers table<function, boolean> --- @field private subscribers table<function, boolean>
--- @field private on_dispose_callbacks function[] --- @field private on_dispose_callbacks function[]
local Signal = {} local Signal = {}
M.Signal = Signal M.Signal = Signal
Signal.__index = Signal Signal.__index = Signal
--- @param value any --- @param value `T`
--- @param name? string --- @param name? string
--- @return u.Signal --- @return u.Signal<T>
function Signal:new(value, name) function Signal:new(value, name)
local obj = setmetatable({ local obj = setmetatable({
name = name, name = name,
@@ -30,7 +30,7 @@ function Signal:new(value, name)
return obj return obj
end end
--- @param value any --- @param value T
function Signal:set(value) function Signal:set(value)
self.value = value self.value = value
@@ -67,11 +67,12 @@ function Signal:set(value)
end end
end end
--- @param value T
function Signal:schedule_set(value) function Signal:schedule_set(value)
vim.schedule(function() self:set(value) end) vim.schedule(function() self:set(value) end)
end end
--- @return any --- @return T
function Signal:get() function Signal:get()
local ctx = M.ExecutionContext.current() local ctx = M.ExecutionContext.current()
if ctx then ctx:track(self) end if ctx then ctx:track(self) end
@@ -85,8 +86,8 @@ function Signal:update(fn) self:set(fn(self.value)) end
function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end
--- @generic U --- @generic U
--- @param fn fun(value: T): U --- @param fn fun(value: T): `U`
--- @return u.Signal --<U> --- @return u.Signal<U>
function Signal:map(fn) function Signal:map(fn)
local mapped_signal = M.create_memo(function() local mapped_signal = M.create_memo(function()
local value = self:get() local value = self:get()
@@ -95,13 +96,13 @@ function Signal:map(fn)
return mapped_signal return mapped_signal
end end
--- @return u.Signal --- @return u.Signal<T>
function Signal:clone() function Signal:clone()
return self:map(function(x) return x end) return self:map(function(x) return x end)
end end
--- @param fn fun(value: T): boolean --- @param fn fun(value: T): boolean
--- @return u.Signal -- <T> --- @return u.Signal<T>
function Signal:filter(fn) function Signal:filter(fn)
local filtered_signal = M.create_signal(nil, self.name and self.name .. ':filtered' or nil) local filtered_signal = M.create_signal(nil, self.name and self.name .. ':filtered' or nil)
local unsubscribe_from_self = self:subscribe(function(value) local unsubscribe_from_self = self:subscribe(function(value)
@@ -112,10 +113,10 @@ function Signal:filter(fn)
end end
--- @param ms number --- @param ms number
--- @return u.Signal -- <T> --- @return u.Signal<T>
function Signal:debounce(ms) function Signal:debounce(ms)
local function set_timeout(timeout, callback) local function set_timeout(timeout, callback)
local timer = (vim.uv or vim.loop).new_timer() local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create new timer')
timer:start(timeout, 0, function() timer:start(timeout, 0, function()
timer:stop() timer:stop()
timer:close() timer:close()
@@ -127,7 +128,7 @@ function Signal:debounce(ms)
local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil) local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil)
--- @diagnostic disable-next-line: undefined-doc-name --- @diagnostic disable-next-line: undefined-doc-name
--- @type { queued: { value: T, ts: number }[]; timer?: uv_timer_t; } --- @type { queued: { value: T, ts: number }[], timer?: uv.uv_timer_t }
local state = { queued = {}, timer = nil } local state = { queued = {}, timer = nil }
local function clear_timeout() local function clear_timeout()
if state.timer == nil then return end if state.timer == nil then return end
@@ -202,6 +203,7 @@ end
-- class ExecutionContext -- class ExecutionContext
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @type u.ExecutionContext|nil
local CURRENT_CONTEXT = nil local CURRENT_CONTEXT = nil
--- @class u.ExecutionContext --- @class u.ExecutionContext
@@ -262,16 +264,18 @@ end
-- Helpers -- Helpers
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @param value any --- @generic T
--- @param value `T`
--- @param name? string --- @param name? string
--- @return u.Signal --- @return u.Signal<T>
function M.create_signal(value, name) return Signal:new(value, name) end function M.create_signal(value, name) return Signal:new(value, name) end
--- @param fn function --- @generic T
--- @param fn fun(): `T`
--- @param name? string --- @param name? string
--- @return u.Signal --- @return u.Signal
function M.create_memo(fn, name) function M.create_memo(fn, name)
--- @type u.Signal --- @type u.Signal<T> | nil
local result local result
local unsubscribe = M.create_effect(function() local unsubscribe = M.create_effect(function()
local value = fn() local value = fn()
@@ -282,8 +286,8 @@ function M.create_memo(fn, name)
result = M.create_signal(value, name and ('m.s:' .. name) or nil) result = M.create_signal(value, name and ('m.s:' .. name) or nil)
end end
end, name) end, name)
result:on_dispose(unsubscribe) assert(result):on_dispose(unsubscribe)
return result return assert(result)
end end
--- @param fn function --- @param fn function

View File

@@ -4,15 +4,63 @@ local M = {}
-- Types -- Types
-- --
--- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string } --- @class u.utils.QfItem
--- @alias KeyMaps table<string, fun(): any | string> } --- @field col number
-- luacheck: ignore --- @field filename string
--- @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: u.Range|nil } --- @field kind string
--- @field lnum number
--- @field text string
--- @class u.utils.RawCmdArgs
--- @field args string
--- @field bang boolean
--- @field count number
--- @field fargs string[]
--- @field line1 number
--- @field line2 number
--- @field mods string
--- @field name string
--- @field range 0|1|2
--- @field reg string
--- @field smods any
--- @class u.utils.CmdArgs: u.utils.RawCmdArgs
--- @field info u.Range|nil
--- @class u.utils.UcmdArgs
--- @field nargs? 0|1|'*'|'?'|'+'
--- @field range? boolean|'%'|number
--- @field count? boolean|number
--- @field addr? string
--- @field completion? string
--- @field force? boolean
--- @field preview? fun(opts: u.utils.UcmdArgs, ns: integer, buf: integer):0|1|2
--
-- Functions
--
--- Debug utility that prints a value and returns it unchanged.
--- Useful for debugging in the middle of expressions or function chains.
---
--- @generic T --- @generic T
--- @param x `T` --- @param x `T` The value to debug print
--- @param message? string --- @param message? string Optional message to print alongside the value
--- @return T --- @return T The original value, unchanged
---
--- @usage
--- ```lua
--- -- Debug a value in the middle of a chain:
--- local result = some_function()
--- :map(utils.dbg) -- prints the intermediate value
--- :filter(predicate)
---
--- -- Debug with a custom message:
--- local config = utils.dbg(get_config(), "Current config:")
---
--- -- Debug return values:
--- return utils.dbg(calculate_result(), "Final result")
--- ```
function M.dbg(x, message) function M.dbg(x, message)
local t = {} local t = {}
if message ~= nil then table.insert(t, message) end if message ~= nil then table.insert(t, message) end
@@ -21,22 +69,37 @@ function M.dbg(x, message)
return x return x
end end
--- A utility for creating user commands that also pre-computes useful information --- Creates a user command with enhanced argument processing.
--- and attaches it to the arguments. --- Automatically computes range information and attaches it as `args.info`.
--- ---
--- @param name string The command name (without the leading colon)
--- @param cmd string | fun(args: u.utils.CmdArgs): any Command implementation
--- @param opts? u.utils.UcmdArgs Command options (nargs, range, etc.)
---
--- @usage
--- ```lua --- ```lua
--- -- Example: --- -- Create a command that works with visual selections:
--- ucmd('MyCmd', function(args) --- utils.ucmd('MyCmd', function(args)
--- -- print the visually selected text: --- -- Print the visually selected text:
--- vim.print(args.info:text()) --- vim.print(args.info:text())
--- -- or get the vtext as an array of lines: --- -- Or get the selection as an array of lines:
--- vim.print(args.info:lines()) --- vim.print(args.info:lines())
--- end, { nargs = '*', range = true }) --- end, { nargs = '*', range = true })
---
--- -- Create a command that processes the current line:
--- utils.ucmd('ProcessLine', function(args)
--- local line_text = args.info:text()
--- -- Process the line...
--- end, { range = '%' })
---
--- -- Create a command with arguments:
--- utils.ucmd('SearchReplace', function(args)
--- local pattern, replacement = args.fargs[1], args.fargs[2]
--- local text = args.info:text()
--- -- Perform search and replace...
--- end, { nargs = 2, range = true })
--- ``` --- ```
--- @param name string
--- @param cmd string | fun(args: CmdArgs): any
-- luacheck: ignore -- luacheck: ignore
--- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
function M.ucmd(name, cmd, opts) function M.ucmd(name, cmd, opts)
local Range = require 'u.range' local Range = require 'u.range'
@@ -48,9 +111,81 @@ function M.ucmd(name, cmd, opts)
return cmd(args) return cmd(args)
end end
end end
vim.api.nvim_create_user_command(name, cmd2, opts or {}) vim.api.nvim_create_user_command(name, cmd2, opts or {} --[[@as any]])
end end
--- Creates command arguments for delegating from one command to another.
--- Preserves all relevant context (range, modifiers, bang, etc.) when
--- implementing a derived command in terms of a base command.
---
--- @param current_args vim.api.keyset.create_user_command.command_args|u.utils.RawCmdArgs The arguments from the current command
--- @return vim.api.keyset.cmd Arguments suitable for vim.cmd() calls
---
--- @usage
--- ```lua
--- -- Implement :MyEdit in terms of :edit, preserving all context:
--- utils.ucmd('MyEdit', function(args)
--- local delegated_args = utils.create_delegated_cmd_args(args)
--- -- Add custom logic here...
--- vim.cmd.edit(delegated_args)
--- end, { nargs = '*', range = true, bang = true })
---
--- -- Implement :MySubstitute that delegates to :substitute:
--- utils.ucmd('MySubstitute', function(args)
--- -- Pre-process arguments
--- local pattern = preprocess_pattern(args.fargs[1])
---
--- local delegated_args = utils.create_delegated_cmd_args(args)
--- delegated_args.args = { pattern, args.fargs[2] }
---
--- vim.cmd.substitute(delegated_args)
--- end, { nargs = 2, range = true, bang = true })
--- ```
function M.create_delegated_cmd_args(current_args)
--- @type vim.api.keyset.cmd
local args = {
range = current_args.range == 1 and { current_args.line1 }
or current_args.range == 2 and { current_args.line1, current_args.line2 }
or nil,
count = (current_args.count ~= -1 and current_args.range == 0) and current_args.count or nil,
reg = current_args.reg ~= '' and current_args.reg or nil,
bang = current_args.bang or nil,
args = #current_args.fargs > 0 and current_args.fargs or nil,
mods = current_args.smods,
}
return args
end
--- Gets the current editor dimensions.
--- Useful for positioning floating windows or calculating layout sizes.
---
--- @return { width: number, height: number } The editor dimensions in columns and lines
---
--- @usage
--- ```lua
--- -- Center a floating window:
--- local dims = utils.get_editor_dimensions()
--- local win_width = 80
--- local win_height = 20
--- local col = math.floor((dims.width - win_width) / 2)
--- local row = math.floor((dims.height - win_height) / 2)
---
--- vim.api.nvim_open_win(bufnr, true, {
--- relative = 'editor',
--- width = win_width,
--- height = win_height,
--- col = col,
--- row = row,
--- })
---
--- -- Check if editor is wide enough for side-by-side layout:
--- local dims = utils.get_editor_dimensions()
--- if dims.width >= 160 then
--- -- Use side-by-side layout
--- else
--- -- Use stacked layout
--- end
--- ```
function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
return M return M

View File

@@ -1,19 +0,0 @@
package = "u.nvim"
version = "0.2.0"
lua = ">=5.1"
[description]
summary = ""
maintainer = "jrop"
labels = [ "library", "neovim", "neovim-plugin", "range", "utility" ]
[dependencies]
# Add your dependencies here
# `busted = ">=2.0"`
[run]
args = [ "src/main.lua" ]
[build]
type = "builtin"

View File

@@ -1,4 +0,0 @@
std = "vim"
[lints]
multiple_statements = "allow"

23
shell.nix Normal file
View File

@@ -0,0 +1,23 @@
{
pkgs ?
import
# nixos-unstable (neovim@0.11.4):
(fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/0b4defa2584313f3b781240b29d61f6f9f7e0df3.tar.gz";
sha256 = "0p3rrd8wwlk0iwgzm7frkw1k98ywrh0avi7fqjjk87i68n3inxrs";
})
{ },
}:
pkgs.mkShell {
packages = [
pkgs.git
pkgs.gnumake
pkgs.emmylua-check
pkgs.lua51Packages.busted
pkgs.lua51Packages.luacov
pkgs.lua51Packages.luarocks
pkgs.neovim
pkgs.stylua
pkgs.watchexec
];
}

View File

@@ -1,5 +1,5 @@
local Range = require 'u.range'
local Pos = require 'u.pos' local Pos = require 'u.pos'
local Range = require 'u.range'
local withbuf = loadfile './spec/withbuf.lua'() local withbuf = loadfile './spec/withbuf.lua'()
describe('Range', function() describe('Range', function()
@@ -238,6 +238,128 @@ describe('Range', function()
end) end)
end) end)
it('from_tsquery_caps', function()
withbuf({
'-- a comment',
'',
'function foo(bar) end',
'',
'-- a middle comment',
'',
'function bar(baz) end',
'',
'-- another comment',
}, function()
vim.cmd.setfiletype 'lua'
--- @param contains_cursor? boolean
local function get_caps(contains_cursor)
if contains_cursor == nil then contains_cursor = true end
return (Range.from_tsquery_caps(
0,
'(function_declaration) @f',
{ contains_cursor = contains_cursor }
)).f or {}
end
local caps = get_caps(false)
assert.are.same(#caps, 2)
assert.are.same(vim.iter(caps):map(function(c) return c:text() end):totable(), {
'function foo(bar) end',
'function bar(baz) end',
})
Pos.new(0, 1, 1):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 0)
Pos.new(0, 3, 18):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 1)
assert.are.same(caps[1]:text(), 'function foo(bar) end')
Pos.new(0, 5, 1):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 0)
Pos.new(0, 7, 1):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 1)
assert.are.same(caps[1]:text(), 'function bar(baz) end')
end)
end)
it('from_tsquery_caps with string array filter', function()
withbuf({
'{',
' sample_key1 = "sample-value1",',
' sample_key2 = "sample-value2",',
'}',
}, function()
vim.cmd.setfiletype 'lua'
-- Place cursor in "sample-value1"
Pos.new(0, 2, 25):save_to_pos '.'
-- Query that captures both keys and values in pairs
local query = [[
(field
name: _ @key
value: _ @value)
]]
local ranges = Range.from_line(0, 2):tsquery(query)
-- Should have both @key and @value captures for the first pair only
-- (since cursor is in sample-value1)
assert(ranges, 'Range should not be nil')
assert(ranges.key, 'Range.key should not be nil')
assert(ranges.value, 'Range.value should not be nil')
-- Should have exactly one key and one value
assert.are.same(#ranges.key, 1)
assert.are.same(#ranges.value, 1)
-- Check that we got sample-key1 and sample-value1
assert.are.same(ranges.key[1]:text(), 'sample_key1')
assert.are.same(ranges.value[1]:text(), '"sample-value1"')
end)
-- Make sure this works when the match is on the last line:
withbuf({
'{sample_key1= "sample-value1",',
'sample_key2= "sample-value2"}',
}, function()
vim.cmd.setfiletype 'lua'
-- Place cursor in "sample-value1"
Pos.new(0, 2, 25):save_to_pos '.'
-- Query that captures both keys and values in pairs
local query = [[
(field
name: _ @key
value: _ @value)
]]
local ranges = Range.from_line(0, 2):tsquery(query)
-- Should have both @key and @value captures for the first pair only
-- (since cursor is in sample-value1)
assert(ranges, 'Range should not be nil')
assert(ranges.key, 'Range.key should not be nil')
assert(ranges.value, 'Range.value should not be nil')
-- Should have exactly one key and one value
assert.are.same(#ranges.key, 1)
assert.are.same(#ranges.value, 1)
-- Check that we got sample-key2 and sample-value2
assert.are.same(ranges.key[1]:text(), 'sample_key2')
assert.are.same(ranges.value[1]:text(), '"sample-value2"')
end)
end)
it('should get nearest block', function() it('should get nearest block', function()
withbuf({ withbuf({
'this is a {', 'this is a {',
@@ -318,18 +440,89 @@ describe('Range', function()
end) end)
end) end)
it('from_cmd_args', function() it('from_cmd_args: range=0', function()
local args = { range = 1 } local args = { range = 0 }
withbuf(
{ 'line one', 'and line two' },
function() assert.are.same(Range.from_cmd_args(args), nil) end
)
end)
it('from_cmd_args: range=1', function()
local args = { range = 1, line1 = 1 }
withbuf({ 'line one', 'and line two' }, function() withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 1, 1) local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 2, 2) local b = Pos.new(nil, 1, Pos.MAX_COL)
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'V')
assert.are.same(range:text(), 'line one')
end)
end)
it('from_cmd_args: range=2: no-visual', function()
local args = { range = 2, line1 = 1, line2 = 2 }
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, Pos.new(nil, 1, 1))
assert.are.same(range.stop, Pos.new(nil, 2, Pos.MAX_COL))
assert.are.same(range.mode, 'V')
assert.are.same(range:text(), 'line one\nand line two')
end)
end)
it('from_cmd_args: range=2: visual: linewise', function()
local args = { range = 2, line1 = 1, line2 = 2 }
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 2, Pos.MAX_COL)
a:save_to_pos "'<" a:save_to_pos "'<"
b:save_to_pos "'>" b:save_to_pos "'>"
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'V')
assert.are.same(range:text(), 'line one\nand line two')
end)
end)
local range = Range.from_cmd_args(args) it('from_cmd_args: range=2: visual: charwise', function()
local args = { range = 2, line1 = 1, line2 = 1 }
withbuf({ 'line one', 'and line two' }, function()
-- Simulate a visual selection:
local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 1, 4)
a:save_to_pos "'<"
b:save_to_pos "'>"
Range.new(a, b, 'v'):set_visual_selection()
assert.are.same(vim.fn.mode(), 'v')
-- In this simulated setup, we need to force visualmode() to return
-- 'v' and histget() to return [['<,'>]]:
-- visualmode()
local orig_visualmode = vim.fn.visualmode
--- @diagnostic disable-next-line: duplicate-set-field
function vim.fn.visualmode() return 'v' end
assert.are.same(vim.fn.visualmode(), 'v')
-- histget()
local orig_histget = vim.fn.histget
--- @diagnostic disable-next-line: duplicate-set-field
function vim.fn.histget(x, y) return [['<,'>]] end
-- Now run the test:
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, a) assert.are.same(range.start, a)
assert.are.same(range.stop, b) assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v') assert.are.same(range.mode, 'v')
assert.are.same(range:text(), 'line')
-- Reset visualmode() and histget():
vim.fn.visualmode = orig_visualmode
vim.fn.histget = orig_histget
end) end)
end) end)
@@ -617,4 +810,86 @@ describe('Range', function()
}, vim.api.nvim_buf_get_lines(b, 0, -1, false)) }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
end) end)
end) end)
it('can save to extmark', function()
withbuf({
'The quick brown',
'fox',
'jumps',
'over',
'the lazy dog',
}, function()
-- Construct a range over 'fox jumps'
local r = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 3, 5), 'v')
local extrange = r:save_to_extmark()
assert.are.same({ 'fox', 'jumps' }, extrange:range():lines())
-- change 'jumps' to 'leaps':
vim.api.nvim_buf_set_text(extrange.bufnr, 2, 0, 2, 4, { 'leap' })
assert.are.same({
'The quick brown',
'fox',
'leaps',
'over',
'the lazy dog',
}, vim.api.nvim_buf_get_lines(extrange.bufnr, 0, -1, false))
assert.are.same({ 'fox', 'leaps' }, extrange:range():lines())
end)
end)
it('can save linewise extmark', function()
withbuf({
'The quick brown',
'fox',
'jumps',
'over',
'the lazy dog',
}, function()
-- Construct a range over 'fox jumps'
local r = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 3, Pos.MAX_COL), 'V')
local extrange = r:save_to_extmark()
assert.are.same({ 'fox', 'jumps' }, extrange:range():lines())
local extmark = vim.api.nvim_buf_get_extmark_by_id(
extrange.bufnr,
vim.api.nvim_create_namespace 'u.range',
extrange.id,
{ details = true }
)
local row0, col0, details = unpack(extmark)
assert.are.same({ 1, 0 }, { row0, col0 })
assert.are.same({ 3, 0 }, { details.end_row, details.end_col })
end)
end)
it('discerns range bounds from extmarks beyond the end of the buffer', function()
local Buffer = require 'u.buffer'
vim.cmd.vnew()
local left = Buffer.current()
left:set_tmp_options()
vim.cmd.vnew()
local right = Buffer.current()
right:set_tmp_options()
left:all():replace {
'one',
'two',
'three',
}
local left_all_ext = left:all():save_to_extmark()
right:all():replace {
'foo',
'bar',
}
vim.api.nvim_set_current_buf(right.bufnr)
vim.cmd [[normal! ggyG]]
vim.api.nvim_set_current_buf(left.bufnr)
vim.cmd [[normal! ggVGp]]
assert.are.same({ 'foo', 'bar' }, left_all_ext:range():lines())
vim.api.nvim_buf_delete(left.bufnr, { force = true })
vim.api.nvim_buf_delete(right.bufnr, { force = true })
end)
end) end)

View File

@@ -83,13 +83,13 @@ describe('Renderer', function()
end) end)
-- --
-- get_pos_infos -- get_tags_at
-- --
it('should return no extmarks for an empty buffer', function() it('should return no extmarks for an empty buffer', function()
withbuf({}, function() withbuf({}, function()
local r = R.Renderer.new(0) local r = R.Renderer.new(0)
local pos_infos = r:get_pos_infos { 0, 0 } local pos_infos = r:get_tags_at { 0, 0 }
assert.are.same(pos_infos, {}) assert.are.same(pos_infos, {})
end) end)
end) end)
@@ -102,12 +102,28 @@ describe('Renderer', function()
R.h('text', { hl = 'HighlightGroup2' }, ' World'), R.h('text', { hl = 'HighlightGroup2' }, ' World'),
} }
local pos_infos = r:get_pos_infos { 0, 2 } local pos_infos = r:get_tags_at { 0, 2 }
assert.are.same(#pos_infos, 1) assert.are.same(#pos_infos, 1)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
assert.are.same(pos_infos[1].extmark.start, { 0, 0 }) assert.are.same(pos_infos[1].extmark.start, { 0, 0 })
assert.are.same(pos_infos[1].extmark.stop, { 0, 5 }) assert.are.same(pos_infos[1].extmark.stop, { 0, 5 })
pos_infos = r:get_tags_at { 0, 4 }
assert.are.same(#pos_infos, 1)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
assert.are.same(pos_infos[1].extmark.start, { 0, 0 })
assert.are.same(pos_infos[1].extmark.stop, { 0, 5 })
pos_infos = r:get_tags_at { 0, 5 }
assert.are.same(#pos_infos, 1)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2')
assert.are.same(pos_infos[1].extmark.start, { 0, 5 })
assert.are.same(pos_infos[1].extmark.stop, { 0, 11 })
-- In insert mode, bounds are eagerly included:
pos_infos = r:get_tags_at({ 0, 5 }, 'i')
assert.are.same(#pos_infos, 2)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup2')
end) end)
end) end)
@@ -125,11 +141,252 @@ describe('Renderer', function()
}), }),
} }
local pos_infos = r:get_pos_infos { 0, 5 } local pos_infos = r:get_tags_at { 0, 5 }
assert.are.same(#pos_infos, 2) assert.are.same(#pos_infos, 2)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2') assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2')
assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1') assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1')
end) end)
end) end)
it('repeated patch_lines calls should not change the buffer content', function()
local lines = {
[[{ {]],
[[ bounds = {]],
[[ start1 = { 1, 1 },]],
[[ stop1 = { 4, 1 }]],
[[ },]],
[[ end_right_gravity = true,]],
[[ id = 1,]],
[[ ns_id = 623,]],
[[ ns_name = "my.renderer:91",]],
[[ right_gravity = false]],
[[ } }]],
[[]],
}
withbuf(lines, function()
local Buffer = require 'u.buffer'
R.Renderer.patch_lines(0, nil, lines)
assert.are.same(Buffer.current():all():lines(), lines)
R.Renderer.patch_lines(0, lines, lines)
assert.are.same(Buffer.current():all():lines(), lines)
R.Renderer.patch_lines(0, lines, lines)
assert.are.same(Buffer.current():all():lines(), lines)
end)
end)
it('should fire text-changed events', function()
withbuf({}, function()
local Buffer = require 'u.buffer'
local buf = Buffer.current()
local r = R.Renderer.new(0)
local captured_changed_text = ''
r:render {
R.h('text', {
on_change = function(txt) captured_changed_text = txt end,
}, {
'one\n',
'two\n',
'three\n',
}),
}
vim.fn.setreg('"', 'bleh')
vim.cmd [[normal! ggVGp]]
-- For some reason, the autocmd does not fire in the busted environment.
-- We'll call the handler ourselves:
r:_on_text_changed()
assert.are.same(buf:all():text(), 'bleh')
assert.are.same(captured_changed_text, 'bleh')
vim.fn.setreg('"', '')
vim.cmd [[normal! ggdG]]
-- We'll call the handler ourselves:
r:_on_text_changed()
assert.are.same(buf:all():text(), '')
assert.are.same(captured_changed_text, '')
end)
withbuf({}, function()
local Buffer = require 'u.buffer'
local buf = Buffer.current()
local r = R.Renderer.new(0)
--- @type string?
local captured_changed_text = nil
r:render {
'prefix:',
R.h('text', {
on_change = function(txt) captured_changed_text = txt end,
}, {
'one',
}),
'suffix',
}
vim.fn.setreg('"', 'bleh')
vim.api.nvim_win_set_cursor(0, { 1, 9 })
vim.cmd [[normal! vhhd]]
-- For some reason, the autocmd does not fire in the busted environment.
-- We'll call the handler ourselves:
r:_on_text_changed()
assert.are.same(buf:all():text(), 'prefix:suffix')
assert.are.same(captured_changed_text, '')
end)
end)
it('should find tags by position', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
'pre',
R.h('text', {
id = 'outer',
}, {
'inner-pre',
R.h('text', {
id = 'inner',
}, {
'inner-text',
}),
'inner-post',
}),
'post',
}
local tags = r:get_tags_at { 0, 11 }
assert.are.same(#tags, 1)
assert.are.same(tags[1].tag.attributes.id, 'outer')
tags = r:get_tags_at { 0, 12 }
assert.are.same(#tags, 2)
assert.are.same(tags[1].tag.attributes.id, 'inner')
assert.are.same(tags[2].tag.attributes.id, 'outer')
end)
end)
it('should find tags by id', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
R.h('text', {
id = 'outer',
}, {
'inner-pre',
R.h('text', {
id = 'inner',
}, {
'inner-text',
}),
'inner-post',
}),
'post',
}
local bounds = r:get_tag_bounds 'outer'
assert.are.same(bounds, { start = { 0, 0 }, stop = { 0, 29 } })
bounds = r:get_tag_bounds 'inner'
assert.are.same(bounds, { start = { 0, 9 }, stop = { 0, 19 } })
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,3 +1,4 @@
require 'luacov'
local function withbuf(lines, f) local function withbuf(lines, f)
vim.go.swapfile = false vim.go.swapfile = false

View File

@@ -1,6 +1,10 @@
syntax = "LuaJIT"
call_parentheses = "None" call_parentheses = "None"
collapse_simple_statement = "Always" collapse_simple_statement = "Always"
column_width = 100 column_width = 100
indent_type = "Spaces" indent_type = "Spaces"
indent_width = 2 indent_width = 2
quote_style = "AutoPreferSingle" quote_style = "AutoPreferSingle"
[sort_requires]
enabled = true

36
vim.yml
View File

@@ -1,36 +0,0 @@
---
base: lua51
globals:
vim:
any: true
assert.are.same:
args:
- type: any
- type: any
assert.are_not.same:
args:
- type: any
- type: any
assert.has.error:
args:
- type: any
- type: any
assert.is_true:
args:
- type: any
- type: any
assert.is_false:
args:
- type: any
- type: any
describe:
args:
- type: string
- type: function
it:
args:
- type: string
- type: function
before_each:
args:
- type: function