initial commit
This commit is contained in:
commit
61460f0180
15
Makefile
Normal file
15
Makefile
Normal file
@ -0,0 +1,15 @@
|
||||
PLENARY_DIR=~/.local/share/nvim/site/pack/test/opt/plenary.nvim
|
||||
|
||||
all: lint test
|
||||
|
||||
lint:
|
||||
selene .
|
||||
|
||||
fmt:
|
||||
stylua .
|
||||
|
||||
test: $(PLENARY_DIR)
|
||||
nvim -u NORC --headless -c 'set packpath+=~/.local/share/nvim/site' -c 'packadd plenary.nvim' -c "PlenaryBustedDirectory spec/"
|
||||
|
||||
$(PLENARY_DIR):
|
||||
git clone https://github.com/nvim-lua/plenary.nvim/ $(PLENARY_DIR)
|
96
README.md
Normal file
96
README.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Text Tools (TT)
|
||||
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- **Range Utility**: Get context-aware selections with ease.
|
||||
- **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'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Creating a Range
|
||||
|
||||
To create a range, use the `Range.new(startPos, endPos, mode)` method:
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
### 2. Working with Code Writer
|
||||
|
||||
To write code with indentation, use the `CodeWriter` class:
|
||||
|
||||
```lua
|
||||
local CodeWriter = require 'tt.codewriter'
|
||||
local cw = CodeWriter.new()
|
||||
cw:write('{')
|
||||
cw:indent(function(innerCW)
|
||||
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
|
||||
|
||||
To manage cursor position, use the `Pos` class:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
#### Buffer Management
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## License (MIT)
|
||||
|
||||
Copyright (c) 2024 jrapodaca@gmail.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
8
lua/__tt_test_tools.lua
Normal file
8
lua/__tt_test_tools.lua
Normal file
@ -0,0 +1,8 @@
|
||||
local function withbuf(lines, f)
|
||||
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
|
72
lua/tt/buffer.lua
Normal file
72
lua/tt/buffer.lua
Normal file
@ -0,0 +1,72 @@
|
||||
local Range = require 'tt.range'
|
||||
|
||||
---@class Buffer
|
||||
---@field buf number
|
||||
local Buffer = {}
|
||||
|
||||
---@param buf? number
|
||||
---@return Buffer
|
||||
function Buffer.new(buf)
|
||||
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
|
||||
local b = { buf = buf }
|
||||
setmetatable(b, { __index = Buffer })
|
||||
return b
|
||||
end
|
||||
|
||||
---@return Buffer
|
||||
function Buffer.current() return Buffer.new(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:set_tmp_options()
|
||||
self:set_option('bufhidden', 'delete')
|
||||
self:set_option('buflisted', false)
|
||||
self:set_option('buftype', 'nowrite')
|
||||
end
|
||||
|
||||
---@param nm string
|
||||
function Buffer:get_option(nm) return vim.api.nvim_get_option_value(nm, { buf = self.buf }) end
|
||||
|
||||
---@param nm string
|
||||
function Buffer:set_option(nm, val) return vim.api.nvim_set_option_value(nm, val, { buf = self.buf }) end
|
||||
|
||||
---@param nm string
|
||||
function Buffer:get_var(nm) return vim.api.nvim_buf_get_var(self.buf, nm) end
|
||||
|
||||
---@param nm string
|
||||
function Buffer:set_var(nm, val) return vim.api.nvim_buf_set_var(self.buf, nm, val) end
|
||||
|
||||
function Buffer:line_count() return vim.api.nvim_buf_line_count(self.buf) end
|
||||
|
||||
function Buffer:all() return Range.from_buf_text(self.buf) end
|
||||
|
||||
function Buffer:is_empty() return self:line_count() == 1 and self:line0(0):text() == '' end
|
||||
|
||||
---@param line string
|
||||
function Buffer:append_line(line)
|
||||
local start = -1
|
||||
if self:is_empty() then start = -2 end
|
||||
vim.api.nvim_buf_set_lines(self.buf, start, -1, false, { line })
|
||||
end
|
||||
|
||||
---@param num number 0-based line index
|
||||
function Buffer:line0(num)
|
||||
if num < 0 then return self:line0(self:line_count() + num) end
|
||||
return Range.from_line(self.buf, num)
|
||||
end
|
||||
|
||||
---@param start number 0-based line index
|
||||
---@param stop number 0-based line index
|
||||
function Buffer:lines(start, stop) return Range.from_lines(self.buf, start, stop) end
|
||||
|
||||
---@param txt_obj string
|
||||
---@param opts? { contains_cursor?: boolean; pos?: Pos }
|
||||
function Buffer:text_object(txt_obj, opts)
|
||||
opts = vim.tbl_extend('force', opts or {}, { buf = self.buf })
|
||||
return Range.from_text_object(txt_obj, opts)
|
||||
end
|
||||
|
||||
return Buffer
|
78
lua/tt/codewriter.lua
Normal file
78
lua/tt/codewriter.lua
Normal file
@ -0,0 +1,78 @@
|
||||
local Buffer = require 'tt.buffer'
|
||||
|
||||
---@class CodeWriter
|
||||
---@field lines string[]
|
||||
---@field indent_level number
|
||||
---@field indent_str string
|
||||
local CodeWriter = {}
|
||||
|
||||
---@param indent_level? number
|
||||
---@param indent_str? string
|
||||
---@return CodeWriter
|
||||
function CodeWriter.new(indent_level, indent_str)
|
||||
if indent_level == nil then indent_level = 0 end
|
||||
if indent_str == nil then indent_str = ' ' end
|
||||
|
||||
local cw = {
|
||||
lines = {},
|
||||
indent_level = indent_level,
|
||||
indent_str = indent_str,
|
||||
}
|
||||
setmetatable(cw, { __index = CodeWriter })
|
||||
return cw
|
||||
end
|
||||
|
||||
---@param p Pos
|
||||
function CodeWriter.from_pos(p)
|
||||
local line = Buffer.new(p.buf):line0(p.lnum):text()
|
||||
return CodeWriter.from_line(line, p.buf)
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@param buf? number
|
||||
function CodeWriter.from_line(line, buf)
|
||||
if buf == nil then buf = vim.api.nvim_get_current_buf() end
|
||||
|
||||
local ws = line:match '^%s*'
|
||||
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = buf })
|
||||
local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = buf })
|
||||
|
||||
local indent_level = 0
|
||||
local indent_str = ''
|
||||
if expandtab then
|
||||
while #indent_str < shiftwidth do
|
||||
indent_str = indent_str .. ' '
|
||||
end
|
||||
indent_level = #ws / shiftwidth
|
||||
else
|
||||
indent_str = '\t'
|
||||
indent_level = #ws
|
||||
end
|
||||
|
||||
return CodeWriter.new(indent_level, indent_str)
|
||||
end
|
||||
|
||||
---@param line string
|
||||
function CodeWriter:write_raw(line)
|
||||
if line:find '\n' then error 'line contains newline character' end
|
||||
line = line:gsub('^\n+', '')
|
||||
line = line:gsub('\n+$', '')
|
||||
table.insert(self.lines, line)
|
||||
end
|
||||
|
||||
---@param line string
|
||||
function CodeWriter:write(line) self:write_raw(self.indent_str:rep(self.indent_level) .. line) end
|
||||
|
||||
---@param f? fun(cw: CodeWriter):any
|
||||
function CodeWriter:indent(f)
|
||||
local cw = {
|
||||
lines = self.lines,
|
||||
indent_level = self.indent_level + 1,
|
||||
indent_str = self.indent_str,
|
||||
}
|
||||
setmetatable(cw, { __index = CodeWriter })
|
||||
if f ~= nil then f(cw) end
|
||||
return cw
|
||||
end
|
||||
|
||||
return CodeWriter
|
43
lua/tt/opkeymap.lua
Normal file
43
lua/tt/opkeymap.lua
Normal file
@ -0,0 +1,43 @@
|
||||
local Range = require 'tt.range'
|
||||
local vim_repeat = require 'tt.repeat'
|
||||
|
||||
---@type fun(range: Range): nil|(fun():any)
|
||||
local MyOpKeymapOpFunc_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
|
||||
local range = Range.from_op_func(ty)
|
||||
local repeat_inject = MyOpKeymapOpFunc_rhs(range)
|
||||
|
||||
vim_repeat.set(function()
|
||||
vim.o.operatorfunc = 'v:lua.MyOpKeymapOpFunc'
|
||||
if repeat_inject ~= nil and type(repeat_inject) == 'function' then repeat_inject() end
|
||||
vim_repeat.native_repeat()
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
--- Registers a function that operates on a text-object, triggered by the given prefix (lhs).
|
||||
--- It works in the following way:
|
||||
--- 1. An expression-map is set, so that whatever the callback returns is executed by Vim (in this case `g@`)
|
||||
--- g@: tells vim to way for a motion, and then call operatorfunc.
|
||||
--- 2. The operatorfunc is set to a lua function that computes the range being operated over, that
|
||||
--- then calls the original passed callback with said range.
|
||||
---@param mode string|string[]
|
||||
---@param lhs string
|
||||
---@param rhs fun(range: Range): nil|(fun():any) This function may return another function, which is called whenever the operator is repeated
|
||||
---@param opts? vim.keymap.set.Opts
|
||||
local function opkeymap(mode, lhs, rhs, opts)
|
||||
vim.keymap.set(mode, lhs, function()
|
||||
MyOpKeymapOpFunc_rhs = rhs
|
||||
vim.o.operatorfunc = 'v:lua.MyOpKeymapOpFunc'
|
||||
return 'g@'
|
||||
end, vim.tbl_extend('force', opts or {}, { expr = true }))
|
||||
end
|
||||
|
||||
return opkeymap
|
254
lua/tt/pos.lua
Normal file
254
lua/tt/pos.lua
Normal file
@ -0,0 +1,254 @@
|
||||
local MAX_COL = vim.v.maxcol
|
||||
|
||||
---@param buf number
|
||||
---@param lnum number
|
||||
local function line_text(buf, lnum) return vim.api.nvim_buf_get_lines(buf, lnum, lnum + 1, false)[1] end
|
||||
|
||||
---@class Pos
|
||||
---@field buf number buffer number
|
||||
---@field lnum number 1-based line index
|
||||
---@field col number 1-based column index
|
||||
---@field off number
|
||||
local Pos = {}
|
||||
Pos.MAX_COL = MAX_COL
|
||||
|
||||
---@param buf? number
|
||||
---@param lnum number
|
||||
---@param col number
|
||||
---@param off? number
|
||||
---@return Pos
|
||||
function Pos.new(buf, lnum, col, off)
|
||||
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
|
||||
if off == nil then off = 0 end
|
||||
local pos = {
|
||||
buf = buf,
|
||||
lnum = lnum,
|
||||
col = col,
|
||||
off = off,
|
||||
}
|
||||
|
||||
local function str()
|
||||
if pos.off ~= 0 then
|
||||
return string.format('Pos(%d:%d){buf=%d, off=%d}', pos.lnum, pos.col, pos.buf, pos.off)
|
||||
else
|
||||
return string.format('Pos(%d:%d){buf=%d}', pos.lnum, pos.col, pos.buf)
|
||||
end
|
||||
end
|
||||
setmetatable(pos, {
|
||||
__index = Pos,
|
||||
__tostring = str,
|
||||
__lt = Pos.__lt,
|
||||
__le = Pos.__le,
|
||||
__eq = Pos.__eq,
|
||||
})
|
||||
return pos
|
||||
end
|
||||
|
||||
function Pos.is(x)
|
||||
local mt = getmetatable(x)
|
||||
return mt and mt.__index == Pos
|
||||
end
|
||||
|
||||
function Pos.__lt(a, b) return a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col) end
|
||||
function Pos.__le(a, b) return a < b or a == b end
|
||||
function Pos.__eq(a, b) return a.lnum == b.lnum and a.col == b.col end
|
||||
|
||||
---@param name string
|
||||
---@return Pos
|
||||
function Pos.from_pos(name)
|
||||
local p = vim.fn.getpos(name)
|
||||
local col = p[3]
|
||||
if col ~= MAX_COL then col = col - 1 end
|
||||
return Pos.new(p[1], p[2] - 1, col, p[4])
|
||||
end
|
||||
|
||||
function Pos:clone() return Pos.new(self.buf, self.lnum, self.col, self.off) end
|
||||
|
||||
---@return boolean
|
||||
function Pos:is_col_max() return self.col == MAX_COL end
|
||||
|
||||
---@return number[]
|
||||
function Pos:as_vim() return { self.buf, self.lnum, self.col, self.off } end
|
||||
|
||||
--- Normalize the position to a real position (take into account vim.v.maxcol).
|
||||
function Pos:as_real()
|
||||
local col = self.col
|
||||
if self:is_col_max() then
|
||||
-- We could use utilities in this file to get the given line, but
|
||||
-- since this is a low-level function, we are going to optimize and
|
||||
-- use the API directly:
|
||||
col = #line_text(self.buf, self.lnum) - 1
|
||||
end
|
||||
return Pos.new(self.buf, self.lnum, col, self.off)
|
||||
end
|
||||
|
||||
---@param pos string
|
||||
function Pos:save_to_pos(pos)
|
||||
if pos == '.' then
|
||||
vim.api.nvim_win_set_cursor(0, { self.lnum + 1, self.col })
|
||||
return
|
||||
end
|
||||
|
||||
local p = self:as_real()
|
||||
vim.fn.setpos(pos, { p.buf, p.lnum + 1, p.col + 1, p.off })
|
||||
end
|
||||
|
||||
---@param mark string
|
||||
function Pos:save_to_mark(mark)
|
||||
local p = self:as_real()
|
||||
vim.api.nvim_buf_set_mark(p.buf, mark, p.lnum + 1, p.col, {})
|
||||
end
|
||||
|
||||
---@return string
|
||||
function Pos:char()
|
||||
local line = line_text(self.buf, self.lnum)
|
||||
if line == nil then return '' end
|
||||
return line:sub(self.col + 1, self.col + 1)
|
||||
end
|
||||
|
||||
---@param dir? -1|1
|
||||
---@param must? boolean
|
||||
---@return Pos|nil
|
||||
function Pos:next(dir, must)
|
||||
if must == nil then must = false end
|
||||
|
||||
if dir == nil or dir == 1 then
|
||||
-- Next:
|
||||
local num_lines = vim.api.nvim_buf_line_count(self.buf)
|
||||
local last_line = line_text(self.buf, num_lines - 1) -- buf:line0(-1)
|
||||
if self.lnum == num_lines - 1 and self.col == (#last_line - 1) then
|
||||
if must then error 'error in Pos:next(): Pos:next() returned nil' end
|
||||
return nil
|
||||
end
|
||||
|
||||
local col = self.col + 1
|
||||
local line = self.lnum
|
||||
local line_max_col = #line_text(self.buf, self.lnum) - 1
|
||||
if col > line_max_col then
|
||||
col = 0
|
||||
line = line + 1
|
||||
end
|
||||
return Pos.new(self.buf, line, col, self.off)
|
||||
else
|
||||
-- Previous:
|
||||
if self.col == 0 and self.lnum == 0 then
|
||||
if must then error 'error in Pos:next(): Pos:next() returned nil' end
|
||||
return nil
|
||||
end
|
||||
|
||||
local col = self.col - 1
|
||||
local line = self.lnum
|
||||
local prev_line_max_col = #(line_text(self.buf, self.lnum - 1) or '') - 1
|
||||
if col < 0 then
|
||||
col = math.max(prev_line_max_col, 0)
|
||||
line = line - 1
|
||||
end
|
||||
return Pos.new(self.buf, line, col, self.off)
|
||||
end
|
||||
end
|
||||
|
||||
---@param dir? -1|1
|
||||
function Pos:must_next(dir)
|
||||
local next = self:next(dir, true)
|
||||
if next == nil then error 'unreachable' end
|
||||
return next
|
||||
end
|
||||
|
||||
---@param dir -1|1
|
||||
---@param predicate fun(p: Pos): boolean
|
||||
---@param test_current? boolean
|
||||
function Pos:next_while(dir, predicate, test_current)
|
||||
if test_current and not predicate(self) then return end
|
||||
local curr = self
|
||||
while true do
|
||||
local next = curr:next(dir)
|
||||
if next == nil or not predicate(next) then break end
|
||||
curr = next
|
||||
end
|
||||
return curr
|
||||
end
|
||||
|
||||
---@param dir -1|1
|
||||
---@param predicate string|fun(p: Pos): boolean
|
||||
function Pos:find_next(dir, predicate)
|
||||
if type(predicate) == 'string' then
|
||||
local s = predicate
|
||||
predicate = function(p) return s == p:char() end
|
||||
end
|
||||
|
||||
---@type Pos|nil
|
||||
local curr = self
|
||||
while curr ~= nil do
|
||||
if predicate(curr) then return curr end
|
||||
curr = curr:next(dir)
|
||||
end
|
||||
return curr
|
||||
end
|
||||
|
||||
--- Finds the matching bracket/paren for the current position.
|
||||
---@param max_chars? number|nil
|
||||
---@param invocations? Pos[]
|
||||
---@return Pos|nil
|
||||
function Pos:find_match(max_chars, invocations)
|
||||
if invocations == nil then invocations = {} end
|
||||
if vim.tbl_contains(invocations, function(p) return self == p end, { predicate = true }) then return nil end
|
||||
table.insert(invocations, self)
|
||||
|
||||
local openers = { '{', '[', '(', '<' }
|
||||
local closers = { '}', ']', ')', '>' }
|
||||
local c = self:char()
|
||||
local is_opener = vim.tbl_contains(openers, c)
|
||||
local is_closer = vim.tbl_contains(closers, c)
|
||||
if not is_opener and not is_closer then return nil end
|
||||
|
||||
---@type number
|
||||
local i
|
||||
---@type string
|
||||
local c_match
|
||||
if is_opener then
|
||||
i, _ = vim.iter(openers):enumerate():find(function(_, c2) return c == c2 end)
|
||||
c_match = closers[i]
|
||||
else
|
||||
i, _ = vim.iter(closers):enumerate():find(function(_, c2) return c == c2 end)
|
||||
c_match = openers[i]
|
||||
end
|
||||
|
||||
---@type Pos|nil
|
||||
local cur = self
|
||||
---@return Pos|nil
|
||||
local function adv()
|
||||
if cur == nil then return nil end
|
||||
|
||||
if max_chars ~= nil then
|
||||
max_chars = max_chars - 1
|
||||
if max_chars < 0 then return nil end
|
||||
end
|
||||
|
||||
if is_opener then
|
||||
-- scan forward
|
||||
return cur:next()
|
||||
else
|
||||
-- scan backward
|
||||
return cur:next(-1)
|
||||
end
|
||||
end
|
||||
|
||||
-- scan until we find a match:
|
||||
cur = adv()
|
||||
while cur ~= nil and cur:char() ~= c_match do
|
||||
cur = adv()
|
||||
if cur == nil then break end
|
||||
|
||||
local c2 = cur:char()
|
||||
if c2 == c_match then break end
|
||||
|
||||
if vim.tbl_contains(openers, c2) or vim.tbl_contains(closers, c2) then
|
||||
cur = cur:find_match(max_chars, invocations)
|
||||
cur = adv() -- move past the match
|
||||
end
|
||||
end
|
||||
|
||||
return cur
|
||||
end
|
||||
|
||||
return Pos
|
410
lua/tt/range.lua
Normal file
410
lua/tt/range.lua
Normal file
@ -0,0 +1,410 @@
|
||||
local Pos = require 'tt.pos'
|
||||
local State = require 'tt.state'
|
||||
local utils = require 'tt.utils'
|
||||
|
||||
---@class Range
|
||||
---@field start Pos
|
||||
---@field stop Pos
|
||||
---@field mode 'v'|'V'
|
||||
local Range = {}
|
||||
|
||||
---@param start Pos
|
||||
---@param stop Pos
|
||||
---@param mode? 'v'|'V'
|
||||
---@return Range
|
||||
function Range.new(start, stop, mode)
|
||||
if stop < start then
|
||||
start, stop = stop, start
|
||||
end
|
||||
|
||||
local r = { start = start, stop = stop, mode = mode or 'v' }
|
||||
local function str()
|
||||
---@param p Pos
|
||||
local function posstr(p)
|
||||
if p.off ~= 0 then
|
||||
return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off)
|
||||
else
|
||||
return string.format('Pos(%d:%d)', p.lnum, p.col)
|
||||
end
|
||||
end
|
||||
|
||||
local _1 = posstr(r.start)
|
||||
local _2 = posstr(r.stop)
|
||||
return string.format('Range{buf=%d, mode=%s, start=%s, stop=%s}', r.start.buf, r.mode, _1, _2)
|
||||
end
|
||||
setmetatable(r, { __index = Range, __tostring = str })
|
||||
return r
|
||||
end
|
||||
|
||||
function Range.is(x)
|
||||
local mt = getmetatable(x)
|
||||
return mt and mt.__index == Range
|
||||
end
|
||||
|
||||
---@param lpos string
|
||||
---@param rpos string
|
||||
---@return Range
|
||||
function Range.from_marks(lpos, rpos)
|
||||
local start = Pos.from_pos(lpos)
|
||||
local stop = Pos.from_pos(rpos)
|
||||
|
||||
---@type 'v'|'V'
|
||||
local mode
|
||||
if stop:is_col_max() then
|
||||
mode = 'V'
|
||||
else
|
||||
mode = 'v'
|
||||
end
|
||||
|
||||
return Range.new(start, stop, mode)
|
||||
end
|
||||
|
||||
---@param buf? number
|
||||
function Range.from_buf_text(buf)
|
||||
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
|
||||
local num_lines = vim.api.nvim_buf_line_count(buf)
|
||||
|
||||
local start = Pos.new(buf, 0, 0)
|
||||
local stop = Pos.new(buf, num_lines - 1, Pos.MAX_COL)
|
||||
return Range.new(start, stop, 'V')
|
||||
end
|
||||
|
||||
---@param buf? number
|
||||
---@param line number 0-based line index
|
||||
function Range.from_line(buf, line) return Range.from_lines(buf, line, line) end
|
||||
|
||||
---@param buf? number
|
||||
---@param start_line number 0-based line index
|
||||
---@param stop_line number 0-based line index
|
||||
function Range.from_lines(buf, start_line, stop_line)
|
||||
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
|
||||
return Range.new(Pos.new(buf, start_line, 0), Pos.new(buf, stop_line, Pos.MAX_COL), 'V')
|
||||
end
|
||||
|
||||
---@param text_obj string
|
||||
---@param opts? { buf?: number; contains_cursor?: boolean; pos?: Pos }
|
||||
---@return Range|nil
|
||||
function Range.from_text_object(text_obj, opts)
|
||||
opts = opts or {}
|
||||
if opts.buf == nil then opts.buf = vim.api.nvim_get_current_buf() end
|
||||
if opts.contains_cursor == nil then opts.contains_cursor = false end
|
||||
|
||||
---@type "a" | "i"
|
||||
local selection_type = text_obj:sub(1, 1)
|
||||
local obj_type = text_obj:sub(#text_obj, #text_obj)
|
||||
local is_quote = vim.tbl_contains({ "'", '"', '`' }, obj_type)
|
||||
local cursor = Pos.from_pos '.'
|
||||
|
||||
-- Yank, then read '[ and '] to know the bounds:
|
||||
---@type { start: Pos; stop: Pos }
|
||||
local positions
|
||||
vim.api.nvim_buf_call(opts.buf, function()
|
||||
positions = State.run(0, function(s)
|
||||
s:track_pos '.'
|
||||
s:track_pos "'["
|
||||
s:track_pos "']"
|
||||
|
||||
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
|
||||
|
||||
local null_pos = Pos.new(0, 0, 0, 0)
|
||||
null_pos:save_to_pos "'["
|
||||
null_pos:save_to_pos "']"
|
||||
|
||||
-- For some reason,
|
||||
-- vim.cmd.normal { cmd = 'normal', args = { '""y' .. text_obj }, mods = { silent = true } }
|
||||
-- does not work in all cases. We resort to using feedkeys instead:
|
||||
utils.feedkeys('""y' .. text_obj)
|
||||
|
||||
local start = Pos.from_pos "'["
|
||||
local stop = Pos.from_pos "']"
|
||||
|
||||
if
|
||||
-- I have no idea why, but when yanking `i"`, the stop-mark is
|
||||
-- placed on the ending quote. For other text-objects, the stop-
|
||||
-- mark is placed before the closing character.
|
||||
(is_quote and selection_type == 'i' and stop:char() == obj_type)
|
||||
-- *Sigh*, this also sometimes happens for `it` as well.
|
||||
or (text_obj == 'it' and stop:char() == '<')
|
||||
then
|
||||
stop = stop:next(-1) or stop
|
||||
end
|
||||
return { start = start, stop = stop }
|
||||
end)
|
||||
end)
|
||||
local start = positions.start
|
||||
local stop = positions.stop
|
||||
if start == stop and start.lnum == 0 and start.col == 0 and start.off == 0 then return nil end
|
||||
if opts.contains_cursor and not Range.new(start, stop):contains(cursor) then return nil end
|
||||
|
||||
if is_quote and selection_type == 'a' then
|
||||
start = start:find_next(1, obj_type) or start
|
||||
stop = stop:find_next(-1, obj_type) or stop
|
||||
end
|
||||
|
||||
return Range.new(start, stop)
|
||||
end
|
||||
|
||||
--- Get range information from the currently selected visual text.
|
||||
--- Note: from within a command mapping or an opfunc, use other specialized
|
||||
--- utilities, such as:
|
||||
--- * Range.from_cmd_args
|
||||
--- * Range.from_op_func
|
||||
function Range.from_vtext()
|
||||
local r = Range.from_marks('v', '.')
|
||||
if vim.fn.mode() == 'V' then r = r:to_linewise() end
|
||||
return r
|
||||
end
|
||||
|
||||
--- Get range information from the current text range being operated on
|
||||
--- as defined by an operator-pending function. Infers line-wise vs. char-wise
|
||||
--- based on the type, as given by the operator-pending function.
|
||||
---@param type 'line'|'char'|'block'
|
||||
function Range.from_op_func(type)
|
||||
if type == 'block' then error 'block motions not supported' end
|
||||
|
||||
local range = Range.from_marks("'[", "']")
|
||||
if type == 'line' then range = range:to_linewise() end
|
||||
return range
|
||||
end
|
||||
|
||||
--- Get range information from command arguments.
|
||||
---@param args unknown
|
||||
---@return Range|nil
|
||||
function Range.from_cmd_args(args)
|
||||
---@type 'v'|'V'
|
||||
local mode
|
||||
---@type nil|Pos
|
||||
local start
|
||||
local stop
|
||||
if args.range == 0 then
|
||||
return nil
|
||||
else
|
||||
start = Pos.from_pos "'<"
|
||||
stop = Pos.from_pos "'>"
|
||||
if stop:is_col_max() then
|
||||
mode = 'V'
|
||||
else
|
||||
mode = 'v'
|
||||
end
|
||||
end
|
||||
return Range.new(start, stop, mode)
|
||||
end
|
||||
|
||||
---
|
||||
function Range.find_nearest_brackets()
|
||||
local a = Range.from_text_object('a<', { contains_cursor = true })
|
||||
local b = Range.from_text_object('a[', { contains_cursor = true })
|
||||
local c = Range.from_text_object('a(', { contains_cursor = true })
|
||||
local d = Range.from_text_object('a{', { contains_cursor = true })
|
||||
return Range.smallest { a, b, c, d }
|
||||
end
|
||||
|
||||
function Range.find_nearest_quotes()
|
||||
local a = Range.from_text_object([[a']], { contains_cursor = true })
|
||||
if a ~= nil and a:is_empty() then a = nil end
|
||||
local b = Range.from_text_object([[a"]], { contains_cursor = true })
|
||||
if b ~= nil and b:is_empty() then b = nil end
|
||||
local c = Range.from_text_object([[a`]], { contains_cursor = true })
|
||||
if c ~= nil and c:is_empty() then c = nil end
|
||||
return Range.smallest { a, b, c }
|
||||
end
|
||||
|
||||
---@param ranges (Range|nil)[]
|
||||
function Range.smallest(ranges)
|
||||
---@type Range[]
|
||||
local new_ranges = {}
|
||||
for _, r in pairs(ranges) do
|
||||
if r ~= nil then table.insert(new_ranges, r) end
|
||||
end
|
||||
ranges = new_ranges
|
||||
if #ranges == 0 then return nil end
|
||||
|
||||
-- find smallest match
|
||||
local max_start = ranges[1].start
|
||||
local min_stop = ranges[1].stop
|
||||
local result = ranges[1]
|
||||
|
||||
for _, r in ipairs(ranges) do
|
||||
local start, stop = r.start, r.stop
|
||||
if start > max_start and stop < min_stop then
|
||||
max_start = start
|
||||
min_stop = stop
|
||||
result = r
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
function Range:clone() return Range.new(self.start:clone(), self.stop:clone(), self.mode) end
|
||||
function Range:line_count() return self.stop.lnum + self.start.lnum + 1 end
|
||||
|
||||
function Range:to_linewise()
|
||||
local r = self:clone()
|
||||
|
||||
r.mode = 'V'
|
||||
r.start.col = 0
|
||||
r.stop.col = Pos.MAX_COL
|
||||
|
||||
return r
|
||||
end
|
||||
|
||||
function Range:is_empty() return self.start == self.stop end
|
||||
|
||||
function Range:trim_start()
|
||||
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()
|
||||
local r = self:clone()
|
||||
while r.stop:char():match '%s' do
|
||||
local next = r.stop:next(-1)
|
||||
if next == nil then break end
|
||||
r.stop = next
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
---@param p Pos
|
||||
function Range:contains(p) return p >= self.start and p <= self.stop end
|
||||
|
||||
---@return string[]
|
||||
function Range:lines()
|
||||
local lines = {}
|
||||
for i = 0, self.stop.lnum - self.start.lnum do
|
||||
local line = self:line0(i)
|
||||
if line ~= nil then table.insert(lines, line.text()) end
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
---@return string
|
||||
function Range:text() return vim.fn.join(self:lines(), '\n') end
|
||||
|
||||
---@param i number 1-based
|
||||
---@param j? number 1-based
|
||||
function Range:sub(i, j) return self:text():sub(i, j) end
|
||||
|
||||
---@param l number
|
||||
---@return { line: string; idx0: { start: number; stop: number; }; lnum: number; range: fun():Range; text: fun():string }|nil
|
||||
function Range:line0(l)
|
||||
if l < 0 then return self:line0(self:line_count() + l) end
|
||||
local line = vim.api.nvim_buf_get_lines(self.start.buf, self.start.lnum + l, self.start.lnum + l + 1, false)[1]
|
||||
if line == nil then return end
|
||||
|
||||
local start = 0
|
||||
local stop = #line - 1
|
||||
if l == 0 then start = self.start.col end
|
||||
if l == self.stop.lnum - self.start.lnum then stop = self.stop.col end
|
||||
if stop == Pos.MAX_COL then stop = #line - 1 end
|
||||
local lnum = self.start.lnum + l
|
||||
|
||||
return {
|
||||
line = line,
|
||||
idx0 = { start = start, stop = stop },
|
||||
lnum = lnum,
|
||||
range = function()
|
||||
return Range.new(
|
||||
Pos.new(self.start.buf, lnum, start, self.start.off),
|
||||
Pos.new(self.start.buf, lnum, stop, self.stop.off),
|
||||
'v'
|
||||
)
|
||||
end,
|
||||
text = function() return line:sub(start + 1, stop + 1) end,
|
||||
}
|
||||
end
|
||||
|
||||
---@param replacement nil|string|string[]
|
||||
function Range:replace(replacement)
|
||||
if type(replacement) == 'string' then replacement = vim.fn.split(replacement, '\n') end
|
||||
|
||||
if replacement == nil and self.mode == 'V' then
|
||||
-- delete the lines:
|
||||
vim.api.nvim_buf_set_lines(self.start.buf, self.start.lnum, self.stop.lnum + 1, false, {})
|
||||
else
|
||||
if replacement == nil then replacement = { '' } end
|
||||
|
||||
if self.mode == 'v' then
|
||||
-- Fixup the bounds:
|
||||
local last_line = vim.api.nvim_buf_get_lines(self.stop.buf, self.stop.lnum, self.stop.lnum + 1, false)[1] or ''
|
||||
local max_col = #last_line
|
||||
if last_line ~= '' then max_col = max_col + 1 end
|
||||
|
||||
vim.api.nvim_buf_set_text(
|
||||
self.start.buf,
|
||||
self.start.lnum,
|
||||
self.start.col,
|
||||
self.stop.lnum,
|
||||
math.min(self.stop.col + 1, max_col),
|
||||
replacement
|
||||
)
|
||||
else
|
||||
vim.api.nvim_buf_set_lines(self.start.buf, self.start.lnum, self.stop.lnum + 1, false, replacement)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param amount number
|
||||
function Range:shrink(amount)
|
||||
local start = self.start
|
||||
local stop = self.stop
|
||||
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 = start 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 then error 'error in Range:must_shrink: Range:shrink() returned nil' end
|
||||
return shrunk
|
||||
end
|
||||
|
||||
---@param left string
|
||||
---@param right string
|
||||
function Range:save_to_pos(left, right)
|
||||
self.start:save_to_pos(left)
|
||||
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.stop:save_to_mark(right)
|
||||
end
|
||||
|
||||
function Range:set_visual_selection()
|
||||
if vim.api.nvim_get_current_buf() ~= self.start.buf then vim.api.nvim_set_current_buf(self.start.buf) end
|
||||
|
||||
State.run(self.start.buf, function(s)
|
||||
s:track_mark 'a'
|
||||
s:track_mark 'b'
|
||||
|
||||
self.start:save_to_mark 'a'
|
||||
self.stop:save_to_mark 'b'
|
||||
local mode = self.mode
|
||||
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
|
||||
|
||||
return nil
|
||||
end)
|
||||
end
|
||||
|
||||
return Range
|
66
lua/tt/repeat.lua
Normal file
66
lua/tt/repeat.lua
Normal file
@ -0,0 +1,66 @@
|
||||
local M = {}
|
||||
|
||||
local function _normal(cmd) vim.cmd.normal { cmd = 'normal', args = { cmd }, bang = true } end
|
||||
local function _feedkeys(keys, mode)
|
||||
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode or 'nx', true)
|
||||
end
|
||||
|
||||
M.native_repeat = function() _feedkeys '.' end
|
||||
M.native_undo = function() _feedkeys 'u' end
|
||||
|
||||
---@param cmd? string|fun():unknown
|
||||
function M.set(cmd)
|
||||
local ts = vim.b.changedtick
|
||||
vim.b.tt_changedtick = ts
|
||||
if cmd ~= nil then vim.b.tt_repeatcmd = cmd end
|
||||
end
|
||||
|
||||
---@generic T
|
||||
---@param cmd string|fun():T
|
||||
---@return T
|
||||
function M.run(cmd)
|
||||
M.set(cmd)
|
||||
local result = cmd()
|
||||
M.set()
|
||||
return result
|
||||
end
|
||||
|
||||
function M.do_repeat()
|
||||
local ts, tt_ts, tt_cmd = vim.b.changedtick, vim.b.tt_changedtick, vim.b.tt_repeatcmd
|
||||
if
|
||||
-- (force formatter break)
|
||||
tt_ts == nil
|
||||
or tt_cmd == nil
|
||||
-- has the buffer been modified after we last modified it?
|
||||
or ts > tt_ts
|
||||
or (type(tt_cmd) ~= 'function' and type(tt_cmd) ~= 'string')
|
||||
then
|
||||
return M.native_repeat()
|
||||
end
|
||||
|
||||
-- execute the cached command:
|
||||
local count = vim.api.nvim_get_vvar 'count1'
|
||||
if type(tt_cmd) == 'string' then
|
||||
_normal(count .. tt_cmd --[[@as string]])
|
||||
else
|
||||
local last_return
|
||||
for _ = 1, count do
|
||||
last_return = M.run(tt_cmd --[[@as fun():any]])
|
||||
end
|
||||
return last_return
|
||||
end
|
||||
end
|
||||
|
||||
function M.undo()
|
||||
M.native_undo()
|
||||
-- Update the current TS on the next event tick,
|
||||
-- to make sure we get the latest
|
||||
vim.schedule(M.set)
|
||||
end
|
||||
|
||||
function M.setup()
|
||||
vim.keymap.set('n', '.', M.do_repeat)
|
||||
vim.keymap.set('n', 'u', M.undo)
|
||||
end
|
||||
|
||||
return M
|
86
lua/tt/state.lua
Normal file
86
lua/tt/state.lua
Normal file
@ -0,0 +1,86 @@
|
||||
---@class State
|
||||
---@field buf number
|
||||
---@field registers table
|
||||
---@field marks table
|
||||
---@field positions table
|
||||
---@field keymaps { mode: string; lhs: any, rhs: any, buffer?: number }[]
|
||||
---@field global_options table<string, any>
|
||||
local State = {}
|
||||
|
||||
---@param buf number
|
||||
---@return State
|
||||
function State.new(buf)
|
||||
if buf == 0 then buf = vim.api.nvim_get_current_buf() end
|
||||
local s = { buf = buf, registers = {}, marks = {}, positions = {}, keymaps = {}, global_options = {} }
|
||||
setmetatable(s, { __index = State })
|
||||
return s
|
||||
end
|
||||
|
||||
---@generic T
|
||||
---@param buf number
|
||||
---@param f fun(s: State):T
|
||||
---@return T
|
||||
function State.run(buf, f)
|
||||
local s = State.new(buf)
|
||||
local ok, result = pcall(f, s)
|
||||
s:restore()
|
||||
if not ok then error(result) end
|
||||
return result
|
||||
end
|
||||
|
||||
---@param buf number
|
||||
---@param f fun(s: State, callback: fun(): any):any
|
||||
---@param callback fun():any
|
||||
function State.run_async(buf, f, callback)
|
||||
local s = State.new(buf)
|
||||
f(s, function()
|
||||
s:restore()
|
||||
callback()
|
||||
end)
|
||||
end
|
||||
|
||||
function State:track_keymap(mode, lhs)
|
||||
local old =
|
||||
-- Look up the mapping in buffer-local maps:
|
||||
vim.iter(vim.api.nvim_buf_get_keymap(self.buf, mode)):find(function(map) return map.lhs == lhs end)
|
||||
-- Look up the mapping in global maps:
|
||||
or vim.iter(vim.api.nvim_get_keymap(mode)):find(function(map) return map.lhs == lhs end)
|
||||
|
||||
-- Did we find a mapping?
|
||||
if old == nil then return end
|
||||
|
||||
-- Track it:
|
||||
table.insert(self.keymaps, { mode = mode, lhs = lhs, rhs = old.rhs or old.callback, buffer = old.buffer })
|
||||
end
|
||||
|
||||
---@param reg string
|
||||
function State:track_register(reg) self.registers[reg] = vim.fn.getreg(reg) end
|
||||
|
||||
---@param mark string
|
||||
function State:track_mark(mark) self.marks[mark] = vim.api.nvim_buf_get_mark(self.buf, mark) end
|
||||
|
||||
---@param pos string
|
||||
function State:track_pos(pos) self.positions[pos] = vim.fn.getpos(pos) end
|
||||
|
||||
---@param nm string
|
||||
function State:track_global_option(nm) self.global_options[nm] = vim.g[nm] end
|
||||
|
||||
function State:restore()
|
||||
for reg, val in pairs(self.registers) do
|
||||
vim.fn.setreg(reg, val)
|
||||
end
|
||||
for mark, val in pairs(self.marks) do
|
||||
vim.api.nvim_buf_set_mark(self.buf, mark, val[1], val[2], {})
|
||||
end
|
||||
for pos, val in pairs(self.positions) do
|
||||
vim.fn.setpos(pos, val)
|
||||
end
|
||||
for _, map in ipairs(self.keymaps) do
|
||||
vim.keymap.set(map.mode, map.lhs, map.rhs, { buffer = map.buffer })
|
||||
end
|
||||
for nm, val in pairs(self.global_options) do
|
||||
vim.g[nm] = val
|
||||
end
|
||||
end
|
||||
|
||||
return State
|
94
lua/tt/utils.lua
Normal file
94
lua/tt/utils.lua
Normal file
@ -0,0 +1,94 @@
|
||||
local M = {}
|
||||
|
||||
--
|
||||
-- Types
|
||||
--
|
||||
|
||||
---@alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
|
||||
---@alias KeyMaps table<string, fun(): any | string> }
|
||||
|
||||
---@param keys string
|
||||
---@param mode? string
|
||||
function M.feedkeys(keys, mode)
|
||||
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode or 'nx', true)
|
||||
end
|
||||
|
||||
---@alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: Range|nil }
|
||||
|
||||
--- A utility for creating user commands that also pre-computes useful information
|
||||
--- and attaches it to the arguments.
|
||||
---
|
||||
--- ```lua
|
||||
--- -- Example:
|
||||
--- ucmd('MyCmd', function(args)
|
||||
--- -- print the visually selected text:
|
||||
--- vim.print(args.info:text())
|
||||
--- -- or get the vtext as an array of lines:
|
||||
--- vim.print(args.info:lines())
|
||||
--- end, { nargs = '*', range = true })
|
||||
--- ```
|
||||
---@param name string
|
||||
---@param cmd string | fun(args: CmdArgs): any
|
||||
---@param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
|
||||
function M.ucmd(name, cmd, opts)
|
||||
opts = opts or {}
|
||||
local cmd2 = cmd
|
||||
if type(cmd) == 'function' then
|
||||
cmd2 = function(args)
|
||||
args.info = M.Range.from_cmd_args(args)
|
||||
return cmd(args)
|
||||
end
|
||||
end
|
||||
vim.api.nvim_create_user_command(name, cmd2, opts or {})
|
||||
end
|
||||
|
||||
---@param key_seq string
|
||||
---@param fn fun(key_seq: string):Range|Pos|nil
|
||||
---@param opts? { buffer: number|nil }
|
||||
function M.define_text_object(key_seq, fn, opts)
|
||||
local Range = require 'tt.range'
|
||||
local Pos = require 'tt.pos'
|
||||
|
||||
if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end
|
||||
|
||||
local function handle_visual()
|
||||
local range_or_pos = fn(key_seq)
|
||||
if range_or_pos == nil then return end
|
||||
|
||||
if Range.is(range_or_pos) then
|
||||
local range = range_or_pos --[[@as Range]]
|
||||
range:set_visual_selection()
|
||||
else
|
||||
M.feedkeys '<Esc>'
|
||||
end
|
||||
end
|
||||
vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil)
|
||||
|
||||
local function handle_normal()
|
||||
local State = require 'tt.state'
|
||||
|
||||
-- enter visual mode:
|
||||
M.feedkeys 'v'
|
||||
|
||||
local range_or_pos = fn(key_seq)
|
||||
if range_or_pos == nil then return end
|
||||
|
||||
if Range.is(range_or_pos) then
|
||||
range_or_pos:set_visual_selection()
|
||||
elseif Pos.is(range_or_pos) then
|
||||
local p = range_or_pos --[[@as Pos]]
|
||||
State.run(0, function(s)
|
||||
s:track_global_option 'eventignore'
|
||||
vim.opt_global.eventignore = 'all'
|
||||
|
||||
-- insert a single space, so we can select it:
|
||||
vim.api.nvim_buf_set_text(0, p.lnum, p.col, p.lnum, p.col, { ' ' })
|
||||
-- select the space:
|
||||
Range.new(p, p, 'v'):set_visual_selection()
|
||||
end)
|
||||
end
|
||||
end
|
||||
vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil)
|
||||
end
|
||||
|
||||
return M
|
4
selene.toml
Normal file
4
selene.toml
Normal file
@ -0,0 +1,4 @@
|
||||
std = "vim"
|
||||
|
||||
[lints]
|
||||
multiple_statements = "allow"
|
13
spec/buffer_spec.lua
Normal file
13
spec/buffer_spec.lua
Normal file
@ -0,0 +1,13 @@
|
||||
local Buffer = require 'tt.buffer'
|
||||
local withbuf = require '__tt_test_tools'
|
||||
|
||||
describe('Buffer', function()
|
||||
it('should replace all lines', function()
|
||||
withbuf({}, function()
|
||||
local buf = Buffer.new()
|
||||
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)
|
||||
end)
|
29
spec/codewriter_spec.lua
Normal file
29
spec/codewriter_spec.lua
Normal file
@ -0,0 +1,29 @@
|
||||
local CodeWriter = require 'tt.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)
|
69
spec/pos_spec.lua
Normal file
69
spec/pos_spec.lua
Normal file
@ -0,0 +1,69 @@
|
||||
local Pos = require 'tt.pos'
|
||||
local withbuf = require '__tt_test_tools'
|
||||
|
||||
describe('Pos', function()
|
||||
it('get a char from a given position', function()
|
||||
withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
|
||||
assert.are.same('a', Pos.new(nil, 0, 0):char())
|
||||
assert.are.same('d', Pos.new(nil, 0, 2):char())
|
||||
assert.are.same('f', Pos.new(nil, 0, 3):char())
|
||||
assert.are.same('a', Pos.new(nil, 2, 0):char())
|
||||
assert.are.same('', Pos.new(nil, 3, 0):char())
|
||||
assert.are.same('o', Pos.new(nil, 4, 2):char())
|
||||
end)
|
||||
end)
|
||||
|
||||
it('comparison operators', function()
|
||||
local a = Pos.new(0, 0, 0, 0)
|
||||
local b = Pos.new(0, 1, 0, 0)
|
||||
assert.are.same(a == a, true)
|
||||
assert.are.same(a < b, true)
|
||||
end)
|
||||
|
||||
it('get the next position', function()
|
||||
withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
|
||||
-- line 1: a => s
|
||||
assert.are.same(Pos.new(nil, 0, 1), Pos.new(nil, 0, 0):next())
|
||||
-- line 1: d => f
|
||||
assert.are.same(Pos.new(nil, 0, 3), Pos.new(nil, 0, 2):next())
|
||||
-- line 1 => 2
|
||||
assert.are.same(Pos.new(nil, 1, 0), Pos.new(nil, 0, 3):next())
|
||||
-- line 3 => 4
|
||||
assert.are.same(Pos.new(nil, 3, 0), Pos.new(nil, 2, 0):next())
|
||||
-- line 4 => 5
|
||||
assert.are.same(Pos.new(nil, 4, 0), Pos.new(nil, 3, 0):next())
|
||||
-- end returns nil
|
||||
assert.are.same(nil, Pos.new(nil, 4, 2):next())
|
||||
end)
|
||||
end)
|
||||
|
||||
it('get the previous position', function()
|
||||
withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function()
|
||||
-- line 1: s => a
|
||||
assert.are.same(Pos.new(nil, 0, 0), Pos.new(nil, 0, 1):next(-1))
|
||||
-- line 1: f => d
|
||||
assert.are.same(Pos.new(nil, 0, 2), Pos.new(nil, 0, 3):next(-1))
|
||||
-- line 2 => 1
|
||||
assert.are.same(Pos.new(nil, 0, 3), Pos.new(nil, 1, 0):next(-1))
|
||||
-- line 4 => 3
|
||||
assert.are.same(Pos.new(nil, 2, 0), Pos.new(nil, 3, 0):next(-1))
|
||||
-- line 5 => 4
|
||||
assert.are.same(Pos.new(nil, 3, 0), Pos.new(nil, 4, 0):next(-1))
|
||||
-- beginning returns nil
|
||||
assert.are.same(nil, Pos.new(nil, 0, 0):next(-1))
|
||||
end)
|
||||
end)
|
||||
|
||||
it('find matching brackets', function()
|
||||
withbuf({ 'asdf ({} def <[{}]>) ;lkj' }, function()
|
||||
-- outer parens are matched:
|
||||
assert.are.same(Pos.new(nil, 0, 19), Pos.new(nil, 0, 5):find_match())
|
||||
-- outer parens are matched (backward):
|
||||
assert.are.same(Pos.new(nil, 0, 5), Pos.new(nil, 0, 19):find_match())
|
||||
-- no potential match returns nil
|
||||
assert.are.same(nil, Pos.new(nil, 0, 0):find_match())
|
||||
-- watchdog expires before an otherwise valid match is found:
|
||||
assert.are.same(nil, Pos.new(nil, 0, 5):find_match(2))
|
||||
end)
|
||||
end)
|
||||
end)
|
279
spec/range_spec.lua
Normal file
279
spec/range_spec.lua
Normal file
@ -0,0 +1,279 @@
|
||||
local Range = require 'tt.range'
|
||||
local Pos = require 'tt.pos'
|
||||
local withbuf = require '__tt_test_tools'
|
||||
|
||||
describe('Range', function()
|
||||
it('get text in buffer', function()
|
||||
withbuf({ 'line one', 'and line two' }, function()
|
||||
local range = Range.from_buf_text()
|
||||
local lines = range:lines()
|
||||
assert.are.same({
|
||||
'line one',
|
||||
'and line two',
|
||||
}, lines)
|
||||
|
||||
local text = range:text()
|
||||
assert.are.same('line one\nand line two', text)
|
||||
end)
|
||||
|
||||
withbuf({}, function()
|
||||
local range = Range.from_buf_text()
|
||||
local lines = range:lines()
|
||||
assert.are.same({ '' }, lines)
|
||||
|
||||
local text = range:text()
|
||||
assert.are.same('', text)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('get from positions: v in single line', function()
|
||||
withbuf({ 'line one', 'and line two' }, function()
|
||||
local range = Range.new(Pos.new(nil, 0, 1), Pos.new(nil, 0, 3), 'v')
|
||||
local lines = range:lines()
|
||||
assert.are.same({ 'ine' }, lines)
|
||||
|
||||
local text = range:text()
|
||||
assert.are.same('ine', text)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('get from positions: v across multiple lines', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 2, 4), 'v')
|
||||
local lines = range:lines()
|
||||
assert.are.same({ 'quick brown fox', 'jumps' }, lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('get from positions: V', function()
|
||||
withbuf({ 'line one', 'and line two' }, function()
|
||||
local range = Range.new(Pos.new(nil, 0, 0), Pos.new(nil, 0, Pos.MAX_COL), 'V')
|
||||
local lines = range:lines()
|
||||
assert.are.same({ 'line one' }, lines)
|
||||
|
||||
local text = range:text()
|
||||
assert.are.same('line one', text)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('get from positions: V across multiple lines', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.new(Pos.new(nil, 1, 0), Pos.new(nil, 2, Pos.MAX_COL), 'V')
|
||||
local lines = range:lines()
|
||||
assert.are.same({ 'the quick brown fox', 'jumps over a lazy dog' }, lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('get from line', function()
|
||||
withbuf({ 'line one', 'and line two' }, function()
|
||||
local range = Range.from_line(nil, 0)
|
||||
local lines = range:lines()
|
||||
assert.are.same({ 'line one' }, lines)
|
||||
|
||||
local text = range:text()
|
||||
assert.are.same('line one', text)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('get from lines', function()
|
||||
withbuf({ 'line one', 'and line two', 'and line 3' }, function()
|
||||
local range = Range.from_lines(nil, 0, 1)
|
||||
local lines = range:lines()
|
||||
assert.are.same({ 'line one', 'and line two' }, lines)
|
||||
|
||||
local text = range:text()
|
||||
assert.are.same('line one\nand line two', text)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('replace within line', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 1, 8), 'v')
|
||||
range:replace 'quack'
|
||||
|
||||
local text = Range.from_line(nil, 1):text()
|
||||
assert.are.same('the quack brown fox', text)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('delete within line', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 1, 9), 'v')
|
||||
range:replace ''
|
||||
|
||||
local text = Range.from_line(nil, 1):text()
|
||||
assert.are.same('the brown fox', text)
|
||||
end)
|
||||
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 1, 9), 'v')
|
||||
range:replace(nil)
|
||||
|
||||
local text = Range.from_line(nil, 1):text()
|
||||
assert.are.same('the brown fox', text)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('replace across multiple lines: v', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.new(Pos.new(nil, 1, 4), Pos.new(nil, 2, 4), 'v')
|
||||
range:replace 'plane flew'
|
||||
|
||||
local lines = Range.from_buf_text():lines()
|
||||
assert.are.same({
|
||||
'pre line',
|
||||
'the plane flew over a lazy dog',
|
||||
'post line',
|
||||
}, lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('replace a line', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.from_line(nil, 1)
|
||||
range:replace 'the rabbit'
|
||||
|
||||
local lines = Range.from_buf_text():lines()
|
||||
assert.are.same({
|
||||
'pre line',
|
||||
'the rabbit',
|
||||
'jumps over a lazy dog',
|
||||
'post line',
|
||||
}, lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('replace multiple lines', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.from_lines(nil, 1, 2)
|
||||
range:replace 'the rabbit'
|
||||
|
||||
local lines = Range.from_buf_text():lines()
|
||||
assert.are.same({
|
||||
'pre line',
|
||||
'the rabbit',
|
||||
'post line',
|
||||
}, lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('delete single line', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.from_line(nil, 1)
|
||||
range:replace(nil) -- delete lines
|
||||
|
||||
local lines = Range.from_buf_text():lines()
|
||||
assert.are.same({
|
||||
'pre line',
|
||||
'jumps over a lazy dog',
|
||||
'post line',
|
||||
}, lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('delete multiple lines', function()
|
||||
withbuf({ 'pre line', 'the quick brown fox', 'jumps over a lazy dog', 'post line' }, function()
|
||||
local range = Range.from_lines(nil, 1, 2)
|
||||
range:replace(nil) -- delete lines
|
||||
|
||||
local lines = Range.from_buf_text():lines()
|
||||
assert.are.same({
|
||||
'pre line',
|
||||
'post line',
|
||||
}, lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('text object: word', function()
|
||||
withbuf({ 'the quick brown fox' }, function()
|
||||
vim.fn.setpos('.', { 0, 1, 5, 0 })
|
||||
assert.are.same('quick ', Range.from_text_object('aw'):text())
|
||||
|
||||
vim.fn.setpos('.', { 0, 1, 5, 0 })
|
||||
assert.are.same('quick', Range.from_text_object('iw'):text())
|
||||
end)
|
||||
end)
|
||||
|
||||
it('text object: quote', function()
|
||||
withbuf({ [[the "quick" brown fox]] }, function()
|
||||
vim.fn.setpos('.', { 0, 1, 5, 0 })
|
||||
assert.are.same('"quick"', Range.from_text_object('a"'):text())
|
||||
|
||||
vim.fn.setpos('.', { 0, 1, 6, 0 })
|
||||
assert.are.same('quick', Range.from_text_object('i"'):text())
|
||||
end)
|
||||
|
||||
withbuf({ [[the 'quick' brown fox]] }, function()
|
||||
vim.fn.setpos('.', { 0, 1, 5, 0 })
|
||||
assert.are.same("'quick'", Range.from_text_object([[a']]):text())
|
||||
|
||||
vim.fn.setpos('.', { 0, 1, 6, 0 })
|
||||
assert.are.same('quick', Range.from_text_object([[i']]):text())
|
||||
end)
|
||||
|
||||
withbuf({ [[the `quick` brown fox]] }, function()
|
||||
vim.fn.setpos('.', { 0, 1, 5, 0 })
|
||||
assert.are.same('`quick`', Range.from_text_object([[a`]]):text())
|
||||
|
||||
vim.fn.setpos('.', { 0, 1, 6, 0 })
|
||||
assert.are.same('quick', Range.from_text_object([[i`]]):text())
|
||||
end)
|
||||
end)
|
||||
|
||||
it('text object: block', function()
|
||||
withbuf({ 'this is a {', 'block', '} here' }, function()
|
||||
vim.fn.setpos('.', { 0, 2, 1, 0 })
|
||||
assert.are.same('{\nblock\n}', Range.from_text_object('a{'):text())
|
||||
|
||||
vim.fn.setpos('.', { 0, 2, 1, 0 })
|
||||
assert.are.same('block', Range.from_text_object('i{'):text())
|
||||
end)
|
||||
end)
|
||||
|
||||
it('text object: restores cursor position', function()
|
||||
withbuf({ 'this is a {block} here' }, function()
|
||||
vim.fn.setpos('.', { 0, 1, 13, 0 })
|
||||
assert.are.same('{block}', Range.from_text_object('a{'):text())
|
||||
assert.are.same(vim.api.nvim_win_get_cursor(0), { 1, 12 })
|
||||
end)
|
||||
end)
|
||||
|
||||
it('should get nearest block', function()
|
||||
withbuf({
|
||||
'this is a {',
|
||||
'block',
|
||||
'} here',
|
||||
}, function()
|
||||
vim.fn.setpos('.', { 0, 2, 1, 0 })
|
||||
assert.are.same('{\nblock\n}', Range.find_nearest_brackets():text())
|
||||
end)
|
||||
|
||||
withbuf({
|
||||
'this is a {',
|
||||
'(block)',
|
||||
'} here',
|
||||
}, function()
|
||||
vim.fn.setpos('.', { 0, 2, 3, 0 })
|
||||
assert.are.same('(block)', Range.find_nearest_brackets():text())
|
||||
end)
|
||||
end)
|
||||
|
||||
it('line0', function()
|
||||
withbuf({
|
||||
'this is a {',
|
||||
'block',
|
||||
'} here',
|
||||
}, function()
|
||||
local range = Range.new(Pos.new(0, 0, 5), Pos.new(0, 1, 4), 'v')
|
||||
local lfirst = range:line0(0)
|
||||
assert.are.same(5, lfirst.idx0.start)
|
||||
assert.are.same(10, lfirst.idx0.stop)
|
||||
assert.are.same(0, lfirst.lnum)
|
||||
assert.are.same('is a {', lfirst.text())
|
||||
assert.are.same('is a {', lfirst.range():text())
|
||||
assert.are.same(Pos.new(0, 0, 5), lfirst.range().start)
|
||||
assert.are.same(Pos.new(0, 0, 10), lfirst.range().stop)
|
||||
assert.are.same('block', range:line0(1).text())
|
||||
end)
|
||||
end)
|
||||
end)
|
6
stylua.toml
Normal file
6
stylua.toml
Normal file
@ -0,0 +1,6 @@
|
||||
call_parentheses = "None"
|
||||
collapse_simple_statement = "Always"
|
||||
column_width = 120
|
||||
indent_type = "Spaces"
|
||||
indent_width = 2
|
||||
quote_style = "AutoPreferSingle"
|
24
vim.yml
Normal file
24
vim.yml
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
base: lua51
|
||||
globals:
|
||||
vim:
|
||||
any: true
|
||||
assert.are.same:
|
||||
args:
|
||||
- type: any
|
||||
- type: any
|
||||
assert.are_not.same:
|
||||
args:
|
||||
- type: any
|
||||
- type: any
|
||||
describe:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
it:
|
||||
args:
|
||||
- type: string
|
||||
- type: function
|
||||
before_each:
|
||||
args:
|
||||
- type: function
|
Loading…
x
Reference in New Issue
Block a user