diff --git a/.busted b/.busted
index 8c64018..655edd3 100644
--- a/.busted
+++ b/.busted
@@ -1,8 +1,7 @@
return {
_all = {
- coverage = false,
lpath = "lua/?.lua;lua/?/init.lua",
- lua = "nlua",
+ lua = "nvim -u NONE -i NONE -l",
},
default = {
verbose = true
diff --git a/.emmyrc.json b/.emmyrc.json
new file mode 100644
index 0000000..4825843
--- /dev/null
+++ b/.emmyrc.json
@@ -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"
+ ]
+ }
+}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index c8e6c01..a74cf7f 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -1,29 +1,60 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
-name: NeoVim tests
-on: [push]
+name: ci
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ tags: ["*"]
+ branches: ["*"]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ MISE_EXPERIMENTAL: true
+
jobs:
- code-quality:
+ ci:
runs-on: ubuntu-latest
- env:
- XDG_CONFIG_HOME: ${{ github.workspace }}/.config/
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- - uses: cachix/install-nix-action@v31
with:
- nix_path: nixpkgs=channel:nixos-unstable
+ submodules: true
- - name: Populate Nix store
- run:
- nix-shell --run 'true'
+ - name: Install apt dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libreadline-dev
- - name: Type-check with lua-language-server
- run:
- nix-shell --run 'make lint'
+ - 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: Check formatting with stylua
- run:
- nix-shell --run 'make fmt-check'
+ - 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: Run busted tests
- run:
- nix-shell --run 'make test'
+ - 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
diff --git a/.gitignore b/.gitignore
index 202f9f8..6eac4e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.prefix/
*.src.rock
*.aider*
luacov.*.out
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..072ad6f
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,12 @@
+[submodule "library/busted"]
+ path = library/busted
+ url = https://github.com/LuaCATS/busted
+ branch = main
+[submodule "library/luv"]
+ path = library/luv
+ url = https://github.com/LuaCATS/luv
+ branch = main
+[submodule "nvimv"]
+ path = nvimv
+ url = https://github.com/jrop/nvimv
+ branch = main
diff --git a/.luacheckrc b/.luacheckrc
deleted file mode 100644
index 8317fde..0000000
--- a/.luacheckrc
+++ /dev/null
@@ -1,2 +0,0 @@
--- :vim set ft=lua
-globals = { "vim" }
diff --git a/.luacov b/.luacov
index 7c09929..1f5c147 100644
--- a/.luacov
+++ b/.luacov
@@ -1,6 +1,6 @@
return {
include = {
- 'lua/u/',
+ 'lua/u$',
},
tick = true,
}
diff --git a/.styluaignore b/.styluaignore
new file mode 100644
index 0000000..69fb129
--- /dev/null
+++ b/.styluaignore
@@ -0,0 +1 @@
+library/
diff --git a/.woodpecker/ci.yaml b/.woodpecker/ci.yaml
new file mode 100644
index 0000000..8374cb6
--- /dev/null
+++ b/.woodpecker/ci.yaml
@@ -0,0 +1,10 @@
+when:
+ - event: push
+
+steps:
+ - name: build
+ image: nixos/nix
+ commands:
+ - nix-shell --pure --run 'make lint'
+ - nix-shell --pure --run 'make fmt-check'
+ - nix-shell --pure --run 'make test'
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 0c4a074..0000000
--- a/Makefile
+++ /dev/null
@@ -1,21 +0,0 @@
-all: lint fmt-check test
-
-lint:
- @echo "## Typechecking"
- @lua-language-server --check=lua/u/ --checklevel=Error
-
-fmt-check:
- @echo "## Checking code format"
- @stylua --check .
-
-fmt:
- @echo "## Formatting code"
- @stylua .
-
-test:
- @rm -f luacov.*.out
- @echo "## Running tests"
- @busted --coverage --verbose
- @echo "## Generating coverage report"
- @luacov
- @awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out
diff --git a/README.md b/README.md
index 4fdda1b..6f8822b 100644
--- a/README.md
+++ b/README.md
@@ -1,235 +1,92 @@
# u.nvim
-Welcome to **u.nvim** - a powerful Lua library designed to enhance your text
-manipulation experience in NeoVim, focusing on text-manipulation utilities.
-This includes a `Range` utility, allowing you to work efficiently with text
-selections based on various conditions, as well as a declarative `Render`-er,
-making coding and editing more intuitive and productive.
+Welcome to **u.nvim** - a 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
-- **Rendering System**: a utility that can declaratively render NeoVim-specific
- hyperscript into a buffer, supporting creating/managing extmarks, highlights,
- and key-event handling (requires NeoVim >0.11)
-- **Signals**: a simple dependency tracking system that pairs well with the
- rendering utilities for creating reactive/interactive UIs in NeoVim.
-- **Range Utility**: Get context-aware selections with ease. Replace regions
- with new text. Think of it as a programmatic way to work with visual
- selections (or regions of text).
-- **Code Writer**: Write code with automatic indentation and formatting.
-- **Operator Key Mapping**: Flexible key mapping that works with the selected
- text.
-- **Text and Position Utilities**: Convenient functions to manage text objects
- and cursor positions.
+- **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.
+- **Position Utilities**: Work with cursor positions, marks, and extmarks.
+- **Operator Key Mapping**: Flexible key mapping that works with motions.
+- **Text Object Definitions**: Define custom text objects easily.
+- **User Command Helpers**: Create commands with range support.
+- **Repeat Utilities**: Dot-repeat support for custom operations.
### Installation
-This being a library, and not a proper plugin, it is recommended that you
-vendor the specific version of this library that you need, including it in your
-code. Package managers are a developing landscape for Lua in the context of
-NeoVim. Perhaps in the future, `lux` will eliminate the need to vendor this
-library in your application code.
+This being a library, and not a proper plugin, it is recommended that you vendor
+the specific version of this library that you need, including it in your code.
+Package managers are a developing landscape for Lua in the context of NeoVim.
+Perhaps in the future, `lux` will eliminate the need to vendor this library in
+your application code.
-## Signal and Rendering Usage
+#### If you are a Plugin Author
-### Overview
-
-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:
+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.
-Example Code: counter.lua
+Example git submodule setup
+
+```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
-local tracker = require 'u.tracker'
-local Buffer = require 'u.buffer'
-local h = require('u.renderer').h
+local u = require('my_plugin.u')
--- Create an buffer for the UI
-vim.cmd.vnew()
-local ui_buf = Buffer.current()
-ui_buf:set_tmp_options()
+local Pos = u.Pos
+local Range = u.Range
+-- etc.
+```
-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
--- 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',
+#### If you are a User
- { 'Counter: ', tostring(count), '\n' },
+If you want to use u.nvim in your config directly:
- '\n',
+
+vim.pack
- {
- h('text', {
- hl = 'DiffDelete',
- nmap = {
- [''] = 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 = {
- [''] = 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 on each "button" above to increment/decrement the counter.' },
- }
-end)
+```lua
+vim.pack.add { 'https://github.com/jrop/u.nvim' }
```
-### `u.tracker`
-
-The `u.tracker` module provides a simple API for creating reactive variables.
-These can be composed in Effects and Memos utilizing Execution Contexts that
-track what signals are used by effects/memos.
+
+lazy.nvim
```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) `` 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, ",
- {
- "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',
-
- -- tags are specified like so:
- -- h('text', attributes, children)
- h('text', {}, "I am a text node."),
-
- -- tags can be highlighted:
- h('text', { hl = 'Comment' }, "I am highlighted."),
-
- -- tags can respond to key events:
- h('text', {
- hl = 'Keyword',
- nmap = {
- [""] = function()
- print("Hello World")
- -- Return '' to swallow the event:
- return ''
- end,
- },
- }, "I am a text node."),
+ 'jrop/u.nvim',
+ lazy = true,
}
```
-Managing complex tables of hyperscript can be done more ergonomically using the
-`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
@@ -265,17 +122,17 @@ interop conversions when calling `:api` functions.
### 1. Creating a Range
-The `Range` utility is the main feature upon which most other things in this
-library are built, aside from a few standalone utilities. Ranges can be
-constructed manually, or preferably, obtained based on a variety of contexts.
+The `Range` utility is the main feature of this library. Ranges can be constructed
+manually, or preferably, obtained based on a variety of contexts.
```lua
-local Range = require 'u.range'
-local start = Pos.new(0, 1, 1) -- Line 1, first column
-local stop = Pos.new(0, 3, 1) -- Line 3, first column
+local u = require 'u'
-Range.new(start, stop, 'v') -- charwise selection
-Range.new(start, stop, 'V') -- linewise selection
+local start = u.Pos.new(nil, 1, 1) -- Line 1, first column
+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
@@ -283,21 +140,23 @@ to get the corresponding context of an edit operation and just "get me the
current Range that represents this context".
```lua
+local u = require 'u'
+
-- get the first line in a buffer:
-Range.from_line(bufnr, 1)
+u.Range.from_line(bufnr, 1)
-- Text Objects (any text object valid in your configuration is supported):
-- get the word the cursor is on:
-Range.from_motion('iw')
+u.Range.from_motion('iw')
-- get the WORD the cursor is on:
-Range.from_motion('iW')
+u.Range.from_motion('iW')
-- get the "..." the cursor is within:
-Range.from_motion('a"')
+u.Range.from_motion('a"')
-- Get the currently visually selected text:
-- NOTE: this does NOT work within certain contexts; more specialized utilities
-- are more appropriate in certain circumstances
-Range.from_vtext()
+u.Range.from_vtext()
--
-- Get the operated on text obtained from a motion:
@@ -305,7 +164,7 @@ Range.from_vtext()
--
--- @param ty 'char'|'line'|'block'
function MyOpFunc(ty)
- local range = Range.from_op_func(ty)
+ local range = u.Range.from_op_func(ty)
-- do something with the range
end
-- Try invoking this with: `toaw`, and the current word will be the
@@ -321,7 +180,7 @@ end, { expr = true })
-- When executing commands in a visual context, getting the selected text has
-- to be done differently:
vim.api.nvim_create_user_command('MyCmd', function(args)
- local range = Range.from_cmd_args(args)
+ local range = u.Range.from_cmd_args(args)
if range == nil then
-- the command was executed in normal mode
else
@@ -336,7 +195,7 @@ range once you have one? Plenty, it turns out!
```lua
local range = ...
range:lines() -- get the lines in the range's region
-range:text() -- get the text (i.e., string) in the range's region
+range:text() -- get the text (i.e., string) in the range's region
range:line(1) -- get the first line within this range
range:line(-1) -- get the last line within this range
-- replace with new contents:
@@ -354,68 +213,125 @@ range:replace(nil)
Define custom (dot-repeatable) key mappings for text objects:
```lua
-local opkeymap = require 'u.opkeymap'
+local u = require 'u'
-- invoke this function by typing, for example, `riw`:
-- `range` will contain the bounds of the motion `iw`.
-opkeymap('n', 'r', function(range)
+u.opkeymap('n', 'r', function(range)
print(range:text()) -- Prints the text within the selected range
end)
```
-### 3. Working with Code Writer
-
-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
+### 3. Utility Functions
#### Custom Text Objects
-Simply by returning a `Range` or a `Pos`, you can easily and quickly define
-your own text objects:
+Simply by returning a `Range`, you can easily define your own text objects:
```lua
-local txtobj = require 'u.txtobj'
-local Range = require 'u.range'
+local u = require 'u'
-- Select whole file:
-txtobj.define('ag', function()
- return Range.from_buf_text()
+u.define_txtobj('ag', function()
+ 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)
```
-#### Buffer Management
+#### User Commands with Range Support
-Access and manipulate buffers easily:
+Create user commands that work with visual selections:
```lua
-local Buffer = require 'u.buffer'
-local buf = Buffer.current()
-buf.b. -- get buffer-local variables
-buf.b. = ... -- set buffer-local variables
-buf.bo. -- get buffer options
-buf.bo. = ... -- set buffer options
-buf:line_count() -- the number of lines in the current buffer
-buf:all() -- returns a Range representing the entire buffer
-buf:is_empty() -- returns true if the buffer has no text
-buf:append_line '...'
-buf:line(1) -- returns a Range representing the first line in the buffer
-buf:line(-1) -- returns a Range representing the last line in the buffer
-buf:lines(1, 2) -- returns a Range representing the first two lines in the buffer
-buf:lines(2, -2) -- returns a Range representing all but the first and last lines of a buffer
-buf:txtobj('iw') -- returns a Range representing the text object 'iw' in the give buffer
+local u = require 'u'
+
+u.ucmd('MyCmd', function(args)
+ if args.info then
+ -- args.info is a Range representing the selection
+ print('Selected text:', args.info:text())
+ else
+ -- No range provided
+ print('No selection')
+ end
+end, { range = true })
```
+#### 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)
Copyright (c) 2024 jrapodaca@gmail.com
diff --git a/examples/counter.lua b/examples/counter.lua
deleted file mode 100644
index fa303c7..0000000
--- a/examples/counter.lua
+++ /dev/null
@@ -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 = {
- [''] = 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 = {
- [''] = 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 on each "button" above to increment/decrement the counter.' },
- }
-end)
diff --git a/examples/filetree.lua b/examples/filetree.lua
deleted file mode 100644
index d3b3998..0000000
--- a/examples/filetree.lua
+++ /dev/null
@@ -1,468 +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) return vim.fs.abspath(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 }
-function H.get_tree_inf(root_path)
- logger:info { 'get_tree_inf', root_path }
- --- @type table
- 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
-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)
-
- --
- -- :help watch-file
- --
- local watcher = vim.uv.new_fs_event()
- if watcher ~= nil then
- --- @diagnostic disable-next-line: unused-local
- watcher:start(opts.root_path, { recursive = true }, function(_err, fname, _status)
- fname = H.normalize(fname)
-
- local dir_path = vim.fs.dirname(fname)
- local dir = s_tree_inf:get().path_to_node[dir_path]
- if not dir then return end
-
- s_tree_inf:schedule_update(function(tree_inf)
- H.populate_dir_children(dir, tree_inf.path_to_node)
- return tree_inf
- end)
- end)
- end
- vim.api.nvim_create_autocmd('WinClosed', {
- once = true,
- pattern = tostring(winnr),
- callback = function()
- if watcher == nil then return end
-
- watcher:stop()
- watcher = nil
- end,
- })
-
- local controller = {}
-
- --- @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 }
- 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
- 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('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[0][0].number = false
- vim.wo[0][0].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
diff --git a/examples/matcher.lua b/examples/matcher.lua
new file mode 100644
index 0000000..5dd65a0
--- /dev/null
+++ b/examples/matcher.lua
@@ -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
diff --git a/examples/notify.lua b/examples/notify.lua
deleted file mode 100644
index c74461b..0000000
--- a/examples/notify.lua
+++ /dev/null
@@ -1,184 +0,0 @@
-local Renderer = require('u.renderer').Renderer
-local TreeBuilder = require('u.renderer').TreeBuilder
-local tracker = require 'u.tracker'
-local utils = require 'u.utils'
-
-local TIMEOUT = 4000
-local ICONS = {
- [vim.log.levels.TRACE] = { text = '', group = 'DiagnosticSignOk' },
- [vim.log.levels.DEBUG] = { text = '', group = 'DiagnosticSignOk' },
- [vim.log.levels.INFO] = { text = '', group = 'DiagnosticSignInfo' },
- [vim.log.levels.WARN] = { text = '', group = 'DiagnosticSignWarn' },
- [vim.log.levels.ERROR] = { text = '', group = 'DiagnosticSignError' },
-}
-local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' }
-
-local S_EDITOR_DIMENSIONS =
- tracker.create_signal(utils.get_editor_dimensions(), 's:editor_dimensions')
-vim.api.nvim_create_autocmd('VimResized', {
- callback = function()
- local new_dim = utils.get_editor_dimensions()
- S_EDITOR_DIMENSIONS:set(new_dim)
- end,
-})
-
---- @alias u.example.Notification {
---- kind: number;
---- id: number;
---- text: string;
---- timer: uv.uv_timer_t;
---- }
-
-local M = {}
-
---- @type { win: integer, buf: integer, renderer: u.Renderer } | nil
-local notifs_w
-
-local s_notifications_raw = tracker.create_signal {}
-local s_notifications = s_notifications_raw:debounce(50)
-
--- Render effect:
-tracker.create_effect(function()
- --- @type u.example.Notification[]
- local notifs = s_notifications:get()
- --- @type { width: integer, height: integer }
- local editor_size = S_EDITOR_DIMENSIONS:get()
-
- if #notifs == 0 then
- if notifs_w then
- if vim.api.nvim_win_is_valid(notifs_w.win) then vim.api.nvim_win_close(notifs_w.win, true) end
- notifs_w = nil
- end
- return
- end
-
- local avail_width = editor_size.width
- local float_width = 40
- local float_height = math.min(#notifs, editor_size.height - 3)
- local win_config = {
- relative = 'editor',
- anchor = 'NE',
- row = 0,
- col = avail_width,
- width = float_width,
- height = float_height,
- border = 'single',
- focusable = false,
- zindex = 900,
- }
- vim.schedule(function()
- if not notifs_w or not vim.api.nvim_win_is_valid(notifs_w.win) then
- local b = vim.api.nvim_create_buf(false, true)
- local w = vim.api.nvim_open_win(b, false, win_config)
- vim.wo[w].cursorline = false
- vim.wo[w].list = false
- vim.wo[w].listchars = ''
- vim.wo[w].number = false
- vim.wo[w].relativenumber = false
- vim.wo[w].wrap = false
- notifs_w = { win = w, buf = b, renderer = Renderer.new(b) }
- else
- vim.api.nvim_win_set_config(notifs_w.win, win_config)
- end
-
- notifs_w.renderer:render(TreeBuilder.new()
- :nest(function(tb)
- for idx, notif in ipairs(notifs) do
- if idx > 1 then tb:put '\n' end
-
- local notif_icon = ICONS[notif.kind] or DEFAULT_ICON
- tb:put_h('text', { hl = notif_icon.group }, notif_icon.text)
- tb:put { ' ', notif.text }
- end
- end)
- :tree())
- vim.api.nvim_win_call(notifs_w.win, function()
- vim.fn.winrestview {
- -- scroll all the way left:
- leftcol = 0,
- -- set the bottom line to be at the bottom of the window:
- topline = vim.api.nvim_buf_line_count(notifs_w.buf) - win_config.height + 1,
- }
- end)
- end)
-end)
-
---- @param id number
-local function _delete_notif(id)
- --- @param notifs u.example.Notification[]
- s_notifications_raw:schedule_update(function(notifs)
- for i, notif in ipairs(notifs) do
- if notif.id == id then
- notif.timer:stop()
- notif.timer:close()
- table.remove(notifs, i)
- break
- end
- end
- return notifs
- end)
-end
-
-local _orig_notify
-
---- @param msg string
---- @param level integer|nil
---- @param opts? { id: number }
-function M.notify(msg, level, opts)
- if level == nil then level = vim.log.levels.INFO end
-
- opts = opts or {}
- local id = opts.id or math.random(999999999)
-
- --- @type u.example.Notification?
- local notif = vim.iter(s_notifications_raw:get()):find(function(n) return n.id == id end)
- if not notif then
- -- Create a new notification (maybe):
- if vim.trim(msg) == '' then return id end
- if level < vim.log.levels.INFO then return id end
-
- local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create timer')
- timer:start(TIMEOUT, 0, function() _delete_notif(id) end)
- notif = {
- id = id,
- kind = level,
- text = msg,
- timer = timer,
- }
- --- @param notifs u.example.Notification[]
- s_notifications_raw:schedule_update(function(notifs)
- table.insert(notifs, notif)
- return notifs
- end)
- else
- -- Update an existing notification:
- s_notifications_raw:schedule_update(function(notifs)
- -- We already have a copy-by-reference of the notif we want to modify:
- notif.timer:stop()
- notif.text = msg
- notif.kind = level
- notif.timer:start(TIMEOUT, 0, function() _delete_notif(id) end)
-
- return notifs
- end)
- end
-
- return id
-end
-
-local _once_msgs = {}
-function M.notify_once(msg, level, opts)
- if vim.tbl_contains(_once_msgs, msg) then return false end
- table.insert(_once_msgs, msg)
- vim.notify(msg, level, opts)
- return true
-end
-
-function M.setup()
- if _orig_notify == nil then _orig_notify = vim.notify end
-
- vim.notify = M.notify
- vim.notify_once = M.notify_once
-end
-
-return M
diff --git a/examples/picker.lua b/examples/picker.lua
deleted file mode 100644
index a388733..0000000
--- a/examples/picker.lua
+++ /dev/null
@@ -1,949 +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 {
---- 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;
---- }
-
---- @generic T
---- @param opts SelectOpts
-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 = vim.o.winborder or 'rounded',
- }
- local w_input_buf = Buffer.create(false, true)
- local w_input = vim.api.nvim_open_win(w_input_buf.bufnr, false, w_input_cfg)
- vim.wo[w_input][0].cursorline = false
- vim.wo[w_input][0].list = false
- vim.wo[w_input][0].number = false
- vim.wo[w_input][0].relativenumber = false
-
- -- The following option is a signal to other plugins like 'cmp' to not mess
- -- with this buffer:
- vim.bo[w_input_buf.bufnr].buftype = 'prompt'
- vim.fn.prompt_setprompt(w_input_buf.bufnr, '')
-
- vim.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.bufnr, false, w_list_cfg)
- vim.wo[w_list][0].number = false
- vim.wo[w_list][0].relativenumber = false
- vim.wo[w_list][0].scrolloff = 0
- 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()
-
- --- @type string
- local filter_pattern
- --- @type boolean
- local use_plain_pattern
- if #formatted_items > 250 and #filter_text <= 3 then
- filter_pattern = filter_text
- use_plain_pattern = true
- elseif #formatted_items > 1000 then
- filter_pattern = filter_text
- use_plain_pattern = true
- else
- filter_pattern = '('
- .. vim.iter(vim.split(filter_text, '')):map(function(c) return c .. '.*' end):join ''
- .. ')'
- use_plain_pattern = false
- end
- filter_pattern = filter_pattern:lower()
-
- --- @type table
- local formatted_strings = {}
- --- @type table
- 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
- if use_plain_pattern then
- local x, y = formatted_as_string:find(filter_pattern, 1, true)
- if x ~= nil and y ~= nil then matches[inf.orig_idx] = formatted_as_string:sub(x, y) end
- else
- matches[inf.orig_idx] = string.match(formatted_as_string, filter_pattern)
- end
-
- return matches[inf.orig_idx] ~= nil
- 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 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.bufnr, { 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', '', function() H.finish(true) end, { buffer = w_input_buf.bufnr })
-
- vim.keymap.set('i', '', function() H.finish() end, { buffer = w_input_buf.bufnr })
-
- 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',
- '',
- safe_wrap(action_next_line),
- { buffer = w_input_buf.bufnr, desc = 'Picker: next' }
- )
- vim.keymap.set(
- 'i',
- '',
- safe_wrap(action_next_line),
- { buffer = w_input_buf.bufnr, desc = 'Picker: next' }
- )
-
- local function action_prev_line()
- local max_line = #s_filtered_items:get()
- 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',
- '',
- safe_wrap(action_prev_line),
- { buffer = w_input_buf.bufnr, desc = 'Picker: previous' }
- )
- vim.keymap.set(
- 'i',
- '',
- safe_wrap(action_prev_line),
- { buffer = w_input_buf.bufnr, desc = 'Picker: previous' }
- )
-
- vim.keymap.set(
- 'i',
- '',
- 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.bufnr }
- )
-
- for key, fn in pairs(opts.mappings or {}) do
- vim.keymap.set(
- 'i',
- key,
- safe_wrap(function() return fn(controller) end),
- { buffer = w_input_buf.bufnr }
- )
- 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
- --- @diagnostic disable-next-line: duplicate-set-field
- 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('<', '<')
- 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 = {
- [''] = 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,
-
- [''] = 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,
-
- [''] = 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
- )
- pcall(vim.fn.jobstop, job_inf.id)
- 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()
- -- 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 = {
- [''] = 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,
-
- [''] = 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,
-
- [''] = 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,
-
- [''] = 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('<', '<')
- 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)
-end
-
-return M
diff --git a/examples/splitjoin.lua b/examples/splitjoin.lua
index 772f7a7..96181c0 100644
--- a/examples/splitjoin.lua
+++ b/examples/splitjoin.lua
@@ -1,6 +1,10 @@
-local vim_repeat = require 'u.repeat'
-local CodeWriter = require 'u.codewriter'
-local Range = require 'u.range'
+--
+-- Split/Join: toggles between single-line and multi-line forms for bracketed expressions.
+-- 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 = {}
@@ -8,8 +12,27 @@ local M = {}
--- @param left string
--- @param right string
local function split(bracket_range, left, right)
- local code = CodeWriter.from_pos(bracket_range.start)
- code:write_raw(left)
+ local bufnr = bracket_range.start.bufnr
+ 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()
if curr == nil then return end
@@ -27,7 +50,7 @@ local function split(bracket_range, left, right)
if vim.tbl_contains({ ',', ';' }, curr:char()) then
-- accumulate item:
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()
if next_last_start == nil then break end
@@ -45,11 +68,11 @@ local function split(bracket_range, left, right)
local pos_before_right = bracket_range.stop:must_next(-1)
if last_start < pos_before_right then
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
- code:write(right)
- bracket_range:replace(code.lines)
+ write(right)
+ bracket_range:replace(lines)
end
--- @param bracket_range u.Range
diff --git a/examples/surround.lua b/examples/surround.lua
index 358c9d0..330a226 100644
--- a/examples/surround.lua
+++ b/examples/surround.lua
@@ -1,7 +1,14 @@
-local vim_repeat = require 'u.repeat'
-local Range = require 'u.range'
-local Buffer = require 'u.buffer'
-local CodeWriter = require 'u.codewriter'
+--
+-- Surround: add, change, and delete surrounding characters (quotes, brackets, HTML tags).
+--
+-- Mappings:
+-- ys{motion}{char} - add surround (e.g., ysiw" surrounds word with quotes)
+-- 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 = {}
@@ -21,47 +28,37 @@ local surrounds = {
['`'] = { left = '`', right = '`' },
}
---- @type { left: string; right: string } | nil
+--- @type { left: string, right: string } | nil
local CACHED_BOUNDS = nil
---- @return { left: string; right: string }|nil
+--- @return { left: string, right: string }|nil
local function prompt_for_bounds()
- if vim_repeat.is_repeating() then
- -- If we are repeating, we don't want to prompt for bounds, because
- -- we want to reuse the last bounds:
- return CACHED_BOUNDS
- end
+ if vim_repeat.is_repeating() then return CACHED_BOUNDS end
local cn = vim.fn.getchar()
- -- Check for non-printable characters:
if type(cn) ~= 'number' or cn < 32 or cn > 126 then return end
local c = vim.fn.nr2char(cn)
if c == '<' then
- -- Surround with a tag:
vim.keymap.set('c', '>', '>')
local tag = '<' .. vim.fn.input '<'
if tag == '<' then return end
vim.keymap.del('c', '>')
local endtag = '' .. tag:sub(2):match '[^ >]*' .. '>'
- -- selene: allow(global_usage)
CACHED_BOUNDS = { left = tag, right = endtag }
return CACHED_BOUNDS
else
- -- Default surround:
- CACHED_BOUNDS = (surrounds)[c] or { left = c, right = c }
+ CACHED_BOUNDS = surrounds[c] or { left = c, right = c }
return CACHED_BOUNDS
end
end
--- @param range u.Range
---- @param bounds { left: string; right: string }
+--- @param bounds { left: string, right: string }
local function do_surround(range, bounds)
local left = bounds.left
local right = bounds.right
if range.mode == 'V' then
- -- If we are surrounding multiple lines, we don't care about
- -- space-padding:
left = vim.trim(left)
right = vim.trim(right)
end
@@ -69,29 +66,35 @@ local function do_surround(range, bounds)
if range.mode == 'v' then
range:replace(left .. range:text() .. right)
elseif range.mode == 'V' then
- local buf = Buffer.current()
- local cw = CodeWriter.from_line(range.start:line(), buf.bufnr)
+ local bufnr = vim.api.nvim_get_current_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:
- cw:write(left)
+ 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 curr_ident_prefix = cw.indent_str:rep(cw.indent_level)
- cw:indent(function(cw2)
- for _, line in ipairs(range:lines()) do
- -- trim the current indent prefix from the line:
- if line:sub(1, #curr_ident_prefix) == curr_ident_prefix then
- --
- line = line:sub(#curr_ident_prefix + 1)
- end
+ local lines = {}
+ local function write(line, indent_offset)
+ table.insert(lines, indent_str:rep(base_indent + (indent_offset or 0)) .. line)
+ end
- cw2:write(line)
- end
- end)
+ write(left)
+ local indent_prefix = indent_str:rep(base_indent)
+ for _, line in ipairs(range:lines()) do
+ if line:sub(1, #indent_prefix) == indent_prefix then line = line:sub(#indent_prefix + 1) end
+ write(line, 1)
+ end
+ write(right)
- -- write the right bound at the current indent level:
- cw:write(right)
-
- range:replace(cw.lines)
+ range:replace(lines)
end
range.start:save_to_pos '.'
@@ -194,7 +197,7 @@ function M.setup()
local txt_obj = vim_repeat.is_repeating() and CACHED_DELETE_FROM or vim.fn.getcharstr()
CACHED_DELETE_FROM = txt_obj
- local buf = Buffer.current()
+ local bufnr = vim.api.nvim_get_current_buf()
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
@@ -216,11 +219,11 @@ function M.setup()
-- `arange` as the final resulting range that holds the modified text
-- delete last line, if it is empty:
- local last = buf:line(arange.stop.lnum)
+ local last = Range.from_line(bufnr, arange.stop.lnum)
if last:text():match '^%s*$' then last:replace(nil) end
-- delete first line, if it is empty:
- local first = buf:line(arange.start.lnum)
+ local first = Range.from_line(bufnr, arange.start.lnum)
if first:text():match '^%s*$' then first:replace(nil) end
else
-- trim start:
diff --git a/examples/text-objects.lua b/examples/text-objects.lua
index af10caa..e644de0 100644
--- a/examples/text-objects.lua
+++ b/examples/text-objects.lua
@@ -1,20 +1,28 @@
-local txtobj = require 'u.txtobj'
-local Pos = require 'u.pos'
-local Range = require 'u.range'
-local Buffer = require 'u.buffer'
+--
+-- Custom text objects:
+-- ag - whole file
+-- 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 = {}
function M.setup()
-- Select whole file:
- txtobj.define('ag', function() return Buffer.current():all() end)
+ u.define_txtobj('ag', function() return Range.from_buf_text(0) end)
-- Select current line:
- txtobj.define('a.', function() return Buffer.current():line(Pos.from_pos('.').lnum) end)
+ u.define_txtobj('a.', function() return Range.from_line(0, Pos.from_pos('.').lnum) end)
-- Select the nearest quote:
- txtobj.define('aq', function() return Range.find_nearest_quotes() end)
- txtobj.define('iq', function()
+ u.define_txtobj('aq', function() return Range.find_nearest_quotes() end)
+ u.define_txtobj('iq', function()
local range = Range.find_nearest_quotes()
if range == nil then return end
return range:shrink(1)
@@ -25,8 +33,8 @@ function M.setup()
local function define_quote_obj(q)
local function select_around() return Range.from_motion('a' .. q) end
- txtobj.define('a' .. q, function() return select_around() end)
- txtobj.define('i' .. q, function()
+ u.define_txtobj('a' .. q, function() return select_around() end)
+ u.define_txtobj('i' .. q, function()
local range = select_around()
if range == nil or range:is_empty() then return range end
@@ -51,8 +59,8 @@ function M.setup()
return Range.from_motion('a' .. q)
end
- txtobj.define('al' .. q, function() return select_around() end)
- txtobj.define('il' .. q, function()
+ u.define_txtobj('al' .. q, function() return select_around() end)
+ u.define_txtobj('il' .. q, function()
local range = select_around()
if range == nil or range:is_empty() then return range end
@@ -82,8 +90,8 @@ function M.setup()
local keybinds = { ... }
table.insert(keybinds, b)
for _, k in ipairs(keybinds) do
- txtobj.define('al' .. k, function() return select_around() end)
- txtobj.define('il' .. k, function()
+ u.define_txtobj('al' .. k, function() return select_around() end)
+ u.define_txtobj('il' .. k, function()
local range = select_around()
return range and range:shrink(1)
end)
diff --git a/library/busted b/library/busted
new file mode 160000
index 0000000..5ed85d0
--- /dev/null
+++ b/library/busted
@@ -0,0 +1 @@
+Subproject commit 5ed85d0e016a5eb5eca097aa52905eedf1b180f1
diff --git a/library/luv b/library/luv
new file mode 160000
index 0000000..3615eb1
--- /dev/null
+++ b/library/luv
@@ -0,0 +1 @@
+Subproject commit 3615eb12c94a7cfa7184b8488cf908abb5e94c9c
diff --git a/lua/u.lua b/lua/u.lua
new file mode 100644
index 0000000..e289b47
--- /dev/null
+++ b/lua/u.lua
@@ -0,0 +1,1328 @@
+local M = {}
+
+--------------------------------------------------------------------------------
+-- Local helpers
+--------------------------------------------------------------------------------
+
+local ESC = vim.api.nvim_replace_termcodes('', true, false, true)
+local NS = vim.api.nvim_create_namespace 'u.range'
+
+--- @param bufnr integer
+--- @param lnum integer 1-based
+local function line_text(bufnr, lnum)
+ return vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1]
+end
+
+--------------------------------------------------------------------------------
+-- Pos class
+--------------------------------------------------------------------------------
+
+--- @class u.Pos
+--- @field bufnr integer buffer number
+--- @field lnum integer 1-based line index
+--- @field col integer 1-based column index
+--- @field off integer
+local Pos = {}
+Pos.__index = Pos
+
+function Pos.__tostring(self)
+ if self.off ~= 0 then
+ return string.format('Pos(%d:%d){bufnr=%d, off=%d}', self.lnum, self.col, self.bufnr, self.off)
+ else
+ return string.format('Pos(%d:%d){bufnr=%d}', self.lnum, self.col, self.bufnr)
+ end
+end
+
+--- @param bufnr? number
+--- @param lnum number 1-based
+--- @param col number 1-based
+--- @param off? number
+--- @return u.Pos
+function Pos.new(bufnr, lnum, col, off)
+ if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
+ if off == nil then off = 0 end
+ --- @type u.Pos
+ return setmetatable({
+ bufnr = bufnr,
+ lnum = lnum,
+ col = col,
+ off = off,
+ }, Pos)
+end
+
+--- @param bufnr? number
+--- @param lnum0 number 1-based
+--- @param col0 number 1-based
+--- @param off? number
+function Pos.from00(bufnr, lnum0, col0, off) return Pos.new(bufnr, lnum0 + 1, col0 + 1, off) end
+
+--- @param bufnr? number
+--- @param lnum0 number 1-based
+--- @param col1 number 1-based
+--- @param off? number
+function Pos.from01(bufnr, lnum0, col1, off) return Pos.new(bufnr, lnum0 + 1, col1, off) end
+
+--- @param bufnr? number
+--- @param lnum1 number 1-based
+--- @param col0 number 1-based
+--- @param off? number
+function Pos.from10(bufnr, lnum1, col0, off) return Pos.new(bufnr, lnum1, col0 + 1, off) end
+
+function Pos.invalid() return Pos.new(0, 0, 0, 0) end
+
+function Pos.__lt(a, b)
+ return a.bufnr == b.bufnr and (a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col))
+end
+function Pos.__le(a, b) return a < b or a == b end
+function Pos.__eq(a, b)
+ return getmetatable(a) == Pos
+ and getmetatable(b) == Pos
+ and a.bufnr == b.bufnr
+ and a.lnum == b.lnum
+ and a.col == b.col
+end
+function Pos.__add(x, y)
+ if type(x) == 'number' then
+ x, y = y, x
+ end
+ if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end
+ return x:next(y)
+end
+function Pos.__sub(x, y)
+ if type(x) == 'number' then
+ x, y = y, x
+ end
+ if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end
+ return x:next(-y)
+end
+
+--- @param bufnr number
+--- @param lnum number
+function Pos.from_eol(bufnr, lnum)
+ if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
+ local pos = Pos.new(bufnr, lnum, 0)
+ pos.col = pos:line():len()
+ return pos
+end
+
+--- @param name string
+--- @return u.Pos
+function Pos.from_pos(name)
+ local p = vim.fn.getpos(name)
+ return Pos.new(p[1], p[2], p[3], p[4])
+end
+
+function Pos:is_invalid() return self.lnum == 0 and self.col == 0 and self.off == 0 end
+
+function Pos:clone() return Pos.new(self.bufnr, self.lnum, self.col, self.off) end
+
+--- @return boolean
+function Pos:is_col_max() return self.col == vim.v.maxcol end
+
+--- Normalize the position to a real position (take into account vim.v.maxcol).
+function Pos:as_real()
+ local maxlen = #line_text(self.bufnr, self.lnum)
+ local col = self.col
+ if col > maxlen then
+ -- We could use utilities in this file to get the given line, but
+ -- since this is a low-level function, we are going to optimize and
+ -- use the API directly:
+ col = maxlen
+ end
+ return Pos.new(self.bufnr, self.lnum, col, self.off)
+end
+
+function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end
+
+function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end
+
+--- @param pos string
+function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end
+
+--- @param winnr? integer
+function Pos:save_to_cursor(winnr)
+ vim.api.nvim_win_set_cursor(winnr or 0, { self.lnum, self.col - 1 })
+end
+
+--- @param mark string
+function Pos:save_to_mark(mark)
+ local p = self:as_real()
+ vim.api.nvim_buf_set_mark(p.bufnr, mark, p.lnum, p.col - 1, {})
+end
+
+--- @return string
+function Pos:char()
+ local line = line_text(self.bufnr, self.lnum)
+ if line == nil then return '' end
+ return line:sub(self.col, self.col)
+end
+
+function Pos:line() return line_text(self.bufnr, self.lnum) end
+
+--- @param dir? -1|1
+--- @param must? boolean
+--- @return u.Pos|nil
+function Pos:next(dir, must)
+ if must == nil then must = false end
+
+ if dir == nil or dir == 1 then
+ -- Next:
+ local num_lines = vim.api.nvim_buf_line_count(self.bufnr)
+ local last_line = line_text(self.bufnr, num_lines)
+ if self.lnum == num_lines and self.col == #last_line then
+ if must then error 'error in Pos:next(): Pos:next() returned nil' end
+ return nil
+ end
+
+ local col = self.col + 1
+ local line = self.lnum
+ local line_max_col = #line_text(self.bufnr, self.lnum)
+ if col > line_max_col then
+ col = 1
+ line = line + 1
+ end
+ return Pos.new(self.bufnr, line, col, self.off)
+ else
+ -- Previous:
+ if self.col == 1 and self.lnum == 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 prev_line_max_col = #(line_text(self.bufnr, self.lnum - 1) or '')
+ if col < 1 then
+ col = math.max(prev_line_max_col, 1)
+ line = line - 1
+ end
+ return Pos.new(self.bufnr, 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: u.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: u.Pos): boolean
+function Pos:find_next(dir, predicate)
+ if type(predicate) == 'string' then
+ local s = predicate
+ predicate = function(p) return s == p:char() end
+ end
+
+ --- @type u.Pos|nil
+ 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.
+---
+--- Algorithm: Scans forward (for openers) or backward (for closers) until we find
+--- a matching character. When encountering a nested bracket of the same type, we
+--- recursively find its match first, then continue scanning. This handles deeply
+--- nested structures correctly.
+---
+--- @param max_chars? number|nil Safety limit to prevent infinite loops
+--- @param invocations? u.Pos[] Tracks positions to prevent infinite recursion
+--- @return u.Pos|nil
+function Pos:find_match(max_chars, invocations)
+ if invocations == nil then invocations = {} end
+ if vim.tbl_contains(invocations, function(p) return self == p end, { predicate = true }) then
+ return nil
+ end
+ 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)
+ -- Store the character we will be looking for:
+ local c_match = (is_opener and closers or openers)[i]
+
+ --- @type u.Pos|nil
+ local cur = self
+ --- `adv` is a helper that moves the current position backward or forward,
+ --- depending on whether we are looking for an opener or a closer. It returns
+ --- nil if 1) the watch-dog `max_chars` falls bellow 0, or 2) if we have gone
+ --- beyond the beginning/end of the file.
+ --- @return u.Pos|nil
+ 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 `c_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
+
+--- @param lines string|string[]
+function Pos:insert_before(lines)
+ if type(lines) == 'string' then lines = vim.split(lines, '\n') end
+ vim.api.nvim_buf_set_text(
+ self.bufnr,
+ self.lnum - 1,
+ self.col - 1,
+ self.lnum - 1,
+ self.col - 1,
+ lines
+ )
+end
+
+--------------------------------------------------------------------------------
+-- Range class (forward declared for Extmark)
+--------------------------------------------------------------------------------
+
+--- @class u.Range
+--- @field start u.Pos
+--- @field stop u.Pos|nil
+--- @field mode 'v'|'V'
+local Range = {}
+
+--------------------------------------------------------------------------------
+-- Extmark class
+--------------------------------------------------------------------------------
+
+--- @class u.Extmark
+--- @field bufnr integer
+--- @field id integer
+--- @field nsid integer
+local Extmark = {}
+Extmark.__index = Extmark
+
+--- @param bufnr integer
+--- @param nsid integer
+--- @param id integer
+function Extmark.new(bufnr, nsid, id)
+ return setmetatable({
+ bufnr = bufnr,
+ nsid = nsid,
+ id = id,
+ }, Extmark)
+end
+
+--- @param range u.Range
+--- @param nsid integer
+function Extmark.from_range(range, nsid)
+ local r = range:to_charwise()
+ local stop = r.stop or r.start
+ local end_row = stop.lnum - 1
+ local end_col = r:is_empty() and (r.start.col - 1) or stop.col
+ if range.mode == 'V' then
+ end_row = end_row + 1
+ end_col = 0
+ end
+ local id = vim.api.nvim_buf_set_extmark(r.start.bufnr, nsid, r.start.lnum - 1, r.start.col - 1, {
+ right_gravity = false,
+ end_right_gravity = true,
+ end_row = end_row,
+ end_col = end_col,
+ })
+ return Extmark.new(r.start.bufnr, nsid, id)
+end
+
+function Extmark:range()
+ local raw_extmark =
+ vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.nsid, self.id, { details = true })
+ local start_row0, start_col0, details = unpack(raw_extmark)
+
+ --- @type u.Pos
+ local start = Pos.from00(self.bufnr, start_row0, start_col0)
+ --- @type u.Pos?
+ local stop = details
+ and details.end_row
+ and details.end_col
+ and Pos.from01(self.bufnr, details.end_row, details.end_col)
+
+ local n_buf_lines = vim.api.nvim_buf_line_count(self.bufnr)
+ if stop and stop.lnum > n_buf_lines then
+ stop.lnum = n_buf_lines
+ stop = stop:eol()
+ end
+ if stop and stop.col == 0 then
+ stop.col = 1
+ stop = stop:next(-1)
+ end
+
+ return Range.new(start, stop, 'v')
+end
+
+function Extmark:delete() vim.api.nvim_buf_del_extmark(self.bufnr, self.nsid, self.id) end
+
+--------------------------------------------------------------------------------
+-- Range class (full implementation)
+--------------------------------------------------------------------------------
+
+Range.__index = Range
+function Range.__tostring(self)
+ --- @param p u.Pos
+ local function posstr(p)
+ if p == nil then
+ return 'nil'
+ elseif p.off ~= 0 then
+ return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off)
+ else
+ return string.format('Pos(%d:%d)', p.lnum, p.col)
+ end
+ end
+
+ local start_str = posstr(self.start)
+ local stop_str = posstr(self.stop)
+ return string.format(
+ 'Range{bufnr=%d, mode=%s, start=%s, stop=%s}',
+ self.start.bufnr,
+ self.mode,
+ start_str,
+ stop_str
+ )
+end
+function Range.__eq(a, b)
+ return getmetatable(a) == Range
+ and getmetatable(b) == Range
+ and a.mode == b.mode
+ and a.start == b.start
+ and a.stop == b.stop
+end
+
+--------------------------------------------------------------------------------
+-- Range constructors:
+--------------------------------------------------------------------------------
+
+--- @param start u.Pos
+--- @param stop u.Pos|nil
+--- @param mode? 'v'|'V'
+--- @return u.Range
+function Range.new(start, stop, mode)
+ if stop ~= nil and stop < start then
+ start, stop = stop, start
+ end
+
+ local r = { start = start, stop = stop, mode = mode or 'v' }
+
+ setmetatable(r, Range)
+ return r
+end
+
+--- @param ranges (u.Range|nil)[]
+function Range.smallest(ranges)
+ --- @type u.Range[]
+ ranges = vim.iter(ranges):filter(function(r) return r ~= nil and not r:is_empty() end):totable()
+ if #ranges == 0 then return nil end
+
+ -- find smallest match
+ local smallest = ranges[1]
+ for _, r in ipairs(ranges) do
+ local start, stop = r.start, r.stop
+ if start > smallest.start and stop < smallest.stop then smallest = r end
+ end
+ return smallest
+end
+
+--- @param lpos string
+--- @param rpos string
+--- @return u.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 bufnr? number
+function Range.from_buf_text(bufnr)
+ if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
+ local num_lines = vim.api.nvim_buf_line_count(bufnr)
+
+ local start = Pos.new(bufnr, 1, 1)
+ local stop = Pos.new(bufnr, num_lines, vim.v.maxcol)
+ return Range.new(start, stop, 'V')
+end
+
+--- @param bufnr? number
+--- @param line number 1-based line index
+function Range.from_line(bufnr, line) return Range.from_lines(bufnr, line, line) end
+
+--- @param bufnr? number
+--- @param start_line number based line index
+--- @param stop_line number based line index
+function Range.from_lines(bufnr, start_line, stop_line)
+ if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
+ if stop_line < 0 then
+ local num_lines = vim.api.nvim_buf_line_count(bufnr)
+ stop_line = num_lines + stop_line + 1
+ end
+ return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, vim.v.maxcol), 'V')
+end
+
+--- @param motion string
+--- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean }
+--- @return u.Range|nil
+function Range.from_motion(motion, opts)
+ -- SECTION: Normalize options
+ opts = opts or {}
+ if opts.bufnr == nil or opts.bufnr == 0 then opts.bufnr = vim.api.nvim_get_current_buf() end
+ if opts.contains_cursor == nil then opts.contains_cursor = false end
+ if opts.user_defined == nil then opts.user_defined = false end
+
+ -- SECTION: Parse motion string
+ --- @type 'a'|'i', string
+ local scope, motion_rest = motion:sub(1, 1), motion:sub(2)
+ local is_txtobj = scope == 'a' or scope == 'i'
+ local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest)
+
+ -- SECTION: Capture original state for restoration
+ local original_state = {
+ winview = vim.fn.winsaveview(),
+ unnamed_register = vim.fn.getreg '"',
+ cursor = vim.fn.getpos '.',
+ mark_lbracket = vim.fn.getpos "'[",
+ mark_rbracket = vim.fn.getpos "']",
+ opfunc = vim.go.operatorfunc,
+ prev_captured_range = _G.Range__from_motion_opfunc_captured_range,
+ prev_mode = vim.fn.mode(),
+ vinf = Range.from_vtext(),
+ }
+ --- @type u.Range|nil
+ _G.Range__from_motion_opfunc_captured_range = nil
+
+ -- SECTION: Execute motion and capture result
+ vim.api.nvim_buf_call(opts.bufnr, function()
+ if opts.pos ~= nil then opts.pos:save_to_pos '.' end
+
+ _G.Range__from_motion_opfunc = function(ty)
+ _G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
+ end
+ local old_eventignore = vim.o.eventignore
+ vim.o.eventignore = 'all'
+ vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc'
+ vim.cmd {
+ cmd = 'normal',
+ bang = not opts.user_defined,
+ args = { ESC .. 'g@' .. motion },
+ mods = { silent = true },
+ }
+ vim.o.eventignore = old_eventignore
+ end)
+ local captured_range = _G.Range__from_motion_opfunc_captured_range
+
+ -- SECTION: Restore state
+ vim.fn.winrestview(original_state.winview)
+ vim.fn.setreg('"', original_state.unnamed_register)
+ vim.fn.setpos('.', original_state.cursor)
+ vim.fn.setpos("'[", original_state.mark_lbracket)
+ vim.fn.setpos("']", original_state.mark_rbracket)
+ if original_state.prev_mode ~= 'n' then original_state.vinf:set_visual_selection() end
+ vim.go.operatorfunc = original_state.opfunc
+ _G.Range__from_motion_opfunc_captured_range = original_state.prev_captured_range
+
+ if not captured_range then return nil end
+
+ -- SECTION: Fixup edge cases (quote text objects, 'it' tag)
+ if
+ -- I have no idea why, but when yanking `i"`, the stop-mark is
+ -- placed on the ending quote. For other text-objects, the stop-
+ -- mark is placed before the closing character.
+ (is_quote_txtobj and scope == 'i' and captured_range.stop:char() == motion_rest)
+ -- *Sigh*, this also sometimes happens for `it` as well.
+ or (motion == 'it' and captured_range.stop:char() == '<')
+ then
+ captured_range.stop = captured_range.stop:next(-1) or captured_range.stop
+ end
+ if is_quote_txtobj and scope == 'a' then
+ captured_range.start = captured_range.start:find_next(1, motion_rest) or captured_range.start
+ captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop
+ end
+
+ -- SECTION: Validate cursor containment
+ if
+ opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor)))
+ then
+ return nil
+ end
+
+ return captured_range
+end
+
+--- @param opts? { contains_cursor?: boolean }
+function Range.from_tsquery_caps(bufnr, query, opts)
+ opts = opts or { contains_cursor = true }
+
+ local ranges = Range.from_buf_text(bufnr):tsquery(query)
+ if not ranges then return end
+ if not opts.contains_cursor then return ranges end
+
+ local cursor = Pos.from_pos '.'
+ return vim.tbl_map(function(cap_ranges)
+ return vim
+ .iter(cap_ranges)
+ :filter(
+ --- @param r u.Range
+ function(r) return r:contains(cursor) end
+ )
+ :totable()
+ end, ranges)
+end
+
+--- Get range information from the currently selected visual text.
+--- 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 u.Range|nil
+function Range.from_cmd_args(args)
+ if args.range == 0 then return nil end
+
+ local bufnr = vim.api.nvim_get_current_buf()
+ if args.range == 1 then
+ return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, vim.v.maxcol), 'V')
+ end
+
+ local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]]
+ --- @type 'v'|'V'
+ local mode = is_visual and vim.fn.visualmode() or 'V'
+
+ if is_visual then
+ return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode)
+ else
+ return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, vim.v.maxcol), mode)
+ end
+end
+
+function Range.find_nearest_brackets()
+ return Range.smallest {
+ Range.from_motion('a<', { contains_cursor = true }),
+ Range.from_motion('a[', { contains_cursor = true }),
+ Range.from_motion('a(', { contains_cursor = true }),
+ Range.from_motion('a{', { contains_cursor = true }),
+ }
+end
+
+function Range.find_nearest_quotes()
+ return Range.smallest {
+ Range.from_motion([[a']], { contains_cursor = true }),
+ Range.from_motion([[a"]], { contains_cursor = true }),
+ Range.from_motion([[a`]], { contains_cursor = true }),
+ }
+end
+
+--------------------------------------------------------------------------------
+-- Structural utilities:
+--------------------------------------------------------------------------------
+
+function Range:clone()
+ return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode)
+end
+
+function Range:is_empty() return self.stop == nil end
+
+function Range:to_linewise()
+ local r = self:clone()
+
+ r.mode = 'V'
+ r.start.col = 1
+ if r.stop ~= nil then r.stop.col = vim.v.maxcol end
+
+ return r
+end
+
+function Range:to_charwise()
+ local r = self:clone()
+ r.mode = 'v'
+ if r.stop and r.stop:is_col_max() then r.stop = r.stop:as_real() end
+ return r
+end
+
+--- @param x u.Pos | u.Range
+function Range:contains(x)
+ if getmetatable(x) == Pos then
+ return not self:is_empty() and x >= self.start and x <= self.stop
+ elseif getmetatable(x) == Range then
+ return self:contains(x.start) and self:contains(x.stop)
+ end
+ return false
+end
+
+--- @param other u.Range
+--- @return u.Range|nil, u.Range|nil
+function Range:difference(other)
+ local outer, inner = self, other
+ if not outer:contains(inner) then
+ outer, inner = inner, outer
+ end
+ if not outer:contains(inner) then return nil, nil end
+
+ local left
+ if outer.start ~= inner.start then
+ local stop = inner.start:clone() - 1
+ left = Range.new(outer.start, stop)
+ else
+ left = Range.new(outer.start) -- empty range
+ end
+
+ local right
+ if inner.stop ~= outer.stop then
+ local start = inner.stop:clone() + 1
+ right = Range.new(start, outer.stop)
+ else
+ right = Range.new(inner.stop) -- empty range
+ end
+
+ return left, right
+end
+
+--- @param left string
+--- @param right string
+function Range:save_to_pos(left, right)
+ self.start:save_to_pos(left);
+ (self:is_empty() and self.start or self.stop):save_to_pos(right)
+end
+
+--- @param left string
+--- @param right string
+function Range:save_to_marks(left, right)
+ self.start:save_to_mark(left);
+ (self:is_empty() and self.start or self.stop):save_to_mark(right)
+end
+
+function Range:save_to_extmark() return Extmark.from_range(self, NS) end
+
+function Range:set_visual_selection()
+ if self:is_empty() then return end
+ if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
+ error 'Range:set_visual_selection() called on a buffer other than the current buffer'
+ end
+
+ local curr_mode = vim.fn.mode()
+ if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end
+
+ self.start:save_to_pos '.'
+ vim.cmd.normal { args = { 'o' }, bang = true }
+ self.stop:save_to_pos '.'
+end
+
+--------------------------------------------------------------------------------
+-- Text access/manipulation utilities:
+--------------------------------------------------------------------------------
+
+--- @param query string
+function Range:tsquery(query)
+ local bufnr = self.start.bufnr
+
+ local lang = vim.treesitter.language.get_lang(vim.bo[bufnr].filetype)
+ if lang == nil then return end
+ local parser = vim.treesitter.get_parser(bufnr, lang)
+ if parser == nil then return end
+ local tree = parser:parse()[1]
+ if tree == nil then return end
+
+ local root = tree:root()
+ local q = vim.treesitter.query.parse(lang, query)
+ --- @type table
+ local ranges = {}
+ for id, match, _meta in
+ q:iter_captures(root, bufnr, self.start.lnum - 1, (self.stop or self.start).lnum)
+ do
+ local start_row0, start_col0, stop_row0, stop_col0 = match:range()
+ local range = Range.new(
+ Pos.new(bufnr, start_row0 + 1, start_col0 + 1),
+ Pos.new(bufnr, stop_row0 + 1, stop_col0),
+ 'v'
+ )
+ if range.stop.lnum > vim.api.nvim_buf_line_count(bufnr) then
+ range.stop = range.stop:must_next(-1)
+ end
+
+ local capture_name = q.captures[id]
+ if not ranges[capture_name] then ranges[capture_name] = {} end
+ if self:contains(range) then table.insert(ranges[capture_name], range) end
+ end
+
+ return ranges
+end
+
+function Range:length()
+ if self:is_empty() then return 0 end
+
+ local line_positions =
+ vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' })
+
+ local len = 0
+ for linenr, line in ipairs(line_positions) do
+ if linenr > 1 then len = len + 1 end -- each newline is counted as a char
+ local line_start_col = line[1][3]
+ local line_stop_col = line[2][3]
+ local line_len = line_stop_col - line_start_col + 1
+ len = len + line_len
+ end
+ return len
+end
+
+function Range:line_count()
+ if self:is_empty() then return 0 end
+ return self.stop.lnum - self.start.lnum + 1
+end
+
+function Range:trim_start()
+ if self:is_empty() then return end
+
+ 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 i number 1-based
+--- @param j? number 1-based
+function Range:sub(i, j)
+ local line_positions =
+ vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' })
+
+ --- @param idx number 1-based
+ --- @return u.Pos|nil
+ local function get_pos(idx)
+ if idx < 0 then return get_pos(self:length() + idx + 1) end
+
+ -- find the position of the first line that contains the i-th character:
+ local curr_len = 0
+ for linenr, line in ipairs(line_positions) do
+ if linenr > 1 then curr_len = curr_len + 1 end -- each newline is counted as a char
+ local line_start_col = line[1][3]
+ local line_stop_col = line[2][3]
+ local line_len = line_stop_col - line_start_col + 1
+
+ if curr_len + line_len >= idx then
+ return Pos.new(self.start.bufnr, line[1][2], line_start_col + (idx - curr_len) - 1)
+ end
+ curr_len = curr_len + line_len
+ end
+ end
+
+ local start = get_pos(i)
+ if not start then
+ -- start is invalid, so return an empty range:
+ return Range.new(self.start, nil, self.mode)
+ end
+
+ local stop
+ if j then stop = get_pos(j) end
+ if not stop then
+ -- stop is invalid, so return an empty range:
+ return Range.new(start, nil, self.mode)
+ end
+ return Range.new(start, stop, 'v')
+end
+
+--- @return string[]
+function Range:lines()
+ if self:is_empty() then return {} end
+ return vim.fn.getregion(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
+end
+
+--- @return string
+function Range:text() return vim.fn.join(self:lines(), '\n') end
+
+--- @param l number
+function Range:line(l)
+ if l < 0 then l = self:line_count() + l + 1 end
+ if l > self:line_count() then return end
+
+ local line_indices =
+ vim.fn.getregionpos(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
+ local line_bounds = line_indices[l]
+
+ local start = Pos.new(unpack(line_bounds[1]))
+ local stop = Pos.new(unpack(line_bounds[2]))
+ return Range.new(start, stop)
+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 bufnr = self.start.bufnr
+ local replace_type = (self:is_empty() and 'insert') or (self.mode == 'v' and 'region') or 'lines'
+
+ local function update_stop_non_linewise()
+ local new_last_line_num = self.start.lnum + #replacement - 1
+ local new_last_col = #(replacement[#replacement] or '')
+ if new_last_line_num == self.start.lnum then
+ new_last_col = new_last_col + self.start.col - 1
+ end
+ self.stop = Pos.new(bufnr, new_last_line_num, new_last_col)
+ end
+ local function update_stop_linewise()
+ if #replacement == 0 then
+ self.stop = nil
+ else
+ local new_last_line_num = self.start.lnum - 1 + #replacement - 1
+ self.stop = Pos.new(bufnr, new_last_line_num + 1, vim.v.maxcol, self.stop.off)
+ end
+ self.mode = 'v'
+ end
+
+ if replace_type == 'insert' then
+ -- To insert text at a given `(row, column)` location, use `start_row =
+ -- end_row = row` and `start_col = end_col = col`.
+ vim.api.nvim_buf_set_text(
+ bufnr,
+ self.start.lnum - 1,
+ self.start.col - 1,
+ self.start.lnum - 1,
+ self.start.col - 1,
+ replacement
+ )
+ update_stop_non_linewise()
+ elseif replace_type == 'region' then
+ -- Fixup the bounds:
+ local max_col = #self.stop:line()
+
+ -- Indexing is zero-based. Row indices are end-inclusive, and column indices
+ -- are end-exclusive.
+ vim.api.nvim_buf_set_text(
+ bufnr,
+ self.start.lnum - 1,
+ self.start.col - 1,
+ self.stop.lnum - 1,
+ math.min(self.stop.col, max_col),
+ replacement
+ )
+ update_stop_non_linewise()
+ elseif replace_type == 'lines' then
+ -- Indexing is zero-based, end-exclusive.
+ vim.api.nvim_buf_set_lines(bufnr, self.start.lnum - 1, self.stop.lnum, true, replacement)
+ update_stop_linewise()
+ 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 group string
+--- @param opts? { timeout?: number, priority?: number, on_macro?: boolean }
+--- @return { ns: integer, clear: fun() }
+function Range:highlight(group, opts)
+ if self:is_empty() then return { ns = 0, clear = function() end } 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 { ns = 0, clear = function() end } end
+
+ local ns = vim.api.nvim_create_namespace ''
+
+ local winview = vim.fn.winsaveview()
+ vim.hl.range(
+ self.start.bufnr,
+ ns,
+ group,
+ { self.start.lnum - 1, self.start.col - 1 },
+ { self.stop.lnum - 1, self.stop.col - 1 },
+ {
+ inclusive = true,
+ priority = opts.priority,
+ timeout = opts.timeout,
+ regtype = self.mode,
+ }
+ )
+ if not in_macro then vim.fn.winrestview(winview) end
+ vim.cmd.redraw()
+
+ return {
+ ns = ns,
+ clear = function()
+ vim.api.nvim_buf_clear_namespace(self.start.bufnr, ns, self.start.lnum - 1, self.stop.lnum)
+ vim.cmd.redraw()
+ end,
+ }
+end
+
+--------------------------------------------------------------------------------
+-- opkeymap
+--------------------------------------------------------------------------------
+
+-- NOTE: These must be global because Neovim's 'operatorfunc' option only
+-- accepts VimScript expressions (v:lua.FunctionName), not direct Lua
+-- references. This is a Neovim API limitation.
+--- @type fun(range: u.Range): nil|(fun():any)
+local __U__OpKeymapOpFunc_rhs = nil
+
+--- This is the global utility function used for operatorfunc in opkeymap
+--- @param ty 'line'|'char'|'block'
+-- selene: allow(unused_variable)
+function _G.__U__OpKeymapOpFunc(ty)
+ if __U__OpKeymapOpFunc_rhs ~= nil then
+ local range = Range.from_op_func(ty)
+ __U__OpKeymapOpFunc_rhs(range)
+ 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: u.Range): nil
+--- @diagnostic disable-next-line: undefined-doc-name
+--- @param opts? vim.keymap.set.Opts
+local function opkeymap(mode, lhs, rhs, opts)
+ vim.keymap.set(mode, lhs, function()
+ -- We don't need to wrap the operation in a repeat, because expr mappings are
+ -- repeated seamlessly by Vim anyway. In addition, the u.repeat:`.` mapping will
+ -- set IS_REPEATING to true, so that callbacks can check if they should used cached
+ -- values.
+ __U__OpKeymapOpFunc_rhs = rhs
+ vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc'
+ return 'g@'
+ end, vim.tbl_extend('force', opts or {}, { expr = true }))
+end
+
+--------------------------------------------------------------------------------
+-- txtobj
+--------------------------------------------------------------------------------
+
+local txtobj = {}
+
+--- @param key_seq string
+--- @param fn fun(key_seq: string):u.Range|nil
+--- @param opts? { buffer: number|nil }
+function txtobj.define(key_seq, fn, opts)
+ if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end
+
+ local function handle_visual()
+ local range = fn(key_seq)
+ if range == nil or range:is_empty() then
+ vim.cmd.normal(ESC)
+ return
+ end
+ range:set_visual_selection()
+ end
+ vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil)
+
+ local function handle_normal()
+ local range = fn(key_seq)
+ if range == nil then return end
+
+ if not range:is_empty() then
+ range:set_visual_selection()
+ else
+ local original_eventignore = vim.go.eventignore
+ vim.go.eventignore = 'all'
+
+ -- insert a single space, so we can select it:
+ local p = range.start
+ p:insert_before ' '
+ vim.go.eventignore = original_eventignore
+
+ -- select the space:
+ Range.new(p, p, 'v'):set_visual_selection()
+ end
+ end
+ vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil)
+end
+
+--------------------------------------------------------------------------------
+-- repeat
+--------------------------------------------------------------------------------
+
+local repeat_ = {}
+
+local IS_REPEATING = false
+--- @type function|nil
+local REPEAT_ACTION = nil
+
+local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end
+
+--- @param f fun()
+function repeat_.run_repeatable(f)
+ REPEAT_ACTION = f
+ ---@diagnostic disable-next-line: need-check-nil
+ REPEAT_ACTION()
+ vim.b.my_changedtick = vim.b.changedtick
+end
+
+function repeat_.is_repeating() return IS_REPEATING end
+
+function repeat_.setup()
+ vim.keymap.set('n', '.', function()
+ IS_REPEATING = true
+ for _ = 1, vim.v.count1 do
+ if is_repeatable_last_mutator() and type(REPEAT_ACTION) == 'function' then
+ repeat_.run_repeatable(REPEAT_ACTION)
+ else
+ vim.cmd { cmd = 'normal', args = { '.' }, bang = true }
+ end
+ end
+ IS_REPEATING = false
+ end)
+ vim.keymap.set('n', 'u', function()
+ local was_repeatable_last_mutator = is_repeatable_last_mutator()
+ for _ = 1, vim.v.count1 do
+ vim.cmd { cmd = 'normal', args = { 'u' }, bang = true }
+ end
+ if was_repeatable_last_mutator then vim.b.my_changedtick = vim.b.changedtick end
+ end)
+end
+
+--------------------------------------------------------------------------------
+-- utils
+--------------------------------------------------------------------------------
+
+local utils = {}
+
+--
+-- Types
+--
+
+--- @class u.utils.QfItem
+--- @field col number
+--- @field filename string
+--- @field kind string
+--- @field lnum number
+--- @field text string
+
+--- @class u.utils.RawCmdArgs
+--- @field args string
+--- @field bang boolean
+--- @field count number
+--- @field fargs string[]
+--- @field line1 number
+--- @field line2 number
+--- @field mods string
+--- @field name string
+--- @field range 0|1|2
+--- @field reg string
+--- @field smods any
+
+--- @class u.utils.CmdArgs: u.utils.RawCmdArgs
+--- @field info u.Range|nil
+
+--- @class u.utils.UcmdArgs
+--- @field nargs? 0|1|'*'|'?'|'+'
+--- @field range? boolean|'%'|number
+--- @field count? boolean|number
+--- @field addr? string
+--- @field completion? string
+--- @field force? boolean
+--- @field preview? fun(opts: u.utils.UcmdArgs, ns: integer, buf: integer):0|1|2
+
+--- Creates a user command with enhanced argument processing.
+--- Automatically computes range information and attaches it as `args.info`.
+---
+--- @param name string The command name (without the leading colon)
+--- @param cmd string | fun(args: u.utils.CmdArgs): any Command implementation
+--- @param opts? u.utils.UcmdArgs Command options (nargs, range, etc.)
+---
+--- @usage
+--- ```lua
+--- -- Create a command that works with visual selections:
+--- utils.ucmd('MyCmd', function(args)
+--- -- Print the visually selected text:
+--- vim.print(args.info:text())
+--- -- Or get the selection as an array of lines:
+--- vim.print(args.info:lines())
+--- end, { nargs = '*', range = true })
+---
+--- -- Create a command that processes the current line:
+--- utils.ucmd('ProcessLine', function(args)
+--- local line_text = args.info:text()
+--- -- Process the line...
+--- end, { range = '%' })
+---
+--- -- Create a command with arguments:
+--- utils.ucmd('SearchReplace', function(args)
+--- local pattern, replacement = args.fargs[1], args.fargs[2]
+--- local text = args.info:text()
+--- -- Perform search and replace...
+--- end, { nargs = 2, range = true })
+--- ```
+function utils.ucmd(name, cmd, opts)
+ 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 {} --[[@as any]])
+end
+
+--- Creates command arguments for delegating from one command to another.
+--- Preserves all relevant context (range, modifiers, bang, etc.) when
+--- implementing a derived command in terms of a base command.
+---
+--- @param current_args vim.api.keyset.create_user_command.command_args|u.utils.RawCmdArgs The arguments from the current command
+--- @return vim.api.keyset.cmd Arguments suitable for vim.cmd() calls
+---
+--- @usage
+--- ```lua
+--- -- Implement :MyEdit in terms of :edit, preserving all context:
+--- utils.ucmd('MyEdit', function(args)
+--- local delegated_args = utils.create_delegated_cmd_args(args)
+--- -- Add custom logic here...
+--- vim.cmd.edit(delegated_args)
+--- end, { nargs = '*', range = true, bang = true })
+---
+--- -- Implement :MySubstitute that delegates to :substitute:
+--- utils.ucmd('MySubstitute', function(args)
+--- -- Pre-process arguments
+--- local pattern = preprocess_pattern(args.fargs[1])
+---
+--- local delegated_args = utils.create_delegated_cmd_args(args)
+--- delegated_args.args = { pattern, args.fargs[2] }
+---
+--- vim.cmd.substitute(delegated_args)
+--- end, { nargs = 2, range = true, bang = true })
+--- ```
+function utils.create_delegated_cmd_args(current_args)
+ --- @type vim.api.keyset.cmd
+ local args = {
+ range = current_args.range == 1 and { current_args.line1 }
+ or current_args.range == 2 and { current_args.line1, current_args.line2 }
+ or nil,
+ count = (current_args.count ~= -1 and current_args.range == 0) and current_args.count or nil,
+ reg = current_args.reg ~= '' and current_args.reg or nil,
+ bang = current_args.bang or nil,
+ args = #current_args.fargs > 0 and current_args.fargs or nil,
+ mods = current_args.smods,
+ }
+ return args
+end
+
+--------------------------------------------------------------------------------
+-- Exports
+--------------------------------------------------------------------------------
+
+M.Pos = Pos
+M.Extmark = Extmark
+M.Range = Range
+M.opkeymap = opkeymap
+M.define_txtobj = txtobj.define
+M.repeat_ = repeat_
+M.ucmd = utils.ucmd
+M.create_delegated_cmd_args = utils.create_delegated_cmd_args
+
+return M
diff --git a/lua/u/.luarc.json b/lua/u/.luarc.json
deleted file mode 100644
index 43c1071..0000000
--- a/lua/u/.luarc.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
- "diagnostics.globals": ["assert", "vim"],
- "runtime.version": "LuaJIT"
-}
diff --git a/lua/u/buffer.lua b/lua/u/buffer.lua
deleted file mode 100644
index 0897a4f..0000000
--- a/lua/u/buffer.lua
+++ /dev/null
@@ -1,128 +0,0 @@
-local Range = require 'u.range'
-local Renderer = require('u.renderer').Renderer
-
---- @class u.Buffer
---- @field bufnr number
---- @field b vim.var_accessor
---- @field bo vim.bo
---- @field private renderer u.Renderer
-local Buffer = {}
-Buffer.__index = Buffer
-
---- @param bufnr? number
---- @return u.Buffer
-function Buffer.from_nr(bufnr)
- if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
- local renderer = Renderer.new(bufnr)
- return setmetatable({
- bufnr = bufnr,
- b = vim.b[bufnr],
- bo = vim.bo[bufnr],
- renderer = renderer,
- }, Buffer)
-end
-
---- @return u.Buffer
-function Buffer.current() return Buffer.from_nr(0) end
-
---- @param listed boolean
---- @param scratch boolean
---- @return u.Buffer
-function Buffer.create(listed, scratch)
- return Buffer.from_nr(vim.api.nvim_create_buf(listed, scratch))
-end
-
-function Buffer:set_tmp_options()
- self.bo.bufhidden = 'delete'
- self.bo.buflisted = false
- self.bo.buftype = 'nowrite'
-end
-
-function Buffer:line_count() return vim.api.nvim_buf_line_count(self.bufnr) end
-
-function Buffer:all() return Range.from_buf_text(self.bufnr) end
-
-function Buffer:is_empty() return self:line_count() == 1 and self:line(1):text() == '' end
-
---- @param line string
-function Buffer:append_line(line)
- local start = -1
- if self:is_empty() then start = -2 end
- vim.api.nvim_buf_set_lines(self.bufnr, start, -1, false, { line })
-end
-
---- @param num number 1-based line index
-function Buffer:line(num)
- if num < 0 then num = self:line_count() + num + 1 end
- return Range.from_line(self.bufnr, num)
-end
-
---- @param start number 1-based line index
---- @param stop number 1-based line index
-function Buffer:lines(start, stop) return Range.from_lines(self.bufnr, start, stop) end
-
---- @param motion string
---- @param opts? { contains_cursor?: boolean; pos?: u.Pos }
-function Buffer:motion(motion, opts)
- opts = vim.tbl_extend('force', opts or {}, { bufnr = self.bufnr })
- return Range.from_motion(motion, opts)
-end
-
---- @param event string|string[]
---- @diagnostic disable-next-line: undefined-doc-name
---- @param opts vim.api.keyset.create_autocmd
-function Buffer:autocmd(event, opts)
- vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.bufnr }))
-end
-
---- @param tree u.renderer.Tree
-function Buffer:render(tree) return self.renderer:render(tree) end
-
---- Filter buffer content through an external command (like Vim's :%!)
---- @param cmd string[] Command to run (with arguments)
---- @param opts? {cwd?: string, preserve_cursor?: boolean}
---- @return nil
---- @throws string Error message if command fails
---- @note Special placeholders in cmd:
---- - $FILE: replaced with the buffer's filename (if any)
---- - $DIR: replaced with the buffer's directory (if any)
-function Buffer:filter_cmd(cmd, opts)
- opts = opts or {}
- local cwd = opts.cwd or vim.uv.cwd()
- local old_lines = self:all():lines()
- -- Save cursor position if needed, defaulting to true
- local save_pos = opts.preserve_cursor ~= false and vim.fn.winsaveview()
-
- -- Run the command
- local result = vim
- .system(
- -- Replace special placeholders in `cmd` with their values:
- vim
- .iter(cmd)
- :map(function(x)
- if x == '$FILE' then return vim.api.nvim_buf_get_name(self.bufnr) end
- if x == '$DIR' then return vim.fs.dirname(vim.api.nvim_buf_get_name(self.bufnr)) end
- return x
- end)
- :totable(),
- {
- cwd = cwd,
- stdin = old_lines,
- text = true,
- }
- )
- :wait()
-
- -- Check for command failure
- if result.code ~= 0 then error('Command failed: ' .. (result.stderr or '')) end
-
- -- Process and apply the result
- local new_lines = vim.split(result.stdout, '\n')
- if new_lines[#new_lines] == '' then table.remove(new_lines) end
- Renderer.patch_lines(self.bufnr, old_lines, new_lines)
-
- -- Restore cursor position if saved
- if save_pos then vim.fn.winrestview(save_pos) end
-end
-
-return Buffer
diff --git a/lua/u/codewriter.lua b/lua/u/codewriter.lua
deleted file mode 100644
index d8e810b..0000000
--- a/lua/u/codewriter.lua
+++ /dev/null
@@ -1,78 +0,0 @@
-local Buffer = require 'u.buffer'
-
---- @class u.CodeWriter
---- @field lines string[]
---- @field indent_level number
---- @field indent_str string
-local CodeWriter = {}
-CodeWriter.__index = CodeWriter
-
---- @param indent_level? number
---- @param indent_str? string
---- @return u.CodeWriter
-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, CodeWriter)
- return cw
-end
-
---- @param p u.Pos
-function CodeWriter.from_pos(p)
- local line = Buffer.from_nr(p.bufnr):line(p.lnum):text()
- return CodeWriter.from_line(line, p.bufnr)
-end
-
---- @param line string
---- @param bufnr? number
-function CodeWriter.from_line(line, bufnr)
- if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
-
- local ws = line:match '^%s*'
- local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr })
- local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = bufnr })
-
- --- @type number
- local indent_level
- 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: u.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
diff --git a/lua/u/logger.lua b/lua/u/logger.lua
deleted file mode 100644
index 07fdf04..0000000
--- a/lua/u/logger.lua
+++ /dev/null
@@ -1,69 +0,0 @@
-local M = {}
-
---- @params name string
-function M.file_for_name(name)
- return vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log', name .. '.log.jsonl')
-end
-
---------------------------------------------------------------------------------
--- Logger class
---------------------------------------------------------------------------------
-
---- @class u.Logger
---- @field name string
---- @field private fd number
-local Logger = {}
-Logger.__index = Logger
-M.Logger = Logger
-
---- @param name string
-function Logger.new(name)
- local file_path = M.file_for_name(name)
- vim.fn.mkdir(vim.fs.dirname(file_path), 'p')
- local self = setmetatable({
- name = name,
- fd = (vim.uv or vim.loop).fs_open(file_path, 'a', tonumber('644', 8)),
- }, Logger)
- return self
-end
-
---- @private
---- @param level string
-function Logger:write(level, ...)
- local data = { ... }
- if #data == 1 then data = data[1] end
- (vim.uv or vim.loop).fs_write(
- self.fd,
- vim.json.encode { ts = os.date(), level = level, data = data } .. '\n'
- )
-end
-
-function Logger:trace(...) self:write('INFO', ...) end
-function Logger:debug(...) self:write('DEBUG', ...) end
-function Logger:info(...) self:write('INFO', ...) end
-function Logger:warn(...) self:write('WARN', ...) end
-function Logger:error(...) self:write('ERROR', ...) end
-
-function M.setup()
- vim.api.nvim_create_user_command('Logfollow', function(args)
- if #args.fargs == 0 then
- vim.print 'expected log name'
- return
- end
-
- local log_file_path = M.file_for_name(args.fargs[1])
- vim.fn.mkdir(vim.fs.dirname(log_file_path), 'p')
- vim.system({ 'touch', log_file_path }):wait()
-
- vim.cmd.new()
-
- local winnr = vim.api.nvim_get_current_win()
- vim.wo[winnr][0].number = false
- vim.wo[winnr][0].relativenumber = false
-
- vim.cmd.terminal('tail -f "' .. log_file_path .. '"')
- vim.cmd.startinsert()
- end, { nargs = '*' })
-end
-
-return M
diff --git a/lua/u/opkeymap.lua b/lua/u/opkeymap.lua
deleted file mode 100644
index 0942495..0000000
--- a/lua/u/opkeymap.lua
+++ /dev/null
@@ -1,41 +0,0 @@
-local Range = require 'u.range'
-
---- @type fun(range: u.Range): nil|(fun():any)
-local __U__OpKeymapOpFunc_rhs = nil
-
---- This is the global utility function used for operatorfunc
---- in opkeymap
---- @type nil|fun(range: u.Range): fun():any|nil
---- @param ty 'line'|'char'|'block'
--- selene: allow(unused_variable)
-function _G.__U__OpKeymapOpFunc(ty)
- if __U__OpKeymapOpFunc_rhs ~= nil then
- local range = Range.from_op_func(ty)
- __U__OpKeymapOpFunc_rhs(range)
- 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: u.Range): nil
---- @diagnostic disable-next-line: undefined-doc-name
---- @param opts? vim.keymap.set.Opts
-local function opkeymap(mode, lhs, rhs, opts)
- vim.keymap.set(mode, lhs, function()
- -- We don't need to wrap the operation in a repeat, because expr mappings are
- -- repeated seamlessly by Vim anyway. In addition, the u.repeat:`.` mapping will
- -- set IS_REPEATING to true, so that callbacks can check if they should used cached
- -- values.
- __U__OpKeymapOpFunc_rhs = rhs
- vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc'
- return 'g@'
- end, vim.tbl_extend('force', opts or {}, { expr = true }))
-end
-
-return opkeymap
diff --git a/lua/u/pos.lua b/lua/u/pos.lua
deleted file mode 100644
index a3216d7..0000000
--- a/lua/u/pos.lua
+++ /dev/null
@@ -1,270 +0,0 @@
-local MAX_COL = vim.v.maxcol
-
---- @param bufnr number
---- @param lnum number 1-based
-local function line_text(bufnr, lnum)
- return vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1]
-end
-
---- @class u.Pos
---- @field bufnr number buffer number
---- @field lnum number 1-based line index
---- @field col number 1-based column index
---- @field off number
-local Pos = {}
-Pos.__index = Pos
-Pos.MAX_COL = MAX_COL
-
-function Pos.__tostring(self)
- if self.off ~= 0 then
- return string.format('Pos(%d:%d){bufnr=%d, off=%d}', self.lnum, self.col, self.bufnr, self.off)
- else
- return string.format('Pos(%d:%d){bufnr=%d}', self.lnum, self.col, self.bufnr)
- end
-end
-
---- @param bufnr? number
---- @param lnum number 1-based
---- @param col number 1-based
---- @param off? number
---- @return u.Pos
-function Pos.new(bufnr, lnum, col, off)
- if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
- if off == nil then off = 0 end
- local pos = {
- bufnr = bufnr,
- lnum = lnum,
- col = col,
- off = off,
- }
- setmetatable(pos, Pos)
- return pos
-end
-
-function Pos.invalid() return Pos.new(0, 0, 0, 0) end
-
-function Pos.__lt(a, b) return a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col) end
-function Pos.__le(a, b) return a < b or a == b end
-function Pos.__eq(a, b)
- return getmetatable(a) == Pos
- and getmetatable(b) == Pos
- and a.bufnr == b.bufnr
- and a.lnum == b.lnum
- and a.col == b.col
-end
-function Pos.__add(x, y)
- if type(x) == 'number' then
- x, y = y, x
- end
- if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end
- return x:next(y)
-end
-function Pos.__sub(x, y)
- if type(x) == 'number' then
- x, y = y, x
- end
- if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end
- return x:next(-y)
-end
-
---- @param name string
---- @return u.Pos
-function Pos.from_pos(name)
- local p = vim.fn.getpos(name)
- return Pos.new(p[1], p[2], p[3], p[4])
-end
-
-function Pos:is_invalid() return self.lnum == 0 and self.col == 0 and self.off == 0 end
-
-function Pos:clone() return Pos.new(self.bufnr, self.lnum, self.col, self.off) end
-
---- @return boolean
-function Pos:is_col_max() return self.col == MAX_COL end
-
---- Normalize the position to a real position (take into account vim.v.maxcol).
-function Pos:as_real()
- local maxlen = #line_text(self.bufnr, self.lnum)
- local col = self.col
- if col > maxlen then
- -- We could use utilities in this file to get the given line, but
- -- since this is a low-level function, we are going to optimize and
- -- use the API directly:
- col = maxlen
- end
- return Pos.new(self.bufnr, self.lnum, col, self.off)
-end
-
-function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end
-
---- @param pos string
-function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end
-
---- @param mark string
-function Pos:save_to_mark(mark)
- local p = self:as_real()
- vim.api.nvim_buf_set_mark(p.bufnr, mark, p.lnum, p.col - 1, {})
-end
-
---- @return string
-function Pos:char()
- local line = line_text(self.bufnr, self.lnum)
- if line == nil then return '' end
- return line:sub(self.col, self.col)
-end
-
-function Pos:line() return line_text(self.bufnr, self.lnum) end
-
---- @param dir? -1|1
---- @param must? boolean
---- @return u.Pos|nil
-function Pos:next(dir, must)
- if must == nil then must = false end
-
- if dir == nil or dir == 1 then
- -- Next:
- local num_lines = vim.api.nvim_buf_line_count(self.bufnr)
- local last_line = line_text(self.bufnr, num_lines)
- if self.lnum == num_lines and self.col == #last_line then
- if must then error 'error in Pos:next(): Pos:next() returned nil' end
- return nil
- end
-
- local col = self.col + 1
- local line = self.lnum
- local line_max_col = #line_text(self.bufnr, self.lnum)
- if col > line_max_col then
- col = 1
- line = line + 1
- end
- return Pos.new(self.bufnr, line, col, self.off)
- else
- -- Previous:
- if self.col == 1 and self.lnum == 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 prev_line_max_col = #(line_text(self.bufnr, self.lnum - 1) or '')
- if col < 1 then
- col = math.max(prev_line_max_col, 1)
- line = line - 1
- end
- return Pos.new(self.bufnr, 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: u.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: u.Pos): boolean
-function Pos:find_next(dir, predicate)
- if type(predicate) == 'string' then
- local s = predicate
- predicate = function(p) return s == p:char() end
- end
-
- --- @type u.Pos|nil
- 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? u.Pos[]
---- @return u.Pos|nil
-function Pos:find_match(max_chars, invocations)
- if invocations == nil then invocations = {} end
- if vim.tbl_contains(invocations, function(p) return self == p end, { predicate = true }) then
- return nil
- end
- 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)
- -- Store the character we will be looking for:
- local c_match = (is_opener and closers or openers)[i]
-
- --- @type u.Pos|nil
- local cur = self
- --- `adv` is a helper that moves the current position backward or forward,
- --- depending on whether we are looking for an opener or a closer. It returns
- --- nil if 1) the watch-dog `max_chars` falls bellow 0, or 2) if we have gone
- --- beyond the beginning/end of the file.
- --- @return u.Pos|nil
- 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 `c_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
-
---- @param lines string|string[]
-function Pos:insert_before(lines)
- if type(lines) == 'string' then lines = vim.split(lines, '\n') end
- vim.api.nvim_buf_set_text(
- self.bufnr,
- self.lnum - 1,
- self.col - 1,
- self.lnum - 1,
- self.col - 1,
- lines
- )
-end
-
-return Pos
diff --git a/lua/u/range.lua b/lua/u/range.lua
deleted file mode 100644
index b8d8c0f..0000000
--- a/lua/u/range.lua
+++ /dev/null
@@ -1,599 +0,0 @@
-local Pos = require 'u.pos'
-
-local ESC = vim.api.nvim_replace_termcodes('', true, false, true)
-
---- @class u.Range
---- @field start u.Pos
---- @field stop u.Pos|nil
---- @field mode 'v'|'V'
-local Range = {}
-Range.__index = Range
-function Range.__tostring(self)
- --- @param p u.Pos
- local function posstr(p)
- if p == nil then
- return 'nil'
- elseif p.off ~= 0 then
- return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off)
- else
- return string.format('Pos(%d:%d)', p.lnum, p.col)
- end
- end
-
- local _1 = posstr(self.start)
- local _2 = posstr(self.stop)
- return string.format(
- 'Range{bufnr=%d, mode=%s, start=%s, stop=%s}',
- self.start.bufnr,
- self.mode,
- _1,
- _2
- )
-end
-
---------------------------------------------------------------------------------
--- Range constructors:
---------------------------------------------------------------------------------
-
---- @param start u.Pos
---- @param stop u.Pos|nil
---- @param mode? 'v'|'V'
---- @return u.Range
-function Range.new(start, stop, mode)
- if stop ~= nil and stop < start then
- start, stop = stop, start
- end
-
- local r = { start = start, stop = stop, mode = mode or 'v' }
-
- setmetatable(r, Range)
- return r
-end
-
---- @param ranges (u.Range|nil)[]
-function Range.smallest(ranges)
- --- @type u.Range[]
- ranges = vim.iter(ranges):filter(function(r) return r ~= nil and not r:is_empty() end):totable()
- if #ranges == 0 then return nil end
-
- -- find smallest match
- local smallest = ranges[1]
- for _, r in ipairs(ranges) do
- local start, stop = r.start, r.stop
- if start > smallest.start and stop < smallest.stop then smallest = r end
- end
- return smallest
-end
-
---- @param lpos string
---- @param rpos string
---- @return u.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 bufnr? number
-function Range.from_buf_text(bufnr)
- if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
- local num_lines = vim.api.nvim_buf_line_count(bufnr)
-
- local start = Pos.new(bufnr, 1, 1)
- local stop = Pos.new(bufnr, num_lines, Pos.MAX_COL)
- return Range.new(start, stop, 'V')
-end
-
---- @param bufnr? number
---- @param line number 1-based line index
-function Range.from_line(bufnr, line) return Range.from_lines(bufnr, line, line) end
-
---- @param bufnr? number
---- @param start_line number based line index
---- @param stop_line number based line index
-function Range.from_lines(bufnr, start_line, stop_line)
- if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
- if stop_line < 0 then
- local num_lines = vim.api.nvim_buf_line_count(bufnr)
- stop_line = num_lines + stop_line + 1
- end
- return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, Pos.MAX_COL), 'V')
-end
-
---- @param motion string
---- @param opts? { bufnr?: number; contains_cursor?: boolean; pos?: u.Pos, user_defined?: boolean }
---- @return u.Range|nil
-function Range.from_motion(motion, opts)
- -- Options handling:
- opts = opts or {}
- if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end
- if opts.contains_cursor == nil then opts.contains_cursor = false end
- if opts.user_defined == nil then opts.user_defined = false end
-
- -- Extract some information from the motion:
- --- @type 'a'|'i', string
- local scope, motion_rest = motion:sub(1, 1), motion:sub(2)
- local is_txtobj = scope == 'a' or scope == 'i'
- local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest)
-
- -- Capture the original state of the buffer for restoration later.
- local original_state = {
- winview = vim.fn.winsaveview(),
- regquote = vim.fn.getreg '"',
- cursor = vim.fn.getpos '.',
- pos_lbrack = vim.fn.getpos "'[",
- pos_rbrack = vim.fn.getpos "']",
- opfunc = vim.go.operatorfunc,
- prev_captured_range = _G.Range__from_motion_opfunc_captured_range,
- prev_mode = vim.fn.mode(),
- vinf = Range.from_vtext(),
- }
- --- @type u.Range|nil
- _G.Range__from_motion_opfunc_captured_range = nil
-
- vim.api.nvim_buf_call(opts.bufnr, function()
- if opts.pos ~= nil then opts.pos:save_to_pos '.' end
-
- _G.Range__from_motion_opfunc = function(ty)
- _G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
- end
- vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc'
- vim.cmd {
- cmd = 'normal',
- bang = not opts.user_defined,
- args = { ESC .. 'g@' .. motion },
- mods = { silent = true },
- }
- end)
- local captured_range = _G.Range__from_motion_opfunc_captured_range
-
- -- Restore original state:
- vim.fn.winrestview(original_state.winview)
- vim.fn.setreg('"', original_state.regquote)
- vim.fn.setpos('.', original_state.cursor)
- vim.fn.setpos("'[", original_state.pos_lbrack)
- vim.fn.setpos("']", original_state.pos_rbrack)
- if original_state.prev_mode ~= 'n' then original_state.vinf:set_visual_selection() end
- vim.go.operatorfunc = original_state.opfunc
- _G.Range__from_motion_opfunc_captured_range = original_state.prev_captured_range
-
- if not captured_range then return nil end
-
- -- Fixup the bounds:
- if
- -- I have no idea why, but when yanking `i"`, the stop-mark is
- -- placed on the ending quote. For other text-objects, the stop-
- -- mark is placed before the closing character.
- (is_quote_txtobj and scope == 'i' and captured_range.stop:char() == motion_rest)
- -- *Sigh*, this also sometimes happens for `it` as well.
- or (motion == 'it' and captured_range.stop:char() == '<')
- then
- captured_range.stop = captured_range.stop:next(-1) or captured_range.stop
- end
- if is_quote_txtobj and scope == 'a' then
- captured_range.start = captured_range.start:find_next(1, motion_rest) or captured_range.start
- captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop
- end
-
- if
- opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor)))
- then
- return nil
- end
-
- return captured_range
-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 u.Range|nil
-function Range.from_cmd_args(args)
- --- @type 'v'|'V'
- local mode
- --- @type nil|u.Pos
- local start
- local stop
- if args.range == 0 then
- return nil
- else
- start = Pos.from_pos "'<"
- stop = Pos.from_pos "'>"
- mode = stop:is_col_max() and 'V' or 'v'
- end
- return Range.new(start, stop, mode)
-end
-
-function Range.find_nearest_brackets()
- return Range.smallest {
- Range.from_motion('a<', { contains_cursor = true }),
- Range.from_motion('a[', { contains_cursor = true }),
- Range.from_motion('a(', { contains_cursor = true }),
- Range.from_motion('a{', { contains_cursor = true }),
- }
-end
-
-function Range.find_nearest_quotes()
- return Range.smallest {
- Range.from_motion([[a']], { contains_cursor = true }),
- Range.from_motion([[a"]], { contains_cursor = true }),
- Range.from_motion([[a`]], { contains_cursor = true }),
- }
-end
-
---------------------------------------------------------------------------------
--- Structural utilities:
---------------------------------------------------------------------------------
-
-function Range:clone()
- return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode)
-end
-
-function Range:is_empty() return self.stop == nil end
-
-function Range:to_linewise()
- local r = self:clone()
-
- r.mode = 'V'
- r.start.col = 1
- if r.stop ~= nil then r.stop.col = Pos.MAX_COL end
-
- return r
-end
-
---- @param x u.Pos | u.Range
-function Range:contains(x)
- if getmetatable(x) == Pos then
- return not self:is_empty() and x >= self.start and x <= self.stop
- elseif getmetatable(x) == Range then
- return self:contains(x.start) and self:contains(x.stop)
- end
- return false
-end
-
---- @param other u.Range
---- @return u.Range|nil, u.Range|nil
-function Range:difference(other)
- local outer, inner = self, other
- if not outer:contains(inner) then
- outer, inner = inner, outer
- end
- if not outer:contains(inner) then return nil, nil end
-
- local left
- if outer.start ~= inner.start then
- local stop = inner.start:clone() - 1
- left = Range.new(outer.start, stop)
- else
- left = Range.new(outer.start) -- empty range
- end
-
- local right
- if inner.stop ~= outer.stop then
- local start = inner.stop:clone() + 1
- right = Range.new(start, outer.stop)
- else
- right = Range.new(inner.stop) -- empty range
- end
-
- return left, right
-end
-
---- @param left string
---- @param right string
-function Range:save_to_pos(left, right)
- if self:is_empty() then
- self.start:save_to_pos(left)
- self.start:save_to_pos(right)
- else
- self.start:save_to_pos(left)
- self.stop:save_to_pos(right)
- end
-end
-
---- @param left string
---- @param right string
-function Range:save_to_marks(left, right)
- if self:is_empty() then
- self.start:save_to_mark(left)
- self.start:save_to_mark(right)
- else
- self.start:save_to_mark(left)
- self.stop:save_to_mark(right)
- end
-end
-
-function Range:set_visual_selection()
- if self:is_empty() then return end
- if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
- error 'Range:set_visual_selection() called on a buffer other than the current buffer'
- end
-
- local curr_mode = vim.fn.mode()
- if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end
-
- self.start:save_to_pos '.'
- vim.cmd.normal { args = { 'o' }, bang = true }
- self.stop:save_to_pos '.'
-end
-
---------------------------------------------------------------------------------
--- Range.from_* functions:
---------------------------------------------------------------------------------
-
---------------------------------------------------------------------------------
--- Text access/manipulation utilities:
---------------------------------------------------------------------------------
-
-function Range:length()
- if self:is_empty() then return 0 end
-
- local line_positions =
- vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' })
-
- local len = 0
- for linenr, line in ipairs(line_positions) do
- if linenr > 1 then len = len + 1 end -- each newline is counted as a char
- local line_start_col = line[1][3]
- local line_stop_col = line[2][3]
- local line_len = line_stop_col - line_start_col + 1
- len = len + line_len
- end
- return len
-end
-
-function Range:line_count()
- if self:is_empty() then return 0 end
- return self.stop.lnum - self.start.lnum + 1
-end
-
-function Range: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 i number 1-based
---- @param j? number 1-based
-function Range:sub(i, j)
- local line_positions =
- vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' })
-
- --- @param idx number 1-based
- --- @return u.Pos|nil
- local function get_pos(idx)
- if idx < 0 then return get_pos(self:length() + idx + 1) end
-
- -- find the position of the first line that contains the i-th character:
- local curr_len = 0
- for linenr, line in ipairs(line_positions) do
- if linenr > 1 then curr_len = curr_len + 1 end -- each newline is counted as a char
- local line_start_col = line[1][3]
- local line_stop_col = line[2][3]
- local line_len = line_stop_col - line_start_col + 1
-
- if curr_len + line_len >= idx then
- return Pos.new(self.start.bufnr, line[1][2], line_start_col + (idx - curr_len) - 1)
- end
- curr_len = curr_len + line_len
- end
- end
-
- local start = get_pos(i)
- if not start then
- -- start is inalid, so return an empty range:
- return Range.new(self.start, nil, self.mode)
- end
-
- local stop
- if j then stop = get_pos(j) end
- if not stop then
- -- stop is inalid, so return an empty range:
- return Range.new(start, nil, self.mode)
- end
- return Range.new(start, stop, 'v')
-end
-
---- @return string[]
-function Range:lines()
- if self:is_empty() then return {} end
- return vim.fn.getregion(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
-end
-
---- @return string
-function Range:text() return vim.fn.join(self:lines(), '\n') end
-
---- @param l number
--- luacheck: ignore
---- @return { line: string; idx0: { start: number; stop: number; }; lnum: number; range: fun():u.Range; text: fun():string }|nil
-function Range:line(l)
- if l < 0 then l = self:line_count() + l + 1 end
- if l > self:line_count() then return end
-
- local line_indices =
- vim.fn.getregionpos(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
- local line_bounds = line_indices[l]
-
- local start = Pos.new(unpack(line_bounds[1]))
- local stop = Pos.new(unpack(line_bounds[2]))
- return Range.new(start, stop)
-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 bufnr = self.start.bufnr
- local replace_type = (self:is_empty() and 'insert') or (self.mode == 'v' and 'region') or 'lines'
-
- local function update_stop_non_linewise()
- local new_last_line_num = self.start.lnum + #replacement - 1
- local new_last_col = #(replacement[#replacement] or '')
- if new_last_line_num == self.start.lnum then
- new_last_col = new_last_col + self.start.col - 1
- end
- self.stop = Pos.new(bufnr, new_last_line_num, new_last_col)
- end
- local function update_stop_linewise()
- if #replacement == 0 then
- self.stop = nil
- else
- local new_last_line_num = self.start.lnum - 1 + #replacement - 1
- self.stop = Pos.new(bufnr, new_last_line_num + 1, Pos.MAX_COL, self.stop.off)
- end
- self.mode = 'v'
- end
-
- if replace_type == 'insert' then
- -- To insert text at a given `(row, column)` location, use `start_row =
- -- end_row = row` and `start_col = end_col = col`.
- vim.api.nvim_buf_set_text(
- bufnr,
- self.start.lnum - 1,
- self.start.col - 1,
- self.start.lnum - 1,
- self.start.col - 1,
- replacement
- )
- update_stop_non_linewise()
- elseif replace_type == 'region' then
- -- Fixup the bounds:
- local max_col = #self.stop:line()
-
- -- Indexing is zero-based. Row indices are end-inclusive, and column indices
- -- are end-exclusive.
- vim.api.nvim_buf_set_text(
- bufnr,
- self.start.lnum - 1,
- self.start.col - 1,
- self.stop.lnum - 1,
- math.min(self.stop.col, max_col),
- replacement
- )
- update_stop_non_linewise()
- elseif replace_type == 'lines' then
- -- Indexing is zero-based, end-exclusive.
- vim.api.nvim_buf_set_lines(bufnr, self.start.lnum - 1, self.stop.lnum, true, replacement)
- update_stop_linewise()
- 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 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 ''
-
- local winview = vim.fn.winsaveview()
- vim.hl.range(
- self.start.bufnr,
- ns,
- group,
- { self.start.lnum - 1, self.start.col - 1 },
- { self.stop.lnum - 1, self.stop.col - 1 },
- {
- inclusive = true,
- priority = opts.priority,
- timeout = opts.timeout,
- regtype = self.mode,
- }
- )
- if not in_macro then vim.fn.winrestview(winview) end
- vim.cmd.redraw()
-
- return {
- ns = ns,
- clear = function()
- vim.api.nvim_buf_clear_namespace(self.start.bufnr, ns, self.start.lnum - 1, self.stop.lnum)
- vim.cmd.redraw()
- end,
- }
-end
-
-return Range
diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua
deleted file mode 100644
index 1873ecc..0000000
--- a/lua/u/renderer.lua
+++ /dev/null
@@ -1,570 +0,0 @@
-local M = {}
-local H = {}
-
---- @alias u.renderer.Tag { kind: 'tag'; name: string, attributes: table, children: u.renderer.Tree }
---- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag
---- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
-
--- luacheck: ignore
---- @type table, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: table, children: u.renderer.Tree): u.renderer.Tag>
-M.h = setmetatable({}, {
- __call = function(_, name, attributes, children)
- return {
- kind = 'tag',
- name = name,
- attributes = attributes or {},
- children = children,
- }
- end,
- __index = function(_, name)
- -- vim.print('dynamic hl ' .. name)
- return function(attributes, children)
- return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
- end
- end,
-})
-
--- Renderer {{{
---- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
-
---- @class u.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
-
---- @private
---- @param x any
---- @return boolean
-function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
-
---- @private
---- @param x any
---- @return boolean
-function Renderer.is_tag_arr(x)
- if type(x) ~= 'table' then return false end
- return #x == 0 or not Renderer.is_tag(x)
-end
---- @param bufnr number|nil
-function Renderer.new(bufnr) -- {{{
- 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: u.renderer.Tree;
---- on_tag?: fun(tag: u.renderer.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 u.renderer.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 --[[@as u.renderer.Node[] ]])
- do
- -- newlines are not controlled by array entries, do NOT output a line here:
- visit(child)
- end
- else -- luacheck: ignore
- 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: u.renderer.Tag): string;
---- }
-function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
-
---- @param bufnr number
---- @param old_lines string[] | nil
---- @param new_lines string[]
-function Renderer.patch_lines(bufnr, old_lines, new_lines)
- --
- -- Helpers:
- --
-
- --- @param start integer
- --- @param end_ integer
- --- @param strict_indexing boolean
- --- @param replacement string[]
- local function _set_lines(start, end_, strict_indexing, replacement)
- vim.api.nvim_buf_set_lines(bufnr, start, end_, strict_indexing, replacement)
- end
-
- --- @param start_row integer
- --- @param start_col integer
- --- @param end_row integer
- --- @param end_col integer
- --- @param replacement string[]
- local function _set_text(start_row, start_col, end_row, end_col, replacement)
- vim.api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, replacement)
- end
-
- -- Morph the text to the desired state:
- local line_changes =
- H.levenshtein(old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), new_lines)
- for _, line_change in ipairs(line_changes) do
- local lnum0 = line_change.index - 1
-
- if line_change.kind == 'add' then
- _set_lines(lnum0, lnum0, true, { line_change.item })
- elseif line_change.kind == 'change' then
- -- Compute inter-line diff, and apply:
- local col_changes =
- H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, ''))
-
- for _, col_change in ipairs(col_changes) do
- local cnum0 = col_change.index - 1
- if col_change.kind == 'add' then
- _set_text(lnum0, cnum0, lnum0, cnum0, { col_change.item })
- elseif col_change.kind == 'change' then
- _set_text(lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to })
- elseif col_change.kind == 'delete' then
- _set_text(lnum0, cnum0, lnum0, cnum0 + 1, {})
- else -- luacheck: ignore
- -- No change
- end
- end
- elseif line_change.kind == 'delete' then
- _set_lines(lnum0, lnum0 + 1, true, {})
- else -- luacheck: ignore
- -- No change
- end
- end
-end
-
---- @param tree u.renderer.Tree
-function Renderer:render(tree) -- {{{
- 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:_expr_map_callback('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 start integer
---- @param end_ integer
---- @param strict_indexing boolean
---- @param replacement string[]
-function Renderer:_set_lines(start, end_, strict_indexing, replacement)
- vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement)
-end
-
---- @private
---- @param start_row integer
---- @param start_col integer
---- @param end_row integer
---- @param end_col integer
---- @param replacement string[]
-function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement)
- vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement)
-end
-
---- @private
-function Renderer:_reconcile() -- {{{
- --
- -- Step 1: morph the text to the desired state:
- --
- Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines)
- 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
-
- self.old = self.curr
-end -- }}}
-
---- @private
---- @param mode string
---- @param lhs string
-function Renderer:_expr_map_callback(mode, lhs) -- {{{
- -- find the tag with the smallest intersection that contains the cursor:
- local pos0 = vim.api.nvim_win_get_cursor(0)
- pos0[1] = pos0[1] - 1 -- make it actually 0-based
- local pos_infos = self:get_pos_infos(pos0)
-
- 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: u.renderer.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: u.renderer.Tag }[]
- local matching_tags = vim
- .iter(intersecting_extmarks)
- --- @param ext RendererExtmark
- :map(function(ext)
- for _, extmark_cache in ipairs(self.curr.extmarks) do
- if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end
- end
- end)
- :totable()
-
- return matching_tags
-end -- }}}
--- }}}
-
--- TreeBuilder {{{
---- @class u.TreeBuilder
---- @field private nodes u.renderer.Node[]
-local TreeBuilder = {}
-TreeBuilder.__index = TreeBuilder
-M.TreeBuilder = TreeBuilder
-
-function TreeBuilder.new()
- local self = setmetatable({ nodes = {} }, TreeBuilder)
- return self
-end
-
---- @param nodes u.renderer.Tree
---- @return u.TreeBuilder
-function TreeBuilder:put(nodes)
- table.insert(self.nodes, nodes)
- return self
-end
-
---- @param name string
---- @param attributes? table
---- @param children? u.renderer.Node | u.renderer.Node[]
---- @return u.TreeBuilder
-function TreeBuilder:put_h(name, attributes, children)
- local tag = M.h(name, attributes, children)
- table.insert(self.nodes, tag)
- return self
-end
-
---- @param fn fun(TreeBuilder): any
---- @return u.TreeBuilder
-function TreeBuilder:nest(fn)
- local nested_writer = TreeBuilder.new()
- fn(nested_writer)
- table.insert(self.nodes, nested_writer.nodes)
- return self
-end
-
---- @return u.renderer.Tree
-function TreeBuilder:tree() return self.nodes end
--- }}}
-
--- Levenshtein utility {{{
--- luacheck: ignore
---- @alias LevenshteinChange ({ 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[] The changes, from last (greatest index) to first (smallest index).
-function H.levenshtein(x, y, cost)
- -- At the moment, this whole `cost` plumbing is not used. Deletes have the
- -- same cost as Adds or Changes. I can imagine a future, however, where
- -- fudging with the costs of operations produces a more optimized change-set
- -- that is tailored to working better with how NeoVim manipulates text. I've
- -- done no further investigation in this area, however, so it's impossible to
- -- tell if such tuning would produce real benefit. For now, I'm leaving this
- -- in here even though it's not actively used. Hopefully having this
- -- callback-based plumbing does not cause too much of a performance hit to
- -- the renderer.
- cost = cost or {}
- local cost_of_delete_f = cost.of_delete or function() return 1 end
- local cost_of_add_f = cost.of_add or function() return 1 end
- local cost_of_change_f = cost.of_change or function() return 1 end
-
- local m, n = #x, #y
- -- Initialize the distance matrix
- local dp = {}
- for i = 0, m do
- dp[i] = {}
- for j = 0, n do
- dp[i][j] = 0
- end
- end
-
- -- Fill the base cases
- for i = 0, m do
- dp[i][0] = i
- end
- for j = 0, n do
- dp[0][j] = j
- end
-
- -- Compute the Levenshtein distance dynamically
- for i = 1, m do
- for j = 1, n do
- if x[i] == y[j] then
- dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
- else
- local cost_delete = dp[i - 1][j] + cost_of_delete_f(x[i])
- local cost_add = dp[i][j - 1] + cost_of_add_f(y[j])
- local cost_change = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j])
- dp[i][j] = math.min(cost_delete, cost_add, cost_change)
- end
- end
- end
-
- -- Backtrack to find the changes
- local i = m
- local j = n
- --- @type LevenshteinChange[]
- local changes = {}
-
- while i > 0 or j > 0 do
- local default_cost = dp[i][j]
- local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost
- local cost_of_add = j > 0 and dp[i][j - 1] or default_cost
- local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost
-
- --- @param u number
- --- @param v number
- --- @param w number
- local function is_first_min(u, v, w) return u <= v and u <= w end
-
- if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
- -- potential change
- if x[i] ~= y[j] then
- --- @type LevenshteinChange
- local change = { kind = 'change', from = x[i], index = i, to = y[j] }
- table.insert(changes, change)
- end
- i = i - 1
- j = j - 1
- elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
- -- addition
- --- @type LevenshteinChange
- local change = { kind = 'add', item = y[j], index = i + 1 }
- table.insert(changes, change)
- j = j - 1
- elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
- -- deletion
- --- @type LevenshteinChange
- local change = { kind = 'delete', item = x[i], index = i }
- table.insert(changes, change)
- i = i - 1
- else
- error 'unreachable'
- end
- end
-
- return changes
-end
--- }}}
-
-return M
diff --git a/lua/u/repeat.lua b/lua/u/repeat.lua
deleted file mode 100644
index 71755c0..0000000
--- a/lua/u/repeat.lua
+++ /dev/null
@@ -1,39 +0,0 @@
-local M = {}
-
-local IS_REPEATING = false
---- @type function
-local REPEAT_ACTION = nil
-
-local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end
-
---- @param f fun()
-function M.run_repeatable(f)
- REPEAT_ACTION = f
- REPEAT_ACTION()
- vim.b.my_changedtick = vim.b.changedtick
-end
-
-function M.is_repeating() return IS_REPEATING end
-
-function M.setup()
- vim.keymap.set('n', '.', function()
- IS_REPEATING = true
- for _ = 1, vim.v.count1 do
- if is_repeatable_last_mutator() and type(REPEAT_ACTION) == 'function' then
- M.run_repeatable(REPEAT_ACTION)
- else
- vim.cmd { cmd = 'normal', args = { '.' }, bang = true }
- end
- end
- IS_REPEATING = false
- end)
- vim.keymap.set('n', 'u', function()
- local was_repeatable_last_mutator = is_repeatable_last_mutator()
- for _ = 1, vim.v.count1 do
- vim.cmd { cmd = 'normal', args = { 'u' }, bang = true }
- end
- if was_repeatable_last_mutator then vim.b.my_changedtick = vim.b.changedtick end
- end)
-end
-
-return M
diff --git a/lua/u/tracker.lua b/lua/u/tracker.lua
deleted file mode 100644
index f3ea8ad..0000000
--- a/lua/u/tracker.lua
+++ /dev/null
@@ -1,307 +0,0 @@
-local M = {}
-
-M.debug = false
-
---------------------------------------------------------------------------------
--- class Signal
---------------------------------------------------------------------------------
-
---- @class u.Signal
---- @field name? string
---- @field private changing boolean
---- @field private value any
---- @field private subscribers table
---- @field private on_dispose_callbacks function[]
-local Signal = {}
-M.Signal = Signal
-Signal.__index = Signal
-
---- @param value any
---- @param name? string
---- @return u.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 u.Signal --
-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 u.Signal
-function Signal:clone()
- return self:map(function(x) return x end)
-end
-
---- @param fn fun(value: T): boolean
---- @return u.Signal --
-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 u.Signal --
-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)
-
- --- @diagnostic disable-next-line: undefined-doc-name
- --- @type { queued: { value: T, ts: number }[]; timer?: uv_timer_t; }
- local state = { queued = {}, timer = nil }
- local function clear_timeout()
- if state.timer == nil then return end
- pcall(function()
- --- @diagnostic disable-next-line: undefined-field
- state.timer:stop()
- --- @diagnostic disable-next-line: undefined-field
- state.timer:close()
- end)
- state.timer = nil
- 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
---------------------------------------------------------------------------------
-
-local CURRENT_CONTEXT = nil
-
---- @class u.ExecutionContext
---- @field signals table
-local ExecutionContext = {}
-M.ExecutionContext = ExecutionContext
-ExecutionContext.__index = ExecutionContext
-
---- @return u.ExecutionContext
-function ExecutionContext.new()
- return setmetatable({
- signals = {},
- subscribers = {},
- }, ExecutionContext)
-end
-
-function ExecutionContext.current() return CURRENT_CONTEXT end
-
---- @param fn function
---- @param ctx u.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 u.Signal
-function M.create_signal(value, name) return Signal:new(value, name) end
-
---- @param fn function
---- @param name? string
---- @return u.Signal
-function M.create_memo(fn, name)
- --- @type u.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
diff --git a/lua/u/txtobj.lua b/lua/u/txtobj.lua
deleted file mode 100644
index dc399a2..0000000
--- a/lua/u/txtobj.lua
+++ /dev/null
@@ -1,45 +0,0 @@
-local Range = require 'u.range'
-
-local M = {}
-
-local ESC = vim.api.nvim_replace_termcodes('', true, false, true)
-
---- @param key_seq string
---- @param fn fun(key_seq: string):u.Range|nil
---- @param opts? { buffer: number|nil }
-function M.define(key_seq, fn, opts)
- if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end
-
- local function handle_visual()
- local range = fn(key_seq)
- if range == nil or range:is_empty() then
- vim.cmd.normal(ESC)
- return
- end
- range:set_visual_selection()
- end
- vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil)
-
- local function handle_normal()
- local range = fn(key_seq)
- if range == nil then return end
-
- if not range:is_empty() then
- range:set_visual_selection()
- else
- local original_eventignore = vim.go.eventignore
- vim.go.eventignore = 'all'
-
- -- insert a single space, so we can select it:
- local p = range.start
- p:insert_before ' '
- vim.go.eventignore = original_eventignore
-
- -- select the space:
- Range.new(p, p, 'v'):set_visual_selection()
- end
- end
- vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil)
-end
-
-return M
diff --git a/lua/u/utils.lua b/lua/u/utils.lua
deleted file mode 100644
index d0d7d90..0000000
--- a/lua/u/utils.lua
+++ /dev/null
@@ -1,56 +0,0 @@
-local M = {}
-
---
--- Types
---
-
---- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
---- @alias KeyMaps table }
--- luacheck: ignore
---- @alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: u.Range|nil }
-
---- @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
--- luacheck: ignore
---- @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
-
-function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
-
-return M
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..c413566
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,107 @@
+################################################################################
+## Tool Alias
+################################################################################
+
+################################################################################
+## Tools
+################################################################################
+
+[tools]
+# nvimv needs jq:
+jq = "1.8.1"
+"asdf:mise-plugins/mise-lua" = "5.1.5"
+# Since we have busted configured to look for "nvim", have a "default" version
+# installed. In the tests, we will override with `eval $(nvimv env VERSION)`,
+# but to avoid having to litter a bunch of commands with that environment
+# initialization, this just makes things simpler:
+neovim = "0.12.1"
+stylua = "2.3.1"
+"cargo:emmylua_ls" = "0.20.0"
+"cargo:emmylua_check" = "0.20.0"
+
+################################################################################
+# Env
+################################################################################
+
+[env]
+ASDF_LUA_LUAROCKS_VERSION = "3.12.2"
+_.source = { path = "./scripts/env.sh", tools = true }
+
+################################################################################
+# Tasks
+################################################################################
+
+[tasks]
+lint = "emmylua_check --ignore '.prefix/**/*.*' ."
+fmt = "stylua ."
+"fmt:check" = "stylua --check ."
+"test:prepare" = '''
+# Install Lua test dependencies (busted, etc.):
+luarocks test --prepare
+echo
+
+# Check that the nightly version of Neovim is not more than a day old:
+if find .prefix -path '**/nightly/**/nvim' -mtime -1 2>/dev/null | grep -q .; then
+ echo "Neovim Nightly is up-to-date"
+else
+ if ./nvimv/nvimv ls | grep nightly >/dev/null; then
+ ./nvimv/nvimv upgrade nightly
+ else
+ ./nvimv/nvimv install nightly
+ fi
+fi
+echo
+'''
+
+[tasks."test:version:no-prep"]
+hide = true
+usage = '''
+arg "" help="The version of Neovim to test with"
+'''
+run = '''
+echo
+echo -----------------------------
+echo -----------------------------
+echo Neovim version=$usage_version
+echo -----------------------------
+echo -----------------------------
+echo
+./nvimv/nvimv install $usage_version
+eval $(./nvimv/nvimv env $usage_version)
+busted --verbose
+'''
+
+[tasks."test:version"]
+depends = ["test:prepare"]
+usage = '''
+arg "" 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
+'''
diff --git a/nvimv b/nvimv
new file mode 160000
index 0000000..bd5c243
--- /dev/null
+++ b/nvimv
@@ -0,0 +1 @@
+Subproject commit bd5c243b9606a93ec7034a9a8cbd5671951b90ff
diff --git a/scripts/env.sh b/scripts/env.sh
new file mode 100644
index 0000000..85e0f19
--- /dev/null
+++ b/scripts/env.sh
@@ -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)
diff --git a/scripts/make-artifact-tag.sh b/scripts/make-artifact-tag.sh
new file mode 100755
index 0000000..f9d7fb7
--- /dev/null
+++ b/scripts/make-artifact-tag.sh
@@ -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 "
+ 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"
diff --git a/shell.nix b/shell.nix
deleted file mode 100644
index aecdea1..0000000
--- a/shell.nix
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- pkgs ?
- import
- # nixpkgs-unstable (neovim@0.11.2):
- (fetchTarball {
- url = "https://github.com/nixos/nixpkgs/archive/e4b09e47ace7d87de083786b404bf232eb6c89d8.tar.gz";
- sha256 = "1a2qvp2yz8j1jcggl1yvqmdxicbdqq58nv7hihmw3bzg9cjyqm26";
- })
- { },
-}:
-pkgs.mkShell {
- packages = [
- pkgs.git
- pkgs.gnumake
- pkgs.lua-language-server
- pkgs.lua51Packages.busted
- pkgs.lua51Packages.luacov
- pkgs.lua51Packages.luarocks
- pkgs.lua51Packages.nlua
- pkgs.neovim
- pkgs.stylua
- ];
-}
diff --git a/spec/buffer_spec.lua b/spec/buffer_spec.lua
deleted file mode 100644
index f54129e..0000000
--- a/spec/buffer_spec.lua
+++ /dev/null
@@ -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.bufnr, 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(2, -2):replace 'too'
- local actual_lines = vim.api.nvim_buf_get_lines(buf.bufnr, 0, -1, false)
- assert.are.same({
- 'one',
- 'too',
- 'three',
- }, actual_lines)
- end)
- end)
-end)
diff --git a/spec/codewriter_spec.lua b/spec/codewriter_spec.lua
deleted file mode 100644
index c4596de..0000000
--- a/spec/codewriter_spec.lua
+++ /dev/null
@@ -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)
diff --git a/spec/pos_spec.lua b/spec/pos_spec.lua
deleted file mode 100644
index 8420311..0000000
--- a/spec/pos_spec.lua
+++ /dev/null
@@ -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, 1, 1):char())
- assert.are.same('d', Pos.new(nil, 1, 3):char())
- assert.are.same('f', Pos.new(nil, 1, 4):char())
- assert.are.same('a', Pos.new(nil, 3, 1):char())
- assert.are.same('', Pos.new(nil, 4, 1):char())
- assert.are.same('o', Pos.new(nil, 5, 3):char())
- 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, 1, 2), Pos.new(nil, 1, 1):next())
- -- line 1: d => f
- assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 1, 3):next())
- -- line 1 => 2
- assert.are.same(Pos.new(nil, 2, 1), Pos.new(nil, 1, 4):next())
- -- line 3 => 4
- assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 3, 1):next())
- -- line 4 => 5
- assert.are.same(Pos.new(nil, 5, 1), Pos.new(nil, 4, 1):next())
- -- end returns nil
- assert.are.same(nil, Pos.new(nil, 5, 3):next())
- end)
- end)
-
- it('get the previous position', function()
- withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
- -- line 1: s => a
- assert.are.same(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2):next(-1))
- -- line 1: f => d
- assert.are.same(Pos.new(nil, 1, 3), Pos.new(nil, 1, 4):next(-1))
- -- line 2 => 1
- assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 2, 1):next(-1))
- -- line 4 => 3
- assert.are.same(Pos.new(nil, 3, 1), Pos.new(nil, 4, 1):next(-1))
- -- line 5 => 4
- assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 5, 1):next(-1))
- -- beginning returns nil
- assert.are.same(nil, Pos.new(nil, 1, 1):next(-1))
- end)
- end)
-
- it('find matching brackets', function()
- withbuf({ 'asdf ({} def <[{}]>) ;lkj' }, function()
- -- outer parens are matched:
- assert.are.same(Pos.new(nil, 1, 20), Pos.new(nil, 1, 6):find_match())
- -- outer parens are matched (backward):
- assert.are.same(Pos.new(nil, 1, 6), Pos.new(nil, 1, 20):find_match())
- -- no potential match returns nil
- assert.are.same(nil, Pos.new(nil, 1, 1):find_match())
- -- watchdog expires before an otherwise valid match is found:
- assert.are.same(nil, Pos.new(nil, 1, 6):find_match(2))
- end)
- end)
-end)
diff --git a/spec/range_spec.lua b/spec/range_spec.lua
deleted file mode 100644
index f30dd40..0000000
--- a/spec/range_spec.lua
+++ /dev/null
@@ -1,620 +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, 1, 2), Pos.new(nil, 1, 4), '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, 2, 5), Pos.new(nil, 3, 5), '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, 1, 1), Pos.new(nil, 1, 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, 2, 1), Pos.new(nil, 3, 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, 1)
- 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, 1, 2)
- 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, 2, 5), Pos.new(nil, 2, 9), 'v')
- range:replace 'quack'
-
- local text = Range.from_line(nil, 2):text()
- assert.are.same('the quack brown fox', text)
- end)
- end)
-
- it('delete within line', function()
- withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
- local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 10), 'v')
- range:replace ''
-
- local text = Range.from_line(nil, 2):text()
- assert.are.same('the brown fox', text)
- end)
-
- withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
- local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 10), 'v')
- range:replace(nil)
-
- local text = Range.from_line(nil, 2):text()
- assert.are.same('the brown fox', text)
- end)
- end)
-
- it('replace across multiple lines: v', function()
- withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
- local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 3, 5), 'v')
- 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, 2)
- 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, 2, 3)
- 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, 2)
- 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, 2, 3)
- 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_motion('aw'):text())
-
- vim.fn.setpos('.', { 0, 1, 5, 0 })
- assert.are.same('quick', Range.from_motion('iw'):text())
- end)
- end)
-
- it('text object: quote', function()
- withbuf({ [[the "quick" brown fox]] }, function()
- vim.fn.setpos('.', { 0, 1, 5, 0 })
- assert.are.same('"quick"', Range.from_motion('a"'):text())
-
- vim.fn.setpos('.', { 0, 1, 6, 0 })
- assert.are.same('quick', Range.from_motion('i"'):text())
- end)
-
- withbuf({ [[the 'quick' brown fox]] }, function()
- vim.fn.setpos('.', { 0, 1, 5, 0 })
- assert.are.same("'quick'", Range.from_motion([[a']]):text())
-
- vim.fn.setpos('.', { 0, 1, 6, 0 })
- assert.are.same('quick', Range.from_motion([[i']]):text())
- end)
-
- withbuf({ [[the `quick` brown fox]] }, function()
- vim.fn.setpos('.', { 0, 1, 5, 0 })
- assert.are.same('`quick`', Range.from_motion([[a`]]):text())
-
- vim.fn.setpos('.', { 0, 1, 6, 0 })
- assert.are.same('quick', Range.from_motion([[i`]]):text())
- end)
- end)
-
- it('text object: block', function()
- withbuf({ 'this is a {', 'block', '} here' }, function()
- vim.fn.setpos('.', { 0, 2, 1, 0 })
- assert.are.same('{\nblock\n}', Range.from_motion('a{'):text())
-
- vim.fn.setpos('.', { 0, 2, 1, 0 })
- assert.are.same('block', Range.from_motion('i{'):text())
- end)
- end)
-
- it('text object: restores cursor position', function()
- withbuf({ 'this is a {block} here' }, function()
- vim.fn.setpos('.', { 0, 1, 13, 0 })
- assert.are.same('{block}', Range.from_motion('a{'):text())
- assert.are.same(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('line', function()
- withbuf({
- 'this is a {',
- 'block',
- '} here',
- }, function()
- local range = Range.new(Pos.new(0, 1, 6), Pos.new(0, 2, 5), 'v')
- local lfirst = assert(range:line(1), 'lfirst null')
- assert.are.same('is a {', lfirst:text())
- assert.are.same(Pos.new(0, 1, 6), lfirst.start)
- assert.are.same(Pos.new(0, 1, 11), lfirst.stop)
- assert.are.same('block', range:line(2):text())
- 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, 1, 3))
- assert.are.same(range.stop, Pos.new(nil, 1, 4))
- assert.are.same(range.mode, 'v')
- end)
- end)
-
- it('from_op_func', function()
- withbuf({ 'line one', 'and line two' }, function()
- local a = Pos.new(nil, 1, 1)
- local b = Pos.new(nil, 2, 2)
- 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, 2, 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, 1, 1)
- local b = Pos.new(nil, 2, 2)
- 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, 1, 5))
- assert.are.same(range.stop, Pos.new(nil, 1, 11))
- end)
-
- withbuf({ [[the 'quick' brown fox]] }, function()
- vim.fn.setpos('.', { 0, 1, 5, 0 })
- local range = Range.find_nearest_quotes()
- assert.are.same(range.start, Pos.new(nil, 1, 5))
- assert.are.same(range.stop, Pos.new(nil, 1, 11))
- end)
- end)
-
- it('smallest', function()
- local r1 = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 1, 4), 'v')
- local r2 = Range.new(Pos.new(nil, 1, 3), Pos.new(nil, 1, 5), 'v')
- local r3 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 6), 'v')
- local smallest = Range.smallest { r1, r2, r3 }
- assert.are.same(smallest.start, Pos.new(nil, 1, 2))
- assert.are.same(smallest.stop, Pos.new(nil, 1, 4))
- 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, 1, 2), Pos.new(nil, 2, 4), 'v')
- local linewise_range = range:to_linewise()
- assert.are.same(linewise_range.start.col, 1)
- 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, 1, 1), nil, 'v')
- assert.is_true(range:is_empty())
-
- local range2 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2), 'v')
- assert.is_false(range2:is_empty())
- end)
- end)
-
- it('trim_start', function()
- withbuf({ ' line one', 'line two' }, function()
- local range = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 10), 'v')
- local trimmed = range:trim_start()
- assert.are.same(trimmed.start, Pos.new(nil, 1, 4)) -- should be after the spaces
- end)
- end)
-
- it('trim_stop', function()
- withbuf({ 'line one ', 'line two' }, function()
- local range = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 10), 'v')
- local trimmed = range:trim_stop()
- assert.are.same(trimmed.stop, Pos.new(nil, 1, 8)) -- should be before the spaces
- end)
- end)
-
- it('contains', function()
- withbuf({ 'line one', 'and line two' }, function()
- local range = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 1, 4), 'v')
- local pos = Pos.new(nil, 1, 3)
- assert.is_true(range:contains(pos))
-
- pos = Pos.new(nil, 1, 5) -- outside of range
- assert.is_false(range:contains(pos))
- end)
- end)
-
- it('difference', function()
- withbuf({ 'line one', 'and line two' }, function()
- local range_outer = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 2, 12), 'v')
- local range_inner = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 8), 'v')
-
- assert.are.same(range_outer:text(), 'and line two')
- assert.are.same(range_inner:text(), 'line')
-
- local left, right = range_outer:difference(range_inner)
- assert.are.same(left:text(), 'and ')
- assert.are.same(right:text(), ' two')
-
- left, right = range_inner:difference(range_outer)
- assert.are.same(left:text(), 'and ')
- assert.are.same(right:text(), ' two')
-
- left, right = range_outer:difference(range_outer)
- assert.are.same(left:is_empty(), true)
- assert.are.same(left:text(), '')
- assert.are.same(right:is_empty(), true)
- assert.are.same(right:text(), '')
- end)
- end)
-
- it('length', function()
- withbuf({ 'line one', 'and line two' }, function()
- local range = Range.new(Pos.new(nil, 2, 4), Pos.new(nil, 2, 9), 'v')
- assert.are.same(range:length(), #range:text())
-
- range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 2, 9), 'v')
- assert.are.same(range:length(), #range:text())
- end)
- end)
-
- it('sub', function()
- withbuf({ 'line one', 'and line two' }, function()
- local range = Range.new(Pos.new(nil, 2, 4), Pos.new(nil, 2, 9), 'v')
- assert.are.same(range:text(), ' line ')
- assert.are.same(range:sub(1, -1):text(), ' line ')
- assert.are.same(range:sub(2, -2):text(), 'line')
- assert.are.same(range:sub(1, 5):text(), ' line')
- assert.are.same(range:sub(2, 5):text(), 'line')
- assert.are.same(range:sub(20, 25):text(), '')
- end)
- end)
-
- it('shrink', function()
- withbuf({ 'line one', 'and line two' }, function()
- local range = Range.new(Pos.new(nil, 2, 3), Pos.new(nil, 3, 5), 'v')
- local shrunk = range:shrink(1)
- assert.are.same(shrunk.start, Pos.new(nil, 2, 4))
- assert.are.same(shrunk.stop, Pos.new(nil, 3, 4))
- end)
- end)
-
- it('must_shrink', function()
- withbuf({ 'line one', 'and line two' }, function()
- local range = Range.new(Pos.new(nil, 2, 3), Pos.new(nil, 3, 5), 'v')
- local shrunk = range:must_shrink(1)
- assert.are.same(shrunk.start, Pos.new(nil, 2, 4))
- assert.are.same(shrunk.stop, Pos.new(nil, 3, 4))
-
- assert.has.error(
- function() range:must_shrink(100) end,
- 'error in Range:must_shrink: Range:shrink() returned nil'
- )
- end)
- end)
-
- it('set_visual_selection', function()
- withbuf({ 'line one', 'and line two' }, function()
- local range = Range.from_lines(nil, 1, 2)
- range:set_visual_selection()
-
- assert.are.same(Pos.from_pos 'v', Pos.new(nil, 1, 1))
- -- Since the selection is 'V' (instead of 'v'), the end
- -- selects one character past the end:
- assert.are.same(Pos.from_pos '.', Pos.new(nil, 2, 13))
- end)
- end)
-
- it('selections set to past the EOL should not error', function()
- withbuf({ 'Rg SET NAMES' }, function()
- local b = vim.api.nvim_get_current_buf()
- local r = Range.new(Pos.new(b, 1, 4), Pos.new(b, 1, 13), 'v')
- r:replace 'bleh'
- assert.are.same({ 'Rg bleh' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
- end)
-
- withbuf({ 'Rg SET NAMES' }, function()
- local b = vim.api.nvim_get_current_buf()
- local r = Range.new(Pos.new(b, 1, 4), Pos.new(b, 1, 12), 'v')
- 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, 1, 5), Pos.new(b, 1, 9), 'v')
-
- r:replace 'bleh1'
- assert.are.same(
- { 'The bleh1 brown fox jumps over the lazy dog' },
- vim.api.nvim_buf_get_lines(b, 0, -1, false)
- )
-
- 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, 1, 21), Pos.new(b, 2, 4), 'v')
- assert.are.same({ 'jumps', 'over' }, r:lines())
-
- r:replace 'bleh1'
- assert.are.same(
- { 'The quick brown fox bleh1 the lazy dog' },
- vim.api.nvim_buf_get_lines(b, 0, -1, false)
- )
- assert.are.same({ 'bleh1' }, r:lines())
-
- 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, 2, 1), Pos.new(b, 4, 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, 2, 1), Pos.new(b, 4, 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)
diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua
deleted file mode 100644
index 8e38a2d..0000000
--- a/spec/renderer_spec.lua
+++ /dev/null
@@ -1,135 +0,0 @@
-local R = require 'u.renderer'
-local withbuf = loadfile './spec/withbuf.lua'()
-
-local function getlines() return vim.api.nvim_buf_get_lines(0, 0, -1, true) end
-
-describe('Renderer', function()
- it('should render text in an empty buffer', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render { 'hello', ' ', 'world' }
- assert.are.same(getlines(), { 'hello world' })
- end)
- end)
-
- it('should result in the correct text after repeated renders', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render { 'hello', ' ', 'world' }
- assert.are.same(getlines(), { 'hello world' })
-
- r:render { 'goodbye', ' ', 'world' }
- assert.are.same(getlines(), { 'goodbye world' })
-
- r:render { 'hello', ' ', 'universe' }
- assert.are.same(getlines(), { 'hello universe' })
- end)
- end)
-
- it('should handle tags correctly', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render {
- R.h('text', { hl = 'HighlightGroup' }, 'hello '),
- R.h('text', { hl = 'HighlightGroup' }, 'world'),
- }
- assert.are.same(getlines(), { 'hello world' })
- end)
- end)
-
- it('should reconcile added lines', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render { 'line 1', '\n', 'line 2' }
- assert.are.same(getlines(), { 'line 1', 'line 2' })
-
- -- Add a new line:
- r:render { 'line 1', '\n', 'line 2\n', 'line 3' }
- assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' })
- end)
- end)
-
- it('should reconcile deleted lines', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render { 'line 1', '\nline 2', '\nline 3' }
- assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' })
-
- -- Remove a line:
- r:render { 'line 1', '\nline 3' }
- assert.are.same(getlines(), { 'line 1', 'line 3' })
- end)
- end)
-
- it('should handle multiple nested elements', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render {
- R.h('text', {}, {
- 'first line',
- }),
- '\n',
- R.h('text', {}, 'second line'),
- }
- assert.are.same(getlines(), { 'first line', 'second line' })
-
- r:render {
- R.h('text', {}, 'updated first line'),
- '\n',
- R.h('text', {}, 'third line'),
- }
- assert.are.same(getlines(), { 'updated first line', 'third line' })
- end)
- end)
-
- --
- -- get_pos_infos
- --
-
- it('should return no extmarks for an empty buffer', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- local pos_infos = r:get_pos_infos { 0, 0 }
- assert.are.same(pos_infos, {})
- end)
- end)
-
- it('should return correct extmark for a given position', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render {
- R.h('text', { hl = 'HighlightGroup1' }, 'Hello'),
- R.h('text', { hl = 'HighlightGroup2' }, ' World'),
- }
-
- local pos_infos = r:get_pos_infos { 0, 2 }
-
- assert.are.same(#pos_infos, 1)
- assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
- assert.are.same(pos_infos[1].extmark.start, { 0, 0 })
- assert.are.same(pos_infos[1].extmark.stop, { 0, 5 })
- end)
- end)
-
- it('should return multiple extmarks for overlapping text', function()
- withbuf({}, function()
- local r = R.Renderer.new(0)
- r:render {
- R.h('text', { hl = 'HighlightGroup1' }, {
- 'Hello',
- R.h(
- 'text',
- { hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } },
- ' World'
- ),
- }),
- }
-
- local pos_infos = r:get_pos_infos { 0, 5 }
-
- assert.are.same(#pos_infos, 2)
- assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2')
- assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1')
- end)
- end)
-end)
diff --git a/spec/tracker_spec.lua b/spec/tracker_spec.lua
deleted file mode 100644
index fa81607..0000000
--- a/spec/tracker_spec.lua
+++ /dev/null
@@ -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 test_signal = Signal:new(5)
- local mapped_signal = test_signal:map(function(value) return value * 2 end)
-
- assert.is.equal(mapped_signal:get(), 10) -- Initial transformation
- test_signal:set(10)
- assert.is.equal(mapped_signal:get(), 20) -- Updated transformation
- end)
-
- it('should handle empty transformations', function()
- local test_signal = Signal:new(nil)
- local mapped_signal = test_signal:map(function(value) return value or 'default' end)
-
- assert.is.equal(mapped_signal:get(), 'default') -- Return default
- test_signal:set 'new value'
- assert.is.equal(mapped_signal:get(), 'new value') -- Return new value
- end)
- end)
-
- describe('Signal:filter', function()
- it('should only emit values that pass the filter', function()
- local test_signal = Signal:new(5)
- local filtered_signal = test_signal:filter(function(value) return value > 10 end)
-
- assert.is.equal(filtered_signal:get(), nil) -- Initial value should not pass
- test_signal:set(15)
- assert.is.equal(filtered_signal:get(), 15) -- Now filtered
- test_signal:set(8)
- assert.is.equal(filtered_signal:get(), 15) -- Does not pass the filter
- end)
-
- it('should handle empty initial values', function()
- local test_signal = Signal:new(nil)
- local filtered_signal = test_signal:filter(function(value) return value ~= nil end)
-
- assert.is.equal(filtered_signal:get(), nil) -- Should be nil
- test_signal:set(10)
- assert.is.equal(filtered_signal:get(), 10) -- Should pass now
- end)
- end)
-
- describe('create_memo', function()
- it('should compute a derived value and update when dependencies change', function()
- local test_signal = Signal:new(2)
- local memoized_signal = tracker.create_memo(function() return test_signal:get() * 2 end)
-
- assert.is.equal(memoized_signal:get(), 4) -- Initially compute 2 * 2
-
- test_signal:set(3)
- assert.is.equal(memoized_signal:get(), 6) -- Update to 3 * 2 = 6
-
- test_signal:set(5)
- assert.is.equal(memoized_signal:get(), 10) -- Update to 5 * 2 = 10
- end)
-
- it('should not recompute if the dependencies do not change', function()
- local call_count = 0
- local test_signal = Signal:new(10)
- local memoized_signal = tracker.create_memo(function()
- call_count = call_count + 1
- return test_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
-
- test_signal:set(10) -- Set the same value
- assert.is.equal(memoized_signal:get(), 11)
- assert.is.equal(call_count, 2)
-
- test_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 test_signal = Signal:new(5)
- local call_count = 0
-
- tracker.create_effect(function()
- test_signal:get() -- track as a dependency
- call_count = call_count + 1
- end)
-
- assert.is.equal(call_count, 1)
- test_signal:set(10)
- assert.is.equal(call_count, 2)
- end)
-
- it('should clean up signals and not call after dispose', function()
- local test_signal = Signal:new(5)
- local call_count = 0
-
- local unsubscribe = tracker.create_effect(function()
- call_count = call_count + 1
- return test_signal:get() * 2
- end)
-
- assert.is.equal(call_count, 1) -- Initially calls
- unsubscribe() -- Unsubscribe the effect
- test_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)
diff --git a/spec/u_spec.lua b/spec/u_spec.lua
new file mode 100644
index 0000000..2b95967
--- /dev/null
+++ b/spec/u_spec.lua
@@ -0,0 +1,1614 @@
+require 'luacov'
+
+--- @diagnostic disable: undefined-field, need-check-nil
+local Pos = require('u').Pos
+local Range = require('u').Range
+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
+
+describe('Pos', function()
+ it('get a char from a given position', function()
+ withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
+ assert.are.same('a', Pos.new(nil, 1, 1):char())
+ assert.are.same('d', Pos.new(nil, 1, 3):char())
+ assert.are.same('f', Pos.new(nil, 1, 4):char())
+ assert.are.same('a', Pos.new(nil, 3, 1):char())
+ assert.are.same('', Pos.new(nil, 4, 1):char())
+ assert.are.same('o', Pos.new(nil, 5, 3):char())
+ 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, 1, 2), Pos.new(nil, 1, 1):next())
+ -- line 1: d => f
+ assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 1, 3):next())
+ -- line 1 => 2
+ assert.are.same(Pos.new(nil, 2, 1), Pos.new(nil, 1, 4):next())
+ -- line 3 => 4
+ assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 3, 1):next())
+ -- line 4 => 5
+ assert.are.same(Pos.new(nil, 5, 1), Pos.new(nil, 4, 1):next())
+ -- end returns nil
+ assert.are.same(nil, Pos.new(nil, 5, 3):next())
+ end)
+ end)
+
+ it('get the previous position', function()
+ withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
+ -- line 1: s => a
+ assert.are.same(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2):next(-1))
+ -- line 1: f => d
+ assert.are.same(Pos.new(nil, 1, 3), Pos.new(nil, 1, 4):next(-1))
+ -- line 2 => 1
+ assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 2, 1):next(-1))
+ -- line 4 => 3
+ assert.are.same(Pos.new(nil, 3, 1), Pos.new(nil, 4, 1):next(-1))
+ -- line 5 => 4
+ assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 5, 1):next(-1))
+ -- beginning returns nil
+ assert.are.same(nil, Pos.new(nil, 1, 1):next(-1))
+ end)
+ end)
+
+ it('find matching brackets', function()
+ withbuf({ 'asdf ({} def <[{}]>) ;lkj' }, function()
+ -- outer parens are matched:
+ assert.are.same(Pos.new(nil, 1, 20), Pos.new(nil, 1, 6):find_match())
+ -- outer parens are matched (backward):
+ assert.are.same(Pos.new(nil, 1, 6), Pos.new(nil, 1, 20):find_match())
+ -- no potential match returns nil
+ assert.are.same(nil, Pos.new(nil, 1, 1):find_match())
+ -- watchdog expires before an otherwise valid match is found:
+ assert.are.same(nil, Pos.new(nil, 1, 6):find_match(2))
+ end)
+ end)
+end)
+
+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, 1, 2), Pos.new(nil, 1, 4), '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, 2, 5), Pos.new(nil, 3, 5), '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, 1, 1), Pos.new(nil, 1, vim.v.maxcol), '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, 2, 1), Pos.new(nil, 3, vim.v.maxcol), '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, 1)
+ 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, 1, 2)
+ 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, 2, 5), Pos.new(nil, 2, 9), 'v')
+ range:replace 'quack'
+
+ local text = Range.from_line(nil, 2):text()
+ assert.are.same('the quack brown fox', text)
+ end)
+ end)
+
+ it('delete within line', function()
+ withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
+ local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 10), 'v')
+ range:replace ''
+
+ local text = Range.from_line(nil, 2):text()
+ assert.are.same('the brown fox', text)
+ end)
+
+ withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
+ local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 10), 'v')
+ range:replace(nil)
+
+ local text = Range.from_line(nil, 2):text()
+ assert.are.same('the brown fox', text)
+ end)
+ end)
+
+ it('replace across multiple lines: v', function()
+ withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
+ local range = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 3, 5), 'v')
+ 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, 2)
+ 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, 2, 3)
+ 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, 2)
+ 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, 2, 3)
+ 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_motion('aw'):text())
+
+ vim.fn.setpos('.', { 0, 1, 5, 0 })
+ assert.are.same('quick', Range.from_motion('iw'):text())
+ end)
+ end)
+
+ it('text object: quote', function()
+ withbuf({ [[the "quick" brown fox]] }, function()
+ vim.fn.setpos('.', { 0, 1, 5, 0 })
+ assert.are.same('"quick"', Range.from_motion('a"'):text())
+
+ vim.fn.setpos('.', { 0, 1, 6, 0 })
+ assert.are.same('quick', Range.from_motion('i"'):text())
+ end)
+
+ withbuf({ [[the 'quick' brown fox]] }, function()
+ vim.fn.setpos('.', { 0, 1, 5, 0 })
+ assert.are.same("'quick'", Range.from_motion([[a']]):text())
+
+ vim.fn.setpos('.', { 0, 1, 6, 0 })
+ assert.are.same('quick', Range.from_motion([[i']]):text())
+ end)
+
+ withbuf({ [[the `quick` brown fox]] }, function()
+ vim.fn.setpos('.', { 0, 1, 5, 0 })
+ assert.are.same('`quick`', Range.from_motion([[a`]]):text())
+
+ vim.fn.setpos('.', { 0, 1, 6, 0 })
+ assert.are.same('quick', Range.from_motion([[i`]]):text())
+ end)
+ end)
+
+ it('text object: block', function()
+ withbuf({ 'this is a {', 'block', '} here' }, function()
+ vim.fn.setpos('.', { 0, 2, 1, 0 })
+ assert.are.same('{\nblock\n}', Range.from_motion('a{'):text())
+
+ vim.fn.setpos('.', { 0, 2, 1, 0 })
+ assert.are.same('block', Range.from_motion('i{'):text())
+ end)
+ end)
+
+ it('text object: restores cursor position', function()
+ withbuf({ 'this is a {block} here' }, function()
+ vim.fn.setpos('.', { 0, 1, 13, 0 })
+ assert.are.same('{block}', Range.from_motion('a{'):text())
+ assert.are.same(vim.api.nvim_win_get_cursor(0), { 1, 12 })
+ end)
+ end)
+
+ it('text object: it (inside HTML tag)', function()
+ withbuf({ 'text
' }, function()
+ vim.cmd.setfiletype 'html'
+ vim.fn.setpos('.', { 0, 1, 12, 0 }) -- inside 'text'
+ local range = Range.from_motion 'it'
+ assert.is_not_nil(range)
+ assert.are.same('text', range:text())
+ end)
+ end)
+
+ it('from_motion with contains_cursor returns nil when cursor outside range', function()
+ withbuf({ 'foo "quoted" bar' }, function()
+ vim.fn.setpos('.', { 0, 1, 2, 0 }) -- cursor at 'foo', not in quotes
+ local range = Range.from_motion('a"', { contains_cursor = true })
+ assert.is_nil(range)
+ end)
+ end)
+
+ it('from_motion with pos option', function()
+ withbuf({ 'the quick brown fox' }, function()
+ vim.fn.setpos('.', { 0, 1, 1, 0 }) -- cursor at start
+ local pos = Pos.new(nil, 1, 5) -- position at 'quick'
+ local range = Range.from_motion('aw', { pos = pos })
+ assert.are.same('quick ', range:text())
+ end)
+ end)
+
+ it('from_motion with simple motion (non-text-object)', function()
+ withbuf({ 'hello world' }, function()
+ vim.fn.setpos('.', { 0, 1, 1, 0 })
+ local range = Range.from_motion 'w'
+ assert.is_not_nil(range)
+ end)
+ end)
+
+ it('from_motion with bufnr option', function()
+ local buf1 = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(buf1, 0, -1, false, { 'buffer one', 'more text' })
+
+ local buf2 = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(buf2, 0, -1, false, { 'buffer two' })
+ vim.api.nvim_set_current_buf(buf2)
+
+ vim.fn.setpos('.', { buf1, 1, 1, 0 })
+ local range = Range.from_motion('aw', { bufnr = buf1 })
+ assert.is_not_nil(range)
+ assert.are.same(buf1, range.start.bufnr)
+
+ vim.api.nvim_buf_delete(buf1, { force = true })
+ vim.api.nvim_buf_delete(buf2, { force = true })
+ end)
+
+ it('from_motion restores visual selection when started in visual mode', function()
+ withbuf({ 'the quick brown fox' }, function()
+ -- Enter visual mode first
+ vim.fn.setpos('.', { 0, 1, 1, 0 })
+ vim.cmd.normal 'vll' -- select 'the' (3 chars)
+
+ -- Record initial visual marks
+ local initial_v = vim.fn.getpos 'v'
+
+ -- Call from_motion (should save and restore visual selection)
+ local range = Range.from_motion 'aw'
+ assert.is_not_nil(range)
+
+ -- Check we're back in visual mode with selection restored
+ -- Note: The exact behavior depends on implementation
+ local mode = vim.fn.mode()
+ assert.is_true(mode == 'v' or mode == 'V' or mode == '\22')
+ end)
+ end)
+
+ it('from_tsquery_caps', function()
+ withbuf({
+ '-- a comment',
+ '',
+ 'function foo(bar) end',
+ '',
+ '-- a middle comment',
+ '',
+ 'function bar(baz) end',
+ '',
+ '-- another comment',
+ }, function()
+ vim.cmd.setfiletype 'lua'
+
+ --- @param contains_cursor? boolean
+ local function get_caps(contains_cursor)
+ if contains_cursor == nil then contains_cursor = true end
+ return (Range.from_tsquery_caps(
+ 0,
+ '(function_declaration) @f',
+ { contains_cursor = contains_cursor }
+ )).f or {}
+ end
+
+ local caps = get_caps(false)
+ assert.are.same(#caps, 2)
+ assert.are.same(vim.iter(caps):map(function(c) return c:text() end):totable(), {
+ 'function foo(bar) end',
+ 'function bar(baz) end',
+ })
+
+ Pos.new(0, 1, 1):save_to_pos '.'
+ caps = get_caps()
+ assert.are.same(#caps, 0)
+
+ Pos.new(0, 3, 18):save_to_pos '.'
+ caps = get_caps()
+ assert.are.same(#caps, 1)
+ assert.are.same(caps[1]:text(), 'function foo(bar) end')
+
+ Pos.new(0, 5, 1):save_to_pos '.'
+ caps = get_caps()
+ assert.are.same(#caps, 0)
+
+ Pos.new(0, 7, 1):save_to_pos '.'
+ caps = get_caps()
+ assert.are.same(#caps, 1)
+ assert.are.same(caps[1]:text(), 'function bar(baz) end')
+ end)
+ end)
+
+ it('from_tsquery_caps with string array filter', function()
+ withbuf({
+ '{',
+ ' sample_key1 = "sample-value1",',
+ ' sample_key2 = "sample-value2",',
+ '}',
+ }, function()
+ vim.cmd.setfiletype 'lua'
+
+ -- Place cursor in "sample-value1"
+ Pos.new(0, 2, 25):save_to_pos '.'
+
+ -- Query that captures both keys and values in pairs
+ local query = [[
+ (field
+ name: _ @key
+ value: _ @value)
+ ]]
+
+ local ranges = Range.from_line(0, 2):tsquery(query)
+
+ -- Should have both @key and @value captures for the first pair only
+ -- (since cursor is in sample-value1)
+ assert(ranges, 'Range should not be nil')
+ assert(ranges.key, 'Range.key should not be nil')
+ assert(ranges.value, 'Range.value should not be nil')
+
+ -- Should have exactly one key and one value
+ assert.are.same(#ranges.key, 1)
+ assert.are.same(#ranges.value, 1)
+
+ -- Check that we got sample-key1 and sample-value1
+ assert.are.same(ranges.key[1]:text(), 'sample_key1')
+ assert.are.same(ranges.value[1]:text(), '"sample-value1"')
+ end)
+
+ -- Make sure this works when the match is on the last line:
+ withbuf({
+ '{sample_key1= "sample-value1",',
+ 'sample_key2= "sample-value2"}',
+ }, function()
+ vim.cmd.setfiletype 'lua'
+
+ -- Place cursor in "sample-value1"
+ Pos.new(0, 2, 25):save_to_pos '.'
+
+ -- Query that captures both keys and values in pairs
+ local query = [[
+ (field
+ name: _ @key
+ value: _ @value)
+ ]]
+
+ local ranges = Range.from_line(0, 2):tsquery(query)
+
+ -- Should have both @key and @value captures for the first pair only
+ -- (since cursor is in sample-value1)
+ assert(ranges, 'Range should not be nil')
+ assert(ranges.key, 'Range.key should not be nil')
+ assert(ranges.value, 'Range.value should not be nil')
+
+ -- Should have exactly one key and one value
+ assert.are.same(#ranges.key, 1)
+ assert.are.same(#ranges.value, 1)
+
+ -- Check that we got sample-key2 and sample-value2
+ assert.are.same(ranges.key[1]:text(), 'sample_key2')
+ assert.are.same(ranges.value[1]:text(), '"sample-value2"')
+ end)
+ end)
+
+ it('should get nearest block', function()
+ withbuf({
+ 'this is a {',
+ '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('line', function()
+ withbuf({
+ 'this is a {',
+ 'block',
+ '} here',
+ }, function()
+ local range = Range.new(Pos.new(0, 1, 6), Pos.new(0, 2, 5), 'v')
+ local lfirst = assert(range:line(1), 'lfirst null')
+ assert.are.same('is a {', lfirst:text())
+ assert.are.same(Pos.new(0, 1, 6), lfirst.start)
+ assert.are.same(Pos.new(0, 1, 11), lfirst.stop)
+ assert.are.same('block', range:line(2):text())
+ 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, 1, 3))
+ assert.are.same(range.stop, Pos.new(nil, 1, 4))
+ assert.are.same(range.mode, 'v')
+ end)
+ end)
+
+ it('from_op_func', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local a = Pos.new(nil, 1, 1)
+ local b = Pos.new(nil, 2, 2)
+ 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, 2, vim.v.maxcol))
+ assert.are.same(range.mode, 'V')
+ end)
+ end)
+
+ it('from_cmd_args: range=0', function()
+ local args = { range = 0 }
+ withbuf(
+ { 'line one', 'and line two' },
+ function() assert.are.same(Range.from_cmd_args(args), nil) end
+ )
+ end)
+
+ it('from_cmd_args: range=1', function()
+ local args = { range = 1, line1 = 1 }
+ withbuf({ 'line one', 'and line two' }, function()
+ local a = Pos.new(nil, 1, 1)
+ local b = Pos.new(nil, 1, vim.v.maxcol)
+
+ local range = Range.from_cmd_args(args) --[[@as u.Range]]
+ assert.are.same(range.start, a)
+ assert.are.same(range.stop, b)
+ assert.are.same(range.mode, 'V')
+ assert.are.same(range:text(), 'line one')
+ end)
+ end)
+
+ it('from_cmd_args: range=2: no-visual', function()
+ local args = { range = 2, line1 = 1, line2 = 2 }
+ withbuf({ 'line one', 'and line two' }, function()
+ local range = Range.from_cmd_args(args) --[[@as u.Range]]
+ assert.are.same(range.start, Pos.new(nil, 1, 1))
+ assert.are.same(range.stop, Pos.new(nil, 2, vim.v.maxcol))
+ assert.are.same(range.mode, 'V')
+ assert.are.same(range:text(), 'line one\nand line two')
+ end)
+ end)
+
+ it('from_cmd_args: range=2: visual: linewise', function()
+ local args = { range = 2, line1 = 1, line2 = 2 }
+ withbuf({ 'line one', 'and line two' }, function()
+ local a = Pos.new(nil, 1, 1)
+ local b = Pos.new(nil, 2, vim.v.maxcol)
+ a:save_to_pos "'<"
+ b:save_to_pos "'>"
+ local range = Range.from_cmd_args(args) --[[@as u.Range]]
+ assert.are.same(range.start, a)
+ assert.are.same(range.stop, b)
+ assert.are.same(range.mode, 'V')
+ assert.are.same(range:text(), 'line one\nand line two')
+ end)
+ end)
+
+ it('from_cmd_args: range=2: visual: charwise', function()
+ local args = { range = 2, line1 = 1, line2 = 1 }
+ withbuf({ 'line one', 'and line two' }, function()
+ -- Simulate a visual selection:
+ local a = Pos.new(nil, 1, 1)
+ local b = Pos.new(nil, 1, 4)
+ a:save_to_pos "'<"
+ b:save_to_pos "'>"
+ Range.new(a, b, 'v'):set_visual_selection()
+ assert.are.same(vim.fn.mode(), 'v')
+
+ -- In this simulated setup, we need to force visualmode() to return
+ -- 'v' and histget() to return [['<,'>]]:
+
+ -- visualmode()
+ local orig_visualmode = vim.fn.visualmode
+ --- @diagnostic disable-next-line: duplicate-set-field
+ function vim.fn.visualmode() return 'v' end
+ assert.are.same(vim.fn.visualmode(), 'v')
+
+ -- histget()
+ local orig_histget = vim.fn.histget
+ --- @diagnostic disable-next-line: duplicate-set-field
+ function vim.fn.histget(x, y) return [['<,'>]] end
+
+ -- Now run the test:
+ local range = Range.from_cmd_args(args) --[[@as u.Range]]
+ assert.are.same(range.start, a)
+ assert.are.same(range.stop, b)
+ assert.are.same(range.mode, 'v')
+ assert.are.same(range:text(), 'line')
+
+ -- Reset visualmode() and histget():
+ vim.fn.visualmode = orig_visualmode
+ vim.fn.histget = orig_histget
+ end)
+ end)
+
+ 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, 1, 5))
+ assert.are.same(range.stop, Pos.new(nil, 1, 11))
+ end)
+
+ withbuf({ [[the 'quick' brown fox]] }, function()
+ vim.fn.setpos('.', { 0, 1, 5, 0 })
+ local range = Range.find_nearest_quotes()
+ assert.are.same(range.start, Pos.new(nil, 1, 5))
+ assert.are.same(range.stop, Pos.new(nil, 1, 11))
+ end)
+ end)
+
+ it('smallest', function()
+ local r1 = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 1, 4), 'v')
+ local r2 = Range.new(Pos.new(nil, 1, 3), Pos.new(nil, 1, 5), 'v')
+ local r3 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 6), 'v')
+ local smallest = Range.smallest { r1, r2, r3 }
+ assert.are.same(smallest.start, Pos.new(nil, 1, 2))
+ assert.are.same(smallest.stop, Pos.new(nil, 1, 4))
+ 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, 1, 2), Pos.new(nil, 2, 4), 'v')
+ local linewise_range = range:to_linewise()
+ assert.are.same(linewise_range.start.col, 1)
+ assert.are.same(linewise_range.stop.col, vim.v.maxcol)
+ 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, 1, 1), nil, 'v')
+ assert.is_true(range:is_empty())
+
+ local range2 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2), 'v')
+ assert.is_false(range2:is_empty())
+ end)
+ end)
+
+ it('trim_start', function()
+ withbuf({ ' line one', 'line two' }, function()
+ local range = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 10), 'v')
+ local trimmed = range:trim_start()
+ assert.are.same(trimmed.start, Pos.new(nil, 1, 4)) -- should be after the spaces
+ end)
+ end)
+
+ it('trim_stop', function()
+ withbuf({ 'line one ', 'line two' }, function()
+ local range = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 10), 'v')
+ local trimmed = range:trim_stop()
+ assert.are.same(trimmed.stop, Pos.new(nil, 1, 8)) -- should be before the spaces
+ end)
+ end)
+
+ it('contains', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local range = Range.new(Pos.new(nil, 1, 2), Pos.new(nil, 1, 4), 'v')
+ local pos = Pos.new(nil, 1, 3)
+ assert.is_true(range:contains(pos))
+
+ pos = Pos.new(nil, 1, 5) -- outside of range
+ assert.is_false(range:contains(pos))
+ end)
+ end)
+
+ it('difference', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local range_outer = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 2, 12), 'v')
+ local range_inner = Range.new(Pos.new(nil, 2, 5), Pos.new(nil, 2, 8), 'v')
+
+ assert.are.same(range_outer:text(), 'and line two')
+ assert.are.same(range_inner:text(), 'line')
+
+ local left, right = range_outer:difference(range_inner)
+ assert.are.same(left:text(), 'and ')
+ assert.are.same(right:text(), ' two')
+
+ left, right = range_inner:difference(range_outer)
+ assert.are.same(left:text(), 'and ')
+ assert.are.same(right:text(), ' two')
+
+ left, right = range_outer:difference(range_outer)
+ assert.are.same(left:is_empty(), true)
+ assert.are.same(left:text(), '')
+ assert.are.same(right:is_empty(), true)
+ assert.are.same(right:text(), '')
+ end)
+ end)
+
+ it('length', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local range = Range.new(Pos.new(nil, 2, 4), Pos.new(nil, 2, 9), 'v')
+ assert.are.same(range:length(), #range:text())
+
+ range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 2, 9), 'v')
+ assert.are.same(range:length(), #range:text())
+ end)
+ end)
+
+ it('sub', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local range = Range.new(Pos.new(nil, 2, 4), Pos.new(nil, 2, 9), 'v')
+ assert.are.same(range:text(), ' line ')
+ assert.are.same(range:sub(1, -1):text(), ' line ')
+ assert.are.same(range:sub(2, -2):text(), 'line')
+ assert.are.same(range:sub(1, 5):text(), ' line')
+ assert.are.same(range:sub(2, 5):text(), 'line')
+ assert.are.same(range:sub(20, 25):text(), '')
+ end)
+ end)
+
+ it('shrink', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local range = Range.new(Pos.new(nil, 2, 3), Pos.new(nil, 3, 5), 'v')
+ local shrunk = range:shrink(1)
+ assert.are.same(shrunk.start, Pos.new(nil, 2, 4))
+ assert.are.same(shrunk.stop, Pos.new(nil, 3, 4))
+ end)
+ end)
+
+ it('must_shrink', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local range = Range.new(Pos.new(nil, 2, 3), Pos.new(nil, 3, 5), 'v')
+ local shrunk = range:must_shrink(1)
+ assert.are.same(shrunk.start, Pos.new(nil, 2, 4))
+ assert.are.same(shrunk.stop, Pos.new(nil, 3, 4))
+
+ assert.has.error(
+ function() range:must_shrink(100) end,
+ 'error in Range:must_shrink: Range:shrink() returned nil'
+ )
+ end)
+ end)
+
+ it('set_visual_selection', function()
+ withbuf({ 'line one', 'and line two' }, function()
+ local range = Range.from_lines(nil, 1, 2)
+ range:set_visual_selection()
+
+ assert.are.same(Pos.from_pos 'v', Pos.new(nil, 1, 1))
+ -- Since the selection is 'V' (instead of 'v'), the end
+ -- selects one character past the end:
+ assert.are.same(Pos.from_pos '.', Pos.new(nil, 2, 13))
+ end)
+ end)
+
+ it('selections set to past the EOL should not error', function()
+ withbuf({ 'Rg SET NAMES' }, function()
+ local b = vim.api.nvim_get_current_buf()
+ local r = Range.new(Pos.new(b, 1, 4), Pos.new(b, 1, 13), 'v')
+ r:replace 'bleh'
+ assert.are.same({ 'Rg bleh' }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
+ end)
+
+ withbuf({ 'Rg SET NAMES' }, function()
+ local b = vim.api.nvim_get_current_buf()
+ local r = Range.new(Pos.new(b, 1, 4), Pos.new(b, 1, 12), 'v')
+ 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, 1, 5), Pos.new(b, 1, 9), 'v')
+
+ r:replace 'bleh1'
+ assert.are.same(
+ { 'The bleh1 brown fox jumps over the lazy dog' },
+ vim.api.nvim_buf_get_lines(b, 0, -1, false)
+ )
+
+ 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, 1, 21), Pos.new(b, 2, 4), 'v')
+ assert.are.same({ 'jumps', 'over' }, r:lines())
+
+ r:replace 'bleh1'
+ assert.are.same(
+ { 'The quick brown fox bleh1 the lazy dog' },
+ vim.api.nvim_buf_get_lines(b, 0, -1, false)
+ )
+ assert.are.same({ 'bleh1' }, r:lines())
+
+ 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, 2, 1), Pos.new(b, 4, vim.v.maxcol), '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, 2, 1), Pos.new(b, 4, vim.v.maxcol), '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)
+
+ it('can save to extmark', function()
+ withbuf({
+ 'The quick brown',
+ 'fox',
+ 'jumps',
+ 'over',
+ 'the lazy dog',
+ }, function()
+ -- Construct a range over 'fox jumps'
+ local r = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 3, 5), 'v')
+ local extrange = r:save_to_extmark()
+ assert.are.same({ 'fox', 'jumps' }, extrange:range():lines())
+ -- change 'jumps' to 'leaps':
+ vim.api.nvim_buf_set_text(extrange.bufnr, 2, 0, 2, 4, { 'leap' })
+ assert.are.same({
+ 'The quick brown',
+ 'fox',
+ 'leaps',
+ 'over',
+ 'the lazy dog',
+ }, vim.api.nvim_buf_get_lines(extrange.bufnr, 0, -1, false))
+ assert.are.same({ 'fox', 'leaps' }, extrange:range():lines())
+ end)
+ end)
+
+ it('can save linewise extmark', function()
+ withbuf({
+ 'The quick brown',
+ 'fox',
+ 'jumps',
+ 'over',
+ 'the lazy dog',
+ }, function()
+ -- Construct a range over 'fox jumps'
+ local r = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 3, vim.v.maxcol), 'V')
+ local extrange = r:save_to_extmark()
+ assert.are.same({ 'fox', 'jumps' }, extrange:range():lines())
+
+ local extmark = vim.api.nvim_buf_get_extmark_by_id(
+ extrange.bufnr,
+ vim.api.nvim_create_namespace 'u.range',
+ extrange.id,
+ { details = true }
+ )
+ local row0, col0, details = unpack(extmark)
+ assert.are.same({ 1, 0 }, { row0, col0 })
+ assert.are.same({ 3, 0 }, { details.end_row, details.end_col })
+ end)
+ end)
+
+ it('discerns range bounds from extmarks beyond the end of the buffer', function()
+ local function set_tmp_options(bufnr)
+ vim.bo[bufnr].bufhidden = 'delete'
+ vim.bo[bufnr].buflisted = false
+ vim.bo[bufnr].buftype = 'nowrite'
+ end
+
+ vim.cmd.vnew()
+ local left_bufnr = vim.api.nvim_get_current_buf()
+ set_tmp_options(left_bufnr)
+ local left = Range.from_buf_text(left_bufnr)
+ vim.cmd.vnew()
+ local right_bufnr = vim.api.nvim_get_current_buf()
+ set_tmp_options(left_bufnr)
+ local right = Range.from_buf_text(right_bufnr)
+
+ left:replace {
+ 'one',
+ 'two',
+ 'three',
+ }
+ local left_all_ext = left:save_to_extmark()
+
+ right:replace {
+ 'foo',
+ 'bar',
+ }
+
+ vim.api.nvim_set_current_buf(right_bufnr)
+ vim.cmd [[normal! ggyG]]
+ vim.api.nvim_set_current_buf(left_bufnr)
+ vim.cmd [[normal! ggVGp]]
+
+ assert.are.same({ 'foo', 'bar' }, left_all_ext:range():lines())
+ vim.api.nvim_buf_delete(left_bufnr, { force = true })
+ vim.api.nvim_buf_delete(right_bufnr, { force = true })
+ end)
+end)
+
+--------------------------------------------------------------------------------
+-- Additional coverage tests
+--------------------------------------------------------------------------------
+
+describe('Pos additional coverage', function()
+ it('__tostring', function()
+ withbuf({ 'test' }, function()
+ local bufnr = vim.api.nvim_get_current_buf()
+ local p = Pos.new(bufnr, 1, 1, 0)
+ local s = tostring(p)
+ assert.is_true(s:find 'Pos%(1:1%)' ~= nil)
+ assert.is_true(s:find('bufnr=' .. bufnr) ~= nil)
+
+ local p2 = Pos.new(bufnr, 1, 1, 5)
+ s = tostring(p2)
+ assert.is_true(s:find 'Pos%(1:1%)' ~= nil)
+ assert.is_true(s:find 'off=5' ~= nil)
+ end)
+ end)
+
+ it('from_eol', function()
+ withbuf({ 'hello world' }, function()
+ local eol = Pos.from_eol(nil, 1)
+ assert.are.same(11, eol.col)
+ assert.are.same('hello world', eol:line())
+ end)
+ end)
+
+ it(':eol', function()
+ withbuf({ 'hello world' }, function()
+ local p = Pos.new(nil, 1, 1)
+ local eol = p:eol()
+ assert.are.same(11, eol.col)
+ end)
+ end)
+
+ it('save_to_cursor', function()
+ withbuf({ 'line one', 'line two' }, function()
+ local p = Pos.new(nil, 2, 5)
+ p:save_to_cursor()
+ assert.are.same({ 2, 4 }, vim.api.nvim_win_get_cursor(0))
+ end)
+ end)
+
+ it('save_to_mark', function()
+ withbuf({ 'line one', 'line two' }, function()
+ local b = vim.api.nvim_get_current_buf()
+ local p = Pos.new(b, 2, 5)
+ p:save_to_mark 'a'
+ local mark = vim.api.nvim_buf_get_mark(b, 'a')
+ assert.are.same({ 2, 4 }, mark)
+ end)
+ end)
+
+ it('must_next', function()
+ withbuf({ 'abc' }, function()
+ local p = Pos.new(nil, 1, 1)
+ local next = p:must_next(1)
+ assert.are.same(Pos.new(nil, 1, 2), next)
+
+ assert.has.error(
+ function() Pos.new(nil, 1, 3):must_next(1) end,
+ 'error in Pos:next(): Pos:next() returned nil'
+ )
+ end)
+ end)
+
+ it('next_while', function()
+ withbuf({ 'aaabbb' }, function()
+ local p = Pos.new(nil, 1, 1)
+ local result = p:next_while(1, function(pos) return pos:char() == 'a' end)
+ assert.are.same(Pos.new(nil, 1, 3), result)
+
+ result = p:next_while(1, function(pos) return pos:char() == 'a' end, true)
+ assert.are.same(Pos.new(nil, 1, 3), result)
+
+ result = p:next_while(1, function() return false end, true)
+ assert.are.same(nil, result)
+ end)
+ end)
+
+ it('insert_before', function()
+ withbuf({ 'world' }, function()
+ local p = Pos.new(nil, 1, 1)
+ p:insert_before 'hello '
+ assert.are.same({ 'hello world' }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
+ end)
+ end)
+
+ it('is_invalid', function()
+ local invalid = Pos.invalid()
+ assert.is_true(invalid:is_invalid())
+
+ withbuf({ 'test' }, function()
+ local valid = Pos.new(nil, 1, 1)
+ assert.is_false(valid:is_invalid())
+ end)
+ end)
+
+ it('__add with number first', function()
+ withbuf({ 'abc' }, function()
+ local p = Pos.new(nil, 1, 1)
+ local result = 1 + p
+ assert.are.same(Pos.new(nil, 1, 2), result)
+ end)
+ end)
+
+ it('__sub with number first', function()
+ withbuf({ 'abc' }, function()
+ local p = Pos.new(nil, 1, 2)
+ local result = 1 - p
+ assert.are.same(Pos.new(nil, 1, 1), result)
+ end)
+ end)
+
+ it('find_next returns nil at end', function()
+ withbuf({ 'abc' }, function()
+ local p = Pos.new(nil, 1, 3)
+ local result = p:find_next(1, 'z')
+ assert.are.same(nil, result)
+ end)
+ end)
+
+ it('find_match with nested brackets', function()
+ withbuf({ '(abc)' }, function()
+ local p = Pos.new(nil, 1, 1) -- '('
+ local match = p:find_match()
+ assert.are.same(Pos.new(nil, 1, 5), match) -- ')'
+ end)
+
+ withbuf({ '{abc}' }, function()
+ local p = Pos.new(nil, 1, 1) -- '{'
+ local match = p:find_match()
+ assert.are.same(Pos.new(nil, 1, 5), match) -- '}'
+ end)
+
+ withbuf({ '(a{b}c)' }, function()
+ -- Test nested: ( at pos 1, { at pos 3
+ local p = Pos.new(nil, 1, 3) -- '{'
+ local match = p:find_match()
+ assert.are.same(Pos.new(nil, 1, 5), match) -- '}'
+ end)
+ end)
+
+ it('from10', function()
+ withbuf({ 'test' }, function()
+ local p = Pos.from10(nil, 1, 0)
+ assert.are.same(1, p.lnum)
+ assert.are.same(1, p.col)
+
+ p = Pos.from10(nil, 2, 5, 10)
+ assert.are.same(2, p.lnum)
+ assert.are.same(6, p.col)
+ assert.are.same(10, p.off)
+ end)
+ end)
+
+ it('as_real with col beyond line length', function()
+ withbuf({ 'ab' }, function()
+ local p = Pos.new(nil, 1, 100)
+ local real = p:as_real()
+ assert.are.same(2, real.col)
+ end)
+ end)
+
+ it('find_next returns nil when not found', function()
+ withbuf({ 'abc' }, function()
+ local p = Pos.new(nil, 1, 1)
+ local result = p:find_next(1, 'z')
+ assert.is_nil(result)
+ end)
+ end)
+end)
+
+describe('Range additional coverage', function()
+ it('__eq', function()
+ withbuf({ 'test' }, function()
+ local r1 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 4), 'v')
+ local r2 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 4), 'v')
+ assert.is_true(r1 == r2)
+
+ local r3 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 5), 'v')
+ assert.is_false(r1 == r3)
+
+ local r4 = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 4), 'V')
+ assert.is_false(r1 == r4)
+
+ assert.is_false(r1 == 'not a range')
+ assert.is_false(r1 == nil)
+ end)
+ end)
+
+ it('__tostring with nil stop', function()
+ withbuf({ 'test' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), nil, 'v')
+ local s = tostring(r)
+ assert.is_true(s:find 'stop=nil' ~= nil)
+ end)
+ end)
+
+ it('__tostring with off != 0', function()
+ withbuf({ 'test' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1, 5), Pos.new(nil, 1, 4, 3), 'v')
+ local s = tostring(r)
+ assert.is_true(s:find 'off=5' ~= nil)
+ end)
+ end)
+
+ it('__tostring', function()
+ withbuf({ 'test' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 4), 'v')
+ local s = tostring(r)
+ assert.are.same('v', r.mode)
+ assert.is_true(s:find 'Range{' == 1)
+ end)
+ end)
+
+ it('from_lines with negative stop_line', function()
+ withbuf({ 'a', 'b', 'c', 'd', 'e' }, function()
+ local r = Range.from_lines(nil, 1, -1)
+ assert.are.same(5, r.stop.lnum)
+
+ r = Range.from_lines(nil, 1, -2)
+ assert.are.same(4, r.stop.lnum)
+ end)
+ end)
+
+ it('save_to_pos', function()
+ withbuf({ 'hello world' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 5), 'v')
+ r:save_to_pos("'[", "']")
+ local start = Pos.from_pos "'["
+ local stop = Pos.from_pos "']"
+ assert.are.same(1, start.col)
+ assert.are.same(5, stop.col)
+ end)
+ end)
+
+ it('save_to_marks', function()
+ withbuf({ 'hello world' }, function()
+ local b = vim.api.nvim_get_current_buf()
+ local r = Range.new(Pos.new(b, 1, 1), Pos.new(b, 1, 5), 'v')
+ r:save_to_marks('m', 'n')
+ local m = vim.api.nvim_buf_get_mark(b, 'm')
+ local n = vim.api.nvim_buf_get_mark(b, 'n')
+ assert.are.same({ 1, 0 }, m)
+ assert.are.same({ 1, 4 }, n)
+ end)
+ end)
+
+ it('new swaps start and stop when needed', function()
+ withbuf({ 'test' }, function()
+ local r = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 1, 1), 'v')
+ assert.are.same(1, r.start.col)
+ assert.are.same(4, r.stop.col)
+ end)
+ end)
+
+ it('from_marks with MAX_COL', function()
+ withbuf({ 'test' }, function()
+ local start = Pos.new(nil, 1, 1)
+ local stop = Pos.new(nil, 1, vim.v.maxcol)
+ start:save_to_pos "'["
+ stop:save_to_pos "']"
+ local r = Range.from_marks("'[", "']")
+ assert.are.same('V', r.mode)
+ end)
+ end)
+
+ it('contains returns false for non-Pos/Range', function()
+ withbuf({ 'test' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 4), 'v')
+ assert.is_false(r:contains 'string')
+ assert.is_false(r:contains(123))
+ end)
+ end)
+
+ it('sub with invalid stop', function()
+ withbuf({ 'abcdef' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 6), 'v')
+ local sub = r:sub(1, 100)
+ assert.is_true(sub:is_empty())
+ end)
+ end)
+
+ it('line with negative index', function()
+ withbuf({ 'a', 'b', 'c' }, function()
+ local r = Range.from_lines(nil, 1, 3)
+ local l = r:line(-1)
+ assert.are.same('c', l:text())
+
+ l = r:line(-2)
+ assert.are.same('b', l:text())
+ end)
+ end)
+
+ it('line returns nil for out of bounds', function()
+ withbuf({ 'a', 'b' }, function()
+ local r = Range.from_lines(nil, 1, 2)
+ assert.is_nil(r:line(10))
+ end)
+ end)
+
+ it('trim_start returns range with start=stop when all whitespace', function()
+ withbuf({ ' ' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 3), 'v')
+ local trimmed = r:trim_start()
+ assert.is_not.same(nil, trimmed)
+ assert.are.same(trimmed.start, trimmed.stop)
+ end)
+ end)
+
+ it('trim_stop returns range with start=stop when all whitespace', function()
+ withbuf({ ' ' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 3), 'v')
+ local trimmed = r:trim_stop()
+ assert.is_not.same(nil, trimmed)
+ assert.are.same(trimmed.start, trimmed.stop)
+ end)
+ end)
+
+ it('smallest returns nil for empty input', function()
+ assert.are.same(nil, Range.smallest {})
+ assert.are.same(nil, Range.smallest { nil })
+ end)
+end)
+
+describe('Extmark additional coverage', function()
+ it('delete', function()
+ withbuf({ 'test' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 4), 'v')
+ local ext = r:save_to_extmark()
+ ext:delete()
+ -- Should not error
+ end)
+ end)
+end)
+
+describe('Extmark edge cases', function()
+ it('tracks multi-byte characters correctly', function()
+ withbuf({ '🚀🌟 hello 你好世界' }, function()
+ -- The string is 27 bytes: 🚀(4) + 🌟(4) + space(1) + hello(5) + space(1) + 你(3) + 好(3) + 世(3) + 界(3)
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 27), 'v')
+ local ext = r:save_to_extmark()
+ local ext_range = ext:range()
+ assert.are.same('🚀🌟 hello 你好世界', ext_range:text())
+ end)
+ end)
+
+ it('clamps start position after buffer shrink', function()
+ withbuf({ 'line 1', 'line 2', 'line 3', '' }, function()
+ local r = Range.from_buf_text()
+ local ext = r:save_to_extmark()
+
+ -- Delete last line
+ vim.api.nvim_buf_set_lines(0, 3, 4, true, {})
+
+ -- Get range from extmark - should clamp to valid buffer
+ local ext_range = ext:range()
+ assert.is_true(ext_range.stop.lnum <= 3)
+ end)
+ end)
+
+ it('handles zero-width extmark (empty range)', function()
+ withbuf({ 'hello world' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), nil, 'v')
+ local ext = r:save_to_extmark()
+ local ext_range = ext:range()
+ assert.is_true(ext_range:is_empty())
+ end)
+ end)
+
+ it('handles extmark at buffer start', function()
+ withbuf({ 'first', 'second', 'third' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 5), 'v')
+ local ext = r:save_to_extmark()
+ local ext_range = ext:range()
+ assert.are.same(1, ext_range.start.lnum)
+ assert.are.same(1, ext_range.start.col)
+ end)
+ end)
+
+ it('handles extmark at buffer end', function()
+ withbuf({ 'first', 'second', 'third' }, function()
+ local r = Range.new(Pos.new(nil, 3, 1), Pos.new(nil, 3, 5), 'v')
+ local ext = r:save_to_extmark()
+ local ext_range = ext:range()
+ assert.are.same(3, ext_range.stop.lnum)
+ assert.are.same(5, ext_range.stop.col)
+ end)
+ end)
+end)
+
+describe('utils', function()
+ local u = require 'u'
+
+ it('create_delegated_cmd_args', function()
+ local args = {
+ range = 2,
+ line1 = 1,
+ line2 = 5,
+ count = -1,
+ reg = '',
+ bang = true,
+ fargs = { 'arg1', 'arg2' },
+ smods = { silent = true },
+ }
+ local delegated = u.create_delegated_cmd_args(args)
+ assert.are.same({ 1, 5 }, delegated.range)
+ assert.are.same(nil, delegated.count)
+ assert.are.same(nil, delegated.reg)
+ assert.are.same(true, delegated.bang)
+ assert.are.same({ 'arg1', 'arg2' }, delegated.args)
+
+ -- Test range = 1
+ args = {
+ range = 1,
+ line1 = 3,
+ line2 = 3,
+ count = -1,
+ reg = '',
+ bang = false,
+ fargs = {},
+ smods = {},
+ }
+ delegated = u.create_delegated_cmd_args(args)
+ assert.are.same({ 3 }, delegated.range)
+
+ -- Test range = 0 with count
+ args = {
+ range = 0,
+ line1 = 1,
+ line2 = 1,
+ count = 5,
+ reg = '"',
+ bang = false,
+ fargs = {},
+ smods = {},
+ }
+ delegated = u.create_delegated_cmd_args(args)
+ assert.are.same(nil, delegated.range)
+ assert.are.same(5, delegated.count)
+ assert.are.same('"', delegated.reg)
+ end)
+
+ it('ucmd with string command', function()
+ u.ucmd('TestUcmdString', 'echo "test"', {})
+ local cmds = vim.api.nvim_get_commands { builtin = false }
+ assert.is_not.same(nil, cmds.TestUcmdString)
+ vim.api.nvim_del_user_command 'TestUcmdString'
+ end)
+
+ it('ucmd with function command', function()
+ local called = false
+ u.ucmd('TestUcmdFunc', function(args)
+ called = true
+ assert.is_not.same(nil, args)
+ end, { range = true })
+
+ local cmds = vim.api.nvim_get_commands { builtin = false }
+ assert.is_not.same(nil, cmds.TestUcmdFunc)
+
+ vim.cmd.TestUcmdFunc()
+ assert.is_true(called)
+
+ vim.api.nvim_del_user_command 'TestUcmdFunc'
+ end)
+end)
+
+describe('repeat_', function()
+ local u = require 'u'
+
+ it(
+ 'is_repeating returns false by default',
+ function() assert.is_false(u.repeat_.is_repeating()) end
+ )
+
+ it('run_repeatable executes the function', function()
+ local called = false
+ u.repeat_.run_repeatable(function() called = true end)
+ assert.is_true(called)
+ end)
+
+ it('setup creates keymaps', function()
+ u.repeat_.setup()
+ local maps = vim.api.nvim_get_keymap 'n'
+ local dot_map = vim.iter(maps):find(function(m) return m.lhs == '.' end)
+ local u_map = vim.iter(maps):find(function(m) return m.lhs == 'u' end)
+ assert.is_not.same(nil, dot_map)
+ assert.is_not.same(nil, u_map)
+ end)
+end)
+
+describe('define_txtobj', function()
+ local u = require 'u'
+
+ it('defines text object keymaps', function()
+ u.define_txtobj(
+ 'aX',
+ function() return Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 5), 'v') end
+ )
+
+ local xmaps = vim.api.nvim_get_keymap 'x'
+ local omaps = vim.api.nvim_get_keymap 'o'
+
+ local xmap = vim.iter(xmaps):find(function(m) return m.lhs == 'aX' end)
+ local omap_found = vim.iter(omaps):find(function(m) return m.lhs == 'aX' end)
+
+ assert.is_not.same(nil, xmap)
+ assert.is_not.same(nil, omap_found)
+ end)
+
+ it('defines buffer-local text object keymaps', function()
+ withbuf({ 'test' }, function()
+ local bufnr = vim.api.nvim_get_current_buf()
+ u.define_txtobj(
+ 'aY',
+ function() return Range.new(Pos.new(bufnr, 1, 1), Pos.new(bufnr, 1, 4), 'v') end,
+ { buffer = bufnr }
+ )
+
+ local xmaps = vim.api.nvim_buf_get_keymap(bufnr, 'x')
+ local omaps = vim.api.nvim_buf_get_keymap(bufnr, 'o')
+
+ local xmap = vim.iter(xmaps):find(function(m) return m.lhs == 'aY' end)
+ local omap_found = vim.iter(omaps):find(function(m) return m.lhs == 'aY' end)
+
+ assert.is_not.same(nil, xmap)
+ assert.is_not.same(nil, omap_found)
+ end)
+ end)
+end)
+
+describe('Range highlight', function()
+ it('highlight creates highlight and returns clear function', function()
+ withbuf({ 'hello world' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 5), 'v')
+ local hl = r:highlight('Search', { timeout = 100 })
+ assert.is_not.same(nil, hl)
+ assert.is_not.same(nil, hl.ns)
+ assert.is_not.same(nil, hl.clear)
+ hl.clear()
+ end)
+ end)
+
+ it('highlight returns nil for empty range', function()
+ withbuf({ 'test' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), nil, 'v')
+ local hl = r:highlight 'Search'
+ assert.is.equal(hl.ns, 0)
+ end)
+ end)
+
+ it('highlight with priority option', function()
+ withbuf({ 'hello world' }, function()
+ local r = Range.new(Pos.new(nil, 1, 1), Pos.new(nil, 1, 5), 'v')
+ local hl = r:highlight('Search', { priority = 100 })
+ assert.is_not.same(nil, hl)
+ hl.clear()
+ end)
+ end)
+end)
diff --git a/spec/withbuf.lua b/spec/withbuf.lua
deleted file mode 100644
index ee1d790..0000000
--- a/spec/withbuf.lua
+++ /dev/null
@@ -1,11 +0,0 @@
-require 'luacov'
-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
diff --git a/stylua.toml b/stylua.toml
index 6bfe2c0..de1c242 100644
--- a/stylua.toml
+++ b/stylua.toml
@@ -1,6 +1,10 @@
+syntax = "LuaJIT"
call_parentheses = "None"
collapse_simple_statement = "Always"
column_width = 100
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"
+
+[sort_requires]
+enabled = true
diff --git a/u-0.0.0-0.rockspec b/u-0.0.0-0.rockspec
new file mode 100644
index 0000000..29f673b
--- /dev/null
+++ b/u-0.0.0-0.rockspec
@@ -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',
+}
diff --git a/u.nvim-0.2.0-1.rockspec b/u.nvim-0.2.0-1.rockspec
index a2dfd80..41c2ff2 100644
--- a/u.nvim-0.2.0-1.rockspec
+++ b/u.nvim-0.2.0-1.rockspec
@@ -12,8 +12,6 @@ description = {
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",