Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- -- File: lua/toggle_quotes.lua
- -- Last Change: Aug 20 2026
- -- Purpose: Alterna aspas ao redor da palavra sob o cursor (normal) ou
- -- da seleção visual. Ciclo: 'foo' -> "foo" -> `foo` -> foo.
- -- Dependências: SOMENTE Neovim (vim.api nativo). Nenhum plugin externo.
- --
- -- Como instalar:
- -- 1. Salve este arquivo como ~/.config/nvim/lua/toggle_quotes.lua
- -- 2. No seu init.lua adicione: require('toggle_quotes')
- -- (ou, se usa lazy.nvim, o spec no fim do arquivo)
- -- 3. Reinicie o Neovim. Pronto.
- local M = {}
- -- Ponto de entrada: detecta se o usuário está em modo visual e despacha.
- -- '\22' é o byte do modo visual em bloco (Ctrl-V), equivalente a '^V'.
- function M.surround_quotes()
- local mode = vim.fn.mode()
- if vim.tbl_contains({ 'v', 'V', '\22' }, mode) then
- return M.surround_quotes_visual()
- end
- return M.surround_quotes_normal()
- end
- -- ---------------------------------------------------------------- NORMAL --
- -- Alterna as aspas da palavra sob o cursor (funciona mesmo se o cursor
- -- estiver no MEIO da palavra). Ciclo das aspas imediatamente adjacentes:
- -- 'foo' -> "foo" -> `foo` -> foo
- -- Se não houver aspas, adiciona aspas simples 'foo'.
- function M.surround_quotes_normal()
- local _, col = unpack(vim.api.nvim_win_get_cursor(0))
- local line = vim.api.nvim_get_current_line()
- -- Índice do cursor na string (1-based)
- local cur_idx = col + 1
- local cur_char = line:sub(cur_idx, cur_idx)
- local prev_char = cur_idx > 1 and line:sub(cur_idx - 1, cur_idx - 1) or ''
- local next_char = cur_idx < #line and line:sub(cur_idx + 1, cur_idx + 1) or ''
- -- Se o cursor não está sobre caractere de palavra, tenta ajustar
- -- para o caractere vizinho (para funcionar com cursor em borda).
- if not cur_char:match('[%w_]') then
- if cur_char:match('%s') then
- return -- cursor em espaço: nada a fazer
- elseif prev_char:match('[%w_]') then
- cur_idx = cur_idx - 1 -- cursor logo após a palavra
- elseif next_char:match('[%w_]') then
- cur_idx = cur_idx + 1 -- cursor logo antes da palavra
- else
- return -- nenhuma palavra por perto
- end
- end
- -- Expande para o INÍCIO da palavra (start_idx)...
- local start_idx = cur_idx
- while start_idx > 1 and line:sub(start_idx - 1, start_idx - 1):match('[%w_]') do
- start_idx = start_idx - 1
- end
- -- ...e para o FIM da palavra (end_idx)
- local end_idx = cur_idx
- while end_idx < #line and line:sub(end_idx + 1, end_idx + 1):match('[%w_]') do
- end_idx = end_idx + 1
- end
- local word = line:sub(start_idx, end_idx)
- -- Caractere imediatamente antes e depois da palavra
- local before = start_idx > 1 and line:sub(start_idx - 1, start_idx - 1) or ''
- local after = end_idx < #line and line:sub(end_idx + 1, end_idx + 1) or ''
- local new_line
- if before == "'" and after == "'" then
- -- 'foo' -> "foo"
- new_line = line:sub(1, start_idx - 2) .. '"' .. word .. '"' .. line:sub(end_idx + 2)
- elseif before == '"' and after == '"' then
- -- "foo" -> `foo`
- new_line = line:sub(1, start_idx - 2) .. '`' .. word .. '`' .. line:sub(end_idx + 2)
- elseif before == '`' and after == '`' then
- -- `foo` -> foo (remove)
- new_line = line:sub(1, start_idx - 2) .. word .. line:sub(end_idx + 2)
- else
- -- foo -> 'foo'
- new_line = line:sub(1, start_idx - 1) .. "'" .. word .. "'" .. line:sub(end_idx + 1)
- end
- vim.api.nvim_set_current_line(new_line)
- end
- -- --------------------------------------------------------------- VISUAL --
- -- Alterna as aspas ao redor da SELEÇÃO visual (caracteres ou bloco).
- -- Mesmo ciclo do modo normal, mas sobre o texto selecionado.
- function M.surround_quotes_visual()
- local sel = get_visual_selection_lines()
- if not sel or vim.tbl_isempty(sel.lines) then
- vim.notify('No selection found', vim.log.levels.WARN)
- return
- end
- local first_line = sel.lines[1]
- local last_line = sel.lines[#sel.lines]
- -- Verifica se a primeira e a última linha da seleção já começam/terminam
- -- com a mesma aspa (ex.: seleção incluiu as aspas).
- local function has_quote(str, quote)
- if #str == 0 then return false end
- local first_byte = str:sub(1, 1)
- local last_byte = str:sub(-1)
- return first_byte == quote and last_byte == quote
- end
- local has_single = has_quote(first_line, "'") and has_quote(last_line, "'")
- local has_double = has_quote(first_line, '"') and has_quote(last_line, '"')
- local has_backtick = has_quote(first_line, '`') and has_quote(last_line, '`')
- -- Constrói o novo texto com o par de aspas correto do ciclo.
- local new_text
- if has_single and #sel.lines == 1 then
- new_text = '"' .. first_line:sub(2, -2) .. '"' -- '...' -> "..."
- elseif has_single then
- new_text = '"' .. first_line:sub(2) .. '\n' .. last_line:sub(1, -2) .. '"'
- elseif has_double and #sel.lines == 1 then
- new_text = '`' .. first_line:sub(2, -2) .. '`' -- "..." -> `...`
- elseif has_double then
- new_text = '`' .. first_line:sub(2) .. '\n' .. last_line:sub(1, -2) .. '`'
- elseif has_backtick and #sel.lines == 1 then
- new_text = first_line:sub(2, -2) -- `...` -> ...
- elseif has_backtick then
- new_text = first_line:sub(2) .. '\n' .. last_line:sub(1, -2)
- elseif #sel.lines == 1 then
- new_text = "'" .. first_line .. "'" -- ... -> '...'
- else
- new_text = "'" .. first_line .. '\n' .. last_line .. "'"
- end
- -- Sai do modo visual antes de editar o buffer.
- local escaped = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
- vim.api.nvim_feedkeys(escaped, 'x', false)
- local new_lines = vim.split(new_text, '\n')
- -- Converte coluna em caracteres para coluna em BYTES (necessário
- -- para nvim_buf_set_text, que trabalha com índices de byte).
- local function char_to_byte_col(line, char_col)
- if char_col <= 1 then return 0 end
- local max_col = vim.fn.strlen(line) + 1
- char_col = math.min(char_col, max_col)
- return vim.str_byteindex(line, char_col - 1)
- end
- local start_byte = char_to_byte_col(sel.raw_lines[1], sel.start_col)
- local end_byte = char_to_byte_col(sel.raw_lines[#sel.raw_lines], sel.end_col)
- -- Substitui o texto selecionado pelo novo texto com aspas.
- vim.api.nvim_buf_set_text(0, sel.start_row - 1, start_byte, sel.end_row - 1, end_byte, new_lines)
- -- Re-seleciona visualmente (gv) após editar, para o usuário continuar.
- vim.defer_fn(function() vim.cmd('normal! gv') end, 10)
- end
- -- ----------------------------------------------------------- HELPERS --
- -- Captura a seleção visual atual de forma confiável.
- -- IMPORTANTE: as marks '< e '> só são atualizadas ao SAIR do modo visual,
- -- então aqui usamos getpos('v') (início) + getpos('.') (fim do cursor),
- -- que funcionam COM a seleção ainda ativa.
- -- Suporta modos: caracteres (v), linhas (V) e bloco (^V).
- -- Retorna: { lines, raw_lines, start_row, end_row, start_col, end_col, mode }
- local function get_visual_selection_lines()
- local mode = vim.fn.mode()
- -- Em modo visual: captura posições do par 'v' (start) e '.' (cursor)
- if vim.tbl_contains({ 'v', 'V', '\22' }, mode) then
- local s_pos = vim.fn.getpos('v')
- local e_pos = vim.fn.getpos('.')
- local s_row, s_col = s_pos[2], s_pos[3]
- local e_row, e_col = e_pos[2], e_pos[3]
- -- Normaliza a direção (top-to-bottom, left-to-right)
- if s_row > e_row or (s_row == e_row and s_col > e_col) then
- s_row, e_row = e_row, s_row
- s_col, e_col = e_col, s_col
- end
- local lines = vim.api.nvim_buf_get_lines(0, s_row - 1, e_row, false)
- if vim.tbl_isempty(lines) then return nil end
- local raw_lines = vim.deepcopy(lines)
- -- Recorta as linhas para refletir exatamente a seleção.
- if mode == 'v' then
- lines[1] = string.sub(lines[1], s_col)
- if #lines == 1 then
- lines[1] = string.sub(lines[1], 1, e_col - s_col + 1)
- else
- lines[#lines] = string.sub(lines[#lines], 1, e_col)
- end
- elseif mode == '\22' then
- -- Modo bloco: recorta cada linha no intervalo de colunas
- for i, line in ipairs(lines) do
- local len = #line
- local start_c = math.min(s_col, len + 1)
- local end_c = math.min(e_col, len)
- lines[i] = string.sub(line, start_c, end_c)
- end
- end
- return {
- lines = lines,
- raw_lines = raw_lines,
- start_row = s_row,
- end_row = e_row,
- start_col = s_col,
- end_col = e_col,
- mode = mode,
- }
- end
- return nil
- end
- -- ---------------------------------------------------------- KEYMAPS --
- -- Registra o keymap ao carregar o módulo.
- -- Mude "<leader>'" conforme sua preferência.
- vim.keymap.set('n', "<leader>'", function()
- require('toggle_quotes').surround_quotes()
- end, { desc = 'Toggle quotes (normal / visual)' })
- -- IMPORTANTE: para usar no modo VISUAL, mapeie também para 'x' e 'v':
- -- vim.keymap.set({ 'x', 'v' }, "<leader>'", function()
- -- require('toggle_quotes').surround_quotes()
- -- end, { desc = 'Toggle quotes (visual)' })
- -- Alternativa via lazy.nvim (spec no lua/plugins/toggle-quotes.lua):
- -- return {
- -- dir = '~/.config/nvim/lua', -- ou dir='~/dotfiles/nvim/lua' se versionar
- -- name = 'toggle_quotes',
- -- lazy = true,
- -- init = function() vim.keymap.set('n', "<leader>'", function()
- -- require('toggle_quotes').surround_quotes() end, { desc = 'Toggle quotes' }) end,
- -- }
- return M
Advertisement
Add Comment
Please, Sign In to add comment