bugfixes; update README.md

This commit is contained in:
Jonathan Apodaca 2024-09-06 13:10:37 -06:00
parent 61460f0180
commit b9edc4d8ff
10 changed files with 366 additions and 58 deletions

15
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,15 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: NeoVim tests
on: [push]
jobs:
plenary-tests:
runs-on: ubuntu-latest
env:
XDG_CONFIG_HOME: ${{ github.workspace }}/.config/
steps:
- uses: actions/checkout@v4
- uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: v0.10.1
- run: make test

157
README.md
View File

@ -2,40 +2,123 @@
Welcome to **Text Tools (TT)** a powerful Lua library designed to enhance your text manipulation experience in NeoVim, focusing primarily on a context-aware "Range" utility. This utility allows you to work efficiently with text selections based on various conditions, in a variety of contexts, making coding and editing more intuitive and productive.
This is meant to be used as a **library**, not a plugin. On its own, text-tools.nvim does nothing on its own. 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.
This is meant to be used as a **library**, not a plugin. On its own, `text-tools.nvim` does nothing on its own. It is meant to be used by plugin authors, to make their lives easier based on the variety of utilities I found I needed while growing my NeoVim config.
## Features
- **Range Utility**: Get context-aware selections with ease.
- **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.
### Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/text-tools.git
```
2. Add the path to your `init.vim` or `init.lua`:
```lua
package.path = package.path .. ';/path/to/text-tools/lua/?.lua'
```
Lazy:
```lua
-- Setting `lazy = true` ensures that the library is only loaded
-- when `require 'tt.<utility>' is called.
{ 'jrop/text-tools.nvim', lazy = true }
```
## Usage
### A note on indices
I love NeoVim. I am coming to love Lua. I don't like 1-based indices; perhaps I am too old. Perhaps I am too steeped in the history of loving the elegance of simple pointer arithmetic. Regardless, the way positions are addressed in NeoVim/Vim is (terrifyingly) mixed. Some methods return 1-based, others accept only 0-based. In order to stay sane, I had to make a choice to store everything in one, uniform representation in this library. I chose (what I humbly think is the only sane way) to stick with the tried-and-true 0-based index scheme. That abstraction leaks into the public API of this library.
### 1. Creating a Range
To create a range, use the `Range.new(startPos, endPos, mode)` method:
The `Range` utility is the main feature upon which most other things in this library are built, aside from a few standalone utilities. Ranges can be constructed manually, or preferably, obtained based on a variety of contexts.
```lua
local Range = require 'tt.range'
local startPos = Pos.new(0, 1, 0) -- Line 1, first column
local endPos = Pos.new(0, 3, 0) -- Line 3, first column
local myRange = Range.new(startPos, endPos)
local start = Pos.new(0, 0, 0) -- Line 1, first column
local stop = Pos.new(0, 2, 0) -- Line 3, first column
Range.new(start, stop, 'v') -- charwise selection
Range.new(start, stop, 'V') -- linewise selection
```
### 2. Working with Code Writer
This is usually not how you want to obtain a `Range`, however. Usually you want to get the corresponding context of an edit operation and just "get me the current Range that represents this context".
```lua
-- get the first line in a buffer:
Range.from_line(0, 0)
-- Text Objects (any text object valid in your configuration is supported):
-- get the word the cursor is on:
Range.from_text_object('iw')
-- get the WORD the cursor is on:
Range.from_text_object('iW')
-- get the "..." the cursor is within:
Range.from_text_object('a"')
-- Get the currently visually selected text:
-- NOTE: this does NOT work within certain contexts; more specialized utilities are more appropriate in certain circumstances
Range.from_vtext()
--
-- Get the operated on text obtained from a motion:
-- (HINT: use the opkeymap utility to make this less verbose)
--
---@param ty 'char'|'line'|'block'
function MyOpFunc(ty)
local range = Range.from_op_func(ty)
-- do something with the range
end
-- Try invoking this with: `<Leader>toaw`, and the current word will be the context:
vim.keymap.set('<Leader>to', function()
vim.g.operatorfunc = 'v:lua.MyOpFunc'
return 'g@'
end, { expr = true })
--
-- Commands:
--
-- When executing commands in a visual context, getting the selected text has to be done differently:
vim.api.nvim_create_user_command('MyCmd', function(args)
local range = Range.from_cmd_args(args)
if range == nil then
-- the command was executed in normal mode
else
-- ...
end
end, { range = true })
```
So far, that's a lot of ways to _get_ a `Range`. But what can you do with a range once you have one? Plenty, it turns out!
```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:line0(0) -- get the first line within this range
range:line0(-1) -- get the last line within this range
-- replace with new contents:
range:replace {
'replacement line 1',
'replacement line 2',
}
range:replace 'with a string'
-- delete the contents of the range:
range:replace(nil)
```
### 2. Defining Key Mappings over Motions
Define custom (dot-repeatable) key mappings for text objects:
```lua
local opkeymap = require 'tt.opkeymap'
-- invoke this function by typing, for example, `<leader>riw`:
-- `range` will contain the bounds of the motion `iw`.
opkeymap('n', '<leader>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:
@ -44,35 +127,25 @@ local CodeWriter = require 'tt.codewriter'
local cw = CodeWriter.new()
cw:write('{')
cw:indent(function(innerCW)
innerCW:write('x: 123')
innerCW:write('x: 123')
end)
cw:write('}')
```
### 3. Defining Key Mappings
Define custom key mappings for text objects:
```lua
local opkeymap = require 'tt.opkeymap'
-- invoke this function by typing, for example, `<leader>riw`:
-- `range` will contain the bounds of the motion `iw`.
opkeymap('n', '<leader>r', function(range)
print(range:text()) -- Prints the text within the selected range
end)
```
### 4. Utility Functions
#### Cursor Position
#### Custom Text Objects
To manage cursor position, use the `Pos` class:
Simply by returning a `Range` or a `Pos`, you can easily and quickly define your own text objects:
```lua
local Pos = require 'tt.pos'
local cursorPos = Pos.new(0, 1, 5) -- Line 1, character 5
print(cursorPos:char()) -- Gets the character at the cursor position
local utils = require 'tt.utils'
local Range = require 'tt.range'
-- Select whole file:
utils.define_text_object('ag', function()
return Range.from_buf_text()
end)
```
#### Buffer Management
@ -82,7 +155,19 @@ Access and manipulate buffers easily:
```lua
local Buffer = require 'tt.buffer'
local buf = Buffer.current()
print(buf:line_count()) -- Number of lines in the current buffer
buf:line_count() -- the number of lines in the current buffer
buf:get_option '...'
buf:set_option('...', ...)
buf:get_var '...'
buf:set_var('...', ...)
buf:all() -- returns a Range representing the entire buffer
buf:is_empty() -- returns true if the buffer has no text
buf:append_line '...'
buf:line0(0) -- returns a Range representing the first line in the buffer
buf:line0(-1) -- returns a Range representing the last line in the buffer
buf:lines(0, 1) -- returns a Range representing the first two lines in the buffer
buf:lines(1, -2) -- returns a Range representing all but the first and last lines of a buffer
buf:text_object('iw') -- returns a Range representing the text object 'iw' in the give buffer
```
## License (MIT)

