Lesson 06 workflow

Search and replace

:s for a range of lines, :g for every line that matches, :find and :replace for the whole project — with a preview you prune before anything is written.

/ finds something in the file and s jumps to something on screen. This lesson is about changing text in bulk: in a few lines, on every matching line, and across every file in the project.

note
Search, :s and :g take literal patterns for now — \d is a backslash and a d. Regular expressions arrive under all of them at once. :find~ and :replace~ already read a regex.

:s — substitute

:[range]s/{pattern}/{replacement}/[flags]
lstring.c :s over a range 1 / 5
:s over a range: step 0 :s over a range: step 1 :s over a range: step 2 :s over a range: step 3 :s over a range: step 4

No range means the cursor’s line, which is why % is the most typed character in the command. The whole command is one undo step, the cursor lands on the last line changed, and the pattern becomes the last search, so n walks what you just replaced. Nothing matched is an error, not a silent no-op.

Command Does
:s/a/b/ the first a on the cursor’s line
:s/a/b/g every a on it
:%s/a/b/g every a in the file
:2,5s/a/b/g every a in lines 2 to 5
:'v s/a/b/g inside the selection — a rectangle’s columns included
:s#/usr#/opt# any delimiter, so a path needs no escaping
:%s//new/g an empty pattern is the last search
Flag Does
g every match on a line, not just the first
i match without case
I match with case
n count the matches, change nothing

Without i or I it is smartcase, exactly like /: all lowercase ignores case, one capital makes it exact. Inside the pattern and the replacement, \ before the delimiter is the delimiter and \\ is a backslash — nothing else is an escape. The closing delimiter is optional: :%s/old/new works.

Doing it again

Key Does
& the last substitute again, on the cursor’s line — flags included
g& the same over the whole file
:[range]& :[range]&& over a range; both spellings mean the same
vs vim
Vim’s :& drops the flags and :&& keeps them. In bi both keep them, so there is one thing to remember. And g& does not swap in the last search pattern — it repeats exactly what ran.

Ranges

Every command that takes lines — :s, :g, :d, :m, :sort, :normal — reads the same small address language.

Address Means
12 line 12
. the cursor’s line
$ the last line
% the whole file
'< '> first and last row of the selection
'v the selection itself, whatever its shape
.+3 $-1 +2 any of the above with an offset

Two addresses make a range: :10,20s/a/b/g, :.,$d, :.,.+5normal A;. Pressing : in visual mode prefills 'v. A line that does not exist is refused (no line 99), not clamped.

:g — a command on every matching line

lstring.c :g and :normal 1 / 5
:g and :normal: step 0 :g and :normal: step 1 :g and :normal: step 2 :g and :normal: step 3 :g and :normal: step 4

:g/pattern/cmd scans first — every line containing the pattern is marked — then runs the command on each, top to bottom. Nothing chases its own output, and the whole run is one undo step. :v (or :g!) takes the lines that do not match. With no range, :g scans the whole file.

Command Does
:g/TODO/d delete every line with a TODO
:v/save/d keep only the lines mentioning save
:g/TODO/s/old/new/g substitute, only on TODO lines
:g/foo/s//bar/g the inner s borrows :g’s pattern
:g/^use /m 0 herd matching lines to the top
:g/fixme/normal A ! append ! to each
:%normal I// prefix every line — no pattern needed
:2,5d delete lines 2 to 5

The command after :g is one of d, s, &, m, case, retab or normal — anything else is refused by name. That whitelist is what keeps :g/x/q from closing the editor on the first match.

:normal {keys} replays keys as if typed, once per line under a range, and presses Esc for you at the end. It uses bi’s own keymap, not your remaps (vim’s :normal!), and takes raw characters — there is no <Esc> notation inside it yet.

tip
To comment lines, reach for gc instead of :normal I// — it knows each language’s comment syntax.

:find — search the project

lstring.c :find across the project 1 / 6
:find across the project: step 0 :find across the project: step 1 :find across the project: step 2 :find across the project: step 3 :find across the project: step 4 :find across the project: step 5

:find walks every file under the project root — skipping what .gitignore ignores and binary files — and puts the matches in a results pane: one heading per file, one row per matching line.

:find needle           literal, smart case
:find src/ needle      only under src/
:find~ \bfn \w+        the pattern as a regular expression
:results               bring the last results pane back

The whole argument is the pattern, so :find fn main() searches for exactly that. A first word ending in / that names a real directory is a scope.

Key In a results pane
j k gg G move between rows
Ctrl-D Ctrl-U ten rows at a time
Enter o open the file at the match’s column — a heading opens its top
Ctrl-V open the match in a vertical split beside the results
x drop a row — a hit, or a file with everything under it
q Esc put back the file the pane displaced
Ctrl-^ after Enter, swap the pane back

The pane is not an overlay: it stays open beside what you are editing, survives Enter, and :results brings back the last list with its prunes intact.

:replace — preview, prune, apply

lstring.c :replace with a preview 1 / 5
:replace with a preview: step 0 :replace with a preview: step 1 :replace with a preview: step 2 :replace with a preview: step 3 :replace with a preview: step 4
:replace /old/new/     find old, preview replacing it with new
:replace //new/        preview rewriting what the focused pane shows
:replace~ /f(\w+)/$1/  regex, with $1 for the first group

:replace never rewrites on Enter. It arms the results pane: the title reads replace: old → new and every row shows its line as it will read. Then:

Key In an armed pane
a apply the selected row — on a heading, the whole file
A apply every row still pending
x drop a row from the offer
Enter open the file to look first; Ctrl-^ comes back, decisions intact
q walk away — nothing you did not press a for was applied

Applied rewrites go into buffers, never straight to disk. Each file is opened, edited as one undo step and left modified: :wa writes the lot, u in any one file takes that file back. A line that changed since the search is skipped and counted rather than rewritten.

A row is a line, so a line with the pattern twice is rewritten twice.

tip
gr lists every reference to a symbol in the same kind of pane. gr, then :replace //new_name/, is a project-wide rename you can review row by row.