Commit abd067b9 authored by Mark Nauwelaerts's avatar Mark Nauwelaerts
Browse files

lspclient: support client-side diagnostic suppression

parent 18be604f
......@@ -17,6 +17,7 @@
#include <KAcceleratorManager>
#include <KActionCollection>
#include <KActionMenu>
#include <KConfigGroup>
#include <KLocalizedString>
#include <KStandardAction>
#include <KXMLGUIFactory>
......@@ -26,6 +27,7 @@
#include <KTextEditor/MainWindow>
#include <KTextEditor/Message>
#include <KTextEditor/MovingInterface>
#include <KTextEditor/SessionConfigInterface>
#include <KTextEditor/View>
#include <KXMLGUIClient>
......@@ -355,6 +357,78 @@ private:
KTextEditor::Range range;
};
class SessionDiagnosticSuppressions
{
// file -> suppression
// (empty file matches any file)
QHash<QString, QSet<QString>> m_suppressions;
public:
void readSessionConfig(const KConfigGroup &cg)
{
qCInfo(LSPCLIENT) << "reading session config";
int numEntries = cg.readEntry(QStringLiteral("NumEntries"), 0);
for (int i = 0; i < numEntries; i++) {
QStringList entry = cg.readEntry(QStringLiteral("Supp_%1").arg(i), QStringList());
if (entry.size() == 2) {
m_suppressions[entry[0]].insert(entry[1]);
}
}
}
void writeSessionConfig(KConfigGroup &cg)
{
qCInfo(LSPCLIENT) << "writing session config";
int cnt = 0;
for (auto it = m_suppressions.begin(); it != m_suppressions.end(); ++it) {
for (const auto &s : it.value()) {
QStringList entry{it.key(), s};
cg.writeEntry(QStringLiteral("Supp_%1").arg(cnt), entry);
++cnt;
}
}
cg.writeEntry("NumEntries", cnt);
}
void add(const QString &file, const QString &diagnostic)
{
m_suppressions[file].insert(diagnostic);
}
void remove(const QString &file, const QString &diagnostic)
{
auto it = m_suppressions.find(file);
if (it != m_suppressions.end()) {
it->remove(diagnostic);
}
}
bool hasSuppression(const QString &file, const QString &diagnostic)
{
auto it = m_suppressions.find(file);
if (it != m_suppressions.end()) {
return it->contains(diagnostic);
} else {
return false;
}
}
QVector<QString> getSuppressions(const QString &file)
{
QVector<QString> result;
for (const auto &entry : {QString(), file}) {
auto it = m_suppressions.find(entry);
if (it != m_suppressions.end()) {
for (const auto &d : it.value()) {
result.push_back(d);
}
}
}
return result;
}
};
class LSPClientActionView : public QObject
{
Q_OBJECT
......@@ -434,6 +508,8 @@ class LSPClientActionView : public QObject
RangeCollection m_diagnosticsRanges;
// and marks
DocumentCollection m_diagnosticsMarks;
// suppression tracked by session config
SessionDiagnosticSuppressions m_sessionDiagnosticSuppressions;
// views on which completions have been registered
QSet<KTextEditor::View *> m_completionViews;
......@@ -507,7 +583,7 @@ public:
{
connect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, &self_type::updateState);
connect(m_mainWindow, &KTextEditor::MainWindow::unhandledShortcutOverride, this, &self_type::handleEsc);
connect(m_serverManager.data(), &LSPClientServerManager::serverChanged, this, &self_type::updateState);
connect(m_serverManager.data(), &LSPClientServerManager::serverChanged, this, &self_type::onServerChanged);
connect(m_serverManager.data(), &LSPClientServerManager::showMessage, this, &self_type::onShowMessage);
connect(m_serverManager.data(), &LSPClientServerManager::serverShowMessage, this, &self_type::onMessage);
connect(m_serverManager.data(), &LSPClientServerManager::serverLogMessage, this, &self_type::onMessage);
......@@ -686,6 +762,11 @@ public:
updateState();
}
SessionDiagnosticSuppressions &sessionDiagnosticSuppressions()
{
return m_sessionDiagnosticSuppressions;
}
void onViewCreated(KTextEditor::View *view)
{
if (view) {
......@@ -860,7 +941,11 @@ public:
auto h = [menu](const QPoint &) {
menu->popup(QCursor::pos());
};
connect(treeView, &QTreeView::customContextMenuRequested, h);
if (m_diagnosticsTree == treeView) {
connect(treeView, &QTreeView::customContextMenuRequested, this, &self_type::onDiagnosticsMenu);
} else {
connect(treeView, &QTreeView::customContextMenuRequested, h);
}
}
void displayOptionChanged()
......@@ -999,6 +1084,11 @@ public:
KTextEditor::View *activeView = m_mainWindow->activeView();
KTextEditor::ConfigInterface *ciface = qobject_cast<KTextEditor::ConfigInterface *>(activeView);
// only consider enabled items
if (!(item->flags() & Qt::ItemIsEnabled)) {
return;
}
auto url = item->data(RangeData::FileUrlRole).toUrl();
// document url could end up empty while in intermediate reload state
// (and then it might match a parent item with no RangeData at all)
......@@ -1235,6 +1325,90 @@ public:
}
};
// helper data that holds diagnostics suppressions
class DiagnosticSuppression
{
struct Suppression {
QRegularExpression diag, code;
};
QVector<Suppression> m_suppressions;
QPointer<KTextEditor::Document> m_document;
public:
// construct from configuration
DiagnosticSuppression(self_type *self, KTextEditor::Document *doc, const QJsonObject &serverConfig)
: m_document(doc)
{
// check regexp and report
auto checkRegExp = [self](const QRegularExpression &regExp) {
auto valid = regExp.isValid();
if (!valid) {
auto error = regExp.errorString();
auto offset = regExp.patternErrorOffset();
auto msg = i18nc("@info", "Error in regular expression: %1\noffset %2: %3", regExp.pattern(), offset, error);
self->onShowMessage(KTextEditor::Message::Error, msg);
}
return valid;
};
Q_ASSERT(doc);
const auto localPath = doc->url().toLocalFile();
const auto supps = serverConfig.value(QStringLiteral("suppressions")).toObject();
for (const auto &entry : supps) {
// should be (array) tuple (last element optional)
// [url regexp, message regexp, code regexp]
const auto patterns = entry.toArray();
if (patterns.size() >= 2) {
const auto urlRegExp = QRegularExpression(patterns.at(0).toString());
if (urlRegExp.isValid() && urlRegExp.match(localPath).hasMatch()) {
QRegularExpression diagRegExp, codeRegExp;
diagRegExp = QRegularExpression(patterns.at(1).toString());
if (patterns.size() >= 3) {
codeRegExp = QRegularExpression(patterns.at(2).toString());
}
if (checkRegExp(diagRegExp) && checkRegExp(codeRegExp)) {
m_suppressions.push_back({diagRegExp, codeRegExp});
}
}
}
}
// also consider session suppressions
for (const auto &entry : self->m_sessionDiagnosticSuppressions.getSuppressions(localPath)) {
auto pattern = QRegularExpression::escape(entry);
m_suppressions.push_back({QRegularExpression(pattern), {}});
}
}
bool match(const QStandardItem &item) const
{
for (const auto &s : m_suppressions) {
if (s.diag.match(item.text()).hasMatch()) {
// retrieve and check code text if we need to match the content as well
if (m_document && !s.code.pattern().isEmpty()) {
auto range = item.data(RangeData::RangeRole).value<LSPRange>();
auto code = m_document->text(range);
if (!s.code.match(code).hasMatch()) {
continue;
}
}
return true;
}
}
return false;
}
KTextEditor::Document *document()
{
return m_document;
}
};
// likewise; a custom item for document level model item
struct DocumentDiagnosticItem : public QStandardItem {
QScopedPointer<DiagnosticSuppression> m_diagnosticSuppression;
bool m_enabled = true;
};
// double click on:
// diagnostic item -> request and add actions (below item)
// code action -> perform action (literal edit and/or execute command)
......@@ -2026,9 +2200,10 @@ public:
static QStandardItem *getItem(const QStandardItemModel &model, const QUrl &url)
{
auto l = model.findItems(url.toLocalFile());
// local file in custom role, Qt::DisplayRole might have additional elements
auto l = model.match(model.index(0, 0, QModelIndex()), Qt::UserRole, url.toLocalFile(), 1, Qt::MatchExactly);
if (l.length()) {
return l.at(0);
return model.itemFromIndex(l.at(0));
}
return nullptr;
}
......@@ -2065,6 +2240,9 @@ public:
}
for (int i = first; i < last; ++i) {
auto item = topItem->child(i);
if (!(item->flags() & Qt::ItemIsEnabled)) {
continue;
}
auto range = item->data(RangeData::RangeRole).value<LSPRange>();
if ((onlyLine && pos.line() == range.start().line()) || (range.contains(pos))) {
targetItem = item;
......@@ -2084,6 +2262,7 @@ public:
auto hint = QAbstractItemView::PositionAtTop;
QStandardItem *topItem = getItem(*m_diagnosticsModel, document->url());
updateDiagnosticsSuppression(topItem, document);
QStandardItem *targetItem = getItem(topItem, {line, 0}, true);
if (targetItem) {
hint = QAbstractItemView::PositionAtCenter;
......@@ -2125,6 +2304,69 @@ public:
}
}
Q_SLOT void onDiagnosticsMenu(const QPoint &pos)
{
Q_UNUSED(pos);
auto treeView = m_diagnosticsTree.data();
auto menu = new QMenu(m_diagnosticsTreeOwn.data());
menu->addAction(i18n("Expand All"), treeView, &QTreeView::expandAll);
menu->addAction(i18n("Collapse All"), treeView, &QTreeView::collapseAll);
menu->addSeparator();
QModelIndex index = treeView->currentIndex();
auto item = m_diagnosticsModel->itemFromIndex(index);
auto diagItem = dynamic_cast<DiagnosticItem *>(item);
auto docDiagItem = dynamic_cast<DocumentDiagnosticItem *>(item);
if (diagItem) {
auto diagText = index.data().toString();
auto parent = index.parent();
docDiagItem = dynamic_cast<DocumentDiagnosticItem *>(m_diagnosticsModel->itemFromIndex(parent));
// track validity of raw pointer
QPersistentModelIndex pindex(parent);
auto h = [this, pindex, diagText, docDiagItem](bool add, const QString &file, const QString &diagnostic) {
if (!pindex.isValid()) {
return;
}
if (add) {
m_sessionDiagnosticSuppressions.add(file, diagnostic);
} else {
m_sessionDiagnosticSuppressions.remove(file, diagnostic);
}
updateDiagnosticsSuppression(docDiagItem, docDiagItem->m_diagnosticSuppression->document(), true);
};
using namespace std::placeholders;
const auto empty = QString();
if (m_sessionDiagnosticSuppressions.hasSuppression(empty, diagText)) {
menu->addAction(i18n("Remove Global Suppression"), this, std::bind(h, false, empty, diagText));
} else {
menu->addAction(i18n("Add Global Suppression"), this, std::bind(h, true, empty, diagText));
}
auto file = parent.data(Qt::UserRole).toString();
if (m_sessionDiagnosticSuppressions.hasSuppression(file, diagText)) {
menu->addAction(i18n("Remove Local Suppression"), this, std::bind(h, false, file, diagText));
} else {
menu->addAction(i18n("Add Local Suppression"), this, std::bind(h, true, file, diagText));
}
} else if (docDiagItem) {
// track validity of raw pointer
QPersistentModelIndex pindex(index);
auto h = [this, docDiagItem, pindex](bool enabled) {
if (pindex.isValid()) {
docDiagItem->m_enabled = enabled;
}
updateDiagnosticsState(docDiagItem);
};
if (docDiagItem->m_enabled) {
menu->addAction(i18n("Disable Suppression"), this, std::bind(h, false));
} else {
menu->addAction(i18n("Enable Suppression"), this, std::bind(h, true));
}
}
menu->popup(QCursor::pos());
}
Q_SLOT void onMarkClicked(KTextEditor::Document *document, KTextEditor::Mark mark, bool &handled)
{
// no action if no mark was sprinkled here
......@@ -2149,9 +2391,10 @@ public:
if (diagnostics.diagnostics.empty()) {
return;
}
topItem = new QStandardItem();
topItem = new DocumentDiagnosticItem();
model->appendRow(topItem);
topItem->setText(diagnostics.uri.toLocalFile());
topItem->setData(diagnostics.uri.toLocalFile(), Qt::UserRole);
} else {
// try to retain current position
auto currentIndex = m_diagnosticsTree->currentIndex();
......@@ -2190,9 +2433,8 @@ public:
// TODO perhaps add some custom delegate that only shows 1 line
// and only the whole text when item selected ??
m_diagnosticsTree->setExpanded(topItem->index(), true);
m_diagnosticsTree->setRowHidden(topItem->row(), QModelIndex(), topItem->rowCount() == 0);
updateMarks();
updateDiagnosticsState(topItem);
// also sync updated diagnostic to current position
auto currentView = m_mainWindow->activeView();
if (currentView && currentView->document()) {
......@@ -2205,6 +2447,71 @@ public:
}
}
void updateDiagnosticsSuppression(QStandardItem *topItem, KTextEditor::Document *doc, bool force = false)
{
if (!topItem || !doc) {
return;
}
auto diagTopItem = static_cast<DocumentDiagnosticItem *>(topItem);
auto &suppressions = diagTopItem->m_diagnosticSuppression;
if (!suppressions || force) {
auto config = m_serverManager->findServerConfig(doc);
if (config.isObject()) {
auto supp = new DiagnosticSuppression(this, doc, config.toObject());
suppressions.reset(supp);
updateDiagnosticsState(topItem);
}
}
}
void updateDiagnosticsState(QStandardItem *topItem)
{
if (!topItem) {
return;
}
auto diagTopItem = static_cast<DocumentDiagnosticItem *>(topItem);
auto enabled = diagTopItem->m_enabled;
auto suppressions = enabled ? diagTopItem->m_diagnosticSuppression.data() : nullptr;
int totalCount = topItem->rowCount();
int count = 0;
for (int i = 0; i < totalCount; ++i) {
auto item = topItem->child(i);
auto hide = suppressions && item && suppressions->match(*item);
// mark accordingly as flag and (un)hide
auto flags = item->flags();
const auto ENABLED = Qt::ItemFlag::ItemIsEnabled;
if ((flags & ENABLED) != !hide) {
flags = hide ? (flags & ~ENABLED) : (flags | ENABLED);
item->setFlags(flags);
m_diagnosticsTree->setRowHidden(item->row(), topItem->index(), hide);
}
count += hide ? 0 : 1;
}
// adjust file item level text
auto suppressed = totalCount - count;
auto text = topItem->data(Qt::UserRole).toString();
topItem->setText(suppressed ? i18nc("@info", "%1 [suppressed: %2]", text, suppressed) : text);
// only hide if really nothing below
m_diagnosticsTree->setRowHidden(topItem->row(), QModelIndex(), totalCount == 0);
updateMarks();
}
void onServerChanged()
{
// need to clear suppressions
// will be filled at suitable time
auto &model = m_diagnosticsModel;
for (int i = 0; i < model->rowCount(); ++i) {
auto diagItem = static_cast<DocumentDiagnosticItem *>(model->item(i));
diagItem->m_diagnosticSuppression.reset();
}
updateState();
}
QString onTextHint(KTextEditor::View *view, const KTextEditor::Cursor &position)
{
QString result;
......@@ -2605,9 +2912,10 @@ public:
}
};
class LSPClientPluginViewImpl : public QObject, public KXMLGUIClient
class LSPClientPluginViewImpl : public QObject, public KXMLGUIClient, public KTextEditor::SessionConfigInterface
{
Q_OBJECT
Q_INTERFACES(KTextEditor::SessionConfigInterface)
typedef LSPClientPluginViewImpl self_type;
......@@ -2644,6 +2952,16 @@ public:
m_mainWindow->guiFactory()->removeClient(this);
}
void readSessionConfig(const KConfigGroup &config) override
{
m_actionView->sessionDiagnosticSuppressions().readSessionConfig(config);
}
void writeSessionConfig(KConfigGroup &config) override
{
m_actionView->sessionDiagnosticSuppressions().writeSessionConfig(config);
}
Q_SIGNALS:
/**
* Signal for outgoing message, the host application will handle them!
......
......@@ -179,6 +179,8 @@ class LSPClientServerManagerImpl : public LSPClientServerManager
struct DocumentInfo {
QSharedPointer<LSPClientServer> server;
// merged server config as obtain from various sources
QJsonObject config;
KTextEditor::MovingInterface *movingInterface;
QUrl url;
qint64 version;
......@@ -357,8 +359,9 @@ public:
auto it = m_docs.find(document);
auto server = it != m_docs.end() ? it->server : nullptr;
if (!server) {
if ((server = _findServer(view, document))) {
trackDocument(document, server);
QJsonObject serverConfig;
if ((server = _findServer(view, document, serverConfig))) {
trackDocument(document, server, serverConfig);
}
}
......@@ -368,6 +371,14 @@ public:
return server;
}
virtual QJsonValue findServerConfig(KTextEditor::Document *document) override
{
// check if document has been seen/processed by now
auto it = m_docs.find(document);
auto config = it != m_docs.end() ? QJsonValue(it->config) : QJsonValue::Null;
return config;
}
// restart a specific server or all servers if server == nullptr
void restart(LSPClientServer *server) override
{
......@@ -534,7 +545,7 @@ private:
}
}
QSharedPointer<LSPClientServer> _findServer(KTextEditor::View *view, KTextEditor::Document *document)
QSharedPointer<LSPClientServer> _findServer(KTextEditor::View *view, KTextEditor::Document *document, QJsonObject &mergedConfig)
{
// compute the LSP standardized language id, none found => no change
auto langId = languageId(document->highlightingMode());
......@@ -747,6 +758,7 @@ private:
serverinfo.useWorkspace = useWorkspace;
}
}
mergedConfig = serverConfig;
return (server && server->state() == LSPClientServer::State::Running) ? server : nullptr;
}
......@@ -807,12 +819,12 @@ private:
Q_EMIT serverChanged();
}
void trackDocument(KTextEditor::Document *doc, const QSharedPointer<LSPClientServer> &server)
void trackDocument(KTextEditor::Document *doc, const QSharedPointer<LSPClientServer> &server, QJsonObject serverConfig)
{
auto it = m_docs.find(doc);
if (it == m_docs.end()) {
KTextEditor::MovingInterface *miface = qobject_cast<KTextEditor::MovingInterface *>(doc);
it = m_docs.insert(doc, {server, miface, doc->url(), 0, false, false, {}});
it = m_docs.insert(doc, {server, serverConfig, miface, doc->url(), 0, false, false, {}});
// track document
connect(doc, &KTextEditor::Document::documentUrlChanged, this, &self_type::untrack, Qt::UniqueConnection);
connect(doc, &KTextEditor::Document::highlightingModeChanged, this, &self_type::untrack, Qt::UniqueConnection);
......
......@@ -43,6 +43,8 @@ public:
virtual QSharedPointer<LSPClientServer> findServer(KTextEditor::View *view, bool updatedoc = true) = 0;
virtual QJsonValue findServerConfig(KTextEditor::Document *document) = 0;
virtual void update(KTextEditor::Document *doc, bool force) = 0;
virtual void restart(LSPClientServer *server) = 0;
......
......@@ -2657,6 +2657,54 @@ transformed to the &JSON; configuration that is used here and outlined above.
</sect3>
<sect3 id="lspclient-diagnostics-suppression">
<title>LSP Server Diagnostic Suppression</title>
<para>
It may happen that diagnostics are reported which are not quite useful.
This can be quite cumbersome, especially if there are many (often of the same
kind). In some cases, this may be tweaked by language (server) specific means.
For example, the <ulink url="https://clangd.llvm.org/config.html">clangd
configuration mechanism</ulink> allows tweaking of some diagnostics aspects. In
general, however, it may not always be evident how to do so, or it may not even
be possible at all in desired ways due to server limitations or bug.
</para>
<para>
As such, the plugin supports diagnostics suppression similar to e.g. valgrind
suppressions. The most fine-grained configuration can be supplied in a
"suppressions" key in the (merged) &JSON; configuration.
</para>
<screen>
{
"servers": {
"c": {
"suppressions": {
"rulename": ["filename", "foo"],
"clang_pointer": ["", "clang-tidy", "clear_pointer"],
}
}
}
}
</screen>
<para>
Each (valid) rule has an arbitrary name and is defined by an array of length
2 or 3 which provides a regex to match against the (full) filename, a regex
to match against the diagnostic (text) and an optional regex matched against
the (source code range of) text to which the diagnostic applies.
</para>
<para>
In addition to the above fine-grained configuration, the context menu in the
diagnostics tab also supports add/remove of suppressions that match a particular
diagnostic (text) exactly, either globally (any file) or locally (the specific
file in question). These suppression are stored in and loaded from session config.
</para>
</sect3>
<sect3 id="lspclient-troubleshooting">
<title>LSP Server Troubleshooting</title>
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment