9 Commits

Author SHA1 Message Date
9199a9bc3a convert to mise
Some checks failed
ci / ci (push) Failing after 3m39s
2026-02-07 19:10:26 -07:00
72b6886838 range: extmarks/tsquery; renderer: text-change
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m18s
2025-10-02 20:01:40 -06:00
12945a4cdf (examples/notify.lua) eliminate dependency on non-existent class
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m17s
2025-10-02 19:53:04 -06:00
6f86bfaa42 ensure normal-mode before running g@...
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m21s
2025-06-04 23:00:22 -06:00
81ba1bb96b Use nix to create environment for testing
All checks were successful
NeoVim tests / code-quality (push) Successful in 14m25s
2025-05-19 19:03:24 -06:00
35b6e123ac Fix issue caused by last commit
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 10s
It seems that closures are not interacting well with the global nature
of operatorfunc's. That is just a hunch, and this feels like a hack, but
it fixes the issue.
2025-05-17 07:33:58 -06:00
237bc9ba5e (range) use g@ for Range.from_motion
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 9s
2025-05-09 21:34:44 -06:00
c760c495b7 update lua API to 1-based indices; add renderer
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 11s
2025-05-04 15:44:09 -06:00
44a97b5baa (README.md) point to v2
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 10s
2025-05-04 15:42:46 -06:00
47 changed files with 2557 additions and 771 deletions

13
.busted Normal file
View File

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

22
.emmyrc.json Normal file
View File

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

View File

@@ -1,16 +1,60 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: NeoVim tests
on: [push]
name: ci
on:
workflow_dispatch:
pull_request:
push:
tags: ["*"]
branches: ["*"]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MISE_EXPERIMENTAL: true
jobs:
plenary-tests:
ci:
runs-on: ubuntu-latest
env:
XDG_CONFIG_HOME: ${{ github.workspace }}/.config/
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: v0.11.0
arch: 'x86_64'
- run: make test
submodules: true
- name: Install apt dependencies
run: |
sudo apt-get update
sudo apt-get install -y libreadline-dev
- name: Setup environment
run: |
if [ -n "${{secrets.TOKEN}}" ]; then
export GITHUB_TOKEN="${{secrets.TOKEN}}"
fi
export MISE_GITHUB_TOKEN="$GITHUB_TOKEN"
echo "$GITHUB_TOKEN" >> $GITHUB_ENV
echo "$MISE_GITHUB_TOKEN" >> $GITHUB_ENV
- name: Install mise
run: |
curl https://mise.run | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
- name: Install mise dependencies
run: |
mise install
mise list --local
- name: Check Lua formatting
run: mise run fmt:check
- name: Check for type-errors
run: mise run lint
- name: Run tests
run: mise run test:all

4
.gitignore vendored
View File

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

12
.gitmodules vendored Normal file
View File

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

6
.luacov Normal file
View File

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

16
.luarc.json Normal file
View File

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

1
.styluaignore Normal file
View File

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

10
.woodpecker/ci.yaml Normal file
View File

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

View File

@@ -1,16 +0,0 @@
PLENARY_DIR=~/.local/share/nvim/site/pack/test/opt/plenary.nvim
all: lint test
lint:
lua-language-server --check=lua/u/ --checklevel=Hint
lux check
fmt:
stylua .
test: $(PLENARY_DIR)
NVIM_APPNAME=noplugstest nvim -u NORC --headless -c 'set packpath+=~/.local/share/nvim/site' -c 'packadd plenary.nvim' -c "PlenaryBustedDirectory spec/"
$(PLENARY_DIR):
git clone https://github.com/nvim-lua/plenary.nvim/ $(PLENARY_DIR)

119
README.md
View File

@@ -1,26 +1,40 @@
# u.nvim
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.
Welcome to **u.nvim** - a powerful Lua library designed to enhance your text
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` 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.
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.
## Features
- **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)
- **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).
- **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)
- **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.
- **Operator Key Mapping**: Flexible key mapping that works with the selected text.
- **Text and Position Utilities**: Convenient functions to manage text objects and cursor positions.
- **Operator Key Mapping**: Flexible key mapping that works with the selected
text.
- **Text and Position Utilities**: Convenient functions to manage text objects
and cursor positions.
### Installation
lazy.nvim:
```lua
-- Setting `lazy = true` ensures that the library is only loaded
-- when `require 'u.<utility>' is called.
{ 'jrop/u.nvim', lazy = true }
```
This being a library, and not a proper plugin, it is recommended that you
vendor the specific version of this library that you need, including it in your
code. Package managers are a developing landscape for Lua in the context of
NeoVim. Perhaps in the future, `lux` will eliminate the need to vendor this
library in your application code.
## Signal and Rendering Usage
@@ -111,7 +125,9 @@ end)
### `u.tracker`
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.
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.
```lua
local tracker = require('u.tracker')
@@ -134,7 +150,8 @@ 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
desired state.
**Hyperscript** is just 1) _text_ 2) `<text>` tags, which can be nested in 3) Lua tables for readability:
**Hyperscript** is just 1) _text_ 2) `<text>` tags, which can be nested in 3)
Lua tables for readability:
```lua
local h = require('u.renderer').h
@@ -196,7 +213,8 @@ renderer:render(
)
```
**Rendering**: The renderer library provides a `render` function that takes hyperscript in, and converts it to formatted buffer text:
**Rendering**: The renderer library provides a `render` function that takes
hyperscript in, and converts it to formatted buffer text:
```lua
local Renderer = require('u.renderer').Renderer
@@ -219,22 +237,37 @@ buf:render {
<blockquote>
<del>
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.
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.
</del>
</blockquote>
<br />
<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 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.
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.
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.
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 interop conversions when calling `:api` functions.
As such, this library now uses 1-based indexing everywhere, doing the necessary
interop conversions when calling `:api` functions.
### 1. Creating a Range
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.
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.
```lua
local Range = require 'u.range'
@@ -245,7 +278,9 @@ Range.new(start, stop, 'v') -- charwise selection
Range.new(start, stop, 'V') -- linewise selection
```
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".
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".
```lua
-- get the first line in a buffer:
@@ -260,7 +295,8 @@ Range.from_motion('iW')
Range.from_motion('a"')
-- Get the currently visually selected text:
-- NOTE: this does NOT work within certain contexts; more specialized utilities are more appropriate in certain circumstances
-- NOTE: this does NOT work within certain contexts; more specialized utilities
-- are more appropriate in certain circumstances
Range.from_vtext()
--
@@ -272,7 +308,8 @@ function MyOpFunc(ty)
local range = Range.from_op_func(ty)
-- do something with the range
end
-- Try invoking this with: `<Leader>toaw`, and the current word will be the context:
-- Try invoking this with: `<Leader>toaw`, and the current word will be the
-- context:
vim.keymap.set('<Leader>to', function()
vim.g.operatorfunc = 'v:lua.MyOpFunc'
return 'g@'
@@ -281,7 +318,8 @@ end, { expr = true })
--
-- Commands:
--
-- When executing commands in a visual context, getting the selected text has to be done differently:
-- When executing commands in a visual context, getting the selected text has
-- to be done differently:
vim.api.nvim_create_user_command('MyCmd', function(args)
local range = Range.from_cmd_args(args)
if range == nil then
@@ -292,7 +330,8 @@ vim.api.nvim_create_user_command('MyCmd', function(args)
end, { range = true })
```
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!
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!
```lua
local range = ...
@@ -362,11 +401,11 @@ Access and manipulate buffers easily:
```lua
local Buffer = require 'u.buffer'
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:get_option '...'
buf:set_option('...', ...)
buf:get_var '...'
buf:set_var('...', ...)
buf:all() -- returns a Range representing the entire buffer
buf:is_empty() -- returns true if the buffer has no text
buf:append_line '...'
@@ -381,8 +420,20 @@ buf:txtobj('iw') -- returns a Range representing the text object 'iw' in the giv
Copyright (c) 2024 jrapodaca@gmail.com
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:
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.
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.

View File

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

View File

@@ -10,10 +10,14 @@
-- change on the underlying filesystem.
--------------------------------------------------------------------------------
--- @alias FsDir { kind: 'dir'; path: string; expanded: boolean; children: FsNode[] }
--- @alias FsFile { kind: 'file'; path: string }
--- @alias FsNode FsDir | FsFile
--- @alias ShowOpts { root_path?: string, width?: number, focus_path?: string }
--- @class u.examples.FsDir
--- @field kind 'dir'
--- @field path string
--- @field expanded boolean
--- @field children u.examples.FsNode[]
--- @alias u.examples.FsFile table
--- @alias u.examples.FsNode u.examples.FsDir | u.examples.FsFile
--- @alias u.examples.ShowOpts { root_path?: string, width?: number, focus_path?: string }
local Buffer = require 'u.buffer'
local Renderer = require('u.renderer').Renderer
@@ -44,11 +48,7 @@ end
--- Normalizes the given path to an absolute path.
--- @param path string
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
function H.normalize(path) return vim.fs.abspath(vim.fs.normalize(path)) end
--- Computes the relative path from `base` to `path`.
--- @param path string
@@ -62,13 +62,13 @@ function H.relative(path, base)
end
--- @param root_path string
--- @return { tree: FsDir; path_to_node: table<string, FsNode> }
--- @return { tree: u.examples.FsDir, path_to_node: table<string, u.examples.FsNode> }
function H.get_tree_inf(root_path)
logger:info { 'get_tree_inf', root_path }
--- @type table<string, FsNode>
--- @type table<string, u.examples.FsNode>
local path_to_node = {}
--- @type FsDir
--- @type u.examples.FsDir
local tree = {
kind = 'dir',
path = H.normalize(root_path or '.'),
@@ -81,8 +81,8 @@ function H.get_tree_inf(root_path)
return { tree = tree, path_to_node = path_to_node }
end
--- @param tree FsDir
--- @param path_to_node table<string, FsNode>
--- @param tree u.examples.FsDir
--- @param path_to_node table<string, u.examples.FsNode>
function H.populate_dir_children(tree, path_to_node)
tree.children = {}
@@ -116,16 +116,18 @@ function H.populate_dir_children(tree, path_to_node)
end)
end
--- @param opts {
--- bufnr: number;
--- prev_winnr: number;
--- root_path: string;
--- focus_path?: string;
--- }
--- @class u.examples.RenderOpts
--- @field bufnr number
--- @field prev_winnr number
--- @field root_path string
--- @field focus_path? string
---
--- @return { expand: fun(path: string), collapse: fun(path: string) }
local function _render_in_buffer(opts)
local winnr = vim.api.nvim_buf_call(opts.bufnr, function() return vim.api.nvim_get_current_win() end)
local winnr = vim.api.nvim_buf_call(
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_focused_path = tracker.create_signal(H.normalize(opts.focus_path or opts.root_path))
@@ -136,7 +138,7 @@ local function _render_in_buffer(opts)
local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path))
local path_to_node = tree_inf.path_to_node
--- @param node FsDir
--- @param node u.examples.FsDir
--- @param child_names string[]
local function expand_to(node, child_names)
if #child_names == 0 then return end
@@ -155,19 +157,35 @@ local function _render_in_buffer(opts)
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))
--
-- -- TODO: proper disposal
-- watcher:stop()
-- end)
-- end
-- :help watch-file
--
local watcher = vim.uv.new_fs_event()
if watcher ~= nil then
--- @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 = {}
@@ -295,7 +313,7 @@ local function _render_in_buffer(opts)
--
local renderer = Renderer.new(opts.bufnr)
tracker.create_effect(function()
--- @type { tree: FsDir; path_to_node: table<string, FsNode> }
--- @type { tree: u.examples.FsDir, path_to_node: table<string, u.examples.FsNode> }
local tree_inf = s_tree_inf:get()
local tree = tree_inf.tree
@@ -314,7 +332,7 @@ local function _render_in_buffer(opts)
--- Since the filesystem is a recursive tree of nodes, we need to
--- recursively render each node. This function does just that:
--- @param node FsNode
--- @param node u.examples.FsNode
--- @param level number
local function render_node(node, level)
local name = vim.fs.basename(node.path)
@@ -331,7 +349,11 @@ local function _render_in_buffer(opts)
return ''
end,
n = function()
vim.schedule(function() controller.new(node.kind == 'file' and vim.fs.dirname(node.path) or node.path) end)
vim.schedule(
function()
controller.new(node.kind == 'file' and vim.fs.dirname(node.path) or node.path)
end
)
return ''
end,
r = function()
@@ -351,7 +373,11 @@ local function _render_in_buffer(opts)
local icon = node.expanded and '' or ''
tb:put {
current_line > 1 and '\n',
h('text', { hl = 'Constant', nmap = nmaps }, { string.rep(' ', level), icon, ' ', name }),
h(
'text',
{ hl = 'Constant', nmap = nmaps },
{ string.rep(' ', level), icon, ' ', name }
),
}
if node.expanded then
for _, child in ipairs(node.children) do
@@ -383,15 +409,14 @@ end
-- Public API functions:
--------------------------------------------------------------------------------
--- @type {
--- bufnr: number;
--- winnr: number;
--- controller: { expand: fun(path: string), collapse: fun(path: string) };
--- } | nil
--- @class u.examples.CurrentInf
--- @field bufnr number
--- @field winnr number
--- @field controller table
local current_inf = nil
--- Show the filetree:
--- @param opts? ShowOpts
--- @param opts? u.examples.ShowOpts
function M.show(opts)
if current_inf ~= nil then return current_inf.controller end
opts = opts or {}
@@ -411,8 +436,8 @@ function M.show(opts)
callback = M.hide,
})
vim.wo.number = false
vim.wo.relativenumber = false
vim.wo[0][0].number = false
vim.wo[0][0].relativenumber = false
local bufnr = vim.api.nvim_get_current_buf()
@@ -433,7 +458,7 @@ function M.hide()
end
--- Toggle the filetree:
--- @param opts? ShowOpts
--- @param opts? u.examples.ShowOpts
function M.toggle(opts)
if current_inf == nil then
M.show(opts)

