/** * Copyright (c) 2009, 2013 Mark Feber, MulgaSoft * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * */ package com.mulgasoft.emacsplus.minibuffer; import org.eclipse.core.commands.ExecutionEvent; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IFindReplaceTarget; import org.eclipse.jface.text.IFindReplaceTargetExtension3; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.ITextSelection; import org.eclipse.jface.text.Position; import org.eclipse.jface.text.source.ISourceViewer; import org.eclipse.jface.viewers.ISelection; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.VerifyEvent; import org.eclipse.swt.graphics.Point; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; import org.eclipse.ui.IWorkbenchPage; import org.eclipse.ui.texteditor.ITextEditor; import com.mulgasoft.emacsplus.EmacsPlusActivator; import com.mulgasoft.emacsplus.MarkUtils; import com.mulgasoft.emacsplus.RegexpRingBuffer; import com.mulgasoft.emacsplus.RingBuffer; import com.mulgasoft.emacsplus.preferences.EmacsPlusPreferenceConstants; /** * Emacs style replace & query-replace minibuffer * * When used for query-replace, it supports the following sub-commands: * Space or `y' to replace one match * Delete or `n' to skip to next * RET or `q' to exit * Period to replace one match and exit * Comma to replace but not move point immediately * ! to replace all remaining matches with no more questions * * TODO - add case-fold-search and case-replace * Matching is independent of case if `case-fold-search' is non-nil and * FROM-STRING has no uppercase letters. Replacement transfers the case * pattern of the old text to the new text, if `case-replace' and * `case-fold-search' are non-nil and FROM-STRING has no uppercase * letters. \(Transferring the case pattern means that if the old text * matched is all caps, or capitalized, then its replacement is upcased * or capitalized.) * * @author Mark Feber - initial API and implementation */ public class SearchReplaceMinibuffer extends SearchMinibuffer { // TODO - html files don't seem to come with a undoer - can we add? // Preference: use yank & yank pop for C-y & M-y if true // else use unified approach private static boolean GNU_YANK = EmacsPlusActivator.getDefault().getPreferenceStore().getBoolean(EmacsPlusPreferenceConstants.P_GNU_YANK); // TODO - preferences ? contextualized sub-commands? private static final char QUIT = 'q'; private static final char YES = 'y'; private static final char YES_SP = ' '; private static final char NO = 'n'; private static final char ALL = '!'; private static final char PAUSE = ','; private static final char DO_ONE = '.'; private final static char QUESTION = '?'; private static final String QR_REPLACE = EmacsPlusActivator.getResourceString("QR_Replace"); //$NON-NLS-1$ private static final String QR_QUERY = EmacsPlusActivator.getResourceString("QR_Query"); //$NON-NLS-1$ private static final String QR_REPLACE_STR = EmacsPlusActivator.getResourceString("QR_Replace_Str"); //$NON-NLS-1$ private static final String QR_REPLACE_REGEXP = EmacsPlusActivator.getResourceString("QR_Replace_Regexp"); //$NON-NLS-1$ private static final String QR_QUERY_STR = EmacsPlusActivator.getResourceString("QR_Query_Str"); //$NON-NLS-1$ private static final String QR_QUERY_WITH = EmacsPlusActivator.getResourceString("QR_Query_With"); //$NON-NLS-1$ private static final String QR_REPLACE_END = EmacsPlusActivator.getResourceString("QR_Replace_End"); //$NON-NLS-1$ private static final String QR_REPLACE_ENDS = EmacsPlusActivator.getResourceString("QR_Replace_Ends"); //$NON-NLS-1$ private String prefix = null; private String replaceStr; private String userReplaceStr; private String searchStr; private String displayStr; // if a selection is present, this is the end of it (in widget coords) private Position regionLimit = null; private boolean replaceAll = false; private enum QueryState { Query, Replace, Search } private QueryState state = QueryState.Query; /* * Emacs q/r commands on match: * Type Space or `y' to replace one match, Delete or `n' to skip to next, * RET or `q' to exit, Period to replace one match and exit, * Comma to replace but not move point immediately, * ! to replace all remaining matches with no more questions, * * Not Implemented: C-r to enter recursive edit (M-C-c to get out again), * Not Implemented: C-w to delete match and recursive edit, * Not Implemented: C-l to clear the frame, redisplay, and offer same replacement again, * Not Implemented: ^ to move point back to previous match. */ /* * From the q/r doc: * Preserves case in each replacement if `case-replace' and `case-fold-search' * are non-nil and FROM-STRING has no uppercase letters. * (Preserving case means that if the string matched is all caps, or capitalized, * then its replacement is upcased or capitalized.) */ public SearchReplaceMinibuffer() { this(false); } public SearchReplaceMinibuffer(boolean regexp) { this(regexp,false); } public SearchReplaceMinibuffer(boolean regexp, boolean replaceAll) { super(true, regexp); setReplaceAll(replaceAll); setIncrFind(true); setGnuSubCommands(GNU_YANK); } public boolean beginSession(ITextEditor editor, IWorkbenchPage page, ExecutionEvent event) { setLimit(WRAP_INDEX); // initialize to ~infinite boolean result = super.beginSession(editor, page, event); if (result) { ISelection selection = editor.getSelectionProvider().getSelection(); if (selection instanceof ITextSelection) { ITextSelection sel = (ITextSelection)selection; // constrain search/replace to selected region if (sel.getLength() > 0) { // set end of this replacement in model coords // so we include any auto-expand regions setLimit(sel.getOffset() + sel.getLength()); // force start to beginning of selection rather than cursor setStartOffset(getTextWidget().getSelectionRange().x); setSearchOffset(getStartOffset()); } } } return result; } /** * @see com.mulgasoft.emacsplus.minibuffer.HistoryMinibuffer#removeOtherListeners(IWorkbenchPage, ISourceViewer, StyledText) */ @Override protected void removeOtherListeners(IWorkbenchPage page, ISourceViewer viewer, StyledText widget) { super.removeOtherListeners(page,viewer,widget); // remove the limit position, if present, on exit setLimit(WRAP_INDEX); } public static void enableGnuSubCommands(boolean gnuYank) { SearchReplaceMinibuffer.GNU_YANK = gnuYank; } public String getMinibufferPrefix() { if (prefix == null) { prefix = getQueryPrefix(); } return prefix; } private void setMinibufferPrefix(String prefix) { this.prefix = prefix; } private String getPrimaryPrefix() { String result = null; if (isReplaceAll()) { result = QR_REPLACE; } else { result = QR_QUERY; } return result; } private String getTypePrefix() { String result = getPrimaryPrefix(); result = result + (isRegexp() ? QR_REPLACE_REGEXP : (isReplaceAll() ? QR_REPLACE_STR : QR_QUERY_STR)); return result; } private String getQueryPrefix() { return getTypePrefix() + KOLON; } private String getReplacePrefix() { return getTypePrefix() + ' ' + normalizeString(getDisplayStr()) + ' ' + QR_QUERY_WITH + KOLON; } private String getSearchPrefix() { return QR_REPLACE + ' ' + normalizeString(getDisplayStr()) + ' ' + QR_QUERY_WITH + normalizeString(userReplaceStr) + QUESTION + KOLON; } /** * @return the replaceAll */ protected boolean isReplaceAll() { return replaceAll; } /** * @param replaceAll the replaceAll to set */ public void setReplaceAll(boolean replaceAll) { this.replaceAll = replaceAll; } /** * @return the displayStr */ protected String getDisplayStr() { return displayStr; } /** * @param displayStr the displayStr to set */ protected void setDisplayStr(String displayStr) { this.displayStr = displayStr; } /** * @return the searchStr */ protected String getSearchStr() { return searchStr; } /** * @param searchStr the searchStr to set */ protected void setSearchStr(String searchStr) { this.searchStr = searchStr; } /** * Set the end of the limitRegion * Use model coords so that if there is a collapsed section in the selected * region, any auto-expand during search will be accounted for in the position * * @param val in model coords or WRAP_INDEX to disable */ private void setLimit(int val) { IDocument doc; if (val == WRAP_INDEX) { if (regionLimit != null && (doc = getDocument()) != null) { doc.removePosition(regionLimit); } regionLimit = null; } else if ((doc = getDocument()) != null) { try { regionLimit = new Position(val); doc.addPosition(regionLimit); } catch (BadLocationException e) { regionLimit = null; } } } private int getLimit() { return (regionLimit != null ? regionLimit.getOffset() : WRAP_INDEX); } /** * @see com.mulgasoft.emacsplus.minibuffer.SearchMinibuffer#executeResult(org.eclipse.ui.texteditor.ITextEditor, java.lang.Object) */ protected boolean executeResult(ITextEditor editor, Object minibufferResult) { boolean result = false; switch(state) { case Query: state = QueryState.Replace; String searchStr = getSearchString(); if (searchStr == null || searchStr.length() == 0) { finish(); } setSearchStr(searchStr); setDisplayStr(getMBString()); addToHistory(getMBString(),getRXString()); setMinibufferPrefix(getReplacePrefix()); initMinibuffer(EMPTY_STR); break; case Replace: state = QueryState.Search; userReplaceStr = getMBString(); addToHistory(userReplaceStr); // Gnu fix ups replaceStr = convertGnuStr(userReplaceStr); if (!isFound()) { // TODO: when implementing tags version, keep looking through files until found // findNextFile(); // else finish(); result = true; break; } if (isReplaceAll()) { replaceAll(); result = true; } else { setMinibufferPrefix(getSearchPrefix()); } initMinibuffer(EMPTY_STR); break; case Search: // time to go result = true; finish(); break; } return result; } // TODO: preference? private boolean isGnuSyntax() { return true; } /** * Examine replacement string for gnu syntax * entire string: \& -> \0 * @param rplString * @return rplString with replacements if gnuSyntax supported */ private String convertGnuStr(String rplString) { String newStr = rplString; if (isRegexp() && isGnuSyntax() && rplString.indexOf('\\') > -1) { char[] chars = new char[rplString.length()]; newStr.getChars(0, newStr.length(), chars, 0); boolean backSlash = false; for (int i=0; i<chars.length; i++) { if (chars[i] == '\\') { backSlash = !backSlash; } else if (backSlash && chars[i] == '&') { chars[i] = '0'; backSlash = false; } else { backSlash = false; } } newStr = new String(chars); } return newStr; } protected void checkCasePos(String str) { switch(state) { case Query: // only check case on search string super.checkCasePos(str); break; default: break; } } /** * @see com.mulgasoft.emacsplus.minibuffer.SearchMinibuffer#addIt(org.eclipse.swt.events.VerifyEvent) */ @Override protected void addIt(VerifyEvent event) { switch(state) { case Query: super.addIt(event); break; case Replace: super.addIt(event,false); break; case Search: processTextInput(event); break; } } // Local interpretation of BS and DEL protected void backSpaceChar(VerifyEvent event){ switch (state) { case Query: popSearchState(); event.doit = false; break; case Search: skipReplace(); event.doit = false; break; default: super.backSpaceChar(event); } } protected void deleteChar(VerifyEvent event){ switch (state) { case Query: popSearchState(); event.doit = false; break; case Search: skipReplace(); event.doit = false; break; default: super.deleteChar(event); } } /** * @see com.mulgasoft.emacsplus.minibuffer.SearchMinibuffer#dispatchCtrl(org.eclipse.swt.events.VerifyEvent) */ @Override protected boolean dispatchCtrl(VerifyEvent event) { boolean result = false; switch(state) { case Query: result = super.dispatchCtrl(event); break; case Replace: if (isQuoting()) { return super.dispatchCtrl(event,false); } else { switch (event.keyCode) { case EOL: result = super.dispatchCtrl(event,false); break; case CTRL_QUOTE: result = super.dispatchCtrl(event); break; case LINEorYANK: if (isGnuYankCommands()) { boolean wasEnabled = isSearchEnabled(); try { setSearchEnabled(false); result = super.dispatchCtrl(event); } finally { setSearchEnabled(wasEnabled); } break; } case CANCEL: case WORD: result = cancelSearch(); break; } } break; case Search: switch (event.keyCode) { case CANCEL: result = cancelSearch(); break; } finish(); break; } return result; } /** * @see com.mulgasoft.emacsplus.minibuffer.SearchMinibuffer#dispatchAlt(org.eclipse.swt.events.VerifyEvent) */ @Override protected boolean dispatchAlt(VerifyEvent event) { boolean result = false; switch(state) { case Query: result = super.dispatchAlt(event); break; case Replace: try { setSearchEnabled(false); result = super.dispatchAlt(event); } finally { setSearchEnabled(true); } break; case Search: finish(); break; } return result; } /** * Reset the search indexes and find 'new' string * * @see com.mulgasoft.emacsplus.minibuffer.HistoryMinibuffer#historyTransition(int) */ protected void historyTransition(int keyCode) { switch(state) { case Query: switch (keyCode) { case NEXT: case PREV: case NEXT_ARROW: case PREV_ARROW: String str = getSearchString(); if (str != null && str.length() > 0) { // first jump to start position int off = getStartOffset(); setSearchOffset(off); getTextWidget().setCaretOffset(off); findNext(str); } break; } default: break; } } /** * @see com.mulgasoft.emacsplus.minibuffer.SearchMinibuffer#dispatchAltCtrl(org.eclipse.swt.events.VerifyEvent) */ protected boolean dispatchAltCtrl(VerifyEvent event) { boolean result = false; switch(state) { case Query: // only when building search string result = super.dispatchAltCtrl(event,true); break; case Replace: case Search: default: finish(); break; } return result; } /** * Return to start offset if not in Query state or can't return to a found state * * @see com.mulgasoft.emacsplus.minibuffer.SearchMinibuffer#cancelSearch() */ protected boolean cancelSearch() { if (state != QueryState.Query || !goToFoundState()) { leave(getStartOffset()); } return true; } private int findCount = 0; private boolean paused = false; private void processTextInput(VerifyEvent event) { switch (event.character) { case ALL: replaceAll(); break; case PAUSE: // replace one (if not paused) and wait if (!paused) { replaceIt(); paused = true; } break; case DO_ONE: // replace one and exit if (replaceIt()){ finish(); } break; case YES_SP: case YES: // replace if (replaceIt() && !findNext(getSearchStr())) finish(); break; case NO: // move along to the next skipReplace(); break; case QUIT: finish(); break; default: // else add the character and terminate finish(); this.resendEvent(event); } } private boolean checkPaused() { boolean result = paused; if (paused) { paused = false; if (!findNext(getSearchStr())) finish(); } return result; } /** * Refine target search to not proceed beyond a region limit, if present * * @see com.mulgasoft.emacsplus.minibuffer.SearchMinibuffer#findTarget(org.eclipse.jface.text.IFindReplaceTarget, java.lang.String, int, boolean) */ protected int findTarget(IFindReplaceTarget target, String searchStr, int searchIndex, boolean forward) { int result = WRAP_INDEX; StyledText text = getTextWidget(); try { text.setRedraw(false); result = super.findTarget(target,searchStr,searchIndex,forward); // if present, check if we've gone past the end of the region if (getLimit() != WRAP_INDEX && result != WRAP_INDEX) { int pos = MarkUtils.widget2ModelOffset(getViewer(), result); // include length of search result in check if (pos + target.getSelection().y > getLimit()) { result = WRAP_INDEX; // restore last position the region setSelection(searchIndex); } } } finally { text.setRedraw(true); } return result; } /** * Skip a single replacement */ private void skipReplace() { if (!checkPaused()) { StyledText w = getTextWidget(); if (w != null && !w.isDisposed()) { // position at the end of the current proposed replacement setSearchOffset(w.getCaretOffset()); } if (!findNext(getSearchStr())) finish(); } } /** * Replace all occurrences */ private void replaceAll() { boolean allState = isReplaceAll(); IDocumentUndoManager undoer = DocumentUndoManagerRegistry.getDocumentUndoManager(getDocument()); try { paused = false; setReplaceAll(true); // force flag so we don't re-enter the undoer during case replacement if (undoer != null) { undoer.beginCompoundChange(); } replaceIt(); while (findNext(getSearchStr())){ replaceIt(); } } finally { setReplaceAll(allState); if (undoer != null) { undoer.endCompoundChange(); } } finish(); } /** * Perform one replacement * * @return the replacement index */ private boolean replaceIt() { boolean result = false; boolean forward = true; if (!checkPaused()) { findCount++; try { result = true; int index = getSearchOffset(); IFindReplaceTarget target = getTarget(); int fpos = findTarget(target,getSearchStr(), index, forward); if (fpos > -1) { boolean initial = false; boolean all = false; String replacer = replaceStr; if (!isCaseSensitive() && replacer.length() > 0) { // Preserve case using the emacs definition // - Preserving case means that if the string matched is all caps, or capitalized, // then its replacement is upper cased or capitalized.) String replacee = target.getSelectionText().trim(); if (replacee != null && replacee.length() > 0) { initial = Character.isUpperCase(replacee.charAt(0)); all = initial; if (initial) { for (int i = 1; i < replacee.length(); i++) { if (!Character.isUpperCase(replacee.charAt(i))) { all = false; break; } } } } } int adjust = 0; if (all || initial) { caseReplace(replacer,index,all); } else { if (isRegexLD()) { adjust = replacer.length(); // prevents repetitious find of same EOL // now we need the offset in model coords ITextSelection sel = (ITextSelection)getEditor().getSelectionProvider().getSelection(); // use document 'insert' instead of broken target replace getDocument().replace(sel.getOffset(),0,replacer); // search uses cursor position - s/r is always forward MarkUtils.setCursorOffset(getEditor(), sel.getOffset()+adjust); } else { ((IFindReplaceTargetExtension3)target).replaceSelection(replacer, isRegexp()); } } Point p = target.getSelection(); int rIndex = p.x + p.y + adjust; setSearchOffset(rIndex); } } catch (Exception e) { setResultString(e.getLocalizedMessage(), true); finish(); beep(); } } return result; } /** * Case-based replacement - after the initial find has already happened * * @param replacer - the replacement string (may be regexp) * @param index - offset of find * @param all - all if true, else initial * @return - the replacement region * * @throws BadLocationException */ private IRegion caseReplace(String replacer, int index, boolean all) throws BadLocationException { IRegion result = null; IDocumentUndoManager undoer = DocumentUndoManagerRegistry.getDocumentUndoManager(getDocument()); try { if (!isReplaceAll() && undoer != null) { undoer.beginCompoundChange(); } IFindReplaceTarget target = getTarget(); // first replace with (possible regexp) string ((IFindReplaceTargetExtension3)target).replaceSelection(replacer, isRegexp()); // adjust case of actual replacement string replacer = target.getSelectionText(); String caseReplacer = replacer; if (all) { caseReplacer = caseReplacer.toUpperCase(); } else { caseReplacer = caseReplacer.trim(); caseReplacer = Character.toUpperCase(caseReplacer.charAt(0)) + caseReplacer.substring(1,caseReplacer.length()).toString(); // now update the replacement string with the re-cased part caseReplacer = replacer.replaceFirst(replacer.trim(), caseReplacer); } int ind = target.findAndSelect(index, replacer, true, false, false); if (ind > -1) { target.replaceSelection(caseReplacer); } } finally { if (!isReplaceAll() && undoer != null) { undoer.endCompoundChange(); } } return result; } private void finish(){ setMinibufferPrefix(EMPTY_STR); if (getResultString() == null) { setResultString(String.format(getTypePrefix() + ' ' + (findCount == 1 ? QR_REPLACE_END : QR_REPLACE_ENDS), findCount),false); } if (isFound() || (findCount > 0 && (isReplaceAll() || getSearchOffset() == WRAP_INDEX))){ StyledText w = getTextWidget(); if (w != null && !w.isDisposed()) { // position at the end of the current proposed replacement setSearchOffset(w.getCaretOffset()); } } setSelection(); MarkUtils.setMark(getEditor(),getMarkOffset()); leave(); } private void setSelection(int off) { ISourceViewer viewer = getViewer(); viewer.setSelectedRange(MarkUtils.widget2ModelOffset(viewer, off), 0); } private void setSelection() { setSelection(getSearchOffset()); } /**** Local RingBuffers: use lazy initialization holder class idiom ****/ /** * @see com.mulgasoft.emacsplus.minibuffer.HistoryMinibuffer#getHistoryRing() */ @Override protected RingBuffer<String> getHistoryRing() { return (isRegexp() ? QRegexpRing.ring : QRRing.ring); } private static class QRRing { static final RingBuffer<String> ring = new RingBuffer<String>(); } private static class QRegexpRing { static final RegexpRingBuffer ring = new RegexpRingBuffer(); } }