diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2650e6d04..063802580 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -177,6 +177,12 @@ qt_add_executable(NotepadNext widgets/QuickFindWidget.cpp widgets/QuickFindWidget.h widgets/QuickFindWidget.ui + widgets/FunctionListWidget.cpp + widgets/FunctionListWidget.h + widgets/CtagsSymbolManager.cpp + widgets/CtagsSymbolManager.h + widgets/EditorPane.cpp + widgets/EditorPane.h widgets/StatusLabel.cpp widgets/StatusLabel.h Sorter.h Sorter.cpp diff --git a/src/DockedEditor.cpp b/src/DockedEditor.cpp index 537ad50fe..2f2a4bf32 100644 --- a/src/DockedEditor.cpp +++ b/src/DockedEditor.cpp @@ -26,6 +26,7 @@ #include "DockAreaTitleBar.h" #include "ScintillaNext.h" +#include "EditorPane.h" #include @@ -70,7 +71,7 @@ DockedEditor::DockedEditor(QWidget *parent) : QObject(parent) connect(dockManager, &ads::CDockManager::focusedDockWidgetChanged, this, [=, this](ads::CDockWidget* old, ads::CDockWidget* now) { Q_UNUSED(old) - ScintillaNext *editor = qobject_cast(now->widget()); + ScintillaNext *editor = EditorPane::editorFromWidget(now->widget()); currentEditor = editor; editor->grabFocus(); @@ -96,6 +97,19 @@ DockedEditor::DockedEditor(QWidget *parent) : QObject(parent) } +ads::CDockWidget *DockedEditor::dockWidgetForEditor(const ScintillaNext *editor) +{ + if (editor == Q_NULLPTR) + return Q_NULLPTR; + + QWidget *w = const_cast(editor)->parentWidget(); + // The editor may be wrapped inside an EditorPane (function list splitter) + if (qobject_cast(w) != Q_NULLPTR) + w = w->parentWidget(); + return qobject_cast(w); +} + + ScintillaNext *DockedEditor::getCurrentEditor() const { return currentEditor; @@ -118,7 +132,7 @@ QVector DockedEditor::editors() const // For each area, for each widget, append it to our list for (const ads::CDockAreaWidget* areaWidget : dockManager->openedDockAreas()) { for (const ads::CDockWidget* dockWidget : areaWidget->dockWidgets()) { - editors.append(qobject_cast(dockWidget->widget())); + editors.append(EditorPane::editorFromWidget(dockWidget->widget())); } } @@ -127,7 +141,7 @@ QVector DockedEditor::editors() const void DockedEditor::switchToEditor(const ScintillaNext *editor) { - ads::CDockWidget *dockWidget = qobject_cast(editor->parentWidget()); + ads::CDockWidget *dockWidget = dockWidgetForEditor(editor); if (dockWidget == Q_NULLPTR) { qWarning() << "Expected editor's parent to be CDockWidget"; @@ -140,7 +154,7 @@ void DockedEditor::switchToEditor(const ScintillaNext *editor) void DockedEditor::dockWidgetCloseRequested() { ads::CDockWidget *dockWidget = qobject_cast(sender()); - ScintillaNext *editor = qobject_cast(dockWidget->widget()); + ScintillaNext *editor = EditorPane::editorFromWidget(dockWidget->widget()); emit editorCloseRequested(editor); } @@ -169,7 +183,11 @@ void DockedEditor::addEditor(ScintillaNext *editor) // We need a unique object name. Can't use the name or file path so use a uuid dockWidget->setObjectName(QUuid::createUuid().toString()); - dockWidget->setWidget(editor); + // Wrap the editor together with a function list panel. The panel is + // hidden by default; the dock widget content is the pane itself so the + // function list can never overlap other docks such as Folder as Workspace. + EditorPane *pane = new EditorPane(editor); + dockWidget->setWidget(pane); dockWidget->setFeature(ads::CDockWidget::DockWidgetFeature::DockWidgetDeleteOnClose, true); dockWidget->setFeature(ads::CDockWidget::DockWidgetFeature::CustomCloseHandling, true); dockWidget->setFeature(ads::CDockWidget::DockWidgetFeature::DockWidgetFloatable, false); @@ -218,7 +236,7 @@ void DockedEditor::editorRenamed(ScintillaNext *editor) { Q_ASSERT(editor != Q_NULLPTR); - ads::CDockWidget *dockWidget = qobject_cast(editor->parentWidget()); + ads::CDockWidget *dockWidget = dockWidgetForEditor(editor); dockWidget->setWindowTitle(editor->getName()); @@ -234,7 +252,7 @@ void DockedEditor::splitToRight(ScintillaNext *editor) { Q_ASSERT(editor != Q_NULLPTR); - ads::CDockWidget *newDockWidget = qobject_cast(editor->parentWidget()); + ads::CDockWidget *newDockWidget = dockWidgetForEditor(editor); if (newDockWidget) { ads::CDockAreaWidget *currentArea = currentDockArea(); if (currentArea) { @@ -247,7 +265,7 @@ void DockedEditor::splitToBottom(ScintillaNext *editor) { Q_ASSERT(editor != Q_NULLPTR); - ads::CDockWidget *newDockWidget = qobject_cast(editor->parentWidget()); + ads::CDockWidget *newDockWidget = dockWidgetForEditor(editor); if (newDockWidget) { ads::CDockAreaWidget *currentArea = currentDockArea(); if (currentArea) { diff --git a/src/DockedEditor.h b/src/DockedEditor.h index 0424cca4b..bec34f975 100644 --- a/src/DockedEditor.h +++ b/src/DockedEditor.h @@ -37,6 +37,10 @@ class DockedEditor : public QObject public: explicit DockedEditor(QWidget *parent); + // Returns the dock widget hosting the editor (even when the editor is + // wrapped inside an EditorPane together with a function list) + static ads::CDockWidget *dockWidgetForEditor(const ScintillaNext *editor); + ScintillaNext *getCurrentEditor() const; ads::CDockAreaWidget *currentDockArea() const; diff --git a/src/dialogs/MainWindow.cpp b/src/dialogs/MainWindow.cpp index d689984a0..1fd3b01d9 100644 --- a/src/dialogs/MainWindow.cpp +++ b/src/dialogs/MainWindow.cpp @@ -77,6 +77,10 @@ #include "QuickFindWidget.h" +#include "EditorPane.h" +#include "FunctionListWidget.h" +#include "CtagsSymbolManager.h" + #include "EditorPrintPreviewRenderer.h" #include "MacroEditorDialog.h" @@ -94,6 +98,16 @@ #include "ActionUtils.h" +// Opt-in diagnostics for the function list: set NOTEPADNEXT_FUNCTIONLIST_DEBUG=1 +// to trace why the panel stays empty (which language was detected, which ctags +// binary is used and how many symbols came back). +static bool functionListDebugEnabled() +{ + static const bool enabled = qEnvironmentVariableIsSet("NOTEPADNEXT_FUNCTIONLIST_DEBUG"); + return enabled; +} + + MainWindow::MainWindow(NotepadNextApplication *app) : ui(new Ui::MainWindow), app(app), @@ -1002,6 +1016,31 @@ MainWindow::MainWindow(NotepadNextApplication *app) : addDockWidget(Qt::LeftDockWidgetArea, fileListDock); ui->menuView->addAction(fileListDock->toggleViewAction()); + // ---------- Function list (attached inside the editor, not a dock) ---------- + ctagsManager = new CtagsSymbolManager(this); + + functionListAction = ui->menuView->addAction(tr("函数列表")); + functionListAction->setCheckable(true); + functionListAction->setChecked(app->getSettings()->value("FunctionList/Visible", true).toBool()); + functionListAction->setObjectName(QStringLiteral("actionFunctionList")); + connect(functionListAction, &QAction::toggled, this, [this, app](bool on) { + app->getSettings()->setValue("FunctionList/Visible", on); + for (ScintillaNext *editor : dockedEditor->editors()) { + updateFunctionList(editor); + } + }); + + // The language and file name maps of ctags are probed in the background at + // start up. Re-evaluate every open file once they are there, so that a panel + // which had to be guessed before the probes finished ends up in the right + // state instead of staying wrong. + connect(ctagsManager, &CtagsSymbolManager::knowledgeReady, this, [this]() { + qInfo(Q_FUNC_INFO); + const QVector editors = dockedEditor->editors(); + for (ScintillaNext *editor : editors) + updateFunctionList(editor); + }); + connect(app->getSettings(), &ApplicationSettings::showMenuBarChanged, this, [this](bool showMenuBar) { // Don't 'hide' it, else the actions won't be enabled ui->menuBar->setMaximumHeight(showMenuBar ? QWIDGETSIZE_MAX : 0); @@ -1373,7 +1412,7 @@ void MainWindow::closeAllToLeft() QVector editors; for (int i = 0; i < index; ++i) { - auto editor = qobject_cast(dockedEditor->currentDockArea()->dockWidget(i)->widget()); + auto editor = EditorPane::editorFromWidget(dockedEditor->currentDockArea()->dockWidget(i)->widget()); editors.append(editor); } @@ -1391,7 +1430,7 @@ void MainWindow::closeAllToRight() QVector editors; for (int i = index + 1; i < total; ++i) { - auto editor = qobject_cast(dockedEditor->currentDockArea()->dockWidget(i)->widget()); + auto editor = EditorPane::editorFromWidget(dockedEditor->currentDockArea()->dockWidget(i)->widget()); editors.append(editor); } @@ -1859,6 +1898,7 @@ void MainWindow::activateEditor(ScintillaNext *editor) checkFileForModification(editor); updateGui(editor); + updateFunctionList(editor); emit editorActivated(editor); } @@ -2179,6 +2219,202 @@ void MainWindow::addEditor(ScintillaNext *editor) // The editor has been entirely configured at this point, so add it to the docked editor dockedEditor->addEditor(editor); + + setupFunctionList(editor); +} + +void MainWindow::setupFunctionList(ScintillaNext *editor) +{ + EditorPane *pane = EditorPane::paneForEditor(editor); + if (pane == Q_NULLPTR) + return; + + FunctionListWidget *funcList = pane->functionList(); + + // Click a symbol -> switch to this editor and jump to the line + connect(funcList, &FunctionListWidget::jumpToLineRequested, this, [this, editor](int lineNumber) { + dockedEditor->switchToEditor(editor); + editor->gotoLine(lineNumber - 1); + editor->grabFocus(); + }); + + // Regenerate the symbol list when the file is saved/reloaded/renamed or when + // its language changes. The cached result is dropped first, otherwise the + // refresh below would consider the file already done. The symbols currently + // on screen stay until the new ones arrive, so the panel does not flicker. + connect(editor, &ScintillaNext::saved, this, [this, editor]() { reparseFunctionList(editor); }); + connect(editor, &ScintillaNext::reloaded, this, [this, editor]() { reparseFunctionList(editor); }); + connect(editor, &ScintillaNext::renamed, this, [this, editor]() { reparseFunctionList(editor); }); + // Language changed -> re-evaluate visibility/contents + connect(editor, &ScintillaNext::lexerChanged, this, [this, editor]() { reparseFunctionList(editor); }); + + // File closed: clear the list and stop any in-flight background ctags + // process. Only this file's request is cancelled - other editors may have + // their own parse running at the same time. + connect(editor, &ScintillaNext::closed, this, [this, editor]() { + EditorPane *p = EditorPane::paneForEditor(editor); + if (p) + p->functionList()->clearSymbols(); + if (editor->isFile()) + ctagsManager->cancel(editor->getFilePath()); + }); + + // Deliver results for this editor (queued from the worker thread). + // The generation token guarantees only the newest request's result is applied. + QPointer editorGuard = editor; + connect(ctagsManager, &CtagsSymbolManager::symbolsReady, this, + [this, editorGuard](int generation, const QString &filePath, const QVector &symbols) { + Q_UNUSED(generation) + if (editorGuard.isNull()) + return; + ScintillaNext *editor = editorGuard.data(); + if (!editor->isFile() || editor->getFilePath() != filePath) + return; + EditorPane *p = EditorPane::paneForEditor(editor); + if (p == Q_NULLPTR) + return; + + // Order matters: clearing the busy flag first keeps the hint from + // coming back when the list below turns out to be empty. + FunctionListWidget *funcList = p->functionList(); + funcList->setBusy(false); + funcList->setSymbols(symbols); + + // Visibility is not touched here on purpose: the panel was opened + // when the parse was requested, once ctags had claimed the file. + // A result without symbols therefore leaves it open and shows a + // hint, which is what makes the panel appear the moment a file is + // opened instead of a moment later. + if (functionListDebugEnabled()) { + qInfo("FunctionList[debug]: %d symbol(s) delivered to %s, panel requested=%d", + int(symbols.size()), qUtf8Printable(filePath), + int(p->isFunctionListRequested())); + } + }); + + updateFunctionList(editor); +} + +// Single entry point of the function list. It is deliberately idempotent and +// cheap - the expensive part (ctags itself) always runs in the background - so +// it can be called on every file open, tab switch, save and menu toggle: +// +// * the panel is opened as soon as ctags says it can handle the file, well +// before any symbol exists, so switching to a file feels instant; +// * the symbol list then fills in when the background parse delivers. +void MainWindow::updateFunctionList(ScintillaNext *editor) +{ + if (editor == Q_NULLPTR) + return; + + EditorPane *pane = EditorPane::paneForEditor(editor); + if (pane == Q_NULLPTR) + return; + + FunctionListWidget *funcList = pane->functionList(); + funcList->setFileLabel(editor->getName()); + + // Feature switch (view menu action). There is no language whitelist any more: + // the language is resolved per file and ctags itself decides what it can + // parse, so every language either project knows about is covered by + // construction instead of by a table that has to be maintained. + const bool enabled = functionListAction != Q_NULLPTR && functionListAction->isChecked(); + pane->setFunctionListEnabled(enabled); + + // Unsaved buffers have no file on disk yet, nothing can be parsed for them. + const QString filePath = editor->isFile() ? editor->getFilePath() : QString(); + const QString ctagsLanguage = ctagsLanguageFor(editor->languageName); + const bool supported = enabled && !filePath.isEmpty() + && CtagsSymbolManager::canParse(filePath, ctagsLanguage); + + pane->setFunctionListVisible(supported); + + if (functionListDebugEnabled()) { + qInfo("FunctionList[debug]: file=%s language=\"%s\" ctagsLanguage=\"%s\" supported=%d panel=%d symbols=%d pending=%d parsed=%d", + qUtf8Printable(editor->getName()), + qUtf8Printable(editor->languageName), + ctagsLanguage.isEmpty() ? "(ctags auto-detect)" : qUtf8Printable(ctagsLanguage), + int(supported), int(pane->isFunctionListRequested()), + funcList->symbolCount(), int(ctagsManager->isPending(filePath)), + int(ctagsManager->hasParsed(filePath))); + } + + if (!supported) { + // Nothing to show, and no cached result may survive: without this a file + // switched off via the menu would still count as parsed and would come + // back empty when the feature is switched on again. + if (!filePath.isEmpty()) + ctagsManager->invalidate(filePath); + funcList->setBusy(false); + funcList->clearSymbols(); + return; + } + + // The list is already up to date (or on its way): this is what keeps tab + // switching from re-running ctags for every file over and over. + if (ctagsManager->isPending(filePath) || ctagsManager->hasParsed(filePath)) + return; + + // Announce the parse while the panel is still empty, so the wait is visible. + if (funcList->symbolCount() == 0) + funcList->setBusy(true); + + ctagsManager->request(filePath, ctagsLanguage); +} + +// The file changed on disk (or its language was changed), so whatever ctags said +// about it before is void. +void MainWindow::reparseFunctionList(ScintillaNext *editor) +{ + if (editor == Q_NULLPTR) + return; + + if (editor->isFile()) + ctagsManager->invalidate(editor->getFilePath()); + + updateFunctionList(editor); +} + +// Language names have to survive the differences in spelling between the two +// projects ("Objective-C" vs "ObjectiveC", "ADA" vs "Ada"), so case and +// separators are removed before comparing. '+' and '#' are deliberately kept: +// without them C, C++ and C# would all collapse into the same key. +static QString normalizeLanguageName(QString name) +{ + name = name.toLower(); + + static const QString separators = QStringLiteral(" \t.-_/()[]"); + for (const QChar separator : separators) + name.remove(separator); + + return name; +} + +QString MainWindow::ctagsLanguageFor(const QString &languageName) +{ + if (languageName.isEmpty()) + return QString(); + + const QString wanted = normalizeLanguageName(languageName); + if (wanted.isEmpty()) + return QString(); + + // The mapping is derived from ctags itself rather than being hand written, so + // it covers every language the installed build understands and stays correct + // across ctags upgrades. + // + // A name without a match returns an empty string, which makes the worker + // leave the language to ctags' own file name detection. That still covers + // every language ctags knows, so a missing entry only costs the cross-check + // (which is what corrects extensions like ".v", taken for V rather than + // Verilog) and never the feature itself. + const QStringList known = CtagsSymbolManager::knownLanguages(); + for (const QString &candidate : known) { + if (normalizeLanguageName(candidate) == wanted) + return candidate; + } + + return QString(); } void MainWindow::checkForUpdates(bool silent) diff --git a/src/dialogs/MainWindow.h b/src/dialogs/MainWindow.h index b79b0b836..c3ee049f3 100644 --- a/src/dialogs/MainWindow.h +++ b/src/dialogs/MainWindow.h @@ -45,6 +45,7 @@ class ShiftWheelToHorizontalScrollFilter; class Converter; class DefaultDirectoryManager; class TabsQuickActionsBar; +class CtagsSymbolManager; class MainWindow : public QMainWindow { @@ -125,6 +126,14 @@ public slots: void addEditor(ScintillaNext *editor); + void setupFunctionList(ScintillaNext *editor); + // Single entry point of the function list: decides whether the panel belongs + // on screen and makes sure a background parse is on its way when it does. + void updateFunctionList(ScintillaNext *editor); + // updateFunctionList() plus a dropped ctags result, for changed files + void reparseFunctionList(ScintillaNext *editor); + static QString ctagsLanguageFor(const QString &languageName); + void checkForUpdates(bool silent = false); void restoreWindowState(); @@ -152,6 +161,9 @@ private slots: NotepadNextApplication *app = Q_NULLPTR; DockedEditor *dockedEditor = Q_NULLPTR; + CtagsSymbolManager *ctagsManager = Q_NULLPTR; + QAction *functionListAction = Q_NULLPTR; + QScopedPointer searchResults; template diff --git a/src/widgets/CtagsSymbolManager.cpp b/src/widgets/CtagsSymbolManager.cpp new file mode 100644 index 000000000..15e7c2893 --- /dev/null +++ b/src/widgets/CtagsSymbolManager.cpp @@ -0,0 +1,939 @@ +/* + * This file is part of Notepad Next. + * Copyright 2026 Notepad Next contributors + * + * Background ctags-based symbol extraction - implementation. + * + * This file is part of a feature licensed under the GNU General Public + * License as published by the Free Software Foundation, either version 3 + * of the License, or (at your option) any later version. + */ + +#include "CtagsSymbolManager.h" + +#include +#include +#include +#include + +namespace { +// ctags writes to stdout ('-f -'), so no temporary files are ever created +// on disk. Every symbol line looks like: +// namefile/^pattern$/;"kindline:N[extra...] +// The extension fields (kind, line:N, ...) follow the tab-separated field that +// carries the /^pattern$/;" part - the ;" marker is appended to the pattern +// itself, it is NOT a field of its own. +constexpr int MaxOutputBytes = 32 * 1024 * 1024; // safety cap: 32 MB + +// Upper bound of candidates probed for a single parse. Only used as a guard +// against pathological PATH entries. +constexpr int MaxCtagsCandidates = 32; + +#ifdef Q_OS_WIN +constexpr const char *CtagsBinaryName = "ctags.exe"; +#else +constexpr const char *CtagsBinaryName = "ctags"; +#endif + +// The candidate cache below is shared between the GUI thread (which calls +// ctagsExecutable() to decide whether to bother requesting a parse at all) and +// the worker thread (which validates and runs the binary). +QMutex ctagsMutex; +QStringList cachedCandidates; +bool candidatesCached = false; +QSet rejectedCtags; // proven unusable, skipped from now on +QHash ctagsValidity; // memoized "--version" probe results +bool warnedNoCtags = false; +QHash knownLanguagesCache; // executable -> --list-languages +QHash> knownFilePatternsCache; // executable -> --list-maps + +int extensionFieldIndex(const QStringList &fields) +{ + // Standard case: the marker is glued to the pattern in field 2 + if (fields.size() > 3 && fields.at(2).endsWith(QStringLiteral(";\""))) + return 3; + + // Defensive: some ctags builds emit the marker as its own field + for (int i = 2; i < fields.size(); ++i) { + if (fields.at(i) == QStringLiteral(";\"")) + return i + 1; + } + + return -1; +} + +// ctags echoes identifier bytes exactly as they appear in the source file, so a +// GBK/Shift-JIS file on Windows produces bytes that are not valid UTF-8. Fall +// back to the local 8-bit codec in that case (which is UTF-8 on macOS/Linux and +// the ANSI code page on Windows) so symbol names are shown correctly instead of +// as replacement characters. +QString decodeTagLine(const QByteArray &raw) +{ + const QString text = QString::fromUtf8(raw); + if (text.contains(QChar::ReplacementCharacter)) + return QString::fromLocal8Bit(raw); + return text; +} + +// Normalizes an existing file into a canonical path so that symlinked or +// differently spelled candidates are probed only once. Returns an empty string +// when the candidate does not exist (directories are not candidates). +QString normalizeCandidate(const QString &path) +{ + const QFileInfo info(path); + if (!info.isFile()) + return QString(); + const QString canonical = info.canonicalFilePath(); + return canonical.isEmpty() ? info.absoluteFilePath() : canonical; +} + +void appendCandidate(QStringList &out, QSet &seen, const QString &path) +{ + const QString normalized = normalizeCandidate(path); + if (normalized.isEmpty() || seen.contains(normalized)) + return; + seen.insert(normalized); + out.append(normalized); +} + +// Every PATH entry, in order. Some toolchains (for example the MinGW bundles +// shipped with GCC distributions) install GNU Emacs etags under the name +// "ctags"; such an entry has to be rejectable so that a later PATH entry or a +// well known location can still be used. That is why PATH is enumerated +// instead of stopping at the first hit like QStandardPaths::findExecutable does. +QStringList pathDirectories() +{ + QStringList directories; + const QString rawPath = QString::fromLocal8Bit(qgetenv("PATH")); + const QStringList parts = rawPath.split(QDir::listSeparator(), Qt::SkipEmptyParts); + + for (QString part : parts) { + part.remove(QLatin1Char('"')); + part = part.trimmed(); + if (!part.isEmpty()) + directories.append(part); + } + + return directories; +} + +// Ordered list of locations worth trying, on every platform. +QStringList collectCandidates() +{ + QStringList out; + QSet seen; + + // 1. Explicit override - escape hatch for unusual installations. + const QByteArray override = qgetenv("NOTEPADNEXT_CTAGS"); + if (!override.isEmpty()) + appendCandidate(out, seen, QFile::decodeName(override)); + + // 2. Next to the executable, so a portable or bundled build can carry its + // own copy (macOS bundle: Contents/MacOS, then Contents/Resources). + const QString appDir = QCoreApplication::applicationDirPath(); + appendCandidate(out, seen, appDir + QLatin1Char('/') + QLatin1String(CtagsBinaryName)); +#ifdef Q_OS_MACOS + appendCandidate(out, seen, + appDir + QLatin1String("/../Resources/") + QLatin1String(CtagsBinaryName)); +#endif + + // 3. Well known installation prefixes. GUI applications on macOS inherit + // only a minimal PATH from launchd, so a Homebrew/MacPorts install has to + // be listed explicitly or it would never be found. +#ifdef Q_OS_MACOS + appendCandidate(out, seen, QStringLiteral("/opt/homebrew/bin/ctags")); // Homebrew, Apple Silicon + appendCandidate(out, seen, QStringLiteral("/usr/local/bin/ctags")); // Homebrew, Intel + appendCandidate(out, seen, QStringLiteral("/opt/local/bin/ctags")); // MacPorts + appendCandidate(out, seen, QStringLiteral("/usr/bin/ctags")); // ships with Xcode tools +#elif !defined(Q_OS_WIN) + appendCandidate(out, seen, QStringLiteral("/usr/local/bin/ctags")); + appendCandidate(out, seen, QStringLiteral("/usr/bin/ctags")); + appendCandidate(out, seen, QStringLiteral("/snap/bin/ctags")); + appendCandidate(out, seen, QStringLiteral("/var/lib/flatpak/exports/bin/ctags")); +#endif + + appendCandidate(out, seen, + QDir::homePath() + QLatin1String("/.local/bin/") + QLatin1String(CtagsBinaryName)); + + // 4. Plain PATH lookup, every entry in order. + const QStringList directories = pathDirectories(); + for (const QString &directory : directories) + appendCandidate(out, seen, QDir(directory).filePath(QLatin1String(CtagsBinaryName))); + + return out; +} + +QStringList ctagsCandidates() +{ + QMutexLocker locker(&ctagsMutex); + if (!candidatesCached) { + cachedCandidates = collectCandidates(); + candidatesCached = true; + } + return cachedCandidates; +} + +// A binary is only accepted when it identifies itself as Universal Ctags or +// Exuberant Ctags: those are the implementations supporting the command line +// used here. GNU Emacs etags ("etags (GNU Emacs ...)") and BSD ctags print a +// different banner and are rejected. +bool probeCtags(const QString &executable) +{ + QProcess probe; + probe.setProgram(executable); + probe.setArguments(QStringList() << QStringLiteral("--version")); + probe.start(); + + if (!probe.waitForStarted(3000)) + return false; + + if (!probe.waitForFinished(5000)) { + probe.kill(); + probe.waitForFinished(1000); + return false; + } + + const QByteArray banner = probe.readAllStandardOutput() + probe.readAllStandardError(); + const QString text = QString::fromLatin1(banner); + + return text.contains(QLatin1String("Universal Ctags"), Qt::CaseInsensitive) + || text.contains(QLatin1String("Exuberant Ctags"), Qt::CaseInsensitive); +} +} // namespace + +CtagsWorker::CtagsWorker(QObject *parent) + : QObject(parent) +{ + qRegisterMetaType(); + qRegisterMetaType>(); +} + +CtagsWorker::~CtagsWorker() +{ + QMutexLocker locker(&mutex); + pending.clear(); + abortProcessLocked(); +} + +void CtagsWorker::doRequest(int generation, const QString &filePath, const QString &ctagsLanguage) +{ + QMutexLocker locker(&mutex); + + // A newer request for the same file replaces anything still queued + for (int i = pending.size() - 1; i >= 0; --i) { + if (pending.at(i).filePath == filePath) + pending.removeAt(i); + } + + if (process != nullptr && currentFile == filePath) { + // Same file requested again (e.g. it was saved): restart from scratch + abortProcessLocked(); + } + else if (process != nullptr) { + // Another file is being parsed right now - never kill it, queue instead + pending.append({generation, filePath, ctagsLanguage}); + return; + } + + startRetries = 0; + forceRetries = 0; + locker.unlock(); + startProcess(generation, filePath, ctagsLanguage); +} + +void CtagsWorker::cancel(const QString &filePath) +{ + QMutexLocker locker(&mutex); + + for (int i = pending.size() - 1; i >= 0; --i) { + if (pending.at(i).filePath == filePath) + pending.removeAt(i); + } + + if (currentFile == filePath) + abortProcessLocked(); +} + +void CtagsWorker::cancelAll() +{ + QMutexLocker locker(&mutex); + pending.clear(); + abortProcessLocked(); + currentFile.clear(); +} + +// Runs on the worker thread at start up. Both probes spawn ctags once and cache +// the result for the whole process, which is what keeps canParse() free of +// subprocesses on the GUI thread later on. +void CtagsWorker::warmUp() +{ + CtagsSymbolManager::knownLanguages(); + CtagsSymbolManager::knownFilePatterns(); + emit knowledgeReady(); +} + +// Walks the candidate list until one binary passes verification. Candidates +// that turn out not to be a usable ctags build are rejected permanently, so +// every later parse starts one step further down the list. +QString CtagsWorker::usableCtagsExecutable() +{ + for (int attempt = 0; attempt < MaxCtagsCandidates; ++attempt) { + const QString candidate = CtagsSymbolManager::ctagsExecutable(); + if (candidate.isEmpty()) + return QString(); + + if (CtagsSymbolManager::validateCtags(candidate)) + return candidate; + + CtagsSymbolManager::rejectCtags(candidate); + } + + return QString(); +} + +void CtagsWorker::startProcess(int generation, const QString &filePath, const QString &ctagsLanguage) +{ + if (!QFileInfo::exists(filePath)) { + // Deleted, or an unsaved buffer: nothing to parse, but the caller must + // still get an answer so the pending flag is cleared. + emit symbolsReady(generation, filePath, {}); + startNextPending(); + return; + } + + const QString ctagsPath = usableCtagsExecutable(); + if (ctagsPath.isEmpty()) { + emit symbolsReady(generation, filePath, {}); + startNextPending(); + return; + } + + QStringList args; + args << QStringLiteral("-f") << QStringLiteral("-") // tags to stdout + << QStringLiteral("--sort=no") // keep file order + << QStringLiteral("--fields=+n"); // add line numbers + + // The language is forced instead of relying on the file name so that header + // files are parsed in the language of their editor. Older builds do not know + // every language, in which case the parse is retried without this option + // (see onProcessFinished): that keeps .sv/.svh and friends working on + // Exuberant Ctags. + if (!ctagsLanguage.isEmpty()) + args << (QStringLiteral("--language-force=") + ctagsLanguage); + + args << QDir::toNativeSeparators(filePath); // identity on POSIX + + { + QMutexLocker locker(&mutex); + currentGen = generation; + currentFile = filePath; + currentLanguage = ctagsLanguage; + currentExecutable = ctagsPath; + outputBuffer.clear(); + errorBuffer.clear(); + + process = new QProcess(this); + process->setWorkingDirectory(QFileInfo(filePath).absolutePath()); + + connect(process, &QProcess::readyReadStandardOutput, + this, &CtagsWorker::onReadyReadStandardOutput); + connect(process, &QProcess::readyReadStandardError, + this, &CtagsWorker::onReadyReadStandardError); + connect(process, qOverload(&QProcess::finished), + this, &CtagsWorker::onProcessFinished); + connect(process, &QProcess::errorOccurred, + this, &CtagsWorker::onProcessError); + } + + // Started outside the lock: QProcess::start() may emit signals synchronously + process->start(ctagsPath, args); +} + +void CtagsWorker::abortProcessLocked() +{ + if (process == nullptr) + return; + + // Disconnect first: onProcessFinished() must not run re-entrantly while + // the mutex is held (that is what used to deadlock). The killed process is + // simply discarded instead - no waitForFinished() is needed. + process->disconnect(this); + if (process->state() != QProcess::NotRunning) + process->kill(); + process->deleteLater(); + process = nullptr; + outputBuffer.clear(); + errorBuffer.clear(); +} + +void CtagsWorker::startNextPending() +{ + PendingRequest next; + { + QMutexLocker locker(&mutex); + if (process != nullptr || pending.isEmpty()) + return; + next = pending.takeFirst(); + startRetries = 0; + forceRetries = 0; + } + startProcess(next.generation, next.filePath, next.ctagsLanguage); +} + +void CtagsWorker::onReadyReadStandardOutput() +{ + QMutexLocker locker(&mutex); + if (!process) + return; + + outputBuffer += process->readAllStandardOutput(); + + if (outputBuffer.size() > MaxOutputBytes) { + // Absurdly large output - drop this parse instead of exhausting memory. + // An (empty) result is still delivered so that the pending flag is + // cleared and the queue keeps moving. + const int gen = currentGen; + const QString file = currentFile; + abortProcessLocked(); + locker.unlock(); + + if (gen != 0) + emit symbolsReady(gen, file, {}); + startNextPending(); + } +} + +void CtagsWorker::onReadyReadStandardError() +{ + QMutexLocker locker(&mutex); + if (!process) + return; + // Only kept to classify failures (unknown language, ...); ctags writes all + // tag data to stdout. + errorBuffer += process->readAllStandardError(); + if (errorBuffer.size() > 8192) + errorBuffer = errorBuffer.right(8192); +} + +void CtagsWorker::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) +{ + QByteArray output; + QByteArray errors; + int gen = 0; + QString file; + QString language; + + { + QMutexLocker locker(&mutex); + if (!process) + return; + + output = outputBuffer; + errors = errorBuffer; + gen = currentGen; + file = currentFile; + language = currentLanguage; + + outputBuffer.clear(); + errorBuffer.clear(); + currentFile.clear(); + currentLanguage.clear(); + currentExecutable.clear(); + process->deleteLater(); + process = nullptr; + } + + if (exitStatus != QProcess::NormalExit || gen == 0) { + // The parser died (crash, killed, ...). An empty result still has to be + // reported, otherwise the pending flag for this file would never clear. + startRetries = 0; + forceRetries = 0; + if (gen != 0 && exitStatus != QProcess::NormalExit) + emit symbolsReady(gen, file, {}); + startNextPending(); + return; + } + + // ctags rejects a language it does not know with "Unknown language ..." and + // exit code 1 (Exuberant Ctags, still shipped by macOS, lacks SystemVerilog + // for instance). Retry once letting ctags pick the language from the file + // name instead of giving up with an empty list. + if (exitCode != 0 && !language.isEmpty() + && QString::fromLatin1(errors).contains(QStringLiteral("language"), Qt::CaseInsensitive) + && forceRetries < 1) { + ++forceRetries; + ++startRetries; // keep the re-tries bounded as a whole + startProcess(gen, file, QString()); + return; + } + + startRetries = 0; + forceRetries = 0; + + // The manager decides per file whether a result is still wanted + emit symbolsReady(gen, file, parseOutput(output)); + + startNextPending(); +} + +// QProcess does not emit finished() when a program could not be launched at all +// (wrong architecture, missing shared library, no execute permission, ...). +// Without handling this the worker would wait forever and every later request +// would pile up in the queue. The offending binary is blacklisted and the parse +// is retried with the next candidate. +void CtagsWorker::onProcessError(QProcess::ProcessError error) +{ + if (error != QProcess::FailedToStart) + return; // the remaining error kinds are followed by finished() + + int gen = 0; + QString file; + QString language; + QString executable; + + { + QMutexLocker locker(&mutex); + if (!process) + return; + + gen = currentGen; + file = currentFile; + language = currentLanguage; + executable = currentExecutable; + + process->disconnect(this); + process->deleteLater(); + process = nullptr; + outputBuffer.clear(); + errorBuffer.clear(); + currentFile.clear(); + currentLanguage.clear(); + currentExecutable.clear(); + } + + CtagsSymbolManager::rejectCtags(executable); + + if (!file.isEmpty() && startRetries < 3) { + ++startRetries; + startProcess(gen, file, language); + return; + } + + startRetries = 0; + forceRetries = 0; + + if (gen != 0) + emit symbolsReady(gen, file, {}); + startNextPending(); +} + +QVector CtagsWorker::parseOutput(const QByteArray &output) const +{ + QVector result; + const QList lines = output.split('\n'); + + for (const QByteArray &rawLine : lines) { + const QString line = decodeTagLine(rawLine).trimmed(); + if (line.isEmpty() || line.startsWith(QLatin1Char('!'))) + continue; // header/comment lines + + const QStringList fields = line.split(QLatin1Char('\t')); + if (fields.size() < 4) + continue; + + const int extIndex = extensionFieldIndex(fields); + if (extIndex < 0) + continue; + + FunctionSymbol sym; + sym.name = fields.at(0); + if (sym.name.isEmpty()) + continue; + + // Everything after the ;" marker: kind letter, line:N, file:, scope:... + bool kindSeen = false; + for (int i = extIndex; i < fields.size(); ++i) { + const QString &f = fields.at(i); + if (f.startsWith(QStringLiteral("line:"))) { + sym.line = f.mid(5).toInt(); + } + else if (f.startsWith(QStringLiteral("kind:"))) { + sym.kind = f.mid(5); + kindSeen = true; + } + else if (!kindSeen && !f.contains(QLatin1Char(':'))) { + // Classic single letter kind: f, p, m, v, d, s, t, c, ... + sym.kind = f; + kindSeen = true; + } + } + + if (sym.line > 0) + result.append(sym); + } + + return result; +} + + +CtagsSymbolManager::CtagsSymbolManager(QObject *parent) + : QObject(parent) +{ + qRegisterMetaType(); + qRegisterMetaType>(); + + worker = new CtagsWorker(); + worker->moveToThread(&workerThread); + + connect(&workerThread, &QThread::finished, worker, &QObject::deleteLater); + // Queued back to the GUI thread + connect(worker, &CtagsWorker::symbolsReady, this, &CtagsSymbolManager::onSymbolsReady, Qt::QueuedConnection); + connect(worker, &CtagsWorker::knowledgeReady, this, &CtagsSymbolManager::knowledgeReady, Qt::QueuedConnection); + + workerThread.start(); + + // Resolving the language list and the file name maps runs a subprocess each, + // so warm them up on the worker thread right away. By the time the first file + // is opened the caches are populated and the GUI thread never has to wait for + // a probe. knowledgeReady() is emitted when they are done, which is after any + // connection made by the code that constructs this manager, so it can never + // be missed. + QMetaObject::invokeMethod(worker, "warmUp", Qt::QueuedConnection); +} + +CtagsSymbolManager::~CtagsSymbolManager() +{ + cancelAll(); + workerThread.quit(); + workerThread.wait(5000); +} + +int CtagsSymbolManager::request(const QString &filePath, const QString &ctagsLanguage) +{ + const int gen = generationCounter.fetchAndAddRelaxed(1) + 1; + pendingByFile.insert(filePath, gen); + + // Queued to the worker thread; requests are processed in order + QMetaObject::invokeMethod(worker, "doRequest", Qt::QueuedConnection, + Q_ARG(int, gen), + Q_ARG(QString, filePath), + Q_ARG(QString, ctagsLanguage)); + return gen; +} + +void CtagsSymbolManager::cancel(const QString &filePath) +{ + invalidate(filePath); + QMetaObject::invokeMethod(worker, "cancel", Qt::QueuedConnection, + Q_ARG(QString, filePath)); +} + +void CtagsSymbolManager::cancelAll() +{ + pendingByFile.clear(); + parsedFiles.clear(); + QMetaObject::invokeMethod(worker, "cancelAll", Qt::QueuedConnection); +} + +void CtagsSymbolManager::invalidate(const QString &filePath) +{ + // Dropping the pending generation is enough for an in-flight result to be + // discarded when it arrives, so the worker is left alone here: the request + // that normally follows right away wants the file parsed anyway. + pendingByFile.remove(filePath); + parsedFiles.remove(filePath); +} + +bool CtagsSymbolManager::isPending(const QString &filePath) const +{ + return pendingByFile.contains(filePath); +} + +bool CtagsSymbolManager::hasParsed(const QString &filePath) const +{ + return parsedFiles.contains(filePath); +} + +void CtagsSymbolManager::onSymbolsReady(int generation, const QString &filePath, const QVector &symbols) +{ + // Drop results superseded by a newer request for the same file + auto it = pendingByFile.find(filePath); + if (it == pendingByFile.end() || it.value() != generation) + return; + pendingByFile.erase(it); + + // An empty result is a result: it marks the file as "done, nothing to find" + // so that switching tabs does not re-run ctags for it over and over. + parsedFiles.insert(filePath); + + // Re-emit on the GUI thread; consumers validate the file path + emit symbolsReady(generation, filePath, symbols); +} + +QString CtagsSymbolManager::ctagsExecutable() +{ + const QStringList candidates = ctagsCandidates(); + + QMutexLocker locker(&ctagsMutex); + for (const QString &candidate : candidates) { + if (!rejectedCtags.contains(candidate)) + return candidate; + } + + if (!warnedNoCtags) { + warnedNoCtags = true; + if (candidates.isEmpty()) { + qWarning("FunctionList: no ctags executable found, the function list will stay empty. " + "Install Universal Ctags (macOS: brew install universal-ctags / Linux: " + "apt install universal-ctags / Windows: https://ctags.io) or set the " + "NOTEPADNEXT_CTAGS environment variable to its full path."); + } + else { + qWarning("FunctionList: none of the %d ctags candidates is usable, the function list " + "will stay empty", int(candidates.size())); + } + } + + return QString(); +} + +QStringList CtagsSymbolManager::knownLanguages() +{ + const QString executable = ctagsExecutable(); + if (executable.isEmpty()) + return QStringList(); + + { + QMutexLocker locker(&ctagsMutex); + const auto it = knownLanguagesCache.constFind(executable); + if (it != knownLanguagesCache.constEnd()) + return it.value(); + } + + QStringList languages; + + // Outside the lock: the probe takes ~30 ms and nothing here depends on + // holding it (a concurrent duplicate probe would only repeat harmless work). + QProcess probe; + probe.setProgram(executable); + probe.setArguments(QStringList() << QStringLiteral("--list-languages")); + probe.start(); + + if (probe.waitForStarted(3000) && probe.waitForFinished(10000)) { + const QStringList lines = QString::fromUtf8(probe.readAllStandardOutput()) + .split(QLatin1Char('\n'), Qt::SkipEmptyParts); + for (const QString &rawLine : lines) { + const QString line = rawLine.trimmed(); + // Placeholders such as "Unknown [disabled]" cannot be forced. + if (line.isEmpty() || line.contains(QLatin1Char('['))) + continue; + languages.append(line); + } + } + else { + probe.kill(); + probe.waitForFinished(1000); + } + + if (languages.isEmpty()) { + qWarning("FunctionList: 'ctags --list-languages' produced no usable output; " + "language names will be left to ctags' own file name detection"); + } + + QMutexLocker locker(&ctagsMutex); + knownLanguagesCache.insert(executable, languages); + return languages; +} + +// Expands the "[Mm]" style character classes of a ctags file name pattern, e.g. +// "[Mm]akefile" -> { "Makefile", "makefile" }. Returns an empty list when the +// pattern is malformed or needs more combinations than a name is worth. +QStringList expandNamePattern(const QString &pattern, int limit = 8) +{ + QStringList alternatives; + alternatives.append(QString()); + + for (int i = 0; i < pattern.size(); ++i) { + const QChar character = pattern.at(i); + + if (character != QLatin1Char('[')) { + for (QString &alternative : alternatives) + alternative.append(character); + continue; + } + + const int closing = pattern.indexOf(QLatin1Char(']'), i + 1); + if (closing < 0) + return QStringList(); // malformed pattern, ignore it + + QStringList expanded; + const QString members = pattern.mid(i + 1, closing - i - 1); + for (const QString &alternative : alternatives) { + for (const QChar member : members) { + expanded.append(alternative + member); + if (expanded.size() > limit) + return QStringList(); + } + } + + alternatives = expanded; + i = closing; + } + + return alternatives; +} + +QSet CtagsSymbolManager::knownFilePatterns() +{ + const QString executable = ctagsExecutable(); + if (executable.isEmpty()) + return QSet(); + + { + QMutexLocker locker(&ctagsMutex); + const auto it = knownFilePatternsCache.constFind(executable); + if (it != knownFilePatternsCache.constEnd()) + return it.value(); + } + + QSet patterns; + + // "ctags --list-maps" prints one line per language: + // C *.c *.h + // Asm *.A51 *.[68][68][kKsSxX] *.asm ... + // Make Makefile makefile *.mk + // The columns are separated by runs of blanks (space padded, NOT tab + // separated, which is why simplified() is used before splitting) and the + // lines may end in CRLF. Only literal patterns are kept: a real glob such as + // *.[68][68]k can never match a plain extension test anyway. + QProcess probe; + probe.setProgram(executable); + probe.setArguments(QStringList() << QStringLiteral("--list-maps")); + probe.start(); + + if (probe.waitForStarted(3000) && probe.waitForFinished(10000)) { + const QStringList lines = QString::fromUtf8(probe.readAllStandardOutput()) + .split(QLatin1Char('\n'), Qt::SkipEmptyParts); + for (const QString &rawLine : lines) { + const QStringList columns = rawLine.simplified().split(QLatin1Char(' '), Qt::SkipEmptyParts); + // Column 0 is the language name, the rest are file name patterns + for (int i = 1; i < columns.size(); ++i) { + const QString pattern = columns.at(i); + if (pattern.isEmpty()) + continue; + + if (pattern.startsWith(QStringLiteral("*."))) { + const QString extension = pattern.mid(2); + if (extension.contains(QLatin1Char('*')) || extension.contains(QLatin1Char('?')) + || extension.contains(QLatin1Char('[')) || extension.contains(QLatin1Char(']'))) + continue; // a glob, not a plain extension + if (!extension.isEmpty()) + patterns.insert(extension.toLower()); + } + else if (!pattern.contains(QLatin1Char('*')) && !pattern.contains(QLatin1Char('?'))) { + // A whole file name such as "Makefile" or "CMakeLists.txt". + // Character classes ("[Mm]akefile", "[._]vimrc") are expanded + // into their alternatives. + const QStringList names = pattern.contains(QLatin1Char('[')) + ? expandNamePattern(pattern) + : QStringList(pattern); + for (const QString &name : names) { + if (!name.isEmpty()) + patterns.insert(name.toLower()); + } + } + } + } + } + else { + probe.kill(); + probe.waitForFinished(1000); + } + + if (patterns.isEmpty()) { + qWarning("FunctionList: 'ctags --list-maps' produced no usable output; " + "file names will only be recognised through their language name"); + } + + QMutexLocker locker(&ctagsMutex); + knownFilePatternsCache.insert(executable, patterns); + return patterns; +} + +bool CtagsSymbolManager::knowledgeLoaded() +{ + const QString executable = ctagsExecutable(); + if (executable.isEmpty()) + return true; // "there is no ctags" is knowledge as well + + QMutexLocker locker(&ctagsMutex); + return knownLanguagesCache.contains(executable) && knownFilePatternsCache.contains(executable); +} + +bool CtagsSymbolManager::canParse(const QString &filePath, const QString &ctagsLanguage) +{ + if (ctagsExecutable().isEmpty()) + return false; // no ctags: there is nothing the panel could ever show + + if (!ctagsLanguage.isEmpty()) + return true; // the editor's language resolved onto a ctags language + + // The probes spawn a subprocess, so they run in the background and may still + // be pending right after start up. Assume "yes" in that case: the caller + // re-evaluates once knowledgeReady() arrives, and a panel that only appears + // a moment later beats one that never appears at all. + if (!knowledgeLoaded()) + return true; + + const QFileInfo info(filePath); + const QSet patterns = knownFilePatterns(); + + // QFileInfo::suffix() is empty for names like "Makefile", which is why whole + // file names are part of the pattern set as well. + const QString suffix = info.suffix().toLower(); + if (!suffix.isEmpty() && patterns.contains(suffix)) + return true; + + return patterns.contains(info.fileName().toLower()); +} + +bool CtagsSymbolManager::validateCtags(const QString &executable) +{ + if (executable.isEmpty()) + return false; + + { + QMutexLocker locker(&ctagsMutex); + const auto it = ctagsValidity.constFind(executable); + if (it != ctagsValidity.constEnd()) + return it.value(); + } + + // Runs on the worker thread: the probe never blocks the GUI + const bool valid = probeCtags(executable); + + QMutexLocker locker(&ctagsMutex); + ctagsValidity.insert(executable, valid); + if (valid) + qInfo("FunctionList: using ctags at '%s'", qUtf8Printable(executable)); + return valid; +} + +void CtagsSymbolManager::rejectCtags(const QString &executable) +{ + if (executable.isEmpty()) + return; + + QMutexLocker locker(&ctagsMutex); + + if (!rejectedCtags.contains(executable)) + qWarning("FunctionList: ignoring '%s' - it is not a usable ctags build, trying the next candidate", + qUtf8Printable(executable)); + + rejectedCtags.insert(executable); + ctagsValidity.insert(executable, false); + candidatesCached = false; // the candidate list may contain duplicates of it + warnedNoCtags = false; + knownLanguagesCache.remove(executable); // its language list is not trustworthy + knownFilePatternsCache.remove(executable); +} diff --git a/src/widgets/CtagsSymbolManager.h b/src/widgets/CtagsSymbolManager.h new file mode 100644 index 000000000..12eccaf30 --- /dev/null +++ b/src/widgets/CtagsSymbolManager.h @@ -0,0 +1,213 @@ +/* + * This file is part of Notepad Next. + * Copyright 2026 Notepad Next contributors + * + * Background ctags-based symbol extraction. + * + * CtagsWorker runs inside a dedicated QThread and owns the ctags QProcess. + * The worker thread and the GUI thread never share mutable data directly: + * requests are delivered via queued signals and results are marshalled back + * the same way. A generation counter makes sure stale results from superseded + * requests are discarded, and a QMutex guards the worker's own request state - + * this keeps the filter box and list refresh safe from data corruption while a + * request is in flight. + * + * Requests are serialized: only one ctags process runs at a time. A request + * for a file that is already being parsed restarts that parse; a request for + * a different file is queued instead of killing the running process. + * + * Locating ctags is platform aware and never hard-codes a path: candidates are + * collected from an environment override, the application directory (so a + * portable/bundled build can ship its own copy), the well known installation + * prefixes of the platform (Homebrew/MacPorts, /usr/local, /usr/bin, ...) and + * finally every PATH entry. Each candidate is verified with a "--version" probe + * before use, because the "ctags" name is also used by GNU Emacs etags and by + * BSD ctags, which do not understand the command line used here. This is what + * makes the lookup work on macOS/Linux, where several unrelated "ctags" + * binaries can be installed side by side. + * + * This file is part of a feature licensed under the GNU General Public + * License as published by the Free Software Foundation, either version 3 + * of the License, or (at your option) any later version. + */ + +#ifndef CTAGSSYMBOLMANAGER_H +#define CTAGSSYMBOLMANAGER_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FunctionListWidget.h" + +class CtagsWorker : public QObject +{ + Q_OBJECT + +public: + explicit CtagsWorker(QObject *parent = nullptr); + ~CtagsWorker() override; + +public slots: + // Executed in the worker thread. Starts a new parse, restarts the running + // one when it is for the same file, or queues it when another file is + // currently being parsed. Results are emitted via symbolsReady(). + void doRequest(int generation, const QString &filePath, const QString &ctagsLanguage); + + // Executed in the worker thread. Drops queued/running work for one file. + void cancel(const QString &filePath); + + // Executed in the worker thread. Drops everything. + void cancelAll(); + + // Executed in the worker thread right after start up. Fills the language and + // file name pattern caches so that the GUI thread can answer canParse() + // without ever waiting for a subprocess, then emits knowledgeReady(). + void warmUp(); + +signals: + void symbolsReady(int generation, const QString &filePath, const QVector &symbols); + + // Emitted at the end of warmUp(); forwarded queued to the GUI thread. + void knowledgeReady(); + +private slots: + void onReadyReadStandardOutput(); + void onReadyReadStandardError(); + void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); + void onProcessError(QProcess::ProcessError error); + +private: + struct PendingRequest + { + int generation = 0; + QString filePath; + QString ctagsLanguage; + }; + + QVector parseOutput(const QByteArray &output) const; + QString usableCtagsExecutable(); + void startProcess(int generation, const QString &filePath, const QString &ctagsLanguage); + void abortProcessLocked(); // caller must hold mutex + void startNextPending(); + + QMutex mutex; // guards process/outputBuffer/current*/pending below + QProcess *process = nullptr; + QByteArray outputBuffer; + QByteArray errorBuffer; + QString currentFile; + QString currentLanguage; + QString currentExecutable; + int currentGen = 0; + int startRetries = 0; // bounded re-tries after a binary failed to launch + int forceRetries = 0; // bounded re-tries without --language-force + QVector pending; +}; + +class CtagsSymbolManager : public QObject +{ + Q_OBJECT + +public: + explicit CtagsSymbolManager(QObject *parent = nullptr); + ~CtagsSymbolManager() override; + + // Returns the generation id of this request. A newer request for the same + // file supersedes this one, so only its result reaches symbolsReady(). + int request(const QString &filePath, const QString &ctagsLanguage); + + // Invalidates the pending result for one file (used when a file is closed) + void cancel(const QString &filePath); + + // Invalidates every pending result + void cancelAll(); + + // True while a request for this file has been issued but not yet delivered + bool isPending(const QString &filePath) const; + + // Path of the ctags binary that should be tried next, or an empty string + // when nothing usable is left. Cheap and non-blocking (candidate existence + // checks only), so it is safe to call from the GUI thread; the actual + // "--version" verification happens in validateCtags() on the worker thread. + static QString ctagsExecutable(); + + // Every language the located ctags knows, as reported by + // "ctags --list-languages". Cached after the first successful call. + // + // This is what lets the editor translate its own language names without a + // hand written table: a name that normalizes to the same string as a ctags + // language is passed as "--language-force", which corrects file extensions + // ctags would otherwise misread (".v" is taken for V, not Verilog). A name + // without a match is left to ctags' own file name detection instead, so an + // incomplete mapping degrades gracefully rather than failing. + // + // The first call starts a subprocess (about 30 ms) - warm it up from a + // background thread. Returns an empty list when there is no usable ctags. + static QStringList knownLanguages(); + + // Everything the located ctags claims to be able to parse, taken from + // "ctags --list-maps": lower cased file extensions ("c", "py") and lower + // cased whole file names ("makefile", "cmakelists.txt"). Cached exactly like + // knownLanguages(); returns an empty set when there is no usable ctags. + static QSet knownFilePatterns(); + + // False while the two probes above have not finished yet (they run on the + // worker thread at start up). Callers that have to decide something in the + // meantime should assume "yes" and re-evaluate once knowledgeReady() fires. + static bool knowledgeLoaded(); + + // True when ctags is able to produce symbols for this file: either the + // editor's language name resolved onto a ctags language (ctagsLanguage, see + // MainWindow::ctagsLanguageFor()) or ctags itself claims the file's name or + // extension. Purely in-memory lookups, so the GUI thread can call this for + // every file without blocking. + static bool canParse(const QString &filePath, const QString &ctagsLanguage); + + // True once a parse result - including an empty one - has been delivered for + // this file, so callers can tell "not parsed yet" from "nothing to find". + bool hasParsed(const QString &filePath) const; + + // Forgets everything about a file (pending request and delivered result) so + // that the next update really re-runs ctags. Used when a file is saved, + // reloaded or renamed, and when its panel is switched off. + void invalidate(const QString &filePath); + + // Verifies and memoizes that the given binary really is Universal Ctags or + // Exuberant Ctags (the two implementations supporting the command line used + // here). Starts a subprocess, so only call it from a background thread. + static bool validateCtags(const QString &executable); + + // Marks a candidate as unusable so that lookups skip it from now on. + static void rejectCtags(const QString &executable); + +signals: + void symbolsReady(int generation, const QString &filePath, const QVector &symbols); + + // Forwarded queued from the worker thread once the ctags language and file + // name pattern caches are populated. Consumers re-evaluate the open files + // then, so a decision that had to be guessed before the probes finished is + // corrected. + void knowledgeReady(); + +private slots: + void onSymbolsReady(int generation, const QString &filePath, const QVector &symbols); + +private: + QThread workerThread; + CtagsWorker *worker = nullptr; + QAtomicInt generationCounter{0}; + + // GUI thread only: newest generation per file, entry erased on delivery + QHash pendingByFile; + + // GUI thread only: files whose result was already delivered + QSet parsedFiles; +}; + +#endif // CTAGSSYMBOLMANAGER_H diff --git a/src/widgets/EditorPane.cpp b/src/widgets/EditorPane.cpp new file mode 100644 index 000000000..e73e27e38 --- /dev/null +++ b/src/widgets/EditorPane.cpp @@ -0,0 +1,100 @@ +/* + * This file is part of Notepad Next. + * Copyright 2026 Notepad Next contributors + * + * EditorPane implementation. + * + * This file is part of a feature licensed under the GNU General Public + * License as published by the Free Software Foundation, either version 3 + * of the License, or (at your option) any later version. + */ + +#include "EditorPane.h" +#include "ScintillaNext.h" + +EditorPane::EditorPane(ScintillaNext *editor, QWidget *parent) + : QSplitter(Qt::Horizontal, parent), ed(editor) +{ + setChildrenCollapsible(false); + setHandleWidth(3); + + funcList = new FunctionListWidget(this); + funcList->setMinimumWidth(120); + addWidget(funcList); + + addWidget(ed); // takes all remaining space + + funcList->setVisible(false); // hidden until a supported file requests it + + // Function list gets a fixed-ish width, editor stretches + setStretchFactor(0, 0); + setStretchFactor(1, 1); + setSizes({lastFuncListWidth, 1000}); + + // Remember how wide the user dragged the panel so it can be restored + connect(this, &QSplitter::splitterMoved, this, [this](int pos, int index) { + Q_UNUSED(index) + if (funcList->isVisible() && pos >= funcList->minimumWidth()) + lastFuncListWidth = pos; + }); +} + +void EditorPane::setFunctionListEnabled(bool enabled) +{ + funcListEnabled = enabled; + + if (!enabled) + setFunctionListVisible(false); +} + +void EditorPane::setFunctionListVisible(bool visible) +{ + funcListRequested = visible; + + if (visible == funcList->isVisible()) { + // Already in the requested state; make sure a panel that is meant to be + // visible really is visible (the pane may have been created before the + // window was shown) + if (visible && !funcList->isVisible()) + funcList->setVisible(true); + return; + } + + if (visible) { + funcList->setVisible(true); + + // A hidden splitter child collapses to zero width, so restore the + // remembered width once the pane has been laid out. + const int total = width(); + if (total > lastFuncListWidth + 80) + setSizes({lastFuncListWidth, total - lastFuncListWidth}); + } + else { + if (funcList->width() > funcList->minimumWidth()) + lastFuncListWidth = qBound(funcList->minimumWidth(), funcList->width(), 600); + funcList->setVisible(false); + } +} + +bool EditorPane::isFunctionListVisible() const +{ + return funcList->isVisible(); +} + +EditorPane *EditorPane::paneForEditor(ScintillaNext *editor) +{ + if (!editor) + return nullptr; + return qobject_cast(editor->parentWidget()); +} + +ScintillaNext *EditorPane::editorFromWidget(QWidget *widget) +{ + if (!widget) + return nullptr; + if (ScintillaNext *editor = qobject_cast(widget)) + return editor; + if (EditorPane *pane = qobject_cast(widget)) + return pane->editor(); + return nullptr; +} diff --git a/src/widgets/EditorPane.h b/src/widgets/EditorPane.h new file mode 100644 index 000000000..ff0c9a0b4 --- /dev/null +++ b/src/widgets/EditorPane.h @@ -0,0 +1,66 @@ +/* + * This file is part of Notepad Next. + * Copyright 2026 Notepad Next contributors + * + * A horizontal splitter that hosts the function list on the left and the + * editor on the right. Because it lives INSIDE the editor dock widget + * (not as a separate ADS dock), it can never overlap the "Folder as + * Workspace" dock and it always follows the editor's size. + * + * This file is part of a feature licensed under the GNU General Public + * License as published by the Free Software Foundation, either version 3 + * of the License, or (at your option) any later version. + */ + +#ifndef EDITORPANE_H +#define EDITORPANE_H + +#include + +#include "FunctionListWidget.h" + +class ScintillaNext; + +class EditorPane : public QSplitter +{ + Q_OBJECT + +public: + explicit EditorPane(ScintillaNext *editor, QWidget *parent = nullptr); + + ScintillaNext *editor() const { return ed; } + FunctionListWidget *functionList() const { return funcList; } + + // Feature switch for this editor (mirrors the view menu action). Kept + // separate from the widget's visibility because the panel is only shown for + // files ctags claims it can parse: a plain text file then costs no screen + // space instead of showing an empty box. Unlike isFunctionListVisible() this + // stays true while the tab is in the background, so callers do not have to + // care about tab visibility. + void setFunctionListEnabled(bool enabled); + bool isFunctionListEnabled() const { return funcListEnabled; } + + // Visibility of the panel widget itself. Only meaningful while enabled. + void setFunctionListVisible(bool visible); + bool isFunctionListVisible() const; + + // The requested state, which - unlike isFunctionListVisible() - does not + // depend on the window or the tab actually being on screen, so it is also + // meaningful while the window is hidden. + bool isFunctionListRequested() const { return funcListRequested; } + + // Returns the pane wrapping the editor, or nullptr if the editor is unwrapped + static EditorPane *paneForEditor(ScintillaNext *editor); + + // Resolves a dock widget content widget to its editor (unwrapped or pane-wrapped) + static ScintillaNext *editorFromWidget(QWidget *widget); + +private: + FunctionListWidget *funcList = nullptr; + ScintillaNext *ed = nullptr; + bool funcListEnabled = false; // view menu switch, independent of tab visibility + bool funcListRequested = false; // requested panel state, independent of tab visibility + int lastFuncListWidth = 230; +}; + +#endif // EDITORPANE_H diff --git a/src/widgets/FunctionListWidget.cpp b/src/widgets/FunctionListWidget.cpp new file mode 100644 index 000000000..91f8a5a8c --- /dev/null +++ b/src/widgets/FunctionListWidget.cpp @@ -0,0 +1,217 @@ +/* + * This file is part of Notepad Next. + * Copyright 2026 Notepad Next contributors + * + * Function list panel implementation. + * + * This file is part of a feature licensed under the GNU General Public + * License as published by the Free Software Foundation, either version 3 + * of the License, or (at your option) any later version. + */ + +#include "FunctionListWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QIcon makeKindIcon(const QColor &color, const QString &letter) +{ + QPixmap pm(16, 16); + pm.fill(Qt::transparent); + QPainter p(&pm); + p.setRenderHint(QPainter::Antialiasing); + p.setPen(Qt::NoPen); + p.setBrush(color); + p.drawEllipse(2, 2, 12, 12); + p.setPen(Qt::white); + QFont f = p.font(); + f.setBold(true); + f.setPixelSize(10); + p.setFont(f); + p.drawText(pm.rect(), Qt::AlignCenter, letter); + return QIcon(pm); +} +} // namespace + +FunctionListWidget::FunctionListWidget(QWidget *parent) + : QWidget(parent) +{ + titleLabel = new QLabel(this); + titleLabel->setObjectName(QStringLiteral("functionListTitle")); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + titleLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + filterEdit = new QLineEdit(this); + filterEdit->setPlaceholderText(tr("Symbol Name (Alt+L)")); + filterEdit->setClearButtonEnabled(true); + + // Hint shown while a parse runs, or when a file simply has no symbols. + statusLabel = new QLabel(this); + statusLabel->setObjectName(QStringLiteral("functionListStatus")); + statusLabel->setWordWrap(true); + statusLabel->setAlignment(Qt::AlignHCenter | Qt::AlignTop); + statusLabel->setVisible(false); + { + QFont statusFont = statusLabel->font(); + statusFont.setItalic(true); + statusLabel->setFont(statusFont); + } + + tree = new QTreeWidget(this); + tree->setHeaderHidden(true); + tree->setRootIsDecorated(false); + tree->setUniformRowHeights(true); + tree->setColumnCount(1); + tree->setFrameShape(QFrame::NoFrame); + tree->setSelectionMode(QAbstractItemView::SingleSelection); + + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + layout->setSpacing(4); + layout->addWidget(titleLabel); + layout->addWidget(filterEdit); + layout->addWidget(statusLabel); + layout->addWidget(tree, 1); + + connect(filterEdit, &QLineEdit::textChanged, this, &FunctionListWidget::onFilterChanged); + connect(tree, &QTreeWidget::itemClicked, this, &FunctionListWidget::onItemClicked); + + // Alt+L focuses the filter box (matches the placeholder hint) + QShortcut *focusFilter = new QShortcut(QKeySequence(QStringLiteral("Alt+L")), this); + focusFilter->setContext(Qt::WidgetWithChildrenShortcut); + connect(focusFilter, &QShortcut::activated, filterEdit, qOverload<>(&QLineEdit::setFocus)); +} + +void FunctionListWidget::setFileLabel(const QString &fileName) +{ + titleLabel->setText(fileName); + titleLabel->setToolTip(fileName); +} + +void FunctionListWidget::setSymbols(const QVector &symbols) +{ + QMutexLocker locker(&symbolsMutex); + this->symbols = symbols; + rebuildTree(); +} + +void FunctionListWidget::clearSymbols() +{ + QMutexLocker locker(&symbolsMutex); + symbols.clear(); + rebuildTree(); +} + +void FunctionListWidget::setBusy(bool busy) +{ + QMutexLocker locker(&symbolsMutex); + if (this->busy == busy) + return; + this->busy = busy; + rebuildTree(); +} + +int FunctionListWidget::symbolCount() const +{ + QMutexLocker locker(&symbolsMutex); + return symbols.size(); +} + +QString FunctionListWidget::currentFilterText() const +{ + return filterEdit->text(); +} + +void FunctionListWidget::onFilterChanged(const QString &text) +{ + QMutexLocker locker(&symbolsMutex); + currentFilter = text; + rebuildTree(); +} + +void FunctionListWidget::onItemClicked(QTreeWidgetItem *item, int column) +{ + Q_UNUSED(column) + if (!item) + return; + const int line = item->data(0, Qt::UserRole).toInt(); + if (line > 0) + emit jumpToLineRequested(line); +} + +void FunctionListWidget::rebuildTree() +{ + // NOTE: must be called with symbolsMutex held + tree->clear(); + + const QString filter = currentFilter.trimmed(); + + for (const FunctionSymbol &sym : symbols) { + if (!filter.isEmpty() && !sym.name.contains(filter, Qt::CaseInsensitive)) + continue; + + QTreeWidgetItem *item = new QTreeWidgetItem(tree); + item->setText(0, sym.name); + item->setIcon(0, iconForKind(sym.kind)); + item->setData(0, Qt::UserRole, sym.line); + item->setToolTip(0, QStringLiteral("%1 [line %2]").arg(sym.kind, QString::number(sym.line))); + } + + // Explain an empty list instead of leaving a blank box behind: the panel is + // opened as soon as ctags claims the file, so "symbols are still being + // collected" and "this file has none" are two very different states. + if (tree->topLevelItemCount() > 0) { + statusLabel->setVisible(false); + return; + } + + if (busy) + statusLabel->setText(tr("Analyzing...")); + else if (!filter.isEmpty()) + statusLabel->setText(tr("No symbol matches \"%1\"").arg(filter)); + else + statusLabel->setText(tr("No symbols found")); + + statusLabel->setVisible(true); +} + +QIcon FunctionListWidget::iconForKind(const QString &kind) +{ + if (!kindIconsReady) { + buildIconCache(); + kindIconsReady = true; + } + + const auto it = kindIcons.constFind(kind); + if (it != kindIcons.constEnd()) + return it.value(); + + return makeKindIcon(QColor(0x6b7280), kind.isEmpty() ? QStringLiteral("?") : kind.left(1)); +} + +// Simple color coding similar to common function list panels +void FunctionListWidget::buildIconCache() +{ + kindIcons.insert(QStringLiteral("f"), makeKindIcon(QColor(0x2e9e4f), QStringLiteral("f"))); // function + kindIcons.insert(QStringLiteral("p"), makeKindIcon(QColor(0x88b04b), QStringLiteral("p"))); // prototype/port + kindIcons.insert(QStringLiteral("m"), makeKindIcon(QColor(0x1d7f9f), QStringLiteral("m"))); // member/module + kindIcons.insert(QStringLiteral("v"), makeKindIcon(QColor(0xd97706), QStringLiteral("v"))); // variable + kindIcons.insert(QStringLiteral("g"), makeKindIcon(QColor(0xd97706), QStringLiteral("g"))); // global var + kindIcons.insert(QStringLiteral("e"), makeKindIcon(QColor(0xd97706), QStringLiteral("e"))); // enumerator + kindIcons.insert(QStringLiteral("d"), makeKindIcon(QColor(0xc2419a), QStringLiteral("d"))); // macro + kindIcons.insert(QStringLiteral("c"), makeKindIcon(QColor(0x2456c7), QStringLiteral("c"))); // class + kindIcons.insert(QStringLiteral("s"), makeKindIcon(QColor(0x2456c7), QStringLiteral("s"))); // struct + kindIcons.insert(QStringLiteral("u"), makeKindIcon(QColor(0x2456c7), QStringLiteral("u"))); // union + kindIcons.insert(QStringLiteral("t"), makeKindIcon(QColor(0x7c3aed), QStringLiteral("t"))); // typedef + kindIcons.insert(QStringLiteral("n"), makeKindIcon(QColor(0x0f766e), QStringLiteral("n"))); // namespace + kindIcons.insert(QStringLiteral("M"), makeKindIcon(QColor(0x0f766e), QStringLiteral("M"))); // lua module +} diff --git a/src/widgets/FunctionListWidget.h b/src/widgets/FunctionListWidget.h new file mode 100644 index 000000000..21f548100 --- /dev/null +++ b/src/widgets/FunctionListWidget.h @@ -0,0 +1,93 @@ +/* + * This file is part of Notepad Next. + * Copyright 2026 Notepad Next contributors + * + * Function list panel: file name label + symbol filter box + symbol list. + * Attached to the left side of the editor inside an EditorPane splitter. + * + * This file is part of a feature licensed under the GNU General Public + * License as published by the Free Software Foundation, either version 3 + * of the License, or (at your option) any later version. + */ + +#ifndef FUNCTIONLISTWIDGET_H +#define FUNCTIONLISTWIDGET_H + +#include +#include +#include +#include +#include + +class QLabel; +class QLineEdit; +class QTreeWidget; +class QTreeWidgetItem; + +struct FunctionSymbol +{ + QString name; + QString kind; + int line = 0; // 1-based line number +}; + +Q_DECLARE_METATYPE(FunctionSymbol) + +class FunctionListWidget : public QWidget +{ + Q_OBJECT + +public: + explicit FunctionListWidget(QWidget *parent = nullptr); + + void setFileLabel(const QString &fileName); + + // Replace the whole symbol list. Safe against concurrent access: + // the internal buffer is guarded by a mutex and re-applies the + // current filter text after the rebuild. + void setSymbols(const QVector &symbols); + + void clearSymbols(); + + // Marks the panel as "a parse is on its way". The panel is opened as soon as + // ctags claims the file, long before the symbols exist, so this is what turns + // the otherwise empty list into a hint that something is happening. + void setBusy(bool busy); + + // Number of symbols currently held (unfiltered) + int symbolCount() const; + + QString currentFilterText() const; + +signals: + // 1-based line number of the clicked symbol + void jumpToLineRequested(int lineNumber); + +private slots: + void onFilterChanged(const QString &text); + void onItemClicked(QTreeWidgetItem *item, int column); + +private: + void rebuildTree(); + QIcon iconForKind(const QString &kind); + void buildIconCache(); + + QLabel *titleLabel = nullptr; + QLineEdit *filterEdit = nullptr; + QLabel *statusLabel = nullptr; // "analyzing" / "no symbols" hint under the filter box + QTreeWidget *tree = nullptr; + + // Kind icons live in the widget instead of a function local static: static + // QIcon/QPixmap objects are destroyed after QGuiApplication, which is a + // known crash source on macOS and Linux, and the cache is also guaranteed + // to be built on the GUI thread. + QHash kindIcons; + bool kindIconsReady = false; + + mutable QMutex symbolsMutex; // guards symbols/currentFilter/busy (thread-safe refresh requirement) + QVector symbols; + QString currentFilter; + bool busy = false; +}; + +#endif // FUNCTIONLISTWIDGET_H