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..c8fe1c0cf4 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/ISnippetContext.java @@ -0,0 +1,33 @@ +/******************************************************************************* +* 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.Map; + +/** + * 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, Map model); +} 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..0e240798cf --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/Snippet.java @@ -0,0 +1,88 @@ +/******************************************************************************* +* 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.Map; +import java.util.function.BiPredicate; + +/** + * 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(BiPredicate, Map> contextFilter, + Map model) { + if (!hasContext()) { + return true; + } + return contextFilter.test(getContext(), model); + } + +} 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..a2689ea186 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/SnippetRegistry.java @@ -0,0 +1,325 @@ +/******************************************************************************* +* 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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.ServiceLoader; +import java.util.function.BiPredicate; +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, BiPredicate, Map> contextFilter) { + if (replaceRange == null) { + return Collections.emptyList(); + } + Map model = new HashMap<>(); + return getSnippets().stream().filter(snippet -> { + return snippet.match(contextFilter, model); + }).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, model, false, lineDelimiter); + item.setKind(CompletionItemKind.Snippet); + item.setDocumentation( + Either.forRight(createDocumentation(snippet, model, 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, Map model, + 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, model, 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, Map model, 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); + } + bodyLine = merge(bodyLine, model, replace); + text.append(bodyLine); + i++; + } + } + return text.toString(); + } + + private static String merge(String line, Map model, boolean replace) { + return replace(line, 0, model, replace, null); + } + + private static String replace(String line, int offset, Map model, boolean replace, + 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; + } + } + String paramName = line.substring(startParam, endParam); + if (model.containsKey(paramName)) { + paramName = model.get(paramName); + } else if (!replace) { + paramName = line.substring(startExpr, endExpr + 1); + } + newLine.append(paramName); + return replace(line, endExpr + 1, model, replace, newLine); + } + + protected 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 (isStop(ch)) { + break; + } else { + expr.insert(0, ch); + } + } + return expr.toString(); + } + + protected boolean isStop(char ch) { + return Character.isWhitespace(ch); + } + +} \ 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..ffaab9b6aa --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/snippets/TextDocumentSnippetRegistry.java @@ -0,0 +1,91 @@ +/******************************************************************************* +* 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.Map; +import java.util.function.BiPredicate; +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, BiPredicate, Map> 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)); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java index 324f5d716b..a34b0c62d2 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java @@ -25,6 +25,7 @@ import org.eclipse.lemminx.commons.BadLocationException; import org.eclipse.lemminx.commons.TextDocument; +import org.eclipse.lemminx.commons.snippets.TextDocumentSnippetRegistry; import org.eclipse.lemminx.customservice.AutoCloseTagResponse; import org.eclipse.lemminx.dom.DOMAttr; import org.eclipse.lemminx.dom.DOMDocument; @@ -39,6 +40,7 @@ import org.eclipse.lemminx.services.extensions.ICompletionRequest; import org.eclipse.lemminx.services.extensions.ICompletionResponse; import org.eclipse.lemminx.services.extensions.XMLExtensionsRegistry; +import org.eclipse.lemminx.services.snippets.IXMLSnippetContext; import org.eclipse.lemminx.settings.SharedSettings; import org.eclipse.lemminx.utils.StringUtils; import org.eclipse.lemminx.utils.XMLPositionUtility; @@ -46,6 +48,7 @@ import org.eclipse.lsp4j.CompletionItemKind; import org.eclipse.lsp4j.CompletionList; import org.eclipse.lsp4j.InsertTextFormat; +import org.eclipse.lsp4j.MarkupKind; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextEdit; @@ -57,10 +60,18 @@ */ public class XMLCompletions { + private static class XMLTextDocumentSnippetRegistry extends TextDocumentSnippetRegistry { + @Override + protected boolean isStop(char ch) { + return super.isStop(ch) || ch == '>'; + } + } + private static final Logger LOGGER = Logger.getLogger(XMLCompletions.class.getName()); private static final Pattern regionCompletionRegExpr = Pattern.compile("^(\\s*)(<(!(-(-\\s*(#\\w*)?)?)?)?)?$"); private final XMLExtensionsRegistry extensionsRegistry; + private TextDocumentSnippetRegistry snippetRegistry; public XMLCompletions(XMLExtensionsRegistry extensionsRegistry) { this.extensionsRegistry = extensionsRegistry; @@ -81,6 +92,7 @@ public CompletionList doComplete(DOMDocument xmlDocument, Position position, Sha DOMNode node = completionRequest.getNode(); String text = xmlDocument.getText(); + collectSnippetSuggestions(completionResponse, completionRequest); if (text.isEmpty()) { // When XML document is empty, try to collect root element (from file // association) @@ -98,13 +110,6 @@ public CompletionList doComplete(DOMDocument xmlDocument, Position position, Sha if (scanner.getTokenEnd() == offset) { int endPos = scanNextForEndPos(offset, scanner, TokenType.StartTag); collectTagSuggestions(offset, endPos, completionRequest, completionResponse); - collectCDATACompletion(completionRequest, completionResponse); - collectCommentCompletion(completionRequest, completionResponse); - return completionResponse; - } else if (text.charAt(scanner.getTokenOffset() + 1) == '!') { - // Case where completion was triggered after snippets = getSnippetRegistry().getCompletionItems(document, offset, + completionRequest.canSupportMarkupKind(MarkupKind.MARKDOWN), (context, model) -> { + if (context instanceof IXMLSnippetContext) { + return (((IXMLSnippetContext) context).isMatch(completionRequest, model)); + } + return false; + }); + for (CompletionItem completionItem : snippets) { + completionResponse.addCompletionItem(completionItem); + } + } + /** * Returns true if completion was triggered inside DTD content (internal or * external DTD) and false otherwise. @@ -317,7 +335,7 @@ private static boolean isInsideDTDContent(DOMNode node, DOMDocument xmlDocument) return (node.getParentNode() != null && node.getParentNode().isDoctype()); } - public boolean isBalanced(DOMNode node) { + private boolean isBalanced(DOMNode node) { if (node.isClosed() == false) { return false; } @@ -641,8 +659,6 @@ private void collectInsideContent(CompletionRequest request, CompletionResponse } } collectionRegionProposals(request, response); - collectCDATACompletion(request, response); - collectCommentCompletion(request, response); } private void collectionRegionProposals(ICompletionRequest request, ICompletionResponse response) { @@ -682,79 +698,6 @@ private void collectionRegionProposals(ICompletionRequest request, ICompletionRe } } - private void collectCDATACompletion(ICompletionRequest request, ICompletionResponse response) { - try { - boolean isSnippetsSupported = request.isCompletionSnippetsSupported(); - InsertTextFormat insertFormat = request.getInsertTextFormat(); - - DOMDocument document = request.getXMLDocument(); - - String filter = "" : ""; - cdataProposal.setTextEdit(new TextEdit(editRange, textEdit)); - cdataProposal.setDocumentation("Insert "); - response.addCompletionItem(cdataProposal); - } catch (BadLocationException e) { - LOGGER.log(Level.SEVERE, "While performing collectCDATACompletion", e); - } - } - - private void collectCommentCompletion(ICompletionRequest request, ICompletionResponse response) { - try { - boolean isSnippetsSupported = request.isCompletionSnippetsSupported(); - InsertTextFormat insertFormat = request.getInsertTextFormat(); - - DOMDocument document = request.getXMLDocument(); - String filter = "" : ""; - commentProposal.setTextEdit(new TextEdit(editRange, textEdit)); - commentProposal.setDocumentation("Insert "); - response.addCompletionItem(commentProposal); - } catch (BadLocationException e) { - LOGGER.log(Level.SEVERE, "While performing collectCommentCompletion", e); - } - } - private void collectAttributeNameSuggestions(int nameStart, CompletionRequest completionRequest, CompletionResponse completionResponse) { collectAttributeNameSuggestions(nameStart, completionRequest.getOffset(), completionRequest, @@ -836,90 +779,6 @@ private void collectAttributeValueSuggestions(int valueStart, int valueEnd, Comp } } - private void collectInsideDTDContent(CompletionRequest request, CompletionResponse response) { - collectInsideDTDContent(request, response, false); - } - - private void collectInsideDTDContent(CompletionRequest request, CompletionResponse response, boolean isContent) { - // Insert DTD Element Declaration - // see https://www.w3.org/TR/REC-xml/#dt-eldecl - boolean isSnippetsSupported = request.isCompletionSnippetsSupported(); - InsertTextFormat insertFormat = request.getInsertTextFormat(); - CompletionItem elementDecl = new CompletionItem(); - elementDecl.setLabel("Insert DTD Element declaration"); - elementDecl.setKind(CompletionItemKind.EnumMember); - elementDecl.setFilterText("" - : ""; - elementDecl.setTextEdit(new TextEdit(editRange, textEdit)); - elementDecl.setDocumentation(""); - response.addCompletionItem(elementDecl); - - // Insert DTD AttrList Declaration - // see https://www.w3.org/TR/REC-xml/#attdecls - CompletionItem attrListDecl = new CompletionItem(); - attrListDecl.setLabel("Insert DTD Attributes list declaration"); - attrListDecl.setKind(CompletionItemKind.EnumMember); - attrListDecl.setFilterText("" - : ""; - attrListDecl.setTextEdit(new TextEdit(editRange, textEdit)); - attrListDecl.setDocumentation(""); - response.addCompletionItem(attrListDecl); - - // Insert Internal DTD Entity Declaration - // see https://www.w3.org/TR/REC-xml/#dt-entdecl - CompletionItem internalEntity = new CompletionItem(); - internalEntity.setLabel("Insert Internal DTD Entity declaration"); - internalEntity.setKind(CompletionItemKind.EnumMember); - internalEntity.setFilterText("" - : ""; - internalEntity.setTextEdit(new TextEdit(editRange, textEdit)); - internalEntity.setDocumentation(""); - response.addCompletionItem(internalEntity); - - // Insert External DTD Entity Declaration - // see https://www.w3.org/TR/REC-xml/#dt-entdecl - CompletionItem externalEntity = new CompletionItem(); - externalEntity.setLabel("Insert External DTD Entity declaration"); - externalEntity.setKind(CompletionItemKind.EnumMember); - externalEntity.setFilterText("" - : ""; - externalEntity.setTextEdit(new TextEdit(editRange, textEdit)); - externalEntity.setDocumentation(""); - response.addCompletionItem(externalEntity); - } - private static int scanNextForEndPos(int offset, Scanner scanner, TokenType nextToken) { if (offset == scanner.getTokenEnd()) { TokenType token = scanner.scan(); @@ -990,4 +849,11 @@ private static String getLineIndent(int offset, String text) { private boolean isEmptyElement(String tag) { return false; } + + private TextDocumentSnippetRegistry getSnippetRegistry() { + if (snippetRegistry == null) { + snippetRegistry = new XMLTextDocumentSnippetRegistry(); + } + return snippetRegistry; + } } diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/AbstractXMLSnippetContextAdapter.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/AbstractXMLSnippetContextAdapter.java new file mode 100644 index 0000000000..36e42e81b0 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/AbstractXMLSnippetContextAdapter.java @@ -0,0 +1,40 @@ +package org.eclipse.lemminx.services.snippets; + +import java.io.IOException; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +public abstract class AbstractXMLSnippetContextAdapter extends TypeAdapter { + + @Override + public T read(final JsonReader in) throws IOException { + JsonToken nextToken = in.peek(); + if (nextToken == JsonToken.NULL) { + return null; + } + + T context = create(); + in.beginObject(); + while (in.hasNext()) { + String name = in.nextName(); + if (!populate(name, in, context)) { + in.skipValue(); + } + } + in.endObject(); + return context; + } + + protected abstract T create(); + + protected abstract boolean populate(String name, JsonReader in, T context); + + @Override + public void write(JsonWriter out, IXMLSnippetContext value) throws IOException { + // Do nothing + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/CDATASnippetContext.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/CDATASnippetContext.java new file mode 100644 index 0000000000..ddebcba327 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/CDATASnippetContext.java @@ -0,0 +1,34 @@ +package org.eclipse.lemminx.services.snippets; + +import java.util.Map; + +import org.eclipse.lemminx.services.extensions.ICompletionRequest; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; + +public class CDATASnippetContext extends CommentSnippetContext { + + public static TypeAdapter TYPE_ADAPTER = new CDATASnippetContextAdapter(); + + @Override + public boolean isMatch(ICompletionRequest request, Map model) { + if (!super.isMatch(request, model)) { + return false; + } + return true; + } + + private static class CDATASnippetContextAdapter extends AbstractXMLSnippetContextAdapter { + @Override + protected CDATASnippetContext create() { + return new CDATASnippetContext(); + } + + @Override + protected boolean populate(String name, JsonReader in, CDATASnippetContext context) { + return false; + } + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/CommentSnippetContext.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/CommentSnippetContext.java new file mode 100644 index 0000000000..5d659415b3 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/CommentSnippetContext.java @@ -0,0 +1,44 @@ +package org.eclipse.lemminx.services.snippets; + +import java.util.Map; + +import org.eclipse.lemminx.dom.DOMElement; +import org.eclipse.lemminx.dom.DOMNode; +import org.eclipse.lemminx.services.extensions.ICompletionRequest; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; + +public class CommentSnippetContext implements IXMLSnippetContext { + + public static TypeAdapter TYPE_ADAPTER = new CommentSnippetContextAdapter(); + + @Override + public boolean isMatch(ICompletionRequest request, Map model) { + DOMNode node = request.getNode(); + if (node == null) { + return false; + } + DOMElement parent = node.getParentElement(); + if (parent == null) { + return false; + } + if (node.isElement() && ((DOMElement) node).getTagName() != null) { + return false; + } + return true; + } + + private static class CommentSnippetContextAdapter extends AbstractXMLSnippetContextAdapter { + @Override + protected CommentSnippetContext create() { + return new CommentSnippetContext(); + } + + @Override + protected boolean populate(String name, JsonReader in, CommentSnippetContext context) { + return false; + } + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/DTDNodeSnippetContext.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/DTDNodeSnippetContext.java new file mode 100644 index 0000000000..ed71763751 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/DTDNodeSnippetContext.java @@ -0,0 +1,46 @@ +package org.eclipse.lemminx.services.snippets; + +import java.util.Map; + +import org.eclipse.lemminx.dom.DOMDocumentType; +import org.eclipse.lemminx.dom.DOMNode; +import org.eclipse.lemminx.services.extensions.ICompletionRequest; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; + +public class DTDNodeSnippetContext implements IXMLSnippetContext { + + public static TypeAdapter TYPE_ADAPTER = new DTDNodeSnippetContextAdapter(); + + @Override + public boolean isMatch(ICompletionRequest request, Map model) { + DOMNode node = request.getNode(); + if (node == null) { + return false; + } + DOMDocumentType docType = null; + if (node.isDoctype()) { + docType = (DOMDocumentType) node; + } else if (node.getParentNode() != null && node.getParentNode().isDoctype()) { + docType = (DOMDocumentType) node.getParentNode(); + } + if (docType == null || !request.getXMLDocument().isWithinInternalDTD(request.getOffset())) { + return false; + } + return true; + } + + private static class DTDNodeSnippetContextAdapter extends AbstractXMLSnippetContextAdapter { + @Override + protected DTDNodeSnippetContext create() { + return new DTDNodeSnippetContext(); + } + + @Override + protected boolean populate(String name, JsonReader in, DTDNodeSnippetContext context) { + return false; + } + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/DocTypeSnippetContext.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/DocTypeSnippetContext.java new file mode 100644 index 0000000000..e3545ce243 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/DocTypeSnippetContext.java @@ -0,0 +1,59 @@ +package org.eclipse.lemminx.services.snippets; + +import java.util.Map; + +import org.eclipse.lemminx.dom.DOMDocument; +import org.eclipse.lemminx.dom.DOMElement; +import org.eclipse.lemminx.dom.DOMNode; +import org.eclipse.lemminx.services.extensions.ICompletionRequest; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; + +public class DocTypeSnippetContext implements IXMLSnippetContext { + + public static TypeAdapter TYPE_ADAPTER = new DocTypeSnippetContextAdapter(); + + @Override + public boolean isMatch(ICompletionRequest request, Map model) { + DOMDocument document = request.getXMLDocument(); + DOMElement documentElement = document.getDocumentElement(); + if (documentElement == null) { + return false; + } + if (document.getDoctype() != null) { + return false; + } + String tagName = documentElement.getTagName(); + if (tagName == null && documentElement.hasChildNodes() && documentElement.getChild(0).isElement()) { + documentElement = ((DOMElement) documentElement.getChild(0)); + tagName = documentElement.getTagName(); + } + if (tagName == null) { + return false; + } + DOMNode parent = request.getNode().getParentNode(); + if (parent != null && parent.isDoctype()) { + return false; + } + int offset = request.getOffset(); + if (offset > documentElement.getStart()) { + return false; + } + model.put("root-element", tagName); + return true; + } + + private static class DocTypeSnippetContextAdapter extends AbstractXMLSnippetContextAdapter { + @Override + protected DocTypeSnippetContext create() { + return new DocTypeSnippetContext(); + } + + @Override + protected boolean populate(String name, JsonReader in, DocTypeSnippetContext context) { + return false; + } + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/IXMLSnippetContext.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/IXMLSnippetContext.java new file mode 100644 index 0000000000..3a7129773b --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/IXMLSnippetContext.java @@ -0,0 +1,8 @@ +package org.eclipse.lemminx.services.snippets; + +import org.eclipse.lemminx.commons.snippets.ISnippetContext; +import org.eclipse.lemminx.services.extensions.ICompletionRequest; + +public interface IXMLSnippetContext extends ISnippetContext { + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/NewFileSnippetContext.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/NewFileSnippetContext.java new file mode 100644 index 0000000000..7a785d27b9 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/NewFileSnippetContext.java @@ -0,0 +1,45 @@ +package org.eclipse.lemminx.services.snippets; + +import java.util.Map; + +import org.eclipse.lemminx.dom.DOMDocument; +import org.eclipse.lemminx.dom.DOMElement; +import org.eclipse.lemminx.dom.DOMNode; +import org.eclipse.lemminx.services.extensions.ICompletionRequest; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; + +public class NewFileSnippetContext implements IXMLSnippetContext { + + public static TypeAdapter TYPE_ADAPTER = new NewFileSnippetContextAdapter(); + + @Override + public boolean isMatch(ICompletionRequest request, Map model) { + DOMDocument document = request.getXMLDocument(); + if (!document.hasChildNodes()) { + return true; + } + DOMNode first = document.getChild(0); + if (first.isText()) { + return true; + } + if (first.isElement() && ((DOMElement) first).getTagName() == null) { + return true; + } + return false; + } + + private static class NewFileSnippetContextAdapter extends AbstractXMLSnippetContextAdapter { + @Override + protected NewFileSnippetContext create() { + return new NewFileSnippetContext(); + } + + @Override + protected boolean populate(String name, JsonReader in, NewFileSnippetContext context) { + return false; + } + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/XMLSnippetRegistryLoader.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/XMLSnippetRegistryLoader.java new file mode 100644 index 0000000000..69d06800b7 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/XMLSnippetRegistryLoader.java @@ -0,0 +1,20 @@ +package org.eclipse.lemminx.services.snippets; + +import org.eclipse.lemminx.commons.snippets.ISnippetRegistryLoader; +import org.eclipse.lemminx.commons.snippets.SnippetRegistry; + +public class XMLSnippetRegistryLoader implements ISnippetRegistryLoader { + + @Override + public void load(SnippetRegistry registry) throws Exception { + registry.registerSnippets(XMLSnippetRegistryLoader.class.getResourceAsStream("newfile-snippets.json"), + NewFileSnippetContext.TYPE_ADAPTER); + registry.registerSnippets(XMLSnippetRegistryLoader.class.getResourceAsStream("cdata-snippets.json"), CDATASnippetContext.TYPE_ADAPTER); + registry.registerSnippets(XMLSnippetRegistryLoader.class.getResourceAsStream("comment-snippets.json"), CommentSnippetContext.TYPE_ADAPTER); + registry.registerSnippets(XMLSnippetRegistryLoader.class.getResourceAsStream("doctype-snippets.json"), + DocTypeSnippetContext.TYPE_ADAPTER); + registry.registerSnippets(XMLSnippetRegistryLoader.class.getResourceAsStream("dtdnode-snippets.json"), + DTDNodeSnippetContext.TYPE_ADAPTER); + } + +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java index 2e67ddedc4..fbebb847bb 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java @@ -39,13 +39,12 @@ public static boolean isQuote(char c) { return c == '\'' || c == '"'; } - public static boolean isWhitespace(String value) { + public static boolean isWhitespace(String value, int index) { if (value == null) { return false; } char c; int end = value.length(); - int index = 0; while (index < end) { c = value.charAt(index); if (Character.isWhitespace(c) == false) { @@ -56,6 +55,10 @@ public static boolean isWhitespace(String value) { return true; } + public static boolean isWhitespace(String value) { + return isWhitespace(value, 0); + } + /** * Normalizes the whitespace characters of a given string and applies it to the * given string builder. diff --git a/org.eclipse.lemminx/src/main/resources/META-INF/services/org.eclipse.lemminx.commons.snippets.ISnippetRegistryLoader b/org.eclipse.lemminx/src/main/resources/META-INF/services/org.eclipse.lemminx.commons.snippets.ISnippetRegistryLoader new file mode 100644 index 0000000000..ea64856b8d --- /dev/null +++ b/org.eclipse.lemminx/src/main/resources/META-INF/services/org.eclipse.lemminx.commons.snippets.ISnippetRegistryLoader @@ -0,0 +1 @@ +org.eclipse.lemminx.services.snippets.XMLSnippetRegistryLoader \ No newline at end of file diff --git a/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/cdata-snippets.json b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/cdata-snippets.json new file mode 100644 index 0000000000..a7b2602c2b --- /dev/null +++ b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/cdata-snippets.json @@ -0,0 +1,15 @@ +{ + "Insert CDATA": { + "prefix": [ + "" + ], + "description": "Insert CDATA", + "context": { + "type": "X" + } + } + } \ No newline at end of file diff --git a/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/comment-snippets.json b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/comment-snippets.json new file mode 100644 index 0000000000..86851a1835 --- /dev/null +++ b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/comment-snippets.json @@ -0,0 +1,15 @@ +{ + "Insert Comment": { + "prefix": [ + "" + ], + "description": "Insert Comment", + "context": { + + } + } + } \ No newline at end of file diff --git a/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/doctype-snippets.json b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/doctype-snippets.json new file mode 100644 index 0000000000..822a199c08 --- /dev/null +++ b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/doctype-snippets.json @@ -0,0 +1,68 @@ +{ + "Insert SYSTEM DOCTYPE": { + "prefix": [ + "" + ], + "description": "Insert SYSTEM DOCTYPE", + "context": { + + } + }, + "Insert PUBLIC DOCTYPE": { + "prefix": [ + "" + ], + "description": "Insert PUBLIC DOCTYPE", + "context": { + + } + }, + "Insert SYSTEM DOCTYPE with subset": { + "prefix": [ + "" + ], + "description": "Insert SYSTEM DOCTYPE with subset", + "context": { + + } + }, + "Insert PUBLIC DOCTYPE with subset": { + "prefix": [ + "" + ], + "description": "Insert PUBLIC DOCTYPE with subset", + "context": { + + } + }, + "Insert DOCTYPE with subset": { + "prefix": [ + "", + "]>" + ], + "description": "Insert DOCTYPE with subset", + "context": { + + } + } + } \ No newline at end of file diff --git a/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/dtdnode-snippets.json b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/dtdnode-snippets.json new file mode 100644 index 0000000000..936bb7d5d4 --- /dev/null +++ b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/dtdnode-snippets.json @@ -0,0 +1,46 @@ +{ + "Insert DTD Element declaration": { + "prefix": [ + "" + ], + "description": "Insert DTD Element declaration", + "context": { + } + }, + "Insert DTD Attributes list declaration": { + "prefix": [ + "" + ], + "description": "Insert DTD Attributes list declaration", + "context": { + } + }, + "Insert Internal DTD Entity Declaration": { + "prefix": [ + "" + ], + "description": "Insert Internal DTD Entity Declaration", + "context": { + } + }, + "Insert External DTD Entity Declaration": { + "prefix": [ + "" + ], + "description": "Insert External DTD Entity Declaration", + "context": { + } + } + } \ No newline at end of file diff --git a/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/newfile-snippets.json b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/newfile-snippets.json new file mode 100644 index 0000000000..2c913c8e55 --- /dev/null +++ b/org.eclipse.lemminx/src/main/resources/org/eclipse/lemminx/services/snippets/newfile-snippets.json @@ -0,0 +1,78 @@ +{ + "Insert SYSTEM DOCTYPE": { + "prefix": [ + "", + "<${1:root-element}>${0}", + "" + ], + "description": "Insert SYSTEM DOCTYPE", + "context": { + + } + }, + "Insert PUBLIC DOCTYPE": { + "prefix": [ + "", + "<${1:root-element}>${0}", + "" + ], + "description": "Insert PUBLIC DOCTYPE", + "context": { + + } + }, + "Insert SYSTEM DOCTYPE with subset": { + "prefix": [ + "", + "<${1:root-element}>${0}", + "" + ], + "description": "Insert SYSTEM DOCTYPE with subset", + "context": { + + } + }, + "Insert PUBLIC DOCTYPE with subset": { + "prefix": [ + "", + "<${1:root-element}>${0}", + "" + ], + "description": "Insert PUBLIC DOCTYPE with subset", + "context": { + + } + }, + "Insert DOCTYPE with subset": { + "prefix": [ + "", + "]>", + "<${1:root-element}>${0}", + "" + ], + "description": "Insert DOCTYPE with subset", + "context": { + + } + } + } \ No newline at end of file diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/XMLAssert.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/XMLAssert.java index 48fadcda69..f3e0c8d462 100644 --- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/XMLAssert.java +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/XMLAssert.java @@ -176,6 +176,9 @@ public static void testCompletionFor(XMLLanguageService xmlLanguageService, Stri List labels = list.getItems().stream().map(i -> i.getLabel()).sorted().collect(Collectors.toList()); String previous = null; for (String label : labels) { + if (label.equals(" { return "Duplicate label " + label + " in " + labels.stream().collect(Collectors.joining(",")) + "}"; });