View File

@ -1,4 +1,6 @@
local function withbuf(lines, f)
vim.opt_global.swapfile = false
vim.cmd.new()
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
local ok, result = pcall(f)

View File

@ -6,7 +6,7 @@ local Buffer = {}
---@param buf? number
---@return Buffer
function Buffer.new(buf)
function Buffer.from_nr(buf)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
local b = { buf = buf }
setmetatable(b, { __index = Buffer })
@ -14,12 +14,12 @@ function Buffer.new(buf)
end
---@return Buffer
function Buffer.current() return Buffer.new(0) end
function Buffer.current() return Buffer.from_nr(0) end
---@param listed boolean
---@param scratch boolean
---@return Buffer
function Buffer.create(listed, scratch) return Buffer.new(vim.api.nvim_create_buf(listed, scratch)) end
function Buffer.create(listed, scratch) return Buffer.from_nr(vim.api.nvim_create_buf(listed, scratch)) end
function Buffer:set_tmp_options()
self:set_option('bufhidden', 'delete')

View File

@ -24,7 +24,7 @@ end
---@param p Pos
function CodeWriter.from_pos(p)
local line = Buffer.new(p.buf):line0(p.lnum):text()
local line = Buffer.from_nr(p.buf):line0(p.lnum):text()
return CodeWriter.from_line(line, p.buf)
end
@ -55,8 +55,6 @@ end
---@param line string
function CodeWriter:write_raw(line)
if line:find '\n' then error 'line contains newline character' end
line = line:gsub('^\n+', '')
line = line:gsub('\n+$', '')
table.insert(self.lines, line)
end

View File

