11 Commits

Author SHA1 Message Date
f9ea5b0658 Range.from_motion: preserve jumplist
Some checks failed
ci / ci (push) Failing after 1m5s
2026-04-14 18:37:13 -06:00
88b7a11efa (mise) use cargo-binstall
Some checks failed
ci / ci (push) Failing after 1m2s
2026-04-13 22:26:43 -06:00
b473ac3923 (chore) install nvimv via mise
Some checks failed
ci / ci (push) Has been cancelled
2026-04-13 22:24:19 -06:00
63c920dbf1 v3
Some checks failed
ci / ci (push) Failing after 3h8m23s
- range: extmarks/tsquery
- mise for dev env
- get rid of obsolete modules
- implement as single file module
2026-04-08 22:44:45 -06:00
12945a4cdf (examples/notify.lua) eliminate dependency on non-existent class
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m17s
2025-10-02 19:53:04 -06:00
6f86bfaa42 ensure normal-mode before running g@...
All checks were successful
NeoVim tests / code-quality (push) Successful in 1m21s
2025-06-04 23:00:22 -06:00
81ba1bb96b Use nix to create environment for testing
All checks were successful
NeoVim tests / code-quality (push) Successful in 14m25s
2025-05-19 19:03:24 -06:00
35b6e123ac Fix issue caused by last commit
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 10s
It seems that closures are not interacting well with the global nature
of operatorfunc's. That is just a hunch, and this feels like a hack, but
it fixes the issue.
2025-05-17 07:33:58 -06:00
237bc9ba5e (range) use g@ for Range.from_motion
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 9s
2025-05-09 21:34:44 -06:00
c760c495b7 update lua API to 1-based indices; add renderer
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 11s
2025-05-04 15:44:09 -06:00
44a97b5baa (README.md) point to v2
All checks were successful
NeoVim tests / plenary-tests (push) Successful in 10s
2025-05-04 15:42:46 -06:00
48 changed files with 3837 additions and 5198 deletions

12
.busted Normal file
View File

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

22
.emmyrc.json Normal file
View File

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

View File

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

3
.gitignore vendored
View File

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

8
.gitmodules vendored Normal file
View File

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

6
.luacov Normal file
View File

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

1
.styluaignore Normal file
View File

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

34
AGENTS.md Normal file
View File

@@ -0,0 +1,34 @@
# AGENTS.md
## Project
Single-file Neovim Lua micro-library (`lua/u.lua`) for range-based text operations, positions, operator-pending mappings, and text objects. Not a plugin — meant to be vendored by other plugins.
## Commands
All tasks use `mise`:
- `mise run fmt` — format (stylua)
- `mise run fmt:check` — check formatting
- `mise run lint` — type-check with `emmylua_check` (ignores `.prefix/`)
- `mise run test` — run busted against Neovim 0.12.1 (includes `test:prepare`)
- `mise run test:all` — test against 0.11.5, 0.12.1, and nightly
- `mise run test:coverage` — test with luacov (only covers `lua/u$`)
- `mise run ci``fmt:check``lint``test:all`
Run a single spec file: `busted spec/u_spec.lua` (after `mise run test:prepare`).
## Architecture
- **`lua/u.lua`** — the entire library (~1300 lines): `Pos`, `Range`, `opkeymap`, `define_txtobj`, `ucmd`, `repeat_`
- **`spec/u_spec.lua`** — all tests in one file
- **`library/`** — type stubs for busted/luv (stylua-ignored, used by emmylua)
- **`.prefix/`** — neovim version installs managed by `nvimv` (git-ignored, emmylua-ignored)
- **`examples/`** — usage examples (surround, splitjoin, text-objects, matcher)
## Conventions
- **1-based indexing** everywhere (v2+). `Pos.from00()` / `Range.from00()` convert from 0-based Neovim API values.
- Stylua: LuaJIT syntax, single quotes, no call parens, 2-space indent, sort requires, 100 col width
- Tests run inside Neovim via busted's `lua = "nvim -u NONE -i NONE -l"` (set in `.busted`)
- `test:prepare` installs busted rocks via `luarocks test --prepare` and manages nightly Neovim via `nvimv`

View File

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

472
README.md
View File

