// 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 // Qt-Security score:significant reason:default #include "qqmllintsuggestions_p.h" #include #include #include #include #include #include #include #include #include #include #include #include #include QT_BEGIN_NAMESPACE Q_STATIC_LOGGING_CATEGORY(lintLog, "qt.languageserver.lint") using namespace QLspSpecification; using namespace QQmlJS::Dom; using namespace Qt::StringLiterals; 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::nullptr_t>, QList>> &&response) { QList> 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 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::QQmlCodeModelManager *codeModelManager) : m_server(server), m_codeModelManager(codeModelManager) { QObject::connect(m_codeModelManager, &QmlLsp::QQmlCodeModelManager::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 couldn't find a build directory. Pass the \"--build-dir \" option to " "qmlls, set the environment variable \"QMLLS_BUILD_DIRS\", or create a .qmlls.ini " "configuration file with a \"buildDir\" value in your project's source folder to avoid " "spurious warnings"; diagnostic.source = QByteArray("qmllint"); return diagnostic; } using AdvanceFunc = qxp::function_ref; static Diagnostic messageToDiagnostic_helper(AdvanceFunc advancePositionPastLocation, std::optional 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 = u"%1: %2 [%3]"_s.arg(message.message, message.fixSuggestion->fixDescription(), message.id.toString()) .simplified() .toUtf8(); } else { diagnostic.message = u"%1 [%2]"_s.arg(message.message, message.id.toString()).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 snapshotVersion, std::optional processedVersion, QmlLsp::UpdatePolicy policy) { if (!snapshotVersion) return false; if (policy == ForceUpdate) return true; if (!processedVersion || *snapshotVersion > *processedVersion) return true; return false; } using namespace std::chrono_literals; QmlLintSuggestions::VersionToDiagnose QmlLintSuggestions::chooseVersionToDiagnoseHelper(const QByteArray &url, QmlLsp::UpdatePolicy policy) { const std::chrono::milliseconds maxInvalidTime = 400ms; QmlLsp::OpenDocumentSnapshot snapshot = m_codeModelManager->snapshotByUrl(url); LastLintUpdate &lastUpdate = m_lastUpdate[url]; // ignore updates when already processed if (policy != ForceUpdate && 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, policy)) return VersionedDocument{ snapshot.validDocVersion, snapshot.validDoc }; // try out an invalid version, if there is one if (isSnapshotNew(snapshot.docVersion, lastUpdate.version, policy)) { 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, QmlLsp::UpdatePolicy policy) { QMutexLocker l(&m_mutex); auto versionToDiagnose = chooseVersionToDiagnoseHelper(url, policy); if (auto versionedDocument = std::get_if(&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, QmlLsp::UpdatePolicy policy) { auto versionedDocument = chooseVersionToDiagnose(url, policy); std::visit(qOverloadedVisitor{ [](NoDocumentAvailable) {}, [this, &url, &policy](const TryAgainLater &tryAgainLater) { QTimer::singleShot(tryAgainLater.time, Qt::VeryCoarseTimer, this, [this, url, policy]() { diagnose(url, policy); }); }, [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_codeModelManager->buildPathsForFileUrl(url); const QString filename = doc.canonicalFilePath(); imports.append(m_codeModelManager->importPathsForUrl(url)); // 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 = QQmlJSUtils::resourceFilesFromBuildFolders(imports); QList categories = QQmlJSLogger::defaultCategories(); QQmlJSLinter linter(imports); for (const QQmlJSLinter::Plugin &plugin : linter.plugins()) { for (const QQmlJS::LoggerCategory &category : plugin.categories()) categories.append(category); } QQmlToolingSettings settings(QLatin1String("qmllint")); if (settings.search(filename).isValid()) { QQmlJS::LoggingUtils::updateLogLevels(categories, settings, nullptr); } // TODO: pass the workspace folders to QQmlJSLinter 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 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(); logger->iterateAllMessages([&](const Message &message) { if (!message.message.contains(u"Failed to import")) { diagnostics.append(messageToDiagnostic(message)); return; } Message modified {message}; modified.message.append(u" Did you build your project?"); diagnostics.append(messageToDiagnostic(modified)); }); 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