blob: f2ff9f80807f6220066e1906d3ede184a515078d [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2016, 2019 Red Hat Inc. and others.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Lucas Bullen (Red Hat Inc.) - initial implementation
* Michał Niewrzał (Rogue Wave Software Inc.)
* Lucas Bullen (Red Hat Inc.) - Refactored for incomplete completion lists
* - [Bug 517428] Requests sent before initialization
*******************************************************************************/
package org.eclipse.lsp4e.operations.completion;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.Path;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jface.internal.text.html.BrowserInformationControl;
import org.eclipse.jface.resource.JFaceResources;
import org.eclipse.jface.text.AbstractReusableInformationControlCreator;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.DefaultInformationControl;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IInformationControl;
import org.eclipse.jface.text.IInformationControlCreator;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.Region;
import org.eclipse.jface.text.contentassist.BoldStylerProvider;
import org.eclipse.jface.text.contentassist.ICompletionProposal;
import org.eclipse.jface.text.contentassist.ICompletionProposalExtension3;
import org.eclipse.jface.text.contentassist.ICompletionProposalExtension4;
import org.eclipse.jface.text.contentassist.ICompletionProposalExtension5;
import org.eclipse.jface.text.contentassist.ICompletionProposalExtension6;
import org.eclipse.jface.text.contentassist.ICompletionProposalExtension7;
import org.eclipse.jface.text.contentassist.IContextInformation;
import org.eclipse.jface.text.link.LinkedModeModel;
import org.eclipse.jface.text.link.LinkedModeUI;
import org.eclipse.jface.text.link.LinkedPosition;
import org.eclipse.jface.text.link.LinkedPositionGroup;
import org.eclipse.jface.viewers.StyledString;
import org.eclipse.jface.viewers.StyledString.Styler;
import org.eclipse.lsp4e.LSPEclipseUtils;
import org.eclipse.lsp4e.LanguageServerPlugin;
import org.eclipse.lsp4e.LanguageServiceAccessor;
import org.eclipse.lsp4e.operations.hover.LSBasedHover;
import org.eclipse.lsp4e.ui.LSPImages;
import org.eclipse.lsp4j.CompletionItem;
import org.eclipse.lsp4j.InsertTextFormat;
import org.eclipse.lsp4j.Position;
import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.TextEdit;
import org.eclipse.lsp4j.services.LanguageServer;
import org.eclipse.swt.SWT;
import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.TextStyle;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.texteditor.link.EditorLinkedModeUI;
import com.google.common.collect.ImmutableList;
@SuppressWarnings("restriction")
public class LSIncompleteCompletionProposal
implements ICompletionProposal, ICompletionProposalExtension3, ICompletionProposalExtension4,
ICompletionProposalExtension5, ICompletionProposalExtension6, ICompletionProposalExtension7,
IContextInformation {
private static final int RESOLVE_TIMEOUT = 500;
// Those variables should be defined in LSP4J and reused here whenever done there
// See https://github.com/eclipse/lsp4j/issues/149
/** The currently selected text or the empty string */
private static final String TM_SELECTED_TEXT = "TM_SELECTED_TEXT"; //$NON-NLS-1$
/** The contents of the current line */
private static final String TM_CURRENT_LINE = "TM_CURRENT_LINE"; //$NON-NLS-1$
/** The contents of the word under cursor or the empty string */
private static final String TM_CURRENT_WORD = "TM_CURRENT_WORD"; //$NON-NLS-1$
/** The zero-index based line number */
private static final String TM_LINE_INDEX = "TM_LINE_INDEX"; //$NON-NLS-1$
/** The one-index based line number */
private static final String TM_LINE_NUMBER = "TM_LINE_NUMBER"; //$NON-NLS-1$
/** The filename of the current document */
private static final String TM_FILENAME = "TM_FILENAME"; //$NON-NLS-1$
/** The filename of the current document without its extensions */
private static final String TM_FILENAME_BASE = "TM_FILENAME_BASE"; //$NON-NLS-1$
/** The directory of the current document */
private static final String TM_DIRECTORY = "TM_DIRECTORY"; //$NON-NLS-1$
/** The full file path of the current document */
private static final String TM_FILEPATH = "TM_FILEPATH"; //$NON-NLS-1$
private static final Styler DEPRECATE = new Styler() {
@Override
public void applyStyles(TextStyle textStyle) {
textStyle.strikeout = true;
textStyle.foreground = PlatformUI.getWorkbench().getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY);
};
};
protected final CompletionItem item;
private int initialOffset = -1;
protected int bestOffset = -1;
protected int currentOffset = -1;
protected ITextViewer viewer;
private final IDocument document;
private IRegion selection;
private LinkedPosition firstPosition;
// private LSPDocumentInfo info;
private Integer rankCategory;
private Integer rankScore;
private String documentFilter;
private String documentFilterAddition = ""; //$NON-NLS-1$
private LanguageServer languageServer;
public LSIncompleteCompletionProposal(@NonNull IDocument document, int offset, @NonNull CompletionItem item,
LanguageServer languageServer) {
this.item = item;
this.document = document;
this.languageServer = languageServer;
this.initialOffset = offset;
this.currentOffset = offset;
this.bestOffset = getPrefixCompletionStart(document, offset);
}
/**
* See {@link CompletionProposalTools.getFilterFromDocument} for filter
* generation logic
*
* @return The document filter for the given offset
*/
public String getDocumentFilter(int offset) throws BadLocationException {
if (documentFilter != null) {
if (offset != currentOffset) {
documentFilterAddition = document.get(initialOffset, offset - initialOffset);
rankScore = null;
rankCategory = null;
currentOffset = offset;
}
return documentFilter + documentFilterAddition;
}
currentOffset = offset;
return getDocumentFilter();
}
/**
* See {@link CompletionProposalTools.getFilterFromDocument} for filter
* generation logic
*
* @return The document filter for the last given offset
*/
public String getDocumentFilter() throws BadLocationException {
if (documentFilter != null) {
return documentFilter + documentFilterAddition;
}
documentFilter = CompletionProposalTools.getFilterFromDocument(document, currentOffset,
getFilterString(), bestOffset);
documentFilterAddition = ""; //$NON-NLS-1$
return documentFilter;
}
/**
* See {@link CompletionProposalTools.getScoreOfFilterMatch} for ranking logic
*
* @return The rank of the match between the document's filter and this
* completion's filter.
*/
public int getRankScore() {
if (rankScore != null)
return rankScore;
try {
rankScore = CompletionProposalTools.getScoreOfFilterMatch(getDocumentFilter(),
getFilterString());
} catch (BadLocationException e) {
LanguageServerPlugin.logError(e);
rankScore = -1;
}
return rankScore;
}
/**
* See {@link CompletionProposalTools.getCategoryOfFilterMatch} for category
* logic
*
* @return The category of the match between the document's filter and this
* completion's filter.
*/
public int getRankCategory() {
if (rankCategory != null) {
return rankCategory;
}
try {
rankCategory = CompletionProposalTools.getCategoryOfFilterMatch(getDocumentFilter(),
getFilterString());
} catch (BadLocationException e) {
LanguageServerPlugin.logError(e);
rankCategory = 5;
}
return rankCategory;
}
public int getBestOffset() {
return this.bestOffset;
}
public void updateOffset(int offset) {
this.bestOffset = getPrefixCompletionStart(document, offset);
}
public CompletionItem getItem() {
return this.item;
}
private boolean isDeprecated() {
return item.getDeprecated() != null && item.getDeprecated().booleanValue();
}
@Override
public StyledString getStyledDisplayString(IDocument document, int offset, BoldStylerProvider boldStylerProvider) {
String rawString = getDisplayString();
StyledString res = isDeprecated()
? new StyledString(rawString, DEPRECATE)
: new StyledString(rawString);
if (offset > this.bestOffset) {
try {
String subString = getDocumentFilter(offset).toLowerCase();
int lastIndex = 0;
String lowerRawString = rawString.toLowerCase();
for (Character c : subString.toCharArray()) {
int index = lowerRawString.indexOf(c, lastIndex);
if (index < 0) {
return res;
} else {
res.setStyle(index, 1, new Styler() {
@Override
public void applyStyles(TextStyle textStyle) {
if (isDeprecated()) {
DEPRECATE.applyStyles(textStyle);
}
boldStylerProvider.getBoldStyler().applyStyles(textStyle);
}
});
lastIndex = index + 1;
}
}
} catch (BadLocationException e) {
LanguageServerPlugin.logError(e);
}
}
return res;
}
@Override
public String getDisplayString() {
return this.item.getLabel();
}
@Override
public StyledString getStyledDisplayString() {
if (Boolean.TRUE.equals(item.getDeprecated())) {
return new StyledString(getDisplayString(), DEPRECATE);
}
return new StyledString(getDisplayString());
}
@Override
public boolean isAutoInsertable() {
// TODO consider what's best
return false;
}
@Override
public IInformationControlCreator getInformationControlCreator() {
return new AbstractReusableInformationControlCreator() {
@Override
protected IInformationControl doCreateInformationControl(Shell parent) {
if (BrowserInformationControl.isAvailable(parent)) {
return new BrowserInformationControl(parent, JFaceResources.DEFAULT_FONT, false) {
@Override
public IInformationControlCreator getInformationPresenterControlCreator() {
return parent1 -> new BrowserInformationControl(parent1, JFaceResources.DEFAULT_FONT, true);
}
};
} else {
return new DefaultInformationControl(parent);
}
}
};
}
@Override
public Object getAdditionalProposalInfo(IProgressMonitor monitor) {
if (LanguageServiceAccessor.checkCapability(languageServer,
capability -> Boolean.TRUE.equals(capability.getCompletionProvider().getResolveProvider()))) {
try {
languageServer.getTextDocumentService().resolveCompletionItem(item).thenAcceptAsync(this::updateCompletionItem)
.get(RESOLVE_TIMEOUT, TimeUnit.MILLISECONDS);
} catch (ExecutionException | TimeoutException e) {
LanguageServerPlugin.logError(e);
} catch (InterruptedException e) {
LanguageServerPlugin.logError(e);
Thread.currentThread().interrupt();
}
}
StringBuilder res = new StringBuilder();
if (this.item.getDetail() != null) {
res.append("<p>" + this.item.getDetail() + "</p>"); //$NON-NLS-1$ //$NON-NLS-2$
}
if (res.length() > 0) {
res.append("<br/>"); //$NON-NLS-1$
}
if (this.item.getDocumentation() != null) {
String htmlDocString = LSPEclipseUtils.getHtmlDocString(this.item.getDocumentation());
if (htmlDocString != null) {
res.append(htmlDocString);
}
}
return LSBasedHover.styleHtml(res.toString());
}
private void updateCompletionItem(CompletionItem resolvedItem) {
if (resolvedItem == null) {
return;
}
if (resolvedItem.getLabel() != null) {
item.setLabel(resolvedItem.getLabel());
}
if (resolvedItem.getKind() != null) {
item.setKind(resolvedItem.getKind());
}
if (resolvedItem.getDetail() != null) {
item.setDetail(resolvedItem.getDetail());
}
if (resolvedItem.getDocumentation() != null) {
item.setDocumentation(resolvedItem.getDocumentation());
}
if (resolvedItem.getInsertText() != null) {
item.setInsertText(resolvedItem.getInsertText());
}
if (resolvedItem.getInsertTextFormat() != null) {
item.setInsertTextFormat(resolvedItem.getInsertTextFormat());
}
if (resolvedItem.getTextEdit() != null) {
item.setTextEdit(resolvedItem.getTextEdit());
}
if (resolvedItem.getAdditionalTextEdits() != null) {
item.setAdditionalTextEdits(resolvedItem.getAdditionalTextEdits());
}
}
@Override
public CharSequence getPrefixCompletionText(IDocument document, int completionOffset) {
return item.getInsertText().substring(completionOffset - this.bestOffset);
}
@Override
public int getPrefixCompletionStart(IDocument document, int completionOffset) {
if (this.item.getTextEdit() != null) {
try {
return LSPEclipseUtils.toOffset(this.item.getTextEdit().getRange().getStart(), document);
} catch (BadLocationException e) {
LanguageServerPlugin.logError(e);
}
}
String insertText = getInsertText();
try {
String subDoc = document.get(
Math.max(0, completionOffset - insertText.length()),
Math.min(insertText.length(), completionOffset));
for (int i = 0; i < insertText.length() && i < completionOffset; i++) {
String tentativeCommonString = subDoc.substring(i);
if (insertText.startsWith(tentativeCommonString)) {
return completionOffset - tentativeCommonString.length();
}
}
} catch (BadLocationException e) {
LanguageServerPlugin.logError(e);
}
return completionOffset;
}
@Override
public void apply(IDocument document) {
apply(document, Character.MIN_VALUE, 0, this.bestOffset);
}
protected void apply(IDocument document, char trigger, int stateMask, int offset) {
String insertText = null;
TextEdit textEdit = item.getTextEdit();
try {
if (textEdit == null) {
insertText = getInsertText();
Position start = LSPEclipseUtils.toPosition(this.bestOffset, document);
Position end = LSPEclipseUtils.toPosition(offset, document); // need 2 distinct objects
textEdit = new TextEdit(new Range(start, end), insertText);
} else if (offset > this.initialOffset) {
// characters were added after completion was activated
int shift = offset - this.initialOffset;
textEdit.getRange().getEnd().setCharacter(textEdit.getRange().getEnd().getCharacter() + shift);
}
{ // workaround https://github.com/Microsoft/vscode/issues/17036
Position start = textEdit.getRange().getStart();
Position end = textEdit.getRange().getEnd();
if (start.getLine() > end.getLine() || (start.getLine() == end.getLine() && start.getCharacter() > end.getCharacter())) {
textEdit.getRange().setEnd(start);
textEdit.getRange().setStart(end);
}
}
{ // allow completion items to be wrong with a too wide range
Position documentEnd = LSPEclipseUtils.toPosition(document.getLength(), document);
Position textEditEnd = textEdit.getRange().getEnd();
if (documentEnd.getLine() < textEditEnd.getLine()
|| (documentEnd.getLine() == textEditEnd.getLine() && documentEnd.getCharacter() < textEditEnd.getCharacter())) {
textEdit.getRange().setEnd(documentEnd);
}
}
if (insertText != null) {
// try to reuse existing characters after completion location
int shift = offset - this.bestOffset;
int commonSize = 0;
while (commonSize < insertText.length() - shift
&& document.getLength() > offset + commonSize
&& document.getChar(this.bestOffset + shift + commonSize) == insertText.charAt(commonSize + shift)) {
commonSize++;
}
textEdit.getRange().getEnd().setCharacter(textEdit.getRange().getEnd().getCharacter() + commonSize);
}
insertText = textEdit.getNewText();
LinkedHashMap<String, List<LinkedPosition>> regions = new LinkedHashMap<>();
int insertionOffset = LSPEclipseUtils.toOffset(textEdit.getRange().getStart(), document);
insertionOffset = computeNewOffset(item.getAdditionalTextEdits(), insertionOffset, document);
if (item.getInsertTextFormat() == InsertTextFormat.Snippet) {
int currentOffset = 0;
while ((currentOffset = insertText.indexOf('$', currentOffset)) != -1) {
StringBuilder keyBuilder = new StringBuilder();
String defaultValue = ""; //$NON-NLS-1$
int length = 1;
while (currentOffset + length < insertText.length() && Character.isDigit(insertText.charAt(currentOffset + length))) {
keyBuilder.append(insertText.charAt(currentOffset + length));
length++;
}
if (length == 1 && insertText.length() >= 2 && insertText.charAt(currentOffset + 1) == '{') {
length++;
while (currentOffset + length < insertText.length() && Character.isDigit(insertText.charAt(currentOffset + length))) {
keyBuilder.append(insertText.charAt(currentOffset + length));
length++;
}
if (currentOffset + length < insertText.length() && insertText.charAt(currentOffset + length) == ':') {
length++;
}
while (currentOffset + length < insertText.length() && insertText.charAt(currentOffset + length) != '}') {
defaultValue += insertText.charAt(currentOffset + length);
length++;
}
if (defaultValue.startsWith("$")) { //$NON-NLS-1$
String varValue = getVariableValue(defaultValue.substring(1));
if (varValue != null) {
defaultValue = varValue;
}
}
if (currentOffset + length < insertText.length() && insertText.charAt(currentOffset + length) == '}') {
length++;
}
}
if (keyBuilder.length() > 0) {
String key = keyBuilder.toString();
if (!regions.containsKey(key)) {
regions.put(key, new ArrayList<>());
}
insertText = insertText.substring(0, currentOffset) + defaultValue + insertText.substring(currentOffset + length);
LinkedPosition position = new LinkedPosition(document, insertionOffset + currentOffset, defaultValue.length());
if (firstPosition == null) {
firstPosition = position;
}
regions.get(key).add(position);
currentOffset += defaultValue.length();
} else {
currentOffset++;
}
}
}
textEdit.setNewText(insertText); // insertText now has placeholder removed
List<TextEdit> additionalEdits = item.getAdditionalTextEdits();
if (additionalEdits != null && !additionalEdits.isEmpty()) {
ImmutableList.Builder<TextEdit> allEdits = ImmutableList.builder();
allEdits.add(textEdit);
allEdits.addAll(additionalEdits);
LSPEclipseUtils.applyEdits(document, allEdits.build());
} else {
LSPEclipseUtils.applyEdit(textEdit, document);
}
if (viewer != null && !regions.isEmpty()) {
LinkedModeModel model = new LinkedModeModel();
for (List<LinkedPosition> positions: regions.values()) {
LinkedPositionGroup group = new LinkedPositionGroup();
for (LinkedPosition position : positions) {
group.addPosition(position);
}
model.addGroup(group);
}
model.forceInstall();
LinkedModeUI ui = new EditorLinkedModeUI(model, viewer);
// ui.setSimpleMode(true);
// ui.setExitPolicy(new ExitPolicy(closingCharacter, document));
// ui.setExitPosition(getTextViewer(), exit, 0, Integer.MAX_VALUE);
ui.setCyclingMode(LinkedModeUI.CYCLE_NEVER);
ui.enter();
} else {
selection = new Region(insertionOffset + textEdit.getNewText().length(), 0);
}
} catch (BadLocationException ex) {
LanguageServerPlugin.logError(ex);
}
}
private int computeNewOffset(List<TextEdit> additionalTextEdits, int insertionOffset, IDocument doc) {
if (additionalTextEdits != null && !additionalTextEdits.isEmpty()) {
int adjustment = 0;
for (TextEdit edit : additionalTextEdits) {
try {
Range rng = edit.getRange();
int start = LSPEclipseUtils.toOffset(rng.getStart(), doc);
if (start <= insertionOffset) {
int end = LSPEclipseUtils.toOffset(rng.getEnd(), doc);
int orgLen = end - start;
int newLeng = edit.getNewText().length();
int editChange = newLeng - orgLen;
adjustment += editChange;
}
} catch (BadLocationException e) {
LanguageServerPlugin.logError(e);
}
}
return insertionOffset + adjustment;
}
return insertionOffset;
}
private String getVariableValue(String variableName) {
switch (variableName) {
case TM_FILENAME_BASE:
IPath pathBase = Path.fromPortableString(LSPEclipseUtils.toUri(document).getPath()).removeFileExtension();
String fileName = pathBase.lastSegment();
return fileName != null ? fileName : ""; //$NON-NLS-1$
case TM_FILENAME:
return Path.fromPortableString(LSPEclipseUtils.toUri(document).getPath()).lastSegment();
case TM_FILEPATH:
return LSPEclipseUtils.toUri(document).getPath();
case TM_DIRECTORY:
return Path.fromPortableString(LSPEclipseUtils.toUri(document).getPath()).removeLastSegments(1).toString();
case TM_LINE_INDEX:
int lineIndex = item.getTextEdit().getRange().getStart().getLine();
return Integer.toString(lineIndex);
case TM_LINE_NUMBER:
int lineNumber = item.getTextEdit().getRange().getStart().getLine();
return Integer.toString(lineNumber + 1);
case TM_CURRENT_LINE:
int currentLineIndex = item.getTextEdit().getRange().getStart().getLine();
try {
IRegion lineInformation = document.getLineInformation(currentLineIndex);
String line = document.get(lineInformation.getOffset(), lineInformation.getLength());
return line;
} catch (BadLocationException e) {
LanguageServerPlugin.logWarning(e.getMessage(), e);
return ""; //$NON-NLS-1$
}
case TM_SELECTED_TEXT:
Range selectedRange = item.getTextEdit().getRange();
try {
int startOffset = LSPEclipseUtils.toOffset(selectedRange.getStart(), document);
int endOffset = LSPEclipseUtils.toOffset(selectedRange.getEnd(), document);
String selectedText = document.get(startOffset, endOffset - startOffset);
return selectedText;
} catch (BadLocationException e) {
LanguageServerPlugin.logWarning(e.getMessage(), e);
return ""; //$NON-NLS-1$
}
case TM_CURRENT_WORD:
return ""; //$NON-NLS-1$
default:
return null;
}
}
protected String getInsertText() {
String insertText = this.item.getInsertText();
if (this.item.getTextEdit() != null) {
insertText = this.item.getTextEdit().getNewText();
}
if (insertText == null) {
insertText = this.item.getLabel();
}
return insertText;
}
@Override
public Point getSelection(IDocument document) {
if (this.firstPosition != null) {
return new Point(this.firstPosition.getOffset(), this.firstPosition.getLength());
}
if (selection == null) {
return null;
}
return new Point(selection.getOffset(), selection.getLength());
}
@Override
public String getAdditionalProposalInfo() {
return this.item.getDetail();
}
@Override
public Image getImage() {
return LSPImages.imageFromCompletionItem(this.item);
}
@Override
public IContextInformation getContextInformation() {
return this;
}
@Override
public String getContextDisplayString() {
return getAdditionalProposalInfo();
}
@Override
public String getInformationDisplayString() {
return getAdditionalProposalInfo();
}
public String getSortText() {
if (item.getSortText() != null && !item.getSortText().isEmpty()) {
return item.getSortText();
}
return item.getLabel();
}
public String getFilterString() {
if (item.getFilterText() != null && !item.getFilterText().isEmpty()) {
return item.getFilterText();
}
return item.getLabel();
}
}