@@ -1,265 +1,174 @@
# u.nvim # u.nvim
Welcome to **u.nvim** a powerful Lua library designed to enhance your text manipulation experience in NeoVim, focusing primarily on a context-aware "Range" utility. This utility allows you to work efficiently with text selections based on various conditions, in a variety of contexts, making coding and editing more intuitive and productive. Welcome to **u.nvim** - a Lua library for text manipulation in Neovim, focusing on
range-based text operations, positions, and operator-pending mappings.
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 a **single-file micro-library** meant to be vendored in your plugin or
config. On its own, `u.nvim` does nothing. It is meant to be used by plugin
authors to make their lives easier. To get an idea of what a plugin built on
top of `u.nvim` would look like, check out the [examples/](./examples/) directory.
## Features ## Features
- **Rendering System**: a utility that can declaratively render NeoVim-specific hyperscript into a buffer, supporting creating/managing extmarks, highlights, and key-event handling (requires NeoVim >0.11) - **Range Utility**: Get context-aware selections with ease. Replace regions with
- **Signals**: a simple dependency tracking system that pairs well with the rendering utilities for creating reactive/interactive UIs in NeoVim. new text. Think of it as a programmatic way to work with visual selections.
- **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). - **Position Utilities**: Work with cursor positions, marks, and extmarks.
- **Code Writer**: Write code with automatic indentation and formatting. - **Operator Key Mapping**: Flexible key mapping that works with motions.
- **Operator Key Mapping**: Flexible key mapping that works with the selected text. - **Text Object Definitions**: Define custom text objects easily.
- **Text and Position Utilities**: Convenient functions to manage text objects and cursor positions. - **User Command Helpers**: Create commands with range support.
- **Repeat Utilities**: Dot-repeat support for custom operations.
### Installation ### Installation
lazy.nvim: This being a library, and not a proper plugin, it is recommended that you vendor
```lua the specific version of this library that you need, including it in your code.
-- Setting `lazy = true` ensures that the library is only loaded Package managers are a developing landscape for Lua in the context of NeoVim.
-- when `require 'u.<utility>' is called. Perhaps in the future, `lux` will eliminate the need to vendor this library in
{ 'jrop/u.nvim', lazy = true } your application code.
```
## Signal and Rendering Usage #### If you are a Plugin Author
### Overview Neovim does not have a good answer for automatic management of plugin dependencies. As such, it is recommended that library authors vendor u.nvim within their plugin. **u.nvim** is implemented in a single file, so this should be relatively painless. Furthermore, `lua/u.lua` versions are published into artifact tags `artifact-vX.Y.Z` as `init.lua` so that plugin authors can add u.nvim as a submodule to their plugin.
The Signal and Rendering mechanisms are two subsystems of u.nvim, that, while
simplistic, [compose](./examples/counter.lua) [together](./examples/filetree.lua)
[powerfully](./examples/picker.lua) to create a system for interactive and
responsive user interfaces. Here is a quick example that show-cases how easy it
is to dive in to make any buffer an interactive UI:
<details> <details>
<summary>Example Code: counter.lua</summary> <summary>Example git submodule setup</summary>
```bash
# In your plugin repository
git submodule add -- https://github.com/jrop/u.nvim lua/my_plugin/u
cd lua/my_plugin/u/
git checkout artifact-v0.2.0 # put whatever version of u.nvim you want to pin here
# ... commit the submodule within your repo
# This would place u.nvim@v0.2.0 at:
# lua/my_plugin/u/init.lua
```
Then in your plugin code:
```lua ```lua
local tracker = require 'u.tracker' local u = require('my_plugin.u')
local Buffer = require 'u.buffer'
local h = require('u.renderer').h
-- Create an buffer for the UI local Pos = u.Pos
vim.cmd.vnew() local Range = u.Range
local ui_buf = Buffer.current() -- etc.
ui_buf:set_tmp_options() ```
local s_count = tracker.create_signal(0) This approach allows plugin authors to:
- Pin to specific versions of u.nvim
- Get updates by pulling/committing new **u.nvim** versions (i.e., the usual git submodule way)
- Keep the dependency explicit and version-controlled
- Avoid namespace conflicts with user-installed plugins
-- Effect: Render </details>
-- Setup the effect for rendering the UI whenever dependencies are updated
tracker.create_effect(function()
-- Calling `Signal:get()` in an effect registers the given signal as a
-- dependency of the current effect. Whenever that signal (or any other
-- dependency) changes, the effect will rerun. In this particular case,
-- rendering the UI is an effect that depends on one signal.
local count = s_count:get()
-- Markup is hyperscript, which is just 1) text, and 2) tags (i.e., #### If you are a User
-- constructed with `h(...)` calls). To help organize the markup, text and
-- tags can be nested in tables at any depth. Line breaks must be specified
-- manually, with '\n'.
ui_buf:render {
'Reactive Counter Example\n',
'========================\n\n',
{ 'Counter: ', tostring(count), '\n' }, If you want to use u.nvim in your config directly:
'\n', <details>
<summary>vim.pack</summary>
{ ```lua
h('text', { vim.pack.add { 'https://github.com/jrop/u.nvim' }
hl = 'DiffDelete',
nmap = {
['<CR>'] = function()
-- Update the contents of the s_count signal, notifying any
-- dependencies (in this case, the render effect):
vim.schedule(function()
s_count:update(function(n) return n - 1 end)
end)
-- Also equivalent: s_count:set(s_count:get() - 1)
return ''
end,
},
}, ' Decrement '),
' ',
h('text', {
hl = 'DiffAdd',
nmap = {
['<CR>'] = function()
-- Update the contents of the s_count signal, notifying any
-- dependencies (in this case, the render effect):
vim.schedule(function()
s_count:update(function(n) return n + 1 end)
end)
-- Also equivalent: s_count:set(s_count:get() - 1)
return ''
end,
},
}, ' Increment '),
},
'\n',
'\n',
{ 'Press <CR> on each "button" above to increment/decrement the counter.' },
}
end)
``` ```
</details> </details>
### `u.tracker` <details>
<summary>lazy.nvim</summary>
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 ```lua
local tracker = require('u.tracker')
local s_number = tracker.Signal:new(0)
-- auto-compute the double of the number each time it changes:
local s_doubled = tracker.create_memo(function() return s_number:get() * 2 end)
tracker.create_effect(function()
local n = s_doubled:get()
-- ...
-- whenever s_doubled changes, this function gets run
end)
```
**Note**: circular dependencies are **not** supported.
### `u.renderer`
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:
```lua
local h = require('u.renderer').h
-- Hyperscript can be organized into tables:
{ {
"Hello, ", 'jrop/u.nvim',
{ lazy = true,
"I am ", { "a" }, " nested table.",
},
'\n', -- newlines must be explicitly specified
-- booleans/nil are ignored:
some_conditional_flag and 'This text only shows when the flag is true',
-- e.g., use the above to show newlines in lists:
idx > 1 and '\n',
-- <text> tags are specified like so:
-- h('text', attributes, children)
h('text', {}, "I am a text node."),
-- <text> tags can be highlighted:
h('text', { hl = 'Comment' }, "I am highlighted."),
-- <text> tags can respond to key events:
h('text', {
hl = 'Keyword',
nmap = {
["<CR>"] = function()
print("Hello World")
-- Return '' to swallow the event:
return ''
end,
},
}, "I am a text node."),
} }
``` ```
Managing complex tables of hyperscript can be done more ergonomically using the </details>
`TreeBuilder` helper class:
```lua
local TreeBuilder = require('u.renderer').TreeBuilder
-- ...
renderer:render(
TreeBuilder.new()
-- text:
:put('some text')
-- hyperscript tables:
:put({ 'some text', 'more hyperscript' })
-- hyperscript tags:
:put_h('text', { --[[attributes]] }, { --[[children]] })
-- callbacks:
--- @param tb TreeBuilder
:nest(function(tb)
tb:put('some text')
end)
:tree()
)
```
**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
local renderer = Renderer:new(0 --[[buffer number]])
renderer:render {
-- ...hyperscript...
}
-- or, if you already have a buffer:
local Buffer = require('u.buffer')
local buf = Buffer.current()
buf:render {
-- ...hyperscript...
}
```
## Range Usage ## Range Usage
### A note on indices ### A note on indices
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. <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.
### 1. Creating a Range ### 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 of this library. Ranges can be constructed
manually, or preferably, obtained based on a variety of contexts.
```lua ```lua
local Range = require 'u.range' local u = require 'u'
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 local start = u.Pos.new(nil, 1, 1) -- Line 1, first column
Range.new(start, stop, 'V') -- linewise selection local stop = u.Pos.new(nil, 3, 1) -- Line 3, first column
u.Range.new(start, stop, 'v') -- charwise selection
u.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 ```lua
local u = require 'u'
-- get the first line in a buffer: -- get the first line in a buffer:
Range.from_line(0, 0) u.Range.from_line(bufnr, 1)
-- Text Objects (any text object valid in your configuration is supported): -- Text Objects (any text object valid in your configuration is supported):
-- get the word the cursor is on: -- get the word the cursor is on:
Range.from_text_object('iw') u.Range.from_motion('iw')
-- get the WORD the cursor is on: -- get the WORD the cursor is on:
Range.from_text_object('iW') u.Range.from_motion('iW')
-- get the "..." the cursor is within: -- get the "..." the cursor is within:
Range.from_text_object('a"') u.Range.from_motion('a"')
-- Get the currently visually selected text: -- Get the currently visually selected text:
-- NOTE: this does NOT work within certain contexts; more specialized utilities are more appropriate in certain circumstances -- NOTE: this does NOT work within certain contexts; more specialized utilities
Range.from_vtext() -- are more appropriate in certain circumstances
u.Range.from_vtext()
-- --
-- Get the operated on text obtained from a motion: -- Get the operated on text obtained from a motion:
-- (HINT: use the opkeymap utility to make this less verbose) -- (HINT: use the opkeymap utility to make this less verbose)
-- --
---@param ty 'char'|'line'|'block' --- @param ty 'char'|'line'|'block'
function MyOpFunc(ty) function MyOpFunc(ty)
local range = Range.from_op_func(ty) local range = u.Range.from_op_func(ty)
-- do something with the range -- do something with the range
end end
-- Try invoking this with: `<Leader>toaw`, and the current word will be the context: -- Try invoking this with: `<Leader>toaw`, and the current word will be the
-- context:
vim.keymap.set('<Leader>to', function() vim.keymap.set('<Leader>to', function()
vim.g.operatorfunc = 'v:lua.MyOpFunc' vim.g.operatorfunc = 'v:lua.MyOpFunc'
return 'g@' return 'g@'
@@ -268,9 +177,10 @@ end, { expr = true })
-- --
-- Commands: -- 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) vim.api.nvim_create_user_command('MyCmd', function(args)
local range = Range.from_cmd_args(args) local range = u.Range.from_cmd_args(args)
if range == nil then if range == nil then
-- the command was executed in normal mode -- the command was executed in normal mode
else else
@@ -279,14 +189,15 @@ vim.api.nvim_create_user_command('MyCmd', function(args)
end, { range = true }) end, { range = true })
``` ```
So far, that's a lot of ways to _get_ a `Range`. But what can you do with a 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 ```lua
local range = ... local range = ...
range:lines() -- get the lines in the range's region range:lines() -- get the lines in the range's region
range:text() -- get the text (i.e., string) in the range's region range:text() -- get the text (i.e., string) in the range's region
range:line0(0) -- get the first line within this range range:line(1) -- get the first line within this range
range:line0(-1) -- get the last line within this range range:line(-1) -- get the last line within this range
-- replace with new contents: -- replace with new contents:
range:replace { range:replace {
'replacement line 1', 'replacement line 1',
@@ -302,74 +213,143 @@ range:replace(nil)
Define custom (dot-repeatable) key mappings for text objects: Define custom (dot-repeatable) key mappings for text objects:
```lua ```lua
local opkeymap = require 'u.opkeymap' local u = require 'u'
-- invoke this function by typing, for example, `<leader>riw`: -- invoke this function by typing, for example, `<leader>riw`:
-- `range` will contain the bounds of the motion `iw`. -- `range` will contain the bounds of the motion `iw`.
opkeymap('n', '<leader>r', function(range) u.opkeymap('n', '<leader>r', function(range)
print(range:text()) -- Prints the text within the selected range print(range:text()) -- Prints the text within the selected range
end) end)
``` ```
### 3. Working with Code Writer ### 3. Utility Functions
To write code with indentation, use the `CodeWriter` class:
```lua
local CodeWriter = require 'u.codewriter'
local cw = CodeWriter.new()
cw:write('{')
cw:indent(function(innerCW)
innerCW:write('x: 123')
end)
cw:write('}')
```
### 4. Utility Functions
#### Custom Text Objects #### Custom Text Objects
Simply by returning a `Range` or a `Pos`, you can easily and quickly define Simply by returning a `Range`, you can easily define your own text objects:
your own text objects:
```lua ```lua
local utils = require 'u.utils' local u = require 'u'
local Range = require 'u.range'
-- Select whole file: -- Select whole file:
utils.define_text_object('ag', function() u.define_txtobj('ag', function()
return Range.from_buf_text() return u.Range.from_buf_text()
end)
-- Select content inside nearest quotes:
u.define_txtobj('iq', function()
return u.Range.find_nearest_quotes()
end)
-- Select content inside nearest brackets:
u.define_txtobj('ib', function()
return u.Range.find_nearest_brackets()
end) end)
``` ```
#### Buffer Management #### User Commands with Range Support
Access and manipulate buffers easily: Create user commands that work with visual selections:
```lua ```lua
local Buffer = require 'u.buffer' local u = require 'u'
local buf = Buffer.current()
buf:line_count() -- the number of lines in the current buffer u.ucmd('MyCmd', function(args)
buf:get_option '...' if args.info then
buf:set_option('...', ...) -- args.info is a Range representing the selection
buf:get_var '...' print('Selected text:', args.info:text())
buf:set_var('...', ...) else
buf:all() -- returns a Range representing the entire buffer -- No range provided
buf:is_empty() -- returns true if the buffer has no text print('No selection')
buf:append_line '...' end
buf:line0(0) -- returns a Range representing the first line in the buffer end, { range = true })
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
``` ```
#### Repeat Utilities
Enable dot-repeat for custom operations:
```lua
local u = require 'u'
-- Call this in your plugin's setup:
u.repeat_.setup()
-- Then use in your operations:
u.repeat_.run_repeatable(function()
-- Your mutation here
-- This will be repeatable with '.'
end)
```
## API Reference
### `u.Pos`
Position class representing a location in a buffer.
```lua
local pos = u.Pos.new(bufnr, lnum, col, off)
pos:next(1) -- next position (forward)
pos:next(-1) -- previous position (backward)
pos:char() -- character at position
pos:line() -- line text
pos:eol() -- end of line position
pos:save_to_cursor() -- move cursor to position
```
### `u.Range`
Range class representing a text region.
```lua
local range = u.Range.new(start, stop, mode)
range:text() -- get text content
range:lines() -- get lines as array
range:replace(text) -- replace with new text
range:contains(pos) -- check if position is within range
range:shrink(n) -- shrink range by n characters from each side
range:highlight('Search') -- highlight range temporarily
```
### `u.opkeymap(mode, lhs, rhs, opts)`
Create operator-pending keymaps.
### `u.define_txtobj(key_seq, fn, opts)`
Define custom text objects.
### `u.ucmd(name, cmd, opts)`
Create user commands with enhanced range support.
### `u.create_delegated_cmd_args(args)`
Create arguments for delegating between commands.
### `u.repeat_`
Module for dot-repeat support.
## License (MIT) ## License (MIT)
Copyright (c) 2024 jrapodaca@gmail.com Copyright (c) 2024 jrapodaca@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,65 +0,0 @@
local tracker = require 'u.tracker'
local Buffer = require 'u.buffer'
local h = require('u.renderer').h
-- Create an buffer for the UI
vim.cmd.vnew()
local ui_buf = Buffer.current()
ui_buf:set_tmp_options()
local s_count = tracker.create_signal(0, 'counter_signal')
-- Effect: Render
-- Setup the effect for rendering the UI whenever dependencies are updated
tracker.create_effect(function()
-- Calling `Signal:get()` in an effect registers the given signal as a
-- dependency of the current effect. Whenever that signal (or any other
-- dependency) changes, the effect will rerun. In this particular case,
-- rendering the UI is an effect that depends on one signal.
local count = s_count:get()
-- Markup is hyperscript, which is just 1) text, and 2) tags (i.e.,
-- constructed with `h(...)` calls). To help organize the markup, text and
-- tags can be nested in tables at any depth. Line breaks must be specified
-- manually, with '\n'.
ui_buf:render {
'Reactive Counter Example\n',
'========================\n\n',
{ 'Counter: ', tostring(count), '\n' },
'\n',
{
h('text', {
hl = 'DiffDelete',
nmap = {
['<CR>'] = function()
-- Update the contents of the s_count signal, notifying any
-- dependencies (in this case, the render effect):
s_count:schedule_update(function(n) return n - 1 end)
-- Also equivalent: s_count:schedule_set(s_count:get() - 1)
return ''
end,
},
}, ' Decrement '),
' ',
h('text', {
hl = 'DiffAdd',
nmap = {
['<CR>'] = function()
-- Update the contents of the s_count signal, notifying any
-- dependencies (in this case, the render effect):
s_count:schedule_update(function(n) return n + 1 end)
-- Also equivalent: s_count:schedule_set(s_count:get() - 1)
return ''
end,
},
}, ' Increment '),
},
'\n',
'\n',
{ 'Press <CR> on each "button" above to increment/decrement the counter.' },
}
end)

View File

@@ -1,445 +0,0 @@
--------------------------------------------------------------------------------
-- File Tree Viewer Module
--
-- Future Enhancements:
-- - Consider implementing additional features like searching for files,
-- filtering displayed nodes, or adding support for more file types.
-- - Improve user experience with customizable UI elements and enhanced
-- navigation options.
-- - Implement a file watcher to automatically update the file tree when files
-- change on the underlying filesystem.
--------------------------------------------------------------------------------
--- @alias FsDir { kind: 'dir'; path: string; expanded: boolean; children: FsNode[] }
--- @alias FsFile { kind: 'file'; path: string }
--- @alias FsNode FsDir | FsFile
--- @alias ShowOpts { root_path?: string, width?: number, focus_path?: string }
local Buffer = require 'u.buffer'
local Renderer = require('u.renderer').Renderer
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 = {}
--------------------------------------------------------------------------------
-- Helpers:
--------------------------------------------------------------------------------
--- Splits the given path into a list of path components.
--- @param path string
function H.split_path(path)
local parts = {}
local curr = path
while #curr > 0 and curr ~= '.' and curr ~= '/' do
table.insert(parts, 1, vim.fs.basename(curr))
curr = vim.fs.dirname(curr)
end
return parts
end
--- Normalizes the given path to an absolute path.
--- @param path string
function H.normalize(path)
path = vim.fs.normalize(path)
if path:sub(1, 1) ~= '/' then path = vim.fs.joinpath(vim.uv.cwd(), path) end
return vim.fs.normalize(path)
end
--- Computes the relative path from `base` to `path`.
--- @param path string
--- @param base string
function H.relative(path, base)
path = H.normalize(path)
base = H.normalize(base)
if path:sub(1, #base) == base then path = path:sub(#base + 1) end
if vim.startswith(path, '/') then path = path:sub(2) end
return path
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 = {}
--- @type FsDir
local tree = {
kind = 'dir',
path = H.normalize(root_path or '.'),
expanded = true,
children = {},
}
path_to_node[tree.path] = tree
H.populate_dir_children(tree, path_to_node)
return { tree = tree, path_to_node = path_to_node }
end
--- @param tree FsDir
--- @param path_to_node table<string, FsNode>
function H.populate_dir_children(tree, path_to_node)
tree.children = {}
for child_path, kind in vim.iter(vim.fs.dir(tree.path, { depth = 1 })) do
child_path = H.normalize(vim.fs.joinpath(tree.path, child_path))
local prev_node = path_to_node[child_path]
if kind == 'directory' then
local new_node = {
kind = 'dir',
path = child_path,
expanded = prev_node and prev_node.expanded or false,
children = prev_node and prev_node.children or {},
}
path_to_node[new_node.path] = new_node
table.insert(tree.children, new_node)
else
local new_node = {
kind = 'file',
path = child_path,
}
path_to_node[new_node.path] = new_node
table.insert(tree.children, new_node)
end
end
table.sort(tree.children, function(a, b)
-- directories first:
if a.kind ~= b.kind then return a.kind == 'dir' end
return a.path < b.path
end)
end
--- @param opts {
--- bufnr: number;
--- prev_winnr: number;
--- root_path: string;
--- focus_path?: string;
--- }
---
--- @return { expand: fun(path: string), collapse: fun(path: string) }
local function _render_in_buffer(opts)
local winnr = vim.api.nvim_buf_call(opts.bufnr, function() return vim.api.nvim_get_current_win() end)
local 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))
tracker.create_effect(function()
local focused_path = s_focused_path:get()
s_tree_inf:update(function(tree_inf)
local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path))
local path_to_node = tree_inf.path_to_node
--- @param node FsDir
--- @param child_names string[]
local function expand_to(node, child_names)
if #child_names == 0 then return end
node.expanded = true
local next_child_name = table.remove(child_names, 1)
for _, child in ipairs(node.children) do
if child.kind == 'dir' and vim.fs.basename(child.path) == next_child_name then
H.populate_dir_children(child, path_to_node)
expand_to(child, child_names)
end
end
end
expand_to(tree_inf.tree, parts)
return tree_inf
end)
end)
-- --
-- -- TODO: :help watch-file
-- --
-- local watcher = vim.uv.new_fs_event()
-- if watcher ~= nil then
-- watcher:start(root_path, { recursive = true }, function(err, fname, status)
-- -- TODO: more efficient update:
-- s_tree_inf:set(H.get_tree(root_path))
--
-- -- TODO: proper disposal
-- watcher:stop()
-- end)
-- end
local controller = {}
--- @param path string
function controller.focus_path(path) s_focused_path:set(H.normalize(path)) end
function controller.refresh() s_tree_inf:set(H.get_tree_inf(opts.root_path)) end
--- @param path string
function controller.expand(path)
path = H.normalize(path)
local path_to_node = s_tree_inf:get().path_to_node
local node = path_to_node[path]
if node == nil then return end
if node.kind == 'dir' then
s_tree_inf:update(function(tree_inf2)
H.populate_dir_children(node, path_to_node)
tree_inf2.path_to_node[node.path].expanded = true
return tree_inf2
end)
if #node.children == 0 then
s_focused_path:set(node.path)
else
s_focused_path:set(node.children[1].path)
end
else
if node.kind == 'file' then
-- open file:
vim.api.nvim_win_call(opts.prev_winnr, function() vim.cmd.edit(node.path) end)
vim.api.nvim_set_current_win(opts.prev_winnr)
end
end
end
--- @param path string
function controller.collapse(path)
path = H.normalize(path)
local path_to_node = s_tree_inf:get().path_to_node
local node = path_to_node[path]
if node == nil then return end
if node.kind == 'dir' then
if node.expanded then
-- collapse self/node:
s_focused_path:set(node.path)
s_tree_inf:update(function(tree_inf2)
tree_inf2.path_to_node[node.path].expanded = false
return tree_inf2
end)
else
-- collapse parent:
local parent_dir = path_to_node[vim.fs.dirname(node.path)]
if parent_dir ~= nil then
s_focused_path:set(parent_dir.path)
s_tree_inf:update(function(tree_inf2)
tree_inf2.path_to_node[parent_dir.path].expanded = false
return tree_inf2
end)
end
end
elseif node.kind == 'file' then
local parent_dir = path_to_node[vim.fs.dirname(node.path)]
if parent_dir ~= nil then
s_focused_path:set(parent_dir.path)
s_tree_inf:update(function(tree_inf2)
tree_inf2.path_to_node[parent_dir.path].expanded = false
return tree_inf2
end)
end
end
end
--- @param root_path string
function controller.new(root_path)
vim.ui.input({
prompt = 'New: ',
completion = 'file',
}, function(input)
if input == nil then return end
local new_path = vim.fs.joinpath(root_path, input)
if vim.endswith(input, '/') then
-- Create a directory:
vim.fn.mkdir(new_path, input, 'p')
else
-- Create a file:
-- First, make sure the parent directory exists:
vim.fn.mkdir(vim.fs.dirname(new_path), 'p')
-- Now create an empty file:
local uv = vim.loop or vim.uv
local fd = uv.fs_open(new_path, 'w', 438)
if fd then uv.fs_write(fd, '') end
end
controller.refresh()
controller.focus_path(new_path)
end)
end
--- @param path string
function controller.rename(path)
path = H.normalize(path)
local root_path = vim.fs.dirname(path)
vim.ui.input({
prompt = 'Rename: ',
default = vim.fs.basename(path),
completion = 'file',
}, function(input)
if input == nil then return end
local new_path = vim.fs.joinpath(root_path, input);
(vim.loop or vim.uv).fs_rename(path, new_path)
controller.refresh()
controller.focus_path(new_path)
end)
end
--
-- Render:
--
local renderer = Renderer.new(opts.bufnr)
tracker.create_effect(function()
--- @type { tree: FsDir; path_to_node: table<string, FsNode> }
local tree_inf = s_tree_inf:get()
local tree = tree_inf.tree
--- @type string
local focused_path = s_focused_path:get()
--- As we render the tree, keep track of what line each node is on, so that
--- we have an easy way to make the cursor jump to each node (i.e., line)
--- at will:
--- @type table<string, number>
local node_lines = {}
local current_line = 0
--- The UI is rendered as a list of hypserscript elements:
local tb = TreeBuilder.new()
--- Since the filesystem is a recursive tree of nodes, we need to
--- recursively render each node. This function does just that:
--- @param node FsNode
--- @param level number
local function render_node(node, level)
local name = vim.fs.basename(node.path)
current_line = current_line + 1
node_lines[node.path] = current_line
local nmaps = {
h = function()
vim.schedule(function() controller.collapse(node.path) end)
return ''
end,
l = function()
vim.schedule(function() controller.expand(node.path) end)
return ''
end,
n = function()
vim.schedule(function() controller.new(node.kind == 'file' and vim.fs.dirname(node.path) or node.path) end)
return ''
end,
r = function()
vim.schedule(function() controller.rename(node.path) end)
return ''
end,
y = function()
vim.fn.setreg([["]], H.relative(node.path, tree.path))
return ''
end,
}
if node.kind == 'dir' then
--
-- Render a directory node:
--
local icon = node.expanded and '' or ''
tb:put {
current_line > 1 and '\n',
h('text', { hl = 'Constant', nmap = nmaps }, { string.rep(' ', level), icon, ' ', name }),
}
if node.expanded then
for _, child in ipairs(node.children) do
render_node(child, level + 1)
end
end
elseif node.kind == 'file' then
tb:put {
current_line > 1 and '\n',
h('text', { nmap = nmaps }, { string.rep(' ', level), '󰈔 ', name }),
}
end
end
render_node(tree, 0)
-- The following modifies buffer contents, so it needs to be scheduled:
vim.schedule(function()
renderer:render(tb:tree())
local cpos = vim.api.nvim_win_get_cursor(winnr)
pcall(vim.api.nvim_win_set_cursor, winnr, { node_lines[focused_path], cpos[2] })
end)
end, 's:tree')
return controller
end
--------------------------------------------------------------------------------
-- Public API functions:
--------------------------------------------------------------------------------
--- @type {
--- bufnr: number;
--- winnr: number;
--- controller: { expand: fun(path: string), collapse: fun(path: string) };
--- } | nil
local current_inf = nil
--- Show the filetree:
--- @param opts? ShowOpts
function M.show(opts)
if current_inf ~= nil then return current_inf.controller end
opts = opts or {}
local prev_winnr = vim.api.nvim_get_current_win()
vim.cmd 'vnew'
local buf = Buffer.from_nr(vim.api.nvim_get_current_buf())
buf:set_tmp_options()
local winnr = vim.api.nvim_get_current_win()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<C-w>H', true, true, true), 'x', false)
vim.api.nvim_win_set_width(0, opts.width or 30)
vim.api.nvim_create_autocmd('WinClosed', {
once = true,
pattern = tostring(winnr),
callback = M.hide,
})
vim.wo.number = false
vim.wo.relativenumber = false
local bufnr = vim.api.nvim_get_current_buf()
local controller = _render_in_buffer(vim.tbl_extend('force', opts, {
bufnr = bufnr,
prev_winnr = prev_winnr,
root_path = opts.root_path or H.normalize '.',
}))
current_inf = { bufnr = bufnr, winnr = winnr, controller = controller }
return controller
end
--- Hide the filetree:
function M.hide()
if current_inf == nil then return end
pcall(vim.cmd.bdelete, current_inf.bufnr)
current_inf = nil
end
--- Toggle the filetree:
--- @param opts? ShowOpts
function M.toggle(opts)
if current_inf == nil then
M.show(opts)
else
M.hide()
end
end
return M

51
examples/matcher.lua Normal file
View File

@@ -0,0 +1,51 @@
--
-- Bracket matcher: highlights the nearest matching pair of brackets (like MatchParen)
-- when the cursor is near them. Updates dynamically on CursorMoved in normal mode.
--
local u = require 'u'
local Range = u.Range
local M = {}
--- @type { clear: fun() }[]
local HIGHLIGHTS = {}
local LAST_RANGE = nil
local function clear_highlights()
for _, hl in ipairs(HIGHLIGHTS) do
hl.clear()
end
HIGHLIGHTS = {}
end
local function update()
local mode = vim.fn.mode():sub(1, 1)
if mode ~= 'n' then return end
local last_range = LAST_RANGE
local bracket_range = Range.find_nearest_brackets()
LAST_RANGE = bracket_range
if not bracket_range then return clear_highlights() end
if bracket_range == last_range then return end
clear_highlights()
local open = Range.new(bracket_range.start, bracket_range.start, 'v')
local close = Range.new(bracket_range.stop, bracket_range.stop, 'v')
HIGHLIGHTS = {
open:highlight('MatchParen', { priority = 999 }),
close:highlight('MatchParen', { priority = 999 }),
}
end
function M.setup()
local group = vim.api.nvim_create_augroup('Matcher', { clear = true })
vim.api.nvim_create_autocmd({ 'CursorMoved' }, {
group = group,
callback = update,
})
end
return M

View File

@@ -1,137 +0,0 @@
local Buffer = require 'u.buffer'
local TreeBuilder = require('u.renderer').TreeBuilder
local tracker = require 'u.tracker'
local utils = require 'u.utils'
local Window = require 'my.window'
local TIMEOUT = 4000
local ICONS = {
[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' }
--- @alias Notification {
--- kind: number;
--- id: number;
--- text: string;
--- }
local M = {}
--- @type Window | 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 Notification[]
local notifs = s_notifications:get()
if #notifs == 0 then
if notifs_w then
notifs_w:close(true)
notifs_w = nil
end
return
end
vim.schedule(function()
local editor_size = utils.get_editor_dimensions()
local avail_width = editor_size.width
local float_width = 40
local win_config = {
relative = 'editor',
anchor = 'NE',
row = 0,
col = avail_width,
width = float_width,
height = math.min(#notifs, editor_size.height - 3),
border = 'single',
focusable = false,
}
if not notifs_w or not vim.api.nvim_win_is_valid(notifs_w.win) then
notifs_w = Window.new(Buffer.create(false, true), win_config)
vim.wo[notifs_w.win].cursorline = false
vim.wo[notifs_w.win].list = false
vim.wo[notifs_w.win].listchars = ''
vim.wo[notifs_w.win].number = false
vim.wo[notifs_w.win].relativenumber = false
vim.wo[notifs_w.win].wrap = false
else
notifs_w:set_config(win_config)
end
notifs_w: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()
-- scroll to bottom:
vim.cmd.normal 'G'
-- scroll all the way to the left:
vim.cmd.normal '9999zh'
end)
end)
end)
local _orig_notify
--- @param msg string
--- @param level integer|nil
--- @param opts table|nil
local function my_notify(msg, level, opts)
vim.schedule(function() _orig_notify(msg, level, opts) end)
if level == nil then level = vim.log.levels.INFO end
if level < vim.log.levels.INFO then return end
local id = math.random(math.huge)
--- @param notifs Notification[]
s_notifications_raw:schedule_update(function(notifs)
table.insert(notifs, { kind = level, id = id, text = msg })
return notifs
end)
vim.defer_fn(function()
--- @param notifs Notification[]
s_notifications_raw:schedule_update(function(notifs)
for i, notif in ipairs(notifs) do
if notif.id == id then
table.remove(notifs, i)
break
end
end
return notifs
end)
end, TIMEOUT)
end
local _once_msgs = {}
local function my_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 = my_notify
vim.notify_once = my_notify_once
end
return M

View File

@@ -1,893 +0,0 @@
local utils = require 'u.utils'
local Buffer = require 'u.buffer'
local Renderer = require('u.renderer').Renderer
local h = require('u.renderer').h
local TreeBuilder = require('u.renderer').TreeBuilder
local tracker = require 'u.tracker'
local M = {}
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,
})
--- @param low number
---@param x number
---@param high number
local function clamp(low, x, high)
x = math.max(low, x)
x = math.min(x, high)
return x
end
--- @generic T
--- @param arr `T`[]
--- @return T[]
local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
--------------------------------------------------------------------------------
-- BEGIN create_picker
--
-- This is the star of the show (in this file, anyway).
-- In summary, the outline of this function is:
-- 1. Setup signals/memos for computing the picker size, and window positions
-- 2. Create the two windows:
-- a. The picker input. This is where the filter is typed
-- b. The picker list. This is where the items are displayed
-- 3. Setup event handlers that respond to user input
-- 4. Render the list. After all the prework above, this is probably the
-- shortest portion of this function.
--------------------------------------------------------------------------------
--- @alias SelectController {
--- get_items: fun(): T[];
--- set_items: fun(items: T[]);
--- set_filter_text: fun(filter_text: string);
--- get_selected_indices: fun(): number[];
--- get_selected_items: fun(): T[];
--- set_selected_indices: fun(indicies: number[], ephemeral?: boolean);
--- close: fun();
--- }
--- @alias SelectOpts<T> {
--- items: `T`[];
--- multi?: boolean;
--- format_item?: fun(item: T): Tree;
--- on_finish?: fun(items: T[], indicies: number[]);
--- on_selection_changed?: fun(items: T[], indicies: number[]);
--- mappings?: table<string, fun(select: SelectController)>;
--- }
--- @generic T
--- @param opts SelectOpts<T>
function M.create_picker(opts)
local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i'
local stopinsert = not is_in_insert_mode
if opts.multi == nil then opts.multi = false end
local H = {}
--- Runs a function `fn`, and if it fails, cleans up the UI by calling
--- `H.finish`
---
--- @generic T
--- @param fn fun(): `T`
--- @return T
local function safe_run(fn, ...)
local ok, result_or_error = pcall(fn, ...)
if not ok then
pcall(H.finish, true, result_or_error)
error(result_or_error .. '\n' .. debug.traceback())
end
return result_or_error
end
--- Creates a function that safely calls the given function, cleaning up the
--- UI if it ever fails
---
--- @generic T
--- @param fn `T`
--- @return T
local function safe_wrap(fn)
return function(...) return safe_run(fn, ...) end
end
--
-- Compute the positions of the input bar and the list:
--
-- Reactively compute the space available for the picker based on the size of
-- the editor
local s_editor_dimensions = S_EDITOR_DIMENSIONS:clone()
local s_picker_space_available = tracker.create_memo(safe_wrap(function()
local editor_dim = s_editor_dimensions:get()
local width = math.floor(editor_dim.width * 0.75)
local height = math.floor(editor_dim.height * 0.75)
local row = math.floor((editor_dim.height - height) / 2)
local col = math.floor((editor_dim.width - width) / 2)
return { width = width, height = height, row = row, col = col }
end))
-- Reactively compute the size of the prompt (input) bar
local s_w_input_coords = tracker.create_memo(safe_wrap(function()
local picker_coords = s_picker_space_available:get()
return {
width = picker_coords.width,
height = 1,
row = picker_coords.row,
col = picker_coords.col,
}
end))
-- Reactively compute the size of the list view
local s_w_list_coords = tracker.create_memo(safe_wrap(function()
local picker_coords = s_picker_space_available:get()
return {
width = picker_coords.width,
height = picker_coords.height - 3,
row = picker_coords.row + 3,
col = picker_coords.col,
}
end))
--
-- Create resources (i.e., windows):
--
local w_input_cfg = {
width = s_w_input_coords:get().width,
height = s_w_input_coords:get().height,
row = s_w_input_coords:get().row,
col = s_w_input_coords:get().col,
relative = 'editor',
focusable = true,
border = 'rounded',
}
local w_input_buf = Buffer.create(false, true)
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.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()
-- update window position/size every time the editor is resized:
w_input_cfg = vim.tbl_deep_extend('force', w_input_cfg, s_w_input_coords:get())
vim.api.nvim_win_set_config(w_input, w_input_cfg)
end))
local w_list_cfg = {
width = s_w_list_coords:get().width,
height = s_w_list_coords:get().height,
row = s_w_list_coords:get().row,
col = s_w_list_coords:get().col,
relative = 'editor',
focusable = true,
border = 'rounded',
}
local w_list_buf = Buffer.create(false, true)
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())
vim.api.nvim_win_set_config(w_list, w_list_cfg)
end))
-- Now that we have created the window with the prompt in it, start insert
-- mode so that the user can type immediately:
vim.cmd.startinsert()
--
-- State:
--
local s_items_raw = tracker.create_signal(opts.items, 's:items')
local s_items = s_items_raw:debounce(100)
local s_selected_indices = tracker.create_signal({}, 's:selected_indices')
local s_top_offset = tracker.create_signal(0, 's:top_offset')
local s_cursor_index = tracker.create_signal(1, 's:cursor_index')
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),
})
local s_filter_text = s_filter_text_undebounced:debounce(50)
--
-- Derived State:
--
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 items = s_items:get()
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()
local filter_pattern = ''
if #formatted_items > 250 and #filter_text <= 3 then
filter_pattern = filter_text:gsub('%.', '%%.')
else
filter_pattern = '('
.. vim.iter(vim.split(filter_text, '')):map(function(c) return c .. '.*' end):join ''
.. ')'
end
filter_pattern = filter_pattern:lower()
--- @type table<integer, string>
local formatted_strings = {}
--- @type table<integer, string>
local matches = {}
local new_filtered_items = vim
.iter(formatted_items)
:enumerate()
: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
matches[inf.orig_idx] = string.match(formatted_as_string, filter_pattern)
return matches[inf.orig_idx] ~= nil
end)
:totable()
-- Don't sort if there are over 500 items:
if #new_filtered_items <= 500 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]
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
end)
end
s_top_offset:set(0)
s_cursor_index:set(1)
return new_filtered_items
end),
'e:(filter_text=>filtered_items)'
)
-- Visible items, are _just_ the items that fit into the current viewport.
-- This is an optimization so that we are not rendering thousands of lines of
-- items on each state-change.
local s_visible_items = tracker.create_memo(
safe_wrap(function()
return vim
.iter(s_filtered_items:get())
:enumerate()
:skip(s_top_offset:get())
:take(s_w_list_coords:get().height)
:map(
function(i, inf)
return {
filtered_idx = i,
orig_idx = inf.orig_idx,
item = inf.item,
formatted = inf.formatted,
}
end
)
:totable()
end),
'm:visible_items'
)
-- Track selection information:
local s_selection_info = tracker.create_memo(
safe_wrap(function()
local items = s_items:get()
local selected_indices = s_selected_indices:get()
--- @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
return {
items = vim.iter(indices):map(function(i) return items[i] end):totable(),
indices = indices,
}
end),
'm:selection_info'
)
--- When it is time to close the picker, this is the main cleanup routine
--- that runs in all cases:
---
--- @param esc? boolean Whether the user pressed <Esc> or not
--- @param err? any Any error that occurred
function H.finish(esc, err)
-- s_editor_dimensions is the only signal that is cloned from a global,
-- one. It is therefore the only one that needs to be manually disposed.
-- The other ones should get cleaned up by the GC
s_editor_dimensions:dispose()
-- If we happen to have any async state-changes coming down the pipeline,
-- we can say right now that we are done rendering new UI (to avoid
-- "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.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)
if stopinsert then vim.cmd.stopinsert() end
local inf = s_selection_info:get()
if not err and opts.on_finish then
-- If on_finish opens another picker, the closing of this one can happen
-- in _too_ quick succession, so put a small delay in there.
--
-- TODO: figure out _why_ this is actually happening, and then a better
-- way to handle this.
vim.defer_fn(function()
if esc then
opts.on_finish({}, {})
else
opts.on_finish(inf.items, inf.indices)
end
end, 100)
end
end
-- On selection info changed:
tracker.create_effect(
safe_wrap(function()
local inf = s_selection_info:get()
if opts.on_selection_changed then opts.on_selection_changed(inf.items, inf.indices) end
end),
'e:selection_changed'
)
--
-- Public API (i.e., `controller`):
-- We will fill in the methods further down, but we need this variable in scope so that it can be
-- closed over by some of the event handlers:
--
local controller = {}
--
-- Events
--
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.buf })
local function action_next_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() > s_w_list_coords:get().height then
s_top_offset:set(s_top_offset:get() + 1)
end
s_cursor_index:set(next_cursor_index)
end
vim.keymap.set('i', '<C-n>', safe_wrap(action_next_line), { buffer = w_input_buf.buf, desc = 'Picker: next' })
vim.keymap.set('i', '<Down>', 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.buf, desc = 'Picker: previous' })
vim.keymap.set('i', '<Up>', safe_wrap(action_prev_line), { buffer = w_input_buf.buf, desc = 'Picker: previous' })
vim.keymap.set(
'i',
'<Tab>',
safe_wrap(function()
if not opts.multi then return end
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())
else
local new_selected_indices = shallow_copy_arr(s_selected_indices:get())
table.insert(new_selected_indices, index)
s_selected_indices:set(new_selected_indices)
end
action_next_line()
end),
{ 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.buf })
end
-- Render:
H.unsubscribe_render_effect = tracker.create_effect(
safe_wrap(function()
local selected_indices = s_selected_indices:get()
local top_offset = s_top_offset:get()
local cursor_index = s_cursor_index:get()
--- @type { filtered_idx: number; orig_idx: number; item: T; formatted: string }[]
local visible_items = s_visible_items:get()
-- The above has to run in the execution context for the signaling to work, but
-- the following cannot run in a NeoVim loop-callback:
vim.schedule(function()
w_list_buf:render(TreeBuilder.new()
:nest(function(tb)
for loop_idx, inf in ipairs(visible_items) do
local is_cur_line = inf.filtered_idx == cursor_index
local is_selected = vim.tbl_contains(selected_indices, inf.orig_idx)
tb:put(loop_idx > 1 and '\n')
tb:put(is_cur_line and h('text', { hl = 'Structure' }, '') or ' ')
tb:put(is_selected and h('text', { hl = 'Comment' }, '* ') or ' ')
tb:put(inf.formatted)
end
end)
:tree())
-- set the window viewport to have the first line in view:
pcall(vim.api.nvim_win_call, w_list, function() vim.fn.winrestview { topline = 1 } end)
pcall(vim.api.nvim_win_set_cursor, w_list, { cursor_index - top_offset, 0 })
end)
end),
'e:render'
)
--
-- Populate the public API:
--
function controller.get_items()
return safe_run(function() return s_items_raw:get() end)
end
---@param items T[]
function controller.set_items(items)
return safe_run(function() s_items_raw:set(items) end)
end
function controller.set_filter_text(filter_text)
return safe_run(function()
vim.api.nvim_win_call(w_input, function() vim.api.nvim_set_current_line(filter_text) end)
end)
end
function controller.get_selected_indices()
return safe_run(function() return s_selection_info:get().indices end)
end
function controller.get_selected_items()
return safe_run(function() return s_selection_info:get().items end)
end
--- @param indicies number[]
---@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)
if matching_filtered_item_idx ~= nil then s_cursor_index:set(indicies[1]) end
else
if not opts.multi then
local err = 'Cannot set multiple selected indices on a single-select picker'
H.finish(true, err)
error(err)
end
s_selected_indices:set(indicies)
end
end)
end
function controller.close()
return safe_run(function() H.finish(true) end)
end
return controller --[[@as SelectController]]
end
--------------------------------------------------------------------------------
-- END create_picker
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- vim.ui.select override
--------------------------------------------------------------------------------
local ORIGINAL_UI_SELECT
function M.register_ui_select()
ORIGINAL_UI_SELECT = vim.ui.select
--- @generic T
--- @param items `T`[]
--- @param opts { prompt?: string, kind?: any, format_item?: fun(item: T):string }
--- @param cb fun(item: T|nil):any
function vim.ui.select(items, opts, cb)
M.create_picker {
items = items,
format_item = function(item)
local s = opts.format_item and opts.format_item(item) or tostring(item)
s = s:gsub('<', '&lt;')
return s
end,
on_finish = function(sel_items)
if #sel_items == 0 then cb(nil) end
cb(sel_items[#sel_items])
end,
}
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
--------------------------------------------------------------------------------
-- Built-in pickers
-- 1. files
-- 2. buffers
-- 3. code-symbols
--------------------------------------------------------------------------------
--- @param opts? { limit?: number }
function M.files(opts)
opts = opts or {}
opts.limit = opts.limit or 10000
local cmd = {}
if vim.fn.executable 'rg' then
cmd = {
'rg',
'--color=never',
'--files',
'--hidden',
'--follow',
'-g',
'!.git',
'-g',
'!node_modules',
'-g',
'!target',
}
elseif vim.fn.executable 'fd' then
cmd = {
'fd',
'--color=never',
'--type',
'f',
'--hidden',
'--follow',
'--exclude',
'.git',
'--exclude',
'node_modules',
'--exclude',
'target',
}
elseif vim.fn.executable 'find' then
cmd = {
'find',
'-type',
'f',
'-not',
'-path',
"'*/.git/*'",
'-not',
'-path',
"'*/node_modules/*'",
'-not',
'-path',
"'*/target/*'",
'-printf',
"'%P\n'",
}
end
if #cmd == 0 then
vim.notify('rg/fd/find executable not found: cannot list files', vim.log.levels.ERROR)
return
end
-- Keep track of the job that will list files independent from the picker. We
-- will stream lines from this process to the picker as they come in:
local job_inf = { id = 0, proc_lines = {}, notified_over_limit = false }
-- Initially, create the picker with no items:
local picker = M.create_picker {
multi = true,
items = {},
--- @params items string[]
on_finish = function(items)
pcall(vim.fn.jobstop, job_inf.id)
if #items == 0 then return end
if #items == 1 then
vim.cmd.edit(items[1])
else
-- populate quickfix:
vim.fn.setqflist(vim
.iter(items)
:map(
function(item)
return {
filename = item,
lnum = 1,
col = 1,
}
end
)
:totable())
vim.cmd.copen()
end
end,
mappings = {
['<C-t>'] = function(sel)
sel.close()
--- @type string[]
local items = sel.get_selected_items()
-- open in new tab:
for _, item in ipairs(items) do
vim.cmd.tabnew(item)
end
end,
['<C-v>'] = function(sel)
sel.close()
--- @type string[]
local items = sel.get_selected_items()
-- open in vertical split:
for _, item in ipairs(items) do
vim.cmd.vsplit(item)
end
end,
['<C-s>'] = function(sel)
sel.close()
--- @type string[]
local items = sel.get_selected_items()
-- open in horizontal split:
for _, item in ipairs(items) do
vim.cmd.split(item)
end
end,
},
}
-- Kick off the process that lists the files. As lines come in, send them to
-- the picker:
job_inf.id = vim.fn.jobstart(cmd, {
--- @param data string[]
on_stdout = vim.schedule_wrap(function(_chanid, data, _name)
local lines = job_inf.proc_lines
local function set_lines_as_items_state()
picker.set_items(vim
.iter(lines)
:enumerate()
:filter(function(idx, item)
-- Filter out an incomplete last line:
local is_last_line = idx == #lines
if is_last_line and item == '' then return false end
return true
end)
:map(function(_, item) return item end)
:totable())
end
-- It's just not a good idea to process large lists with Lua. The default
-- limit is 10,000 items, and even crunching through this is iffy on a
-- 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)
job_inf.notified_over_limit = true
end
return
end
-- :help channel-lines
local eof = #data == 1 and data[1] == ''
if eof then set_lines_as_items_state() end
-- Complete the previous line:
if #lines > 0 then lines[#lines] = lines[#lines] .. table.remove(data, 1) end
for _, l in ipairs(data) do
table.insert(lines, l)
end
set_lines_as_items_state()
end),
})
end
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
--- @type { name: string; changed: number; bufnr: number }[]
local bufs = vim.fn.getbufinfo { buflisted = 1 }
M.create_picker {
multi = true,
items = bufs,
--- @param item { name: string; changed: number; bufnr: number }
format_item = function(item)
local item_name = item.name
if item_name == '' then item_name = '[No Name]' end
-- trim leading `cwd` from the buffer name:
if item_name:sub(1, #cwd) == cwd then item_name = item_name:sub(#cwd + 1) end
return TreeBuilder.new()
:put(item.changed == 1 and '[+] ' or ' ')
:put(item_name)
:tree()
end,
--- @params items { bufnr: number }[]
on_finish = function(items)
if #items == 0 then return end
if #items == 1 then
vim.cmd.buffer(items[1].bufnr)
else
-- populate quickfix:
vim.fn.setqflist(vim
.iter(items)
:map(
function(item)
return {
bufnr = item.bufnr,
filename = item.name,
lnum = 1,
col = 1,
}
end
)
:totable())
vim.cmd.copen()
end
end,
mappings = {
['<C-t>'] = function(sel)
sel.close()
--- @type { bufnr: number }[]
local items = sel.get_selected_items()
-- open in new tab:
for _, item in ipairs(items) do
vim.cmd.tabnew()
vim.cmd.buffer(item.bufnr)
end
end,
['<C-v>'] = function(sel)
sel.close()
--- @type { bufnr: number }[]
local items = sel.get_selected_items()
-- open in new vertial split:
for _, item in ipairs(items) do
vim.cmd.vsplit()
vim.cmd.buffer(item.bufnr)
end
end,
['<C-s>'] = function(sel)
sel.close()
--- @type { bufnr: number }[]
local items = sel.get_selected_items()
-- open in horizontal split:
for _, item in ipairs(items) do
vim.cmd.split()
vim.cmd.buffer(item.bufnr)
end
end,
['<C-x>'] = function(sel)
local selected_items = sel.get_selected_items()
for _, item in ipairs(selected_items) do
-- delete the buffer
vim.cmd.bdelete(item.bufnr)
end
sel.set_selected_indices {}
sel.set_items(
vim
.iter(sel.get_items())
:filter(function(item) return not vim.tbl_contains(selected_items, item) end)
:totable()
)
end,
},
}
end
local IS_CODE_SYMBOL_RUNNING = false
function M.lsp_code_symbols()
if IS_CODE_SYMBOL_RUNNING then return end
IS_CODE_SYMBOL_RUNNING = true
-- Avoid callback-hell with a wizard-based "steps"-system. Define each "step"
-- sequentially in the code, and wire up the callbacks to call the next step:
-- a simple, yet powerful, and easy to understand pattern/approach.
local STEPS = {}
--- @param info vim.lsp.LocationOpts.OnList
function STEPS._1_on_symbols(info)
M.create_picker {
items = info.items,
--- @param item { text: string }
format_item = function(item)
local s = item.text:gsub('<', '&lt;')
return s
end,
on_finish = STEPS._2_on_symbol_picked,
}
end
--- @param items { filename: string, lnum: integer, col: integer }[]
function STEPS._2_on_symbol_picked(items)
if #items == 0 then return STEPS._finally() end
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)
if buf ~= nil then
vim.api.nvim_win_set_buf(0, buf.bufnr)
else
vim.cmd.edit(item.filename)
end
-- Jump to the specific location:
vim.api.nvim_win_set_cursor(0, { item.lnum, item.col - 1 })
vim.cmd.normal 'zz'
STEPS._finally()
end
function STEPS._finally() IS_CODE_SYMBOL_RUNNING = false end
-- Kick off the async operation:
vim.lsp.buf.document_symbol { on_list = STEPS._1_on_symbols }
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

@@ -1,15 +1,38 @@
local vim_repeat = require 'u.repeat' --
local CodeWriter = require 'u.codewriter' -- Split/Join: toggles between single-line and multi-line forms for bracketed expressions.
local Range = require 'u.range' -- Example: { a, b, c } <-> {\n a,\n b,\n c\n}
-- Maps to gS in normal mode.
--
local Range = require('u').Range
local vim_repeat = require('u').repeat_
local M = {} local M = {}
---@param bracket_range Range --- @param bracket_range u.Range
---@param left string --- @param left string
---@param right string --- @param right string
local function split(bracket_range, left, right) local function split(bracket_range, left, right)
local code = CodeWriter.from_pos(bracket_range.start) local bufnr = bracket_range.start.bufnr
code:write_raw(left) local first_line = Range.from_line(bufnr, bracket_range.start.lnum):text()
local ws = first_line:match '^%s*'
local expandtab = vim.bo[bufnr].expandtab
local shiftwidth = vim.bo[bufnr].shiftwidth
local indent_str, base_indent
if expandtab then
indent_str = string.rep(' ', shiftwidth)
base_indent = math.floor(#ws / shiftwidth)
else
indent_str = '\t'
base_indent = #ws
end
local lines = {}
local function write(line, indent_offset)
table.insert(lines, indent_str:rep(base_indent + (indent_offset or 0)) .. line)
end
table.insert(lines, left)
local curr = bracket_range.start:next() local curr = bracket_range.start:next()
if curr == nil then return end if curr == nil then return end
@@ -27,7 +50,7 @@ local function split(bracket_range, left, right)
if vim.tbl_contains({ ',', ';' }, curr:char()) then if vim.tbl_contains({ ',', ';' }, curr:char()) then
-- accumulate item: -- accumulate item:
local item = vim.trim(Range.new(last_start, curr):text()) local item = vim.trim(Range.new(last_start, curr):text())
if item ~= '' then code:indent():write(item) end if item ~= '' then write(item, 1) end
local next_last_start = curr:next() local next_last_start = curr:next()
if next_last_start == nil then break end if next_last_start == nil then break end
@@ -45,24 +68,28 @@ local function split(bracket_range, left, right)
local pos_before_right = bracket_range.stop:must_next(-1) local pos_before_right = bracket_range.stop:must_next(-1)
if last_start < pos_before_right then if last_start < pos_before_right then
local item = vim.trim(Range.new(last_start, pos_before_right):text()) local item = vim.trim(Range.new(last_start, pos_before_right):text())
if item ~= '' then code:indent():write(item) end if item ~= '' then write(item, 1) end
end end
code:write(right) write(right)
bracket_range:replace(code.lines) bracket_range:replace(lines)
end end
---@param bracket_range Range --- @param bracket_range u.Range
---@param left string --- @param left string
---@param right string --- @param right string
local function join(bracket_range, left, right) local function join(bracket_range, left, right)
local inner_range = Range.new(bracket_range.start:must_next(), bracket_range.stop:must_next(-1), bracket_range.mode) local inner_range = bracket_range:shrink(1)
if inner_range then
local newline = vim local newline = vim
.iter(inner_range:lines()) .iter(inner_range:lines())
:map(function(l) return vim.trim(l) end) :map(function(l) return vim.trim(l) end)
:filter(function(l) return l ~= '' end) :filter(function(l) return l ~= '' end)
:join ' ' :join ' '
bracket_range:replace { left .. newline .. right } bracket_range:replace { left .. newline .. right }
else
bracket_range:replace { left .. right }
end
end end
local function splitjoin() local function splitjoin()
@@ -80,7 +107,7 @@ local function splitjoin()
end end
function M.setup() function M.setup()
vim.keymap.set('n', 'gS', function() vim_repeat.run(splitjoin) end) vim.keymap.set('n', 'gS', function() vim_repeat.run_repeatable(splitjoin) end)
end end
return M return M

View File

@@ -1,12 +1,19 @@
local vim_repeat = require 'u.repeat' --
local opkeymap = require 'u.opkeymap' -- Surround: add, change, and delete surrounding characters (quotes, brackets, HTML tags).
local Pos = require 'u.pos' --
local Range = require 'u.range' -- Mappings:
local Buffer = require 'u.buffer' -- ys{motion}{char} - add surround (e.g., ysiw" surrounds word with quotes)
local CodeWriter = require 'u.codewriter' -- cs{from}{to} - change surround (e.g., cs"' changes " to ')
-- ds{char} - delete surround (e.g., ds" removes surrounding quotes)
-- S{char} - surround visual selection
--
local Range = require('u').Range
local vim_repeat = require('u').repeat_
local M = {} local M = {}
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
local surrounds = { local surrounds = {
[')'] = { left = '(', right = ')' }, [')'] = { left = '(', right = ')' },
['('] = { left = '( ', right = ' )' }, ['('] = { left = '( ', right = ' )' },
@@ -21,36 +28,37 @@ local surrounds = {
['`'] = { left = '`', right = '`' }, ['`'] = { left = '`', right = '`' },
} }
---@return { left: string; right: string }|nil --- @type { left: string, right: string } | nil
local CACHED_BOUNDS = nil
--- @return { left: string, right: string }|nil
local function prompt_for_bounds() local function prompt_for_bounds()
if vim_repeat.is_repeating() then return CACHED_BOUNDS end
local cn = vim.fn.getchar() local cn = vim.fn.getchar()
-- Check for non-printable characters:
if type(cn) ~= 'number' or cn < 32 or cn > 126 then return end if type(cn) ~= 'number' or cn < 32 or cn > 126 then return end
local c = vim.fn.nr2char(cn) local c = vim.fn.nr2char(cn)
if c == '<' then if c == '<' then
-- Surround with a tag:
vim.keymap.set('c', '>', '><CR>') vim.keymap.set('c', '>', '><CR>')
local tag = '<' .. vim.fn.input '<' local tag = '<' .. vim.fn.input '<'
if tag == '<' then return end if tag == '<' then return end
vim.keymap.del('c', '>') vim.keymap.del('c', '>')
local endtag = '</' .. tag:sub(2):match '[^ >]*' .. '>' local endtag = '</' .. tag:sub(2):match '[^ >]*' .. '>'
-- selene: allow(global_usage) CACHED_BOUNDS = { left = tag, right = endtag }
return { left = tag, right = endtag } return CACHED_BOUNDS
else else
-- Default surround: CACHED_BOUNDS = surrounds[c] or { left = c, right = c }
return (surrounds)[c] or { left = c, right = c } return CACHED_BOUNDS
end end
end end
---@param range Range --- @param range u.Range
---@param bounds { left: string; right: string } --- @param bounds { left: string, right: string }
local function do_surround(range, bounds) local function do_surround(range, bounds)
local left = bounds.left local left = bounds.left
local right = bounds.right local right = bounds.right
if range.mode == 'V' then if range.mode == 'V' then
-- If we are surrounding multiple lines, we don't care about
-- space-padding:
left = vim.trim(left) left = vim.trim(left)
right = vim.trim(right) right = vim.trim(right)
end end
@@ -58,59 +66,85 @@ local function do_surround(range, bounds)
if range.mode == 'v' then if range.mode == 'v' then
range:replace(left .. range:text() .. right) range:replace(left .. range:text() .. right)
elseif range.mode == 'V' then elseif range.mode == 'V' then
local buf = Buffer.current() local bufnr = vim.api.nvim_get_current_buf()
local cw = CodeWriter.from_line(buf:line0(range.start.lnum):text(), buf.buf) local first_line = Range.from_line(bufnr, range.start.lnum):text()
local ws = first_line:match '^%s*'
local expandtab = vim.bo[bufnr].expandtab
local shiftwidth = vim.bo[bufnr].shiftwidth
-- write the left bound at the current indent level: local indent_str, base_indent
cw:write(left) if expandtab then
indent_str = string.rep(' ', shiftwidth)
base_indent = math.floor(#ws / shiftwidth)
else
indent_str = '\t'
base_indent = #ws
end
local curr_ident_prefix = cw.indent_str:rep(cw.indent_level) local lines = {}
cw:indent(function(cw2) local function write(line, indent_offset)
table.insert(lines, indent_str:rep(base_indent + (indent_offset or 0)) .. line)
end
write(left)
local indent_prefix = indent_str:rep(base_indent)
for _, line in ipairs(range:lines()) do for _, line in ipairs(range:lines()) do
-- trim the current indent prefix from the line: if line:sub(1, #indent_prefix) == indent_prefix then line = line:sub(#indent_prefix + 1) end
if line:sub(1, #curr_ident_prefix) == curr_ident_prefix then write(line, 1)
--
line = line:sub(#curr_ident_prefix + 1)
end end
write(right)
cw2:write(line) range:replace(lines)
end
end)
-- write the right bound at the current indent level:
cw:write(right)
range:replace(cw.lines)
end end
range.start:save_to_pos '.' range.start:save_to_pos '.'
end 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() function M.setup()
require('u.repeat').setup() require('u.repeat').setup()
-- Visual -- Visual
vim.keymap.set('v', 'S', function() vim.keymap.set('x', 'S', function()
local c = vim.fn.getcharstr()
local range = Range.from_vtext() local range = Range.from_vtext()
local bounds = surrounds[c] or { left = c, right = c } local bounds = prompt_for_bounds()
vim_repeat.run(function() if bounds == nil then return end
do_surround(range, bounds) do_surround(range, bounds)
-- this is a visual mapping: end in normal mode: -- this is a visual mapping: end in normal mode:
vim.cmd { cmd = 'normal', args = { '' }, bang = true } vim.cmd.normal(ESC)
end)
end, { noremap = true, silent = true }) end, { noremap = true, silent = true })
-- Change -- Change
vim.keymap.set('n', 'cs', function() vim.keymap.set('n', 'cs', function()
local from_cn = vim.fn.getchar() local from_cn = vim.fn.getchar() --[[@as number]]
-- Check for non-printable characters: -- Check for non-printable characters:
if from_cn < 32 or from_cn > 126 then return end 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_c = vim.fn.nr2char(from_cn)
local from = surrounds[from_c] or { left = from_c, right = from_c } local from = surrounds[from_c] or { left = from_c, right = from_c }
local function get_fresh_arange() local function get_fresh_arange()
local arange = Range.from_text_object('a' .. from_c, { user_defined = true }) local arange = Range.from_motion('a' .. from_c, { user_defined = true })
if arange == nil then return nil end if arange == nil then return end
if from_c == 'q' then if from_c == 'q' then
from.left = arange.start:char() from.left = arange.start:char()
from.right = arange.stop:char() from.right = arange.stop:char()
@@ -119,10 +153,12 @@ function M.setup()
end end
local arange = get_fresh_arange() local arange = get_fresh_arange()
if arange == nil then return nil end if arange == nil then return end
local hl_info1 = Range.new(arange.start, arange.start, 'v'):highlight('IncSearch', { priority = 999 }) local hl_info1 = vim_repeat.is_repeating() and nil
local hl_info2 = Range.new(arange.stop, arange.stop, 'v'):highlight('IncSearch', { priority = 999 }) 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() local hl_clear = function()
if hl_info1 then hl_info1.clear() end if hl_info1 then hl_info1.clear() end
if hl_info2 then hl_info2.clear() end if hl_info2 then hl_info2.clear() end
@@ -132,51 +168,39 @@ function M.setup()
hl_clear() hl_clear()
if to == nil then return end 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 if from_c == 't' then
-- For tags, we want to replace the inner text, not the tag: -- For tags, we want to replace the inner text, not the tag:
local irange = Range.from_text_object('i' .. from_c, { user_defined = true }) local irange = Range.from_motion('i' .. from_c, { user_defined = true })
if arange == nil or irange == nil then return nil end if arange == nil or irange == nil then return end
local lrange = Range.new(arange.start, irange.start:must_next(-1)) local lrange, rrange = arange:difference(irange)
local rrange = Range.new(irange.stop:must_next(1), arange.stop) if not lrange or not rrange then return end
rrange:replace(to.right) rrange:replace(to.right)
lrange:replace(to.left) lrange:replace(to.left)
else else
-- replace `from.right` with `to.right`: -- replace `from.right` with `to.right`:
local last_line = arange:line0(-1).text() --[[@as string]] local right_text = arange:sub(-1, -#from.right)
local from_right_match = last_line:match(vim.pesc(from.right) .. '$') right_text:replace(to.right)
if from_right_match then
local match_start = arange.stop:clone()
match_start.col = match_start.col - #from_right_match + 1
Range.new(match_start, arange.stop):replace(to.right)
end
-- replace `from.left` with `to.left`: -- replace `from.left` with `to.left`:
local first_line = arange:line0(0).text() --[[@as string]] local left_text = arange:sub(1, #from.left)
local from_left_match = first_line:match('^' .. vim.pesc(from.left)) left_text:replace(to.left)
if from_left_match then
local match_end = arange.start:clone()
match_end.col = match_end.col + #from_left_match - 1
Range.new(arange.start, match_end):replace(to.left)
end
end end
end) end)
end, { noremap = true, silent = true }) end, { noremap = true, silent = true })
-- Delete -- Delete
local CACHED_DELETE_FROM = nil
vim.keymap.set('n', 'ds', function() vim.keymap.set('n', 'ds', function()
local txt_obj = vim.fn.getcharstr() vim_repeat.run_repeatable(function()
vim_repeat.run(function() local txt_obj = vim_repeat.is_repeating() and CACHED_DELETE_FROM or vim.fn.getcharstr()
local buf = Buffer.current() CACHED_DELETE_FROM = txt_obj
local irange = Range.from_text_object('i' .. txt_obj)
local arange = Range.from_text_object('a' .. txt_obj) local bufnr = vim.api.nvim_get_current_buf()
if arange == nil or irange == nil then return nil end 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 starting_cursor_pos = arange.start:clone() local starting_cursor_pos = arange.start:clone()
-- Now, replace `arange` with the content of `irange`. If `arange` was multiple lines, -- Now, replace `arange` with the content of `irange`. If `arange` was multiple lines,
@@ -187,28 +211,19 @@ function M.setup()
-- Dedenting moves the cursor, so we need to set the cursor to a consistent starting spot: -- Dedenting moves the cursor, so we need to set the cursor to a consistent starting spot:
arange.start:save_to_pos '.' arange.start:save_to_pos '.'
-- Dedenting also changed the inner text, so re-acquire it: -- Dedenting also changed the inner text, so re-acquire it:
arange = Range.from_text_object('a' .. txt_obj) arange = Range.from_motion('a' .. txt_obj)
irange = Range.from_text_object('i' .. txt_obj) irange = Range.from_motion('i' .. txt_obj)
if arange == nil or irange == nil then return end -- should never be true if arange == nil or irange == nil then return end -- should never be true
arange:replace(irange:lines()) arange:replace(irange:lines())
-- `arange:replace(..)` updates its own `stop` position, so we will use
local final_range = Range.new( -- `arange` as the final resulting range that holds the modified text
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: -- delete last line, if it is empty:
local last = buf:line0(final_range.stop.lnum) local last = Range.from_line(bufnr, arange.stop.lnum)
if last:text():match '^%s*$' then last:replace(nil) end if last:text():match '^%s*$' then last:replace(nil) end
-- delete first line, if it is empty: -- delete first line, if it is empty:
local first = buf:line0(final_range.start.lnum) local first = Range.from_line(bufnr, arange.start.lnum)
if first:text():match '^%s*$' then first:replace(nil) end if first:text():match '^%s*$' then first:replace(nil) end
else else
-- trim start: -- trim start:
@@ -220,35 +235,10 @@ function M.setup()
end) end)
end, { noremap = true, silent = true }) end, { noremap = true, silent = true })
opkeymap('n', 'ys', function(range) vim.keymap.set('n', 'ys', function()
local hl_info = range:highlight('IncSearch', { priority = 999 }) vim.o.operatorfunc = 'v:lua.MySurroundOpFunc'
return 'g@'
---@type { left: string; right: string } end, { expr = true })
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 end
return M return M

View File

@@ -1,58 +1,47 @@
local utils = require 'u.utils' --
local Pos = require 'u.pos' -- Custom text objects:
local Range = require 'u.range' -- ag - whole file
local Buffer = require 'u.buffer' -- a. - current line
-- aq/iq - nearest quote (any type)
-- a"/i", a'/i', a`/i` - specific quotes
-- al"/il", al'/il', al`/il` - "last" quote (searches backward)
-- alB/ilB, al]/il], alb/ilb, al>/il> - "last" bracket objects
--
local u = require 'u'
local Pos = u.Pos
local Range = u.Range
local M = {} local M = {}
function M.setup() function M.setup()
-- Select whole file: -- Select whole file:
utils.define_text_object('ag', function() return Buffer.current():all() end) u.define_txtobj('ag', function() return Range.from_buf_text(0) end)
-- Select current line: -- Select current line:
utils.define_text_object('a.', function() u.define_txtobj('a.', function() return Range.from_line(0, Pos.from_pos('.').lnum) end)
local lnum = Pos.from_pos('.').lnum
return Buffer.current():line0(lnum)
end)
-- Select the nearest quote: -- Select the nearest quote:
utils.define_text_object('aq', function() return Range.find_nearest_quotes() end) u.define_txtobj('aq', function() return Range.find_nearest_quotes() end)
utils.define_text_object('iq', function() u.define_txtobj('iq', function()
local range = Range.find_nearest_quotes() local range = Range.find_nearest_quotes()
if range == nil then return end if range == nil then return end
return range:shrink(1) return range:shrink(1)
end) end)
---Selects the next quote object (searches forward) ---Selects the next quote object (searches forward)
---@param q string --- @param q string
local function define_quote_obj(q) local function define_quote_obj(q)
local function select_around() local function select_around() return Range.from_motion('a' .. q) end
-- 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 }
-- Now check on the visually selected text: u.define_txtobj('a' .. q, function() return select_around() end)
local range = Range.from_vtext() u.define_txtobj('i' .. q, function()
if range:is_empty() then return range.start end local range = select_around()
range.start = range.start:find_next(1, q) or range.start if range == nil or range:is_empty() then return range end
range.stop = range.stop:find_next(-1, q) or range.stop
return range
end
utils.define_text_object('a' .. q, function() return select_around() end) local start_next = range.start:next(1) or range.start
utils.define_text_object('i' .. q, function() local stop_prev = range.stop:next(-1)
local range_or_pos = select_around() if start_next > stop_prev then return Range.new(start_next) end
if Range.is(range_or_pos) then return range:shrink(1) or range
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)
end end
define_quote_obj [["]] define_quote_obj [["]]
@@ -60,36 +49,26 @@ function M.setup()
define_quote_obj [[`]] define_quote_obj [[`]]
---Selects the "last" quote object (searches backward) ---Selects the "last" quote object (searches backward)
---@param q string --- @param q string
local function define_last_quote_obj(q) local function define_last_quote_obj(q)
local function select_around() local function select_around()
local curr = Pos.from_pos('.'):find_next(-1, q) local curr = Pos.from_pos('.'):find_next(-1, q)
if not curr then return end if not curr then return end
-- Reset visual selection to current context: -- Reset visual selection to current context:
Range.new(curr, curr):set_visual_selection() curr:save_to_pos '.'
vim.cmd.normal('a' .. q) return Range.from_motion('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 end
utils.define_text_object('al' .. q, function() return select_around() end) u.define_txtobj('al' .. q, function() return select_around() end)
utils.define_text_object('il' .. q, function() u.define_txtobj('il' .. q, function()
local range_or_pos = select_around() local range = select_around()
if range_or_pos == nil then return end if range == nil or range:is_empty() then return range end
if Range.is(range_or_pos) then local start_next = range.start:next(1) or range.start
local start_next = range_or_pos.start:next(1) local stop_prev = range.stop:next(-1)
local stop_prev = range_or_pos.stop:next(-1) if start_next > stop_prev then return Range.new(start_next) end
if start_next > stop_prev then return start_next end
local range = range_or_pos:shrink(1) return range:shrink(1) or range
return range
else
return range_or_pos
end
end) end)
end end
define_last_quote_obj [["]] define_last_quote_obj [["]]
@@ -111,8 +90,8 @@ function M.setup()
local keybinds = { ... } local keybinds = { ... }
table.insert(keybinds, b) table.insert(keybinds, b)
for _, k in ipairs(keybinds) do for _, k in ipairs(keybinds) do
utils.define_text_object('al' .. k, function() return select_around() end) u.define_txtobj('al' .. k, function() return select_around() end)
utils.define_text_object('il' .. k, function() u.define_txtobj('il' .. k, function()
local range = select_around() local range = select_around()
return range and range:shrink(1) return range and range:shrink(1)
end) end)

1
library/busted Submodule

Submodule library/busted added at 5ed85d0e01

1
library/luv Submodule

Submodule library/luv added at 3615eb12c9

1326
lua/u.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +0,0 @@
local Range = require 'u.range'
local Renderer = require 'u.renderer'.Renderer
---@class Buffer
---@field buf number
---@field private renderer Renderer
local Buffer = {}
---@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({
buf = buf,
renderer = renderer,
}, { __index = Buffer })
end
---@return Buffer
function Buffer.current() return Buffer.from_nr(0) 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:set_option('bufhidden', 'delete')
self:set_option('buflisted', false)
self:set_option('buftype', 'nowrite')
end
---@param nm string
function Buffer:get_option(nm) return vim.api.nvim_get_option_value(nm, { buf = self.buf }) end
---@param nm string
function Buffer:set_option(nm, val) return vim.api.nvim_set_option_value(nm, val, { buf = self.buf }) end
---@param nm string
function Buffer:get_var(nm) return vim.api.nvim_buf_get_var(self.buf, nm) end
---@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.buf, start, -1, false, { line })
end
---@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 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 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[]
--- @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.buf }))
end
--- @param tree Tree
function Buffer:render(tree) return self.renderer:render(tree) end
return Buffer

View File

@@ -1,76 +0,0 @@
local Buffer = require 'u.buffer'
---@class CodeWriter
---@field lines string[]
---@field indent_level number
---@field indent_str string
local 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
local cw = {
lines = {},
indent_level = indent_level,
indent_str = indent_str,
}
setmetatable(cw, { __index = CodeWriter })
return cw
end
---@param p Pos
function CodeWriter.from_pos(p)
local line = Buffer.from_nr(p.buf):line0(p.lnum):text()
return CodeWriter.from_line(line, p.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 = buf })
local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = buf })
local indent_level = 0
local indent_str = ''
if expandtab then
while #indent_str < shiftwidth do
indent_str = indent_str .. ' '
end
indent_level = #ws / shiftwidth
else
indent_str = '\t'
indent_level = #ws
end
return CodeWriter.new(indent_level, indent_str)
end
---@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
function CodeWriter:write(line) self:write_raw(self.indent_str:rep(self.indent_level) .. line) end
---@param f? fun(cw: CodeWriter):any
function CodeWriter:indent(f)
local cw = {
lines = self.lines,
indent_level = self.indent_level + 1,
indent_str = self.indent_str,
}
setmetatable(cw, { __index = CodeWriter })
if f ~= nil then f(cw) end
return cw
end
return CodeWriter

View File

@@ -1,56 +0,0 @@
local M = {}
--- @params name string
function M.file_for_name(name) return vim.fs.joinpath(vim.fn.stdpath 'cache', 'my.log', name .. '.log.jsonl') end
--------------------------------------------------------------------------------
-- Logger class
--------------------------------------------------------------------------------
--- @class 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.Term('tail -f "' .. log_file_path .. '"')
end, { nargs = '*' })
end
return M

View File

@@ -1,43 +0,0 @@
local Range = require 'u.range'
local vim_repeat = require 'u.repeat'
---@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: Range): fun():any|nil
---@param ty 'line'|'char'|'block'
-- selene: allow(unused_variable)
function __U__OpKeymapOpFunc(ty)
if __U__OpKeymapOpFunc_rhs ~= nil then
local range = Range.from_op_func(ty)
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
--- Registers a function that operates on a text-object, triggered by the given prefix (lhs).
--- It works in the following way:
--- 1. An expression-map is set, so that whatever the callback returns is executed by Vim (in this case `g@`)
--- 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: 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()
__U__OpKeymapOpFunc_rhs = rhs
vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc'
return 'g@'
end, vim.tbl_extend('force', opts or {}, { expr = true }))
end
return opkeymap

View File

@@ -1,239 +0,0 @@
local MAX_COL = vim.v.maxcol
---@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 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.MAX_COL = MAX_COL
---@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 = {
buf = buf,
lnum = lnum,
col = col,
off = off,
}
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.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 a.lnum == b.lnum and a.col == b.col end
---@param name string
---@return Pos
function Pos.from_pos(name)
local p = vim.fn.getpos(name)
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:clone() return Pos.new(self.buf, self.lnum, self.col, self.off) end
---@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 col = self.col
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 = #line_text(self.buf, self.lnum) - 1
end
return Pos.new(self.buf, self.lnum, 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
local p = self:as_real()
vim.fn.setpos(pos, { p.buf, p.lnum + 1, p.col + 1, p.off })
end
---@param mark string
function Pos:save_to_mark(mark)
local p = self:as_real()
vim.api.nvim_buf_set_mark(p.buf, mark, p.lnum + 1, p.col, {})
end
---@return string
function Pos:char()
local line = line_text(self.buf, self.lnum)
if line == nil then return '' end
return line:sub(self.col + 1, self.col + 1)
end
---@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.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.buf, self.lnum) - 1
if col > line_max_col then
col = 0
line = line + 1
end
return Pos.new(self.buf, line, col, self.off)
else
-- Previous:
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.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.buf, line, col, self.off)
end
end
---@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: 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
while true do
local next = curr:next(dir)
if next == nil or not predicate(next) then break end
curr = next
end
return curr
end
---@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 Pos|nil
local curr = self
while curr ~= nil do
if predicate(curr) then return curr end
curr = curr:next(dir)
end
return curr
end
--- Finds the matching bracket/paren for the current position.
---@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
table.insert(invocations, self)
local openers = { '{', '[', '(', '<' }
local closers = { '}', ']', ')', '>' }
local c = self:char()
local is_opener = vim.tbl_contains(openers, c)
local is_closer = vim.tbl_contains(closers, c)
if not is_opener and not is_closer then return nil end
local i, _ = vim.iter(is_opener and openers or closers):enumerate():find(function(_, c2) return c == c2 end)
local c_match = (is_opener and closers or openers)[i]
---@type Pos|nil
local cur = self
---@return Pos|nil
local function adv()
if cur == nil then return nil end
if max_chars ~= nil then
max_chars = max_chars - 1
if max_chars < 0 then return nil end
end
return cur:next(is_opener and 1 or -1)
end
-- scan until we find a match:
cur = adv()
while cur ~= nil and cur:char() ~= c_match do
cur = adv()
if cur == nil then break end
local c2 = cur:char()
if c2 == c_match then break end
if vim.tbl_contains(openers, c2) or vim.tbl_contains(closers, c2) then
cur = cur:find_match(max_chars, invocations)
cur = adv() -- move past the match
end
end
return cur
end
return Pos

