Git commit 98aab64cf96ed8903b2c58d4b1c5257279c7af42 by Christoph Cullmann, on behalf of Mark Nauwelaerts. Committed on 15/08/2025 at 16:40. Pushed by cullmann into branch 'master'.
lspclient: support execution environment prefix and path mapping M +2 -2 addons/lspclient/lspclientpluginview.cpp M +155 -17 addons/lspclient/lspclientserver.cpp M +13 -1 addons/lspclient/lspclientserver.h M +93 -7 addons/lspclient/lspclientservermanager.cpp M +6 -0 apps/lib/CMakeLists.txt M +1 -0 apps/lib/autotests/CMakeLists.txt A +124 -0 apps/lib/autotests/exec_tests.cpp [License: MIT] A +34 -0 apps/lib/exec_inspect.sh A +231 -0 apps/lib/exec_utils.cpp [License: MIT] A +65 -0 apps/lib/exec_utils.h [License: MIT] M +205 -1 doc/kate/plugins.docbook https://invent.kde.org/utilities/kate/-/commit/98aab64cf96ed8903b2c58d4b1c5257279c7af42 diff --git a/addons/lspclient/lspclientpluginview.cpp b/addons/lspclient/lspclientpluginview.cpp index 94f2859411..a277439562 100644 --- a/addons/lspclient/lspclientpluginview.cpp +++ b/addons/lspclient/lspclientpluginview.cpp @@ -1884,9 +1884,9 @@ public: return; } - auto h = [this](const QString &reply) { + auto h = [this](const QUrl &reply) { if (!reply.isEmpty()) { - m_mainWindow->openUrl(QUrl(reply)); + goToDocumentLocation(reply, KTextEditor::Range()); } else { showMessage(i18n("Corresponding Header/Source not found"), KTextEditor::Message::Information); } diff --git a/addons/lspclient/lspclientserver.cpp b/addons/lspclient/lspclientserver.cpp index a783d6c2bf..fce68e4042 100644 --- a/addons/lspclient/lspclientserver.cpp +++ b/addons/lspclient/lspclientserver.cpp @@ -68,6 +68,45 @@ static constexpr char MEMBER_ITEMS[] = "items"; static constexpr char MEMBER_SCOPE_URI[] = "scopeUri"; static constexpr char MEMBER_SECTION[] = "section"; +// slightly unfortunate/unconventional +// but otherwise a whole lot of changes are needed to get this through +// the call stack down to the few places where it is actually needed +// so, all in all, it is far less intrusive to solve it this way +static thread_local LSPClientServer *currentServer = nullptr; + +static QUrl urlTransform(const QUrl &url, bool fromLocal) +{ + // this should always be around + // if not, it means we missed a (call) spot + if (!currentServer) { + qCWarning(LSPCLIENT) << "missing currrent server"; + return url; + } + return currentServer->mapPath(url, fromLocal); +} + +// local helper to set the above +class PushCurrentServer +{ + // should only ever move from null to non-null and back, but anyways + LSPClientServer *prev; + +public: + PushCurrentServer(LSPClientServer *c) + { + prev = currentServer; + currentServer = c; + } + + // make non-copyable etc + PushCurrentServer(PushCurrentServer &&other) = delete; + + ~PushCurrentServer() + { + currentServer = prev; + } +}; + static QByteArray rapidJsonStringify(const rapidjson::Value &v) { rapidjson::StringBuffer buf; @@ -138,7 +177,7 @@ static const rapidjson::Value &GetJsonArrayForKey(const rapidjson::Value &v, std static QJsonValue encodeUrl(const QUrl &url) { - return QJsonValue(QLatin1String(url.toEncoded())); + return QJsonValue(QLatin1String(urlTransform(url, true).toEncoded())); } // message construction helpers @@ -343,7 +382,11 @@ static QJsonArray to_json(const QList<LSPWorkspaceFolder> &l) { QJsonArray result; for (const auto &e : l) { - result.push_back(workspaceFolder(e)); + // skip cases not mappable on the other side + if (urlTransform(e.uri, true).isEmpty()) + continue; + auto wf = workspaceFolder(e); + result.push_back(wf); } return result; } @@ -496,10 +539,16 @@ static void from_json(LSPServerCapabilities &caps, const rapidjson::Value &json) caps.inlayHintProvider = json.HasMember("inlayHintProvider"); } +static QUrl urlFromRemote(const QString &s, bool normalize = true) +{ + auto url = urlTransform(QUrl(s), false); + return normalize ? Utils::normalizeUrl(url) : url; +} + static void from_json(LSPVersionedTextDocumentIdentifier &id, const rapidjson::Value &json) { if (json.IsObject()) { - id.uri = Utils::normalizeUrl(QUrl(GetStringValue(json, MEMBER_URI))); + id.uri = urlFromRemote(GetStringValue(json, MEMBER_URI)); id.version = GetIntValue(json, MEMBER_VERSION, -1); } } @@ -591,18 +640,18 @@ static QList<std::shared_ptr<LSPSelectionRange>> parseSelectionRanges(const rapi static LSPLocation parseLocation(const rapidjson::Value &loc) { - auto uri = Utils::normalizeUrl(QUrl(GetStringValue(loc, MEMBER_URI))); + auto uri = urlFromRemote(GetStringValue(loc, MEMBER_URI)); KTextEditor::Range range; if (auto it = loc.FindMember(MEMBER_RANGE); it != loc.MemberEnd()) { range = parseRange(it->value); } - return {QUrl(uri), range}; + return {uri, range}; } static LSPLocation parseLocationLink(const rapidjson::Value &loc) { auto urlString = GetStringValue(loc, MEMBER_TARGET_URI); - auto uri = Utils::normalizeUrl(QUrl(urlString)); + auto uri = urlFromRemote(urlString); // both should be present, selection contained by the other // so let's preferentially pick the smallest one KTextEditor::Range range; @@ -611,7 +660,7 @@ static LSPLocation parseLocationLink(const rapidjson::Value &loc) } else if (auto it = loc.FindMember(MEMBER_TARGET_RANGE); it != loc.MemberEnd()) { range = parseRange(it->value); } - return {QUrl(uri), range}; + return {uri, range}; } static QList<LSPTextEdit> parseTextEdit(const rapidjson::Value &result) @@ -927,9 +976,10 @@ static LSPSignatureHelp parseSignatureHelp(const rapidjson::Value &result) return ret; } -static QString parseClangdSwitchSourceHeader(const rapidjson::Value &result) +static QUrl parseClangdSwitchSourceHeader(const rapidjson::Value &result) { - return result.IsString() ? QString::fromUtf8(result.GetString(), result.GetStringLength()) : QString(); + auto surl = result.IsString() ? QString::fromUtf8(result.GetString(), result.GetStringLength()) : QString(); + return urlFromRemote(surl, false); } static LSPExpandedMacro parseExpandedMacro(const rapidjson::Value &result) @@ -960,7 +1010,7 @@ static LSPWorkspaceEdit parseWorkSpaceEdit(const rapidjson::Value &result) const auto &changes = GetJsonObjectForKey(result, "changes"); for (const auto &change : changes.GetObject()) { auto url = QString::fromUtf8(change.name.GetString()); - ret.changes.insert(Utils::normalizeUrl(QUrl(url)), parseTextEdit(change.value.GetArray())); + ret.changes.insert(urlFromRemote(url), parseTextEdit(change.value.GetArray())); } const auto &documentChanges = GetJsonArrayForKey(result, "documentChanges"); @@ -1162,7 +1212,7 @@ static LSPPublishDiagnosticsParams parseDiagnostics(const rapidjson::Value &resu auto it = result.FindMember(MEMBER_URI); if (it != result.MemberEnd()) { - ret.uri = QUrl(QString::fromUtf8(it->value.GetString(), it->value.GetStringLength())); + ret.uri = urlFromRemote(QString::fromUtf8(it->value.GetString(), it->value.GetStringLength()), false); } it = result.FindMember(MEMBER_DIAGNOSTICS); @@ -1398,6 +1448,27 @@ public: return m_capabilities; } + PathMappingPtr pathMapping() const + { + return m_config.map; + } + + QUrl mapPath(const QUrl &url, bool fromLocal) const + { + auto &m = m_config.map; + if (!m || m->isEmpty()) + return url; + auto result = Utils::mapPath(*m, url, fromLocal); + qCDebug(LSPCLIENT) << "transform url" << fromLocal << url << "->" << result; + // use special scheme to mark unmappable remote file + // unlikely, as some fallback should always have been added + if (result.isEmpty() && !fromLocal && url.isLocalFile()) { + result = url; + result.setScheme(QStringLiteral("unknown")); + } + return result; + } + int cancel(int reqid) { if (m_handlers.remove(reqid)) { @@ -1512,6 +1583,14 @@ private: qCInfo(LSPCLIENT, "got message payload size %d", length); qCDebug(LSPCLIENT, "message payload:\n%s", payload.constData()); + // check for and signal non-protocol out-of-band data + if (payload.front() != '{' && !isblank(payload.front())) { + /* this does not make for valid json, so treat as extra */ + qCInfo(LSPCLIENT) << "message is extra oob"; + Q_EMIT q->extraData(q, payload); + continue; + } + rapidjson::Document doc; doc.ParseInsitu(payload.data()); if (doc.HasParseError()) { @@ -1553,6 +1632,7 @@ private: // run handler, might e.g. trigger some new LSP actions for this server // process and provide error if caller interested, // otherwise reply will resolve to 'empty' response + PushCurrentServer g(q); auto &h = handler.first; auto &eh = handler.second; if (auto it = result.FindMember(MEMBER_ERROR); it != result.MemberEnd() && eh) { @@ -1729,9 +1809,10 @@ private: capabilities[QStringLiteral("workspace")] = workspaceCapabilities; // NOTE a typical server does not use root all that much, // other than for some corner case (in) requests + auto root = mapPath(m_root, true); QJsonObject params{{QStringLiteral("processId"), QCoreApplication::applicationPid()}, - {QStringLiteral("rootPath"), m_root.isValid() ? m_root.toLocalFile() : QJsonValue()}, - {QStringLiteral("rootUri"), m_root.isValid() ? m_root.toString() : QJsonValue()}, + {QStringLiteral("rootPath"), root.isValid() ? root.toLocalFile() : QJsonValue()}, + {QStringLiteral("rootUri"), root.isValid() ? root.toString() : QJsonValue()}, {QStringLiteral("capabilities"), capabilities}, {QStringLiteral("initializationOptions"), m_init}}; // only add new style workspaces init if so specified @@ -1759,6 +1840,16 @@ public: args.pop_front(); qCInfo(LSPCLIENT) << "starting" << m_server << "with root" << m_root; + // consider additional environment + if (const auto &environment = m_config.environment; !environment.isEmpty()) { + qCInfo(LSPCLIENT) << "extra env" << environment; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + for (auto it = environment.begin(); it != environment.end(); ++it) { + env.insert(it.key(), it.value()); + } + m_sproc.setProcessEnvironment(env); + } + // start LSP server in project root m_sproc.setWorkingDirectory(m_root.toLocalFile()); @@ -1790,60 +1881,70 @@ public: RequestHandle documentSymbols(const QUrl &document, const GenericReplyHandler &h, const GenericReplyHandler &eh) { + PushCurrentServer g(q); auto params = textDocumentParams(document); return send(init_request(QStringLiteral("textDocument/documentSymbol"), params), h, eh); } RequestHandle documentDefinition(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/definition"), params), h); } RequestHandle documentDeclaration(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/declaration"), params), h); } RequestHandle documentTypeDefinition(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/typeDefinition"), params), h); } RequestHandle documentImplementation(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/implementation"), params), h); } RequestHandle documentHover(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/hover"), params), h); } RequestHandle documentHighlight(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/documentHighlight"), params), h); } RequestHandle documentReferences(const QUrl &document, const LSPPosition &pos, bool decl, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = referenceParams(document, pos, decl); return send(init_request(QStringLiteral("textDocument/references"), params), h); } RequestHandle documentCompletion(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/completion"), params), h); } RequestHandle documentCompletionResolve(const LSPCompletionItem &c, const GenericReplyHandler &h) { + PushCurrentServer g(q); QJsonObject params; auto dataDoc = QJsonDocument::fromJson(c.data); if (dataDoc.isObject()) { @@ -1862,18 +1963,21 @@ public: RequestHandle signatureHelp(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("textDocument/signatureHelp"), params), h); } RequestHandle selectionRange(const QUrl &document, const QList<LSPPosition> &positions, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionsParams(document, positions); return send(init_request(QStringLiteral("textDocument/selectionRange"), params), h); } RequestHandle clangdSwitchSourceHeader(const QUrl &document, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = QJsonObject{{QLatin1String(MEMBER_URI), encodeUrl(document)}}; return send(init_request(QStringLiteral("textDocument/switchSourceHeader"), params), h); } @@ -1885,18 +1989,21 @@ public: RequestHandle rustAnalyzerExpandMacro(const QUrl &document, const LSPPosition &pos, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentPositionParams(document, pos); return send(init_request(QStringLiteral("rust-analyzer/expandMacro"), params), h); } RequestHandle documentFormatting(const QUrl &document, const LSPFormattingOptions &options, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = documentRangeFormattingParams(document, nullptr, options); return send(init_request(QStringLiteral("textDocument/formatting"), params), h); } RequestHandle documentRangeFormatting(const QUrl &document, const LSPRange &range, const LSPFormattingOptions &options, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = documentRangeFormattingParams(document, &range, options); return send(init_request(QStringLiteral("textDocument/rangeFormatting"), params), h); } @@ -1904,12 +2011,14 @@ public: RequestHandle documentOnTypeFormatting(const QUrl &document, const LSPPosition &pos, QChar lastChar, const LSPFormattingOptions &options, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = documentOnTypeFormattingParams(document, pos, lastChar, options); return send(init_request(QStringLiteral("textDocument/onTypeFormatting"), params), h); } RequestHandle documentRename(const QUrl &document, const LSPPosition &pos, const QString &newName, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = renameParams(document, pos, newName); return send(init_request(QStringLiteral("textDocument/rename"), params), h); } @@ -1920,12 +2029,14 @@ public: const QList<LSPDiagnostic> &diagnostics, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = codeActionParams(document, range, kinds, diagnostics); return send(init_request(QStringLiteral("textDocument/codeAction"), params), h); } RequestHandle documentSemanticTokensFull(const QUrl &document, bool delta, const QString &requestId, const LSPRange &range, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentParams(document); // Delta if (delta && !requestId.isEmpty()) { @@ -1943,6 +2054,7 @@ public: RequestHandle documentInlayHint(const QUrl &document, const LSPRange &range, const GenericReplyHandler &h) { + PushCurrentServer g(q); auto params = textDocumentParams(document); params[QLatin1String(MEMBER_RANGE)] = to_json(range); return send(init_request(QStringLiteral("textDocument/inlayHint"), params), h); @@ -1950,13 +2062,15 @@ public: void executeCommand(const LSPCommand &command) { + PushCurrentServer g(q); auto params = executeCommandParams(command); // Pass an empty lambda as reply handler because executeCommand is a Request, but we ignore the result - send(init_request(QStringLiteral("workspace/executeCommand"), params), [](const auto &) { }); + send(init_request(QStringLiteral("workspace/executeCommand"), params), [](const auto &) {}); } void didOpen(const QUrl &document, int version, const QString &langId, const QString &text) { + PushCurrentServer g(q); auto params = textDocumentParams(textDocumentItem(document, langId, text, version)); send(init_request(QStringLiteral("textDocument/didOpen"), params)); } @@ -1964,6 +2078,7 @@ public: void didChange(const QUrl &document, int version, const QString &text, const QList<LSPTextDocumentContentChangeEvent> &changes) { Q_ASSERT(text.isEmpty() || changes.empty()); + PushCurrentServer g(q); auto params = textDocumentParams(document, version); params[QStringLiteral("contentChanges")] = text.size() ? QJsonArray{QJsonObject{{QLatin1String(MEMBER_TEXT), text}}} : to_json(changes); send(init_request(QStringLiteral("textDocument/didChange"), params)); @@ -1971,6 +2086,7 @@ public: void didSave(const QUrl &document, const QString &text) { + PushCurrentServer g(q); auto params = textDocumentParams(document); if (!text.isNull()) { params[QStringLiteral("text")] = text; @@ -1980,18 +2096,21 @@ public: void didClose(const QUrl &document) { + PushCurrentServer g(q); auto params = textDocumentParams(document); send(init_request(QStringLiteral("textDocument/didClose"), params)); } void didChangeConfiguration(const QJsonValue &settings) { + PushCurrentServer g(q); auto params = changeConfigurationParams(settings); send(init_request(QStringLiteral("workspace/didChangeConfiguration"), params)); } void didChangeWorkspaceFolders(const QList<LSPWorkspaceFolder> &added, const QList<LSPWorkspaceFolder> &removed) { + PushCurrentServer g(q); auto params = changeWorkspaceFoldersParams(added, removed); send(init_request(QStringLiteral("workspace/didChangeWorkspaceFolders"), params)); } @@ -2018,6 +2137,7 @@ public: auto methodLen = methodId->value.GetStringLength(); QByteArrayView method(methodString, methodLen); + PushCurrentServer g(q); const bool isObj = methodParamsIt->value.IsObject(); auto &obj = methodParamsIt->value; if (isObj && method == "textDocument/publishDiagnostics") { @@ -2058,10 +2178,17 @@ public: } template<typename ReplyType> - static ReplyHandler<ReplyType> responseHandler(const ReplyHandler<QJsonValue> &h, - typename utils::identity<std::function<QJsonValue(const ReplyType &)>>::type c) + ReplyHandler<ReplyType> responseHandler(const ReplyHandler<QJsonValue> &h, typename utils::identity<std::function<QJsonValue(const ReplyType &)>>::type c) { - return [h, c](const ReplyType &m) { + // if we get called, both this and q are still valid + // (or we are in trouble by other ways) + auto ctx = QPointer<LSPClientServer>(q); + return [h, c, ctx](const ReplyType &m) { + if (!ctx) { + return; + } + + PushCurrentServer g(ctx); h(c(m)); }; } @@ -2079,6 +2206,7 @@ public: msgId = GetIntValue(msg, MEMBER_ID, -1); } + PushCurrentServer g(q); const auto ¶ms = GetJsonObjectForKey(msg, MEMBER_PARAMS); bool handled = false; if (method == QLatin1String("workspace/applyEdit")) { @@ -2191,6 +2319,16 @@ const LSPServerCapabilities &LSPClientServer::capabilities() const return d->capabilities(); } +auto LSPClientServer::pathMapping() const -> PathMappingPtr +{ + return d->pathMapping(); +} + +QUrl LSPClientServer::mapPath(const QUrl &url, bool fromLocal) const +{ + return d->mapPath(url, fromLocal); +} + bool LSPClientServer::start(bool forwardStdError) { return d->start(forwardStdError); diff --git a/addons/lspclient/lspclientserver.h b/addons/lspclient/lspclientserver.h index 5f53977466..5c8593563a 100644 --- a/addons/lspclient/lspclientserver.h +++ b/addons/lspclient/lspclientserver.h @@ -18,6 +18,8 @@ #include <functional> #include <optional> +#include <exec_utils.h> + namespace utils { // template helper @@ -64,7 +66,7 @@ using CodeActionReplyHandler = ReplyHandler<QList<LSPCodeAction>>; using WorkspaceEditReplyHandler = ReplyHandler<LSPWorkspaceEdit>; using ApplyEditReplyHandler = ReplyHandler<LSPApplyWorkspaceEditResponse>; using WorkspaceFoldersReplyHandler = ReplyHandler<QList<LSPWorkspaceFolder>>; -using SwitchSourceHeaderHandler = ReplyHandler<QString>; +using SwitchSourceHeaderHandler = ReplyHandler<QUrl>; using MemoryUsageHandler = ReplyHandler<QString>; using ExpandMacroHandler = ReplyHandler<LSPExpandedMacro>; using SemanticTokensDeltaReplyHandler = ReplyHandler<LSPSemanticTokensDelta>; @@ -116,6 +118,8 @@ public: QList<QChar> include; }; + using PathMappingPtr = Utils::PathMappingPtr; + // collect additional tweaks into a helper struct to avoid ever growing parameter list // (which then also needs to be duplicated in a few places) struct ExtraServerConfig { @@ -123,6 +127,8 @@ public: LSPClientCapabilities caps; TriggerCharactersOverride completion; TriggerCharactersOverride signature; + PathMappingPtr map; + QHash<QString, QString> environment; }; LSPClientServer(const QStringList &server, @@ -147,8 +153,14 @@ public: State state() const; Q_SIGNAL void stateChanged(LSPClientServer *server); + // extra out-of-band data + Q_SIGNAL void extraData(LSPClientServer *server, QByteArray payload); + const LSPServerCapabilities &capabilities() const; + PathMappingPtr pathMapping() const; + QUrl mapPath(const QUrl &url, bool fromLocal) const; + // language RequestHandle documentSymbols(const QUrl &document, const QObject *context, const DocumentSymbolsReplyHandler &h, const ErrorReplyHandler &eh = nullptr); RequestHandle documentDefinition(const QUrl &document, const LSPPosition &pos, const QObject *context, const DocumentDefinitionReplyHandler &h); diff --git a/addons/lspclient/lspclientservermanager.cpp b/addons/lspclient/lspclientservermanager.cpp index 495da039fc..060def97d2 100644 --- a/addons/lspclient/lspclientservermanager.cpp +++ b/addons/lspclient/lspclientservermanager.cpp @@ -10,6 +10,7 @@ #include "lspclientservermanager.h" +#include "exec_io_utils.h" #include "hostprocess.h" #include "ktexteditor_utils.h" #include "lspclient_debug.h" @@ -217,6 +218,8 @@ class LSPClientServerManagerImpl : public LSPClientServerManager QJsonValue settings; // use of workspace folders allowed bool useWorkspace = false; + // execPrefix started with, if any + QStringList execPrefix; }; struct DocumentInfo { @@ -615,6 +618,21 @@ private: } } + void onExtraData(LSPClientServer *server, QByteArray data) + { + qCDebug(LSPCLIENT) << "extradata" << data; + + // if path mapping is enabled ... + auto mapping = server->pathMapping(); + if (!mapping) + return; + + // ... then it could be introspected path mapping + bool ok = Utils::updateMapping(*mapping, data); + qCInfo(LSPCLIENT) << "map updated" << ok << "now;\n" << *mapping; + } + + // try to find server at specified index, set to std::shared_ptr<LSPClientServer> _findServer(KTextEditor::View *view, KTextEditor::Document *document, QJsonObject &mergedConfig) { // compute the LSP standardized language id, none found => no change @@ -633,8 +651,9 @@ private: const auto projectMap = Utils::projectMapForDocument(document); // merge with project specific - auto projectConfig = QJsonDocument::fromVariant(projectMap).object().value(QStringLiteral("lspclient")).toObject(); - auto serverConfig = json::merge(m_serverConfig, projectConfig); + auto pluginName = QStringLiteral("lspclient"); + auto projectConfig = QJsonDocument::fromVariant(projectMap).object(); + auto serverConfig = json::merge(m_serverConfig, projectConfig.value(pluginName).toObject()); // locate server config QJsonValue config; @@ -660,6 +679,8 @@ private: return nullptr; } + // store overall settings for later use + auto lspConfig = serverConfig; // merge global settings serverConfig = json::merge(serverConfig.value(QStringLiteral("global")).toObject(), config.toObject()); @@ -707,6 +728,35 @@ private: } } + QStringList execPrefix; + Utils::PathMappingPtr pathMapping; + // if we are dealing with a rogue document, i.e. not belonging to a project, + // then there is a good chance this was opened by following some definition/declaration reference + // so let's see if we can find a server of proper language with an execPrefix and + if (projectBase.isEmpty()) { + // look for a server of same language with an execPrefix and pathMapping + // such that the root (or document) maps into the remote exec space + const auto &cs = m_servers; + for (auto m = cs.begin(); m != cs.end(); ++m) { + auto it = m.value().find(langId); + if (it != m.value().end()) { + auto &si = *it; + auto checkurl = rootpath ? QUrl::fromLocalFile(*rootpath) : document->url(); + auto map = si.server ? si.server->pathMapping() : nullptr; + if (!si.execPrefix.isEmpty() && map && !si.server->mapPath(checkurl, true).isEmpty()) { + // got one + // then we can re-use the existing server instance or use that execPrefix for a new server + // the latter should reasonably work as it was so configured + execPrefix = si.execPrefix; + pathMapping = map; + // if no reasonable root yet, re-use existing server and root, if any + if (!rootpath && si.server) + rootpath = m.key().toLocalFile(); + } + } + } + } + // is it actually safe/reasonable to use workspaces? // in practice, (at this time) servers do do not quite consider or support all that // so in that regard workspace folders represents a bit of "spec endulgance" @@ -746,6 +796,9 @@ private: } } + // try to collect all exec related info + auto execConfig = Utils::ExecConfig::load(serverConfig, projectConfig, {lspConfig}); + QStringList cmdline; if (!server) { // need to find command line for server @@ -765,6 +818,25 @@ private: } } + // consider and prefix command with execPrefix if supplied + // note; we might have obtained it from existing server above + // but any config that is found explicitly overrides that guess + auto vexecPrefix = execConfig.prefix(); + if (vexecPrefix.isArray()) { + execPrefix.clear(); + for (const auto &c : vexecPrefix.toArray()) { + execPrefix.push_back(c.toString()); + } + } + + // NOTE no substitution in execPrefix itself + // only as part of cmdline below + // it may be used elsewhere, + // so up to user to ensure it also makes sense there + if (!execPrefix.isEmpty()) { + cmdline = execPrefix + cmdline; + } + // some more expansion and substitution // unlikely to be used here, but anyway for (auto &e : cmdline) { @@ -780,6 +852,7 @@ private: serverinfo.url = serverConfig.value(QStringLiteral("url")).toString(); // leave failcount as-is serverinfo.useWorkspace = useWorkspace; + serverinfo.execPrefix = execPrefix; // ensure we always only take the server executable from the PATH or user defined paths // QProcess will take the executable even just from current working directory without this => BAD @@ -840,13 +913,26 @@ private: // extract some more additional config auto completionOverride = parseTriggerOverride(serverConfig.value(QStringLiteral("completionTriggerCharacters"))); auto signatureOverride = parseTriggerOverride(serverConfig.value(QStringLiteral("signatureTriggerCharacters"))); + decltype(LSPClientServer::ExtraServerConfig::environment) env; + if (!execPrefix.isEmpty()) { + if (!pathMapping) + pathMapping = execConfig.init_mapping(view); + env[Utils::ExecConfig::ENV_KATE_EXEC_PLUGIN] = pluginName; + env[QStringLiteral("KATE_EXEC_SERVER")] = realLangId; + // allow/enable mount inspection + if (pathMapping) + env[Utils::ExecConfig::ENV_KATE_EXEC_INSPECT] = QStringLiteral("1"); + } + // request server and setup - server.reset(new LSPClientServer(cmdline, - root, - realLangId, - serverConfig.value(QStringLiteral("initializationOptions")), - {.folders = folders, .caps = caps, .completion = completionOverride, .signature = signatureOverride})); + server.reset(new LSPClientServer( + cmdline, + root, + realLangId, + serverConfig.value(QStringLiteral("initializationOptions")), + {.folders = folders, .caps = caps, .completion = completionOverride, .signature = signatureOverride, .map = pathMapping, .environment = env})); connect(server.get(), &LSPClientServer::stateChanged, this, &self_type::onStateChanged, Qt::UniqueConnection); + connect(server.get(), &LSPClientServer::extraData, this, &self_type::onExtraData, Qt::UniqueConnection); if (!server->start(m_plugin->m_debugMode)) { QString message = i18n("Failed to start server: %1", cmdline.join(QLatin1Char(' '))); const auto url = serverConfig.value(QStringLiteral("url")).toString(); diff --git a/apps/lib/CMakeLists.txt b/apps/lib/CMakeLists.txt index 32ac13cb4e..c64135b683 100644 --- a/apps/lib/CMakeLists.txt +++ b/apps/lib/CMakeLists.txt @@ -159,6 +159,7 @@ target_sources( gitprocess.cpp quickdialog.cpp ktexteditor_utils.cpp + exec_utils.cpp data/kateprivate.qrc hostprocess.cpp @@ -208,6 +209,11 @@ endif () target_link_libraries(kateprivate PRIVATE executils) +install( + PROGRAMS exec_inspect.sh + TYPE BIN +) + if (ENABLE_PCH) target_precompile_headers(kateprivate REUSE_FROM katepch) endif() diff --git a/apps/lib/autotests/CMakeLists.txt b/apps/lib/autotests/CMakeLists.txt index 8d17172f6b..e72c638d9c 100644 --- a/apps/lib/autotests/CMakeLists.txt +++ b/apps/lib/autotests/CMakeLists.txt @@ -33,6 +33,7 @@ kate_executable_tests( basic_ui_tests doc_or_widget_test file_history_tests + exec_tests ) # expects some Linux specific idioms diff --git a/apps/lib/autotests/exec_tests.cpp b/apps/lib/autotests/exec_tests.cpp new file mode 100644 index 0000000000..ac9a48126f --- /dev/null +++ b/apps/lib/autotests/exec_tests.cpp @@ -0,0 +1,124 @@ +/* + * This file is part of the Kate project. + * + * SPDX-FileCopyrightText: 2025 Mark Nauwelaerts <[email protected]> + * + * SPDX-License-Identifier: MIT + */ + +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QTest> +#include <QUrl> + +#include "../exec_utils.h" + +class ExecTest : public QObject +{ + Q_OBJECT + + QJsonDocument parse(const QByteArray &data) + { + QJsonParseError error; + auto json = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qDebug() << "failed parse " << error.errorString(); + } + + return json; + } + +private Q_SLOTS: + + void testMap() + { + const char *data = R"|( + [ + { "localRoot": "/home/me/src", "remoteRoot": "/workdir" }, + [ "/tmp/root", "/" ] + ] + )|"; + + auto json = parse(QByteArray::fromStdString(data)); + QVERIFY(json.isArray()); + + auto smap = Utils::loadMapping(json.array()); + auto &map = *smap; + + QCOMPARE(map.size(), 2); + qDebug() << map; + + { + auto p = QUrl::fromLocalFile(QStringLiteral("/home/me/src/foo")); + auto rp = Utils::mapPath(map, p, true); + QCOMPARE(rp, QUrl::fromLocalFile(QStringLiteral("/workdir/foo"))); + auto q = Utils::mapPath(map, rp, false); + QCOMPARE(q, p); + } + + { + auto p = QUrl::fromLocalFile(QStringLiteral("/home/me/src")); + auto rp = Utils::mapPath(map, p, true); + QCOMPARE(rp, QUrl::fromLocalFile(QStringLiteral("/workdir"))); + auto q = Utils::mapPath(map, rp, false); + QCOMPARE(q, p); + } + + { + auto p = QUrl::fromLocalFile(QStringLiteral("/tmp")); + auto rp = Utils::mapPath(map, p, false); + QCOMPARE(rp, QUrl::fromLocalFile(QStringLiteral("/tmp/root/tmp"))); + auto q = Utils::mapPath(map, rp, true); + QCOMPARE(q, p); + } + + { + auto p = QUrl::fromLocalFile(QStringLiteral("/tmp")); + auto rp = Utils::mapPath(map, p, true); + QCOMPARE(rp, QUrl()); + } + } + + void testLoad() + { + const char *project_json = + R"|( +{ + "name": "test", + "exec": { + "hostname": "foobar", + "prefix": "prefix arg", + "mapRemoteRoot": true, + "pathMappings": [ ] + }, + "lspclient": { + "python": { + "root": ".", + "exec": { "hostname": "foobar" } + } + } +} + )|"; + + auto projectConfig = parse(QByteArray::fromStdString(project_json)); + QVERIFY(projectConfig.isObject()); + + auto pc = projectConfig.object(); + auto sc = pc.value(QStringLiteral("lspclient")).toObject().value(QStringLiteral("python")).toObject(); + + auto execConfig = Utils::ExecConfig::load(sc, pc, {}); + QCOMPARE(execConfig.hostname(), QStringLiteral("foobar")); + auto prefixArray = QJsonArray::fromStringList(QStringList{QStringLiteral("prefix"), QStringLiteral("arg")}); + QCOMPARE(execConfig.prefix().toArray(), prefixArray); + auto pm = execConfig.init_mapping(nullptr); + // a fallback should have been arranged + QCOMPARE(pm->size(), 1); + } +}; + +QTEST_MAIN(ExecTest) + +#include "exec_tests.moc" + +// kate: space-indent on; indent-width 4; replace-tabs on; diff --git a/apps/lib/exec_inspect.sh b/apps/lib/exec_inspect.sh new file mode 100755 index 0000000000..5f9adce7a4 --- /dev/null +++ b/apps/lib/exec_inspect.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# arguments: <container name> <engine> <cmdline ...> +# the latter is (e.g.) podman/docker +# +# It then uses <engine> inspect to obtain mounts, +# and uses OOB extradata to pass this along. + +function inspect() { + TEMPLATE='[{{ range .Mounts }} + [ "{{ .Source }}", "{{ .Destination }}" ], + {{ end }} [] ] + ' + + container=$1 + # expected to be podman/docker + engine=$2 + + DATA=`$2 inspect --format "$TEMPLATE" "$1"` + HEADER=$'X-Type: Mounts\r\n\r\n' + + HEADER_S=${#HEADER} + DATA_S=${#DATA} + + echo -ne "Content-Length: $(($HEADER_S + $DATA_S))\r\n\r\n" + echo -n "${HEADER}${DATA}" +} + +if [ "x$KATE_EXEC_INSPECT" != "x" ] ; then + inspect $1 $2 +fi + +shift +exec "$@" diff --git a/apps/lib/exec_utils.cpp b/apps/lib/exec_utils.cpp new file mode 100644 index 0000000000..4b4033a980 --- /dev/null +++ b/apps/lib/exec_utils.cpp @@ -0,0 +1,231 @@ +/* + SPDX-FileCopyrightText: 2025 Mark Nauwelaerts <[email protected]> + + SPDX-License-Identifier: MIT +*/ + +#include "exec_utils.h" + +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QJsonValue> +#include <QSet> +#include <QUrl> + +#include <memory> + +#include <KTextEditor/Editor> +#include <KTextEditor/View> + +#include "exec_io_utils.h" +#include "json_utils.h" + +#include "kate_exec_debug.h" + +namespace Utils +{ +// (localRoot = probably file but need not be, remoteRoot = should be file) +using PathMap = std::pair<QUrl, QUrl>; +using PathMapping = QSet<PathMap>; +using PathMappingPtr = std::shared_ptr<PathMapping>; + +PathMappingPtr loadMapping(const QJsonValue &json, KTextEditor::View *view) +{ + PathMappingPtr m; + + if (!json.isArray()) { + return m; + } + + m.reset(new PathMapping()); + updateMapping(*m, json, view); + + return m; +} + +void updateMapping(PathMapping &mapping, const QJsonValue &json, KTextEditor::View *view) +{ + if (!json.isArray()) + return; + + auto editor = KTextEditor::Editor::instance(); + + auto make_url = [editor, view](QString v) { + if (view) + v = editor->expandText(v, view); + auto url = QUrl(v); + // no normalize at this stage + // so subsequent transformation has clear semantics + // any normalization can/will happen later if needed + if (url.isRelative()) + url.setScheme(QStringLiteral("file")); + return url; + }; + + auto add_entry = [&](QJsonValue local, QJsonValue remote) { + if (local.isString() && remote.isString()) { + mapping.insert({make_url(local.toString()), make_url(remote.toString())}); + } + }; + + for (auto e : json.toArray()) { + // allow various common representations + if (e.isObject()) { + auto obj = e.toObject(); + auto local = obj.value(QStringLiteral("localRoot")); + auto remote = obj.value(QStringLiteral("remoteRoot")); + add_entry(local, remote); + } else if (e.isArray()) { + auto a = e.toArray(); + if (a.size() == 2) { + auto local = a[0]; + auto remote = a[1]; + add_entry(local, remote); + } + } else if (e.isString()) { + auto parts = e.toString().split(QLatin1Char(':')); + if (parts.size() == 2) { + mapping.insert({make_url(parts[0]), make_url(parts[1])}); + } + } + } +} + +bool updateMapping(PathMapping &mapping, const QByteArray &data) +{ + auto header = QByteArray("X-Type: Mounts"); + if (data.indexOf(header) < 0) + return false; + + // find start of what should be JSON array + auto index = data.indexOf('['); + if (index < 0) + return false; + + // parse + auto payload = data.mid(index); + QJsonParseError error; + auto json = QJsonDocument::fromJson(payload, &error); + qDebug() << "payload" << payload; + if (error.error != QJsonParseError::NoError) { + qDebug() << "payload parse failed" << error.errorString(); + qCWarning(LibKateExec) << "payload parse failed" << error.errorString(); + return false; + } + + updateMapping(mapping, json.array()); + + return true; +} + +QUrl mapPath(const PathMapping &mapping, const QUrl &p, bool fromLocal) +{ + const auto SEP = QLatin1Char('/'); + const PathMap *entry = nullptr; + QString suffix; + QUrl result; + + if (!p.isValid() || !p.path().startsWith(SEP)) + return result; + + for (auto &m : mapping) { + auto &root = fromLocal ? m.first : m.second; + // .parentOf does not accept the == case + if (root.isParentOf(p) || root == p) { + auto rootPath = root.path(QUrl::FullyEncoded); + auto suf = p.path(QUrl::FullyEncoded).mid(rootPath.size()); + if (suf.size() && suf[0] == SEP) + suf.erase(suf.begin()); + if (!entry || suf.size() < suffix.size()) { + entry = &m; + suffix = suf; + } + } + } + if (entry) { + result = fromLocal ? entry->second : entry->first; + if (suffix.size()) { + auto path = result.path(QUrl::FullyEncoded); + if (path.back() != SEP) + path.append(SEP); + path.append(suffix); + result.setPath(path, QUrl::TolerantMode); + } + } + + return result; +} + +static void findExec(const QJsonValue &value, const QString &hostname, QJsonObject ¤t) +{ + auto check = [&hostname](const QJsonObject &ob) { + return ob.value(QStringLiteral("hostname")).toString() == hostname; + }; + + json::find(value, check, current); +} + +PathMappingPtr ExecConfig::init_mapping(KTextEditor::View *view) +{ + // load path mapping, with var substitution + auto pathMapping = Utils::loadMapping(config.value(QStringLiteral("pathMappings")), view); + // check if user has specified map for remote root + auto rooturl = QUrl::fromLocalFile(QLatin1String("/")); + if (pathMapping && Utils::mapPath(*pathMapping, rooturl, false).isEmpty()) { + auto &epm = Utils::ExecPrefixManager::instance(); + // if not, then add a mapping + // if enabled, use a kio exec root with specified host + // otherwise, use same protocol with empty host + // the latter maps nowhere, but it least it provides both a path + // and a clear indication not to confuse it with a mere local path + auto fallback = config.value(QStringLiteral("mapRemoteRoot")).toBool(); + auto hn = hostname(); + pathMapping->insert({QUrl(QLatin1String("%1://%2/").arg(epm.scheme(), fallback ? hn : QString())), rooturl}); + if (fallback) { + // use substituted part of cmdline as prefix + auto editor = KTextEditor::Editor::instance(); + QStringList sub_prefix; + for (const auto &e : prefix().toArray()) { + sub_prefix.push_back(editor->expandText(e.toString(), view)); + } + epm.update(hn, sub_prefix); + } + } + return pathMapping; +} + +ExecConfig ExecConfig::load(const QJsonObject &localConfig, const QJsonObject &projectConfig, QList<QJsonValue> extra) +{ + ExecConfig result; + + // try to collect all exec related info + auto EXEC = QStringLiteral("exec"); + auto execConfig = localConfig.value(QStringLiteral("exec")).toObject(); + QString hostname; + if (!execConfig.isEmpty()) { + hostname = execConfig.value(QStringLiteral("hostname")).toString(); + // convenience; let's try to find more info for this hostname elsewhere + if (!hostname.isEmpty()) { + QJsonObject current; + // first look into a common project config part + findExec(projectConfig.value(QStringLiteral("exec")), hostname, current); + // search extra parts + for (const auto &e : extra) + findExec(e, hostname, current); + // merge + execConfig = json::merge(current, execConfig); + } + } + // normalize string prefix to array + auto PREFIX = QStringLiteral("prefix"); + if (auto sprefix = execConfig.value(PREFIX).toString(); !sprefix.isEmpty()) { + execConfig[PREFIX] = QJsonArray::fromStringList(sprefix.split(QLatin1Char(' '))); + } + + result.config = execConfig; + + return result; +} + +} // Utils diff --git a/apps/lib/exec_utils.h b/apps/lib/exec_utils.h new file mode 100644 index 0000000000..796853b814 --- /dev/null +++ b/apps/lib/exec_utils.h @@ -0,0 +1,65 @@ +/* + SPDX-FileCopyrightText: 2025 Mark Nauwelaerts <[email protected]> + + SPDX-License-Identifier: MIT +*/ + +#pragma once + +#include <QJsonObject> +#include <QJsonValue> +#include <QSet> +#include <QUrl> + +#include <memory> + +#include "kateprivate_export.h" + +namespace KTextEditor +{ +class View; +} + +namespace Utils +{ +// (localRoot = probably file but need not be, remoteRoot = should be file) +using PathMap = std::pair<QUrl, QUrl>; +using PathMapping = QSet<PathMap>; +using PathMappingPtr = std::shared_ptr<PathMapping>; + +KATE_PRIVATE_EXPORT PathMappingPtr loadMapping(const QJsonValue &json, KTextEditor::View *view = nullptr); + +KATE_PRIVATE_EXPORT void updateMapping(PathMapping &mapping, const QJsonValue &json, KTextEditor::View *view = nullptr); + +KATE_PRIVATE_EXPORT bool updateMapping(PathMapping &mapping, const QByteArray &extraData); + +// tries to map, returns empty QUrl if not possible +KATE_PRIVATE_EXPORT QUrl mapPath(const PathMapping &mapping, const QUrl &p, bool fromLocal); + +class KATE_PRIVATE_EXPORT ExecConfig +{ + QJsonObject config; + +public: + static inline QString M_HOSTNAME = QStringLiteral("hostname"); + static inline QString M_PREFIX = QStringLiteral("prefix"); + // environment var + static inline QString ENV_KATE_EXEC_PLUGIN = QStringLiteral("KATE_EXEC_PLUGIN"); + static inline QString ENV_KATE_EXEC_INSPECT = QStringLiteral("KATE_EXEC_INSPECT"); + + static ExecConfig load(const QJsonObject &localConfig, const QJsonObject &projectConfig, QList<QJsonValue> extra); + + QString hostname() + { + return config.value(M_HOSTNAME).toString(); + } + + QJsonValue prefix() + { + return config.value(M_PREFIX); + } + + PathMappingPtr init_mapping(KTextEditor::View *view); +}; + +} // Utils diff --git a/doc/kate/plugins.docbook b/doc/kate/plugins.docbook index 9d5dee97f8..32a026ba3a 100644 --- a/doc/kate/plugins.docbook +++ b/doc/kate/plugins.docbook @@ -2788,7 +2788,11 @@ source XYZ # server mileage or arguments may vary exec myserver </screen> - +<para> +This is but one example of a more general pattern which may be handled a bit +more comfortably as outlined in the +<link linkend="lspclient-exec">Execution environment</link> section below. +</para> <sect3 id="lspclient-customization"> <title>LSP Server Configuration</title> @@ -2936,6 +2940,206 @@ when &kate; is invoked with the following </sect3> +<sect3 id="lspclient-exec"> +<title>Execution environment setup</title> + +<para> +The python virtualenv example above is but one example of an +"execution environment" that operates in a distinct and separate way from the +usual host environment. This could be achieved by different variable settings +(e.g. virtualenv), or a (s)chroot setup (switching to another dir as new root), +a container (e.g. podman, docker), or an ssh session to another host. +In each case, the "other environment" is defined by an "execution prefix". +That is, some program can be invoked/run in the other environment by means +of a "prefix" (a program and arguments) appended with the intended +invocation. For example, <literal>podman exec -i containername</literal> +or <literal>ssh user@host</literal>. +</para> + +<para> +In particular, as in the previous virtualenv example, one may choose/need to run +an LSP server is such a separate environment (e.g. a container with all +dependencies needed by some project). The "manual" approach outlined above +has as disadvantage that it replaces the standard LSP command-line, which +then has to specified and duplicated again in the wrapper script. Also, +in some of the other examples mentioned above the "path namespace" of host +(as viewed by the editor) may be different from that of the environment. +To address these matters in a more systematic way (than a "custom" approach), +some additional configuration can be specified. +</para> + +<para> +For example, the following can be specified in a <literal>.kateproject</literal> +configuration. Obviously, the "fake" comments should not be included. +</para> + +<screen> +{ + // this may also be an array of objects + "exec": { + "hostname": "foobar" + // the command could also be an array of string + "prefix": "podman exec -i foobarcontainer", + "mapRemoteRoot": true, + "pathMappings": [ + // either of the following forms are possible + // a more automagic alternative exists as well, see later/below + [ "/dir/on/host", "/mounted/in/container" ] + { "localRoot": "/local/dir", "remoteRoot": "/remote/dir" } + ] + }, + "lspclient": { + "servers": { + "python": { + // this will match/join with the above object + "exec": { "hostname": "foobar" }, + // confine this server to this project root, + // so it is not used for other projects that may be opened + // (other servers may already employ specific roots, but python generally not) + "root": "." + }, + "c": { + // as above + "exec": { "hostname": "foobar" }, + "root": "." + } + } + } +} +</screen> + +<para> +So, what happens as a result of the above? As mentioned, the +<literal>lspclient</literal> part of the above is merged onto the global config, +hence an <literal>exec</literal> section is found (for specified languages). +A search is performed for another object (that specifies matching +<literal>hostname</literal>) in either <literal>exec</literal> or +<literal>lspclient</literal> section, and a matching one is as a basis for a +merge. As a result, an LSP server (for C and python) will have its commandline +appended to the specified (variable substituted) prefix, and will therefore be +started within the given container. Of course, the container must have been +created, in a proper started state and equipped with proper LSP servers. One +might have been tempted to use the <literal>global</literal> section (within +<literal>lspclient</literal>.) That could also work, but then +<emphasis>all</emphasis> LSP servers would be started with that prefix, +including those for e.g. Markdown, Bash script or JSON. It is more likely that +the usual host will still supply these (if in use). So, as always, it depends +on your particular setup. +</para> + +<para> +However, the LSP server may now observe different (remote) paths than the +(local) ones that are seen and used by the editor. The specified +<literal>pathMappings</literal> are used to translate back and forth between +either within the communication with the LSP server. That is, of course, +as much as possible. Clearly, not all local paths have a remote representation, +but the missing ones are also not seen (by the server) and do not pose a problem. +Conversely, however, the (remote) server may now see and provide references +in the "remote root" which are not evidently/easily represented in the local +system. The enabled <literal>mapRemoteRoot</literal> implicitly maps the +remote root onto a "local URL" <literal>exec://foobar/</literal>, which +is then handled by a plain-and-simple KIO protocol. The latter essentially +uses (e.g.) <literal>podman exec -i foobarcontainer cat somefile"</literal> +for copy from remote to local (and other such variations using tools from +the <literal>coreutils</literal> suite). Suffice it to say +it is not meant for general use and claims no performance whatsoever, but it +does suffice to get a referenced file quickly and easily loaded into editor. +</para> + +<para> +It is now easily seen that a simplified version of above configuration +(without <literal>hostname</literal> or <literal>pathMappings</literal>) +could be used to handle the virtualenv without having to duplicate server +commandline (in wrapper script). +</para> + +<para> +The following may not be so easily seen, so it is mentioned here explicitly. +</para> +<itemizedlist> +<listitem> +<para> +Both a <literal>hostname</literal> and the actual <literal>prefix</literal> +define the execution environment (the former by name, the latter by content). +Within an editor process instance, the same <literal>hostname</literal> +should not be associated with different <literal>prefix</literal>, as such +leads to undefined behavior (with no diagnostic required). +</para> +</listitem> +<listitem> +<para> +Both <literal>prefix</literal> and <literal>pathMappings</literal> are subject +to (editor) variable expansion (but do mind the foregoing item). +</para> +</listitem> +<listitem> +<para> +At runtime, some environment variables are also set, which may be used to +(subtly) adjust the "prefix launcher"'s behavior (though again mind the +first item). In particular, <literal>KATE_EXEC_PLUGIN</literal> +is set to <literal>lspclient</literal> and <literal>KATE_EXEC_SERVER</literal> +is set to the server's id (e.g. <literal>python</literal>). +</para> +</listitem> +<listitem> +<para> +In particular, also <literal>KATE_EXEC_INSPECT</literal> is set to <literal>1</literal>. +This notifies the "prefix launcher" that the receiver (Kate plugin) accepts some +out-of-band/protocol data. This allows the launcher to use some means +to determine path mappings (e.g. <literal>podman inspect</literal>) and to +communicate this in suitable format. This will then be +removed from the otherwise LSP protocol conforming stream and used to +extend any defined path mapping. This may serve as an alternative to specifying +e.g. bind mounts explicitly (again, duplicating the container's definition). +Concretely, the above snippet could then be used instead; +<screen> +{ + // ... + "exec": { + "hostname": "foobar" + // unfortunate repetition of name, but the helper script is plain-and-simple + "prefix": "exec_inspect.sh foobarcontainer podman exec -i foobarcontainer", + "mapRemoteRoot": true, + "pathMappings": [] + } + // ... +} +</screen> +</para> +</listitem> +</itemizedlist> + +<para> +Last but not least, what if the "fallback KIO" approach is not considered +adequate? It is in practice often possible to "mount" the remote root +into the local filesystem and then specify the latter in a +<literal>pathMappings</literal>. +For starters, the well-known <literal>sshfs</literal> (fuse) filesystem can +mount a (really) remote system into the local one. For a container +(podman/docker), most (filesystem driver) setups support the following trick; +<screen> +$ rootdir=/proc/`podman inspect --format '{{.State.Pid}}' containername`/root +# some symlinks may cause issues, see also alternative below +$ sudo mount --bind $rootdir /somewhere/containername +</screen> + +If that fails, then one could resort to <literal>sshfs</literal>, or in fact +a subset thereof to mount the remote/container root; +<screen> +# see respective man-pages; sftp-server suffices for actual (unencrypted) file ops protocol +$ socat 'exec:podman exec -i containername /usr/lib/openssh/sftp-server' + 'exec:sshfs -o transform_symlinks -o passive \:/ /somewhere/containername' +# ... +$ fusermount -u /somewhere/containername +</screen> + +Note that such mount of remote root into host filesystem is likely to be specified +manually and explicitly in a <literal>pathMappings</literal> section +(barring a very intelligent prefix launcher that arranges all such automagically). +</para> + +</sect3> + </sect2> <!--TODO: Supported languages, describe features and actions a bit -->
