Revision control
Copy as Markdown
// 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 <QtLanguageServer/private/qlanguageserverspec_p.h>
#include <QtQmlCompiler/private/qqmljslogger_p.h>
#include <QtQmlCompiler/private/qqmljsutils_p.h>
#include <QtQmlDom/private/qqmldom_utils_p.h>
#include <QtQmlDom/private/qqmldomtop_p.h>
#include <QtQmlLint/private/qqmljslinter_p.h>
#include <QtQmlLint/private/qqmllintsettings_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>
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;
}
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 <buildDir>\" 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<void(const QQmlJS::SourceLocation &, Position &)>;
QJsonArray suggestionToJson(AdvanceFunc advancePositionPastLocation, const Message &message)
{
const auto addLocationToJsonObject = [&](const auto &location, QJsonObject &object) {
const unsigned line = location.isValid() ? location.startLine - 1 : 0;
const unsigned column = location.isValid() ? location.startColumn - 1 : 0;
Position end = { line, column };
if (location.isValid())
advancePositionPastLocation(location, end);
object["lspBeginLine"_L1] = double(line);
object["lspBeginCharacter"_L1] = double(column);
object["lspEndLine"_L1] = double(end.line);
object["lspEndCharacter"_L1] = double(end.character);
};
if (!message.fixSuggestion.has_value())
return QJsonArray();
const QQmlJSFixSuggestion &suggestion = message.fixSuggestion.value();
QJsonArray documentEditsJson;
const auto &documentEdits = suggestion.documentEdits();
for (const auto &documentEdit : documentEdits) {
// 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 unsigned line = cut.isValid() ? cut.startLine - 1 : 0;
const unsigned column = cut.isValid() ? cut.startColumn - 1 : 0;
Position end = { line, column };
if (cut.isValid())
advancePositionPastLocation(cut, end);
QJsonObject documentEditJson{
{ "filename"_L1, QUrl::fromLocalFile(documentEdit.m_filename).toString() },
{ "replacement"_L1, documentEdit.m_replacement }
};
addLocationToJsonObject(documentEdit.m_location, documentEditJson);
documentEditsJson.append(documentEditJson);
}
QJsonObject fixSuggestionJson;
fixSuggestionJson["message"_L1] = message.fixSuggestion->description();
addLocationToJsonObject(message.fixSuggestion->location(), fixSuggestionJson);
fixSuggestionJson["documentEdits"_L1] = documentEditsJson;
fixSuggestionJson["autoApplicable"_L1] = message.fixSuggestion->isAutoApplicable();
QJsonArray fixSuggestionsJson;
fixSuggestionsJson.append(fixSuggestionJson);
return fixSuggestionsJson;
}
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->description().isEmpty()) {
diagnostic.message = u"%1%2 [%3]"_s.arg(message.message, message.fixSuggestion->description(), 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;
QJsonObject data;
data[u"suggestions"] = suggestionToJson(advancePositionPastLocation, message);
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,
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<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, 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->importPathsForUrl(url);
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?
const QString fileContents = doc.field(Fields::code).value().toString();
const QStringList qmltypesFiles;
const QStringList resourceFiles = m_codeModelManager->resourceFilesForFileUrl(url);
QList<QQmlJS::LoggerCategory> categories = QQmlJSLogger::builtinCategories();
QQmlJSLinter linter(imports);
for (const QQmlJSLinter::Plugin &plugin : linter.plugins()) {
for (const QQmlJS::LoggerCategory &category : plugin.categories())
categories.append(category);
}
QQmlLintSettings settings;
if (settings.search(filename).isValid()) {
QQmlJS::LoggingUtils::updateLogSeverities(categories, settings, nullptr);
}
// TODO: pass the workspace folders to QQmlJSLinter
QQmlJSLinter::LintOptions lintOptions;
lintOptions.setFlag(QQmlJSLinter::Silent);
const bool wasPrepared = linter.prepareFileForBatchLinting(
filename, &fileContents, lintOptions, imports, qmltypesFiles, resourceFiles, categories,
settings.maxWarnings());
Q_ASSERT(wasPrepared);
QQmlJSLinter::Result result = linter.lintFileInBatch(filename);
// ### 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 = result.logger.get()) {
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