View File

@@ -1,522 +0,0 @@
local Pos = require 'u.pos'
local State = require 'u.state'
local orig_on_yank = (vim.hl or vim.highlight).on_yank
local on_yank_enabled = true;
((vim.hl or vim.highlight) --[[@as any]]).on_yank = function(opts)
if not on_yank_enabled then return end
return orig_on_yank(opts)
end
---@class Range
---@field start Pos
---@field stop Pos|nil
---@field mode 'v'|'V'
local 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
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
function Range.is(x)
local mt = getmetatable(x)
return mt and mt.__index == Range
end
---@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'
local mode
if stop:is_col_max() then
mode = 'V'
else
mode = 'v'
end
return Range.new(start, stop, mode)
end
---@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(buf, 0, 0)
local stop = Pos.new(buf, num_lines - 1, Pos.MAX_COL)
return Range.new(start, stop, 'V')
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 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(buf)
stop_line = num_lines + stop_line
end
return Range.new(Pos.new(buf, start_line, 0), Pos.new(buf, stop_line, Pos.MAX_COL), 'V')
end
---@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.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
---@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 '.'
-- 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 "']"
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
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 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
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
return Range.new(start, stop)
end
--- Get range information from the currently selected visual text.
--- Note: from within a command mapping or an opfunc, use other specialized
--- utilities, such as:
--- * Range.from_cmd_args
--- * Range.from_op_func
function Range.from_vtext()
local r = Range.from_marks('v', '.')
if vim.fn.mode() == 'V' then r = r:to_linewise() end
return r
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'
function Range.from_op_func(type)
if type == 'block' then error 'block motions not supported' end
local range = Range.from_marks("'[", "']")
if type == 'line' then range = range:to_linewise() end
return range
end
--- Get range information from command arguments.
---@param args unknown
---@return Range|nil
function Range.from_cmd_args(args)
---@type 'v'|'V'
local mode
---@type nil|Pos
local start
local stop
if args.range == 0 then
return nil
else
start = Pos.from_pos "'<"
stop = Pos.from_pos "'>"
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()
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()
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
---@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
-- 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: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 = 0
if r.stop ~= nil then r.stop.col = Pos.MAX_COL end
return r
end
function Range:is_empty() return self.stop == nil end
function Range:trim_start()
if self:is_empty() then return end
local r = self:clone()
while r.start:char():match '%s' do
local next = r.start:next(1)
if next == nil then break end
r.start = next
end
return r
end
function Range:trim_stop()
if self:is_empty() then return end
local r = self:clone()
while r.stop:char():match '%s' do
local next = r.stop:next(-1)
if next == nil then break end
r.stop = next
end
return r
end
---@param p Pos
function Range:contains(p) return not self:is_empty() and p >= self.start and p <= self.stop end
---@return string[]
function Range:lines()
if self:is_empty() then return {} end
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
function Range:text() return vim.fn.join(self:lines(), '\n') end
---@param i number 1-based
---@param j? number 1-based
function Range:sub(i, j) return self:text():sub(i, j) end
---@param l number
---@return { line: string; idx0: { start: number; stop: number; }; lnum: number; range: fun():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 = 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 = 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[]
function Range:replace(replacement)
if replacement == nil then replacement = {} end
if type(replacement) == 'string' then replacement = vim.fn.split(replacement, '\n') end
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'
---@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 == 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
---@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 = 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
set_text(start_lnum, start_col, start_lnum, start_col, replacement)
elseif replace_type == 'region' then
-- Fixup the bounds:
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
set_lines(start_lnum, stop_lnum, replacement)
else
error 'unreachable'
end
end
---@param amount number
function Range:shrink(amount)
local start = self.start
local stop = self.stop
if stop == nil then return self:clone() end
for _ = 1, amount do
local next_start = start:next(1)
local next_stop = stop:next(-1)
if next_start == nil or next_stop == nil then return end
start = next_start
stop = next_stop
if next_start > next_stop then break end
end
if start > stop then stop = nil end
return Range.new(start, stop, self.mode)
end
---@param amount number
function Range:must_shrink(amount)
local shrunk = self:shrink(amount)
if shrunk == nil or shrunk:is_empty() then error 'error in Range:must_shrink: Range:shrink() returned nil' end
return shrunk
end
---@param left string
---@param right string
function Range:save_to_pos(left, right)
if self:is_empty() then
self.start:save_to_pos(left)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
end
---@param left string
---@param right string
function Range:save_to_marks(left, right)
if self:is_empty() then
self.start:save_to_mark(left)
self.start:save_to_mark(right)
else
self.start:save_to_mark(left)
self.stop:save_to_mark(right)
end
end
function Range:set_visual_selection()
if self:is_empty() then return end
if vim.api.nvim_get_current_buf() ~= self.start.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
opts = opts or { on_macro = false }
if opts.on_macro == nil then opts.on_macro = false end
local in_macro = vim.fn.reg_executing() ~= ''
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
(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()
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,437 +0,0 @@
local utils = require 'u.utils'
local M = {}
--- @alias Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: Tree }
--- @alias Node nil | boolean | string | Tag
--- @alias Tree Node | Node[]
local TagMetaTable = {}
--- @param name string
--- @param attributes? table<string, any>
--- @param children? Node | Node[]
--- @return Tag
function M.h(name, attributes, children)
return {
kind = 'tag',
name = name,
attributes = attributes or {},
children = children,
}
end
--------------------------------------------------------------------------------
-- Renderer class
--------------------------------------------------------------------------------
--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
--- @class Renderer
--- @field bufnr number
--- @field ns number
--- @field changedtick number
--- @field old { lines: string[]; extmarks: RendererExtmark[] }
--- @field curr { lines: string[]; extmarks: RendererExtmark[] }
local Renderer = {}
Renderer.__index = Renderer
M.Renderer = Renderer
--- @param x any
--- @return boolean
function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
--- @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)
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
if vim.b[bufnr]._renderer_ns == nil then
vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('my.renderer:' .. tostring(bufnr))
end
local self = setmetatable({
bufnr = bufnr,
ns = vim.b[bufnr]._renderer_ns,
changedtick = 0,
old = { lines = {}, extmarks = {} },
curr = { lines = {}, extmarks = {} },
}, Renderer)
return self
end
--- @param opts {
--- tree: Tree;
--- on_tag?: fun(tag: Tag, start0: [number, number], stop0: [number, number]): any;
--- }
function Renderer.markup_to_lines(opts)
--- @type string[]
local lines = {}
local curr_line1 = 1
local curr_col1 = 1 -- exclusive: sits one position **beyond** the last inserted text
--- @param s string
local function put(s)
lines[curr_line1] = (lines[curr_line1] or '') .. s
curr_col1 = #lines[curr_line1] + 1
end
local function put_line()
table.insert(lines, '')
curr_line1 = curr_line1 + 1
curr_col1 = 1
end
--- @param node Node
local function visit(node)
if node == nil or type(node) == 'boolean' then return end
if type(node) == 'string' then
local node_lines = vim.split(node, '\n')
for lnum, s in ipairs(node_lines) do
if lnum > 1 then put_line() end
put(s)
end
elseif Renderer.is_tag(node) then
local start0 = { curr_line1 - 1, curr_col1 - 1 }
-- visit the children:
if Renderer.is_tag_arr(node.children) then
for _, child in ipairs(node.children) do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child)
end
else
visit(node.children)
end
local stop0 = { curr_line1 - 1, curr_col1 - 1 }
if opts.on_tag then opts.on_tag(node, start0, stop0) end
elseif Renderer.is_tag_arr(node) then
for _, child in ipairs(node) do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child)
end
end
end
visit(opts.tree)
return lines
end
--- @param opts {
--- tree: string;
--- format_tag?: fun(tag: Tag): string;
--- }
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
--- @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) }
self.changedtick = changedtick
end
--- @type RendererExtmark[]
local extmarks = {}
--- @type string[]
local lines = Renderer.markup_to_lines {
tree = tree,
on_tag = function(tag, start0, stop0)
if tag.name == 'text' then
local hl = tag.attributes.hl
if type(hl) == 'string' then
tag.attributes.extmark = tag.attributes.extmark or {}
tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl
end
local extmark = tag.attributes.extmark
-- Set any necessary keymaps:
for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do
-- Force creating an extmark if there are key handlers. To accurately
-- sense the bounds of the text, we need an extmark:
extmark = extmark or {}
vim.keymap.set(
'n',
lhs,
function() return self:_on_expr_map('n', lhs) end,
{ buffer = self.bufnr, expr = true, replace_keycodes = true }
)
end
end
if extmark then
table.insert(extmarks, {
start = start0,
stop = stop0,
opts = extmark,
tag = tag,
})
end
end
end,
}
self.old = self.curr
self.curr = { lines = lines, extmarks = extmarks }
self:_reconcile()
end
--- @private
--- @param info string
--- @param start integer
--- @param end_ integer
--- @param strict_indexing boolean
--- @param replacement string[]
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(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:_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:
--
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:
for _, extmark in ipairs(self.curr.extmarks) do
extmark.id = vim.api.nvim_buf_set_extmark(
self.bufnr,
self.ns,
extmark.start[1],
extmark.start[2],
vim.tbl_extend('force', {
id = extmark.id,
end_row = extmark.stop[1],
end_col = extmark.stop[2],
}, extmark.opts)
)
end
end
--- @private
--- @param mode string
--- @param lhs string
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
local pos_infos = self:get_pos_infos(pos0)
if #pos_infos == 0 then return lhs end
-- Find the first tag that is listening for this event:
local cancel = false
for _, pos_info in ipairs(pos_infos) do
local tag = pos_info.tag
-- is the tag listening?
local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs)
if type(f) == 'function' then
local result = f()
if result == '' then
-- bubble-up to the next tag, but set cancel to true, in case there are
-- no more tags to bubble up to:
cancel = true
else
return result
end
end
end
-- Resort to default behavior:
return cancel and '' or lhs
end
--- Returns pairs of extmarks and tags associate with said extmarks. The
--- returned tags/extmarks are sorted smallest (innermost) to largest
--- (outermost).
---
--- @private (private for now)
--- @param pos0 [number; number]
--- @return { extmark: RendererExtmark; tag: Tag; }[]
function Renderer:get_pos_infos(pos0)
local cursor_line0, cursor_col0 = pos0[1], pos0[2]
-- The cursor (block) occupies **two** extmark spaces: one for it's left
-- edge, and one for it's right. We need to do our own intersection test,
-- because the NeoVim API is over-inclusive in what it returns:
--- @type RendererExtmark[]
local intersecting_extmarks = vim
.iter(vim.api.nvim_buf_get_extmarks(self.bufnr, self.ns, pos0, pos0, { details = true, overlap = true }))
--- @return RendererExtmark
:map(function(ext)
--- @type number, number, number, { end_row?: number; end_col?: number }|nil
local id, line0, col0, details = unpack(ext)
local start = { line0, col0 }
local stop = { line0, col0 }
if details and details.end_row ~= nil and details.end_col ~= nil then
stop = { details.end_row, details.end_col }
end
return { id = id, start = start, stop = stop, opts = details }
end)
--- @param ext RendererExtmark
:filter(function(ext)
if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
return cursor_line0 >= ext.start[1]
and cursor_col0 >= ext.start[2]
and cursor_line0 <= ext.stop[1]
and cursor_col0 < ext.stop[2]
else
return true
end
end)
:totable()
-- Sort the tags into smallest (inner) to largest (outer):
table.sort(
intersecting_extmarks,
--- @param x1 RendererExtmark
--- @param x2 RendererExtmark
function(x1, x2)
if
x1.start[1] == x2.start[1]
and x1.start[2] == x2.start[2]
and x1.stop[1] == x2.stop[1]
and x1.stop[2] == x2.stop[2]
then
return x1.id < x2.id
end
return x1.start[1] >= x2.start[1]
and x1.start[2] >= x2.start[2]
and x1.stop[1] <= x2.stop[1]
and x1.stop[2] <= x2.stop[2]
end
)
-- When we set the extmarks in the step above, we captured the IDs of the
-- created extmarks in self.curr.extmarks, which also has which tag each
-- extmark is associated with. Cross-reference with that list to get a list
-- of tags that we need to fire events for:
--- @type { extmark: RendererExtmark; tag: Tag }[]
local matching_tags = vim
.iter(intersecting_extmarks)
--- @param ext RendererExtmark
:map(function(ext)
for _, extmark_cache in ipairs(self.curr.extmarks) do
if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end
end
end)
:totable()
return matching_tags
end
--------------------------------------------------------------------------------
-- TreeBuilder class
--------------------------------------------------------------------------------
--- @class TreeBuilder
--- @field private nodes Node[]
local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder
M.TreeBuilder = TreeBuilder
function TreeBuilder.new()
local self = setmetatable({ nodes = {} }, TreeBuilder)
return self
end
--- @param nodes Tree
--- @return TreeBuilder
function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes)
return self
end
--- @param name string
--- @param attributes? table<string, any>
--- @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)
return self
end
--- @param fn fun(TreeBuilder): any
--- @return TreeBuilder
function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new()
fn(nested_writer)
table.insert(self.nodes, nested_writer.nodes)
return self
end
--- @return Tree
function TreeBuilder:tree() return self.nodes end
return M

