1// Copyright (C) 2021 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
4#include "qqmllintsuggestions_p.h"
5
6#include <QtLanguageServer/private/qlanguageserverspec_p.h>
7#include <QtQmlCompiler/private/qqmljslinter_p.h>
8#include <QtQmlCompiler/private/qqmljslogger_p.h>
9#include <QtQmlCompiler/private/qqmljsutils_p.h>
10#include <QtQmlDom/private/qqmldom_utils_p.h>
11#include <QtQmlDom/private/qqmldomtop_p.h>
12#include <QtCore/qdebug.h>
13#include <QtCore/qdir.h>
14#include <QtCore/qfileinfo.h>
15#include <QtCore/qlibraryinfo.h>
16#include <QtCore/qtimer.h>
17#include <QtCore/qxpfunctional.h>
18#include <chrono>
19
20QT_BEGIN_NAMESPACE
21
22Q_STATIC_LOGGING_CATEGORY(lintLog, "qt.languageserver.lint")
23
24using namespace QLspSpecification;
25using namespace QQmlJS::Dom;
26using namespace Qt::StringLiterals;
27
28namespace QmlLsp {
29
30static DiagnosticSeverity severityFromMsgType(QtMsgType t)
31{
32 switch (t) {
33 case QtDebugMsg:
34 return DiagnosticSeverity::Hint;
35 case QtInfoMsg:
36 return DiagnosticSeverity::Information;
37 case QtWarningMsg:
38 return DiagnosticSeverity::Warning;
39 case QtCriticalMsg:
40 case QtFatalMsg:
41 break;
42 }
43 return DiagnosticSeverity::Error;
44}
45
46static void codeActionHandler(
47 const QByteArray &, const CodeActionParams &params,
48 LSPPartialResponse<std::variant<QList<std::variant<Command, CodeAction>>, std::nullptr_t>,
49 QList<std::variant<Command, CodeAction>>> &&response)
50{
51 QList<std::variant<Command, CodeAction>> responseData;
52
53 for (const Diagnostic &diagnostic : params.context.diagnostics) {
54 if (!diagnostic.data.has_value())
55 continue;
56
57 const auto &data = diagnostic.data.value();
58
59 int version = data[u"version"].toInt();
60 QJsonArray suggestions = data[u"suggestions"].toArray();
61
62 QList<WorkspaceEdit::DocumentChange> edits;
63 QString message;
64 for (const QJsonValue &suggestion : suggestions) {
65 QString replacement = suggestion[u"replacement"].toString();
66 message += suggestion[u"message"].toString() + u"\n";
67
68 TextEdit textEdit;
69 textEdit.range = { .start: Position { .line: suggestion[u"lspBeginLine"].toInt(),
70 .character: suggestion[u"lspBeginCharacter"].toInt() },
71 .end: Position { .line: suggestion[u"lspEndLine"].toInt(),
72 .character: suggestion[u"lspEndCharacter"].toInt() } };
73 textEdit.newText = replacement.toUtf8();
74
75 TextDocumentEdit textDocEdit;
76 textDocEdit.textDocument = { params.textDocument, .version: version };
77 textDocEdit.edits.append(t: textEdit);
78
79 edits.append(t: textDocEdit);
80 }
81 message.chop(n: 1);
82 WorkspaceEdit edit;
83 edit.documentChanges = edits;
84
85 CodeAction action;
86 // VS Code and QtC ignore everything that is not a 'quickfix'.
87 action.kind = u"quickfix"_s.toUtf8();
88 action.edit = edit;
89 action.title = message.toUtf8();
90
91 responseData.append(t: action);
92 }
93
94 response.sendResponse(r: responseData);
95}
96
97void QmlLintSuggestions::registerHandlers(QLanguageServer *, QLanguageServerProtocol *protocol)
98{
99 protocol->registerCodeActionRequestHandler(handler: &codeActionHandler);
100}
101
102void QmlLintSuggestions::setupCapabilities(const QLspSpecification::InitializeParams &,
103 QLspSpecification::InitializeResult &serverInfo)
104{
105 serverInfo.capabilities.codeActionProvider = true;
106}
107
108QmlLintSuggestions::QmlLintSuggestions(QLanguageServer *server, QmlLsp::QQmlCodeModel *codeModel)
109 : m_server(server), m_codeModel(codeModel)
110{
111 QObject::connect(sender: m_codeModel, signal: &QmlLsp::QQmlCodeModel::updatedSnapshot, context: this,
112 slot: &QmlLintSuggestions::diagnose, type: Qt::DirectConnection);
113}
114
115static void advancePositionPastLocation_helper(const QString &fileContents, const QQmlJS::SourceLocation &location, Position &position) {
116 const int startOffset = location.offset;
117 const int length = location.length;
118 int i = startOffset;
119 int iEnd = i + length;
120 if (iEnd > int(fileContents.size()))
121 iEnd = fileContents.size();
122 while (i < iEnd) {
123 if (fileContents.at(i) == u'\n') {
124 ++position.line;
125 position.character = 0;
126 if (i + 1 < iEnd && fileContents.at(i) == u'\r')
127 ++i;
128 } else {
129 ++position.character;
130 }
131 ++i;
132 }
133};
134
135static Diagnostic createMissingBuildDirDiagnostic()
136{
137 Diagnostic diagnostic;
138 diagnostic.severity = DiagnosticSeverity::Warning;
139 Range &range = diagnostic.range;
140 Position &position = range.start;
141 position.line = 0;
142 position.character = 0;
143 Position &positionEnd = range.end;
144 positionEnd.line = 1;
145 diagnostic.message =
146 "qmlls couldn't find a build directory. Pass the \"--build-dir <buildDir>\" option to "
147 "qmlls, set the environment variable \"QMLLS_BUILD_DIRS\", or create a .qmlls.ini "
148 "configuration file with a \"buildDir\" value in your project's source folder to avoid "
149 "spurious warnings";
150 diagnostic.source = QByteArray("qmllint");
151 return diagnostic;
152}
153
154using AdvanceFunc = qxp::function_ref<void(const QQmlJS::SourceLocation &, Position &)>;
155static Diagnostic messageToDiagnostic_helper(AdvanceFunc advancePositionPastLocation,
156 std::optional<int> version, const Message &message)
157{
158 Diagnostic diagnostic;
159 diagnostic.severity = severityFromMsgType(t: message.type);
160 Range &range = diagnostic.range;
161 Position &position = range.start;
162
163 QQmlJS::SourceLocation srcLoc = message.loc;
164
165 if (srcLoc.isValid()) {
166 position.line = srcLoc.startLine - 1;
167 position.character = srcLoc.startColumn - 1;
168 range.end = position;
169 advancePositionPastLocation(message.loc, range.end);
170 }
171
172 if (message.fixSuggestion && !message.fixSuggestion->fixDescription().isEmpty()) {
173 diagnostic.message = u"%1: %2 [%3]"_s.arg(args: message.message, args: message.fixSuggestion->fixDescription(), args: message.id.toString())
174 .simplified()
175 .toUtf8();
176 } else {
177 diagnostic.message = u"%1 [%2]"_s.arg(args: message.message, args: message.id.toString()).toUtf8();
178 }
179
180 diagnostic.source = QByteArray("qmllint");
181
182 auto suggestion = message.fixSuggestion;
183 if (!suggestion.has_value())
184 return diagnostic;
185
186 // We need to interject the information about where the fix suggestions end
187 // here since we don't have access to the textDocument to calculate it later.
188 const QQmlJS::SourceLocation cut = suggestion->location();
189
190 const int line = cut.isValid() ? cut.startLine - 1 : 0;
191 const int column = cut.isValid() ? cut.startColumn - 1 : 0;
192
193 QJsonObject object;
194 object.insert(key: "lspBeginLine"_L1, value: line);
195 object.insert(key: "lspBeginCharacter"_L1, value: column);
196
197 Position end = { .line: line, .character: column };
198
199 if (srcLoc.isValid())
200 advancePositionPastLocation(cut, end);
201 object.insert(key: "lspEndLine"_L1, value: end.line);
202 object.insert(key: "lspEndCharacter"_L1, value: end.character);
203
204 object.insert(key: "message"_L1, value: suggestion->fixDescription());
205 object.insert(key: "replacement"_L1, value: suggestion->replacement());
206
207 QJsonArray fixedSuggestions;
208 fixedSuggestions.append(value: object);
209 QJsonObject data;
210 data[u"suggestions"] = fixedSuggestions;
211
212 Q_ASSERT(version.has_value());
213 data[u"version"] = version.value();
214
215 diagnostic.data = data;
216
217 return diagnostic;
218};
219
220static bool isSnapshotNew(std::optional<int> snapshotVersion, std::optional<int> processedVersion,
221 bool force)
222{
223 if (!snapshotVersion)
224 return false;
225 if (force)
226 return true;
227 if (!processedVersion || *snapshotVersion > *processedVersion)
228 return true;
229 return false;
230}
231
232using namespace std::chrono_literals;
233
234QmlLintSuggestions::VersionToDiagnose
235QmlLintSuggestions::chooseVersionToDiagnoseHelper(const QByteArray &url, bool force)
236{
237 const std::chrono::milliseconds maxInvalidTime = 400ms;
238 QmlLsp::OpenDocumentSnapshot snapshot = m_codeModel->snapshotByUrl(url);
239
240 LastLintUpdate &lastUpdate = m_lastUpdate[url];
241
242 // ignore updates when already processed
243 if (!force && lastUpdate.version && *lastUpdate.version == snapshot.docVersion) {
244 qCDebug(lspServerLog) << "skipped update of " << url << "unchanged valid doc";
245 return NoDocumentAvailable{};
246 }
247
248 // try out a valid version, if there is one
249 if (isSnapshotNew(snapshotVersion: snapshot.validDocVersion, processedVersion: lastUpdate.version, force))
250 return VersionedDocument{ .version: snapshot.validDocVersion, .item: snapshot.validDoc };
251
252 // try out an invalid version, if there is one
253 if (isSnapshotNew(snapshotVersion: snapshot.docVersion, processedVersion: lastUpdate.version, force)) {
254 if (auto since = lastUpdate.invalidUpdatesSince) {
255 // did we wait enough to get a valid document?
256 if (std::chrono::steady_clock::now() - *since > maxInvalidTime) {
257 return VersionedDocument{ .version: snapshot.docVersion, .item: snapshot.doc };
258 }
259 } else {
260 // first time hitting the invalid document:
261 lastUpdate.invalidUpdatesSince = std::chrono::steady_clock::now();
262 }
263
264 // wait some time for extra keystrokes before diagnose
265 return TryAgainLater{ .time: maxInvalidTime };
266 }
267 return NoDocumentAvailable{};
268}
269
270QmlLintSuggestions::VersionToDiagnose
271QmlLintSuggestions::chooseVersionToDiagnose(const QByteArray &url, bool force)
272{
273 QMutexLocker l(&m_mutex);
274 auto versionToDiagnose = chooseVersionToDiagnoseHelper(url, force);
275 if (auto versionedDocument = std::get_if<VersionedDocument>(ptr: &versionToDiagnose)) {
276 // update immediately, and do not keep track of sent version, thus in extreme cases sent
277 // updates could be out of sync
278 LastLintUpdate &lastUpdate = m_lastUpdate[url];
279 lastUpdate.version = versionedDocument->version;
280 lastUpdate.invalidUpdatesSince.reset();
281 }
282 return versionToDiagnose;
283}
284
285void QmlLintSuggestions::diagnose(const QByteArray &url)
286{
287 diagnoseImpl(url, force: false);
288}
289
290void QmlLintSuggestions::forceDiagnose(const QByteArray &url)
291{
292 diagnoseImpl(url, force: true);
293}
294
295void QmlLintSuggestions::diagnoseImpl(const QByteArray &url, bool force)
296{
297 auto versionedDocument = chooseVersionToDiagnose(url, force);
298
299 std::visit(visitor: qOverloadedVisitor{
300 [](NoDocumentAvailable) {},
301 [this, &url, &force](const TryAgainLater &tryAgainLater) {
302 QTimer::singleShot(interval: tryAgainLater.time, timerType: Qt::VeryCoarseTimer, receiver: this,
303 slot: [this, url, force]() { diagnoseImpl(url, force); });
304 },
305 [this, &url](const VersionedDocument &versionedDocument) {
306 diagnoseHelper(uri: url, document: versionedDocument);
307 },
308
309 },
310 variants&: versionedDocument);
311}
312
313void QmlLintSuggestions::diagnoseHelper(const QByteArray &url,
314 const VersionedDocument &versionedDocument)
315{
316 auto [version, doc] = versionedDocument;
317
318 PublishDiagnosticsParams diagnosticParams;
319 diagnosticParams.uri = url;
320 diagnosticParams.version = version;
321
322 qCDebug(lintLog) << "has doc, do real lint";
323 QStringList imports = m_codeModel->buildPathsForFileUrl(url);
324 const QString filename = doc.canonicalFilePath();
325 imports.append(other: m_codeModel->importPathsForFile(fileName: filename));
326 // add source directory as last import as fallback in case there is no qmldir in the build
327 // folder this mimics qmllint behaviors
328 imports.append(t: QFileInfo(filename).dir().absolutePath());
329 // add m_server->clientInfo().rootUri & co?
330 bool silent = true;
331 const QString fileContents = doc.field(name: Fields::code).value().toString();
332 const QStringList qmltypesFiles;
333 const QStringList resourceFiles = QQmlJSUtils::resourceFilesFromBuildFolders(buildFolders: imports);
334
335 QList<QQmlJS::LoggerCategory> categories = QQmlJSLogger::defaultCategories();
336
337 QQmlJSLinter linter(imports);
338
339 for (const QQmlJSLinter::Plugin &plugin : linter.plugins()) {
340 for (const QQmlJS::LoggerCategory &category : plugin.categories())
341 categories.append(t: category);
342 }
343
344 QQmlToolingSettings settings(QLatin1String("qmllint"));
345 if (settings.search(path: filename)) {
346 QQmlJS::LoggingUtils::updateLogLevels(categories, settings, parser: nullptr);
347 }
348
349 // TODO: pass the workspace folders to QQmlJSLinter
350 linter.lintFile(filename, fileContents: &fileContents, silent, json: nullptr, qmlImportPaths: imports, qmldirFiles: qmltypesFiles, resourceFiles,
351 categories);
352
353 // ### TODO: C++20 replace with bind_front
354 auto advancePositionPastLocation = [&fileContents](const QQmlJS::SourceLocation &location, Position &position)
355 {
356 advancePositionPastLocation_helper(fileContents, location, position);
357 };
358 auto messageToDiagnostic = [&advancePositionPastLocation,
359 versionedDocument](const Message &message) {
360 return messageToDiagnostic_helper(advancePositionPastLocation, version: versionedDocument.version,
361 message);
362 };
363
364 QList<Diagnostic> diagnostics;
365 doc.iterateErrors(
366 visitor: [&diagnostics, &advancePositionPastLocation](const DomItem &, const ErrorMessage &msg) {
367 Diagnostic diagnostic;
368 diagnostic.severity = severityFromMsgType(t: QtMsgType(int(msg.level)));
369 // do something with msg.errorGroups ?
370 auto &location = msg.location;
371 Range &range = diagnostic.range;
372 range.start.line = location.startLine - 1;
373 range.start.character = location.startColumn - 1;
374 range.end = range.start;
375 advancePositionPastLocation(location, range.end);
376 diagnostic.code = QByteArray(msg.errorId.data(), msg.errorId.size());
377 diagnostic.source = "domParsing";
378 diagnostic.message = msg.message.toUtf8();
379 diagnostics.append(t: diagnostic);
380 return true;
381 },
382 iterate: true);
383
384 if (const QQmlJSLogger *logger = linter.logger()) {
385 qsizetype nDiagnostics = diagnostics.size();
386 logger->iterateAllMessages(f: [&](const Message &message) {
387 if (!message.message.contains(s: u"Failed to import")) {
388 diagnostics.append(t: messageToDiagnostic(message));
389 return;
390 }
391
392 Message modified {message};
393 modified.message.append(
394 v: u" Did you build your project? If yes, did you set the "
395 u"\"QT_QML_GENERATE_QMLLS_INI\" CMake variable on your project to \"ON\"?");
396
397 diagnostics.append(t: messageToDiagnostic(modified));
398 });
399 if (diagnostics.size() != nDiagnostics && imports.size() == 1)
400 diagnostics.append(t: createMissingBuildDirDiagnostic());
401 }
402
403 diagnosticParams.diagnostics = diagnostics;
404
405 m_server->protocol()->notifyPublishDiagnostics(params: diagnosticParams);
406 qCDebug(lintLog) << "lint" << QString::fromUtf8(ba: url) << "found"
407 << diagnosticParams.diagnostics.size() << "issues"
408 << QTypedJson::toJsonValue(params: diagnosticParams);
409}
410
411} // namespace QmlLsp
412QT_END_NAMESPACE
413

source code of qtdeclarative/src/qmlls/qqmllintsuggestions.cpp