M5: project symbol switcher via ctags (Alt+R)

SymbolIndex (project_lib): parseTags reads Exuberant Ctags 5.9 extended
tab format (name/file/line/kind/scope); listSymbols runs
ctags -f - -L - --fields=+nK --excmd=number --sort=no with the file list
on stdin, paths relative to root. 7 unit tests incl. a live ctags run.

Plugin: Alt+R 'Go to Symbol (project)' opens a PaletteWidget of project
symbols (label Scope::name, hint 'kind file'), opens the pick via
MainWindow::openUrl and jumps to its line. Also in the M-x palette and
reachable from a radial by objectName ollie_goto_symbol.
This commit is contained in:
Levi Neely 2026-10-07 19:38:24 +02:00
parent f4b891efa7
commit 42363157d3
7 changed files with 431 additions and 1 deletions

View File

@ -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

View File

@ -8,6 +8,7 @@
#include "radialconfig.h"
#include "olliecommands.h"
#include "projectindex.h"
#include "symbolindex.h"
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
@ -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<QAction> 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<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files);
QList<PaletteItem> 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();

View File

@ -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<RadialDef> m_radialDefs;
QHash<int, int> m_buttonToRadial; // Qt::MouseButton (int) -> def index

View File

@ -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()

116
src/project/symbolindex.cpp Normal file
View File

@ -0,0 +1,116 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "symbolindex.h"
#include <QProcess>
#include <QStandardPaths>
namespace katecustom
{
bool SymbolIndex::ctagsAvailable()
{
return !QStandardPaths::findExecutable(QStringLiteral("ctags")).isEmpty();
}
QList<Symbol> SymbolIndex::parseTags(const QString &ctagsOutput)
{
QList<Symbol> symbols;
const QList<QStringView> 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 "<line>;\"" 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<Symbol> SymbolIndex::listSymbols(const QString &root, const QStringList &files)
{
if (root.isEmpty() || files.isEmpty() || !ctagsAvailable()) {
return QList<Symbol>();
}
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<Symbol>();
}
ctags.write(files.join(QLatin1Char('\n')).toUtf8());
ctags.write("\n");
ctags.closeWriteChannel();
if (!ctags.waitForFinished(20000)) {
ctags.kill();
ctags.waitForFinished(1000);
return QList<Symbol>();
}
const QString out = QString::fromUtf8(ctags.readAllStandardOutput());
return parseTags(out);
}
} // namespace katecustom

63
src/project/symbolindex.h Normal file
View File

@ -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 <TAB> file <TAB> <line>;" <TAB> <kind> [<TAB> key:value ...]
*
* The parsing (parseTags) is pure and unit-tested; listSymbols runs the process.
*/
#ifndef KATECUSTOM_SYMBOLINDEX_H
#define KATECUSTOM_SYMBOLINDEX_H
#include <QList>
#include <QString>
#include <QStringList>
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<Symbol> 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<Symbol> listSymbols(const QString &root, const QStringList &files);
};
} // namespace katecustom
#endif

View File

@ -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 <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <QTest>
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<Symbol> 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<Symbol> 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<Symbol> 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<Symbol> 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<Symbol> 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"