View File

@@ -1,61 +0,0 @@
local M = {}
local function _normal(cmd) vim.cmd { cmd = 'normal', args = { cmd }, bang = true } end
M.native_repeat = function() _normal '.' end
M.native_undo = function() _normal 'u' end
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
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', '.', M.do_repeat)
vim.keymap.set('n', 'u', M.undo)
end
return M

View File

@@ -1,90 +0,0 @@
---@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.go[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.go[nm] = val
end
if self.win_view ~= nil then vim.fn.winrestview(self.win_view) end
end
return State

View File

@@ -1,301 +0,0 @@
local M = {}
M.debug = false
--------------------------------------------------------------------------------
-- class Signal
--------------------------------------------------------------------------------
--- @class Signal
--- @field name? string
--- @field private changing boolean
--- @field private value any
--- @field private subscribers table<function, boolean>
--- @field private on_dispose_callbacks function[]
local Signal = {}
M.Signal = Signal
Signal.__index = Signal
--- @param value any
--- @param name? string
--- @return Signal
function Signal:new(value, name)
local obj = setmetatable({
name = name,
changing = false,
value = value,
subscribers = {},
on_dispose_callbacks = {},
}, self)
return obj
end
--- @param value any
function Signal:set(value)
self.value = value
-- We don't handle cyclic updates:
if self.changing then
if M.debug then
vim.notify('circular dependency detected' .. (self.name and (' in ' .. self.name) or ''), vim.log.levels.WARN)
end
return
end
local prev_changing = self.changing
self.changing = true
local ok = true
local err = nil
for _, cb in ipairs(self.subscribers) do
local ok2, err2 = pcall(cb, value)
if not ok2 then
ok = false
err = err or err2
end
end
self.changing = prev_changing
if not ok then
vim.notify(
'error notifying' .. (self.name and (' in ' .. self.name) or '') .. ': ' .. tostring(err),
vim.log.levels.WARN
)
error(err)
end
end
function Signal:schedule_set(value)
vim.schedule(function() self:set(value) end)
end
--- @return any
function Signal:get()
local ctx = M.ExecutionContext.current()
if ctx then ctx:track(self) end
return self.value
end
--- @param fn function
function Signal:update(fn) self:set(fn(self.value)) end
--- @param fn function
function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end
--- @generic U
--- @param fn fun(value: T): U
--- @return Signal --<U>
function Signal:map(fn)
local mapped_signal = M.create_memo(function()
local value = self:get()
return fn(value)
end, self.name and self.name .. ':mapped' or nil)
return mapped_signal
end
--- @return Signal
function Signal:clone()
return self:map(function(x) return x end)
end
--- @param fn fun(value: T): boolean
--- @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)
if fn(value) then filtered_signal:set(value) end
end)
filtered_signal:on_dispose(unsubscribe_from_self)
return filtered_signal
end
--- @param ms number
--- @return Signal -- <T>
function Signal:debounce(ms)
local function set_timeout(timeout, callback)
local timer = (vim.uv or vim.loop).new_timer()
timer:start(timeout, 0, function()
timer:stop()
timer:close()
callback()
end)
return timer
end
local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil)
--- @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()
state.timer:stop()
state.timer:close()
end)
state.timer = nil
end
local unsubscribe_from_self = self:subscribe(function(value)
-- Stop any previously running timer:
if state.timer then clear_timeout() end
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 last_older_than_ms = older_than_ms[#older_than_ms]
if last_older_than_ms then
filtered:set(last_older_than_ms.value)
state.queued = {}
end
-- overwrite anything young enough
table.insert(state.queued, { value = value, ts = now_ms })
state.timer = set_timeout(ms, function()
vim.schedule(function() filtered:set(value) end)
-- If a timer was allowed to run to completion, that means that no other
-- item has been queued, since the timer is reset every time a new item
-- comes in. This means we can reset the queue
clear_timeout()
state.queued = {}
end)
end)
filtered:on_dispose(unsubscribe_from_self)
return filtered
end
--- @param callback function
function Signal:subscribe(callback)
table.insert(self.subscribers, callback)
return function() self:unsubscribe(callback) end
end
--- @param callback function
function Signal:on_dispose(callback) table.insert(self.on_dispose_callbacks, callback) end
--- @param callback function
function Signal:unsubscribe(callback)
for i, cb in ipairs(self.subscribers) do
if cb == callback then
table.remove(self.subscribers, i)
break
end
end
end
function Signal:dispose()
self.subscribers = {}
for _, callback in ipairs(self.on_dispose_callbacks) do
callback()
end
end
--------------------------------------------------------------------------------
-- class ExecutionContext
--------------------------------------------------------------------------------
CURRENT_CONTEXT = nil
--- @class ExecutionContext
--- @field signals table<Signal, boolean>
local ExecutionContext = {}
M.ExecutionContext = ExecutionContext
ExecutionContext.__index = ExecutionContext
--- @return ExecutionContext
function ExecutionContext:new()
return setmetatable({
signals = {},
subscribers = {},
}, ExecutionContext)
end
function ExecutionContext.current() return CURRENT_CONTEXT end
--- @param fn function
--- @param ctx ExecutionContext
function ExecutionContext:run(fn, ctx)
local oldCtx = CURRENT_CONTEXT
CURRENT_CONTEXT = ctx
local result
local success, err = pcall(function() result = fn() end)
CURRENT_CONTEXT = oldCtx
if not success then error(err) end
return result
end
function ExecutionContext:track(signal) self.signals[signal] = true end
--- @param callback function
function ExecutionContext:subscribe(callback)
local wrapped_callback = function() callback() end
for signal in pairs(self.signals) do
signal:subscribe(wrapped_callback)
end
return function()
for signal in pairs(self.signals) do
signal:unsubscribe(wrapped_callback)
end
end
end
function ExecutionContext:dispose()
for signal, _ in pairs(self.signals) do
signal:dispose()
end
self.signals = {}
end
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
--- @param value any
--- @param name? string
--- @return Signal
function M.create_signal(value, name) return Signal:new(value, name) end
--- @param fn function
--- @param name? string
--- @return Signal
function M.create_memo(fn, name)
--- @type Signal
local result
local unsubscribe = M.create_effect(function()
local value = fn()
if name and M.debug then vim.notify(name) end
if result then
result:set(value)
else
result = M.create_signal(value, name and ('m.s:' .. name) or nil)
end
end, name)
result:on_dispose(unsubscribe)
return result
end
--- @param fn function
--- @param name? string
function M.create_effect(fn, name)
local ctx = M.ExecutionContext:new()
M.ExecutionContext:run(fn, ctx)
return ctx:subscribe(function()
if name and M.debug then
local deps = vim
.iter(vim.tbl_keys(ctx.signals))
:map(function(s) return s.name end)
:filter(function(nm) return nm ~= nil end)
:join ','
vim.notify(name .. '(deps=' .. deps .. ')')
end
fn()
end)
end
return M

View File

@@ -1,215 +0,0 @@
local M = {}
--
-- Types
--
---@alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
---@alias KeyMaps table<string, fun(): any | string> }
---@alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: Range|nil }
--- @generic T
--- @param x `T`
--- @param message? string
--- @return T
function M.dbg(x, message)
local t = {}
if message ~= nil then table.insert(t, message) end
table.insert(t, x)
vim.print(t)
return x
end
--- A utility for creating user commands that also pre-computes useful information
--- and attaches it to the arguments.
---
--- ```lua
--- -- Example:
--- ucmd('MyCmd', function(args)
--- -- print the visually selected text:
--- vim.print(args.info:text())
--- -- or get the vtext as an array of lines:
--- vim.print(args.info:lines())
--- end, { nargs = '*', range = true })
--- ```
---@param name string
---@param cmd string | fun(args: CmdArgs): any
---@param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
function M.ucmd(name, cmd, opts)
local Range = require 'u.range'
opts = opts or {}
local cmd2 = cmd
if type(cmd) == 'function' then
cmd2 = function(args)
args.info = Range.from_cmd_args(args)
return cmd(args)
end
end
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.go.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

