1// Copyright (C) 2025 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
3
4#include "qqmljslintervisitor_p.h"
5
6QT_BEGIN_NAMESPACE
7
8using namespace Qt::StringLiterals;
9using namespace QQmlJS::AST;
10
11namespace QQmlJS {
12/*!
13 \internal
14 \class QQmlJS::LinterVisitor
15 Extends QQmlJSImportVisitor with extra warnings that are required for linting but unrelated to
16 QQmlJSImportVisitor actual task that is constructing QQmlJSScopes. One example of such warnings
17 are purely syntactic checks, or style-checks warnings that don't make sense during compilation.
18 */
19
20LinterVisitor::LinterVisitor(
21 const QQmlJSScope::Ptr &target, QQmlJSImporter *importer, QQmlJSLogger *logger,
22 const QString &implicitImportDirectory, const QStringList &qmldirFiles,
23 QQmlJS::Engine *engine)
24 : QQmlJSImportVisitor(target, importer, logger, implicitImportDirectory, qmldirFiles)
25 , m_engine(engine)
26{
27}
28
29void LinterVisitor::leaveEnvironment()
30{
31 const auto leaveEnv = qScopeGuard(f: [this] { QQmlJSImportVisitor::leaveEnvironment(); });
32
33 if (m_currentScope->scopeType() != QQmlSA::ScopeType::QMLScope)
34 return;
35
36 if (auto base = m_currentScope->baseType()) {
37 if (base->internalName() == u"QQmlComponent"_s) {
38 const auto nChildren = std::count_if(
39 first: m_currentScope->childScopesBegin(), last: m_currentScope->childScopesEnd(),
40 pred: [](const QQmlJSScope::ConstPtr &scope) {
41 return scope->scopeType() == QQmlSA::ScopeType::QMLScope;
42 });
43 if (nChildren != 1) {
44 m_logger->log(message: "Components must have exactly one child"_L1,
45 id: qmlComponentChildrenCount, srcLocation: m_currentScope->sourceLocation());
46 }
47 }
48 }
49}
50
51bool LinterVisitor::visit(StringLiteral *sl)
52{
53 QQmlJSImportVisitor::visit(sl);
54 const QString s = m_logger->code().mid(position: sl->literalToken.begin(), n: sl->literalToken.length);
55
56 if (s.contains(c: QLatin1Char('\r')) || s.contains(c: QLatin1Char('\n')) || s.contains(c: QChar(0x2028u))
57 || s.contains(c: QChar(0x2029u))) {
58 QString templateString;
59
60 bool escaped = false;
61 const QChar stringQuote = s[0];
62 for (qsizetype i = 1; i < s.size() - 1; i++) {
63 const QChar c = s[i];
64
65 if (c == u'\\') {
66 escaped = !escaped;
67 } else if (escaped) {
68 // If we encounter an escaped quote, unescape it since we use backticks here
69 if (c == stringQuote)
70 templateString.chop(n: 1);
71
72 escaped = false;
73 } else {
74 if (c == u'`')
75 templateString += u'\\';
76 if (c == u'$' && i + 1 < s.size() - 1 && s[i + 1] == u'{')
77 templateString += u'\\';
78 }
79
80 templateString += c;
81 }
82
83 QQmlJSFixSuggestion suggestion = { "Use a template literal instead."_L1, sl->literalToken,
84 u"`" % templateString % u"`" };
85 suggestion.setAutoApplicable();
86 m_logger->log(QStringLiteral("String contains unescaped line terminator which is "
87 "deprecated."),
88 id: qmlMultilineStrings, srcLocation: sl->literalToken, showContext: true, showFileName: true, suggestion);
89 }
90 return true;
91}
92
93bool LinterVisitor::preVisit(Node *n)
94{
95 m_ancestryIncludingCurrentNode.push_back(x: n);
96 return true;
97}
98
99void LinterVisitor::postVisit(Node *n)
100{
101 Q_ASSERT(m_ancestryIncludingCurrentNode.back() == n);
102 m_ancestryIncludingCurrentNode.pop_back();
103}
104
105Node *LinterVisitor::astParentOfVisitedNode() const
106{
107 if (m_ancestryIncludingCurrentNode.size() < 2)
108 return nullptr;
109 return m_ancestryIncludingCurrentNode[m_ancestryIncludingCurrentNode.size() - 2];
110}
111
112bool LinterVisitor::visit(CommaExpression *expression)
113{
114 QQmlJSImportVisitor::visit(expression);
115 if (!expression->left || !expression->right)
116 return true;
117
118 // don't warn about commas in "for" statements
119 if (cast<ForStatement *>(ast: astParentOfVisitedNode()))
120 return true;
121
122 m_logger->log(message: "Do not use comma expressions."_L1, id: qmlComma, srcLocation: expression->commaToken);
123 return true;
124}
125
126static void warnAboutLiteralConstructors(NewMemberExpression *expression, QQmlJSLogger *logger)
127{
128 static constexpr std::array literals{ "Boolean"_L1, "Function"_L1, "JSON"_L1,
129 "Math"_L1, "Number"_L1, "String"_L1 };
130
131 const IdentifierExpression *identifier = cast<IdentifierExpression *>(ast: expression->base);
132 if (!identifier)
133 return;
134
135 if (std::find(first: literals.cbegin(), last: literals.cend(), val: identifier->name) != literals.cend()) {
136 logger->log(message: "Do not use '%1' as a constructor."_L1.arg(args: identifier->name),
137 id: qmlLiteralConstructor, srcLocation: identifier->identifierToken);
138 }
139}
140
141bool LinterVisitor::visit(NewMemberExpression *expression)
142{
143 QQmlJSImportVisitor::visit(expression);
144 warnAboutLiteralConstructors(expression, logger: m_logger);
145 return true;
146}
147
148bool LinterVisitor::visit(VoidExpression *ast)
149{
150 QQmlJSImportVisitor::visit(ast);
151 m_logger->log(message: "Do not use void expressions."_L1, id: qmlVoid, srcLocation: ast->voidToken);
152 return true;
153}
154
155static SourceLocation confusingPluses(BinaryExpression *exp)
156{
157 Q_ASSERT(exp->op == QSOperator::Add);
158
159 SourceLocation location = exp->operatorToken;
160
161 // a++ + b
162 if (auto increment = cast<PostIncrementExpression *>(ast: exp->left))
163 location = combine(l1: increment->incrementToken, l2: location);
164 // a + +b
165 if (auto unary = cast<UnaryPlusExpression *>(ast: exp->right))
166 location = combine(l1: location, l2: unary->plusToken);
167 // a + ++b
168 if (auto increment = cast<PreIncrementExpression *>(ast: exp->right))
169 location = combine(l1: location, l2: increment->incrementToken);
170
171 if (location == exp->operatorToken)
172 return SourceLocation{};
173
174 return location;
175}
176
177static SourceLocation confusingMinuses(BinaryExpression *exp)
178{
179 Q_ASSERT(exp->op == QSOperator::Sub);
180
181 SourceLocation location = exp->operatorToken;
182
183 // a-- - b
184 if (auto decrement = cast<PostDecrementExpression *>(ast: exp->left))
185 location = combine(l1: decrement->decrementToken, l2: location);
186 // a - -b
187 if (auto unary = cast<UnaryMinusExpression *>(ast: exp->right))
188 location = combine(l1: location, l2: unary->minusToken);
189 // a - --b
190 if (auto decrement = cast<PreDecrementExpression *>(ast: exp->right))
191 location = combine(l1: location, l2: decrement->decrementToken);
192
193 if (location == exp->operatorToken)
194 return SourceLocation{};
195
196 return location;
197}
198
199bool LinterVisitor::visit(BinaryExpression *exp)
200{
201 QQmlJSImportVisitor::visit(exp);
202 switch (exp->op) {
203 case QSOperator::Add:
204 if (SourceLocation loc = confusingPluses(exp); loc.isValid())
205 m_logger->log(message: "Confusing pluses."_L1, id: qmlConfusingPluses, srcLocation: loc);
206 break;
207 case QSOperator::Sub:
208 if (SourceLocation loc = confusingMinuses(exp); loc.isValid())
209 m_logger->log(message: "Confusing minuses."_L1, id: qmlConfusingMinuses, srcLocation: loc);
210 break;
211 default:
212 break;
213 }
214
215 return true;
216}
217
218bool LinterVisitor::visit(QQmlJS::AST::UiImport *import)
219{
220 QQmlJSImportVisitor::visit(import);
221
222 const auto locAndName = [](const UiImport *i) {
223 if (!i->importUri)
224 return std::make_pair(x: i->fileNameToken, y: i->fileName.toString());
225
226 QQmlJS::SourceLocation l = i->importUri->firstSourceLocation();
227 if (i->importIdToken.isValid())
228 l = combine(l1: l, l2: i->importIdToken);
229 else if (i->version)
230 l = combine(l1: l, l2: i->version->minorToken);
231 else
232 l = combine(l1: l, l2: i->importUri->lastSourceLocation());
233
234 return std::make_pair(x&: l, y: i->importUri->toString());
235 };
236
237 SeenImport i(import);
238 if (const auto it = m_seenImports.constFind(value: i); it != m_seenImports.constEnd()) {
239 const auto locAndNameImport = locAndName(import);
240 const auto locAndNameSeen = locAndName(it->uiImport);
241 m_logger->log(message: "Duplicate import '%1'"_L1.arg(args: locAndNameImport.second),
242 id: qmlDuplicateImport, srcLocation: locAndNameImport.first);
243 m_logger->log(message: "Note: previous import '%1' here"_L1.arg(args: locAndNameSeen.second),
244 id: qmlDuplicateImport, srcLocation: locAndNameSeen.first, showContext: true, showFileName: true, suggestion: {}, overrideFileName: {},
245 customLineForDisabling: locAndName(import).first.startLine);
246 }
247
248 m_seenImports.insert(value: i);
249 return true;
250}
251
252void LinterVisitor::handleDuplicateEnums(UiEnumMemberList *members, QStringView key,
253 const QQmlJS::SourceLocation &location)
254{
255 m_logger->log(message: u"Enum key '%1' has already been declared"_s.arg(a: key), id: qmlDuplicateEnumEntries,
256 srcLocation: location);
257 for (const auto *member = members; member; member = member->next) {
258 if (member->member.toString() == key) {
259 m_logger->log(message: u"Note: previous declaration of '%1' here"_s.arg(a: key),
260 id: qmlDuplicateEnumEntries, srcLocation: member->memberToken);
261 return;
262 }
263 }
264}
265
266bool LinterVisitor::visit(QQmlJS::AST::UiEnumDeclaration *uied)
267{
268 QQmlJSImportVisitor::visit(uied);
269
270 if (m_currentScope->isInlineComponent()) {
271 m_logger->log(message: u"Enums declared inside of inline component are ignored."_s, id: qmlSyntax,
272 srcLocation: uied->firstSourceLocation());
273 } else if (m_currentScope->componentRootStatus() == QQmlJSScope::IsComponentRoot::No
274 && !m_currentScope->isFileRootComponent()) {
275 m_logger->log(message: u"Enum declared outside the root element. It won't be accessible."_s,
276 id: qmlNonRootEnums, srcLocation: uied->firstSourceLocation());
277 }
278
279 QHash<QStringView, const QQmlJS::AST::UiEnumMemberList *> seen;
280 for (const auto *member = uied->members; member; member = member->next) {
281 QStringView key = member->member;
282 if (!key.front().isUpper()) {
283 m_logger->log(message: u"Enum keys should start with an uppercase."_s, id: qmlSyntax,
284 srcLocation: member->memberToken);
285 }
286
287 if (seen.contains(key))
288 handleDuplicateEnums(members: uied->members, key, location: member->memberToken);
289 else
290 seen[member->member] = member;
291
292 if (uied->name == key) {
293 m_logger->log(message: "Enum entry should be named differently than the enum itself to avoid "
294 "confusion."_L1, id: qmlEnumEntryMatchesEnum, srcLocation: member->firstSourceLocation());
295 }
296 }
297
298 return true;
299}
300
301static bool allCodePathsReturnInsideCase(Node *statement)
302{
303 using namespace AST;
304 if (!statement)
305 return false;
306
307 switch (statement->kind) {
308 case Node::Kind_Block: {
309 return allCodePathsReturnInsideCase(statement: cast<Block *>(ast: statement)->statements);
310 }
311 case Node::Kind_BreakStatement:
312 return true;
313 case Node::Kind_CaseBlock: {
314 const CaseBlock *caseBlock = cast<CaseBlock *>(ast: statement);
315 if (caseBlock->defaultClause)
316 return allCodePathsReturnInsideCase(statement: caseBlock->defaultClause);
317 return allCodePathsReturnInsideCase(statement: caseBlock->clauses);
318 }
319 case Node::Kind_CaseClause:
320 return allCodePathsReturnInsideCase(statement: cast<CaseClause *>(ast: statement)->statements);
321 case Node::Kind_CaseClauses: {
322 for (CaseClauses *caseClauses = cast<CaseClauses *>(ast: statement); caseClauses;
323 caseClauses = caseClauses->next) {
324 if (!allCodePathsReturnInsideCase(statement: caseClauses->clause))
325 return false;
326 }
327 return true;
328 }
329 case Node::Kind_ContinueStatement:
330 // allCodePathsReturn() doesn't recurse into loops, so any encountered `continue` should
331 // belong to a loop outside the switch statement.
332 return true;
333 case Node::Kind_DefaultClause:
334 return allCodePathsReturnInsideCase(statement: cast<DefaultClause *>(ast: statement)->statements);
335 case Node::Kind_IfStatement: {
336 const auto *ifStatement = cast<IfStatement *>(ast: statement);
337 return allCodePathsReturnInsideCase(statement: ifStatement->ok)
338 && allCodePathsReturnInsideCase(statement: ifStatement->ko);
339 }
340 case Node::Kind_LabelledStatement:
341 return allCodePathsReturnInsideCase(statement: cast<LabelledStatement *>(ast: statement)->statement);
342 case Node::Kind_ReturnStatement:
343 return true;
344 case Node::Kind_StatementList: {
345 for (StatementList *list = cast<StatementList *>(ast: statement); list; list = list->next) {
346 if (allCodePathsReturnInsideCase(statement: list->statement))
347 return true;
348 }
349 return false;
350 }
351 case Node::Kind_SwitchStatement:
352 return allCodePathsReturnInsideCase(statement: cast<SwitchStatement *>(ast: statement)->block);
353 case Node::Kind_ThrowStatement:
354 return true;
355 case Node::Kind_TryStatement: {
356 auto *tryStatement = cast<TryStatement *>(ast: statement);
357 if (allCodePathsReturnInsideCase(statement: tryStatement->statement))
358 return true;
359 return allCodePathsReturnInsideCase(statement: tryStatement->finallyExpression->statement);
360 }
361 case Node::Kind_WithStatement:
362 return allCodePathsReturnInsideCase(statement: cast<WithStatement *>(ast: statement)->statement);
363 default:
364 break;
365 }
366 return false;
367}
368
369void LinterVisitor::checkCaseFallthrough(StatementList *statements, SourceLocation errorLoc,
370 SourceLocation nextLoc)
371{
372 if (!statements || !nextLoc.isValid())
373 return;
374
375 if (allCodePathsReturnInsideCase(statement: statements))
376 return;
377
378 quint32 afterLastStatement = 0;
379 for (StatementList *it = statements; it; it = it->next) {
380 if (!it->next) {
381 afterLastStatement = it->statement->lastSourceLocation().end();
382 }
383 }
384
385 const auto &comments = m_engine->comments();
386 auto it = std::find_if(first: comments.cbegin(), last: comments.cend(),
387 pred: [&](auto c) { return afterLastStatement < c.offset; });
388 auto end = std::find_if(first: it, last: comments.cend(),
389 pred: [&](auto c) { return c.offset >= nextLoc.offset; });
390
391 for (; it != end; ++it) {
392 const QString &commentText = m_engine->code().mid(position: it->offset, n: it->length);
393 if (commentText.contains(s: "fall through"_L1)
394 || commentText.contains(s: "fall-through"_L1)
395 || commentText.contains(s: "fallthrough"_L1)) {
396 return;
397 }
398 }
399
400 m_logger->log(message: "Unterminated non-empty case block"_L1, id: qmlUnterminatedCase, srcLocation: errorLoc);
401}
402
403bool LinterVisitor::visit(QQmlJS::AST::CaseBlock *block)
404{
405 QQmlJSImportVisitor::visit(ast: block);
406
407 std::vector<std::pair<SourceLocation, StatementList *>> clauses;
408 for (CaseClauses *it = block->clauses; it; it = it->next)
409 clauses.push_back(x: { it->clause->caseToken, it->clause->statements });
410 if (block->defaultClause)
411 clauses.push_back(x: { block->defaultClause->defaultToken, block->defaultClause->statements });
412 for (CaseClauses *it = block->moreClauses; it; it = it->next)
413 clauses.push_back(x: { it->clause->caseToken, it->clause->statements });
414
415 // check all but the last clause for fallthrough
416 for (size_t i = 0; i < clauses.size() - 1; ++i) {
417 const SourceLocation nextToken = clauses[i + 1].first;
418 checkCaseFallthrough(statements: clauses[i].second, errorLoc: clauses[i].first, nextLoc: nextToken);
419 }
420 return true;
421}
422
423/*!
424\internal
425
426This assumes that there is no custom coercion enabled via \c Symbol.toPrimitive or similar.
427*/
428static bool isUselessExpressionStatement(ExpressionNode *ast)
429{
430 switch (ast->kind) {
431 case Node::Kind_CallExpression:
432 case Node::Kind_DeleteExpression:
433 case Node::Kind_NewExpression:
434 case Node::Kind_PreDecrementExpression:
435 case Node::Kind_PreIncrementExpression:
436 case Node::Kind_PostDecrementExpression:
437 case Node::Kind_PostIncrementExpression:
438 case Node::Kind_YieldExpression:
439 case Node::Kind_FunctionExpression:
440 return false;
441 default:
442 break;
443 };
444 BinaryExpression *binary = cast<BinaryExpression *>(ast);
445 if (!binary)
446 return false;
447
448 switch (binary->op) {
449 case QSOperator::InplaceAnd:
450 case QSOperator::Assign:
451 case QSOperator::InplaceSub:
452 case QSOperator::InplaceDiv:
453 case QSOperator::InplaceExp:
454 case QSOperator::InplaceAdd:
455 case QSOperator::InplaceLeftShift:
456 case QSOperator::InplaceMod:
457 case QSOperator::InplaceMul:
458 case QSOperator::InplaceOr:
459 case QSOperator::InplaceRightShift:
460 case QSOperator::InplaceURightShift:
461 case QSOperator::InplaceXor:
462 return false;
463 default:
464 return true;
465 }
466 Q_UNREACHABLE_RETURN(true);
467}
468
469static bool canHaveUselessExpressionStatement(Node *parent)
470{
471 return parent->kind != Node::Kind_UiScriptBinding && parent->kind != Node::Kind_UiPublicMember;
472}
473
474bool LinterVisitor::visit(ExpressionStatement *ast)
475{
476 QQmlJSImportVisitor::visit(ast);
477
478 if (canHaveUselessExpressionStatement(parent: astParentOfVisitedNode())
479 && isUselessExpressionStatement(ast: ast->expression)) {
480 m_logger->log(message: "Expression statement has no obvious effect."_L1,
481 id: qmlConfusingExpressionStatement,
482 srcLocation: combine(l1: ast->firstSourceLocation(), l2: ast->lastSourceLocation()));
483 }
484
485 return true;
486}
487
488QQmlJSImportVisitor::BindingExpressionParseResult LinterVisitor::parseBindingExpression(
489 const QString &name, const QQmlJS::AST::Statement *statement,
490 const QQmlJS::AST::UiPublicMember *associatedPropertyDefinition)
491{
492 if (statement && statement->kind == (int)AST::Node::Kind::Kind_Block) {
493 const auto *block = static_cast<const AST::Block *>(statement);
494 if (!block->statements && associatedPropertyDefinition) {
495 m_logger->log(message: "Unintentional empty block, use ({}) for empty object literal"_L1,
496 id: qmlUnintentionalEmptyBlock,
497 srcLocation: combine(l1: block->lbraceToken, l2: block->rbraceToken));
498 }
499 }
500
501 return QQmlJSImportVisitor::parseBindingExpression(name, statement, associatedPropertyDefinition);
502}
503
504void LinterVisitor::handleLiteralBinding(const QQmlJSMetaPropertyBinding &binding,
505 const UiPublicMember *associatedPropertyDefinition)
506{
507 if (!m_currentScope->hasOwnProperty(name: binding.propertyName()))
508 return;
509
510 if (!associatedPropertyDefinition->isReadonly())
511 return;
512
513 const auto &prop = m_currentScope->property(name: binding.propertyName());
514 const auto log = [&](const QString &preferredType) {
515 m_logger->log(message: "Prefer more specific type %1 over var"_L1.arg(args: preferredType),
516 id: qmlPreferNonVarProperties, srcLocation: prop.sourceLocation());
517 };
518
519 if (prop.typeName() != "QVariant"_L1)
520 return;
521
522 switch (binding.bindingType()) {
523 case QQmlSA::BindingType::BoolLiteral: {
524 log("bool"_L1);
525 break;
526 }
527 case QQmlSA::BindingType::NumberLiteral: {
528 double v = binding.numberValue();
529 auto loc = binding.sourceLocation();
530 QStringView literal = QStringView(m_engine->code()).mid(pos: loc.offset, n: loc.length);
531 if (literal.contains(c: u'.') || double(int(v)) != v)
532 log("real or double"_L1);
533 else
534 log("int"_L1);
535 break;
536 }
537 case QQmlSA::BindingType::StringLiteral: {
538 log("string"_L1);
539 break;
540 }
541 default: {
542 break;
543 }
544 }
545}
546
547} // namespace QQmlJS
548
549QT_END_NAMESPACE
550

source code of qtdeclarative/src/qmlcompiler/qqmljslintervisitor.cpp