Skip to content

Latest commit

 

History

26 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

greplace.nvim

WORK IN PROGRESS

Project-wide search and replace by editing the grep results.

:Gsearch <query>

greps the working tree and collects every matching line into a split named greplace://replace. Each line is the matched line itself — plain, editable text. The file:line shown in front of it is virtual: it is not part of the line, it cannot be typed over, and it is what tells the plugin where the line came from.

Edit the lines however you like — :%s/…, visual block, macros, by hand — and write the buffer:

:w

Every changed line is written back to its source in memory: files already open keep their buffer, files that are not open are loaded into one. Nothing is written to disk, so the whole change is one :wa away — or one u per buffer from being undone.

Requires Neovim ≥ 0.11 and ripgrep on $PATH. No plugin dependencies.

Installation

lazy.nvim

{
  "mbfoss/greplace.nvim",
  config = function()
    require("greplace").setup()
  end,
}

setup() is optional; the :Gsearch and :Greplace commands register themselves, and nothing under lua/greplace/ is loaded until one is first run.

Usage

Command Effect
:Gsearch foo bar literal search for foo bar (smart-case)
:Gsearch --hidden -- foo the same search with flags — see below
:Gsearch with nothing at all: cancel the search still running
:Greplace put the panel back on screen (:Greplace open)
:Greplace close take it off again, keeping the list in it
:Greplace toggle one or the other, whichever it is not
:Greplace qf fill the panel from the quickfix list instead of a search

Two commands, split by what they do: :Gsearch is the one that produces a list, and :Greplace is what you do with the panel afterwards — so the panel's own verbs never have to compete with a query for the same argument.

Everything after :Gsearch is the query, and it is always searched literally — quotes, backslashes and leading dashes included. It is read by Vim's own rules (:h <f-args>), so a run of whitespace inside the query is written \ per space and a literal backslash \\; every other backslash, \d and \s among them, stands for itself. A regex, a case rule or a narrowed file set is asked for with flags, below.

:Greplace — the panel itself

:Greplace never searches. With no subcommand — or with open — it puts the panel back on screen with the list and any unapplied edits it was holding. close takes it off again and toggle does whichever of the two applies; both keep the buffer, so showing it again brings back the same list rather than searching for it again. qf refills it, below. The subcommands tab-complete, and there is nothing to show until a search has filled the panel once.

:Gsearch --flags — searching with flags

A :Gsearch line that opens with -- is a flag line: flags first, then a bare --, then the query.

:Gsearch --glob *.lua --glob !*_spec.lua --hidden -- handle_event
:Gsearch --type md --dir docs -- TODO
:Gsearch --regex --word -- ^fn\s+\w+

That is also how a query of its own that starts with -- is written: put it after a bare separator, :Gsearch -- --hidden. Everything else is read as the query itself, with no separator needed.

The whole line — flags and query alike — is split by Vim's own rules (:h <f-args>): unescaped whitespace separates words, \ is a space inside one and \\ a backslash, and every other backslash stands for itself. So a space is written \ wherever it belongs to what you mean, in a flag value (--dir my\ src) or in the query (-- two\ words), while a regex keeps its \s and \w untouched. Quotes are not special. Flags are written --switch, --key value or --key=value, and a repeatable flag is repeated rather than given a list.

The -- is what ends the flags: nothing after the first bare one is read as one, so a query may hold leading dashes, quotes and another --. A flag line without it is an error rather than a guess at where the flags stopped. Anything else that is wrong with the line — an unknown flag, a value flag left without one — is reported instead of searched.

Flag names and values tab-complete: --type against rg --type-list, --dir against directories, whether the value is written after the flag or glued to it with =. A switch already on the line drops out of the candidates, and past the -- nothing completes at all — those words are a query, not a list.