117
examples/form.lua Normal file
View File

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

View File

@@ -1,8 +1,7 @@
local Buffer = require 'u.buffer'
local Renderer = require('u.renderer').Renderer
local TreeBuilder = require('u.renderer').TreeBuilder
local tracker = require 'u.tracker'
local utils = require 'u.utils'
local Window = require 'my.window'
local TIMEOUT = 4000
local ICONS = {
@@ -14,15 +13,24 @@ local ICONS = {
}
local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' }
--- @alias Notification {
--- kind: number;
--- id: number;
--- text: string;
--- }
local S_EDITOR_DIMENSIONS =
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,
})
--- @class u.examples.Notification
--- @field kind number
--- @field id number
--- @field text string
--- @field timer uv.uv_timer_t
local M = {}
--- @type Window | nil
--- @type { win: integer, buf: integer, renderer: u.renderer.Renderer } | nil
local notifs_w
local s_notifications_raw = tracker.create_signal {}
@@ -30,44 +38,49 @@ local s_notifications = s_notifications_raw:debounce(50)
-- Render effect:
tracker.create_effect(function()
--- @type Notification[]
--- @type u.examples.Notification[]
local notifs = s_notifications:get()
--- @type { width: integer, height: integer }
local editor_size = S_EDITOR_DIMENSIONS:get()
if #notifs == 0 then
if notifs_w then
notifs_w:close(true)
if vim.api.nvim_win_is_valid(notifs_w.win) then vim.api.nvim_win_close(notifs_w.win, true) end
notifs_w = nil
end
return
end
vim.schedule(function()
local editor_size = utils.get_editor_dimensions()
local avail_width = editor_size.width
local float_width = 40
local float_height = math.min(#notifs, editor_size.height - 3)
local win_config = {
relative = 'editor',
anchor = 'NE',
row = 0,
col = avail_width,
width = float_width,
height = math.min(#notifs, editor_size.height - 3),
height = float_height,
border = 'single',
focusable = false,
zindex = 900,
}
vim.schedule(function()
if not notifs_w or not vim.api.nvim_win_is_valid(notifs_w.win) then
notifs_w = Window.new(Buffer.create(false, true), win_config)
vim.wo[notifs_w.win].cursorline = false
vim.wo[notifs_w.win].list = false
vim.wo[notifs_w.win].listchars = ''
vim.wo[notifs_w.win].number = false
vim.wo[notifs_w.win].relativenumber = false
vim.wo[notifs_w.win].wrap = false
local b = vim.api.nvim_create_buf(false, true)
local w = vim.api.nvim_open_win(b, false, win_config)
vim.wo[w].cursorline = false
vim.wo[w].list = false
vim.wo[w].listchars = ''
vim.wo[w].number = false
vim.wo[w].relativenumber = false
vim.wo[w].wrap = false
notifs_w = { win = w, buf = b, renderer = Renderer.new(b) }
else
notifs_w:set_config(win_config)
vim.api.nvim_win_set_config(notifs_w.win, win_config)
end
notifs_w:render(TreeBuilder.new()
notifs_w.renderer:render(TreeBuilder.new()
:nest(function(tb)
for idx, notif in ipairs(notifs) do
if idx > 1 then tb:put '\n' end
@@ -79,48 +92,81 @@ tracker.create_effect(function()
end)
:tree())
vim.api.nvim_win_call(notifs_w.win, function()
-- scroll to bottom:
vim.cmd.normal 'G'
-- scroll all the way to the left:
vim.cmd.normal '9999zh'
vim.fn.winrestview {
-- scroll all the way left:
leftcol = 0,
-- set the bottom line to be at the bottom of the window:
topline = vim.api.nvim_buf_line_count(notifs_w.buf) - win_config.height + 1,
}
end)
end)
end)
local _orig_notify
--- @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[]
--- @param id number
local function _delete_notif(id)
--- @param notifs u.examples.Notification[]
s_notifications_raw:schedule_update(function(notifs)
for i, notif in ipairs(notifs) do
if notif.id == id then
notif.timer:stop()
notif.timer:close()
table.remove(notifs, i)
break
end
end
return notifs
end)
end, TIMEOUT)
end
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.examples.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.examples.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
local _once_msgs = {}
local function my_notify_once(msg, level, opts)
function M.notify_once(msg, level, opts)
if vim.tbl_contains(_once_msgs, msg) then return false end
table.insert(_once_msgs, msg)
vim.notify(msg, level, opts)
@@ -130,8 +176,8 @@ end
function M.setup()
if _orig_notify == nil then _orig_notify = vim.notify end
vim.notify = my_notify
vim.notify_once = my_notify_once
vim.notify = M.notify
vim.notify_once = M.notify_once
end
return M

View File

@@ -1,5 +1,5 @@
local utils = require 'u.utils'
local Buffer = require 'u.buffer'
local utils = require 'u.utils'
local Renderer = require('u.renderer').Renderer
local h = require('u.renderer').h
local TreeBuilder = require('u.renderer').TreeBuilder
@@ -7,7 +7,8 @@ local tracker = require 'u.tracker'
local M = {}
local S_EDITOR_DIMENSIONS = tracker.create_signal(utils.get_editor_dimensions(), 's:editor_dimensions')
local S_EDITOR_DIMENSIONS =
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()
@@ -43,26 +44,24 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
-- shortest portion of this function.
--------------------------------------------------------------------------------
--- @alias SelectController {
--- get_items: fun(): T[];
--- set_items: fun(items: T[]);
--- set_filter_text: fun(filter_text: string);
--- get_selected_indices: fun(): number[];
--- get_selected_items: fun(): T[];
--- set_selected_indices: fun(indicies: number[], ephemeral?: boolean);
--- close: fun();
--- }
--- @alias SelectOpts<T> {
--- items: `T`[];
--- multi?: boolean;
--- format_item?: fun(item: T): Tree;
--- on_finish?: fun(items: T[], indicies: number[]);
--- on_selection_changed?: fun(items: T[], indicies: number[]);
--- mappings?: table<string, fun(select: SelectController)>;
--- }
--- @class u.examples.SelectController<T>
--- @field get_items fun(): T[]
--- @field set_items fun(items: T[])
--- @field set_filter_text fun(filter_text: string)
--- @field get_selected_indices fun(): number[]
--- @field get_selected_items fun(): T[]
--- @field set_selected_indices fun(indices: number[], ephemeral?: boolean)
--- @field close fun()
--- @class u.examples.SelectOpts<T>
--- @field items T[]
--- @field multi? boolean
--- @field format_item? fun(item: T): u.renderer.Tree
--- @field on_finish? fun(items: T[], indices: number[])
--- @field on_selection_changed? fun(items: T[], indices: number[])
--- @field mappings? table<string, fun(select: u.examples.SelectController)>
--- @generic T
--- @param opts SelectOpts<T>
--- @param opts u.examples.SelectOpts<T>
function M.create_picker(opts) -- {{{
local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i'
local stopinsert = not is_in_insert_mode
@@ -202,7 +201,9 @@ function M.create_picker(opts) -- {{{
local s_filter_text_undebounced = tracker.create_signal('', 's:filter_text')
w_input_buf:autocmd('TextChangedI', {
callback = safe_wrap(function() s_filter_text_undebounced:set(vim.api.nvim_get_current_line()) end),
callback = safe_wrap(
function() s_filter_text_undebounced:set(vim.api.nvim_get_current_line()) end
),
})
local s_filter_text = s_filter_text_undebounced:debounce(50)
@@ -211,10 +212,15 @@ function M.create_picker(opts) -- {{{
--
local s_formatted_items = tracker.create_memo(function()
local function _format_item(item) return opts.format_item and opts.format_item(item) or tostring(item) end
local function _format_item(item)
return opts.format_item and opts.format_item(item) or tostring(item)
end
local items = s_items:get()
return vim.iter(items):map(function(item) return { item = item, formatted = _format_item(item) } end):totable()
return vim
.iter(items)
:map(function(item) return { item = item, formatted = _format_item(item) } end)
:totable()
end)
-- When the filter text changes, update the filtered items:
@@ -223,8 +229,10 @@ function M.create_picker(opts) -- {{{
local formatted_items = s_formatted_items:get()
local filter_text = vim.trim(s_filter_text:get()):lower()
local filter_pattern = ''
local use_plain_pattern = false
--- @type string
local filter_pattern
--- @type boolean
local use_plain_pattern
if #formatted_items > 250 and #filter_text <= 3 then
filter_pattern = filter_text
use_plain_pattern = true
@@ -247,7 +255,9 @@ function M.create_picker(opts) -- {{{
local new_filtered_items = vim
.iter(formatted_items)
:enumerate()
:map(function(i, inf) return { orig_idx = i, item = inf.item, formatted = inf.formatted } end)
:map(
function(i, inf) return { orig_idx = i, item = inf.item, formatted = inf.formatted } end
)
:filter(function(inf)
if filter_text == '' then return true end
local formatted_as_string = Renderer.markup_to_string({ tree = inf.formatted }):lower()
@@ -255,9 +265,7 @@ function M.create_picker(opts) -- {{{
formatted_strings[inf.orig_idx] = formatted_as_string
if use_plain_pattern then
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
matches[inf.orig_idx] = string.match(formatted_as_string, filter_pattern)
end
@@ -316,11 +324,13 @@ function M.create_picker(opts) -- {{{
safe_wrap(function()
local items = s_items:get()
local selected_indices = s_selected_indices:get()
--- @type { orig_idx: number; item: T }[]
--- @type { orig_idx: number, item: T }[]
local filtered_items = s_filtered_items:get()
local cursor_index = s_cursor_index:get()
local indices = shallow_copy_arr(selected_indices)
if #indices == 0 and #filtered_items > 0 then indices = { filtered_items[cursor_index].orig_idx } end
if #indices == 0 and #filtered_items > 0 then
indices = { filtered_items[cursor_index].orig_idx }
end
return {
items = vim.iter(indices):map(function(i) return items[i] end):totable(),
indices = indices,
@@ -397,8 +407,18 @@ function M.create_picker(opts) -- {{{
end
s_cursor_index:set(next_cursor_index)
end
vim.keymap.set('i', '<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' })
vim.keymap.set(
'i',
'<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 max_line = #s_filtered_items:get()
@@ -406,8 +426,18 @@ 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
s_cursor_index:set(next_cursor_index)
end
vim.keymap.set('i', '<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(
'i',
'<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(
'i',
@@ -417,7 +447,9 @@ function M.create_picker(opts) -- {{{
local index = s_filtered_items:get()[s_cursor_index:get()].orig_idx
if vim.tbl_contains(s_selected_indices:get(), index) then
s_selected_indices:set(vim.iter(s_selected_indices:get()):filter(function(i) return i ~= index end):totable())
s_selected_indices:set(
vim.iter(s_selected_indices:get()):filter(function(i) return i ~= index end):totable()
)
else
local new_selected_indices = shallow_copy_arr(s_selected_indices:get())
table.insert(new_selected_indices, index)
@@ -429,7 +461,12 @@ function M.create_picker(opts) -- {{{
)
for key, fn in pairs(opts.mappings or {}) do
vim.keymap.set('i', key, safe_wrap(function() return fn(controller) end), { buffer = w_input_buf.bufnr })
vim.keymap.set(
'i',
key,
safe_wrap(function() return fn(controller) end),
{ buffer = w_input_buf.bufnr }
)
end
-- Render:
@@ -438,7 +475,7 @@ function M.create_picker(opts) -- {{{
local selected_indices = s_selected_indices:get()
local top_offset = s_top_offset:get()
local cursor_index = s_cursor_index:get()
--- @type { filtered_idx: number; orig_idx: number; item: T; formatted: string }[]
--- @type { filtered_idx: number, orig_idx: number, item: T, formatted: string }[]
local visible_items = s_visible_items:get()
-- The above has to run in the execution context for the signaling to work, but
@@ -499,10 +536,9 @@ function M.create_picker(opts) -- {{{
if ephemeral == nil then ephemeral = false end
if ephemeral and #indicies == 1 then
local matching_filtered_item_idx, _ = vim
.iter(s_filtered_items:get())
:enumerate()
:find(function(_idx, inf) return inf.orig_idx == indicies[1] end)
local matching_filtered_item_idx, _ = vim.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
else
if not opts.multi then
@@ -519,7 +555,7 @@ function M.create_picker(opts) -- {{{
return safe_run(function() H.finish(true) end)
end
return controller --[[@as SelectController]]
return controller --[[@as u.examples.SelectController]]
end -- }}}
--------------------------------------------------------------------------------
@@ -721,7 +757,10 @@ function M.files(opts) -- {{{
-- fast laptop. Show a warning and truncate the list in this case.
if #lines >= opts.limit then
if not job_inf.notified_over_limit then
vim.notify('Picker list is too large (truncating list to ' .. opts.limit .. ' items)', vim.log.levels.WARN)
vim.notify(
'Picker list is too large (truncating list to ' .. opts.limit .. ' items)',
vim.log.levels.WARN
)
pcall(vim.fn.jobstop, job_inf.id)
job_inf.notified_over_limit = true
end
@@ -750,24 +789,21 @@ function M.buffers() -- {{{
-- ensure that `cwd` ends with a trailing slash:
if cwd[#cwd] ~= '/' then cwd = cwd .. '/' end
--- @type { name: string; changed: number; bufnr: number }[]
--- @type { name: string, changed: number, bufnr: number }[]
local bufs = vim.fn.getbufinfo { buflisted = 1 }
M.create_picker {
multi = true,
items = bufs,
--- @param item { name: string; changed: number; bufnr: number }
--- @param item { name: string, changed: number, bufnr: number }
format_item = function(item)
local item_name = item.name
if item_name == '' then item_name = '[No Name]' end
-- trim leading `cwd` from the buffer name:
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,
--- @params items { bufnr: number }[]
@@ -880,7 +916,9 @@ function M.lsp_code_symbols() -- {{{
local item = items[1]
-- Jump to the file/buffer:
local buf = vim.iter(vim.fn.getbufinfo { buflisted = 1 }):find(function(b) return b.name == item.filename end)
local buf = vim
.iter(vim.fn.getbufinfo { buflisted = 1 })
:find(function(b) return b.name == item.filename end)
if buf ~= nil then
vim.api.nvim_win_set_buf(0, buf.bufnr)
else

View File

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

View File

@@ -1,11 +1,12 @@
local vim_repeat = require 'u.repeat'
local Pos = require 'u.pos'
local Range = require 'u.range'
local Buffer = require 'u.buffer'
local CodeWriter = require 'u.codewriter'
local Range = require 'u.range'
local vim_repeat = require 'u.repeat'
local M = {}
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
local surrounds = {
[')'] = { left = '(', right = ')' },
['('] = { left = '( ', right = ' )' },
@@ -20,10 +21,10 @@ local surrounds = {
['`'] = { left = '`', right = '`' },
}
--- @type { left: string; right: string } | nil
--- @type { left: string, right: string } | nil
local CACHED_BOUNDS = nil
--- @return { left: string; right: string }|nil
--- @return { left: string, right: string }|nil
local function prompt_for_bounds()
if vim_repeat.is_repeating() then
-- If we are repeating, we don't want to prompt for bounds, because
@@ -54,7 +55,7 @@ local function prompt_for_bounds()
end
--- @param range u.Range
--- @param bounds { left: string; right: string }
--- @param bounds { left: string, right: string }
local function do_surround(range, bounds)
local left = bounds.left
local right = bounds.right
@@ -126,7 +127,7 @@ function M.setup()
do_surround(range, bounds)
-- this is a visual mapping: end in normal mode:
vim.cmd { cmd = 'normal', args = { '' }, bang = true }
vim.cmd.normal(ESC)
end, { noremap = true, silent = true })
-- Change
@@ -169,29 +170,19 @@ function M.setup()
local irange = Range.from_motion('i' .. from_c, { user_defined = true })
if arange == nil or irange == nil then return end
local lrange = Range.new(arange.start, irange.start:must_next(-1))
local rrange = Range.new(irange.stop:must_next(1), arange.stop)
local lrange, rrange = arange:difference(irange)
if not lrange or not rrange then return end
rrange:replace(to.right)
lrange:replace(to.left)
else
-- replace `from.right` with `to.right`:
local last_line = arange:line(-1):text()
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
local right_text = arange:sub(-1, -#from.right)
right_text:replace(to.right)
-- replace `from.left` with `to.left`:
local first_line = arange:line(1):text()
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
local left_text = arange:sub(1, #from.left)
left_text:replace(to.left)
end
end)
end, { noremap = true, silent = true })

View File

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

1
library/busted Submodule

Submodule library/busted added at 5ed85d0e01

1
library/luv Submodule

Submodule library/luv added at 3615eb12c9

View File

@@ -2,20 +2,24 @@ local Range = require 'u.range'
local Renderer = require('u.renderer').Renderer
--- @class u.Buffer
--- @field bufnr number
--- @field private renderer u.Renderer
--- @field bufnr integer
--- @field b vim.var_accessor
--- @field bo vim.bo
--- @field renderer u.renderer.Renderer
local Buffer = {}
Buffer.__index = Buffer
--- @param bufnr? number
--- @return u.Buffer
function Buffer.from_nr(bufnr)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local renderer = Renderer.new(bufnr)
return setmetatable({
bufnr = bufnr,
b = vim.b[bufnr],
bo = vim.bo[bufnr],
renderer = renderer,
}, { __index = Buffer })
}, Buffer)
end
--- @return u.Buffer
@@ -24,25 +28,15 @@ function Buffer.current() return Buffer.from_nr(0) end
--- @param listed boolean
--- @param scratch boolean
--- @return u.Buffer
function Buffer.create(listed, scratch) return Buffer.from_nr(vim.api.nvim_create_buf(listed, scratch)) end
function Buffer:set_tmp_options()
self:set_option('bufhidden', 'delete')
self:set_option('buflisted', false)
self:set_option('buftype', 'nowrite')
function Buffer.create(listed, scratch)
return Buffer.from_nr(vim.api.nvim_create_buf(listed, scratch))
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:set_tmp_options()
self.bo.bufhidden = 'delete'
self.bo.buflisted = false
self.bo.buftype = 'nowrite'
end
function Buffer:line_count() return vim.api.nvim_buf_line_count(self.bufnr) end
@@ -67,21 +61,71 @@ end
--- @param stop number 1-based line index
function Buffer:lines(start, stop) return Range.from_lines(self.bufnr, start, stop) end
--- @param txt_obj string
--- @param opts? { contains_cursor?: boolean; pos?: u.Pos }
function Buffer:txtobj(txt_obj, opts)
--- @param motion string
--- @param opts? { contains_cursor?: boolean, pos?: u.Pos }
function Buffer:motion(motion, opts)
opts = vim.tbl_extend('force', opts or {}, { bufnr = self.bufnr })
return Range.from_motion(txt_obj, opts)
return Range.from_motion(motion, opts)
end
--- @param event string|string[]
--- @param event vim.api.keyset.events|vim.api.keyset.events[]
--- @diagnostic disable-next-line: undefined-doc-name
--- @param opts vim.api.keyset.create_autocmd
function Buffer:autocmd(event, opts)
vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.bufnr }))
end
--- @param tree Tree
--- @param fn function
function Buffer:call(fn) return vim.api.nvim_buf_call(self.bufnr, fn) end
--- @param tree u.renderer.Tree
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

View File

@@ -5,6 +5,7 @@ local Buffer = require 'u.buffer'
--- @field indent_level number
--- @field indent_str string
local CodeWriter = {}
CodeWriter.__index = CodeWriter
--- @param indent_level? number
--- @param indent_str? string
@@ -18,7 +19,7 @@ function CodeWriter.new(indent_level, indent_str)
indent_level = indent_level,
indent_str = indent_str,
}
setmetatable(cw, { __index = CodeWriter })
setmetatable(cw, CodeWriter)
return cw
end
@@ -31,13 +32,14 @@ end
--- @param line string
--- @param bufnr? number
function CodeWriter.from_line(line, bufnr)
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local ws = line:match '^%s*'
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr })
local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = bufnr })
local indent_level = 0
--- @type number
local indent_level
local indent_str = ''
if expandtab then
while #indent_str < shiftwidth do

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

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

View File

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

View File

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

View File

@@ -2,16 +2,27 @@ local MAX_COL = vim.v.maxcol
--- @param bufnr number
--- @param lnum number 1-based
local function line_text(bufnr, lnum) return vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1] end
local function line_text(bufnr, lnum)
return vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1]
end
--- @class u.Pos
--- @field bufnr number buffer number
--- @field lnum number 1-based line index
--- @field col number 1-based column index
--- @field bufnr integer buffer number
--- @field lnum integer 1-based line index
--- @field col integer 1-based column index
--- @field off number
local Pos = {}
Pos.__index = Pos
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 lnum number 1-based
--- @param col number 1-based
@@ -20,56 +31,68 @@ Pos.MAX_COL = MAX_COL
function Pos.new(bufnr, lnum, col, off)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
if off == nil then off = 0 end
local pos = {
--- @type u.Pos
return setmetatable({
bufnr = bufnr,
lnum = lnum,
col = col,
off = off,
}
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
}, Pos)
end
--- @param bufnr? number
--- @param lnum0 number 1-based
--- @param col0 number 1-based
--- @param off? number
function Pos.from00(bufnr, lnum0, col0, off) return Pos.new(bufnr, lnum0 + 1, col0 + 1, off) end
--- @param bufnr? number
--- @param lnum0 number 1-based
--- @param col1 number 1-based
--- @param off? number
function Pos.from01(bufnr, lnum0, col1, off) return Pos.new(bufnr, lnum0 + 1, col1, off) end
--- @param bufnr? number
--- @param lnum1 number 1-based
--- @param col0 number 1-based
--- @param off? number
function Pos.from10(bufnr, lnum1, col0, off) return Pos.new(bufnr, lnum1, col0 + 1, off) end
function Pos.invalid() return Pos.new(0, 0, 0, 0) end
function Pos.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.__le(a, b) return a < b or a == b end
function Pos.__eq(a, b) return Pos.is(a) and Pos.is(b) and a.bufnr == b.bufnr and a.lnum == b.lnum and a.col == b.col end
function Pos.__eq(a, b)
return getmetatable(a) == Pos
and getmetatable(b) == Pos
and a.bufnr == b.bufnr
and a.lnum == b.lnum
and a.col == b.col
end
function Pos.__add(x, y)
if type(x) == 'number' then
x, y = y, x
end
if not Pos.is(x) or type(y) ~= 'number' then return nil end
if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end
return x:next(y)
end
function Pos.__sub(x, y)
if type(x) == 'number' then
x, y = y, x
end
if not Pos.is(x) or type(y) ~= 'number' then return nil end
if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end
return x:next(-y)
end
--- @param bufnr number
--- @param lnum number
function Pos.from_eol(bufnr, lnum)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local pos = Pos.new(bufnr, lnum, 0)
pos.col = pos:line():len()
return pos
end
--- @param name string
--- @return u.Pos
function Pos.from_pos(name)
@@ -77,7 +100,7 @@ function Pos.from_pos(name)
return Pos.new(p[1], p[2], p[3], p[4])
end
function Pos:is_invalid() return self.bufnr == 0 and self.lnum == 0 and self.col == 0 and self.off == 0 end
function Pos:is_invalid() return self.lnum == 0 and self.col == 0 and self.off == 0 end
function Pos:clone() return Pos.new(self.bufnr, self.lnum, self.col, self.off) end
@@ -99,9 +122,16 @@ end
function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end
function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end
--- @param pos string
function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end
--- @param winnr? integer
function Pos:save_to_cursor(winnr)
vim.api.nvim_win_set_cursor(winnr or 0, { self.lnum, self.col - 1 })
end
--- @param mark string
function Pos:save_to_mark(mark)
local p = self:as_real()
@@ -202,7 +232,9 @@ end
--- @return u.Pos|nil
function Pos:find_match(max_chars, invocations)
if invocations == nil then invocations = {} end
if vim.tbl_contains(invocations, function(p) return self == p end, { predicate = true }) then return nil end
if vim.tbl_contains(invocations, function(p) return self == p end, { predicate = true }) then
return nil
end
table.insert(invocations, self)
local openers = { '{', '[', '(', '<' }
@@ -212,7 +244,10 @@ function Pos:find_match(max_chars, invocations)
local is_closer = vim.tbl_contains(closers, c)
if not is_opener and not is_closer then return nil end
local i, _ = vim.iter(is_opener and openers or closers):enumerate():find(function(_, c2) return c == c2 end)
local i, _ = vim
.iter(is_opener and openers or closers)
:enumerate()
:find(function(_, c2) return c == c2 end)
-- Store the character we will be looking for:
local c_match = (is_opener and closers or openers)[i]
@@ -255,7 +290,14 @@ end
--- @param lines string|string[]
function Pos:insert_before(lines)
if type(lines) == 'string' then lines = vim.split(lines, '\n') end
vim.api.nvim_buf_set_text(self.bufnr, self.lnum - 1, self.col - 1, self.lnum - 1, self.col - 1, lines)
vim.api.nvim_buf_set_text(
self.bufnr,
self.lnum - 1,
self.col - 1,
self.lnum - 1,
self.col - 1,
lines
)
end
return Pos

View File

@@ -1,29 +1,16 @@
local Extmark = require 'u.extmark'
local Pos = require 'u.pos'
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
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
local NS = vim.api.nvim_create_namespace 'u.range'
--- @class u.Range
--- @field start u.Pos
--- @field stop u.Pos|nil
--- @field mode 'v'|'V'
local Range = {}
--- @param start u.Pos
--- @param stop u.Pos|nil
--- @param mode? 'v'|'V'
--- @return u.Range
function Range.new(start, stop, mode)
if stop ~= nil and stop < start then
start, stop = stop, start
end
local r = { start = start, stop = stop, mode = mode or 'v' }
local function str()
Range.__index = Range
function Range.__tostring(self)
--- @param p u.Pos
local function posstr(p)
if p == nil then
@@ -35,17 +22,49 @@ function Range.new(start, stop, mode)
end
end
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)
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 stop u.Pos|nil
--- @param mode? 'v'|'V'
--- @return u.Range
function Range.new(start, stop, mode)
if stop ~= nil and stop < start then
start, stop = stop, start
end
setmetatable(r, { __index = Range, __tostring = str })
local r = { start = start, stop = stop, mode = mode or 'v' }
setmetatable(r, Range)
return r
end
function Range.is(x)
local mt = getmetatable(x)
return mt and mt.__index == Range
--- @param ranges (u.Range|nil)[]
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
--- @param lpos string
@@ -92,81 +111,111 @@ function Range.from_lines(bufnr, start_line, stop_line)
return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, Pos.MAX_COL), 'V')
end
--- @param text_obj string
--- @param opts? { bufnr?: number; contains_cursor?: boolean; pos?: u.Pos, user_defined?: boolean }
--- @param motion string
--- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean }
--- @return u.Range|nil
function Range.from_motion(text_obj, opts)
function Range.from_motion(motion, opts)
-- Options handling:
opts = opts or {}
if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end
if opts.bufnr == nil or opts.bufnr == 0 then opts.bufnr = vim.api.nvim_get_current_buf() end
if opts.contains_cursor == nil then opts.contains_cursor = false end
if opts.user_defined == nil then opts.user_defined = false end
--- @type "a" | "i"
local selection_type = text_obj:sub(1, 1)
local obj_type = text_obj:sub(#text_obj, #text_obj)
local is_quote = vim.tbl_contains({ "'", '"', '`' }, obj_type)
local cursor = Pos.from_pos '.'
-- Extract some information from the motion:
--- @type 'a'|'i', string
local scope, motion_rest = motion:sub(1, 1), motion:sub(2)
local is_txtobj = scope == 'a' or scope == 'i'
local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest)
--- @type u.Pos
local start
--- @type u.Pos
local stop
vim.api.nvim_buf_call(opts.bufnr, function()
-- Capture the original state of the buffer for restoration later.
local original_state = {
winview = vim.fn.winsaveview(),
regquote = vim.fn.getreg '"',
posdot = vim.fn.getpos '.',
poslb = vim.fn.getpos "'[",
posrb = vim.fn.getpos "']",
cursor = vim.fn.getpos '.',
pos_lbrack = 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()
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
Pos.invalid():save_to_pos "'["
Pos.invalid():save_to_pos "']"
local prev_on_yank_enabled = on_yank_enabled
on_yank_enabled = false
_G.Range__from_motion_opfunc = function(ty)
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
end
local old_eventignore = vim.o.eventignore
vim.o.eventignore = 'all'
vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc'
vim.cmd {
cmd = 'normal',
bang = not opts.user_defined,
args = { '""y' .. text_obj },
args = { ESC .. 'g@' .. motion },
mods = { silent = true },
}
on_yank_enabled = prev_on_yank_enabled
start = Pos.from_pos "'["
stop = Pos.from_pos "']"
vim.o.eventignore = old_eventignore
end)
local captured_range = _G.Range__from_motion_opfunc_captured_range
-- Restore original state:
vim.fn.winrestview(original_state.winview)
vim.fn.setreg('"', original_state.regquote)
vim.fn.setpos('.', original_state.posdot)
vim.fn.setpos("'[", original_state.poslb)
vim.fn.setpos("']", original_state.posrb)
vim.fn.setpos('.', original_state.cursor)
vim.fn.setpos("'[", original_state.pos_lbrack)
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
-- Fixup the bounds:
if
-- I have no idea why, but when yanking `i"`, the stop-mark is
-- placed on the ending quote. For other text-objects, the stop-
-- mark is placed before the closing character.
(is_quote and selection_type == 'i' and stop:char() == obj_type)
(is_quote_txtobj and scope == 'i' and captured_range.stop:char() == motion_rest)
-- *Sigh*, this also sometimes happens for `it` as well.
or (text_obj == 'it' and stop:char() == '<')
or (motion == 'it' and captured_range.stop:char() == '<')
then
stop = stop:next(-1) or stop
captured_range.stop = captured_range.stop:next(-1) or captured_range.stop
end
end)
if start == stop and start:is_invalid() then return nil end
if opts.contains_cursor and not Range.new(start, stop):contains(cursor) then return nil end
if is_quote and selection_type == 'a' then
start = start:find_next(1, obj_type) or start
stop = stop:find_next(-1, obj_type) or stop
if is_quote_txtobj and scope == 'a' then
captured_range.start = captured_range.start:find_next(1, motion_rest) or captured_range.start
captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop
end
return Range.new(start, stop)
if
opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor)))
then
return nil
end
return captured_range
end
--- @param opts? { contains_cursor?: boolean }
function Range.from_tsquery_caps(bufnr, query, opts)
opts = opts or { contains_cursor = true }
local ranges = Range.from_buf_text(bufnr):tsquery(query)
if not ranges then return end
if not opts.contains_cursor then return ranges end
local cursor = Pos.from_pos '.'
return vim.tbl_map(function(cap_ranges)
return vim
.iter(cap_ranges)
:filter(
--- @param r u.Range
function(r) return r:contains(cursor) end
)
:totable()
end, ranges)
end
--- Get range information from the currently selected visual text.
@@ -196,22 +245,24 @@ end
--- @param args unknown
--- @return u.Range|nil
function Range.from_cmd_args(args)
--- @type 'v'|'V'
local mode
--- @type nil|u.Pos
local start
local stop
if args.range == 0 then
return nil
else
start = Pos.from_pos "'<"
stop = Pos.from_pos "'>"
mode = stop:is_col_max() and 'V' or 'v'
if args.range == 0 then return nil end
local bufnr = vim.api.nvim_get_current_buf()
if args.range == 1 then
return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, Pos.MAX_COL), 'V')
end
local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]]
--- @type 'v'|'V'
local mode = is_visual and vim.fn.visualmode() or 'V'
if is_visual then
return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode)
else
return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, Pos.MAX_COL), mode)
end
return Range.new(start, stop, mode)
end
---
function Range.find_nearest_brackets()
return Range.smallest {
Range.from_motion('a<', { contains_cursor = true }),
@@ -229,26 +280,15 @@ function Range.find_nearest_quotes()
}
end
--- @param ranges (u.Range|nil)[]
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
--------------------------------------------------------------------------------
-- Structural utilities:
--------------------------------------------------------------------------------
-- 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
function Range:clone()
return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode)
end
function Range:clone() return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode) end
function Range:line_count()
if self:is_empty() then return 0 end
return self.stop.lnum - self.start.lnum + 1
end
function Range:is_empty() return self.stop == nil end
function Range:to_linewise()
local r = self:clone()
@@ -260,7 +300,142 @@ function Range:to_linewise()
return r
end
function Range:is_empty() return self.stop == nil end
function Range:to_charwise()
local r = self:clone()
r.mode = 'v'
if r.stop:is_col_max() then r.stop = r.stop:as_real() end
return r
end
--- @param x u.Pos | u.Range
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)
self.start:save_to_pos(left);
(self:is_empty() and self.start or self.stop):save_to_pos(right)
end
--- @param left string
--- @param right string
function Range:save_to_marks(left, right)
self.start:save_to_mark(left);
(self:is_empty() and self.start or self.stop):save_to_mark(right)
end
function Range:save_to_extmark() return Extmark.from_range(self, NS) 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
--------------------------------------------------------------------------------
-- Text access/manipulation utilities:
--------------------------------------------------------------------------------
--- @param query string
function Range:tsquery(query)
local bufnr = self.start.bufnr
local lang = vim.treesitter.language.get_lang(vim.bo[bufnr].filetype)
if lang == nil then return end
local parser = vim.treesitter.get_parser(bufnr, lang)
if parser == nil then return end
local tree = parser:parse()[1]
if tree == nil then return end
local root = tree:root()
local q = vim.treesitter.query.parse(lang, query)
--- @type table<string, u.Range[]>
local ranges = {}
for id, match, _meta in
q:iter_captures(root, bufnr, self.start.lnum - 1, (self.stop or self.start).lnum)
do
local start_row0, start_col0, stop_row0, stop_col0 = match:range()
local range = Range.new(
Pos.new(bufnr, start_row0 + 1, start_col0 + 1),
Pos.new(bufnr, stop_row0 + 1, stop_col0),
'v'
)
if range.stop.lnum > vim.api.nvim_buf_line_count(bufnr) then
range.stop = range.stop:must_next(-1)
end
local capture_name = q.captures[id]
if not ranges[capture_name] then ranges[capture_name] = {} end
if self:contains(range) then table.insert(ranges[capture_name], range) end
end
return ranges
end
function Range:length()
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()
if self:is_empty() then return end
@@ -286,8 +461,46 @@ function Range:trim_stop()
return r
end
--- @param p u.Pos
function Range:contains(p) return not self:is_empty() and p >= self.start and p <= self.stop end
--- @param i number 1-based
--- @param j? number 1-based
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[]
function Range:lines()
@@ -298,17 +511,15 @@ end
--- @return string
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
--- @return { line: string; idx0: { start: number; stop: number; }; lnum: number; range: fun():u.Range; text: fun():string }|nil
-- luacheck: ignore
--- @return { line: string, idx0: { start: number, stop: number }, lnum: number, range: fun():u.Range, text: fun():string }|nil
function Range:line(l)
if l < 0 then l = self:line_count() + l + 1 end
if l > self:line_count() then return end
local line_indices = vim.fn.getregionpos(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
local line_indices =
vim.fn.getregionpos(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
local line_bounds = line_indices[l]
local start = Pos.new(unpack(line_bounds[1]))
@@ -327,7 +538,9 @@ function Range:replace(replacement)
local function update_stop_non_linewise()
local new_last_line_num = self.start.lnum + #replacement - 1
local new_last_col = #(replacement[#replacement] or '')
if new_last_line_num == self.start.lnum then new_last_col = new_last_col + self.start.col - 1 end
if new_last_line_num == self.start.lnum then
new_last_col = new_last_col + self.start.col - 1
end
self.stop = Pos.new(bufnr, new_last_line_num, new_last_col)
end
local function update_stop_linewise()
@@ -397,48 +610,12 @@ end
--- @param amount number
function Range:must_shrink(amount)
local shrunk = self:shrink(amount)
if shrunk == nil or shrunk:is_empty() then error 'error in Range:must_shrink: Range:shrink() returned nil' end
if shrunk == nil or shrunk:is_empty() then
error 'error in Range:must_shrink: Range:shrink() returned nil'
end
return shrunk
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 opts? { timeout?: number, priority?: number, on_macro?: boolean }
function Range:highlight(group, opts)
@@ -452,8 +629,8 @@ function Range:highlight(group, opts)
local ns = vim.api.nvim_create_namespace ''
local winview = vim.fn.winsaveview();
(vim.hl or vim.highlight).range(
local winview = vim.fn.winsaveview()
vim.hl.range(
self.start.bufnr,
ns,
group,

View File

@@ -1,32 +1,63 @@
function _G.URendererOpFuncSwallow() end
local ENABLE_LOG = false
local function log(...)
if not ENABLE_LOG then return end
local f = assert(io.open(vim.fs.joinpath(vim.fn.stdpath 'log', 'u.renderer.log'), 'a+'))
f:write(os.date() .. '\t' .. vim.inspect { ... } .. '\n')
f:close()
end
local M = {}
local H = {}
--- @alias Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: Tree }
--- @alias Node nil | boolean | string | Tag
--- @alias Tree Node | Node[]
--- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string
--- @param name string
--- @param attributes? table<string, any>
--- @param children? Node | Node[]
--- @return Tag
function M.h(name, attributes, children)
--- @alias u.renderer.TagAttributes { [string]?: unknown, imap?: table<string, u.renderer.TagEventHandler>, nmap?: table<string, u.renderer.TagEventHandler>, vmap?: table<string, u.renderer.TagEventHandler>, xmap?: table<string, u.renderer.TagEventHandler>, omap?: table<string, u.renderer.TagEventHandler>, on_change?: fun(text: string): unknown }
--- @class u.renderer.Tag
--- @field kind 'tag'
--- @field name string
--- @field attributes u.renderer.TagAttributes
--- @field children u.renderer.Tree
--- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
-- luacheck: ignore
--- @type table<string, fun(attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>
M.h = setmetatable({}, {
__call = function(_, name, attributes, children)
return {
kind = 'tag',
name = name,
attributes = attributes or {},
children = children,
}
end
end,
__index = function(_, name)
return function(attributes, children)
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end
end,
})
-- Renderer {{{
--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
--- @class u.renderer.RendererExtmark
--- @field id? number
--- @field start [number, number]
--- @field stop [number, number]
--- @field opts vim.api.keyset.set_extmark
--- @field tag u.renderer.Tag
--- @class u.Renderer
--- @class u.renderer.Renderer
--- @field bufnr number
--- @field ns number
--- @field changedtick number
--- @field old { lines: string[]; extmarks: RendererExtmark[] }
--- @field curr { lines: string[]; extmarks: RendererExtmark[] }
--- @field old { lines: string[], extmarks: u.renderer.RendererExtmark[] }
--- @field curr { lines: string[], extmarks: u.renderer.RendererExtmark[] }
local Renderer = {}
Renderer.__index = Renderer
M.Renderer = Renderer
@@ -45,7 +76,7 @@ function Renderer.is_tag_arr(x)
end
--- @param bufnr number|nil
function Renderer.new(bufnr) -- {{{
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
if vim.b[bufnr]._renderer_ns == nil then
vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('my.renderer:' .. tostring(bufnr))
@@ -58,13 +89,18 @@ function Renderer.new(bufnr) -- {{{
old = { lines = {}, extmarks = {} },
curr = { lines = {}, extmarks = {} },
}, Renderer)
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, {
buffer = bufnr,
callback = function() self:_on_text_changed() end,
})
return self
end -- }}}
--- @param opts {
--- tree: Tree;
--- on_tag?: fun(tag: Tag, start0: [number, number], stop0: [number, number]): any;
--- }
--- @class u.renderer.MarkupOpts
--- @field tree u.renderer.Tree
--- @field on_tag? fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any
function Renderer.markup_to_lines(opts) -- {{{
--- @type string[]
local lines = {}
@@ -82,7 +118,7 @@ function Renderer.markup_to_lines(opts) -- {{{
curr_col1 = 1
end
--- @param node Node
--- @param node u.renderer.Node
local function visit(node) -- {{{
if node == nil or type(node) == 'boolean' then return end
@@ -98,12 +134,12 @@ function Renderer.markup_to_lines(opts) -- {{{
-- visit the children:
if Renderer.is_tag_arr(node.children) then
for _, child in
ipairs(node.children --[[@as Node[] ]])
ipairs(node.children --[[@as u.renderer.Node[] ]])
do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child)
end
else
else -- luacheck: ignore
visit(node.children)
end
@@ -121,13 +157,70 @@ function Renderer.markup_to_lines(opts) -- {{{
return lines
end -- }}}
--- @param opts {
--- tree: string;
--- format_tag?: fun(tag: Tag): string;
--- }
--- @class u.renderer.StringOpts
--- @field tree u.renderer.Tree
--- @field format_tag? fun(tag: u.renderer.Tag): string
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
--- @param tree Tree
--- @param bufnr number
--- @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) -- {{{
local changedtick = vim.b[self.bufnr].changedtick
if changedtick ~= self.changedtick then
@@ -135,7 +228,7 @@ function Renderer:render(tree) -- {{{
self.changedtick = changedtick
end
--- @type RendererExtmark[]
--- @type u.renderer.RendererExtmark[]
local extmarks = {}
--- @type string[]
@@ -150,101 +243,69 @@ function Renderer:render(tree) -- {{{
tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl
end
local extmark = tag.attributes.extmark
local extmark_opts = tag.attributes.extmark or {}
-- Set any necessary keymaps:
for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do
-- Force creating an extmark if there are key handlers. To accurately
-- sense the bounds of the text, we need an extmark:
extmark = extmark or {}
vim.keymap.set(
'n',
lhs,
function() return self:_expr_map_callback('n', lhs) end,
{ buffer = self.bufnr, expr = true, replace_keycodes = true }
)
vim.keymap.set(mode, lhs, function()
local result = self:_expr_map_callback(mode, lhs)
-- If the handler indicates that it wants to swallow the event,
-- we have to convert that intention into something compatible
-- with expr-mappings, which don't support '<Nop>' (they try to
-- execute the literal characters). We'll use the 'g@' operator
-- to do that, forwarding the event to an operatorfunc that does
-- nothing:
if result == '' then
if mode == 'i' then
return ''
else
vim.go.operatorfunc = 'v:lua.URendererOpFuncSwallow'
return 'g@ '
end
end
return result
end, { buffer = self.bufnr, expr = true, replace_keycodes = true })
end
end
if extmark then
table.insert(extmarks, {
start = start0,
stop = stop0,
opts = extmark,
opts = extmark_opts,
tag = tag,
})
end
end
end, -- }}}
}
self.old = self.curr
self.curr = { lines = lines, extmarks = extmarks }
self:_reconcile()
vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } }
end -- }}}
--- @private
--- @param start integer
--- @param end_ integer
--- @param strict_indexing boolean
--- @param replacement string[]
function Renderer:_set_lines(start, end_, strict_indexing, replacement)
vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement)
end
--- @private
--- @param start_row integer
--- @param start_col integer
--- @param end_row integer
--- @param end_col integer
--- @param replacement string[]
function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement)
vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement)
end
--- @private
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:
--
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
Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines)
self.changedtick = vim.b[self.bufnr].changedtick
--
-- Step 2: reconcile extmarks:
-- You may be tempted to try to keep track of which extmarks are needed, and
-- only delete those that are not needed. However, each time a tree is
-- rendered, brand new extmarks are created. For simplicity, it is better to
-- just delete all extmarks, and recreate them.
--
-- Clear current extmarks:
vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1)
-- Set current extmarks:
for _, extmark in ipairs(self.curr.extmarks) do
extmark.id = vim.api.nvim_buf_set_extmark(
@@ -256,9 +317,18 @@ function Renderer:_reconcile() -- {{{
id = extmark.id,
end_row = extmark.stop[1],
end_col = extmark.stop[2],
-- If we change the text starting from the beginning (where the extmark
-- is), we don't want the extmark to move to the right.
right_gravity = false,
-- If we change the text starting from the end (where the end extmark
-- is), we don't want the extmark to stay stationary: we want it to
-- move to the right.
end_right_gravity = true,
}, extmark.opts)
)
end
self.old = self.curr
end -- }}}
--- @private
@@ -268,7 +338,9 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
-- find the tag with the smallest intersection that contains the cursor:
local pos0 = vim.api.nvim_win_get_cursor(0)
pos0[1] = pos0[1] - 1 -- make it actually 0-based
local pos_infos = self:get_pos_infos(pos0)
log('_expr_map_callback: pos0:', pos0)
local pos_infos = self:get_tags_at(pos0)
log('_expr_map_callback: pos_infos:', pos_infos)
if #pos_infos == 0 then return lhs end
@@ -278,9 +350,10 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
local tag = pos_info.tag
-- is the tag listening?
--- @type u.renderer.TagEventHandler?
local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs)
if type(f) == 'function' then
local result = f()
local result = f(tag, mode, lhs)
if result == '' then
-- bubble-up to the next tag, but set cancel to true, in case there are
-- no more tags to bubble up to:
@@ -295,25 +368,198 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
return cancel and '' or lhs
end -- }}}
function Renderer:_on_text_changed() -- {{{
-- Reset changedtick, so that the reconciler knows to refresh its cached
-- buffer-content before computing the diff:
self.changedtick = 0
--- @type integer, integer
local l, c = unpack(vim.api.nvim_win_get_cursor(0))
l = l - 1 -- make it actually 0-based
local pos_infos = self:get_tags_at({ l, c }, 'i')
log('_on_text_changed', { cursor_0_0 = { l, c }, pos_infos = pos_infos })
for _, pos_info in ipairs(pos_infos) do
local extmark_inf = pos_info.extmark
local tag = pos_info.tag
local on_change = tag.attributes.on_change
if on_change and type(on_change) == 'function' then
local extmark =
vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.ns, extmark_inf.id, { details = true })
--- @type integer, integer, vim.api.keyset.extmark_details
local start_row0, start_col0, details = unpack(extmark)
local end_row0, end_col0 = details.end_row, details.end_col
log('_on_text_changed: fetched current extmark for pos_info', {
pos_info = pos_info,
curr_extmark = {
start_row0 = start_row0,
start_col0 = start_col0,
end_row0 = end_row0,
end_col0 = end_col0,
details = details,
},
})
if start_row0 == end_row0 and start_col0 == end_col0 then
on_change ''
return
end
local buf_max_line0 = math.max(1, vim.api.nvim_buf_line_count(self.bufnr) - 1)
if end_row0 > buf_max_line0 then
end_row0 = buf_max_line0
local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1]
or ''
end_col0 = last_line:len()
end
if end_col0 == 0 then
end_row0 = end_row0 - 1
local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1]
or ''
end_col0 = last_line:len()
end
log('_on_text_changed: after position correction', {
curr_extmark = {
start_row0 = start_row0,
start_col0 = start_col0,
end_row0 = end_row0,
end_col0 = end_col0,
},
})
if start_row0 == end_row0 and start_col0 == end_col0 then
on_change ''
return
end
local pos1 = { self.bufnr, start_row0 + 1, start_col0 + 1 }
local pos2 = { self.bufnr, end_row0 + 1, end_col0 }
local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' })
if not ok then
log('_on_text_changed: getregion: invalid-pos ', {
{ pos1, pos2 },
})
vim.api.nvim_echo({
{ '(u.nvim:getregion:invalid-pos) ', 'ErrorMsg' },
{
'{ start, end } = ' .. vim.inspect({ pos1, pos2 }, { newline = ' ', indent = '' }),
},
}, true, {})
error(lines)
end
local text = table.concat(lines, '\n')
on_change(text)
end
end
end -- }}}
--- @private
function Renderer:_debug() -- {{{
local prev_w = vim.api.nvim_get_current_win()
vim.cmd.vnew()
local info_bufnr = vim.api.nvim_get_current_buf()
vim.bo.bufhidden = 'delete'
vim.bo.buflisted = false
vim.bo.buftype = 'nowrite'
local ids = {}
local function cleanup()
for _, id in ipairs(ids) do
vim.api.nvim_del_autocmd(id)
end
vim.api.nvim_buf_delete(info_bufnr, { force = true })
end
local function autocmd_callback()
if vim.api.nvim_get_current_win() ~= prev_w then return end
local l, c = unpack(vim.api.nvim_win_get_cursor(0))
l = l - 1 -- make it actually 0-based
local info = {
cursor = {
pos = { l, c },
tags = self:get_tags_at { l, c },
extmarks = vim.api.nvim_buf_get_extmarks(
self.bufnr,
self.ns,
{ l, c },
{ l, c },
{ details = true, overlap = true }
),
},
computed = {
extmarks = self.curr.extmarks,
},
}
vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n'))
end
table.insert(
ids,
vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
callback = autocmd_callback,
})
)
table.insert(
ids,
vim.api.nvim_create_autocmd({ 'User' }, {
pattern = 'Renderer:' .. tostring(self.bufnr) .. ':render',
callback = autocmd_callback,
})
)
table.insert(
ids,
vim.api.nvim_create_autocmd('WinClosed', {
pattern = tostring(vim.api.nvim_get_current_win()),
callback = cleanup,
})
)
table.insert(
ids,
vim.api.nvim_create_autocmd('WinClosed', {
pattern = tostring(prev_w),
callback = cleanup,
})
)
vim.api.nvim_set_current_win(prev_w)
end -- }}}
--- Returns pairs of extmarks and tags associate with said extmarks. The
--- returned tags/extmarks are sorted smallest (innermost) to largest
--- (outermost).
---
--- @private (private for now)
--- @param pos0 [number; number]
--- @return { extmark: RendererExtmark; tag: Tag; }[]
function Renderer:get_pos_infos(pos0) -- {{{
--- @param pos0 [number, number]
--- @param mode string?
--- @return { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[]
function Renderer:get_tags_at(pos0, mode) -- {{{
local cursor_line0, cursor_col0 = pos0[1], pos0[2]
if not mode then mode = vim.api.nvim_get_mode().mode end
mode = mode:sub(1, 1) -- we don't care about sub-modes
local raw_overlapping_extmarks = vim.api.nvim_buf_get_extmarks(
self.bufnr,
self.ns,
pos0,
pos0,
{ details = true, overlap = true }
)
log(
'get_tags_at: context:',
{ pos0 = pos0, mode = mode, raw_overlapping_extmarks = raw_overlapping_extmarks }
)
-- The cursor (block) occupies **two** extmark spaces: one for it's left
-- edge, and one for it's right. We need to do our own intersection test,
-- because the NeoVim API is over-inclusive in what it returns:
--- @type RendererExtmark[]
local intersecting_extmarks = vim
.iter(vim.api.nvim_buf_get_extmarks(self.bufnr, self.ns, pos0, pos0, { details = true, overlap = true }))
--- @return RendererExtmark
--- @type u.renderer.RendererExtmark[]
local mapped_extmarks = vim
.iter(raw_overlapping_extmarks)
:map(function(ext)
--- @type number, number, number, { end_row?: number; end_col?: number }|nil
--- @type number, number, number, { end_row?: number, end_col?: number }|nil
local id, line0, col0, details = unpack(ext)
local start = { line0, col0 }
local stop = { line0, col0 }
@@ -322,13 +568,42 @@ function Renderer:get_pos_infos(pos0) -- {{{
end
return { id = id, start = start, stop = stop, opts = details }
end)
--- @param ext RendererExtmark
:totable()
local intersecting_extmarks = vim
.iter(mapped_extmarks)
:filter(function(ext)
if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
return cursor_line0 >= ext.start[1]
and cursor_col0 >= ext.start[2]
-- If we've "ciw" and "collapsed" an extmark onto the cursor,
-- the cursor pos will equal the exmark's start AND end. In this
-- case, we want to include the extmark.
if
cursor_line0 == ext.start[1]
and cursor_col0 == ext.start[2]
and cursor_line0 == ext.stop[1]
and cursor_col0 == ext.stop[2]
then
return true
end
return
-- START: line check
cursor_line0 >= ext.start[1]
-- START: column check
and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2])
-- STOP: line check
and cursor_line0 <= ext.stop[1]
and cursor_col0 < ext.stop[2]
-- STOP: column check
and (
cursor_line0 ~= ext.stop[1]
or (
mode == 'i'
-- In insert mode, the cursor is "thin", so <= to compensate:
and cursor_col0 <= ext.stop[2]
-- In normal mode, the cursor is "wide", so < to compensate:
or cursor_col0 < ext.stop[2]
)
)
else
return true
end
@@ -338,8 +613,8 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- Sort the tags into smallest (inner) to largest (outer):
table.sort(
intersecting_extmarks,
--- @param x1 RendererExtmark
--- @param x2 RendererExtmark
--- @param x1 u.renderer.RendererExtmark
--- @param x2 u.renderer.RendererExtmark
function(x1, x2)
if
x1.start[1] == x2.start[1]
@@ -361,10 +636,9 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- 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
-- of tags that we need to fire events for:
--- @type { extmark: RendererExtmark; tag: Tag }[]
--- @type { extmark: u.renderer.RendererExtmark, tag: u.renderer.Tag }[]
local matching_tags = vim
.iter(intersecting_extmarks)
--- @param ext RendererExtmark
:map(function(ext)
for _, extmark_cache in ipairs(self.curr.extmarks) do
if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end
@@ -374,11 +648,24 @@ function Renderer:get_pos_infos(pos0) -- {{{
return matching_tags
end -- }}}
--- @private
--- @param tag_or_id string | u.renderer.Tag
--- @return { start: [number, number], stop: [number, number] } | nil
function Renderer:get_tag_bounds(tag_or_id) -- {{{
for _, x in ipairs(self.curr.extmarks) do
local pos = { start = x.start, stop = x.stop }
local does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id
or x.tag == tag_or_id
if does_tag_match then return pos end
end
end -- }}}
-- }}}
-- TreeBuilder {{{
--- @class u.TreeBuilder
--- @field private nodes Node[]
--- @class u.renderer.TreeBuilder
--- @field private nodes u.renderer.Node[]
local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder
M.TreeBuilder = TreeBuilder
@@ -388,8 +675,8 @@ function TreeBuilder.new()
return self
end
--- @param nodes Tree
--- @return u.TreeBuilder
--- @param nodes u.renderer.Tree
--- @return u.renderer.TreeBuilder
function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes)
return self
@@ -397,16 +684,16 @@ end
--- @param name string
--- @param attributes? table<string, any>
--- @param children? Node | Node[]
--- @return u.TreeBuilder
--- @param children? u.renderer.Node | u.renderer.Node[]
--- @return u.renderer.TreeBuilder
function TreeBuilder:put_h(name, attributes, children)
local tag = M.h(name, attributes, children)
table.insert(self.nodes, tag)
return self
end
--- @param fn fun(TreeBuilder): any
--- @return u.TreeBuilder
--- @param fn fun(tb: u.renderer.TreeBuilder): any
--- @return u.renderer.TreeBuilder
function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new()
fn(nested_writer)
@@ -414,18 +701,40 @@ function TreeBuilder:nest(fn)
return self
end
--- @return Tree
--- @generic T
--- @param arr T[]
--- @param f fun(tb: u.renderer.TreeBuilder, item: T, idx: number): any
function TreeBuilder:ipairs(arr, f)
return self:nest(function(tb)
for idx, item in ipairs(arr) do
f(tb, item, idx)
end
end)
end
--- @param tab table
--- @param f fun(tb: u.renderer.TreeBuilder, key: any, value: any): any
function TreeBuilder:pairs(tab, f)
return self:nest(function(tb)
for k, v in pairs(tab) do
f(tb, k, v)
end
end)
end
--- @return u.renderer.Tree
function TreeBuilder:tree() return self.nodes end
-- }}}
-- Levenshtein utility {{{
--- @alias LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
-- luacheck: ignore
--- @alias u.renderer.LevenshteinChange<T> ({ kind: 'add', item: T, index: number } | { kind: 'delete', item: T, index: number } | { kind: 'change', from: T, to: T, index: number })
--- @private
--- @generic T
--- @param x `T`[]
--- @param y T[]
--- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; }
--- @return LevenshteinChange<T>[]
--- @param cost? { of_delete?: (fun(x: T): number), of_add?: (fun(x: T): number), of_change?: (fun(x: T, y: T): number) }
--- @return u.renderer.LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index).
function H.levenshtein(x, y, cost)
-- 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
@@ -465,10 +774,10 @@ function H.levenshtein(x, y, cost)
if x[i] == y[j] then
dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
else
local costDelete = dp[i - 1][j] + cost_of_delete_f(x[i])
local costAdd = dp[i][j - 1] + cost_of_add_f(y[j])
local costChange = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j])
dp[i][j] = math.min(costDelete, costAdd, costChange)
local cost_delete = dp[i - 1][j] + cost_of_delete_f(x[i])
local cost_add = 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])
dp[i][j] = math.min(cost_delete, cost_add, cost_change)
end
end
end
@@ -476,7 +785,7 @@ function H.levenshtein(x, y, cost)
-- Backtrack to find the changes
local i = m
local j = n
--- @type LevenshteinChange[]
--- @type u.renderer.LevenshteinChange[]
local changes = {}
while i > 0 or j > 0 do
@@ -493,7 +802,7 @@ function H.levenshtein(x, y, cost)
if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change
if x[i] ~= y[j] then
--- @type LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'change', from = x[i], index = i, to = y[j] }
table.insert(changes, change)
end
@@ -501,13 +810,13 @@ function H.levenshtein(x, y, cost)
j = j - 1
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition
--- @type LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'add', item = y[j], index = i + 1 }
table.insert(changes, change)
j = j - 1
elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
-- deletion
--- @type LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'delete', item = x[i], index = i }
table.insert(changes, change)
i = i - 1

View File

@@ -6,19 +6,19 @@ M.debug = false
-- class Signal
--------------------------------------------------------------------------------
--- @class u.Signal
--- @class u.Signal<T>
--- @field name? string
--- @field private changing boolean
--- @field private value any
--- @field private value T
--- @field private subscribers table<function, boolean>
--- @field private on_dispose_callbacks function[]
local Signal = {}
M.Signal = Signal
Signal.__index = Signal
--- @param value any
--- @param value `T`
--- @param name? string
--- @return u.Signal
--- @return u.Signal<T>
function Signal:new(value, name)
local obj = setmetatable({
name = name,
@@ -30,14 +30,17 @@ function Signal:new(value, name)
return obj
end
--- @param value any
--- @param value T
function Signal:set(value)
self.value = value
-- We don't handle cyclic updates:
if self.changing then
if M.debug then
vim.notify('circular dependency detected' .. (self.name and (' in ' .. self.name) or ''), vim.log.levels.WARN)
vim.notify(
'circular dependency detected' .. (self.name and (' in ' .. self.name) or ''),
vim.log.levels.WARN
)
end
return
end
@@ -64,11 +67,12 @@ function Signal:set(value)
end
end
--- @param value T
function Signal:schedule_set(value)
vim.schedule(function() self:set(value) end)
end
--- @return any
--- @return T
function Signal:get()
local ctx = M.ExecutionContext.current()
if ctx then ctx:track(self) end
@@ -82,8 +86,8 @@ function Signal:update(fn) self:set(fn(self.value)) end
function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end
--- @generic U
--- @param fn fun(value: T): U
--- @return u.Signal --<U>
--- @param fn fun(value: T): `U`
--- @return u.Signal<U>
function Signal:map(fn)
local mapped_signal = M.create_memo(function()
local value = self:get()
@@ -92,13 +96,13 @@ function Signal:map(fn)
return mapped_signal
end
--- @return u.Signal
--- @return u.Signal<T>
function Signal:clone()
return self:map(function(x) return x end)
end
--- @param fn fun(value: T): boolean
--- @return u.Signal -- <T>
--- @return u.Signal<T>
function Signal:filter(fn)
local filtered_signal = M.create_signal(nil, self.name and self.name .. ':filtered' or nil)
local unsubscribe_from_self = self:subscribe(function(value)
@@ -109,10 +113,10 @@ function Signal:filter(fn)
end
--- @param ms number
--- @return u.Signal -- <T>
--- @return u.Signal<T>
function Signal:debounce(ms)
local function set_timeout(timeout, callback)
local timer = (vim.uv or vim.loop).new_timer()
local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create new timer')
timer:start(timeout, 0, function()
timer:stop()
timer:close()
@@ -124,7 +128,7 @@ function Signal:debounce(ms)
local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil)
--- @diagnostic disable-next-line: undefined-doc-name
--- @type { queued: { value: T, ts: number }[]; timer?: uv_timer_t; }
--- @type { queued: { value: T, ts: number }[], timer?: uv.uv_timer_t }
local state = { queued = {}, timer = nil }
local function clear_timeout()
if state.timer == nil then return end
@@ -143,7 +147,10 @@ function Signal:debounce(ms)
local now_ms = (vim.uv or vim.loop).hrtime() / 1e6
-- If there is anything older than `ms` in our queue, emit it:
local older_than_ms = vim.iter(state.queued):filter(function(item) return now_ms - item.ts > ms end):totable()
local older_than_ms = vim
.iter(state.queued)
:filter(function(item) return now_ms - item.ts > ms end)
:totable()
local last_older_than_ms = older_than_ms[#older_than_ms]
if last_older_than_ms then
filtered:set(last_older_than_ms.value)
@@ -196,7 +203,8 @@ end
-- class ExecutionContext
--------------------------------------------------------------------------------
CURRENT_CONTEXT = nil
--- @type u.ExecutionContext|nil
local CURRENT_CONTEXT = nil
--- @class u.ExecutionContext
--- @field signals table<u.Signal, boolean>
@@ -205,7 +213,7 @@ M.ExecutionContext = ExecutionContext
ExecutionContext.__index = ExecutionContext
--- @return u.ExecutionContext
function ExecutionContext:new()
function ExecutionContext.new()
return setmetatable({
signals = {},
subscribers = {},
@@ -216,7 +224,7 @@ function ExecutionContext.current() return CURRENT_CONTEXT end
--- @param fn function
--- @param ctx u.ExecutionContext
function ExecutionContext:run(fn, ctx)
function ExecutionContext.run(fn, ctx)
local oldCtx = CURRENT_CONTEXT
CURRENT_CONTEXT = ctx
local result
@@ -256,16 +264,18 @@ end
-- Helpers
--------------------------------------------------------------------------------
--- @param value any
--- @generic T
--- @param value `T`
--- @param name? string
--- @return u.Signal
--- @return u.Signal<T>
function M.create_signal(value, name) return Signal:new(value, name) end
--- @param fn function
--- @generic T
--- @param fn fun(): `T`
--- @param name? string
--- @return u.Signal
function M.create_memo(fn, name)
--- @type u.Signal
--- @type u.Signal<T> | nil
local result
local unsubscribe = M.create_effect(function()
local value = fn()
@@ -276,15 +286,15 @@ function M.create_memo(fn, name)
result = M.create_signal(value, name and ('m.s:' .. name) or nil)
end
end, name)
result:on_dispose(unsubscribe)
return result
assert(result):on_dispose(unsubscribe)
return assert(result)
end
--- @param fn function
--- @param name? string
function M.create_effect(fn, name)
local ctx = M.ExecutionContext:new()
M.ExecutionContext:run(fn, ctx)
local ctx = M.ExecutionContext.new()
M.ExecutionContext.run(fn, ctx)
return ctx:subscribe(function()
if name and M.debug then
local deps = vim

View File

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

View File

@@ -1,19 +0,0 @@
package = "u.nvim"
version = "0.1.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"

107
mise.toml Normal file
View File

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

1
nvimv Submodule

Submodule nvimv added at bd5c243b96

6
scripts/env.sh Normal file
View File

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

View File

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

23
shell.nix Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

16
u-0.0.0-0.rockspec Normal file
View File

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

36
vim.yml
View File

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