diff --git a/.busted b/.busted index 8c64018..655edd3 100644 --- a/.busted +++ b/.busted @@ -1,8 +1,7 @@ return { _all = { - coverage = false, lpath = "lua/?.lua;lua/?/init.lua", - lua = "nlua", + lua = "nvim -u NONE -i NONE -l", }, default = { verbose = true diff --git a/.emmyrc.json b/.emmyrc.json new file mode 100644 index 0000000..4825843 --- /dev/null +++ b/.emmyrc.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json", + "diagnostics": { + "disable": [ + "access-invisible", + "redefined-local" + ] + }, + "runtime": { + "version": "LuaJIT" + }, + "workspace": { + "ignoreDir": [ + ".prefix" + ], + "library": [ + "$VIMRUNTIME", + "library/busted", + "library/luv" + ] + } +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c8e6c01..a74cf7f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,29 +1,60 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -name: NeoVim tests -on: [push] +name: ci + +on: + workflow_dispatch: + pull_request: + push: + tags: ["*"] + branches: ["*"] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + MISE_EXPERIMENTAL: true + jobs: - code-quality: + ci: runs-on: ubuntu-latest - env: - XDG_CONFIG_HOME: ${{ github.workspace }}/.config/ + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v31 with: - nix_path: nixpkgs=channel:nixos-unstable + submodules: true - - name: Populate Nix store - run: - nix-shell --run 'true' + - name: Install apt dependencies + run: | + sudo apt-get update + sudo apt-get install -y libreadline-dev - - name: Type-check with lua-language-server - run: - nix-shell --run 'make lint' + - name: Setup environment + run: | + if [ -n "${{secrets.TOKEN}}" ]; then + export GITHUB_TOKEN="${{secrets.TOKEN}}" + fi + export MISE_GITHUB_TOKEN="$GITHUB_TOKEN" + echo "$GITHUB_TOKEN" >> $GITHUB_ENV + echo "$MISE_GITHUB_TOKEN" >> $GITHUB_ENV - - name: Check formatting with stylua - run: - nix-shell --run 'make fmt-check' + - name: Install mise + run: | + curl https://mise.run | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH + echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH + echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH - - name: Run busted tests - run: - nix-shell --run 'make test' + - name: Install mise dependencies + run: | + mise install + mise list --local + + - name: Check Lua formatting + run: mise run fmt:check + + - name: Check for type-errors + run: mise run lint + + - name: Run tests + run: mise run test:all diff --git a/.gitignore b/.gitignore index 202f9f8..6eac4e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.prefix/ *.src.rock *.aider* luacov.*.out diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..072ad6f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "library/busted"] + path = library/busted + url = https://github.com/LuaCATS/busted + branch = main +[submodule "library/luv"] + path = library/luv + url = https://github.com/LuaCATS/luv + branch = main +[submodule "nvimv"] + path = nvimv + url = https://github.com/jrop/nvimv + branch = main diff --git a/.luacov b/.luacov index 7c09929..1f5c147 100644 --- a/.luacov +++ b/.luacov @@ -1,6 +1,6 @@ return { include = { - 'lua/u/', + 'lua/u$', }, tick = true, } diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..a659baf --- /dev/null +++ b/.luarc.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/refs/heads/master/setting/schema.json", + "runtime": { + "version": "LuaJIT" + }, + "workspace": { + "ignoreDir": [ + ".prefix" + ], + "library": [ + "$VIMRUNTIME", + "library/busted", + "library/luv" + ] + } +} diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..69fb129 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +library/ diff --git a/.woodpecker/ci.yaml b/.woodpecker/ci.yaml new file mode 100644 index 0000000..8374cb6 --- /dev/null +++ b/.woodpecker/ci.yaml @@ -0,0 +1,10 @@ +when: + - event: push + +steps: + - name: build + image: nixos/nix + commands: + - nix-shell --pure --run 'make lint' + - nix-shell --pure --run 'make fmt-check' + - nix-shell --pure --run 'make test' diff --git a/Makefile b/Makefile deleted file mode 100644 index 0c4a074..0000000 --- a/Makefile +++ /dev/null @@ -1,21 +0,0 @@ -all: lint fmt-check test - -lint: - @echo "## Typechecking" - @lua-language-server --check=lua/u/ --checklevel=Error - -fmt-check: - @echo "## Checking code format" - @stylua --check . - -fmt: - @echo "## Formatting code" - @stylua . - -test: - @rm -f luacov.*.out - @echo "## Running tests" - @busted --coverage --verbose - @echo "## Generating coverage report" - @luacov - @awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out diff --git a/README.md b/README.md index 4fdda1b..6f8822b 100644 --- a/README.md +++ b/README.md @@ -1,235 +1,92 @@ # u.nvim -Welcome to **u.nvim** - a powerful Lua library designed to enhance your text -manipulation experience in NeoVim, focusing on text-manipulation utilities. -This includes a `Range` utility, allowing you to work efficiently with text -selections based on various conditions, as well as a declarative `Render`-er, -making coding and editing more intuitive and productive. +Welcome to **u.nvim** - a Lua library for text manipulation in Neovim, focusing on +range-based text operations, positions, and operator-pending mappings. -This is meant to be used as a **library**, not a plugin. On its own, `u.nvim` -does nothing. It is meant to be used by plugin authors, to make their lives -easier based on the variety of utilities I found I needed while growing my -NeoVim config. To get an idea of what a plugin built on top of `u.nvim` would -look like, check out the [examples/](./examples/) directory. +This is a **single-file micro-library** meant to be vendored in your plugin or +config. On its own, `u.nvim` does nothing. It is meant to be used by plugin +authors to make their lives easier. To get an idea of what a plugin built on +top of `u.nvim` would look like, check out the [examples/](./examples/) directory. ## Features -- **Rendering System**: a utility that can declaratively render NeoVim-specific - hyperscript into a buffer, supporting creating/managing extmarks, highlights, - and key-event handling (requires NeoVim >0.11) -- **Signals**: a simple dependency tracking system that pairs well with the - rendering utilities for creating reactive/interactive UIs in NeoVim. -- **Range Utility**: Get context-aware selections with ease. Replace regions - with new text. Think of it as a programmatic way to work with visual - selections (or regions of text). -- **Code Writer**: Write code with automatic indentation and formatting. -- **Operator Key Mapping**: Flexible key mapping that works with the selected - text. -- **Text and Position Utilities**: Convenient functions to manage text objects - and cursor positions. +- **Range Utility**: Get context-aware selections with ease. Replace regions with + new text. Think of it as a programmatic way to work with visual selections. +- **Position Utilities**: Work with cursor positions, marks, and extmarks. +- **Operator Key Mapping**: Flexible key mapping that works with motions. +- **Text Object Definitions**: Define custom text objects easily. +- **User Command Helpers**: Create commands with range support. +- **Repeat Utilities**: Dot-repeat support for custom operations. ### Installation -This being a library, and not a proper plugin, it is recommended that you -vendor the specific version of this library that you need, including it in your -code. Package managers are a developing landscape for Lua in the context of -NeoVim. Perhaps in the future, `lux` will eliminate the need to vendor this -library in your application code. +This being a library, and not a proper plugin, it is recommended that you vendor +the specific version of this library that you need, including it in your code. +Package managers are a developing landscape for Lua in the context of NeoVim. +Perhaps in the future, `lux` will eliminate the need to vendor this library in +your application code. -## Signal and Rendering Usage +#### If you are a Plugin Author -### Overview - -The Signal and Rendering mechanisms are two subsystems of u.nvim, that, while -simplistic, [compose](./examples/counter.lua) [together](./examples/filetree.lua) -[powerfully](./examples/picker.lua) to create a system for interactive and -responsive user interfaces. Here is a quick example that show-cases how easy it -is to dive in to make any buffer an interactive UI: +Neovim does not have a good answer for automatic management of plugin dependencies. As such, it is recommended that library authors vendor u.nvim within their plugin. **u.nvim** is implemented in a single file, so this should be relatively painless. Furthermore, `lua/u.lua` versions are published into artifact tags `artifact-vX.Y.Z` as `init.lua` so that plugin authors can add u.nvim as a submodule to their plugin.
-Example Code: counter.lua +Example git submodule setup + +```bash +# In your plugin repository +git submodule add -- https://github.com/jrop/u.nvim lua/my_plugin/u +cd lua/my_plugin/u/ +git checkout artifact-v0.2.0 # put whatever version of u.nvim you want to pin here +# ... commit the submodule within your repo + +# This would place u.nvim@v0.2.0 at: +# lua/my_plugin/u/init.lua +``` + +Then in your plugin code: ```lua -local tracker = require 'u.tracker' -local Buffer = require 'u.buffer' -local h = require('u.renderer').h +local u = require('my_plugin.u') --- Create an buffer for the UI -vim.cmd.vnew() -local ui_buf = Buffer.current() -ui_buf:set_tmp_options() +local Pos = u.Pos +local Range = u.Range +-- etc. +``` -local s_count = tracker.create_signal(0) +This approach allows plugin authors to: +- Pin to specific versions of u.nvim +- Get updates by pulling/committing new **u.nvim** versions (i.e., the usual git submodule way) +- Keep the dependency explicit and version-controlled +- Avoid namespace conflicts with user-installed plugins --- Effect: Render --- Setup the effect for rendering the UI whenever dependencies are updated -tracker.create_effect(function() - -- Calling `Signal:get()` in an effect registers the given signal as a - -- dependency of the current effect. Whenever that signal (or any other - -- dependency) changes, the effect will rerun. In this particular case, - -- rendering the UI is an effect that depends on one signal. - local count = s_count:get() +
- -- Markup is hyperscript, which is just 1) text, and 2) tags (i.e., - -- constructed with `h(...)` calls). To help organize the markup, text and - -- tags can be nested in tables at any depth. Line breaks must be specified - -- manually, with '\n'. - ui_buf:render { - 'Reactive Counter Example\n', - '========================\n\n', +#### If you are a User - { 'Counter: ', tostring(count), '\n' }, +If you want to use u.nvim in your config directly: - '\n', +
+vim.pack - { - h('text', { - hl = 'DiffDelete', - nmap = { - [''] = function() - -- Update the contents of the s_count signal, notifying any - -- dependencies (in this case, the render effect): - vim.schedule(function() - s_count:update(function(n) return n - 1 end) - end) - -- Also equivalent: s_count:set(s_count:get() - 1) - return '' - end, - }, - }, ' Decrement '), - ' ', - h('text', { - hl = 'DiffAdd', - nmap = { - [''] = function() - -- Update the contents of the s_count signal, notifying any - -- dependencies (in this case, the render effect): - vim.schedule(function() - s_count:update(function(n) return n + 1 end) - end) - -- Also equivalent: s_count:set(s_count:get() - 1) - return '' - end, - }, - }, ' Increment '), - }, - - '\n', - '\n', - { 'Press on each "button" above to increment/decrement the counter.' }, - } -end) +```lua +vim.pack.add { 'https://github.com/jrop/u.nvim' } ```
-### `u.tracker` - -The `u.tracker` module provides a simple API for creating reactive variables. -These can be composed in Effects and Memos utilizing Execution Contexts that -track what signals are used by effects/memos. +
+lazy.nvim ```lua -local tracker = require('u.tracker') - -local s_number = tracker.Signal:new(0) --- auto-compute the double of the number each time it changes: -local s_doubled = tracker.create_memo(function() return s_number:get() * 2 end) -tracker.create_effect(function() - local n = s_doubled:get() - -- ... - -- whenever s_doubled changes, this function gets run -end) -``` - -**Note**: circular dependencies are **not** supported. - -### `u.renderer` - -The renderer library renders hyperscript into a buffer. Each render performs a -minimal set of changes in order to transform the current buffer text into the -desired state. - -**Hyperscript** is just 1) _text_ 2) `` tags, which can be nested in 3) -Lua tables for readability: - -```lua -local h = require('u.renderer').h --- Hyperscript can be organized into tables: { - "Hello, ", - { - "I am ", { "a" }, " nested table.", - }, - '\n', -- newlines must be explicitly specified - - -- booleans/nil are ignored: - some_conditional_flag and 'This text only shows when the flag is true', - -- e.g., use the above to show newlines in lists: - idx > 1 and '\n', - - -- tags are specified like so: - -- h('text', attributes, children) - h('text', {}, "I am a text node."), - - -- tags can be highlighted: - h('text', { hl = 'Comment' }, "I am highlighted."), - - -- tags can respond to key events: - h('text', { - hl = 'Keyword', - nmap = { - [""] = function() - print("Hello World") - -- Return '' to swallow the event: - return '' - end, - }, - }, "I am a text node."), + 'jrop/u.nvim', + lazy = true, } ``` -Managing complex tables of hyperscript can be done more ergonomically using the -`TreeBuilder` helper class: +
-```lua -local TreeBuilder = require('u.renderer').TreeBuilder - --- ... -renderer:render( - TreeBuilder.new() - -- text: - :put('some text') - -- hyperscript tables: - :put({ 'some text', 'more hyperscript' }) - -- hyperscript tags: - :put_h('text', { --[[attributes]] }, { --[[children]] }) - -- callbacks: - --- @param tb TreeBuilder - :nest(function(tb) - tb:put('some text') - end) - :tree() -) -``` - -**Rendering**: The renderer library provides a `render` function that takes -hyperscript in, and converts it to formatted buffer text: - -```lua -local Renderer = require('u.renderer').Renderer -local renderer = Renderer:new(0 --[[buffer number]]) -renderer:render { - -- ...hyperscript... -} - --- or, if you already have a buffer: -local Buffer = require('u.buffer') -local buf = Buffer.current() -buf:render { - -- ...hyperscript... -} -``` ## Range Usage @@ -265,17 +122,17 @@ interop conversions when calling `:api` functions. ### 1. Creating a Range -The `Range` utility is the main feature upon which most other things in this -library are built, aside from a few standalone utilities. Ranges can be -constructed manually, or preferably, obtained based on a variety of contexts. +The `Range` utility is the main feature of this library. Ranges can be constructed +manually, or preferably, obtained based on a variety of contexts. ```lua -local Range = require 'u.range' -local start = Pos.new(0, 1, 1) -- Line 1, first column -local stop = Pos.new(0, 3, 1) -- Line 3, first column +local u = require 'u' -Range.new(start, stop, 'v') -- charwise selection -Range.new(start, stop, 'V') -- linewise selection +local start = u.Pos.new(nil, 1, 1) -- Line 1, first column +local stop = u.Pos.new(nil, 3, 1) -- Line 3, first column + +u.Range.new(start, stop, 'v') -- charwise selection +u.Range.new(start, stop, 'V') -- linewise selection ``` This is usually not how you want to obtain a `Range`, however. Usually you want @@ -283,21 +140,23 @@ to get the corresponding context of an edit operation and just "get me the current Range that represents this context". ```lua +local u = require 'u' + -- get the first line in a buffer: -Range.from_line(bufnr, 1) +u.Range.from_line(bufnr, 1) -- Text Objects (any text object valid in your configuration is supported): -- get the word the cursor is on: -Range.from_motion('iw') +u.Range.from_motion('iw') -- get the WORD the cursor is on: -Range.from_motion('iW') +u.Range.from_motion('iW') -- get the "..." the cursor is within: -Range.from_motion('a"') +u.Range.from_motion('a"') -- Get the currently visually selected text: -- NOTE: this does NOT work within certain contexts; more specialized utilities -- are more appropriate in certain circumstances -Range.from_vtext() +u.Range.from_vtext() -- -- Get the operated on text obtained from a motion: @@ -305,7 +164,7 @@ Range.from_vtext() -- --- @param ty 'char'|'line'|'block' function MyOpFunc(ty) - local range = Range.from_op_func(ty) + local range = u.Range.from_op_func(ty) -- do something with the range end -- Try invoking this with: `toaw`, and the current word will be the @@ -321,7 +180,7 @@ end, { expr = true }) -- When executing commands in a visual context, getting the selected text has -- to be done differently: vim.api.nvim_create_user_command('MyCmd', function(args) - local range = Range.from_cmd_args(args) + local range = u.Range.from_cmd_args(args) if range == nil then -- the command was executed in normal mode else @@ -336,7 +195,7 @@ range once you have one? Plenty, it turns out! ```lua local range = ... range:lines() -- get the lines in the range's region -range:text() -- get the text (i.e., string) in the range's region +range:text() -- get the text (i.e., string) in the range's region range:line(1) -- get the first line within this range range:line(-1) -- get the last line within this range -- replace with new contents: @@ -354,68 +213,125 @@ range:replace(nil) Define custom (dot-repeatable) key mappings for text objects: ```lua -local opkeymap = require 'u.opkeymap' +local u = require 'u' -- invoke this function by typing, for example, `riw`: -- `range` will contain the bounds of the motion `iw`. -opkeymap('n', 'r', function(range) +u.opkeymap('n', 'r', function(range) print(range:text()) -- Prints the text within the selected range end) ``` -### 3. Working with Code Writer - -To write code with indentation, use the `CodeWriter` class: - -```lua -local CodeWriter = require 'u.codewriter' -local cw = CodeWriter.new() -cw:write('{') -cw:indent(function(innerCW) - innerCW:write('x: 123') -end) -cw:write('}') -``` - -### 4. Utility Functions +### 3. Utility Functions #### Custom Text Objects -Simply by returning a `Range` or a `Pos`, you can easily and quickly define -your own text objects: +Simply by returning a `Range`, you can easily define your own text objects: ```lua -local txtobj = require 'u.txtobj' -local Range = require 'u.range' +local u = require 'u' -- Select whole file: -txtobj.define('ag', function() - return Range.from_buf_text() +u.define_txtobj('ag', function() + return u.Range.from_buf_text() +end) + +-- Select content inside nearest quotes: +u.define_txtobj('iq', function() + return u.Range.find_nearest_quotes() +end) + +-- Select content inside nearest brackets: +u.define_txtobj('ib', function() + return u.Range.find_nearest_brackets() end) ``` -#### Buffer Management +#### User Commands with Range Support -Access and manipulate buffers easily: +Create user commands that work with visual selections: ```lua -local Buffer = require 'u.buffer' -local buf = Buffer.current() -buf.b.