text.lua 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. -- Text processing functions.
  2. local M = {}
  3. local alphabet = '0123456789ABCDEF'
  4. local atoi = {} ---@type table<string, integer>
  5. local itoa = {} ---@type table<integer, string>
  6. do
  7. for i = 1, #alphabet do
  8. local char = alphabet:sub(i, i)
  9. itoa[i - 1] = char
  10. atoi[char] = i - 1
  11. atoi[char:lower()] = i - 1
  12. end
  13. end
  14. --- Hex encode a string.
  15. ---
  16. --- @param str string String to encode
  17. --- @return string : Hex encoded string
  18. function M.hexencode(str)
  19. local enc = {} ---@type string[]
  20. for i = 1, #str do
  21. local byte = str:byte(i)
  22. enc[2 * i - 1] = itoa[math.floor(byte / 16)]
  23. enc[2 * i] = itoa[byte % 16]
  24. end
  25. return table.concat(enc)
  26. end
  27. --- Hex decode a string.
  28. ---
  29. --- @param enc string String to decode
  30. --- @return string? : Decoded string
  31. --- @return string? : Error message, if any
  32. function M.hexdecode(enc)
  33. if #enc % 2 ~= 0 then
  34. return nil, 'string must have an even number of hex characters'
  35. end
  36. local str = {} ---@type string[]
  37. for i = 1, #enc, 2 do
  38. local u = atoi[enc:sub(i, i)]
  39. local l = atoi[enc:sub(i + 1, i + 1)]
  40. if not u or not l then
  41. return nil, 'string must contain only hex characters'
  42. end
  43. str[(i + 1) / 2] = string.char(u * 16 + l)
  44. end
  45. return table.concat(str), nil
  46. end
  47. --- Sets the indent (i.e. the common leading whitespace) of non-empty lines in `text` to `size`
  48. --- spaces/tabs.
  49. ---
  50. --- Indent is calculated by number of consecutive indent chars.
  51. --- - The first indented, non-empty line decides the indent char (space/tab):
  52. --- - `SPC SPC TAB …` = two-space indent.
  53. --- - `TAB SPC …` = one-tab indent.
  54. --- - Set `opts.expandtab` to treat tabs as spaces.
  55. ---
  56. --- To "dedent" (remove the common indent), pass `size=0`:
  57. --- ```lua
  58. --- vim.print(vim.text.indent(0, ' a\n b\n'))
  59. --- ```
  60. ---
  61. --- To adjust relative-to an existing indent, call indent() twice:
  62. --- ```lua
  63. --- local indented, old_indent = vim.text.indent(0, ' a\n b\n')
  64. --- indented = vim.text.indent(old_indent + 2, indented)
  65. --- vim.print(indented)
  66. --- ```
  67. ---
  68. --- To ignore the final, blank line when calculating the indent, use gsub() before calling indent():
  69. --- ```lua
  70. --- local text = ' a\n b\n '
  71. --- vim.print(vim.text.indent(0, (text:gsub('\n[\t ]+\n?$', '\n'))))
  72. --- ```
  73. ---
  74. --- @param size integer Number of spaces.
  75. --- @param text string Text to indent.
  76. --- @param opts? { expandtab?: number }
  77. --- @return string # Indented text.
  78. --- @return integer # Indent size _before_ modification.
  79. function M.indent(size, text, opts)
  80. vim.validate('size', size, 'number')
  81. vim.validate('text', text, 'string')
  82. vim.validate('opts', opts, 'table', true)
  83. -- TODO(justinmk): `opts.prefix`, `predicate` like python https://docs.python.org/3/library/textwrap.html
  84. opts = opts or {}
  85. local tabspaces = opts.expandtab and (' '):rep(opts.expandtab) or nil
  86. --- Minimum common indent shared by all lines.
  87. local old_indent --[[@type number?]]
  88. local prefix = tabspaces and ' ' or nil -- Indent char (space or tab).
  89. --- Check all non-empty lines, capturing leading whitespace (if any).
  90. --- @diagnostic disable-next-line: no-unknown
  91. for line_ws, extra in text:gmatch('([\t ]*)([^\n]+)') do
  92. line_ws = tabspaces and line_ws:gsub('[\t]', tabspaces) or line_ws
  93. -- XXX: blank line will miss the last whitespace char in `line_ws`, so we need to check `extra`.
  94. line_ws = line_ws .. (extra:match('^%s+$') or '')
  95. if 0 == #line_ws then
  96. -- Optimization: If any non-empty line has indent=0, there is no common indent.
  97. old_indent = 0
  98. break
  99. end
  100. prefix = prefix and prefix or line_ws:sub(1, 1)
  101. local _, end_ = line_ws:find('^[' .. prefix .. ']+')
  102. old_indent = math.min(old_indent or math.huge, end_ or 0)
  103. end
  104. -- Default to 0 if all lines are empty.
  105. old_indent = old_indent or 0
  106. prefix = prefix and prefix or ' '
  107. if old_indent == size then
  108. -- Optimization: if the indent is the same, return the text unchanged.
  109. return text, old_indent
  110. end
  111. local new_indent = prefix:rep(size)
  112. --- Replaces indentation of a line.
  113. --- @param line string
  114. local function replace_line(line)
  115. -- Match the existing indent exactly; avoid over-matching any following whitespace.
  116. local pat = prefix:rep(old_indent)
  117. -- Expand tabs before replacing indentation.
  118. line = not tabspaces and line
  119. or line:gsub('^[\t ]+', function(s)
  120. return s:gsub('\t', tabspaces)
  121. end)
  122. -- Text following the indent.
  123. local line_text = line:match('^' .. pat .. '(.*)') or line
  124. return new_indent .. line_text
  125. end
  126. return (text:gsub('[^\n]+', replace_line)), old_indent
  127. end
  128. return M