/**
* Copyright (c) 2014 Codetrails GmbH.
* 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:
* Marcel Bruch - initial API and implementation.
*/
package org.eclipse.recommenders.internal.subwords.rcp;
import static java.lang.Math.min;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.eclipse.recommenders.completion.rcp.CompletionContextKey.JAVA_PROPOSALS;
import static org.eclipse.recommenders.completion.rcp.processable.ProposalTag.*;
import static org.eclipse.recommenders.internal.subwords.rcp.LCSS.containsSubsequence;
import static org.eclipse.recommenders.internal.subwords.rcp.l10n.LogMessages.*;
import static org.eclipse.recommenders.utils.Logs.log;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.SortedSet;
import javax.inject.Inject;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jdt.core.CompletionProposal;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.Signature;
import org.eclipse.jdt.core.compiler.CharOperation;
import org.eclipse.jdt.internal.codeassist.InternalCompletionContext;
import org.eclipse.jdt.internal.codeassist.RelevanceConstants;
import org.eclipse.jdt.internal.codeassist.complete.CompletionOnFieldType;
import org.eclipse.jdt.internal.compiler.ast.ASTNode;
import org.eclipse.jdt.internal.ui.javaeditor.EditorUtility;
import org.eclipse.jdt.internal.ui.text.java.JavaCompletionProposal;
import org.eclipse.jdt.internal.ui.text.java.JavaCompletionProposalComputer;
import org.eclipse.jdt.internal.ui.text.java.LazyJavaCompletionProposal;
import org.eclipse.jdt.internal.ui.text.javadoc.HTMLTagCompletionProposalComputer;
import org.eclipse.jdt.internal.ui.text.javadoc.JavadocContentAssistInvocationContext;
import org.eclipse.jdt.ui.text.java.IJavaCompletionProposal;
import org.eclipse.jdt.ui.text.java.IJavaCompletionProposalComputer;
import org.eclipse.jdt.ui.text.java.JavaContentAssistInvocationContext;
import org.eclipse.jface.resource.JFaceResources;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.contentassist.ICompletionProposal;
import org.eclipse.jface.viewers.StyledString;
import org.eclipse.jface.viewers.StyledString.Styler;
import org.eclipse.recommenders.completion.rcp.CompletionContextKey;
import org.eclipse.recommenders.completion.rcp.CompletionContexts;
import org.eclipse.recommenders.completion.rcp.HtmlTagProposals;
import org.eclipse.recommenders.completion.rcp.IRecommendersCompletionContext;
import org.eclipse.recommenders.completion.rcp.processable.IProcessableProposal;
import org.eclipse.recommenders.completion.rcp.processable.NoProposalCollectingCompletionRequestor;
import org.eclipse.recommenders.completion.rcp.processable.ProposalCollectingCompletionRequestor;
import org.eclipse.recommenders.completion.rcp.processable.ProposalProcessor;
import org.eclipse.recommenders.completion.rcp.processable.SessionProcessor;
import org.eclipse.recommenders.internal.subwords.rcp.l10n.LogMessages;
import org.eclipse.recommenders.utils.Checks;
import org.eclipse.recommenders.utils.Logs;
import org.eclipse.recommenders.utils.Reflections;
import org.eclipse.recommenders.utils.rcp.TimeDelimitedProgressMonitor;
import org.eclipse.swt.graphics.Font;
import org.eclipse.ui.IEditorPart;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.collect.Sets;
@SuppressWarnings("restriction")
public class SubwordsSessionProcessor extends SessionProcessor {
/**
* Use the timeout set by {@link JavaCompletionProposalComputer#JAVA_CODE_ASSIST_TIMEOUT}.
*/
private static final long COMPLETION_TIME_OUT = Long.getLong("org.eclipse.jdt.ui.codeAssistTimeout", 5000); //$NON-NLS-1$
private static final int JAVADOC_TYPE_REF_HIGHLIGHT_ADJUSTMENT = "{@link ".length(); //$NON-NLS-1$
// Negative value ensures subsequence matches have a lower relevance than standard JDT or template proposals
public static final int SUBWORDS_RANGE_START = -9000;
public static final int CASE_SENSITIVE_EXACT_MATCH_START = 16
* (RelevanceConstants.R_EXACT_NAME + RelevanceConstants.R_CASE);
public static final int CASE_INSENSITIVE_EXACT_MATCH_START = 16 * RelevanceConstants.R_EXACT_NAME;
private static final int[] EMPTY_SEQUENCE = new int[0];
private static final Class<?> BOLD_STYLER_PROVIDER = Reflections
.loadClass(SubwordsSessionProcessor.class.getClassLoader(),
"org.eclipse.jface.text.contentassist.BoldStylerProvider") //$NON-NLS-1$
.orNull();
private static final Constructor<?> NEW_BOLD_STYLER_PROVIDER = Reflections
.getDeclaredConstructor(BOLD_STYLER_PROVIDER, Font.class).orNull();
private static final Method GET_BOLD_STYLER = Reflections.getDeclaredMethod(BOLD_STYLER_PROVIDER, "getBoldStyler") //$NON-NLS-1$
.orNull();
private static final Method DISPOSE = Reflections.getDeclaredMethod(BOLD_STYLER_PROVIDER, "dispose") //$NON-NLS-1$
.orNull();
private Object stylerProvider;
private Styler styler;
private static final Field CORE_CONTEXT = Reflections
.getDeclaredField(true, JavaContentAssistInvocationContext.class, "fCoreContext").orNull(); //$NON-NLS-1$
private static final Field CU = Reflections.getDeclaredField(true, JavaContentAssistInvocationContext.class, "fCU") //$NON-NLS-1$
.orNull();
private static final Field CU_COMPUTED = Reflections
.getDeclaredField(true, JavaContentAssistInvocationContext.class, "fCUComputed").orNull(); //$NON-NLS-1$
private final HTMLTagCompletionProposalComputer htmlTagProposalComputer = new HTMLTagCompletionProposalComputer();
private final SubwordsRcpPreferences prefs;
private int minPrefixLengthForTypes;
@Inject
public SubwordsSessionProcessor(SubwordsRcpPreferences prefs) {
this.prefs = prefs;
}
@Override
public void initializeContext(IRecommendersCompletionContext recContext) {
try {
minPrefixLengthForTypes = prefs.minPrefixLengthForTypes;
JavaContentAssistInvocationContext jdtContext = recContext.getJavaContext();
ICompilationUnit cu = jdtContext.getCompilationUnit();
int offset = jdtContext.getInvocationOffset();
// Tricky: we bypass the normal code completion request (triggered at the actual cursor position) by
// replacing all required keys manually and triggering content assist where we need it:
// TODO maybe we can get rid of that call by simply using the 'right' collector for the first time? This
// would save ~5 ms I guess.
NoProposalCollectingCompletionRequestor collector = new NoProposalCollectingCompletionRequestor();
cu.codeComplete(offset, collector, new TimeDelimitedProgressMonitor(COMPLETION_TIME_OUT, MILLISECONDS));
InternalCompletionContext compContext = collector.getCoreContext();
if (compContext == null) {
Logs.log(LogMessages.ERROR_COMPLETION_CONTEXT_NOT_COLLECTED, cu.getPath());
return;
}
CORE_CONTEXT.set(jdtContext, compContext);
recContext.set(CompletionContextKey.INTERNAL_COMPLETIONCONTEXT, compContext);
String prefix = getPrefix(jdtContext);
int length = prefix.length();
recContext.set(CompletionContextKey.COMPLETION_PREFIX, prefix);
Map<IJavaCompletionProposal, CompletionProposal> baseProposals = Maps.newHashMap();
recContext.set(JAVA_PROPOSALS, baseProposals);
ASTNode completionNode = compContext.getCompletionNode();
ASTNode completionNodeParent = compContext.getCompletionNodeParent();
SortedSet<Integer> triggerlocations = computeTriggerLocations(offset, completionNode, completionNodeParent,
length);
Set<String> sortkeys = Sets.newHashSet();
for (int trigger : triggerlocations) {
Map<IJavaCompletionProposal, CompletionProposal> newProposals = getNewProposals(jdtContext, trigger);
testAndInsertNewProposals(recContext, baseProposals, sortkeys, newProposals);
}
if (jdtContext instanceof JavadocContentAssistInvocationContext) {
ITextViewer viewer = jdtContext.getViewer();
IEditorPart editor = lookupEditor(cu);
JavadocContentAssistInvocationContext newJdtContext = new JavadocContentAssistInvocationContext(viewer,
offset - length, editor, 0);
testAndInsertNewProposals(recContext, baseProposals, sortkeys,
HtmlTagProposals.computeHtmlTagProposals(htmlTagProposalComputer, newJdtContext));
}
} catch (Exception e) {
Logs.log(LogMessages.ERROR_EXCEPTION_DURING_CODE_COMPLETION, e);
}
}
private SortedSet<Integer> computeTriggerLocations(int offset, ASTNode completionNode, ASTNode completionNodeParent,
int length) {
// It is important to trigger at higher locations first, as the base relevance assigned to a proposal by the JDT
// may depend on the prefix. Proposals which are made for both an empty prefix and a non-empty prefix are thus
// assigned a base relevance that is as close as possible to that the JDT would assign without subwords
// completion enabled.
// TODO MB Need an example.
SortedSet<Integer> triggerlocations = Sets.newTreeSet(Ordering.natural().reverse());
int emptyPrefix = offset - length;
// to make sure we get method stub creation proposals like exe --> private void exe()
// See bug https://bugs.eclipse.org/bugs/show_bug.cgi?id=477801
if (completionNode instanceof CompletionOnFieldType) {
triggerlocations.add(offset);
}
// Trigger first with either the specified prefix or the specified minimum prefix length. Note that this is only
// effective for type and constructor completions, but this situation cannot be detected reliably.
int triggerOffset = min(minPrefixLengthForTypes, length);
triggerlocations.add(emptyPrefix + triggerOffset);
// Always trigger with empty prefix to get all members at the current location:
triggerlocations.add(emptyPrefix);
return triggerlocations;
}
private String getPrefix(JavaContentAssistInvocationContext jdtContext) throws BadLocationException {
CharSequence prefix = jdtContext.computeIdentifierPrefix();
return prefix == null ? "" : prefix.toString(); //$NON-NLS-1$
}
private Map<IJavaCompletionProposal, CompletionProposal> getNewProposals(
JavaContentAssistInvocationContext originalContext, int triggerOffset) {
if (triggerOffset < 0) {
// XXX not sure when this happens but is has happened in the past
return Maps.<IJavaCompletionProposal, CompletionProposal>newHashMap();
}
ICompilationUnit cu = originalContext.getCompilationUnit();
ITextViewer viewer = originalContext.getViewer();
IEditorPart editor = lookupEditor(cu);
JavaContentAssistInvocationContext newJdtContext = new JavaContentAssistInvocationContext(viewer, triggerOffset,
editor);
setCompilationUnit(newJdtContext, cu);
ProposalCollectingCompletionRequestor collector = computeProposals(cu, newJdtContext, triggerOffset);
Map<IJavaCompletionProposal, CompletionProposal> proposals = collector.getProposals();
return proposals != null ? proposals : Maps.<IJavaCompletionProposal, CompletionProposal>newHashMap();
}
private void setCompilationUnit(JavaContentAssistInvocationContext newJdtContext, ICompilationUnit cu) {
if (Checks.anyIsNull(CU, CU_COMPUTED)) {
return;
}
try {
CU.set(newJdtContext, cu);
CU_COMPUTED.set(newJdtContext, true);
} catch (Exception e) {
Logs.log(ERROR_EXCEPTION_DURING_CODE_COMPLETION, e);
}
}
private void testAndInsertNewProposals(IRecommendersCompletionContext crContext,
Map<IJavaCompletionProposal, CompletionProposal> baseProposals, Set<String> sortkeys,
final Map<IJavaCompletionProposal, CompletionProposal> newProposals) {
for (Entry<IJavaCompletionProposal, CompletionProposal> entry : newProposals.entrySet()) {
IJavaCompletionProposal javaProposal = entry.getKey();
CompletionProposal coreProposal = entry.getValue();
// we need a completion string (close to a display string) that allows to spot duplicated proposals
// key point: we don't want to use the display string of lazy completion proposals for performance reasons.
String completionIdentifier = computeCompletionIdentifier(javaProposal, coreProposal);
String completion = CompletionContexts.getPrefixMatchingArea(completionIdentifier);
if (!sortkeys.contains(completionIdentifier) && containsSubsequence(completion, crContext.getPrefix())) {
baseProposals.put(javaProposal, coreProposal);
sortkeys.add(completionIdentifier);
}
}
}
private String computeCompletionIdentifier(IJavaCompletionProposal javaProposal, CompletionProposal coreProposal) {
String completionIdentifier;
if (javaProposal instanceof LazyJavaCompletionProposal && coreProposal != null) {
switch (coreProposal.getKind()) {
case CompletionProposal.CONSTRUCTOR_INVOCATION: {
// result: ClassSimpleName(Lsome/Param;I)V
completionIdentifier = new StringBuilder().append(coreProposal.getName()).append(' ')
.append(coreProposal.getSignature()).append(coreProposal.getDeclarationSignature()).toString();
break;
}
case CompletionProposal.JAVADOC_TYPE_REF: {
// result: ClassSimpleName fully.qualified.ClassSimpleName javadoc
char[] signature = coreProposal.getSignature();
char[] simpleName = Signature.getSignatureSimpleName(signature);
int indexOf = CharOperation.lastIndexOf('.', simpleName);
simpleName = CharOperation.subarray(simpleName, indexOf + 1, simpleName.length);
completionIdentifier = new StringBuilder().append(simpleName).append(' ').append(signature)
.append(" javadoc").toString(); //$NON-NLS-1$
break;
}
case CompletionProposal.TYPE_REF: {
// result: ClassSimpleName fully.qualified.ClassSimpleName
char[] signature = coreProposal.getSignature();
char[] simpleName = Signature.getSignatureSimpleName(signature);
int indexOf = CharOperation.lastIndexOf('.', simpleName);
simpleName = CharOperation.subarray(simpleName, indexOf + 1, simpleName.length);
completionIdentifier = new StringBuilder().append(simpleName).append(' ').append(signature).toString();
break;
}
case CompletionProposal.PACKAGE_REF:
// result: org.eclipse.my.package
completionIdentifier = new String(coreProposal.getDeclarationSignature());
break;
case CompletionProposal.METHOD_REF:
case CompletionProposal.METHOD_REF_WITH_CASTED_RECEIVER:
case CompletionProposal.METHOD_NAME_REFERENCE: {
// result: myMethodName(Lsome/Param;I)V
completionIdentifier = new StringBuilder().append(coreProposal.getName()).append(' ')
.append(coreProposal.getSignature()).append(coreProposal.getDeclarationSignature()).toString();
break;
}
case CompletionProposal.JAVADOC_METHOD_REF: {
// result: myMethodName(Lsome/Param;I)V
completionIdentifier = new StringBuilder().append(coreProposal.getName()).append(' ')
.append(coreProposal.getSignature()).append(coreProposal.getDeclarationSignature())
.append(" javadoc").toString(); //$NON-NLS-1$
break;
}
case CompletionProposal.JAVADOC_PARAM_REF:
case CompletionProposal.JAVADOC_BLOCK_TAG:
case CompletionProposal.JAVADOC_INLINE_TAG: {
completionIdentifier = javaProposal.getDisplayString();
break;
}
default:
// result: display string. This should not happen. We should issue a warning here...
completionIdentifier = javaProposal.getDisplayString();
Logs.log(ERROR_UNEXPECTED_FALL_THROUGH, coreProposal.getKind(), javaProposal.getClass());
break;
}
} else {
completionIdentifier = javaProposal.getDisplayString();
}
return completionIdentifier;
}
@Override
public boolean startSession(IRecommendersCompletionContext crContext) {
return true;
}
private ProposalCollectingCompletionRequestor computeProposals(ICompilationUnit cu,
JavaContentAssistInvocationContext coreContext, int offset) {
ProposalCollectingCompletionRequestor collector = new ProposalCollectingCompletionRequestor(coreContext);
try {
cu.codeComplete(offset, collector, new TimeDelimitedProgressMonitor(COMPLETION_TIME_OUT, MILLISECONDS));
} catch (final Exception e) {
log(ERROR_EXCEPTION_DURING_CODE_COMPLETION, e);
}
return collector;
}
@VisibleForTesting
protected IEditorPart lookupEditor(ICompilationUnit cu) {
return EditorUtility.isOpenInEditor(cu);
}
@Override
public void process(final IProcessableProposal proposal) {
String completionIdentifier = computeCompletionIdentifier(proposal, proposal.getCoreProposal().orNull());
final String matchingArea = CompletionContexts.getPrefixMatchingArea(completionIdentifier);
proposal.getProposalProcessorManager().addProcessor(new ProposalProcessor() {
int[] bestSequence = EMPTY_SEQUENCE;
String prefix;
@Override
public boolean isPrefix(String prefix) {
if (this.prefix != prefix) {
this.prefix = prefix;
CompletionProposal coreProposal = proposal.getCoreProposal().orNull();
if (coreProposal != null
&& (coreProposal.getKind() == CompletionProposal.FIELD_REF_WITH_CASTED_RECEIVER
|| coreProposal.getKind() == CompletionProposal.METHOD_REF_WITH_CASTED_RECEIVER)) {
// This covers the case where the user starts with a prefix of "receiver.ge" and continues
// typing 't' from there: In this case, prefix == "receiver.get" rather than "get".
// I have only ever encountered this with proposal kinds of *_REF_WITH_CASTED_RECEIVER.
int lastIndexOfDot = prefix.lastIndexOf('.');
bestSequence = LCSS.bestSubsequence(matchingArea, prefix.substring(lastIndexOfDot + 1));
} else {
int lastIndexOfHash = prefix.lastIndexOf('#');
if (lastIndexOfHash >= 0) {
// This covers the case where the user starts with a prefix of "Collections#" and continues
// from there.
bestSequence = LCSS.bestSubsequence(matchingArea, prefix.substring(lastIndexOfHash + 1));
} else {
// Besides the obvious, this also covers the case where the user starts with a prefix of
// "Collections#e", which manifests itself as just "e".
bestSequence = LCSS.bestSubsequence(matchingArea, prefix);
}
}
}
if (bestSequence.length > 0) {
// We will highlight on demand in modifyDisplayString.
proposal.setTag(IS_HIGHLIGHTED, true);
}
return prefix.isEmpty() || bestSequence.length > 0;
}
@Override
public void modifyDisplayString(StyledString displayString) {
final int highlightAdjustment;
CompletionProposal coreProposal = proposal.getCoreProposal().orNull();
if (coreProposal == null) {
// HTML tag proposals are non-lazy(!) JavaCompletionProposals that don't have a core proposal.
if (proposal instanceof JavaCompletionProposal && displayString.toString().startsWith("</")) { //$NON-NLS-1$
highlightAdjustment = 2;
} else if (proposal instanceof JavaCompletionProposal && displayString.toString().startsWith("<")) { //$NON-NLS-1$
highlightAdjustment = 1;
} else {
highlightAdjustment = 0;
}
} else {
switch (coreProposal.getKind()) {
case CompletionProposal.JAVADOC_FIELD_REF:
case CompletionProposal.JAVADOC_METHOD_REF:
case CompletionProposal.JAVADOC_VALUE_REF:
highlightAdjustment = displayString.toString().lastIndexOf('#') + 1;
break;
case CompletionProposal.JAVADOC_TYPE_REF:
highlightAdjustment = JAVADOC_TYPE_REF_HIGHLIGHT_ADJUSTMENT;
break;
default:
highlightAdjustment = 0;
}
}
for (int index : bestSequence) {
displayString.setStyle(index + highlightAdjustment, 1, getStyler());
}
}
/**
* The fundamental logic of this method has been taken from the method
* {@link org.eclipse.jdt.internal.codeassist.CompletionEngine#computeRelevanceForCaseMatching}
*/
@Override
public int modifyRelevance() {
if (ArrayUtils.isEmpty(bestSequence)) {
proposal.setTag(IS_PREFIX_MATCH, true);
return 0;
}
int relevanceBoost = 0;
/*
* In providing subwords proposals we simulate two content assist triggers. The first is at position 0
* and the second at the position of the minimum prefix length. For the case that the prefix is longer
* than the minimum prefix length we need to take extra steps to ensure that possible recommendations
* which would be an exact match to this full prefix are given a boost, as they would be for
* non-subwords proposals. (see Bug 468494)
*
* The boost for full prefix matches is the same one as JDT adds at {@link
* org.eclipse.jdt.internal.codeassist.CompletionEngine#computeRelevanceForCaseMatching}
*
* The boost is further multiplied by 16 which reflects what happens in {@link
* org.eclipse.jdt.internal.ui.text.java.LazyJavaCompletionProposal#computeRelevance}
*/
if (StringUtils.equals(prefix, matchingArea)) {
if (minPrefixLengthForTypes < prefix.length()) {
relevanceBoost = CASE_SENSITIVE_EXACT_MATCH_START;
}
proposal.setTag(SUBWORDS_SCORE, null);
proposal.setTag(IS_EXACT_MATCH, true);
proposal.setTag(IS_PREFIX_MATCH, true);
} else if (StringUtils.equalsIgnoreCase(prefix, matchingArea)) {
if (minPrefixLengthForTypes < prefix.length()) {
relevanceBoost = CASE_INSENSITIVE_EXACT_MATCH_START;
}
proposal.setTag(SUBWORDS_SCORE, null);
proposal.setTag(IS_EXACT_MATCH, true);
proposal.setTag(IS_CASE_INSENSITIVE_PREFIX_MATCH, true);
} else if (StringUtils.startsWithIgnoreCase(matchingArea, prefix)) {
// Don't adjust score
proposal.setTag(SUBWORDS_SCORE, null);
proposal.setTag(IS_CASE_INSENSITIVE_PREFIX_MATCH, true);
} else if (CharOperation.camelCaseMatch(prefix.toCharArray(), matchingArea.toCharArray())) {
// Don't adjust score
proposal.setTag(IS_CAMEL_CASE_MATCH, true);
} else {
int score = LCSS.scoreSubsequence(bestSequence);
proposal.setTag(SUBWORDS_SCORE, score);
relevanceBoost = SUBWORDS_RANGE_START + score;
}
return relevanceBoost;
}
/**
* Some {@link IProcessableProposal}s are not produced by the {@link JavaCompletionProposalComputer}, but by
* some other {@link IJavaCompletionProposalComputer}, e.g., the {@link HTMLTagCompletionProposalComputer}.
* These proposals do not have a core proposal.
*/
private boolean isFromJavaCompletionProposalComputer(final IProcessableProposal proposal) {
return proposal.getCoreProposal().isPresent();
}
});
}
@Override
public void endSession(List<ICompletionProposal> proposals) {
styler = null;
if (DISPOSE != null && stylerProvider != null) {
try {
DISPOSE.invoke(stylerProvider);
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
}
}
stylerProvider = null;
}
private Styler getStyler() {
if (styler != null) {
return styler;
}
if (NEW_BOLD_STYLER_PROVIDER != null && GET_BOLD_STYLER != null) {
try {
stylerProvider = NEW_BOLD_STYLER_PROVIDER.newInstance(JFaceResources.getDefaultFont());
styler = (Styler) GET_BOLD_STYLER.invoke(stylerProvider);
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException
| InstantiationException | SecurityException e) {
styler = StyledString.COUNTER_STYLER;
}
} else {
styler = StyledString.COUNTER_STYLER;
}
return styler;
}
}