Add a new completion API for experimental plugins feature. (#5000)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Mm2PL 2023-12-10 14:41:05 +01:00 committed by GitHub
parent e4258160cd
commit fd4cac2c2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 448 additions and 16 deletions

View file

@ -16,6 +16,7 @@
- Minor: Add an option to use new experimental smarter emote completion. (#4987)
- Minor: Add `--safe-mode` command line option that can be used for troubleshooting when Chatterino is misbehaving or is misconfigured. It disables hiding the settings button & prevents plugins from loading. (#4985)
- Minor: Updated the flatpakref link included with nightly builds to point to up-to-date flathub-beta builds. (#5008)
- Minor: Add a new completion API for experimental plugins feature. (#5000)
- Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840)
- Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848)
- Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834)

21
docs/chatterino.d.ts vendored
View file

@ -19,4 +19,25 @@ declare module c2 {
): boolean;
function send_msg(channel: String, text: String): boolean;
function system_msg(channel: String, text: String): boolean;
class CompletionList {
values: String[];
hide_others: boolean;
}
enum EventType {
RegisterCompletions = "RegisterCompletions",
}
type CbFuncCompletionsRequested = (
query: string,
full_text_content: string,
cursor_position: number,
is_first_word: boolean
) => CompletionList;
type CbFunc<T> = T extends EventType.RegisterCompletions
? CbFuncCompletionsRequested
: never;
function register_callback<T>(type: T, func: CbFunc<T>): void;
}

View file

@ -1,5 +1,6 @@
#include "controllers/completion/TabCompletionModel.hpp"
#include "Application.hpp"
#include "common/Channel.hpp"
#include "controllers/completion/sources/CommandSource.hpp"
#include "controllers/completion/sources/EmoteSource.hpp"
@ -9,6 +10,9 @@
#include "controllers/completion/strategies/ClassicUserStrategy.hpp"
#include "controllers/completion/strategies/CommandStrategy.hpp"
#include "controllers/completion/strategies/SmartEmoteStrategy.hpp"
#include "controllers/plugins/LuaUtilities.hpp"
#include "controllers/plugins/Plugin.hpp"
#include "controllers/plugins/PluginController.hpp"
#include "singletons/Settings.hpp"
namespace chatterino {
@ -19,7 +23,9 @@ TabCompletionModel::TabCompletionModel(Channel &channel, QObject *parent)
{
}
void TabCompletionModel::updateResults(const QString &query, bool isFirstWord)
void TabCompletionModel::updateResults(const QString &query,
const QString &fullTextContent,
int cursorPosition, bool isFirstWord)
{
this->updateSourceFromQuery(query);
@ -29,6 +35,17 @@ void TabCompletionModel::updateResults(const QString &query, bool isFirstWord)
// Copy results to this model
QStringList results;
#ifdef CHATTERINO_HAVE_PLUGINS
// Try plugins first
bool done{};
std::tie(done, results) = getApp()->plugins->updateCustomCompletions(
query, fullTextContent, cursorPosition, isFirstWord);
if (done)
{
this->setStringList(results);
return;
}
#endif
this->source_->addToStringList(results, 0, isFirstWord);
this->setStringList(results);
}

View file

@ -26,8 +26,12 @@ public:
/// @brief Updates the model based on the completion query
/// @param query Completion query
/// @param fullTextContent Full text of the input, used by plugins for contextual completion
/// @param cursorPosition Number of characters behind the cursor from the
/// beginning of fullTextContent, also used by plugins
/// @param isFirstWord Whether the completion is the first word in the input
void updateResults(const QString &query, bool isFirstWord = false);
void updateResults(const QString &query, const QString &fullTextContent,
int cursorPosition, bool isFirstWord = false);
private:
enum class SourceKind {

View file

@ -94,6 +94,37 @@ int c2_register_command(lua_State *L)
return 1;
}
int c2_register_callback(lua_State *L)
{
auto *pl = getApp()->plugins->getPluginByStatePtr(L);
if (pl == nullptr)
{
luaL_error(L, "internal error: no plugin");
return 0;
}
EventType evtType{};
if (!lua::peek(L, &evtType, 1))
{
luaL_error(L, "cannot get event name (1st arg of register_callback, "
"expected a string)");
return 0;
}
if (lua_isnoneornil(L, 2))
{
luaL_error(L, "missing argument for register_callback: function "
"\"pointer\"");
return 0;
}
auto callbackSavedName = QString("c2cb-%1").arg(
magic_enum::enum_name<EventType>(evtType).data());
lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.toStdString().c_str());
lua_pop(L, 2);
return 0;
}
int c2_send_msg(lua_State *L)
{
QString text;
@ -167,6 +198,7 @@ int c2_system_msg(lua_State *L)
lua::push(L, false);
return 1;
}
const auto chn = getApp()->twitch->getChannelOrEmpty(channel);
if (chn->isEmpty())
{

View file

@ -1,14 +1,18 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include <QString>
# include <vector>
struct lua_State;
namespace chatterino::lua::api {
// names in this namespace reflect what's visible inside Lua and follow the lua naming scheme
// function names in this namespace reflect what's visible inside Lua and follow the lua naming scheme
// NOLINTBEGIN(readability-identifier-naming)
// Following functions are exposed in c2 table.
int c2_register_command(lua_State *L);
int c2_register_callback(lua_State *L);
int c2_send_msg(lua_State *L);
int c2_system_msg(lua_State *L);
int c2_log(lua_State *L);
@ -23,6 +27,24 @@ int g_import(lua_State *L);
// Represents "calls" to qCDebug, qCInfo ...
enum class LogLevel { Debug, Info, Warning, Critical };
// Exposed as c2.EventType
// Represents callbacks c2 can do into lua world
enum class EventType {
CompletionRequested,
};
/**
* This is for custom completion, a registered function returns this type
* however in Lua array part (value) and object part (hideOthers) are in the same
* table.
*/
struct CompletionList {
std::vector<QString> values{};
// exposed as hide_others
bool hideOthers{};
};
} // namespace chatterino::lua::api
#endif

View file

@ -4,6 +4,7 @@
# include "common/Channel.hpp"
# include "common/QLogging.hpp"
# include "controllers/commands/CommandContext.hpp"
# include "controllers/plugins/LuaAPI.hpp"
# include <lauxlib.h>
# include <lua.h>
@ -75,6 +76,9 @@ QString humanErrorText(lua_State *L, int errCode)
case LUA_ERRFILE:
errName = "(file error)";
break;
case ERROR_BAD_PEEK:
errName = "(unable to convert value to c++)";
break;
default:
errName = "(unknown error type)";
}
@ -111,6 +115,7 @@ StackIdx push(lua_State *L, const std::string &str)
StackIdx push(lua_State *L, const CommandContext &ctx)
{
StackGuard guard(L, 1);
auto outIdx = pushEmptyTable(L, 2);
push(L, ctx.words);
@ -127,8 +132,27 @@ StackIdx push(lua_State *L, const bool &b)
return lua_gettop(L);
}
StackIdx push(lua_State *L, const int &b)
{
lua_pushinteger(L, b);
return lua_gettop(L);
}
bool peek(lua_State *L, bool *out, StackIdx idx)
{
StackGuard guard(L);
if (!lua_isboolean(L, idx))
{
return false;
}
*out = bool(lua_toboolean(L, idx));
return true;
}
bool peek(lua_State *L, double *out, StackIdx idx)
{
StackGuard guard(L);
int ok{0};
auto v = lua_tonumberx(L, idx, &ok);
if (ok != 0)
@ -140,6 +164,7 @@ bool peek(lua_State *L, double *out, StackIdx idx)
bool peek(lua_State *L, QString *out, StackIdx idx)
{
StackGuard guard(L);
size_t len{0};
const char *str = lua_tolstring(L, idx, &len);
if (str == nullptr)
@ -156,6 +181,7 @@ bool peek(lua_State *L, QString *out, StackIdx idx)
bool peek(lua_State *L, QByteArray *out, StackIdx idx)
{
StackGuard guard(L);
size_t len{0};
const char *str = lua_tolstring(L, idx, &len);
if (str == nullptr)
@ -172,6 +198,7 @@ bool peek(lua_State *L, QByteArray *out, StackIdx idx)
bool peek(lua_State *L, std::string *out, StackIdx idx)
{
StackGuard guard(L);
size_t len{0};
const char *str = lua_tolstring(L, idx, &len);
if (str == nullptr)
@ -186,6 +213,23 @@ bool peek(lua_State *L, std::string *out, StackIdx idx)
return true;
}
bool peek(lua_State *L, api::CompletionList *out, StackIdx idx)
{
StackGuard guard(L);
int typ = lua_getfield(L, idx, "values");
if (typ != LUA_TTABLE)
{
lua_pop(L, 1);
return false;
}
if (!lua::pop(L, &out->values, -1))
{
return false;
}
lua_getfield(L, idx, "hide_others");
return lua::pop(L, &out->hideOthers);
}
QString toString(lua_State *L, StackIdx idx)
{
size_t len{};

View file

@ -2,14 +2,19 @@
#ifdef CHATTERINO_HAVE_PLUGINS
# include "common/QLogging.hpp"
# include <lua.h>
# include <lualib.h>
# include <magic_enum/magic_enum.hpp>
# include <QList>
# include <cassert>
# include <optional>
# include <string>
# include <string_view>
# include <type_traits>
# include <variant>
# include <vector>
struct lua_State;
class QJsonObject;
@ -19,6 +24,12 @@ struct CommandContext;
namespace chatterino::lua {
namespace api {
struct CompletionList;
} // namespace api
constexpr int ERROR_BAD_PEEK = LUA_OK - 1;
/**
* @brief Dumps the Lua stack into qCDebug(chatterinoLua)
*
@ -52,20 +63,136 @@ StackIdx push(lua_State *L, const CommandContext &ctx);
StackIdx push(lua_State *L, const QString &str);
StackIdx push(lua_State *L, const std::string &str);
StackIdx push(lua_State *L, const bool &b);
StackIdx push(lua_State *L, const int &b);
// returns OK?
bool peek(lua_State *L, bool *out, StackIdx idx = -1);
bool peek(lua_State *L, double *out, StackIdx idx = -1);
bool peek(lua_State *L, QString *out, StackIdx idx = -1);
bool peek(lua_State *L, QByteArray *out, StackIdx idx = -1);
bool peek(lua_State *L, std::string *out, StackIdx idx = -1);
bool peek(lua_State *L, api::CompletionList *out, StackIdx idx = -1);
/**
* @brief Converts Lua object at stack index idx to a string.
*/
QString toString(lua_State *L, StackIdx idx = -1);
// This object ensures that the stack is of expected size when it is destroyed
class StackGuard
{
int expected;
lua_State *L;
public:
/**
* Use this constructor if you expect the stack size to be the same on the
* destruction of the object as its creation
*/
StackGuard(lua_State *L)
: expected(lua_gettop(L))
, L(L)
{
}
/**
* Use this if you expect the stack size changing, diff is the expected difference
* Ex: diff=3 means three elements added to the stack
*/
StackGuard(lua_State *L, int diff)
: expected(lua_gettop(L) + diff)
, L(L)
{
}
~StackGuard()
{
if (expected < 0)
{
return;
}
int after = lua_gettop(this->L);
if (this->expected != after)
{
stackDump(this->L, "StackGuard check tripped");
// clang-format off
// clang format likes to insert a new line which means that some builds won't show this message fully
assert(false && "internal error: lua stack was not in an expected state");
// clang-format on
}
}
// This object isn't meant to be passed around
StackGuard operator=(StackGuard &) = delete;
StackGuard &operator=(StackGuard &&) = delete;
StackGuard(StackGuard &) = delete;
StackGuard(StackGuard &&) = delete;
// This function tells the StackGuard that the stack isn't in an expected state but it was handled
void handled()
{
this->expected = -1;
}
};
/// TEMPLATES
template <typename T>
bool peek(lua_State *L, std::optional<T> *out, StackIdx idx = -1)
{
if (lua_isnil(L, idx))
{
*out = std::nullopt;
return true;
}
*out = T();
return peek(L, out->operator->(), idx);
}
template <typename T>
bool peek(lua_State *L, std::vector<T> *vec, StackIdx idx = -1)
{
StackGuard guard(L);
if (!lua_istable(L, idx))
{
lua::stackDump(L, "!table");
qCDebug(chatterinoLua)
<< "value is not a table, type is" << lua_type(L, idx);
return false;
}
auto len = lua_rawlen(L, idx);
if (len == 0)
{
qCDebug(chatterinoLua) << "value has 0 length";
return true;
}
if (len > 1'000'000)
{
qCDebug(chatterinoLua) << "value is too long";
return false;
}
// count like lua
for (int i = 1; i <= len; i++)
{
lua_geti(L, idx, i);
std::optional<T> obj;
if (!lua::peek(L, &obj))
{
//lua_seti(L, LUA_REGISTRYINDEX, 1); // lazy
qCDebug(chatterinoLua)
<< "Failed to convert lua object into c++: at array index " << i
<< ":";
stackDump(L, "bad conversion into string");
return false;
}
lua_pop(L, 1);
vec->push_back(obj.value());
}
return true;
}
/**
* @brief Converts object at stack index idx to enum given by template parameter T
*/
@ -150,6 +277,7 @@ StackIdx push(lua_State *L, T inp)
template <typename T>
bool pop(lua_State *L, T *out, StackIdx idx = -1)
{
StackGuard guard(L, -1);
auto ok = peek(L, out, idx);
if (ok)
{
@ -186,6 +314,58 @@ StackIdx pushEnumTable(lua_State *L)
return out;
}
// Represents a Lua function on the stack
template <typename ReturnType, typename... Args>
class CallbackFunction
{
StackIdx stackIdx_;
lua_State *L;
public:
CallbackFunction(lua_State *L, StackIdx stackIdx)
: stackIdx_(stackIdx)
, L(L)
{
}
// this type owns the stackidx, it must not be trivially copiable
CallbackFunction operator=(CallbackFunction &) = delete;
CallbackFunction(CallbackFunction &) = delete;
// Permit only move
CallbackFunction &operator=(CallbackFunction &&) = default;
CallbackFunction(CallbackFunction &&) = default;
~CallbackFunction()
{
lua_remove(L, this->stackIdx_);
}
std::variant<int, ReturnType> operator()(Args... arguments)
{
lua_pushvalue(this->L, this->stackIdx_);
( // apparently this calls lua::push() for every Arg
[this, &arguments] {
lua::push(this->L, arguments);
}(),
...);
int res = lua_pcall(L, sizeof...(Args), 1, 0);
if (res != LUA_OK)
{
qCDebug(chatterinoLua) << "error is: " << res;
return {res};
}
ReturnType val;
if (!lua::pop(L, &val))
{
return {ERROR_BAD_PEEK};
}
return {val};
}
};
} // namespace chatterino::lua
#endif

View file

@ -2,6 +2,8 @@
#ifdef CHATTERINO_HAVE_PLUGINS
# include "Application.hpp"
# include "controllers/plugins/LuaAPI.hpp"
# include "controllers/plugins/LuaUtilities.hpp"
# include <QDir>
# include <QString>
@ -85,10 +87,51 @@ public:
return this->loadDirectory_;
}
// Note: The CallbackFunction object's destructor will remove the function from the lua stack
using LuaCompletionCallback =
lua::CallbackFunction<lua::api::CompletionList, QString, QString, int,
bool>;
std::optional<LuaCompletionCallback> getCompletionCallback()
{
if (this->state_ == nullptr || !this->error_.isNull())
{
return {};
}
// this uses magic enum to help automatic tooling find usages
auto typ =
lua_getfield(this->state_, LUA_REGISTRYINDEX,
QString("c2cb-%1")
.arg(magic_enum::enum_name<lua::api::EventType>(
lua::api::EventType::CompletionRequested)
.data())
.toStdString()
.c_str());
if (typ != LUA_TFUNCTION)
{
lua_pop(this->state_, 1);
return {};
}
// move
return std::make_optional<lua::CallbackFunction<
lua::api::CompletionList, QString, QString, int, bool>>(
this->state_, lua_gettop(this->state_));
}
/**
* If the plugin crashes while evaluating the main file, this function will return the error
*/
QString error()
{
return this->error_;
}
private:
QDir loadDirectory_;
lua_State *state_;
QString error_;
// maps command name -> function name
std::unordered_map<QString, QString> ownedCommands;

View file

@ -19,6 +19,7 @@
# include <memory>
# include <utility>
# include <variant>
namespace chatterino {
@ -46,15 +47,13 @@ void PluginController::loadPlugins()
auto dir = QDir(getPaths()->pluginsDirectory);
qCDebug(chatterinoLua) << "Loading plugins in" << dir.path();
for (const auto &info :
dir.entryInfoList(QDir::NoFilter | QDir::NoDotAndDotDot))
dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot))
{
if (info.isDir())
{
auto pluginDir = QDir(info.absoluteFilePath());
this->tryLoadFromDir(pluginDir);
}
auto pluginDir = QDir(info.absoluteFilePath());
this->tryLoadFromDir(pluginDir);
}
}
bool PluginController::tryLoadFromDir(const QDir &pluginDir)
{
// look for init.lua
@ -139,6 +138,7 @@ void PluginController::openLibrariesFor(lua_State *L,
static const luaL_Reg c2Lib[] = {
{"system_msg", lua::api::c2_system_msg},
{"register_command", lua::api::c2_register_command},
{"register_callback", lua::api::c2_register_callback},
{"send_msg", lua::api::c2_send_msg},
{"log", lua::api::c2_log},
{nullptr, nullptr},
@ -146,14 +146,17 @@ void PluginController::openLibrariesFor(lua_State *L,
lua_pushglobaltable(L);
auto global = lua_gettop(L);
// count of elements in C2LIB + LogLevel
auto c2libIdx = lua::pushEmptyTable(L, 5);
// count of elements in C2LIB + LogLevel + EventType
auto c2libIdx = lua::pushEmptyTable(L, 8);
luaL_setfuncs(L, c2Lib, 0);
lua::pushEnumTable<lua::api::LogLevel>(L);
lua_setfield(L, c2libIdx, "LogLevel");
lua::pushEnumTable<lua::api::EventType>(L);
lua_setfield(L, c2libIdx, "EventType");
lua_setfield(L, global, "c2");
// ban functions
@ -198,6 +201,7 @@ void PluginController::load(const QFileInfo &index, const QDir &pluginDir,
auto pluginName = pluginDir.dirName();
lua_State *l = luaL_newstate();
auto plugin = std::make_unique<Plugin>(pluginName, l, meta, pluginDir);
auto *temp = plugin.get();
this->plugins_.insert({pluginName, std::move(plugin)});
if (getArgs().safeMode)
@ -220,9 +224,10 @@ void PluginController::load(const QFileInfo &index, const QDir &pluginDir,
int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str());
if (err != 0)
{
temp->error_ = lua::humanErrorText(l, err);
qCWarning(chatterinoLua)
<< "Failed to load" << pluginName << "plugin from" << index << ": "
<< lua::humanErrorText(l, err);
<< temp->error_;
return;
}
qCInfo(chatterinoLua) << "Loaded" << pluginName << "plugin from" << index;
@ -307,5 +312,52 @@ const std::map<QString, std::unique_ptr<Plugin>> &PluginController::plugins()
return this->plugins_;
}
}; // namespace chatterino
std::pair<bool, QStringList> PluginController::updateCustomCompletions(
const QString &query, const QString &fullTextContent, int cursorPosition,
bool isFirstWord) const
{
QStringList results;
for (const auto &[name, pl] : this->plugins())
{
if (!pl->error().isNull())
{
continue;
}
lua::StackGuard guard(pl->state_);
auto opt = pl->getCompletionCallback();
if (opt)
{
qCDebug(chatterinoLua)
<< "Processing custom completions from plugin" << name;
auto &cb = *opt;
auto errOrList =
cb(query, fullTextContent, cursorPosition, isFirstWord);
if (std::holds_alternative<int>(errOrList))
{
guard.handled();
int err = std::get<int>(errOrList);
qCDebug(chatterinoLua)
<< "Got error from plugin " << pl->meta.name
<< " while refreshing tab completion: "
<< lua::humanErrorText(pl->state_, err);
continue;
}
auto list = std::get<lua::api::CompletionList>(errOrList);
if (list.hideOthers)
{
results = QStringList(list.values.begin(), list.values.end());
return {true, results};
}
results += QStringList(list.values.begin(), list.values.end());
}
}
return {false, results};
}
} // namespace chatterino
#endif

View file

@ -36,6 +36,7 @@ public:
// This is required to be public because of c functions
Plugin *getPluginByStatePtr(lua_State *L);
// TODO: make a function that iterates plugins that aren't errored/enabled
const std::map<QString, std::unique_ptr<Plugin>> &plugins() const;
/**
@ -52,6 +53,10 @@ public:
*/
static bool isPluginEnabled(const QString &id);
std::pair<bool, QStringList> updateCustomCompletions(
const QString &query, const QString &fullTextContent,
int cursorPosition, bool isFirstWord) const;
private:
void loadPlugins();
void load(const QFileInfo &index, const QDir &pluginDir,
@ -64,5 +69,5 @@ private:
std::map<QString, std::unique_ptr<Plugin>> plugins_;
};
}; // namespace chatterino
} // namespace chatterino
#endif

View file

@ -170,8 +170,9 @@ void ResizingTextEdit::keyPressEvent(QKeyEvent *event)
// First type pressing tab after modifying a message, we refresh our
// completion model
this->completer_->setModel(completionModel);
completionModel->updateResults(currentCompletion,
this->isFirstWord());
completionModel->updateResults(
currentCompletion, this->toPlainText(),
this->textCursor().position(), this->isFirstWord());
this->completionInProgress_ = true;
{
// this blocks cursor movement events from resetting tab completion

View file

@ -108,6 +108,16 @@ void PluginsPage::rebuildContent()
warningLabel->setStyleSheet("color: #f00");
pluginEntry->addRow(warningLabel);
}
if (!plugin->error().isNull())
{
auto *errorLabel =
new QLabel("There was an error while loading this plugin: " +
plugin->error(),
this->dataFrame_);
errorLabel->setStyleSheet("color: #f00");
errorLabel->setWordWrap(true);
pluginEntry->addRow(errorLabel);
}
auto *description =
new QLabel(plugin->meta.description, this->dataFrame_);