diff --git a/src/plugins/languageclient/CMakeLists.txt b/src/plugins/languageclient/CMakeLists.txt index c624860b804..0020a4d069b 100644 --- a/src/plugins/languageclient/CMakeLists.txt +++ b/src/plugins/languageclient/CMakeLists.txt @@ -24,4 +24,5 @@ add_qtc_plugin(LanguageClient lspinspector.cpp lspinspector.h progressmanager.cpp progressmanager.h semantichighlightsupport.cpp semantichighlightsupport.h + snippet.cpp snippet.h ) diff --git a/src/plugins/languageclient/client.cpp b/src/plugins/languageclient/client.cpp index 15fcded4ab2..f37617d9dba 100644 --- a/src/plugins/languageclient/client.cpp +++ b/src/plugins/languageclient/client.cpp @@ -220,7 +220,7 @@ static ClientCapabilities generateClientCapabilities() completionCapabilities.setCompletionItemKind(completionItemKindCapabilities); TextDocumentClientCapabilities::CompletionCapabilities::CompletionItemCapbilities completionItemCapbilities; - completionItemCapbilities.setSnippetSupport(false); + completionItemCapbilities.setSnippetSupport(true); completionItemCapbilities.setCommitCharacterSupport(true); completionCapabilities.setCompletionItem(completionItemCapbilities); documentCapabilities.setCompletion(completionCapabilities); diff --git a/src/plugins/languageclient/languageclient.pro b/src/plugins/languageclient/languageclient.pro index a29a27c910d..2efa6da63a3 100644 --- a/src/plugins/languageclient/languageclient.pro +++ b/src/plugins/languageclient/languageclient.pro @@ -24,7 +24,7 @@ HEADERS += \ lspinspector.h \ progressmanager.h \ semantichighlightsupport.h \ - + snippet.h \ SOURCES += \ client.cpp \ @@ -47,6 +47,7 @@ SOURCES += \ lspinspector.cpp \ progressmanager.cpp \ semantichighlightsupport.cpp \ + snippet.cpp \ RESOURCES += \ languageclient.qrc diff --git a/src/plugins/languageclient/languageclient.qbs b/src/plugins/languageclient/languageclient.qbs index a5dbc641f65..7cd37ab50db 100644 --- a/src/plugins/languageclient/languageclient.qbs +++ b/src/plugins/languageclient/languageclient.qbs @@ -5,6 +5,10 @@ QtcPlugin { name: "LanguageClient" Depends { name: "Qt.core" } + Depends { + name: "Qt.testlib" + condition: qtc.testsEnabled + } Depends { name: "Utils" } Depends { name: "ProjectExplorer" } @@ -56,6 +60,8 @@ QtcPlugin { "progressmanager.h", "semantichighlightsupport.cpp", "semantichighlightsupport.h", + "snippet.cpp", + "snippet.h", ] Export { Depends { name: "LanguageServerProtocol" } } diff --git a/src/plugins/languageclient/languageclientcompletionassist.cpp b/src/plugins/languageclient/languageclientcompletionassist.cpp index 3e6c4e883a9..97c02a6b8dc 100644 --- a/src/plugins/languageclient/languageclientcompletionassist.cpp +++ b/src/plugins/languageclient/languageclientcompletionassist.cpp @@ -27,6 +27,7 @@ #include "client.h" #include "languageclientutils.h" +#include "snippet.h" #include #include @@ -108,7 +109,7 @@ void LanguageClientCompletionItem::apply(TextDocumentManipulatorInterface &manip { const int pos = manipulator.currentPosition(); if (auto edit = m_item.textEdit()) { - applyTextEdit(manipulator, *edit); + applyTextEdit(manipulator, *edit, isSnippet()); } else { const QString textToInsert(m_item.insertText().value_or(text())); int length = 0; @@ -126,7 +127,12 @@ void LanguageClientCompletionItem::apply(TextDocumentManipulatorInterface &manip QRegularExpressionMatch match = identifier.match(blockTextUntilPosition); int matchLength = match.hasMatch() ? match.capturedLength(0) : 0; length = qMax(length, matchLength); - manipulator.replace(pos - length, length, textToInsert); + if (isSnippet()) { + manipulator.replace(pos - length, length, {}); + manipulator.insertCodeSnippet(pos - length, textToInsert, &parseSnippet); + } else { + manipulator.replace(pos - length, length, textToInsert); + } } if (auto additionalEdits = m_item.additionalTextEdits()) { @@ -182,9 +188,7 @@ QString LanguageClientCompletionItem::detail() const bool LanguageClientCompletionItem::isSnippet() const { - // FIXME add lsp > creator snippet converter - // return m_item.insertTextFormat().value_or(CompletionItem::PlainText); - return false; + return m_item.insertTextFormat().value_or(CompletionItem::PlainText); } bool LanguageClientCompletionItem::isValid() const @@ -226,6 +230,8 @@ bool LanguageClientCompletionItem::isPerfectMatch(int pos, QTextDocument *doc) c if (!additionalEdits.value().isEmpty()) return false; } + if (isSnippet()) + return false; if (auto edit = m_item.textEdit()) { auto range = edit->range(); const int start = positionInText(doc, range.start().line() + 1, range.start().character() + 1); diff --git a/src/plugins/languageclient/languageclientplugin.h b/src/plugins/languageclient/languageclientplugin.h index 8fbf66de034..827aaee0655 100644 --- a/src/plugins/languageclient/languageclientplugin.h +++ b/src/plugins/languageclient/languageclientplugin.h @@ -51,6 +51,12 @@ private: private: LanguageClientOutlineWidgetFactory m_outlineFactory; + +#ifdef WITH_TESTS +private slots: + void testSnippetParsing_data(); + void testSnippetParsing(); +#endif }; } // namespace LanguageClient diff --git a/src/plugins/languageclient/languageclientutils.cpp b/src/plugins/languageclient/languageclientutils.cpp index 22c6cd02e1c..2b5e52179a7 100644 --- a/src/plugins/languageclient/languageclientutils.cpp +++ b/src/plugins/languageclient/languageclientutils.cpp @@ -29,6 +29,7 @@ #include "languageclient_global.h" #include "languageclientmanager.h" #include "languageclientoutline.h" +#include "snippet.h" #include #include @@ -102,14 +103,21 @@ bool applyTextEdits(const DocumentUri &uri, const QList &edits) return file->apply(); } -void applyTextEdit(TextDocumentManipulatorInterface &manipulator, const TextEdit &edit) +void applyTextEdit(TextDocumentManipulatorInterface &manipulator, + const TextEdit &edit, + bool newTextIsSnippet) { using namespace Utils::Text; const Range range = edit.range(); const QTextDocument *doc = manipulator.textCursorAt(manipulator.currentPosition()).document(); const int start = positionInText(doc, range.start().line() + 1, range.start().character() + 1); const int end = positionInText(doc, range.end().line() + 1, range.end().character() + 1); - manipulator.replace(start, end - start, edit.newText()); + if (newTextIsSnippet) { + manipulator.replace(start, end - start, {}); + manipulator.insertCodeSnippet(start, edit.newText(), &parseSnippet); + } else { + manipulator.replace(start, end - start, edit.newText()); + } } bool applyWorkspaceEdit(const WorkspaceEdit &edit) diff --git a/src/plugins/languageclient/languageclientutils.h b/src/plugins/languageclient/languageclientutils.h index 99a52a2d88e..2d1a79420c5 100644 --- a/src/plugins/languageclient/languageclientutils.h +++ b/src/plugins/languageclient/languageclientutils.h @@ -52,7 +52,8 @@ applyTextDocumentEdit(const LanguageServerProtocol::TextDocumentEdit &edit); bool LANGUAGECLIENT_EXPORT applyTextEdits(const LanguageServerProtocol::DocumentUri &uri, const QList &edits); void LANGUAGECLIENT_EXPORT applyTextEdit(TextEditor::TextDocumentManipulatorInterface &manipulator, - const LanguageServerProtocol::TextEdit &edit); + const LanguageServerProtocol::TextEdit &edit, + bool newTextIsSnippet = false); void LANGUAGECLIENT_EXPORT updateCodeActionRefactoringMarker(Client *client, const LanguageServerProtocol::CodeAction &action, diff --git a/src/plugins/languageclient/snippet.cpp b/src/plugins/languageclient/snippet.cpp new file mode 100644 index 00000000000..1270a9cc0b1 --- /dev/null +++ b/src/plugins/languageclient/snippet.cpp @@ -0,0 +1,284 @@ +/**************************************************************************** +** +** Copyright (C) 2021 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of Qt Creator. +** +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3 as published by the Free Software +** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT +** included in the packaging of this file. Please review the following +** information to ensure the GNU General Public License requirements will +** be met: https://www.gnu.org/licenses/gpl-3.0.html. +** +****************************************************************************/ + +#include "snippet.h" + +#include "languageclientplugin.h" + +#ifdef WITH_TESTS +#include +#endif + +using namespace TextEditor; + +namespace LanguageClient { + +constexpr char dollar = '$'; +constexpr char backSlash = '\\'; +constexpr char underscore = '_'; +constexpr char comma = ','; +constexpr char openBrace = '{'; +constexpr char closeBrace = '}'; +constexpr char pipe = '|'; +constexpr char colon = ':'; + +class SnippetParseException +{ +public: + QString message; +}; + +void skipSpaces(QString::const_iterator &it) +{ + while (it->isSpace()) + ++it; +} + +QString join(const QList &chars) +{ + QString result; + const QList::const_iterator begin = chars.begin(); + const QList::const_iterator end = chars.end(); + for (QList::const_iterator it = begin; it < end; ++it) { + if (it == begin) + result += "'"; + else if (it + 1 == end) + result += ", or '"; + else + result += ", '"; + result += *it + "'"; + } + return result; +} + +bool checkChars(QString::const_iterator &it, const QList &chars) +{ + if (*it == backSlash) { + ++it; + if (!chars.contains(*it)) + throw SnippetParseException{"expected " + join(chars) + "after escaping '\\'"}; + return false; + } + return chars.contains(*it); +} + +void skipToEndOfTabstop(QString::const_iterator &it, const QString::const_iterator &end) +{ + while (it < end && checkChars(it, {closeBrace})) + ++it; +} + +int parseTabstopIndex(QString::const_iterator &it) +{ + int result = 0; + while (it->isDigit()) { + result = 10 * result + it->digitValue(); + ++it; + } + return result; +} + +QString parseVariable(QString::const_iterator &it) +{ + // TODO: implement replacing variable with data + QString result; + const QString::const_iterator start = it; + while (it->isLetter() || *it == underscore || (start != it && it->isDigit())) { + result.append(*it); + ++it; + } + return result; +} + +ParsedSnippet::Part parseTabstop(QString::const_iterator &it, const QString::const_iterator &end) +{ + ParsedSnippet::Part result; + if (*it != dollar) + throw SnippetParseException{"Expected a '$' (tabstop)"}; + skipSpaces(++it); + if (it->isDigit()) { + result.variableIndex = parseTabstopIndex(it); + if (result.variableIndex == 0) + result.finalPart = true; + } else if (*it == openBrace) { + skipSpaces(++it); + if (it->isDigit()) { + result.variableIndex = parseTabstopIndex(it); + if (result.variableIndex == 0) + result.finalPart = true; + skipSpaces(it); + if (*it == colon) { + ++it; + while (it < end && !checkChars(it, {closeBrace})) { + result.text.append(*it); + ++it; + } + } else if (*it == pipe) { + ++it; + // TODO: Implement Choices for now take the first choice and use it as a placeholder + for (; it < end && !checkChars(it, {comma, pipe, closeBrace}); ++it) + result.text.append(*it); + skipToEndOfTabstop(it, end); + } + } else if (it->isLetter() || *it == underscore) { + result.text = parseVariable(it); + // TODO: implement variable transformation + skipToEndOfTabstop(it, end); + } + if (*it != closeBrace) + throw SnippetParseException{"Expected a closing curly brace"}; + ++it; + } else if (it->isLetter() || *it == underscore) { + result.text = parseVariable(it); + } else { + throw SnippetParseException{"Expected tabstop index, variable, or open curly brace"}; + } + return result; +} + +SnippetParseResult parseSnippet(const QString &snippet) +{ + ParsedSnippet result; + ParsedSnippet::Part currentPart; + + Utils::optional error; + auto it = snippet.begin(); + const auto end = snippet.end(); + + while (it < end) { + try { + if (checkChars(it, {dollar})) { + if (!currentPart.text.isEmpty()) { + if (currentPart.variableIndex != -1) { + throw SnippetParseException{ + "Internal Error: expected variable index -1 in snippet part"}; + } + result.parts.append(currentPart); + currentPart.text.clear(); + } + const ParsedSnippet::Part &part = parseTabstop(it, end); + while (result.variables.size() < part.variableIndex + 1) + result.variables.append(QList()); + result.variables[part.variableIndex] << result.parts.size(); + result.parts.append(part); + } else { + currentPart.text.append(*it); + ++it; + } + } catch (const SnippetParseException &e) { + return SnippetParseError{e.message, snippet, int(it - snippet.begin())}; + } + } + + if (!currentPart.text.isEmpty()) + result.parts.append(currentPart); + + return result; +} + +} // namespace LanguageClient + +#ifdef WITH_TESTS + +const char NOMANGLER_ID[] = "TextEditor::NoMangler"; + +struct SnippetPart +{ + SnippetPart() = default; + explicit SnippetPart(const QString &text, + int index = -1, + const Utils::Id &manglerId = NOMANGLER_ID, + const QList &nested = {}) + : text(text) + , variableIndex(index) + , manglerId(manglerId) + , nested(nested) + {} + QString text; + int variableIndex = -1; // if variable index is >= 0 the text is interpreted as a variable + Utils::Id manglerId; + QList nested; +}; +Q_DECLARE_METATYPE(SnippetPart); + +using Parts = QList; +void LanguageClient::LanguageClientPlugin::testSnippetParsing_data() +{ + QTest::addColumn("input"); + QTest::addColumn("success"); + QTest::addColumn("parts"); + + QTest::newRow("no input") << QString() << true << Parts(); + QTest::newRow("empty input") << QString("") << true << Parts(); + + QTest::newRow("empty tabstop") << QString("$1") << true << Parts{SnippetPart("", 1)}; + QTest::newRow("empty tabstop with braces") << QString("${1}") << true << Parts{SnippetPart("", 1)}; + QTest::newRow("double tabstop") + << QString("$1$1") << true << Parts{SnippetPart("", 1), SnippetPart("", 1)}; + QTest::newRow("different tabstop") + << QString("$1$2") << true << Parts{SnippetPart("", 1), SnippetPart("", 2)}; + QTest::newRow("empty tabstop") << QString("$1") << true << Parts{SnippetPart("", 1)}; + QTest::newRow("double dollar") << QString("$$1") << false << Parts(); + QTest::newRow("escaped tabstop") << QString("\\$1") << true << Parts{SnippetPart("$1")}; + QTest::newRow("escaped double tabstop") + << QString("\\$$1") << true << Parts{SnippetPart("$"), SnippetPart("", 1)}; + + QTest::newRow("placeholder") << QString("${1:foo}") << true << Parts{SnippetPart("foo", 1)}; + QTest::newRow("placeholder with text") + << QString("text${1:foo}text") << true + << Parts{SnippetPart("text"), SnippetPart("foo", 1), SnippetPart("text")}; + QTest::newRow("2 placeholder") << QString("${1:foo}${2:bar}") << true + << Parts{SnippetPart("foo", 1), SnippetPart("bar", 2)}; + QTest::newRow("2 placeholder same tabstop") + << QString("${1:foo}${1:bar}") << true + << Parts{SnippetPart("foo", 1), SnippetPart("bar", 1)}; +} + +void LanguageClient::LanguageClientPlugin::testSnippetParsing() +{ + QFETCH(QString, input); + QFETCH(bool, success); + QFETCH(Parts, parts); + + SnippetParseResult result = LanguageClient::parseSnippet(input); + QCOMPARE(Utils::holds_alternative(result), success); + if (!success) + return; + + ParsedSnippet snippet = Utils::get(result); + + auto rangesCompare = [&](const ParsedSnippet::Part &actual, const SnippetPart &expected) { + QCOMPARE(actual.text, expected.text); + QCOMPARE(actual.variableIndex, expected.variableIndex); + auto manglerId = actual.mangler ? actual.mangler->id() : NOMANGLER_ID; + QCOMPARE(manglerId, expected.manglerId); + }; + + QCOMPARE(snippet.parts.count(), parts.count()); + + for (int i = 0; i < parts.count(); ++i) + rangesCompare(snippet.parts.at(i), parts.at(i)); +} +#endif diff --git a/src/plugins/languageclient/snippet.h b/src/plugins/languageclient/snippet.h new file mode 100644 index 00000000000..d7f613bb89c --- /dev/null +++ b/src/plugins/languageclient/snippet.h @@ -0,0 +1,36 @@ +/**************************************************************************** +** +** Copyright (C) 2021 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of Qt Creator. +** +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3 as published by the Free Software +** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT +** included in the packaging of this file. Please review the following +** information to ensure the GNU General Public License requirements will +** be met: https://www.gnu.org/licenses/gpl-3.0.html. +** +****************************************************************************/ + +#pragma once + +#include "languageclient_global.h" + +#include + +namespace LanguageClient { + +LANGUAGECLIENT_EXPORT TextEditor::SnippetParseResult parseSnippet(const QString &snippet); + +} // namespace LanguageClient