5 Commits

Author SHA1 Message Date
362d9e905d (stylua) lower max line length to 100
Some checks failed
NeoVim tests / plenary-tests (push) Failing after 8s
2025-04-12 08:57:11 -06:00
3fe84197c1 more cleanup 2025-04-12 08:56:10 -06:00
87930bf3af rename Range.from_txtobj => from_motion
Some checks failed
NeoVim tests / plenary-tests (push) Failing after 7s
2025-04-11 21:37:28 -06:00
0ee6caa7ba 1-based indexing rewrite
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 14s
2025-04-11 17:08:20 -06:00
d9bb01be8b renderer
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 9s
2025-04-09 21:38:32 -06:00
26 changed files with 510 additions and 901 deletions

13
.busted
View File

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

View File

@@ -2,28 +2,15 @@
name: NeoVim tests name: NeoVim tests
on: [push] on: [push]
jobs: jobs:
code-quality: plenary-tests:
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: cachix/install-nix-action@v31 - uses: rhysd/action-setup-vim@v1
with: with:
nix_path: nixpkgs=channel:nixos-unstable neovim: true
version: v0.11.0
- name: Populate Nix store arch: 'x86_64'
run: - run: make test
nix-shell --run 'true'
- name: Type-check with lua-language-server
run:
nix-shell --run 'make lint'
- name: Check formatting with stylua
run:
nix-shell --run 'make fmt-check'
- name: Run busted tests
run:
nix-shell --run 'make test'

4
.gitignore vendored
View File

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

View File

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

View File

@@ -1,21 +1,16 @@
all: lint fmt-check test PLENARY_DIR=~/.local/share/nvim/site/pack/test/opt/plenary.nvim
all: lint test
lint: lint:
@echo "## Typechecking" lua-language-server --check=lua/u/ --checklevel=Hint
@lua-language-server --check=lua/u/ --checklevel=Error lux check
fmt-check:
@echo "## Checking code format"
@stylua --check .
fmt: fmt:
@echo "## Formatting code" stylua .
@stylua .
test: test: $(PLENARY_DIR)
@rm -f luacov.*.out NVIM_APPNAME=noplugstest nvim -u NORC --headless -c 'set packpath+=~/.local/share/nvim/site' -c 'packadd plenary.nvim' -c "PlenaryBustedDirectory spec/"
@echo "## Running tests"
@busted --coverage --verbose $(PLENARY_DIR):
@echo "## Generating coverage report" git clone https://github.com/nvim-lua/plenary.nvim/ $(PLENARY_DIR)
@luacov
@awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out

119
README.md
View File

