Skip to content

Update default vim substitute command behavior and add support for 'g' flag - #28138

Merged
ConradIrwin merged 5 commits into
zed-industries:mainfrom
dinocosta:24450-global-replace
Apr 9, 2025
Merged

Update default vim substitute command behavior and add support for 'g' flag#28138
ConradIrwin merged 5 commits into
zed-industries:mainfrom
dinocosta:24450-global-replace

Conversation

@dinocosta

@dinocosta dinocosta commented Apr 5, 2025

Copy link
Copy Markdown
Member

This Pull Request updates the default behavior of the substitute (s) command in vim mode to only replace the next match by default, instead of all, and replace all matches only when the g flag is provided, making it more similar to NeoVim's behavior.

In order to achieve this, the following changes were introduced:

  • Update BufferSearchBar::replace_next to be a public method, so it can be called from Vim::replace_command .
  • Update the Replacement::parse to set the should_replace_all field to false by default, and only set it to true if the 'g' flag is present in the query.
  • Add support for when the Replacement.should_replace_all is set to false in Vim::replace_command, so as to have it only replace the next occurrence instead of all occurrences in the line.
  • Introduce BufferSearchBar::select_first_match so as to activate the first match on the line under the cursor.

Closes #24450

Release Notes:

  • vim: The :s// command now defaults to replacing the first match per line (like vim). Use /g to replace all matches.

@cla-bot cla-bot Bot added the cla-signed The user has signed the Contributor License Agreement label Apr 5, 2025
Comment thread crates/search/src/buffer_search.rs Outdated
return;
}

self.active_match_index = Some(0);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we should directly update the field here, but when trying to use update_matches and activate_match, just like select_last_match does, with and index of 0, the active match index was not being updated, not sure why 🤔

@dinocosta

Copy link
Copy Markdown
Member Author

@ConradIrwin Two quick questions from my side ✍️

  1. Do you think Zed should still provide an indicator in the search bar for when the should_replace_all option is enabled? Asking because of this comment.
  2. Should the default behavior be to replace the match under the cursor or the first one in the current line? My initial commit was doing the former, but I ended up introducing select_first_match and updating it to the latter because that's how vim handles it.
    • A quick example for context, imagine a line with the text "this and this and this", if the cursor is on the second occurrence of this, should the result of running :s/this/that be "this and that and this" or should it be "that and this and this"?

Thanks! 🙂

@dinocosta dinocosta changed the title feat(vim): update default substitute command behavior and add support for 'g' flag Update default vim substitute command behavior and add support for 'g' flag Apr 5, 2025
@ConradIrwin

Copy link
Copy Markdown
Member

