Repository: nvim-lua/plenary.nvim
Pull request #148 (2021-04-22 19:24:37)

async await 2

oberblastmeister: @lewis6991 @RianFuro @mfussenegger breaking changes!

## changes

### remove `async` and `await` functions.

Async functions can be called normally provided that the root function is wrapped with a coroutine.

### Improved speed

he `async` and `await` functions have been removed because they create closures and thunks every time which can cause significant slowdowns. Every async function would be a closure and calling them would creating a thunk by packing the arguments and then unpack when the thunk is called, which takes significant time. If you want to create a thunk yourself for you can using tools from `plenary/fun.lua`. This can be useful when you want to join the same function but with different arguments.

### Vararg generation

We do a code generation for varargs of up to 16. This way we do not have to pack and unpack or insert a value into the table. This is necessary because you cannot append values to a vararg. See `plenary/vararg/rotate.lua. Rotating 5 arguments is benchmarked 2e7 times. Packing and unpacking takes a massive 13 seconds while the generated vararg functions only take 0.015 seconds.

### Benefits

Benefits of removing `async` and `await` is that you can just call async functions like normal functions, provided the root function is wrapped with a coroutine. This feels much more idiomatic for lua and makes the implementation less [colored](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/). It also shows the power of coroutines, they can yield across almost any boundary. This means you can use functions that take regular functions and pass in an async function. For example, you can pass an async function into `pcall`, and the coroutine will yield through it. Another cool thing is that you can yield through iterators with the exact same syntax. For example:
```lua
-- now iterators just work like normal
for entry in readdir() do
end

-- instead of
local iter = await(readdir())
while true do
  local entry = await(iter())
  if entry == nil then break end
end
```
While other languages have their own [syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of) for awaiting iterators, we can just use the power of lua coroutines! Checkout out https://github.com/nvim-lua/plenary.nvim/pull/150 for the real readdir iterator.

### name changes

Because we are not working with futures anymore and we are just working with functions, some names have changed.
- `run_all`,  `await_all` -> `join`
- channel condvar etc is moved to control.lua
oberblastmeister: The reason I have two libraries is because if I were to delete the old async library it would be a pain to develop because I use both `neogit` and `gitsigns` which both depend on it. My plan was to keep both libraries for a while to allow plugins to migrate without errors and then delete the old library.
oberblastmeister: Yes I understand. I have removed all the compatibility. Are you saying you want me to change the name to `async` instead of `async2`?


Patch:
diff --git a/README.md b/README.md
index 1b671a3..1e4ee71 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ Plug 'nvim-lua/plenary.nvim'
 - `plenary.context_manager`
 - `plenary.test_harness`
 - `plenary.filetype`
+- `plenary.strings`
 
 ### plenary.job
 
@@ -179,6 +180,19 @@ return {
 }
 ```
 
+### plenary.strings
+
+Re-implement VimL funcs to use them in Lua loop.
+
+* `strings.strdisplaywidth`
+* `strings.strcharpart`
+
+And some other funcs are here to deal with common problems.
+
+* `strings.truncate`
+* `strings.align_str`
+* `strings.dedent`
+
 ### plenary.popup
 
 `popup_*` clone of Vim's commands. If it gets good enough, will submit PR to Neovim and write C wrappers
@@ -192,6 +206,23 @@ Window helper functions to wrap some of the more difficult cases. Particularly f
 
 Status: WIP
 
+### plenary.collections
+
+Contains pure lua implementations for various standard collections.
+
+```lua
+local List = require 'plenary.collections.py_list'
+
+local myList = List { 9, 14, 32, 5 }
+
+for i, v in myList:iter() do
+    print(i, v)
+end
+
+```
+
+Status: WIP
+
 ### Troubleshooting
 
 If you're having trouble / things are hanging / other problems:
@@ -205,4 +236,3 @@ This will enable debuggin for the plugin.
 ### plenary.neorocks
 
 DELETED: Please use packer.nvim or other lua-rocks wrapper instead. This no longer exists.
