| 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 | QT_BEGIN_NAMESPACE |
| 21 | |
| 22 | Q_STATIC_LOGGING_CATEGORY(lintLog, "qt.languageserver.lint" ) |
| 23 | |
| 24 | using namespace QLspSpecification; |
| 25 | using namespace QQmlJS::Dom; |
| 26 | using namespace Qt::StringLiterals; |
| 27 | |
| 28 | namespace QmlLsp { |
| 29 | |
| 30 | static 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 | |
| 46 | static void codeActionHandler( |
| 47 | const QByteArray &, const CodeActionParams ¶ms, |
| 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 | |
| 97 | void QmlLintSuggestions::registerHandlers(QLanguageServer *, QLanguageServerProtocol *protocol) |
| 98 | { |
| 99 | protocol->registerCodeActionRequestHandler(handler: &codeActionHandler); |
| 100 | } |
| 101 | |
| 102 | void QmlLintSuggestions::setupCapabilities(const QLspSpecification::InitializeParams &, |
| 103 | QLspSpecification::InitializeResult &serverInfo) |
| 104 | { |
| 105 | serverInfo.capabilities.codeActionProvider = true; |
| 106 | } |
| 107 | |
| 108 | QmlLintSuggestions::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 | |
| 115 | static 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 | |
| 135 | static 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 | |
| 154 | using AdvanceFunc = qxp::function_ref<void(const QQmlJS::SourceLocation &, Position &)>; |
| 155 | static 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 | |
| 220 | static 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 | |
| 232 | using namespace std::chrono_literals; |
| 233 | |
| 234 | QmlLintSuggestions::VersionToDiagnose |
| 235 | QmlLintSuggestions::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 | |
| 270 | QmlLintSuggestions::VersionToDiagnose |
| 271 | QmlLintSuggestions::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 | |
| 285 | void QmlLintSuggestions::diagnose(const QByteArray &url) |
| 286 | { |
| 287 | diagnoseImpl(url, force: false); |
| 288 | } |
| 289 | |
| 290 | void QmlLintSuggestions::forceDiagnose(const QByteArray &url) |
| 291 | { |
| 292 | diagnoseImpl(url, force: true); |
| 293 | } |
| 294 | |
| 295 | void 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 | |
| 313 | void 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 |
| 412 | QT_END_NAMESPACE |
| 413 | |