109
mise.toml Normal file
View File

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

6
scripts/env.sh Normal file
View File

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

43
scripts/make-artifact-tag.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# After tagging a release (say, v1.2.3), use this script to publish
# lua/u.lua in an artifact branch: artifact-v1.2.3:init.lua. To do so,
# invoke the script from the root of the repo like so:
#
# ./scripts/make-artifact-tag.sh v1.2.3
#
# This will create a temporary orphan branch, tag it and immediately delete the
# branch.
#
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <TAG>"
exit 1
fi
TAG="$1"
if [[ "$(echo "$TAG" | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print "YES" }')" != "YES" ]]; then
echo "Invalid tag name: expected 'vX.Y.Z'"
exit 1
fi
ARTIFACT_TAG="artifact-$TAG"
EXISTING_ARTIFACT_TAG=$(git tag -l "$ARTIFACT_TAG")
if [[ "$ARTIFACT_TAG" = "$EXISTING_ARTIFACT_TAG" ]]; then
echo "Artifact tag already exists"
exit 1
fi
git worktree add --orphan -b "$ARTIFACT_TAG" "$ARTIFACT_TAG"
git cat-file -p "$TAG":lua/u.lua > "$ARTIFACT_TAG"/init.lua
pushd "$ARTIFACT_TAG"
git add --all
git commit --message "$ARTIFACT_TAG"
git tag "$ARTIFACT_TAG"
popd
git worktree remove -f "$ARTIFACT_TAG"
git branch -D "$ARTIFACT_TAG"

