voyeg3r

Toggle quotes

Aug 20th, 2026 (edited)
55
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 9.35 KB | None | 0 0
  1. -- File: lua/toggle_quotes.lua
  2. -- Last Change: Aug 20 2026
  3. -- Purpose: Alterna aspas ao redor da palavra sob o cursor (normal) ou
  4. --          da seleção visual. Ciclo: 'foo' -> "foo" -> `foo` -> foo.
  5. -- Dependências: SOMENTE Neovim (vim.api nativo). Nenhum plugin externo.
  6. --
  7. -- Como instalar:
  8. --   1. Salve este arquivo como ~/.config/nvim/lua/toggle_quotes.lua
  9. --   2. No seu init.lua adicione:  require('toggle_quotes')
  10. --      (ou, se usa lazy.nvim, o spec no fim do arquivo)
  11. --   3. Reinicie o Neovim. Pronto.
  12.  
  13. local M = {}
  14.  
  15. -- Ponto de entrada: detecta se o usuário está em modo visual e despacha.
  16. -- '\22' é o byte do modo visual em bloco (Ctrl-V), equivalente a '^V'.
  17. function M.surround_quotes()
  18.   local mode = vim.fn.mode()
  19.   if vim.tbl_contains({ 'v', 'V', '\22' }, mode) then
  20.     return M.surround_quotes_visual()
  21.   end
  22.   return M.surround_quotes_normal()
  23. end
  24.  
  25. -- ---------------------------------------------------------------- NORMAL --
  26. -- Alterna as aspas da palavra sob o cursor (funciona mesmo se o cursor
  27. -- estiver no MEIO da palavra). Ciclo das aspas imediatamente adjacentes:
  28. --   'foo' -> "foo" -> `foo` -> foo
  29. -- Se não houver aspas, adiciona aspas simples 'foo'.
  30. function M.surround_quotes_normal()
  31.   local _, col = unpack(vim.api.nvim_win_get_cursor(0))
  32.   local line = vim.api.nvim_get_current_line()
  33.  
  34.   -- Índice do cursor na string (1-based)
  35.   local cur_idx = col + 1
  36.  
  37.   local cur_char = line:sub(cur_idx, cur_idx)
  38.   local prev_char = cur_idx > 1 and line:sub(cur_idx - 1, cur_idx - 1) or ''
  39.   local next_char = cur_idx < #line and line:sub(cur_idx + 1, cur_idx + 1) or ''
  40.  
  41.   -- Se o cursor não está sobre caractere de palavra, tenta ajustar
  42.   -- para o caractere vizinho (para funcionar com cursor em borda).
  43.   if not cur_char:match('[%w_]') then
  44.     if cur_char:match('%s') then
  45.       return -- cursor em espaço: nada a fazer
  46.     elseif prev_char:match('[%w_]') then
  47.       cur_idx = cur_idx - 1 -- cursor logo após a palavra
  48.     elseif next_char:match('[%w_]') then
  49.       cur_idx = cur_idx + 1 -- cursor logo antes da palavra
  50.     else
  51.       return -- nenhuma palavra por perto
  52.     end
  53.   end
  54.  
  55.   -- Expande para o INÍCIO da palavra (start_idx)...
  56.   local start_idx = cur_idx
  57.   while start_idx > 1 and line:sub(start_idx - 1, start_idx - 1):match('[%w_]') do
  58.     start_idx = start_idx - 1
  59.   end
  60.  
  61.   -- ...e para o FIM da palavra (end_idx)
  62.   local end_idx = cur_idx
  63.   while end_idx < #line and line:sub(end_idx + 1, end_idx + 1):match('[%w_]') do
  64.     end_idx = end_idx + 1
  65.   end
  66.  
  67.   local word = line:sub(start_idx, end_idx)
  68.  
  69.   -- Caractere imediatamente antes e depois da palavra
  70.   local before = start_idx > 1 and line:sub(start_idx - 1, start_idx - 1) or ''
  71.   local after = end_idx < #line and line:sub(end_idx + 1, end_idx + 1) or ''
  72.  
  73.   local new_line
  74.   if before == "'" and after == "'" then
  75.     -- 'foo' -> "foo"
  76.     new_line = line:sub(1, start_idx - 2) .. '"' .. word .. '"' .. line:sub(end_idx + 2)
  77.   elseif before == '"' and after == '"' then
  78.     -- "foo" -> `foo`
  79.     new_line = line:sub(1, start_idx - 2) .. '`' .. word .. '`' .. line:sub(end_idx + 2)
  80.   elseif before == '`' and after == '`' then
  81.     -- `foo` -> foo (remove)
  82.     new_line = line:sub(1, start_idx - 2) .. word .. line:sub(end_idx + 2)
  83.   else
  84.     -- foo -> 'foo'
  85.     new_line = line:sub(1, start_idx - 1) .. "'" .. word .. "'" .. line:sub(end_idx + 1)
  86.   end
  87.  
  88.   vim.api.nvim_set_current_line(new_line)
  89. end
  90.  
  91. -- --------------------------------------------------------------- VISUAL --
  92. -- Alterna as aspas ao redor da SELEÇÃO visual (caracteres ou bloco).
  93. -- Mesmo ciclo do modo normal, mas sobre o texto selecionado.
  94. function M.surround_quotes_visual()
  95.   local sel = get_visual_selection_lines()
  96.   if not sel or vim.tbl_isempty(sel.lines) then
  97.     vim.notify('No selection found', vim.log.levels.WARN)
  98.     return
  99.   end
  100.  
  101.   local first_line = sel.lines[1]
  102.   local last_line = sel.lines[#sel.lines]
  103.  
  104.   -- Verifica se a primeira e a última linha da seleção já começam/terminam
  105.   -- com a mesma aspa (ex.: seleção incluiu as aspas).
  106.   local function has_quote(str, quote)
  107.     if #str == 0 then return false end
  108.     local first_byte = str:sub(1, 1)
  109.     local last_byte = str:sub(-1)
  110.     return first_byte == quote and last_byte == quote
  111.   end
  112.  
  113.   local has_single = has_quote(first_line, "'") and has_quote(last_line, "'")
  114.   local has_double = has_quote(first_line, '"') and has_quote(last_line, '"')
  115.   local has_backtick = has_quote(first_line, '`') and has_quote(last_line, '`')
  116.  
  117.   -- Constrói o novo texto com o par de aspas correto do ciclo.
  118.   local new_text
  119.   if has_single and #sel.lines == 1 then
  120.     new_text = '"' .. first_line:sub(2, -2) .. '"' -- '...' -> "..."
  121.   elseif has_single then
  122.     new_text = '"' .. first_line:sub(2) .. '\n' .. last_line:sub(1, -2) .. '"'
  123.   elseif has_double and #sel.lines == 1 then
  124.     new_text = '`' .. first_line:sub(2, -2) .. '`' -- "..." -> `...`
  125.   elseif has_double then
  126.     new_text = '`' .. first_line:sub(2) .. '\n' .. last_line:sub(1, -2) .. '`'
  127.   elseif has_backtick and #sel.lines == 1 then
  128.     new_text = first_line:sub(2, -2) -- `...` -> ...
  129.   elseif has_backtick then
  130.     new_text = first_line:sub(2) .. '\n' .. last_line:sub(1, -2)
  131.   elseif #sel.lines == 1 then
  132.     new_text = "'" .. first_line .. "'" -- ... -> '...'
  133.   else
  134.     new_text = "'" .. first_line .. '\n' .. last_line .. "'"
  135.   end
  136.  
  137.   -- Sai do modo visual antes de editar o buffer.
  138.   local escaped = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
  139.   vim.api.nvim_feedkeys(escaped, 'x', false)
  140.  
  141.   local new_lines = vim.split(new_text, '\n')
  142.  
  143.   -- Converte coluna em caracteres para coluna em BYTES (necessário
  144.   -- para nvim_buf_set_text, que trabalha com índices de byte).
  145.   local function char_to_byte_col(line, char_col)
  146.     if char_col <= 1 then return 0 end
  147.     local max_col = vim.fn.strlen(line) + 1
  148.     char_col = math.min(char_col, max_col)
  149.     return vim.str_byteindex(line, char_col - 1)
  150.   end
  151.  
  152.   local start_byte = char_to_byte_col(sel.raw_lines[1], sel.start_col)
  153.   local end_byte = char_to_byte_col(sel.raw_lines[#sel.raw_lines], sel.end_col)
  154.  
  155.   -- Substitui o texto selecionado pelo novo texto com aspas.
  156.   vim.api.nvim_buf_set_text(0, sel.start_row - 1, start_byte, sel.end_row - 1, end_byte, new_lines)
  157.  
  158.   -- Re-seleciona visualmente (gv) após editar, para o usuário continuar.
  159.   vim.defer_fn(function() vim.cmd('normal! gv') end, 10)
  160. end
  161.  
  162. -- ----------------------------------------------------------- HELPERS --
  163. -- Captura a seleção visual atual de forma confiável.
  164. -- IMPORTANTE: as marks '< e '> só são atualizadas ao SAIR do modo visual,
  165. -- então aqui usamos getpos('v') (início) + getpos('.') (fim do cursor),
  166. -- que funcionam COM a seleção ainda ativa.
  167. -- Suporta modos: caracteres (v), linhas (V) e bloco (^V).
  168. -- Retorna: { lines, raw_lines, start_row, end_row, start_col, end_col, mode }
  169. local function get_visual_selection_lines()
  170.   local mode = vim.fn.mode()
  171.   -- Em modo visual: captura posições do par 'v' (start) e '.' (cursor)
  172.   if vim.tbl_contains({ 'v', 'V', '\22' }, mode) then
  173.     local s_pos = vim.fn.getpos('v')
  174.     local e_pos = vim.fn.getpos('.')
  175.  
  176.     local s_row, s_col = s_pos[2], s_pos[3]
  177.     local e_row, e_col = e_pos[2], e_pos[3]
  178.  
  179.     -- Normaliza a direção (top-to-bottom, left-to-right)
  180.     if s_row > e_row or (s_row == e_row and s_col > e_col) then
  181.       s_row, e_row = e_row, s_row
  182.       s_col, e_col = e_col, s_col
  183.     end
  184.  
  185.     local lines = vim.api.nvim_buf_get_lines(0, s_row - 1, e_row, false)
  186.     if vim.tbl_isempty(lines) then return nil end
  187.  
  188.     local raw_lines = vim.deepcopy(lines)
  189.  
  190.     -- Recorta as linhas para refletir exatamente a seleção.
  191.     if mode == 'v' then
  192.       lines[1] = string.sub(lines[1], s_col)
  193.       if #lines == 1 then
  194.         lines[1] = string.sub(lines[1], 1, e_col - s_col + 1)
  195.       else
  196.         lines[#lines] = string.sub(lines[#lines], 1, e_col)
  197.       end
  198.     elseif mode == '\22' then
  199.       -- Modo bloco: recorta cada linha no intervalo de colunas
  200.       for i, line in ipairs(lines) do
  201.         local len = #line
  202.         local start_c = math.min(s_col, len + 1)
  203.         local end_c = math.min(e_col, len)
  204.         lines[i] = string.sub(line, start_c, end_c)
  205.       end
  206.     end
  207.  
  208.     return {
  209.       lines = lines,
  210.       raw_lines = raw_lines,
  211.       start_row = s_row,
  212.       end_row = e_row,
  213.       start_col = s_col,
  214.       end_col = e_col,
  215.       mode = mode,
  216.     }
  217.   end
  218.   return nil
  219. end
  220.  
  221. -- ---------------------------------------------------------- KEYMAPS --
  222. -- Registra o keymap ao carregar o módulo.
  223. -- Mude "<leader>'" conforme sua preferência.
  224. vim.keymap.set('n', "<leader>'", function()
  225.   require('toggle_quotes').surround_quotes()
  226. end, { desc = 'Toggle quotes (normal / visual)' })
  227.  
  228. -- IMPORTANTE: para usar no modo VISUAL, mapeie também para 'x' e 'v':
  229. -- vim.keymap.set({ 'x', 'v' }, "<leader>'", function()
  230. --   require('toggle_quotes').surround_quotes()
  231. -- end, { desc = 'Toggle quotes (visual)' })
  232.  
  233. -- Alternativa via lazy.nvim (spec no lua/plugins/toggle-quotes.lua):
  234. -- return {
  235. --   dir = '~/.config/nvim/lua', -- ou dir='~/dotfiles/nvim/lua' se versionar
  236. --   name = 'toggle_quotes',
  237. --   lazy = true,
  238. --   init = function() vim.keymap.set('n', "<leader>'", function()
  239. --     require('toggle_quotes').surround_quotes() end, { desc = 'Toggle quotes' }) end,
  240. -- }
  241.  
  242. return M
Tags: lua Quotes Toggle
Advertisement
Add Comment
Please, Sign In to add comment