@@ -1,40 +1,26 @@
# u.nvim # u.nvim
Welcome to **u.nvim** - a powerful Lua library designed to enhance your text Welcome to **u.nvim** a powerful Lua library designed to enhance your text manipulation experience in NeoVim, focusing primarily on a context-aware "Range" utility. This utility allows you to work efficiently with text selections based on various conditions, in a variety of contexts, making coding and editing more intuitive and productive.
manipulation experience in NeoVim, focusing on text-manipulation utilities.
This includes a `Range` utility, allowing you to work efficiently with text
selections based on various conditions, as well as a declarative `Render`-er,
making coding and editing more intuitive and productive.
This is meant to be used as a **library**, not a plugin. On its own, `u.nvim` This is meant to be used as a **library**, not a plugin. On its own, `u.nvim` does nothing. It is meant to be used by plugin authors, to make their lives easier based on the variety of utilities I found I needed while growing my NeoVim config. To get an idea of what a plugin built on top of `u.nvim` would look like, check out the [examples/](./examples/) directory.
does nothing. It is meant to be used by plugin authors, to make their lives
easier based on the variety of utilities I found I needed while growing my
NeoVim config. To get an idea of what a plugin built on top of `u.nvim` would
look like, check out the [examples/](./examples/) directory.
## Features ## Features
- **Rendering System**: a utility that can declaratively render NeoVim-specific - **Rendering System**: a utility that can declaratively render NeoVim-specific hyperscript into a buffer, supporting creating/managing extmarks, highlights, and key-event handling (requires NeoVim >0.11)
hyperscript into a buffer, supporting creating/managing extmarks, highlights, - **Signals**: a simple dependency tracking system that pairs well with the rendering utilities for creating reactive/interactive UIs in NeoVim.
and key-event handling (requires NeoVim >0.11) - **Range Utility**: Get context-aware selections with ease. Replace regions with new text. Think of it as a programmatic way to work with visual selections (or regions of text).
- **Signals**: a simple dependency tracking system that pairs well with the
rendering utilities for creating reactive/interactive UIs in NeoVim.
- **Range Utility**: Get context-aware selections with ease. Replace regions
with new text. Think of it as a programmatic way to work with visual
selections (or regions of text).
- **Code Writer**: Write code with automatic indentation and formatting. - **Code Writer**: Write code with automatic indentation and formatting.
- **Operator Key Mapping**: Flexible key mapping that works with the selected - **Operator Key Mapping**: Flexible key mapping that works with the selected text.
text. - **Text and Position Utilities**: Convenient functions to manage text objects and cursor positions.
- **Text and Position Utilities**: Convenient functions to manage text objects
and cursor positions.
### Installation ### Installation
This being a library, and not a proper plugin, it is recommended that you lazy.nvim:
vendor the specific version of this library that you need, including it in your ```lua
code. Package managers are a developing landscape for Lua in the context of -- Setting `lazy = true` ensures that the library is only loaded
NeoVim. Perhaps in the future, `lux` will eliminate the need to vendor this -- when `require 'u.<utility>' is called.
library in your application code. { 'jrop/u.nvim', lazy = true }
```
## Signal and Rendering Usage ## Signal and Rendering Usage
@@ -125,9 +111,7 @@ end)
### `u.tracker` ### `u.tracker`
The `u.tracker` module provides a simple API for creating reactive variables. The `u.tracker` module provides a simple API for creating reactive variables. These can be composed in Effects and Memos utilizing Execution Contexts that track what signals are used by effects/memos.
These can be composed in Effects and Memos utilizing Execution Contexts that
track what signals are used by effects/memos.
```lua ```lua
local tracker = require('u.tracker') local tracker = require('u.tracker')
@@ -150,8 +134,7 @@ The renderer library renders hyperscript into a buffer. Each render performs a
minimal set of changes in order to transform the current buffer text into the minimal set of changes in order to transform the current buffer text into the
desired state. desired state.
**Hyperscript** is just 1) _text_ 2) `<text>` tags, which can be nested in 3) **Hyperscript** is just 1) _text_ 2) `<text>` tags, which can be nested in 3) Lua tables for readability:
Lua tables for readability:
```lua ```lua
local h = require('u.renderer').h local h = require('u.renderer').h
@@ -213,8 +196,7 @@ renderer:render(
) )
``` ```
**Rendering**: The renderer library provides a `render` function that takes **Rendering**: The renderer library provides a `render` function that takes hyperscript in, and converts it to formatted buffer text:
hyperscript in, and converts it to formatted buffer text:
```lua ```lua
local Renderer = require('u.renderer').Renderer local Renderer = require('u.renderer').Renderer
@@ -237,37 +219,22 @@ buf:render {
<blockquote> <blockquote>
<del> <del>
I love NeoVim. I am coming to love Lua. I don't like 1-based indices; perhaps I I love NeoVim. I am coming to love Lua. I don't like 1-based indices; perhaps I am too old. Perhaps I am too steeped in the history of loving the elegance of simple pointer arithmetic. Regardless, the way positions are addressed in NeoVim/Vim is (terrifyingly) mixed. Some methods return 1-based, others accept only 0-based. In order to stay sane, I had to make a choice to store everything in one, uniform representation in this library. I chose (what I humbly think is the only sane way) to stick with the tried-and-true 0-based index scheme. That abstraction leaks into the public API of this library.
am too old. Perhaps I am too steeped in the history of loving the elegance of
simple pointer arithmetic. Regardless, the way positions are addressed in
NeoVim/Vim is (terrifyingly) mixed. Some methods return 1-based, others accept
only 0-based. In order to stay sane, I had to make a choice to store everything
in one, uniform representation in this library. I chose (what I humbly think is
the only sane way) to stick with the tried-and-true 0-based index scheme. That
abstraction leaks into the public API of this library.
</del> </del>
</blockquote> </blockquote>
<br /> <br />
<b>This has changed in v2</b>. After much thought, I realized that: <b>This has changed in v2</b>. After much thought, I realized that:
1. The 0-based indexing in NeoVim is prevelant in the `:api`, which is designed 1. The 0-based indexing in NeoVim is prevelant in the `:api`, which is designed to be exposed to many languages. As such, it makes sense for this interface to use 0-based indexing. However, many internal Vim functions use 1-based indexing.
to be exposed to many languages. As such, it makes sense for this interface 2. This is a Lua library (surprise, surprise, duh) - the idioms of the language should take precedence over my preference
to use 0-based indexing. However, many internal Vim functions use 1-based 3. There were subtle bugs in the code where indices weren't being normalized to 0-based, anyways. Somehow it worked most of the time.
indexing.
2. This is a Lua library (surprise, surprise, duh) - the idioms of the language
should take precedence over my preference
3. There were subtle bugs in the code where indices weren't being normalized to
0-based, anyways. Somehow it worked most of the time.
As such, this library now uses 1-based indexing everywhere, doing the necessary As such, this library now uses 1-based indexing everywhere, doing the necessary interop conversions when calling `:api` functions.
interop conversions when calling `:api` functions.
### 1. Creating a Range ### 1. Creating a Range
The `Range` utility is the main feature upon which most other things in this The `Range` utility is the main feature upon which most other things in this library are built, aside from a few standalone utilities. Ranges can be constructed manually, or preferably, obtained based on a variety of contexts.
library are built, aside from a few standalone utilities. Ranges can be
constructed manually, or preferably, obtained based on a variety of contexts.
```lua ```lua
local Range = require 'u.range' local Range = require 'u.range'
@@ -278,9 +245,7 @@ Range.new(start, stop, 'v') -- charwise selection
Range.new(start, stop, 'V') -- linewise selection Range.new(start, stop, 'V') -- linewise selection
``` ```
This is usually not how you want to obtain a `Range`, however. Usually you want This is usually not how you want to obtain a `Range`, however. Usually you want to get the corresponding context of an edit operation and just "get me the current Range that represents this context".
to get the corresponding context of an edit operation and just "get me the
current Range that represents this context".
```lua ```lua
-- get the first line in a buffer: -- get the first line in a buffer:
@@ -295,8 +260,7 @@ Range.from_motion('iW')
Range.from_motion('a"') Range.from_motion('a"')
-- Get the currently visually selected text: -- Get the currently visually selected text:
-- NOTE: this does NOT work within certain contexts; more specialized utilities -- NOTE: this does NOT work within certain contexts; more specialized utilities are more appropriate in certain circumstances
-- are more appropriate in certain circumstances
Range.from_vtext() Range.from_vtext()
-- --
@@ -308,8 +272,7 @@ function MyOpFunc(ty)
local range = Range.from_op_func(ty) local range = Range.from_op_func(ty)
-- do something with the range -- do something with the range
end end
-- Try invoking this with: `<Leader>toaw`, and the current word will be the -- Try invoking this with: `<Leader>toaw`, and the current word will be the context:
-- context:
vim.keymap.set('<Leader>to', function() vim.keymap.set('<Leader>to', function()
vim.g.operatorfunc = 'v:lua.MyOpFunc' vim.g.operatorfunc = 'v:lua.MyOpFunc'
return 'g@' return 'g@'
@@ -318,8 +281,7 @@ end, { expr = true })
-- --
-- Commands: -- Commands:
-- --
-- When executing commands in a visual context, getting the selected text has -- When executing commands in a visual context, getting the selected text has to be done differently:
-- to be done differently:
vim.api.nvim_create_user_command('MyCmd', function(args) vim.api.nvim_create_user_command('MyCmd', function(args)
local range = Range.from_cmd_args(args) local range = Range.from_cmd_args(args)
if range == nil then if range == nil then
@@ -330,8 +292,7 @@ vim.api.nvim_create_user_command('MyCmd', function(args)
end, { range = true }) end, { range = true })
``` ```
So far, that's a lot of ways to _get_ a `Range`. But what can you do with a So far, that's a lot of ways to _get_ a `Range`. But what can you do with a range once you have one? Plenty, it turns out!
range once you have one? Plenty, it turns out!
```lua ```lua
local range = ... local range = ...
@@ -401,11 +362,11 @@ Access and manipulate buffers easily:
```lua ```lua
local Buffer = require 'u.buffer' local Buffer = require 'u.buffer'
local buf = Buffer.current() local buf = Buffer.current()
buf.b.<option> -- get buffer-local variables
buf.b.<option> = ... -- set buffer-local variables
buf.bo.<option> -- get buffer options
buf.bo.<option> = ... -- set buffer options
buf:line_count() -- the number of lines in the current buffer buf:line_count() -- the number of lines in the current buffer
buf:get_option '...'
buf:set_option('...', ...)
buf:get_var '...'
buf:set_var('...', ...)
buf:all() -- returns a Range representing the entire buffer buf:all() -- returns a Range representing the entire buffer
buf:is_empty() -- returns true if the buffer has no text buf:is_empty() -- returns true if the buffer has no text
buf:append_line '...' buf:append_line '...'
@@ -420,20 +381,8 @@ buf:txtobj('iw') -- returns a Range representing the text object 'iw' in th
Copyright (c) 2024 jrapodaca@gmail.com Copyright (c) 2024 jrapodaca@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy of Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -44,7 +44,11 @@ end
--- Normalizes the given path to an absolute path. --- Normalizes the given path to an absolute path.
--- @param path string --- @param path string
function H.normalize(path) return vim.fs.abspath(vim.fs.normalize(path)) end function H.normalize(path)
path = vim.fs.normalize(path)
if path:sub(1, 1) ~= '/' then path = vim.fs.joinpath(vim.uv.cwd(), path) end
return vim.fs.normalize(path)
end
--- Computes the relative path from `base` to `path`. --- Computes the relative path from `base` to `path`.
--- @param path string --- @param path string
@@ -121,10 +125,7 @@ end
--- ---
--- @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)
local winnr = vim.api.nvim_buf_call( local winnr = vim.api.nvim_buf_call(opts.bufnr, function() return vim.api.nvim_get_current_win() end)
opts.bufnr,
function() return vim.api.nvim_get_current_win() end
)
local s_tree_inf = tracker.create_signal(H.get_tree_inf(opts.root_path)) local s_tree_inf = tracker.create_signal(H.get_tree_inf(opts.root_path))
local s_focused_path = tracker.create_signal(H.normalize(opts.focus_path or opts.root_path)) local s_focused_path = tracker.create_signal(H.normalize(opts.focus_path or opts.root_path))
@@ -154,35 +155,19 @@ local function _render_in_buffer(opts)
end) end)
end) end)
-- --
-- -- TODO: :help watch-file
-- --
-- local watcher = vim.uv.new_fs_event()
-- if watcher ~= nil then
-- watcher:start(root_path, { recursive = true }, function(err, fname, status)
-- -- TODO: more efficient update:
-- s_tree_inf:set(H.get_tree(root_path))
-- --
-- :help watch-file -- -- TODO: proper disposal
-- -- watcher:stop()
local watcher = vim.uv.new_fs_event() -- end)
if watcher ~= nil then -- end
--- @diagnostic disable-next-line: unused-local
watcher:start(opts.root_path, { recursive = true }, function(_err, fname, _status)
fname = H.normalize(fname)
local dir_path = vim.fs.dirname(fname)
local dir = s_tree_inf:get().path_to_node[dir_path]
if not dir then return end
s_tree_inf:schedule_update(function(tree_inf)
H.populate_dir_children(dir, tree_inf.path_to_node)
return tree_inf
end)
end)
end
vim.api.nvim_create_autocmd('WinClosed', {
once = true,
pattern = tostring(winnr),
callback = function()
if watcher == nil then return end
watcher:stop()
watcher = nil
end,
})
local controller = {} local controller = {}
@@ -346,11 +331,7 @@ local function _render_in_buffer(opts)
return '' return ''
end, end,
n = function() n = function()
vim.schedule( vim.schedule(function() controller.new(node.kind == 'file' and vim.fs.dirname(node.path) or node.path) end)
function()
controller.new(node.kind == 'file' and vim.fs.dirname(node.path) or node.path)
end
)
return '' return ''
end, end,
r = function() r = function()
@@ -370,11 +351,7 @@ local function _render_in_buffer(opts)
local icon = node.expanded and '' or '' local icon = node.expanded and '' or ''
tb:put { tb:put {
current_line > 1 and '\n', current_line > 1 and '\n',
h( h('text', { hl = 'Constant', nmap = nmaps }, { string.rep(' ', level), icon, ' ', name }),
'text',
{ hl = 'Constant', nmap = nmaps },
{ string.rep(' ', level), icon, ' ', name }
),
} }
if node.expanded then if node.expanded then
for _, child in ipairs(node.children) do for _, child in ipairs(node.children) do
@@ -434,8 +411,8 @@ function M.show(opts)
callback = M.hide, callback = M.hide,
}) })
vim.wo[0][0].number = false vim.wo.number = false
vim.wo[0][0].relativenumber = false vim.wo.relativenumber = false
local bufnr = vim.api.nvim_get_current_buf() local bufnr = vim.api.nvim_get_current_buf()

View File

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

View File

@@ -7,8 +7,7 @@ local tracker = require 'u.tracker'
local M = {} local M = {}
local S_EDITOR_DIMENSIONS = local S_EDITOR_DIMENSIONS = tracker.create_signal(utils.get_editor_dimensions(), 's:editor_dimensions')
tracker.create_signal(utils.get_editor_dimensions(), 's:editor_dimensions')
vim.api.nvim_create_autocmd('VimResized', { vim.api.nvim_create_autocmd('VimResized', {
callback = function() callback = function()
local new_dim = utils.get_editor_dimensions() local new_dim = utils.get_editor_dimensions()
@@ -203,9 +202,7 @@ function M.create_picker(opts) -- {{{
local s_filter_text_undebounced = tracker.create_signal('', 's:filter_text') local s_filter_text_undebounced = tracker.create_signal('', 's:filter_text')
w_input_buf:autocmd('TextChangedI', { w_input_buf:autocmd('TextChangedI', {
callback = safe_wrap( callback = safe_wrap(function() s_filter_text_undebounced:set(vim.api.nvim_get_current_line()) end),
function() s_filter_text_undebounced:set(vim.api.nvim_get_current_line()) end
),
}) })
local s_filter_text = s_filter_text_undebounced:debounce(50) local s_filter_text = s_filter_text_undebounced:debounce(50)
@@ -214,15 +211,10 @@ function M.create_picker(opts) -- {{{
-- --
local s_formatted_items = tracker.create_memo(function() local s_formatted_items = tracker.create_memo(function()
local function _format_item(item) local function _format_item(item) return opts.format_item and opts.format_item(item) or tostring(item) end
return opts.format_item and opts.format_item(item) or tostring(item)
end
local items = s_items:get() local items = s_items:get()
return vim return vim.iter(items):map(function(item) return { item = item, formatted = _format_item(item) } end):totable()
.iter(items)
:map(function(item) return { item = item, formatted = _format_item(item) } end)
:totable()
end) end)
-- When the filter text changes, update the filtered items: -- When the filter text changes, update the filtered items:
@@ -231,10 +223,8 @@ function M.create_picker(opts) -- {{{
local formatted_items = s_formatted_items:get() local formatted_items = s_formatted_items:get()
local filter_text = vim.trim(s_filter_text:get()):lower() local filter_text = vim.trim(s_filter_text:get()):lower()
--- @type string local filter_pattern = ''
local filter_pattern local use_plain_pattern = false
--- @type boolean
local use_plain_pattern
if #formatted_items > 250 and #filter_text <= 3 then if #formatted_items > 250 and #filter_text <= 3 then
filter_pattern = filter_text filter_pattern = filter_text
use_plain_pattern = true use_plain_pattern = true
@@ -257,9 +247,7 @@ function M.create_picker(opts) -- {{{
local new_filtered_items = vim local new_filtered_items = vim
.iter(formatted_items) .iter(formatted_items)
:enumerate() :enumerate()
:map( :map(function(i, inf) return { orig_idx = i, item = inf.item, formatted = inf.formatted } end)
function(i, inf) return { orig_idx = i, item = inf.item, formatted = inf.formatted } end
)
:filter(function(inf) :filter(function(inf)
if filter_text == '' then return true end if filter_text == '' then return true end
local formatted_as_string = Renderer.markup_to_string({ tree = inf.formatted }):lower() local formatted_as_string = Renderer.markup_to_string({ tree = inf.formatted }):lower()
@@ -267,7 +255,9 @@ function M.create_picker(opts) -- {{{
formatted_strings[inf.orig_idx] = formatted_as_string formatted_strings[inf.orig_idx] = formatted_as_string
if use_plain_pattern then if use_plain_pattern then
local x, y = formatted_as_string:find(filter_pattern, 1, true) local x, y = formatted_as_string:find(filter_pattern, 1, true)
if x ~= nil and y ~= nil then matches[inf.orig_idx] = formatted_as_string:sub(x, y) end if x ~= nil and y ~= nil then
matches[inf.orig_idx] = formatted_as_string:sub(x, y)
end
else else
matches[inf.orig_idx] = string.match(formatted_as_string, filter_pattern) matches[inf.orig_idx] = string.match(formatted_as_string, filter_pattern)
end end
@@ -330,9 +320,7 @@ function M.create_picker(opts) -- {{{
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)
if #indices == 0 and #filtered_items > 0 then if #indices == 0 and #filtered_items > 0 then indices = { filtered_items[cursor_index].orig_idx } end
indices = { filtered_items[cursor_index].orig_idx }
end
return { return {
items = vim.iter(indices):map(function(i) return items[i] end):totable(), items = vim.iter(indices):map(function(i) return items[i] end):totable(),
indices = indices, indices = indices,
@@ -409,18 +397,8 @@ function M.create_picker(opts) -- {{{
end end
s_cursor_index:set(next_cursor_index) s_cursor_index:set(next_cursor_index)
end end
vim.keymap.set( vim.keymap.set('i', '<C-n>', safe_wrap(action_next_line), { buffer = w_input_buf.bufnr, desc = 'Picker: next' })
'i', vim.keymap.set('i', '<Down>', safe_wrap(action_next_line), { buffer = w_input_buf.bufnr, desc = 'Picker: next' })
'<C-n>',
safe_wrap(action_next_line),
{ buffer = w_input_buf.bufnr, desc = 'Picker: next' }
)
vim.keymap.set(
'i',
'<Down>',
safe_wrap(action_next_line),
{ buffer = w_input_buf.bufnr, desc = 'Picker: next' }
)
local function action_prev_line() local function action_prev_line()
local max_line = #s_filtered_items:get() local max_line = #s_filtered_items:get()
@@ -428,18 +406,8 @@ function M.create_picker(opts) -- {{{
if next_cursor_index - s_top_offset:get() < 1 then s_top_offset:set(s_top_offset:get() - 1) end if next_cursor_index - s_top_offset:get() < 1 then s_top_offset:set(s_top_offset:get() - 1) end
s_cursor_index:set(next_cursor_index) s_cursor_index:set(next_cursor_index)
end end
vim.keymap.set( vim.keymap.set('i', '<C-p>', safe_wrap(action_prev_line), { buffer = w_input_buf.bufnr, desc = 'Picker: previous' })
'i', vim.keymap.set('i', '<Up>', safe_wrap(action_prev_line), { buffer = w_input_buf.bufnr, desc = 'Picker: previous' })
'<C-p>',
safe_wrap(action_prev_line),
{ buffer = w_input_buf.bufnr, desc = 'Picker: previous' }
)
vim.keymap.set(
'i',
'<Up>',
safe_wrap(action_prev_line),
{ buffer = w_input_buf.bufnr, desc = 'Picker: previous' }
)
vim.keymap.set( vim.keymap.set(
'i', 'i',
@@ -449,9 +417,7 @@ function M.create_picker(opts) -- {{{
local index = s_filtered_items:get()[s_cursor_index:get()].orig_idx local index = s_filtered_items:get()[s_cursor_index:get()].orig_idx
if vim.tbl_contains(s_selected_indices:get(), index) then if vim.tbl_contains(s_selected_indices:get(), index) then
s_selected_indices:set( s_selected_indices:set(vim.iter(s_selected_indices:get()):filter(function(i) return i ~= index end):totable())
vim.iter(s_selected_indices:get()):filter(function(i) return i ~= index end):totable()
)
else else
local new_selected_indices = shallow_copy_arr(s_selected_indices:get()) local new_selected_indices = shallow_copy_arr(s_selected_indices:get())
table.insert(new_selected_indices, index) table.insert(new_selected_indices, index)
@@ -463,12 +429,7 @@ function M.create_picker(opts) -- {{{
) )
for key, fn in pairs(opts.mappings or {}) do for key, fn in pairs(opts.mappings or {}) do
vim.keymap.set( vim.keymap.set('i', key, safe_wrap(function() return fn(controller) end), { buffer = w_input_buf.bufnr })
'i',
key,
safe_wrap(function() return fn(controller) end),
{ buffer = w_input_buf.bufnr }
)
end end
-- Render: -- Render:
@@ -538,9 +499,10 @@ function M.create_picker(opts) -- {{{
if ephemeral == nil then ephemeral = false end if ephemeral == nil then ephemeral = false end
if ephemeral and #indicies == 1 then if ephemeral and #indicies == 1 then
local matching_filtered_item_idx, _ = vim.iter(s_filtered_items:get()):enumerate():find( local matching_filtered_item_idx, _ = vim
function(_idx, inf) return inf.orig_idx == indicies[1] end .iter(s_filtered_items:get())
) :enumerate()
:find(function(_idx, inf) return inf.orig_idx == indicies[1] end)
if matching_filtered_item_idx ~= nil then s_cursor_index:set(indicies[1]) end if matching_filtered_item_idx ~= nil then s_cursor_index:set(indicies[1]) end
else else
if not opts.multi then if not opts.multi then
@@ -759,10 +721,7 @@ function M.files(opts) -- {{{
-- fast laptop. Show a warning and truncate the list in this case. -- fast laptop. Show a warning and truncate the list in this case.
if #lines >= opts.limit then if #lines >= opts.limit then
if not job_inf.notified_over_limit then if not job_inf.notified_over_limit then
vim.notify( vim.notify('Picker list is too large (truncating list to ' .. opts.limit .. ' items)', vim.log.levels.WARN)
'Picker list is too large (truncating list to ' .. opts.limit .. ' items)',
vim.log.levels.WARN
)
pcall(vim.fn.jobstop, job_inf.id) pcall(vim.fn.jobstop, job_inf.id)
job_inf.notified_over_limit = true job_inf.notified_over_limit = true
end end
@@ -805,7 +764,10 @@ function M.buffers() -- {{{
-- trim leading `cwd` from the buffer name: -- trim leading `cwd` from the buffer name:
if item_name:sub(1, #cwd) == cwd then item_name = item_name:sub(#cwd + 1) end if item_name:sub(1, #cwd) == cwd then item_name = item_name:sub(#cwd + 1) end
return TreeBuilder.new():put(item.changed == 1 and '[+] ' or ' '):put(item_name):tree() return TreeBuilder.new()
:put(item.changed == 1 and '[+] ' or ' ')
:put(item_name)
:tree()
end, end,
--- @params items { bufnr: number }[] --- @params items { bufnr: number }[]
@@ -918,9 +880,7 @@ function M.lsp_code_symbols() -- {{{
local item = items[1] local item = items[1]
-- Jump to the file/buffer: -- Jump to the file/buffer:
local buf = vim local buf = vim.iter(vim.fn.getbufinfo { buflisted = 1 }):find(function(b) return b.name == item.filename end)
.iter(vim.fn.getbufinfo { buflisted = 1 })
:find(function(b) return b.name == item.filename end)
if buf ~= nil then if buf ~= nil then
vim.api.nvim_win_set_buf(0, buf.bufnr) vim.api.nvim_win_set_buf(0, buf.bufnr)
else else

View File

@@ -1,12 +1,11 @@
local vim_repeat = require 'u.repeat' local vim_repeat = require 'u.repeat'
local Pos = require 'u.pos'
local Range = require 'u.range' 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 M = {} local M = {}
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
local surrounds = { local surrounds = {
[')'] = { left = '(', right = ')' }, [')'] = { left = '(', right = ')' },
['('] = { left = '( ', right = ' )' }, ['('] = { left = '( ', right = ' )' },
@@ -127,7 +126,7 @@ function M.setup()
do_surround(range, bounds) do_surround(range, bounds)
-- this is a visual mapping: end in normal mode: -- this is a visual mapping: end in normal mode:
vim.cmd.normal(ESC) vim.cmd { cmd = 'normal', args = { '' }, bang = true }
end, { noremap = true, silent = true }) end, { noremap = true, silent = true })
-- Change -- Change
@@ -170,19 +169,29 @@ function M.setup()
local irange = Range.from_motion('i' .. from_c, { user_defined = true }) local irange = Range.from_motion('i' .. from_c, { user_defined = true })
if arange == nil or irange == nil then return end if arange == nil or irange == nil then return end
local lrange, rrange = arange:difference(irange) local lrange = Range.new(arange.start, irange.start:must_next(-1))
if not lrange or not rrange then return end local rrange = Range.new(irange.stop:must_next(1), arange.stop)
rrange:replace(to.right) rrange:replace(to.right)
lrange:replace(to.left) lrange:replace(to.left)
else else
-- replace `from.right` with `to.right`: -- replace `from.right` with `to.right`:
local right_text = arange:sub(-1, -#from.right) local last_line = arange:line(-1):text()
right_text:replace(to.right) local from_right_match = last_line:match(vim.pesc(from.right) .. '$')
if from_right_match then
local match_start = arange.stop:clone()
match_start.col = match_start.col - #from_right_match + 1
Range.new(match_start, arange.stop):replace(to.right)
end
-- replace `from.left` with `to.left`: -- replace `from.left` with `to.left`:
local left_text = arange:sub(1, #from.left) local first_line = arange:line(1):text()
left_text:replace(to.left) local from_left_match = first_line:match('^' .. vim.pesc(from.left))
if from_left_match then
local match_end = arange.start:clone()
match_end.col = match_end.col + #from_left_match - 1
Range.new(arange.start, match_end):replace(to.left)
end
end end
end) end)
end, { noremap = true, silent = true }) end, { noremap = true, silent = true })

View File

@@ -3,23 +3,19 @@ local Renderer = require('u.renderer').Renderer
--- @class u.Buffer --- @class u.Buffer
--- @field bufnr number --- @field bufnr number
--- @field b vim.var_accessor
--- @field bo vim.bo
--- @field private renderer u.Renderer --- @field private renderer u.Renderer
local Buffer = {} local Buffer = {}
Buffer.__index = Buffer
--- @param bufnr? number --- @param bufnr? number
--- @return u.Buffer --- @return u.Buffer
function Buffer.from_nr(bufnr) function Buffer.from_nr(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
local renderer = Renderer.new(bufnr) local renderer = Renderer.new(bufnr)
return setmetatable({ return setmetatable({
bufnr = bufnr, bufnr = bufnr,
b = vim.b[bufnr],
bo = vim.bo[bufnr],
renderer = renderer, renderer = renderer,
}, Buffer) }, { __index = Buffer })
end end
--- @return u.Buffer --- @return u.Buffer
@@ -33,11 +29,25 @@ function Buffer.create(listed, scratch)
end end
function Buffer:set_tmp_options() function Buffer:set_tmp_options()
self.bo.bufhidden = 'delete' self:set_option('bufhidden', 'delete')
self.bo.buflisted = false self:set_option('buflisted', false)
self.bo.buftype = 'nowrite' self:set_option('buftype', 'nowrite')
end end
--- @param nm string
function Buffer:get_option(nm) return vim.api.nvim_get_option_value(nm, { buf = self.bufnr }) end
--- @param nm string
function Buffer:set_option(nm, val)
return vim.api.nvim_set_option_value(nm, val, { buf = self.bufnr })
end
--- @param nm string
function Buffer:get_var(nm) return vim.api.nvim_buf_get_var(self.bufnr, nm) end
--- @param nm string
function Buffer:set_var(nm, val) return vim.api.nvim_buf_set_var(self.bufnr, nm, val) end
function Buffer:line_count() return vim.api.nvim_buf_line_count(self.bufnr) end function Buffer:line_count() return vim.api.nvim_buf_line_count(self.bufnr) end
function Buffer:all() return Range.from_buf_text(self.bufnr) end function Buffer:all() return Range.from_buf_text(self.bufnr) end
@@ -75,54 +85,7 @@ 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 tree u.renderer.Tree --- @param tree Tree
function Buffer:render(tree) return self.renderer:render(tree) end function Buffer:render(tree) return self.renderer:render(tree) end
--- Filter buffer content through an external command (like Vim's :%!)
--- @param cmd string[] Command to run (with arguments)
--- @param opts? {cwd?: string, preserve_cursor?: boolean}
--- @return nil
--- @throws string Error message if command fails
--- @note Special placeholders in cmd:
--- - $FILE: replaced with the buffer's filename (if any)
--- - $DIR: replaced with the buffer's directory (if any)
function Buffer:filter_cmd(cmd, opts)
opts = opts or {}
local cwd = opts.cwd or vim.uv.cwd()
local old_lines = self:all():lines()
-- Save cursor position if needed, defaulting to true
local save_pos = opts.preserve_cursor ~= false and vim.fn.winsaveview()
-- Run the command
local result = vim
.system(
-- Replace special placeholders in `cmd` with their values:
vim
.iter(cmd)
:map(function(x)
if x == '$FILE' then return vim.api.nvim_buf_get_name(self.bufnr) end
if x == '$DIR' then return vim.fs.dirname(vim.api.nvim_buf_get_name(self.bufnr)) end
return x
end)
:totable(),
{
cwd = cwd,
stdin = old_lines,
text = true,
}
)
:wait()
-- Check for command failure
if result.code ~= 0 then error('Command failed: ' .. (result.stderr or '')) end
-- Process and apply the result
local new_lines = vim.split(result.stdout, '\n')
if new_lines[#new_lines] == '' then table.remove(new_lines) end
Renderer.patch_lines(self.bufnr, old_lines, new_lines)
-- Restore cursor position if saved
if save_pos then vim.fn.winrestview(save_pos) end
end
return Buffer return Buffer

View File

@@ -5,7 +5,6 @@ local Buffer = require 'u.buffer'
--- @field indent_level number --- @field indent_level number
--- @field indent_str string --- @field indent_str string
local CodeWriter = {} local CodeWriter = {}
CodeWriter.__index = CodeWriter
--- @param indent_level? number --- @param indent_level? number
--- @param indent_str? string --- @param indent_str? string
@@ -19,7 +18,7 @@ function CodeWriter.new(indent_level, indent_str)
indent_level = indent_level, indent_level = indent_level,
indent_str = indent_str, indent_str = indent_str,
} }
setmetatable(cw, CodeWriter) setmetatable(cw, { __index = CodeWriter })
return cw return cw
end end
@@ -38,8 +37,7 @@ function CodeWriter.from_line(line, bufnr)
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr }) local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr })
local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = bufnr }) local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = bufnr })
--- @type number local indent_level = 0
local indent_level
local indent_str = '' local indent_str = ''
if expandtab then if expandtab then
while #indent_str < shiftwidth do while #indent_str < shiftwidth do

View File

@@ -8,7 +8,7 @@ local __U__OpKeymapOpFunc_rhs = nil
--- @type nil|fun(range: u.Range): fun():any|nil --- @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 __U__OpKeymapOpFunc(ty)
if __U__OpKeymapOpFunc_rhs ~= nil then if __U__OpKeymapOpFunc_rhs ~= nil then
local range = Range.from_op_func(ty) local range = Range.from_op_func(ty)
__U__OpKeymapOpFunc_rhs(range) __U__OpKeymapOpFunc_rhs(range)

View File

@@ -12,17 +12,8 @@ end
--- @field col number 1-based column index --- @field col number 1-based column index
--- @field off number --- @field off number
local Pos = {} local Pos = {}
Pos.__index = Pos
Pos.MAX_COL = MAX_COL Pos.MAX_COL = MAX_COL
function Pos.__tostring(self)
if self.off ~= 0 then
return string.format('Pos(%d:%d){bufnr=%d, off=%d}', self.lnum, self.col, self.bufnr, self.off)
else
return string.format('Pos(%d:%d){bufnr=%d}', self.lnum, self.col, self.bufnr)
end
end
--- @param bufnr? number --- @param bufnr? number
--- @param lnum number 1-based --- @param lnum number 1-based
--- @param col number 1-based --- @param col number 1-based
@@ -37,33 +28,49 @@ function Pos.new(bufnr, lnum, col, off)
col = col, col = col,
off = off, off = off,
} }
setmetatable(pos, Pos)
local function str()
if pos.off ~= 0 then
return string.format('Pos(%d:%d){bufnr=%d, off=%d}', pos.lnum, pos.col, pos.bufnr, pos.off)
else
return string.format('Pos(%d:%d){bufnr=%d}', pos.lnum, pos.col, pos.bufnr)
end
end
setmetatable(pos, {
__index = Pos,
__tostring = str,
__lt = Pos.__lt,
__le = Pos.__le,
__eq = Pos.__eq,
})
return pos return pos
end 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.is(x)
if not type(x) == 'table' then return false end
local mt = getmetatable(x)
return mt and mt.__index == Pos
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
function Pos.__le(a, b) return a < b or a == b end function Pos.__le(a, b) return a < b or a == b end
function Pos.__eq(a, b) function Pos.__eq(a, b)
return getmetatable(a) == Pos return Pos.is(a) and Pos.is(b) and a.bufnr == b.bufnr and a.lnum == b.lnum and a.col == b.col
and getmetatable(b) == Pos
and a.bufnr == b.bufnr
and a.lnum == b.lnum
and a.col == b.col
end end
function Pos.__add(x, y) function Pos.__add(x, y)
if type(x) == 'number' then if type(x) == 'number' then
x, y = y, x x, y = y, x
end end
if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end if not Pos.is(x) or type(y) ~= 'number' then return nil end
return x:next(y) return x:next(y)
end end
function Pos.__sub(x, y) function Pos.__sub(x, y)
if type(x) == 'number' then if type(x) == 'number' then
x, y = y, x x, y = y, x
end end
if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end if not Pos.is(x) or type(y) ~= 'number' then return nil end
return x:next(-y) return x:next(-y)
end end

View File

@@ -1,39 +1,17 @@
local Pos = require 'u.pos' local Pos = require 'u.pos'
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true) local orig_on_yank = (vim.hl or vim.highlight).on_yank
local on_yank_enabled = true;
((vim.hl or vim.highlight) --[[@as any]]).on_yank = function(opts)
if not on_yank_enabled then return end
return orig_on_yank(opts)
end
--- @class u.Range --- @class u.Range
--- @field start u.Pos --- @field start u.Pos
--- @field stop u.Pos|nil --- @field stop u.Pos|nil
--- @field mode 'v'|'V' --- @field mode 'v'|'V'
local Range = {} local Range = {}
Range.__index = Range
function Range.__tostring(self)
--- @param p u.Pos
local function posstr(p)
if p == nil then
return 'nil'
elseif p.off ~= 0 then
return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off)
else
return string.format('Pos(%d:%d)', p.lnum, p.col)
end
end
local _1 = posstr(self.start)
local _2 = posstr(self.stop)
return string.format(
'Range{bufnr=%d, mode=%s, start=%s, stop=%s}',
self.start.bufnr,
self.mode,
_1,
_2
)
end
--------------------------------------------------------------------------------
-- Range constructors:
--------------------------------------------------------------------------------
--- @param start u.Pos --- @param start u.Pos
--- @param stop u.Pos|nil --- @param stop u.Pos|nil
@@ -45,24 +23,35 @@ function Range.new(start, stop, mode)
end end
local r = { start = start, stop = stop, mode = mode or 'v' } local r = { start = start, stop = stop, mode = mode or 'v' }
local function str()
--- @param p u.Pos
local function posstr(p)
if p == nil then
return 'nil'
elseif p.off ~= 0 then
return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off)
else
return string.format('Pos(%d:%d)', p.lnum, p.col)
end
end
setmetatable(r, Range) local _1 = posstr(r.start)
local _2 = posstr(r.stop)
return string.format(
'Range{bufnr=%d, mode=%s, start=%s, stop=%s}',
r.start.bufnr,
r.mode,
_1,
_2
)
end
setmetatable(r, { __index = Range, __tostring = str })
return r return r
end end
--- @param ranges (u.Range|nil)[] function Range.is(x)
function Range.smallest(ranges) local mt = getmetatable(x)
--- @type u.Range[] return mt and mt.__index == Range
ranges = vim.iter(ranges):filter(function(r) return r ~= nil and not r:is_empty() end):totable()
if #ranges == 0 then return nil end
-- find smallest match
local smallest = ranges[1]
for _, r in ipairs(ranges) do
local start, stop = r.start, r.stop
if start > smallest.start and stop < smallest.stop then smallest = r end
end
return smallest
end end
--- @param lpos string --- @param lpos string
@@ -125,6 +114,10 @@ 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(),
@@ -132,65 +125,59 @@ 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
_G.Range__from_motion_opfunc = function(ty) Pos.invalid():save_to_pos "'["
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty) Pos.invalid():save_to_pos "']"
end
vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc' local prev_on_yank_enabled = on_yank_enabled
on_yank_enabled = false
vim.cmd { vim.cmd {
cmd = 'normal', cmd = 'normal',
bang = not opts.user_defined, bang = not opts.user_defined,
args = { ESC .. 'g@' .. motion }, args = { '""y' .. motion },
mods = { silent = true }, mods = { silent = true },
} }
end) on_yank_enabled = prev_on_yank_enabled
local captured_range = _G.Range__from_motion_opfunc_captured_range
start = Pos.from_pos "'["
stop = Pos.from_pos "']"
end)
-- 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 not captured_range then return nil end if start == stop and start:is_invalid() 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 captured_range.stop:char() == motion_rest) (is_quote_txtobj and scope == 'i' and 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 captured_range.stop:char() == '<') or (motion == 'it' and stop:char() == '<')
then then
captured_range.stop = captured_range.stop:next(-1) or captured_range.stop stop = stop:next(-1) or stop
end end
if is_quote_txtobj and scope == 'a' then if is_quote_txtobj and scope == 'a' then
captured_range.start = captured_range.start:find_next(1, motion_rest) or captured_range.start start = start:find_next(1, motion_rest) or start
captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop stop = stop:find_next(-1, motion_rest) or stop
end end
if if
opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor))) opts.contains_cursor
and not Range.new(start, stop):contains(Pos.new(unpack(original_state.cursor)))
then then
return nil return nil
end end
return captured_range return Range.new(start, stop)
end end
--- Get range information from the currently selected visual text. --- Get range information from the currently selected visual text.
@@ -235,6 +222,7 @@ function Range.from_cmd_args(args)
return Range.new(start, stop, mode) return Range.new(start, stop, mode)
end end
---
function Range.find_nearest_brackets() function Range.find_nearest_brackets()
return Range.smallest { return Range.smallest {
Range.from_motion('a<', { contains_cursor = true }), Range.from_motion('a<', { contains_cursor = true }),
@@ -252,15 +240,28 @@ function Range.find_nearest_quotes()
} }
end end
-------------------------------------------------------------------------------- --- @param ranges (u.Range|nil)[]
-- Structural utilities: function Range.smallest(ranges)
-------------------------------------------------------------------------------- --- @type u.Range[]
ranges = vim.iter(ranges):filter(function(r) return r ~= nil and not r:is_empty() end):totable()
if #ranges == 0 then return nil end
-- find smallest match
local smallest = ranges[1]
for _, r in ipairs(ranges) do
local start, stop = r.start, r.stop
if start > smallest.start and stop < smallest.stop then smallest = r end
end
return smallest
end
function Range:clone() function Range:clone()
return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode) return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode)
end end
function Range:line_count()
function Range:is_empty() return self.stop == nil end if self:is_empty() then return 0 end
return self.stop.lnum - self.start.lnum + 1
end
function Range:to_linewise() function Range:to_linewise()
local r = self:clone() local r = self:clone()
@@ -272,111 +273,7 @@ function Range:to_linewise()
return r return r
end end
--- @param x u.Pos | u.Range function Range:is_empty() return self.stop == nil end
function Range:contains(x)
if getmetatable(x) == Pos then
return not self:is_empty() and x >= self.start and x <= self.stop
elseif getmetatable(x) == Range then
return self:contains(x.start) and self:contains(x.stop)
end
return false
end
--- @param other u.Range
--- @return u.Range|nil, u.Range|nil
function Range:difference(other)
local outer, inner = self, other
if not outer:contains(inner) then
outer, inner = inner, outer
end
if not outer:contains(inner) then return nil, nil end
local left
if outer.start ~= inner.start then
local stop = inner.start:clone() - 1
left = Range.new(outer.start, stop)
else
left = Range.new(outer.start) -- empty range
end
local right
if inner.stop ~= outer.stop then
local start = inner.stop:clone() + 1
right = Range.new(start, outer.stop)
else
right = Range.new(inner.stop) -- empty range
end
return left, right
end
--- @param left string
--- @param right string
function Range:save_to_pos(left, right)
if self:is_empty() then
self.start:save_to_pos(left)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
end
--- @param left string
--- @param right string
function Range:save_to_marks(left, right)
if self:is_empty() then
self.start:save_to_mark(left)
self.start:save_to_mark(right)
else
self.start:save_to_mark(left)
self.stop:save_to_mark(right)
end
end
function Range:set_visual_selection()
if self:is_empty() then return end
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
error 'Range:set_visual_selection() called on a buffer other than the current buffer'
end
local curr_mode = vim.fn.mode()
if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end
self.start:save_to_pos '.'
vim.cmd.normal { args = { 'o' }, bang = true }
self.stop:save_to_pos '.'
end
--------------------------------------------------------------------------------
-- Range.from_* functions:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Text access/manipulation utilities:
--------------------------------------------------------------------------------
function Range:length()
if self:is_empty() then return 0 end
local line_positions =
vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' })
local len = 0
for linenr, line in ipairs(line_positions) do
if linenr > 1 then len = len + 1 end -- each newline is counted as a char
local line_start_col = line[1][3]
local line_stop_col = line[2][3]
local line_len = line_stop_col - line_start_col + 1
len = len + line_len
end
return len
end
function Range:line_count()
if self:is_empty() then return 0 end
return self.stop.lnum - self.start.lnum + 1
end
function Range:trim_start() function Range:trim_start()
if self:is_empty() then return end if self:is_empty() then return end
@@ -402,46 +299,8 @@ function Range:trim_stop()
return r return r
end end
--- @param i number 1-based --- @param p u.Pos
--- @param j? number 1-based function Range:contains(p) return not self:is_empty() and p >= self.start and p <= self.stop end
function Range:sub(i, j)
local line_positions =
vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' })
--- @param idx number 1-based
--- @return u.Pos|nil
local function get_pos(idx)
if idx < 0 then return get_pos(self:length() + idx + 1) end
-- find the position of the first line that contains the i-th character:
local curr_len = 0
for linenr, line in ipairs(line_positions) do
if linenr > 1 then curr_len = curr_len + 1 end -- each newline is counted as a char
local line_start_col = line[1][3]
local line_stop_col = line[2][3]
local line_len = line_stop_col - line_start_col + 1
if curr_len + line_len >= idx then
return Pos.new(self.start.bufnr, line[1][2], line_start_col + (idx - curr_len) - 1)
end
curr_len = curr_len + line_len
end
end
local start = get_pos(i)
if not start then
-- start is inalid, so return an empty range:
return Range.new(self.start, nil, self.mode)
end
local stop
if j then stop = get_pos(j) end
if not stop then
-- stop is inalid, so return an empty range:
return Range.new(start, nil, self.mode)
end
return Range.new(start, stop, 'v')
end
--- @return string[] --- @return string[]
function Range:lines() function Range:lines()
@@ -452,8 +311,11 @@ end
--- @return string --- @return string
function Range:text() return vim.fn.join(self:lines(), '\n') end function Range:text() return vim.fn.join(self:lines(), '\n') end
--- @param i number 1-based
--- @param j? number 1-based
function Range:sub(i, j) return self:text():sub(i, j) end
--- @param l number --- @param l number
-- 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
@@ -557,6 +419,44 @@ function Range:must_shrink(amount)
return shrunk return shrunk
end end
--- @param left string
--- @param right string
function Range:save_to_pos(left, right)
if self:is_empty() then
self.start:save_to_pos(left)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
end
--- @param left string
--- @param right string
function Range:save_to_marks(left, right)
if self:is_empty() then
self.start:save_to_mark(left)
self.start:save_to_mark(right)
else
self.start:save_to_mark(left)
self.stop:save_to_mark(right)
end
end
function Range:set_visual_selection()
if self:is_empty() then return end
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
error 'Range:set_visual_selection() called on a buffer other than the current buffer'
end
local curr_mode = vim.fn.mode()
if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end
self.start:save_to_pos '.'
vim.cmd.normal { args = { 'o' }, bang = true }
self.stop:save_to_pos '.'
end
--- @param group string --- @param group string
--- @param opts? { timeout?: number, priority?: number, on_macro?: boolean } --- @param opts? { timeout?: number, priority?: number, on_macro?: boolean }
function Range:highlight(group, opts) function Range:highlight(group, opts)
@@ -570,8 +470,8 @@ function Range:highlight(group, opts)
local ns = vim.api.nvim_create_namespace '' local ns = vim.api.nvim_create_namespace ''
local winview = vim.fn.winsaveview() local winview = vim.fn.winsaveview();
vim.hl.range( (vim.hl or vim.highlight).range(
self.start.bufnr, self.start.bufnr,
ns, ns,
group, group,

View File

@@ -1,28 +1,22 @@
local M = {} local M = {}
local H = {} local H = {}
--- @alias u.renderer.Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: u.renderer.Tree } --- @alias Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: Tree }
--- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag --- @alias Node nil | boolean | string | Tag
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[] --- @alias Tree Node | Node[]
-- luacheck: ignore --- @param name string
--- @type table<string, fun(attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag> --- @param attributes? table<string, any>
M.h = setmetatable({}, { --- @param children? Node | Node[]
__call = function(_, name, attributes, children) --- @return Tag
function M.h(name, attributes, children)
return { return {
kind = 'tag', kind = 'tag',
name = name, name = name,
attributes = attributes or {}, attributes = attributes or {},
children = children, children = children,
} }
end, end
__index = function(_, name)
-- vim.print('dynamic hl ' .. name)
return function(attributes, children)
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end
end,
})
-- Renderer {{{ -- Renderer {{{
--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any } --- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
@@ -68,8 +62,8 @@ function Renderer.new(bufnr) -- {{{
end -- }}} end -- }}}
--- @param opts { --- @param opts {
--- tree: u.renderer.Tree; --- tree: Tree;
--- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any; --- on_tag?: fun(tag: Tag, start0: [number, number], stop0: [number, number]): any;
--- } --- }
function Renderer.markup_to_lines(opts) -- {{{ function Renderer.markup_to_lines(opts) -- {{{
--- @type string[] --- @type string[]
@@ -88,7 +82,7 @@ function Renderer.markup_to_lines(opts) -- {{{
curr_col1 = 1 curr_col1 = 1
end end
--- @param node u.renderer.Node --- @param node Node
local function visit(node) -- {{{ local function visit(node) -- {{{
if node == nil or type(node) == 'boolean' then return end if node == nil or type(node) == 'boolean' then return end
@@ -104,12 +98,12 @@ function Renderer.markup_to_lines(opts) -- {{{
-- visit the children: -- visit the children:
if Renderer.is_tag_arr(node.children) then if Renderer.is_tag_arr(node.children) then
for _, child in for _, child in
ipairs(node.children --[[@as u.renderer.Node[] ]]) ipairs(node.children --[[@as Node[] ]])
do do
-- newlines are not controlled by array entries, do NOT output a line here: -- newlines are not controlled by array entries, do NOT output a line here:
visit(child) visit(child)
end end
else -- luacheck: ignore else
visit(node.children) visit(node.children)
end end
@@ -129,69 +123,11 @@ end -- }}}
--- @param opts { --- @param opts {
--- tree: string; --- tree: string;
--- format_tag?: fun(tag: u.renderer.Tag): string; --- format_tag?: fun(tag: 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 tree Tree
--- @param old_lines string[] | nil
--- @param new_lines string[]
function Renderer.patch_lines(bufnr, old_lines, new_lines)
--
-- Helpers:
--
--- @param start integer
--- @param end_ integer
--- @param strict_indexing boolean
--- @param replacement string[]
local function _set_lines(start, end_, strict_indexing, replacement)
vim.api.nvim_buf_set_lines(bufnr, start, end_, strict_indexing, replacement)
end
--- @param start_row integer
--- @param start_col integer
--- @param end_row integer
--- @param end_col integer
--- @param replacement string[]
local function _set_text(start_row, start_col, end_row, end_col, replacement)
vim.api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, replacement)
end
-- Morph the text to the desired state:
local line_changes =
H.levenshtein(old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), new_lines)
for _, line_change in ipairs(line_changes) do
local lnum0 = line_change.index - 1
if line_change.kind == 'add' then
_set_lines(lnum0, lnum0, true, { line_change.item })
elseif line_change.kind == 'change' then
-- Compute inter-line diff, and apply:
local col_changes =
H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, ''))
for _, col_change in ipairs(col_changes) do
local cnum0 = col_change.index - 1
if col_change.kind == 'add' then
_set_text(lnum0, cnum0, lnum0, cnum0, { col_change.item })
elseif col_change.kind == 'change' then
_set_text(lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to })
elseif col_change.kind == 'delete' then
_set_text(lnum0, cnum0, lnum0, cnum0 + 1, {})
else -- luacheck: ignore
-- No change
end
end
elseif line_change.kind == 'delete' then
_set_lines(lnum0, lnum0 + 1, true, {})
else -- luacheck: ignore
-- No change
end
end
end
--- @param tree u.renderer.Tree
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
@@ -269,16 +205,45 @@ end
--- @private --- @private
function Renderer:_reconcile() -- {{{ function Renderer:_reconcile() -- {{{
local line_changes = H.levenshtein(self.old.lines, self.curr.lines)
self.old = self.curr
-- --
-- Step 1: morph the text to the desired state: -- Step 1: morph the text to the desired state:
-- --
Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines) for _, line_change in ipairs(line_changes) do
local lnum0 = line_change.index - 1
if line_change.kind == 'add' then
self:_set_lines(lnum0, lnum0, true, { line_change.item })
elseif line_change.kind == 'change' then
-- Compute inter-line diff, and apply:
local col_changes =
H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, ''))
for _, col_change in ipairs(col_changes) do
local cnum0 = col_change.index - 1
if col_change.kind == 'add' then
self:_set_text(lnum0, cnum0, lnum0, cnum0, { col_change.item })
elseif col_change.kind == 'change' then
self:_set_text(lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to })
elseif col_change.kind == 'delete' then
self:_set_text(lnum0, cnum0, lnum0, cnum0 + 1, {})
else
-- No change
end
end
elseif line_change.kind == 'delete' then
self:_set_lines(lnum0, lnum0 + 1, true, {})
else
-- No change
end
end
self.changedtick = vim.b[self.bufnr].changedtick self.changedtick = vim.b[self.bufnr].changedtick
-- --
-- Step 2: reconcile extmarks: -- Step 2: reconcile extmarks:
-- --
-- Clear current extmarks: -- Clear current extmarks:
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:
@@ -295,8 +260,6 @@ function Renderer:_reconcile() -- {{{
}, extmark.opts) }, extmark.opts)
) )
end end
self.old = self.curr
end -- }}} end -- }}}
--- @private --- @private
@@ -339,7 +302,7 @@ end -- }}}
--- ---
--- @private (private for now) --- @private (private for now)
--- @param pos0 [number; number] --- @param pos0 [number; number]
--- @return { extmark: RendererExtmark; tag: u.renderer.Tag; }[] --- @return { extmark: RendererExtmark; tag: Tag; }[]
function Renderer:get_pos_infos(pos0) -- {{{ function Renderer:get_pos_infos(pos0) -- {{{
local cursor_line0, cursor_col0 = pos0[1], pos0[2] local cursor_line0, cursor_col0 = pos0[1], pos0[2]
@@ -407,7 +370,7 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- created extmarks in self.curr.extmarks, which also has which tag each -- created extmarks in self.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: RendererExtmark; tag: u.renderer.Tag }[] --- @type { extmark: RendererExtmark; tag: Tag }[]
local matching_tags = vim local matching_tags = vim
.iter(intersecting_extmarks) .iter(intersecting_extmarks)
--- @param ext RendererExtmark --- @param ext RendererExtmark
@@ -424,7 +387,7 @@ end -- }}}
-- TreeBuilder {{{ -- TreeBuilder {{{
--- @class u.TreeBuilder --- @class u.TreeBuilder
--- @field private nodes u.renderer.Node[] --- @field private nodes Node[]
local TreeBuilder = {} local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder TreeBuilder.__index = TreeBuilder
M.TreeBuilder = TreeBuilder M.TreeBuilder = TreeBuilder
@@ -434,7 +397,7 @@ function TreeBuilder.new()
return self return self
end end
--- @param nodes u.renderer.Tree --- @param nodes Tree
--- @return u.TreeBuilder --- @return u.TreeBuilder
function TreeBuilder:put(nodes) function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes) table.insert(self.nodes, nodes)
@@ -443,7 +406,7 @@ end
--- @param name string --- @param name string
--- @param attributes? table<string, any> --- @param attributes? table<string, any>
--- @param children? u.renderer.Node | u.renderer.Node[] --- @param children? Node | Node[]
--- @return u.TreeBuilder --- @return u.TreeBuilder
function TreeBuilder:put_h(name, attributes, children) function TreeBuilder:put_h(name, attributes, children)
local tag = M.h(name, attributes, children) local tag = M.h(name, attributes, children)
@@ -460,19 +423,18 @@ function TreeBuilder:nest(fn)
return self return self
end end
--- @return u.renderer.Tree --- @return Tree
function TreeBuilder:tree() return self.nodes end function TreeBuilder:tree() return self.nodes end
-- }}} -- }}}
-- Levenshtein utility {{{ -- Levenshtein utility {{{
-- luacheck: ignore
--- @alias LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; }) --- @alias LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
--- @private --- @private
--- @generic T --- @generic T
--- @param x `T`[] --- @param x `T`[]
--- @param y T[] --- @param y T[]
--- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; } --- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; }
--- @return LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index). --- @return LevenshteinChange<T>[]
function H.levenshtein(x, y, cost) function H.levenshtein(x, y, cost)
-- 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
@@ -512,10 +474,10 @@ function H.levenshtein(x, y, cost)
if x[i] == y[j] then if x[i] == y[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 costDelete = dp[i - 1][j] + cost_of_delete_f(x[i])
local cost_add = dp[i][j - 1] + cost_of_add_f(y[j]) local costAdd = dp[i][j - 1] + cost_of_add_f(y[j])
local cost_change = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j]) local costChange = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j])
dp[i][j] = math.min(cost_delete, cost_add, cost_change) dp[i][j] = math.min(costDelete, costAdd, costChange)
end end
end end
end end

View File

@@ -202,7 +202,7 @@ end
-- class ExecutionContext -- class ExecutionContext
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
local CURRENT_CONTEXT = nil CURRENT_CONTEXT = nil
--- @class u.ExecutionContext --- @class u.ExecutionContext
--- @field signals table<u.Signal, boolean> --- @field signals table<u.Signal, boolean>
@@ -211,7 +211,7 @@ M.ExecutionContext = ExecutionContext
ExecutionContext.__index = ExecutionContext ExecutionContext.__index = ExecutionContext
--- @return u.ExecutionContext --- @return u.ExecutionContext
function ExecutionContext.new() function ExecutionContext:new()
return setmetatable({ return setmetatable({
signals = {}, signals = {},
subscribers = {}, subscribers = {},
@@ -222,7 +222,7 @@ function ExecutionContext.current() return CURRENT_CONTEXT end
--- @param fn function --- @param fn function
--- @param ctx u.ExecutionContext --- @param ctx u.ExecutionContext
function ExecutionContext.run(fn, ctx) function ExecutionContext:run(fn, ctx)
local oldCtx = CURRENT_CONTEXT local oldCtx = CURRENT_CONTEXT
CURRENT_CONTEXT = ctx CURRENT_CONTEXT = ctx
local result local result
@@ -289,8 +289,8 @@ end
--- @param fn function --- @param fn function
--- @param name? string --- @param name? string
function M.create_effect(fn, name) function M.create_effect(fn, name)
local ctx = M.ExecutionContext.new() local ctx = M.ExecutionContext:new()
M.ExecutionContext.run(fn, ctx) M.ExecutionContext:run(fn, ctx)
return ctx:subscribe(function() return ctx:subscribe(function()
if name and M.debug then if name and M.debug then
local deps = vim local deps = vim

View File

@@ -6,7 +6,6 @@ local M = {}
--- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string } --- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
--- @alias KeyMaps table<string, fun(): any | string> } --- @alias KeyMaps table<string, fun(): any | string> }
-- luacheck: ignore
--- @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 } --- @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 }
--- @generic T --- @generic T
@@ -35,7 +34,6 @@ end
--- ``` --- ```
--- @param name string --- @param name string
--- @param cmd string | fun(args: CmdArgs): any --- @param cmd string | fun(args: CmdArgs): any
-- luacheck: ignore
--- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string } --- @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'

19
lux.toml Normal file
View File

@@ -0,0 +1,19 @@
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"

4
selene.toml Normal file
View File

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

View File

@@ -1,23 +0,0 @@
{
pkgs ?
import
# nixpkgs-unstable (neovim@0.11.2):
(fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/e4b09e47ace7d87de083786b404bf232eb6c89d8.tar.gz";
sha256 = "1a2qvp2yz8j1jcggl1yvqmdxicbdqq58nv7hihmw3bzg9cjyqm26";
})
{ },
}:
pkgs.mkShell {
packages = [
pkgs.git
pkgs.gnumake
pkgs.lua-language-server
pkgs.lua51Packages.busted
pkgs.lua51Packages.luacov
pkgs.lua51Packages.luarocks
pkgs.lua51Packages.nlua
pkgs.neovim
pkgs.stylua
];
}

View File

@@ -422,52 +422,6 @@ describe('Range', function()
end) end)
end) end)
it('difference', function()
withbuf({ 'line one', 'and line two' }, function()
local range_outer = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 2, 12), 'v')
local range_inner = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 8), 'v')
assert.are.same(range_outer:text(), 'and line two')
assert.are.same(range_inner:text(), 'line')
local left, right = range_outer:difference(range_inner)
assert.are.same(left:text(), 'and ')
assert.are.same(right:text(), ' two')
left, right = range_inner:difference(range_outer)
assert.are.same(left:text(), 'and ')
assert.are.same(right:text(), ' two')
left, right = range_outer:difference(range_outer)
assert.are.same(left:is_empty(), true)
assert.are.same(left:text(), '')
assert.are.same(right:is_empty(), true)
assert.are.same(right:text(), '')
end)
end)
it('length', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 2, 4), Pos.new(nil, 2, 9), 'v')
assert.are.same(range:length(), #range:text())
range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 2, 9), 'v')
assert.are.same(range:length(), #range:text())
end)
end)
it('sub', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 2, 4), Pos.new(nil, 2, 9), 'v')
assert.are.same(range:text(), ' line ')
assert.are.same(range:sub(1, -1):text(), ' line ')
assert.are.same(range:sub(2, -2):text(), 'line')
assert.are.same(range:sub(1, 5):text(), ' line')
assert.are.same(range:sub(2, 5):text(), 'line')
assert.are.same(range:sub(20, 25):text(), '')
end)
end)
it('shrink', function() it('shrink', function()
withbuf({ 'line one', 'and line two' }, function() withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 2, 3), Pos.new(nil, 3, 5), 'v') local range = Range.new(Pos.new(nil, 2, 3), Pos.new(nil, 3, 5), 'v')
@@ -484,10 +438,7 @@ describe('Range', function()
assert.are.same(shrunk.start, Pos.new(nil, 2, 4)) assert.are.same(shrunk.start, Pos.new(nil, 2, 4))
assert.are.same(shrunk.stop, Pos.new(nil, 3, 4)) assert.are.same(shrunk.stop, Pos.new(nil, 3, 4))
assert.has.error( assert.has.error(function() range:must_shrink(100) end, 'error in Range:must_shrink: Range:shrink() returned nil')
function() range:must_shrink(100) end,
'error in Range:must_shrink: Range:shrink() returned nil'
)
end) end)
end) end)
@@ -525,16 +476,10 @@ describe('Range', function()
local r = Range.new(Pos.new(b, 1, 5), Pos.new(b, 1, 9), 'v') local r = Range.new(Pos.new(b, 1, 5), Pos.new(b, 1, 9), 'v')
r:replace 'bleh1' r:replace 'bleh1'
assert.are.same( assert.are.same({ 'The bleh1 brown fox jumps over the lazy dog' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
{ 'The bleh1 brown fox jumps over the lazy dog' },
vim.api.nvim_buf_get_lines(b, 0, -1, false)
)
r:replace 'bleh2' r:replace 'bleh2'
assert.are.same( assert.are.same({ 'The bleh2 brown fox jumps over the lazy dog' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
{ 'The bleh2 brown fox jumps over the lazy dog' },
vim.api.nvim_buf_get_lines(b, 0, -1, false)
)
end) end)
end) end)
@@ -548,17 +493,11 @@ describe('Range', function()
assert.are.same({ 'jumps', 'over' }, r:lines()) assert.are.same({ 'jumps', 'over' }, r:lines())
r:replace 'bleh1' r:replace 'bleh1'
assert.are.same( assert.are.same({ 'The quick brown fox bleh1 the lazy dog' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
{ 'The quick brown fox bleh1 the lazy dog' },
vim.api.nvim_buf_get_lines(b, 0, -1, false)
)
assert.are.same({ 'bleh1' }, r:lines()) assert.are.same({ 'bleh1' }, r:lines())
r:replace 'blehGoo2' r:replace 'blehGoo2'
assert.are.same( assert.are.same({ 'The quick brown fox blehGoo2 the lazy dog' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
{ 'The quick brown fox blehGoo2 the lazy dog' },
vim.api.nvim_buf_get_lines(b, 0, -1, false)
)
end) end)
end) end)

View File

@@ -117,11 +117,7 @@ describe('Renderer', function()
r:render { r:render {
R.h('text', { hl = 'HighlightGroup1' }, { R.h('text', { hl = 'HighlightGroup1' }, {
'Hello', 'Hello',
R.h( R.h('text', { hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } }, ' World'),
'text',
{ hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } },
' World'
),
}), }),
} }

View File

@@ -58,66 +58,66 @@ describe('Signal', function()
describe('Signal:map', function() describe('Signal:map', function()
it('should transform the signal value', function() it('should transform the signal value', function()
local test_signal = Signal:new(5) local signal = Signal:new(5)
local mapped_signal = test_signal:map(function(value) return value * 2 end) local mapped_signal = signal:map(function(value) return value * 2 end)
assert.is.equal(mapped_signal:get(), 10) -- Initial transformation assert.is.equal(mapped_signal:get(), 10) -- Initial transformation
test_signal:set(10) signal:set(10)
assert.is.equal(mapped_signal:get(), 20) -- Updated transformation assert.is.equal(mapped_signal:get(), 20) -- Updated transformation
end) end)
it('should handle empty transformations', function() it('should handle empty transformations', function()
local test_signal = Signal:new(nil) local signal = Signal:new(nil)
local mapped_signal = test_signal:map(function(value) return value or 'default' end) local mapped_signal = signal:map(function(value) return value or 'default' end)
assert.is.equal(mapped_signal:get(), 'default') -- Return default assert.is.equal(mapped_signal:get(), 'default') -- Return default
test_signal:set 'new value' signal:set 'new value'
assert.is.equal(mapped_signal:get(), 'new value') -- Return new value assert.is.equal(mapped_signal:get(), 'new value') -- Return new value
end) end)
end) end)
describe('Signal:filter', function() describe('Signal:filter', function()
it('should only emit values that pass the filter', function() it('should only emit values that pass the filter', function()
local test_signal = Signal:new(5) local signal = Signal:new(5)
local filtered_signal = test_signal:filter(function(value) return value > 10 end) local filtered_signal = signal:filter(function(value) return value > 10 end)
assert.is.equal(filtered_signal:get(), nil) -- Initial value should not pass assert.is.equal(filtered_signal:get(), nil) -- Initial value should not pass
test_signal:set(15) signal:set(15)
assert.is.equal(filtered_signal:get(), 15) -- Now filtered assert.is.equal(filtered_signal:get(), 15) -- Now filtered
test_signal:set(8) signal:set(8)
assert.is.equal(filtered_signal:get(), 15) -- Does not pass the filter assert.is.equal(filtered_signal:get(), 15) -- Does not pass the filter
end) end)
it('should handle empty initial values', function() it('should handle empty initial values', function()
local test_signal = Signal:new(nil) local signal = Signal:new(nil)
local filtered_signal = test_signal:filter(function(value) return value ~= nil end) local filtered_signal = signal:filter(function(value) return value ~= nil end)
assert.is.equal(filtered_signal:get(), nil) -- Should be nil assert.is.equal(filtered_signal:get(), nil) -- Should be nil
test_signal:set(10) signal:set(10)
assert.is.equal(filtered_signal:get(), 10) -- Should pass now assert.is.equal(filtered_signal:get(), 10) -- Should pass now
end) end)
end) end)
describe('create_memo', function() describe('create_memo', function()
it('should compute a derived value and update when dependencies change', function() it('should compute a derived value and update when dependencies change', function()
local test_signal = Signal:new(2) local signal = Signal:new(2)
local memoized_signal = tracker.create_memo(function() return test_signal:get() * 2 end) local memoized_signal = tracker.create_memo(function() return signal:get() * 2 end)
assert.is.equal(memoized_signal:get(), 4) -- Initially compute 2 * 2 assert.is.equal(memoized_signal:get(), 4) -- Initially compute 2 * 2
test_signal:set(3) signal:set(3)
assert.is.equal(memoized_signal:get(), 6) -- Update to 3 * 2 = 6 assert.is.equal(memoized_signal:get(), 6) -- Update to 3 * 2 = 6
test_signal:set(5) signal:set(5)
assert.is.equal(memoized_signal:get(), 10) -- Update to 5 * 2 = 10 assert.is.equal(memoized_signal:get(), 10) -- Update to 5 * 2 = 10
end) end)
it('should not recompute if the dependencies do not change', function() it('should not recompute if the dependencies do not change', function()
local call_count = 0 local call_count = 0
local test_signal = Signal:new(10) local signal = Signal:new(10)
local memoized_signal = tracker.create_memo(function() local memoized_signal = tracker.create_memo(function()
call_count = call_count + 1 call_count = call_count + 1
return test_signal:get() + 1 return signal:get() + 1
end) end)
assert.is.equal(memoized_signal:get(), 11) -- Compute first value assert.is.equal(memoized_signal:get(), 11) -- Compute first value
@@ -126,11 +126,11 @@ describe('Signal', function()
memoized_signal:get() -- Call again, should use memoized value memoized_signal:get() -- Call again, should use memoized value
assert.is.equal(call_count, 1) -- Still should only be one call assert.is.equal(call_count, 1) -- Still should only be one call
test_signal:set(10) -- Set the same value signal:set(10) -- Set the same value
assert.is.equal(memoized_signal:get(), 11) assert.is.equal(memoized_signal:get(), 11)
assert.is.equal(call_count, 2) assert.is.equal(call_count, 2)
test_signal:set(20) signal:set(20)
assert.is.equal(memoized_signal:get(), 21) assert.is.equal(memoized_signal:get(), 21)
assert.is.equal(call_count, 3) assert.is.equal(call_count, 3)
end) end)
@@ -138,31 +138,31 @@ describe('Signal', function()
describe('create_effect', function() describe('create_effect', function()
it('should track changes and execute callback', function() it('should track changes and execute callback', function()
local test_signal = Signal:new(5) local signal = Signal:new(5)
local call_count = 0 local call_count = 0
tracker.create_effect(function() tracker.create_effect(function()
test_signal:get() -- track as a dependency signal:get() -- track as a dependency
call_count = call_count + 1 call_count = call_count + 1
end) end)
assert.is.equal(call_count, 1) assert.is.equal(call_count, 1)
test_signal:set(10) signal:set(10)
assert.is.equal(call_count, 2) assert.is.equal(call_count, 2)
end) end)
it('should clean up signals and not call after dispose', function() it('should clean up signals and not call after dispose', function()
local test_signal = Signal:new(5) local signal = Signal:new(5)
local call_count = 0 local call_count = 0
local unsubscribe = tracker.create_effect(function() local unsubscribe = tracker.create_effect(function()
call_count = call_count + 1 call_count = call_count + 1
return test_signal:get() * 2 return signal:get() * 2
end) end)
assert.is.equal(call_count, 1) -- Initially calls assert.is.equal(call_count, 1) -- Initially calls
unsubscribe() -- Unsubscribe the effect unsubscribe() -- Unsubscribe the effect
test_signal:set(10) -- Update signal value signal:set(10) -- Update signal value
assert.is.equal(call_count, 1) -- Callback should not be called again assert.is.equal(call_count, 1) -- Callback should not be called again
end) end)
end) end)

View File

@@ -1,4 +1,3 @@
require 'luacov'
local function withbuf(lines, f) local function withbuf(lines, f)
vim.go.swapfile = false vim.go.swapfile = false

36
vim.yml Normal file
View File

@@ -0,0 +1,36 @@
---
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