Lua: Add lsp support

Change-Id: I47a1f73a1e1191e116c7cf3b06db5af9e7548fc0
Reviewed-by: Marcus Tillmanns <marcus.tillmanns@qt.io>
This commit is contained in:
Marcus Tillmanns
2024-04-12 14:41:35 +02:00
parent f91d071c66
commit 26993a274e
13 changed files with 755 additions and 1 deletions

View File

@@ -119,3 +119,4 @@ add_subdirectory(qnx)
add_subdirectory(mcusupport)
add_subdirectory(qtapplicationmanager)
add_subdirectory(tellajoke)
add_subdirectory(lualsp)

View File

@@ -36,3 +36,5 @@ add_qtc_plugin(LanguageClient
semantichighlightsupport.cpp semantichighlightsupport.h
snippet.cpp snippet.h
)
add_subdirectory(lualanguageclient)

View File

@@ -606,6 +606,27 @@ void LanguageClientSettings::init()
LanguageClientManager::applySettings();
}
QList<Utils::Store> LanguageClientSettings::storesBySettingsType(Utils::Id settingsTypeId)
{
QList<Utils::Store> result;
QtcSettings *settingsIn = Core::ICore::settings();
settingsIn->beginGroup(settingsGroupKey);
for (const QVariantList &varList :
{settingsIn->value(clientsKey).toList(), settingsIn->value(typedClientsKey).toList()}) {
for (const QVariant &var : varList) {
const Store store = storeFromVariant(var);
if (settingsTypeId == Id::fromSetting(store.value(typeIdKey)))
result << store;
}
}
settingsIn->endGroup();
return result;
}
QList<BaseSettings *> LanguageClientSettings::fromSettings(QtcSettings *settingsIn)
{
settingsIn->beginGroup(settingsGroupKey);

View File

@@ -140,6 +140,8 @@ public:
static QList<BaseSettings *> pageSettings();
static QList<BaseSettings *> changedSettings();
static QList<Utils::Store> storesBySettingsType(Utils::Id settingsTypeId);
/**
* must be called before the delayed initialize phase
* otherwise the settings are not loaded correctly

View File

@@ -0,0 +1,6 @@
add_qtc_plugin(LuaLanguageClient
CONDITION TARGET Lua
PLUGIN_DEPENDS LanguageClient Lua
SOURCES
lualanguageclient.cpp
)

View File

@@ -0,0 +1,21 @@
{
"Name" : "LuaLanguageClient",
"Version" : "${IDE_VERSION}",
"DisabledByDefault" : true,
"SoftLoadable" : true,
"CompatVersion" : "${IDE_VERSION_COMPAT}",
"Vendor" : "The Qt Company Ltd",
"Copyright" : "(C) ${IDE_COPYRIGHT_YEAR} The Qt Company Ltd",
"License" : [ "Commercial Usage",
"",
"Licensees holding valid Qt Commercial licenses may use this plugin in accordance with the Qt Commercial License Agreement provided with the Software or, alternatively, in accordance with the terms contained in a written agreement between you and The Qt Company.",
"",
"GNU General Public License Usage",
"",
"Alternatively, this plugin may be used under the terms of the GNU General Public License version 3 as published by the Free Software Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT included in the packaging of this plugin. Please review the following information to ensure the GNU General Public License requirements will be met: https://www.gnu.org/licenses/gpl-3.0.html."
],
"Category" : "Scripting",
"Description" : "Lua Language Client scripting support",
"Url" : "http://www.qt.io",
${IDE_PLUGIN_DEPENDENCIES}
}

View File

@@ -0,0 +1,500 @@
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
#include <languageclient/languageclientinterface.h>
#include <languageclient/languageclientmanager.h>
#include <languageclient/languageclientsettings.h>
#include <lua/bindings/inheritance.h>
#include <lua/luaengine.h>
#include <extensionsystem/iplugin.h>
#include <extensionsystem/pluginmanager.h>
#include <projectexplorer/project.h>
#include <utils/commandline.h>
#include <utils/layoutbuilder.h>
#include <QJsonDocument>
using namespace Utils;
using namespace Core;
using namespace TextEditor;
using namespace ProjectExplorer;
namespace LanguageClient::Lua {
static void registerLuaApi();
class LuaLanguageClientPlugin final : public ExtensionSystem::IPlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QtCreatorPlugin" FILE "LuaLanguageClient.json")
public:
LuaLanguageClientPlugin() {}
private:
void initialize() final { registerLuaApi(); }
};
class LuaLocalSocketClientInterface : public LocalSocketClientInterface
{
public:
LuaLocalSocketClientInterface(const CommandLine &cmd, const QString &serverName)
: LocalSocketClientInterface(serverName)
, m_cmd(cmd)
, m_logFile("lua-lspclient.XXXXXX.log")
{}
void startImpl() override
{
if (m_process) {
QTC_CHECK(!m_process->isRunning());
delete m_process;
}
m_process = new Process;
m_process->setProcessMode(ProcessMode::Writer);
connect(m_process,
&Process::readyReadStandardError,
this,
&LuaLocalSocketClientInterface::readError);
connect(m_process,
&Process::readyReadStandardOutput,
this,
&LuaLocalSocketClientInterface::readOutput);
connect(m_process, &Process::started, this, [this]() {
this->LocalSocketClientInterface::startImpl();
emit started();
});
connect(m_process, &Process::done, this, [this] {
if (m_process->result() != ProcessResult::FinishedWithSuccess)
emit error(QString("%1 (see logs in \"%2\")")
.arg(m_process->exitMessage())
.arg(m_logFile.fileName()));
emit finished();
});
m_logFile.write(
QString("Starting server: %1\nOutput:\n\n").arg(m_cmd.toUserOutput()).toUtf8());
m_process->setCommand(m_cmd);
m_process->setWorkingDirectory(m_workingDirectory);
if (m_env.hasChanges())
m_process->setEnvironment(m_env);
m_process->start();
}
void setWorkingDirectory(const FilePath &workingDirectory)
{
m_workingDirectory = workingDirectory;
}
FilePath serverDeviceTemplate() const override { return m_cmd.executable(); }
void readError()
{
QTC_ASSERT(m_process, return);
const QByteArray stdErr = m_process->readAllRawStandardError();
m_logFile.write(stdErr);
}
void readOutput()
{
QTC_ASSERT(m_process, return);
const QByteArray &out = m_process->readAllRawStandardOutput();
parseData(out);
}
private:
Utils::CommandLine m_cmd;
Utils::FilePath m_workingDirectory;
Utils::Process *m_process = nullptr;
Utils::Environment m_env;
QTemporaryFile m_logFile;
};
class LuaClientWrapper;
class LuaClientSettings : public BaseSettings
{
std::weak_ptr<LuaClientWrapper> m_wrapper;
public:
LuaClientSettings(const std::weak_ptr<LuaClientWrapper> &wrapper);
~LuaClientSettings() override = default;
bool applyFromSettingsWidget(QWidget *widget) override;
Utils::Store toMap() const override;
void fromMap(const Utils::Store &map) override;
QWidget *createSettingsWidget(QWidget *parent = nullptr) const override;
BaseSettings *copy() const override { return new LuaClientSettings(*this); }
protected:
BaseClientInterface *createInterface(ProjectExplorer::Project *project) const override;
};
enum class TransportType { StdIO, LocalSocket };
class LuaClientWrapper : public QObject
{
public:
TransportType m_transportType{TransportType::StdIO};
std::function<expected_str<void>(CommandLine &)> m_cmdLineCallback;
AspectContainer *m_aspects{nullptr};
QString m_name;
Utils::Id m_settingsTypeId;
QString m_initializationOptions;
CommandLine m_cmdLine;
QString m_serverName;
LanguageFilter m_languageFilter;
BaseSettings::StartBehavior m_startBehavior = BaseSettings::RequiresFile;
std::optional<sol::protected_function> m_onInstanceStart;
QMap<QString, sol::protected_function> m_messageCallbacks;
QList<Client *> m_clients;
public:
static BaseSettings::StartBehavior startBehaviorFromString(const QString &str)
{
if (str == "RequiresProject")
return BaseSettings::RequiresProject;
if (str == "RequiresFile")
return BaseSettings::RequiresFile;
if (str == "AlwaysOn")
return BaseSettings::AlwaysOn;
throw sol::error("Unknown start behavior: " + str.toStdString());
}
LuaClientWrapper(const sol::table &options)
{
m_cmdLineCallback = addValue<CommandLine>(
options,
"cmd",
m_cmdLine,
[](const sol::protected_function_result &res) -> expected_str<CommandLine> {
if (res.get_type(0) != sol::type::table)
return make_unexpected(QString("cmd callback did not return a table"));
return cmdFromTable(res.get<sol::table>());
});
m_name = options.get<QString>("name");
m_settingsTypeId = Utils::Id::fromString(QString("Lua_%1").arg(m_name));
m_serverName = options.get_or<QString>("serverName", "");
m_startBehavior = startBehaviorFromString(
options.get_or<QString>("startBehavior", "AlwaysOn"));
QString transportType = options.get_or<QString>("transport", "stdio");
if (transportType == "stdio")
m_transportType = TransportType::StdIO;
else if (transportType == "localsocket")
m_transportType = TransportType::LocalSocket;
else
qWarning() << "Unknown transport type:" << transportType;
auto languageFilter = options.get<std::optional<sol::table>>("languageFilter");
if (languageFilter) {
auto patterns = languageFilter->get<std::optional<sol::table>>("patterns");
auto mimeTypes = languageFilter->get<std::optional<sol::table>>("mimeTypes");
if (patterns)
for (auto [_, v] : *patterns)
m_languageFilter.filePattern.push_back(v.as<QString>());
if (mimeTypes)
for (auto [_, v] : *mimeTypes)
m_languageFilter.mimeTypes.push_back(v.as<QString>());
}
auto initOptionsTable = options.get<sol::optional<sol::table>>("initializationOptions");
if (initOptionsTable) {
QJsonValue json = ::Lua::LuaEngine::toJson(*initOptionsTable);
QJsonDocument doc;
if (json.isArray()) {
doc.setArray(json.toArray());
m_initializationOptions = QString::fromUtf8(doc.toJson());
} else if (json.isObject()) {
doc.setObject(json.toObject());
m_initializationOptions = QString::fromUtf8(doc.toJson());
}
}
auto initOptionsString = options.get<sol::optional<QString>>("initializationOptions");
if (initOptionsString)
m_initializationOptions = *initOptionsString;
// get<sol::optional<>> because on MSVC, get_or(..., nullptr) fails to compile
m_aspects = options.get<sol::optional<AspectContainer *>>("settings").value_or(nullptr);
connect(
LanguageClientManager::instance(),
&LanguageClientManager::clientInitialized,
this,
[this](Client *c) {
if (m_onInstanceStart) {
if (auto settings = LanguageClientManager::settingForClient(c)) {
if (settings->m_settingsTypeId == m_settingsTypeId) {
auto result = m_onInstanceStart->call();
if (!result.valid()) {
qWarning() << "Error calling instance start callback:"
<< (result.get<sol::error>().what());
}
m_clients.push_back(c);
updateMessageCallbacks();
}
}
}
});
connect(
LanguageClientManager::instance(),
&LanguageClientManager::clientRemoved,
this,
[this](Client *c) {
if (m_clients.contains(c))
m_clients.removeOne(c);
});
}
// TODO: Unregister Client settings from LanguageClientManager
~LuaClientWrapper() = default;
TransportType transportType() { return m_transportType; }
void applySettings()
{
if (m_aspects)
m_aspects->apply();
updateOptions();
}
void fromMap(const Utils::Store &map)
{
if (m_aspects)
m_aspects->fromMap(map);
updateOptions();
}
void toMap(Utils::Store &map) const
{
if (m_aspects)
m_aspects->toMap(map);
}
std::optional<Layouting::LayoutItem> settingsLayout()
{
if (m_aspects && m_aspects->layouter())
return m_aspects->layouter()();
return {};
}
void registerMessageCallback(const QString &msg, const sol::function &callback)
{
m_messageCallbacks.insert(msg, callback);
updateMessageCallbacks();
}
void updateMessageCallbacks()
{
for (Client *c : m_clients) {
for (const auto &[msg, func] : m_messageCallbacks.asKeyValueRange()) {
c->registerCustomMethod(
msg, [name = msg, f = func](const LanguageServerProtocol::JsonRpcMessage &m) {
auto table = ::Lua::LuaEngine::toTable(f.lua_state(), m.toJsonObject());
auto result = f.call(table);
if (!result.valid()) {
qWarning() << "Error calling message callback for:" << name << ":"
<< (result.get<sol::error>().what());
}
});
}
}
}
void updateOptions()
{
if (m_cmdLineCallback) {
auto result = m_cmdLineCallback(m_cmdLine);
if (!result)
qWarning() << "Error applying option callback:" << result.error();
}
}
static CommandLine cmdFromTable(const sol::table &tbl)
{
CommandLine cmdLine;
cmdLine.setExecutable(FilePath::fromUserInput(tbl.get<QString>(1)));
for (size_t i = 2; i < tbl.size() + 1; i++)
cmdLine.addArg(tbl.get<QString>(i));
return cmdLine;
}
template<typename T>
std::function<expected_str<void>(T &)> addValue(
const sol::table &options,
const char *fieldName,
T &dest,
std::function<expected_str<T>(const sol::protected_function_result &)> transform)
{
auto fixed = options.get<sol::optional<sol::table>>(fieldName);
auto cb = options.get<sol::optional<sol::protected_function>>(fieldName);
if (fixed) {
dest = fixed.value().get<T>(1);
} else if (cb) {
std::function<expected_str<void>(T &)> callback =
[cb, transform](T &dest) -> expected_str<void> {
auto res = cb.value().call();
if (!res.valid()) {
sol::error err = res;
return Utils::make_unexpected(QString::fromLocal8Bit(err.what()));
}
expected_str<T> trResult = transform(res);
if (!trResult)
return make_unexpected(trResult.error());
dest = *trResult;
return {};
};
QTC_CHECK_EXPECTED(callback(dest));
return callback;
}
return {};
}
BaseClientInterface *createInterface(ProjectExplorer::Project *project)
{
if (m_transportType == TransportType::StdIO) {
auto interface = new StdIOClientInterface;
interface->setCommandLine(m_cmdLine);
if (project)
interface->setWorkingDirectory(project->projectDirectory());
return interface;
} else if (m_transportType == TransportType::LocalSocket) {
if (m_serverName.isEmpty())
return nullptr;
auto interface = new LuaLocalSocketClientInterface(m_cmdLine, m_serverName);
if (project)
interface->setWorkingDirectory(project->projectDirectory());
return interface;
}
return nullptr;
}
};
LuaClientSettings::LuaClientSettings(const std::weak_ptr<LuaClientWrapper> &wrapper)
: m_wrapper(wrapper)
{
if (auto w = m_wrapper.lock()) {
m_name = w->m_name;
m_settingsTypeId = w->m_settingsTypeId;
m_languageFilter = w->m_languageFilter;
m_initializationOptions = w->m_initializationOptions;
m_startBehavior = w->m_startBehavior;
}
}
bool LuaClientSettings::applyFromSettingsWidget(QWidget *widget)
{
BaseSettings::applyFromSettingsWidget(widget);
if (auto w = m_wrapper.lock())
w->applySettings();
return true;
}
Utils::Store LuaClientSettings::toMap() const
{
auto store = BaseSettings::toMap();
if (auto w = m_wrapper.lock())
w->toMap(store);
return store;
}
void LuaClientSettings::fromMap(const Utils::Store &map)
{
BaseSettings::fromMap(map);
if (auto w = m_wrapper.lock()) {
w->m_name = m_name;
w->m_initializationOptions = m_initializationOptions;
w->m_languageFilter = m_languageFilter;
w->m_startBehavior = m_startBehavior;
w->fromMap(map);
}
}
QWidget *LuaClientSettings::createSettingsWidget(QWidget *parent) const
{
using namespace Layouting;
if (auto w = m_wrapper.lock())
if (std::optional<LayoutItem> layout = w->settingsLayout())
return new BaseSettingsWidget(this, parent, layout->subItems);
return new BaseSettingsWidget(this, parent);
}
BaseClientInterface *LuaClientSettings::createInterface(ProjectExplorer::Project *project) const
{
if (auto w = m_wrapper.lock())
return w->createInterface(project);
return nullptr;
}
static void registerLuaApi()
{
::Lua::LuaEngine::registerProvider("LSP", [](sol::state_view lua) -> sol::object {
sol::table result = lua.create_table();
auto wrapperClass = result.new_usertype<LuaClientWrapper>(
"Client",
"on_instance_start",
sol::property(
[](const LuaClientWrapper *c) -> sol::function {
if (!c->m_onInstanceStart)
return sol::lua_nil;
return c->m_onInstanceStart.value();
},
[](LuaClientWrapper *c, const sol::function &f) { c->m_onInstanceStart = f; }),
"registerMessage",
&LuaClientWrapper::registerMessageCallback,
"create",
[](const sol::table &options) -> std::shared_ptr<LuaClientWrapper> {
auto luaClient = std::make_shared<LuaClientWrapper>(options);
auto client = new LuaClientSettings(luaClient);
// The order is important!
// First restore the settings ...
const QList<Utils::Store> savedSettings
= LanguageClientSettings::storesBySettingsType(luaClient->m_settingsTypeId);
if (!savedSettings.isEmpty())
client->fromMap(savedSettings.first());
// ... then register the settings.
LanguageClientManager::registerClientSettings(client);
return luaClient;
});
return result;
});
}
} // namespace LanguageClient::Lua
#include "lualanguageclient.moc"

View File

@@ -70,6 +70,8 @@ return {
[](const FilePath &self) { return self.searchInPath(); },
"exists",
&FilePath::exists,
"resolveSymlinks",
&FilePath::resolveSymlinks,
"isExecutableFile",
&FilePath::isExecutableFile,
"dirEntries",

View File

@@ -4,8 +4,9 @@ local lsp = {}
---@class ClientOptions
---@field name string The name under which to register the language server.
---@field cmd string[] The command to start the language server
---@field cmd function|string[] The command to start the language server, or a function returning a string[].
---@field transport? "stdio"|"localsocket" Defaults to stdio
---@field serverName? string The socket path when transport == "localsocket"
---@field languageFilter LanguageFilter The language filter deciding which files to open with the language server
---@field startBehavior? "AlwaysOn"|"RequiresFile"|"RequiresProject"
---@field initializationOptions? table|string The initialization options to pass to the language server, either a json string, or a table

View File

@@ -66,4 +66,8 @@ function utils.FilePath:resolvePath(tail) end
---@return FilePath
function utils.FilePath:parentDir() end
---If the path targets a symlink, this function returns the target of the symlink
---@return FilePath The resolved path
function utils.FilePath:resolveSymlinks() end
return utils

View File

@@ -0,0 +1,4 @@
add_qtc_lua_plugin(lualsp
SOURCES lualsp/lualsp.lua
lualsp/init.lua
)

View File

@@ -0,0 +1,160 @@
-- Copyright (C) 2024 The Qt Company Ltd.
-- SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
local LSP = require('LSP')
local mm = require('MessageManager')
local Utils = require('Utils')
local Process = require('Process')
local S = require('Settings')
local Layout = require('Layout')
local a = require('async')
Settings = {}
local function createCommand()
local cmd = { Settings.binary.expandedValue:nativePath() }
if Settings.showNode.value then
table.insert(cmd, '--shownode=true')
end
if Settings.showSource.value then
table.insert(cmd, '--showsource=true')
end
if Settings.developMode.value then
table.insert(cmd, '--develop=true')
end
return cmd
end
local function setupClient()
Client = LSP.Client.create({
name = 'Lua Language Server',
cmd = createCommand,
transport = 'stdio',
languageFilter = {
patterns = { '*.lua' },
mimeTypes = { 'text/x-lua' }
},
settings = Settings,
startBehavior = "RequiresFile",
})
Client.on_instance_start = function()
print("Instance has started")
end
Client:registerMessage("$/status/report", function(params)
mm.writeFlashing(params.params.text .. ": " .. params.params.tooltip);
end)
end
local function installServer()
print("Lua Language Server not found, installing ...")
local cmds = {
mac = "brew install lua-language-server",
windows = "winget install lua-language-server",
linux = "sudo apt install lua-language-server"
}
if a.wait(Process.runInTerminal(cmds[Utils.HostOsInfo.os])) == 0 then
print("Lua Language Server installed!")
Settings.binary.defaultPath = Utils.FilePath.fromUserInput("lua-language-server"):resolveSymlinks()
Settings:apply()
return true
end
print("Lua Language Server installation failed!")
return false
end
local function using(tbl)
local result = _G
for k, v in pairs(tbl) do result[k] = v end
return result
end
local function layoutSettings()
--- "using namespace Layout"
local _ENV = using(Layout)
local installButton = {}
if Settings.binary.expandedValue:isExecutableFile() == false then
installButton = {
"Language server not found:",
Row {
PushButton {
text("Try to install lua language server"),
onClicked(function() a.sync(installServer)() end),
br,
},
st
}
}
end
local layout = Form {
Settings.binary, br,
Settings.developMode, br,
Settings.showSource, br,
Settings.showNode, br,
table.unpack(installButton)
}
return layout
end
local function setupAspect()
---@class Settings: AspectContainer
Settings = S.AspectContainer.create({
autoApply = false,
layouter = layoutSettings,
});
Settings.binary = S.FilePathAspect.create({
settingsKey = "LuaCopilot.Binary",
displayName = "Binary",
labelText = "Binary:",
toolTip = "The path to the lua-language-server binary.",
expectedKind = S.Kind.ExistingCommand,
defaultPath = Utils.FilePath.fromUserInput("lua-language-server"):resolveSymlinks(),
})
Settings.developMode = S.BoolAspect.create({
settingsKey = "LuaCopilot.DevelopMode",
displayName = "Enable Develop Mode",
labelText = "Enable Develop Mode:",
toolTip = "Turns on the develop mode of the language server.",
defaultValue = false,
labelPlacement = S.LabelPlacement.InExtraLabel,
})
Settings.showSource = S.BoolAspect.create({
settingsKey = "LuaCopilot.ShowSource",
displayName = "Show Source",
labelText = "Show Source:",
toolTip = "Display the internal data of the hovering token.",
defaultValue = false,
labelPlacement = S.LabelPlacement.InExtraLabel,
})
Settings.showNode = S.BoolAspect.create({
settingsKey = "LuaCopilot.ShowNode",
displayName = "Show Node",
labelText = "Show Node:",
toolTip = "Display the internal data of the hovering token.",
defaultValue = false,
labelPlacement = S.LabelPlacement.InExtraLabel,
})
return Settings
end
local function setup(parameters)
print("Setting up Lua Language Server ...")
setupAspect()
local serverPath = Utils.FilePath.fromUserInput("lua-language-server")
local absolute = serverPath:searchInPath():resolveSymlinks()
if absolute:isExecutableFile() == true then
Settings.binary.defaultPath = absolute
else
a.sync(installServer)()
end
setupClient()
end
return {
setup = setup,
}

View File

@@ -0,0 +1,30 @@
-- Copyright (C) 2024 The Qt Company Ltd.
-- SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
return {
Name = "LuaLanguageServer",
Version = "1.0.0",
CompatVersion = "1.0.0",
Vendor = "The Qt Company",
Category = "Language Client",
Description = "The Lua Language Server",
Experimental = false,
DisabledByDefault = false,
LongDescription = [[
This plugin provides the Lua Language Server.
It will try to install it if it is not found.
]],
Dependencies = {
{ Name = "Core", Version = "13.0.82", Required = true },
{ Name = "Lua", Version = "13.0.82", Required = true },
{ Name = "LuaLanguageClient", Version = "13.0.82", Required = true }
},
setup = function()
require 'init'.setup()
end,
hooks = {
editors = {
documentOpened = function(doc) print("documentOpened", doc) end,
documentClosed = function(doc) print("documentClosed", doc) end,
}
}
} --[[@as QtcPlugin]]