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 <QtCore/qlibraryinfo.h> |
10 | #include <QtCore/qtimer.h> |
11 | #include <QtCore/qdebug.h> |
12 | |
13 | using namespace QLspSpecification; |
14 | using namespace QQmlJS::Dom; |
15 | using namespace Qt::StringLiterals; |
16 | |
17 | Q_LOGGING_CATEGORY(lintLog, "qt.languageserver.lint" ) |
18 | |
19 | QT_BEGIN_NAMESPACE |
20 | namespace QmlLsp { |
21 | |
22 | static DiagnosticSeverity severityFromMsgType(QtMsgType t) |
23 | { |
24 | switch (t) { |
25 | case QtDebugMsg: |
26 | return DiagnosticSeverity::Hint; |
27 | case QtInfoMsg: |
28 | return DiagnosticSeverity::Information; |
29 | case QtWarningMsg: |
30 | return DiagnosticSeverity::Warning; |
31 | case QtCriticalMsg: |
32 | case QtFatalMsg: |
33 | break; |
34 | } |
35 | return DiagnosticSeverity::Error; |
36 | } |
37 | |
38 | static void codeActionHandler( |
39 | const QByteArray &, const CodeActionParams ¶ms, |
40 | LSPPartialResponse<std::variant<QList<std::variant<Command, CodeAction>>, std::nullptr_t>, |
41 | QList<std::variant<Command, CodeAction>>> &&response) |
42 | { |
43 | QList<std::variant<Command, CodeAction>> responseData; |
44 | |
45 | for (const Diagnostic &diagnostic : params.context.diagnostics) { |
46 | if (!diagnostic.data.has_value()) |
47 | continue; |
48 | |
49 | const auto &data = diagnostic.data.value(); |
50 | |
51 | int version = data[u"version" ].toInt(); |
52 | QJsonArray suggestions = data[u"suggestions" ].toArray(); |
53 | |
54 | QList<TextDocumentEdit> edits; |
55 | QString message; |
56 | for (const QJsonValue &suggestion : suggestions) { |
57 | QString replacement = suggestion[u"replacement" ].toString(); |
58 | message += suggestion[u"message" ].toString() + u"\n" ; |
59 | |
60 | TextEdit textEdit; |
61 | textEdit.range = { .start: Position { .line: suggestion[u"lspBeginLine" ].toInt(), |
62 | .character: suggestion[u"lspBeginCharacter" ].toInt() }, |
63 | .end: Position { .line: suggestion[u"lspEndLine" ].toInt(), |
64 | .character: suggestion[u"lspEndCharacter" ].toInt() } }; |
65 | textEdit.newText = replacement.toUtf8(); |
66 | |
67 | TextDocumentEdit textDocEdit; |
68 | textDocEdit.textDocument = { params.textDocument, .version: version }; |
69 | textDocEdit.edits.append(t: textEdit); |
70 | |
71 | edits.append(t: textDocEdit); |
72 | } |
73 | message.chop(n: 1); |
74 | WorkspaceEdit edit; |
75 | edit.documentChanges = edits; |
76 | |
77 | CodeAction action; |
78 | // VS Code and QtC ignore everything that is not a 'quickfix'. |
79 | action.kind = u"quickfix"_s .toUtf8(); |
80 | action.edit = edit; |
81 | action.title = message.toUtf8(); |
82 | |
83 | responseData.append(t: action); |
84 | } |
85 | |
86 | response.sendResponse(r: responseData); |
87 | } |
88 | |
89 | void QmlLintSuggestions::registerHandlers(QLanguageServer *, QLanguageServerProtocol *protocol) |
90 | { |
91 | protocol->registerCodeActionRequestHandler(handler: &codeActionHandler); |
92 | } |
93 | |
94 | void QmlLintSuggestions::setupCapabilities(const QLspSpecification::InitializeParams &, |
95 | QLspSpecification::InitializeResult &serverInfo) |
96 | { |
97 | serverInfo.capabilities.codeActionProvider = true; |
98 | } |
99 | |
100 | QmlLintSuggestions::QmlLintSuggestions(QLanguageServer *server, QmlLsp::QQmlCodeModel *codeModel) |
101 | : m_server(server), m_codeModel(codeModel) |
102 | { |
103 | QObject::connect(sender: m_codeModel, signal: &QmlLsp::QQmlCodeModel::updatedSnapshot, context: this, |
104 | slot: &QmlLintSuggestions::diagnose, type: Qt::DirectConnection); |
105 | } |
106 | |
107 | void QmlLintSuggestions::diagnose(const QByteArray &url) |
108 | { |
109 | const int maxInvalidMsec = 4000; |
110 | qCDebug(lintLog) << "diagnose start" ; |
111 | QmlLsp::OpenDocumentSnapshot snapshot = m_codeModel->snapshotByUrl(url); |
112 | QList<Diagnostic> diagnostics; |
113 | std::optional<int> version; |
114 | DomItem doc; |
115 | { |
116 | QMutexLocker l(&m_mutex); |
117 | LastLintUpdate &lastUpdate = m_lastUpdate[url]; |
118 | if (lastUpdate.version && *lastUpdate.version == version) { |
119 | qCDebug(lspServerLog) << "skipped update of " << url << "unchanged valid doc" ; |
120 | return; |
121 | } |
122 | if (snapshot.validDocVersion |
123 | && (!lastUpdate.version || *snapshot.validDocVersion > *lastUpdate.version)) { |
124 | doc = snapshot.validDoc; |
125 | version = snapshot.validDocVersion; |
126 | } else if (snapshot.docVersion |
127 | && (!lastUpdate.version || *snapshot.docVersion > *lastUpdate.version)) { |
128 | if (!lastUpdate.version || !snapshot.validDocVersion |
129 | || (lastUpdate.invalidUpdatesSince |
130 | && lastUpdate.invalidUpdatesSince->msecsTo(QDateTime::currentDateTime()) |
131 | > maxInvalidMsec)) { |
132 | doc = snapshot.doc; |
133 | version = snapshot.docVersion; |
134 | } else if (!lastUpdate.invalidUpdatesSince) { |
135 | lastUpdate.invalidUpdatesSince = QDateTime::currentDateTime(); |
136 | QTimer::singleShot(interval: maxInvalidMsec, timerType: Qt::VeryCoarseTimer, receiver: this, |
137 | slot: [this, url]() { diagnose(url); }); |
138 | } |
139 | } |
140 | if (doc) { |
141 | // update immediately, and do not keep track of sent version, thus in extreme cases sent |
142 | // updates could be out of sync |
143 | lastUpdate.version = version; |
144 | lastUpdate.invalidUpdatesSince.reset(); |
145 | } |
146 | } |
147 | QString fileContents; |
148 | if (doc) { |
149 | qCDebug(lintLog) << "has doc, do real lint" ; |
150 | QStringList imports = m_codeModel->buildPathsForFileUrl(url); |
151 | imports.append(t: QLibraryInfo::path(p: QLibraryInfo::QmlImportsPath)); |
152 | // add m_server->clientInfo().rootUri & co? |
153 | bool silent = true; |
154 | QString filename = doc.canonicalFilePath(); |
155 | fileContents = doc.field(name: Fields::code).value().toString(); |
156 | QStringList qmltypesFiles; |
157 | QStringList resourceFiles; |
158 | QList<QQmlJS::LoggerCategory> categories; |
159 | |
160 | QQmlJSLinter linter(imports); |
161 | |
162 | linter.lintFile(filename, fileContents: &fileContents, silent, json: nullptr, qmlImportPaths: imports, qmldirFiles: qmltypesFiles, |
163 | resourceFiles, categories); |
164 | auto addLength = [&fileContents](Position &position, int startOffset, int length) { |
165 | int i = startOffset; |
166 | int iEnd = i + length; |
167 | if (iEnd > int(fileContents.size())) |
168 | iEnd = fileContents.size(); |
169 | while (i < iEnd) { |
170 | if (fileContents.at(i) == u'\n') { |
171 | ++position.line; |
172 | position.character = 0; |
173 | if (i + 1 < iEnd && fileContents.at(i) == u'\r') |
174 | ++i; |
175 | } else { |
176 | ++position.character; |
177 | } |
178 | ++i; |
179 | } |
180 | }; |
181 | |
182 | auto messageToDiagnostic = [&addLength, &version](const Message &message) { |
183 | Diagnostic diagnostic; |
184 | diagnostic.severity = severityFromMsgType(t: message.type); |
185 | Range &range = diagnostic.range; |
186 | Position &position = range.start; |
187 | |
188 | QQmlJS::SourceLocation srcLoc = message.loc; |
189 | |
190 | position.line = srcLoc.isValid() ? srcLoc.startLine - 1 : 0; |
191 | position.character = srcLoc.isValid() ? srcLoc.startColumn - 1 : 0; |
192 | range.end = position; |
193 | addLength(range.end, srcLoc.isValid() ? message.loc.offset : 0, srcLoc.isValid() ? message.loc.length : 0); |
194 | diagnostic.message = message.message.toUtf8(); |
195 | diagnostic.source = QByteArray("qmllint" ); |
196 | |
197 | auto suggestion = message.fixSuggestion; |
198 | if (suggestion.has_value()) { |
199 | // We need to interject the information about where the fix suggestions end |
200 | // here since we don't have access to the textDocument to calculate it later. |
201 | QJsonArray fixedSuggestions; |
202 | const QQmlJS::SourceLocation cut = suggestion->location(); |
203 | |
204 | const int line = cut.isValid() ? cut.startLine - 1 : 0; |
205 | const int column = cut.isValid() ? cut.startColumn - 1 : 0; |
206 | |
207 | QJsonObject object; |
208 | object.insert(key: "lspBeginLine"_L1 , value: line); |
209 | object.insert(key: "lspBeginCharacter"_L1 , value: column); |
210 | |
211 | Position end = { .line: line, .character: column }; |
212 | |
213 | addLength(end, srcLoc.isValid() ? cut.offset : 0, |
214 | srcLoc.isValid() ? cut.length : 0); |
215 | object.insert(key: "lspEndLine"_L1 , value: end.line); |
216 | object.insert(key: "lspEndCharacter"_L1 , value: end.character); |
217 | |
218 | object.insert(key: "message"_L1 , value: suggestion->fixDescription()); |
219 | object.insert(key: "replacement"_L1 , value: suggestion->replacement()); |
220 | |
221 | fixedSuggestions << object; |
222 | QJsonObject data; |
223 | data[u"suggestions" ] = fixedSuggestions; |
224 | |
225 | Q_ASSERT(version.has_value()); |
226 | data[u"version" ] = version.value(); |
227 | |
228 | diagnostic.data = data; |
229 | } |
230 | return diagnostic; |
231 | }; |
232 | doc.iterateErrors( |
233 | visitor: [&diagnostics, &addLength](DomItem, ErrorMessage msg) { |
234 | Diagnostic diagnostic; |
235 | diagnostic.severity = severityFromMsgType(t: QtMsgType(int(msg.level))); |
236 | // do something with msg.errorGroups ? |
237 | auto &location = msg.location; |
238 | Range &range = diagnostic.range; |
239 | range.start.line = location.startLine - 1; |
240 | range.start.character = location.startColumn - 1; |
241 | range.end = range.start; |
242 | addLength(range.end, location.offset, location.length); |
243 | diagnostic.code = QByteArray(msg.errorId.data(), msg.errorId.size()); |
244 | diagnostic.source = "domParsing" ; |
245 | diagnostic.message = msg.message.toUtf8(); |
246 | diagnostics.append(t: diagnostic); |
247 | return true; |
248 | }, |
249 | iterate: true); |
250 | |
251 | if (const QQmlJSLogger *logger = linter.logger()) { |
252 | qsizetype nDiagnostics = diagnostics.size(); |
253 | for (const auto &messages : { logger->infos(), logger->warnings(), logger->errors() }) { |
254 | for (const Message &message : messages) { |
255 | diagnostics.append(t: messageToDiagnostic(message)); |
256 | } |
257 | } |
258 | if (diagnostics.size() != nDiagnostics && imports.size() == 1) { |
259 | Diagnostic diagnostic; |
260 | diagnostic.severity = DiagnosticSeverity::Warning; |
261 | Range &range = diagnostic.range; |
262 | Position &position = range.start; |
263 | position.line = 0; |
264 | position.character = 0; |
265 | Position &positionEnd = range.end; |
266 | positionEnd.line = 1; |
267 | diagnostic.message = |
268 | "qmlls could not find a build directory, without a build directory " |
269 | "containing a current build there could be spurious warnings, you might " |
270 | "want to pass the --build-dir <buildDir> option to qmlls, or set the " |
271 | "environment variable QMLLS_BUILD_DIRS." ; |
272 | diagnostic.source = QByteArray("qmllint" ); |
273 | diagnostics.append(t: diagnostic); |
274 | } |
275 | } |
276 | } |
277 | PublishDiagnosticsParams diagnosticParams; |
278 | diagnosticParams.uri = url; |
279 | diagnosticParams.diagnostics = diagnostics; |
280 | diagnosticParams.version = version; |
281 | |
282 | m_server->protocol()->notifyPublishDiagnostics(params: diagnosticParams); |
283 | qCDebug(lintLog) << "lint" << QString::fromUtf8(ba: url) << "found" |
284 | << diagnosticParams.diagnostics.size() << "issues" |
285 | << QTypedJson::toJsonValue(params: diagnosticParams); |
286 | } |
287 | |
288 | } // namespace QmlLsp |
289 | QT_END_NAMESPACE |
290 | |