1 Commits

Author SHA1 Message Date
ff04acb158 experimental: renderer
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 9s
2025-03-12 23:12:40 -06:00
37 changed files with 1524 additions and 2123 deletions

13
.busted
View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,21 +1,15 @@
all: lint fmt-check test
PLENARY_DIR=~/.local/share/nvim/site/pack/test/opt/plenary.nvim
all: lint test
lint:
@echo "## Typechecking"
@lua-language-server --check=lua/u/ --checklevel=Error
fmt-check:
@echo "## Checking code format"
@stylua --check .
selene .
fmt:
@echo "## Formatting code"
@stylua .
stylua .
test:
@rm -f luacov.*.out
@echo "## Running tests"
@busted --coverage --verbose
@echo "## Generating coverage report"
@luacov
@awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out
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)

162
README.md
View File

@@ -1,40 +1,26 @@
# u.nvim
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.
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.
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.
## 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
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.
lazy.nvim:
```lua
-- Setting `lazy = true` ensures that the library is only loaded
-- when `require 'u.<utility>' is called.
{ 'jrop/u.nvim', lazy = true }
```
## Signal and Rendering Usage
@@ -125,9 +111,7 @@ 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')
@@ -150,8 +134,7 @@ The renderer library renders hyperscript into a buffer. Each render performs a
minimal set of changes in order to transform the current buffer text into the
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
@@ -213,8 +196,7 @@ 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
@@ -235,81 +217,49 @@ buf:render {
### A note on indices
<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.
</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.
As such, this library now uses 1-based indexing everywhere, doing the necessary
interop conversions when calling `:api` functions.
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.
### 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'
local start = Pos.new(0, 1, 1) -- Line 1, first column
local stop = Pos.new(0, 3, 1) -- Line 3, first column
local start = Pos.new(0, 0, 0) -- Line 1, first column
local stop = Pos.new(0, 2, 0) -- Line 3, first column
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:
Range.from_line(bufnr, 1)
Range.from_line(0, 0)
-- Text Objects (any text object valid in your configuration is supported):
-- get the word the cursor is on:
Range.from_motion('iw')
Range.from_text_object('iw')
-- get the WORD the cursor is on:
Range.from_motion('iW')
Range.from_text_object('iW')
-- get the "..." the cursor is within:
Range.from_motion('a"')
Range.from_text_object('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()
--
-- Get the operated on text obtained from a motion:
-- (HINT: use the opkeymap utility to make this less verbose)
--
--- @param ty 'char'|'line'|'block'
---@param ty 'char'|'line'|'block'
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@'
@@ -318,8 +268,7 @@ 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
@@ -330,15 +279,14 @@ 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 = ...
range:lines() -- get the lines in the range's region
range:text() -- get the text (i.e., string) in the range's region
range:line(1) -- get the first line within this range
range:line(-1) -- get the last line within this range
range:line0(0) -- get the first line within this range
range:line0(-1) -- get the last line within this range
-- replace with new contents:
range:replace {
'replacement line 1',
@@ -385,11 +333,11 @@ Simply by returning a `Range` or a `Pos`, you can easily and quickly define
your own text objects:
```lua
local txtobj = require 'u.txtobj'
local utils = require 'u.utils'
local Range = require 'u.range'
-- Select whole file:
txtobj.define('ag', function()
utils.define_text_object('ag', function()
return Range.from_buf_text()
end)
```
@@ -401,39 +349,27 @@ 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:all() -- returns a Range representing the entire buffer
buf:is_empty() -- returns true if the buffer has no text
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 '...'
buf:line(1) -- returns a Range representing the first line in the buffer
buf:line(-1) -- returns a Range representing the last line in the buffer
buf:lines(1, 2) -- returns a Range representing the first two lines in the buffer
buf:lines(2, -2) -- returns a Range representing all but the first and last lines of a buffer
buf:txtobj('iw') -- returns a Range representing the text object 'iw' in the give buffer
buf:line0(0) -- returns a Range representing the first line in the buffer
buf:line0(-1) -- returns a Range representing the last line in the buffer
buf:lines(0, 1) -- returns a Range representing the first two lines in the buffer
buf:lines(1, -2) -- returns a Range representing all but the first and last lines of a buffer
buf:text_object('iw') -- returns a Range representing the text object 'iw' in the give buffer
```
## License (MIT)
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

@@ -21,8 +21,6 @@ local TreeBuilder = require('u.renderer').TreeBuilder
local h = require('u.renderer').h
local tracker = require 'u.tracker'
local logger = require('u.logger').Logger.new 'filetree'
local M = {}
local H = {}
@@ -44,7 +42,11 @@ end
--- Normalizes the given path to an absolute path.
--- @param path string
function H.normalize(path) return vim.fs.abspath(vim.fs.normalize(path)) end
function H.normalize(path)
path = vim.fs.normalize(path)
if path:sub(1, 1) ~= '/' then path = vim.fs.joinpath(vim.uv.cwd(), path) end
return vim.fs.normalize(path)
end
--- Computes the relative path from `base` to `path`.
--- @param path string
@@ -60,7 +62,6 @@ end
--- @param root_path string
--- @return { tree: FsDir; path_to_node: table<string, FsNode> }
function H.get_tree_inf(root_path)
logger:info { 'get_tree_inf', root_path }
--- @type table<string, FsNode>
local path_to_node = {}
@@ -91,7 +92,7 @@ function H.populate_dir_children(tree, path_to_node)
kind = 'dir',
path = child_path,
expanded = prev_node and prev_node.expanded or false,
children = prev_node and prev_node.children or {},
children = {},
}
path_to_node[new_node.path] = new_node
table.insert(tree.children, new_node)
@@ -121,10 +122,7 @@ end
---
--- @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))
@@ -154,35 +152,19 @@ 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))
--
-- :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,
})
-- -- TODO: proper disposal
-- watcher:stop()
-- end)
-- end
local controller = {}
@@ -346,11 +328,7 @@ 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()
@@ -370,11 +348,7 @@ 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
@@ -434,8 +408,8 @@ function M.show(opts)
callback = M.hide,
})
vim.wo[0][0].number = false
vim.wo[0][0].relativenumber = false
vim.wo.number = false
vim.wo.relativenumber = false
local bufnr = vim.api.nvim_get_current_buf()

View File

