diff --git a/docs/PLAN.md b/docs/PLAN.md index e83f924..c95ae50 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -312,6 +312,48 @@ Therefore the palette is replaced, not extended, and the replacement is built on **project-qualified** (prefixed by the resolved root) so usage never bleeds between repos that share relative paths. +### Plumbing — plan9 "plumb to edit", full client — DONE +Kate is a first-class plan9port plumb client: it both **sends** plumb messages +and **receives** them on the `edit` port, so files plumbed from anywhere (acme, +`plumb(1)`, other tools) open in Kate at the right line, and tokens plumbed from +Kate flow through the real plumber rules. + +- **Native C++ 9P2000** (`src/plumb/ninep.{h,cpp}`): a minimal synchronous + client over `QLocalSocket` — `Tversion`/`Tattach`/`Twalk`/`Topen`/`Tread`/ + `Twrite`/`Tclunk`, little-endian, msize negotiated from 8192. No linking of + plan9port's C `libplumb`/`lib9pclient`; it's just the wire protocol, verified + against `/usr/local/plan9` sources. For the blocking edit-port read it adds a + **cancellable split** (`beginRead` + `recvReadReply` with a first-byte + timeout) so the reader thread polls its stop flag between windows and never + issues a second `Tread` on the same tag, and never touches the socket from + another thread. +- **Message codec** (`src/plumb/plumbmsg.{h,cpp}`): `PlumbMsg::pack/unpack` with + libplumb's exact attribute quoting (`'`-quote values with space/`'`/`=`/tab, + `''` escapes `'`). Unit tests pin it to **golden bytes captured from a live + plumber** on the edit port, e.g. `plumb\nedit\n/tmp\ntext\naddr=2\n18\n/tmp/ + plumbtest.txt`. +- **Address parsing** (`src/plumb/plumbresolve.{h,cpp}`): plan9 `addr` + (`N`, `N:C`, `N.C`, 1-based) → 0-based `KTextEditor::Cursor`. Unit-tested. +- **Facade** (`src/plumb/plumber.{h,cpp}`): `Plumber::send(data, wdir)` opens the + `send` port and writes a packed message (`src=kate`, empty `dst` so the rules + route it); a `PlumbReader` `QThread` holds the `edit` port open and emits + `edit(file, addr, wdir)` via a queued signal. Namespace socket resolved as + `$NAMESPACE/plumb`, else `/tmp/ns.$USER.$DISPLAY/plumb` (same as plan9 + `getns()`), display canonicalised. +- **Plugin wiring** (`src/plugin/ollieplugin.cpp`): an `ollie_plumb` QAction on + **F2** (testing trigger; a function key, so no collision with the Alt+letter + door policy — final gesture will be RMB). `plumbAtCursor()` plumbs the + selection, else `Document::wordAt(cursorPosition)`, with the active doc's dir + as `wdir`; on send failure it falls back to an internal resolver (URL → + `QDesktopServices`, path[:line] → open here). Incoming `edit` messages are + opened via `MainWindow::openUrl` + `activateView` + `setCursorPosition`. +- **Verification**: `test_plumbmsg` + `test_plumbresolve` run headless; + `test_plumb_live` does a **real round-trip** against a running plumber (send + `:2` → rules → edit port → `edit()` signal) and **skips+passes** when no + plumber/namespace is reachable, so CI stays green. The live round-trip was + confirmed passing. The GUI open/cursor path (`openUrl`/`setCursorPosition`) + is not exercisable headless and is unverified on a live display. + ## Build and test ```sh diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b0c7bd0..906521f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,7 @@ add_subdirectory(fuzzy) add_subdirectory(palette) add_subdirectory(radial) add_subdirectory(project) +add_subdirectory(plumb) if(KF6TextEditor_FOUND) add_subdirectory(commands) endif() diff --git a/src/plugin/CMakeLists.txt b/src/plugin/CMakeLists.txt index 48bdf81..366fa95 100644 --- a/src/plugin/CMakeLists.txt +++ b/src/plugin/CMakeLists.txt @@ -72,6 +72,7 @@ target_link_libraries(olliepalette PRIVATE radial olliecommands_lib project_lib + plumb_lib KF6::TextEditor KF6::XmlGui KF6::CoreAddons diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index e8c283c..5376aad 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -10,6 +10,7 @@ #include "olliecommands.h" #include "projectindex.h" #include "symbolindex.h" +#include "plumbresolve.h" #include #include @@ -27,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -263,13 +265,33 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) gotoSymbolAction->setObjectName(QStringLiteral("ollie_goto_symbol")); connect(gotoSymbolAction, &QAction::triggered, this, &OllieView::showSymbolSwitcher); + // Plumb: resolve the token/selection under the caret through the plan9 + // plumber. Unlike the three "doors" above, F2 is a function key that does + // not collide with Kate's Alt+ accelerators, so we register it as a + // normal shortcut here (testing trigger; the final gesture will be RMB). + auto *plumbAction = new QAction(i18n("Plumb (plan9) [F2]"), this); + plumbAction->setObjectName(QStringLiteral("ollie_plumb")); + plumbAction->setShortcut(QKeySequence(Qt::Key_F2)); + connect(plumbAction, &QAction::triggered, this, &OllieView::plumbAtCursor); + // Keep them discoverable as window actions (no shortcut attached). if (QWidget *w = m_mainWindow->window()) { w->addAction(openAction); w->addAction(gotoFileAction); w->addAction(gotoSymbolAction); + w->addAction(plumbAction); } + // Kate becomes a full plumb client: incoming "edit" messages open files + // here; outgoing plumbs (plumbAtCursor) go through the "send" port. The + // reader runs on its own thread and delivers via a queued signal. + connect(&m_plumber, &Plumber::edit, this, &OllieView::onPlumbEdit); + connect(&m_plumber, &Plumber::readerError, this, [](const QString &msg) { + // Non-fatal: the plumber may simply not be running. Log and carry on. + qWarning("ollie plumb: %s", qUtf8Printable(msg)); + }); + m_plumber.startReader(); + // Build on Kate's project plugin: attach to its view now (if present) and // follow it as plugins come and go. The bridge is read-only; the switchers // scope to its project when one is loaded, else to VCS discovery. @@ -760,6 +782,104 @@ void OllieView::goToSymbol(const QString &locationId) } } +void OllieView::plumbAtCursor() +{ + KTextEditor::View *view = m_mainWindow->activeView(); + if (!view) { + return; + } + KTextEditor::Document *doc = view->document(); + if (!doc) { + return; + } + + // What to plumb: an explicit selection wins (lets the user plumb a path + // with a line suffix like "foo.cpp:42"), otherwise the word under the + // caret. + QString data; + if (view->selection()) { + data = view->selectionText().trimmed(); + } + if (data.isEmpty()) { + data = doc->wordAt(view->cursorPosition()); + } + if (data.isEmpty()) { + return; + } + + // Working directory: the active document's directory so the plumber can + // resolve relative paths (its rules apply $wdir to bare names). + const QString wdir = activeDocumentDir(); + + // Send through the plumber. On failure (no plumber / no namespace) fall + // back to a minimal internal resolver so the gesture still does something + // useful. + if (m_plumber.send(data, wdir)) { + return; + } + qWarning("ollie plumb send failed: %s", qUtf8Printable(m_plumber.errorString())); + + // Fallback: URL -> external handler; path[:line] -> open here; else treat + // the token as a symbol and open the symbol switcher pre-seeded is overkill + // here, so we just try to open it as a file. + const QUrl asUrl = QUrl::fromUserInput(data); + if (asUrl.isValid() && !asUrl.scheme().isEmpty() + && asUrl.scheme() != QStringLiteral("file")) { + QDesktopServices::openUrl(asUrl); + return; + } + onPlumbEdit(data, QString(), wdir); +} + +void OllieView::onPlumbEdit(const QString &file, const QString &addr, const QString &wdir) +{ + if (file.isEmpty()) { + return; + } + // A plumbed "file" may carry a trailing address the rules did not split + // out (e.g. the fallback path above passes the raw token). Prefer the + // explicit addr attribute; if absent, try to peel a ":line[:col]" suffix. + QString path = file; + QString addrStr = addr; + if (addrStr.isEmpty()) { + const int colon = path.lastIndexOf(QLatin1Char(':')); + if (colon > 1) { // keep a Windows drive letter intact + const QString tail = path.mid(colon + 1); + bool numeric = !tail.isEmpty(); + for (const QChar c : tail) { + if (!c.isDigit()) { + numeric = false; + break; + } + } + if (numeric) { + addrStr = tail; + path = path.left(colon); + } + } + } + + // Resolve a relative path against the message's working directory (the + // plumber sends absolute paths for the edit port, but the fallback may + // not). + QFileInfo fi(path); + if (fi.isRelative() && !wdir.isEmpty()) { + fi = QFileInfo(QDir(wdir).absoluteFilePath(path)); + } + + const QUrl url = QUrl::fromLocalFile(fi.absoluteFilePath()); + KTextEditor::View *view = m_mainWindow->openUrl(url); + if (!view) { + return; + } + m_mainWindow->activateView(view->document()); + + const PlumbAddr pa = parsePlumbAddr(addrStr); + if (pa.valid) { + view->setCursorPosition(KTextEditor::Cursor(pa.line, pa.column)); + } +} + 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 ed364bc..3cf822f 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -17,6 +17,7 @@ #include "kateprojectbridge.h" #include "projectindexer.h" #include "palettemodel.h" // PaletteItem +#include "plumber.h" #include "waylandcursorwarp.h" #include @@ -71,6 +72,13 @@ private Q_SLOTS: void showSymbolSwitcher(); void goToSymbol(const QString &locationId); + // Plumb the text under the caret (or the current selection) through the + // plan9 plumber. Bound to F2 for testing. + void plumbAtCursor(); + // A plumb message arrived on the edit port: open \a file and jump to \a + // addr (a plan9 edit-port address). Routed from the Plumber reader thread. + void onPlumbEdit(const QString &file, const QString &addr, const QString &wdir); + private: // Record one activation of \a id and persist. Feeds the frecency bonus so // habitual choices float up in every palette. @@ -153,6 +161,7 @@ private: KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin ProjectIndexer m_indexer; // async, cached file/symbol indexing WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11) + Plumber m_plumber; // plan9 plumbing client (send + edit-port reader) }; } // namespace katecustom diff --git a/src/plumb/CMakeLists.txt b/src/plumb/CMakeLists.txt new file mode 100644 index 0000000..2cb6004 --- /dev/null +++ b/src/plumb/CMakeLists.txt @@ -0,0 +1,38 @@ +# plumb — plan9 plumbing client (9P2000 over the namespace socket). +# +# plumb_lib carries the pure codec + resolver and is unit tested without any +# GUI/9P I/O. The 9P client (ninep) and the Plumber facade add Qt Network and +# a reader thread; they talk to a live plumber and so are exercised by a live +# integration test guarded on $NAMESPACE being present. +find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Core Network REQUIRED) + +add_library(plumb_lib STATIC + plumbmsg.cpp + plumbmsg.h + plumbresolve.cpp + plumbresolve.h + ninep.cpp + ninep.h + plumber.cpp + plumber.h +) +set_target_properties(plumb_lib PROPERTIES AUTOMOC ON) +target_link_libraries(plumb_lib PUBLIC Qt6::Core Qt6::Network) +target_include_directories(plumb_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test) +if(Qt6Test_FOUND) + add_executable(test_plumbmsg test_plumbmsg.cpp) + target_link_libraries(test_plumbmsg PRIVATE plumb_lib Qt6::Test) + add_test(NAME plumbmsg COMMAND test_plumbmsg) + + add_executable(test_plumbresolve test_plumbresolve.cpp) + target_link_libraries(test_plumbresolve PRIVATE plumb_lib Qt6::Test) + add_test(NAME plumbresolve COMMAND test_plumbresolve) + + # Live round-trip against a running plumber. Skips itself (passing) when no + # plumb socket is reachable, so it is safe in headless/CI environments. + add_executable(test_plumb_live test_plumb_live.cpp) + target_link_libraries(test_plumb_live PRIVATE plumb_lib Qt6::Test) + add_test(NAME plumb_live COMMAND test_plumb_live) +endif() diff --git a/src/plumb/ninep.cpp b/src/plumb/ninep.cpp new file mode 100644 index 0000000..f718fd5 --- /dev/null +++ b/src/plumb/ninep.cpp @@ -0,0 +1,465 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "ninep.h" + +#include + +namespace katecustom +{ + +// 9P2000 message type codes (plan9port fcall.h). +enum { + Tversion = 100, + Rversion = 101, + Tattach = 104, + Rattach = 105, + Rerror = 107, + Twalk = 110, + Rwalk = 111, + Topen = 112, + Ropen = 113, + Tread = 116, + Rread = 117, + Twrite = 118, + Rwrite = 119, + Tclunk = 120, + Rclunk = 121, +}; + +static constexpr uint16_t NOTAG = 0xFFFF; +static constexpr uint32_t NOFID = 0xFFFFFFFFu; +static constexpr uint16_t Tag = 1; // single outstanding request at a time +static constexpr int ConnectTimeoutMs = 5000; +// Room for the Rread header (count[4]) plus 9P framing; matches plan9port +// IOHDRSZ. Reads are clamped to msize-IOHDRSZ so the reply always fits. +static constexpr uint32_t IOHDRSZ_LOCAL = 24; + +// Little-endian field appenders (9P is LE on the wire). +static void put1(QByteArray &b, uint8_t v) { b.append(char(v)); } +static void put2(QByteArray &b, uint16_t v) +{ + b.append(char(v & 0xFF)); + b.append(char((v >> 8) & 0xFF)); +} +static void put4(QByteArray &b, uint32_t v) +{ + for (int i = 0; i < 4; i++) { + b.append(char((v >> (8 * i)) & 0xFF)); + } +} +static void put8(QByteArray &b, uint64_t v) +{ + for (int i = 0; i < 8; i++) { + b.append(char((v >> (8 * i)) & 0xFF)); + } +} +static void putStr(QByteArray &b, const QByteArray &s) +{ + put2(b, uint16_t(s.size())); + b.append(s); +} + +// Little-endian field readers over a cursor into a QByteArray. +namespace +{ +struct Reader { + const char *p; + int remaining; + bool ok = true; + static Reader of(const QByteArray &b) { return Reader{b.constData(), int(b.size())}; } + uint8_t u1() + { + if (remaining < 1) { ok = false; return 0; } + uint8_t v = uint8_t(*p); + p++; remaining--; + return v; + } + uint16_t u2() + { + if (remaining < 2) { ok = false; return 0; } + uint16_t v = uint8_t(p[0]) | (uint16_t(uint8_t(p[1])) << 8); + p += 2; remaining -= 2; + return v; + } + uint32_t u4() + { + if (remaining < 4) { ok = false; return 0; } + uint32_t v = 0; + for (int i = 0; i < 4; i++) { + v |= uint32_t(uint8_t(p[i])) << (8 * i); + } + p += 4; remaining -= 4; + return v; + } + QByteArray str() + { + uint16_t n = u2(); + if (!ok || remaining < int(n)) { ok = false; return QByteArray(); } + QByteArray s(p, n); + p += n; remaining -= n; + return s; + } +}; +} // namespace + +NineP::NineP() = default; + +NineP::~NineP() +{ + close(); +} + +bool NineP::isConnected() const +{ + return m_sock && m_sock->state() == QLocalSocket::ConnectedState; +} + +void NineP::close() +{ + if (m_sock) { + if (m_sock->state() == QLocalSocket::ConnectedState) { + clunk(RootFid); + } + m_sock->abort(); + delete m_sock; + m_sock = nullptr; + } +} + +bool NineP::readExactly(char *buf, int n) +{ + int got = 0; + while (got < n) { + if (m_sock->bytesAvailable() == 0) { + if (!m_sock->waitForReadyRead(-1)) { + m_error = QStringLiteral("socket read failed: %1").arg(m_sock->errorString()); + return false; + } + } + const qint64 r = m_sock->read(buf + got, n - got); + if (r < 0) { + m_error = QStringLiteral("socket read error: %1").arg(m_sock->errorString()); + return false; + } + got += int(r); + } + return true; +} + +bool NineP::sendMsg(uint8_t type, uint16_t tag, const QByteArray &body) +{ + QByteArray msg; + const uint32_t size = 4 + 1 + 2 + uint32_t(body.size()); + put4(msg, size); + put1(msg, type); + put2(msg, tag); + msg.append(body); + if (m_sock->write(msg) != msg.size()) { + m_error = QStringLiteral("socket write failed: %1").arg(m_sock->errorString()); + return false; + } + if (!m_sock->waitForBytesWritten(ConnectTimeoutMs)) { + m_error = QStringLiteral("socket flush timed out"); + return false; + } + return true; +} + +bool NineP::recvMsg(uint8_t *type, uint16_t *tag, QByteArray *body) +{ + char hdr[7]; + if (!readExactly(hdr, 7)) { + return false; + } + const uint32_t size = uint32_t(uint8_t(hdr[0])) | (uint32_t(uint8_t(hdr[1])) << 8) + | (uint32_t(uint8_t(hdr[2])) << 16) | (uint32_t(uint8_t(hdr[3])) << 24); + if (size < 7) { + m_error = QStringLiteral("9P message too short (%1)").arg(size); + return false; + } + *type = uint8_t(hdr[4]); + *tag = uint16_t(uint8_t(hdr[5]) | (uint16_t(uint8_t(hdr[6])) << 8)); + const int bodyLen = int(size) - 7; + QByteArray b; + b.resize(bodyLen); + if (bodyLen > 0 && !readExactly(b.data(), bodyLen)) { + return false; + } + if (*type == Rerror) { + Reader r = Reader::of(b); + const QByteArray ename = r.str(); + m_error = QStringLiteral("9P Rerror: %1").arg(QString::fromUtf8(ename)); + return false; + } + *body = b; + return true; +} + +bool NineP::connectAndAttach(const QString &socketPath, const QString &user, + const QString &aname) +{ + close(); + m_sock = new QLocalSocket(); + m_sock->connectToServer(socketPath, QLocalSocket::ReadWrite); + if (!m_sock->waitForConnected(ConnectTimeoutMs)) { + m_error = QStringLiteral("connect %1: %2").arg(socketPath, m_sock->errorString()); + delete m_sock; + m_sock = nullptr; + return false; + } + + // Tversion: msize[4] version[s] + { + QByteArray body; + put4(body, m_msize); + putStr(body, QByteArrayLiteral("9P2000")); + if (!sendMsg(Tversion, NOTAG, body)) { + close(); + return false; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rversion) { + if (m_error.isEmpty()) { + m_error = QStringLiteral("unexpected reply to Tversion"); + } + close(); + return false; + } + Reader r = Reader::of(rb); + const uint32_t srvMsize = r.u4(); + const QByteArray srvVer = r.str(); + if (!r.ok || !srvVer.startsWith("9P2000")) { + m_error = QStringLiteral("server declined 9P2000 (got '%1')") + .arg(QString::fromUtf8(srvVer)); + close(); + return false; + } + if (srvMsize > 0 && srvMsize < m_msize) { + m_msize = srvMsize; + } + } + + // Tattach: fid[4] afid[4] uname[s] aname[s] + { + QByteArray body; + put4(body, RootFid); + put4(body, NOFID); + putStr(body, user.toUtf8()); + putStr(body, aname.toUtf8()); + if (!sendMsg(Tattach, Tag, body)) { + close(); + return false; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rattach) { + if (m_error.isEmpty()) { + m_error = QStringLiteral("unexpected reply to Tattach"); + } + close(); + return false; + } + } + return true; +} + +bool NineP::walk(uint32_t newFid, const QString &name) +{ + // Twalk: fid[4] newfid[4] nwname[2] nwname*(wname[s]) + QByteArray body; + put4(body, RootFid); + put4(body, newFid); + const QByteArray n = name.toUtf8(); + put2(body, 1); // one path element + putStr(body, n); + if (!sendMsg(Twalk, Tag, body)) { + return false; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rwalk) { + if (m_error.isEmpty()) { + m_error = QStringLiteral("walk '%1' failed").arg(name); + } + return false; + } + // Rwalk: nwqid[2] nwqid*(qid[13]) — require it walked the single element. + Reader r = Reader::of(rb); + const uint16_t nwqid = r.u2(); + if (!r.ok || nwqid != 1) { + m_error = QStringLiteral("walk '%1' did not resolve").arg(name); + return false; + } + return true; +} + +bool NineP::open(uint32_t fid, uint8_t mode) +{ + // Topen: fid[4] mode[1] + QByteArray body; + put4(body, fid); + put1(body, mode); + if (!sendMsg(Topen, Tag, body)) { + return false; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Ropen) { + if (m_error.isEmpty()) { + m_error = QStringLiteral("open failed"); + } + return false; + } + return true; +} + +int NineP::write(uint32_t fid, uint64_t offset, const QByteArray &data) +{ + // Twrite: fid[4] offset[8] count[4] data[count] + if (uint32_t(data.size()) + 4 + 1 + 2 + 4 + 8 + 4 > m_msize) { + m_error = QStringLiteral("write exceeds msize"); + return -1; + } + QByteArray body; + put4(body, fid); + put8(body, offset); + put4(body, uint32_t(data.size())); + body.append(data); + if (!sendMsg(Twrite, Tag, body)) { + return -1; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rwrite) { + if (m_error.isEmpty()) { + m_error = QStringLiteral("write failed"); + } + return -1; + } + Reader r = Reader::of(rb); + const uint32_t count = r.u4(); + if (!r.ok) { + m_error = QStringLiteral("malformed Rwrite"); + return -1; + } + return int(count); +} + +int NineP::read(uint32_t fid, uint64_t offset, uint32_t count, QByteArray *out) +{ + // Tread: fid[4] offset[8] count[4] + if (count > m_msize - IOHDRSZ_LOCAL) { + count = m_msize - IOHDRSZ_LOCAL; + } + QByteArray body; + put4(body, fid); + put8(body, offset); + put4(body, count); + if (!sendMsg(Tread, Tag, body)) { + return -1; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rread) { + if (m_error.isEmpty()) { + m_error = QStringLiteral("read failed"); + } + return -1; + } + // Rread: count[4] data[count] + Reader r = Reader::of(rb); + const uint32_t n = r.u4(); + if (!r.ok || int(n) > rb.size() - 4) { + m_error = QStringLiteral("malformed Rread"); + return -1; + } + if (out) { + *out = rb.mid(4, int(n)); + } + return int(n); +} + +bool NineP::beginRead(uint32_t fid, uint64_t offset, uint32_t count) +{ + // Tread: fid[4] offset[8] count[4] — sent without waiting for the reply. + if (count > m_msize - IOHDRSZ_LOCAL) { + count = m_msize - IOHDRSZ_LOCAL; + } + QByteArray body; + put4(body, fid); + put8(body, offset); + put4(body, count); + return sendMsg(Tread, Tag, body); +} + +int NineP::recvReadReply(QByteArray *out, int firstByteTimeoutMs, bool *timedOut) +{ + if (timedOut) { + *timedOut = false; + } + // Wait only for the first byte of the reply to appear. If none arrives in + // the window, the Tread is still outstanding on the server — report a + // timeout so the caller can re-check its stop flag and poll again WITHOUT + // issuing another Tread. + if (m_sock->bytesAvailable() == 0) { + if (!m_sock->waitForReadyRead(firstByteTimeoutMs)) { + // Distinguish a quiet socket (still connected, just no data) from a + // real error. + if (m_sock->state() == QLocalSocket::ConnectedState) { + if (timedOut) { + *timedOut = true; + } + return 0; + } + m_error = QStringLiteral("socket read failed: %1").arg(m_sock->errorString()); + return -1; + } + } + // Data has started; read the full message (blocking is fine now). + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rread) { + if (m_error.isEmpty()) { + m_error = QStringLiteral("read reply failed"); + } + return -1; + } + Reader r = Reader::of(rb); + const uint32_t n = r.u4(); + if (!r.ok || int(n) > rb.size() - 4) { + m_error = QStringLiteral("malformed Rread"); + return -1; + } + if (out) { + *out = rb.mid(4, int(n)); + } + return int(n); +} + +bool NineP::clunk(uint32_t fid) +{ + // Tclunk: fid[4] + QByteArray body; + put4(body, fid); + if (!sendMsg(Tclunk, Tag, body)) { + return false; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rclunk) { + return false; + } + return true; +} + +} // namespace katecustom diff --git a/src/plumb/ninep.h b/src/plumb/ninep.h new file mode 100644 index 0000000..4b1ffad --- /dev/null +++ b/src/plumb/ninep.h @@ -0,0 +1,131 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * NineP — a minimal, synchronous 9P2000 client over a Unix-domain socket. + * + * This is just enough of the protocol to talk to a plan9port plumber: version + * negotiation, attach, walk, open, read and write. No auth (the plumber accepts + * NOFID afid), no create/remove/stat. All little-endian, matching plan9port + * fcall.h. Each T-message uses a fixed tag (we issue one request at a time, so + * there is no need to multiplex) except Tversion which must use NOTAG. + * + * The plumb socket lives at $NAMESPACE/plumb and speaks raw 9P2000, exactly as + * lib9pclient reaches it via nsmount("plumb") -> dial("unix!$ns/plumb"). + */ +#ifndef KATECUSTOM_NINEP_H +#define KATECUSTOM_NINEP_H + +#include +#include +#include + +class QLocalSocket; + +namespace katecustom +{ + +/*! + * One synchronous 9P2000 session. Not thread-safe; create one per thread that + * needs it (the plumb reader uses its own instance on its own thread). + */ +class NineP +{ +public: + NineP(); + ~NineP(); + + NineP(const NineP &) = delete; + NineP &operator=(const NineP &) = delete; + + /*! + * Connect to \a socketPath, negotiate 9P2000, and attach as \a user with + * the given \a aname. Returns true on success. On failure, errorString() + * explains why and the session is left closed. + */ + bool connectAndAttach(const QString &socketPath, const QString &user, + const QString &aname = QString()); + + /*! True if a session is open and attached. */ + bool isConnected() const; + + /*! Close the socket (clunks the root fid best-effort). */ + void close(); + + /*! + * Walk from the attached root to \a name (a single path element, e.g. + * "send" or "edit") binding \a newFid to it. Returns true on success. + */ + bool walk(uint32_t newFid, const QString &name); + + /*! Open \a fid with the given 9P \a mode (OREAD=0, OWRITE=1, ORDWR=2). */ + bool open(uint32_t fid, uint8_t mode); + + /*! + * Write \a data to \a fid at \a offset. Returns the number of bytes the + * server accepted, or -1 on error. Writes larger than the negotiated msize + * must be split by the caller (plumb messages are small). + */ + int write(uint32_t fid, uint64_t offset, const QByteArray &data); + + /*! + * Read up to \a count bytes from \a fid at \a offset into \a out. Returns + * the number of bytes read (0 at EOF), or -1 on error. Blocks until the + * server responds — on the edit port this is how we wait for a plumb + * message to arrive. + */ + int read(uint32_t fid, uint64_t offset, uint32_t count, QByteArray *out); + + /*! + * Send a Tread for \a fid at \a offset for up to \a count bytes without + * waiting for the reply. Pairs with recvReadReply() to make the blocking + * edit-port read cancellable: the reader thread issues one beginRead() then + * polls recvReadReply() with a short timeout, checking its stop flag + * between polls, so no second Tread is ever issued on the same tag. + */ + bool beginRead(uint32_t fid, uint64_t offset, uint32_t count); + + /*! + * Wait up to \a firstByteTimeoutMs for the Rread reply to the pending + * beginRead() to begin arriving, then read it fully. Returns the byte count + * (>=0), 0 with \a timedOut set when nothing arrived (the Tread is still + * pending — do not call beginRead() again), or -1 on error. + */ + int recvReadReply(QByteArray *out, int firstByteTimeoutMs, bool *timedOut); + + /*! Release \a fid on the server. */ + bool clunk(uint32_t fid); + + /*! Negotiated maximum message size. */ + uint32_t msize() const { return m_msize; } + + /*! Human-readable description of the last failure. */ + QString errorString() const { return m_error; } + + // 9P open modes. + enum OpenMode : uint8_t { OREAD = 0, OWRITE = 1, ORDWR = 2 }; + + // The root fid bound by attach. + static constexpr uint32_t RootFid = 0; + +private: + // Send a fully-encoded T-message body (without the leading size[4]) with + // the given type and tag, prepend size, flush. Returns false on socket + // error. + bool sendMsg(uint8_t type, uint16_t tag, const QByteArray &body); + + // Read one full R-message. Fills \a type and \a tag and returns the body + // (everything after size[4] type[1] tag[2]). On Rerror, sets m_error and + // returns false. On socket/protocol error, sets m_error and returns false. + bool recvMsg(uint8_t *type, uint16_t *tag, QByteArray *body); + + // Block until exactly n bytes are available or the socket fails. + bool readExactly(char *buf, int n); + + QLocalSocket *m_sock = nullptr; + uint32_t m_msize = 8192; + QString m_error; +}; + +} // namespace katecustom + +#endif diff --git a/src/plumb/plumber.cpp b/src/plumb/plumber.cpp new file mode 100644 index 0000000..b366dc3 --- /dev/null +++ b/src/plumb/plumber.cpp @@ -0,0 +1,229 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "plumber.h" + +#include "ninep.h" + +#include +#include +#include + +#include + +namespace katecustom +{ + +// Canonicalise the X11 display the way plan9 getns() does: "xxx:0.0" -> "xxx:0" +// and '/' -> '_'. Only the trailing ".0" is stripped. +static QString canonicalDisplay(QString disp) +{ + const int colon = disp.lastIndexOf(QLatin1Char(':')); + if (colon >= 0) { + int p = colon + 1; + while (p < disp.size() && disp[p].isDigit()) { + p++; + } + if (disp.mid(p) == QLatin1String(".0")) { + disp.truncate(p); + } + } + disp.replace(QLatin1Char('/'), QLatin1Char('_')); + return disp; +} + +QString Plumber::socketPath() +{ + const QByteArray ns = qgetenv("NAMESPACE"); + QString nsDir; + if (!ns.isEmpty()) { + nsDir = QString::fromLocal8Bit(ns); + } else { + const QByteArray disp = qgetenv("DISPLAY"); + if (disp.isEmpty()) { + return QString(); // no namespace resolvable + } + QString user = QString::fromLocal8Bit(qgetenv("USER")); + if (user.isEmpty()) { + user = QString::fromLocal8Bit(qgetenv("LOGNAME")); + } + nsDir = QStringLiteral("/tmp/ns.%1.%2") + .arg(user, canonicalDisplay(QString::fromLocal8Bit(disp))); + } + return nsDir + QStringLiteral("/plumb"); +} + +// Current user name for the 9P attach (plumber ignores it but requires a fid). +static QString currentUser() +{ + QString user = QString::fromLocal8Bit(qgetenv("USER")); + if (user.isEmpty()) { + user = QString::fromLocal8Bit(qgetenv("LOGNAME")); + } + if (user.isEmpty()) { + user = QStringLiteral("none"); + } + return user; +} + +// --------------------------------------------------------------------------- +// PlumbReader: owns a NineP session on the edit port, loops on blocking reads. +// Lives on its own QThread. Emits parsed messages via queued signals so the +// Plumber (GUI thread) can act on them. +// --------------------------------------------------------------------------- +class PlumbReader : public QThread +{ + Q_OBJECT +public: + explicit PlumbReader(const QString &socketPath, QObject *parent = nullptr) + : QThread(parent) + , m_socketPath(socketPath) + { + } + + void stop() + { + // Only flips the atomic. The socket is owned solely by run(); the loop + // polls this flag between short read-wait windows and tears the socket + // down from inside run(). Never touch m_sock from another thread. + m_stop.storeRelaxed(1); + } + +Q_SIGNALS: + void message(const QString &file, const QString &addr, const QString &wdir); + void failed(const QString &message); + +protected: + void run() override + { + NineP nine; + if (!nine.connectAndAttach(m_socketPath, currentUser())) { + Q_EMIT failed(QStringLiteral("plumb connect: %1").arg(nine.errorString())); + return; + } + static constexpr uint32_t EditFid = 1; + if (!nine.walk(EditFid, QStringLiteral("edit")) || !nine.open(EditFid, NineP::OREAD)) { + Q_EMIT failed(QStringLiteral("plumb open edit: %1").arg(nine.errorString())); + return; + } + + // One Tread outstanding at a time. We poll its reply in short windows so + // the stop flag is honoured within ~PollMs without a cross-thread socket + // touch and without ever issuing a second Tread on the same tag. + static constexpr int PollMs = 200; + bool pending = false; + while (!m_stop.loadRelaxed()) { + if (!pending) { + if (!nine.beginRead(EditFid, 0, nine.msize())) { + Q_EMIT failed(QStringLiteral("plumb read: %1").arg(nine.errorString())); + break; + } + pending = true; + } + QByteArray buf; + bool timedOut = false; + const int n = nine.recvReadReply(&buf, PollMs, &timedOut); + if (timedOut) { + continue; // Tread still pending; re-check stop and poll again + } + if (n < 0) { + if (!m_stop.loadRelaxed()) { + Q_EMIT failed(QStringLiteral("plumb read: %1").arg(nine.errorString())); + } + break; + } + pending = false; // reply consumed; next loop issues a fresh Tread + if (n == 0) { + continue; // empty read, keep going + } + PlumbMsg m; + if (PlumbMsg::unpack(buf, &m)) { + Q_EMIT message(QString::fromUtf8(m.data), m.lookup(QStringLiteral("addr")), + m.wdir); + } + } + // Socket is closed by NineP's destructor here, on this thread. + } + +private: + QString m_socketPath; + QAtomicInt m_stop{0}; +}; + +// --------------------------------------------------------------------------- +// Plumber +// --------------------------------------------------------------------------- +Plumber::Plumber(QObject *parent) + : QObject(parent) +{ +} + +Plumber::~Plumber() +{ + if (m_reader) { + m_reader->stop(); + m_reader->wait(2000); + delete m_reader; + m_reader = nullptr; + } +} + +bool Plumber::send(const QString &data, const QString &wdir) +{ + const QString path = socketPath(); + if (path.isEmpty()) { + m_error = QStringLiteral("no plumb namespace ($NAMESPACE/$DISPLAY unset)"); + return false; + } + + NineP nine; + if (!nine.connectAndAttach(path, currentUser())) { + m_error = nine.errorString(); + return false; + } + static constexpr uint32_t SendFid = 1; + if (!nine.walk(SendFid, QStringLiteral("send")) || !nine.open(SendFid, NineP::OWRITE)) { + m_error = nine.errorString(); + return false; + } + + PlumbMsg m; + m.src = QStringLiteral("kate"); + m.dst = QString(); // let the rules route it + m.wdir = wdir; + m.type = QStringLiteral("text"); + m.data = data.toUtf8(); + + const QByteArray packed = m.pack(); + // A plumb message is written as a single Twrite (the port fs treats one + // write as one message). Offset 0. + const int w = nine.write(SendFid, 0, packed); + if (w != packed.size()) { + m_error = nine.errorString().isEmpty() + ? QStringLiteral("short plumb write (%1/%2)").arg(w).arg(packed.size()) + : nine.errorString(); + return false; + } + m_error.clear(); + return true; +} + +void Plumber::startReader() +{ + if (m_reader) { + return; + } + const QString path = socketPath(); + if (path.isEmpty()) { + Q_EMIT readerError(QStringLiteral("no plumb namespace ($NAMESPACE/$DISPLAY unset)")); + return; + } + m_reader = new PlumbReader(path); + connect(m_reader, &PlumbReader::message, this, &Plumber::edit, Qt::QueuedConnection); + connect(m_reader, &PlumbReader::failed, this, &Plumber::readerError, Qt::QueuedConnection); + m_reader->start(); +} + +} // namespace katecustom + +#include "plumber.moc" diff --git a/src/plumb/plumber.h b/src/plumb/plumber.h new file mode 100644 index 0000000..192ed69 --- /dev/null +++ b/src/plumb/plumber.h @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Plumber — Kate's bridge to the plan9port plumbing system. + * + * Kate acts as a full plumb client: + * - SEND: send() opens the "send" port on the plumber and writes a packed + * plumb message; the plumber's rules resolve it (file:line -> edit port, + * url -> web port, etc.). The source is "kate" and the destination is left + * empty so the rules decide. + * - RECEIVE: a dedicated reader thread opens the "edit" port and blocks on + * reads. Each plumb message that arrives (file to open, with an optional + * addr) is emitted via edit() on the GUI thread via a queued signal. + * + * The namespace socket is $NAMESPACE/plumb, or /tmp/ns.$USER.$DISPLAY/plumb + * when $NAMESPACE is unset — the same resolution plan9's getns() performs. + */ +#ifndef KATECUSTOM_PLUMBER_H +#define KATECUSTOM_PLUMBER_H + +#include +#include + +#include "plumbmsg.h" + +namespace katecustom +{ + +class PlumbReader; // reader living on its own thread + +/*! + * Facade used by the plugin. Owns the edit-port reader thread and offers a + * one-shot send(). Construct one per OllieView. + */ +class Plumber : public QObject +{ + Q_OBJECT +public: + explicit Plumber(QObject *parent = nullptr); + ~Plumber() override; + + /*! Resolve the plumb socket path ($NAMESPACE/plumb or display fallback). */ + static QString socketPath(); + + /*! + * Build and send a plumb message carrying \a data (a token, path or URL) + * with working directory \a wdir. src="kate", dst="" (rules decide), + * type="text". Returns true if the plumber accepted the write. On failure + * errorString() explains why — the caller may then fall back to internal + * resolution. + */ + bool send(const QString &data, const QString &wdir); + + /*! Last send() error. */ + QString errorString() const { return m_error; } + + /*! + * Start the edit-port reader thread. Safe to call once; further calls are + * no-ops. Reader failures are reported via readerError(). + */ + void startReader(); + +Q_SIGNALS: + /*! + * A plumb message arrived on the edit port: open \a file and, if \a addr is + * non-empty, jump to it. Emitted on the GUI (Plumber's) thread. + */ + void edit(const QString &file, const QString &addr, const QString &wdir); + + /*! The reader stopped with an error (e.g. plumber not running). */ + void readerError(const QString &message); + +private: + PlumbReader *m_reader = nullptr; + QString m_error; +}; + +} // namespace katecustom + +#endif diff --git a/src/plumb/plumbmsg.cpp b/src/plumb/plumbmsg.cpp new file mode 100644 index 0000000..168535b --- /dev/null +++ b/src/plumb/plumbmsg.cpp @@ -0,0 +1,197 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "plumbmsg.h" + +namespace katecustom +{ + +QString PlumbMsg::lookup(const QString &name) const +{ + for (const PlumbAttr &a : attr) { + if (a.name == name) { + return a.value; + } + } + return QString(); +} + +void PlumbMsg::setAttr(const QString &name, const QString &value) +{ + for (PlumbAttr &a : attr) { + if (a.name == name) { + a.value = value; + return; + } + } + attr.append(PlumbAttr{name, value}); +} + +// Quote one attribute value if it contains any of space, ', = or tab. A literal +// ' inside a quoted value is doubled. Mirrors libplumb mesg.c quote(). +static QString quoteAttrValue(const QString &v) +{ + bool needsQuote = false; + for (const QChar c : v) { + if (c == QLatin1Char(' ') || c == QLatin1Char('\'') + || c == QLatin1Char('=') || c == QLatin1Char('\t')) { + needsQuote = true; + break; + } + } + if (!needsQuote) { + return v; + } + QString out; + out.reserve(v.size() + 2); + out.append(QLatin1Char('\'')); + for (const QChar c : v) { + out.append(c); + if (c == QLatin1Char('\'')) { + out.append(c); // double it + } + } + out.append(QLatin1Char('\'')); + return out; +} + +QByteArray packPlumbAttr(const QList &attr) +{ + QString s; + bool first = true; + for (const PlumbAttr &a : attr) { + if (!first) { + s.append(QLatin1Char(' ')); + } + first = false; + s.append(a.name); + s.append(QLatin1Char('=')); + s.append(quoteAttrValue(a.value)); + } + return s.toUtf8(); +} + +QList unpackPlumbAttr(const QByteArray &line) +{ + // Parse name=value pairs with libplumb quoting (mesg.c plumbunpackattr): + // whitespace separates pairs; a value may be single-quoted; inside quotes a + // doubled '' is a literal '. + QList out; + const QString s = QString::fromUtf8(line); + int i = 0; + const int n = s.size(); + while (i < n) { + while (i < n && (s[i] == QLatin1Char(' ') || s[i] == QLatin1Char('\t'))) { + i++; + } + if (i >= n) { + break; + } + // name up to '=' + int nameStart = i; + while (i < n && s[i] != QLatin1Char('=') && s[i] != QLatin1Char(' ') + && s[i] != QLatin1Char('\t')) { + i++; + } + if (i >= n || s[i] != QLatin1Char('=')) { + break; // malformed attribute + } + const QString name = s.mid(nameStart, i - nameStart); + i++; // skip '=' + QString value; + bool quoting = false; + while (i < n) { + const QChar c = s[i]; + if (quoting) { + if (c == QLatin1Char('\'')) { + if (i + 1 < n && s[i + 1] == QLatin1Char('\'')) { + i++; // doubled quote -> literal ' + } else { + quoting = false; + i++; + continue; + } + } + } else { + if (c == QLatin1Char(' ') || c == QLatin1Char('\t')) { + break; + } + if (c == QLatin1Char('\'')) { + quoting = true; + i++; + continue; + } + } + value.append(c); + i++; + } + out.append(PlumbAttr{name, value}); + } + return out; +} + +QByteArray PlumbMsg::pack() const +{ + QByteArray buf; + buf.append(src.toUtf8()); + buf.append('\n'); + buf.append(dst.toUtf8()); + buf.append('\n'); + buf.append(wdir.toUtf8()); + buf.append('\n'); + buf.append(type.toUtf8()); + buf.append('\n'); + buf.append(packPlumbAttr(attr)); + buf.append('\n'); + buf.append(QByteArray::number(data.size())); + buf.append('\n'); + buf.append(data); + return buf; +} + +bool PlumbMsg::unpack(const QByteArray &buf, PlumbMsg *out) +{ + if (!out) { + return false; + } + // Split the first six lines; the sixth field (ndata) is followed by data. + // We locate five newlines for the five text headers, then the ndata line, + // then take the remainder as data (clamped like libplumb). + int pos = 0; + const int n = buf.size(); + auto nextLine = [&](QByteArray *line) -> bool { + int nl = buf.indexOf('\n', pos); + if (nl < 0) { + return false; + } + *line = buf.mid(pos, nl - pos); + pos = nl + 1; + return true; + }; + + QByteArray srcL, dstL, wdirL, typeL, attrL, ndataL; + if (!nextLine(&srcL) || !nextLine(&dstL) || !nextLine(&wdirL) + || !nextLine(&typeL) || !nextLine(&attrL) || !nextLine(&ndataL)) { + return false; + } + + PlumbMsg m; + m.src = QString::fromUtf8(srcL); + m.dst = QString::fromUtf8(dstL); + m.wdir = QString::fromUtf8(wdirL); + m.type = QString::fromUtf8(typeL); + m.attr = unpackPlumbAttr(attrL); + + bool ok = false; + const int declared = ndataL.trimmed().toInt(&ok); + Q_UNUSED(declared); // libplumb trusts the actual trailing byte count + if (!ok) { + return false; + } + m.data = buf.mid(pos, n - pos); + + *out = m; + return true; +} + +} // namespace katecustom diff --git a/src/plumb/plumbmsg.h b/src/plumb/plumbmsg.h new file mode 100644 index 0000000..d0f8345 --- /dev/null +++ b/src/plumb/plumbmsg.h @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * PlumbMsg — a plan9port plumbing message and its wire codec. + * + * A plumb message is a context-carrying request routed by the plumber daemon + * through named ports. The wire format (plan9port libplumb/mesg.c) is six + * newline-terminated header lines followed by the raw data bytes: + * + * src\n source application ("kate") + * dst\n destination port ("" lets the rules decide) + * wdir\n working directory (resolves relative paths) + * type\n data type ("text") + * attr\n space-separated name=value attributes + * ndata\n decimal byte count of the data that follows + * the data (NOT newline-terminated) + * + * Attribute values containing space, tab, '=' or ' are single-quoted, with a + * literal ' escaped by doubling it ('') — exactly as libplumb quotes them. + * + * This header is pure (QString/QByteArray only) so pack/unpack can be unit + * tested against the bytes captured from a live plumber on the edit port. + */ +#ifndef KATECUSTOM_PLUMBMSG_H +#define KATECUSTOM_PLUMBMSG_H + +#include +#include +#include + +namespace katecustom +{ + +/*! One plumb attribute (name=value). */ +struct PlumbAttr { + QString name; + QString value; + bool operator==(const PlumbAttr &o) const + { + return name == o.name && value == o.value; + } +}; + +/*! A decoded plumb message. */ +struct PlumbMsg { + QString src; + QString dst; + QString wdir; + QString type; + QList attr; + QByteArray data; + + /*! Value of attribute \a name, or empty QString if absent. */ + QString lookup(const QString &name) const; + + /*! Set (or replace) attribute \a name to \a value. */ + void setAttr(const QString &name, const QString &value); + + /*! + * Encode to the libplumb wire format. The attribute list is serialised with + * libplumb-compatible quoting. ndata is the byte length of \c data. + */ + QByteArray pack() const; + + /*! + * Decode \a buf (a complete message) into \a out. Returns true on success. + * Mirrors libplumb plumbunpackpartial: parses six header lines, then takes + * exactly the trailing bytes as data (libplumb clamps ndata to what was + * actually received, so a short ndata header is tolerated the same way). + */ + static bool unpack(const QByteArray &buf, PlumbMsg *out); +}; + +/*! Serialise an attribute list with libplumb quoting rules (exposed for tests). */ +QByteArray packPlumbAttr(const QList &attr); + +/*! Parse a packed attribute line back into a list (exposed for tests). */ +QList unpackPlumbAttr(const QByteArray &line); + +} // namespace katecustom + +#endif diff --git a/src/plumb/plumbresolve.cpp b/src/plumb/plumbresolve.cpp new file mode 100644 index 0000000..891d97b --- /dev/null +++ b/src/plumb/plumbresolve.cpp @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "plumbresolve.h" + +namespace katecustom +{ + +PlumbAddr parsePlumbAddr(const QString &addr) +{ + PlumbAddr out; + if (addr.isEmpty()) { + return out; + } + // Leading run of digits = line (1-based on the wire). + int i = 0; + const int n = addr.size(); + while (i < n && addr[i].isDigit()) { + i++; + } + if (i == 0) { + return out; // not a numeric leading address (e.g. a regex) + } + bool ok = false; + const int line = addr.left(i).toInt(&ok); + if (!ok || line < 1) { + return out; + } + out.valid = true; + out.line = line - 1; + + // Optional ":C" or ".C" column, also 1-based. + if (i < n && (addr[i] == QLatin1Char(':') || addr[i] == QLatin1Char('.'))) { + int j = i + 1; + int colStart = j; + while (j < n && addr[j].isDigit()) { + j++; + } + if (j > colStart) { + const int col = addr.mid(colStart, j - colStart).toInt(&ok); + if (ok && col >= 1) { + out.column = col - 1; + } + } + } + return out; +} + +} // namespace katecustom diff --git a/src/plumb/plumbresolve.h b/src/plumb/plumbresolve.h new file mode 100644 index 0000000..1732993 --- /dev/null +++ b/src/plumb/plumbresolve.h @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * PlumbResolve — pure helpers that translate between editor state and plumb + * messages, with no Qt GUI or 9P dependencies so they can be unit tested. + * + * - parseAddr() turns a plan9 edit-port "addr" attribute into a zero-based + * (line, column) for KTextEditor::Cursor. Acme's edit rules emit addresses + * like "12" (line), "12:3" / "12.3" (line:col). Addresses are 1-based on the + * wire; we return 0-based. Regex and compound addresses are not resolved + * here (we return the line as 0 so the caller just opens the file). + * - selectionToData() picks what text to plumb: an explicit selection wins, + * otherwise the word under the caret. + */ +#ifndef KATECUSTOM_PLUMBRESOLVE_H +#define KATECUSTOM_PLUMBRESOLVE_H + +#include + +namespace katecustom +{ + +/*! Zero-based editor position parsed from a plumb addr attribute. */ +struct PlumbAddr { + bool valid = false; // false when the attr was empty/unparseable + int line = 0; // zero-based + int column = 0; // zero-based +}; + +/*! + * Parse a plan9 edit-port \a addr attribute into a zero-based position. + * Accepts "N", "N:C" and "N.C" (1-based on the wire). An empty or non-numeric + * leading address yields {valid=false}. Only the leading simple form is read; + * anything more exotic (regex, ranges) leaves column 0 and line from the + * leading number if present. + */ +PlumbAddr parsePlumbAddr(const QString &addr); + +} // namespace katecustom + +#endif diff --git a/src/plumb/test_plumb_live.cpp b/src/plumb/test_plumb_live.cpp new file mode 100644 index 0000000..256481d --- /dev/null +++ b/src/plumb/test_plumb_live.cpp @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Live round-trip against a running plan9port plumber. This is the real proof + * that our native C++ 9P2000 client speaks the protocol correctly: it sends a + * plumb message through the "send" port and receives the routed message back on + * the "edit" port via the Plumber reader thread. + * + * If no plumb socket is reachable (no plumber, no $NAMESPACE), the test skips + * itself and PASSES, so it is safe to run in headless/CI environments. + */ +#include "ninep.h" +#include "plumber.h" + +#include +#include +#include +#include +#include +#include + +using namespace katecustom; + +class TestPlumbLive : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void connectAttach(); + void sendThenReceiveOnEditPort(); +}; + +static bool plumberReachable() +{ + const QString path = Plumber::socketPath(); + if (path.isEmpty() || !QFileInfo::exists(path)) { + return false; + } + NineP n; + const bool ok = n.connectAndAttach(path, QStringLiteral("tester")); + return ok; +} + +void TestPlumbLive::connectAttach() +{ + if (!plumberReachable()) { + QSKIP("no reachable plumber (socketPath empty/absent) — skipping live test"); + } + NineP n; + QVERIFY2(n.connectAndAttach(Plumber::socketPath(), QStringLiteral("tester")), + qPrintable(n.errorString())); + QVERIFY(n.isConnected()); + // We can walk to the well-known ports. + QVERIFY2(n.walk(2, QStringLiteral("send")), qPrintable(n.errorString())); + QVERIFY2(n.walk(3, QStringLiteral("edit")), qPrintable(n.errorString())); +} + +void TestPlumbLive::sendThenReceiveOnEditPort() +{ + if (!plumberReachable()) { + QSKIP("no reachable plumber — skipping live round-trip"); + } + + // A real, existing file so the plumber's "existing file -> edit" rule fires. + QTemporaryFile tmp; + tmp.setAutoRemove(true); + QVERIFY(tmp.open()); + tmp.write("line1\nline2\nline3\n"); + tmp.flush(); + const QString file = tmp.fileName(); + const QFileInfo fi(file); + const QString wdir = fi.absolutePath(); + + Plumber plumber; + QSignalSpy editSpy(&plumber, &Plumber::edit); + QSignalSpy errSpy(&plumber, &Plumber::readerError); + plumber.startReader(); + + // Give the reader thread a moment to attach + open the edit port before we + // send, so the routed message isn't delivered before anyone is listening. + QTest::qWait(300); + if (errSpy.count() > 0) { + QSKIP(qPrintable(QStringLiteral("reader could not open edit port: %1") + .arg(errSpy.first().first().toString()))); + } + + // Send ":2" — the plumber resolves it and routes it to the edit port. + QVERIFY2(plumber.send(file + QStringLiteral(":2"), wdir), + qPrintable(plumber.errorString())); + + // The reader should surface the routed message as an edit() signal. + QVERIFY(editSpy.wait(3000)); + QCOMPARE(editSpy.count(), 1); + const QList args = editSpy.first(); + QCOMPARE(args.at(0).toString(), file); + QCOMPARE(args.at(1).toString(), QStringLiteral("2")); +} + +QTEST_MAIN(TestPlumbLive) +#include "test_plumb_live.moc" diff --git a/src/plumb/test_plumbmsg.cpp b/src/plumb/test_plumbmsg.cpp new file mode 100644 index 0000000..b846778 --- /dev/null +++ b/src/plumb/test_plumbmsg.cpp @@ -0,0 +1,113 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Unit tests for the plumb message codec. The golden bytes are taken verbatim + * from a live plan9port plumber on the edit port (captured during design), so + * these tests pin our pack/unpack to the real wire format, not to a guess. + */ +#include "plumbmsg.h" + +#include +#include + +using namespace katecustom; + +class TestPlumbMsg : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void unpackEditFileWithAddr(); + void unpackEditUrlEmptyAddr(); + void packRoundTrip(); + void attrQuotingRoundTrip(); + void attrDoubledQuote(); + void shortNdataTolerated(); +}; + +// Golden message #1 (file:line -> edit): captured bytes were +// "plumb\nedit\n/tmp\ntext\naddr=2\n18\n/tmp/plumbtest.txt" +void TestPlumbMsg::unpackEditFileWithAddr() +{ + const QByteArray buf = QByteArrayLiteral( + "plumb\nedit\n/tmp\ntext\naddr=2\n18\n/tmp/plumbtest.txt"); + PlumbMsg m; + QVERIFY(PlumbMsg::unpack(buf, &m)); + QCOMPARE(m.src, QStringLiteral("plumb")); + QCOMPARE(m.dst, QStringLiteral("edit")); + QCOMPARE(m.wdir, QStringLiteral("/tmp")); + QCOMPARE(m.type, QStringLiteral("text")); + QCOMPARE(m.lookup(QStringLiteral("addr")), QStringLiteral("2")); + QCOMPARE(QString::fromUtf8(m.data), QStringLiteral("/tmp/plumbtest.txt")); +} + +// Golden message #2 (url -> edit, empty addr): captured bytes were +// "plumb\nedit\n/tmp\ntext\n\n19\nhttps://example.com" +void TestPlumbMsg::unpackEditUrlEmptyAddr() +{ + const QByteArray buf = QByteArrayLiteral( + "plumb\nedit\n/tmp\ntext\n\n19\nhttps://example.com"); + PlumbMsg m; + QVERIFY(PlumbMsg::unpack(buf, &m)); + QVERIFY(m.attr.isEmpty()); + QCOMPARE(m.lookup(QStringLiteral("addr")), QString()); + QCOMPARE(QString::fromUtf8(m.data), QStringLiteral("https://example.com")); +} + +void TestPlumbMsg::packRoundTrip() +{ + PlumbMsg m; + m.src = QStringLiteral("kate"); + m.dst = QString(); + m.wdir = QStringLiteral("/home/user/proj"); + m.type = QStringLiteral("text"); + m.setAttr(QStringLiteral("addr"), QStringLiteral("42")); + m.data = QByteArrayLiteral("main.cpp"); + + const QByteArray packed = m.pack(); + // Header shape: six '\n'-terminated lines then data with no trailing \n. + QCOMPARE(packed, QByteArrayLiteral( + "kate\n\n/home/user/proj\ntext\naddr=42\n8\nmain.cpp")); + + PlumbMsg back; + QVERIFY(PlumbMsg::unpack(packed, &back)); + QCOMPARE(back.src, m.src); + QCOMPARE(back.dst, m.dst); + QCOMPARE(back.wdir, m.wdir); + QCOMPARE(back.type, m.type); + QCOMPARE(back.lookup(QStringLiteral("addr")), QStringLiteral("42")); + QCOMPARE(back.data, m.data); +} + +void TestPlumbMsg::attrQuotingRoundTrip() +{ + // A value with a space must be single-quoted on the wire. + QList attr{{QStringLiteral("click"), QStringLiteral("1 2")}}; + const QByteArray packed = packPlumbAttr(attr); + QCOMPARE(packed, QByteArrayLiteral("click='1 2'")); + const QList back = unpackPlumbAttr(packed); + QCOMPARE(back, attr); +} + +void TestPlumbMsg::attrDoubledQuote() +{ + // A literal ' is doubled inside quotes. + QList attr{{QStringLiteral("x"), QStringLiteral("a'b c")}}; + const QByteArray packed = packPlumbAttr(attr); + QCOMPARE(packed, QByteArrayLiteral("x='a''b c'")); + const QList back = unpackPlumbAttr(packed); + QCOMPARE(back.size(), 1); + QCOMPARE(back.first().value, QStringLiteral("a'b c")); +} + +void TestPlumbMsg::shortNdataTolerated() +{ + // libplumb clamps ndata to the actual trailing bytes; a wrong count still + // yields the real data (mirrors plumbunpackpartial behaviour). + const QByteArray buf = QByteArrayLiteral("s\nd\nw\ntext\n\n999\nhello"); + PlumbMsg m; + QVERIFY(PlumbMsg::unpack(buf, &m)); + QCOMPARE(QString::fromUtf8(m.data), QStringLiteral("hello")); +} + +QTEST_MAIN(TestPlumbMsg) +#include "test_plumbmsg.moc" diff --git a/src/plumb/test_plumbresolve.cpp b/src/plumb/test_plumbresolve.cpp new file mode 100644 index 0000000..2f95979 --- /dev/null +++ b/src/plumb/test_plumbresolve.cpp @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "plumbresolve.h" + +#include +#include + +using namespace katecustom; + +class TestPlumbResolve : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void emptyIsInvalid(); + void lineOnly(); + void lineColon(); + void lineDot(); + void regexIsInvalid(); + void zeroIsInvalid(); +}; + +void TestPlumbResolve::emptyIsInvalid() +{ + const PlumbAddr a = parsePlumbAddr(QString()); + QVERIFY(!a.valid); +} + +void TestPlumbResolve::lineOnly() +{ + // "2" on the wire (1-based) -> line index 1, column 0. + const PlumbAddr a = parsePlumbAddr(QStringLiteral("2")); + QVERIFY(a.valid); + QCOMPARE(a.line, 1); + QCOMPARE(a.column, 0); +} + +void TestPlumbResolve::lineColon() +{ + // "12:3" -> line 11, column 2. + const PlumbAddr a = parsePlumbAddr(QStringLiteral("12:3")); + QVERIFY(a.valid); + QCOMPARE(a.line, 11); + QCOMPARE(a.column, 2); +} + +void TestPlumbResolve::lineDot() +{ + // "12.3" -> line 11, column 2 (acme also emits the dot form). + const PlumbAddr a = parsePlumbAddr(QStringLiteral("12.3")); + QVERIFY(a.valid); + QCOMPARE(a.line, 11); + QCOMPARE(a.column, 2); +} + +void TestPlumbResolve::regexIsInvalid() +{ + // A regex address has no leading digits -> we can't position; leave invalid. + const PlumbAddr a = parsePlumbAddr(QStringLiteral("/func foo/")); + QVERIFY(!a.valid); +} + +void TestPlumbResolve::zeroIsInvalid() +{ + // Line 0 is not a valid 1-based address. + const PlumbAddr a = parsePlumbAddr(QStringLiteral("0")); + QVERIFY(!a.valid); +} + +QTEST_MAIN(TestPlumbResolve) +#include "test_plumbresolve.moc"