View File

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

View File

@@ -1,30 +0,0 @@
local Buffer = require 'u.buffer'
local withbuf = loadfile './spec/withbuf.lua'()
describe('Buffer', function()
it('should replace all lines', function()
withbuf({}, function()
local buf = Buffer.from_nr()
buf:all():replace 'bleh'
local actual_lines = vim.api.nvim_buf_get_lines(buf.buf, 0, -1, false)
assert.are.same({ 'bleh' }, actual_lines)
end)
end)
it('should replace all but first and last lines', function()
withbuf({
'one',
'two',
'three',
}, function()
local buf = Buffer.from_nr()
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',
'three',
}, actual_lines)
end)
end)
end)

View File

@@ -1,29 +0,0 @@
local CodeWriter = require 'u.codewriter'
describe('CodeWriter', function()
it('should write with indentation', function()
local cw = CodeWriter.new()
cw:write '{'
cw:indent(function(cw2) cw2:write 'x: 123' end)
cw:write '}'
assert.are.same(cw.lines, { '{', ' x: 123', '}' })
end)
it('should keep relative indentation', function()
local cw = CodeWriter.new()
cw:write '{'
cw:indent(function(cw2)
cw2:write 'x: 123'
cw2:write ' y: 123'
end)
cw:write '}'
assert.are.same(cw.lines, {
'{',
' x: 123',
' y: 123',
'}',
})
end)
end)