@@ -1,184 +0,0 @@
local Renderer = require('u.renderer').Renderer
local TreeBuilder = require('u.renderer').TreeBuilder
local tracker = require 'u.tracker'
local utils = require 'u.utils'
local TIMEOUT = 4000
local ICONS = {
[vim.log.levels.TRACE] = { text = '󰃤', group = 'DiagnosticSignOk' },
[vim.log.levels.DEBUG] = { text = '󰃤', group = 'DiagnosticSignOk' },
[vim.log.levels.INFO] = { text = '', group = 'DiagnosticSignInfo' },
[vim.log.levels.WARN] = { text = '', group = 'DiagnosticSignWarn' },
[vim.log.levels.ERROR] = { text = '', group = 'DiagnosticSignError' },
}
local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' }
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,
})
--- @alias u.example.Notification {
--- kind: number;
--- id: number;
--- text: string;
--- timer: uv.uv_timer_t;
--- }
local M = {}
--- @type { win: integer, buf: integer, renderer: u.Renderer } | nil
local notifs_w
local s_notifications_raw = tracker.create_signal {}
local s_notifications = s_notifications_raw:debounce(50)
-- Render effect:
tracker.create_effect(function()
--- @type u.example.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
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
local avail_width = editor_size.width
local float_width = 40
local float_height = math.min(#notifs, editor_size.height - 3)
local win_config = {
relative = 'editor',
anchor = 'NE',
row = 0,
col = avail_width,
width = float_width,
height = float_height,
border = 'single',
focusable = false,
zindex = 900,
}
vim.schedule(function()
if not notifs_w or not vim.api.nvim_win_is_valid(notifs_w.win) then
local b = vim.api.nvim_create_buf(false, true)
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
vim.api.nvim_win_set_config(notifs_w.win, win_config)
end
notifs_w.renderer:render(TreeBuilder.new()
:nest(function(tb)
for idx, notif in ipairs(notifs) do
if idx > 1 then tb:put '\n' end
local notif_icon = ICONS[notif.kind] or DEFAULT_ICON
tb:put_h('text', { hl = notif_icon.group }, notif_icon.text)
tb:put { ' ', notif.text }
end
end)
:tree())
vim.api.nvim_win_call(notifs_w.win, function()
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)
--- @param id number
local function _delete_notif(id)
--- @param notifs u.example.Notification[]
s_notifications_raw:schedule_update(function(notifs)
for i, notif in ipairs(notifs) do
if notif.id == id then
notif.timer:stop()
notif.timer:close()
table.remove(notifs, i)
break
end
end
return notifs
end)
end
local _orig_notify
--- @param msg string
--- @param level integer|nil
--- @param opts? { id: number }
function M.notify(msg, level, opts)
if level == nil then level = vim.log.levels.INFO end
opts = opts or {}
local id = opts.id or math.random(999999999)
--- @type u.example.Notification?
local notif = vim.iter(s_notifications_raw:get()):find(function(n) return n.id == id end)
if not notif then
-- Create a new notification (maybe):
if vim.trim(msg) == '' then return id end
if level < vim.log.levels.INFO then return id end
local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create timer')
timer:start(TIMEOUT, 0, function() _delete_notif(id) end)
notif = {
id = id,
kind = level,
text = msg,
timer = timer,
}
--- @param notifs u.example.Notification[]
s_notifications_raw:schedule_update(function(notifs)
table.insert(notifs, notif)
return notifs
end)
else
-- Update an existing notification:
s_notifications_raw:schedule_update(function(notifs)
-- We already have a copy-by-reference of the notif we want to modify:
notif.timer:stop()
notif.text = msg
notif.kind = level
notif.timer:start(TIMEOUT, 0, function() _delete_notif(id) end)
return notifs
end)
end
return id
end
local _once_msgs = {}
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)
return true
end
function M.setup()
if _orig_notify == nil then _orig_notify = vim.notify end
vim.notify = M.notify
vim.notify_once = M.notify_once
end
return M

View File

@@ -7,8 +7,7 @@ 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()
@@ -17,8 +16,8 @@ vim.api.nvim_create_autocmd('VimResized', {
})
--- @param low number
--- @param x number
--- @param high number
---@param x number
---@param high number
local function clamp(low, x, high)
x = math.max(low, x)
x = math.min(x, high)
@@ -56,7 +55,7 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
--- @alias SelectOpts<T> {
--- items: `T`[];
--- multi?: boolean;
--- format_item?: fun(item: T): Tree;
--- 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)>;
@@ -64,7 +63,7 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
--- @generic T
--- @param opts SelectOpts<T>
function M.create_picker(opts) -- {{{
function M.create_picker(opts)
local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i'
local stopinsert = not is_in_insert_mode
@@ -146,19 +145,17 @@ function M.create_picker(opts) -- {{{
col = s_w_input_coords:get().col,
relative = 'editor',
focusable = true,
border = vim.o.winborder or 'rounded',
border = 'rounded',
}
local w_input_buf = Buffer.create(false, true)
local w_input = vim.api.nvim_open_win(w_input_buf.bufnr, false, w_input_cfg)
vim.wo[w_input][0].cursorline = false
vim.wo[w_input][0].list = false
vim.wo[w_input][0].number = false
vim.wo[w_input][0].relativenumber = false
local w_input = vim.api.nvim_open_win(w_input_buf.buf, false, w_input_cfg)
vim.wo[w_input].number = false
vim.wo[w_input].relativenumber = false
-- The following option is a signal to other plugins like 'cmp' to not mess
-- with this buffer:
vim.bo[w_input_buf.bufnr].buftype = 'prompt'
vim.fn.prompt_setprompt(w_input_buf.bufnr, '')
vim.bo[w_input_buf.buf].buftype = 'prompt'
vim.fn.prompt_setprompt(w_input_buf.buf, '')
vim.api.nvim_set_current_win(w_input)
tracker.create_effect(safe_wrap(function()
@@ -177,10 +174,10 @@ function M.create_picker(opts) -- {{{
border = 'rounded',
}
local w_list_buf = Buffer.create(false, true)
local w_list = vim.api.nvim_open_win(w_list_buf.bufnr, false, w_list_cfg)
vim.wo[w_list][0].number = false
vim.wo[w_list][0].relativenumber = false
vim.wo[w_list][0].scrolloff = 0
local w_list = vim.api.nvim_open_win(w_list_buf.buf, false, w_list_cfg)
vim.wo[w_list].number = false
vim.wo[w_list].relativenumber = false
vim.wo[w_list].scrolloff = 0
tracker.create_effect(safe_wrap(function()
-- update window position/size every time the editor is resized:
w_list_cfg = vim.tbl_deep_extend('force', w_list_cfg, s_w_list_coords:get())
@@ -203,9 +200,7 @@ 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)
@@ -214,78 +209,42 @@ 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:
local s_filtered_items = tracker.create_memo(
safe_wrap(function()
local formatted_items = s_formatted_items:get()
local filter_text = vim.trim(s_filter_text:get()):lower()
--- @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
elseif #formatted_items > 1000 then
filter_pattern = filter_text
use_plain_pattern = true
else
filter_pattern = '('
.. vim.iter(vim.split(filter_text, '')):map(function(c) return c .. '.*' end):join ''
.. ')'
use_plain_pattern = false
end
filter_pattern = filter_pattern:lower()
--- @type table<integer, string>
local formatted_strings = {}
--- @type table<integer, string>
local matches = {}
local filter_text = vim.trim(s_filter_text:get())
local filter_pattern = (#formatted_items > 250 and #filter_text <= 3) and (filter_text:gsub('%.', '%%.'))
or vim.iter(vim.split(filter_text, '')):map(function(c) return c .. '.*' end):join ''
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()
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
else
matches[inf.orig_idx] = string.match(formatted_as_string, filter_pattern)
end
return matches[inf.orig_idx] ~= nil
local formatted_as_string = Renderer.markup_to_string { tree = inf.formatted }
local match = string.match(Renderer.markup_to_string { tree = formatted_as_string }, filter_pattern)
return match ~= nil
end)
:totable()
-- Don't sort if there are over 500 items:
if #new_filtered_items <= 500 then
-- Don't do the levenshtein-sort if there are over 250 items:
if #new_filtered_items <= 250 then
table.sort(new_filtered_items, function(a_inf, b_inf)
local a = formatted_strings[a_inf.orig_idx]
local b = formatted_strings[b_inf.orig_idx]
local a = Renderer.markup_to_string { markup = a_inf.formatted }
local b = Renderer.markup_to_string { markup = b_inf.formatted }
if a == b then return false end
local a_match = matches[a_inf.orig_idx]
local b_match = matches[b_inf.orig_idx]
return #a_match < #b_match
local lev_a = utils.levenshtein(vim.split(filter_text, ''), vim.split(a, ''))
local lev_b = utils.levenshtein(vim.split(filter_text, ''), vim.split(b, ''))
return #lev_a < #lev_b
end)
end
@@ -329,10 +288,8 @@ function M.create_picker(opts) -- {{{
--- @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
local indices = shallow_copy_arr(selected_indices) -- shallow copy
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,
@@ -356,7 +313,7 @@ function M.create_picker(opts) -- {{{
-- "invalid window ID" errors):
H.unsubscribe_render_effect()
-- buftype=prompt buffers are not "temporary", so delete the buffer manually:
vim.api.nvim_buf_delete(w_input_buf.bufnr, { force = true })
vim.api.nvim_buf_delete(w_input_buf.buf, { force = true })
-- The following is not needed, since the buffer is deleted above:
-- vim.api.nvim_win_close(w_input, false)
vim.api.nvim_win_close(w_list, false)
@@ -397,9 +354,9 @@ function M.create_picker(opts) -- {{{
--
-- Events
--
vim.keymap.set('i', '<Esc>', function() H.finish(true) end, { buffer = w_input_buf.bufnr })
vim.keymap.set('i', '<Esc>', function() H.finish(true) end, { buffer = w_input_buf.buf })
vim.keymap.set('i', '<CR>', function() H.finish() end, { buffer = w_input_buf.bufnr })
vim.keymap.set('i', '<CR>', function() H.finish() end, { buffer = w_input_buf.buf })
local function action_next_line()
local max_line = #s_filtered_items:get()
@@ -409,36 +366,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.buf, desc = 'Picker: next' })
local function action_prev_line()
local max_line = #s_filtered_items:get()
local next_cursor_index = clamp(1, s_cursor_index:get() - 1, max_line)
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' }
safe_wrap(function()
local max_line = #s_filtered_items:get()
local next_cursor_index = clamp(1, s_cursor_index:get() - 1, max_line)
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),
{ buffer = w_input_buf.buf, desc = 'Picker: previous' }
)
vim.keymap.set(
@@ -449,9 +388,7 @@ 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)
@@ -459,16 +396,11 @@ function M.create_picker(opts) -- {{{
end
action_next_line()
end),
{ buffer = w_input_buf.bufnr }
{ buffer = w_input_buf.buf }
)
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.buf })
end
-- Render:
@@ -512,7 +444,7 @@ function M.create_picker(opts) -- {{{
return safe_run(function() return s_items_raw:get() end)
end
--- @param items T[]
---@param items T[]
function controller.set_items(items)
return safe_run(function() s_items_raw:set(items) end)
end
@@ -532,15 +464,16 @@ function M.create_picker(opts) -- {{{
end
--- @param indicies number[]
--- @param ephemeral? boolean
---@param ephemeral? boolean
function controller.set_selected_indices(indicies, ephemeral)
return safe_run(function()
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
@@ -558,7 +491,7 @@ function M.create_picker(opts) -- {{{
end
return controller --[[@as SelectController]]
end -- }}}
end
--------------------------------------------------------------------------------
-- END create_picker
@@ -575,7 +508,6 @@ function M.register_ui_select()
--- @param items `T`[]
--- @param opts { prompt?: string, kind?: any, format_item?: fun(item: T):string }
--- @param cb fun(item: T|nil):any
--- @diagnostic disable-next-line: duplicate-set-field
function vim.ui.select(items, opts, cb)
M.create_picker {
items = items,
@@ -591,12 +523,7 @@ function M.register_ui_select()
}
end
end
function M.unregister_ui_select()
if not ORIGINAL_UI_SELECT then return end
vim.ui.select = ORIGINAL_UI_SELECT
ORIGINAL_UI_SELECT = nil
end
function M.unregister_ui_select() vim.ui.select = ORIGINAL_UI_SELECT end
--------------------------------------------------------------------------------
-- Built-in pickers
@@ -606,7 +533,7 @@ end
--------------------------------------------------------------------------------
--- @param opts? { limit?: number }
function M.files(opts) -- {{{
function M.files(opts)
opts = opts or {}
opts.limit = opts.limit or 10000
@@ -759,11 +686,7 @@ 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
)
pcall(vim.fn.jobstop, job_inf.id)
vim.notify('Picker list is too large (truncating list to ' .. opts.limit .. ' items)', vim.log.levels.WARN)
job_inf.notified_over_limit = true
end
return
@@ -784,10 +707,10 @@ function M.files(opts) -- {{{
set_lines_as_items_state()
end),
})
end -- }}}
end
function M.buffers() -- {{{
local cwd = vim.fn.getcwd()
function M.buffers()
local cwd = vim.fn.getcwd(0, 0)
-- ensure that `cwd` ends with a trailing slash:
if cwd[#cwd] ~= '/' then cwd = cwd .. '/' end
@@ -805,7 +728,10 @@ function M.buffers() -- {{{
-- 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 }[]
@@ -886,10 +812,10 @@ function M.buffers() -- {{{
end,
},
}
end -- }}}
end
local IS_CODE_SYMBOL_RUNNING = false
function M.lsp_code_symbols() -- {{{
function M.lsp_code_symbols()
if IS_CODE_SYMBOL_RUNNING then return end
IS_CODE_SYMBOL_RUNNING = true
@@ -918,9 +844,7 @@ 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
@@ -938,12 +862,13 @@ function M.lsp_code_symbols() -- {{{
-- Kick off the async operation:
vim.lsp.buf.document_symbol { on_list = STEPS._1_on_symbols }
end -- }}}
end
function M.setup()
utils.ucmd('Files', M.files)
utils.ucmd('Buffers', M.buffers)
utils.ucmd('Lspcodesymbols', M.lsp_code_symbols)
M.register_ui_select()
end
return M

View File

@@ -4,9 +4,9 @@ local Range = require 'u.range'
local M = {}
--- @param bracket_range u.Range
--- @param left string
--- @param right string
---@param bracket_range Range
---@param left string
---@param right string
local function split(bracket_range, left, right)
local code = CodeWriter.from_pos(bracket_range.start)
code:write_raw(left)
@@ -52,21 +52,17 @@ local function split(bracket_range, left, right)
bracket_range:replace(code.lines)
end
--- @param bracket_range u.Range
--- @param left string
--- @param right string
---@param bracket_range Range
---@param left string
---@param right string
local function join(bracket_range, left, right)
local inner_range = bracket_range:shrink(1)
if inner_range then
local newline = vim
.iter(inner_range:lines())
:map(function(l) return vim.trim(l) end)
:filter(function(l) return l ~= '' end)
:join ' '
bracket_range:replace { left .. newline .. right }
else
bracket_range:replace { left .. right }
end
local inner_range = Range.new(bracket_range.start:must_next(), bracket_range.stop:must_next(-1), bracket_range.mode)
local newline = vim
.iter(inner_range:lines())
:map(function(l) return vim.trim(l) end)
:filter(function(l) return l ~= '' end)
:join ' '
bracket_range:replace { left .. newline .. right }
end
local function splitjoin()
@@ -84,7 +80,7 @@ local function splitjoin()
end
function M.setup()
vim.keymap.set('n', 'gS', function() vim_repeat.run_repeatable(splitjoin) end)
vim.keymap.set('n', 'gS', function() vim_repeat.run(splitjoin) end)
end
return M

View File

@@ -1,12 +1,12 @@
local vim_repeat = require 'u.repeat'
local opkeymap = require 'u.opkeymap'
local Pos = require 'u.pos'
local Range = require 'u.range'
local Buffer = require 'u.buffer'
local CodeWriter = require 'u.codewriter'
local M = {}
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
local surrounds = {
[')'] = { left = '(', right = ')' },
['('] = { left = '( ', right = ' )' },
@@ -21,17 +21,8 @@ local surrounds = {
['`'] = { left = '`', right = '`' },
}
--- @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
-- we want to reuse the last bounds:
return CACHED_BOUNDS
end
local cn = vim.fn.getchar()
-- Check for non-printable characters:
if type(cn) ~= 'number' or cn < 32 or cn > 126 then return end
@@ -45,17 +36,15 @@ local function prompt_for_bounds()
vim.keymap.del('c', '>')
local endtag = '</' .. tag:sub(2):match '[^ >]*' .. '>'
-- selene: allow(global_usage)
CACHED_BOUNDS = { left = tag, right = endtag }
return CACHED_BOUNDS
return { left = tag, right = endtag }
else
-- Default surround:
CACHED_BOUNDS = (surrounds)[c] or { left = c, right = c }
return CACHED_BOUNDS
return (surrounds)[c] or { left = c, right = c }
end
end
--- @param range u.Range
--- @param bounds { left: string; right: string }
---@param range Range
---@param bounds { left: string; right: string }
local function do_surround(range, bounds)
local left = bounds.left
local right = bounds.right
@@ -70,7 +59,7 @@ local function do_surround(range, bounds)
range:replace(left .. range:text() .. right)
elseif range.mode == 'V' then
local buf = Buffer.current()
local cw = CodeWriter.from_line(range.start:line(), buf.bufnr)
local cw = CodeWriter.from_line(buf:line0(range.start.lnum):text(), buf.buf)
-- write the left bound at the current indent level:
cw:write(left)
@@ -97,107 +86,97 @@ local function do_surround(range, bounds)
range.start:save_to_pos '.'
end
-- Add surround:
--- @param ty 'line' | 'char' | 'block'
function _G.MySurroundOpFunc(ty)
if ty == 'block' then
-- We won't handle block-selection:
return
end
local range = Range.from_op_func(ty)
local hl
if not vim_repeat.is_repeating() then hl = range:highlight('IncSearch', { priority = 999 }) end
local bounds = prompt_for_bounds()
if hl then hl.clear() end
if bounds == nil then return end
do_surround(range, bounds)
end
function M.setup()
require('u.repeat').setup()
-- Visual
vim.keymap.set('x', 'S', function()
vim.keymap.set('v', 'S', function()
local c = vim.fn.getcharstr()
local range = Range.from_vtext()
local bounds = prompt_for_bounds()
if bounds == nil then return end
do_surround(range, bounds)
-- this is a visual mapping: end in normal mode:
vim.cmd.normal(ESC)
local bounds = surrounds[c] or { left = c, right = c }
vim_repeat.run(function()
do_surround(range, bounds)
-- this is a visual mapping: end in normal mode:
vim.cmd { cmd = 'normal', args = { '' }, bang = true }
end)
end, { noremap = true, silent = true })
-- Change
vim.keymap.set('n', 'cs', function()
local from_cn = vim.fn.getchar() --[[@as number]]
local from_cn = vim.fn.getchar()
-- Check for non-printable characters:
if from_cn < 32 or from_cn > 126 then return end
vim_repeat.run_repeatable(function()
local from_c = vim.fn.nr2char(from_cn)
local from = surrounds[from_c] or { left = from_c, right = from_c }
local function get_fresh_arange()
local arange = Range.from_motion('a' .. from_c, { user_defined = true })
if arange == nil then return end
if from_c == 'q' then
from.left = arange.start:char()
from.right = arange.stop:char()
end
return arange
local from_c = vim.fn.nr2char(from_cn)
local from = surrounds[from_c] or { left = from_c, right = from_c }
local function get_fresh_arange()
local arange = Range.from_text_object('a' .. from_c, { user_defined = true })
if arange == nil then return nil end
if from_c == 'q' then
from.left = arange.start:char()
from.right = arange.stop:char()
end
return arange
end
local arange = get_fresh_arange()
if arange == nil then return end
local arange = get_fresh_arange()
if arange == nil then return nil end
local hl_info1 = vim_repeat.is_repeating() and nil
or Range.new(arange.start, arange.start, 'v'):highlight('IncSearch', { priority = 999 })
local hl_info2 = vim_repeat.is_repeating() and nil
or Range.new(arange.stop, arange.stop, 'v'):highlight('IncSearch', { priority = 999 })
local hl_clear = function()
if hl_info1 then hl_info1.clear() end
if hl_info2 then hl_info2.clear() end
end
local hl_info1 = Range.new(arange.start, arange.start, 'v'):highlight('IncSearch', { priority = 999 })
local hl_info2 = Range.new(arange.stop, arange.stop, 'v'):highlight('IncSearch', { priority = 999 })
local hl_clear = function()
if hl_info1 then hl_info1.clear() end
if hl_info2 then hl_info2.clear() end
end
local to = prompt_for_bounds()
hl_clear()
if to == nil then return end
local to = prompt_for_bounds()
hl_clear()
if to == nil then return end
vim_repeat.run(function()
-- Re-fetch the arange, just in case this action is being repeated:
arange = get_fresh_arange()
if arange == nil then return nil end
if from_c == 't' then
-- For tags, we want to replace the inner text, not the tag:
local irange = Range.from_motion('i' .. from_c, { user_defined = true })
if arange == nil or irange == nil then return end
local irange = Range.from_text_object('i' .. from_c, { user_defined = true })
if arange == nil or irange == nil then return nil end
local lrange, rrange = arange:difference(irange)
if not lrange or not rrange 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)
rrange:replace(to.right)
lrange:replace(to.left)
else
-- replace `from.right` with `to.right`:
local right_text = arange:sub(-1, -#from.right)
right_text:replace(to.right)
local last_line = arange:line0(-1).text() --[[@as string]]
local from_right_match = last_line:match(vim.pesc(from.right) .. '$')
if from_right_match then
local match_start = arange.stop:clone()
match_start.col = match_start.col - #from_right_match + 1
Range.new(match_start, arange.stop):replace(to.right)
end
-- replace `from.left` with `to.left`:
local left_text = arange:sub(1, #from.left)
left_text:replace(to.left)
local first_line = arange:line0(0).text() --[[@as string]]
local from_left_match = first_line:match('^' .. vim.pesc(from.left))
if from_left_match then
local match_end = arange.start:clone()
match_end.col = match_end.col + #from_left_match - 1
Range.new(arange.start, match_end):replace(to.left)
end
end
end)
end, { noremap = true, silent = true })
-- Delete
local CACHED_DELETE_FROM = nil
vim.keymap.set('n', 'ds', function()
vim_repeat.run_repeatable(function()
local txt_obj = vim_repeat.is_repeating() and CACHED_DELETE_FROM or vim.fn.getcharstr()
CACHED_DELETE_FROM = txt_obj
local txt_obj = vim.fn.getcharstr()
vim_repeat.run(function()
local buf = Buffer.current()
local irange = Range.from_motion('i' .. txt_obj)
local arange = Range.from_motion('a' .. txt_obj)
if arange == nil or irange == nil then return end
local irange = Range.from_text_object('i' .. txt_obj)
local arange = Range.from_text_object('a' .. txt_obj)
if arange == nil or irange == nil then return nil end
local starting_cursor_pos = arange.start:clone()
-- Now, replace `arange` with the content of `irange`. If `arange` was multiple lines,
@@ -208,19 +187,28 @@ function M.setup()
-- Dedenting moves the cursor, so we need to set the cursor to a consistent starting spot:
arange.start:save_to_pos '.'
-- Dedenting also changed the inner text, so re-acquire it:
arange = Range.from_motion('a' .. txt_obj)
irange = Range.from_motion('i' .. txt_obj)
arange = Range.from_text_object('a' .. txt_obj)
irange = Range.from_text_object('i' .. txt_obj)
if arange == nil or irange == nil then return end -- should never be true
arange:replace(irange:lines())
-- `arange:replace(..)` updates its own `stop` position, so we will use
-- `arange` as the final resulting range that holds the modified text
local final_range = Range.new(
arange.start,
Pos.new(
arange.stop.buf,
irange.start.lnum + (arange.stop.lnum + arange.start.lnum),
arange.stop.col,
arange.stop.off
),
irange.mode
)
-- delete last line, if it is empty:
local last = buf:line(arange.stop.lnum)
local last = buf:line0(final_range.stop.lnum)
if last:text():match '^%s*$' then last:replace(nil) end
-- delete first line, if it is empty:
local first = buf:line(arange.start.lnum)
local first = buf:line0(final_range.start.lnum)
if first:text():match '^%s*$' then first:replace(nil) end
else
-- trim start:
@@ -232,10 +220,35 @@ function M.setup()
end)
end, { noremap = true, silent = true })
vim.keymap.set('n', 'ys', function()
vim.o.operatorfunc = 'v:lua.MySurroundOpFunc'
return 'g@'
end, { expr = true })
opkeymap('n', 'ys', function(range)
local hl_info = range:highlight('IncSearch', { priority = 999 })
---@type { left: string; right: string }
local bounds
-- selene: allow(global_usage)
if _G.my_surround_bounds ~= nil then
-- This command was repeated with `.`, we don't need
-- to prompt for the bounds:
-- selene: allow(global_usage)
bounds = _G.my_surround_bounds
else
local prompted_bounds = prompt_for_bounds()
if prompted_bounds == nil and hl_info then return hl_info.clear() end
if prompted_bounds then bounds = prompted_bounds end
end
if hl_info then hl_info.clear() end
do_surround(range, bounds)
-- selene: allow(global_usage)
_G.my_surround_bounds = nil
-- return repeatable injection
return function()
-- on_repeat, we "stage" the bounds that we were originally called with:
-- selene: allow(global_usage)
_G.my_surround_bounds = bounds
end
end)
end
return M

View File

@@ -1,4 +1,4 @@
local txtobj = require 'u.txtobj'
local utils = require 'u.utils'
local Pos = require 'u.pos'
local Range = require 'u.range'
local Buffer = require 'u.buffer'
@@ -7,33 +7,52 @@ local M = {}
function M.setup()
-- Select whole file:
txtobj.define('ag', function() return Buffer.current():all() end)
utils.define_text_object('ag', function() return Buffer.current():all() end)
-- Select current line:
txtobj.define('a.', function() return Buffer.current():line(Pos.from_pos('.').lnum) end)
utils.define_text_object('a.', function()
local lnum = Pos.from_pos('.').lnum
return Buffer.current():line0(lnum)
end)
-- Select the nearest quote:
txtobj.define('aq', function() return Range.find_nearest_quotes() end)
txtobj.define('iq', function()
utils.define_text_object('aq', function() return Range.find_nearest_quotes() end)
utils.define_text_object('iq', function()
local range = Range.find_nearest_quotes()
if range == nil then return end
return range:shrink(1)
end)
---Selects the next quote object (searches forward)
--- @param q string
---@param q string
local function define_quote_obj(q)
local function select_around() return Range.from_motion('a' .. q) end
local function select_around()
-- Operator mappings are effectively running in visual mode, the way
-- `define_text_object` is implemented, so feed the keys `a${q}` to vim
-- to select the appropriate text-object
vim.cmd { cmd = 'normal', args = { 'a' .. q }, bang = true }
txtobj.define('a' .. q, function() return select_around() end)
txtobj.define('i' .. q, function()
local range = select_around()
if range == nil or range:is_empty() then return range end
-- Now check on the visually selected text:
local range = Range.from_vtext()
if range:is_empty() then return range.start end
range.start = range.start:find_next(1, q) or range.start
range.stop = range.stop:find_next(-1, q) or range.stop
return range
end
local start_next = range.start:next(1) or range.start
local stop_prev = range.stop:next(-1)
if start_next > stop_prev then return Range.new(start_next) end
return range:shrink(1) or range
utils.define_text_object('a' .. q, function() return select_around() end)
utils.define_text_object('i' .. q, function()
local range_or_pos = select_around()
if Range.is(range_or_pos) then
local start_next = range_or_pos.start:next(1)
local stop_prev = range_or_pos.stop:next(-1)
if start_next > stop_prev then return start_next end
local range = range_or_pos:shrink(1)
return range
else
return range_or_pos
end
end)
end
define_quote_obj [["]]
@@ -41,26 +60,36 @@ function M.setup()
define_quote_obj [[`]]
---Selects the "last" quote object (searches backward)
--- @param q string
---@param q string
local function define_last_quote_obj(q)
local function select_around()
local curr = Pos.from_pos('.'):find_next(-1, q)
if not curr then return end
-- Reset visual selection to current context:
curr:save_to_pos '.'
return Range.from_motion('a' .. q)
Range.new(curr, curr):set_visual_selection()
vim.cmd.normal('a' .. q)
local range = Range.from_vtext()
if range:is_empty() then return range.start end
range.start = range.start:find_next(1, q) or range.start
range.stop = range.stop:find_next(-1, q) or range.stop
return range
end
txtobj.define('al' .. q, function() return select_around() end)
txtobj.define('il' .. q, function()
local range = select_around()
if range == nil or range:is_empty() then return range end
utils.define_text_object('al' .. q, function() return select_around() end)
utils.define_text_object('il' .. q, function()
local range_or_pos = select_around()
if range_or_pos == nil then return end
local start_next = range.start:next(1) or range.start
local stop_prev = range.stop:next(-1)
if start_next > stop_prev then return Range.new(start_next) end
if Range.is(range_or_pos) then
local start_next = range_or_pos.start:next(1)
local stop_prev = range_or_pos.stop:next(-1)
if start_next > stop_prev then return start_next end
return range:shrink(1) or range
local range = range_or_pos:shrink(1)
return range
else
return range_or_pos
end
end)
end
define_last_quote_obj [["]]
@@ -82,8 +111,8 @@ function M.setup()
local keybinds = { ... }
table.insert(keybinds, b)
for _, k in ipairs(keybinds) do
txtobj.define('al' .. k, function() return select_around() end)
txtobj.define('il' .. k, function()
utils.define_text_object('al' .. k, function() return select_around() end)
utils.define_text_object('il' .. k, function()
local range = select_around()
return range and range:shrink(1)
end)

View File

@@ -1,5 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"diagnostics.globals": ["assert", "vim"],
"runtime.version": "LuaJIT"
}

View File

@@ -1,128 +1,86 @@
local Range = require 'u.range'
local Renderer = require('u.renderer').Renderer
local Renderer = require 'u.renderer'.Renderer
--- @class u.Buffer
--- @field bufnr number
--- @field b vim.var_accessor
--- @field bo vim.bo
--- @field private renderer u.Renderer
---@class Buffer
---@field buf number
---@field private 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)
---@param buf? number
---@return Buffer
function Buffer.from_nr(buf)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
local renderer = Renderer.new(buf)
return setmetatable({
bufnr = bufnr,
b = vim.b[bufnr],
bo = vim.bo[bufnr],
buf = buf,
renderer = renderer,
}, Buffer)
}, { __index = Buffer })
end
--- @return u.Buffer
---@return Buffer
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
---@param listed boolean
---@param scratch boolean
---@return Buffer
function Buffer.create(listed, scratch) return Buffer.from_nr(vim.api.nvim_create_buf(listed, scratch)) end
function Buffer:set_tmp_options()
self.bo.bufhidden = 'delete'
self.bo.buflisted = false
self.bo.buftype = 'nowrite'
self:set_option('bufhidden', 'delete')
self:set_option('buflisted', false)
self:set_option('buftype', 'nowrite')
end
function Buffer:line_count() return vim.api.nvim_buf_line_count(self.bufnr) end
---@param nm string
function Buffer:get_option(nm) return vim.api.nvim_get_option_value(nm, { buf = self.buf }) end
function Buffer:all() return Range.from_buf_text(self.bufnr) end
---@param nm string
function Buffer:set_option(nm, val) return vim.api.nvim_set_option_value(nm, val, { buf = self.buf }) end
function Buffer:is_empty() return self:line_count() == 1 and self:line(1):text() == '' end
---@param nm string
function Buffer:get_var(nm) return vim.api.nvim_buf_get_var(self.buf, nm) end
--- @param line string
---@param nm string
function Buffer:set_var(nm, val) return vim.api.nvim_buf_set_var(self.buf, nm, val) end
function Buffer:line_count() return vim.api.nvim_buf_line_count(self.buf) end
function Buffer:all() return Range.from_buf_text(self.buf) end
function Buffer:is_empty() return self:line_count() == 1 and self:line0(0):text() == '' end
---@param line string
function Buffer:append_line(line)
local start = -1
if self:is_empty() then start = -2 end
vim.api.nvim_buf_set_lines(self.bufnr, start, -1, false, { line })
vim.api.nvim_buf_set_lines(self.buf, start, -1, false, { line })
end
--- @param num number 1-based line index
function Buffer:line(num)
if num < 0 then num = self:line_count() + num + 1 end
return Range.from_line(self.bufnr, num)
---@param num number 0-based line index
function Buffer:line0(num)
if num < 0 then return self:line0(self:line_count() + num) end
return Range.from_line(self.buf, num)
end
--- @param start number 1-based line index
--- @param stop number 1-based line index
function Buffer:lines(start, stop) return Range.from_lines(self.bufnr, start, stop) end
---@param start number 0-based line index
---@param stop number 0-based line index
function Buffer:lines(start, stop) return Range.from_lines(self.buf, start, stop) end
--- @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(motion, opts)
---@param txt_obj string
---@param opts? { contains_cursor?: boolean; pos?: Pos }
function Buffer:text_object(txt_obj, opts)
opts = vim.tbl_extend('force', opts or {}, { buf = self.buf })
return Range.from_text_object(txt_obj, opts)
end
--- @param event string|string[]
--- @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 }))
vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.buf }))
end
--- @param tree u.renderer.Tree
--- @param tree 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

@@ -1,15 +1,14 @@
local Buffer = require 'u.buffer'
--- @class u.CodeWriter
--- @field lines string[]
--- @field indent_level number
--- @field indent_str string
---@class CodeWriter
---@field lines string[]
---@field indent_level number
---@field indent_str string
local CodeWriter = {}
CodeWriter.__index = CodeWriter
--- @param indent_level? number
--- @param indent_str? string
--- @return u.CodeWriter
---@param indent_level? number
---@param indent_str? string
---@return CodeWriter
function CodeWriter.new(indent_level, indent_str)
if indent_level == nil then indent_level = 0 end
if indent_str == nil then indent_str = ' ' end
@@ -19,27 +18,26 @@ function CodeWriter.new(indent_level, indent_str)
indent_level = indent_level,
indent_str = indent_str,
}
setmetatable(cw, CodeWriter)
setmetatable(cw, { __index = CodeWriter })
return cw
end
--- @param p u.Pos
---@param p Pos
function CodeWriter.from_pos(p)
local line = Buffer.from_nr(p.bufnr):line(p.lnum):text()
return CodeWriter.from_line(line, p.bufnr)
local line = Buffer.from_nr(p.buf):line0(p.lnum):text()
return CodeWriter.from_line(line, p.buf)
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
---@param line string
---@param buf? number
function CodeWriter.from_line(line, buf)
if buf == nil then buf = 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 expandtab = vim.api.nvim_get_option_value('expandtab', { buf = buf })
local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = buf })
--- @type number
local indent_level
local indent_level = 0
local indent_str = ''
if expandtab then
while #indent_str < shiftwidth do
@@ -54,16 +52,16 @@ function CodeWriter.from_line(line, bufnr)
return CodeWriter.new(indent_level, indent_str)
end
--- @param line string
---@param line string
function CodeWriter:write_raw(line)
if line:find '\n' then error 'line contains newline character' end
table.insert(self.lines, line)
end
--- @param line string
---@param line string
function CodeWriter:write(line) self:write_raw(self.indent_str:rep(self.indent_level) .. line) end
--- @param f? fun(cw: u.CodeWriter):any
---@param f? fun(cw: CodeWriter):any
function CodeWriter:indent(f)
local cw = {
lines = self.lines,

View File

@@ -1,69 +0,0 @@
local M = {}
--- @params name string
function M.file_for_name(name)
return vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log', name .. '.log.jsonl')
end
--------------------------------------------------------------------------------
-- Logger class
--------------------------------------------------------------------------------
--- @class u.Logger
--- @field name string
--- @field private fd number
local Logger = {}
Logger.__index = Logger
M.Logger = Logger
--- @param name string
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)
return self
end
--- @private
--- @param level string
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'
)
end
function Logger:trace(...) self:write('INFO', ...) end
function Logger:debug(...) self:write('DEBUG', ...) end
function Logger:info(...) self:write('INFO', ...) end
function Logger:warn(...) self:write('WARN', ...) end
function Logger:error(...) self:write('ERROR', ...) end
function M.setup()
vim.api.nvim_create_user_command('Logfollow', function(args)
if #args.fargs == 0 then
vim.print 'expected log name'
return
end
local log_file_path = M.file_for_name(args.fargs[1])
vim.fn.mkdir(vim.fs.dirname(log_file_path), 'p')
vim.system({ 'touch', log_file_path }):wait()
vim.cmd.new()
local winnr = vim.api.nvim_get_current_win()
vim.wo[winnr][0].number = false
vim.wo[winnr][0].relativenumber = false
vim.cmd.terminal('tail -f "' .. log_file_path .. '"')
vim.cmd.startinsert()
end, { nargs = '*' })
end
return M

View File

@@ -1,17 +1,24 @@
local Range = require 'u.range'
local vim_repeat = require 'u.repeat'
--- @type fun(range: u.Range): nil|(fun():any)
---@type fun(range: 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
--- @param ty 'line'|'char'|'block'
---@type nil|fun(range: Range): fun():any|nil
---@param ty 'line'|'char'|'block'
-- selene: allow(unused_variable)
function _G.__U__OpKeymapOpFunc(ty)
function __U__OpKeymapOpFunc(ty)
if __U__OpKeymapOpFunc_rhs ~= nil then
local range = Range.from_op_func(ty)
__U__OpKeymapOpFunc_rhs(range)
local repeat_inject = __U__OpKeymapOpFunc_rhs(range)
vim_repeat.set(function()
vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc'
if repeat_inject ~= nil and type(repeat_inject) == 'function' then repeat_inject() end
vim_repeat.native_repeat()
end)
end
end
@@ -21,17 +28,12 @@ end
--- g@: tells vim to way for a motion, and then call operatorfunc.
--- 2. The operatorfunc is set to a lua function that computes the range being operated over, that
--- then calls the original passed callback with said range.
--- @param mode string|string[]
--- @param lhs string
--- @param rhs fun(range: u.Range): nil
--- @diagnostic disable-next-line: undefined-doc-name
--- @param opts? vim.keymap.set.Opts
---@param mode string|string[]
---@param lhs string
---@param rhs fun(range: Range): nil|(fun():any) This function may return another function, which is called whenever the operator is repeated
---@param opts? vim.keymap.set.Opts
local function opkeymap(mode, lhs, rhs, opts)
vim.keymap.set(mode, lhs, function()
-- We don't need to wrap the operation in a repeat, because expr mappings are
-- repeated seamlessly by Vim anyway. In addition, the u.repeat:`.` mapping will
-- set IS_REPEATING to true, so that callbacks can check if they should used cached
-- values.
__U__OpKeymapOpFunc_rhs = rhs
vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc'
return 'g@'

View File

@@ -1,170 +1,162 @@
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
---@param buf number
---@param lnum number
local function line_text(buf, lnum) return vim.api.nvim_buf_get_lines(buf, lnum, lnum + 1, 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 off number
---@class Pos
---@field buf number buffer number
---@field lnum number 1-based line index
---@field col number 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
--- @param off? number
--- @return u.Pos
function Pos.new(bufnr, lnum, col, off)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
---@param buf? number
---@param lnum number
---@param col number
---@param off? number
---@return Pos
function Pos.new(buf, lnum, col, off)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
if off == nil then off = 0 end
local pos = {
bufnr = bufnr,
buf = buf,
lnum = lnum,
col = col,
off = off,
}
setmetatable(pos, Pos)
local function str()
if pos.off ~= 0 then
return string.format('Pos(%d:%d){buf=%d, off=%d}', pos.lnum, pos.col, pos.buf, pos.off)
else
return string.format('Pos(%d:%d){buf=%d}', pos.lnum, pos.col, pos.buf)
end
end
setmetatable(pos, {
__index = Pos,
__tostring = str,
__lt = Pos.__lt,
__le = Pos.__le,
__eq = Pos.__eq,
})
return pos
end
function Pos.invalid() return Pos.new(0, 0, 0, 0) end
function Pos.is(x)
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 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 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 getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end
return x:next(-y)
end
function Pos.__eq(a, b) return a.lnum == b.lnum and a.col == b.col end
--- @param name string
--- @return u.Pos
---@param name string
---@return Pos
function Pos.from_pos(name)
local p = vim.fn.getpos(name)
return Pos.new(p[1], p[2], p[3], p[4])
local col = p[3]
if col ~= MAX_COL then col = col - 1 end
return Pos.new(p[1], p[2] - 1, col, p[4])
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.buf, self.lnum, self.col, self.off) end
function Pos:clone() return Pos.new(self.bufnr, self.lnum, self.col, self.off) end
--- @return boolean
---@return boolean
function Pos:is_col_max() return self.col == MAX_COL end
---@return number[]
function Pos:as_vim() return { self.buf, self.lnum, self.col, self.off } end
--- Normalize the position to a real position (take into account vim.v.maxcol).
function Pos:as_real()
local maxlen = #line_text(self.bufnr, self.lnum)
local col = self.col
if col > maxlen then
if self:is_col_max() then
-- We could use utilities in this file to get the given line, but
-- since this is a low-level function, we are going to optimize and
-- use the API directly:
col = maxlen
col = #line_text(self.buf, self.lnum) - 1
end
return Pos.new(self.bufnr, self.lnum, col, self.off)
return Pos.new(self.buf, self.lnum, col, self.off)
end
function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end
---@param pos string
function Pos:save_to_pos(pos)
if pos == '.' then
vim.api.nvim_win_set_cursor(0, { self.lnum + 1, self.col })
return
end
--- @param pos string
function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end
local p = self:as_real()
vim.fn.setpos(pos, { p.buf, p.lnum + 1, p.col + 1, p.off })
end
--- @param mark string
---@param mark string
function Pos:save_to_mark(mark)
local p = self:as_real()
vim.api.nvim_buf_set_mark(p.bufnr, mark, p.lnum, p.col - 1, {})
vim.api.nvim_buf_set_mark(p.buf, mark, p.lnum + 1, p.col, {})
end
--- @return string
---@return string
function Pos:char()
local line = line_text(self.bufnr, self.lnum)
local line = line_text(self.buf, self.lnum)
if line == nil then return '' end
return line:sub(self.col, self.col)
return line:sub(self.col + 1, self.col + 1)
end
function Pos:line() return line_text(self.bufnr, self.lnum) end
--- @param dir? -1|1
--- @param must? boolean
--- @return u.Pos|nil
---@param dir? -1|1
---@param must? boolean
---@return Pos|nil
function Pos:next(dir, must)
if must == nil then must = false end
if dir == nil or dir == 1 then
-- Next:
local num_lines = vim.api.nvim_buf_line_count(self.bufnr)
local last_line = line_text(self.bufnr, num_lines)
if self.lnum == num_lines and self.col == #last_line then
local num_lines = vim.api.nvim_buf_line_count(self.buf)
local last_line = line_text(self.buf, num_lines - 1) -- buf:line0(-1)
if self.lnum == num_lines - 1 and self.col == (#last_line - 1) then
if must then error 'error in Pos:next(): Pos:next() returned nil' end
return nil
end
local col = self.col + 1
local line = self.lnum
local line_max_col = #line_text(self.bufnr, self.lnum)
local line_max_col = #line_text(self.buf, self.lnum) - 1
if col > line_max_col then
col = 1
col = 0
line = line + 1
end
return Pos.new(self.bufnr, line, col, self.off)
return Pos.new(self.buf, line, col, self.off)
else
-- Previous:
if self.col == 1 and self.lnum == 1 then
if self.col == 0 and self.lnum == 0 then
if must then error 'error in Pos:next(): Pos:next() returned nil' end
return nil
end
local col = self.col - 1
local line = self.lnum
local prev_line_max_col = #(line_text(self.bufnr, self.lnum - 1) or '')
if col < 1 then
col = math.max(prev_line_max_col, 1)
local prev_line_max_col = #(line_text(self.buf, self.lnum - 1) or '') - 1
if col < 0 then
col = math.max(prev_line_max_col, 0)
line = line - 1
end
return Pos.new(self.bufnr, line, col, self.off)
return Pos.new(self.buf, line, col, self.off)
end
end
--- @param dir? -1|1
---@param dir? -1|1
function Pos:must_next(dir)
local next = self:next(dir, true)
if next == nil then error 'unreachable' end
return next
end
--- @param dir -1|1
--- @param predicate fun(p: u.Pos): boolean
--- @param test_current? boolean
---@param dir -1|1
---@param predicate fun(p: Pos): boolean
---@param test_current? boolean
function Pos:next_while(dir, predicate, test_current)
if test_current and not predicate(self) then return end
local curr = self
@@ -176,15 +168,15 @@ function Pos:next_while(dir, predicate, test_current)
return curr
end
--- @param dir -1|1
--- @param predicate string|fun(p: u.Pos): boolean
---@param dir -1|1
---@param predicate string|fun(p: Pos): boolean
function Pos:find_next(dir, predicate)
if type(predicate) == 'string' then
local s = predicate
predicate = function(p) return s == p:char() end
end
--- @type u.Pos|nil
---@type Pos|nil
local curr = self
while curr ~= nil do
if predicate(curr) then return curr end
@@ -194,14 +186,12 @@ function Pos:find_next(dir, predicate)
end
--- Finds the matching bracket/paren for the current position.
--- @param max_chars? number|nil
--- @param invocations? u.Pos[]
--- @return u.Pos|nil
---@param max_chars? number|nil
---@param invocations? Pos[]
---@return 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 = { '{', '[', '(', '<' }
@@ -211,20 +201,12 @@ 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)
-- Store the character we will be looking for:
local i, _ = vim.iter(is_opener and openers or closers):enumerate():find(function(_, c2) return c == c2 end)
local c_match = (is_opener and closers or openers)[i]
--- @type u.Pos|nil
---@type Pos|nil
local cur = self
--- `adv` is a helper that moves the current position backward or forward,
--- depending on whether we are looking for an opener or a closer. It returns
--- nil if 1) the watch-dog `max_chars` falls bellow 0, or 2) if we have gone
--- beyond the beginning/end of the file.
--- @return u.Pos|nil
---@return Pos|nil
local function adv()
if cur == nil then return nil end
@@ -236,7 +218,7 @@ function Pos:find_match(max_chars, invocations)
return cur:next(is_opener and 1 or -1)
end
-- scan until we find `c_match`:
-- scan until we find a match:
cur = adv()
while cur ~= nil and cur:char() ~= c_match do
cur = adv()
@@ -254,17 +236,4 @@ function Pos:find_match(max_chars, invocations)
return cur
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
)
end
return Pos

View File

@@ -1,78 +1,62 @@
local Pos = require 'u.pos'
local State = require 'u.state'
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
--- @class u.Range
--- @field start u.Pos
--- @field stop u.Pos|nil
--- @field mode 'v'|'V'
local Range = {}
Range.__index = Range
function Range.__tostring(self)
--- @param p u.Pos
local function posstr(p)
if p == nil then
return 'nil'
elseif p.off ~= 0 then
return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off)
else
return string.format('Pos(%d:%d)', p.lnum, p.col)
end
end
local _1 = posstr(self.start)
local _2 = posstr(self.stop)
return string.format(
'Range{bufnr=%d, mode=%s, start=%s, stop=%s}',
self.start.bufnr,
self.mode,
_1,
_2
)
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
--------------------------------------------------------------------------------
-- Range constructors:
--------------------------------------------------------------------------------
---@class Range
---@field start Pos
---@field stop Pos|nil
---@field mode 'v'|'V'
local Range = {}
--- @param start u.Pos
--- @param stop u.Pos|nil
--- @param mode? 'v'|'V'
--- @return u.Range
---@param start Pos
---@param stop Pos|nil
---@param mode? 'v'|'V'
---@return 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()
---@param p Pos
local function posstr(p)
if p == nil then
return 'nil'
elseif p.off ~= 0 then
return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off)
else
return string.format('Pos(%d:%d)', p.lnum, p.col)
end
end
setmetatable(r, Range)
local _1 = posstr(r.start)
local _2 = posstr(r.stop)
return string.format('Range{buf=%d, mode=%s, start=%s, stop=%s}', r.start.buf, r.mode, _1, _2)
end
setmetatable(r, { __index = Range, __tostring = str })
return r
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
-- 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.is(x)
local mt = getmetatable(x)
return mt and mt.__index == Range
end
--- @param lpos string
--- @param rpos string
--- @return u.Range
---@param lpos string
---@param rpos string
---@return Range
function Range.from_marks(lpos, rpos)
local start = Pos.from_pos(lpos)
local stop = Pos.from_pos(rpos)
--- @type 'v'|'V'
---@type 'v'|'V'
local mode
if stop:is_col_max() then
mode = 'V'
@@ -83,114 +67,101 @@ function Range.from_marks(lpos, rpos)
return Range.new(start, stop, mode)
end
--- @param bufnr? number
function Range.from_buf_text(bufnr)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local num_lines = vim.api.nvim_buf_line_count(bufnr)
---@param buf? number
function Range.from_buf_text(buf)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
local num_lines = vim.api.nvim_buf_line_count(buf)
local start = Pos.new(bufnr, 1, 1)
local stop = Pos.new(bufnr, num_lines, Pos.MAX_COL)
local start = Pos.new(buf, 0, 0)
local stop = Pos.new(buf, num_lines - 1, Pos.MAX_COL)
return Range.new(start, stop, 'V')
end
--- @param bufnr? number
--- @param line number 1-based line index
function Range.from_line(bufnr, line) return Range.from_lines(bufnr, line, line) end
---@param buf? number
---@param line number 0-based line index
function Range.from_line(buf, line) return Range.from_lines(buf, line, line) end
--- @param bufnr? number
--- @param start_line number based line index
--- @param stop_line number based line index
function Range.from_lines(bufnr, start_line, stop_line)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
---@param buf? number
---@param start_line number 0-based line index
---@param stop_line number 0-based line index
function Range.from_lines(buf, start_line, stop_line)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
if stop_line < 0 then
local num_lines = vim.api.nvim_buf_line_count(bufnr)
stop_line = num_lines + stop_line + 1
local num_lines = vim.api.nvim_buf_line_count(buf)
stop_line = num_lines + stop_line
end
return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, Pos.MAX_COL), 'V')
return Range.new(Pos.new(buf, start_line, 0), Pos.new(buf, stop_line, Pos.MAX_COL), 'V')
end
--- @param motion string
--- @param opts? { bufnr?: number; contains_cursor?: boolean; pos?: u.Pos, user_defined?: boolean }
--- @return u.Range|nil
function Range.from_motion(motion, opts)
-- Options handling:
---@param text_obj string
---@param opts? { buf?: number; contains_cursor?: boolean; pos?: Pos, user_defined?: boolean }
---@return Range|nil
function Range.from_text_object(text_obj, opts)
opts = opts or {}
if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end
if opts.buf == nil then opts.buf = 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
-- 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 "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 '.'
-- Capture the original state of the buffer for restoration later.
local original_state = {
winview = vim.fn.winsaveview(),
regquote = vim.fn.getreg '"',
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
-- Yank, then read '[ and '] to know the bounds:
---@type { start: Pos; stop: Pos }
local positions
vim.api.nvim_buf_call(opts.buf, function()
positions = State.run(0, function(s)
s:track_winview()
s:track_register '"'
s:track_pos '.'
s:track_pos "'["
s:track_pos "']"
vim.api.nvim_buf_call(opts.bufnr, function()
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
_G.Range__from_motion_opfunc = function(ty)
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
end
vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc'
vim.cmd {
cmd = 'normal',
bang = not opts.user_defined,
args = { ESC .. 'g@' .. motion },
mods = { silent = true },
}
local null_pos = Pos.new(0, 0, 0, 0)
null_pos:save_to_pos "'["
null_pos:save_to_pos "']"
local prev_on_yank_enabled = on_yank_enabled
on_yank_enabled = false
vim.cmd {
cmd = 'normal',
bang = not opts.user_defined,
args = { '""y' .. text_obj },
mods = { silent = true },
}
on_yank_enabled = prev_on_yank_enabled
local start = Pos.from_pos "'["
local stop = Pos.from_pos "']"
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)
-- *Sigh*, this also sometimes happens for `it` as well.
or (text_obj == 'it' and stop:char() == '<')
then
stop = stop:next(-1) or stop
end
return { start = start, stop = stop }
end)
end)
local captured_range = _G.Range__from_motion_opfunc_captured_range
local start = positions.start
local stop = positions.stop
if start == stop and start.lnum == 0 and start.col == 0 and start.off == 0 then return nil end
if opts.contains_cursor and not Range.new(start, stop):contains(cursor) then return nil end
-- Restore original state:
vim.fn.winrestview(original_state.winview)
vim.fn.setreg('"', original_state.regquote)
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_txtobj and scope == 'i' and captured_range.stop:char() == motion_rest)
-- *Sigh*, this also sometimes happens for `it` as well.
or (motion == 'it' and captured_range.stop:char() == '<')
then
captured_range.stop = captured_range.stop:next(-1) or captured_range.stop
end
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
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
end
if
opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor)))
then
return nil
end
return captured_range
return Range.new(start, stop)
end
--- Get range information from the currently selected visual text.
@@ -207,7 +178,7 @@ end
--- Get range information from the current text range being operated on
--- as defined by an operator-pending function. Infers line-wise vs. char-wise
--- based on the type, as given by the operator-pending function.
--- @param type 'line'|'char'|'block'
---@param type 'line'|'char'|'block'
function Range.from_op_func(type)
if type == 'block' then error 'block motions not supported' end
@@ -217,12 +188,12 @@ function Range.from_op_func(type)
end
--- Get range information from command arguments.
--- @param args unknown
--- @return u.Range|nil
---@param args unknown
---@return Range|nil
function Range.from_cmd_args(args)
--- @type 'v'|'V'
---@type 'v'|'V'
local mode
--- @type nil|u.Pos
---@type nil|Pos
local start
local stop
if args.range == 0 then
@@ -230,153 +201,78 @@ function Range.from_cmd_args(args)
else
start = Pos.from_pos "'<"
stop = Pos.from_pos "'>"
mode = stop:is_col_max() and 'V' or 'v'
if stop:is_col_max() then
mode = 'V'
else
mode = 'v'
end
end
return Range.new(start, stop, mode)
end
---
function Range.find_nearest_brackets()
return Range.smallest {
Range.from_motion('a<', { contains_cursor = true }),
Range.from_motion('a[', { contains_cursor = true }),
Range.from_motion('a(', { contains_cursor = true }),
Range.from_motion('a{', { contains_cursor = true }),
}
local a = Range.from_text_object('a<', { contains_cursor = true })
local b = Range.from_text_object('a[', { contains_cursor = true })
local c = Range.from_text_object('a(', { contains_cursor = true })
local d = Range.from_text_object('a{', { contains_cursor = true })
return Range.smallest { a, b, c, d }
end
function Range.find_nearest_quotes()
return Range.smallest {
Range.from_motion([[a']], { contains_cursor = true }),
Range.from_motion([[a"]], { contains_cursor = true }),
Range.from_motion([[a`]], { contains_cursor = true }),
}
local a = Range.from_text_object([[a']], { contains_cursor = true })
if a ~= nil and a:is_empty() then a = nil end
local b = Range.from_text_object([[a"]], { contains_cursor = true })
if b ~= nil and b:is_empty() then b = nil end
local c = Range.from_text_object([[a`]], { contains_cursor = true })
if c ~= nil and c:is_empty() then c = nil end
return Range.smallest { a, b, c }
end
--------------------------------------------------------------------------------
-- Structural utilities:
--------------------------------------------------------------------------------
---@param ranges (Range|nil)[]
function Range.smallest(ranges)
---@type Range[]
local new_ranges = {}
for _, r in pairs(ranges) do
if r ~= nil then table.insert(new_ranges, r) end
end
ranges = new_ranges
if #ranges == 0 then return nil end
function Range:clone()
return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode)
-- find smallest match
local max_start = ranges[1].start
local min_stop = ranges[1].stop
local result = ranges[1]
for _, r in ipairs(ranges) do
local start, stop = r.start, r.stop
if start > max_start and stop < min_stop then
max_start = start
min_stop = stop
result = r
end
end
return result
end
function Range:is_empty() return self.stop == nil 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:to_linewise()
local r = self:clone()
r.mode = 'V'
r.start.col = 1
r.start.col = 0
if r.stop ~= nil then r.stop.col = Pos.MAX_COL 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)
if self:is_empty() then
self.start:save_to_pos(left)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
end
--- @param left string
--- @param right string
function Range:save_to_marks(left, right)
if self:is_empty() then
self.start:save_to_mark(left)
self.start:save_to_mark(right)
else
self.start:save_to_mark(left)
self.stop:save_to_mark(right)
end
end
function Range:set_visual_selection()
if self:is_empty() then return end
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
error 'Range:set_visual_selection() called on a buffer other than the current buffer'
end
local curr_mode = vim.fn.mode()
if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end
self.start:save_to_pos '.'
vim.cmd.normal { args = { 'o' }, bang = true }
self.stop:save_to_pos '.'
end
--------------------------------------------------------------------------------
-- Range.from_* functions:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Text access/manipulation utilities:
--------------------------------------------------------------------------------
function Range:length()
if self:is_empty() then return 0 end
local line_positions =
vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' })
local len = 0
for linenr, line in ipairs(line_positions) do
if linenr > 1 then len = len + 1 end -- each newline is counted as a char
local line_start_col = line[1][3]
local line_stop_col = line[2][3]
local line_len = line_stop_col - line_start_col + 1
len = len + line_len
end
return len
end
function Range:line_count()
if self:is_empty() then return 0 end
return self.stop.lnum - self.start.lnum + 1
end
function Range:is_empty() return self.stop == nil end
function Range:trim_start()
if self:is_empty() then return end
@@ -402,135 +298,116 @@ function Range:trim_stop()
return r
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 p Pos
function Range:contains(p) return not self:is_empty() and p >= self.start and p <= self.stop end
--- @param idx number 1-based
--- @return u.Pos|nil
local function get_pos(idx)
if idx < 0 then return get_pos(self:length() + idx + 1) end
-- find the position of the first line that contains the i-th character:
local curr_len = 0
for linenr, line in ipairs(line_positions) do
if linenr > 1 then curr_len = curr_len + 1 end -- each newline is counted as a char
local line_start_col = line[1][3]
local line_stop_col = line[2][3]
local line_len = line_stop_col - line_start_col + 1
if curr_len + line_len >= idx then
return Pos.new(self.start.bufnr, line[1][2], line_start_col + (idx - curr_len) - 1)
end
curr_len = curr_len + line_len
end
end
local start = get_pos(i)
if not start then
-- start is inalid, so return an empty range:
return Range.new(self.start, nil, self.mode)
end
local stop
if j then stop = get_pos(j) end
if not stop then
-- stop is inalid, so return an empty range:
return Range.new(start, nil, self.mode)
end
return Range.new(start, stop, 'v')
end
--- @return string[]
---@return string[]
function Range:lines()
if self:is_empty() then return {} end
return vim.fn.getregion(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
local lines = {}
for i = 0, self.stop.lnum - self.start.lnum do
local line = self:line0(i)
if line ~= nil then table.insert(lines, line.text()) end
end
return lines
end
--- @return string
---@return string
function Range:text() return vim.fn.join(self:lines(), '\n') end
--- @param l number
-- 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
---@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():Range; text: fun():string }|nil
function Range:line0(l)
if l < 0 then return self:line0(self:line_count() + l) 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_bounds = line_indices[l]
local line = vim.api.nvim_buf_get_lines(self.start.buf, self.start.lnum + l, self.start.lnum + l + 1, false)[1]
if line == nil then return end
local start = Pos.new(unpack(line_bounds[1]))
local stop = Pos.new(unpack(line_bounds[2]))
return Range.new(start, stop)
local start = 0
local stop = #line - 1
if l == 0 then start = self.start.col end
if l == self.stop.lnum - self.start.lnum then stop = self.stop.col end
if stop == Pos.MAX_COL then stop = #line - 1 end
local lnum = self.start.lnum + l
return {
line = line,
idx0 = { start = start, stop = stop },
lnum = lnum,
range = function()
return Range.new(
Pos.new(self.start.buf, lnum, start, self.start.off),
Pos.new(self.start.buf, lnum, stop, self.stop.off),
'v'
)
end,
text = function() return line:sub(start + 1, stop + 1) end,
}
end
--- @param replacement nil|string|string[]
---@param replacement nil|string|string[]
function Range:replace(replacement)
if replacement == nil then replacement = {} end
if type(replacement) == 'string' then replacement = vim.fn.split(replacement, '\n') end
local bufnr = self.start.bufnr
local buf = self.start.buf
-- convert to start-inclusive, stop-exclusive coordinates:
local start_lnum, stop_lnum = self.start.lnum, (self.stop and self.stop.lnum or self.start.lnum) + 1
local start_col, stop_col = self.start.col, (self.stop and self.stop.col or self.start.col) + 1
local replace_type = (self:is_empty() and 'insert') or (self.mode == 'v' and 'region') or 'lines'
local function update_stop_non_linewise()
---@param alnum number
---@param acol number
---@param blnum number
---@param bcol number
local function set_text(alnum, acol, blnum, bcol, repl)
-- row indices are end-inclusive, and column indices are end-exclusive.
vim.api.nvim_buf_set_text(buf, alnum, acol, blnum, bcol, repl)
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
self.stop = Pos.new(bufnr, new_last_line_num, new_last_col)
if new_last_line_num == start_lnum then new_last_col = new_last_col + start_col - 1 end
self.stop = Pos.new(buf, new_last_line_num, new_last_col)
end
local function update_stop_linewise()
if #replacement == 0 then
---@param alnum number
---@param blnum number
local function set_lines(alnum, blnum, repl)
-- indexing is zero-based, end-exclusive
vim.api.nvim_buf_set_lines(buf, alnum, blnum, false, repl)
if #repl == 0 then
self.stop = nil
else
local new_last_line_num = self.start.lnum - 1 + #replacement - 1
self.stop = Pos.new(bufnr, new_last_line_num + 1, Pos.MAX_COL, self.stop.off)
local new_last_line_num = start_lnum + #replacement - 1
self.stop = Pos.new(self.start.buf, new_last_line_num, Pos.MAX_COL, self.stop.off)
end
self.mode = 'v'
end
if replace_type == 'insert' then
-- To insert text at a given `(row, column)` location, use `start_row =
-- end_row = row` and `start_col = end_col = col`.
vim.api.nvim_buf_set_text(
bufnr,
self.start.lnum - 1,
self.start.col - 1,
self.start.lnum - 1,
self.start.col - 1,
replacement
)
update_stop_non_linewise()
set_text(start_lnum, start_col, start_lnum, start_col, replacement)
elseif replace_type == 'region' then
-- Fixup the bounds:
local max_col = #self.stop:line()
-- Indexing is zero-based. Row indices are end-inclusive, and column indices
-- are end-exclusive.
vim.api.nvim_buf_set_text(
bufnr,
self.start.lnum - 1,
self.start.col - 1,
self.stop.lnum - 1,
math.min(self.stop.col, max_col),
replacement
)
update_stop_non_linewise()
local last_line = vim.api.nvim_buf_get_lines(buf, stop_lnum - 1, stop_lnum, false)[1] or ''
local max_col = #last_line
set_text(start_lnum, start_col, stop_lnum - 1, math.min(stop_col, max_col), replacement)
elseif replace_type == 'lines' then
-- Indexing is zero-based, end-exclusive.
vim.api.nvim_buf_set_lines(bufnr, self.start.lnum - 1, self.stop.lnum, true, replacement)
update_stop_linewise()
set_lines(start_lnum, stop_lnum, replacement)
else
error 'unreachable'
end
end
--- @param amount number
---@param amount number
function Range:shrink(amount)
local start = self.start
local stop = self.stop
@@ -548,17 +425,61 @@ function Range:shrink(amount)
return Range.new(start, stop, self.mode)
end
--- @param amount number
---@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 group string
--- @param opts? { timeout?: number, priority?: number, on_macro?: boolean }
---@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.buf then vim.api.nvim_set_current_buf(self.start.buf) end
State.run(self.start.buf, function(s)
s:track_mark 'a'
s:track_mark 'b'
self.start:save_to_mark 'a'
self.stop:save_to_mark 'b'
local mode = self.mode
local normal_cmd_args = ''
if vim.api.nvim_get_mode().mode == 'n' then normal_cmd_args = normal_cmd_args .. mode end
normal_cmd_args = normal_cmd_args .. '`ao`b'
vim.cmd { cmd = 'normal', args = { normal_cmd_args }, bang = true }
return nil
end)
end
---@param group string
---@param opts? { timeout?: number, priority?: number, on_macro?: boolean }
function Range:highlight(group, opts)
if self:is_empty() then return end
@@ -569,31 +490,33 @@ function Range:highlight(group, opts)
if not opts.on_macro and in_macro then return { clear = function() end } end
local ns = vim.api.nvim_create_namespace ''
State.run(self.start.buf, function(s)
if not in_macro then s:track_winview() end
local winview = vim.fn.winsaveview()
vim.hl.range(
self.start.bufnr,
ns,
group,
{ self.start.lnum - 1, self.start.col - 1 },
{ self.stop.lnum - 1, self.stop.col - 1 },
{
inclusive = true,
priority = opts.priority,
timeout = opts.timeout,
regtype = self.mode,
}
)
if not in_macro then vim.fn.winrestview(winview) end
(vim.hl or vim.highlight).range(
self.start.buf,
ns,
group,
{ self.start.lnum, self.start.col },
{ self.stop.lnum, self.stop.col },
{
inclusive = true,
priority = opts.priority,
regtype = self.mode,
}
)
return nil
end)
vim.cmd.redraw()
return {
ns = ns,
clear = function()
vim.api.nvim_buf_clear_namespace(self.start.bufnr, ns, self.start.lnum - 1, self.stop.lnum)
vim.cmd.redraw()
end,
}
local function clear()
vim.api.nvim_buf_clear_namespace(self.start.buf, ns, self.start.lnum, self.stop.lnum + 1)
vim.cmd.redraw()
end
if opts.timeout ~= nil then vim.defer_fn(clear, opts.timeout) end
return { ns = ns, clear = clear }
end
return Range

View File

@@ -1,33 +1,31 @@
local utils = require 'u.utils'
local M = {}
local H = {}
--- @alias u.renderer.Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: u.renderer.Tree }
--- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
--- @alias Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: Tree }
--- @alias Node nil | boolean | string | Tag
--- @alias Tree Node | Node[]
local TagMetaTable = {}
-- luacheck: ignore
--- @type table<string, fun(attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag>
M.h = setmetatable({}, {
__call = function(_, name, attributes, children)
return {
kind = 'tag',
name = name,
attributes = attributes or {},
children = children,
}
end,
__index = function(_, name)
-- vim.print('dynamic hl ' .. name)
return function(attributes, children)
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end
end,
})
--- @param name string
--- @param attributes? table<string, any>
--- @param children? Node | Node[]
--- @return Tag
function M.h(name, attributes, children)
return setmetatable({
kind = 'tag',
name = name,
attributes = attributes or {},
children = children,
}, TagMetaTable)
end
-- Renderer {{{
--------------------------------------------------------------------------------
-- Renderer class
--------------------------------------------------------------------------------
--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
--- @class u.Renderer
--- @class Renderer
--- @field bufnr number
--- @field ns number
--- @field changedtick number
@@ -37,20 +35,19 @@ local Renderer = {}
Renderer.__index = Renderer
M.Renderer = Renderer
--- @private
--- @param x any
--- @return boolean
function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
function Renderer.is_tag(x) return type(x) == 'table' and getmetatable(x) == TagMetaTable end
--- @private
--- @param x any
--- @return boolean
function Renderer.is_tag_arr(x)
if type(x) ~= 'table' then return false end
return #x == 0 or not Renderer.is_tag(x)
end
--- @param bufnr number|nil
function Renderer.new(bufnr) -- {{{
function Renderer.new(bufnr)
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
if vim.b[bufnr]._renderer_ns == nil then
@@ -65,13 +62,13 @@ function Renderer.new(bufnr) -- {{{
curr = { lines = {}, extmarks = {} },
}, Renderer)
return self
end -- }}}
end
--- @param opts {
--- tree: u.renderer.Tree;
--- on_tag?: fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any;
--- tree: Tree;
--- on_tag?: fun(tag: Tag, start0: [number, number], stop0: [number, number]): any;
--- }
function Renderer.markup_to_lines(opts) -- {{{
function Renderer.markup_to_lines(opts)
--- @type string[]
local lines = {}
@@ -88,8 +85,8 @@ function Renderer.markup_to_lines(opts) -- {{{
curr_col1 = 1
end
--- @param node u.renderer.Node
local function visit(node) -- {{{
--- @param node Node
local function visit(node)
if node == nil or type(node) == 'boolean' then return end
if type(node) == 'string' then
@@ -103,13 +100,11 @@ function Renderer.markup_to_lines(opts) -- {{{
-- visit the children:
if Renderer.is_tag_arr(node.children) then
for _, child in
ipairs(node.children --[[@as u.renderer.Node[] ]])
do
for _, child in ipairs(node.children) do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child)
end
else -- luacheck: ignore
else
visit(node.children)
end
@@ -121,78 +116,20 @@ function Renderer.markup_to_lines(opts) -- {{{
visit(child)
end
end
end -- }}}
end
visit(opts.tree)
return lines
end -- }}}
end
--- @param opts {
--- tree: string;
--- format_tag?: fun(tag: u.renderer.Tag): string;
--- format_tag?: fun(tag: Tag): string;
--- }
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
--- @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) -- {{{
--- @param tree Tree
function Renderer:render(tree)
local changedtick = vim.b[self.bufnr].changedtick
if changedtick ~= self.changedtick then
self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
@@ -206,7 +143,7 @@ function Renderer:render(tree) -- {{{
local lines = Renderer.markup_to_lines {
tree = tree,
on_tag = function(tag, start0, stop0) -- {{{
on_tag = function(tag, start0, stop0)
if tag.name == 'text' then
local hl = tag.attributes.hl
if type(hl) == 'string' then
@@ -225,7 +162,7 @@ function Renderer:render(tree) -- {{{
vim.keymap.set(
'n',
lhs,
function() return self:_expr_map_callback('n', lhs) end,
function() return self:_on_expr_map('n', lhs) end,
{ buffer = self.bufnr, expr = true, replace_keycodes = true }
)
end
@@ -240,45 +177,88 @@ function Renderer:render(tree) -- {{{
})
end
end
end, -- }}}
end,
}
self.old = self.curr
self.curr = { lines = lines, extmarks = extmarks }
self:_reconcile()
end -- }}}
end
--- @private
--- @param info string
--- @param start integer
--- @param end_ integer
--- @param strict_indexing boolean
--- @param replacement string[]
function Renderer:_set_lines(start, end_, strict_indexing, replacement)
function Renderer:_set_lines(info, start, end_, strict_indexing, replacement)
self:_log { 'set_lines', self.bufnr, start, end_, strict_indexing, replacement }
vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement)
self:_log { 'after(' .. info .. ')', vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
end
--- @private
--- @param info string
--- @param start_row integer
--- @param start_col integer
--- @param end_row integer
--- @param end_col integer
--- @param replacement string[]
function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement)
function Renderer:_set_text(info, start_row, start_col, end_row, end_col, replacement)
self:_log { 'set_text', self.bufnr, start_row, start_col, end_row, end_col, replacement }
vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement)
self:_log { 'after(' .. info .. ')', vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
end
--- @private
function Renderer:_reconcile() -- {{{
function Renderer:_log(...)
--
-- vim.print(...)
end
--- @private
function Renderer:_reconcile()
local line_changes = utils.levenshtein(self.old.lines, self.curr.lines)
self.old = self.curr
--
-- Step 1: morph the text to the desired state:
--
Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines)
self:_log { line_changes = line_changes }
for _, line_change in ipairs(line_changes) do
local lnum0 = line_change.index - 1
if line_change.kind == 'add' then
self:_set_lines('add-line', lnum0, lnum0, true, { line_change.item })
elseif line_change.kind == 'change' then
-- Compute inter-line diff, and apply:
self:_log '--------------------------------------------------------------------------------'
local col_changes = utils.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, ''))
for _, col_change in ipairs(col_changes) do
local cnum0 = col_change.index - 1
self:_log { line_change = col_change, cnum = cnum0, lnum = lnum0 }
if col_change.kind == 'add' then
self:_set_text('add-char', lnum0, cnum0, lnum0, cnum0, { col_change.item })
elseif col_change.kind == 'change' then
self:_set_text('change-char', lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to })
elseif col_change.kind == 'delete' then
self:_set_text('del-char', lnum0, cnum0, lnum0, cnum0 + 1, {})
else
-- No change
end
end
elseif line_change.kind == 'delete' then
self:_set_lines('del-line', lnum0, lnum0 + 1, true, {})
else
-- No change
end
end
self.changedtick = vim.b[self.bufnr].changedtick
--
-- Step 2: reconcile extmarks:
--
-- Clear current extmarks:
vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1)
-- Set current extmarks:
@@ -295,14 +275,12 @@ function Renderer:_reconcile() -- {{{
}, extmark.opts)
)
end
self.old = self.curr
end -- }}}
end
--- @private
--- @param mode string
--- @param lhs string
function Renderer:_expr_map_callback(mode, lhs) -- {{{
function Renderer:_on_expr_map(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
@@ -331,7 +309,7 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
-- Resort to default behavior:
return cancel and '' or lhs
end -- }}}
end
--- Returns pairs of extmarks and tags associate with said extmarks. The
--- returned tags/extmarks are sorted smallest (innermost) to largest
@@ -339,8 +317,8 @@ end -- }}}
---
--- @private (private for now)
--- @param pos0 [number; number]
--- @return { extmark: RendererExtmark; tag: u.renderer.Tag; }[]
function Renderer:get_pos_infos(pos0) -- {{{
--- @return { extmark: RendererExtmark; tag: Tag; }[]
function Renderer:get_pos_infos(pos0)
local cursor_line0, cursor_col0 = pos0[1], pos0[2]
-- The cursor (block) occupies **two** extmark spaces: one for it's left
@@ -348,15 +326,7 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- 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 }
)
)
.iter(vim.api.nvim_buf_get_extmarks(self.bufnr, self.ns, pos0, pos0, { details = true, overlap = true }))
--- @return RendererExtmark
:map(function(ext)
--- @type number, number, number, { end_row?: number; end_col?: number }|nil
@@ -407,7 +377,7 @@ 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: u.renderer.Tag }[]
--- @type { extmark: RendererExtmark; tag: Tag }[]
local matching_tags = vim
.iter(intersecting_extmarks)
--- @param ext RendererExtmark
@@ -419,12 +389,14 @@ function Renderer:get_pos_infos(pos0) -- {{{
:totable()
return matching_tags
end -- }}}
-- }}}
end
-- TreeBuilder {{{
--- @class u.TreeBuilder
--- @field private nodes u.renderer.Node[]
--------------------------------------------------------------------------------
-- TreeBuilder class
--------------------------------------------------------------------------------
--- @class TreeBuilder
--- @field private nodes Node[]
local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder
M.TreeBuilder = TreeBuilder
@@ -434,8 +406,8 @@ function TreeBuilder.new()
return self
end
--- @param nodes u.renderer.Tree
--- @return u.TreeBuilder
--- @param nodes Tree
--- @return TreeBuilder
function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes)
return self
@@ -443,8 +415,8 @@ end
--- @param name string
--- @param attributes? table<string, any>
--- @param children? u.renderer.Node | u.renderer.Node[]
--- @return u.TreeBuilder
--- @param children? Node | Node[]
--- @return TreeBuilder
function TreeBuilder:put_h(name, attributes, children)
local tag = M.h(name, attributes, children)
table.insert(self.nodes, tag)
@@ -452,7 +424,7 @@ function TreeBuilder:put_h(name, attributes, children)
end
--- @param fn fun(TreeBuilder): any
--- @return u.TreeBuilder
--- @return TreeBuilder
function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new()
fn(nested_writer)
@@ -460,111 +432,7 @@ function TreeBuilder:nest(fn)
return self
end
--- @return u.renderer.Tree
--- @return Tree
function TreeBuilder:tree() return self.nodes end
-- }}}
-- Levenshtein utility {{{
-- luacheck: ignore
--- @alias LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
--- @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>[] 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
-- fudging with the costs of operations produces a more optimized change-set
-- that is tailored to working better with how NeoVim manipulates text. I've
-- done no further investigation in this area, however, so it's impossible to
-- tell if such tuning would produce real benefit. For now, I'm leaving this
-- in here even though it's not actively used. Hopefully having this
-- callback-based plumbing does not cause too much of a performance hit to
-- the renderer.
cost = cost or {}
local cost_of_delete_f = cost.of_delete or function() return 1 end
local cost_of_add_f = cost.of_add or function() return 1 end
local cost_of_change_f = cost.of_change or function() return 1 end
local m, n = #x, #y
-- Initialize the distance matrix
local dp = {}
for i = 0, m do
dp[i] = {}
for j = 0, n do
dp[i][j] = 0
end
end
-- Fill the base cases
for i = 0, m do
dp[i][0] = i
end
for j = 0, n do
dp[0][j] = j
end
-- Compute the Levenshtein distance dynamically
for i = 1, m do
for j = 1, n do
if x[i] == y[j] then
dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
else
local 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
-- Backtrack to find the changes
local i = m
local j = n
--- @type LevenshteinChange[]
local changes = {}
while i > 0 or j > 0 do
local default_cost = dp[i][j]
local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost
local cost_of_add = j > 0 and dp[i][j - 1] or default_cost
local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost
--- @param u number
--- @param v number
--- @param w number
local function is_first_min(u, v, w) return u <= v and u <= w end
if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change
if x[i] ~= y[j] then
--- @type LevenshteinChange
local change = { kind = 'change', from = x[i], index = i, to = y[j] }
table.insert(changes, change)
end
i = i - 1
j = j - 1
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition
--- @type LevenshteinChange
local change = { kind = 'add', item = y[j], index = i + 1 }
table.insert(changes, change)
j = j - 1
elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
-- deletion
--- @type LevenshteinChange
local change = { kind = 'delete', item = x[i], index = i }
table.insert(changes, change)
i = i - 1
else
error 'unreachable'
end
end
return changes
end
-- }}}
return M

View File

@@ -1,39 +1,61 @@
local M = {}
local IS_REPEATING = false
--- @type function
local REPEAT_ACTION = nil
local function _normal(cmd) vim.cmd { cmd = 'normal', args = { cmd }, bang = true } end
local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end
M.native_repeat = function() _normal '.' end
M.native_undo = function() _normal 'u' end
--- @param f fun()
function M.run_repeatable(f)
REPEAT_ACTION = f
REPEAT_ACTION()
vim.b.my_changedtick = vim.b.changedtick
local function update_ts() vim.b.tt_changedtick = vim.b.changedtick end
---@param cmd? string|fun():unknown
function M.set(cmd)
update_ts()
if cmd ~= nil then vim.b.tt_repeatcmd = cmd end
end
function M.is_repeating() return IS_REPEATING end
local function tt_was_last_repeatable()
local ts, tt_ts = vim.b.changedtick, vim.b.tt_changedtick
return tt_ts ~= nil and ts <= tt_ts
end
---@generic T
---@param cmd string|fun():T
---@return T
function M.run(cmd)
M.set(cmd)
local result = cmd()
update_ts()
return result
end
function M.do_repeat()
local tt_cmd = vim.b.tt_repeatcmd
if not tt_was_last_repeatable() or (type(tt_cmd) ~= 'function' and type(tt_cmd) ~= 'string') then
return M.native_repeat()
end
-- execute the cached command:
local count = vim.api.nvim_get_vvar 'count1'
if type(tt_cmd) == 'string' then
_normal(count .. tt_cmd --[[@as string]])
else
local last_return
for _ = 1, count do
last_return = M.run(tt_cmd --[[@as fun():any]])
end
return last_return
end
end
function M.undo()
local tt_was_last_repeatable_before_undo = tt_was_last_repeatable()
M.native_undo()
if tt_was_last_repeatable_before_undo then update_ts() end
end
function M.setup()
vim.keymap.set('n', '.', function()
IS_REPEATING = true
for _ = 1, vim.v.count1 do
if is_repeatable_last_mutator() and type(REPEAT_ACTION) == 'function' then
M.run_repeatable(REPEAT_ACTION)
else
vim.cmd { cmd = 'normal', args = { '.' }, bang = true }
end
end
IS_REPEATING = false
end)
vim.keymap.set('n', 'u', function()
local was_repeatable_last_mutator = is_repeatable_last_mutator()
for _ = 1, vim.v.count1 do
vim.cmd { cmd = 'normal', args = { 'u' }, bang = true }
end
if was_repeatable_last_mutator then vim.b.my_changedtick = vim.b.changedtick end
end)
vim.keymap.set('n', '.', M.do_repeat)
vim.keymap.set('n', 'u', M.undo)
end
return M

90
lua/u/state.lua Normal file
View File

@@ -0,0 +1,90 @@
---@class State
---@field buf number
---@field registers table
---@field marks table
---@field positions table
---@field keymaps { mode: string; lhs: any, rhs: any, buffer?: number }[]
---@field global_options table<string, any>
---@field win_view vim.fn.winsaveview.ret|nil
local State = {}
---@param buf number
---@return State
function State.new(buf)
if buf == 0 then buf = vim.api.nvim_get_current_buf() end
local s = { buf = buf, registers = {}, marks = {}, positions = {}, keymaps = {}, global_options = {} }
setmetatable(s, { __index = State })
return s
end
---@generic T
---@param buf number
---@param f fun(s: State):T
---@return T
function State.run(buf, f)
local s = State.new(buf)
local ok, result = pcall(f, s)
s:restore()
if not ok then error(result) end
return result
end
---@param buf number
---@param f fun(s: State, callback: fun(): any):any
---@param callback fun():any
function State.run_async(buf, f, callback)
local s = State.new(buf)
f(s, function()
s:restore()
callback()
end)
end
function State:track_keymap(mode, lhs)
local old =
-- Look up the mapping in buffer-local maps:
vim.iter(vim.api.nvim_buf_get_keymap(self.buf, mode)):find(function(map) return map.lhs == lhs end)
-- Look up the mapping in global maps:
or vim.iter(vim.api.nvim_get_keymap(mode)):find(function(map) return map.lhs == lhs end)
-- Did we find a mapping?
if old == nil then return end
-- Track it:
table.insert(self.keymaps, { mode = mode, lhs = lhs, rhs = old.rhs or old.callback, buffer = old.buffer })
end
---@param reg string
function State:track_register(reg) self.registers[reg] = vim.fn.getreg(reg) end
---@param mark string
function State:track_mark(mark) self.marks[mark] = vim.api.nvim_buf_get_mark(self.buf, mark) end
---@param pos string
function State:track_pos(pos) self.positions[pos] = vim.fn.getpos(pos) end
---@param nm string
function State:track_global_option(nm) self.global_options[nm] = vim.g[nm] end
function State:track_winview() self.win_view = vim.fn.winsaveview() end
function State:restore()
for reg, val in pairs(self.registers) do
vim.fn.setreg(reg, val)
end
for mark, val in pairs(self.marks) do
vim.api.nvim_buf_set_mark(self.buf, mark, val[1], val[2], {})
end
for pos, val in pairs(self.positions) do
vim.fn.setpos(pos, val)
end
for _, map in ipairs(self.keymaps) do
vim.keymap.set(map.mode, map.lhs, map.rhs, { buffer = map.buffer })
end
for nm, val in pairs(self.global_options) do
vim.g[nm] = val
end
if self.win_view ~= nil then vim.fn.winrestview(self.win_view) end
end
return State

View File

@@ -6,7 +6,7 @@ M.debug = false
-- class Signal
--------------------------------------------------------------------------------
--- @class u.Signal
--- @class Signal
--- @field name? string
--- @field private changing boolean
--- @field private value any
@@ -18,7 +18,7 @@ Signal.__index = Signal
--- @param value any
--- @param name? string
--- @return u.Signal
--- @return Signal
function Signal:new(value, name)
local obj = setmetatable({
name = name,
@@ -37,10 +37,7 @@ function Signal:set(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
@@ -86,7 +83,7 @@ function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end
--- @generic U
--- @param fn fun(value: T): U
--- @return u.Signal --<U>
--- @return Signal --<U>
function Signal:map(fn)
local mapped_signal = M.create_memo(function()
local value = self:get()
@@ -95,13 +92,13 @@ function Signal:map(fn)
return mapped_signal
end
--- @return u.Signal
--- @return Signal
function Signal:clone()
return self:map(function(x) return x end)
end
--- @param fn fun(value: T): boolean
--- @return u.Signal -- <T>
--- @return 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)
@@ -112,7 +109,7 @@ function Signal:filter(fn)
end
--- @param ms number
--- @return u.Signal -- <T>
--- @return Signal -- <T>
function Signal:debounce(ms)
local function set_timeout(timeout, callback)
local timer = (vim.uv or vim.loop).new_timer()
@@ -126,15 +123,15 @@ 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_timer_t
-- }
local state = { queued = {}, timer = nil }
local function clear_timeout()
if state.timer == nil then return end
pcall(function()
--- @diagnostic disable-next-line: undefined-field
state.timer:stop()
--- @diagnostic disable-next-line: undefined-field
state.timer:close()
end)
state.timer = nil
@@ -146,10 +143,7 @@ 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)
@@ -202,16 +196,16 @@ end
-- class ExecutionContext
--------------------------------------------------------------------------------
local CURRENT_CONTEXT = nil
CURRENT_CONTEXT = nil
--- @class u.ExecutionContext
--- @field signals table<u.Signal, boolean>
--- @class ExecutionContext
--- @field signals table<Signal, boolean>
local ExecutionContext = {}
M.ExecutionContext = ExecutionContext
ExecutionContext.__index = ExecutionContext
--- @return u.ExecutionContext
function ExecutionContext.new()
--- @return ExecutionContext
function ExecutionContext:new()
return setmetatable({
signals = {},
subscribers = {},
@@ -221,8 +215,8 @@ end
function ExecutionContext.current() return CURRENT_CONTEXT end
--- @param fn function
--- @param ctx u.ExecutionContext
function ExecutionContext.run(fn, ctx)
--- @param ctx ExecutionContext
function ExecutionContext:run(fn, ctx)
local oldCtx = CURRENT_CONTEXT
CURRENT_CONTEXT = ctx
local result
@@ -264,14 +258,14 @@ end
--- @param value any
--- @param name? string
--- @return u.Signal
--- @return Signal
function M.create_signal(value, name) return Signal:new(value, name) end
--- @param fn function
--- @param name? string
--- @return u.Signal
--- @return Signal
function M.create_memo(fn, name)
--- @type u.Signal
--- @type Signal
local result
local unsubscribe = M.create_effect(function()
local value = fn()
@@ -289,8 +283,8 @@ 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

@@ -1,45 +0,0 @@
local Range = require 'u.range'
local M = {}
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
--- @param key_seq string
--- @param fn fun(key_seq: string):u.Range|nil
--- @param opts? { buffer: number|nil }
function M.define(key_seq, fn, opts)
if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end
local function handle_visual()
local range = fn(key_seq)
if range == nil or range:is_empty() then
vim.cmd.normal(ESC)
return
end
range:set_visual_selection()
end
vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil)
local function handle_normal()
local range = fn(key_seq)
if range == nil then return end
if not range:is_empty() then
range:set_visual_selection()
else
local original_eventignore = vim.go.eventignore
vim.go.eventignore = 'all'
-- insert a single space, so we can select it:
local p = range.start
p:insert_before ' '
vim.go.eventignore = original_eventignore
-- select the space:
Range.new(p, p, 'v'):set_visual_selection()
end
end
vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil)
end
return M

View File

@@ -4,10 +4,9 @@ local M = {}
-- Types
--
--- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
--- @alias KeyMaps table<string, fun(): any | string> }
-- luacheck: ignore
--- @alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: u.Range|nil }
---@alias 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: Range|nil }
--- @generic T
--- @param x `T`
@@ -33,10 +32,9 @@ end
--- vim.print(args.info:lines())
--- end, { nargs = '*', range = true })
--- ```
--- @param name string
--- @param cmd string | fun(args: CmdArgs): any
-- luacheck: ignore
--- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
---@param name string
---@param cmd string | fun(args: CmdArgs): any
---@param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
function M.ucmd(name, cmd, opts)
local Range = require 'u.range'
@@ -51,6 +49,167 @@ function M.ucmd(name, cmd, opts)
vim.api.nvim_create_user_command(name, cmd2, opts or {})
end
---@param key_seq string
---@param fn fun(key_seq: string):Range|Pos|nil
---@param opts? { buffer: number|nil }
function M.define_text_object(key_seq, fn, opts)
local Range = require 'u.range'
local Pos = require 'u.pos'
if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end
local function handle_visual()
local range_or_pos = fn(key_seq)
if range_or_pos == nil then return end
if Range.is(range_or_pos) and range_or_pos:is_empty() then range_or_pos = range_or_pos.start end
if Range.is(range_or_pos) then
local range = range_or_pos --[[@as Range]]
range:set_visual_selection()
else
vim.cmd { cmd = 'normal', args = { '<Esc>' }, bang = true }
end
end
vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil)
local function handle_normal()
local State = require 'u.state'
-- enter visual mode:
vim.cmd { cmd = 'normal', args = { 'v' }, bang = true }
local range_or_pos = fn(key_seq)
if range_or_pos == nil then return end
if Range.is(range_or_pos) and range_or_pos:is_empty() then range_or_pos = range_or_pos.start end
if Range.is(range_or_pos) then
range_or_pos:set_visual_selection()
elseif Pos.is(range_or_pos) then
local p = range_or_pos --[[@as Pos]]
State.run(0, function(s)
s:track_global_option 'eventignore'
vim.opt_global.eventignore = 'all'
-- insert a single space, so we can select it:
vim.api.nvim_buf_set_text(0, p.lnum, p.col, p.lnum, p.col, { ' ' })
-- select the space:
Range.new(p, p, 'v'):set_visual_selection()
end)
end
end
vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil)
end
---@type fun(): nil|(fun():any)
local __U__RepeatableOpFunc_rhs = nil
--- This is the global utility function used for operatorfunc
--- in repeatablemap
---@type nil|fun(range: Range): fun():any|nil
-- selene: allow(unused_variable)
function __U__RepeatableOpFunc()
if __U__RepeatableOpFunc_rhs ~= nil then __U__RepeatableOpFunc_rhs() end
end
function M.repeatablemap(mode, lhs, rhs, opts)
vim.keymap.set(mode, lhs, function()
__U__RepeatableOpFunc_rhs = rhs
vim.o.operatorfunc = 'v:lua.__U__RepeatableOpFunc'
return 'g@ '
end, vim.tbl_extend('force', opts or {}, { expr = true }))
end
function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
--- @alias 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>[]
function M.levenshtein(x, y, cost)
cost = cost or {}
local cost_of_delete_f = cost.of_delete or function() return 1 end
local cost_of_add_f = cost.of_add or function() return 1 end
local cost_of_change_f = cost.of_change or function() return 1 end
local m, n = #x, #y
-- Initialize the distance matrix
local dp = {}
for i = 0, m do
dp[i] = {}
for j = 0, n do
dp[i][j] = 0
end
end
-- Fill the base cases
for i = 0, m do
dp[i][0] = i
end
for j = 0, n do
dp[0][j] = j
end
-- Compute the Levenshtein distance dynamically
for i = 1, m do
for j = 1, n do
if x[i] == y[j] then
dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
else
local costDelete = dp[i - 1][j] + cost_of_delete_f(x[i])
local costAdd = dp[i][j - 1] + cost_of_add_f(y[j])
local costChange = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j])
dp[i][j] = math.min(costDelete, costAdd, costChange)
end
end
end
-- Backtrack to find the changes
local i = m
local j = n
--- @type LevenshteinChange[]
local changes = {}
while i > 0 or j > 0 do
local default_cost = dp[i][j]
local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost
local cost_of_add = j > 0 and dp[i][j - 1] or default_cost
local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost
--- @param u number
--- @param v number
--- @param w number
local function is_first_min(u, v, w) return u <= v and u <= w end
if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change
if x[i] ~= y[j] then
--- @type LevenshteinChange
local change = { kind = 'change', from = x[i], index = i, to = y[j] }
table.insert(changes, change)
end
i = i - 1
j = j - 1
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition
--- @type LevenshteinChange
local change = { kind = 'add', item = y[j], index = i + 1 }
table.insert(changes, change)
j = j - 1
elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
-- deletion
--- @type LevenshteinChange
local change = { kind = 'delete', item = x[i], index = i }
table.insert(changes, change)
i = i - 1
else
error 'unreachable'
end
end
return changes
end
return M

4
selene.toml Normal file
View File

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

View File

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

View File

@@ -6,7 +6,7 @@ describe('Buffer', function()
withbuf({}, function()
local buf = Buffer.from_nr()
buf:all():replace 'bleh'
local actual_lines = vim.api.nvim_buf_get_lines(buf.bufnr, 0, -1, false)
local actual_lines = vim.api.nvim_buf_get_lines(buf.buf, 0, -1, false)
assert.are.same({ 'bleh' }, actual_lines)
end)
end)
@@ -18,8 +18,8 @@ describe('Buffer', function()
'three',
}, function()
local buf = Buffer.from_nr()
buf:lines(2, -2):replace 'too'
local actual_lines = vim.api.nvim_buf_get_lines(buf.bufnr, 0, -1, false)
buf:lines(1, -2):replace 'too'
local actual_lines = vim.api.nvim_buf_get_lines(buf.buf, 0, -1, false)
assert.are.same({
'one',
'too',

View File

@@ -4,12 +4,12 @@ local withbuf = loadfile './spec/withbuf.lua'()
describe('Pos', function()
it('get a char from a given position', function()
withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
assert.are.same('a', Pos.new(nil, 1, 1):char())
assert.are.same('d', Pos.new(nil, 1, 3):char())
assert.are.same('f', Pos.new(nil, 1, 4):char())
assert.are.same('a', Pos.new(nil, 3, 1):char())
assert.are.same('', Pos.new(nil, 4, 1):char())
assert.are.same('o', Pos.new(nil, 5, 3):char())
assert.are.same('a', Pos.new(nil, 0, 0):char())
assert.are.same('d', Pos.new(nil, 0, 2):char())
assert.are.same('f', Pos.new(nil, 0, 3):char())
assert.are.same('a', Pos.new(nil, 2, 0):char())
assert.are.same('', Pos.new(nil, 3, 0):char())
assert.are.same('o', Pos.new(nil, 4, 2):char())
end)
end)
@@ -23,47 +23,47 @@ describe('Pos', function()
it('get the next position', function()
withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
-- line 1: a => s
assert.are.same(Pos.new(nil, 1, 2), Pos.new(nil, 1, 1):next())
assert.are.same(Pos.new(nil, 0, 1), Pos.new(nil, 0, 0):next())
-- line 1: d => f
assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 1, 3):next())
assert.are.same(Pos.new(nil, 0, 3), Pos.new(nil, 0, 2):next())
-- line 1 => 2
assert.are.same(Pos.new(nil, 2, 1), Pos.new(nil, 1, 4):next())
assert.are.same(Pos.new(nil, 1, 0), Pos.new(nil, 0, 3):next())
-- line 3 => 4
assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 3, 1):next())
assert.are.same(Pos.new(nil, 3, 0), Pos.new(nil, 2, 0):next())
-- line 4 => 5
assert.are.same(Pos.new(nil, 5, 1), Pos.new(nil, 4, 1):next())
assert.are.same(Pos.new(nil, 4, 0), Pos.new(nil, 3, 0):next())
-- end returns nil
assert.are.same(nil, Pos.new(nil, 5, 3):next())
assert.are.same(nil, Pos.new(nil, 4, 2):next())
end)
end)
it('get the previous position', function()
withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
-- line 1: s => a
assert.are.same(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2):next(-1))
assert.are.same(Pos.new(nil, 0, 0), Pos.new(nil, 0, 1):next(-1))
-- line 1: f => d
assert.are.same(Pos.new(nil, 1, 3), Pos.new(nil, 1, 4):next(-1))
assert.are.same(Pos.new(nil, 0, 2), Pos.new(nil, 0, 3):next(-1))
-- line 2 => 1
assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 2, 1):next(-1))
assert.are.same(Pos.new(nil, 0, 3), Pos.new(nil, 1, 0):next(-1))
-- line 4 => 3
assert.are.same(Pos.new(nil, 3, 1), Pos.new(nil, 4, 1):next(-1))
assert.are.same(Pos.new(nil, 2, 0), Pos.new(nil, 3, 0):next(-1))
-- line 5 => 4
assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 5, 1):next(-1))
assert.are.same(Pos.new(nil, 3, 0), Pos.new(nil, 4, 0):next(-1))
-- beginning returns nil
assert.are.same(nil, Pos.new(nil, 1, 1):next(-1))
assert.are.same(nil, Pos.new(nil, 0, 0):next(-1))
end)
end)
it('find matching brackets', function()
withbuf({ 'asdf ({} def <[{}]>) ;lkj' }, function()
-- outer parens are matched:
assert.are.same(Pos.new(nil, 1, 20), Pos.new(nil, 1, 6):find_match())
assert.are.same(Pos.new(nil, 0, 19), Pos.new(nil, 0, 5):find_match())
-- outer parens are matched (backward):
assert.are.same(Pos.new(nil, 1, 6), Pos.new(nil, 1, 20):find_match())
assert.are.same(Pos.new(nil, 0, 5), Pos.new(nil, 0, 19):find_match())
-- no potential match returns nil
assert.are.same(nil, Pos.new(nil, 1, 1):find_match())
assert.are.same(nil, Pos.new(nil, 0, 0):find_match())
-- watchdog expires before an otherwise valid match is found:
assert.are.same(nil, Pos.new(nil, 1, 6):find_match(2))
assert.are.same(nil, Pos.new(nil, 0, 5):find_match(2))
end)
end)
end)

View File

@@ -28,7 +28,7 @@ describe('Range', function()
it('get from positions: v in single line', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 1, 4), 'v')
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 0, 3), 'v')
local lines = range:lines()
assert.are.same({ 'ine' }, lines)
@@ -39,7 +39,7 @@ describe('Range', function()
it('get from positions: v across multiple lines', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 3, 5), 'v')
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 2, 4), 'v')
local lines = range:lines()
assert.are.same({ 'quick brown fox', 'jumps' }, lines)
end)
@@ -47,7 +47,7 @@ describe('Range', function()
it('get from positions: V', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, Pos.MAX_COL), 'V')
local range = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, Pos.MAX_COL), 'V')
local lines = range:lines()
assert.are.same({ 'line one' }, lines)
@@ -58,7 +58,7 @@ describe('Range', function()
it('get from positions: V across multiple lines', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 3, Pos.MAX_COL), 'V')
local range = Range.new(Pos.new(nil, 1, 0), Pos.new(nil, 2, Pos.MAX_COL), 'V')
local lines = range:lines()
assert.are.same({ 'the quick brown fox', 'jumps over a lazy dog' }, lines)
end)
@@ -66,7 +66,7 @@ describe('Range', function()
it('get from line', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_line(nil, 1)
local range = Range.from_line(nil, 0)
local lines = range:lines()
assert.are.same({ 'line one' }, lines)
@@ -77,7 +77,7 @@ describe('Range', function()
it('get from lines', function()
withbuf({ 'line one', 'and line two', 'and line 3' }, function()
local range = Range.from_lines(nil, 1, 2)
local range = Range.from_lines(nil, 0, 1)
local lines = range:lines()
assert.are.same({ 'line one', 'and line two' }, lines)
@@ -88,35 +88,35 @@ describe('Range', function()
it('replace within line', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 9), 'v')
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 1, 8), 'v')
range:replace 'quack'
local text = Range.from_line(nil, 2):text()
local text = Range.from_line(nil, 1):text()
assert.are.same('the quack brown fox', text)
end)
end)
it('delete within line', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 10), 'v')
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 1, 9), 'v')
range:replace ''
local text = Range.from_line(nil, 2):text()
local text = Range.from_line(nil, 1):text()
assert.are.same('the brown fox', text)
end)
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 10), 'v')
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 1, 9), 'v')
range:replace(nil)
local text = Range.from_line(nil, 2):text()
local text = Range.from_line(nil, 1):text()
assert.are.same('the brown fox', text)
end)
end)
it('replace across multiple lines: v', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 3, 5), 'v')
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 2, 4), 'v')
range:replace 'plane flew'
local lines = Range.from_buf_text():lines()
@@ -130,7 +130,7 @@ describe('Range', function()
it('replace a line', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.from_line(nil, 2)
local range = Range.from_line(nil, 1)
range:replace 'the rabbit'
local lines = Range.from_buf_text():lines()
@@ -145,7 +145,7 @@ describe('Range', function()
it('replace multiple lines', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.from_lines(nil, 2, 3)
local range = Range.from_lines(nil, 1, 2)
range:replace 'the rabbit'
local lines = Range.from_buf_text():lines()
@@ -159,7 +159,7 @@ describe('Range', function()
it('delete single line', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.from_line(nil, 2)
local range = Range.from_line(nil, 1)
range:replace(nil) -- delete lines
local lines = Range.from_buf_text():lines()
@@ -173,7 +173,7 @@ describe('Range', function()
it('delete multiple lines', function()
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
local range = Range.from_lines(nil, 2, 3)
local range = Range.from_lines(nil, 1, 2)
range:replace(nil) -- delete lines
local lines = Range.from_buf_text():lines()
@@ -187,53 +187,53 @@ describe('Range', function()
it('text object: word', function()
withbuf({ 'the quick brown fox' }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
assert.are.same('quick ', Range.from_motion('aw'):text())
assert.are.same('quick ', Range.from_text_object('aw'):text())
vim.fn.setpos('.', { 0, 1, 5, 0 })
assert.are.same('quick', Range.from_motion('iw'):text())
assert.are.same('quick', Range.from_text_object('iw'):text())
end)
end)
it('text object: quote', function()
withbuf({ [[the "quick" brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
assert.are.same('"quick"', Range.from_motion('a"'):text())
assert.are.same('"quick"', Range.from_text_object('a"'):text())
vim.fn.setpos('.', { 0, 1, 6, 0 })
assert.are.same('quick', Range.from_motion('i"'):text())
assert.are.same('quick', Range.from_text_object('i"'):text())
end)
withbuf({ [[the 'quick' brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
assert.are.same("'quick'", Range.from_motion([[a']]):text())
assert.are.same("'quick'", Range.from_text_object([[a']]):text())
vim.fn.setpos('.', { 0, 1, 6, 0 })
assert.are.same('quick', Range.from_motion([[i']]):text())
assert.are.same('quick', Range.from_text_object([[i']]):text())
end)
withbuf({ [[the `quick` brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
assert.are.same('`quick`', Range.from_motion([[a`]]):text())
assert.are.same('`quick`', Range.from_text_object([[a`]]):text())
vim.fn.setpos('.', { 0, 1, 6, 0 })
assert.are.same('quick', Range.from_motion([[i`]]):text())
assert.are.same('quick', Range.from_text_object([[i`]]):text())
end)
end)
it('text object: block', function()
withbuf({ 'this is a {', 'block', '} here' }, function()
vim.fn.setpos('.', { 0, 2, 1, 0 })
assert.are.same('{\nblock\n}', Range.from_motion('a{'):text())
assert.are.same('{\nblock\n}', Range.from_text_object('a{'):text())
vim.fn.setpos('.', { 0, 2, 1, 0 })
assert.are.same('block', Range.from_motion('i{'):text())
assert.are.same('block', Range.from_text_object('i{'):text())
end)
end)
it('text object: restores cursor position', function()
withbuf({ 'this is a {block} here' }, function()
vim.fn.setpos('.', { 0, 1, 13, 0 })
assert.are.same('{block}', Range.from_motion('a{'):text())
assert.are.same('{block}', Range.from_text_object('a{'):text())
assert.are.same(vim.api.nvim_win_get_cursor(0), { 1, 12 })
end)
end)
@@ -258,18 +258,22 @@ describe('Range', function()
end)
end)
it('line', function()
it('line0', function()
withbuf({
'this is a {',
'block',
'} here',
}, function()
local range = Range.new(Pos.new(0, 1, 6), Pos.new(0, 2, 5), 'v')
local lfirst = assert(range:line(1), 'lfirst null')
assert.are.same('is a {', lfirst:text())
assert.are.same(Pos.new(0, 1, 6), lfirst.start)
assert.are.same(Pos.new(0, 1, 11), lfirst.stop)
assert.are.same('block', range:line(2):text())
local range = Range.new(Pos.new(0, 0, 5), Pos.new(0, 1, 4), 'v')
local lfirst = range:line0(0)
assert.are.same(5, lfirst.idx0.start)
assert.are.same(10, lfirst.idx0.stop)
assert.are.same(0, lfirst.lnum)
assert.are.same('is a {', lfirst.text())
assert.are.same('is a {', lfirst.range():text())
assert.are.same(Pos.new(0, 0, 5), lfirst.range().start)
assert.are.same(Pos.new(0, 0, 10), lfirst.range().stop)
assert.are.same('block', range:line0(1).text())
end)
end)
@@ -293,16 +297,16 @@ describe('Range', function()
vim.cmd.normal 'v' -- enter visual mode
vim.cmd.normal 'l' -- select one character to the right
local range = Range.from_vtext()
assert.are.same(range.start, Pos.new(nil, 1, 3))
assert.are.same(range.stop, Pos.new(nil, 1, 4))
assert.are.same(range.start, Pos.new(nil, 0, 2))
assert.are.same(range.stop, Pos.new(nil, 0, 3))
assert.are.same(range.mode, 'v')
end)
end)
it('from_op_func', function()
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 2, 2)
local a = Pos.new(nil, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'["
b:save_to_pos "']"
@@ -313,7 +317,7 @@ describe('Range', function()
range = Range.from_op_func 'line'
assert.are.same(range.start, a)
assert.are.same(range.stop, Pos.new(nil, 2, Pos.MAX_COL))
assert.are.same(range.stop, Pos.new(nil, 1, Pos.MAX_COL))
assert.are.same(range.mode, 'V')
end)
end)
@@ -321,8 +325,8 @@ describe('Range', function()
it('from_cmd_args', function()
local args = { range = 1 }
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 2, 2)
local a = Pos.new(nil, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'<"
b:save_to_pos "'>"
@@ -337,25 +341,25 @@ describe('Range', function()
withbuf({ [[the "quick" brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
local range = Range.find_nearest_quotes()
assert.are.same(range.start, Pos.new(nil, 1, 5))
assert.are.same(range.stop, Pos.new(nil, 1, 11))
assert.are.same(range.start, Pos.new(nil, 0, 4))
assert.are.same(range.stop, Pos.new(nil, 0, 10))
end)
withbuf({ [[the 'quick' brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
local range = Range.find_nearest_quotes()
assert.are.same(range.start, Pos.new(nil, 1, 5))
assert.are.same(range.stop, Pos.new(nil, 1, 11))
assert.are.same(range.start, Pos.new(nil, 0, 4))
assert.are.same(range.stop, Pos.new(nil, 0, 10))
end)
end)
it('smallest', function()
local r1 = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 1, 4), 'v')
local r2 = Range.new(Pos.new(nil, 1, 3), Pos.new(nil, 1, 5), 'v')
local r3 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 6), 'v')
local r1 = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 0, 3), 'v')
local r2 = Range.new(Pos.new(nil, 0, 2), Pos.new(nil, 0, 4), 'v')
local r3 = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 5), 'v')
local smallest = Range.smallest { r1, r2, r3 }
assert.are.same(smallest.start, Pos.new(nil, 1, 2))
assert.are.same(smallest.stop, Pos.new(nil, 1, 4))
assert.are.same(smallest.start, Pos.new(nil, 0, 1))
assert.are.same(smallest.stop, Pos.new(nil, 0, 3))
end)
it('clone', function()
@@ -377,9 +381,9 @@ describe('Range', function()
it('to_linewise()', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 2, 4), 'v')
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 1, 3), 'v')
local linewise_range = range:to_linewise()
assert.are.same(linewise_range.start.col, 1)
assert.are.same(linewise_range.start.col, 0)
assert.are.same(linewise_range.stop.col, Pos.MAX_COL)
assert.are.same(linewise_range.mode, 'V')
end)
@@ -387,133 +391,82 @@ describe('Range', function()
it('is_empty', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 1, 1), nil, 'v')
local range = Range.new(Pos.new(nil, 0, 0), nil, 'v')
assert.is_true(range:is_empty())
local range2 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2), 'v')
local range2 = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 1), 'v')
assert.is_false(range2:is_empty())
end)
end)
it('trim_start', function()
withbuf({ ' line one', 'line two' }, function()
local range = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 10), 'v')
local range = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 9), 'v')
local trimmed = range:trim_start()
assert.are.same(trimmed.start, Pos.new(nil, 1, 4)) -- should be after the spaces
assert.are.same(trimmed.start, Pos.new(nil, 0, 3)) -- should be after the spaces
end)
end)
it('trim_stop', function()
withbuf({ 'line one ', 'line two' }, function()
local range = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 10), 'v')
local range = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 9), 'v')
local trimmed = range:trim_stop()
assert.are.same(trimmed.stop, Pos.new(nil, 1, 8)) -- should be before the spaces
assert.are.same(trimmed.stop, Pos.new(nil, 0, 7)) -- should be before the spaces
end)
end)
it('contains', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 1, 4), 'v')
local pos = Pos.new(nil, 1, 3)
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 0, 3), 'v')
local pos = Pos.new(nil, 0, 2)
assert.is_true(range:contains(pos))
pos = Pos.new(nil, 1, 5) -- outside of range
pos = Pos.new(nil, 0, 4) -- outside of range
assert.is_false(range:contains(pos))
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')
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 1, 3), 'v')
local shrunk = range:shrink(1)
assert.are.same(shrunk.start, Pos.new(nil, 2, 4))
assert.are.same(shrunk.stop, Pos.new(nil, 3, 4))
assert.are.same(shrunk.start, Pos.new(nil, 0, 2))
assert.are.same(shrunk.stop, Pos.new(nil, 1, 2))
end)
end)
it('must_shrink', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 2, 3), Pos.new(nil, 3, 5), 'v')
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 1, 3), 'v')
local shrunk = range:must_shrink(1)
assert.are.same(shrunk.start, Pos.new(nil, 2, 4))
assert.are.same(shrunk.stop, Pos.new(nil, 3, 4))
assert.are.same(shrunk.start, Pos.new(nil, 0, 2))
assert.are.same(shrunk.stop, Pos.new(nil, 1, 2))
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)
it('set_visual_selection', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_lines(nil, 1, 2)
local range = Range.from_lines(nil, 0, 1)
range:set_visual_selection()
assert.are.same(Pos.from_pos 'v', Pos.new(nil, 1, 1))
-- Since the selection is 'V' (instead of 'v'), the end
-- selects one character past the end:
assert.are.same(Pos.from_pos '.', Pos.new(nil, 2, 13))
assert.are.same(Pos.from_pos 'v', Pos.new(nil, 0, 0))
assert.are.same(Pos.from_pos '.', Pos.new(nil, 1, 11))
end)
end)
it('selections set to past the EOL should not error', function()
withbuf({ 'Rg SET NAMES' }, function()
local b = vim.api.nvim_get_current_buf()
local r = Range.new(Pos.new(b, 1, 4), Pos.new(b, 1, 13), 'v')
local r = Range.new(Pos.new(b, 0, 3), Pos.new(b, 0, 12), 'v')
r:replace 'bleh'
assert.are.same({ 'Rg bleh' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
end)
withbuf({ 'Rg SET NAMES' }, function()
local b = vim.api.nvim_get_current_buf()
local r = Range.new(Pos.new(b, 1, 4), Pos.new(b, 1, 12), 'v')
local r = Range.new(Pos.new(b, 0, 3), Pos.new(b, 0, 11), 'v')
r:replace 'bleh'
assert.are.same({ 'Rg bleh' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
end)
@@ -522,19 +475,13 @@ describe('Range', function()
it('replace updates Range.stop: same line', function()
withbuf({ 'The quick brown fox jumps over the lazy dog' }, function()
local b = vim.api.nvim_get_current_buf()
local r = Range.new(Pos.new(b, 1, 5), Pos.new(b, 1, 9), 'v')
local r = Range.new(Pos.new(b, 0, 4), Pos.new(b, 0, 8), '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)
@@ -544,21 +491,14 @@ describe('Range', function()
'over the lazy dog',
}, function()
local b = vim.api.nvim_get_current_buf()
local r = Range.new(Pos.new(b, 1, 21), Pos.new(b, 2, 4), 'v')
local r = Range.new(Pos.new(b, 0, 20), Pos.new(b, 1, 3), 'v')
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({ 'bleh1' }, r:lines())
assert.are.same({ 'The quick brown fox bleh1 the lazy dog' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
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)
@@ -571,7 +511,7 @@ describe('Range', function()
'the lazy dog',
}, function()
local b = vim.api.nvim_get_current_buf()
local r = Range.new(Pos.new(b, 2, 1), Pos.new(b, 4, Pos.MAX_COL), 'V')
local r = Range.new(Pos.new(b, 1, 0), Pos.new(b, 3, Pos.MAX_COL), 'V')
assert.are.same({ 'fox', 'jumps', 'over' }, r:lines())
r:replace { 'bleh1', 'bleh2' }
@@ -600,7 +540,7 @@ describe('Range', function()
'the lazy dog',
}, function()
local b = vim.api.nvim_get_current_buf()
local r = Range.new(Pos.new(b, 2, 1), Pos.new(b, 4, Pos.MAX_COL), 'V')
local r = Range.new(Pos.new(b, 1, 0), Pos.new(b, 3, Pos.MAX_COL), 'V')
assert.are.same({ 'fox', 'jumps', 'over' }, r:lines())
r:replace(nil)

View File

@@ -1,135 +0,0 @@
local R = require 'u.renderer'
local withbuf = loadfile './spec/withbuf.lua'()
local function getlines() return vim.api.nvim_buf_get_lines(0, 0, -1, true) end
describe('Renderer', function()
it('should render text in an empty buffer', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render { 'hello', ' ', 'world' }
assert.are.same(getlines(), { 'hello world' })
end)
end)
it('should result in the correct text after repeated renders', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render { 'hello', ' ', 'world' }
assert.are.same(getlines(), { 'hello world' })
r:render { 'goodbye', ' ', 'world' }
assert.are.same(getlines(), { 'goodbye world' })
r:render { 'hello', ' ', 'universe' }
assert.are.same(getlines(), { 'hello universe' })
end)
end)
it('should handle tags correctly', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
R.h('text', { hl = 'HighlightGroup' }, 'hello '),
R.h('text', { hl = 'HighlightGroup' }, 'world'),
}
assert.are.same(getlines(), { 'hello world' })
end)
end)
it('should reconcile added lines', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render { 'line 1', '\n', 'line 2' }
assert.are.same(getlines(), { 'line 1', 'line 2' })
-- Add a new line:
r:render { 'line 1', '\n', 'line 2\n', 'line 3' }
assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' })
end)
end)
it('should reconcile deleted lines', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render { 'line 1', '\nline 2', '\nline 3' }
assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' })
-- Remove a line:
r:render { 'line 1', '\nline 3' }
assert.are.same(getlines(), { 'line 1', 'line 3' })
end)
end)
it('should handle multiple nested elements', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
R.h('text', {}, {
'first line',
}),
'\n',
R.h('text', {}, 'second line'),
}
assert.are.same(getlines(), { 'first line', 'second line' })
r:render {
R.h('text', {}, 'updated first line'),
'\n',
R.h('text', {}, 'third line'),
}
assert.are.same(getlines(), { 'updated first line', 'third line' })
end)
end)
--
-- get_pos_infos
--
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 }
assert.are.same(pos_infos, {})
end)
end)
it('should return correct extmark for a given position', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
R.h('text', { hl = 'HighlightGroup1' }, 'Hello'),
R.h('text', { hl = 'HighlightGroup2' }, ' World'),
}
local pos_infos = r:get_pos_infos { 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 })
end)
end)
it('should return multiple extmarks for overlapping text', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
R.h('text', { hl = 'HighlightGroup1' }, {
'Hello',
R.h(
'text',
{ hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } },
' World'
),
}),
}
local pos_infos = r:get_pos_infos { 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)
end)

View File

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

70
spec/utils_spec.lua Normal file
View File

@@ -0,0 +1,70 @@
local utils = require 'u.utils'
--- @param s string
local function split(s) return vim.split(s, '') end
--- @param original string
--- @param changes LevenshteinChange[]
local function morph(original, changes)
local t = split(original)
for _, change in ipairs(changes) do
if change.kind == 'add' then
table.insert(t, change.index, change.item)
elseif change.kind == 'delete' then
table.remove(t, change.index)
elseif change.kind == 'change' then
t[change.index] = change.to
end
end
return vim.iter(t):join ''
end
describe('utils', function()
it('levenshtein', function()
local original = 'abc'
local result = 'absece'
local changes = utils.levenshtein(split(original), split(result))
assert.are.same(changes, {
{
item = 'e',
kind = 'add',
index = 4,
},
{
item = 'e',
kind = 'add',
index = 3,
},
{
item = 's',
kind = 'add',
index = 3,
},
})
assert.are.same(morph(original, changes), result)
original = 'jonathan'
result = 'ajoanthan'
changes = utils.levenshtein(split(original), split(result))
assert.are.same(changes, {
{
from = 'a',
index = 4,
kind = 'change',
to = 'n',
},
{
from = 'n',
index = 3,
kind = 'change',
to = 'a',
},
{
index = 1,
item = 'a',
kind = 'add',
},
})
assert.are.same(morph(original, changes), result)
end)
end)

View File

@@ -1,6 +1,5 @@
require 'luacov'
local function withbuf(lines, f)
vim.go.swapfile = false
vim.opt_global.swapfile = false
vim.cmd.new()
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)

View File

@@ -1,6 +1,6 @@
call_parentheses = "None"
collapse_simple_statement = "Always"
column_width = 100
column_width = 120
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"

36
vim.yml Normal file
View File

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