diff --git a/docs/PLAN.md b/docs/PLAN.md index 0de9937..98563d4 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -203,7 +203,20 @@ Therefore the palette is replaced, not extended, and the replacement is built on document's directory (fallback: cwd). Activation opens the file via `MainWindow::openUrl` + `activateView`. Also reachable from the M-x palette (`ollie:switch:file`) and, by objectName `ollie_goto_file`, from a radial. -- Fuzzy symbol switcher scoped to the folder: TODO (ctags/LSP; ctags present). +- Fuzzy symbol switcher scoped to the folder: DONE. + `src/project/symbolindex.{h,cpp}` (in `project_lib`, 7 unit tests incl. a + live ctags run): + - System ctags is **Exuberant 5.9** (no JSON), so `parseTags` parses the + classic extended tab format from `ctags -f - -L - --fields=+nK + --excmd=number --sort=no` (file list fed on stdin so a big project never + overflows ARG_MAX; paths relative to the root). Each `Symbol` carries + name / file / line / kind / scope. + - Wired into the plugin: **Alt+R** "Go to Symbol (project)" opens a third + PaletteWidget of the project's symbols (label = `Scope::name`, hint = + `kind file`). Activation opens the file via `MainWindow::openUrl` and + jumps the cursor to the symbol's line (ctags 1-based → cursor 0-based). + Also in the M-x palette (`ollie:switch:symbol`) and reachable from a radial + by objectName `ollie_goto_symbol`. ## Build and test diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 50ad3c7..fd87ba7 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -8,6 +8,7 @@ #include "radialconfig.h" #include "olliecommands.h" #include "projectindex.h" +#include "symbolindex.h" #include #include @@ -136,6 +137,10 @@ const QLatin1String kCmdPrefix("ollie:cmd:"); // the file switcher. const QLatin1String kFilePrefix("ollie:file:"); +// Prefix marking a palette id that is a symbol location ("relpath\tline") to +// jump to in the symbol switcher. +const QLatin1String kSymbolPrefix("ollie:sym:"); + // The complete (argument-free) command verbs offered in the palette. Verbs that // need free-text arguments (pipe) stay on the command line; `case` is expanded // into one entry per style so each is directly runnable. @@ -236,6 +241,18 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) w->addAction(gotoFileAction); } + // Project "Go to Symbol" switcher: Alt+R (single modifier). Lists ctags + // symbols across the project's files, scoped to the same root as the file + // switcher. Activation opens the file and jumps to the symbol's line. + auto *gotoSymbolAction = new QAction(i18n("Go to Symbol (project)"), this); + gotoSymbolAction->setObjectName(QStringLiteral("ollie_goto_symbol")); + gotoSymbolAction->setShortcut(QKeySequence(Qt::ALT | Qt::Key_R)); + gotoSymbolAction->setShortcutContext(Qt::ApplicationShortcut); + connect(gotoSymbolAction, &QAction::triggered, this, &OllieView::showSymbolSwitcher); + if (QWidget *w = m_mainWindow->window()) { + w->addAction(gotoSymbolAction); + } + setupRadials(); // One application-wide filter serves both the mouse-button radial gesture @@ -248,6 +265,7 @@ OllieView::~OllieView() { delete m_palette; delete m_filePalette; + delete m_symbolPalette; delete m_radial; } @@ -411,6 +429,15 @@ void OllieView::showCommandPalette() items.push_back(item); } + // Offer the project symbol switcher as a palette entry too (also on Alt+R). + { + PaletteItem item; + item.id = QStringLiteral("ollie:switch:symbol"); + item.label = i18n("Go to Symbol (project)"); + item.group = QStringLiteral("Alt+R"); + items.push_back(item); + } + m_palette->setItems(items); // Center the palette near the top of the active window. @@ -444,6 +471,12 @@ void OllieView::runAction(const QString &actionId) return; } + // The project symbol switcher, surfaced as a palette entry. + if (actionId == QLatin1String("ollie:switch:symbol")) { + showSymbolSwitcher(); + return; + } + QPointer action = m_actionsById.value(actionId); if (action) { action->trigger(); @@ -506,6 +539,86 @@ void OllieView::openProjectFile(const QString &relativePath) } } +void OllieView::showSymbolSwitcher() +{ + if (!m_symbolPalette) { + m_symbolPalette = new PaletteWidget(m_mainWindow->window()); + connect(m_symbolPalette, &PaletteWidget::activatedId, this, + [this](const QString &id) { + if (id.startsWith(kSymbolPrefix)) { + goToSymbol(id.mid(QString(kSymbolPrefix).size())); + } + }); + } + + m_symbolSwitcherRoot = projectRootFor(m_mainWindow); + const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot); + const QList symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files); + + QList items; + items.reserve(symbols.size()); + for (const Symbol &s : symbols) { + PaletteItem item; + // The id encodes the jump target as "relpath\tline"; the location is + // resolved against m_symbolSwitcherRoot on activation. + item.id = kSymbolPrefix + s.file + QLatin1Char('\t') + QString::number(s.line); + + // Label: scope-qualified name so "Class::method" matches naturally. + QString label = s.name; + if (!s.scope.isEmpty()) { + label = s.scope + QStringLiteral("::") + s.name; + } + item.label = label; + + // Trailing hint: kind plus the file for provenance. + QString hint = s.kind; + if (!s.file.isEmpty()) { + hint = hint.isEmpty() ? s.file : (s.kind + QStringLiteral(" ") + s.file); + } + item.group = hint; + + items.push_back(item); + } + m_symbolPalette->setItems(items); + + if (QWidget *w = m_mainWindow->window()) { + const QSize sz = m_symbolPalette->size(); + const QPoint topLeft = w->mapToGlobal(QPoint((w->width() - sz.width()) / 2, + w->height() / 6)); + m_symbolPalette->move(topLeft); + } + + m_symbolPalette->activate(); +} + +void OllieView::goToSymbol(const QString &locationId) +{ + if (m_symbolSwitcherRoot.isEmpty()) { + return; + } + const int tab = locationId.indexOf(QLatin1Char('\t')); + if (tab < 0) { + return; + } + const QString relativePath = locationId.left(tab); + const int line = locationId.mid(tab + 1).toInt(); + if (relativePath.isEmpty()) { + return; + } + + const QString absolute = QDir(m_symbolSwitcherRoot).absoluteFilePath(relativePath); + const QUrl url = QUrl::fromLocalFile(absolute); + KTextEditor::View *view = m_mainWindow->openUrl(url); + if (!view) { + return; + } + m_mainWindow->activateView(view->document()); + if (line > 0) { + // ctags lines are 1-based; KTextEditor cursors are 0-based. + view->setCursorPosition(KTextEditor::Cursor(line - 1, 0)); + } +} + QAction *OllieView::findActionByName(const QString &nameOrText) const { KXMLGUIFactory *factory = m_mainWindow->guiFactory(); diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index 6ee5326..b6df1ec 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -62,6 +62,8 @@ private Q_SLOTS: void runAction(const QString &actionId); void showFileSwitcher(); void openProjectFile(const QString &relativePath); + void showSymbolSwitcher(); + void goToSymbol(const QString &locationId); private: // One harvested, runnable action with the provenance needed to build a @@ -109,6 +111,8 @@ private: PaletteWidget *m_palette = nullptr; PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher QString m_fileSwitcherRoot; // project root for the open switcher + PaletteWidget *m_symbolPalette = nullptr; // project "Go to Symbol" switcher + QString m_symbolSwitcherRoot; // project root for the symbol switcher RadialMenu *m_radial = nullptr; QList m_radialDefs; QHash m_buttonToRadial; // Qt::MouseButton (int) -> def index diff --git a/src/project/CMakeLists.txt b/src/project/CMakeLists.txt index 5b81b1a..b2562e0 100644 --- a/src/project/CMakeLists.txt +++ b/src/project/CMakeLists.txt @@ -1,6 +1,8 @@ add_library(project_lib STATIC projectindex.cpp projectindex.h + symbolindex.cpp + symbolindex.h ) target_link_libraries(project_lib PUBLIC Qt6::Core @@ -11,4 +13,8 @@ if(Qt6Test_FOUND) add_executable(test_projectindex test_projectindex.cpp) target_link_libraries(test_projectindex PRIVATE project_lib Qt6::Core Qt6::Test) add_test(NAME projectindex COMMAND test_projectindex) + + add_executable(test_symbolindex test_symbolindex.cpp) + target_link_libraries(test_symbolindex PRIVATE project_lib Qt6::Core Qt6::Test) + add_test(NAME symbolindex COMMAND test_symbolindex) endif() diff --git a/src/project/symbolindex.cpp b/src/project/symbolindex.cpp new file mode 100644 index 0000000..6f736b7 --- /dev/null +++ b/src/project/symbolindex.cpp @@ -0,0 +1,116 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "symbolindex.h" + +#include +#include + +namespace katecustom +{ + +bool SymbolIndex::ctagsAvailable() +{ + return !QStandardPaths::findExecutable(QStringLiteral("ctags")).isEmpty(); +} + +QList SymbolIndex::parseTags(const QString &ctagsOutput) +{ + QList symbols; + const QList lines = + QStringView(ctagsOutput).split(QLatin1Char('\n'), Qt::SkipEmptyParts); + + for (const QStringView &lineView : lines) { + const QString line = lineView.toString(); + if (line.startsWith(QLatin1String("!_TAG_"))) { + continue; // pseudo-tag header + } + const QStringList parts = line.split(QLatin1Char('\t')); + if (parts.size() < 3) { + continue; // malformed + } + + Symbol s; + s.name = parts.at(0); + s.file = parts.at(1); + + // The excmd field (parts[2]) is ";\"" under --excmd=number; parse + // its leading integer as a fallback line number. + const QString excmd = parts.at(2); + int i = 0; + while (i < excmd.size() && excmd.at(i).isDigit()) { + ++i; + } + if (i > 0) { + s.line = QStringView(excmd).left(i).toInt(); + } + + // Extension fields (parts[3..]): a bare token is the kind; "key:value" + // tokens carry line and scope. The authoritative line comes from the + // "line:" field when present. + for (int p = 3; p < parts.size(); ++p) { + const QString &field = parts.at(p); + const int colon = field.indexOf(QLatin1Char(':')); + if (colon < 0) { + s.kind = field; // bare kind long-name + continue; + } + const QString key = field.left(colon); + const QString value = field.mid(colon + 1); + if (key == QLatin1String("line")) { + if (!value.isEmpty()) { + s.line = value.toInt(); + } + } else if (key == QLatin1String("kind")) { + s.kind = value; + } else if (key == QLatin1String("file")) { + // file-scope marker; no useful scope value + } else if (s.scope.isEmpty() && !value.isEmpty()) { + // class:/struct:/namespace:/function:/enum:… — the first such + // field names the enclosing scope. + s.scope = value; + } + } + + if (!s.name.isEmpty()) { + symbols.push_back(s); + } + } + return symbols; +} + +QList SymbolIndex::listSymbols(const QString &root, const QStringList &files) +{ + if (root.isEmpty() || files.isEmpty() || !ctagsAvailable()) { + return QList(); + } + + QProcess ctags; + ctags.setWorkingDirectory(root); + // Classic extended format with kind + line fields, numeric addresses, and + // the file list fed on stdin (-L -) so a huge project never overflows the + // command line. Paths are relative to the working directory, so the parsed + // Symbol::file is already project-relative. + ctags.start(QStringLiteral("ctags"), + {QStringLiteral("-f"), QStringLiteral("-"), + QStringLiteral("-L"), QStringLiteral("-"), + QStringLiteral("--fields=+nK"), + QStringLiteral("--excmd=number"), + QStringLiteral("--sort=no")}); + if (!ctags.waitForStarted(3000)) { + return QList(); + } + ctags.write(files.join(QLatin1Char('\n')).toUtf8()); + ctags.write("\n"); + ctags.closeWriteChannel(); + if (!ctags.waitForFinished(20000)) { + ctags.kill(); + ctags.waitForFinished(1000); + return QList(); + } + + const QString out = QString::fromUtf8(ctags.readAllStandardOutput()); + return parseTags(out); +} + +} // namespace katecustom diff --git a/src/project/symbolindex.h b/src/project/symbolindex.h new file mode 100644 index 0000000..a1b26a0 --- /dev/null +++ b/src/project/symbolindex.h @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * SymbolIndex — project-scoped symbol enumeration via ctags. + * + * Given a project root and a list of files (from ProjectIndex::listFiles), run + * ctags over them and parse the classic tab-separated "extended" format into a + * flat symbol list suitable for the fuzzy switcher. Exuberant Ctags 5.9 has no + * JSON output, so we parse the extended format: + * + * name file ;" [ key:value ...] + * + * The parsing (parseTags) is pure and unit-tested; listSymbols runs the process. + */ +#ifndef KATECUSTOM_SYMBOLINDEX_H +#define KATECUSTOM_SYMBOLINDEX_H + +#include +#include +#include + +namespace katecustom +{ + +/*! One symbol located in the project. */ +struct Symbol { + QString name; // identifier, e.g. "runVerb" + QString file; // path relative to the project root + int line = 0; // 1-based line number (0 if unknown) + QString kind; // ctags kind long name, e.g. "function", "class" + QString scope; // enclosing scope value, e.g. "OllieCommands" (may be empty) + + bool operator==(const Symbol &o) const + { + return name == o.name && file == o.file && line == o.line + && kind == o.kind && scope == o.scope; + } +}; + +class SymbolIndex +{ +public: + /*! True if a ctags binary is usable. */ + static bool ctagsAvailable(); + + /*! + * Parse ctags extended tab-separated output into a symbol list. Pure: no + * process, no filesystem. Lines that are comments (starting with "!_TAG_") + * or malformed (fewer than three tab fields) are skipped. + */ + static QList parseTags(const QString &ctagsOutput); + + /*! + * Run ctags over \a files (relative to \a root) and return the parsed + * symbols. Returns empty when ctags is unavailable, \a root is empty, or + * \a files is empty. + */ + static QList listSymbols(const QString &root, const QStringList &files); +}; + +} // namespace katecustom + +#endif diff --git a/src/project/test_symbolindex.cpp b/src/project/test_symbolindex.cpp new file mode 100644 index 0000000..31fd123 --- /dev/null +++ b/src/project/test_symbolindex.cpp @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Unit tests for SymbolIndex. parseTags is exercised directly against sample + * ctags extended output; listSymbols is exercised end-to-end only when a ctags + * binary is available (skipped otherwise). + */ +#include "symbolindex.h" + +#include +#include +#include +#include + +using namespace katecustom; + +class TestSymbolIndex : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void parsesNameFileLineKind() + { + const QString out = + QStringLiteral("foo\tsrc/a.cpp\t1;\"\tfunction\tline:1\n") + + QStringLiteral("Bar\tsrc/a.cpp\t2;\"\tclass\tline:2\tfile:\n"); + const QList syms = SymbolIndex::parseTags(out); + QCOMPARE(syms.size(), 2); + + QCOMPARE(syms.at(0).name, QStringLiteral("foo")); + QCOMPARE(syms.at(0).file, QStringLiteral("src/a.cpp")); + QCOMPARE(syms.at(0).line, 1); + QCOMPARE(syms.at(0).kind, QStringLiteral("function")); + QVERIFY(syms.at(0).scope.isEmpty()); + + QCOMPARE(syms.at(1).name, QStringLiteral("Bar")); + QCOMPARE(syms.at(1).kind, QStringLiteral("class")); + QCOMPARE(syms.at(1).line, 2); + } + + void parsesScopeField() + { + const QString out = + QStringLiteral("m\tsrc/a.cpp\t3;\"\tfunction\tline:3\tclass:Bar\n"); + const QList syms = SymbolIndex::parseTags(out); + QCOMPARE(syms.size(), 1); + QCOMPARE(syms.at(0).name, QStringLiteral("m")); + QCOMPARE(syms.at(0).scope, QStringLiteral("Bar")); + QCOMPARE(syms.at(0).line, 3); + QCOMPARE(syms.at(0).kind, QStringLiteral("function")); + } + + void lineFallsBackToExcmd() + { + // No line: field — the numeric excmd supplies the line. + const QString out = QStringLiteral("x\tf.c\t42;\"\tvariable\n"); + const QList syms = SymbolIndex::parseTags(out); + QCOMPARE(syms.size(), 1); + QCOMPARE(syms.at(0).line, 42); + QCOMPARE(syms.at(0).kind, QStringLiteral("variable")); + } + + void skipsPseudoTagsAndMalformed() + { + const QString out = + QStringLiteral("!_TAG_PROGRAM_NAME\tCtags\t//\n") + + QStringLiteral("brokenlinewithnotabs\n") + + QStringLiteral("good\tf.c\t1;\"\tfunction\n"); + const QList syms = SymbolIndex::parseTags(out); + QCOMPARE(syms.size(), 1); + QCOMPARE(syms.at(0).name, QStringLiteral("good")); + } + + void listSymbolsEndToEnd() + { + if (!SymbolIndex::ctagsAvailable()) { + QSKIP("ctags not available"); + } + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + const QString rel = QStringLiteral("a.cpp"); + QFile f(base.filePath(rel)); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("int alpha(int x){return x;}\n" + "class Beta { public: void gamma(); };\n"); + f.close(); + + const QList syms = SymbolIndex::listSymbols(tmp.path(), {rel}); + QVERIFY(!syms.isEmpty()); + + bool haveAlpha = false, haveBeta = false; + for (const Symbol &s : syms) { + QCOMPARE(s.file, rel); // relative to root + if (s.name == QLatin1String("alpha") && s.kind == QLatin1String("function")) { + haveAlpha = true; + QCOMPARE(s.line, 1); + } + if (s.name == QLatin1String("Beta") && s.kind == QLatin1String("class")) { + haveBeta = true; + } + } + QVERIFY(haveAlpha); + QVERIFY(haveBeta); + } + + void listSymbolsEmptyInputs() + { + QVERIFY(SymbolIndex::listSymbols(QString(), {QStringLiteral("a")}).isEmpty()); + QVERIFY(SymbolIndex::listSymbols(QStringLiteral("/tmp"), {}).isEmpty()); + } +}; + +QTEST_MAIN(TestSymbolIndex) +#include "test_symbolindex.moc"