aboutsummaryrefslogtreecommitdiffstats
path: root/src/app/qbs/lspserver.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/qbs/lspserver.cpp')
-rw-r--r--src/app/qbs/lspserver.cpp629
1 files changed, 629 insertions, 0 deletions
diff --git a/src/app/qbs/lspserver.cpp b/src/app/qbs/lspserver.cpp
new file mode 100644
index 000000000..c6cce6706
--- /dev/null
+++ b/src/app/qbs/lspserver.cpp
@@ -0,0 +1,629 @@
+/****************************************************************************
+**
+** Copyright (C) 2023 The Qt Company Ltd.
+** Contact: https://www.qt.io/licensing/
+**
+** This file is part of Qbs.
+**
+** $QT_BEGIN_LICENSE:LGPL$
+** 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 Lesser General Public License Usage
+** Alternatively, this file may be used under the terms of the GNU Lesser
+** General Public License version 3 as published by the Free Software
+** Foundation and appearing in the file LICENSE.LGPL3 included in the
+** packaging of this file. Please review the following information to
+** ensure the GNU Lesser General Public License version 3 requirements
+** will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
+**
+** GNU General Public License Usage
+** Alternatively, this file may be used under the terms of the GNU
+** General Public License version 2.0 or (at your option) the GNU General
+** Public license version 3 or any later version approved by the KDE Free
+** Qt Foundation. The licenses are as published by the Free Software
+** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
+** 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-2.0.html and
+** https://www.gnu.org/licenses/gpl-3.0.html.
+**
+** $QT_END_LICENSE$
+**
+****************************************************************************/
+
+#include "lspserver.h"
+
+#include <api/projectdata.h>
+#include <logging/translator.h>
+#include <lsp/basemessage.h>
+#include <lsp/completion.h>
+#include <lsp/initializemessages.h>
+#include <lsp/jsonrpcmessages.h>
+#include <lsp/messages.h>
+#include <lsp/servercapabilities.h>
+#include <lsp/textsynchronization.h>
+#include <parser/qmljsastvisitor_p.h>
+#include <parser/qmljslexer_p.h>
+#include <parser/qmljsparser_p.h>
+#include <tools/qbsassert.h>
+#include <tools/stlutils.h>
+
+#include <QBuffer>
+#include <QLocalServer>
+#include <QLocalSocket>
+#include <QMap>
+
+#include <unordered_map>
+#ifdef Q_OS_WINDOWS
+#include <process.h>
+#else
+#include <unistd.h>
+#endif
+
+namespace qbs::Internal {
+
+static int getPid()
+{
+#ifdef Q_OS_WINDOWS
+ return _getpid();
+#else
+ return getpid();
+#endif
+}
+
+using LspErrorResponse = lsp::ResponseError<std::nullptr_t>;
+using LspErrorCode = LspErrorResponse::ErrorCodes;
+
+class Document {
+public:
+ bool isPositionUpToDate(const CodePosition &pos) const;
+ bool isPositionUpToDate(const lsp::Position &pos) const;
+
+ QString savedContent;
+ QString currentContent;
+};
+
+static CodePosition posFromLspPos(const lsp::Position &pos)
+{
+ return {pos.line() + 1, pos.character() + 1};
+}
+
+static lsp::Position lspPosFromCodeLocation(const CodeLocation &loc)
+{
+ return {loc.line() - 1, loc.column() - 1};
+}
+
+static QString uriToString(const lsp::DocumentUri &uri)
+{
+ return uri.toFilePath([](const lsp::Utils::FilePath &fp) { return fp; });
+}
+
+static int posToOffset(const CodePosition &pos, const QString &doc);
+static int posToOffset(const lsp::Position &pos, const QString &doc)
+{
+ return posToOffset(posFromLspPos(pos), doc);
+}
+
+class AstNodeLocator : public QbsQmlJS::AST::Visitor
+{
+public:
+ AstNodeLocator(int position, QbsQmlJS::AST::UiProgram &ast)
+ : m_position(position)
+ {
+ ast.accept(this);
+ }
+
+ QList<QbsQmlJS::AST::Node *> path() const { return m_path; }
+
+private:
+ bool preVisit(QbsQmlJS::AST::Node *node) override
+ {
+ if (int(node->firstSourceLocation().offset) > m_position)
+ return false;
+ if (int(node->lastSourceLocation().offset) < m_position)
+ return false;
+ m_path << node;
+ return true;
+ }
+
+ const int m_position;
+ QList<QbsQmlJS::AST::Node *> m_path;
+};
+
+class LspServer::Private
+{
+public:
+ void setupConnection();
+ void handleIncomingData();
+ void discardSocket();
+ template<typename T> void sendResponse(const T &msg);
+ void sendErrorResponse(LspErrorCode code, const QString &message);
+ void sendErrorNotification(const QString &message);
+ void sendNoSuchDocumentError(const QString &filePath);
+ void sendMessage(const lsp::JsonObject &msg);
+ void sendMessage(const lsp::JsonRpcMessage &msg);
+ void handleCurrentMessage();
+ void handleInitializeRequest();
+ void handleInitializedNotification();
+ void handleGotoDefinitionRequest();
+ void handleCompletionRequest();
+ void handleShutdownRequest();
+ void handleDidOpenNotification();
+ void handleDidChangeNotification();
+ void handleDidSaveNotification();
+ void handleDidCloseNotification();
+
+ QLocalServer server;
+ QBuffer incomingData;
+ lsp::BaseMessage currentMessage;
+ QJsonObject messageObject;
+ QLocalSocket *socket = nullptr;
+ ProjectData projectData;
+ CodeLinks codeLinks;
+ std::unordered_map<QString, Document> documents;
+
+ enum class State { None, InitRequest, InitNotification, Shutdown };
+ State state = State::None;
+};
+
+LspServer::LspServer() : d(new Private)
+{
+ if (!d->server.listen(QString::fromLatin1("qbs-lsp-%1").arg(getPid()))) {
+ // This is not fatal, qbs main operation can continue.
+ qWarning() << "failed to open lsp socket:" << d->server.errorString();
+ return;
+ }
+ QObject::connect(&d->server, &QLocalServer::newConnection, [this] {
+ d->socket = d->server.nextPendingConnection();
+ d->setupConnection();
+ d->server.close();
+ });
+}
+
+LspServer::~LspServer() { delete d; }
+
+void LspServer::updateProjectData(const ProjectData &projectData, const CodeLinks &codeLinks)
+{
+ d->projectData = projectData;
+ d->codeLinks = codeLinks;
+}
+
+QString LspServer::socketPath() const { return d->server.fullServerName(); }
+
+void LspServer::Private::setupConnection()
+{
+ QBS_ASSERT(socket, return);
+
+ QObject::connect(socket, &QLocalSocket::errorOccurred, [this] { discardSocket(); });
+ QObject::connect(socket, &QLocalSocket::disconnected, [this] { discardSocket(); });
+ QObject::connect(socket, &QLocalSocket::readyRead, [this] { handleIncomingData(); });
+ incomingData.open(QIODevice::ReadWrite | QIODevice::Append);
+ handleIncomingData();
+}
+
+void LspServer::Private::handleIncomingData()
+{
+ const int pos = incomingData.pos();
+ incomingData.write(socket->readAll());
+ incomingData.seek(pos);
+ QString parseError;
+ lsp::BaseMessage::parse(&incomingData, parseError, currentMessage);
+ if (!parseError.isEmpty())
+ return sendErrorResponse(LspErrorResponse::ParseError, parseError);
+ if (currentMessage.isComplete()) {
+ incomingData.buffer().remove(0, incomingData.pos());
+ incomingData.seek(0);
+ handleCurrentMessage();
+ currentMessage = {};
+ messageObject = {};
+ if (socket)
+ handleIncomingData();
+ }
+}
+
+void LspServer::Private::discardSocket()
+{
+ socket->disconnect();
+ socket->deleteLater();
+ socket = nullptr;
+}
+
+template<typename T> void LspServer::Private::sendResponse(const T &msg)
+{
+ lsp::Response<T, std::nullptr_t> response(lsp::MessageId(messageObject.value(lsp::idKey)));
+ response.setResult(msg);
+ sendMessage(response);
+}
+
+void LspServer::Private::sendErrorResponse(LspErrorCode code, const QString &message)
+{
+ lsp::Response<lsp::JsonObject, std::nullptr_t> response(lsp::MessageId(
+ messageObject.value(lsp::idKey)));
+ lsp::ResponseError<std::nullptr_t> error;
+ error.setCode(code);
+ error.setMessage(message);
+ response.setError(error);
+ sendMessage(response);
+}
+
+void LspServer::Private::sendErrorNotification(const QString &message)
+{
+ lsp::ShowMessageParams params;
+ params.setType(lsp::Error);
+ params.setMessage(message);
+ sendMessage(lsp::ShowMessageNotification(params));
+}
+
+void LspServer::Private::sendNoSuchDocumentError(const QString &filePath)
+{
+ sendErrorNotification(Tr::tr("No such document: '%1'").arg(filePath));
+}
+
+void LspServer::Private::sendMessage(const lsp::JsonObject &msg)
+{
+ sendMessage(lsp::JsonRpcMessage(msg));
+}
+
+void LspServer::Private::sendMessage(const lsp::JsonRpcMessage &msg)
+{
+ lsp::BaseMessage baseMsg = msg.toBaseMessage();
+ socket->write(baseMsg.header());
+ socket->write(baseMsg.content);
+}
+
+void LspServer::Private::handleCurrentMessage()
+{
+ messageObject = lsp::JsonRpcMessage(currentMessage).toJsonObject();
+ const QString method = messageObject.value(lsp::methodKey).toString();
+ if (method == QLatin1String("exit"))
+ return discardSocket();
+ if (state == State::Shutdown) {
+ return sendErrorResponse(LspErrorResponse::InvalidRequest,
+ Tr::tr("Method '%1' not allowed after shutdown."));
+ }
+ if (method == "shutdown")
+ return handleShutdownRequest();
+ if (method == QLatin1String("initialize"))
+ return handleInitializeRequest();
+ if (state == State::None) {
+ return sendErrorResponse(LspErrorResponse::ServerNotInitialized,
+ Tr::tr("First message must be initialize request."));
+ }
+ if (method == "initialized")
+ return handleInitializedNotification();
+ if (method == "textDocument/didOpen")
+ return handleDidOpenNotification();
+ if (method == "textDocument/didChange")
+ return handleDidChangeNotification();
+ if (method == "textDocument/didSave")
+ return handleDidSaveNotification();
+ if (method == "textDocument/didClose")
+ return handleDidCloseNotification();
+ if (method == "textDocument/definition")
+ return handleGotoDefinitionRequest();
+ if (method == "textDocument/completion")
+ return handleCompletionRequest();
+
+ sendErrorResponse(LspErrorResponse::MethodNotFound, Tr::tr("This server can do very little."));
+}
+
+void LspServer::Private::handleInitializeRequest()
+{
+ if (state != State::None) {
+ return sendErrorResponse(LspErrorResponse::InvalidRequest,
+ Tr::tr("Received initialize request in initialized state."));
+ }
+ state = State::InitRequest;
+
+ lsp::ServerInfo serverInfo;
+ serverInfo.insert(lsp::nameKey, "qbs");
+ serverInfo.insert(lsp::versionKey, QBS_VERSION);
+ lsp::InitializeResult result;
+ result.insert(lsp::serverInfoKey, serverInfo);
+ lsp::ServerCapabilities capabilities; // TODO: hover
+ capabilities.setDefinitionProvider(true);
+ capabilities.setTextDocumentSync({int(lsp::TextDocumentSyncKind::Incremental)});
+ lsp::ServerCapabilities::CompletionOptions completionOptions;
+ completionOptions.setTriggerCharacters({"."});
+ capabilities.setCompletionProvider(completionOptions);
+ result.setCapabilities(capabilities);
+ sendResponse(result);
+}
+
+void LspServer::Private::handleInitializedNotification()
+{
+ if (state != State::InitRequest) {
+ return sendErrorResponse(LspErrorResponse::InvalidRequest,
+ Tr::tr("Unexpected initialized notification."));
+ }
+ state = State::InitNotification;
+}
+
+void LspServer::Private::handleGotoDefinitionRequest()
+{
+ const lsp::TextDocumentPositionParams params(messageObject.value(lsp::paramsKey));
+ const QString sourceFile = params.textDocument().uri().toLocalFile();
+ const Document *sourceDoc = nullptr;
+ if (const auto it = documents.find(sourceFile); it != documents.end())
+ sourceDoc = &it->second;
+ const auto fileEntry = codeLinks.constFind(sourceFile);
+ if (fileEntry == codeLinks.constEnd())
+ return sendResponse(nullptr);
+ const CodePosition sourcePos = posFromLspPos(params.position());
+ if (sourceDoc && !sourceDoc->isPositionUpToDate(sourcePos))
+ return sendResponse(nullptr);
+ for (auto it = fileEntry->cbegin(); it != fileEntry->cend(); ++it) {
+ if (!it.key().contains(sourcePos))
+ continue;
+ if (sourceDoc && !sourceDoc->isPositionUpToDate(it.key().end()))
+ return sendResponse(nullptr);
+ QList<CodeLocation> targets = it.value();
+ QBS_ASSERT(!targets.isEmpty(), return sendResponse(nullptr));
+ for (auto it = targets.begin(); it != targets.end();) {
+ const Document *targetDoc = nullptr;
+ if (it->filePath() == sourceFile)
+ targetDoc = sourceDoc;
+ else if (const auto docIt = documents.find(it->filePath()); docIt != documents.end())
+ targetDoc = &docIt->second;
+ if (targetDoc && !targetDoc->isPositionUpToDate(CodePosition(it->line(), it->column())))
+ it = targets.erase(it);
+ else
+ ++it;
+ }
+ struct JsonArray : public QJsonArray { void reserve(std::size_t) {}};
+ const auto locations = transformed<JsonArray>(targets,
+ [](const CodeLocation &loc) {
+ const lsp::Position startPos = lspPosFromCodeLocation(loc);
+ const lsp::Position endPos(startPos.line(), startPos.character() + 1);
+ lsp::Location targetLocation;
+ targetLocation.setUri(lsp::DocumentUri::fromProtocol(
+ QUrl::fromLocalFile(loc.filePath()).toString()));
+ targetLocation.setRange({startPos, endPos});
+ return QJsonObject(targetLocation);
+ });
+ if (locations.size() == 1)
+ return sendResponse(locations.first().toObject());
+ return sendResponse(locations);
+ }
+ sendResponse(nullptr);
+}
+
+// We operate under the assumption that the client has basic QML support.
+// Therefore, we only provide completion for qbs modules and their properties.
+// Only a simple prefix match is implemented, with no regard to the contents after the cursor.
+void LspServer::Private::handleCompletionRequest()
+{
+ if (!projectData.isValid())
+ return sendResponse(nullptr);
+
+ const lsp::CompletionParams params(messageObject.value(lsp::paramsKey));
+ const QString sourceFile = params.textDocument().uri().toLocalFile();
+ const Document *sourceDoc = nullptr;
+ if (const auto it = documents.find(sourceFile); it != documents.end())
+ sourceDoc = &it->second;
+ if (!sourceDoc)
+ return sendResponse(nullptr);
+
+ // If there are products corresponding to this file, check only these when looking for modules.
+ // Otherwise, check all products.
+ const QList<ProductData> allProducts = projectData.allProducts();
+ if (allProducts.isEmpty())
+ return sendResponse(nullptr);
+ QList<ProductData> relevantProducts;
+ for (const ProductData &p : allProducts) {
+ if (p.location().filePath() == sourceFile)
+ relevantProducts << p;
+ }
+ if (relevantProducts.isEmpty())
+ relevantProducts = allProducts;
+
+ QString identifierPrefix;
+ QStringList modulePrefixes;
+ const int offset = posToOffset(params.position(), sourceDoc->currentContent) - 1;
+ if (offset < 0 || offset >= sourceDoc->currentContent.length())
+ return sendResponse(nullptr);
+ const auto collectFromRawString = [&] {
+ int currentPos = offset;
+ const auto constructIdentifier = [&] {
+ QString id;
+ while (currentPos >= 0) {
+ const QChar c = sourceDoc->currentContent.at(currentPos);
+ if (!c.isLetterOrNumber() && c != '_')
+ break;
+ id.prepend(c);
+ --currentPos;
+ }
+ return id;
+ };
+ identifierPrefix = constructIdentifier();
+ while (true) {
+ if (currentPos <= 0 || sourceDoc->currentContent.at(currentPos) != '.')
+ return;
+ --currentPos;
+ const QString modulePrefix = constructIdentifier();
+ if (modulePrefix.isEmpty())
+ return;
+ modulePrefixes.prepend(modulePrefix);
+ }
+ };
+
+ // Parse the current file. Note that completion usually happens on invalid code, which
+ // often confuses the parser so much that it yields unusable results. Therefore, we always
+ // gather our input parameters from the raw string. We only use the parse result to skip
+ // completion in contexts where it is undesirable.
+ QbsQmlJS::Engine engine;
+ QbsQmlJS::Lexer lexer(&engine);
+ lexer.setCode(sourceDoc->currentContent, 1);
+ QbsQmlJS::Parser parser(&engine);
+ parser.parse();
+ if (parser.ast()) {
+ AstNodeLocator locator(offset, *parser.ast());
+ const QList<QbsQmlJS::AST::Node *> &astPath = locator.path();
+ if (!astPath.isEmpty()) {
+ switch (astPath.last()->kind) {
+ case QbsQmlJS::AST::Node::Kind_FieldMemberExpression:
+ case QbsQmlJS::AST::Node::Kind_UiObjectDefinition:
+ case QbsQmlJS::AST::Node::Kind_UiQualifiedId:
+ case QbsQmlJS::AST::Node::Kind_UiScriptBinding:
+ break;
+ default:
+ return sendResponse(nullptr);
+ }
+ }
+ }
+
+ collectFromRawString();
+ if (modulePrefixes.isEmpty() && identifierPrefix.isEmpty())
+ return sendResponse(nullptr); // We do not want to start completion from nothing.
+
+ QJsonArray results;
+ QMap<QString, QString> namesAndTypes;
+ for (const ProductData &product : std::as_const(relevantProducts)) {
+ const PropertyMap &moduleProps = product.moduleProperties();
+ const QStringList allModules = moduleProps.allModules();
+ const QString moduleNameOrPrefix = modulePrefixes.join('.');
+
+ // Case 1: Prefixes match a module name. Identifier can only expand to the name
+ // of a module property.
+ // Example: "Qt.core.a^" -> "Qt.core.availableBuildVariants"
+ if (!modulePrefixes.isEmpty() && allModules.contains(moduleNameOrPrefix)) {
+ for (const PropertyMap::PropertyInfo &info :
+ moduleProps.allPropertiesForModule(moduleNameOrPrefix)) {
+ if (info.isBuiltin)
+ continue;
+ if (!identifierPrefix.isEmpty() && !info.name.startsWith(identifierPrefix))
+ continue;
+ namesAndTypes.insert(info.name, info.type);
+ }
+ continue;
+ }
+
+ // Case 2: Isolated identifier. Can only expand to a module name.
+ // Example: "Q^" -> "Qt.core", "Qt.widgets", ...
+ // Case 3: Prefixes match a module prefix. Identifier can only expand to a module name.
+ // Example: "Qt.c^" -> "Qt.core", "Qt.concurrent", ...
+ QString fullPrefix = identifierPrefix;
+ int nameOffset = 0;
+ if (!modulePrefixes.isEmpty()) {
+ fullPrefix.prepend(moduleNameOrPrefix + '.');
+ nameOffset = moduleNameOrPrefix.length() + 1;
+ }
+ for (const QString &module : allModules) {
+ if (module.startsWith(fullPrefix))
+ namesAndTypes.insert(module.mid(nameOffset), {});
+ }
+ }
+
+ for (auto it = namesAndTypes.cbegin(); it != namesAndTypes.cend(); ++it) {
+ lsp::CompletionItem item;
+ item.setLabel(it.key());
+ if (!it.value().isEmpty())
+ item.setDetail(it.value());
+ results.append(QJsonObject(item));
+ };
+ sendResponse(results);
+}
+
+void LspServer::Private::handleShutdownRequest()
+{
+ state = State::Shutdown;
+ sendResponse(nullptr);
+}
+
+void LspServer::Private::handleDidOpenNotification()
+{
+ const lsp::TextDocumentItem docItem = lsp::DidOpenTextDocumentNotification(messageObject)
+ .params().value_or(lsp::DidOpenTextDocumentParams())
+ .textDocument();
+ if (!docItem.isValid())
+ return sendErrorNotification(Tr::tr("Invalid didOpen parameters."));
+ const QString filePath = uriToString(docItem.uri());
+ Document &doc = documents[filePath];
+ doc.savedContent = doc.currentContent = docItem.text();
+}
+
+void LspServer::Private::handleDidChangeNotification()
+{
+ const auto params = lsp::DidChangeTextDocumentNotification(messageObject)
+ .params().value_or(lsp::DidChangeTextDocumentParams());
+ if (!params.isValid())
+ return sendErrorNotification(Tr::tr("Invalid didChange parameters."));
+ const QString filePath = uriToString(params.textDocument().uri());
+ const auto docIt = documents.find(filePath);
+ if (docIt == documents.end())
+ return sendNoSuchDocumentError(filePath);
+ Document &doc = docIt->second;
+ const auto changes = params.contentChanges();
+ for (const auto &change : changes) {
+ const auto range = change.range();
+ if (!range) {
+ doc.currentContent = change.text();
+ continue;
+ }
+ const int startPos = posToOffset(range->start(), doc.currentContent);
+ const int endPos = posToOffset(range->end(), doc.currentContent);
+ if (startPos == -1 || endPos == -1 || startPos > endPos)
+ return sendErrorResponse(LspErrorResponse::InvalidParams, Tr::tr("Invalid range."));
+ doc.currentContent.replace(startPos, endPos - startPos, change.text());
+ }
+}
+
+void LspServer::Private::handleDidSaveNotification()
+{
+ const QString filePath = uriToString(lsp::DidSaveTextDocumentNotification(messageObject)
+ .params().value_or(lsp::DidSaveTextDocumentParams())
+ .textDocument().uri());
+ const auto docIt = documents.find(filePath);
+ if (docIt == documents.end())
+ return sendNoSuchDocumentError(filePath);
+ docIt->second.savedContent = docIt->second.currentContent;
+
+ // TODO: Mark the project data as out of date until the next update(),if the document
+ // is in buildSystemFiles().
+}
+
+void LspServer::Private::handleDidCloseNotification()
+{
+ const QString filePath = uriToString(lsp::DidCloseTextDocumentNotification(messageObject)
+ .params().value_or(lsp::DidCloseTextDocumentParams())
+ .textDocument().uri());
+ const auto docIt = documents.find(filePath);
+ if (docIt == documents.end())
+ return sendNoSuchDocumentError(filePath);
+ documents.erase(docIt);
+}
+
+static int posToOffset(const CodePosition &pos, const QString &doc)
+{
+ int offset = 0;
+ for (int newlines = 0, next = 0; newlines < pos.line() - 1; ++newlines) {
+ offset = doc.indexOf('\n', next);
+ if (offset == -1)
+ return -1;
+ next = offset + 1;
+ }
+ return offset + pos.column();
+}
+
+bool Document::isPositionUpToDate(const CodePosition &pos) const
+{
+ const int origOffset = posToOffset(pos, savedContent);
+ if (origOffset > int(currentContent.size()))
+ return false;
+ return QStringView(currentContent).left(origOffset)
+ == QStringView(savedContent).left(origOffset);
+}
+
+bool Document::isPositionUpToDate(const lsp::Position &pos) const
+{
+ return isPositionUpToDate(posFromLspPos(pos));
+}
+
+} // namespace qbs::Internal
+