File indexing completed on 2024-05-26 05:51:52

0001 /*
0002     SPDX-FileCopyrightText: 2022 Waqar Ahmed <waqar.17a@gmail.com>
0003     SPDX-License-Identifier: LGPL-2.0-or-later
0004 */
0005 #include "EslintPlugin.h"
0006 
0007 #include <KLocalizedString>
0008 #include <KPluginFactory>
0009 #include <KSharedConfig>
0010 #include <KTextEditor/Application>
0011 #include <KTextEditor/Editor>
0012 #include <KTextEditor/View>
0013 #include <KXMLGUIFactory>
0014 
0015 #include <QFileInfo>
0016 #include <QJsonArray>
0017 #include <QJsonDocument>
0018 #include <QJsonObject>
0019 #include <QPointer>
0020 
0021 #include <hostprocess.h>
0022 #include <ktexteditor_utils.h>
0023 
0024 K_PLUGIN_FACTORY_WITH_JSON(ESLintPluginFactory, "EslintPlugin.json", registerPlugin<ESLintPlugin>();)
0025 
0026 ESLintPlugin::ESLintPlugin(QObject *parent, const QVariantList &)
0027     : KTextEditor::Plugin(parent)
0028 {
0029 }
0030 
0031 QObject *ESLintPlugin::createView(KTextEditor::MainWindow *mainWindow)
0032 {
0033     return new ESLintPluginView(mainWindow);
0034 }
0035 
0036 ESLintPluginView::ESLintPluginView(KTextEditor::MainWindow *mainWin)
0037     : QObject(mainWin)
0038     , m_mainWindow(mainWin)
0039     , m_provider(mainWin, this)
0040 {
0041     m_provider.setObjectName(QStringLiteral("ESLintDiagnosticProvider"));
0042     m_provider.name = i18n("ESLint");
0043 
0044     connect(mainWin, &KTextEditor::MainWindow::viewChanged, this, &ESLintPluginView::onActiveViewChanged);
0045     connect(&m_eslintProcess, &QProcess::readyReadStandardOutput, this, &ESLintPluginView::onReadyRead);
0046     connect(&m_eslintProcess, &QProcess::readyReadStandardError, this, &ESLintPluginView::onError);
0047     connect(&m_provider, &DiagnosticsProvider::requestFixes, this, &ESLintPluginView::onFixesRequested);
0048 
0049     m_mainWindow->guiFactory()->addClient(this);
0050 }
0051 
0052 ESLintPluginView::~ESLintPluginView()
0053 {
0054     disconnect(&m_eslintProcess, &QProcess::readyReadStandardOutput, this, &ESLintPluginView::onReadyRead);
0055     disconnect(&m_eslintProcess, &QProcess::readyReadStandardError, this, &ESLintPluginView::onError);
0056     if (m_eslintProcess.state() == QProcess::Running) {
0057         m_eslintProcess.kill();
0058         m_eslintProcess.waitForFinished();
0059     }
0060     disconnect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, &ESLintPluginView::onActiveViewChanged);
0061     m_mainWindow->guiFactory()->removeClient(this);
0062 }
0063 
0064 void ESLintPluginView::onActiveViewChanged(KTextEditor::View *v)
0065 {
0066     if (v && v->document() == m_activeDoc) {
0067         return;
0068     }
0069 
0070     if (m_activeDoc) {
0071         disconnect(m_activeDoc, &KTextEditor::Document::documentSavedOrUploaded, this, &ESLintPluginView::onSaved);
0072     }
0073 
0074     m_activeDoc = v ? v->document() : nullptr;
0075 
0076     if (m_activeDoc) {
0077         connect(m_activeDoc, &KTextEditor::Document::documentSavedOrUploaded, this, &ESLintPluginView::onSaved, Qt::QueuedConnection);
0078     }
0079 }
0080 
0081 static bool canLintDoc(KTextEditor::Document *d)
0082 {
0083     if (!d || !d->url().isLocalFile()) {
0084         return false;
0085     }
0086     const auto mode = d->highlightingMode().toLower();
0087     return (mode == QStringLiteral("javascript") || mode == QStringLiteral("typescript") || mode == QStringLiteral("typescript react (tsx)")
0088             || mode == QStringLiteral("javascript react (jsx)"));
0089 }
0090 
0091 void ESLintPluginView::onSaved(KTextEditor::Document *d)
0092 {
0093     m_diagsWithFix.clear();
0094     if (!canLintDoc(d)) {
0095         return;
0096     }
0097 
0098     if (m_eslintProcess.state() == QProcess::Running) {
0099         return;
0100     }
0101 
0102     const auto name = safeExecutableName(QStringLiteral("npx"));
0103     if (name.isEmpty()) {
0104         // TODO: error
0105         return;
0106     }
0107     const QStringList args{QStringLiteral("eslint"), QStringLiteral("-f"), QStringLiteral("json"), {d->url().toLocalFile()}};
0108     startHostProcess(m_eslintProcess, name, args);
0109 }
0110 
0111 static FileDiagnostics parseLine(const QString &line, std::vector<DiagnosticWithFix> &diagWithFix)
0112 {
0113     QJsonParseError e;
0114     QJsonDocument d = QJsonDocument::fromJson(line.toUtf8(), &e);
0115     if (e.error != QJsonParseError::NoError) {
0116         return {};
0117     }
0118 
0119     const auto arr = d.array();
0120     if (arr.empty()) {
0121         return {};
0122     }
0123     auto obj = arr.at(0).toObject();
0124     QUrl uri = QUrl::fromLocalFile(obj.value(QStringLiteral("filePath")).toString());
0125     if (!uri.isValid()) {
0126         return {};
0127     }
0128 
0129     FileDiagnostics fd;
0130     fd.uri = uri;
0131     const auto messages = obj.value(QStringLiteral("messages")).toArray();
0132     if (messages.empty()) {
0133         // No errors in this file
0134         return fd;
0135     }
0136 
0137     QList<Diagnostic> diags;
0138     diags.reserve(messages.size());
0139     for (const auto &m : messages) {
0140         const auto msg = m.toObject();
0141         if (msg.isEmpty()) {
0142             continue;
0143         }
0144 
0145         const int startLine = msg.value(QStringLiteral("line")).toInt() - 1;
0146         const int startColumn = msg.value(QStringLiteral("column")).toInt() - 1;
0147         const int endLine = msg.value(QStringLiteral("endLine")).toInt() - 1;
0148         const int endColumn = msg.value(QStringLiteral("endColumn")).toInt() - 1;
0149         Diagnostic d;
0150         d.range = {startLine, startColumn, endLine, endColumn};
0151         if (!d.range.isValid()) {
0152             continue;
0153         }
0154         d.code = msg.value(QStringLiteral("ruleId")).toString();
0155         d.message = msg.value(QStringLiteral("message")).toString();
0156         d.source = QStringLiteral("eslint");
0157         const int severity = msg.value(QStringLiteral("severity")).toInt();
0158         if (severity == 1) {
0159             d.severity = DiagnosticSeverity::Warning;
0160         } else if (severity == 2) {
0161             d.severity = DiagnosticSeverity::Error;
0162         } else {
0163             // fallback, even though there is no other severity
0164             d.severity = DiagnosticSeverity::Information;
0165         }
0166 
0167         auto fixObject = msg.value(QStringLiteral("fix")).toObject();
0168         if (!fixObject.isEmpty()) {
0169             const auto rangeArray = fixObject.value(QStringLiteral("range")).toArray();
0170             int s, e;
0171             if (rangeArray.size() == 2) {
0172                 s = rangeArray[0].toInt(-1);
0173                 e = rangeArray[1].toInt(-1);
0174                 auto v = fixObject.value(QStringLiteral("text"));
0175                 if (!v.isUndefined()) {
0176                     DiagnosticWithFix df;
0177                     d.message += QStringLiteral(" (fix available)");
0178                     df.diag = d;
0179                     df.fix = {s, e, v.toString()};
0180                     diagWithFix.push_back(df);
0181                 }
0182             }
0183         }
0184 
0185         diags << d;
0186     }
0187     return {uri, diags};
0188 }
0189 
0190 void ESLintPluginView::onReadyRead()
0191 {
0192     /**
0193      * get results of analysis
0194      */
0195     QHash<QUrl, QList<Diagnostic>> fileDiagnostics;
0196     const auto out = m_eslintProcess.readAllStandardOutput().split('\n');
0197     for (const auto &rawLine : out) {
0198         /**
0199          * get one line, split it, skip it, if too few elements
0200          */
0201         QString line = QString::fromLocal8Bit(rawLine);
0202         FileDiagnostics fd = parseLine(line, m_diagsWithFix);
0203         if (!fd.uri.isValid()) {
0204             continue;
0205         }
0206         fileDiagnostics[fd.uri] << fd.diagnostics;
0207     }
0208 
0209     for (auto it = fileDiagnostics.cbegin(); it != fileDiagnostics.cend(); ++it) {
0210         m_provider.diagnosticsAdded(FileDiagnostics{it.key(), it.value()});
0211     }
0212 }
0213 
0214 void ESLintPluginView::onError()
0215 {
0216     const QString err = QString::fromUtf8(m_eslintProcess.readAllStandardError());
0217     if (!err.isEmpty()) {
0218         const QString message = i18n("Eslint failed, error: %1", err);
0219         Utils::showMessage(message, {}, i18n("ESLint"), MessageType::Warning, m_mainWindow);
0220     }
0221 }
0222 
0223 void ESLintPluginView::onFixesRequested(const QUrl &u, const Diagnostic &d, const QVariant &v)
0224 {
0225     if (m_diagsWithFix.empty()) {
0226         return;
0227     }
0228 
0229     for (const auto &fd : m_diagsWithFix) {
0230         const auto &diag = fd.diag;
0231         if (diag.range == d.range && diag.code == d.code && diag.message == d.message) {
0232             DiagnosticFix f;
0233             f.fixTitle = QStringLiteral("replace with %1").arg(fd.fix.text);
0234             f.fixCallback = [u, fix = fd.fix, this] {
0235                 fixDiagnostic(u, fix);
0236             };
0237             Q_EMIT m_provider.fixesAvailable({f}, v);
0238         }
0239     }
0240 }
0241 
0242 void ESLintPluginView::fixDiagnostic(const QUrl &url, const DiagnosticWithFix::Fix &fix)
0243 {
0244     KTextEditor::Document *d = nullptr;
0245     if (m_activeDoc && m_activeDoc->url() == url) {
0246         d = m_activeDoc;
0247     } else {
0248         d = KTextEditor::Editor::instance()->application()->findUrl(url);
0249     }
0250     if (!d) {
0251         const QString message = i18n("Failed to find doc with url %1", url.toLocalFile());
0252         Utils::showMessage(message, {}, i18n("ESLint"), MessageType::Info, m_mainWindow);
0253         return;
0254     }
0255 
0256     KTextEditor::Cursor s = d->offsetToCursor(fix.rangeStart);
0257     KTextEditor::Cursor e = d->offsetToCursor(fix.rangeEnd);
0258     if (s.isValid() && e.isValid()) {
0259         d->replaceText({s, e}, fix.text);
0260     }
0261 }
0262 
0263 #include "EslintPlugin.moc"
0264 #include "moc_EslintPlugin.cpp"