View File

@@ -1,69 +0,0 @@
local Pos = require 'u.pos'
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, 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)
it('comparison operators', function()
local a = Pos.new(0, 0, 0, 0)
local b = Pos.new(0, 1, 0, 0)
assert.are.same(a == a, true)
assert.are.same(a < b, true)
end)
it('get the next position', function()
withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
-- line 1: a => s
assert.are.same(Pos.new(nil, 0, 1), Pos.new(nil, 0, 0):next())
-- line 1: d => f
assert.are.same(Pos.new(nil, 0, 3), Pos.new(nil, 0, 2):next())
-- line 1 => 2
assert.are.same(Pos.new(nil, 1, 0), Pos.new(nil, 0, 3):next())
-- line 3 => 4
assert.are.same(Pos.new(nil, 3, 0), Pos.new(nil, 2, 0):next())
-- line 4 => 5
assert.are.same(Pos.new(nil, 4, 0), Pos.new(nil, 3, 0):next())
-- end returns nil
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, 0, 0), Pos.new(nil, 0, 1):next(-1))
-- line 1: f => d
assert.are.same(Pos.new(nil, 0, 2), Pos.new(nil, 0, 3):next(-1))
-- line 2 => 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, 2, 0), Pos.new(nil, 3, 0):next(-1))
-- line 5 => 4
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, 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, 0, 19), Pos.new(nil, 0, 5):find_match())
-- outer parens are matched (backward):
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, 0, 0):find_match())
-- watchdog expires before an otherwise valid match is found:
assert.are.same(nil, Pos.new(nil, 0, 5):find_match(2))
end)
end)
end)

