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