Repository: nvim-lua/plenary.nvim
Pull request #109 (2021-03-31 06:11:25)

Plenary list
This PR adds an implementation of python-like lists, with a syntax similar to that of penlight's `pl.List` class. List-like tables are pretty barebones by default imo, so it would be nice to have an implementation that is a little bit more powerful and object oriented, providing basic operations and sintactic sugar. Over time, I plan on extending the implementation even further, wrapping vim's builtin list functions to work with it.

Since the methods are all accessed through the `List` metatable, calling `vim.tbl_islist(List{1,2,3})` yields true.

Basic methods like `index`, `append` and `count` are also provided. I'm unsure as if to implement methods like map, and join, since they seem to be covered by `plenary.functional`.

There is a caveat that you cannot use the `==` operator directly between a List and a normal table, since it's part of lua's design, so an `equal()` method is provided as well as a workaround.

Usage:

```lua
local List = require'plenary.list'

local l1 = List{1, 2, 3}
print(l1) -- [1, 2, 3]

local l2 = l1 * 3
print(l2) -- [[1, 2, 3], [1, 2, 3], [1, 2, 3]]
l1:append(4)
print(l2) -- [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]]

-- Works with any list-like table
print(List{'a', 'b'} .. {'c', 'd'}) -- ['a', 'b', 'c', 'd']
```

tjdevries: I think let's call it `py_list` maybe?
Joakker: Do you mean the class, the module or both?
Conni2461: I should probably mention that i did some work for a Array class that keeps track of the size, because `table.insert` is not that fast and `tbl[idx] = val` is just faster. See https://github.com/Conni2461/plenary.nvim/commit/834056c77385e1a925c4aa2249fb3677a994d892 I just haven't finished it.

Performance diff for scandir on homedir with hidden (100 runs)
|        | scandir master | scandir experimental | fd       |
|--------|----------------|----------------------|----------|
|min:    | 0.907004       | 0.613642             | 0.706125 |
|max:    | 1.284986       | 0.991585             | 0.915934 |
|mean:   | 1.071764       | 0.797159             | 0.747807 |
|median: | 1.082431       | 0.814736             | 0.738394 |
Joakker: Fair enough. Your implementation seems a bit more complete than mine 😅 
Conni2461: `tbl[idx] = val` is only faster if you keep track of the size. Otherwise its the same. 

I don't need to finish my implementation so you can do it and i just port the changes to scandir so its faster :laughing: I would also put it in a `collections` dir or similar. So we can have other stuff as well. e.g. we can move tj linked list from telescope to here.
Joakker: Ah ok 👍🏾 
Joakker: There we go. Some simple methods for iterating forwards and backwards. Let me know what you think 😃 

Conni2461: :+1: Good job. I will try to use this branch. I don't think you need to add a next function. I can emulate this myself. I am also not sure about the size storing, we could do some `__index` magic to make it work. At least that is what i was thinking about when i worked on it. I will look at this more closer pretty soon :)
Joakker: Fair enough. I chose a weak table since we don't need to keep track of the actual instance, and when all other references to it are gone, the entry in lendb gets collected as well
oberblastmeister: I also think that storing the size inside of the list would be better. We don't need weak ref tables because it is a field inside of the list, which seems much simpler. Also, I don't know if hashing a table is a good idea, it might be slower than just hashing a primitive but I am not sure.
oberblastmeister: Should we have a `plenary.structs` for common datastructures? Then it can export different things. For example in the `async_lib` there is a `Deque` implementation. Maybe we could move them all to a common place?
Joakker: `py_list` is already in a directory called `collections` so that could be used. Also, the main reason I use a weakref table is so that if we run it through `vim.tbl_islist()` it returns true. Still, if most people want to have it as a member, I can do that.
oberblastmeister: you could probably create a function that uses `getmetatable` to see if something is of the list class
Joakker: How about there?
Joakker: Sorry about that 😅 
oberblastmeister: Can you also add some docs? Thanks!
Joakker: Gotcha
Joakker: Oh, ok ok
oberblastmeister: Some other methods that would be cool to have, `List:join` would join the list with a string separator, `List:clear` would clear the table, and `List:move` is the same as `table.move`. Also the `pack` and `unpack` methods from lua would also be good to have.
oberblastmeister: I don't think you should add those higher order functions as they are just duplicates of the ones from the iterator library
Joakker: I think I should, in case the user wants to chain them. Like so
```lua
local list1 = List {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}

list1:reverse():filter_indexed(function(i, e)
  return i % 3 == 0 or e % 3 == 0
end):map(function(e)
  return tostring(e) .. ' little puppies'
end):for_each(print)
--[[ result:
0 little puppies
9 little puppies
8 little puppies
6 little puppies
5 little puppies
3 little puppies
2 little puppies
]]
```
This way, you have the choice of chaining the functions like in kotlin or java, or using a functional approach
oberblastmeister: That does the exact same thing as iterators already do. You can chain iterators
Joakker: Oh, I hadn't realized. I'll change it now.

In another note, should we have a method `List.from_iterator(r)` which translates iterators, like ranges to List objects? I see there is an `Iterator:to_list()` and `Iterator:to_listn()` which transforms them into a regular list-like tables.
oberblastmeister: Also the functions you added are probably much slower, they allocate a new table for each function
oberblastmeister: Yes, you can create a `from_iter`. You could also create higher order functions that relate specifically to `List`, such as `partition` which could have the signature `Iter -> (a -> Bool) -> (List, List)`
Joakker: A caveat I've run into, is that you can only use the `partition()` method directly, that is `myList:iter():partition()`, since the method doesn't seem to be preserved between chained calls

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/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/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/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,


Test patch:
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/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)