@dinocosta awesome, thanks for working on this!

  1. After you run a search with s://g, if you then interact with the search bar does it inherit the /g behavior? If so, we need an indicator that that will happen; if not we can descope the indicator.
  2. Copying vim is the right call, people complain when we diverge :D. It "makes sense" (even if it's a bit unexpected) because s// is an ex mode command, and those are all line-wise.

@ConradIrwin

Copy link
Copy Markdown
Member

@dinocosta Also, testing this out, vim's behavior is a bit more complicated: /g it should replace the first match on every line in the search range, not just the first match overall.

@dinocosta

Copy link
Copy Markdown
Member Author
  1. After you run a search with s://g, if you then interact with the search bar does it inherit the /g behavior? If so, we need an indicator that that will happen; if not we can descope the indicator.

Interesting, I was wondering what would be the best way to actually test this! For what it's worth, after running :s/[SEARCH]/[REPLACEMENT]/g the "Replace Next Match" and "Replace All Matches" buttons in the search bar continue working as expected.

@dinocosta Also, testing this out, vim's behavior is a bit more complicated: /g it should replace the first match on every line in the search range, not just the first match overall.

That's a good catch, thank you for pointing it out! I'll try to work on a fix for this 🙇

@dinocosta

Copy link
Copy Markdown
Member Author

@ConradIrwin Somewhat unrelated but I also just noticed that, with the current Zed release, after running :s/[SEARCH]/[REPLACEMENT]/, the search bar will report 0 matches, even if there's more matches in the buffer, I believe this might be happening because it's only taking the current line into consideration. Here's a quick screen capture ↓

Screen.Recording.2025-04-05.at.15.21.09.mov

Not sure if this could be considered a bug, but thought it's worth pointing out, in case you want me to take a look at it or to open an issue 👍

@ConradIrwin

Copy link
Copy Markdown
Member

Hmm, that doesn't seem right.

There are two possibilities:

  • Either we need to make the "search within selection" state persistent, to make it clear that there are no matches within the selection.
  • Or, as you say, re-run the search when we turn the "search within selection" off.
    Not sure which is better (or which is easier to implement :D), but happy to have that tracked separately to this PR.

- Update BufferSearchBar::replace_next to be a public method, so it can
be called from Vim::replace_command .
- Update the Replacement::parse to set the should_replace_all field to
false by default, and only set it to true if the 'g' flag is present in
the query.
- Add support for when the Replacement.should_replace_all is set to
false in Vim::replace_command, so as to have it only replace the next
ocurrence instead of all ocurrences in the line.
Update the default behaviour of the substitute command in vim mode to
match what NeoVim does when replacing only one ocurrence of a string,
where it replaces only the first ocurrence in the line, instead of the
first ocurrence relative to the cursor.

- Introduce BufferSarchBar::select_first_match to update the active
match index to 0, so as to be able to select the first match in the
line, regardless of the cursor's position.
- Update the Vim::replace_command so as to call
BufferSearchBar::select_first_match if should_replace_all is set to
false, before replacing the next match with
BufferSearchBar::replace_next .
@dinocosta

Copy link
Copy Markdown
Member Author

Hmm, that doesn't seem right.

There are two possibilities:

  • Either we need to make the "search within selection" state persistent, to make it clear that there are no matches within the selection.
  • Or, as you say, re-run the search when we turn the "search within selection" off.
    Not sure which is better (or which is easier to implement :D), but happy to have that tracked separately to this PR.

I'm wondering if we could potentially call editor.set_search_within_ranges again at the end of Vim::replace_command, providing it with the full range of the snapshot? Sounds like the fastest/easiest implementation, provided that we can actually get the full range from the EditorSnapshot, as I'm not familiar with the part of the code.

@ConradIrwin I also took a quick look into actually fixing the issue where running :s/{pattern}/{string} on multiple lines doesn't replace the first occurrence on each line and I wonder what the best approach would be:

  1. For each line in the action's range, build a new range which is simply the line's range, and then updating the editor's search range (possibly with Editor::set_search_within_ranges), calling the method to select the first match followed by the method to replace the next match
  2. Updating the way that BufferSearchBar allows callers to select matches, so as to allow to select only the first match on each line

I believe option 1. would be easier to implement, seeing as all of the methods to do it seem to already be available, albeit it's probably less efficient, while option 2. would probably involve refactoring SearchableItemHandle to support this? 🤔

Let me know what you think, but I might try option 1. in the meantime, just as a proof of concept 👍

@ConradIrwin

Copy link
Copy Markdown
Member

@dinocosta I think option 2. is the correct approach :D. Happy to pair if you want: https://cal.com/conradirwin/pairing

We already have a number of options that affect the search, so adding a new one seems fine.

I think we can add a check here:

let text = text.text_for_range(m.clone()).collect::<Vec<_>>();
that does something like

let point = m.range.start.anchor.to_point(&text)
if point.row != last_point.row {
  last_point = point
} else if query.one_match_per_line {
  continue
}

@dinocosta

Copy link
Copy Markdown
Member Author

@ConradIrwin Thank you so much for the suggestion, I wasn't aware that we could actually convert the Anchor struct to a Point one, that's very helpful here! 🙌

@dinocosta I think option 2. is the correct approach :D. Happy to pair if you want: https://cal.com/conradirwin/pairing

I'll try to implement the suggested change and test it a bit, but we still have a pairing session scheduled for tomorrow, so we can always review the approach then and get your feedback on it!

This commit introduces the necessary changes in order to support
replacing only the first match per line when using vim's subsitute
command, for example, :s/{pattern}/{string}/ .

In order to support this, the following changes have been introduced:

- Update `SearchQuery::Regex` with a new field, `one_match_per_line`,
which controls whether all matches will be replaced when using replace
all, or if only one match per line should be replaced.
- Add `ONE_MATCH_PER_LINE` to `SearchOptions` struct.
- Update `Vim::replace_command` to set the
`SearchOptions::ONE_MATCH_PER_LINE` option, if
`replacement.should_replace_all` is false.
- Update `SearchQuery::regex` method in order to expect the
`one_match_per_line` argument to be provided, allowing callers to
specify whether the search query should be used to replace only one
match per line.

Lastly, since they're no longer necessary, the
`BufferSearchBar::select_first_match` method has been removed, and the
`BufferSearchBar::replace_next` method has been moved to private again.
@dinocosta

Copy link
Copy Markdown
Member Author

@ConradIrwin I've pushed 4d209ef which adds the change you proposed and it seems things are working as expected for the :s/{pattern}/{string} command, namely

  • :'<,'>s/{pattern}/{string} replaces only the first match for each of the lines in the range
  • :'<,'>s/{pattern}/{string}/g replaces all matches for each of the lines in the range
  • :s/{pattern}/{string} replaces only the first match in the current line
  • :s/{pattern}/{string}/g replaces all the matches in the current line

There's two issues I'm aware of that I'll need to fix, but I first wanted to double-check that the approach in that commit seems good before committing to fixing the issues, so do let me know what you think!

If the approach looks good, I'll go ahead and attempt the issues:

  1. After running :s/{pattern}/{string}, clicking the "Replace All Matches" button in the search bar only replaces the first match per line. I believe this is the issue you alerted to in this comment , in point 1 , so we might need to add the indicator after all.
  2. When running :%s/{pattern}/{string} it only starts replacing from the second line onwards, not entirely sure why, but will try to figure out 🔍

Thanks!

@ConradIrwin

Copy link
Copy Markdown
Member

Amazing! Yes, we'd either need the indicator (or to reset the state after running the replace the first time). This is great.

Fix two issues introduced with the recent changes that update
:s/{pattern}/{string} to allow only replacing the first occurrence on
each line, namely:

- Fix `:%s/{pattern}/{string}` to actually work on all lines, instead
of only from line 1. This issue was happening because the default point
we were checking against in `editor::items::replace_all` was having its
`row` value set to `0`, which would always end up being the same as the
first line, so that line would be skipped if the `one_match_per_line`
option was set to true.
- Disabled the search bar's `ONE_MATCH_PER_LINE` search option after
running vim's replace command, as having leaving this enabled would
cause the "Replace All Matches" functionality to only replace the first
occurrence on each line, and I'm not sure if this is something Zed
wants to eventually support and add an icon for in the search bar.
@dinocosta

Copy link
Copy Markdown
Member Author

Amazing! Yes, we'd either need the indicator (or to reset the state after running the replace the first time). This is great.

Thank you! 🙇

I've pushed cbb54f7 which fixes the two issues I mentioned !

As for the indicator in the search bar, I believe it's a good idea to also expose this functionality to non-vim users, but I'm wondering if that should be tracked in a different issue, as it seems we'd need to involve someone else from Zed's team to create an icon for that, let me know 🤔

@dinocosta

Copy link
Copy Markdown
Member Author

@ConradIrwin Something else I just noticed is that, after releasing these changes it might be better to update Zed's Vim Mode documentation, specifically the section on Replacement, as it states:

Also, by default, Zed always replaces all occurrences of the search pattern in the current line.

This will no longer be true if these changes end up being shipped, let me know if I should also update docs/src/vim.md with the new behavior, although I'm not sure when these updates are published to Zed's website 🤔

@ConradIrwin

Copy link
Copy Markdown
Member

We should!

I was holding off hitting merge until today's preview is out – Before this change ships to preview (though after it's merged) I'd also like to ship a setting to re-enable the current behavior for those who set gdefault in vim

