blob: a8db1336b85f93b9655d3955d2b54459df6008d6 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2000, 2015 IBM Corporation and others.
* 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
*
* Contributors:
* Genady Beryozkin, me@genady.org - initial API and implementation
* Fabio Zadrozny <fabiofz at gmail dot com> - [typing] HippieCompleteAction is slow ( Alt+/ ) - https://bugs.eclipse.org/bugs/show_bug.cgi?id=270385
*******************************************************************************/
package org.eclipse.ui.internal.texteditor;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.FindReplaceDocumentAdapter;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.ui.IEditorPart;
import org.eclipse.ui.IEditorReference;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.texteditor.IDocumentProvider;
import org.eclipse.ui.texteditor.ITextEditor;
/**
* This class contains the hippie completion engine methods that actually
* compute the possible completions.
* <p>
* This engine is used by the <code>org.eclipse.ui.texteditor.HippieCompleteAction</code>.
* </p>
*
* TODO: Sort by editor type
* TODO: Provide history option
*
* @since 3.1
* @author Genady Beryozkin, me@genady.org
*/
public final class HippieCompletionEngine {
/**
* Regular expression that is used to find words.
*/
// unicode identifier part
// private static final String COMPLETION_WORD_REGEX= "[\\p{L}[\\p{Mn}[\\p{Pc}[\\p{Nd}[\\p{Nl}]]]]]+"; //$NON-NLS-1$
// java identifier part (unicode id part + currency symbols)
private static final String COMPLETION_WORD_REGEX= "[\\p{L}[\\p{Mn}[\\p{Pc}[\\p{Nd}[\\p{Nl}[\\p{Sc}]]]]]]+"; //$NON-NLS-1$
/**
* The pre-compiled word pattern.
*
* @since 3.2
*/
private static final Pattern COMPLETION_WORD_PATTERN= Pattern.compile(COMPLETION_WORD_REGEX);
/**
* Word boundary pattern that does not allow searching at the beginning of the document.
*
* @since 3.2
*/
private static final String NON_EMPTY_COMPLETION_BOUNDARY= "[\\s\\p{Z}[\\p{P}&&[\\P{Pc}]][\\p{S}&&[\\P{Sc}]]]+"; //$NON-NLS-1$
/**
* The word boundary pattern string.
*
* @since 3.2
*/
private static final String COMPLETION_BOUNDARY= "(^|" + NON_EMPTY_COMPLETION_BOUNDARY + ")"; //$NON-NLS-1$ //$NON-NLS-2$
// with a 1.5 JRE, you can do this:
// private static final String COMPLETION_WORD_REGEX= "\\p{javaUnicodeIdentifierPart}+"; //$NON-NLS-1$
// private static final String COMPLETION_WORD_REGEX= "\\p{javaJavaIdentifierPart}+"; //$NON-NLS-1$
/**
* Is completion case sensitive? Even if set to <code>false</code>, the
* case of the prefix won't be changed.
*/
private static final boolean CASE_SENSITIVE= true;
/**
* Creates a new engine.
*/
public HippieCompletionEngine() {
}
/*
* Copied from {@link FindReplaceDocumentAdapter#asRegPattern(java.lang.String)}.
*/
/**
* Converts a non-regex string to a pattern that can be used with the regex
* search engine.
*
* @param string the non-regex pattern
* @return the string converted to a regex pattern
*/
private String asRegPattern(CharSequence string) {
StringBuffer out= new StringBuffer(string.length());
boolean quoting= false;
for (int i= 0, length= string.length(); i < length; i++) {
char ch= string.charAt(i);
if (ch == '\\') {
if (quoting) {
out.append("\\E"); //$NON-NLS-1$
quoting= false;
}
out.append("\\\\"); //$NON-NLS-1$
continue;
}
if (!quoting) {
out.append("\\Q"); //$NON-NLS-1$
quoting= true;
}
out.append(ch);
}
if (quoting)
out.append("\\E"); //$NON-NLS-1$
return out.toString();
}
/**
* Return the list of completion suggestions that correspond to the
* provided prefix.
*
* @param document the document to be scanned
* @param prefix the prefix to search for
* @param firstPosition the initial position in the document that
* the search will start from. In order to search from the
* beginning of the document use <code>firstPosition=0</code>.
* @param currentWordLast if <code>true</code> the word at caret position
* should be that last completion. <code>true</code> is good
* for searching in the currently open document and <code>false</code>
* is good for searching in other documents.
* @return a {@link List} of possible completions (as {@link String}s),
* excluding the common prefix
* @throws BadLocationException if there is some error scanning the
* document.
*/
public List<String> getCompletionsForward(IDocument document, CharSequence prefix,
int firstPosition, boolean currentWordLast) throws BadLocationException {
ArrayList<String> res= new ArrayList<>();
for (Iterator<String> it= getForwardIterator(document, prefix, firstPosition, currentWordLast); it.hasNext();) {
res.add(it.next());
}
return res;
}
/**
* Search for possible completions in the backward direction. If there
* is a possible completion that begins before <code>firstPosition</code>
* but ends after that position, it will not be included in the results.
*
* @param document the document to be scanned
* @param prefix the completion prefix
* @param firstPosition the caret position
* @return a {@link List} of possible completions ({@link String}s)
* from the caret position to the beginning of the document.
* The empty suggestion is not included in the results.
* @throws BadLocationException if any error occurs
*/
public List<String> getCompletionsBackwards(IDocument document, CharSequence prefix, int firstPosition) throws BadLocationException {
ArrayList<String> res= new ArrayList<>();
for (Iterator<String> it= getBackwardIterator(document, prefix, firstPosition); it.hasNext();) {
res.add(it.next());
}
return res;
}
/**
* Returns the text between the provided position and the preceding word boundary.
*
* @param doc the document that will be scanned.
* @param pos the caret position.
* @return the text if found, or null.
* @throws BadLocationException if an error occurs.
* @since 3.2
*/
public String getPrefixString(IDocument doc, int pos) throws BadLocationException {
Matcher m= COMPLETION_WORD_PATTERN.matcher(""); //$NON-NLS-1$
int prevNonAlpha= pos;
while (prevNonAlpha > 0) {
m.reset(doc.get(prevNonAlpha-1, pos - prevNonAlpha + 1));
if (!m.matches()) {
break;
}
prevNonAlpha--;
}
if (prevNonAlpha != pos) {
return doc.get(prevNonAlpha, pos - prevNonAlpha);
}
return null;
}
/**
* Remove duplicate suggestions (excluding the prefix), leaving the closest
* to list head.
*
* @param suggestions a list of suggestions ({@link String}).
* @return a list of unique completion suggestions.
*/
public List<String> makeUnique(List<String> suggestions) {
HashSet<String> seenAlready= new HashSet<>();
ArrayList<String> uniqueSuggestions= new ArrayList<>();
for (Iterator<String> i= suggestions.iterator(); i.hasNext();) {
String suggestion= i.next();
if (!seenAlready.contains(suggestion)) {
seenAlready.add(suggestion);
uniqueSuggestions.add(suggestion);
}
}
return uniqueSuggestions;
}
/**
* Calculates the documents to be searched. Note that the first returned document is always from
* the current editor and if we have no current editor, an empty list is returned even if there
* are other documents available.
*
* @param currentTextEditor this is the currently opened text editor.
* @return A List of IDocument with the opened documents so that the first document in that list
* is always the current document.
* @since 3.6
*/
public static List<IDocument> computeDocuments(ITextEditor currentTextEditor) {
ArrayList<IDocument> documentsForSearch= new ArrayList<>();
if (currentTextEditor == null) {
return documentsForSearch;
}
IDocumentProvider provider= currentTextEditor.getDocumentProvider();
if (provider == null) {
return documentsForSearch;
}
IDocument currentDocument= provider.getDocument(currentTextEditor.getEditorInput());
if(currentDocument == null){
return documentsForSearch;
}
List<IDocument> computedDocuments= new ArrayList<>();
IWorkbenchWindow window= currentTextEditor.getSite().getWorkbenchWindow();
IEditorReference editorsArray[]= window.getActivePage().getEditorReferences();
for (int i= 0; i < editorsArray.length; i++) {
IEditorPart realEditor= editorsArray[i].getEditor(false);
if (realEditor instanceof ITextEditor && !realEditor.equals(currentTextEditor)) {
ITextEditor textEditor= (ITextEditor)realEditor;
provider= textEditor.getDocumentProvider();
if (provider == null) {
continue;
}
IDocument doc= provider.getDocument(textEditor.getEditorInput());
if (doc == null) {
continue;
}
computedDocuments.add(doc);
}
}
//The first is always the one related to the passed currentTextEditor.
computedDocuments.add(0, currentDocument);
return computedDocuments;
}
/**
* Provides an iterator that will get the completions that start with the passed prefix after
* the passed position (forward until the end of the document).
*
* @param document the document to be scanned
* @param prefix the prefix to search for
* @param firstPosition the initial position in the document that the search will start from. In
* order to search from the beginning of the document use
* <code>firstPosition=0</code>.
* @param currentWordLast if <code>true</code> the word at caret position should be that last
* completion. <code>true</code> is good for searching in the currently open document
* and <code>false</code> is good for searching in other documents.
* @return Iterator (for Strings) that will get the completions forward from the passed
* position.
*
* @since 3.6
*/
public Iterator<String> getForwardIterator(IDocument document, CharSequence prefix, int firstPosition, boolean currentWordLast) {
return new HippieCompletionForwardIterator(document, prefix, firstPosition, currentWordLast);
}
/**
* Provides an iterator that will get the completions that start with the passed prefix before
* the passed position (backwards until the start of the document).
*
* @param document the document to be scanned
* @param prefix the prefix to search for
* @param firstPosition the initial position in the document that the search will start from. In
* order to search from the end of the document use
* <code>firstPosition=document.getLength()</code>.
* @return Iterator that will get the completions backward from the passed position.
*
* @since 3.6
*/
public Iterator<String> getBackwardIterator(IDocument document, CharSequence prefix, int firstPosition) {
return new HippieCompletionBackwardIterator(document, prefix, firstPosition);
}
/**
* Provides an iterator that will get the completions for all the documents received, starting
* at the "document" passed (first going backward and then forward from the position passed) and
* later going forward through each of the "otherDocuments".
*
* @param document the document to be scanned
* @param otherDocuments the additional documents to be scanned
* @param prefix the prefix to search for
* @param firstPosition the initial position in the document that the search will start from.
* @return Iterator that will first get the completions backward from the document passed, then
* forward in that same document and when that is finished it will get it forward for
* the other documents (in the same sequence the documents are available).
*
* @since 3.6
*/
public Iterator<String> getMultipleDocumentsIterator(IDocument document, List<IDocument> otherDocuments, CharSequence prefix, int firstPosition) {
return new MultipleDocumentsIterator(document, otherDocuments, prefix, firstPosition);
}
/**
* Class that keeps the state while iterating the suggestions
*
* @since 3.6
*/
private final class MultipleDocumentsIterator implements Iterator<String> {
/**
* This is the next token to be returned (when null, no more tokens should be returned)
*/
private String fNext;
/**
* -1 means that we still haven't checked the current do completions Any other number means
* that we'll get the completions for some other editor.
*/
private int fCurrLocation= -1;
/** These are the suggestions which we already loaded. */
private final List<String> fSuggestions;
/** This marks the current suggestion to be returned */
private int fCurrSuggestion= 0;
/** This is the prefix that should be searched */
private final CharSequence fPrefix;
/** The list of IDocuments that we should search */
private final List<IDocument> fOtherDocuments;
/**
* The document that's currently opened (that's the 1st we should look and we should 1st
* search backwards from the current offset and later forwards)
*/
private final IDocument fOpenDocument;
/** The current offset in the opened document */
private final int fSelectionOffset;
/** Indicates whether we already added the empty completion. */
private boolean fAddedEmpty= false;
/** The 'current' forward iterator. */
private Iterator<String> fCompletionsForwardIterator;
/** The 'current' backward iterator. */
private Iterator<String> fCompletionsBackwardIterator;
private MultipleDocumentsIterator(IDocument openDocument, List<IDocument> otherDocuments,
CharSequence prefix, int selectionOffset) {
this.fPrefix= prefix;
this.fSuggestions= new ArrayList<>();
this.fOtherDocuments= otherDocuments;
this.fSelectionOffset= selectionOffset;
this.fOpenDocument= openDocument;
calculateNext();
}
/**
* This method calculates the next token to be returned (so, after creating the class or
* after calling next(), this function must be called).
*
* It'll check which document should be used and will get the completions on that document
* until some completion is found.
*
* An empty completion is always added at the end.
*
* After the empty completion, the next is set to null.
*/
private void calculateNext() {
if (fCurrLocation == -1) {
fCompletionsBackwardIterator= getBackwardIterator(
fOpenDocument, fPrefix, fSelectionOffset);
fCompletionsForwardIterator= getForwardIterator(
fOpenDocument, fPrefix, (fSelectionOffset - fPrefix.length()), true);
fCurrLocation++;
}
if (checkNext()) {
return;
}
while (fCurrLocation < this.fOtherDocuments.size()) {
fCompletionsForwardIterator= getForwardIterator(
(this.fOtherDocuments.get(fCurrLocation)), fPrefix, 0, false);
fCurrLocation++;
if (checkNext()) {
return;
}
}
// add the empty suggestion (last one)
if (!fAddedEmpty) {
fSuggestions.add(""); //$NON-NLS-1$
fAddedEmpty= true;
}
checkNext();
}
/**
* @return true if a completion was found and false if it couldn't be found -- in which case
* the next is set to null.
*/
private boolean checkNext() {
if (fCompletionsBackwardIterator != null) {
if (fCompletionsBackwardIterator.hasNext()) {
fSuggestions.add(fCompletionsBackwardIterator.next());
} else {
fCompletionsBackwardIterator= null;
}
}
if (fCompletionsBackwardIterator == null) {
//only get if backward completions are consumed
if (fCompletionsForwardIterator != null && fCompletionsForwardIterator.hasNext()) {
fSuggestions.add(fCompletionsForwardIterator.next());
}
}
if (fSuggestions.size() > fCurrSuggestion) {
fNext= fSuggestions.get(fCurrSuggestion);
fCurrSuggestion++;
return true;
}
fNext= null;
return false;
}
/**
* We always calculate the next to see if it's available.
*
* @return <code>true</code> if the next token to be returned is not null (we always
* pre-calculate things)
*/
@Override
public boolean hasNext() {
return fNext != null;
}
/**
* @return the next suggestion
*/
@Override
public String next() {
if (fNext == null) {
throw new NoSuchElementException("No more elements to iterate"); //$NON-NLS-1$
}
String ret= fNext;
calculateNext();
return ret;
}
/**
* Not supported!
*
* @throws UnsupportedOperationException always.
*/
@Override
public void remove() {
throw new UnsupportedOperationException("Not supported"); //$NON-NLS-1$
}
}
/**
* Base class for Iterator that gets the word completions in a document, and returns them one by
* one (lazily gotten).
*
* @since 3.6
*/
private abstract class HippieCompletionIterator implements Iterator<String> {
/** The document to be scanned */
protected IDocument fDocument;
/** The prefix to search for */
protected CharSequence fPrefix;
/**
* The initial position in the document that the search will start from. In order to search
* from the beginning of the document use <code>firstPosition=0</code>.
*/
protected int fFirstPosition;
/** Determines if we have a next element to be returned. */
protected boolean fHasNext;
/** The next element to be returned */
protected String fNext;
/** The current state for the iterator */
protected int fCurrentState= 0;
/** The class that'll do the search */
protected FindReplaceDocumentAdapter fSearcher;
/** Pattern to be used -- search only at word boundaries */
protected String fSearchPattern;
/** The next place to search for */
protected int fNextPos;
/**
* Constructor
*
* @param document the document to be scanned
* @param prefix the prefix to search for
* @param firstPosition the initial position in the document that the search will start
* from. In order to search from the beginning of the document use
* <code>firstPosition=0</code>.
*/
public HippieCompletionIterator(IDocument document, CharSequence prefix, int firstPosition) {
this.fDocument= document;
this.fPrefix= prefix;
this.fFirstPosition= firstPosition;
}
/**
* Must be called to calculate the first completion (subclasses must explicitly call it when
* properly initialized).
*/
protected void calculateFirst() {
try {
calculateNext();
} catch (BadLocationException e) {
log(e);
fHasNext= false;
fNext= null;
}
}
@Override
public boolean hasNext() {
return fHasNext;
}
@Override
public String next() {
if (!fHasNext) {
throw new NoSuchElementException();
}
String ret= fNext;
try {
calculateNext();
} catch (BadLocationException e) {
log(e);
fHasNext= false;
fNext= null;
}
return ret;
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
/**
* Subclasses must override to calculates whether we have a next element to be returned and
* which element it is (set fHasNext and fNext).
*
* @throws BadLocationException if we're at an invalid position in the document.
*/
protected abstract void calculateNext() throws BadLocationException;
}
/**
* Iterator that gets the word completions in a document, and returns them one by one (lazily
* gotten) from the current position.
*
* @since 3.6
*/
private class HippieCompletionForwardIterator extends HippieCompletionIterator {
/**
* If <code>true</code> the word at caret position should be that last completion.
* <code>true</code> is good for searching in the currently open document and
* <code>false</code> is good for searching in other documents.
*/
private boolean fCurrentWordLast;
/** The completion for the current word -- fix bug 132533 */
private String fCurrentWordCompletion= null;
private HippieCompletionForwardIterator(IDocument document, CharSequence prefix, int firstPosition, boolean currentWordLast) {
super(document, prefix, firstPosition);
this.fCurrentWordLast= currentWordLast;
calculateFirst();
}
@Override
protected void calculateNext() throws BadLocationException {
if (fCurrentState == 0) {
if (fFirstPosition == fDocument.getLength()) {
this.fHasNext= false;
return;
}
fSearcher= new FindReplaceDocumentAdapter(fDocument);
// unless we are at the beginning of the document, the completion boundary
// matches one character. It is enough to move just one character backwards
// because the boundary pattern has the (....)+ form.
// see HippieCompletionTest#testForwardSearch().
if (fFirstPosition > 0) {
fFirstPosition--;
// empty spacing is not permitted now.
fSearchPattern= NON_EMPTY_COMPLETION_BOUNDARY + asRegPattern(fPrefix);
} else {
fSearchPattern= COMPLETION_BOUNDARY + asRegPattern(fPrefix);
}
fNextPos= fFirstPosition;
fCurrentState= 1;
}
if (fCurrentState == 1) {
fHasNext= false;
IRegion reg= fSearcher.find(fNextPos, fSearchPattern, true, CASE_SENSITIVE, false, true);
while (reg != null) {
IRegion word= checkRegion(reg);
fNextPos= word.getOffset() + word.getLength();
if (fNextPos >= fDocument.getLength()) {
fCurrentState= 2;
if (fHasNext) {
return;
}
break;
} else {
if (fHasNext) {
return;
}
reg= fSearcher.find(fNextPos, fSearchPattern, true, CASE_SENSITIVE, false, true);
}
}
fCurrentState= 2;
}
if (fCurrentState == 2) {
fCurrentState= 3;
// the word at caret position goes last (bug 132533).
if (fCurrentWordCompletion != null) {
fNext= fCurrentWordCompletion;
fHasNext= true;
return;
}
}
fNext= null;
fHasNext= false;
return;
}
/**
* Checks the given region for a word to be returned in this iterator.
*
* @param reg the region to check
* @return the word region.
* @throws BadLocationException if we're at an invalid position in the document.
*/
private IRegion checkRegion(IRegion reg) throws BadLocationException {
// since the boundary may be of nonzero length
int wordSearchPos= reg.getOffset() + reg.getLength() - fPrefix.length();
// try to complete to a word. case is irrelevant here.
IRegion word= fSearcher.find(wordSearchPos, COMPLETION_WORD_REGEX, true, true, false, true);
if (word.getLength() > fPrefix.length()) { // empty suggestion will be added later
String wholeWord= fDocument.get(word.getOffset(), word.getLength());
String completion= wholeWord.substring(fPrefix.length());
if (fCurrentWordLast && reg.getOffset() == fFirstPosition) { // we got the word at caret as completion
if (fCurrentWordCompletion == null) {
fCurrentWordCompletion= completion; // add it as the last word.
}
} else {
fNext= completion;
fHasNext= true;
}
}
return word;
}
}
/**
* Iterator that gets the word completions in a document, and returns them one by one (lazily
* gotten) backward from the current position.
*
* @since 3.6
*/
private class HippieCompletionBackwardIterator extends HippieCompletionIterator {
/** Last position searched **/
private int fLastSearchPos= -1;
private HippieCompletionBackwardIterator(IDocument document, CharSequence prefix, int firstPosition) {
super(document, prefix, firstPosition);
calculateFirst();
}
@Override
protected void calculateNext() throws BadLocationException {
if (fCurrentState == 0) {
fCurrentState= 1;
// FindReplaceDocumentAdapter expects the start offset to be before the
// actual caret position, probably for compatibility with forward search.
if (fFirstPosition <= 1) {
this.fNext= null;
this.fHasNext= false;
return;
}
fSearcher= new FindReplaceDocumentAdapter(fDocument);
// search only at word boundaries
fSearchPattern= COMPLETION_BOUNDARY + asRegPattern(fPrefix);
int length= fDocument.getLength();
fNextPos= fFirstPosition;
if (fNextPos >= length) {
fNextPos= length - 1;
}
}
while (true) {
if (fNextPos <= 0) {
this.fNext= null;
this.fHasNext= false;
return;
}
Assert.isTrue(fLastSearchPos != fNextPos, "Position did not change in loop (this would lead to recursion -- and should never happen)."); //$NON-NLS-1$
fLastSearchPos= fNextPos;
IRegion reg= fSearcher.find(fNextPos, fSearchPattern, false, CASE_SENSITIVE, false, true);
if (reg == null) {
this.fNext= null;
this.fHasNext= false;
return;
}
// since the boundary may be of nonzero length
int wordSearchPos= reg.getOffset() + reg.getLength() - fPrefix.length();
// try to complete to a word. case is of no matter here
IRegion word= fSearcher.find(wordSearchPos, COMPLETION_WORD_REGEX, true, true, false, true);
fNextPos= word.getOffset() - 1;
if (word.getOffset() + word.getLength() > fFirstPosition) {
continue;
}
if (word.getLength() > fPrefix.length()) { // empty suggestion will be added later
String found= fDocument.get(word.getOffset(), word.getLength());
this.fHasNext= true;
this.fNext= found.substring(fPrefix.length());
return;
}
}
//Note: unreachable section
}
}
/**
* Logs the exception.
*
* @param e the exception
*
* @since 3.6
*/
private void log(BadLocationException e) {
String msg= e.getLocalizedMessage();
if (msg == null)
msg= "unable to access the document"; //$NON-NLS-1$
TextEditorPlugin.getDefault().getLog().log(new Status(IStatus.ERROR, TextEditorPlugin.PLUGIN_ID, IStatus.OK, msg, e));
}
}