mirror of
https://github.com/chylex/IntelliJ-IdeaVim.git
synced 2025-06-17 06:40:00 +02:00
1405 lines
56 KiB
Java
1405 lines
56 KiB
Java
/*
|
|
* IdeaVim - Vim emulator for IDEs based on the IntelliJ platform
|
|
* Copyright (C) 2003-2022 The IdeaVim authors
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 2 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
package com.maddyhome.idea.vim.group;
|
|
|
|
import com.intellij.openapi.application.ApplicationManager;
|
|
import com.intellij.openapi.components.PersistentStateComponent;
|
|
import com.intellij.openapi.components.RoamingType;
|
|
import com.intellij.openapi.components.State;
|
|
import com.intellij.openapi.components.Storage;
|
|
import com.intellij.openapi.diagnostic.Logger;
|
|
import com.intellij.openapi.editor.Caret;
|
|
import com.intellij.openapi.editor.Document;
|
|
import com.intellij.openapi.editor.Editor;
|
|
import com.intellij.openapi.editor.LogicalPosition;
|
|
import com.intellij.openapi.editor.event.DocumentEvent;
|
|
import com.intellij.openapi.editor.event.DocumentListener;
|
|
import com.intellij.openapi.editor.markup.RangeHighlighter;
|
|
import com.intellij.openapi.fileEditor.FileEditorManagerEvent;
|
|
import com.intellij.openapi.project.Project;
|
|
import com.intellij.openapi.project.ProjectManager;
|
|
import com.intellij.openapi.util.Ref;
|
|
import com.intellij.openapi.util.Trinity;
|
|
import com.maddyhome.idea.vim.VimPlugin;
|
|
import com.maddyhome.idea.vim.common.CharacterPosition;
|
|
import com.maddyhome.idea.vim.common.TextRange;
|
|
import com.maddyhome.idea.vim.ex.ExException;
|
|
import com.maddyhome.idea.vim.ex.ranges.LineRange;
|
|
import com.maddyhome.idea.vim.helper.*;
|
|
import com.maddyhome.idea.vim.newapi.IjVimEditor;
|
|
import com.maddyhome.idea.vim.regexp.CharPointer;
|
|
import com.maddyhome.idea.vim.regexp.CharacterClasses;
|
|
import com.maddyhome.idea.vim.regexp.RegExp;
|
|
import com.maddyhome.idea.vim.ui.ModalEntry;
|
|
import com.maddyhome.idea.vim.ui.ex.ExEntryPanel;
|
|
import com.maddyhome.idea.vim.vimscript.model.VimLContext;
|
|
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimDataType;
|
|
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString;
|
|
import com.maddyhome.idea.vim.vimscript.model.expressions.Expression;
|
|
import com.maddyhome.idea.vim.vimscript.model.expressions.SimpleExpression;
|
|
import com.maddyhome.idea.vim.vimscript.model.functions.handlers.SubmatchFunctionHandler;
|
|
import com.maddyhome.idea.vim.options.OptionChangeListener;
|
|
import com.maddyhome.idea.vim.vimscript.parser.VimscriptParser;
|
|
import com.maddyhome.idea.vim.options.OptionConstants;
|
|
import com.maddyhome.idea.vim.options.OptionScope;
|
|
import kotlin.Pair;
|
|
import kotlin.jvm.functions.Function1;
|
|
import org.jdom.Element;
|
|
import org.jetbrains.annotations.*;
|
|
|
|
import javax.swing.*;
|
|
import java.text.NumberFormat;
|
|
import java.text.ParsePosition;
|
|
import java.util.*;
|
|
|
|
import static com.maddyhome.idea.vim.helper.HelperKt.localEditors;
|
|
import static com.maddyhome.idea.vim.helper.SearchHelperKtKt.shouldIgnoreCase;
|
|
|
|
@State(name = "VimSearchSettings", storages = {
|
|
@Storage(value = "$APP_CONFIG$/vim_settings_local.xml", roamingType = RoamingType.DISABLED)
|
|
})
|
|
public class SearchGroup implements PersistentStateComponent<Element> {
|
|
public SearchGroup() {
|
|
VimPlugin.getOptionService().addListener(
|
|
OptionConstants.hlsearchName,
|
|
new OptionChangeListener<VimDataType>() {
|
|
@Override
|
|
public void processGlobalValueChange(@Nullable VimDataType oldValue) {
|
|
resetShowSearchHighlight();
|
|
forceUpdateSearchHighlights();
|
|
}
|
|
},
|
|
false
|
|
);
|
|
|
|
final OptionChangeListener<VimDataType> updateHighlightsIfVisible =
|
|
new OptionChangeListener<VimDataType>() {
|
|
@Override
|
|
public void processGlobalValueChange(@Nullable VimDataType oldValue) {
|
|
if (showSearchHighlight) {
|
|
forceUpdateSearchHighlights();
|
|
}
|
|
}
|
|
};
|
|
VimPlugin.getOptionService().addListener(OptionConstants.ignorecaseName, updateHighlightsIfVisible, false);
|
|
VimPlugin.getOptionService().addListener(OptionConstants.smartcaseName, updateHighlightsIfVisible, false);
|
|
}
|
|
|
|
public void turnOn() {
|
|
updateSearchHighlights();
|
|
}
|
|
|
|
public void turnOff() {
|
|
final boolean show = showSearchHighlight;
|
|
clearSearchHighlight();
|
|
showSearchHighlight = show;
|
|
}
|
|
|
|
@TestOnly
|
|
public void resetState() {
|
|
lastPatternIdx = RE_SEARCH;
|
|
lastSearch = lastSubstitute = lastReplace = null;
|
|
lastPatternOffset = "";
|
|
lastIgnoreSmartCase = false;
|
|
lastDir = Direction.FORWARDS;
|
|
resetShowSearchHighlight();
|
|
}
|
|
|
|
/**
|
|
* Get the last pattern used for searching. Does not include pattern used in substitution
|
|
*
|
|
* @return The pattern used for last search. Can be null
|
|
*/
|
|
public @Nullable String getLastSearchPattern() {
|
|
return lastSearch;
|
|
}
|
|
|
|
/**
|
|
* Get the last pattern used in substitution.
|
|
* @return The pattern used for the last substitute command. Can be null
|
|
*/
|
|
public @Nullable String getLastSubstitutePattern() {
|
|
return lastSubstitute;
|
|
}
|
|
|
|
/**
|
|
* Get the pattern last used for either searching or substitution.
|
|
*
|
|
* @return The pattern last used for either searching or substitution. Can be null
|
|
*/
|
|
public @Nullable String getLastUsedPattern() {
|
|
switch (lastPatternIdx) {
|
|
case RE_SEARCH: return lastSearch;
|
|
case RE_SUBST: return lastSubstitute;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the last used search direction
|
|
*
|
|
* <p>This method is used in the AceJump integration plugin</p>
|
|
*
|
|
* @return Returns the integer value of Direction. 1 for FORWARDS, -1 for BACKWARDS
|
|
*/
|
|
@SuppressWarnings("unused")
|
|
public int getLastDir() {
|
|
return lastDir.toInt();
|
|
}
|
|
|
|
/**
|
|
* Set the last used pattern
|
|
*
|
|
* <p>Only updates the last used flag if the pattern is new. This prevents incorrectly setting the last used pattern
|
|
* when search or substitute doesn't explicitly set the pattern but uses the last saved value. It also ensures the
|
|
* last used pattern is updated when a new pattern with the same value is used.</p>
|
|
*
|
|
* <p>Also saves the text to the search register and history.</p>
|
|
*
|
|
* @param pattern The pattern to remember
|
|
* @param which_pat Which pattern to save - RE_SEARCH, RE_SUBST or RE_BOTH
|
|
* @param isNewPattern Flag to indicate if the pattern is new, or comes from a last used pattern. True means to
|
|
* update the last used pattern index
|
|
*/
|
|
private void setLastUsedPattern(@NotNull String pattern, int which_pat, boolean isNewPattern) {
|
|
// Only update the last pattern with a new input pattern. Do not update if we're reusing the last pattern
|
|
if ((which_pat == RE_SEARCH || which_pat == RE_BOTH) && isNewPattern) {
|
|
lastSearch = pattern;
|
|
lastPatternIdx = RE_SEARCH;
|
|
}
|
|
if ((which_pat == RE_SUBST || which_pat == RE_BOTH) && isNewPattern) {
|
|
lastSubstitute = pattern;
|
|
lastPatternIdx = RE_SUBST;
|
|
}
|
|
|
|
// Vim never actually sets this register, but looks it up on request
|
|
VimPlugin.getRegister().storeTextSpecial(RegisterGroup.LAST_SEARCH_REGISTER, pattern);
|
|
|
|
// This will remove an existing entry and add it back to the end, and is expected to do so even if the string value
|
|
// is the same
|
|
VimPlugin.getHistory().addEntry(HistoryGroup.SEARCH, pattern);
|
|
}
|
|
|
|
/**
|
|
* Sets the last search state, purely for tests
|
|
*
|
|
* @param editor The editor to update
|
|
* @param pattern The pattern to save. This is the last search pattern, not the last substitute pattern
|
|
* @param patternOffset The pattern offset, e.g. `/{pattern}/{offset}`
|
|
* @param direction The direction to search
|
|
*/
|
|
@TestOnly
|
|
public void setLastSearchState(@SuppressWarnings("unused") @NotNull Editor editor, @NotNull String pattern,
|
|
@NotNull String patternOffset, Direction direction) {
|
|
setLastUsedPattern(pattern, RE_SEARCH, true);
|
|
lastIgnoreSmartCase = false;
|
|
lastPatternOffset = patternOffset;
|
|
lastDir = direction;
|
|
}
|
|
|
|
|
|
// *******************************************************************************************************************
|
|
//
|
|
// Search
|
|
//
|
|
// *******************************************************************************************************************
|
|
|
|
/**
|
|
* Find all occurrences of the pattern
|
|
*
|
|
* @deprecated Use SearchHelper#findAll instead. Kept for compatibility with existing plugins
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param pattern The pattern to search for
|
|
* @param startLine The start line of the range to search for
|
|
* @param endLine The end line of the range to search for, or -1 for the whole document
|
|
* @param ignoreCase Case sensitive or insensitive searching
|
|
* @return A list of TextRange objects representing the results
|
|
*/
|
|
@Deprecated()
|
|
public static @NotNull List<TextRange> findAll(@NotNull Editor editor,
|
|
@NotNull String pattern,
|
|
int startLine,
|
|
int endLine,
|
|
boolean ignoreCase) {
|
|
return SearchHelper.findAll(editor, pattern, startLine, endLine, ignoreCase);
|
|
}
|
|
|
|
/**
|
|
* Process the search command, searching for the pattern from the given document offset
|
|
*
|
|
* <p>Parses the pattern from the search command and will search for the given pattern, immediately setting RE_SEARCH
|
|
* and RE_LAST. Updates the search register and history and search highlights. Also updates last pattern offset and
|
|
* direction. scanwrap and ignorecase come from options. Will ensure that RE_LAST is valid if the given pattern is
|
|
* empty by using the existing RE_SEARCH or falling back to RE_SUBST. Will error if both are unset.</p>
|
|
*
|
|
* <p>Will parse the entire command, including patterns separated by `;`</p>
|
|
*
|
|
* <p>Note that this method should only be called when the ex command argument should be parsed, and start should be
|
|
* updated. I.e. only for the search commands. Consider using SearchHelper.findPattern to find text.</p>
|
|
*
|
|
* <p>Equivalent to normal.c:nv_search + search.c:do_search</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param startOffset The offset to start searching from
|
|
* @param command The command text entered into the Ex entry panel. Does not include the leading `/` or `?`.
|
|
* Can include a trailing offset, e.g. /{pattern}/{offset}, or multiple commands separated by a semicolon.
|
|
* If the pattern is empty, the last used (search? substitute?) pattern (and offset?) is used.
|
|
* @param dir The direction to search
|
|
* @return Offset to the next occurrence of the pattern or -1 if not found
|
|
*/
|
|
public int processSearchCommand(@NotNull Editor editor, @NotNull String command, int startOffset, @NotNull Direction dir) {
|
|
boolean isNewPattern = false;
|
|
String pattern = null;
|
|
String patternOffset = null;
|
|
|
|
final char type = dir == Direction.FORWARDS ? '/' : '?';
|
|
|
|
if (command.length() > 0) {
|
|
if (command.charAt(0) != type) {
|
|
CharPointer p = new CharPointer(command);
|
|
CharPointer end = RegExp.skip_regexp(p.ref(0), type, true);
|
|
|
|
pattern = p.substring(end.pointer() - p.pointer());
|
|
isNewPattern = true;
|
|
|
|
if (logger.isDebugEnabled()) logger.debug("pattern=" + pattern);
|
|
|
|
if (p.charAt() != type) {
|
|
if (end.charAt() == type) {
|
|
end.inc();
|
|
patternOffset = end.toString();
|
|
} else {
|
|
logger.debug("no offset");
|
|
patternOffset = "";
|
|
}
|
|
}
|
|
else {
|
|
p.inc();
|
|
patternOffset = p.toString();
|
|
if (logger.isDebugEnabled()) logger.debug("offset=" + patternOffset);
|
|
}
|
|
}
|
|
else if (command.length() == 1) {
|
|
patternOffset = "";
|
|
}
|
|
else {
|
|
patternOffset = command.substring(1);
|
|
if (logger.isDebugEnabled()) logger.debug("offset=" + patternOffset);
|
|
}
|
|
}
|
|
|
|
// Vim's logic is spread over several methods (do_search -> searchit -> search_regcomp), and rather tricky to follow
|
|
// When searching, it will search for the given pattern or RE_LAST. Pattern offset always come from RE_SEARCH.
|
|
// If the pattern is explicitly entered, this is saved as RE_SEARCH and this becomes RE_LAST.
|
|
// Pattern offset is also parsed, and is saved (to RE_SEARCH)
|
|
// If the pattern is missing, Vim checks RE_SEARCH:
|
|
// If RE_SEARCH is set, the given pattern is set to an empty string, meaning search will use RE_LAST.
|
|
// If RE_LAST is unset, then error e_noprevre (searchit -> search_regcomp)
|
|
// BUT: RE_LAST is *always* set. The default is RE_SEARCH, which we know is valid. If it's RE_SUBST, it's been
|
|
// explicitly set and is valid.
|
|
// Pattern offset always comes from RE_SEARCH.
|
|
// If RE_SEARCH is unset, fall back to RE_SUBST:
|
|
// If RE_SUBST is set, save this as RE_SEARCH, which becomes RE_LAST
|
|
// RE_SUBST does not have pattern offsets to save, so pattern offset will be RE_SEARCH - unset/default
|
|
// If RE_SUBST is unset, error e_noprevre
|
|
// Pattern offset is always used from RE_SEARCH. Only saved when explicitly entered
|
|
// Direction is saved in do_search
|
|
// IgnoreSmartCase is only ever set for searching words (`*`, `#`, `g*`, etc.) and is reset for all other operations
|
|
|
|
if (pattern == null || pattern.length() == 0) {
|
|
pattern = getLastSearchPattern();
|
|
patternOffset = lastPatternOffset;
|
|
if (pattern == null || pattern.length() == 0) {
|
|
isNewPattern = true;
|
|
pattern = getLastSubstitutePattern();
|
|
if (pattern == null || pattern.length() == 0) {
|
|
VimPlugin.showMessage(MessageHelper.message("e_noprevre"));
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save the pattern. If it's explicitly entered, or comes from RE_SUBST, isNewPattern is true, and this becomes
|
|
// RE_LAST. If it comes from RE_SEARCH, then a) it's not null and b) we know that RE_LAST is already valid.
|
|
setLastUsedPattern(pattern, RE_SEARCH, isNewPattern);
|
|
lastIgnoreSmartCase = false;
|
|
lastPatternOffset = patternOffset; // This might include extra search patterns separated by `;`
|
|
lastDir = dir;
|
|
|
|
if (logger.isDebugEnabled()) {
|
|
logger.debug("lastSearch=" + lastSearch);
|
|
logger.debug("lastOffset=" + lastPatternOffset);
|
|
logger.debug("lastDir=" + lastDir);
|
|
}
|
|
|
|
resetShowSearchHighlight();
|
|
forceUpdateSearchHighlights();
|
|
|
|
return findItOffset(editor, startOffset, 1, lastDir);
|
|
}
|
|
|
|
/**
|
|
* Process the pattern being used as a search range
|
|
*
|
|
* <p>Find the next offset of the search pattern, without processing the pattern further. This is not a full search
|
|
* pattern, as handled by processSearchCommand. It does not contain a pattern offset and there are not multiple
|
|
* patterns separated by `;`. Ranges do support multiple patterns, separation with both `;` and `,` and a `+/-{num}`
|
|
* suffix, but these are all handled by the range itself.</p>
|
|
*
|
|
* <p>This method is essentially a wrapper around SearchHelper.findPattern (via findItOffset) that updates state and
|
|
* highlighting.</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param pattern The pattern to search for. Does not include leading or trailing `/` and `?` characters
|
|
* @param patternOffset The offset applied to the range. Not used during searching, but used to populate lastPatternOffset
|
|
* @param startOffset The offset to start searching from
|
|
* @param direction The direction to search in
|
|
* @return The offset of the match or -1 if not found
|
|
*/
|
|
public int processSearchRange(@NotNull Editor editor, @NotNull String pattern, int patternOffset, int startOffset, @NotNull Direction direction) {
|
|
|
|
// Will set RE_LAST, required by findItOffset
|
|
// IgnoreSmartCase and Direction are always reset.
|
|
// PatternOffset is cleared before searching. ExRanges will add/subtract the line offset from the final search range
|
|
// pattern, but we need the value to update lastPatternOffset for future searches.
|
|
// TODO: Consider improving pattern offset handling
|
|
setLastUsedPattern(pattern, RE_SEARCH, true);
|
|
lastIgnoreSmartCase = false;
|
|
lastPatternOffset = ""; // Do not apply a pattern offset yet!
|
|
lastDir = direction;
|
|
|
|
if (logger.isDebugEnabled()) {
|
|
logger.debug("lastSearch=" + lastSearch);
|
|
logger.debug("lastOffset=" + lastPatternOffset);
|
|
logger.debug("lastDir=" + lastDir);
|
|
}
|
|
|
|
resetShowSearchHighlight();
|
|
forceUpdateSearchHighlights();
|
|
|
|
final int result = findItOffset(editor, startOffset, 1, lastDir);
|
|
|
|
// Set lastPatternOffset AFTER searching so it doesn't affect the result
|
|
lastPatternOffset = patternOffset != 0 ? Integer.toString(patternOffset) : "";
|
|
|
|
if (logger.isDebugEnabled()) {
|
|
logger.debug("lastSearch=" + lastSearch);
|
|
logger.debug("lastOffset=" + lastPatternOffset);
|
|
logger.debug("lastDir=" + lastDir);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Search for the word under the given caret
|
|
*
|
|
* <p>Updates RE_SEARCH and RE_LAST, last pattern offset and direction. Ignore smart case is set to true. Highlights
|
|
* are updated. scanwrap and ignorecase come from options.</p>
|
|
*
|
|
* <p>Equivalent to normal.c:nv_ident</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param caret The caret to use to look for the current word
|
|
* @param count Search for the nth occurrence of the current word
|
|
* @param whole Include word boundaries in the search pattern
|
|
* @param dir Which direction to search
|
|
* @return The offset of the result or the start of the word under the caret if not found. Returns -1 on error
|
|
*/
|
|
public int searchWord(@NotNull Editor editor, @NotNull Caret caret, int count, boolean whole, Direction dir) {
|
|
TextRange range = SearchHelper.findWordUnderCursor(editor, caret);
|
|
if (range == null) {
|
|
logger.warn("No range was found");
|
|
return -1;
|
|
}
|
|
|
|
final String pattern = SearchHelper.makeSearchPattern(EditorHelper.getText(editor, range.getStartOffset(), range.getEndOffset()), whole);
|
|
|
|
// Updates RE_LAST, ready for findItOffset
|
|
// Direction is always saved
|
|
// IgnoreSmartCase is always set to true
|
|
// There is no pattern offset available
|
|
setLastUsedPattern(pattern, RE_SEARCH, true);
|
|
lastIgnoreSmartCase = true;
|
|
lastPatternOffset = "";
|
|
lastDir = dir;
|
|
|
|
resetShowSearchHighlight();
|
|
forceUpdateSearchHighlights();
|
|
|
|
final int offset = findItOffset(editor, range.getStartOffset(), count, lastDir);
|
|
return offset == -1 ? range.getStartOffset() : offset;
|
|
}
|
|
|
|
/**
|
|
* Find the next occurrence of the last used pattern
|
|
*
|
|
* <p>Searches for RE_LAST, including last used pattern offset. Direction is the same as the last used direction.
|
|
* E.g. `?foo` followed by `n` will search backwards. scanwrap and ignorecase come from options.</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param caret Used to get the offset to start searching from
|
|
* @param count Find the nth occurrence
|
|
* @return The offset of the next match, or -1 if not found
|
|
*/
|
|
public int searchNext(@NotNull Editor editor, @NotNull Caret caret, int count) {
|
|
return searchNextWithDirection(editor, caret, count, lastDir);
|
|
}
|
|
|
|
/**
|
|
* Find the next occurrence of the last used pattern
|
|
*
|
|
* <p>Searches for RE_LAST, including last used pattern offset. Direction is the opposite of the last used direction.
|
|
* E.g. `?foo` followed by `N` will be forwards. scanwrap and ignorecase come from options.</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param caret Used to get the offset to starting searching from
|
|
* @param count Find the nth occurrence
|
|
* @return The offset of the next match, or -1 if not found
|
|
*/
|
|
public int searchPrevious(@NotNull Editor editor, @NotNull Caret caret, int count) {
|
|
return searchNextWithDirection(editor, caret, count, lastDir.reverse());
|
|
}
|
|
|
|
// See normal.c:nv_next
|
|
private int searchNextWithDirection(@NotNull Editor editor, @NotNull Caret caret, int count, Direction dir) {
|
|
resetShowSearchHighlight();
|
|
updateSearchHighlights();
|
|
final int startOffset = caret.getOffset();
|
|
int offset = findItOffset(editor, startOffset, count, dir);
|
|
if (offset == startOffset) {
|
|
/* Avoid getting stuck on the current cursor position, which can
|
|
* happen when an offset is given and the cursor is on the last char
|
|
* in the buffer: Repeat with count + 1. */
|
|
offset = findItOffset(editor, startOffset, count + 1, dir);
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
|
|
// *******************************************************************************************************************
|
|
//
|
|
// Substitute
|
|
//
|
|
// *******************************************************************************************************************
|
|
|
|
/**
|
|
* Parse and execute the substitute command
|
|
*
|
|
* <p>Updates state for the last substitute pattern (RE_SUBST and RE_LAST) and last replacement text. Updates search
|
|
* history and register. Also updates stored substitution flags.</p>
|
|
*
|
|
* <p>Saves the current location as a jump location and restores caret location after completion. If confirmation is
|
|
* enabled and the substitution is abandoned, the current caret location is kept, and the original location is not
|
|
* restored.</p>
|
|
*
|
|
* <p>See ex_cmds.c:ex_substitute</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param caret The caret to use for initial search offset, and to move for interactive substitution
|
|
* @param range Only search and substitute within the given line range. Must be valid
|
|
* @param excmd The command part of the ex command line, e.g. `s` or `substitute`, or `~`
|
|
* @param exarg The argument to the substitute command, such as `/{pattern}/{string}/[flags]`
|
|
* @return True if the substitution succeeds, false on error. Will succeed even if nothing is modified
|
|
*/
|
|
@RWLockLabel.SelfSynchronized
|
|
public boolean processSubstituteCommand(@NotNull Editor editor, @NotNull Caret caret, @NotNull LineRange range,
|
|
@NotNull @NonNls String excmd, @NonNls String exarg, @NotNull VimLContext parent) {
|
|
// Explicitly exit visual mode here, so that visual mode marks don't change when we move the cursor to a match.
|
|
List<ExException> exceptions = new ArrayList<>();
|
|
if (CommandStateHelper.inVisualMode(editor)) {
|
|
ModeHelper.exitVisualMode(editor);
|
|
}
|
|
|
|
CharPointer cmd = new CharPointer(new StringBuffer(exarg));
|
|
|
|
int which_pat;
|
|
if ("~".equals(excmd)) {
|
|
which_pat = RE_LAST; /* use last used regexp */
|
|
}
|
|
else {
|
|
which_pat = RE_SUBST; /* use last substitute regexp */
|
|
}
|
|
|
|
CharPointer pat;
|
|
CharPointer sub;
|
|
char delimiter;
|
|
/* new pattern and substitution */
|
|
if (excmd.charAt(0) == 's' && !cmd.isNul() && !Character.isWhitespace(
|
|
cmd.charAt()) && "0123456789cegriIp|\"".indexOf(cmd.charAt()) == -1) {
|
|
/* don't accept alphanumeric for separator */
|
|
if (CharacterClasses.isAlpha(cmd.charAt())) {
|
|
VimPlugin.showMessage(MessageHelper.message(Msg.E146));
|
|
return false;
|
|
}
|
|
/*
|
|
* undocumented vi feature:
|
|
* "\/sub/" and "\?sub?" use last used search pattern (almost like
|
|
* //sub/r). "\&sub&" use last substitute pattern (like //sub/).
|
|
*/
|
|
if (cmd.charAt() == '\\') {
|
|
cmd.inc();
|
|
if ("/?&".indexOf(cmd.charAt()) == -1) {
|
|
VimPlugin.showMessage(MessageHelper.message(Msg.e_backslash));
|
|
return false;
|
|
}
|
|
if (cmd.charAt() != '&') {
|
|
which_pat = RE_SEARCH; /* use last '/' pattern */
|
|
}
|
|
pat = new CharPointer(""); /* empty search pattern */
|
|
delimiter = cmd.charAt(); /* remember delimiter character */
|
|
cmd.inc();
|
|
}
|
|
else {
|
|
/* find the end of the regexp */
|
|
which_pat = RE_LAST; /* use last used regexp */
|
|
delimiter = cmd.charAt(); /* remember delimiter character */
|
|
cmd.inc();
|
|
pat = cmd.ref(0); /* remember start of search pat */
|
|
cmd = RegExp.skip_regexp(cmd, delimiter, true);
|
|
if (cmd.charAt() == delimiter) /* end delimiter found */ {
|
|
cmd.set('\u0000').inc(); /* replace it with a NUL */
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Small incompatibility: vi sees '\n' as end of the command, but in
|
|
* Vim we want to use '\n' to find/substitute a NUL.
|
|
*/
|
|
sub = cmd.ref(0); /* remember the start of the substitution */
|
|
|
|
while (!cmd.isNul()) {
|
|
if (cmd.charAt() == delimiter) /* end delimiter found */ {
|
|
cmd.set('\u0000').inc(); /* replace it with a NUL */
|
|
break;
|
|
}
|
|
if (cmd.charAt(0) == '\\' && cmd.charAt(1) != 0) /* skip escaped characters */ {
|
|
cmd.inc();
|
|
}
|
|
cmd.inc();
|
|
}
|
|
}
|
|
else {
|
|
/* use previous pattern and substitution */
|
|
if (lastReplace == null) {
|
|
/* there is no previous command */
|
|
VimPlugin.showMessage(MessageHelper.message(Msg.e_nopresub));
|
|
return false;
|
|
}
|
|
pat = null; /* search_regcomp() will use previous pattern */
|
|
sub = new CharPointer(lastReplace);
|
|
}
|
|
|
|
/*
|
|
* Find trailing options. When '&' is used, keep old options.
|
|
*/
|
|
if (cmd.charAt() == '&') {
|
|
cmd.inc();
|
|
}
|
|
else {
|
|
// :h :&& - "Note that :s and :& don't keep the flags"
|
|
do_all = VimPlugin.getOptionService().isSet(new OptionScope.LOCAL(new IjVimEditor(editor)), OptionConstants.gdefaultName, OptionConstants.gdefaultName);
|
|
do_ask = false;
|
|
do_error = true;
|
|
do_ic = 0;
|
|
}
|
|
while (!cmd.isNul()) {
|
|
/*
|
|
* Note that 'g' and 'c' are always inverted, also when p_ed is off.
|
|
* 'r' is never inverted.
|
|
*/
|
|
if (cmd.charAt() == 'g') {
|
|
do_all = !do_all;
|
|
}
|
|
else if (cmd.charAt() == 'c') {
|
|
do_ask = !do_ask;
|
|
}
|
|
else if (cmd.charAt() == 'e') {
|
|
do_error = !do_error;
|
|
}
|
|
else if (cmd.charAt() == 'r') {
|
|
/* use last used regexp */
|
|
which_pat = RE_LAST;
|
|
}
|
|
else if (cmd.charAt() == 'i') {
|
|
/* ignore case */
|
|
do_ic = 'i';
|
|
}
|
|
else if (cmd.charAt() == 'I') {
|
|
/* don't ignore case */
|
|
do_ic = 'I';
|
|
}
|
|
else if (cmd.charAt() != 'p' && cmd.charAt() != 'l' && cmd.charAt() != '#' && cmd.charAt() != 'n') {
|
|
// TODO: Support printing last changed line, with options for line number/list format
|
|
// TODO: Support 'n' to report number of matches without substituting
|
|
break;
|
|
}
|
|
cmd.inc();
|
|
}
|
|
|
|
int line1 = range.startLine;
|
|
int line2 = range.endLine;
|
|
|
|
if (line1 < 0 || line2 < 0) {
|
|
return false;
|
|
}
|
|
|
|
/*
|
|
* check for a trailing count
|
|
*/
|
|
cmd.skipWhitespaces();
|
|
if (Character.isDigit(cmd.charAt())) {
|
|
int i = cmd.getDigits();
|
|
if (i <= 0 && do_error) {
|
|
VimPlugin.showMessage(MessageHelper.message(Msg.e_zerocount));
|
|
return false;
|
|
}
|
|
line1 = line2;
|
|
line2 = EditorHelper.normalizeLine(editor, line1 + i - 1);
|
|
}
|
|
|
|
/*
|
|
* check for trailing command or garbage
|
|
*/
|
|
cmd.skipWhitespaces();
|
|
if (!cmd.isNul() && cmd.charAt() != '"') {
|
|
/* if not end-of-line or comment */
|
|
VimPlugin.showMessage(MessageHelper.message(Msg.e_trailing));
|
|
return false;
|
|
}
|
|
|
|
Pair<Boolean, Trinity<RegExp.regmmatch_T, String, RegExp>> booleanregmmatch_tPair = search_regcomp(pat, which_pat,
|
|
RE_SUBST);
|
|
if (!booleanregmmatch_tPair.getFirst()) {
|
|
if (do_error) {
|
|
VimPlugin.showMessage(MessageHelper.message(Msg.e_invcmd));
|
|
VimPlugin.indicateError();
|
|
}
|
|
return false;
|
|
}
|
|
RegExp.regmmatch_T regmatch = booleanregmmatch_tPair.getSecond().getFirst();
|
|
String pattern = booleanregmmatch_tPair.getSecond().getSecond();
|
|
RegExp sp = booleanregmmatch_tPair.getSecond().getThird();
|
|
|
|
/* the 'i' or 'I' flag overrules 'ignorecase' and 'smartcase' */
|
|
if (do_ic == 'i') {
|
|
regmatch.rmm_ic = true;
|
|
}
|
|
else if (do_ic == 'I') {
|
|
regmatch.rmm_ic = false;
|
|
}
|
|
|
|
/*
|
|
* ~ in the substitute pattern is replaced with the old pattern.
|
|
* We do it here once to avoid it to be replaced over and over again.
|
|
* But don't do it when it starts with "\=", then it's an expression.
|
|
*/
|
|
if (!(sub.charAt(0) == '\\' && sub.charAt(1) == '=') && lastReplace != null) {
|
|
StringBuffer tmp = new StringBuffer(sub.toString());
|
|
int pos = 0;
|
|
while ((pos = tmp.indexOf("~", pos)) != -1) {
|
|
if (pos == 0 || tmp.charAt(pos - 1) != '\\') {
|
|
tmp.replace(pos, pos + 1, lastReplace);
|
|
pos += lastReplace.length();
|
|
}
|
|
pos++;
|
|
}
|
|
sub = new CharPointer(tmp);
|
|
}
|
|
|
|
lastReplace = sub.toString();
|
|
|
|
resetShowSearchHighlight();
|
|
forceUpdateSearchHighlights();
|
|
|
|
int start = editor.getDocument().getLineStartOffset(line1);
|
|
int end = editor.getDocument().getLineEndOffset(line2);
|
|
|
|
if (logger.isDebugEnabled()) {
|
|
logger.debug("search range=[" + start + "," + end + "]");
|
|
logger.debug("pattern=" + pattern + ", replace=" + sub);
|
|
}
|
|
|
|
int lastMatch = -1;
|
|
int lastLine = -1;
|
|
int searchcol = 0;
|
|
boolean firstMatch = true;
|
|
boolean got_quit = false;
|
|
int lcount = EditorHelper.getLineCount(editor);
|
|
Expression expression = null;
|
|
int latestOff = -1;
|
|
for (int lnum = line1; lnum <= line2 && !got_quit; ) {
|
|
CharacterPosition newpos = null;
|
|
int nmatch = sp.vim_regexec_multi(regmatch, editor, lcount, lnum, searchcol);
|
|
if (nmatch > 0) {
|
|
if (firstMatch) {
|
|
VimPlugin.getMark().saveJumpLocation(editor);
|
|
firstMatch = false;
|
|
}
|
|
|
|
|
|
String match = sp.vim_regsub_multi(regmatch, lnum, sub, 1, false);
|
|
if (sub.charAt(0) == '\\' && sub.charAt(1) == '=') {
|
|
String exprString = sub.toString().substring(2);
|
|
expression = VimscriptParser.INSTANCE.parseExpression(exprString);
|
|
if (expression == null) {
|
|
exceptions.add(new ExException("E15: Invalid expression: " + exprString));
|
|
expression = new SimpleExpression(new VimString(""));
|
|
}
|
|
} else if (match == null) {
|
|
return false;
|
|
}
|
|
|
|
int line = lnum + regmatch.startpos[0].lnum;
|
|
CharacterPosition startpos = new CharacterPosition(lnum + regmatch.startpos[0].lnum, regmatch.startpos[0].col);
|
|
CharacterPosition endpos = new CharacterPosition(lnum + regmatch.endpos[0].lnum, regmatch.endpos[0].col);
|
|
int startoff = startpos.toOffset(editor);
|
|
int endoff = endpos.toOffset(editor);
|
|
int newend = startoff + match.length();
|
|
|
|
if (do_all || line != lastLine) {
|
|
boolean doReplace = true;
|
|
if (do_ask) {
|
|
RangeHighlighter hl = SearchHighlightsHelper.addSubstitutionConfirmationHighlight(editor, startoff, endoff);
|
|
final ReplaceConfirmationChoice choice = confirmChoice(editor, match, caret, startoff);
|
|
editor.getMarkupModel().removeHighlighter(hl);
|
|
switch (choice) {
|
|
case SUBSTITUTE_THIS:
|
|
doReplace = true;
|
|
break;
|
|
case SKIP:
|
|
doReplace = false;
|
|
break;
|
|
case SUBSTITUTE_ALL:
|
|
do_ask = false;
|
|
break;
|
|
case QUIT:
|
|
doReplace = false;
|
|
got_quit = true;
|
|
break;
|
|
case SUBSTITUTE_LAST:
|
|
do_all = false;
|
|
line2 = lnum;
|
|
doReplace = true;
|
|
break;
|
|
}
|
|
}
|
|
if (doReplace && startoff != latestOff) {
|
|
latestOff = startoff;
|
|
SubmatchFunctionHandler.INSTANCE.setLatestMatch(editor.getDocument().getText(new com.intellij.openapi.util.TextRange(startoff, endoff)));
|
|
MotionGroup.moveCaret(editor, caret, startoff);
|
|
if (expression != null) {
|
|
try {
|
|
match = expression
|
|
.evaluate(editor, EditorDataContext.init(editor, null), parent)
|
|
.toInsertableString();
|
|
} catch (Exception e) {
|
|
exceptions.add((ExException) e);
|
|
match = "";
|
|
}
|
|
}
|
|
|
|
String finalMatch = match;
|
|
ApplicationManager.getApplication().runWriteAction(() -> editor.getDocument().replaceString(startoff, endoff,
|
|
finalMatch));
|
|
lastMatch = startoff;
|
|
newpos = CharacterPosition.Companion.fromOffset(editor, newend);
|
|
|
|
lnum += newpos.line - endpos.line;
|
|
line2 += newpos.line - endpos.line;
|
|
}
|
|
}
|
|
|
|
lastLine = line;
|
|
|
|
lnum += nmatch - 1;
|
|
if (do_all && startoff != endoff) {
|
|
if (newpos != null) {
|
|
lnum = newpos.line;
|
|
searchcol = newpos.column;
|
|
}
|
|
else {
|
|
searchcol = endpos.column;
|
|
}
|
|
}
|
|
else {
|
|
searchcol = 0;
|
|
lnum++;
|
|
}
|
|
}
|
|
else {
|
|
lnum++;
|
|
searchcol = 0;
|
|
}
|
|
}
|
|
|
|
if (!got_quit) {
|
|
if (lastMatch != -1) {
|
|
MotionGroup.moveCaret(editor, caret,
|
|
VimPlugin.getMotion().moveCaretToLineStartSkipLeading(editor, editor.offsetToLogicalPosition(lastMatch).line));
|
|
}
|
|
else {
|
|
VimPlugin.showMessage(MessageHelper.message(Msg.e_patnotf2, pattern));
|
|
}
|
|
}
|
|
|
|
SubmatchFunctionHandler.INSTANCE.setLatestMatch("");
|
|
|
|
// todo throw multiple exceptions at once
|
|
if (!exceptions.isEmpty()) {
|
|
VimPlugin.indicateError();
|
|
VimPlugin.showMessage(exceptions.get(0).toString());
|
|
}
|
|
|
|
// TODO: Support reporting number of changes (:help 'report')
|
|
|
|
return true;
|
|
}
|
|
|
|
public Pair<Boolean, Trinity<RegExp.regmmatch_T, String, RegExp>> search_regcomp(CharPointer pat,
|
|
int which_pat,
|
|
int patSave) {
|
|
// We don't need to worry about lastIgnoreSmartCase, it's always false. Vim resets after checking, and it only sets
|
|
// it to true when searching for a word with `*`, `#`, `g*`, etc.
|
|
boolean isNewPattern = true;
|
|
String pattern = "";
|
|
if (pat == null || pat.isNul()) {
|
|
isNewPattern = false;
|
|
if (which_pat == RE_LAST) {
|
|
which_pat = lastPatternIdx;
|
|
}
|
|
String errorMessage = null;
|
|
switch (which_pat) {
|
|
case RE_SEARCH:
|
|
pattern = lastSearch;
|
|
errorMessage = MessageHelper.message("e_nopresub");
|
|
break;
|
|
case RE_SUBST:
|
|
pattern = lastSubstitute;
|
|
errorMessage = MessageHelper.message("e_noprevre");
|
|
break;
|
|
}
|
|
|
|
// Pattern was never defined
|
|
if (pattern == null) {
|
|
VimPlugin.showMessage(errorMessage);
|
|
return new Pair<>(false, null);
|
|
}
|
|
}
|
|
else {
|
|
pattern = pat.toString();
|
|
}
|
|
|
|
// Set RE_SUBST and RE_LAST, but only for explicitly typed patterns. Reused patterns are not saved/updated
|
|
setLastUsedPattern(pattern, patSave, isNewPattern);
|
|
|
|
// Always reset after checking, only set for nv_ident
|
|
lastIgnoreSmartCase = false;
|
|
// Substitute does NOT reset last direction or pattern offset!
|
|
|
|
RegExp sp;
|
|
RegExp.regmmatch_T regmatch = new RegExp.regmmatch_T();
|
|
regmatch.rmm_ic = shouldIgnoreCase(pattern, false);
|
|
sp = new RegExp();
|
|
regmatch.regprog = sp.vim_regcomp(pattern, 1);
|
|
if (regmatch.regprog == null) {
|
|
return new Pair<>(false, null);
|
|
}
|
|
return new Pair<>(true, new Trinity<>(regmatch, pattern, sp));
|
|
}
|
|
|
|
private static @NotNull ReplaceConfirmationChoice confirmChoice(@NotNull Editor editor, @NotNull String match, @NotNull Caret caret, int startoff) {
|
|
final Ref<ReplaceConfirmationChoice> result = Ref.create(ReplaceConfirmationChoice.QUIT);
|
|
final Function1<KeyStroke, Boolean> keyStrokeProcessor = key -> {
|
|
final ReplaceConfirmationChoice choice;
|
|
final char c = key.getKeyChar();
|
|
if (StringHelper.isCloseKeyStroke(key) || c == 'q') {
|
|
choice = ReplaceConfirmationChoice.QUIT;
|
|
} else if (c == 'y') {
|
|
choice = ReplaceConfirmationChoice.SUBSTITUTE_THIS;
|
|
} else if (c == 'l') {
|
|
choice = ReplaceConfirmationChoice.SUBSTITUTE_LAST;
|
|
} else if (c == 'n') {
|
|
choice = ReplaceConfirmationChoice.SKIP;
|
|
} else if (c == 'a') {
|
|
choice = ReplaceConfirmationChoice.SUBSTITUTE_ALL;
|
|
} else {
|
|
return true;
|
|
}
|
|
// TODO: Handle <C-E> and <C-Y>
|
|
result.set(choice);
|
|
return false;
|
|
};
|
|
if (ApplicationManager.getApplication().isUnitTestMode()) {
|
|
MotionGroup.moveCaret(editor, caret, startoff);
|
|
final TestInputModel inputModel = TestInputModel.getInstance(editor);
|
|
for (KeyStroke key = inputModel.nextKeyStroke(); key != null; key = inputModel.nextKeyStroke()) {
|
|
if (!keyStrokeProcessor.invoke(key)) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
// XXX: The Ex entry panel is used only for UI here, its logic might be inappropriate for this method
|
|
final ExEntryPanel exEntryPanel = ExEntryPanel.getInstanceWithoutShortcuts();
|
|
exEntryPanel.activate(editor, EditorDataContext.init(editor, null), MessageHelper.message("replace.with.0", match), "", 1);
|
|
MotionGroup.moveCaret(editor, caret, startoff);
|
|
ModalEntry.INSTANCE.activate(keyStrokeProcessor);
|
|
exEntryPanel.deactivate(true, false);
|
|
}
|
|
return result.get();
|
|
}
|
|
|
|
|
|
// *******************************************************************************************************************
|
|
//
|
|
// gn implementation
|
|
//
|
|
// *******************************************************************************************************************
|
|
|
|
/**
|
|
* Find the range of the next occurrence of the last used search pattern
|
|
*
|
|
* <p>Used for the implementation of the gn and gN commands.</p>
|
|
*
|
|
* <p>Searches for the range of the next occurrence of the last used search pattern (RE_LAST). If the current primary
|
|
* caret is inside the range of an occurrence, will return that instance. Uses the last used search pattern. Does not
|
|
* update any other state. Direction is explicit, not from state.</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param count Find the nth occurrence
|
|
* @param forwards Search forwards or backwards
|
|
* @return The TextRange of the next occurrence or null if not found
|
|
*/
|
|
public @Nullable TextRange getNextSearchRange(@NotNull Editor editor, int count, boolean forwards) {
|
|
editor.getCaretModel().removeSecondaryCarets();
|
|
TextRange current = findUnderCaret(editor);
|
|
|
|
if (current == null || CommandStateHelper.inVisualMode(editor) && atEdgeOfGnRange(current, editor, forwards)) {
|
|
current = findNextSearchForGn(editor, count, forwards);
|
|
}
|
|
else if (count > 1) {
|
|
current = findNextSearchForGn(editor, count - 1, forwards);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
private boolean atEdgeOfGnRange(@NotNull TextRange nextRange, @NotNull Editor editor, boolean forwards) {
|
|
int currentPosition = editor.getCaretModel().getOffset();
|
|
if (forwards) {
|
|
return nextRange.getEndOffset() - VimPlugin.getVisualMotion().getSelectionAdj() == currentPosition;
|
|
}
|
|
else {
|
|
return nextRange.getStartOffset() == currentPosition;
|
|
}
|
|
}
|
|
|
|
private @Nullable TextRange findNextSearchForGn(@NotNull Editor editor, int count, boolean forwards) {
|
|
if (forwards) {
|
|
final EnumSet<SearchOptions> searchOptions = EnumSet.of(SearchOptions.WRAP, SearchOptions.WHOLE_FILE);
|
|
return SearchHelper.findPattern(editor, getLastUsedPattern(), editor.getCaretModel().getOffset(), count, searchOptions);
|
|
} else {
|
|
return searchBackward(editor, editor.getCaretModel().getOffset(), count);
|
|
}
|
|
}
|
|
|
|
private @Nullable TextRange findUnderCaret(@NotNull Editor editor) {
|
|
final TextRange backSearch = searchBackward(editor, editor.getCaretModel().getOffset() + 1, 1);
|
|
if (backSearch == null) return null;
|
|
return backSearch.contains(editor.getCaretModel().getOffset()) ? backSearch : null;
|
|
}
|
|
|
|
private @Nullable TextRange searchBackward(@NotNull Editor editor, int offset, int count) {
|
|
// Backward search returns wrongs end offset for some cases. That's why we should perform additional forward search
|
|
final EnumSet<SearchOptions> searchOptions = EnumSet.of(SearchOptions.WRAP, SearchOptions.WHOLE_FILE, SearchOptions.BACKWARDS);
|
|
final TextRange foundBackward = SearchHelper.findPattern(editor, getLastUsedPattern(), offset, count, searchOptions);
|
|
if (foundBackward == null) return null;
|
|
int startOffset = foundBackward.getStartOffset() - 1;
|
|
if (startOffset < 0) startOffset = EditorHelperRt.getFileSize(editor);
|
|
searchOptions.remove(SearchOptions.BACKWARDS);
|
|
return SearchHelper.findPattern(editor, getLastUsedPattern(), startOffset, 1, searchOptions);
|
|
}
|
|
|
|
|
|
// *******************************************************************************************************************
|
|
//
|
|
// Highlighting
|
|
//
|
|
// *******************************************************************************************************************
|
|
//region Search highlights
|
|
public void clearSearchHighlight() {
|
|
showSearchHighlight = false;
|
|
updateSearchHighlights();
|
|
}
|
|
|
|
private void forceUpdateSearchHighlights() {
|
|
// Sync the search highlights to the current state, potentially hiding or showing highlights. Will always update,
|
|
// even if the pattern hasn't changed.
|
|
SearchHighlightsHelper.updateSearchHighlights(getLastUsedPattern(), lastIgnoreSmartCase, showSearchHighlight, true);
|
|
}
|
|
|
|
private void updateSearchHighlights() {
|
|
// Sync the search highlights to the current state, potentially hiding or showing highlights. Will only update if
|
|
// the pattern has changed.
|
|
SearchHighlightsHelper.updateSearchHighlights(getLastUsedPattern(), lastIgnoreSmartCase, showSearchHighlight, false);
|
|
}
|
|
|
|
/**
|
|
* Reset the search highlights to the last used pattern after highlighting incsearch results.
|
|
*/
|
|
public void resetIncsearchHighlights() {
|
|
SearchHighlightsHelper.updateSearchHighlights(getLastUsedPattern(), lastIgnoreSmartCase, showSearchHighlight, true);
|
|
}
|
|
|
|
private void resetShowSearchHighlight() {
|
|
showSearchHighlight = VimPlugin.getOptionService().isSet(OptionScope.GLOBAL.INSTANCE, OptionConstants.hlsearchName, OptionConstants.hlsearchName);
|
|
}
|
|
|
|
private void highlightSearchLines(@NotNull Editor editor, int startLine, int endLine) {
|
|
final String pattern = getLastUsedPattern();
|
|
if (pattern != null) {
|
|
final List<TextRange> results = SearchHelper.findAll(editor, pattern, startLine, endLine,
|
|
shouldIgnoreCase(pattern, lastIgnoreSmartCase));
|
|
SearchHighlightsHelper.highlightSearchResults(editor, pattern, results, -1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates search highlights when the selected editor changes
|
|
*/
|
|
public static void fileEditorManagerSelectionChangedCallback(@SuppressWarnings("unused") @NotNull FileEditorManagerEvent event) {
|
|
VimPlugin.getSearch().updateSearchHighlights();
|
|
}
|
|
|
|
/**
|
|
* Removes and adds highlights for current search pattern when the document is edited
|
|
*/
|
|
public static class DocumentSearchListener implements DocumentListener {
|
|
|
|
public static DocumentSearchListener INSTANCE = new DocumentSearchListener();
|
|
|
|
@Contract(pure = true)
|
|
private DocumentSearchListener () {
|
|
}
|
|
|
|
@Override
|
|
public void documentChanged(@NotNull DocumentEvent event) {
|
|
for (Project project : ProjectManager.getInstance().getOpenProjects()) {
|
|
final Document document = event.getDocument();
|
|
|
|
for (Editor editor : localEditors(document, project)) {
|
|
Collection<RangeHighlighter> hls = UserDataManager.getVimLastHighlighters(editor);
|
|
if (hls == null) {
|
|
continue;
|
|
}
|
|
|
|
if (logger.isDebugEnabled()) {
|
|
logger.debug("hls=" + hls);
|
|
logger.debug("event=" + event);
|
|
}
|
|
|
|
// We can only re-highlight whole lines, so clear any highlights in the affected lines
|
|
final LogicalPosition startPosition = editor.offsetToLogicalPosition(event.getOffset());
|
|
final LogicalPosition endPosition = editor.offsetToLogicalPosition(event.getOffset() + event.getNewLength());
|
|
final int startLineOffset = document.getLineStartOffset(startPosition.line);
|
|
final int endLineOffset = document.getLineEndOffset(endPosition.line);
|
|
|
|
final Iterator<RangeHighlighter> iter = hls.iterator();
|
|
while (iter.hasNext()) {
|
|
final RangeHighlighter highlighter = iter.next();
|
|
if (!highlighter.isValid() || (highlighter.getStartOffset() >= startLineOffset && highlighter.getEndOffset() <= endLineOffset)) {
|
|
iter.remove();
|
|
editor.getMarkupModel().removeHighlighter(highlighter);
|
|
}
|
|
}
|
|
|
|
VimPlugin.getSearch().highlightSearchLines(editor, startPosition.line, endPosition.line);
|
|
|
|
if (logger.isDebugEnabled()) {
|
|
hls = UserDataManager.getVimLastHighlighters(editor);
|
|
logger.debug("sl=" + startPosition.line + ", el=" + endPosition.line);
|
|
logger.debug("hls=" + hls);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
//endregion
|
|
|
|
|
|
// *******************************************************************************************************************
|
|
//
|
|
// Implementation details
|
|
//
|
|
// *******************************************************************************************************************
|
|
|
|
/**
|
|
* Searches for the RE_LAST saved pattern, applying the last saved pattern offset. Will loop over trailing search
|
|
* commands.
|
|
*
|
|
* <p>Make sure that RE_LAST has been updated before calling this. wrapscan and ignorecase come from options.</p>
|
|
*
|
|
* <p>See search.c:do_search (and a little bit of normal.c:normal_search)</p>
|
|
*
|
|
* @param editor The editor to search in
|
|
* @param startOffset The offset to search from
|
|
* @param count Find the nth occurrence
|
|
* @param dir The direction to search in
|
|
* @return The offset to the occurrence or -1 if not found
|
|
*/
|
|
private int findItOffset(@NotNull Editor editor, int startOffset, int count, Direction dir) {
|
|
boolean wrap = VimPlugin.getOptionService().isSet(OptionScope.GLOBAL.INSTANCE, OptionConstants.wrapscanName, OptionConstants.wrapscanName);
|
|
logger.debug("Perform search. Direction: " + dir + " wrap: " + wrap);
|
|
|
|
int offset = 0;
|
|
boolean offsetIsLineOffset = false;
|
|
boolean hasEndOffset = false;
|
|
|
|
ParsePosition pp = new ParsePosition(0);
|
|
|
|
if (lastPatternOffset.length() > 0) {
|
|
if (Character.isDigit(lastPatternOffset.charAt(0)) || lastPatternOffset.charAt(0) == '+' || lastPatternOffset.charAt(0) == '-') {
|
|
offsetIsLineOffset = true;
|
|
|
|
if (lastPatternOffset.equals("+")) {
|
|
offset = 1;
|
|
} else if (lastPatternOffset.equals("-")) {
|
|
offset = -1;
|
|
} else {
|
|
if (lastPatternOffset.charAt(0) == '+') {
|
|
lastPatternOffset = lastPatternOffset.substring(1);
|
|
}
|
|
NumberFormat nf = NumberFormat.getIntegerInstance();
|
|
pp = new ParsePosition(0);
|
|
Number num = nf.parse(lastPatternOffset, pp);
|
|
if (num != null) {
|
|
offset = num.intValue();
|
|
}
|
|
}
|
|
} else if ("ebs".indexOf(lastPatternOffset.charAt(0)) != -1) {
|
|
if (lastPatternOffset.length() >= 2) {
|
|
if ("+-".indexOf(lastPatternOffset.charAt(1)) != -1) {
|
|
offset = 1;
|
|
}
|
|
NumberFormat nf = NumberFormat.getIntegerInstance();
|
|
pp = new ParsePosition(lastPatternOffset.charAt(1) == '+' ? 2 : 1);
|
|
Number num = nf.parse(lastPatternOffset, pp);
|
|
if (num != null) {
|
|
offset = num.intValue();
|
|
}
|
|
}
|
|
|
|
hasEndOffset = lastPatternOffset.charAt(0) == 'e';
|
|
}
|
|
}
|
|
|
|
/*
|
|
* If there is a character offset, subtract it from the current
|
|
* position, so we don't get stuck at "?pat?e+2" or "/pat/s-2".
|
|
* Skip this if pos.col is near MAXCOL (closed fold).
|
|
* This is not done for a line offset, because then we would not be vi
|
|
* compatible.
|
|
*/
|
|
if (!offsetIsLineOffset && offset != 0) {
|
|
startOffset = Math.max(0, Math.min(startOffset - offset, EditorHelperRt.getFileSize(editor) - 1));
|
|
}
|
|
|
|
EnumSet<SearchOptions> searchOptions = EnumSet.of(SearchOptions.SHOW_MESSAGES, SearchOptions.WHOLE_FILE);
|
|
if (dir == Direction.BACKWARDS) searchOptions.add(SearchOptions.BACKWARDS);
|
|
if (lastIgnoreSmartCase) searchOptions.add(SearchOptions.IGNORE_SMARTCASE);
|
|
if (wrap) searchOptions.add(SearchOptions.WRAP);
|
|
if (hasEndOffset) searchOptions.add(SearchOptions.WANT_ENDPOS);
|
|
|
|
// Uses RE_LAST. We know this is always set before being called
|
|
TextRange range = SearchHelper.findPattern(editor, getLastUsedPattern(), startOffset, count, searchOptions);
|
|
if (range == null) {
|
|
logger.warn("No range is found");
|
|
return -1;
|
|
}
|
|
|
|
int res = range.getStartOffset();
|
|
|
|
if (offsetIsLineOffset) {
|
|
int line = editor.offsetToLogicalPosition(range.getStartOffset()).line;
|
|
int newLine = EditorHelper.normalizeLine(editor, line + offset);
|
|
|
|
// TODO: Don't move the caret!
|
|
res = VimPlugin.getMotion().moveCaretToLineStart(editor, newLine);
|
|
}
|
|
else if (hasEndOffset || offset != 0) {
|
|
int base = hasEndOffset ? range.getEndOffset() - 1 : range.getStartOffset();
|
|
res = Math.max(0, Math.min(base + offset, EditorHelperRt.getFileSize(editor) - 1));
|
|
}
|
|
|
|
int ppos = pp.getIndex();
|
|
if (ppos < lastPatternOffset.length() - 1 && lastPatternOffset.charAt(ppos) == ';') {
|
|
final Direction nextDir;
|
|
if (lastPatternOffset.charAt(ppos + 1) == '/') {
|
|
nextDir = Direction.FORWARDS;
|
|
}
|
|
else if (lastPatternOffset.charAt(ppos + 1) == '?') {
|
|
nextDir = Direction.BACKWARDS;
|
|
}
|
|
else {
|
|
return res;
|
|
}
|
|
|
|
if (lastPatternOffset.length() - ppos > 2) {
|
|
ppos++;
|
|
}
|
|
|
|
res = processSearchCommand(editor, lastPatternOffset.substring(ppos + 1), res, nextDir);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
|
|
// *******************************************************************************************************************
|
|
//
|
|
// Persistent state
|
|
//
|
|
// *******************************************************************************************************************
|
|
//region Persistent state
|
|
public void saveData(@NotNull Element element) {
|
|
logger.debug("saveData");
|
|
Element search = new Element("search");
|
|
|
|
addOptionalTextElement(search, "last-search", lastSearch);
|
|
addOptionalTextElement(search, "last-substitute", lastSubstitute);
|
|
addOptionalTextElement(search, "last-offset", lastPatternOffset.length() > 0 ? lastPatternOffset : null);
|
|
addOptionalTextElement(search, "last-replace", lastReplace);
|
|
addOptionalTextElement(search, "last-pattern", lastPatternIdx == RE_SEARCH ? lastSearch : lastSubstitute);
|
|
addOptionalTextElement(search, "last-dir", Integer.toString(lastDir.toInt()));
|
|
addOptionalTextElement(search, "show-last", Boolean.toString(showSearchHighlight));
|
|
|
|
element.addContent(search);
|
|
}
|
|
|
|
private static void addOptionalTextElement(@NotNull Element element, @NotNull String name, @Nullable String text) {
|
|
if (text != null) {
|
|
element.addContent(StringHelper.setSafeXmlText(new Element(name), text));
|
|
}
|
|
}
|
|
|
|
public void readData(@NotNull Element element) {
|
|
logger.debug("readData");
|
|
Element search = element.getChild("search");
|
|
if (search == null) {
|
|
return;
|
|
}
|
|
|
|
lastSearch = getSafeChildText(search, "last-search");
|
|
lastSubstitute = getSafeChildText(search, "last-substitute");
|
|
lastReplace = getSafeChildText(search, "last-replace");
|
|
lastPatternOffset = getSafeChildText(search, "last-offset", "");
|
|
|
|
final String lastPatternText = getSafeChildText(search, "last-pattern");
|
|
if (lastPatternText == null || lastPatternText.equals(lastSearch)) {
|
|
lastPatternIdx = RE_SEARCH;
|
|
}
|
|
else {
|
|
lastPatternIdx = RE_SUBST;
|
|
}
|
|
|
|
Element dir = search.getChild("last-dir");
|
|
try {
|
|
lastDir = Direction.Companion.fromInt(Integer.parseInt(dir.getText()));
|
|
}
|
|
catch (NumberFormatException e) {
|
|
lastDir = Direction.FORWARDS;
|
|
}
|
|
|
|
Element show = search.getChild("show-last");
|
|
final String vimInfo = ((VimString) VimPlugin.getOptionService().getOptionValue(OptionScope.GLOBAL.INSTANCE, OptionConstants.viminfoName, OptionConstants.viminfoName)).getValue();
|
|
final boolean disableHighlight = Set.of(vimInfo.split(",")).contains("h");
|
|
showSearchHighlight = !disableHighlight && Boolean.parseBoolean(show.getText());
|
|
if (logger.isDebugEnabled()) {
|
|
logger.debug("show=" + show + "(" + show.getText() + ")");
|
|
logger.debug("showSearchHighlight=" + showSearchHighlight);
|
|
}
|
|
}
|
|
|
|
private static @Nullable String getSafeChildText(@NotNull Element element, @NotNull String name) {
|
|
final Element child = element.getChild(name);
|
|
return child != null ? StringHelper.getSafeXmlText(child) : null;
|
|
}
|
|
|
|
@SuppressWarnings("SameParameterValue")
|
|
private static @NotNull String getSafeChildText(@NotNull Element element, @NotNull String name, @NotNull String defaultValue) {
|
|
final Element child = element.getChild(name);
|
|
if (child != null) {
|
|
final String value = StringHelper.getSafeXmlText(child);
|
|
return value != null ? value : defaultValue;
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
@Nullable
|
|
@Override
|
|
public Element getState() {
|
|
Element element = new Element("search");
|
|
saveData(element);
|
|
return element;
|
|
}
|
|
|
|
@Override
|
|
public void loadState(@NotNull Element state) {
|
|
readData(state);
|
|
}
|
|
//endregion
|
|
|
|
|
|
private enum ReplaceConfirmationChoice {
|
|
SUBSTITUTE_THIS,
|
|
SUBSTITUTE_LAST,
|
|
SKIP,
|
|
QUIT,
|
|
SUBSTITUTE_ALL,
|
|
}
|
|
|
|
// Vim saves the patterns used for searching (`/`) and substitution (`:s`) separately
|
|
// viminfo records them as `# Last Search Pattern` and `# Last Substitute Search Pattern` respectively
|
|
// Vim also saves flags in viminfo - ~<magic><smartcase><line><end><off>[~]
|
|
// The trailing tilde tracks which was the last used pattern, but line/end/off is only used for search, not substitution
|
|
// Search values can contain new lines, etc. Vim saves these as CTRL chars, e.g. ^M
|
|
// Before saving, Vim reads existing viminfo, merges and writes
|
|
private @Nullable String lastSearch; // Pattern used for last search command (`/`)
|
|
private @Nullable String lastSubstitute; // Pattern used for last substitute command (`:s`)
|
|
private int lastPatternIdx; // Which pattern was used last? RE_SEARCH or RE_SUBST?
|
|
private @Nullable String lastReplace; // `# Last Substitute String` from viminfo
|
|
private @NotNull String lastPatternOffset = ""; // /{pattern}/{offset}. Do not confuse with caret offset!
|
|
private boolean lastIgnoreSmartCase;
|
|
private @NotNull Direction lastDir = Direction.FORWARDS;
|
|
private boolean showSearchHighlight = VimPlugin.getOptionService().isSet(OptionScope.GLOBAL.INSTANCE, OptionConstants.hlsearchName, OptionConstants.hlsearchName);
|
|
|
|
private boolean do_all = false; /* do multiple substitutions per line */
|
|
private boolean do_ask = false; /* ask for confirmation */
|
|
private boolean do_error = true; /* if false, ignore errors */
|
|
//private boolean do_print = false; /* print last line with subs. */
|
|
private char do_ic = 0; /* ignore case flag */
|
|
|
|
// Matching the values defined in Vim. Do not change these values, they are used as indexes
|
|
public static final int RE_SEARCH = 0; // Save/use search pattern
|
|
public static final int RE_SUBST = 1; // Save/use substitute pattern
|
|
public static final int RE_BOTH = 2; // Save to both patterns
|
|
public static final int RE_LAST = 2; // Use last used pattern if "pat" is NULL
|
|
|
|
private static final Logger logger = Logger.getInstance(SearchGroup.class.getName());
|
|
}
|