1 | // Copyright (C) 2016 Klaralvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Sergio Martins <sergio.martins@kdab.com> |
2 | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 |
3 | |
4 | #include <QtQmlToolingSettings/private/qqmltoolingsettings_p.h> |
5 | |
6 | #include <QtQmlCompiler/private/qqmljsresourcefilemapper_p.h> |
7 | #include <QtQmlCompiler/private/qqmljscompiler_p.h> |
8 | #include <QtQmlCompiler/private/qqmljslinter_p.h> |
9 | #include <QtQmlCompiler/private/qqmljsloggingutils_p.h> |
10 | |
11 | #include <QtCore/qdebug.h> |
12 | #include <QtCore/qfile.h> |
13 | #include <QtCore/qfileinfo.h> |
14 | #include <QtCore/qcoreapplication.h> |
15 | #include <QtCore/qdiriterator.h> |
16 | #include <QtCore/qjsonobject.h> |
17 | #include <QtCore/qjsonarray.h> |
18 | #include <QtCore/qjsondocument.h> |
19 | #include <QtCore/qscopeguard.h> |
20 | |
21 | #if QT_CONFIG(commandlineparser) |
22 | #include <QtCore/qcommandlineparser.h> |
23 | #endif |
24 | |
25 | #include <QtCore/qlibraryinfo.h> |
26 | |
27 | #include <cstdio> |
28 | |
29 | using namespace Qt::StringLiterals; |
30 | |
31 | constexpr int JSON_LOGGING_FORMAT_REVISION = 3; |
32 | |
33 | int main(int argv, char *argc[]) |
34 | { |
35 | QHashSeed::setDeterministicGlobalSeed(); |
36 | QList<QQmlJS::LoggerCategory> categories; |
37 | |
38 | QCoreApplication app(argv, argc); |
39 | QCoreApplication::setApplicationName("qmllint" ); |
40 | QCoreApplication::setApplicationVersion(QT_VERSION_STR); |
41 | QCommandLineParser parser; |
42 | QQmlToolingSettings settings(QLatin1String("qmllint" )); |
43 | parser.setApplicationDescription(QLatin1String(R"(QML syntax verifier and analyzer |
44 | |
45 | All warnings can be set to three levels: |
46 | disable - Fully disables the warning. |
47 | info - Displays the warning but does not influence the return code. |
48 | warning - Displays the warning and leads to a non-zero exit code if encountered. |
49 | )" )); |
50 | |
51 | parser.addHelpOption(); |
52 | parser.addVersionOption(); |
53 | |
54 | QCommandLineOption silentOption(QStringList() << "s" << "silent" , |
55 | QLatin1String("Don't output syntax errors" )); |
56 | parser.addOption(commandLineOption: silentOption); |
57 | |
58 | QCommandLineOption jsonOption(QStringList() << "json" , |
59 | QLatin1String("Write output as JSON to file (or use the special " |
60 | "filename '-' to write to stdout)" ), |
61 | QLatin1String("file" ), QString()); |
62 | parser.addOption(commandLineOption: jsonOption); |
63 | |
64 | QCommandLineOption writeDefaultsOption( |
65 | QStringList() << "write-defaults" , |
66 | QLatin1String("Writes defaults settings to .qmllint.ini and exits (Warning: This " |
67 | "will overwrite any existing settings and comments!)" )); |
68 | parser.addOption(commandLineOption: writeDefaultsOption); |
69 | |
70 | QCommandLineOption ignoreSettings(QStringList() << "ignore-settings" , |
71 | QLatin1String("Ignores all settings files and only takes " |
72 | "command line options into consideration" )); |
73 | parser.addOption(commandLineOption: ignoreSettings); |
74 | |
75 | QCommandLineOption moduleOption({ QStringLiteral("M" ), QStringLiteral("module" ) }, |
76 | QStringLiteral("Lint modules instead of files" )); |
77 | parser.addOption(commandLineOption: moduleOption); |
78 | |
79 | QCommandLineOption resourceOption( |
80 | { QStringLiteral("resource" ) }, |
81 | QStringLiteral("Look for related files in the given resource file" ), |
82 | QStringLiteral("resource" )); |
83 | parser.addOption(commandLineOption: resourceOption); |
84 | const QString &resourceSetting = QLatin1String("ResourcePath" ); |
85 | settings.addOption(name: resourceSetting); |
86 | |
87 | QCommandLineOption qmlImportPathsOption( |
88 | QStringList() << "I" |
89 | << "qmldirs" , |
90 | QLatin1String("Look for QML modules in specified directory" ), |
91 | QLatin1String("directory" )); |
92 | parser.addOption(commandLineOption: qmlImportPathsOption); |
93 | const QString qmlImportPathsSetting = QLatin1String("AdditionalQmlImportPaths" ); |
94 | settings.addOption(name: qmlImportPathsSetting); |
95 | |
96 | QCommandLineOption qmlImportNoDefault( |
97 | QStringList() << "bare" , |
98 | QLatin1String("Do not include default import directories or the current directory. " |
99 | "This may be used to run qmllint on a project using a different Qt version." )); |
100 | parser.addOption(commandLineOption: qmlImportNoDefault); |
101 | const QString qmlImportNoDefaultSetting = QLatin1String("DisableDefaultImports" ); |
102 | settings.addOption(name: qmlImportNoDefaultSetting, defaultValue: false); |
103 | |
104 | QCommandLineOption qmldirFilesOption( |
105 | QStringList() << "i" |
106 | << "qmltypes" , |
107 | QLatin1String("Import the specified qmldir files. By default, the qmldir file found " |
108 | "in the current directory is used if present. If no qmldir file is found," |
109 | "but qmltypes files are, those are imported instead. When this option is " |
110 | "set, you have to explicitly add the qmldir or any qmltypes files in the " |
111 | "current directory if you want it to be used. Importing qmltypes files " |
112 | "without their corresponding qmldir file is inadvisable." ), |
113 | QLatin1String("qmldirs" )); |
114 | parser.addOption(commandLineOption: qmldirFilesOption); |
115 | const QString qmldirFilesSetting = QLatin1String("OverwriteImportTypes" ); |
116 | settings.addOption(name: qmldirFilesSetting); |
117 | |
118 | QCommandLineOption absolutePath( |
119 | QStringList() << "absolute-path" , |
120 | QLatin1String("Use absolute paths for logging instead of relative ones." )); |
121 | absolutePath.setFlags(QCommandLineOption::HiddenFromHelp); |
122 | parser.addOption(commandLineOption: absolutePath); |
123 | |
124 | QCommandLineOption fixFile(QStringList() << "f" |
125 | << "fix" , |
126 | QLatin1String("Automatically apply fix suggestions" )); |
127 | parser.addOption(commandLineOption: fixFile); |
128 | |
129 | QCommandLineOption dryRun(QStringList() << "dry-run" , |
130 | QLatin1String("Only print out the contents of the file after fix " |
131 | "suggestions without applying them" )); |
132 | parser.addOption(commandLineOption: dryRun); |
133 | |
134 | QCommandLineOption listPluginsOption(QStringList() << "list-plugins" , |
135 | QLatin1String("List all available plugins" )); |
136 | parser.addOption(commandLineOption: listPluginsOption); |
137 | |
138 | QCommandLineOption pluginsDisable( |
139 | QStringList() << "D" |
140 | << "disable-plugins" , |
141 | QLatin1String("List of qmllint plugins to disable (all to disable all plugins)" ), |
142 | QLatin1String("plugins" )); |
143 | parser.addOption(commandLineOption: pluginsDisable); |
144 | const QString pluginsDisableSetting = QLatin1String("DisablePlugins" ); |
145 | settings.addOption(name: pluginsDisableSetting); |
146 | |
147 | QCommandLineOption pluginPathsOption( |
148 | QStringList() << "P" |
149 | << "plugin-paths" , |
150 | QLatin1String("Look for qmllint plugins in specified directory" ), |
151 | QLatin1String("directory" )); |
152 | parser.addOption(commandLineOption: pluginPathsOption); |
153 | |
154 | auto levelToString = [](const QQmlJS::LoggerCategory &category) -> QString { |
155 | Q_ASSERT(category.isIgnored() || category.level() != QtCriticalMsg); |
156 | if (category.isIgnored()) |
157 | return QStringLiteral("disable" ); |
158 | |
159 | switch (category.level()) { |
160 | case QtInfoMsg: |
161 | return QStringLiteral("info" ); |
162 | case QtWarningMsg: |
163 | return QStringLiteral("warning" ); |
164 | default: |
165 | Q_UNREACHABLE(); |
166 | break; |
167 | } |
168 | }; |
169 | |
170 | auto addCategory = [&](const QQmlJS::LoggerCategory &category) { |
171 | categories.push_back(t: category); |
172 | if (category.isDefault()) |
173 | return; |
174 | QCommandLineOption option( |
175 | category.id().name().toString(), |
176 | category.description() |
177 | + QStringLiteral(" (default: %1)" ).arg(a: levelToString(category)), |
178 | QStringLiteral("level" ), levelToString(category)); |
179 | if (category.isIgnored()) |
180 | option.setFlags(QCommandLineOption::HiddenFromHelp); |
181 | parser.addOption(commandLineOption: option); |
182 | settings.addOption(QStringLiteral("Warnings/" ) + category.settingsName(), |
183 | defaultValue: levelToString(category)); |
184 | }; |
185 | |
186 | for (const auto &category : QQmlJSLogger::defaultCategories()) { |
187 | addCategory(category); |
188 | } |
189 | |
190 | parser.addPositionalArgument(name: QLatin1String("files" ), |
191 | description: QLatin1String("list of qml or js files to verify" )); |
192 | if (!parser.parse(arguments: app.arguments())) { |
193 | if (parser.unknownOptionNames().isEmpty()) { |
194 | qWarning().noquote() << parser.errorText(); |
195 | return 1; |
196 | } |
197 | } |
198 | |
199 | // Since we can't use QCommandLineParser::process(), we need to handle version and help manually |
200 | if (parser.isSet(name: "version" )) |
201 | parser.showVersion(); |
202 | |
203 | if (parser.isSet(name: "help" ) || parser.isSet(name: "help-all" )) |
204 | parser.showHelp(exitCode: 0); |
205 | |
206 | if (parser.isSet(option: writeDefaultsOption)) { |
207 | return settings.writeDefaults() ? 0 : 1; |
208 | } |
209 | |
210 | auto updateLogLevels = [&]() { |
211 | for (auto &category : categories) { |
212 | if (category.isDefault()) |
213 | continue; |
214 | |
215 | const QString &key = category.id().name().toString(); |
216 | const QString &settingsName = QStringLiteral("Warnings/" ) + category.settingsName(); |
217 | if (parser.isSet(name: key) || settings.isSet(name: settingsName)) { |
218 | const QString value = parser.isSet(name: key) ? parser.value(name: key) |
219 | : settings.value(name: settingsName).toString(); |
220 | |
221 | // Do not try to set the levels if it's due to a default config option. |
222 | // This way we can tell which options have actually been overwritten by the user. |
223 | if (levelToString(category) == value && !parser.isSet(name: key)) |
224 | continue; |
225 | |
226 | if (value == "disable"_L1 ) { |
227 | category.setLevel(QtCriticalMsg); |
228 | category.setIgnored(true); |
229 | } else if (value == "info"_L1 ) { |
230 | category.setLevel(QtInfoMsg); |
231 | category.setIgnored(false); |
232 | } else if (value == "warning"_L1 ) { |
233 | category.setLevel(QtWarningMsg); |
234 | category.setIgnored(false); |
235 | } else { |
236 | qWarning() << "Invalid logging level" << value << "provided for" |
237 | << category.id().name().toString() |
238 | << "(allowed are: disable, info, warning)" ; |
239 | parser.showHelp(exitCode: -1); |
240 | } |
241 | } |
242 | } |
243 | }; |
244 | |
245 | bool silent = parser.isSet(option: silentOption); |
246 | bool useAbsolutePath = parser.isSet(option: absolutePath); |
247 | bool useJson = parser.isSet(option: jsonOption); |
248 | |
249 | // use host qml import path as a sane default if not explicitly disabled |
250 | QStringList defaultImportPaths = { QDir::currentPath() }; |
251 | |
252 | if (parser.isSet(option: resourceOption)) { |
253 | defaultImportPaths.append(t: QLatin1String(":/qt-project.org/imports" )); |
254 | defaultImportPaths.append(t: QLatin1String(":/qt/qml" )); |
255 | }; |
256 | |
257 | defaultImportPaths.append(t: QLibraryInfo::path(p: QLibraryInfo::QmlImportsPath)); |
258 | |
259 | QStringList qmlImportPaths = |
260 | parser.isSet(option: qmlImportNoDefault) ? QStringList {} : defaultImportPaths; |
261 | |
262 | QStringList defaultQmldirFiles; |
263 | if (parser.isSet(option: qmldirFilesOption)) { |
264 | defaultQmldirFiles = parser.values(option: qmldirFilesOption); |
265 | } else { |
266 | // If nothing given explicitly, use the qmldir file from the current directory. |
267 | QFileInfo qmldirFile(QStringLiteral("qmldir" )); |
268 | if (qmldirFile.isFile()) { |
269 | defaultQmldirFiles.append(t: qmldirFile.absoluteFilePath()); |
270 | } else { |
271 | // If no qmldir file is found, use the qmltypes files |
272 | // from the current directory for backwards compatibility. |
273 | QDirIterator it("." , {"*.qmltypes" }, QDir::Files); |
274 | while (it.hasNext()) { |
275 | it.next(); |
276 | defaultQmldirFiles.append(t: it.fileInfo().absoluteFilePath()); |
277 | } |
278 | } |
279 | } |
280 | QStringList qmldirFiles = defaultQmldirFiles; |
281 | |
282 | const QStringList defaultResourceFiles = |
283 | parser.isSet(option: resourceOption) ? parser.values(option: resourceOption) : QStringList {}; |
284 | QStringList resourceFiles = defaultResourceFiles; |
285 | |
286 | bool success = true; |
287 | |
288 | QStringList pluginPaths = { QQmlJSLinter::defaultPluginPath() }; |
289 | |
290 | if (parser.isSet(option: pluginPathsOption)) |
291 | pluginPaths << parser.values(option: pluginPathsOption); |
292 | |
293 | QQmlJSLinter linter(qmlImportPaths, pluginPaths, useAbsolutePath); |
294 | |
295 | for (const QQmlJSLinter::Plugin &plugin : linter.plugins()) { |
296 | for (const QQmlJS::LoggerCategory &category : plugin.categories()) |
297 | addCategory(category); |
298 | } |
299 | |
300 | if (!parser.unknownOptionNames().isEmpty()) |
301 | parser.process(app); |
302 | |
303 | updateLogLevels(); |
304 | |
305 | if (parser.isSet(option: listPluginsOption)) { |
306 | const std::vector<QQmlJSLinter::Plugin> &plugins = linter.plugins(); |
307 | if (!plugins.empty()) { |
308 | qInfo().nospace().noquote() << "Plugin\t\t\tBuilt-in?\tVersion\tAuthor\t\tDescription" ; |
309 | for (const QQmlJSLinter::Plugin &plugin : plugins) { |
310 | qInfo().nospace().noquote() |
311 | << plugin.name() << "\t\t\t" << (plugin.isBuiltin() ? "Yes" : "No" ) |
312 | << "\t\t" << plugin.version() << "\t" << plugin.author() << "\t\t" |
313 | << plugin.description(); |
314 | } |
315 | } else { |
316 | qWarning() << "No plugins installed." ; |
317 | } |
318 | return 0; |
319 | } |
320 | |
321 | const auto positionalArguments = parser.positionalArguments(); |
322 | if (positionalArguments.isEmpty()) { |
323 | parser.showHelp(exitCode: -1); |
324 | } |
325 | |
326 | QJsonArray jsonFiles; |
327 | |
328 | for (const QString &filename : positionalArguments) { |
329 | if (!parser.isSet(option: ignoreSettings)) { |
330 | settings.search(path: filename); |
331 | updateLogLevels(); |
332 | |
333 | const QDir fileDir = QFileInfo(filename).absoluteDir(); |
334 | auto addAbsolutePaths = [&](QStringList &list, const QStringList &entries) { |
335 | for (const QString &file : entries) |
336 | list << (QFileInfo(file).isAbsolute() ? file : fileDir.filePath(fileName: file)); |
337 | }; |
338 | |
339 | resourceFiles = defaultResourceFiles; |
340 | |
341 | addAbsolutePaths(resourceFiles, settings.value(name: resourceSetting).toStringList()); |
342 | |
343 | qmldirFiles = defaultQmldirFiles; |
344 | if (settings.isSet(name: qmldirFilesSetting) |
345 | && !settings.value(name: qmldirFilesSetting).toStringList().isEmpty()) { |
346 | qmldirFiles = {}; |
347 | addAbsolutePaths(qmldirFiles, |
348 | settings.value(name: qmldirFilesSetting).toStringList()); |
349 | } |
350 | |
351 | if (parser.isSet(option: qmlImportNoDefault) |
352 | || (settings.isSet(name: qmlImportNoDefaultSetting) |
353 | && settings.value(name: qmlImportNoDefaultSetting).toBool())) { |
354 | qmlImportPaths = {}; |
355 | } else { |
356 | qmlImportPaths = defaultImportPaths; |
357 | } |
358 | |
359 | if (parser.isSet(option: qmlImportPathsOption)) |
360 | qmlImportPaths << parser.values(option: qmlImportPathsOption); |
361 | |
362 | addAbsolutePaths(qmlImportPaths, settings.value(name: qmlImportPathsSetting).toStringList()); |
363 | |
364 | QSet<QString> disabledPlugins; |
365 | |
366 | if (parser.isSet(option: pluginsDisable)) { |
367 | for (const QString &plugin : parser.values(option: pluginsDisable)) |
368 | disabledPlugins << plugin.toLower(); |
369 | } |
370 | |
371 | if (settings.isSet(name: pluginsDisableSetting)) { |
372 | for (const QString &plugin : settings.value(name: pluginsDisableSetting).toStringList()) |
373 | disabledPlugins << plugin.toLower(); |
374 | } |
375 | |
376 | linter.setPluginsEnabled(!disabledPlugins.contains(value: "all" )); |
377 | |
378 | if (!linter.pluginsEnabled()) |
379 | continue; |
380 | |
381 | auto &plugins = linter.plugins(); |
382 | |
383 | for (auto &plugin : plugins) |
384 | plugin.setEnabled(!disabledPlugins.contains(value: plugin.name().toLower())); |
385 | } |
386 | |
387 | const bool isFixing = parser.isSet(option: fixFile); |
388 | |
389 | QQmlJSLinter::LintResult lintResult; |
390 | |
391 | if (parser.isSet(option: moduleOption)) { |
392 | lintResult = linter.lintModule(uri: filename, silent, json: useJson ? &jsonFiles : nullptr, |
393 | qmlImportPaths, resourceFiles); |
394 | } else { |
395 | lintResult = linter.lintFile(filename, fileContents: nullptr, silent: silent || isFixing, |
396 | json: useJson ? &jsonFiles : nullptr, qmlImportPaths, |
397 | qmldirFiles, resourceFiles, categories); |
398 | } |
399 | success &= (lintResult == QQmlJSLinter::LintSuccess); |
400 | |
401 | if (isFixing) { |
402 | if (lintResult != QQmlJSLinter::LintSuccess && lintResult != QQmlJSLinter::HasWarnings) |
403 | continue; |
404 | |
405 | QString fixedCode; |
406 | const QQmlJSLinter::FixResult result = linter.applyFixes(fixedCode: &fixedCode, silent); |
407 | |
408 | if (result != QQmlJSLinter::NothingToFix && result != QQmlJSLinter::FixSuccess) { |
409 | success = false; |
410 | continue; |
411 | } |
412 | |
413 | if (parser.isSet(option: dryRun)) { |
414 | QTextStream(stdout) << fixedCode; |
415 | } else { |
416 | if (result == QQmlJSLinter::NothingToFix) { |
417 | if (!silent) |
418 | qWarning().nospace() << "Nothing to fix in " << filename; |
419 | continue; |
420 | } |
421 | |
422 | const QString backupFile = filename + u".bak"_s ; |
423 | if (QFile::exists(fileName: backupFile) && !QFile::remove(fileName: backupFile)) { |
424 | if (!silent) { |
425 | qWarning().nospace() << "Failed to remove old backup file " << backupFile |
426 | << ", aborting" ; |
427 | } |
428 | success = false; |
429 | continue; |
430 | } |
431 | if (!QFile::copy(fileName: filename, newName: backupFile)) { |
432 | if (!silent) { |
433 | qWarning().nospace() |
434 | << "Failed to create backup file " << backupFile << ", aborting" ; |
435 | } |
436 | success = false; |
437 | continue; |
438 | } |
439 | |
440 | QFile file(filename); |
441 | if (!file.open(flags: QIODevice::WriteOnly)) { |
442 | if (!silent) { |
443 | qWarning().nospace() << "Failed to open " << filename |
444 | << " for writing:" << file.errorString(); |
445 | } |
446 | success = false; |
447 | continue; |
448 | } |
449 | |
450 | const QByteArray data = fixedCode.toUtf8(); |
451 | if (file.write(data) != data.size()) { |
452 | if (!silent) { |
453 | qWarning().nospace() << "Failed to write new contents to " << filename |
454 | << ": " << file.errorString(); |
455 | } |
456 | success = false; |
457 | continue; |
458 | } |
459 | if (!silent) { |
460 | qDebug().nospace() << "Applied fixes to " << filename << ". Backup created at " |
461 | << backupFile; |
462 | } |
463 | } |
464 | } |
465 | } |
466 | |
467 | if (useJson) { |
468 | QJsonObject result; |
469 | |
470 | result[u"revision"_s ] = JSON_LOGGING_FORMAT_REVISION; |
471 | result[u"files"_s ] = jsonFiles; |
472 | |
473 | QString fileName = parser.value(option: jsonOption); |
474 | |
475 | const QByteArray json = QJsonDocument(result).toJson(format: QJsonDocument::Compact); |
476 | |
477 | if (fileName == u"-" ) { |
478 | QTextStream(stdout) << QString::fromUtf8(ba: json); |
479 | } else { |
480 | QFile file(fileName); |
481 | file.open(flags: QFile::WriteOnly); |
482 | file.write(data: json); |
483 | } |
484 | } |
485 | |
486 | return success ? 0 : -1; |
487 | } |
488 | |