[utilities/kate] /: lspclient: support execution environment prefix and path mapping

Christoph Cullmann null at kde.org
Fri Aug 15 17:48:17 BST 2025


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 &params = 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 <mark.nauwelaerts at gmail.com>
+ *
+ *  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 <mark.nauwelaerts at gmail.com>
+
+    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 &current)
+{
+    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 <mark.nauwelaerts at gmail.com>
+
+    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 at 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 -->



More information about the kde-doc-english mailing list