[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 ¶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 <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 ¤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 <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