diff options
Diffstat (limited to 'src/qmlls/qqmllintsuggestions.cpp')
-rw-r--r-- | src/qmlls/qqmllintsuggestions.cpp | 378 |
1 files changed, 378 insertions, 0 deletions
diff --git a/src/qmlls/qqmllintsuggestions.cpp b/src/qmlls/qqmllintsuggestions.cpp new file mode 100644 index 0000000000..ab12195b4d --- /dev/null +++ b/src/qmlls/qqmllintsuggestions.cpp @@ -0,0 +1,378 @@ +// Copyright (C) 2021 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "qqmllintsuggestions_p.h" + +#include <QtLanguageServer/private/qlanguageserverspec_p.h> +#include <QtQmlCompiler/private/qqmljslinter_p.h> +#include <QtQmlCompiler/private/qqmljslogger_p.h> +#include <QtQmlDom/private/qqmldom_utils_p.h> +#include <QtQmlDom/private/qqmldomtop_p.h> +#include <QtCore/qdebug.h> +#include <QtCore/qdir.h> +#include <QtCore/qfileinfo.h> +#include <QtCore/qlibraryinfo.h> +#include <QtCore/qtimer.h> +#include <QtCore/qxpfunctional.h> +#include <chrono> + +using namespace QLspSpecification; +using namespace QQmlJS::Dom; +using namespace Qt::StringLiterals; + +Q_LOGGING_CATEGORY(lintLog, "qt.languageserver.lint") + +QT_BEGIN_NAMESPACE +namespace QmlLsp { + +static DiagnosticSeverity severityFromMsgType(QtMsgType t) +{ + switch (t) { + case QtDebugMsg: + return DiagnosticSeverity::Hint; + case QtInfoMsg: + return DiagnosticSeverity::Information; + case QtWarningMsg: + return DiagnosticSeverity::Warning; + case QtCriticalMsg: + case QtFatalMsg: + break; + } + return DiagnosticSeverity::Error; +} + +static void codeActionHandler( + const QByteArray &, const CodeActionParams ¶ms, + LSPPartialResponse<std::variant<QList<std::variant<Command, CodeAction>>, std::nullptr_t>, + QList<std::variant<Command, CodeAction>>> &&response) +{ + QList<std::variant<Command, CodeAction>> responseData; + + for (const Diagnostic &diagnostic : params.context.diagnostics) { + if (!diagnostic.data.has_value()) + continue; + + const auto &data = diagnostic.data.value(); + + int version = data[u"version"].toInt(); + QJsonArray suggestions = data[u"suggestions"].toArray(); + + QList<WorkspaceEdit::DocumentChange> edits; + QString message; + for (const QJsonValue &suggestion : suggestions) { + QString replacement = suggestion[u"replacement"].toString(); + message += suggestion[u"message"].toString() + u"\n"; + + TextEdit textEdit; + textEdit.range = { Position { suggestion[u"lspBeginLine"].toInt(), + suggestion[u"lspBeginCharacter"].toInt() }, + Position { suggestion[u"lspEndLine"].toInt(), + suggestion[u"lspEndCharacter"].toInt() } }; + textEdit.newText = replacement.toUtf8(); + + TextDocumentEdit textDocEdit; + textDocEdit.textDocument = { params.textDocument, version }; + textDocEdit.edits.append(textEdit); + + edits.append(textDocEdit); + } + message.chop(1); + WorkspaceEdit edit; + edit.documentChanges = edits; + + CodeAction action; + // VS Code and QtC ignore everything that is not a 'quickfix'. + action.kind = u"quickfix"_s.toUtf8(); + action.edit = edit; + action.title = message.toUtf8(); + + responseData.append(action); + } + + response.sendResponse(responseData); +} + +void QmlLintSuggestions::registerHandlers(QLanguageServer *, QLanguageServerProtocol *protocol) +{ + protocol->registerCodeActionRequestHandler(&codeActionHandler); +} + +void QmlLintSuggestions::setupCapabilities(const QLspSpecification::InitializeParams &, + QLspSpecification::InitializeResult &serverInfo) +{ + serverInfo.capabilities.codeActionProvider = true; +} + +QmlLintSuggestions::QmlLintSuggestions(QLanguageServer *server, QmlLsp::QQmlCodeModel *codeModel) + : m_server(server), m_codeModel(codeModel) +{ + QObject::connect(m_codeModel, &QmlLsp::QQmlCodeModel::updatedSnapshot, this, + &QmlLintSuggestions::diagnose, Qt::DirectConnection); +} + +static void advancePositionPastLocation_helper(const QString &fileContents, const QQmlJS::SourceLocation &location, Position &position) { + const int startOffset = location.offset; + const int length = location.length; + int i = startOffset; + int iEnd = i + length; + if (iEnd > int(fileContents.size())) + iEnd = fileContents.size(); + while (i < iEnd) { + if (fileContents.at(i) == u'\n') { + ++position.line; + position.character = 0; + if (i + 1 < iEnd && fileContents.at(i) == u'\r') + ++i; + } else { + ++position.character; + } + ++i; + } +}; + +static Diagnostic createMissingBuildDirDiagnostic() +{ + Diagnostic diagnostic; + diagnostic.severity = DiagnosticSeverity::Warning; + Range &range = diagnostic.range; + Position &position = range.start; + position.line = 0; + position.character = 0; + Position &positionEnd = range.end; + positionEnd.line = 1; + diagnostic.message = + "qmlls could not find a build directory, without a build directory " + "containing a current build there could be spurious warnings, you might " + "want to pass the --build-dir <buildDir> option to qmlls, or set the " + "environment variable QMLLS_BUILD_DIRS."; + diagnostic.source = QByteArray("qmllint"); + return diagnostic; +} + +using AdvanceFunc = qxp::function_ref<void(const QQmlJS::SourceLocation &, Position &)>; +static Diagnostic messageToDiagnostic_helper(AdvanceFunc advancePositionPastLocation, + std::optional<int> version, const Message &message) +{ + Diagnostic diagnostic; + diagnostic.severity = severityFromMsgType(message.type); + Range &range = diagnostic.range; + Position &position = range.start; + + QQmlJS::SourceLocation srcLoc = message.loc; + + if (srcLoc.isValid()) { + position.line = srcLoc.startLine - 1; + position.character = srcLoc.startColumn - 1; + range.end = position; + advancePositionPastLocation(message.loc, range.end); + } + + if (message.fixSuggestion && !message.fixSuggestion->fixDescription().isEmpty()) { + diagnostic.message = QString(message.message) + .append(u": "_s) + .append(message.fixSuggestion->fixDescription()) + .simplified() + .toUtf8(); + } else { + diagnostic.message = message.message.toUtf8(); + } + + diagnostic.source = QByteArray("qmllint"); + + auto suggestion = message.fixSuggestion; + if (!suggestion.has_value()) + return diagnostic; + + // We need to interject the information about where the fix suggestions end + // here since we don't have access to the textDocument to calculate it later. + const QQmlJS::SourceLocation cut = suggestion->location(); + + const int line = cut.isValid() ? cut.startLine - 1 : 0; + const int column = cut.isValid() ? cut.startColumn - 1 : 0; + + QJsonObject object; + object.insert("lspBeginLine"_L1, line); + object.insert("lspBeginCharacter"_L1, column); + + Position end = { line, column }; + + if (srcLoc.isValid()) + advancePositionPastLocation(cut, end); + object.insert("lspEndLine"_L1, end.line); + object.insert("lspEndCharacter"_L1, end.character); + + object.insert("message"_L1, suggestion->fixDescription()); + object.insert("replacement"_L1, suggestion->replacement()); + + QJsonArray fixedSuggestions; + fixedSuggestions.append(object); + QJsonObject data; + data[u"suggestions"] = fixedSuggestions; + + Q_ASSERT(version.has_value()); + data[u"version"] = version.value(); + + diagnostic.data = data; + + return diagnostic; +}; + +static bool isSnapshotNew(std::optional<int> snapshotVersion, std::optional<int> processedVersion) +{ + if (!snapshotVersion) + return false; + if (!processedVersion || *snapshotVersion > *processedVersion) + return true; + return false; +} + +using namespace std::chrono_literals; + +QmlLintSuggestions::VersionToDiagnose +QmlLintSuggestions::chooseVersionToDiagnoseHelper(const QByteArray &url) +{ + const std::chrono::milliseconds maxInvalidTime = 400ms; + QmlLsp::OpenDocumentSnapshot snapshot = m_codeModel->snapshotByUrl(url); + + LastLintUpdate &lastUpdate = m_lastUpdate[url]; + + // ignore updates when already processed + if (lastUpdate.version && *lastUpdate.version == snapshot.docVersion) { + qCDebug(lspServerLog) << "skipped update of " << url << "unchanged valid doc"; + return NoDocumentAvailable{}; + } + + // try out a valid version, if there is one + if (isSnapshotNew(snapshot.validDocVersion, lastUpdate.version)) + return VersionedDocument{ snapshot.validDocVersion, snapshot.validDoc }; + + // try out an invalid version, if there is one + if (isSnapshotNew(snapshot.docVersion, lastUpdate.version)) { + if (auto since = lastUpdate.invalidUpdatesSince) { + // did we wait enough to get a valid document? + if (std::chrono::steady_clock::now() - *since > maxInvalidTime) { + return VersionedDocument{ snapshot.docVersion, snapshot.doc }; + } + } else { + // first time hitting the invalid document: + lastUpdate.invalidUpdatesSince = std::chrono::steady_clock::now(); + } + + // wait some time for extra keystrokes before diagnose + return TryAgainLater{ maxInvalidTime }; + } + return NoDocumentAvailable{}; +} + +QmlLintSuggestions::VersionToDiagnose +QmlLintSuggestions::chooseVersionToDiagnose(const QByteArray &url) +{ + QMutexLocker l(&m_mutex); + auto versionToDiagnose = chooseVersionToDiagnoseHelper(url); + if (auto versionedDocument = std::get_if<VersionedDocument>(&versionToDiagnose)) { + // update immediately, and do not keep track of sent version, thus in extreme cases sent + // updates could be out of sync + LastLintUpdate &lastUpdate = m_lastUpdate[url]; + lastUpdate.version = versionedDocument->version; + lastUpdate.invalidUpdatesSince.reset(); + } + return versionToDiagnose; +} + +void QmlLintSuggestions::diagnose(const QByteArray &url) +{ + auto versionedDocument = chooseVersionToDiagnose(url); + + std::visit(qOverloadedVisitor{ + [](NoDocumentAvailable) {}, + [this, &url](const TryAgainLater &tryAgainLater) { + QTimer::singleShot(tryAgainLater.time, Qt::VeryCoarseTimer, this, + [this, url]() { diagnose(url); }); + }, + [this, &url](const VersionedDocument &versionedDocument) { + diagnoseHelper(url, versionedDocument); + }, + + }, + versionedDocument); +} + +void QmlLintSuggestions::diagnoseHelper(const QByteArray &url, + const VersionedDocument &versionedDocument) +{ + auto [version, doc] = versionedDocument; + + PublishDiagnosticsParams diagnosticParams; + diagnosticParams.uri = url; + diagnosticParams.version = version; + + qCDebug(lintLog) << "has doc, do real lint"; + QStringList imports = m_codeModel->buildPathsForFileUrl(url); + imports.append(m_codeModel->importPaths()); + const QString filename = doc.canonicalFilePath(); + // add source directory as last import as fallback in case there is no qmldir in the build + // folder this mimics qmllint behaviors + imports.append(QFileInfo(filename).dir().absolutePath()); + // add m_server->clientInfo().rootUri & co? + bool silent = true; + const QString fileContents = doc.field(Fields::code).value().toString(); + const QStringList qmltypesFiles; + const QStringList resourceFiles = resourceFilesFromBuildFolders(imports); + + QList<QQmlJS::LoggerCategory> categories; + + QQmlJSLinter linter(imports); + + linter.lintFile(filename, &fileContents, silent, nullptr, imports, qmltypesFiles, + resourceFiles, categories); + + // ### TODO: C++20 replace with bind_front + auto advancePositionPastLocation = [&fileContents](const QQmlJS::SourceLocation &location, Position &position) + { + advancePositionPastLocation_helper(fileContents, location, position); + }; + auto messageToDiagnostic = [&advancePositionPastLocation, + versionedDocument](const Message &message) { + return messageToDiagnostic_helper(advancePositionPastLocation, versionedDocument.version, + message); + }; + + QList<Diagnostic> diagnostics; + doc.iterateErrors( + [&diagnostics, &advancePositionPastLocation](const DomItem &, const ErrorMessage &msg) { + Diagnostic diagnostic; + diagnostic.severity = severityFromMsgType(QtMsgType(int(msg.level))); + // do something with msg.errorGroups ? + auto &location = msg.location; + Range &range = diagnostic.range; + range.start.line = location.startLine - 1; + range.start.character = location.startColumn - 1; + range.end = range.start; + advancePositionPastLocation(location, range.end); + diagnostic.code = QByteArray(msg.errorId.data(), msg.errorId.size()); + diagnostic.source = "domParsing"; + diagnostic.message = msg.message.toUtf8(); + diagnostics.append(diagnostic); + return true; + }, + true); + + if (const QQmlJSLogger *logger = linter.logger()) { + qsizetype nDiagnostics = diagnostics.size(); + for (const auto &messages : { logger->infos(), logger->warnings(), logger->errors() }) + for (const Message &message : messages) + diagnostics.append(messageToDiagnostic(message)); + if (diagnostics.size() != nDiagnostics && imports.size() == 1) + diagnostics.append(createMissingBuildDirDiagnostic()); + } + + diagnosticParams.diagnostics = diagnostics; + + m_server->protocol()->notifyPublishDiagnostics(diagnosticParams); + qCDebug(lintLog) << "lint" << QString::fromUtf8(url) << "found" + << diagnosticParams.diagnostics.size() << "issues" + << QTypedJson::toJsonValue(diagnosticParams); +} + +} // namespace QmlLsp +QT_END_NAMESPACE |