@ -2,20 +2,20 @@ local Range = require 'tt.range'
local vim_repeat = require 'tt.repeat'
---@type fun(range: Range): nil|(fun():any)
local MyOpKeymapOpFunc_rhs = nil
local __TT__OpKeymapOpFunc_rhs = nil
--- This is the global utility function used for operatorfunc
--- in opkeymap
---@type nil|fun(range: Range): fun():any|nil
---@param ty 'line'|'char'|'block'
-- selene: allow(unused_variable)
function MyOpKeymapOpFunc(ty)
if MyOpKeymapOpFunc_rhs ~= nil then
function __TT__OpKeymapOpFunc(ty)
if __TT__OpKeymapOpFunc_rhs ~= nil then
local range = Range.from_op_func(ty)
local repeat_inject = MyOpKeymapOpFunc_rhs(range)
local repeat_inject = __TT__OpKeymapOpFunc_rhs(range)
vim_repeat.set(function()
vim.o.operatorfunc = 'v:lua.MyOpKeymapOpFunc'
vim.o.operatorfunc = 'v:lua.__TT__OpKeymapOpFunc'
if repeat_inject ~= nil and type(repeat_inject) == 'function' then repeat_inject() end
vim_repeat.native_repeat()
end)
@ -34,8 +34,8 @@ end
---@param opts? vim.keymap.set.Opts
local function opkeymap(mode, lhs, rhs, opts)
vim.keymap.set(mode, lhs, function()
MyOpKeymapOpFunc_rhs = rhs
vim.o.operatorfunc = 'v:lua.MyOpKeymapOpFunc'
__TT__OpKeymapOpFunc_rhs = rhs
vim.o.operatorfunc = 'v:lua.__TT__OpKeymapOpFunc'
return 'g@'
end, vim.tbl_extend('force', opts or {}, { expr = true }))
end

View File

@ -78,6 +78,10 @@ function Range.from_line(buf, line) return Range.from_lines(buf, line, line) end
---@param stop_line number 0-based line index
function Range.from_lines(buf, start_line, stop_line)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
if stop_line < 0 then
local num_lines = vim.api.nvim_buf_line_count(buf)
stop_line = num_lines + stop_line
end
return Range.new(Pos.new(buf, start_line, 0), Pos.new(buf, stop_line, Pos.MAX_COL), 'V')
end
@ -237,7 +241,7 @@ function Range.smallest(ranges)
end
function Range:clone() return Range.new(self.start:clone(), self.stop:clone(), self.mode) end
function Range:line_count() return self.stop.lnum + self.start.lnum + 1 end
function Range:line_count() return self.stop.lnum - self.start.lnum + 1 end
function Range:to_linewise()
local r = self:clone()
@ -369,7 +373,7 @@ end
---@param amount number
function Range:must_shrink(amount)
local shrunk = self:shrink(amount)
if shrunk == nil then error 'error in Range:must_shrink: Range:shrink() returned nil' end
if shrunk == nil or shrunk:is_empty() then error 'error in Range:must_shrink: Range:shrink() returned nil' end
return shrunk
end
@ -397,12 +401,8 @@ function Range:set_visual_selection()
self.start:save_to_mark 'a'
self.stop:save_to_mark 'b'
local mode = self.mode
if vim.api.nvim_get_mode().mode == 'n' then
vim.cmd.normal { cmd = 'normal', args = { '`a' .. mode .. '`b' }, bang = true }
else
utils.feedkeys '`ao`b'
end
if vim.api.nvim_get_mode().mode == 'n' then utils.feedkeys(mode) end
utils.feedkeys '`ao`b'
return nil
end)
end

View File

@ -4,10 +4,27 @@ local withbuf = require '__tt_test_tools'
describe('Buffer', function()
it('should replace all lines', function()
withbuf({}, function()
local buf = Buffer.new()
local buf = Buffer.from_nr()
buf:all():replace 'bleh'
local actual_lines = vim.api.nvim_buf_get_lines(buf.buf, 0, -1, false)
assert.are.same({ 'bleh' }, actual_lines)
end)
end)
it('should replace all but first and last lines', function()
withbuf({
'one',
'two',
'three',
}, function()
local buf = Buffer.from_nr()
buf:lines(1, -2):replace 'too'
local actual_lines = vim.api.nvim_buf_get_lines(buf.buf, 0, -1, false)
assert.are.same({
'one',
'too',
'three',
}, actual_lines)
end)
end)
end)

View File

