feat: initial plugin

This commit is contained in:
Rónán Carrigan 2023-12-20 15:07:38 +00:00 committed by Rónán Carrigan
parent b6448cb770
commit d6f92542f1
34 changed files with 7245 additions and 0 deletions

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: rcarriga

103
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,103 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: rcarriga
---
**NeoVim Version**
Output of `nvim --version`
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Please provide a minimal `init.lua` to reproduce which can be run as the following:
```sh
nvim --clean -u minimal.lua
```
You can edit the following example file to include your adapters and other required setup.
```lua
-- ignore default config and plugins
vim.opt.runtimepath:remove(vim.fn.expand("~/.config/nvim"))
vim.opt.packpath:remove(vim.fn.expand("~/.local/share/nvim/site"))
vim.opt.termguicolors = true
-- append test directory
local test_dir = "/tmp/nvim-config"
vim.opt.runtimepath:append(vim.fn.expand(test_dir))
vim.opt.packpath:append(vim.fn.expand(test_dir))
-- install packer
local install_path = test_dir .. "/pack/packer/start/packer.nvim"
local install_plugins = false
if vim.fn.empty(vim.fn.glob(install_path)) > 0 then
vim.cmd("!git clone https://github.com/wbthomason/packer.nvim " .. install_path)
vim.cmd("packadd packer.nvim")
install_plugins = true
end
local packer = require("packer")
packer.init({
package_root = test_dir .. "/pack",
compile_path = test_dir .. "/plugin/packer_compiled.lua",
})
packer.startup(function(use)
use("wbthomason/packer.nvim")
use({
"nvim-neotest/neotest",
requires = {
"vim-test/vim-test",
"nvim-lua/plenary.nvim",
"nvim-treesitter/nvim-treesitter",
"antoinemadec/FixCursorHold.nvim",
},
config = function()
require("neotest").setup({
adapters = {},
})
end,
})
if install_plugins then
packer.sync()
end
end)
vim.cmd([[
command! NeotestSummary lua require("neotest").summary.toggle()
command! NeotestFile lua require("neotest").run.run(vim.fn.expand("%"))
command! Neotest lua require("neotest").run.run(vim.fn.getcwd())
command! NeotestNearest lua require("neotest").run.run()
command! NeotestDebug lua require("neotest").run.run({ strategy = "dap" })
command! NeotestAttach lua require("neotest").run.attach()
command! NeotestOutput lua require("neotest").output.open()
]])
```
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
Please provide example test files to reproduce.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logs**
1. Wipe the `neotest.log` file in `stdpath("log")` or `stdpath("data")`.
2. Set `log_level = vim.log.levels.DEBUG` in your neotest setup config.
3. Reproduce the issue.
4. Provide the new logs.
**Additional context**
Add any other context about the problem here.

55
.github/workflows/docgen.yaml vendored Normal file
View file

@ -0,0 +1,55 @@
# Taken from telescope
name: Generate docs
on:
push:
branches-ignore:
- master
pull_request: ~
jobs:
build-sources:
name: Generate docs
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-20.04
url: https://github.com/neovim/neovim/releases/download/v0.5.1/nvim-linux64.tar.gz
steps:
- uses: actions/checkout@v2
- run: date +%F > todays-date
- name: Restore cache for today's nightly.
uses: actions/cache@v2
with:
path: _neovim
key: ${{ runner.os }}-${{ matrix.url }}-${{ hashFiles('todays-date') }}
- name: Prepare
run: |
test -d _neovim || {
mkdir -p _neovim
curl -sL ${{ matrix.url }} | tar xzf - --strip-components=1 -C "${PWD}/_neovim"
}
mkdir -p ~/.local/share/nvim/site/pack/vendor/start
git clone --depth 1 https://github.com/echasnovski/mini.nvim ~/.local/share/nvim/site/pack/vendor/start/mini.nvim
- name: Generating docs
run: |
export PATH="${PWD}/_neovim/bin:${PATH}"
export VIM="${PWD}/_neovim/share/nvim/runtime"
nvim --version
./scripts/docgen
- name: Update documentation
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_MSG: |
docs: update doc/neotest.txt
skip-checks: true
run: |
git config user.email "actions@github"
git config user.name "Github Actions"
git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git
git add doc/
# Only commit and push if we have changes
git diff --quiet && git diff --staged --quiet || (git commit -m "${COMMIT_MSG}"; git push origin HEAD:${GITHUB_REF})

16
.github/workflows/issues.yaml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Add issues to tracking project
on:
issues:
types:
- opened
jobs:
add-to-project:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v0.3.0
with:
project-url: https://github.com/orgs/nvim-neotest/projects/1
github-token: ${{ secrets.GITHUB_TOKEN }}

29
.github/workflows/luarocks-release.yaml vendored Normal file
View file

@ -0,0 +1,29 @@
---
on:
release:
types:
- created
push:
tags:
- '*'
workflow_dispatch: # Allow manual trigger
pull_request: # Tests the luarocks installation without releasing on PR
jobs:
luarocks-upload:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get Version
# tags do not trigger the workflow when they are created by other workflows or releases
run: echo "LUAROCKS_VERSION=$(git describe --abbrev=0 --tags)" >> $GITHUB_ENV
- name: LuaRocks Upload
uses: nvim-neorocks/luarocks-tag-release@v5
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}
with:
version: ${{ env.LUAROCKS_VERSION }}
dependencies: |
plenary.nvim

77
.github/workflows/workflow.yaml vendored Normal file
View file

@ -0,0 +1,77 @@
name: neotest Workflow
on:
push:
branches:
- master
pull_request: ~
jobs:
style:
name: style
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: JohnnyMorganz/stylua-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --check lua/ tests/
tests:
name: tests
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
rev: nightly/nvim-linux64.tar.gz
- os: ubuntu-22.04
rev: v0.9.1/nvim-linux64.tar.gz
steps:
- uses: actions/checkout@v3
- run: date +%F > todays-date
- name: Restore cache for today's nightly.
uses: actions/cache@v3
with:
path: _neovim
key: ${{ runner.os }}-${{ matrix.rev }}-${{ hashFiles('todays-date') }}
- name: Prepare dependencies
run: |
test -d _neovim || {
mkdir -p _neovim
curl -sL "https://github.com/neovim/neovim/releases/download/${{ matrix.rev }}" | tar xzf - --strip-components=1 -C "${PWD}/_neovim"
}
mkdir -p ~/.local/share/nvim/site/pack/vendor/start
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim
ln -s $(pwd) ~/.local/share/nvim/site/pack/vendor/start
export PATH="${PWD}/_neovim/bin:${PATH}"
export VIM="${PWD}/_neovim/share/nvim/runtime"
- name: Run tests
run: |
export PATH="${PWD}/_neovim/bin:${PATH}"
export VIM="${PWD}/_neovim/share/nvim/runtime"
nvim --version
./scripts/test
release:
name: release
if: ${{ github.ref == 'refs/heads/master' }}
needs:
- style
- tests
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 18
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
neovim/
plenary.nvim/
doc/tags
Session.vim

12
.releaserc.json Normal file
View file

@ -0,0 +1,12 @@
{
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/github",
{
"successComment": false
}
]
]
}

21
LICENCE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Rónán Carrigan
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.

178
README.md
View file