View File

@@ -1,560 +0,0 @@
local Range = require 'u.range'
local Pos = require 'u.pos'
local withbuf = loadfile './spec/withbuf.lua'()
describe('Range', function()
it('get text in buffer', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_buf_text()
local lines = range:lines()
assert.are.same({
'line one',
'and line two',
}, lines)
local text = range:text()
assert.are.same('line one\nand line two', text)
end)
withbuf({}, function()
local range = Range.from_buf_text()
local lines = range:lines()
assert.are.same({ '' }, lines)
local text = range:text()
assert.are.same('', text)
end)
end)
it('get from positions: v in single line', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 0, 3), 'v')
local lines = range:lines()
assert.are.same({ 'ine' }, lines)
local text = range:text()
assert.are.same('ine', text)
end)
end)
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, 1, 4), Pos.new(nil, 2, 4), 'v')
local lines = range:lines()
assert.are.same({ 'quick brown fox', 'jumps' }, lines)
end)
end)
it('get from positions: V', function()
withbuf({ 'line one', 'and line two' }, function()
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)
local text = range:text()
assert.are.same('line one', text)
end)
end)
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, 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)
end)
it('get from line', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_line(nil, 0)
local lines = range:lines()
assert.are.same({ 'line one' }, lines)
local text = range:text()
assert.are.same('line one', text)
end)
end)
it('get from lines', function()
withbuf({ 'line one', 'and line two', 'and line 3' }, function()
local range = Range.from_lines(nil, 0, 1)
local lines = range:lines()
assert.are.same({ 'line one', 'and line two' }, lines)
local text = range:text()
assert.are.same('line one\nand line two', text)
end)
end)
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, 1, 4), Pos.new(nil, 1, 8), 'v')
range:replace 'quack'
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, 1, 4), Pos.new(nil, 1, 9), 'v')
range:replace ''
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, 1, 4), Pos.new(nil, 1, 9), 'v')
range:replace(nil)
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, 1, 4), Pos.new(nil, 2, 4), 'v')
range:replace 'plane flew'
local lines = Range.from_buf_text():lines()
assert.are.same({
'pre line',
'the plane flew over a lazy dog',
'post line',
}, lines)
end)
end)
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, 1)
range:replace 'the rabbit'
local lines = Range.from_buf_text():lines()
assert.are.same({
'pre line',
'the rabbit',
'jumps over a lazy dog',
'post line',
}, lines)
end)
end)
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, 1, 2)
range:replace 'the rabbit'
local lines = Range.from_buf_text():lines()
assert.are.same({
'pre line',
'the rabbit',
'post line',
}, lines)
end)
end)
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, 1)
range:replace(nil) -- delete lines
local lines = Range.from_buf_text():lines()
assert.are.same({
'pre line',
'jumps over a lazy dog',
'post line',
}, lines)
end)
end)
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, 1, 2)
range:replace(nil) -- delete lines
local lines = Range.from_buf_text():lines()
assert.are.same({
'pre line',
'post line',
}, lines)
end)
end)
it('text object: word', function()
withbuf({ 'the quick brown fox' }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
assert.are.same('quick ', Range.from_text_object('aw'):text())
vim.fn.setpos('.', { 0, 1, 5, 0 })
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_text_object('a"'):text())
vim.fn.setpos('.', { 0, 1, 6, 0 })
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_text_object([[a']]):text())
vim.fn.setpos('.', { 0, 1, 6, 0 })
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_text_object([[a`]]):text())
vim.fn.setpos('.', { 0, 1, 6, 0 })
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_text_object('a{'):text())
vim.fn.setpos('.', { 0, 2, 1, 0 })
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_text_object('a{'):text())
assert.are.same(vim.api.nvim_win_get_cursor(0), { 1, 12 })
end)
end)
it('should get nearest block', function()
withbuf({
'this is a {',
'block',
'} here',
}, function()
vim.fn.setpos('.', { 0, 2, 1, 0 })
assert.are.same('{\nblock\n}', Range.find_nearest_brackets():text())
end)
withbuf({
'this is a {',
'(block)',
'} here',
}, function()
vim.fn.setpos('.', { 0, 2, 3, 0 })
assert.are.same('(block)', Range.find_nearest_brackets():text())
end)
end)
it('line0', function()
withbuf({
'this is a {',
'block',
'} here',
}, function()
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)
it('from_marks', function()
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'["
b:save_to_pos "']"
local range = Range.from_marks("'[", "']")
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v')
end)
end)
it('from_vtext', function()
withbuf({ 'line one', 'and line two' }, function()
vim.fn.setpos('.', { 0, 1, 3, 0 }) -- cursor at position (1, 3)
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, 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, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'["
b:save_to_pos "']"
local range = Range.from_op_func 'char'
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v')
range = Range.from_op_func 'line'
assert.are.same(range.start, a)
assert.are.same(range.stop, Pos.new(nil, 1, Pos.MAX_COL))
assert.are.same(range.mode, 'V')
end)
end)
it('from_cmd_args', function()
local args = { range = 1 }
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'<"
b:save_to_pos "'>"
local range = Range.from_cmd_args(args)
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v')
end)
end)
it('find_nearest_quotes', 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, 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, 0, 4))
assert.are.same(range.stop, Pos.new(nil, 0, 10))
end)
end)
it('smallest', function()
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, 0, 1))
assert.are.same(smallest.stop, Pos.new(nil, 0, 3))
end)
it('clone', function()
withbuf({ 'line one', 'and line two' }, function()
local original = Range.from_lines(nil, 0, 1)
local cloned = original:clone()
assert.are.same(original.start, cloned.start)
assert.are.same(original.stop, cloned.stop)
assert.are.same(original.mode, cloned.mode)
end)
end)
it('line_count', function()
withbuf({ 'line one', 'and line two', 'line three' }, function()
local range = Range.from_lines(nil, 0, 2)
assert.are.same(range:line_count(), 3)
end)
end)
it('to_linewise()', function()
withbuf({ 'line one', 'and line two' }, function()
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, 0)
assert.are.same(linewise_range.stop.col, Pos.MAX_COL)
assert.are.same(linewise_range.mode, 'V')
end)
end)
it('is_empty', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 0, 0), nil, 'v')
assert.is_true(range:is_empty())
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, 0, 0), Pos.new(nil, 0, 9), 'v')
local trimmed = range:trim_start()
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, 0, 0), Pos.new(nil, 0, 9), 'v')
local trimmed = range:trim_stop()
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, 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, 0, 4) -- outside of range
assert.is_false(range:contains(pos))
end)
end)
it('shrink', function()
withbuf({ 'line one', 'and line two' }, function()
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, 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, 0, 1), Pos.new(nil, 1, 3), 'v')
local shrunk = range:must_shrink(1)
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')
end)
end)
it('set_visual_selection', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_lines(nil, 0, 1)
range:set_visual_selection()
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, 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, 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)
end)
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, 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))
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))
end)
end)
it('replace updates Range.stop: multi-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, 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))
r:replace 'blehGoo2'
assert.are.same({ 'The quick brown fox blehGoo2 the lazy dog' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
end)
end)
it('replace updates Range.stop: multi-line (blockwise)', 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, 0), Pos.new(b, 3, Pos.MAX_COL), 'V')
assert.are.same({ 'fox', 'jumps', 'over' }, r:lines())
r:replace { 'bleh1', 'bleh2' }
assert.are.same({
'The quick brown',
'bleh1',
'bleh2',
'the lazy dog',
}, vim.api.nvim_buf_get_lines(b, 0, -1, false))
r:replace 'blehGoo2'
assert.are.same({
'The quick brown',
'blehGoo2',
'the lazy dog',
}, vim.api.nvim_buf_get_lines(b, 0, -1, false))
end)
end)
it('replace after delete', 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, 0), Pos.new(b, 3, Pos.MAX_COL), 'V')
assert.are.same({ 'fox', 'jumps', 'over' }, r:lines())
r:replace(nil)
assert.are.same({
'The quick brown',
'the lazy dog',
}, vim.api.nvim_buf_get_lines(b, 0, -1, false))
r:replace { 'blehGoo2', '' }
assert.are.same({
'The quick brown',
'blehGoo2',
'the lazy dog',
}, vim.api.nvim_buf_get_lines(b, 0, -1, false))
end)
end)
end)

View File

@@ -1,206 +0,0 @@
local tracker = require 'u.tracker'
local Signal = tracker.Signal
local ExecutionContext = tracker.ExecutionContext
describe('Signal', function()
local signal
before_each(function() signal = Signal:new(0, 'testSignal') end)
it('should initialize with correct parameters', function()
assert.is.equal(signal.value, 0)
assert.is.equal(signal.name, 'testSignal')
assert.is.not_nil(signal.subscribers)
assert.is.equal(#signal.subscribers, 0)
assert.is.equal(signal.changing, false)
end)
it('should set new value and notify subscribers', function()
local called = false
signal:subscribe(function(value)
called = true
assert.is.equal(value, 42)
end)
signal:set(42)
assert.is.equal(called, true)
end)
it('should not notify subscribers during circular dependency', function()
signal.changing = true
local notified = false
signal:subscribe(function() notified = true end)
signal:set(42)
assert.is.equal(notified, false) -- No notification should occur
end)
it('should get current value', function()
signal:set(100)
assert.is.equal(signal:get(), 100)
end)
it('should update value with function', function()
signal:set(10)
signal:update(function(value) return value * 2 end)
assert.is.equal(signal:get(), 20)
end)
it('should dispose subscribers', function()
local called = false
local unsubscribe = signal:subscribe(function() called = true end)
unsubscribe()
signal:set(10)
assert.is.equal(called, false) -- Should not be notified
end)
describe('Signal:map', function()
it('should transform the signal value', function()
local signal = Signal:new(5)
local mapped_signal = signal:map(function(value) return value * 2 end)
assert.is.equal(mapped_signal:get(), 10) -- Initial transformation
signal:set(10)
assert.is.equal(mapped_signal:get(), 20) -- Updated transformation
end)
it('should handle empty transformations', function()
local signal = Signal:new(nil)
local mapped_signal = signal:map(function(value) return value or 'default' end)
assert.is.equal(mapped_signal:get(), 'default') -- Return default
signal:set 'new value'
assert.is.equal(mapped_signal:get(), 'new value') -- Return new value
end)
end)
describe('Signal:filter', function()
it('should only emit values that pass the filter', function()
local signal = Signal:new(5)
local filtered_signal = signal:filter(function(value) return value > 10 end)
assert.is.equal(filtered_signal:get(), nil) -- Initial value should not pass
signal:set(15)
assert.is.equal(filtered_signal:get(), 15) -- Now filtered
signal:set(8)
assert.is.equal(filtered_signal:get(), 15) -- Does not pass the filter
end)
it('should handle empty initial values', function()
local signal = Signal:new(nil)
local filtered_signal = signal:filter(function(value) return value ~= nil end)
assert.is.equal(filtered_signal:get(), nil) -- Should be nil
signal:set(10)
assert.is.equal(filtered_signal:get(), 10) -- Should pass now
end)
end)
describe('create_memo', function()
it('should compute a derived value and update when dependencies change', function()
local signal = Signal:new(2)
local memoized_signal = tracker.create_memo(function() return signal:get() * 2 end)
assert.is.equal(memoized_signal:get(), 4) -- Initially compute 2 * 2
signal:set(3)
assert.is.equal(memoized_signal:get(), 6) -- Update to 3 * 2 = 6
signal:set(5)
assert.is.equal(memoized_signal:get(), 10) -- Update to 5 * 2 = 10
end)
it('should not recompute if the dependencies do not change', function()
local call_count = 0
local signal = Signal:new(10)
local memoized_signal = tracker.create_memo(function()
call_count = call_count + 1
return signal:get() + 1
end)
assert.is.equal(memoized_signal:get(), 11) -- Compute first value
assert.is.equal(call_count, 1) -- Should compute once
memoized_signal:get() -- Call again, should use memoized value
assert.is.equal(call_count, 1) -- Still should only be one call
signal:set(10) -- Set the same value
assert.is.equal(memoized_signal:get(), 11)
assert.is.equal(call_count, 2)
signal:set(20)
assert.is.equal(memoized_signal:get(), 21)
assert.is.equal(call_count, 3)
end)
end)
describe('create_effect', function()
it('should track changes and execute callback', function()
local signal = Signal:new(5)
local call_count = 0
tracker.create_effect(function()
signal:get() -- track as a dependency
call_count = call_count + 1
end)
assert.is.equal(call_count, 1)
signal:set(10)
assert.is.equal(call_count, 2)
end)
it('should clean up signals and not call after dispose', function()
local signal = Signal:new(5)
local call_count = 0
local unsubscribe = tracker.create_effect(function()
call_count = call_count + 1
return signal:get() * 2
end)
assert.is.equal(call_count, 1) -- Initially calls
unsubscribe() -- Unsubscribe the effect
signal:set(10) -- Update signal value
assert.is.equal(call_count, 1) -- Callback should not be called again
end)
end)
end)
describe('ExecutionContext', function()
local context
before_each(function() context = ExecutionContext:new() end)
it('should initialize a new context', function()
assert.is.table(context.signals)
assert.is.table(context.subscribers)
end)
it('should track signals', function()
local signal = Signal:new(0)
context:track(signal)
assert.is.equal(next(context.signals), signal) -- Check if signal is tracked
end)
it('should subscribe to signals', function()
local signal = Signal:new(0)
local callback_called = false
context:track(signal)
context:subscribe(function() callback_called = true end)
signal:set(100)
assert.is.equal(callback_called, true) -- Callback should be called
end)
it('should dispose tracked signals', function()
local signal = Signal:new(0)
context:track(signal)
context:dispose()
assert.is.falsy(next(context.signals)) -- Should not have any tracked signals
end)
end)

1694
spec/u_spec.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
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,10 +0,0 @@
local function withbuf(lines, f)
vim.go.swapfile = false
vim.cmd.new()
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
local ok, result = pcall(f)
vim.cmd.bdelete { bang = true }
if not ok then error(result) end
end
return withbuf

View File

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

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

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

View File

@@ -1,24 +0,0 @@
package = "u.nvim"
version = "0.2.0-1"
source = {
url = "git+https://github.com/jrop/u.nvim"
}
description = {
summary = "nvim a powerful Lua library designed to enhance your text manipulation experience in NeoVim, focusing primarily on a context-aware \"Range\" utility.",
detailed = "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.",
homepage = "https://github.com/jrop/u.nvim",
license = "MIT"
}
build = {
type = "builtin",
modules = {
["u.buffer"] = "lua/u/buffer.lua",
["u.codewriter"] = "lua/u/codewriter.lua",
["u.opkeymap"] = "lua/u/opkeymap.lua",
["u.pos"] = "lua/u/pos.lua",
["u.range"] = "lua/u/range.lua",
["u.repeat"] = "lua/u/repeat.lua",
["u.state"] = "lua/u/state.lua",
["u.utils"] = "lua/u/utils.lua"
}
}

36
vim.yml
View File

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