@ -276,4 +276,183 @@ describe('Range', function()
assert.are.same('block', range:line0(1).text())
end)
end)
it('from_marks', function()
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'["
b:save_to_pos "']"
local range = Range.from_marks("'[", "']")
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v')
end)
end)
it('from_vtext', function()
withbuf({ 'line one', 'and line two' }, function()
vim.fn.setpos('.', { 0, 1, 3, 0 }) -- cursor at position (1, 3)
vim.cmd.normal 'v' -- enter visual mode
vim.cmd.normal 'l' -- select one character to the right
local range = Range.from_vtext()
assert.are.same(range.start, Pos.new(nil, 0, 2))
assert.are.same(range.stop, Pos.new(nil, 0, 3))
assert.are.same(range.mode, 'v')
end)
end)
it('from_op_func', function()
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'["
b:save_to_pos "']"
local range = Range.from_op_func 'char'
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v')
range = Range.from_op_func 'line'
assert.are.same(range.start, a)
assert.are.same(range.stop, Pos.new(nil, 1, Pos.MAX_COL))
assert.are.same(range.mode, 'V')
end)
end)
it('from_cmd_args', function()
local args = { range = 1 }
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 0, 0)
local b = Pos.new(nil, 1, 1)
a:save_to_pos "'<"
b:save_to_pos "'>"
local range = Range.from_cmd_args(args)
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v')
end)
end)
it('find_nearest_quotes', function()
withbuf({ [[the "quick" brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
local range = Range.find_nearest_quotes()
assert.are.same(range.start, Pos.new(nil, 0, 4))
assert.are.same(range.stop, Pos.new(nil, 0, 10))
end)
withbuf({ [[the 'quick' brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
local range = Range.find_nearest_quotes()
assert.are.same(range.start, Pos.new(nil, 0, 4))
assert.are.same(range.stop, Pos.new(nil, 0, 10))
end)
end)
it('smallest', function()
local r1 = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 0, 3), 'v')
local r2 = Range.new(Pos.new(nil, 0, 2), Pos.new(nil, 0, 4), 'v')
local r3 = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 5), 'v')
local smallest = Range.smallest { r1, r2, r3 }
assert.are.same(smallest.start, Pos.new(nil, 0, 1))
assert.are.same(smallest.stop, Pos.new(nil, 0, 3))
end)
it('clone', function()
withbuf({ 'line one', 'and line two' }, function()
local original = Range.from_lines(nil, 0, 1)
local cloned = original:clone()
assert.are.same(original.start, cloned.start)
assert.are.same(original.stop, cloned.stop)
assert.are.same(original.mode, cloned.mode)
end)
end)
it('line_count', function()
withbuf({ 'line one', 'and line two', 'line three' }, function()
local range = Range.from_lines(nil, 0, 2)
assert.are.same(range:line_count(), 3)
end)
end)
it('to_linewise()', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 1, 3), 'v')
local linewise_range = range:to_linewise()
assert.are.same(linewise_range.start.col, 0)
assert.are.same(linewise_range.stop.col, Pos.MAX_COL)
assert.are.same(linewise_range.mode, 'V')
end)
end)
it('is_empty', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 0), 'v')
assert.is_true(range:is_empty())
local range2 = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 1), 'v')
assert.is_false(range2:is_empty())
end)
end)
it('trim_start', function()
withbuf({ ' line one', 'line two' }, function()
local range = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 9), 'v')
local trimmed = range:trim_start()
assert.are.same(trimmed.start, Pos.new(nil, 0, 3)) -- should be after the spaces
end)
end)
it('trim_stop', function()
withbuf({ 'line one ', 'line two' }, function()
local range = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, 9), 'v')
local trimmed = range:trim_stop()
assert.are.same(trimmed.stop, Pos.new(nil, 0, 7)) -- should be before the spaces
end)
end)
it('contains', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 0, 3), 'v')
local pos = Pos.new(nil, 0, 2)
assert.is_true(range:contains(pos))
pos = Pos.new(nil, 0, 4) -- outside of range
assert.is_false(range:contains(pos))
end)
end)
it('shrink', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 1, 3), 'v')
local shrunk = range:shrink(1)
assert.are.same(shrunk.start, Pos.new(nil, 0, 2))
assert.are.same(shrunk.stop, Pos.new(nil, 1, 2))
end)
end)
it('must_shrink', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 1, 3), 'v')
local shrunk = range:must_shrink(1)
assert.are.same(shrunk.start, Pos.new(nil, 0, 2))
assert.are.same(shrunk.stop, Pos.new(nil, 1, 2))
assert.has.error(function() range:must_shrink(100) end, 'error in Range:must_shrink: Range:shrink() returned nil')
end)
end)
it('set_visual_selection', function()
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_lines(nil, 0, 1)
range:set_visual_selection()
assert.are.same(Pos.from_pos 'v', Pos.new(nil, 0, 0))
assert.are.same(Pos.from_pos '.', Pos.new(nil, 1, 11))
end)
end)
end)

12
vim.yml
View File

@ -11,6 +11,18 @@ globals:
args:
- type: any
- type: any
assert.has.error:
args:
- type: any
- type: any
assert.is_true:
args:
- type: any
- type: any
assert.is_false:
args:
- type: any
- type: any
describe:
args:
- type: string