From f560613ef6cd3765092925430847a1a14b2708c5 Mon Sep 17 00:00:00 2001 From: JINNOUCHI Yasushi Date: Sun, 30 May 2021 17:58:00 +0900 Subject: [PATCH] feat: move telescope `string` functions into plenary module (#96) --- README.md | 15 +- lua/plenary/strings.lua | 167 ++++++++++++++++++++++ lua/plenary/window/border.lua | 16 ++- tests/plenary/strings_spec.lua | 248 +++++++++++++++++++++++++++++++++ 4 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 lua/plenary/strings.lua create mode 100644 tests/plenary/strings_spec.lua diff --git a/README.md b/README.md index 1b671a32..81ad0ee1 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 @@ -205,4 +219,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/lua/plenary/strings.lua b/lua/plenary/strings.lua new file mode 100644 index 00000000..2b14f62d --- /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_loop() 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_loop() 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 f8db15b2..c447a12a 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/tests/plenary/strings_spec.lua b/tests/plenary/strings_spec.lua new file mode 100644 index 00000000..a528414f --- /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{ + ' -> 13 spaces', + ' 5 spaces -> 0 space', + }, + }, + expected = lines{ + ' -> 13 spaces', + '5 spaces -> 0 space', + }, + }, + { + msg = 'in case tabs are shorter than spaces', + tabstop = 2, + args = { + lines{ + ' -> 0 space', + ' 5spaces -> 1 space', + }, + }, + expected = lines{ + ' -> 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{ + ' -> 2 spaces', + 'Here is no indent.', + ' 4 spaces will remain', + }, + }, + expected = lines{ + ' -> 2 spaces', + 'Here is no indent.', + ' 4 spaces will remain', + }, + }, + { + msg = 'leave_indent = 4', + tabstop = 2, + args = { + lines{ + ' -> 6 spaces', + '0 indent -> 4 spaces', + ' 4 spaces -> 8 spaces', + }, + 4, + }, + expected = lines{ + ' -> 6 spaces', + ' 0 indent -> 4 spaces', + ' 4 spaces -> 8 spaces', + }, + }, + { + msg = 'typical usecase: 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)