diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/ISnippetContext.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/ISnippetContext.java new file mode 100644 index 0000000000..8840262ae8 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/ISnippetContext.java @@ -0,0 +1,31 @@ +/******************************************************************************* +* Copyright (c) 2020 Red Hat Inc. and others. +* All rights reserved. This program and the accompanying materials +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat Inc. - initial API and implementation +*******************************************************************************/ +package org.eclipse.lemminx.commons.snippets; + +/** + * Snippet context used to filter the snippet. + * + * @author Angelo ZERR + * + * @param the value type waited by the snippet context. + */ +public interface ISnippetContext { + + /** + * Return true if the given value match the snippet context and false otherwise. + * + * @param value the value to check. + * @return true if the given value match the snippet context and false + * otherwise. + */ + boolean isMatch(T value); +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/ISnippetRegistryLoader.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/ISnippetRegistryLoader.java new file mode 100644 index 0000000000..e832b1eb5d --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/ISnippetRegistryLoader.java @@ -0,0 +1,38 @@ +/******************************************************************************* +* Copyright (c) 2020 Red Hat Inc. and others. +* All rights reserved. This program and the accompanying materials +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat Inc. - initial API and implementation +*******************************************************************************/ +package org.eclipse.lemminx.commons.snippets; + +/** + * Loader used to load snippets in a given registry for a language id. + * + * @author Angelo ZERR + * + */ +public interface ISnippetRegistryLoader { + + /** + * Register snippets in the given snippet registry. + * + * @param registry + * @throws Exception + */ + void load(SnippetRegistry registry) throws Exception; + + /** + * Returns the language id and null otherwise. + * + * @return the language id and null otherwise. + */ + default String getLanguageId() { + return null; + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/Snippet.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/Snippet.java new file mode 100644 index 0000000000..ef95cc0fd6 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/Snippet.java @@ -0,0 +1,86 @@ +/******************************************************************************* +* Copyright (c) 2020 Red Hat Inc. and others. +* All rights reserved. This program and the accompanying materials +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat Inc. - initial API and implementation +*******************************************************************************/ +package org.eclipse.lemminx.commons.snippets; + +import java.util.List; +import java.util.function.Predicate; + +/** + * Snippet description (like vscode snippet). + * + * @author Angelo ZERR + * + */ +public class Snippet { + + private List prefixes; + + private List body; + + private String description; + + private String scope; + + private ISnippetContext context; + + public List getPrefixes() { + return prefixes; + } + + public void setPrefixes(List prefixes) { + this.prefixes = prefixes; + } + + public List getBody() { + return body; + } + + public void setBody(List body) { + this.body = body; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public ISnippetContext getContext() { + return context; + } + + public void setContext(ISnippetContext context) { + this.context = context; + } + + public boolean hasContext() { + return getContext() != null; + } + + public boolean match(Predicate> contextFilter) { + if (!hasContext()) { + return true; + } + return contextFilter.test(getContext()); + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/SnippetDeserializer.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/SnippetDeserializer.java new file mode 100644 index 0000000000..a2b11010f6 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/SnippetDeserializer.java @@ -0,0 +1,108 @@ +/******************************************************************************* +* Copyright (c) 2020 Red Hat Inc. and others. +* All rights reserved. This program and the accompanying materials +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat Inc. - initial API and implementation +*******************************************************************************/ +package org.eclipse.lemminx.commons.snippets; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; + +/** + * GSON deserializer to build Snippet from vscode JSON snippet. + * + * @author Angelo ZERR + * + */ +class SnippetDeserializer implements JsonDeserializer { + + private static final String PREFIX_ELT = "prefix"; + private static final String DESCRIPTION_ELT = "description"; + private static final String SCOPE_ELT = "scope"; + private static final String BODY_ELT = "body"; + private static final String CONTEXT_ELT = "context"; + + private final TypeAdapter> contextDeserializer; + + public SnippetDeserializer(TypeAdapter> contextDeserializer) { + this.contextDeserializer = contextDeserializer; + } + + @Override + public Snippet deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + Snippet snippet = new Snippet(); + JsonObject snippetObj = json.getAsJsonObject(); + + // prefix + List prefixes = new ArrayList<>(); + JsonElement prefixElt = snippetObj.get(PREFIX_ELT); + if (prefixElt != null) { + if (prefixElt.isJsonArray()) { + JsonArray prefixArray = (JsonArray) prefixElt; + prefixArray.forEach(elt -> { + prefixes.add(elt.getAsString()); + }); + } else if (prefixElt.isJsonPrimitive()) { + prefixes.add(prefixElt.getAsString()); + } + } + snippet.setPrefixes(prefixes); + + // body + List body = new ArrayList<>(); + JsonElement bodyElt = snippetObj.get(BODY_ELT); + if (bodyElt != null) { + if (bodyElt.isJsonArray()) { + JsonArray bodyArray = (JsonArray) bodyElt; + bodyArray.forEach(elt -> { + body.add(elt.getAsString()); + }); + } else if (bodyElt.isJsonPrimitive()) { + body.add(bodyElt.getAsString()); + } + } + snippet.setBody(body); + + // description + JsonElement descriptionElt = snippetObj.get(DESCRIPTION_ELT); + if (descriptionElt != null) { + String description = descriptionElt.getAsString(); + snippet.setDescription(description); + } + + // scope + JsonElement scopeElt = snippetObj.get(SCOPE_ELT); + if (scopeElt != null) { + String scope = scopeElt.getAsString(); + snippet.setScope(scope); + } + + // context + if (contextDeserializer != null) { + JsonElement contextElt = snippetObj.get(CONTEXT_ELT); + if (contextElt != null) { + ISnippetContext snippetContext = contextDeserializer.fromJsonTree(contextElt); + snippet.setContext(snippetContext); + } + } + + return snippet; + } + +} \ No newline at end of file diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/SnippetRegistry.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/SnippetRegistry.java new file mode 100644 index 0000000000..9f0dee0763 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/SnippetRegistry.java @@ -0,0 +1,311 @@ +/******************************************************************************* +* Copyright (c) 2020 Red Hat Inc. and others. +* All rights reserved. This program and the accompanying materials +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat Inc. - initial API and implementation +*******************************************************************************/ +package org.eclipse.lemminx.commons.snippets; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.ServiceLoader; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.InsertTextFormat; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; + +/** + * A registry for snippets which uses the same format than vscode snippet. + * + * @author Angelo ZERR + * + */ +public class SnippetRegistry { + + private static final Logger LOGGER = Logger.getLogger(SnippetRegistry.class.getName()); + + private final List snippets; + + public SnippetRegistry() { + this(null); + } + + /** + * Snippet registry for a given language id. + * + * @param languageId the language id and null otherwise. + */ + public SnippetRegistry(String languageId) { + snippets = new ArrayList<>(); + // Load snippets from SPI + ServiceLoader loaders = ServiceLoader.load(ISnippetRegistryLoader.class); + loaders.forEach(loader -> { + if (Objects.equals(languageId, loader.getLanguageId())) { + try { + loader.load(this); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error while consumming snippet loader " + loader.getClass().getName(), e); + } + } + }); + } + + /** + * Register the given snippet. + * + * @param snippet the snippet to register. + */ + public void registerSnippet(Snippet snippet) { + snippets.add(snippet); + } + + /** + * Register the snippets from the given JSON input stream. + * + * @param in the JSON input stream which declares snippets with vscode snippet + * format. + * @throws IOException + */ + public void registerSnippets(InputStream in) throws IOException { + registerSnippets(in, null); + } + + /** + * Register the snippets from the given JSON stream with a context. + * + * @param in the JSON input stream which declares snippets with + * vscode snippet format. + * @param contextDeserializer the GSON context deserializer used to create Java + * context. + * @throws IOException + */ + public void registerSnippets(InputStream in, TypeAdapter> contextDeserializer) + throws IOException { + registerSnippets(new InputStreamReader(in, StandardCharsets.UTF_8.name()), contextDeserializer); + } + + /** + * Register the snippets from the given JSON reader. + * + * @param in the JSON reader which declares snippets with vscode snippet format. + * @throws IOException + */ + public void registerSnippets(Reader in) throws IOException { + registerSnippets(in, null); + } + + /** + * Register the snippets from the given JSON reader with a context. + * + * @param in the JSON reader which declares snippets with + * vscode snippet format. + * @param contextDeserializer the GSON context deserializer used to create Java + * context. + * @throws IOException + */ + public void registerSnippets(Reader in, TypeAdapter> contextDeserializer) + throws IOException { + JsonReader reader = new JsonReader(in); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + Snippet snippet = createSnippet(reader, contextDeserializer); + if (snippet.getDescription() == null) { + snippet.setDescription(name); + } + registerSnippet(snippet); + } + reader.endObject(); + } + + private static Snippet createSnippet(JsonReader reader, + TypeAdapter> contextDeserializer) throws JsonIOException, JsonSyntaxException { + GsonBuilder builder = new GsonBuilder(); + builder.registerTypeAdapter(Snippet.class, new SnippetDeserializer(contextDeserializer)); + return builder.create().fromJson(reader, Snippet.class); + } + + /** + * Returns all snippets. + * + * @return all snippets. + */ + public List getSnippets() { + return snippets; + } + + /** + * Returns the snippet completion items according to the context filter. + * + * @param replaceRange the replace range. + * @param lineDelimiter the line delimiter. + * @param canSupportMarkdown true if markdown is supported to generate + * documentation and false otherwise. + * @param contextFilter the context filter. + * @return the snippet completion items according to the context filter. + */ + public List getCompletionItem(final Range replaceRange, final String lineDelimiter, + boolean canSupportMarkdown, Predicate> contextFilter) { + if (replaceRange == null) { + return Collections.emptyList(); + } + return getSnippets().stream().filter(snippet -> { + return snippet.match(contextFilter); + }).map(snippet -> { + String prefix = snippet.getPrefixes().get(0); + String label = prefix; + CompletionItem item = new CompletionItem(); + item.setLabel(label); + item.setDetail(snippet.getDescription()); + String insertText = getInsertText(snippet, false, lineDelimiter); + item.setKind(CompletionItemKind.Snippet); + item.setDocumentation(Either.forRight(createDocumentation(snippet, canSupportMarkdown, lineDelimiter))); + item.setFilterText(prefix); + item.setTextEdit(new TextEdit(replaceRange, insertText)); + item.setInsertTextFormat(InsertTextFormat.Snippet); + return item; + + }).filter(item -> item != null).collect(Collectors.toList()); + } + + private static MarkupContent createDocumentation(Snippet snippet, boolean canSupportMarkdown, + String lineDelimiter) { + StringBuilder doc = new StringBuilder(); + if (canSupportMarkdown) { + doc.append(System.lineSeparator()); + doc.append("```"); + String scope = snippet.getScope(); + if (scope != null) { + doc.append(scope); + } + doc.append(System.lineSeparator()); + } + String insertText = getInsertText(snippet, true, lineDelimiter); + doc.append(insertText); + if (canSupportMarkdown) { + doc.append(System.lineSeparator()); + doc.append("```"); + doc.append(System.lineSeparator()); + } + return new MarkupContent(canSupportMarkdown ? MarkupKind.MARKDOWN : MarkupKind.PLAINTEXT, doc.toString()); + } + + private static String getInsertText(Snippet snippet, boolean replace, String lineDelimiter) { + StringBuilder text = new StringBuilder(); + int i = 0; + List body = snippet.getBody(); + if (body != null) { + for (String bodyLine : body) { + if (i > 0) { + text.append(lineDelimiter); + } + if (replace) { + bodyLine = replace(bodyLine); + } + text.append(bodyLine); + i++; + } + } + return text.toString(); + } + + private static String replace(String line) { + return replace(line, 0, null); + } + + private static String replace(String line, int offset, StringBuilder newLine) { + int startExpr = line.indexOf("${", offset); + if (startExpr == -1) { + if (newLine == null) { + return line; + } + newLine.append(line.substring(offset, line.length())); + return newLine.toString(); + } + int endExpr = line.indexOf("}", startExpr); + if (endExpr == -1) { + // Should never occur + return line; + } + if (newLine == null) { + newLine = new StringBuilder(); + } + newLine.append(line.substring(offset, startExpr)); + // Parameter + int startParam = startExpr + 2; + int endParam = endExpr; + boolean startsWithNumber = true; + for (int i = startParam; i < endParam; i++) { + char ch = line.charAt(i); + if (Character.isDigit(ch)) { + startsWithNumber = true; + } else if (ch == ':') { + if (startsWithNumber) { + startParam = i + 1; + } + break; + } else if (ch == '|') { + if (startsWithNumber) { + startParam = i + 1; + int index = line.indexOf(',', startExpr); + if (index != -1) { + endParam = index; + } + } + break; + } else { + break; + } + } + newLine.append(line.substring(startParam, endParam)); + return replace(line, endExpr + 1, newLine); + } + + protected static String findExprBeforeAt(String text, int offset) { + if (offset < 0 || offset > text.length()) { + return null; + } + if (offset == 0) { + return ""; + } + StringBuilder expr = new StringBuilder(); + int i = offset - 1; + for (; i >= 0; i--) { + char ch = text.charAt(i); + if (Character.isWhitespace(ch)) { + break; + } else { + expr.insert(0, ch); + } + } + return expr.toString(); + } + +} \ No newline at end of file diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/TextDocumentSnippetRegistry.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/TextDocumentSnippetRegistry.java new file mode 100644 index 0000000000..f08b054a19 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/TextDocumentSnippetRegistry.java @@ -0,0 +1,90 @@ +/******************************************************************************* +* Copyright (c) 2020 Red Hat Inc. and others. +* All rights reserved. This program and the accompanying materials +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Red Hat Inc. - initial API and implementation +*******************************************************************************/ +package org.eclipse.lemminx.commons.snippets; + +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.eclipse.lemminx.commons.BadLocationException; +import org.eclipse.lemminx.commons.TextDocument; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.Range; + +/** + * Snippet registry which works with {@link TextDocument}. + * + * @author Angelo ZERR + * + */ +public class TextDocumentSnippetRegistry extends SnippetRegistry { + + private static final Logger LOGGER = Logger.getLogger(TextDocumentSnippetRegistry.class.getName()); + + public TextDocumentSnippetRegistry() { + super(); + } + + public TextDocumentSnippetRegistry(String languageId) { + super(languageId); + } + + /** + * Returns the snippet completion items for the given completion offset and + * context filter. + * + * @param document the text document. + * @param completionOffset the completion offset. + * @param canSupportMarkdown true if markdown is supported to generate + * documentation and false otherwise. + * @param contextFilter the context filter. + * @return the snippet completion items for the given completion offset and + * context filter. + */ + public List getCompletionItems(TextDocument document, int completionOffset, + boolean canSupportMarkdown, Predicate> contextFilter) { + try { + String lineDelimiter = getLineDelimiter(document, completionOffset); + Range range = getReplaceRange(document, completionOffset); + return super.getCompletionItem(range, lineDelimiter, canSupportMarkdown, contextFilter); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error while computing snippet completion items", e); + return Collections.emptyList(); + } + } + + private static String getLineDelimiter(TextDocument document, int completionOffset) throws BadLocationException { + int lineNumber = document.positionAt(completionOffset).getLine(); + return document.lineDelimiter(lineNumber); + } + + public Range getReplaceRange(TextDocument document, int completionOffset) throws BadLocationException { + String expr = getExpr(document, completionOffset); + if (expr == null) { + return null; + } + int startOffset = completionOffset - expr.length(); + int endOffset = completionOffset; + return getReplaceRange(startOffset, endOffset, document); + } + + protected String getExpr(TextDocument document, int completionOffset) { + return findExprBeforeAt(document.getText(), completionOffset); + } + + protected Range getReplaceRange(int replaceStart, int replaceEnd, TextDocument document) + throws BadLocationException { + return new Range(document.positionAt(replaceStart), document.positionAt(replaceEnd)); + } +}