Skip to content

Commit

Permalink
feat: Support for ranges. In visual *line* mode uses range automatically
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisgrieser committed Jun 14, 2024
1 parent a51bfcf commit 14ddad2
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 46 deletions.
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,19 @@ A modern substitute for vim's `:substitute`, using `ripgrep`.
- Search and replace in the current buffer using
[ripgrep](https://github.com/BurntSushi/ripgrep).
- Uses common regex syntax (pcre2) — no more arcane vim regex.
- Incremental preview of matches and replacements.
- Incrementally updated count of matches.
- Live count of matches, incremental preview of matched strings and
replacements.
- Popup window instead of command line. This entails:
+ Syntax highlighting of the regex.
+ Editing with vim motions.
+ Snippets and completions work.
+ No more dealing with delimiters.
- Sensible defaults: searches the entire buffer (`%`), all matches in a line
(`/g`), case-sensitive (`/I`).
- Automatic prefill of the search term: cursorword in normal mode, and the
selected text in visual mode.
- Quality-of-Life features: prefill-text is automatically escaped, capture
groups tokens can be automatically added, popup window adapts to length of
input.
- Range support when started from visual line mode.
- Quality-of-Life features: automatic prefill of the escaped cursorword or
selection, capture groups tokens can be automatically added, popup window
adapts dynamically to length of input.
- History of previous substitutions.
- Performant: Even in a file with 5000 lines and thousands of matches, still
performs blazingly fast.™
Expand Down Expand Up @@ -134,12 +133,16 @@ require("rip-substitute").setup {
```

## Usage
In normal or visual mode, call:

```lua
require("rip-substitute").sub()
```

- Normal mode: prefills the cursorword.
- Visual mode: prefills the first line of the selection.
- Visual **line** mode: replacements are only applied to the selected lines,
that is, the selection is used as range.

## Advanced
**`autoBraceSimpleCaptureGroups`**
One annoying *gotcha* of `ripgrep`'s regex syntax is it treats `$1a` as the
Expand Down
37 changes: 36 additions & 1 deletion lua/rip-substitute/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,42 @@ local M = {}
---@param userConfig? ripSubstituteConfig
function M.setup(userConfig) require("rip-substitute.config").setup(userConfig) end

function M.sub() require("rip-substitute.popup-win").openSubstitutionPopup() end
function M.sub()
local config = require("rip-substitute.config").config
local mode = vim.fn.mode()

-- PREFILL
local prefill = ""
if mode == "n" and config.prefill.normal == "cursorWord" then
prefill = vim.fn.expand("<cword>")
elseif mode == "v" and config.prefill.visual == "selectionFirstLine" then
vim.cmd.normal { '"zy', bang = true }
prefill = vim.fn.getreg("z"):gsub("[\n\r].*", "") -- only first line
end
prefill = prefill:gsub("[.(){}[%]*+?^$]", [[\%1]]) -- escape special chars

-- RANGE
---@type CmdRange|false
local range = false
if mode == "V" then
vim.cmd.normal { "V", bang = true } -- leave visual mode, so marks are set
local startLn = vim.api.nvim_buf_get_mark(0, "<")[1]
local endLn = vim.api.nvim_buf_get_mark(0, ">")[1]
range = { start = startLn, end_ = endLn }
end

-- SET STATE
require("rip-substitute.state").update {
targetBuf = vim.api.nvim_get_current_buf(),
targetWin = vim.api.nvim_get_current_win(),
labelNs = vim.api.nvim_create_namespace("rip-substitute-labels"),
incPreviewNs = vim.api.nvim_create_namespace("rip-substitute-incpreview"),
targetFile = vim.api.nvim_buf_get_name(0),
range = range,
}

require("rip-substitute.popup-win").openSubstitutionPopup(prefill)
end

--------------------------------------------------------------------------------
return M
24 changes: 3 additions & 21 deletions lua/rip-substitute/popup-win.lua
Original file line number Diff line number Diff line change
Expand Up @@ -123,29 +123,11 @@ end

--------------------------------------------------------------------------------

function M.openSubstitutionPopup()
-- IMPORTS & INITIALIZATION
---@param prefill string
function M.openSubstitutionPopup(prefill)
local rg = require("rip-substitute.rg-operations")
local config = require("rip-substitute.config").config
require("rip-substitute.state").update {
targetBuf = vim.api.nvim_get_current_buf(),
targetWin = vim.api.nvim_get_current_win(),
labelNs = vim.api.nvim_create_namespace("rip-substitute-labels"),
incPreviewNs = vim.api.nvim_create_namespace("rip-substitute-incpreview"),
targetFile = vim.api.nvim_buf_get_name(0),
}
local state = require("rip-substitute.state").state

-- PREFILL
local prefill = ""
local mode = vim.fn.mode()
if mode == "n" and config.prefill.normal then
prefill = vim.fn.expand("<cword>")
elseif mode:find("[Vv]") and config.prefill.visual == "selectionFirstLine" then
vim.cmd.normal { '"zy', bang = true }
prefill = vim.fn.getreg("z"):gsub("[\n\r].*", "") -- only first line
end
prefill = prefill:gsub("[.(){}[%]*+?^$]", [[\%1]]) -- escape special chars
local config = require("rip-substitute.config").config

-- CREATE RG-BUFFER
state.popupBufNr = vim.api.nvim_create_buf(false, true)
Expand Down
50 changes: 36 additions & 14 deletions lua/rip-substitute/rg-operations.lua
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ function M.executeSubstitution()
-- preserves folds and marks
for _, repl in pairs(results) do
local lineStr, newLine = repl:match("^(%d+):(.*)")
local lnum = assert(tonumber(lineStr))
vim.api.nvim_buf_set_lines(state.targetBuf, lnum - 1, lnum, false, { newLine })
local lnum = assert(tonumber(lineStr), "rg parsing error")
local inRange = state.range and lnum >= state.range.start and lnum <= state.range.end_
if inRange then
vim.api.nvim_buf_set_lines(state.targetBuf, lnum - 1, lnum, false, { newLine })
end
end
end

Expand All @@ -76,29 +79,46 @@ function M.incrementalPreviewAndMatchCount()

-- DETERMINE MATCHES
local rgArgs = { toSearch, "--line-number", "--column", "--only-matching" }
local matchEndcolsInViewport = {}
local code, searchMatches = runRipgrep(rgArgs)
if code ~= 0 then return end

-- FILTER MATCHES OUTSIDE VIEWPORT
-- RANGE: FILTER MATCHES
-- PERF For single files, `rg` gives us results sorted by lines, so we can
-- slice instead of filter to improve performance.
local viewportStart = vim.fn.line("w0", state.targetWin)
local viewportEnd = vim.fn.line("w$", state.targetWin)
local start, ending
local rangeStartIdx, rangeEndIdx
if state.range then
for i = 1, #searchMatches do
local lnum = tonumber(searchMatches[i]:match("^(%d+):"))
local inRange = lnum >= state.range.start and lnum <= state.range.end_
if rangeStartIdx == nil and inRange then rangeStartIdx = i end
if rangeStartIdx and lnum > state.range.end_ then
rangeEndIdx = i - 1
break
end
end
searchMatches = vim.list_slice(searchMatches, rangeStartIdx, rangeEndIdx)
end

-- VIEWPORT: FILTER MATCHES
local viewStartLnum = vim.fn.line("w0", state.targetWin)
local viewEndLine = vim.fn.line("w$", state.targetWin)
local viewStartIdx, viewEndIdx
for i = 1, #searchMatches do
local lnum = tonumber(searchMatches[i]:match("^(%d+):"))
if not start and lnum >= viewportStart and lnum <= viewportEnd then start = i end
if start and lnum > viewportEnd then
ending = i - 1
if not viewStartIdx and lnum >= viewStartLnum and lnum <= viewEndLine then
viewStartIdx = i
end
if viewStartIdx and lnum > viewEndLine then
viewEndIdx = i - 1
break
end
end
if not start then return #searchMatches end -- no matches in viewport
if not ending then ending = #searchMatches end
if not viewStartIdx then return #searchMatches end -- no matches in viewport
if not viewEndIdx then viewEndIdx = #searchMatches end

-- HIGHLIGHT SEARCH MATCHES
vim.iter(searchMatches):slice(start, ending):map(parseRgResult):each(function(match)
local matchEndcolsInViewport = {}
vim.iter(searchMatches):slice(viewStartIdx, viewEndIdx):map(parseRgResult):each(function(match)
local matchEndCol = match.col + #match.text
vim.api.nvim_buf_add_highlight(
state.targetBuf,
Expand All @@ -121,7 +141,9 @@ function M.incrementalPreviewAndMatchCount()
local code2, replacements = runRipgrep(rgArgs)
if code2 ~= 0 then return #searchMatches end

vim.iter(replacements):slice(start, ending):map(parseRgResult):each(function(repl)
if state.range then replacements = vim.list_slice(replacements, rangeStartIdx, rangeEndIdx) end

vim.iter(replacements):slice(viewStartIdx, viewEndIdx):map(parseRgResult):each(function(repl)
local matchEndCol = table.remove(matchEndcolsInViewport, 1)
local virtText = { repl.text, hl.replacement }
vim.api.nvim_buf_set_extmark(
Expand Down
9 changes: 7 additions & 2 deletions lua/rip-substitute/state.lua
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
local M = {}

---@class (exact) ripSubstituteState
---@class (exact) CmdRange
---@field start number
---@field end_ number

---@class (exact) RipSubstituteState
---@field targetBuf number
---@field targetWin number
---@field targetFile string
---@field range CmdRange|false
---@field labelNs number
---@field incPreviewNs number
---@field popupBufNr? number
Expand All @@ -15,7 +20,7 @@ M.state = {
popupHistory = {},
}

---@param newState ripSubstituteState
---@param newState RipSubstituteState
function M.update(newState) M.state = vim.tbl_deep_extend("force", M.state, newState) end

return M

0 comments on commit 14ddad2

Please sign in to comment.