Flag Effect
--dir <path> search root (default: the working directory)
--glob <glob> glob filter, repeatable: *.txt, !*.lua, **/dir/**
--iglob <glob> the same, matched regardless of case
--type <name> rg file type, repeatable: lua, rust, !md (see rg --type-list)
--max-depth <n> maximum directory depth to descend
--regex treat the query as a regex
--case / --nocase case-sensitive / -insensitive (default: smart case)
--word / --line match whole words / whole lines only
--invert collect the lines that do not match
--hidden include hidden files (dotfiles)
--no-ignore ignore .gitignore / .ignore rules
--follow follow symlinks

There is no passthrough of raw ripgrep arguments, by design: the panel's whole contract is that a shown line is the source line byte for byte, and --replace, --only-matching, -l, --count and -A/-B/-C all break it.

File-selection flags are also applied to open buffers, in-process — ripgrep sees the buffer pass as one nameless stdin stream, so its own -g and -t cannot reach it, and without that a --glob *.lua search would quietly report matches from a .md you have open.

--glob and --iglob are ripgrep's own two spellings and mean what they do there: --glob *.lua leaves out a file named A.LUA, --iglob *.lua takes it in. Both passes agree on that — the in-process globs are compiled to match the case rule the flag names, so a file is never filtered one way on disk and the other way in a buffer.

:Greplace qf — editing the quickfix list

:Greplace qf runs no search: it fills the panel from the quickfix list as it now stands, so whatever put entries there — :grep, :vimgrep, an LSP's references, a test runner — becomes an editable, writable list. Writing the buffer pushes the edits back exactly as it does for a search.

Only the file and line of each entry are used. The entry's own text is ignored — :vimgrep trims it, an LSP replaces it with a message — and the line is read back from the file, or from its buffer when it has one, so what the panel shows is the text a replacement will land on. Entries with no file, no line number, or a line that can no longer be read are skipped, with a note saying how many. Several entries on one line are listed once: the panel edits lines, and offering the same line twice would apply the edit twice.

The panel opens as soon as the search is triggered and says that it is searching until the results replace it.

Files that are open in a buffer are searched from their current, unsaved text, not from disk — their locations are marked with a distinct highlight.

Mappings

Key Effect
<CR> open the source of the line under the cursor, at that line and column
K show the match under the cursor in a floating window: its full path, relative path, line number, whether it is already loaded in a buffer, and the source line as the panel rendered it

The file opens in a regular window — the panel keeps its own, and is never opened over. Set keys.open or keys.hover to a different key, or to false, to change or drop either mapping.

The file:line column is capped at path_width display cells; a longer location is cropped on the left, keeping the file name and line number visible. K is how you see the whole path.

Editing rules

The anchor in front of a line owns everything from that line down to the next anchor, which makes the obvious edits mean the obvious thing:

  • change the line → the source line is rewritten
  • delete the line (dd) → that match is dropped: its source line is left exactly as it is
  • split it into several lines → the source line is replaced by all of them
  • empty the panel (ggdG) → nothing is changed at all

Deleting lines is how you narrow a result set down to the matches you actually want to replace — it never removes anything from a file. The dropped line's file:line disappears with it, so the locations left in the panel keep lining up with the lines they belong to, and u brings both back.

A line whose source has moved since the search (an edit elsewhere, a reload) is left untouched and reported as skipped. After a write the panel re-renders with the applied text and corrected line numbers.

Configuration

require("greplace").setup({
  height     = 15,    -- height of the result split
  limit      = 10000, -- maximum matches collected per search; a search that
                      -- hits it says so in the winbar
  winbar     = true,  -- show the panel's counts in a winbar
  path_width = 60,    -- greatest width of the `file:line` column, in cells
  keys = {
    open  = "<CR>", -- open the source of the line under the cursor
    hover = "K",    -- show the full details of the match under the cursor
  },
})

Highlight groups

Group Default Meaning
GreplaceLocation Directory file:line of an on-disk match
GreplaceBufferLocation Special file:line of a match in an open buffer
GreplaceSeparator Comment the between location and text
GreplaceMatch Label the matched text itself
GreplaceLimit WarningMsg the winbar's "limit of N reached" note

Development

make test

Tests use busted, run through tests/nvim-lua so each spec executes inside a real Neovim. busted must already be installed for Lua 5.1 — the version Neovim embeds — with luarocks --lua-version=5.1 --local install busted; make test fails if it is missing rather than installing anything.

License

MIT

About

Seach and replace across multiple files in Neovim

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages