diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 759ac9b..ef4ab02 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,5 +11,6 @@ jobs: - uses: rhysd/action-setup-vim@v1 with: neovim: true - version: v0.10.1 + version: v0.11.0 + arch: 'x86_64' - run: make test diff --git a/.gitignore b/.gitignore index c3a523b..9718e15 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ +/.lux/ +lux.lock *.src.rock +*.aider* diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..8317fde --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,2 @@ +-- :vim set ft=lua +globals = { "vim" } diff --git a/Makefile b/Makefile index 386fc81..fe9d0eb 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ PLENARY_DIR=~/.local/share/nvim/site/pack/test/opt/plenary.nvim all: lint test lint: - selene . + lua-language-server --check=lua/u/ --checklevel=Error + lx check fmt: stylua . diff --git a/README.md b/README.md index 7ecf73a..4fdda1b 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,315 @@ # u.nvim -Welcome to **u.nvim** – a powerful Lua library designed to enhance your text manipulation experience in NeoVim, focusing primarily on a context-aware "Range" utility. This utility allows you to work efficiently with text selections based on various conditions, in a variety of contexts, making coding and editing more intuitive and productive. +Welcome to **u.nvim** - a powerful Lua library designed to enhance your text +manipulation experience in NeoVim, focusing on text-manipulation utilities. +This includes a `Range` utility, allowing you to work efficiently with text +selections based on various conditions, as well as a declarative `Render`-er, +making coding and editing more intuitive and productive. -This is meant to be used as a **library**, not a plugin. On its own, `u.nvim` does nothing. It is meant to be used by plugin authors, to make their lives easier based on the variety of utilities I found I needed while growing my NeoVim config. +This is meant to be used as a **library**, not a plugin. On its own, `u.nvim` +does nothing. It is meant to be used by plugin authors, to make their lives +easier based on the variety of utilities I found I needed while growing my +NeoVim config. To get an idea of what a plugin built on top of `u.nvim` would +look like, check out the [examples/](./examples/) directory. ## Features -- **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). +- **Rendering System**: a utility that can declaratively render NeoVim-specific + hyperscript into a buffer, supporting creating/managing extmarks, highlights, + and key-event handling (requires NeoVim >0.11) +- **Signals**: a simple dependency tracking system that pairs well with the + rendering utilities for creating reactive/interactive UIs in NeoVim. +- **Range Utility**: Get context-aware selections with ease. Replace regions + with new text. Think of it as a programmatic way to work with visual + selections (or regions of text). - **Code Writer**: Write code with automatic indentation and formatting. -- **Operator Key Mapping**: Flexible key mapping that works with the selected text. -- **Text and Position Utilities**: Convenient functions to manage text objects and cursor positions. +- **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 -lazy.nvim: +This being a library, and not a proper plugin, it is recommended that you +vendor the specific version of this library that you need, including it in your +code. Package managers are a developing landscape for Lua in the context of +NeoVim. Perhaps in the future, `lux` will eliminate the need to vendor this +library in your application code. + +## Signal and Rendering Usage + +### Overview + +The Signal and Rendering mechanisms are two subsystems of u.nvim, that, while +simplistic, [compose](./examples/counter.lua) [together](./examples/filetree.lua) +[powerfully](./examples/picker.lua) to create a system for interactive and +responsive user interfaces. Here is a quick example that show-cases how easy it +is to dive in to make any buffer an interactive UI: + +
+Example Code: counter.lua + ```lua --- Setting `lazy = true` ensures that the library is only loaded --- when `require 'u.' is called. -{ 'jrop/u.nvim', lazy = true } +local tracker = require 'u.tracker' +local Buffer = require 'u.buffer' +local h = require('u.renderer').h + +-- Create an buffer for the UI +vim.cmd.vnew() +local ui_buf = Buffer.current() +ui_buf:set_tmp_options() + +local s_count = tracker.create_signal(0) + +-- Effect: Render +-- Setup the effect for rendering the UI whenever dependencies are updated +tracker.create_effect(function() + -- Calling `Signal:get()` in an effect registers the given signal as a + -- dependency of the current effect. Whenever that signal (or any other + -- dependency) changes, the effect will rerun. In this particular case, + -- rendering the UI is an effect that depends on one signal. + local count = s_count:get() + + -- Markup is hyperscript, which is just 1) text, and 2) tags (i.e., + -- constructed with `h(...)` calls). To help organize the markup, text and + -- tags can be nested in tables at any depth. Line breaks must be specified + -- manually, with '\n'. + ui_buf:render { + 'Reactive Counter Example\n', + '========================\n\n', + + { 'Counter: ', tostring(count), '\n' }, + + '\n', + + { + h('text', { + hl = 'DiffDelete', + nmap = { + [''] = function() + -- Update the contents of the s_count signal, notifying any + -- dependencies (in this case, the render effect): + vim.schedule(function() + s_count:update(function(n) return n - 1 end) + end) + -- Also equivalent: s_count:set(s_count:get() - 1) + return '' + end, + }, + }, ' Decrement '), + ' ', + h('text', { + hl = 'DiffAdd', + nmap = { + [''] = function() + -- Update the contents of the s_count signal, notifying any + -- dependencies (in this case, the render effect): + vim.schedule(function() + s_count:update(function(n) return n + 1 end) + end) + -- Also equivalent: s_count:set(s_count:get() - 1) + return '' + end, + }, + }, ' Increment '), + }, + + '\n', + '\n', + { 'Press on each "button" above to increment/decrement the counter.' }, + } +end) ``` -## Usage +
+ +### `u.tracker` + +The `u.tracker` module provides a simple API for creating reactive variables. +These can be composed in Effects and Memos utilizing Execution Contexts that +track what signals are used by effects/memos. + +```lua +local tracker = require('u.tracker') + +local s_number = tracker.Signal:new(0) +-- auto-compute the double of the number each time it changes: +local s_doubled = tracker.create_memo(function() return s_number:get() * 2 end) +tracker.create_effect(function() + local n = s_doubled:get() + -- ... + -- whenever s_doubled changes, this function gets run +end) +``` + +**Note**: circular dependencies are **not** supported. + +### `u.renderer` + +The renderer library renders hyperscript into a buffer. Each render performs a +minimal set of changes in order to transform the current buffer text into the +desired state. + +**Hyperscript** is just 1) _text_ 2) `` tags, which can be nested in 3) +Lua tables for readability: + +```lua +local h = require('u.renderer').h +-- Hyperscript can be organized into tables: +{ + "Hello, ", + { + "I am ", { "a" }, " nested table.", + }, + '\n', -- newlines must be explicitly specified + + -- booleans/nil are ignored: + some_conditional_flag and 'This text only shows when the flag is true', + -- e.g., use the above to show newlines in lists: + idx > 1 and '\n', + + -- tags are specified like so: + -- h('text', attributes, children) + h('text', {}, "I am a text node."), + + -- tags can be highlighted: + h('text', { hl = 'Comment' }, "I am highlighted."), + + -- tags can respond to key events: + h('text', { + hl = 'Keyword', + nmap = { + [""] = function() + print("Hello World") + -- Return '' to swallow the event: + return '' + end, + }, + }, "I am a text node."), +} +``` + +Managing complex tables of hyperscript can be done more ergonomically using the +`TreeBuilder` helper class: + +```lua +local TreeBuilder = require('u.renderer').TreeBuilder + +-- ... +renderer:render( + TreeBuilder.new() + -- text: + :put('some text') + -- hyperscript tables: + :put({ 'some text', 'more hyperscript' }) + -- hyperscript tags: + :put_h('text', { --[[attributes]] }, { --[[children]] }) + -- callbacks: + --- @param tb TreeBuilder + :nest(function(tb) + tb:put('some text') + end) + :tree() +) +``` + +**Rendering**: The renderer library provides a `render` function that takes +hyperscript in, and converts it to formatted buffer text: + +```lua +local Renderer = require('u.renderer').Renderer +local renderer = Renderer:new(0 --[[buffer number]]) +renderer:render { + -- ...hyperscript... +} + +-- or, if you already have a buffer: +local Buffer = require('u.buffer') +local buf = Buffer.current() +buf:render { + -- ...hyperscript... +} +``` + +## Range Usage ### 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. +
+ +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. + +
+ +
+This has changed in v2. After much thought, I realized that: + +1. The 0-based indexing in NeoVim is prevelant in the `:api`, which is designed + to be exposed to many languages. As such, it makes sense for this interface + to use 0-based indexing. However, many internal Vim functions use 1-based + indexing. +2. This is a Lua library (surprise, surprise, duh) - the idioms of the language + should take precedence over my preference +3. There were subtle bugs in the code where indices weren't being normalized to + 0-based, anyways. Somehow it worked most of the time. + +As such, this library now uses 1-based indexing everywhere, doing the necessary +interop conversions when calling `:api` functions. ### 1. Creating a Range -The `Range` utility is the main feature upon which most other things in this library are built, aside from a few standalone utilities. Ranges can be constructed manually, or preferably, obtained based on a variety of contexts. +The `Range` utility is the main feature 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 'u.range' -local start = Pos.new(0, 0, 0) -- Line 1, first column -local stop = Pos.new(0, 2, 0) -- Line 3, first column +local start = Pos.new(0, 1, 1) -- Line 1, first column +local stop = Pos.new(0, 3, 1) -- Line 3, first column Range.new(start, stop, 'v') -- charwise selection Range.new(start, stop, 'V') -- linewise selection ``` -This is usually not how you want to obtain a `Range`, however. Usually you want to get the corresponding context of an edit operation and just "get me the current Range that represents this context". +This is usually not how you want to obtain a `Range`, however. Usually you want +to get the corresponding context of an edit operation and just "get me the +current Range that represents this context". ```lua -- get the first line in a buffer: -Range.from_line(0, 0) +Range.from_line(bufnr, 1) -- Text Objects (any text object valid in your configuration is supported): -- get the word the cursor is on: -Range.from_text_object('iw') +Range.from_motion('iw') -- get the WORD the cursor is on: -Range.from_text_object('iW') +Range.from_motion('iW') -- get the "..." the cursor is within: -Range.from_text_object('a"') +Range.from_motion('a"') -- Get the currently visually selected text: --- NOTE: this does NOT work within certain contexts; more specialized utilities are more appropriate in certain circumstances +-- 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' +--- @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: +-- 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@' @@ -75,7 +318,8 @@ end, { expr = true }) -- -- Commands: -- --- When executing commands in a visual context, getting the selected text has to be done differently: +-- When executing commands in a visual context, getting the selected text has +-- to be done differently: vim.api.nvim_create_user_command('MyCmd', function(args) local range = Range.from_cmd_args(args) if range == nil then @@ -86,14 +330,15 @@ vim.api.nvim_create_user_command('MyCmd', function(args) end, { range = true }) ``` -So far, that's a lot of ways to _get_ a `Range`. But what can you do with a range once you have one? Plenty, it turns out! +So far, that's a lot of ways to _get_ a `Range`. But what can you do with a +range once you have one? Plenty, it turns out! ```lua 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 +range:line(1) -- get the first line within this range +range:line(-1) -- get the last line within this range -- replace with new contents: range:replace { 'replacement line 1', @@ -136,14 +381,15 @@ cw:write('}') #### Custom Text Objects -Simply by returning a `Range` or a `Pos`, you can easily and quickly define your own text objects: +Simply by returning a `Range` or a `Pos`, you can easily and quickly define +your own text objects: ```lua -local utils = require 'u.utils' +local txtobj = require 'u.txtobj' local Range = require 'u.range' -- Select whole file: -utils.define_text_object('ag', function() +txtobj.define('ag', function() return Range.from_buf_text() end) ``` @@ -155,27 +401,39 @@ Access and manipulate buffers easily: ```lua local Buffer = require 'u.buffer' local buf = Buffer.current() -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.b.