-
diff --git a/data/plenary/filetypes/builtin.lua b/data/plenary/filetypes/builtin.lua
index 600a6f5..9947aba 100644
--- a/data/plenary/filetypes/builtin.lua
+++ b/data/plenary/filetypes/builtin.lua
@@ -3,6 +3,7 @@ local shebang_fts = {
   ['sh'] = 'sh',
   ['bash'] = 'sh',
   ['zsh'] = 'zsh',
+  ['fish'] = 'fish',
   ['python'] = 'python',
   ['python2'] = 'python',
   ['python3'] = 'python',
@@ -32,6 +33,7 @@ return {
     ['coffee'] = 'coffee',
     ['_coffee'] = 'coffee',
     ['nix'] = 'nix',
+    ['erb'] = 'eruby',
   },
   file_name = {
     ['cakefile'] = 'coffee',
diff --git a/lua/plenary/async/api.lua b/lua/plenary/async/api.lua
new file mode 100644
index 0000000..4a7e84a
--- /dev/null
+++ b/lua/plenary/async/api.lua
@@ -0,0 +1,13 @@
+local a = require('plenary.async.async')
+local util = require('plenary.async.util')
+
+return setmetatable({}, {__index = function(t, k)
+  return function(...)
+    -- if we are in a fast event await the scheduler
+    if vim.in_fast_event() then
+      util.scheduler()
+    end
+
+    vim.api[k](...)
+  end
+end})
diff --git a/lua/plenary/async/async.lua b/lua/plenary/async/async.lua
new file mode 100644
index 0000000..490dfad
--- /dev/null
+++ b/lua/plenary/async/async.lua
@@ -0,0 +1,112 @@
+local co = coroutine
+local vararg = require('plenary.vararg')
+local errors = require('plenary.errors')
+local traceback_error = errors.traceback_error
+local f = require('plenary.functional')
+
+local M = {}
+
+---because we can't store varargs
+local function callback_or_next(step, thread, callback, ...)
+  local stat = f.first(...)
+
+  if not stat then
+    error(string.format("The coroutine failed with this message: %s", f.second(...)))
+  end
+
+  if co.status(thread) == "dead" then
+    if callback == nil then return end
+    callback(select(2, ...))
+  else
+    local returned_function = f.second(...)
+    assert(type(returned_function) == "function", "type error :: expected func")
+    local stat, msg = pcall(returned_function, vararg.rotate(step, select(3, ...)))
+    if not stat then
+      error(('Failed to call leaf async function: %s'):format(msg))
+    end
+  end
+end
+
+---Executes a future with a callback when it is done
+---@param async_function Future: the future to execute
+---@param callback function: the callback to call when done
+local execute = function(async_function, callback)
+  assert(type(async_function) == "function", "type error :: expected func")
+
+  local thread = co.create(async_function)
+
+  local step
+  step = function(...)
+    callback_or_next(step, thread, callback, co.resume(thread, ...))
+  end
+
+  step()
+end
+
+local add_leaf_function
+do
+  ---A table to store all leaf async functions
+  _PlenaryLeafTable = setmetatable({}, {
+    __mode = "k",
+  })
+
+  add_leaf_function = function(async_func)
+    assert(_PlenaryLeafTable[async_func] == nil, "Async function should not already be in the table")
+    _PlenaryLeafTable[async_func] = true
+  end
+
+  function M.is_leaf_function(async_func)
+    return _PlenaryLeafTable[async_func] ~= nil
+  end
+end
+
+---Creates an async function with a callback style function.
+---@param func function: A callback style function to be converted. The last argument must be the callback.
+---@param argc number: The number of arguments of func. Must be included.
+---@return function: Returns an async function
+M.wrap = function(func, argc)
+  if type(func) ~= "function" then
+    traceback_error("type error :: expected func, got " .. type(func))
+  end
+
+  if type(argc) ~= "number" then
+    traceback_error("type error :: expected number, got " .. type(argc))
+  end
+
+  local function leaf(...)
+    local nargs = select('#', ...)
+
+    if not (nargs == argc - 1 or nargs == argc) then
+      print(('Expected %s or %s number of arguments, got %s'):format(argc - 1, argc, nargs))
+    end
+
+    if nargs == argc then
+      return func(...)
+    else
+      return co.yield(func, ...)
+    end
+  end
+
+  add_leaf_function(leaf)
+
+  return leaf
+end
+
+---Use this to either run a future concurrently and then do something else
+---or use it to run a future with a callback in a non async context
+---@param async_function function
+---@param callback function
+M.run = function(async_function, callback)
+  if M.is_leaf_function(async_function) then
+    async_function(callback)
+  else
+    execute(async_function, callback)
+  end
+end
+
+---this needs to be fixed
+M.void = function(async_fun)
+  return co.wrap(async_fun)
+end
+
+return M
diff --git a/lua/plenary/async/control.lua b/lua/plenary/async/control.lua
new file mode 100644
index 0000000..094cc22
--- /dev/null
+++ b/lua/plenary/async/control.lua
@@ -0,0 +1,214 @@
+local a = require('plenary.async.async')
+local Deque = require('plenary.async.structs').Deque
+local tbl = require('plenary.tbl')
+
+local M = {}
+
+local Condvar = {}
+Condvar.__index = Condvar
+
+---@class Condvar
+---@return Condvar
+function Condvar.new()
+  return setmetatable({handles = {}}, Condvar)
+end
+
+---`blocks` the thread until a notification is received
+Condvar.wait = a.wrap(function(self, callback)
+  -- not calling the callback will block the coroutine
+  table.insert(self.handles, callback)
+end, 2)
+
+---notify everyone that is waiting on this Condvar
+function Condvar:notify_all()
+  for i, callback in ipairs(self.handles) do
+    callback()
+    self.handles[i] = nil
+  end
+end
+
+---notify randomly one person that is waiting on this Condvar
+function Condvar:notify_one()
+  if #self.handles == 0 then return end
+
+  local idx = math.random(#self.handles)
+  self.handles[idx]()
+  table.remove(self.handles, idx)
+end
+
+M.Condvar = Condvar
+
+local Semaphore = {}
+Semaphore.__index = Semaphore
+
+---@class Semaphore
+---@param initial_permits number: the number of permits that it can give out
+---@return Semaphore
+function Semaphore.new(initial_permits) 
+  vim.validate {
+    initial_permits = {
+      initial_permits,
+      function(n) return n > 0 end,
+      'number greater than 0'
+    }
+  }
+
+  return setmetatable({permits = initial_permits, handles = {}}, Semaphore)
+end
+
+---async function, blocks until a permit can be acquired
+---example:
+---local semaphore = Semaphore.new(1024)
+---local permit = await(semaphore:acquire())
+---permit:forget()
+---when a permit can be acquired returns it
+---call permit:forget() to forget the permit
+Semaphore.acquire = a.wrap(function(self, callback)
+  if self.permits > 0 then
+    self.permits = self.permits - 1
+  else
+    table.insert(self.handles, callback)
+    return
+  end
+
+  local permit = {}
+
+  permit.forget = function(self_permit)
+    self.permits = self.permits + 1
+
+    if self.permits > 0 and #self.handles > 0 then
+      self.permits = self.permits - 1
+      local callback = table.remove(self.handles)
+      callback(self_permit)
+    end
+  end
+
+  callback(permit)
+end, 2)
+
+M.Semaphore = Semaphore
+
+M.channel = {}
+
+---Creates a oneshot channel
+---returns a sender and receiver function
+---the sender is not async while the receiver is
+---@return function, function
+M.channel.oneshot = function()
+  local val = nil
+  local saved_callback = nil
+  local sent = false
+  local received = false
+  local is_single = false
+
+  --- sender is not async
+  --- sends a value which can be nil
+  local sender = function(...)
+    assert(not sent, 'Oneshot channel can only send once')
+    sent = true
+
+    if saved_callback ~= nil then
+      saved_callback(...)
+      return
+    end
+
+    -- optimise for when there is only one or zero argument, no need to pack
+    local nargs = select('#', ...)
+    if nargs == 1 or nargs == 0 then
+      val = ...
+      is_single = true
+    else
+      val = tbl.pack(...)
+    end
+  end
+
+  --- receiver is async
+  --- blocks until a value is received
+  local receiver = a.wrap(function(callback)
+    assert(not received, 'Oneshot channel can only receive one value!')
+
+    if sent then
+      received = true
+      if is_single then
+        return callback(val)
+      else
+        return callback(tbl.unpack(val))
+      end
+    else
+      saved_callback = callback
+    end
+  end, 1)
+
+  return sender, receiver
+end
+
+---A counter channel.
+---Basically a channel that you want to use only to notify and not to send any actual values.
+---@return function: sender
+---@return function: receiver
+M.channel.counter = function()
+  local counter = 0
+  local condvar = Condvar.new()
+
+  local Sender = {}
+
+  function Sender:send()
+    counter = counter + 1
+    condvar:notify_all()
+  end
+
+  local Receiver = {}
+
+  Receiver.recv = function()
+    if counter == 0 then
+      await(condvar:wait())
+    end
+    counter = counter - 1
+  end
+
+  Receiver.last = function()
+    if counter == 0 then
+      await(condvar:wait())
+    end
+    counter = 0
+  end
+
+  return Sender, Receiver
+end
+
+---A multiple producer single consumer channel
+---@return table
+---@return table
+M.channel.mpsc = function()
+  local deque = Deque.new()
+  local condvar = Condvar.new()
+
+  local Sender = {}
+
+  function Sender.send(...)
+    deque:pushleft({...})
+    condvar:notify_all()
+  end
+
+  local Receiver = {}
+
+  Receiver.recv = function()
+    if deque:is_empty() then
+      condvar:wait()
+    end
+    return unpack(deque:popright())
+  end
+
+  Receiver.last = function()
+    if deque:is_empty() then
+      condvar:wait()
+    end
+    local val = deque:popright()
+    deque:clear()
+    return unpack(val)
+  end
+
+  return Sender, Receiver
+end
+
+return M
diff --git a/lua/plenary/async/init.lua b/lua/plenary/async/init.lua
new file mode 100644
index 0000000..93a08a0
--- /dev/null
+++ b/lua/plenary/async/init.lua
@@ -0,0 +1,42 @@
+---@brief [[
+--- NOTE: This API is still under construction.
+---         It may change in the future :)
+---@brief ]]
+
+local exports = require('plenary.async.async')
+exports.uv = require('plenary.async.uv_async')
+exports.util = require('plenary.async.util')
+exports.lsp = require('plenary.async.lsp')
+exports.api = require('plenary.async.api')
+exports.tests = require('plenary.async.tests')
+exports.control = require('plenary.async.control')
+
+exports.tests.add_globals = function()
+  a = exports
+  async = exports.async
+  await = exports.await
+  await_all = exports.await_all
+
+  -- must prefix with a or stack overflow, plenary.test harness already added it
+  a.describe = exports.tests.describe
+  -- must prefix with a or stack overflow
+  a.it = exports.tests.it
+end
+
+exports.tests.add_to_env = function()
+  local env = getfenv(2)
+
+  env.a = exports
+  env.async = exports.async
+  env.await = exports.await
+  env.await_all = exports.await_all
+
+  -- must prefix with a or stack overflow, plenary.test harness already added it
+  env.a.describe = exports.tests.describe
+  -- must prefix with a or stack overflow
+  env.a.it = exports.tests.it
+
+  setfenv(2, env)
+end
+
+return exports
diff --git a/lua/plenary/async/lsp.lua b/lua/plenary/async/lsp.lua
new file mode 100644
index 0000000..c0271cd
--- /dev/null
+++ b/lua/plenary/async/lsp.lua
@@ -0,0 +1,12 @@
+local a = require('plenary.async.async')
+
+local M = {}
+
+---This will be deprecated because the callback can be called multiple times.
+---This will give a coroutine error because the coroutine will be resumed multiple times.
+---Please use buf_request_all instead.
+M.buf_request = a.wrap(vim.lsp.buf_request, 4)
+
+M.buf_request_all = a.wrap(vim.lsp.buf_request_all, 4)
+
+return M
diff --git a/lua/plenary/async/structs.lua b/lua/plenary/async/structs.lua
new file mode 100644
index 0000000..a5c2f97
--- /dev/null
+++ b/lua/plenary/async/structs.lua
@@ -0,0 +1,112 @@
+local M = {}
+
+Deque = {}
+Deque.__index = Deque
+
+---@class Deque
+---A double ended queue
+---
+---@return Deque
+function Deque.new()
+  -- the indexes are created with an offset so that the indices are consequtive
+  -- otherwise, when both pushleft and pushright are used, the indices will have a 1 length hole in the middle
+  return setmetatable({first = 0, last = -1}, Deque)
+end
+
+---push to the left of the deque
+---@param value any
+function Deque:pushleft(value)
+  local first = self.first - 1
+  self.first = first
+  self[first] = value
+end
+
+---push to the right of the deque
+---@param value any
+function Deque:pushright(value)
+  local last = self.last + 1
+  self.last = last
+  self[last] = value
+end
+
+---pop from the left of the deque
+---@return any
+function Deque:popleft()
+  local first = self.first
+  if first > self.last then return nil end
+  local value = self[first]
+  self[first] = nil        -- to allow garbage collection
+  self.first = first + 1
+  return value
+end
+
+---pops from the right of the deque
+---@return any
+function Deque:popright()
+  local last = self.last
+  if self.first > last then return nil end
+  local value = self[last]
+  self[last] = nil         -- to allow garbage collection
+  self.last = last - 1
+  return value
+end
+
+---checks if the deque is empty
+---@return boolean
+function Deque:is_empty()
+  return self:len() == 0
+end
+
+---returns the number of elements of the deque
+---@return number
+function Deque:len()
+  return self.last - self.first + 1
+end
+
+---returns and iterator of the indices and values starting from the left
+---@return function
+function Deque:ipairs_left()
+  local i = self.first
+
+  return function()
+    local res = self[i]
+    local idx = i
+
+    if res then
+      i = i + 1
+
+      return idx, res
+    end
+  end
+end
+
+---returns and iterator of the indices and values starting from the right
+---@return function
+function Deque:ipairs_right()
+  local i = self.last
+
+  return function()
+    local res = self[i]
+    local idx = i
+
+    if res then
+      i = i - 1 -- advance the iterator before we return
+
+      return idx, res
+    end
+  end
+end
+
+---removes all values from the deque
+---@return nil
+function Deque:clear()
+  for i, _ in self:ipairs_left() do
+    self[i] = nil
+  end
+  self.first = 0
+  self.last = -1
+end
+
+M.Deque = Deque
+
+return M
diff --git a/lua/plenary/async/util.lua b/lua/plenary/async/util.lua
new file mode 100644
index 0000000..684e0c1
--- /dev/null
+++ b/lua/plenary/async/util.lua
@@ -0,0 +1,130 @@
+local a = require('plenary.async.async')
+local await = a.await
+local async = a.async
+local vararg = require('plenary.vararg')
+local uv = vim.loop
+-- local control = a.control
+local control = require('plenary.async.control')
+local channel = control.channel
+
+local M = {}
+
+local defer_swapped = function(timeout, callback)
+  vim.defer_fn(callback, timeout)
+end
+
+---Sleep for milliseconds
+---@param ms number
+M.sleep = a.wrap(defer_swapped, 2)
+
+---This will COMPLETELY block neovim
+---please just use a.run unless you have a very special usecase
+---for example, in plenary test_harness you must use this
+---@param async_function Future
+---@param timeout number: Stop blocking if the timeout was surpassed. Default 2000.
+M.block_on = function(async_function, timeout)
+  async_function = M.protected(async_function)
+
+  local stat, ret
+
+  a.run(async_function, function(_stat, ...)
+    stat = _stat
+    ret = {...}
+  end)
+
+  local function check()
+    if stat == false then
+      error("Blocking on future failed " .. unpack(ret))
+    end
+    return stat == true
+  end
+
+  if not vim.wait(timeout or 2000, check, 20, false) then
+    error("Blocking on future timed out or was interrupted")
+  end
+
+  return unpack(ret)
+end
+
+M.will_block = function(async_func)
+  return function()
+    M.block_on(async_func)
+  end
+end
+
+M.join = function(async_fns)
+  local len = #async_fns
+  local results = {}
+  local done = 0
+
+  local tx, rx = channel.oneshot()
+
+  for i, async_fn in ipairs(async_fns) do
+    assert(type(async_fn) == "function", "type error :: future must be function")
+
+    local cb = function(...)
+      results[i] = {...}
+      done = done + 1
+      if done == len then
+        tx()
+      end
+    end
+
+    a.run(async_fn, cb)
+  end
+
+  rx()
+
+  return results
+end
+
+---Returns a future that when run will select the first async_function that finishes
+---@param async_funs table: The async_function that you want to select
+---@return ...
+M.run_first = a.wrap(function(async_funs, step)
+  local ran = false
+
+  for _, future in ipairs(async_funs) do
+    assert(type(future) == "function", "type error :: future must be function")
+
+    local callback = function(...)
+      if not ran then
+        ran = true
+        step(...)
+      end
+    end
+
+    future(callback)
+  end
+end, 2)
+
+M.run_all = function(async_fns, callback)
+  a.run(function()
+    M.join(async_fns)
+  end, callback)
+end
+
+function M.apcall(async_fn, ...)
+  if a.is_leaf_function(async_fn) then
+    local tx, rx = channel.oneshot()
+    local stat, ret = pcall(async_fn, vararg.rotate(tx, ...))
+    if not stat then
+      return stat, ret
+    else
+      return stat, rx()
+    end
+  else
+    return pcall(async_fn, ...)
+  end
+end
+
+function M.protected(async_fn)
+  return function()
+    return M.apcall(async_fn)
+  end
+end
+
+---An async function that when called will yield to the neovim scheduler to be able to call the api.
+M.scheduler = a.wrap(vim.schedule, 1)
+
+return M
diff --git a/lua/plenary/async/uv_async.lua b/lua/plenary/async/uv_async.lua
new file mode 100644
index 0000000..66f3b2c
--- /dev/null
+++ b/lua/plenary/async/uv_async.lua
@@ -0,0 +1,82 @@
+local a = require('plenary.async.async')
+local uv = vim.loop
+
+local M = {}
+
+local function add(name, argc)
+  local success, ret = pcall(a.wrap, uv[name], argc)
+
+  if not success then
+    error("Failed to add function with name " .. name)
+  end
+
+  M[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)
+-- add('fs_opendir', 3) -- TODO: fix this one
+add('fs_readdir', 2)
+add('fs_closedir', 2)
+-- 'fs_statfs',
+
+-- 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)
+
+-- tcp
+add('tcp_connect', 4)
+-- 'tcp_close_reset',
+
+-- pipe
+add('pipe_connect', 3)
+
+-- udp
+add('udp_send', 5)
+add('udp_recv_start', 2)
+
+-- fs event (wip make into async await event)
+-- fs poll event (wip make into async await event)
+
+-- dns
+add('getaddrinfo', 4)
+add('getnameinfo', 2)
+
+return M
diff --git a/lua/plenary/collections/py_list.lua b/lua/plenary/collections/py_list.lua
new file mode 100644
index 0000000..06d9bec
--- /dev/null
+++ b/lua/plenary/collections/py_list.lua
@@ -0,0 +1,363 @@
+---@brief [[
+--- This module implements python-like lists. It can be used like so:
+--- <pre>
+---     local List = require 'plenary.collections.py_list'
+---     local l = List{3, 20, 44}
+---     print(l)  -- [3, 20, 44]
+--- </pre>
+---@brief ]]
+local List = {}
+
+---@class List @The base class for all list objects
+
+---List constructor. Can be used in higher order functions
+---@param  tbl table: A list-like table containing the initial elements of the list
+---@return List: A new list object
+function List.new(tbl)
+  if type(tbl) == 'table' then
+    local len = #tbl
+    local obj = setmetatable(tbl, List)
+    obj._len = len
+    return obj
+  end
+  error 'List constructor must be called with table argument'
+end
+
+--- Checks whether the argument is a List object
+--- @param tbl table: The object to test
+--- @return boolean: Whether tbl is an instance of List
+function List.is_list(tbl)
+  local meta = getmetatable(tbl) or {}
+  return meta == List
+end
+
+function List:__index(key)
+  if self ~= List then
+    local field = List[key]
+    if field then return field end
+  end
+end
+
+-- TODO: Similar to python, use [...] if the table references itself --
+function List:__tostring()
+  local elements = self:join ', '
+  return '[' .. elements .. ']'
+end
+
+function List:__eq(other)
+  if #self ~= #other then return false end
+  for i = 1, #self do if self[i] ~= other[i] then return false end end
+  return true
+end
+
+function List:__mul(other)
+  local result = List.new {}
+  for i = 1, other do result[i] = self end
+  return result
+end
+
+function List:__len()
+  return self._len
+end
+
+function List:__concat(other)
+  return self:concat(other)
+end
+
+--- Pushes the element to the end of the list
+--- @param other any: The object to append
+--- @see List.pop
+function List:push(other)
+  self[#self + 1] = other
+  self._len = self._len + 1
+end
+
+--- Pops the last element off the list and returns it
+--- @return any: The (previously) last element from the list
+--- @see List.push
+function List:pop()
+  local result = table.remove(self)
+  self._len = self._len - 1
+  return result
+end
+
+--- Inserts other into the specified idx
+--- @param idx number: The index that other will be inserted to
+--- @param other any: The element to insert
+--- @see List.remove
+function List:insert(idx, other)
+  table.insert(self, idx, other)
+  self._len = self._len + 1
+end
+
+--- Removes the element at index idx and returns it
+--- @param idx number: The index of the element to remove
+--- @return any: The element previously at index idx
+--- @see List.insert
+function List:remove(idx)
+  self._len = self._len - 1
+  return table.remove(self, idx)
+end
+
+--- Can be used to compare elements with any list-like table. It only checks for
+--- shallow equality
+--- @param other any: The element to test for
+--- @return boolean: True if other is a list object and all it's elements are equal
+--- @see List.deep_equal
+function List:equal(other)
+  return self:__eq(other)
+end
+
+--- Checks for deep equality between lists. This uses vim.deep_equal for testing
+--- @param other any: The element to test for
+--- @return boolean: True if all elements and their children are equal
+--- @see List.equal
+--- @see vim.deep_equal
+function List:deep_equal(other)
+  return vim.deep_equal(self, other)
+end
+
+--- Returns a copy of the list with elements between a and b, inclusive
+--- <pre>
+---     local list = List{1, 2, 3, 4}
+---     local slice = list:slice(2, 3)
+---     print(slice) -- [2, 3]
+--- </pre>
+--- @param a number: The low end of the slice
+--- @param b number: The high end of the slice
+--- @return List: A list with elements between a and b
+function List:slice(a, b)
+  return List.new(vim.list_slice(self, a, b))
+end
+
+--- Similar to slice, but with every element. It only makes a shallow copy
+--- @return List: A slice from 1 to #self, i.e., a complete copy of the list
+--- @see List.deep_copy
+function List:copy()
+  return self:slice(1, #self)
+end
+
+--- Similar to copy, but makes a deep copy instead
+--- @return List: A deep copy of the object
+--- @see List.copy
+--- @see vim.deep_copy
+function List:deep_copy()
+  return vim.deep_copy(self)
+end
+
+--- Reverses the list in place. If you don't want this, you could do something
+--- like this
+--- <pre>
+---     local list = List{1, 2, 3, 4}
+---     local reversed = list:copy():reverse()
+--- </pre>
+--- @return List: The list itself, so you can chain method calls
+--- @see List.copy
+--- @see List.deep_copy
+function List:reverse()
+  local n = #self
+  local i = 1
+  while i < n do
+    self[i], self[n] = self[n], self[i]
+    i = i + 1
+    n = n - 1
+  end
+  return self
+end
+
+--- Concatenates the elements whithin the list separated by the given string
+--- <pre>
+---     local list = List{1, 2, 3, 4}
+---     print(list:join('-'))  -- 1-2-3-4
+--- </pre>
+--- @param sep string: The separator to place between the elements. Default ''
+--- @return string: The elements in the list separated by sep
+function List:join(sep)
+  sep = sep or ''
+  local result = ''
+  for i, v in self:iter() do
+    result = result .. tostring(v)
+    if i ~= #self then result = result .. sep end
+  end
+  return result
+end
+
+--- Returns a list with the elements of self concatenated with those in the
+--- given arguments
+--- @vararg table|List: The sequences to concatenate to this one
+--- @return List
+function List:concat(...)
+  local result = self:copy()
+  local others = {...}
+  for _, other in ipairs(others) do
+    for _, v in ipairs(other) do result:push(v) end
+  end
+  return result
+end
+
+--- Moves the elements between from and from+len in self, to positions between
+--- to and to+len in other, like so
+--- <pre>
+---     other[to], other[to+1]... other[to+len] = self[from], self[from+1]... self[from+len]
+--- </pre>
+--- @param from number: The first index of the origin slice
+--- @param len number: The length of the slices
+--- @param to number: The first index of the destination slice
+--- @param other table|List: The destination list. Defaults to self
+--- @see table.move
+function List:move(from, len, to, other)
+  return table.move(self, from, len, to, other)
+end
+
+--- Packs the given elements into a list. Similar to lua 5.3's table.pack
+--- @vararg any: The elements to pack
+--- @return List: a list containing all the given elements
+--- @see table.pack
+function List.pack(...)
+  return List.new {...}
+end
+
+--- Unpacks the elements from this list and returns them
+--- @return ...any: All the elements from self[1] to self[#self]
+function List:unpack()
+  return unpack(self, 1, #self)
+end
+
+-- Iterator stuff
+
+local Iter = require 'plenary.iterators'
+
+local itermetatable = getmetatable(Iter:wrap())
+
+local function forward_list_gen(param, state)
+  state = state + 1
+  local v = param[state]
+  if v ~= nil then return state, v end
+end
+
+local function backward_list_gen(param, state)
+  state = state - 1
+  local v = param[state]
+  if v ~= nil then return state, v end
+end
+
+--- Run the given predicate through all the elements pointed by this iterator,
+--- and classify them into two lists. The first one holds the elements for which
+--- predicate returned a truthy value, and the second holds the rest. For
+--- example:
+---
+--- <pre>
+---     local list = List{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
+---     local evens, odds = list:iter():partition(function(e)
+---         return e % 2 == 0
+---     end)
+---     print(evens, odds)
+--- </pre>
+---
+--- Would print
+---
+--- <pre>
+---     [0, 2, 4, 6, 8] [1, 3, 5, 7, 9]
+--- </pre>
+---@param predicate function: The predicate to classify the elements
+---@return List,List
+local function partition(self, predicate)
+  local list1, list2 = List.new {}, List.new {}
+  for _, v in self do
+    if predicate(v) then
+      list1:push(v)
+    else
+      list2:push(v)
+    end
+  end
+  return list1, list2
+end
+
+local function wrap_iter(f, l, n)
+  local iter = Iter.wrap(f, l, n)
+  iter.partition = partition
+  return iter
+end
+
+--- Counts the occurrences of e inside the list
+--- @param e any: The element to test for
+--- @return number: The number of occurrences of e
+function List:count(e)
+  local count = 0
+  for _, v in self:iter() do if e == v then count = count + 1 end end
+  return count
+end
+
+--- Appends the elements in the given iterator to the list
+--- @param other table: An iterator object
+function List:extend(other)
+  if type(other) == 'table' and getmetatable(other) == itermetatable then
+    for _, v in other do self:push(v) end
+  else
+    error 'Argument must be an iterator'
+  end
+end
+
+--- Checks whether there is an occurence of the given element in the list
+--- @param e any: The object to test for
+--- @return boolean: True if e is present
+function List:contains(e)
+  for _, v in self:iter() do if v == e then return true end end
+  return false
+end
+
+--- Creates an iterator for the list. For example:
+--- <pre>
+---     local list = List{8, 4, 7, 9}
+---     for i, v in list:iter() do
+---         print(i, v)
+---     end
+--- </pre>
+--- Would print:
+--- <pre>
+---     1    8
+---     2    4
+---     3    7
+---     4    9
+--- </pre>
+--- @return table: An iterator object
+function List:iter()
+  return wrap_iter(forward_list_gen, self, 0)
+end
+
+--- Creates a reverse iterator for the list. For example:
+--- <pre>
+---     local list = List{8, 4, 7, 9}
+---     for i, v in list:riter() do
+---         print(i, v)
+---     end
+--- </pre>
+--- Would print:
+--- <pre>
+---     4    9
+---     3    7
+---     2    4
+---     1    8
+--- </pre>
+--- @return table: An iterator object
+function List:riter()
+  return wrap_iter(backward_list_gen, self, #self + 1)
+end
+
+-- Miscellaneous
+
+--- Create a list from the elements pointed at by the given iterator.
+--- @param iter table: An iterator object
+--- @return List
+function List.from_iter(iter)
+  local result = List.new {}
+  for _, v in iter do result:push(v) end
+  return result
+end
+
+return setmetatable({}, {
+  __call = function(_, tbl)
+    return List.new(tbl)
+  end,
+  __index = List,
+})
diff --git a/lua/plenary/fun.lua b/lua/plenary/fun.lua
new file mode 100644
index 0000000..2f46677
--- /dev/null
+++ b/lua/plenary/fun.lua
@@ -0,0 +1,37 @@
+local tbl = require('plenary.tbl')
+
+local M = {}
+
+function M.bind(fn, ...)
+  if select('#', ...) == 1 then
+    local arg = ...
+    return function(...)
+      fn(arg, ...)
+    end
+  end
+
+  local args = tbl.pack(...)
+  return function(...)
+    fn(tbl.unpack(args), ...)
+  end
+end
+
+function M.arify(fn, argc)
+  return function(...)
+    if select('#', ...) ~= argc then
+      error(("Expected %s number of arguments"):format(argc))
+    end
+
+    fn(...)
+  end
+end
+
+function M.create_wrapper(map)
+  return function(to_wrap)
+    return function(...)
+      return map(to_wrap(...))
+    end
+  end
+end
+
+return M
diff --git a/lua/plenary/path.lua b/lua/plenary/path.lua
index 96a0ccf..a7b0802 100644
--- a/lua/plenary/path.lua
+++ b/lua/plenary/path.lua
@@ -341,7 +341,7 @@ local shorten = (function()
     end
   end
   return function(filename)
-    shorten_len(filename, 1)
+    return shorten_len(filename, 1)
   end
 end)()
 
diff --git a/lua/plenary/scandir.lua b/lua/plenary/scandir.lua
index c3b107d..91d6eec 100644
--- a/lua/plenary/scandir.lua
+++ b/lua/plenary/scandir.lua
@@ -80,9 +80,11 @@ local process_item = function(opts, name, typ, current_dir, next_dir, bp, data,
         table.insert(next_dir, entry)
       end
       if opts.add_dirs then
-        if not msp or msp(entry) then
-          table.insert(data, entry)
-          if opts.on_insert then opts.on_insert(entry, typ) end
+        if not giti or interpret_gitignore(giti, bp, entry .. "/") then
+          if not msp or msp(entry) then
+            table.insert(data, entry)
+            if opts.on_insert then opts.on_insert(entry, typ) end
+          end
         end
       end
     else
@@ -293,7 +295,13 @@ local get_username = (function()
 
     return function(tbl, id)
       if tbl[id] then return tbl[id] end
-      local name = ffi.string(ffi.C.getpwuid(id).pw_name)
+      local struct = ffi.C.getpwuid(id)
+      local name
+      if struct == nil then
+        name = tostring(id)
+      else
+        name = ffi.string(struct.pw_name)
+      end
       tbl[id] = name
       return name
     end
@@ -325,7 +333,13 @@ local get_groupname = (function()
 
     return function(tbl, id)
       if tbl[id] then return tbl[id] end
-      local name = ffi.string(ffi.C.getgrgid(id).gr_name)
+      local struct = ffi.C.getgrgid(id)
+      local name
+      if struct == nil then
+        name = tostring(id)
+      else
+        name = ffi.string(struct.gr_name)
+      end
       tbl[id] = name
       return name
     end
diff --git a/lua/plenary/strings.lua b/lua/plenary/strings.lua
new file mode 100644
index 0000000..1fd3e8e
--- /dev/null
+++ b/lua/plenary/strings.lua
@@ -0,0 +1,167 @@
+local path = require('plenary.path').path
+
+local M = {}
+
+M.strdisplaywidth = (function()
+  if jit and path.sep ~= [[\]] then
+    local ffi = require('ffi')
+    ffi.cdef[[
+      typedef unsigned char char_u;
+      int linetabsize_col(int startcol, char_u *s);
+    ]]
+
+    return function(str, col)
+      str = tostring(str)
+      local startcol = col or 0
+      local s = ffi.new('char[?]', #str + 1)
+      ffi.copy(s, str)
+      return ffi.C.linetabsize_col(startcol, s) - startcol
+    end
+  else
+    return function(str, col)
+      str = tostring(str)
+      if vim.in_fast_event() then
+        return #str - (col or 0)
+      end
+      return vim.fn.strdisplaywidth(str, col)
+    end
+  end
+end)()
+
+M.strcharpart = (function()
+  if jit and path.sep ~= [[\]] then
+    local ffi = require('ffi')
+    ffi.cdef[[
+      typedef unsigned char char_u;
+      int utf_ptr2len(const char_u *const p);
+    ]]
+
+    local function utf_ptr2len(str)
+      local c_str = ffi.new('char[?]', #str + 1)
+      ffi.copy(c_str, str)
+      return ffi.C.utf_ptr2len(c_str)
+    end
+
+    return function(str, nchar, charlen)
+      local nbyte = 0
+      if nchar > 0 then
+        while nchar > 0 and nbyte < #str do
+          nbyte = nbyte + utf_ptr2len(str:sub(nbyte + 1))
+          nchar = nchar - 1
+        end
+      else
+        nbyte = nchar
+      end
+
+      local len = 0
+      if charlen then
+        while charlen > 0 and nbyte + len < #str do
+          local off = nbyte + len
+          if off < 0 then
+            len = len + 1
+          else
+            len = len + utf_ptr2len(str:sub(off + 1))
+          end
+          charlen = charlen - 1
+        end
+      else
+        len = #str - nbyte
+      end
+
+      if nbyte < 0 then
+        len = len + nbyte
+        nbyte = 0
+      elseif nbyte > #str then
+        nbyte = #str
+      end
+      if len < 0 then
+        len = 0
+      elseif nbyte + len > #str then
+        len = #str - nbyte
+      end
+
+      return str:sub(nbyte + 1, nbyte + len)
+    end
+  else
+    return function(str, nchar, charlen)
+      if vim.in_fast_event() then
+        return str:sub(nchar + 1, charlen)
+      end
+      return vim.fn.strcharpart(str, nchar, charlen)
+    end
+  end
+end)()
+
+M.truncate = function(str, len, dots)
+  str = tostring(str) -- We need to make sure its an actually a string and not a number
+  dots = dots or '…'
+  if M.strdisplaywidth(str) <= len then
+    return str
+  end
+  local start = 0
+  local current = 0
+  local result = ''
+  local len_of_dots = M.strdisplaywidth(dots)
+  while true do
+    local part = M.strcharpart(str, start, 1)
+    current = current + M.strdisplaywidth(part)
+    if (current + len_of_dots) > len then
+      result = result .. dots
+      break
+    end
+    result = result .. part
+    start = start + 1
+  end
+  return result
+end
+
+M.align_str = function(string, width, right_justify)
+  local str_len = M.strdisplaywidth(string)
+  return right_justify
+    and string.rep(" ", width - str_len)..string
+    or string..string.rep(" ", width - str_len)
+end
+
+M.dedent = function(str, leave_indent)
+  -- Check each line and detect the minimum indent.
+  local indent
+  local info = {}
+  for line in str:gmatch('[^\n]*\n?') do
+    -- It matches '' for the last line.
+    if line ~= '' then
+      local chars, width
+      local line_indent = line:match('^[ \t]+')
+      if line_indent then
+        chars = #line_indent
+        width = M.strdisplaywidth(line_indent)
+        if not indent or width < indent then
+          indent = width
+        end
+      -- Ignore empty lines
+      elseif line ~= '\n' then
+        indent = 0
+      end
+      table.insert(info, {line = line, chars = chars, width = width})
+    end
+  end
+
+  -- Build up the result
+  leave_indent = leave_indent or 0
+  local result = {}
+  for _, i in ipairs(info) do
+    local line
+    if i.chars then
+      local content = i.line:sub(i.chars + 1)
+      local indent_width = i.width - indent + leave_indent
+      line = (' '):rep(indent_width) .. content
+    elseif i.line == '\n' then
+      line = '\n'
+    else
+      line = (' '):rep(leave_indent) .. i.line
+    end
+    table.insert(result, line)
+  end
+  return table.concat(result)
+end
+
+return M
diff --git a/lua/plenary/vararg/init.lua b/lua/plenary/vararg/init.lua
new file mode 100644
index 0000000..d60cbd8
--- /dev/null
+++ b/lua/plenary/vararg/init.lua
@@ -0,0 +1,3 @@
+return {
+  rotate = require('plenary.vararg.rotate'),
+}
diff --git a/lua/plenary/vararg/rotate.lua b/lua/plenary/vararg/rotate.lua
new file mode 100644
index 0000000..d840128
--- /dev/null
+++ b/lua/plenary/vararg/rotate.lua
@@ -0,0 +1,75 @@
+---@brief [[
+---Do not edit this file, it was generated!
+---Provides a function to rotate a lua vararg
+---@brief ]]
+local tbl = require('plenary.tbl')
+
+local rotate_lookup = {}
+
+rotate_lookup[0] = function() return A0 end
+
+rotate_lookup[1] = function(A0) return A0 end
+
+rotate_lookup[2] = function(A0, A1) return A1, A0 end
+
+rotate_lookup[3] = function(A0, A1, A2) return A1, A2, A0 end
+
+rotate_lookup[4] = function(A0, A1, A2, A3) return A1, A2, A3, A0 end
+
+rotate_lookup[5] = function(A0, A1, A2, A3, A4) return A1, A2, A3, A4, A0 end
+
+rotate_lookup[6] = function(A0, A1, A2, A3, A4, A5) return A1, A2, A3, A4, A5,
+                                                           A0 end
+
+rotate_lookup[7] = function(A0, A1, A2, A3, A4, A5, A6)
+    return A1, A2, A3, A4, A5, A6, A0
+end
+
+rotate_lookup[8] = function(A0, A1, A2, A3, A4, A5, A6, A7)
+    return A1, A2, A3, A4, A5, A6, A7, A0
+end
+
+rotate_lookup[9] = function(A0, A1, A2, A3, A4, A5, A6, A7, A8)
+    return A1, A2, A3, A4, A5, A6, A7, A8, A0
+end
+
+rotate_lookup[10] = function(A0, A1, A2, A3, A4, A5, A6, A7, A8, A9)
+    return A1, A2, A3, A4, A5, A6, A7, A8, A9, A0
+end
+
+rotate_lookup[11] = function(A0, A1, A2, A3, A4, A5, A6, A7, A8, A9, A10)
+    return A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A0
+end
+
+rotate_lookup[12] = function(A0, A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11)
+    return A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A0
+end
+
+rotate_lookup[13] = function(A0, A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11,
+                             A12)
+    return A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A0
+end
+
+rotate_lookup[14] = function(A0, A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11,
+                             A12, A13)
+    return A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A0
+end
+
+rotate_lookup[15] = function(A0, A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11,
+                             A12, A13, A14)
+    return A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A0
+end
+
+local function rotate_n(first, ...)
+    local args = tbl.pack(...)
+    args[#args + 1] = first
+    return tbl.unpack(args)
+end
+
+local function rotate(...)
+    local nargs = select('#', ...)
+
+    return (rotate_lookup[nargs] or rotate_n)(...)
+end
+
+return rotate
diff --git a/lua/plenary/window/border.lua b/lua/plenary/window/border.lua
index f8db15b..c447a12 100644
--- a/lua/plenary/window/border.lua
+++ b/lua/plenary/window/border.lua
@@ -1,4 +1,5 @@
 local tbl = require('plenary.tbl')
+local strings = require('plenary.strings')
 
 local Border = {}
 
@@ -29,14 +30,23 @@ function Border._create_lines(content_win_options, border_win_options)
 
   if content_win_options.row > 0 then
     if border_win_options.title then
+      local title_len
       local title = border_win_options.title
-      if title ~= '' then
+      if title == '' then
+        title_len = 0
+      else
+        local len = strings.strdisplaywidth(title)
+        local max_title_width = content_win_options.width - 2
+        if len > max_title_width then
+          title = strings.truncate(title, max_title_width)
+          len = strings.strdisplaywidth(title)
+        end
         title = string.format(" %s ", title)
+        title_len = len + 2
       end
-      local title_len = string.len(title)
 
       local midpoint = math.floor(content_win_options.width / 2)
-      local left_start = midpoint - math.floor(title_len / 2)
+      local left_start = midpoint - math.ceil(title_len / 2)
 
       topline = string.format("%s%s%s%s%s",
         topleft,
diff --git a/scratch/async.lua b/scratch/async.lua
deleted file mode 100644
index 38c7881..0000000
--- a/scratch/async.lua
+++ /dev/null
@@ -1,5 +0,0 @@
-local a = require('plenary.async_lib')
-local async = a.async
-
-async(nil)
--- a.wrap(function() end, nil)
diff --git a/scratch/rotate_bench.lua b/scratch/rotate_bench.lua
new file mode 100644
index 0000000..2b6a9d3
--- /dev/null
+++ b/scratch/rotate_bench.lua
@@ -0,0 +1,21 @@
+local tbl = require('plenary/tbl')
+
+local vararg = require('plenary/vararg')
+local rotate = vararg.rotate
+local bench = require('plenary.profile').benchmark
+
+local function rotate_n(first, ...)
+  local args = tbl.pack(...)
+  args[#args+1] = first
+  return tbl.unpack(args)
+end
+
+local num = 2e7 -- 2e4
+
+print('rotate: ', bench(num, function()
+  local a, b, c, d, e, f, g = rotate(1, 2, 3, 4, 5, 6)
+end))
+
+print('rotate_n: ', bench(num, function()
+  local a, b, c, d, e, f, g = rotate_n(1, 2, 3, 4, 5, 6)
+end))
diff --git a/scripts/update_vararg.py b/scripts/update_vararg.py
new file mode 100755
index 0000000..68f6733
--- /dev/null
+++ b/scripts/update_vararg.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+import os
+import subprocess
+
+def generate_file(name, outpath, **kwargs):
+    from jinja2 import Environment, FileSystemLoader
+    env = Environment(loader=FileSystemLoader('./vararg'))
+    template = env.get_template(name)
+    path = os.path.join(outpath, name)
+    with open(path, 'w') as fp:
+        fp.write(template.render(kwargs))
+    subprocess.run(["lua-format", "-i", path])
+
+if __name__ == '__main__':
+    generate_file('rotate.lua', '../lua/plenary/vararg', amount=16)
diff --git a/scripts/vararg/rotate.lua b/scripts/vararg/rotate.lua
new file mode 100644
index 0000000..43066a9
--- /dev/null
+++ b/scripts/vararg/rotate.lua
@@ -0,0 +1,28 @@
+---@brief [[
+---Do not edit this file, it was generated!
+---Provides a function to rotate a lua vararg
+---@brief ]]
+
+local tbl = require('plenary.tbl')
+
+local rotate_lookup = {}
+
+{% for n in range(0, amount) %}
+  rotate_lookup[{{n}}] = function ({% for n in range(n) %} A{{n}} {{ ", " if not loop.last else "" }} {% endfor %})
+    return {% for n in range(1, n) %} A{{n}}, {% endfor %} A0
+  end
+{% endfor %}
+
+local function rotate_n(first, ...)
+  local args = tbl.pack(...)
+  args[#args+1] = first
+  return tbl.unpack(args)
+end
+
+local function rotate(...)
+  local nargs = select('#', ...)
+
+  return (rotate_lookup[nargs] or rotate_n)(...)
+end
+
+return rotate


Test patch:
diff --git a/lua/plenary/async/tests.lua b/lua/plenary/async/tests.lua
new file mode 100644
index 0000000..26654c6
--- /dev/null
+++ b/lua/plenary/async/tests.lua
@@ -0,0 +1,14 @@
+local a = require('plenary.async.async')
+local util = require('plenary.async.util')
+
+local M = {}
+
+M.describe = function(s, async_func)
+  describe(s, async_func)
+end
+
+M.it = function(s, async_func)
+  it(s, util.will_block(async_func))
+end
+
+return M
diff --git a/lua/plenary/test_harness.lua b/lua/plenary/test_harness.lua
index 35caf63..87aeeb4 100644
--- a/lua/plenary/test_harness.lua
+++ b/lua/plenary/test_harness.lua
@@ -75,7 +75,9 @@ function harness.test_directory(directory, opts)
         string.format('lua require("plenary.busted").run("%s")', p:absolute())
       }
 
-      if opts.minimal_init ~= nil then
+      if opts.minimal ~= nil then
+        table.insert(args, '--noplugin')
+      elseif opts.minimal_init ~= nil then
         table.insert(args, '--noplugin')
 
         table.insert(args, '-u')
diff --git a/tests/plenary/async_lib/channel_spec.lua b/tests/plenary/async/channel_spec.lua
similarity index 57%
rename from tests/plenary/async_lib/channel_spec.lua
rename to tests/plenary/async/channel_spec.lua
index f7da479..ee12a4e 100644
--- a/tests/plenary/async_lib/channel_spec.lua
+++ b/tests/plenary/async/channel_spec.lua
@@ -1,19 +1,21 @@
-require('plenary.async_lib').tests.add_to_env()
-local channel = a.util.channel
+require('plenary.async').tests.add_to_env()
+local channel = a.control.channel
 local eq = assert.are.same
-local protected = a.util.protected
+local apcall = a.util.apcall
 
-a.describe('channel', function()
-  a.describe('oneshot', function()
+describe('channel', function()
+  describe('oneshot', function()
     a.it('should work when rx is used first', function()
       local tx, rx = channel.oneshot()
 
-      a.run(a.future(function()
-        local got = await(rx())
+      a.run(function()
+        local got = rx()
+
         eq("sent value", got)
-      end))
+      end)
 
       tx("sent value")
+
     end)
 
     a.it('should work when tx is used first', function()
@@ -21,39 +23,52 @@ a.describe('channel', function()
 
       tx("sent value")
 
-      a.run(a.future(function()
-        local got = await(rx())
-        eq("sent value", got)
-      end))
+      local got = rx()
+
+      eq("sent value", got)
     end)
 
     a.it('should work with multiple returns', function()
       local tx, rx = channel.oneshot()
 
-      a.run(a.future(function()
-        local got, got2 = await(rx())
+      a.run(function()
+        local got, got2 = rx()
         eq("sent value", got)
         eq("another sent value", got2)
-      end))
+      end)
 
       tx("sent value", "another sent value")
     end)
 
+    a.it('should work when sending a falsey value', function ()
+      local tx, rx = channel.oneshot()
+
+      tx(false)
+
+      local res = rx()
+      eq(res, false)
+
+      local stat, ret = apcall(rx)
+      eq(stat, false)
+      local stat, ret = apcall(rx)
+      eq(stat, false)
+    end)
+
     a.it('should work when sending a nil value', function ()
       local tx, rx = channel.oneshot()
 
       tx(nil)
 
-      local res = await(rx())
+      local res = rx()
       eq(res, nil)
 
-      local stat, ret = await(protected(rx()))
+      local stat, ret = apcall(rx)
       eq(stat, false)
-      local stat, ret = await(protected(rx()))
+      local stat, ret = apcall(rx)
       eq(stat, false)
     end)
 
-    a.it('should block sending mulitple times', function()
+    a.it('should error when sending mulitple times', function()
       local tx, rx = channel.oneshot()
 
       tx()
@@ -61,16 +76,16 @@ a.describe('channel', function()
       eq(stat, false)
     end)
 
-    a.it('should block receiving multiple times', function ()
+    a.it('should block receiving multiple times', function()
       local tx, rx = channel.oneshot()
-      tx()
-      await(rx())
-      local stat = await(protected(rx()))
+      tx(true)
+      rx()
+      local stat = apcall(rx)
       eq(stat, false)
     end)
   end)
 
-  a.describe('counter', function()
+  describe('counter', function()
     a.it('should work', function()
       local tx, rx = channel.counter()
 
@@ -80,15 +95,13 @@ a.describe('channel', function()
 
       local counter = 0
 
-      local recv_stuff = async(function()
+      a.run(function()
         for i = 1, 3 do
-          await(rx.recv())
+          rx.recv()
           counter = counter + 1
         end
       end)
 
-      a.run(recv_stuff())
-
       eq(counter, 3)
     end)
 
@@ -101,15 +114,11 @@ a.describe('channel', function()
 
       local counter = 0
 
-      local recv_stuff = async(function()
-        for i = 1, 3 do
-          await(rx.last())
-          counter = counter + 1
-        end
+      a.run(function()
+        rx.last()
+        counter = counter + 1
       end)
 
-      a.run(recv_stuff())
-
       eq(counter, 1)
     end)
   end)
diff --git a/tests/plenary/async_lib/condvar_spec.lua b/tests/plenary/async/condvar_spec.lua
similarity index 56%
rename from tests/plenary/async_lib/condvar_spec.lua
rename to tests/plenary/async/condvar_spec.lua
index 1793c29..afe2f55 100644
--- a/tests/plenary/async_lib/condvar_spec.lua
+++ b/tests/plenary/async/condvar_spec.lua
@@ -1,20 +1,19 @@
-require('plenary.async_lib').tests.add_to_env()
-local Condvar = a.util.Condvar
+require('plenary.async').tests.add_to_env()
+local Condvar = a.control.Condvar
 local eq = assert.are.same
+local join, run_all = a.util.join, a.util.run_all
 
-a.describe('condvar', function()
+describe('condvar', function()
   a.it('should allow blocking', function()
     local var = false
 
     local condvar = Condvar.new()
 
-    local blocking = async(function()
-      await(condvar:wait())
+    a.run(function()
+      condvar:wait()
       var = true
     end)
 
-    a.run(blocking())
-
     eq(var, false)
 
     condvar:notify_one()
@@ -27,22 +26,24 @@ a.describe('condvar', function()
 
     local condvar = Condvar.new()
 
-    local first = async(function()
-      await(condvar:wait())
+    local first = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    local second = async(function()
-      await(condvar:wait())
+    local second = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    local third = async(function()
-      await(condvar:wait())
+    local third = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    a.run_all { first(), second(), third() }
+    a.run(function()
+      join { first, second, third }
+    end)
 
     eq(0, counter)
 
@@ -64,22 +65,22 @@ a.describe('condvar', function()
 
     local condvar = Condvar.new()
 
-    local first = async(function()
-      await(condvar:wait())
+    local first = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    local second = async(function()
-      await(condvar:wait())
+    local second = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    local third = async(function()
-      await(condvar:wait())
+    local third = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    a.run_all { first(), second(), third() }
+    run_all { first, second, third }
 
     eq(0, counter)
 
@@ -101,22 +102,22 @@ a.describe('condvar', function()
 
     local condvar = Condvar.new()
 
-    local first = async(function()
-      await(condvar:wait())
+    local first = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    local second = async(function()
-      await(condvar:wait())
+    local second = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    local third = async(function()
-      await(condvar:wait())
+    local third = function()
+      condvar:wait()
       counter = counter + 1
-    end)
+    end
 
-    a.run_all { first(), second(), third() }
+    run_all { first, second, third }
 
     eq(0, counter)
 
diff --git a/tests/plenary/async_lib/deque_spec.lua b/tests/plenary/async/deque_spec.lua
similarity index 95%
rename from tests/plenary/async_lib/deque_spec.lua
rename to tests/plenary/async/deque_spec.lua
index ae14f83..3143037 100644
--- a/tests/plenary/async_lib/deque_spec.lua
+++ b/tests/plenary/async/deque_spec.lua
@@ -1,4 +1,4 @@
-local Deque = require('plenary.async_lib.structs').Deque
+local Deque = require('plenary.async.structs').Deque
 local eq = assert.are.same
 
 -- just a helper to create the test deque
diff --git a/tests/plenary/async_lib/read_file_bench.lua b/tests/plenary/async/read_file_bench.lua
similarity index 98%
rename from tests/plenary/async_lib/read_file_bench.lua
rename to tests/plenary/async/read_file_bench.lua
index c4b0d37..a976c73 100644
--- a/tests/plenary/async_lib/read_file_bench.lua
+++ b/tests/plenary/async/read_file_bench.lua
@@ -1,5 +1,5 @@
 -- do not run this for now, the asset was removed
-local a = require('plenary.async_lib')
+local a = require('plenary.async')
 local async = a.async
 local await = a.await
 local await_all = a.await_all
diff --git a/tests/plenary/async_lib/semaphore_spec.lua b/tests/plenary/async/semaphore_spec.lua
similarity index 54%
rename from tests/plenary/async_lib/semaphore_spec.lua
rename to tests/plenary/async/semaphore_spec.lua
index f3df6a4..3c23031 100644
--- a/tests/plenary/async_lib/semaphore_spec.lua
+++ b/tests/plenary/async/semaphore_spec.lua
@@ -1,9 +1,9 @@
-require('plenary.async_lib').tests.add_to_env()
-local Semaphore = a.util.Semaphore
+require('plenary.async').tests.add_to_env()
+local Semaphore = a.control.Semaphore
 
 local eq = assert.are.same
 
-a.describe('semaphore', function()
+describe('semaphore', function()
   a.it('should validate arguments', function()
     local status = pcall(Semaphore.new, -1)
     eq(status, false)
@@ -13,44 +13,44 @@ a.describe('semaphore', function()
   end)
 
   a.it('should acquire a permit if available', function ()
-    local sem = a.util.Semaphore.new(1)
-    local permit = await(sem:acquire())
+    local sem = Semaphore.new(1)
+    local permit = sem:acquire()
     assert(permit ~= nil)
   end)
 
   a.it('should block if no permit is available', function ()
-    local sem = a.util.Semaphore.new(1)
-    await(sem:acquire())
-    
+    local sem = Semaphore.new(1)
+    sem:acquire()
+
     local completed = false
-    local blocking = async(function ()
-      await(sem:acquire())
+    local blocking = function ()
+      sem:acquire()
       completed = true
-    end)
-    a.run(blocking())
+    end
+    a.run(blocking)
 
     eq(completed, false)
   end)
 
   a.it('should give another permit when an acquired permit is released', function ()
-    local sem = a.util.Semaphore.new(1)
-    local permit = await(sem:acquire())
+    local sem = Semaphore.new(1)
+    local permit = sem:acquire()
     permit:forget()
-    local next_permit = await(sem:acquire())
+    local next_permit = sem:acquire()
     assert(next_permit ~= nil)
   end)
 
   a.it('should permit the next waiting client when a permit is released', function () 
-    local sem = a.util.Semaphore.new(1)
-    local permit = await(sem:acquire())
+    local sem = Semaphore.new(1)
+    local permit = sem:acquire()
 
     local completed = false
-    local blocking = async(function ()
-      await(sem:acquire())
+    local blocking = function ()
+      sem:acquire()
       completed = true
-    end)
+    end
 
-    a.run(blocking())
+    a.run(blocking)
     permit:forget()
 
     eq(completed, true)
diff --git a/tests/plenary/async/util_spec.lua b/tests/plenary/async/util_spec.lua
new file mode 100644
index 0000000..b0b0da0
--- /dev/null
+++ b/tests/plenary/async/util_spec.lua
@@ -0,0 +1,50 @@
+require('plenary.async').tests.add_to_env()
+local block_on = a.util.block_on
+local eq = assert.are.same
+local id = a.util.id
+
+describe('async await util', function()
+  describe('block_on', function()
+    a.it('should block_on', function()
+      local fn = function()
+        a.util.sleep(100)
+        return 'hello'
+      end
+
+      local res = fn()
+      eq(res, 'hello')
+    end)
+
+    a.it('should work even when failing', function ()
+      local nonleaf = function()
+        eq(true, false)
+      end
+
+      local stat = pcall(block_on, nonleaf)
+      eq(stat, false)
+    end)
+  end)
+
+  describe('protect', function()
+    a.it('should be able to protect a non-leaf future', function()
+      local nonleaf = function()
+        error("This should error")
+        return 'return'
+      end
+
+      local stat, ret = pcall(nonleaf)
+      eq(false, stat)
+      assert(ret:match("This should error"))
+    end)
+
+    a.it('should be able to protect a non-leaf future that doesnt fail', function()
+      local nonleaf = function()
+        return 'didnt fail'
+      end
+
+      local stat, ret = pcall(nonleaf)
+      eq(stat, true)
+      eq(ret, 'didnt fail')
+    end)
+  end)
+end)
diff --git a/tests/plenary/async_lib/util_spec.lua b/tests/plenary/async_lib/util_spec.lua
deleted file mode 100644
index ccbc1b6..0000000
--- a/tests/plenary/async_lib/util_spec.lua
+++ /dev/null
@@ -1,104 +0,0 @@
-require('plenary.async_lib').tests.add_to_env()
-local block_on = a.util.block_on
-local eq = assert.are.same
-local id = a.util.id
-
-a.describe('async await util', function()
-  a.describe('block_on', function()
-    a.it('should block_on', function()
-      local fn = async(function()
-        await(a.util.sleep(100))
-        return 'hello'
-      end)
-
-      local res = block_on(fn())
-      eq(res, 'hello')
-    end)
-
-    a.it('should work even when failing', function ()
-      local nonleaf = async(function()
-        eq(true, false)
-      end)
-
-      local stat = pcall(block_on, nonleaf())
-      eq(stat, false)
-    end)
-  end)
-
-  a.describe('protect', function()
-    a.it('should be able to protect a non-leaf future', function()
-      local nonleaf = async(function()
-        error("This should error")
-        return 'return'
-      end)
-
-      local stat, ret = await(a.util.protected_non_leaf(nonleaf()))
-      eq(false, stat)
-      assert(ret:match("This should error"))
-    end)
-
-    a.it('should be able to protect a non-leaf future that doesnt fail', function()
-      local nonleaf = async(function()
-        return 'didnt fail'
-      end)
-
-      local stat, ret = await(a.util.protected_non_leaf(nonleaf()))
-      eq(stat, true)
-      eq(ret, 'didnt fail')
-    end)
-
-    a.it('should be able to protect a leaf future', function()
-      local leaf = a.wrap(function(callback)
-        error("This should error")
-        callback()
-      end, 1)
-
-      local stat, ret = await(a.util.protected(leaf()))
-      eq(stat, false)
-      assert(ret:match("This should error") ~= nil)
-    end)
-
-    a.it('should be able to protect a leaf future that doesnt fail', function()
-      local fn = a.wrap(function(callback)
-        callback('didnt fail')
-      end, 1)
-
-      local stat, ret = await(a.util.protected(fn()))
-      eq(stat, true)
-      eq(ret, 'didnt fail')
-    end)
-  end)
-
-  a.describe('timeout', function()
-    a.it('should block one and work', function()
-      local timed_out = await(a.util.timeout(a.util.sleep(1000), 500))
-
-      print('timed out 2:', timed_out)
-
-      assert(timed_out == true)
-    end)
-
-    a.it('should work when timeout is longer', function ()
-      local timed_out = await(a.util.timeout(a.util.sleep(100), 1000))
-
-      print('timed out 1:', timed_out)
-
-      assert(timed_out == false)
-    end)
-  end)
-
-  a.it('id should work', function()
-    eq(await(id(1, 2, 3, 4, 5)), 1, 2, 3, 4, 5)
-  end)
-
-  a.it('yield_now should work', function ()
-    local yield_now = a.util.yield_now
-    yield_now()
-    yield_now()
-    yield_now()
-    yield_now()
-    yield_now()
-    yield_now()
-    yield_now()
-  end)
-end)
diff --git a/tests/plenary/py_list_spec.lua b/tests/plenary/py_list_spec.lua
new file mode 100644
index 0000000..c90485c
--- /dev/null
+++ b/tests/plenary/py_list_spec.lua
@@ -0,0 +1,61 @@
+local List = require('plenary.collections.py_list')
+local Iter = require('plenary.iterators')
+
+describe('List', function()
+  it('should detect whether a value is an instance of List', function()
+    local l = List {1, 2}
+    local n = 42
+    assert(List.is_list(l))
+    assert(not List.is_list(n))
+  end)
+  it('should be equal if all elements are equal', function()
+    local l1 = List {1, 2, 3}
+    local l2 = List {1, 2, 3}
+    local l3 = List {4, 5, 6}
+    assert.are.equal(l1, l2)
+    assert.are_not.equal(l1, l3)
+  end)
+  it('can be concatenated to other list-like tables', function()
+    local l1 = List {1, 2, 3} .. {4}
+    assert.are.equal(l1, List {1, 2, 3, 4})
+  end)
+  it('can create a copy of itself with equal elements', function()
+    local l1 = List {1, 2, 3}
+    local l2 = l1:copy()
+    assert.are.equal(l1, l2)
+  end)
+  it('can create a slice between two indices', function()
+    local l1 = List {1, 2, 3, 4}
+    local l2 = l1:slice(2, 4)
+    assert.are.equal(l2, List {2, 3, 4})
+  end)
+  it('can reverse itself in place', function()
+    local l = List {1, 2, 3, 4}
+    l:reverse()
+    assert.are.equal(l, List {4, 3, 2, 1})
+  end)
+  it('can push elements to itself', function()
+    local l = List {1, 2, 3}
+    l:push(4)
+    assert.are.equal(l, List {1, 2, 3, 4})
+  end)
+  it('can pop the n-th element from itself (last one by default)', function()
+    local l = List {1, 2, 3, 4}
+    local n = l:pop()
+    assert.are.equal(l, List {1, 2, 3})
+    assert.are.equal(n, 4)
+  end)
+  it('can create a list from an iterable', function()
+    local l = List.from_iter(Iter.range(5, 10))
+    assert.are.equal(l, List {5, 6, 7, 8, 9, 10})
+  end)
+  it('can be partitioned based on a predicate', function()
+    local l = List.from_iter(Iter.range(1, 10))
+    local evens, odds = l:iter():partition(
+                            function(e)
+          return e % 2 == 0
+        end)
+    assert.are.equal(evens, List {2, 4, 6, 8, 10})
+    assert.are.equal(odds, List {1, 3, 5, 7, 9})
+  end)
+end)
diff --git a/tests/plenary/rotate_spec.lua b/tests/plenary/rotate_spec.lua
new file mode 100644
index 0000000..44c2f9e
--- /dev/null
+++ b/tests/plenary/rotate_spec.lua
@@ -0,0 +1,18 @@
+local rotate = require('plenary.vararg').rotate
+
+local eq = vim.deep_equal
+
+describe('rotate', function ()
+  it('should rotate varargs', function ()
+    eq({rotate(1, 2, 3)}, {2, 3, 1})
+    eq({rotate(1, 2, 3, 4, 5, 6, 7, 8, 9)}, {2, 3, 4, 5, 6, 7, 8, 9, 1})
+  end)
+
+  it('should rotate none', function ()
+    eq({rotate()}, {})
+  end)
+
+  it('should rotate one', function ()
+    eq({rotate(1)}, {1})
+  end)
+end)
diff --git a/tests/plenary/strings_spec.lua b/tests/plenary/strings_spec.lua
new file mode 100644
index 0000000..a528414
--- /dev/null
+++ b/tests/plenary/strings_spec.lua
@@ -0,0 +1,248 @@
+local strings = require('plenary.strings')
+local eq = assert.are.same
+
+describe('strings', function()
+  describe('strdisplaywidth', function()
+    for _, case in ipairs{
+      {str = 'abcde', expected = {single = 5, double = 5}},
+      -- This space below is a tab (U+0009)
+      {str = 'abc	de', expected = {single = 10, double = 10}},
+      {str = 'アイウエオ', expected = {single = 10, double = 10}},
+      {str = '├─┤', expected = {single = 3, double = 6}},
+      {str = 123, expected = {single = 3, double = 3}},
+    } do
+      for _, ambiwidth in ipairs{'single', 'double'} do
+        local item = type(case.str) == 'string' and '"%s"' or '%s'
+        local msg = ('ambiwidth = %s, '..item..' -> %d'):format(ambiwidth, case.str, case.expected[ambiwidth])
+        local original = vim.o.ambiwidth
+        vim.o.ambiwidth = ambiwidth
+        it('lua: '..msg, function()
+          eq(case.expected[ambiwidth], strings.strdisplaywidth(case.str))
+        end)
+        it('vim: '..msg, function()
+          eq(case.expected[ambiwidth], vim.fn.strdisplaywidth(case.str))
+        end)
+        vim.o.ambiwidth = original
+      end
+    end
+  end)
+
+  describe('strcharpart', function()
+    for _, case in ipairs{
+      {args = {'abcde', 2}, expected = 'cde'},
+      {args = {'abcde', 2, 2}, expected = 'cd'},
+      {args = {'アイウエオ', 2, 2}, expected = 'ウエ'},
+      {args = {'├───┤', 2, 2}, expected = '──'},
+    } do
+      local msg = ('("%s", %d, %s) -> "%s"'):format(case.args[1], case.args[2], tostring(case.args[3]), case.expected)
+      it('lua: '..msg, function()
+        eq(case.expected, strings.strcharpart(unpack(case.args)))
+      end)
+      it('vim: '..msg, function()
+        eq(case.expected, vim.fn.strcharpart(unpack(case.args)))
+      end)
+    end
+  end)
+
+  describe('truncate', function()
+    for _, case in ipairs{
+      {args = {'abcde', 6}, expected = {single = 'abcde', double = 'abcde'}},
+      {args = {'abcde', 5}, expected = {single = 'abcde', double = 'abcde'}},
+      {args = {'abcde', 4}, expected = {single = 'abc…', double = 'ab…'}},
+      {args = {'アイウエオ', 11}, expected = {single = 'アイウエオ', double = 'アイウエオ'}},
+      {args = {'アイウエオ', 10}, expected = {single = 'アイウエオ', double = 'アイウエオ'}},
+      {args = {'アイウエオ', 9}, expected = {single = 'アイウエ…', double = 'アイウ…'}},
+      {args = {'アイウエオ', 8}, expected = {single = 'アイウ…', double = 'アイウ…'}},
+      {args = {'├─┤', 7}, expected = {single = '├─┤', double = '├─┤'}},
+      {args = {'├─┤', 6}, expected = {single = '├─┤', double = '├─┤'}},
+      {args = {'├─┤', 5}, expected = {single = '├─┤', double = '├…'}},
+      {args = {'├─┤', 4}, expected = {single = '├─┤', double = '├…'}},
+      {args = {'├─┤', 3}, expected = {single = '├─┤', double = '…'}},
+      {args = {'├─┤', 2}, expected = {single = '├…', double = '…'}},
+    } do
+      for _, ambiwidth in ipairs{'single', 'double'} do
+        local msg = ('ambiwidth = %s, [%s, %d] -> %s'):format(
+          ambiwidth,
+          case.args[1],
+          case.args[2],
+          case.expected[ambiwidth]
+        )
+        it(msg, function()
+          local original = vim.o.ambiwidth
+          vim.o.ambiwidth = ambiwidth
+          eq(case.expected[ambiwidth], strings.truncate(unpack(case.args)))
+          vim.o.ambiwidth = original
+        end)
+      end
+    end
+  end)
+
+  describe('align_str', function()
+    for _, case in ipairs{
+      {args = {'abcde', 8}, expected = {single = 'abcde   ', double = 'abcde   '}},
+      {args = {'アイウ', 8}, expected = {single = 'アイウ  ', double = 'アイウ  '}},
+      {args = {'├─┤', 8}, expected = {single = '├─┤     ', double = '├─┤  '}},
+      {args = {'abcde', 8, true}, expected = {single = '   abcde', double = '   abcde'}},
+      {args = {'アイウ', 8, true}, expected = {single = '  アイウ', double = '  アイウ'}},
+      {args = {'├─┤', 8, true}, expected = {single = '     ├─┤', double = '  ├─┤'}},
+    } do
+      for _, ambiwidth in ipairs{'single', 'double'} do
+        local msg = ('ambiwidth = %s, [%s, %d, %s] -> "%s"'):format(
+          ambiwidth,
+          case.args[1],
+          case.args[2],
+          tostring(case.args[3]),
+          case.expected[ambiwidth]
+        )
+        it(msg, function()
+          local original = vim.o.ambiwidth
+          vim.o.ambiwidth = ambiwidth
+          eq(case.expected[ambiwidth], strings.align_str(unpack(case.args)))
+          vim.o.ambiwidth = original
+        end)
+      end
+    end
+  end)
+
+  describe('dedent', function()
+    local function lines(t)
+      return table.concat(t, '\n')
+    end
+    for _, case in ipairs{
+      {
+        msg = 'empty string',
+        tabstop = 8,
+        args = {''},
+        expected = '',
+      },
+      {
+        msg = 'in case tabs are longer than spaces',
+        tabstop = 8,
+        args = {
+          lines{
+            '		<Tab><Tab> -> 13 spaces',
+            '     5 spaces -> 0 space',
+          },
+        },
+        expected = lines{
+          '           <Tab><Tab> -> 13 spaces',
+          '5 spaces -> 0 space',
+        },
+      },
+      {
+        msg = 'in case tabs are shorter than spaces',
+        tabstop = 2,
+        args = {
+          lines{
+            '		<Tab><Tab> -> 0 space',
+            '     5spaces -> 1 space',
+          },
+        },
+        expected = lines{
+          '<Tab><Tab> -> 0 space',
+          ' 5spaces -> 1 space',
+        },
+      },
+      {
+        msg = 'ignores empty lines',
+        tabstop = 2,
+        args = {
+          lines{
+            '',
+            '',
+            '',
+            '        8 spaces -> 3 spaces',
+            '',
+            '',
+            '     5 spaces -> 0 space',
+            '',
+            '',
+            '',
+          },
+        },
+        expected = lines{
+          '',
+          '',
+          '',
+          '   8 spaces -> 3 spaces',
+          '',
+          '',
+          '5 spaces -> 0 space',
+          '',
+          '',
+          '',
+        },
+      },
+      {
+        msg = 'no indent',
+        tabstop = 2,
+        args = {
+          lines{
+            '	<Tab> -> 2 spaces',
+            'Here is no indent.',
+            '    4 spaces will remain',
+          },
+        },
+        expected = lines{
+          '  <Tab> -> 2 spaces',
+          'Here is no indent.',
+          '    4 spaces will remain',
+        },
+      },
+      {
+        msg = 'leave_indent = 4',
+        tabstop = 2,
+        args = {
+          lines{
+            '	<Tab> -> 6 spaces',
+            '0 indent -> 4 spaces',
+            '    4 spaces -> 8 spaces',
+          },
+          4,
+        },
+        expected = lines{
+          '      <Tab> -> 6 spaces',
+          '    0 indent -> 4 spaces',
+          '        4 spaces -> 8 spaces',
+        },
+      },
+      {
+        msg = 'typical usecase: <Tab> to 5 spaces',
+        tabstop = 4,
+        args = {
+          lines{
+            '',
+            '		Chapter 1',
+            '',
+            '	  Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed',
+            '	do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+            '',
+            '	  Ut enim ad minim veniam, quis nostrud exercitation ullamco',
+            '	laboris nisi ut aliquip ex ea commodo consequat.',
+            '',
+          },
+          5,
+        },
+        expected = lines{
+          '',
+          '         Chapter 1',
+          '',
+          '       Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed',
+          '     do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+          '',
+          '       Ut enim ad minim veniam, quis nostrud exercitation ullamco',
+          '     laboris nisi ut aliquip ex ea commodo consequat.',
+          '',
+        },
+      },
+    } do
+      local msg = ('tabstop = %d, %s'):format(case.tabstop, case.msg)
+      it(msg, function()
+        local original = vim.bo.tabstop
+        vim.bo.tabstop = case.tabstop
+        eq(case.expected, strings.dedent(unpack(case.args)))
+        vim.bo.tabstop = original
+      end)
+    end
+  end)
+end)