From b9edc4d8ff2c3497047be992a43803b01e3ffa53 Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Fri, 6 Sep 2024 13:10:37 -0600 Subject: [PATCH] bugfixes; update README.md --- .github/workflows/ci.yaml | 15 ++++ README.md | 157 +++++++++++++++++++++++++-------- lua/__tt_test_tools.lua | 2 + lua/tt/buffer.lua | 6 +- lua/tt/codewriter.lua | 4 +- lua/tt/opkeymap.lua | 14 +-- lua/tt/range.lua | 16 ++-- spec/buffer_spec.lua | 19 +++- spec/range_spec.lua | 179 ++++++++++++++++++++++++++++++++++++++ vim.yml | 12 +++ 10 files changed, 366 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..759ac9b --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/README.md b/README.md index ee9ec8a..4a4feb8 100644 --- a/README.md +++ b/README.md @@ -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.' 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: `toaw`, and the current word will be the context: +vim.keymap.set('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, `riw`: +-- `range` will contain the bounds of the motion `iw`. +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: @@ -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, `riw`: --- `range` will contain the bounds of the motion `iw`. -opkeymap('n', '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) diff --git a/lua/__tt_test_tools.lua b/lua/__tt_test_tools.lua index 7a9bb77..4121087 100644 --- a/lua/__tt_test_tools.lua +++ b/lua/__tt_test_tools.lua @@ -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) diff --git a/lua/tt/buffer.lua b/lua/tt/buffer.lua index dd4bcfc..4001461 100644 --- a/lua/tt/buffer.lua +++ b/lua/tt/buffer.lua @@ -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') diff --git a/lua/tt/codewriter.lua b/lua/tt/codewriter.lua index 79628c7..4e91953 100644 --- a/lua/tt/codewriter.lua +++ b/lua/tt/codewriter.lua @@ -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 diff --git a/lua/tt/opkeymap.lua b/lua/tt/opkeymap.lua index a1f8ed5..d509bec 100644 --- a/lua/tt/opkeymap.lua +++ b/lua/tt/opkeymap.lua @@ -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 diff --git a/lua/tt/range.lua b/lua/tt/range.lua index e94cd58..f63cda9 100644 --- a/lua/tt/range.lua +++ b/lua/tt/range.lua @@ -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 diff --git a/spec/buffer_spec.lua b/spec/buffer_spec.lua index d4c8100..5fa1856 100644 --- a/spec/buffer_spec.lua +++ b/spec/buffer_spec.lua @@ -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) diff --git a/spec/range_spec.lua b/spec/range_spec.lua index ab375d2..1c21c3f 100644 --- a/spec/range_spec.lua +++ b/spec/range_spec.lua @@ -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) diff --git a/vim.yml b/vim.yml index 3b7c394..4e0149f 100644 --- a/vim.yml +++ b/vim.yml @@ -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