/* * 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()); }