qtdeclarative/src/qmlcompiler/qqmljslinter.cpp

688 lines
24 KiB
C++

/****************************************************************************
**
** Copyright (C) 2021 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of the tools applications of the Qt Toolkit.
**
** $QT_BEGIN_LICENSE:GPL-EXCEPT$
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 3 as published by the Free Software
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
**
** $QT_END_LICENSE$
**
****************************************************************************/
#include "qqmljslinter_p.h"
#include "qqmljslintercodegen_p.h"
#include <QtQmlCompiler/private/qqmljsimporter_p.h>
#include <QtQmlCompiler/private/qqmljsimportvisitor_p.h>
#include <QtQmlCompiler/private/qqmljsliteralbindingcheck_p.h>
#include <QtCore/qjsonobject.h>
#include <QtCore/qfileinfo.h>
#include <QtCore/qloggingcategory.h>
#include <QtCore/qpluginloader.h>
#include <QtCore/qlibraryinfo.h>
#include <QtCore/qdir.h>
#include <QtCore/private/qduplicatetracker_p.h>
#include <QtCore/qscopedpointer.h>
#include <QtQmlCompiler/private/qqmlsa_p.h>
#if QT_CONFIG(library)
# include <QtCore/qdiriterator.h>
# include <QtCore/qlibrary.h>
#endif
#include <QtQml/private/qqmljslexer_p.h>
#include <QtQml/private/qqmljsparser_p.h>
#include <QtQml/private/qqmljsengine_p.h>
#include <QtQml/private/qqmljsastvisitor_p.h>
#include <QtQml/private/qqmljsast_p.h>
#include <QtQml/private/qqmljsdiagnosticmessage_p.h>
QT_BEGIN_NAMESPACE
using namespace Qt::StringLiterals;
class CodegenWarningInterface final : public QV4::Compiler::CodegenWarningInterface
{
public:
CodegenWarningInterface(QQmlJSLogger *logger) : m_logger(logger) { }
void reportVarUsedBeforeDeclaration(const QString &name, const QString &fileName,
QQmlJS::SourceLocation declarationLocation,
QQmlJS::SourceLocation accessLocation) override
{
Q_UNUSED(fileName)
m_logger->log(
u"Variable \"%1\" is used here before its declaration. The declaration is at %2:%3."_s
.arg(name)
.arg(declarationLocation.startLine)
.arg(declarationLocation.startColumn),
Log_Type, accessLocation);
}
private:
QQmlJSLogger *m_logger;
};
QString QQmlJSLinter::defaultPluginPath()
{
return QLibraryInfo::path(QLibraryInfo::PluginsPath) + QDir::separator() + u"qmllint";
}
QQmlJSLinter::QQmlJSLinter(const QStringList &importPaths, const QStringList &pluginPaths,
bool useAbsolutePath)
: m_useAbsolutePath(useAbsolutePath),
m_enablePlugins(true),
m_importer(importPaths, nullptr, true)
{
m_plugins = loadPlugins(pluginPaths);
}
QQmlJSLinter::Plugin::Plugin(QQmlJSLinter::Plugin &&plugin) noexcept
{
m_name = plugin.m_name;
m_author = plugin.m_author;
m_description = plugin.m_description;
m_version = plugin.m_version;
m_instance = plugin.m_instance;
m_loader = plugin.m_loader;
m_isValid = plugin.m_isValid;
m_isBuiltin = plugin.m_isBuiltin;
// Mark the old Plugin as invalid and make sure it doesn't delete the loader
plugin.m_loader = nullptr;
plugin.m_instance = nullptr;
plugin.m_isValid = false;
}
#if QT_CONFIG(library)
QQmlJSLinter::Plugin::Plugin(QString path)
{
m_loader = new QPluginLoader(path);
if (!parseMetaData(m_loader->metaData(), path))
return;
QObject *object = m_loader->instance();
if (!object)
return;
m_instance = qobject_cast<QQmlSA::LintPlugin *>(object);
if (!m_instance)
return;
m_isValid = true;
}
#endif
QQmlJSLinter::Plugin::Plugin(const QStaticPlugin &staticPlugin)
{
if (!parseMetaData(staticPlugin.metaData(), u"built-in"_s))
return;
m_instance = qobject_cast<QQmlSA::LintPlugin *>(staticPlugin.instance());
if (!m_instance)
return;
m_isValid = true;
}
QQmlJSLinter::Plugin::~Plugin()
{
#if QT_CONFIG(library)
if (m_loader != nullptr) {
m_loader->unload();
m_loader->deleteLater();
}
#endif
}
bool QQmlJSLinter::Plugin::parseMetaData(const QJsonObject &metaData, QString pluginName)
{
const QString pluginIID = QStringLiteral(QmlLintPluginInterface_iid);
if (metaData[u"IID"].toString() != pluginIID)
return false;
QJsonObject pluginMetaData = metaData[u"MetaData"].toObject();
for (const QString &requiredKey : { u"name"_s, u"version"_s, u"author"_s }) {
if (!pluginMetaData.contains(requiredKey)) {
qWarning() << pluginName << "is missing the required " << requiredKey
<< "metadata, skipping";
return false;
}
}
m_name = pluginMetaData[u"name"].toString();
m_author = pluginMetaData[u"author"].toString();
m_version = pluginMetaData[u"version"].toString();
m_description = pluginMetaData[u"description"].toString(u"-/-"_s);
return true;
}
std::vector<QQmlJSLinter::Plugin> QQmlJSLinter::loadPlugins(QStringList paths)
{
std::vector<Plugin> plugins;
QDuplicateTracker<QString> seenPlugins;
for (const QStaticPlugin &staticPlugin : QPluginLoader::staticPlugins()) {
Plugin plugin(staticPlugin);
if (!plugin.isValid())
continue;
if (seenPlugins.hasSeen(plugin.name().toLower())) {
qWarning() << "Two plugins named" << plugin.name()
<< "present, make sure no plugins are duplicated. The second plugin will "
"not be loaded.";
continue;
}
plugins.push_back(std::move(plugin));
}
#if QT_CONFIG(library)
for (const QString &pluginDir : paths) {
QDirIterator it { pluginDir };
while (it.hasNext()) {
auto potentialPlugin = it.next();
if (!QLibrary::isLibrary(potentialPlugin))
continue;
Plugin plugin(potentialPlugin);
if (!plugin.isValid())
continue;
if (seenPlugins.hasSeen(plugin.name().toLower())) {
qWarning() << "Two plugins named" << plugin.name()
<< "present, make sure no plugins are duplicated. The second plugin "
"will not be loaded.";
continue;
}
plugins.push_back(std::move(plugin));
}
}
#endif
return plugins;
}
void QQmlJSLinter::parseComments(QQmlJSLogger *logger,
const QList<QQmlJS::SourceLocation> &comments)
{
QHash<int, QSet<QQmlJSLoggerCategory>> disablesPerLine;
QHash<int, QSet<QQmlJSLoggerCategory>> enablesPerLine;
QHash<int, QSet<QQmlJSLoggerCategory>> oneLineDisablesPerLine;
const QString code = logger->code();
const QStringList lines = code.split(u'\n');
for (const auto &loc : comments) {
const QString comment = code.mid(loc.offset, loc.length);
if (!comment.startsWith(u" qmllint ") && !comment.startsWith(u"qmllint "))
continue;
QStringList words = comment.split(u' ');
if (words.constFirst().isEmpty())
words.removeFirst();
const QString command = words.at(1);
QSet<QQmlJSLoggerCategory> categories;
for (qsizetype i = 2; i < words.size(); i++) {
const QString category = words.at(i);
const auto option = logger->options().constFind(category);
if (option != logger->options().constEnd())
categories << option->m_category;
else
logger->log(u"qmllint directive on unknown category \"%1\""_s.arg(category),
Log_Syntax, loc);
}
if (categories.isEmpty()) {
for (const auto &option : logger->options())
categories << option.m_category;
}
if (command == u"disable"_s) {
const QString line = lines[loc.startLine - 1];
const QString preComment = line.left(line.indexOf(comment) - 2);
bool lineHasContent = false;
for (qsizetype i = 0; i < preComment.size(); i++) {
if (!preComment[i].isSpace()) {
lineHasContent = true;
break;
}
}
if (lineHasContent)
oneLineDisablesPerLine[loc.startLine] |= categories;
else
disablesPerLine[loc.startLine] |= categories;
} else if (command == u"enable"_s) {
enablesPerLine[loc.startLine + 1] |= categories;
} else {
logger->log(u"Invalid qmllint directive \"%1\" provided"_s.arg(command), Log_Syntax,
loc);
}
}
if (disablesPerLine.isEmpty() && oneLineDisablesPerLine.isEmpty())
return;
QSet<QQmlJSLoggerCategory> currentlyDisabled;
for (qsizetype i = 1; i <= lines.length(); i++) {
currentlyDisabled.unite(disablesPerLine[i]).subtract(enablesPerLine[i]);
currentlyDisabled.unite(oneLineDisablesPerLine[i]);
if (!currentlyDisabled.isEmpty())
logger->ignoreWarnings(i, currentlyDisabled);
currentlyDisabled.subtract(oneLineDisablesPerLine[i]);
}
}
QQmlJSLinter::LintResult QQmlJSLinter::lintFile(const QString &filename,
const QString *fileContents, const bool silent,
QJsonArray *json, const QStringList &qmlImportPaths,
const QStringList &qmldirFiles,
const QStringList &resourceFiles,
const QMap<QString, QQmlJSLogger::Option> &options)
{
// Make sure that we don't expose an old logger if we return before a new one is created.
m_logger.reset();
QJsonArray warnings;
QJsonObject result;
bool success = true;
QScopeGuard jsonOutput([&] {
if (!json)
return;
result[u"filename"_s] = QFileInfo(filename).absoluteFilePath();
result[u"warnings"] = warnings;
result[u"success"] = success;
json->append(result);
});
auto addJsonWarning = [&](const QQmlJS::DiagnosticMessage &message,
const std::optional<FixSuggestion> &suggestion = {}) {
QJsonObject jsonMessage;
QString type;
switch (message.type) {
case QtDebugMsg:
type = u"debug"_s;
break;
case QtWarningMsg:
type = u"warning"_s;
break;
case QtCriticalMsg:
type = u"critical"_s;
break;
case QtFatalMsg:
type = u"fatal"_s;
break;
case QtInfoMsg:
type = u"info"_s;
break;
default:
type = u"unknown"_s;
break;
}
jsonMessage[u"type"_s] = type;
if (message.loc.isValid()) {
jsonMessage[u"line"_s] = static_cast<int>(message.loc.startLine);
jsonMessage[u"column"_s] = static_cast<int>(message.loc.startColumn);
jsonMessage[u"charOffset"_s] = static_cast<int>(message.loc.offset);
jsonMessage[u"length"_s] = static_cast<int>(message.loc.length);
}
jsonMessage[u"message"_s] = message.message;
QJsonArray suggestions;
if (suggestion.has_value()) {
for (const auto &fix : suggestion->fixes) {
QJsonObject jsonFix;
jsonFix[u"message"] = fix.message;
jsonFix[u"line"_s] = static_cast<int>(fix.cutLocation.startLine);
jsonFix[u"column"_s] = static_cast<int>(fix.cutLocation.startColumn);
jsonFix[u"charOffset"_s] = static_cast<int>(fix.cutLocation.offset);
jsonFix[u"length"_s] = static_cast<int>(fix.cutLocation.length);
jsonFix[u"replacement"_s] = fix.replacementString;
jsonFix[u"isHint"] = fix.isHint;
if (!fix.fileName.isEmpty())
jsonFix[u"fileName"] = fix.fileName;
suggestions << jsonFix;
}
}
jsonMessage[u"suggestions"] = suggestions;
warnings << jsonMessage;
};
QString code;
if (fileContents == nullptr) {
QFile file(filename);
if (!file.open(QFile::ReadOnly)) {
if (json) {
addJsonWarning(
QQmlJS::DiagnosticMessage { QStringLiteral("Failed to open file %1: %2")
.arg(filename, file.errorString()),
QtCriticalMsg, QQmlJS::SourceLocation() });
success = false;
} else if (!silent) {
qWarning() << "Failed to open file" << filename << file.error();
}
return FailedToOpen;
}
code = QString::fromUtf8(file.readAll());
file.close();
} else {
code = *fileContents;
}
m_fileContents = code;
QQmlJS::Engine engine;
QQmlJS::Lexer lexer(&engine);
QFileInfo info(filename);
const QString lowerSuffix = info.suffix().toLower();
const bool isESModule = lowerSuffix == QLatin1String("mjs");
const bool isJavaScript = isESModule || lowerSuffix == QLatin1String("js");
lexer.setCode(code, /*lineno = */ 1, /*qmlMode=*/!isJavaScript);
QQmlJS::Parser parser(&engine);
success = isJavaScript ? (isESModule ? parser.parseModule() : parser.parseProgram())
: parser.parse();
if (!success) {
const auto diagnosticMessages = parser.diagnosticMessages();
for (const QQmlJS::DiagnosticMessage &m : diagnosticMessages) {
if (json) {
addJsonWarning(m);
} else if (!silent) {
qWarning().noquote() << QString::fromLatin1("%1:%2:%3: %4")
.arg(filename)
.arg(m.loc.startLine)
.arg(m.loc.startColumn)
.arg(m.message);
}
}
return FailedToParse;
}
if (success && !isJavaScript) {
const auto processMessages = [&]() {
if (json) {
for (const auto &error : m_logger->errors())
addJsonWarning(error, error.fixSuggestion);
for (const auto &warning : m_logger->warnings())
addJsonWarning(warning, warning.fixSuggestion);
for (const auto &info : m_logger->infos())
addJsonWarning(info, info.fixSuggestion);
}
};
const auto check = [&](QQmlJSResourceFileMapper *mapper) {
if (m_importer.importPaths() != qmlImportPaths)
m_importer.setImportPaths(qmlImportPaths);
m_importer.setResourceFileMapper(mapper);
m_logger.reset(new QQmlJSLogger);
m_logger->setFileName(m_useAbsolutePath ? info.absoluteFilePath() : filename);
m_logger->setCode(code);
m_logger->setSilent(silent || json);
QQmlJSScope::Ptr target = QQmlJSScope::create();
QQmlJSImportVisitor v { target, &m_importer, m_logger.get(),
QQmlJSImportVisitor::implicitImportDirectory(
m_logger->fileName(), m_importer.resourceFileMapper()),
qmldirFiles };
parseComments(m_logger.get(), engine.comments());
for (auto it = options.cbegin(); it != options.cend(); ++it) {
if (!it.value().m_changed)
continue;
m_logger->setCategoryIgnored(it.value().m_category, it.value().m_ignored);
m_logger->setCategoryLevel(it.value().m_category, it.value().m_level);
}
QQmlJSTypeResolver typeResolver(&m_importer);
// Type resolving is using document parent mode here so that it produces fewer false
// positives on the "parent" property of QQuickItem. It does produce a few false
// negatives this way because items can be reparented. Furthermore, even if items are
// not reparented, the document parent may indeed not be their visual parent. See
// QTBUG-95530. Eventually, we'll need cleverer logic to deal with this.
typeResolver.setParentMode(QQmlJSTypeResolver::UseDocumentParent);
// We don't need to create tracked types and such as we are just linting the code here
// and not actually compiling it. The duplicated scopes would cause issues during
// linting.
typeResolver.setCloneMode(QQmlJSTypeResolver::DoNotCloneTypes);
typeResolver.init(&v, parser.rootNode());
QQmlJSLiteralBindingCheck literalCheck;
literalCheck.run(&v, &typeResolver);
QScopedPointer<QQmlSA::PassManager> passMan;
if (m_enablePlugins) {
passMan.reset(new QQmlSA::PassManager(&v, &typeResolver));
for (const Plugin &plugin : m_plugins) {
if (!plugin.isValid() || !plugin.isEnabled())
continue;
QQmlSA::LintPlugin *instance = plugin.m_instance;
Q_ASSERT(instance);
instance->registerPasses(passMan.get(), v.result());
}
passMan->analyze(v.result());
}
success = !m_logger->hasWarnings() && !m_logger->hasErrors();
if (m_logger->hasErrors()) {
processMessages();
return;
}
QQmlJSTypeInfo typeInfo;
const QStringList resourcePaths = mapper
? mapper->resourcePaths(QQmlJSResourceFileMapper::localFileFilter(filename))
: QStringList();
const QString resolvedPath =
(resourcePaths.size() == 1) ? u':' + resourcePaths.first() : filename;
QQmlJSLinterCodegen codegen { &m_importer, resolvedPath, qmldirFiles, m_logger.get(),
&typeInfo };
codegen.setTypeResolver(std::move(typeResolver));
if (passMan)
codegen.setPassManager(passMan.get());
QQmlJSSaveFunction saveFunction = [](const QV4::CompiledData::SaveableUnitPointer &,
const QQmlJSAotFunctionMap &,
QString *) { return true; };
QQmlJSCompileError error;
QLoggingCategory::setFilterRules(u"qt.qml.compiler=false"_s);
CodegenWarningInterface interface(m_logger.get());
qCompileQmlFile(filename, saveFunction, &codegen, &error, true, &interface,
fileContents);
QList<QQmlJS::DiagnosticMessage> warnings = m_importer.takeGlobalWarnings();
if (!warnings.isEmpty()) {
m_logger->log(QStringLiteral("Type warnings occurred while evaluating file:"),
Log_Import, QQmlJS::SourceLocation());
m_logger->processMessages(warnings, Log_Import);
}
success &= !m_logger->hasWarnings() && !m_logger->hasErrors();
processMessages();
};
if (resourceFiles.isEmpty()) {
check(nullptr);
} else {
QQmlJSResourceFileMapper mapper(resourceFiles);
check(&mapper);
}
}
return success ? LintSuccess : HasWarnings;
}
QQmlJSLinter::FixResult QQmlJSLinter::applyFixes(QString *fixedCode, bool silent)
{
Q_ASSERT(fixedCode != nullptr);
// This means that the necessary analysis for applying fixes hasn't run for some reason
// (because it was JS file, a syntax error etc.). We can't procede without it and if an error
// has occurred that has to be handled by the caller of lintFile(). Just say that there is
// nothing to fix.
if (m_logger == nullptr)
return NothingToFix;
QString code = m_fileContents;
QList<FixSuggestion::Fix> fixesToApply;
QFileInfo info(m_logger->fileName());
const QString currentFileAbsolutePath = info.absoluteFilePath();
const QString lowerSuffix = info.suffix().toLower();
const bool isESModule = lowerSuffix == QLatin1String("mjs");
const bool isJavaScript = isESModule || lowerSuffix == QLatin1String("js");
if (isESModule || isJavaScript)
return NothingToFix;
for (const auto &messages : { m_logger->infos(), m_logger->warnings(), m_logger->errors() })
for (const Message &msg : messages) {
if (!msg.fixSuggestion.has_value())
continue;
for (const auto &fix : msg.fixSuggestion->fixes) {
if (fix.isHint)
continue;
// Ignore fix suggestions for other files
if (!fix.fileName.isEmpty()
&& QFileInfo(fix.fileName).absoluteFilePath() != currentFileAbsolutePath) {
continue;
}
fixesToApply << fix;
}
}
if (fixesToApply.isEmpty())
return NothingToFix;
std::sort(fixesToApply.begin(), fixesToApply.end(),
[](FixSuggestion::Fix &a, FixSuggestion::Fix &b) {
return a.cutLocation.offset < b.cutLocation.offset;
});
for (auto it = fixesToApply.begin(); it + 1 != fixesToApply.end(); it++) {
QQmlJS::SourceLocation srcLocA = it->cutLocation;
QQmlJS::SourceLocation srcLocB = (it + 1)->cutLocation;
if (srcLocA.offset + srcLocA.length > srcLocB.offset) {
if (!silent)
qWarning() << "Fixes for two warnings are overlapping, aborting. Please file a bug "
"report.";
return FixError;
}
}
int offsetChange = 0;
for (const auto &fix : fixesToApply) {
qsizetype cutLocation = fix.cutLocation.offset + offsetChange;
QString before = code.left(cutLocation);
QString after = code.mid(cutLocation + fix.cutLocation.length);
code = before + fix.replacementString + after;
offsetChange += fix.replacementString.length() - fix.cutLocation.length;
}
QQmlJS::Engine engine;
QQmlJS::Lexer lexer(&engine);
lexer.setCode(code, /*lineno = */ 1, /*qmlMode=*/!isJavaScript);
QQmlJS::Parser parser(&engine);
bool success = parser.parse();
if (!success) {
const auto diagnosticMessages = parser.diagnosticMessages();
if (!silent) {
qDebug() << "File became unparseable after suggestions were applied. Please file a bug "
"report.";
} else {
return FixError;
}
for (const QQmlJS::DiagnosticMessage &m : diagnosticMessages) {
qWarning().noquote() << QString::fromLatin1("%1:%2:%3: %4")
.arg(m_logger->fileName())
.arg(m.loc.startLine)
.arg(m.loc.startColumn)
.arg(m.message);
}
return FixError;
}
*fixedCode = code;
return FixSuccess;
}
QT_END_NAMESPACE