aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim/text.lua
blob: 93220a2e420ec820d403c2d6b7e9891a386ee069 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
-- Text processing functions.

local M = {}

local alphabet = '0123456789ABCDEF'
local atoi = {} ---@type table<string, integer>
local itoa = {} ---@type table<integer, string>
do
  for i = 1, #alphabet do
    local char = alphabet:sub(i, i)
    itoa[i - 1] = char
    atoi[char] = i - 1
    atoi[char:lower()] = i - 1
  end
end

--- Hex encode a string.
---
--- @param str string String to encode
--- @return string : Hex encoded string
function M.hexencode(str)
  local enc = {} ---@type string[]
  for i = 1, #str do
    local byte = str:byte(i)
    enc[2 * i - 1] = itoa[math.floor(byte / 16)]
    enc[2 * i] = itoa[byte % 16]
  end
  return table.concat(enc)
end

--- Hex decode a string.
---
--- @param enc string String to decode
--- @return string? : Decoded string
--- @return string? : Error message, if any
function M.hexdecode(enc)
  if #enc % 2 ~= 0 then
    return nil, 'string must have an even number of hex characters'
  end

  local str = {} ---@type string[]
  for i = 1, #enc, 2 do
    local u = atoi[enc:sub(i, i)]
    local l = atoi[enc:sub(i + 1, i + 1)]
    if not u or not l then
      return nil, 'string must contain only hex characters'
    end
    str[(i + 1) / 2] = string.char(u * 16 + l)
  end
  return table.concat(str), nil
end

--- Sets the indent (i.e. the common leading whitespace) of non-empty lines in `text` to `size`
--- spaces/tabs.
---
--- Indent is calculated by number of consecutive indent chars.
--- - The first indented, non-empty line decides the indent char (space/tab):
---   - `SPC SPC TAB …` = two-space indent.
---   - `TAB SPC …` = one-tab indent.
--- - Set `opts.expandtab` to treat tabs as spaces.
---
--- To "dedent" (remove the common indent), pass `size=0`:
--- ```lua
--- vim.print(vim.text.indent(0, ' a\n  b\n'))
--- ```
---
--- To adjust relative-to an existing indent, call indent() twice:
--- ```lua
--- local indented, old_indent = vim.text.indent(0, ' a\n b\n')
--- indented = vim.text.indent(old_indent + 2, indented)
--- vim.print(indented)
--- ```
---
--- To ignore the final, blank line when calculating the indent, use gsub() before calling indent():
--- ```lua
--- local text = '  a\n  b\n '
--- vim.print(vim.text.indent(0, (text:gsub('\n[\t ]+\n?$', '\n'))))
--- ```
---
--- @param size integer Number of spaces.
--- @param text string Text to indent.
--- @param opts? { expandtab?: number }
--- @return string # Indented text.
--- @return integer # Indent size _before_ modification.
function M.indent(size, text, opts)
  vim.validate('size', size, 'number')
  vim.validate('text', text, 'string')
  vim.validate('opts', opts, 'table', true)
  -- TODO(justinmk): `opts.prefix`, `predicate` like python https://docs.python.org/3/library/textwrap.html
  opts = opts or {}
  local tabspaces = opts.expandtab and (' '):rep(opts.expandtab) or nil

  --- Minimum common indent shared by all lines.
  local old_indent --[[@type number?]]
  local prefix = tabspaces and ' ' or nil -- Indent char (space or tab).
  --- Check all non-empty lines, capturing leading whitespace (if any).
  --- @diagnostic disable-next-line: no-unknown
  for line_ws, extra in text:gmatch('([\t ]*)([^\n]+)') do
    line_ws = tabspaces and line_ws:gsub('[\t]', tabspaces) or line_ws
    -- XXX: blank line will miss the last whitespace char in `line_ws`, so we need to check `extra`.
    line_ws = line_ws .. (extra:match('^%s+$') or '')
    if 0 == #line_ws then
      -- Optimization: If any non-empty line has indent=0, there is no common indent.
      old_indent = 0
      break
    end
    prefix = prefix and prefix or line_ws:sub(1, 1)
    local _, end_ = line_ws:find('^[' .. prefix .. ']+')
    old_indent = math.min(old_indent or math.huge, end_ or 0)
  end
  -- Default to 0 if all lines are empty.
  old_indent = old_indent or 0
  prefix = prefix and prefix or ' '

  if old_indent == size then
    -- Optimization: if the indent is the same, return the text unchanged.
    return text, old_indent
  end

  local new_indent = prefix:rep(size)

  --- Replaces indentation of a line.
  --- @param line string
  local function replace_line(line)
    -- Match the existing indent exactly; avoid over-matching any following whitespace.
    local pat = prefix:rep(old_indent)
    -- Expand tabs before replacing indentation.
    line = not tabspaces and line
      or line:gsub('^[\t ]+', function(s)
        return s:gsub('\t', tabspaces)
      end)
    -- Text following the indent.
    local line_text = line:match('^' .. pat .. '(.*)') or line
    return new_indent .. line_text
  end

  return (text:gsub('[^\n]+', replace_line)), old_indent
end

return M