@ -0,0 +1,178 @@
# nvim-nio
A library for asynchronous IO in Neovim, inspired by the asyncio library in Python. The library focuses on providing
both common asynchronous primitives and asynchronous APIs for Neovim's core.
## Motivation
Work has been ongoing around async libraries in Neovim for years, with a lot of discussion around a [Neovim core
implementation](https://github.com/neovim/neovim/issues/19624). A lot of the motivation behind this library can be seen
in that discussion.
nvim-nio aims to provide a simple interface to Lua coroutines that doesn't feel like it gets in the way of your actual
logic. You won't even know you're using them. An example of this is error handling. With other libraries, a custom
`pcall` or some other custom handling must be used to catch errors. With nvim-nio, Lua's built-in `pcall` works exactly
as you'd expect.
nvim-nio is focused on providing a great developer experience. The API is well documented with examples and full type
annotations, which can all be used by the Lua LSP. It's recommended to use
[neodev.nvim](https://github.com/folke/neodev.nvim) to get LSP support.
![image](https://github.com/nvim-lua/plenary.nvim/assets/24252670/0dda462c-0b5c-4300-8e65-b7218e3d2c1e)
Credit to the async library in [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) and
[async.nvim](https://github.com/lewis6991/async.nvim) for inspiring nvim-nio and its implementation.
If Neovim core integrates an async library, nvim-nio will aim to maintain compatibility with it if possible.
## Installation
Install with your favourite package manager
[lazy.nvim](https://github.com/folke/lazy.nvim)
```lua
{ "nvim-neotest/nvim-nio" }
```
[dein](https://github.com/Shougo/dein.vim):
```vim
call dein#add("nvim-neotest/nvim-nio")
```
[vim-plug](https://github.com/junegunn/vim-plug)
```vim
Plug 'nvim-neotest/nvim-nio'
```
[packer.nvim](https://github.com/wbthomason/packer.nvim)
```lua
use { "nvim-neotest/nvim-nio" }
```
## Configuration
There are no configuration options currently available.
## Usage
nvim-nio is based on the concept of tasks. These tasks represent a series of asynchronous actions that run in a single
context. Under the hood, each task is running on a separate lua coroutine.
Tasks are created by providing an async function to `nio.run`. All async
functions must be called from a task.
```lua
local nio = require("nio")
local task = nio.run(function()
nio.sleep(10)
print("Hello world")
end)
```
For simple use cases tasks won't be too important but they support features such as cancelling and retrieving stack traces.
nvim-nio comes with built-in modules to help with writing async code. See `:help nvim-nio` for extensive documentation.
`nio.control`: Primitives for flow control in async functions
```lua
local event = nio.control.event()
local worker = nio.tasks.run(function()
nio.sleep(1000)
event.set()
end)
local listeners = {
nio.tasks.run(function()
event.wait()
print("First listener notified")
end),
nio.tasks.run(function()
event.wait()
print("Second listener notified")
end),
}
```
`nio.lsp`: A fully typed and documented async LSP client library, generated from the LSP specification.
```lua
local client = nio.lsp.get_clients({ name = "lua_ls" })[1]
local err, response = client.request.textDocument_semanticTokens_full({
textDocument = { uri = vim.uri_from_bufnr(0) },
})
assert(not err, err)
for _, token in pairs(response.data) do
print(token)
end
```
`nio.uv`: Async versions of `vim.loop` functions
```lua
local file_path = "README.md"
local open_err, file_fd = nio.uv.fs_open(file_path, "r", 438)
assert(not open_err, open_err)
local stat_err, stat = nio.uv.fs_fstat(file_fd)
assert(not stat_err, stat_err)
local read_err, data = nio.uv.fs_read(file_fd, stat.size, 0)
assert(not read_err, read_err)
local close_err = nio.uv.fs_close(file_fd)
assert(not close_err, close_err)
print(data)
```
`nio.ui`: Async versions of vim.ui functions
```lua
local value = nio.ui.input({ prompt = "Enter something: " })
print(("You entered: %s"):format(value))
```
`nio.tests`: Async versions of plenary.nvim's test functions
```lua
nio.tests.it("notifies listeners", function()
local event = nio.control.event()
local notified = 0
for _ = 1, 10 do
nio.run(function()
event.wait()
notified = notified + 1
end)
end
event.set()
nio.sleep(10)
assert.equals(10, notified)
end)
```
It is also easy to wrap callback style functions to make them asynchronous using `nio.wrap`, which allows easily
integrating third-party APIs with nvim-nio.
```lua
local nio = require("nio")
local sleep = nio.wrap(function(ms, cb)
vim.defer_fn(cb, ms)
end, 2)
nio.run(function()
sleep(10)
print("Slept for 10ms")
end)
```

556
doc/nio.txt Normal file
View file

@ -0,0 +1,556 @@
*nvim-nio.txt* A library for asynchronous IO in Neovim
==============================================================================
nvim-nio *nvim-nio*
nio....................................................................|nio|
nio.control....................................................|nio.control|
nio.lsp............................................................|nio.lsp|
nio.uv..............................................................|nio.uv|
nio.ui..............................................................|nio.ui|
nio.tests........................................................|nio.tests|
A library for asynchronous IO in Neovim, inspired by the asyncio library in
Python. The library focuses on providing both common asynchronous primitives
and asynchronous APIs for Neovim's core.
nio *nio*
*nio.run()*
`run`({func}, {cb})
Run a function in an async context. This is the entrypoint to all async
functionality.
>lua
local nio = require("nio")
nio.run(function()
nio.sleep(10)
print("Hello world")
end)
<
Parameters~
{func} `(function)`
{cb?} `(fun(success: boolean,...))` Callback to invoke when the task is
complete. If success is false then the parameters will be an error message and
a traceback of the error, otherwise it will be the result of the async
function.
Return~
`(nio.tasks.Task)`
*nio.wrap()*
`wrap`({func}, {argc})
Creates an async function with a callback style function.
>lua
local nio = require("nio")
local sleep = nio.wrap(function(ms, cb)
vim.defer_fn(cb, ms)
end, 2)
nio.run(function()
sleep(10)
print("Slept for 10ms")
end)
<
Parameters~
{func} `(function)` A callback style function to be converted. The last
argument must be the callback.
{argc} `(integer)` The number of arguments of func. Must be included.
Return~
`(function)` Returns an async function
*nio.create()*
`create`({func}, {argc})
Takes an async function and returns a function that can run in both async
and non async contexts. When running in an async context, the function can
return values, but when run in a non-async context, a Task object is
returned and an extra callback argument can be supplied to receive the
result, with the same signature as the callback for `nio.run`.
This is useful for APIs where users don't want to create async
contexts but which are still used in async contexts internally.
Parameters~
{func} `(async fun(...))`
{argc?} `(integer)` The number of arguments of func. Must be included if there
are arguments.
*nio.gather()*
`gather`({functions})
Run a collection of async functions concurrently and return when
all have finished.
If any of the functions fail, all pending tasks will be cancelled and the
error will be re-raised
Parameters~
{functions} `(function[])`
Return~
`(any[])` Results of all functions
*nio.first()*
`first`({functions})
Run a collection of async functions concurrently and return the result of
the first to finish.
Parameters~
{functions} `(function[])`
Return~
`(any)`
*nio.sleep()*
`sleep`({ms})
Suspend the current task for given time.
Parameters~
{ms} `(number)` Time in milliseconds
*nio.scheduler()*
`scheduler`()
Yields to the Neovim scheduler to be able to call the API.
nio.api *nio.api*
Safely proxies calls to the vim.api module while in an async context.
nio.fn *nio.fn*
Safely proxies calls to the vim.fn module while in an async context.
==============================================================================
nio.control *nio.control*
Provides primitives for flow control in async functions
*nio.control.event()*
`event`()
Create a new event
An event can signal to multiple listeners to resume execution
The event can be set from a non-async context.
>lua
local event = nio.control.event()
local worker = nio.tasks.run(function()
nio.sleep(1000)
event.set()
end)
local listeners = {
nio.tasks.run(function()
event.wait()
print("First listener notified")
end),
nio.tasks.run(function()
event.wait()
print("Second listener notified")
end),
}
<
Return~
`(nio.control.Event)`
*nio.control.Event*
Fields~
{set} `(fun(max_woken?: integer): nil)` Set the event and signal to all (or
limited number of) listeners that the event has occurred. If max_woken is
provided and there are more listeners then the event is cleared immediately
{wait} `(async fun(): nil)` Wait for the event to occur, returning immediately
if
already set
{clear} `(fun(): nil)` Clear the event
{is_set} `(fun(): boolean)` Returns true if the event is set
*nio.control.future()*
`future`()
Create a new future
An future represents a value that will be available at some point and can be awaited upon.
The future result can be set from a non-async context.
>lua
local future = nio.control.future()
nio.run(function()
nio.sleep(100 * math.random(1, 10))
if not future.is_set() then
future.set("Success!")
end
end)
nio.run(function()
nio.sleep(100 * math.random(1, 10))
if not future.is_set() then
future.set_error("Failed!")
end
end)
local success, value = pcall(future.wait)
print(("%s: %s"):format(success, value))
<
Return~
`(nio.control.Future)`
*nio.control.Future*
Fields~
{set} `(fun(value): nil)` Set the future value and wake all waiters.
{set_error} `(fun(message): nil)` Set the error for this future to raise to
waiters
{wait} `(async fun(): any)` Wait for the value to be set, returning
immediately if already set
{is_set} `(fun(): boolean)` Returns true if the future is set
*nio.control.queue()*
`queue`({max_size})
Create a new FIFO queue with async support.
>lua
local queue = nio.control.queue()
local producer = nio.tasks.run(function()
for i = 1, 10 do
nio.sleep(100)
queue.put(i)
end
queue.put(nil)
end)
while true do
local value = queue.get()
if value == nil then
break
end
print(value)
end
print("Done")
<
Parameters~
{max_size?} `(integer)` The maximum number of items in the queue, defaults to
no limit
Return~
`(nio.control.Queue)`
*nio.control.Queue*
Fields~
{size} `(fun(): number)` Returns the number of items in the queue
{max_size} `(fun(): number|nil)` Returns the maximum number of items in the
queue
{get} `(async fun(): any)` Get a value from the queue, blocking if the queue
is empty
{get_nowait} `(fun(): any)` Get a value from the queue, erroring if queue is
empty.
{put} `(async fun(value: any): nil)` Put a value into the queue
{put_nowait} `(fun(value: any): nil)` Put a value into the queue, erroring if
queue is full.
*nio.control.semaphore()*
`semaphore`({value})
Create an async semaphore that allows up to a given number of acquisitions.
>lua
local semaphore = nio.control.semaphore(2)
local value = 0
for _ = 1, 10 do
nio.run(function()
semaphore.with(function()
value = value + 1
nio.sleep(10)
print(value) -- Never more than 2
value = value - 1
end)
end)
end
<
Parameters~
{value} `(integer)` The number of allowed concurrent acquisitions
Return~
`(nio.control.Semaphore)`
*nio.control.Semaphore*
Fields~
{with} `(async fun(callback: fun(): nil): nil)` Run the callback with the
semaphore acquired
{acquire} `(async fun(): nil)` Acquire the semaphore
{release} `(fun(): nil)` Release the semaphore
==============================================================================
nio.lsp *nio.lsp*
*nio.lsp.Client*
Fields~
{request} `(nio.lsp.RequestClient)` Interface to all requests that can be sent
by the client
{notify} `(nio.lsp.NotifyClient)` Interface to all notifications that can be
sent by the client
{server_capabilities} `(nio.lsp.types.ServerCapabilities)`
*nio.lsp.get_clients()*
`get_clients`({filters})
Get active clients, optionally matching the given filters
Equivalent to |vim.lsp.get_clients|
Parameters~
{filters?} `(nio.lsp.GetClientsFilters)`
Return~
`(nio.lsp.Client[])`
*nio.lsp.GetClientsFilters*
Fields~
{id?} `(integer)`
{name?} `(string)`
{bufnr?} `(integer)`
{method?} `(string)`
*nio.lsp.client()*
`client`({client_id})
an async client for the given client id
Parameters~
{client_id} `(integer)`
Return~
`(nio.lsp.Client)`
==============================================================================
nio.uv *nio.uv*
Provides asynchronous versions of vim.loop functions.
See corresponding function documentation for parameter and return
information.
>lua
local file_path = "README.md"
local open_err, file_fd = nio.uv.fs_open(file_path, "r", 438)
assert(not open_err, open_err)
local stat_err, stat = nio.uv.fs_fstat(file_fd)
assert(not stat_err, stat_err)
local read_err, data = nio.uv.fs_read(file_fd, stat.size, 0)
assert(not read_err, read_err)
local close_err = nio.uv.fs_close(file_fd)
assert(not close_err, close_err)
print(data)
<
Fields~
{close} `(async fun(handle: nio.uv.Handle))`
{fs_open} `(async fun(path: any, flags: any, mode: any):
(string|nil,integer|nil))`
{fs_read} `(async fun(fd: integer, size: integer, offset?: integer):
(string|nil,string|nil))`
{fs_close} `(async fun(fd: integer): (string|nil,boolean|nil))`
{fs_unlink} `(async fun(path: string): (string|nil,boolean|nil))`
{fs_write} `(async fun(fd: any, data: any, offset?: any):
(string|nil,integer|nil))`
{fs_mkdir} `(async fun(path: string, mode: integer):
(string|nil,boolean|nil))`
{fs_mkdtemp} `(async fun(template: string): (string|nil,string|nil))`
{fs_rmdir} `(async fun(path: string): (string|nil,boolean|nil))`
{fs_stat} `(async fun(path: string): (string|nil,nio.uv.Stat|nil))`
{fs_fstat} `(async fun(fd: integer): (string|nil,nio.uv.Stat|nil))`
{fs_lstat} `(async fun(path: string): (string|nil,nio.uv.Stat|nil))`
{fs_statfs} `(async fun(path: string): (string|nil,nio.uv.StatFs|nil))`
{fs_rename} `(async fun(old_path: string, new_path: string):
(string|nil,boolean|nil))`
{fs_fsync} `(async fun(fd: integer): (string|nil,boolean|nil))`
{fs_fdatasync} `(async fun(fd: integer): (string|nil,boolean|nil))`
{fs_ftruncate} `(async fun(fd: integer, offset: integer):
(string|nil,boolean|nil))`
{fs_sendfile} `(async fun(out_fd: integer, in_fd: integer, in_offset: integer,
length: integer): (string|nil,integer|nil))`
{fs_access} `(async fun(path: string, mode: integer):
(string|nil,boolean|nil))`
{fs_chmod} `(async fun(path: string, mode: integer):
(string|nil,boolean|nil))`
{fs_fchmod} `(async fun(fd: integer, mode: integer):
(string|nil,boolean|nil))`
{fs_utime} `(async fun(path: string, atime: number, mtime: number):
(string|nil,boolean|nil))`
{fs_futime} `(async fun(fd: integer, atime: number, mtime: number):
(string|nil,boolean|nil))`
{fs_link} `(async fun(path: string, new_path: string):
(string|nil,boolean|nil))`
{fs_symlink} `(async fun(path: string, new_path: string, flags?: integer):
(string|nil,boolean|nil))`
{fs_readlink} `(async fun(path: string): (string|nil,string|nil))`
{fs_realpath} `(async fun(path: string): (string|nil,string|nil))`
{fs_chown} `(async fun(path: string, uid: integer, gid: integer):
(string|nil,boolean|nil))`
{fs_fchown} `(async fun(fd: integer, uid: integer, gid: integer):
(string|nil,boolean|nil))`
{fs_lchown} `(async fun(path: string, uid: integer, gid: integer):
(string|nil,boolean|nil))`
{fs_copyfile} `(async fun(path: any, new_path: any, flags?: any):
(string|nil,boolean|nil))`
{fs_opendir} `(async fun(path: string, entries?: integer):
(string|nil,nio.uv.Dir|nil))`
{fs_readdir} `(async fun(dir: nio.uv.Dir):
(string|nil,nio.uv.DirEntry[]|nil))`
{fs_closedir} `(async fun(dir: nio.uv.Dir): (string|nil,boolean|nil))`
{fs_scandir} `(async fun(path: string): (string|nil,nio.uv.DirEntry[]|nil))`
{shutdown} `(async fun(stream: nio.uv.Stream): string|nil)`
{listen} `(async fun(stream: nio.uv.Stream, backlog: integer): string|nil)`
{write} `(async fun(stream: nio.uv.Stream, data: string|string[]):
string|nil)`
{write2} `(async fun(stream: nio.uv.Stream, data: string|string[],
send_handle: nio.uv.Stream): string|nil)`
*nio.uv.Handle*
*nio.uv.Stream*
Inherits: `nio.uv.Handle`
*nio.uv.Stat*
Fields~
{dev} `(integer)`
{mode} `(integer)`
{nlink} `(integer)`
{uid} `(integer)`
{gid} `(integer)`
{rdev} `(integer)`
{ino} `(integer)`
{size} `(integer)`
{blksize} `(integer)`
{blocks} `(integer)`
{flags} `(integer)`
{gen} `(integer)`
{atime} `(nio.uv.StatTime)`
{mtime} `(nio.uv.StatTime)`
{ctime} `(nio.uv.StatTime)`
{birthtime} `(nio.uv.StatTime)`
{type} `(string)`
*nio.uv.StatTime*
Fields~
{sec} `(integer)`
{nsec} `(integer)`
*nio.uv.StatFs*
Fields~
{type} `(integer)`
{bsize} `(integer)`
{blocks} `(integer)`
{bfree} `(integer)`
{bavail} `(integer)`
{files} `(integer)`
{ffree} `(integer)`
*nio.uv.Dir*
*nio.uv.DirEntry*
==============================================================================
nio.ui *nio.ui*
Async versions of vim.ui functions.
*nio.ui.input()*
`input`({args})
Prompt the user for input.
See |vim.ui.input()| for details.
>lua
local value = nio.ui.input({ prompt = "Enter something: " })
print(("You entered: %s"):format(value))
<
Parameters~
{args} `(nio.ui.InputArgs)`
*nio.ui.InputArgs*
Fields~
{prompt} `(string|nil)` Text of the prompt
{default} `(string|nil)` Default reply to the input
{completion} `(string|nil)` Specifies type of completion supported for input.
Supported types are the same that can be supplied to a user-defined command
using the "-complete=" argument. See |:command-completion|
{highlight} `(function)` Function that will be used for highlighting user
inputs.
*nio.ui.select()*
`select`({items}, {args})
Prompts the user to pick from a list of items
See |vim.ui.select()| for details.
<
local value = nio.ui.select({ "foo", "bar", "baz" }, { prompt = "Select something: " })
print(("You entered: %s"):format(value))
<
Parameters~
{items} `(any[])`
{args} `(nio.ui.SelectArgs)`
*nio.ui.SelectArgs*
Fields~
{prompt} `(string|nil)` Text of the prompt. Defaults to `Select one of:`
{format_item} `(function|nil)` Function to format an individual item from
`items`. Defaults to `tostring`.
{kind} `(string|nil)` Arbitrary hint string indicating the item shape. Plugins
reimplementing `vim.ui.select` may wish to use this to infer the structure or
semantics of `items`, or the context in which select() was called.
==============================================================================
nio.tests *nio.tests*
Wrappers around plenary.nvim's test functions for writing async tests
>lua
a.it("notifies listeners", function()
local event = nio.control.event()
local notified = 0
for _ = 1, 10 do
nio.run(function()
event.wait()
notified = notified + 1
end)
end
event.set()
nio.sleep(10)
assert.equals(10, notified)
end)
*nio.tests.it()*
`it`({name}, {async_func})
Parameters~
{name} `(string)`
{async_func} `(function)`
*nio.tests.before_each()*
`before_each`({async_func})
Parameters~
{async_func} `(function)`
*nio.tests.after_each()*
`after_each`({async_func})
Parameters~
{async_func} `(function)`
vim:tw=78:ts=8:noet:ft=help:norl:

304
lua/nio/control.lua Normal file
View file

@ -0,0 +1,304 @@
local tasks = require("nio.tasks")
local nio = {}
---@toc_entry nio.control
---@text
--- Provides primitives for flow control in async functions
---@class nio.control
nio.control = {}
--- Create a new event
---
--- An event can signal to multiple listeners to resume execution
--- The event can be set from a non-async context.
---
--- ```lua
--- local event = nio.control.event()
---
--- local worker = nio.tasks.run(function()
--- nio.sleep(1000)
--- event.set()
--- end)
---
--- local listeners = {
--- nio.tasks.run(function()
--- event.wait()
--- print("First listener notified")
--- end),
--- nio.tasks.run(function()
--- event.wait()
--- print("Second listener notified")
--- end),
--- }
--- ```
---@return nio.control.Event
function nio.control.event()
local waiters = {}
local is_set = false
return {
is_set = function()
return is_set
end,
set = function(max_woken)
if is_set then
return
end
is_set = true
local waiters_to_notify = {}
max_woken = max_woken or #waiters
while #waiters > 0 and #waiters_to_notify < max_woken do
waiters_to_notify[#waiters_to_notify + 1] = table.remove(waiters)
end
if #waiters > 0 then
is_set = false
end
for _, waiter in ipairs(waiters_to_notify) do
waiter()
end
end,
wait = tasks.wrap(function(callback)
if is_set then
callback()
else
waiters[#waiters + 1] = callback
end
end, 1),
clear = function()
is_set = false
end,
}
end
---@class nio.control.Event
---@field set fun(max_woken?: integer): nil Set the event and signal to all (or limited number of) listeners that the event has occurred. If max_woken is provided and there are more listeners then the event is cleared immediately
---@field wait async fun(): nil Wait for the event to occur, returning immediately if
--- already set
---@field clear fun(): nil Clear the event
---@field is_set fun(): boolean Returns true if the event is set
--- Create a new future
---
--- An future represents a value that will be available at some point and can be awaited upon.
--- The future result can be set from a non-async context.
--- ```lua
--- local future = nio.control.future()
---
--- nio.run(function()
--- nio.sleep(100 * math.random(1, 10))
--- if not future.is_set() then
--- future.set("Success!")
--- end
--- end)
--- nio.run(function()
--- nio.sleep(100 * math.random(1, 10))
--- if not future.is_set() then
--- future.set_error("Failed!")
--- end
--- end)
---
--- local success, value = pcall(future.wait)
--- print(("%s: %s"):format(success, value))
--- ```
---@return nio.control.Future
function nio.control.future()
local waiters = {}
local result, err, is_set
local wait = tasks.wrap(function(callback)
if is_set then
callback()
else
waiters[#waiters + 1] = callback
end
end, 1)
local wake = function()
for _, waiter in ipairs(waiters) do
waiter()
end
end
return {
is_set = function()
return is_set
end,
set = function(value)
if is_set then
error("Future already set")
end
result = value
is_set = true
wake()
end,
set_error = function(message)
if is_set then
error("Future already set")
end
err = message
is_set = true
wake()
end,
wait = function()
if not is_set then
wait()
end
if err then
error(err)
end
return result
end,
}
end
---@class nio.control.Future
---@field set fun(value): nil Set the future value and wake all waiters.
---@field set_error fun(message): nil Set the error for this future to raise to
---the waiters
---@field wait async fun(): any Wait for the value to be set, returning immediately if already set
---@field is_set fun(): boolean Returns true if the future is set
--- Create a new FIFO queue with async support.
--- ```lua
--- local queue = nio.control.queue()
---
--- local producer = nio.tasks.run(function()
--- for i = 1, 10 do
--- nio.sleep(100)
--- queue.put(i)
--- end
--- queue.put(nil)
--- end)
---
--- while true do
--- local value = queue.get()
--- if value == nil then
--- break
--- end
--- print(value)
--- end
--- print("Done")
--- ```
---@param max_size? integer The maximum number of items in the queue, defaults to no limit
---@return nio.control.Queue
function nio.control.queue(max_size)
local items = {}
local left_i = 0
local right_i = 0
local non_empty = nio.control.event()
local non_full = nio.control.event()
non_full.set()
local queue = {}
function queue.size()
return right_i - left_i
end
function queue.max_size()
return max_size
end
function queue.put(value)
non_full.wait()
queue.put_nowait(value)
end
function queue.get()
non_empty.wait()
return queue.get_nowait()
end
function queue.get_nowait()
if queue.size() == 0 then
error("Queue is empty")
end
left_i = left_i + 1
local item = items[left_i]
items[left_i] = nil
if left_i == right_i then
non_empty.clear()
end
non_full.set(1)
return item
end
function queue.put_nowait(value)
if queue.size() == max_size then
error("Queue is full")
end
right_i = right_i + 1
items[right_i] = value
non_empty.set(1)
if queue.size() == max_size then
non_full.clear()
end
end
return queue
end
---@class nio.control.Queue
---@field size fun(): number Returns the number of items in the queue
---@field max_size fun(): number|nil Returns the maximum number of items in the queue
---@field get async fun(): any Get a value from the queue, blocking if the queue is empty
---@field get_nowait fun(): any Get a value from the queue, erroring if queue is empty.
---@field put async fun(value: any): nil Put a value into the queue
---@field put_nowait fun(value: any): nil Put a value into the queue, erroring if queue is full.
--- Create an async semaphore that allows up to a given number of acquisitions.
---
--- ```lua
--- local semaphore = nio.control.semaphore(2)
---
--- local value = 0
--- for _ = 1, 10 do
--- nio.run(function()
--- semaphore.with(function()
--- value = value + 1
---
--- nio.sleep(10)
--- print(value) -- Never more than 2
---
--- value = value - 1
--- end)
--- end)
--- end
--- ```
---@param value integer The number of allowed concurrent acquisitions
---@return nio.control.Semaphore
function nio.control.semaphore(value)
value = value or 1
local released_event = nio.control.event()
released_event.set()
local acquire = function()
released_event.wait()
value = value - 1
assert(value >= 0, "Semaphore value is negative")
if value == 0 then
released_event.clear()
end
end
local release = function()
value = value + 1
released_event.set(1)
end
return {
acquire = acquire,
release = release,
with = function(cb)
acquire()
local success, err = pcall(cb)
release()
if not success then
error(err)
end
end,
}
end
---@class nio.control.Semaphore
---@field with async fun(callback: fun(): nil): nil Run the callback with the semaphore acquired
---@field acquire async fun(): nil Acquire the semaphore
---@field release fun(): nil Release the semaphore
return nio.control

197
lua/nio/init.lua Normal file
View file

@ -0,0 +1,197 @@
local nio = {}
local tasks = require("nio.tasks")
local control = require("nio.control")
local uv = require("nio.uv")
local tests = require("nio.tests")
local ui = require("nio.ui")
local lsp = require("nio.lsp")
---@tag nvim-nio
---@toc
---@text
---
--- A library for asynchronous IO in Neovim, inspired by the asyncio library in
--- Python. The library focuses on providing both common asynchronous primitives
--- and asynchronous APIs for Neovim's core.
---@toc_entry nio
---@class nio
nio = {}
nio.control = control
nio.uv = uv
nio.ui = ui
nio.tests = tests
nio.tasks = tasks
nio.lsp = lsp
--- Run a function in an async context. This is the entrypoint to all async
--- functionality.
--- ```lua
--- local nio = require("nio")
--- nio.run(function()
--- nio.sleep(10)
--- print("Hello world")
--- end)
--- ```
---@param func function
---@param cb? fun(success: boolean,...) Callback to invoke when the task is complete. If success is false then the parameters will be an error message and a traceback of the error, otherwise it will be the result of the async function.
---@return nio.tasks.Task
function nio.run(func, cb)
return tasks.run(func, cb)
end
--- Creates an async function with a callback style function.
--- ```lua
--- local nio = require("nio")
--- local sleep = nio.wrap(function(ms, cb)
--- vim.defer_fn(cb, ms)
--- end, 2)
---
--- nio.run(function()
--- sleep(10)
--- print("Slept for 10ms")
--- end)
--- ```
---@param func function A callback style function to be converted. The last argument must be the callback.
---@param argc integer The number of arguments of func. Must be included.
---@return function Returns an async function
function nio.wrap(func, argc)
return tasks.wrap(func, argc)
end
--- Takes an async function and returns a function that can run in both async
--- and non async contexts. When running in an async context, the function can
--- return values, but when run in a non-async context, a Task object is
--- returned and an extra callback argument can be supplied to receive the
--- result, with the same signature as the callback for `nio.run`.
---
--- This is useful for APIs where users don't want to create async
--- contexts but which are still used in async contexts internally.
---@param func async fun(...)
---@param argc? integer The number of arguments of func. Must be included if there are arguments.
function nio.create(func, argc)
return tasks.create(func, argc)
end
--- Run a collection of async functions concurrently and return when
--- all have finished.
--- If any of the functions fail, all pending tasks will be cancelled and the
--- error will be re-raised
---@async
---@param functions function[]
---@return any[] Results of all functions
function nio.gather(functions)
local results = {}
local done_event = control.event()
local err
local running = {}
for i, func in ipairs(functions) do
local task = tasks.run(func, function(success, ...)
if not success then
err = ...
done_event.set()
end
results[#results + 1] = { i = i, success = success, result = ... }
if #results == #functions then
done_event.set()
end
end)
running[#running + 1] = task
end
done_event.wait()
if err then
for _, task in ipairs(running) do
task.cancel()
end
error(err)
end
local sorted = {}
for _, result in ipairs(results) do
sorted[result.i] = result.result
end
return sorted
end
--- Run a collection of async functions concurrently and return the result of
--- the first to finish.
---@async
---@param functions function[]
---@return any
function nio.first(functions)
local running_tasks = {}
local event = control.event()
local failed, result
for _, func in ipairs(functions) do
local task = tasks.run(func, function(success, ...)
if event.is_set() then
return
end
failed = not success
result = { ... }
event.set()
end)
table.insert(running_tasks, task)
end
event.wait()
for _, task in ipairs(running_tasks) do
task.cancel()
end
if failed then
error(unpack(result))
end
return unpack(result)
end
local async_defer = nio.wrap(function(time, cb)
assert(cb, "Cannot call sleep from non-async context")
vim.defer_fn(cb, time)
end, 2)
--- Suspend the current task for given time.
---@param ms number Time in milliseconds
function nio.sleep(ms)
async_defer(ms)
end
local wrapped_schedule = nio.wrap(vim.schedule, 1)
--- Yields to the Neovim scheduler to be able to call the API.
---@async
function nio.scheduler()
wrapped_schedule()
end
---@nodoc
local function proxy_vim(prop)
return setmetatable({}, {
__index = function(_, k)
return function(...)
-- if we are in a fast event await the scheduler
if vim.in_fast_event() then
nio.scheduler()
end
return vim[prop][k](...)
end
end,
})
end
--- Safely proxies calls to the vim.api module while in an async context.
nio.api = proxy_vim("api")
--- Safely proxies calls to the vim.fn module while in an async context.
nio.fn = proxy_vim("fn")
-- For type checking
if false then
nio.api = vim.api
nio.fn = vim.fn
end
return nio

103
lua/nio/logger.lua Normal file
View file

@ -0,0 +1,103 @@
local loggers = {}
local log_date_format = "%FT%H:%M:%SZ%z"
---@class nio.Logger
---@field trace function
---@field debug function
---@field info function
---@field warn function
---@field error function
local Logger = {}
local LARGE = 1e9
---@return nio.Logger
function Logger.new(filename, opts)
opts = opts or {}
local logger = loggers[filename]
if logger then
return logger
end
logger = {}
setmetatable(logger, { __index = Logger })
loggers[filename] = logger
local path_sep = (function()
if jit then
local os = string.lower(jit.os)
if os == "linux" or os == "osx" or os == "bsd" then
return "/"
else
return "\\"
end
else
return package.config:sub(1, 1)
end
end)()
local function path_join(...)
return table.concat(vim.tbl_flatten({ ... }), path_sep)
end
logger._level = opts.level or vim.log.levels.WARN
local ok, logpath = pcall(vim.fn.stdpath, "log")
if not ok then
logpath = vim.fn.stdpath("cache")
end
logger._filename = path_join(logpath, filename .. ".log")
vim.fn.mkdir(logpath, "p")
local logfile = assert(io.open(logger._filename, "a+"))
local log_info = vim.loop.fs_stat(logger._filename)
if log_info and log_info.size > LARGE then
local warn_msg =
string.format("Nio log is large (%d MB): %s", log_info.size / (1000 * 1000), logger._filename)
vim.notify(warn_msg, vim.log.levels.WARN)
end
for level, levelnr in pairs(vim.log.levels) do
logger[level:lower()] = function(...)
local argc = select("#", ...)
if levelnr < logger._level then
return false
end
if argc == 0 then
return true
end
local info = debug.getinfo(2, "Sl")
local fileinfo = string.format("%s:%s", info.short_src, info.currentline)
local parts = {
table.concat({ level, "|", os.date(log_date_format), "|", fileinfo, "|" }, " "),
}
for i = 1, argc do
local arg = select(i, ...)
if arg == nil then
table.insert(parts, "<nil>")
elseif type(arg) == "string" then
table.insert(parts, arg)
elseif type(arg) == "table" and arg.__tostring then
table.insert(parts, arg.__tostring(arg))
else
table.insert(parts, vim.inspect(arg))
end
end
logfile:write(table.concat(parts, " "), "\n")
logfile:flush()
end
end
return logger
end
function Logger:set_level(level)
self._level = assert(
type(level) == "number" and level or vim.log.levels[tostring(level):upper()],
string.format("Log level must be one of (trace, debug, info, warn, error), got: %q", level)
)
end
function Logger:get_filename()
return self._filename
end
return Logger.new("nio")

3024
lua/nio/lsp-types.lua Normal file

File diff suppressed because it is too large Load diff

112
lua/nio/lsp.lua Normal file
View file

@ -0,0 +1,112 @@
local tasks = require("nio.tasks")
local control = require("nio.control")
local logger = require("nio.logger")
local nio = {}
---@toc_entry nio.lsp
---@class nio.lsp
nio.lsp = {}
---@class nio.lsp.Client
---@field request nio.lsp.RequestClient Interface to all requests that can be sent by the client
---@field notify nio.lsp.NotifyClient Interface to all notifications that can be sent by the client
---@field server_capabilities nio.lsp.types.ServerCapabilities
local async_request = tasks.wrap(function(client, method, params, bufnr, request_id_future, cb)
local success, req_id = client.request(method, params, cb, bufnr)
if not success then
if request_id_future then
request_id_future.set_error("Request failed")
end
error(("Failed to send request. Client %s has shut down"):format(client.id))
end
if request_id_future then
request_id_future.set(req_id)
end
end, 6)
--- Get active clients, optionally matching the given filters
--- Equivalent to |vim.lsp.get_clients|
---@param filters? nio.lsp.GetClientsFilters
---@return nio.lsp.Client[]
function nio.lsp.get_clients(filters)
local clients = {}
for _, client in pairs(vim.lsp.get_active_clients(filters)) do
clients[#clients + 1] = nio.lsp.client(client.id)
end
return clients
end
---@class nio.lsp.GetClientsFilters
---@field id? integer
---@field name? string
---@field bufnr? integer
---@field method? string
---Create an async client for the given client id
---@param client_id integer
---@return nio.lsp.Client
function nio.lsp.client(client_id)
local n = require("nio")
local internal_client =
assert(vim.lsp.get_client_by_id(client_id), ("Client not found with ID %s"):format(client_id))
---@param name string
local convert_method = function(name)
return name:gsub("__", "$/"):gsub("_", "/")
end
return {
server_capabilities = internal_client.server_capabilities,
notify = setmetatable({}, {
__index = function(_, method)
method = convert_method(method)
return function(params)
return internal_client.notify(method, params)
end
end,
}),
request = setmetatable({}, {
__index = function(_, method)
method = convert_method(method)
---@param opts? nio.lsp.RequestOpts
return function(params, bufnr, opts)
-- No params for this request
if type(params) ~= "table" then
opts = bufnr
bufnr = params
end
opts = opts or {}
local err, result
local start = vim.loop.now()
if opts.timeout then
local req_future = control.future()
err, result = n.first({
function()
n.sleep(opts.timeout)
local req_id = req_future.wait()
n.run(function()
async_request(internal_client, "$/cancelRequest", { requestId = req_id }, bufnr)
end)
return { code = -1, message = "Request timed out" }
end,
function()
return async_request(internal_client, method, params, bufnr, req_future)
end,
})
else
err, result = async_request(internal_client, method, params, bufnr)
end
local elapsed = vim.loop.now() - start
logger.trace("Request", method, "took", elapsed, "ms")
return err, result
end
end,
}),
}
end
return nio.lsp

212
lua/nio/tasks.lua Normal file
View file

@ -0,0 +1,212 @@
local nio = {}
---@class nio.tasks
nio.tasks = {}
---@type table<thread, nio.tasks.Task>
---@nodoc
local tasks = {}
---@type table<nio.tasks.Task, nio.tasks.Task[]>
---@nodoc
local child_tasks = {}
-- Coroutine.running() was changed between Lua 5.1 and 5.2:
-- - 5.1: Returns the running coroutine, or nil when called by the main thread.
-- - 5.2: Returns the running coroutine plus a boolean, true when the running coroutine is the main one.
-- For LuaJIT, 5.2 behaviour is enabled with LUAJIT_ENABLE_LUA52COMPAT
---@nodoc
local function current_non_main_co()
local data = { coroutine.running() }
if select("#", unpack(data)) == 2 then
local co, is_main = unpack(data)
if is_main then
return nil
end
return co
end
return unpack(data)
end
---@text
--- Tasks represent a top level running asynchronous function
--- Only one task is ever executing at any time.
---@class nio.tasks.Task
---@field parent? nio.tasks.Task Parent task
---@field cancel fun(): nil Cancels the task
---@field trace fun(): string Get the stack trace of the task
---@field wait async function Wait for the task to finish, returning any result
---@class nio.tasks.TaskError
---@field message string
---@field traceback? string
local format_error = function(message, traceback)
if not traceback then
return string.format("The coroutine failed with this message: %s", message)
end
return string.format(
"The coroutine failed with this message: %s\n%s",
type(message) == "string" and vim.startswith(traceback, message) and ""
or ("\n" .. tostring(message)),
traceback
)
end
---@return nio.tasks.Task
---@nodoc
function nio.tasks.run(func, cb)
local co = coroutine.create(func)
local cancelled = false
local task = { parent = nio.tasks.current_task() }
if task.parent then
child_tasks[task.parent] = child_tasks[task.parent] or {}
table.insert(child_tasks[task.parent], task)
end
local future = require("nio").control.future()
function task.cancel()
if coroutine.status(co) == "dead" then
return
end
for _, child in pairs(child_tasks[task] or {}) do
child.cancel()
end
cancelled = true
end
function task.trace()
return debug.traceback(co)
end
function task.wait()
return future.wait()
end
local function close_task(result, err)
tasks[co] = nil
if err then
future.set_error(err)
if cb then
cb(false, err)
elseif not cancelled then
error("Async task failed without callback: " .. err)
end
else
future.set(unpack(result))
if cb then
cb(true, unpack(result))
end
end
end
tasks[co] = task
local function step(...)
if cancelled then
close_task(nil, format_error("Task was cancelled"))
return
end
local yielded = { coroutine.resume(co, ...) }
local success = yielded[1]
if not success then
close_task(nil, format_error(yielded[2], debug.traceback(co)))
return
end
if coroutine.status(co) == "dead" then
close_task({ unpack(yielded, 2, table.maxn(yielded)) })
return
end
local _, nargs, err_or_fn = unpack(yielded)
if type(err_or_fn) ~= "function" then
error(
("Async internal error: expected function, got %s\nContext: %s\n%s"):format(
type(err_or_fn),
vim.inspect(yielded),
debug.traceback(co)
)
)
end
local args = { select(4, unpack(yielded)) }
args[nargs] = step
err_or_fn(unpack(args, 1, nargs))
end
step()
return task
end
---@param func function
---@param argc? number
---@return function
function nio.tasks.create(func, argc)
vim.validate({
func = { func, "function" },
argc = { argc, "number", true },
})
argc = argc or 0
return function(...)
if current_non_main_co() then
return func(...)
end
local args = { ... }
local callback
if #args > argc then
callback = table.remove(args)
end
return nio.tasks.run(function()
func(unpack(args))
end, callback)
end
end
---@package
---@nodoc
function nio.tasks.wrap(func, argc)
vim.validate({ func = { func, "function" }, argc = { argc, "number" } })
local protected = function(...)
local args = { ... }
local cb = args[argc]
args[argc] = function(...)
cb(true, ...)
end
xpcall(func, function(err)
cb(false, err, debug.traceback())
end, unpack(args, 1, argc))
end
return function(...)
if not current_non_main_co() then
return func(...)
end
local ret = { coroutine.yield(argc, protected, ...) }
local success = ret[1]
if not success then
error(("Wrapped function failed: %s\n%s"):format(ret[2], ret[3]))
end
return unpack(ret, 2, table.maxn(ret))
end
end
--- Get the current running task
---@return nio.tasks.Task|nil
function nio.tasks.current_task()
local co = current_non_main_co()
if not co then
return nil
end
return tasks[co]
end
return nio.tasks

64
lua/nio/tests.lua Normal file
View file

@ -0,0 +1,64 @@
local tasks = require("nio.tasks")
local nio = {}
---@toc_entry nio.tests
---@text
--- Wrappers around plenary.nvim's test functions for writing async tests
--- ```lua
--- a.it("notifies listeners", function()
--- local event = nio.control.event()
--- local notified = 0
--- for _ = 1, 10 do
--- nio.run(function()
--- event.wait()
--- notified = notified + 1
--- end)
--- end
---
--- event.set()
--- nio.sleep(10)
--- assert.equals(10, notified)
--- end)
---@class nio.tests
nio.tests = {}
local with_timeout = function(func, timeout)
local success, err
return function()
local task = tasks.run(func, function(success_, err_)
success = success_
if not success_ then
err = err_
end
end)
vim.wait(timeout or 2000, function()
return success ~= nil
end, 20, false)
if success == nil then
error(string.format("Test task timed out\n%s", task.trace()))
elseif not success then
error(string.format("Test task failed with message:\n%s", err))
end
end
end
---@param name string
---@param async_func function
nio.tests.it = function(name, async_func)
it(name, with_timeout(async_func, tonumber(vim.env.PLENARY_TEST_TIMEOUT)))
end
---@param async_func function
nio.tests.before_each = function(async_func)
before_each(with_timeout(async_func))
end
---@param async_func function
nio.tests.after_each = function(async_func)
after_each(with_timeout(async_func))
end
return nio.tests

50
lua/nio/ui.lua Normal file
View file

@ -0,0 +1,50 @@
local tasks = require("nio.tasks")
local nio = {}
---@toc_entry nio.ui
---@text
--- Async versions of vim.ui functions.
---@class nio.ui
nio.ui = {}
--- Prompt the user for input.
--- See |vim.ui.input()| for details.
--- ```lua
--- local value = nio.ui.input({ prompt = "Enter something: " })
--- print(("You entered: %s"):format(value))
--- ```
---@async
---@param args nio.ui.InputArgs
function nio.ui.input(args) end
---@class nio.ui.InputArgs
---@field prompt string|nil Text of the prompt
---@field default string|nil Default reply to the input
---@field completion string|nil Specifies type of completion supported for input. Supported types are the same that can be supplied to a user-defined command using the "-complete=" argument. See |:command-completion|
---@field highlight function Function that will be used for highlighting user inputs.
--- Prompts the user to pick from a list of items
--- See |vim.ui.select()| for details.
--- ```
--- local value = nio.ui.select({ "foo", "bar", "baz" }, { prompt = "Select something: " })
--- print(("You entered: %s"):format(value))
--- ```
---@async
---@param items any[]
---@param args nio.ui.SelectArgs
function nio.ui.select(items, args) end
---@class nio.ui.SelectArgs
---@field prompt string|nil Text of the prompt. Defaults to `Select one of:`
---@field format_item function|nil Function to format an individual item from `items`. Defaults to `tostring`.
---@field kind string|nil Arbitrary hint string indicating the item shape. Plugins reimplementing `vim.ui.select` may wish to use this to infer the structure or semantics of `items`, or the context in which select() was called.
nio.ui = {
---@nodoc
select = tasks.wrap(vim.ui.select, 3),
---@nodoc
input = tasks.wrap(vim.ui.input, 2),
}
return nio.ui

169
lua/nio/uv.lua Normal file
View file

@ -0,0 +1,169 @@
local tasks = require("nio.tasks")
local nio = {}
---@toc_entry nio.uv
---@text
--- Provides asynchronous versions of vim.loop functions.
--- See corresponding function documentation for parameter and return
--- information.
--- ```lua
--- local file_path = "README.md"
---
--- local open_err, file_fd = nio.uv.fs_open(file_path, "r", 438)
--- assert(not open_err, open_err)
---
--- local stat_err, stat = nio.uv.fs_fstat(file_fd)
--- assert(not stat_err, stat_err)
---
--- local read_err, data = nio.uv.fs_read(file_fd, stat.size, 0)
--- assert(not read_err, read_err)
---
--- local close_err = nio.uv.fs_close(file_fd)
--- assert(not close_err, close_err)
---
--- print(data)
--- ```
---
---@class nio.uv
---@field close async fun(handle: nio.uv.Handle)
---@field fs_open async fun(path: any, flags: any, mode: any): (string|nil,integer|nil)
---@field fs_read async fun(fd: integer, size: integer, offset?: integer): (string|nil,string|nil)
---@field fs_close async fun(fd: integer): (string|nil,boolean|nil)
---@field fs_unlink async fun(path: string): (string|nil,boolean|nil)
---@field fs_write async fun(fd: any, data: any, offset?: any): (string|nil,integer|nil)
---@field fs_mkdir async fun(path: string, mode: integer): (string|nil,boolean|nil)
---@field fs_mkdtemp async fun(template: string): (string|nil,string|nil)
---@field fs_rmdir async fun(path: string): (string|nil,boolean|nil)
---@field fs_stat async fun(path: string): (string|nil,nio.uv.Stat|nil)
---@field fs_fstat async fun(fd: integer): (string|nil,nio.uv.Stat|nil)
---@field fs_lstat async fun(path: string): (string|nil,nio.uv.Stat|nil)
---@field fs_statfs async fun(path: string): (string|nil,nio.uv.StatFs|nil)
---@field fs_rename async fun(old_path: string, new_path: string): (string|nil,boolean|nil)
---@field fs_fsync async fun(fd: integer): (string|nil,boolean|nil)
---@field fs_fdatasync async fun(fd: integer): (string|nil,boolean|nil)
---@field fs_ftruncate async fun(fd: integer, offset: integer): (string|nil,boolean|nil)
---@field fs_sendfile async fun(out_fd: integer, in_fd: integer, in_offset: integer, length: integer): (string|nil,integer|nil)
---@field fs_access async fun(path: string, mode: integer): (string|nil,boolean|nil)
---@field fs_chmod async fun(path: string, mode: integer): (string|nil,boolean|nil)
---@field fs_fchmod async fun(fd: integer, mode: integer): (string|nil,boolean|nil)
---@field fs_utime async fun(path: string, atime: number, mtime: number): (string|nil,boolean|nil)
---@field fs_futime async fun(fd: integer, atime: number, mtime: number): (string|nil,boolean|nil)
---@field fs_link async fun(path: string, new_path: string): (string|nil,boolean|nil)
---@field fs_symlink async fun(path: string, new_path: string, flags?: integer): (string|nil,boolean|nil)
---@field fs_readlink async fun(path: string): (string|nil,string|nil)
---@field fs_realpath async fun(path: string): (string|nil,string|nil)
---@field fs_chown async fun(path: string, uid: integer, gid: integer): (string|nil,boolean|nil)
---@field fs_fchown async fun(fd: integer, uid: integer, gid: integer): (string|nil,boolean|nil)
---@field fs_lchown async fun(path: string, uid: integer, gid: integer): (string|nil,boolean|nil)
---@field fs_copyfile async fun(path: any, new_path: any, flags?: any): (string|nil,boolean|nil)
---@field fs_opendir async fun(path: string, entries?: integer): (string|nil,nio.uv.Dir|nil)
---@field fs_readdir async fun(dir: nio.uv.Dir): (string|nil,nio.uv.DirEntry[]|nil)
---@field fs_closedir async fun(dir: nio.uv.Dir): (string|nil,boolean|nil)
---@field fs_scandir async fun(path: string): (string|nil,nio.uv.DirEntry[]|nil)
---@field shutdown async fun(stream: nio.uv.Stream): string|nil
---@field listen async fun(stream: nio.uv.Stream, backlog: integer): string|nil
---@field write async fun(stream: nio.uv.Stream, data: string|string[]): string|nil
---@field write2 async fun(stream: nio.uv.Stream, data: string|string[], send_handle: nio.uv.Stream): string|nil
nio.uv = {}
---@class nio.uv.Handle
---@class nio.uv.Stream : nio.uv.Handle
---@class nio.uv.Stat
---@field dev integer
---@field mode integer
---@field nlink integer
---@field uid integer
---@field gid integer
---@field rdev integer
---@field ino integer
---@field size integer
---@field blksize integer
---@field blocks integer
---@field flags integer
---@field gen integer
---@field atime nio.uv.StatTime
---@field mtime nio.uv.StatTime
---@field ctime nio.uv.StatTime
---@field birthtime nio.uv.StatTime
---@field type string
---@class nio.uv.StatTime
---@field sec integer
---@field nsec integer
---@class nio.uv.StatFs
---@field type integer
---@field bsize integer
---@field blocks integer
---@field bfree integer
---@field bavail integer
---@field files integer
---@field ffree integer
---@class nio.uv.Dir
---@class nio.uv.DirEntry
---@nodoc
local function add(name, argc)
local success, ret = pcall(tasks.wrap, vim.loop[name], argc)
if not success then
error("Failed to add function with name " .. name)
end
nio.uv[name] = ret
end
add("close", 4) -- close a handle
-- filesystem operations
add("fs_open", 4)
add("fs_read", 4)
add("fs_close", 2)
add("fs_unlink", 2)
add("fs_write", 4)
add("fs_mkdir", 3)
add("fs_mkdtemp", 2)
-- 'fs_mkstemp',
add("fs_rmdir", 2)
add("fs_scandir", 2)
add("fs_stat", 2)
add("fs_fstat", 2)
add("fs_lstat", 2)
add("fs_rename", 3)
add("fs_fsync", 2)
add("fs_fdatasync", 2)
add("fs_ftruncate", 3)
add("fs_sendfile", 5)
add("fs_access", 3)
add("fs_chmod", 3)
add("fs_fchmod", 3)
add("fs_utime", 4)
add("fs_futime", 4)
-- 'fs_lutime',
add("fs_link", 3)
add("fs_symlink", 4)
add("fs_readlink", 2)
add("fs_realpath", 2)
add("fs_chown", 4)
add("fs_fchown", 4)
-- 'fs_lchown',
add("fs_copyfile", 4)
nio.uv.fs_opendir = tasks.wrap(function(path, entries, cb)
vim.loop.fs_opendir(path, cb, entries)
end, 3)
add("fs_readdir", 2)
add("fs_closedir", 2)
add("fs_statfs", 2)
-- stream
add("shutdown", 2)
add("listen", 3)
-- add('read_start', 2) -- do not do this one, the callback is made multiple times
add("write", 3)
add("write2", 4)
add("shutdown", 2)
return nio.uv

3
scripts/docgen Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
nvim --headless -c "luafile ./scripts/gendocs.lua" -c 'qa'

854
scripts/gendocs.lua Normal file
View file

@ -0,0 +1,854 @@
-- TODO: A lot of this is private code from minidoc, which could be removed if made public
local minidoc = require("mini.doc")
local H = {}
--stylua: ignore start
H.pattern_sets = {
-- Patterns for working with afterlines. At the moment deliberately crafted
-- to work only on first line without indent.
-- Determine if line is a function definition. Captures function name and
-- arguments. For reference see '2.5.9 Function Definitions' in Lua manual.
afterline_fundef = {
'^function%s+(%S-)(%b())', -- Regular definition
'^local%s+function%s+(%S-)(%b())', -- Local definition
'^(%S+)%s*=%s*function(%b())', -- Regular assignment
'^(%S+)%s*=%s*nio.create%(function(%b())', -- Regular assignment
'^local%s+(%S+)%s*=%s*function(%b())', -- Local assignment
},
-- Determine if line is a general assignment
afterline_assign = {
'^(%S-)%s*=', -- General assignment
'^local%s+(%S-)%s*=', -- Local assignment
},
-- Patterns to work with type descriptions
-- (see https://github.com/sumneko/lua-language-server/wiki/EmmyLua-Annotations#types-and-type)
types = {
'table%b<>',
'fun%b(): %S+', 'fun%b()', 'async fun%b(): %S+', 'async fun%b()',
'nil', 'any', 'boolean', 'string', 'number', 'integer', 'function', 'table', 'thread', 'userdata', 'lightuserdata',
'%.%.%.',
"%S+",
},
}
H.apply_config = function(config)
MiniDoc.config = config
end
H.is_disabled = function()
return vim.g.minidoc_disable == true or vim.b.minidoc_disable == true
end
H.get_config = function(config)
return vim.tbl_deep_extend("force", MiniDoc.config, vim.b.minidoc_config or {}, config or {})
end
-- Work with project specific script ==========================================
H.execute_project_script = function(input, output, config)
-- Don't process script if there are more than one active `generate` calls
if H.generate_is_active then
return
end
-- Don't process script if at least one argument is not default
if not (input == nil and output == nil and config == nil) then
return
end
-- Store information
local global_config_cache = vim.deepcopy(MiniDoc.config)
local local_config_cache = vim.b.minidoc_config
-- Pass information to a possible `generate()` call inside script
H.generate_is_active = true
H.generate_recent_output = nil
-- Execute script
local success = pcall(vim.cmd, "luafile " .. H.get_config(config).script_path)
-- Restore information
MiniDoc.config = global_config_cache
vim.b.minidoc_config = local_config_cache
H.generate_is_active = nil
return success
end
-- Default documentation targets ----------------------------------------------
H.default_input = function()
-- Search in current and recursively in other directories for files with
-- 'lua' extension
local res = {}
for _, dir_glob in ipairs({ ".", "lua/**", "after/**", "colors/**" }) do
local files = vim.fn.globpath(dir_glob, "*.lua", false, true)
-- Use full paths
files = vim.tbl_map(function(x)
return vim.fn.fnamemodify(x, ":p")
end, files)
-- Put 'init.lua' first among files from same directory
table.sort(files, function(a, b)
if vim.fn.fnamemodify(a, ":h") == vim.fn.fnamemodify(b, ":h") then
if vim.fn.fnamemodify(a, ":t") == "init.lua" then
return true
end
if vim.fn.fnamemodify(b, ":t") == "init.lua" then
return false
end
end
return a < b
end)
table.insert(res, files)
end
return vim.tbl_flatten(res)
end
-- Parsing --------------------------------------------------------------------
H.lines_to_block_arr = function(lines, config)
local matched_prev, matched_cur
local res = {}
local block_raw = { annotation = {}, section_id = {}, afterlines = {}, line_begin = 1 }
for i, l in ipairs(lines) do
local from, to, section_id = config.annotation_extractor(l)
matched_prev, matched_cur = matched_cur, from ~= nil
if matched_cur then
if not matched_prev then
-- Finish current block
block_raw.line_end = i - 1
table.insert(res, H.raw_block_to_block(block_raw, config))
-- Start new block
block_raw = { annotation = {}, section_id = {}, afterlines = {}, line_begin = i }
end
-- Add annotation line without matched annotation pattern
table.insert(block_raw.annotation, ("%s%s"):format(l:sub(0, from - 1), l:sub(to + 1)))
-- Add section id (it is empty string in case of no section id capture)
table.insert(block_raw.section_id, section_id or "")
else
-- Add afterline
table.insert(block_raw.afterlines, l)
end
end
block_raw.line_end = #lines
table.insert(res, H.raw_block_to_block(block_raw, config))
return res
end
-- Raw block structure is an intermediate step added for convenience. It is
-- a table with the following keys:
-- - `annotation` - lines (after removing matched annotation pattern) that were
-- parsed as annotation.
-- - `section_id` - array with length equal to `annotation` length with strings
-- captured as section id. Empty string of no section id was captured.
-- - Everything else is used as block info (like `afterlines`, etc.).
H.raw_block_to_block = function(block_raw, config)
if #block_raw.annotation == 0 and #block_raw.afterlines == 0 then
return nil
end
local block = H.new_struct("block", {
afterlines = block_raw.afterlines,
line_begin = block_raw.line_begin,
line_end = block_raw.line_end,
})
local block_begin = block.info.line_begin
-- Parse raw block annotation lines from top to bottom. New section starts
-- when section id is detected in that line.
local section_cur = H.new_struct(
"section",
{ id = config.default_section_id, line_begin = block_begin }
)
for i, annotation_line in ipairs(block_raw.annotation) do
local id = block_raw.section_id[i]
if id ~= "" then
-- Finish current section
if #section_cur > 0 then
section_cur.info.line_end = block_begin + i - 2
block:insert(section_cur)
end
-- Start new section
section_cur = H.new_struct("section", { id = id, line_begin = block_begin + i - 1 })
end
section_cur:insert(annotation_line)
end
if #section_cur > 0 then
section_cur.info.line_end = block_begin + #block_raw.annotation - 1
block:insert(section_cur)
end
return block
end
-- Hooks ----------------------------------------------------------------------
H.apply_structure_hooks = function(doc, hooks)
for _, file in ipairs(doc) do
for _, block in ipairs(file) do
hooks.block_pre(block)
for _, section in ipairs(block) do
hooks.section_pre(section)
local hook = hooks.sections[section.info.id]
if hook ~= nil then
hook(section)
end
hooks.section_post(section)
end
hooks.block_post(block)
end
hooks.file(file)
end
hooks.doc(doc)
end
H.alias_register = function(s)
if #s == 0 then
return
end
-- Remove first word (with bits of surrounding whitespace) while capturing it
local alias_name
s[1] = s[1]:gsub("%s*(%S+) ?", function(x)
alias_name = x
return ""
end, 1)
if alias_name == nil then
return
end
MiniDoc.current.aliases = MiniDoc.current.aliases or {}
MiniDoc.current.aliases[alias_name] = table.concat(s, "\n")
end
H.alias_replace = function(s)
if MiniDoc.current.aliases == nil then
return
end
for i, _ in ipairs(s) do
for alias_name, alias_desc in pairs(MiniDoc.current.aliases) do
-- Escape special characters. This is done here and not while registering
-- alias to allow user to refer to aliases by its original name.
-- Store escaped words in separate variables because `vim.pesc()` returns
-- two values which might conflict if outputs are used as arguments.
local name_escaped = vim.pesc(alias_name)
local desc_escaped = vim.pesc(alias_desc)
s[i] = s[i]:gsub(name_escaped, desc_escaped)
end
end
end
H.toc_register = function(s)
MiniDoc.current.toc = MiniDoc.current.toc or {}
table.insert(MiniDoc.current.toc, s)
end
H.toc_insert = function(s)
if MiniDoc.current.toc == nil then
return
end
-- Render table of contents
local toc_lines = {}
for _, toc_entry in ipairs(MiniDoc.current.toc) do
local _, tag_section = toc_entry.parent:has_descendant(function(x)
return type(x) == "table" and x.type == "section" and x.info.id == "@tag"
end)
tag_section = tag_section or {}
local lines = {}
for i = 1, math.max(#toc_entry, #tag_section) do
local left = toc_entry[i] or ""
-- Use tag refernce instead of tag enclosure
local right = string.match(tag_section[i], "%*.*%*"):gsub("%*", "|")
-- local right = vim.trim((tag_section[i] or ""):gsub("%*", "|"))
-- Add visual line only at first entry (while not adding trailing space)
local filler = i == 1 and "." or (right == "" and "" or " ")
-- Make padding of 2 spaces at both left and right
local n_filler = math.max(74 - H.visual_text_width(left) - H.visual_text_width(right), 3)
table.insert(lines, (" %s%s%s"):format(left, filler:rep(n_filler), right))
end
table.insert(toc_lines, lines)
-- Don't show `toc_entry` lines in output
toc_entry:clear_lines()
end
for _, l in ipairs(vim.tbl_flatten(toc_lines)) do
s:insert(l)
end
end
H.add_section_heading = function(s, heading)
if #s == 0 or s.type ~= "section" then
return
end
-- Add heading
s:insert(1, ("%s~"):format(heading))
end
H.enclose_var_name = function(s)
if #s == 0 or s.type ~= "section" then
return
end
s[1] = s[1]:gsub("(%S+)", "{%1}", 1)
end
---@param init number Start of searching for first "type-like" string. It is
--- needed to not detect type early. Like in `@param a_function function`.
---@private
H.enclose_type = function(s, enclosure, init)
if #s == 0 or s.type ~= "section" then
return
end
enclosure = enclosure or "`%(%1%)`"
init = init or 1
local cur_type = H.match_first_pattern(s[1], H.pattern_sets["types"], init)
if #cur_type == 0 then
return
end
-- Add `%S*` to front and back of found pattern to support their combination
-- with `|`. Also allows using `[]` and `?` prefixes.
local type_pattern = ("(%%S*%s%%S*)"):format(vim.pesc(cur_type[1]))
-- Avoid replacing possible match before `init`
local l_start = s[1]:sub(1, init - 1)
local l_end = s[1]:sub(init):gsub(type_pattern, enclosure, 1)
s[1] = ("%s%s"):format(l_start, l_end)
end
-- Infer data from afterlines -------------------------------------------------
H.infer_header = function(b)
local has_signature = b:has_descendant(function(x)
return type(x) == "table" and x.type == "section" and x.info.id == "@signature"
end)
local has_tag = b:has_descendant(function(x)
return type(x) == "table" and x.type == "section" and x.info.id == "@tag"
end)
if has_signature and has_tag then
return
end
local l_all = table.concat(b.info.afterlines, " ")
local tag, signature
-- Try function definition
local fun_capture = H.match_first_pattern(l_all, H.pattern_sets["afterline_fundef"])
if #fun_capture > 0 then
tag = tag or ("%s()"):format(fun_capture[1])
signature = signature or ("%s%s"):format(fun_capture[1], fun_capture[2])
end
-- Try general assignment
local assign_capture = H.match_first_pattern(l_all, H.pattern_sets["afterline_assign"])
if #assign_capture > 0 then
tag = tag or assign_capture[1]
signature = signature or assign_capture[1]
end
if tag ~= nil then
-- First insert signature (so that it will appear after tag section)
if not has_signature then
b:insert(1, H.as_struct({ signature }, "section", { id = "@signature" }))
end
-- Insert tag
if not has_tag then
b:insert(1, H.as_struct({ tag }, "section", { id = "@tag" }))
end
end
end
function H.is_module(name)
if string.find(name, "%(") then
return false
end
if string.find(name, "[A-Z]") then
return false
end
return true
end
H.format_signature = function(line)
-- Try capture function signature
local name, args = line:match("(%S-)(%b())")
-- Otherwise pick first word
name = name or line:match("(%S+)")
if not args and H.is_module(name) then
return ""
end
local name_elems = vim.split(name, ".", { plain = true })
name = name_elems[#name_elems]
if not name then
return ""
end
-- Tidy arguments
if args and args ~= "()" then
local arg_parts = vim.split(args:sub(2, -2), ",")
local arg_list = {}
for _, a in ipairs(arg_parts) do
-- Enclose argument in `{}` while controlling whitespace
table.insert(arg_list, ("{%s}"):format(vim.trim(a)))
end
args = ("(%s)"):format(table.concat(arg_list, ", "))
end
return ("`%s`%s"):format(name, args or "")
end
-- Work with structures -------------------------------------------------------
-- Constructor
H.new_struct = function(struct_type, info)
local output = {
info = info or {},
type = struct_type,
}
output.insert = function(self, index, child)
-- Allow both `x:insert(child)` and `x:insert(1, child)`
if child == nil then
child, index = index, #self + 1
end
if type(child) == "table" then
child.parent = self
child.parent_index = index
end
table.insert(self, index, child)
H.sync_parent_index(self)
end
output.remove = function(self, index)
index = index or #self
table.remove(self, index)
H.sync_parent_index(self)
end
output.has_descendant = function(self, predicate)
local bool_res, descendant = false, nil
H.apply_recursively(function(x)
if not bool_res and predicate(x) then
bool_res = true
descendant = x
end
end, self)
return bool_res, descendant
end
output.has_lines = function(self)
return self:has_descendant(function(x)
return type(x) == "string"
end)
end
output.clear_lines = function(self)
for i, x in ipairs(self) do
if type(x) == "string" then
self[i] = nil
else
x:clear_lines()
end
end
end
return output
end
H.sync_parent_index = function(x)
for i, _ in ipairs(x) do
if type(x[i]) == "table" then
x[i].parent_index = i
end
end
return x
end
-- Converter (this ensures that children have proper parent-related data)
H.as_struct = function(array, struct_type, info)
-- Make default info `info` for cases when structure is created manually
local default_info = ({
section = { id = "@text", line_begin = -1, line_end = -1 },
block = { afterlines = {}, line_begin = -1, line_end = -1 },
file = { path = "" },
doc = { input = {}, output = "", config = H.get_config() },
})[struct_type]
info = vim.tbl_deep_extend("force", default_info, info or {})
local res = H.new_struct(struct_type, info)
for _, x in ipairs(array) do
res:insert(x)
end
return res
end
-- Work with text -------------------------------------------------------------
H.ensure_indent = function(text, n_indent_target)
local lines = vim.split(text, "\n")
local n_indent, n_indent_cur = math.huge, math.huge
-- Find number of characters in indent
for _, l in ipairs(lines) do
-- Update lines indent: minimum of all indents except empty lines
if n_indent > 0 then
_, n_indent_cur = l:find("^%s*")
-- Condition "current n-indent equals line length" detects empty line
if (n_indent_cur < n_indent) and (n_indent_cur < l:len()) then
n_indent = n_indent_cur
end
end
end
-- Ensure indent
local indent = string.rep(" ", n_indent_target)
for i, l in ipairs(lines) do
if l ~= "" then
lines[i] = indent .. l:sub(n_indent + 1)
end
end
return table.concat(lines, "\n")
end
H.align_text = function(text, width, direction)
if type(text) ~= "string" then
return
end
text = vim.trim(text)
width = width or 78
direction = direction or "left"
-- Don't do anything if aligning left or line is a whitespace
if direction == "left" or text:find("^%s*$") then
return text
end
local n_left = math.max(0, 78 - H.visual_text_width(text))
if direction == "center" then
n_left = math.floor(0.5 * n_left)
end
return (" "):rep(n_left) .. text
end
H.visual_text_width = function(text)
-- Ignore concealed characters (usually "invisible" in 'help' filetype)
local _, n_concealed_chars = text:gsub("([*|`])", "%1")
return vim.fn.strdisplaywidth(text) - n_concealed_chars
end
--- Return earliest match among many patterns
---
--- Logic here is to test among several patterns. If several got a match,
--- return one with earliest match.
---
---@private
H.match_first_pattern = function(text, pattern_set, init)
local start_tbl = vim.tbl_map(function(pattern)
return text:find(pattern, init) or math.huge
end, pattern_set)
local min_start, min_id = math.huge, nil
for id, st in ipairs(start_tbl) do
if st < min_start then
min_start, min_id = st, id
end
end
if min_id == nil then
return {}
end
return { text:match(pattern_set[min_id], init) }
end
-- Utilities ------------------------------------------------------------------
H.apply_recursively = function(f, x, used)
used = used or {}
if used[x] then
return
end
f(x)
used[x] = true
if type(x) == "table" then
for _, t in ipairs(x) do
H.apply_recursively(f, t, used)
end
end
end
H.collect_strings = function(x)
local res = {}
H.apply_recursively(function(y)
if type(y) == "string" then
-- Allow `\n` in strings
table.insert(res, vim.split(y, "\n"))
end
end, x)
-- Flatten to only have strings and not table of strings (from `vim.split`)
return vim.tbl_flatten(res)
end
H.file_read = function(path)
local file = assert(io.open(path))
local contents = file:read("*all")
file:close()
return vim.split(contents, "\n")
end
H.file_write = function(path, lines)
-- Ensure target directory exists
local dir = vim.fn.fnamemodify(path, ":h")
vim.fn.mkdir(dir, "p")
-- Write to file
vim.fn.writefile(lines, path, "b")
end
H.full_path = function(path)
return vim.fn.resolve(vim.fn.fnamemodify(path, ":p"))
end
H.message = function(msg)
vim.cmd("echomsg " .. vim.inspect("(mini.doc) " .. msg))
end
local function wrap(str, limit, indent, indent1)
indent = indent or ""
indent1 = indent1 or indent
limit = limit or 79
local here = 1 - #indent1
local wrapped = indent1
.. str:gsub("(%s+)()(%S+)()", function(sp, st, word, fi)
local delta = 0
word:gsub("@([@%a])", function(c)
if c == "@" then
delta = delta + 1
elseif c == "x" then
delta = delta + 5
else
delta = delta + 2
end
end)
here = here + delta
if fi - here > limit then
here = st - #indent + delta
return "\n" .. indent .. word
end
end)
return vim.split(wrapped, "\n")
end
local function create_config(module, header)
return {
hooks = vim.tbl_extend("force", minidoc.default_hooks, {
block_pre = function(b)
-- Infer metadata based on afterlines
if b:has_lines() and #b.info.afterlines > 0 then H.infer_header(b) end
end,
section_post = function(section)
for i, line in ipairs(section) do
if type(line) == "string" then
if string.find(line, "^```") then
string.gsub(line, "```(.*)", function(lang)
section[i] = lang == "" and "<" or (">%s"):format(lang)
end)
end
end
end
end,
block_post = function(b)
if not b:has_lines() then return end
local found_param, found_field = false, false
local n_tag_sections = 0
H.apply_recursively(function(x)
if not (type(x) == 'table' and x.type == 'section') then return end
-- Add headings before first occurence of a section which type usually
-- appear several times
if not found_param and x.info.id == '@param' then
H.add_section_heading(x, 'Parameters')
found_param = true
end
if not found_field and x.info.id == '@field' then
H.add_section_heading(x, 'Fields')
found_field = true
end
if x.info.id == '@tag' then
local text = x[1]
local tag = string.match(text, "%*.*%*")
local prefix = (string.sub(tag, 2, #tag - 1))
if not H.is_module(prefix) then
prefix = ""
end
local n_filler = math.max(78 - H.visual_text_width(prefix) - H.visual_text_width(tag), 3)
local line = ("%s%s%s"):format(prefix, (" "):rep(n_filler), tag)
x:remove(1)
x:insert(1, line)
x.parent:remove(x.parent_index)
n_tag_sections = n_tag_sections + 1
x.parent:insert(n_tag_sections, x)
end
end, b)
-- b:insert(1, H.as_struct({ string.rep('=', 78) }, 'section'))
b:insert(H.as_struct({ '' }, 'section'))
end,
doc = function(d)
-- Render table of contents
H.apply_recursively(function(x)
if not (type(x) == 'table' and x.type == 'section' and x.info.id == '@toc') then return end
H.toc_insert(x)
end, d)
-- Insert modeline
d:insert(
H.as_struct(
{ H.as_struct({ H.as_struct({ ' vim:tw=78:ts=8:noet:ft=help:norl:' }, 'section') }, 'block') },
'file'
)
)
end,
sections = {
['@generic'] = function(s)
s:remove(1)
end,
['@field'] = function(s)
-- H.mark_optional(s)
if string.find(s[1], "^private ") then
s:remove(1)
return
end
H.enclose_var_name(s)
H.enclose_type(s, '`%(%1%)`', s[1]:find('%s'))
local wrapped = wrap(s[1], 78, "")
s:remove(1)
for i, line in ipairs(wrapped) do
s:insert(i, line)
end
end,
['@alias'] = function(s)
local name = s[1]:match('%s*(%S*)')
local alias = s[1]:match('%s(.*)$')
s[1] = ("`%s` → `%s`"):format(name, alias)
H.add_section_heading(s, 'Alias')
s:insert(1, H.as_struct({ ("*%s*"):format(name) }, "section", { id = "@tag" }))
end,
['@param'] = function(s)
H.enclose_var_name(s)
H.enclose_type(s, '`%(%1%)`', s[1]:find('%s'))
local wrapped = wrap(s[1], 78, "")
s:remove(1)
for i, line in ipairs(wrapped) do
s:insert(i, line)
end
end,
['@return'] = function(s)
H.enclose_type(s, '`%(%1%)`', 1)
H.add_section_heading(s, 'Return')
end,
['@nodoc'] = function(s) s.parent:clear_lines() end,
['@class'] = function(s)
H.enclose_var_name(s)
-- Add heading
local line = s[1]
s:remove(1)
local class_name = string.match(line, "%{(.*)%}")
local inherits = string.match(line, ": (.*)")
if inherits then
s:insert(1, ("Inherits: `%s`"):format(inherits))
s:insert(2, "")
end
s:insert(1, H.as_struct({ ("*%s*"):format(class_name) }, "section", { id = "@tag" }))
end,
['@signature'] = function(s)
s[1] = H.format_signature(s[1])
if s[1] ~= "" then
table.insert(s, "")
end
end,
},
file = function(f)
if not f:has_lines() then
return
end
if f.info.path ~= "./lua/" .. module .. "/init.lua" then
f:insert(1, H.as_struct({ H.as_struct({ string.rep("=", 78) }, "section") }, "block"))
f:insert(H.as_struct({ H.as_struct({ "" }, "section") }, "block"))
else
f:insert(
1,
H.as_struct(
{
H.as_struct(
{ header },
"section"
),
},
"block"
)
)
f:insert(2, H.as_struct({ H.as_struct({ "" }, "section") }, "block"))
f:insert(3, H.as_struct({ H.as_struct({ string.rep("=", 78) }, "section") }, "block"))
f:insert(H.as_struct({ H.as_struct({ "" }, "section") }, "block"))
end
end,
}),
}
end
minidoc.setup({})
minidoc.generate(
{
"./lua/nio/init.lua",
"./lua/nio/control.lua",
"./lua/nio/lsp.lua",
"./lua/nio/uv.lua",
"./lua/nio/ui.lua",
"./lua/nio/tests.lua",
},
"doc/nio.txt",
create_config("nio", "*nvim-nio.txt* A library for asynchronous IO in Neovim")
)

View file

@ -0,0 +1,535 @@
---@class Model
---@field requests Request[]
---@field notifications Notification[]
---@field enumerations Enumeration
---@field typeAliases TypeAlias[]
---@field structures Structure[]
---@alias BaseTypes "URI" | "DocumentUri" | "integer" | "uinteger" | "decimal" | "RegExp" | "string" | "boolean" | "null"
---@class BooleanLiteralType
---@field kind "boolean"
---@field value boolean
---@class EnumerationEntry
---@field documentation? string An optional documentation.
---@field name string The name of the enum item.
---@field proposed? boolean Whether this is a proposed enumeration entry. If omitted, the enumeration entry is final.
---@field since? string Since when (release number) this enumeration entry is available. Is undefined if not known.
---@field value string | float The value.
---@alias Name "string" | "integer" | "uinteger"
---@class EnumerationType
---@field kind "enumeration"
---@field name Name
---@class IntegerLiteralType
---@field kind "integer" Represents an integer literal type (e.g. `kind: 1`).
---@field value float
---@alias Name1 "URI" | "DocumentUri" | "string" | "integer"
---@class MapKeyTypeItem
---@field kind TypeKind
---@field name Name1
---@alias MessageDirection "clientToServer" | "serverToClient" | "both"
---@class MetaData
---@field version string The protocol version.
---@class ReferenceType
---@field kind "reference"
---@field name string
---@class StringLiteralType
---@field kind "stringLiteral"
---@field value string
---@alias TypeKind "base" | "reference" | "array" | "map" | "and" | "or" | "tuple" | "literal" | "stringLiteral" | "integerLiteral" | "booleanLiteral"
---@class BaseType
---@field kind "base"
---@field name BaseTypes
---@class Enumeration
---@field documentation? string An optional documentation.
---@field name string The name of the enumeration.
---@field proposed? boolean Whether this is a proposed enumeration. If omitted, the enumeration is final.
---@field since? string Since when (release number) this enumeration is available. Is undefined if not known.
---@field supportsCustomValues? boolean Whether the enumeration supports custom values (e.g. values which are not part of the set defined in `values`). If omitted no custom values are supported.
---@field type EnumerationType The type of the elements.
---@field values EnumerationEntry[] The enum values.
---- Represents a type that can be used as a key in a map type. If a reference type is used then the type must either resolve to a `string` or `integer` type. (e.g. `type ChangeAnnotationIdentifier === string`).
---@alias MapKeyType MapKeyTypeItem | ReferenceType
---@class AndType
---@field items Type[]
---@field kind "and"
---@class ArrayType
---@field element Type
---@field kind "array"
---@class MapType
---@field key MapKeyType
---@field kind "map"
---@field value Type
---@class MetaModel
---@field enumerations Enumeration[] The enumerations.
---@field metaData MetaData Additional meta data.
---@field notifications Notification[] The notifications.
---@field requests Request[] The requests.
---@field structures Structure[] The structures.
---@field typeAliases TypeAlias[] The type aliases.
---@class Notification
---@field documentation? string An optional documentation;
---@field messageDirection MessageDirection The direction in which this notification is sent in the protocol.
---@field method string The request's method name.
---@field params? Type | Type[] The parameter type(s) if any.
---@field proposed? boolean Whether this is a proposed notification. If omitted the notification is final.
---@field registrationMethod? string Optional a dynamic registration method if it different from the request's method.
---@field registrationOptions? Type Optional registration options if the notification supports dynamic registration.
---@field since? string Since when (release number) this notification is available. Is undefined if not known.
---@class OrType
---@field items Type[]
---@field kind "or"
---@class Property
---@field documentation? string An optional documentation.
---@field name string The property name;
---@field optional? boolean Whether the property is optional. If omitted, the property is mandatory.
---@field proposed? boolean Whether this is a proposed property. If omitted, the structure is final.
---@field since? string Since when (release number) this property is available. Is undefined if not known.
---@field type Type The type of the property
---@class Request
---@field documentation? string An optional documentation;
---@field errorData? Type An optional error data type.
---@field messageDirection MessageDirection The direction in which this request is sent in the protocol.
---@field method string The request's method name.
---@field params? Type | Type[] The parameter type(s) if any.
---@field partialResult? Type Optional partial result type if the request supports partial result reporting.
---@field proposed? boolean Whether this is a proposed feature. If omitted the feature is final.
---@field registrationMethod? string Optional a dynamic registration method if it different from the request's method.
---@field registrationOptions? Type Optional registration options if the request supports dynamic registration.
---@field result Type The result type.
---@field since? string Since when (release number) this request is available. Is undefined if not known.
---@class Structure
---@field documentation? string An optional documentation;
---@field extends? Type[] Structures extended from. This structures form a polymorphic type hierarchy.
---@field mixins? Type[] Structures to mix in. The properties of these structures are `copied` into this structure. Mixins don't form a polymorphic type hierarchy in LSP.
---@field name string The name of the structure.
---@field properties Property[] The properties.
---@field proposed? boolean Whether this is a proposed structure. If omitted, the structure is final.
---@field since? string Since when (release number) this structure is available. Is undefined if not known.
---@class StructureLiteral
---@field documentation? string An optional documentation.
---@field properties Property[] The properties.
---@field proposed? boolean Whether this is a proposed structure. If omitted, the structure is final.
---@field since? string Since when (release number) this structure is available. Is undefined if not known.
---@class StructureLiteralType
---@field kind "literal"
---@field value StructureLiteral
---@class TupleType
---@field items Type[]
---@field kind "tuple"
---@alias Type BaseType | ReferenceType | ArrayType | MapType | AndType | OrType | TupleType | StructureLiteralType | StringLiteralType | IntegerLiteralType | BooleanLiteralType
---@class TypeAlias
---@field documentation? string An optional documentation.
---@field name string The name of the type alias.
---@field proposed? boolean Whether this is a proposed type alias. If omitted, the type alias is final.
---@field since? string Since when (release number) this structure is available. Is undefined if not known.
---@field type Type The aliased type.
---@class Generator
---@field known_objs table<string, Structure | TypeAlias>
---@field known_literals table<StructureLiteral, Structure>
---@field model Model
local Generator = {}
Generator.__index = Generator
function Generator.new(model)
local self = setmetatable({}, Generator)
self.model = model
self.known_objs = {}
self.known_literals = {}
return self
end
---@param obj Structure | TypeAlias | StructureLiteral
---@return Structure | TypeAlias
function Generator:register(obj)
if not obj.name then
if not self.known_literals[obj] then
self.known_literals[obj] = {
name = ("Structure%s"):format(#vim.tbl_keys(self.known_literals)),
documentation = obj.documentation,
properties = obj.properties,
proposed = obj.proposed,
since = obj.since,
extends = {},
mixins = {},
}
end
obj = self.known_literals[obj]
end
print(("Registering %s"):format(obj.name))
if not self.known_objs[obj.name] then
self.known_objs[obj.name] = obj
end
return obj
end
---@param name string
---@return string
function Generator:convert_method_name(name)
local new_name = name:gsub("/", "_"):gsub("%$", "_")
return new_name
end
---@return string
function Generator:type_prefix()
return "nio.lsp.types"
end
---@param orig_name string
---@return string
function Generator:structure_name(orig_name)
return self:type_prefix() .. "." .. orig_name
end
---@param items ReferenceType[]
---@return Structure
function Generator:and_type(items)
local names = vim.tbl_map(function(item)
return item.name
end, items)
local sub_structure = {
name = table.concat(names, "And"),
documentation = "",
extends = items,
properties = {},
mixins = {},
proposed = nil,
since = nil,
}
return sub_structure
end
---@param name Name | Name1
---@return string
function Generator:key_name_type(name)
if name == "URI" then
return self:type_prefix() .. ".URI"
elseif name == "DocumentUri" then
return self:type_prefix() .. ".DocumentUri"
else
return name
end
end
---@param type_ Type | MapKeyType)
---@return string
function Generator:type_name(type_)
if type_.kind == "base" then
local name = type_.name
if name == "integer" or name == "uinteger" then
return "integer"
elseif name == "decimal" then
return "number"
elseif name == "string" then
return "string"
elseif name == "boolean" then
return "boolean"
elseif name == "null" then
return "nil"
else
return self:key_name_type(name)
end
elseif type_.kind == "reference" then
local name = type_.name
return self:structure_name(name)
elseif type_.kind == "array" then
local element = type_.element
return self:type_name(element) .. "[]"
elseif type_.kind == "map" then
local key, value = type_.key, type_.value
if key.kind == "reference" then
return ("table<%s, %s>"):format(self:type_name(key), self:type_name(value))
else
local name = key.name
return ("table<%s, %s>"):format(self:key_name_type(name), self:type_name(value))
end
elseif type_.kind == "and" then
local items = type_.items
local refs = {}
for _, item in ipairs(items) do
if item.kind == "reference" then
refs[#refs + 1] = item
end
end
if #items > #refs then
print(("Discarding non-reference/literal types from AndType"):format())
end
local struc = self:and_type(refs)
self:register(struc)
return self:structure_name(struc.name)
elseif type_.kind == "or" then
local items = type_.items
local names = vim.tbl_map(function(item)
return self:type_name(item)
end, items)
return table.concat(names, "|")
elseif type_.kind == "tuple" then
local items = type_.items
local names = vim.tbl_map(function(item)
return self:type_name(item)
end, items)
return table.concat(names, ",")
elseif type_.kind == "literal" then
local value = type_.value
local struc = self:register(value)
return self:structure_name(struc.name)
elseif type_.kind == "stringLiteral" then
local value = type_.value
return ("'%s'"):format(value)
end
error("Unknown type " .. type_.kind)
end
---@param doc string
---@param multiline boolean
---@return string[]
function Generator:prepare_doc(doc, multiline)
local lines = vim.split(doc, "\n", { trimempty = false, plain = true })
if multiline then
return vim.tbl_map(function(line)
return #line and ("--- %s"):format(line) or "---"
end, lines)
end
return { table.concat(lines, " ") }
end
---@param structure Structure
---@return string[]
function Generator:structure(structure)
local lines = { "" }
if structure.documentation then
vim.list_extend(lines, self:prepare_doc(structure.documentation, true))
end
lines[#lines + 1] = ("---@class %s"):format(self:structure_name(structure.name))
if structure.extends or structure.mixins then
local extends = vim.list_extend(vim.deepcopy(structure.extends or {}), structure.mixins or {})
local names = vim.tbl_map(function(type_)
return self:type_name(type_)
end, extends)
if #names > 0 then
lines[#lines] = lines[#lines] .. " : " .. table.concat(names, ",")
end
end
for _, prop in ipairs(structure.properties) do
local line = ("---@field %s%s %s"):format(
prop.name,
prop.optional and "?" or "",
self:type_name(prop.type)
)
if prop.documentation then
line = line .. " " .. self:prepare_doc(prop.documentation, false)[1]
end
lines[#lines + 1] = line
end
return lines
end
---@param type_alias TypeAlias
---@return string[]
function Generator:type_alias(type_alias)
self:register(type_alias)
return {
("---@alias %s.%s %s"):format(
self:type_prefix(),
type_alias.name,
self:type_name(type_alias.type)
),
}
end
---@param request Request
---@return string[]
function Generator:request(request)
local lines = {}
if request.documentation then
vim.list_extend(lines, self:prepare_doc(request.documentation, true))
end
lines[#lines + 1] = "---@async"
if request.params then
lines[#lines + 1] = ("---@param args %s Arguments to the request"):format(self:type_name(request.params))
end
lines[#lines + 1] = "---@param bufnr integer? Buffer number (0 for current buffer)"
lines[#lines + 1] = "---@param opts? nio.lsp.RequestOpts Options for the request handling"
lines[#lines + 1] = ("---@return %s.ResponseError|nil error The error object in case a request fails."):format(self:type_prefix())
if request.result then
lines[#lines + 1] = (("---@return %s"):format(self:type_name(request.result)))
if not vim.endswith(lines[#lines], "|nil") then
lines[#lines] = lines[#lines] .. "|nil"
end
lines[#lines] = lines[#lines] .. " result The result of the request"
end
lines[#lines + 1] = (
("function LSPRequestClient.%s(%sbufnr, opts) end"):format(
self:convert_method_name(request.method),
request.params and "args, " or ""
)
)
lines[#lines + 1] = ""
return lines
end
---@param notification Notification
---@return string[]
function Generator:notification(notification)
local lines = {}
if notification.documentation then
vim.list_extend(lines, self:prepare_doc(notification.documentation, true))
end
lines[#lines + 1] = "---@async"
if notification.params then
lines[#lines + 1] = (("---@param args %s"):format(self:type_name(notification.params)))
lines[#lines + 1] = (
("function LSPNotifyClient.%s(%s) end"):format(
self:convert_method_name(notification.method),
notification.params and "args" or ""
)
)
lines[#lines + 1] = ""
end
return lines
end
---@param enum Enumeration
---@return string[]
function Generator:enumeration(enum)
local lines = {}
if enum.documentation then
vim.list_extend(lines, self:prepare_doc(enum.documentation, true))
end
lines[#lines + 1] = (
("---@alias %s.%s %s"):format(
self:type_prefix(),
enum.name,
table.concat(
vim.tbl_map(function(val)
return vim.json.encode(val.value)
end, enum.values),
"|"
)
)
)
lines[#lines + 1] = ""
return lines
end
function Generator:generate()
local lines = {
("---Generated on %s"):format(os.date("!%Y-%m-%d-%H:%M:%S GMT")),
"",
"---@class nio.lsp.RequestClient",
"local LSPRequestClient = {}",
"---@class nio.lsp.RequestOpts",
"---@field timeout integer Timeout of request in milliseconds",
"---@class nio.lsp.types.ResponseError",
"---@field code number A number indicating the error type that occurred.",
"---@field message string A string providing a short description of the error.",
"---@field data any A Primitive or Structured value that contains additional information about the error. Can be omitted.",
"",
}
local strucs = {}
vim.list_extend(strucs, self.model.structures)
vim.list_extend(strucs, self.model.typeAliases)
vim.list_extend(strucs, self.model.enumerations)
for _, obj in ipairs(strucs) do
self:register(obj)
end
print("Generating requests")
for _, request in ipairs(self.model.requests) do
if request.messageDirection == "clientToServer" or request.messageDirection == "both" then
vim.list_extend(lines, self:request(request))
end
end
vim.list_extend(lines, {
"---@class nio.lsp.NotifyClient",
"local LSPNotifyClient = {}",
"",
})
print("Generating notifications")
for _, notification in ipairs(self.model.notifications) do
if notification.messageDirection == "clientToServer" or notification.messageDirection == "both"
then
vim.list_extend(lines, self:notification(notification))
end
end
vim.list_extend(lines, {
("---@alias %s string"):format(self:key_name_type("URI")),
("---@alias %s string"):format(self:key_name_type("DocumentUri")),
})
local length = function()
return #vim.tbl_keys(self.known_objs)
end
local last_length = 0
print("Discovering types")
while length() > last_length do
for _, obj in pairs(self.known_objs) do
if obj.properties then
self:structure(obj)
elseif obj.values then
self:enumeration(obj)
else
self:type_alias(obj)
end
end
last_length = length()
end
print("Generating structures")
for _, obj in pairs(self.known_objs) do
if obj.properties then
vim.list_extend(lines, self:structure(obj))
elseif obj.values then
vim.list_extend(lines, self:enumeration(obj))
else
vim.list_extend(lines, self:type_alias(obj))
end
end
print(("Generated %d lines\n"):format(#lines))
return lines
end
local file = assert(io.open("lsp.json"))
local model = vim.json.decode(file:read("*a"))
file:close()
local lines = Generator.new(model):generate()
local out = assert(io.open("lua/nio/lsp-types.lua", "w"))
out:write(table.concat(lines, "\n"))
out:close()
vim.cmd("exit")

3
scripts/style Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
stylua lua tests

20
scripts/test Executable file
View file

@ -0,0 +1,20 @@
#!/bin/bash
tempfile=".test_output.tmp"
if [[ -n $1 ]]; then
nvim --headless --noplugin -u tests/init.vim -c "PlenaryBustedFile $1" | tee "${tempfile}"
else
nvim --headless --noplugin -u tests/init.vim -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/init.vim'}" | tee "${tempfile}"
fi
# Plenary doesn't emit exit code 1 when tests have errors during setup
errors=$(sed 's/\x1b\[[0-9;]*m//g' "${tempfile}" | awk '/(Errors|Failed) :/ {print $3}' | grep -v '0')
rm "${tempfile}"
if [[ -n $errors ]]; then
echo "Tests failed"
exit 1
fi
exit 0

5
stylua.toml Normal file
View file

@ -0,0 +1,5 @@
column_width = 100
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferDouble"

16
test.lua Normal file
View file

@ -0,0 +1,16 @@
local nio = require("nio")
nio.run(function()
local client = nio.lsp.get_clients({ name = "lua_ls" })[1]
local err, response = client.request.textDocument_semanticTokens_full({
textDocument = { uri = vim.uri_from_bufnr(0) },
})
assert(not err, err)
for _, i in pairs(response and response.data or {}) do
print(i)
end
end)

145
tests/control_spec.lua Normal file
View file

@ -0,0 +1,145 @@
local nio = require("nio")
local a = nio.tests
describe("event", function()
a.it("notifies listeners", function()
local event = nio.control.event()
local notified = 0
for _ = 1, 10 do
nio.run(function()
event.wait()
notified = notified + 1
end)
end
event.set()
nio.sleep(10)
assert.equals(10, notified)
end)
a.it("notifies listeners when already set", function()
local event = nio.control.event()
local notified = 0
event.set()
for _ = 1, 10 do
nio.run(function()
event.wait()
notified = notified + 1
end)
end
nio.sleep(10)
assert.equals(10, notified)
end)
end)
describe("future", function()
a.it("provides listeners result", function()
local future = nio.control.future()
local notified = 0
for _ = 1, 10 do
nio.run(function()
local val = future.wait()
notified = notified + val
end)
end
future.set(1)
nio.sleep(10)
assert.equals(10, notified)
end)
a.it("notifies listeners when already set", function()
local future = nio.control.future()
local notified = 0
future.set(1)
for _ = 1, 10 do
nio.run(function()
notified = notified + future.wait()
end)
end
nio.sleep(10)
assert.equals(10, notified)
end)
a.it("raises error for listeners", function()
local future = nio.control.future()
local notified = 0
future.set_error("test")
local success, err = pcall(future.wait)
nio.sleep(10)
assert.False(success)
assert.True(vim.endswith(err, "test"))
end)
end)
describe("queue", function()
a.it("adds and removes items", function()
local queue = nio.control.queue()
queue.put(1)
queue.put(2)
assert.same(queue.size(), 2)
assert.same(1, queue.get())
assert.same(2, queue.get())
assert.same(queue.size(), 0)
end)
a.it("get blocks while empty", function()
local queue = nio.control.queue()
nio.run(function()
nio.sleep(10)
queue.put(1)
end)
assert.same(1, queue.get())
end)
a.it("put blocks while full", function()
local queue = nio.control.queue(1)
nio.run(function()
nio.sleep(10)
queue.get()
end)
queue.put(1)
queue.put(2)
assert.same(2, queue.get())
end)
it("get_nowait errors when empty", function()
local queue = nio.control.queue()
assert.error(queue.get_nowait)
end)
it("put_nowait errors while full", function()
local queue = nio.control.queue(1)
queue.put_nowait(1)
assert.error(function()
queue.put_nowait(2)
end)
end)
end)
describe("semaphore", function()
a.it("only allows permitted number of concurrent accesses", function()
local concurrent = 0
local max_concurrent = 0
local allowed = 3
local semaphore = nio.control.semaphore(allowed)
local worker = function()
semaphore.with(function()
concurrent = concurrent + 1
max_concurrent = math.max(max_concurrent, concurrent)
nio.sleep(10)
concurrent = concurrent - 1
end)
end
local workers = {}
for _ = 1, 10 do
table.insert(workers, worker)
end
nio.gather(workers)
assert.same(max_concurrent, allowed)
end)
end)

1
tests/init.vim Normal file
View file

@ -0,0 +1 @@
source tests/minimal_init.lua

118
tests/init_spec.lua Normal file
View file

@ -0,0 +1,118 @@
local nio = require("nio")
local a = nio.tests
describe("async helpers", function()
a.it("sleep", function()
local start = vim.loop.now()
nio.sleep(10)
local end_ = vim.loop.now()
assert.True(end_ - start >= 10)
end)
a.it("wrap returns values provided to callback", function()
local result
local wrapped = nio.wrap(function(_, _, cb)
cb(1, 2)
end, 3)
nio.run(wrapped, function(_, ...)
result = { ... }
end)
assert.same({ 1, 2 }, result)
end)
a.it("gather returns results", function()
local worker = function(i)
return function()
nio.sleep(100 - (i * 10))
return i
end
end
local workers = {}
for i = 1, 10 do
table.insert(workers, worker(i))
end
local results = nio.gather(workers)
assert.same({ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, results)
end)
a.it("gather raises errors", function()
local worker = function(i)
return function()
if i == 4 then
error("error")
end
nio.sleep(100 - (i * 10))
return i
end
end
local workers = {}
for i = 1, 10 do
table.insert(workers, worker(i))
end
assert.error(function()
nio.gather(workers)
end)
end)
a.it("first returns first result", function()
local worker = function(i)
return function()
nio.sleep(100 - (i * 10))
return i
end
end
local workers = {}
for i = 1, 10 do
table.insert(workers, worker(i))
end
local result = nio.first(workers)
assert.same(10, result)
end)
a.it("first cancels pending tasks", function()
local worker = function(i)
return function()
nio.sleep(100 - (i * 10))
if i ~= 10 then
error("error")
end
return i
end
end
local workers = {}
for i = 1, 10 do
table.insert(workers, worker(i))
end
nio.first(workers)
end)
a.it("first raises errors", function()
local worker = function(i)
return function()
if i == 4 then
error("error")
end
nio.sleep(100 - (i * 10))
return i
end
end
local workers = {}
for i = 1, 10 do
table.insert(workers, worker(i))
end
assert.error(function()
nio.first(workers)
end)
end)
end)

96
tests/lsp_spec.lua Normal file
View file

@ -0,0 +1,96 @@
local nio = require("nio")
local a = nio.tests
describe("lsp client", function()
a.it("sends request and returns result", function()
local expected_result = { "test" }
local expected_params = { a = "b" }
vim.lsp.get_client_by_id = function(id)
return {
request = function(method, params, callback, bufnr)
assert.equals("textDocument/diagnostic", method)
assert.equals(0, bufnr)
assert.same(params, params)
callback(nil, expected_result)
return true, 1
end,
}
end
local client = nio.lsp.client(1)
local err, result =
client.request.textDocument_diagnostic(expected_params, 0, { timeout = 1000 })
assert.same(expected_result, result)
end)
a.it("returns error for request", function()
local params = { a = "b" }
vim.lsp.get_client_by_id = function(id)
return {
request = function(method, params, callback, bufnr)
callback({ message = "error" }, nil)
return true, 1
end,
}
end
local client = nio.lsp.client(1)
local err, result = client.request.textDocument_diagnostic(0, params)
assert.same(err.message, "error")
assert.Nil(result)
end)
a.it("raises error on timeout", function()
vim.lsp.get_client_by_id = function(id)
return {
request = function(method, params, callback, bufnr)
return true, 1
end,
}
end
local client = nio.lsp.client(1)
local err, result = client.request.textDocument_diagnostic({}, 0, { timeout = 10 })
assert.same(err.message, "Request timed out")
assert.Nil(result)
end)
a.it("cancels request on timeout", function()
local cancel_received = false
vim.lsp.get_client_by_id = function(id)
return {
request = function(method, params, callback, bufnr)
if method == "$/cancelRequest" then
cancel_received = true
end
return true, 1
end,
}
end
local client = nio.lsp.client(1)
client.request.textDocument_diagnostic({}, 0, { timeout = 10 })
assert.True(cancel_received)
end)
a.it("raises errors on client shutdown", function()
vim.lsp.get_client_by_id = function(id)
return {
id = id,
request = function(method, params, callback, bufnr)
return false
end,
}
end
local client = nio.lsp.client(1)
local success, err = pcall(client.request.textDocument_diagnostic, {}, 0, { timeout = 10 })
assert.False(success)
assert.Not.Nil(string.find(err, "Client 1 has shut down"))
end)
end)

9
tests/minimal_init.lua Normal file
View file

@ -0,0 +1,9 @@
local lazypath = vim.fn.stdpath("data") .. "/lazy"
vim.notify = print
vim.opt.rtp:append(".")
vim.opt.rtp:append(lazypath .. "/plenary.nvim")
vim.cmd("runtime! plugin/plenary.vim")
vim.opt.swapfile = false
A = function(...)
print(vim.inspect(...))
end

120
tests/tasks_spec.lua Normal file
View file

@ -0,0 +1,120 @@
local tasks = require("nio.tasks")
local nio = require("nio")
local a = nio.tests
describe("task", function()
a.it("provides result in callback", function()
local result
tasks.run(function()
nio.sleep(5)
return "test"
end, function(_, result_)
result = result_
end)
nio.sleep(10)
assert.equals("test", result)
end)
a.it("cancels", function()
local err
local task = tasks.run(function()
nio.sleep(10)
return "test"
end, function(_, err_)
err = err_
end)
task.cancel()
nio.sleep(10)
assert.True(vim.endswith(vim.split(err, "\n")[1], "Task was cancelled"))
end)
a.it("cancels children", function()
local should_be_nil
local task = tasks.run(function()
tasks.run(function()
nio.sleep(10)
should_be_nil = "not nil"
end)
nio.sleep(10)
end)
task.cancel()
nio.sleep(20)
assert.Nil(should_be_nil)
end)
a.it("assigns parent task", function()
local current = tasks.current_task()
local task = tasks.run(function()
return "test"
end)
assert.Not.Nil(task.parent)
assert.equal(current, task.parent)
end)
it("assigns no parent task", function()
local task = tasks.run(function()
return "test"
end)
assert.Nil(task.parent)
end)
a.it("returns error in function", function()
local success, err
tasks.run(function()
error("test")
end, function(success_, err_)
success, err = success_, err_
end)
nio.sleep(10)
assert.False(success)
assert.True(vim.endswith(vim.split(err, "\n")[2], "test"))
end)
a.it("returns error when wrapped function errors", function()
local success, err
local bad_wrapped = tasks.wrap(function()
error("test")
end, 1)
tasks.run(bad_wrapped, function(success_, err_)
success, err = success_, err_
end)
nio.sleep(10)
assert.False(success)
assert.True(vim.endswith(vim.split(err, "\n")[2], "test"))
end)
a.it("pcall returns result", function()
local success, x, y = pcall(function()
return 1, 2
end)
assert.True(success)
assert.equals(1, x)
assert.equals(2, y)
end)
a.it("pcall returns error", function()
local success, err = pcall(function()
error("test")
end)
assert.False(success)
assert.True(vim.endswith(vim.split(err, "\n")[1], "test"))
end)
a.it("pcall returns error when wrapped function errors", function()
local success, err = pcall(tasks.wrap(function(...)
error("test")
end, 1))
nio.sleep(10)
assert.False(success)
assert.True(vim.endswith(vim.split(err, "\n")[1], "test"))
end)
a.it("current task", function()
local current
local task = tasks.run(function()
current = tasks.current_task()
end)
assert.equal(task, current)
end)
end)

31
tests/uv_spec.lua Normal file
View file

@ -0,0 +1,31 @@
local nio = require("nio")
local a = nio.tests
describe("file operations", function()
local path = vim.fn.tempname()
a.after_each(function()
os.remove(path)
end)
a.it("reads a file", function()
local f = assert(io.open(path, "w"))
f:write("test read")
f:close()
local _, file = nio.uv.fs_open(path, "r", 438)
local _, data = nio.uv.fs_read(file, 1024, -1)
nio.uv.fs_close(file)
assert.equals("test read", data)
end)
a.it("writes a file", function()
local _, file = nio.uv.fs_open(path, "w", 438)
nio.uv.fs_write(file, "test write")
nio.uv.fs_close(file)
local file = assert(io.open(path, "r"))
local data = file:read()
file:close()
assert.equals("test write", data)
end)
end)