@dinocosta

Copy link
Copy Markdown
Member Author

We should!

I was holding off hitting merge until today's preview is out – Before this change ships to preview (though after it's merged) I'd also like to ship a setting to re-enable the current behavior for those who set gdefault in vim

Awesome! I believe folks who are used to the g flag being on by default, and prefer it that way, will greatly appreciate that. Happy to take a stab at it if you'd like ✌️

@ConradIrwin

Copy link
Copy Markdown
Member

Go for it! If you don't get to it this week I'll take a pass.

@ConradIrwin
ConradIrwin merged commit af5318d into zed-industries:main Apr 9, 2025
ConradIrwin pushed a commit that referenced this pull request Apr 28, 2025
With the introduction of
#28138, the current vim docs
became stale.

This PR makes a small update to the docs to reflect this.
jasonsmithio pushed a commit to paddleboarddev/paddleboard that referenced this pull request May 31, 2026
With the introduction of
zed-industries/zed#28138, the current vim docs
became stale.

This PR makes a small update to the docs to reflect this.
jolutz pushed a commit to jolutz/zed that referenced this pull request Aug 8, 2026
…' flag (zed-industries#28138)

This Pull Request updates the default behavior of the substitute (`s`)
command in vim mode to only replace the next match by default, instead
of all, and replace all matches only when the `g` flag is provided,
making it more similar to NeoVim's behavior.

In order to achieve this, the following changes were introduced:

- Update `BufferSearchBar::replace_next` to be a public method, so it
can be called from `Vim::replace_command` .
- Update the `Replacement::parse` to set the `should_replace_all` field
to `false` by default, and only set it to `true` if the `'g'` flag is
present in the query.
- Add support for when the `Replacement.should_replace_all` is set to
`false` in `Vim::replace_command`, so as to have it only replace the
next occurrence instead of all occurrences in the line.
- Introduce `BufferSearchBar::select_first_match` so as to activate the
first match on the line under the cursor.

Closes zed-industries#24450 

Release Notes:

- Improved vim's substitute command so as to only replace the first
match by default, and replace all matches if the `'g'` flag is provided

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vim :s without g still replaces all matches

2 participants