diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1805ed..cd00604d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index c2efdb1b..aece0988 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -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 extends EventType.RegisterCompletions + ? CbFuncCompletionsRequested + : never; + + function register_callback(type: T, func: CbFunc): void; } diff --git a/src/controllers/completion/TabCompletionModel.cpp b/src/controllers/completion/TabCompletionModel.cpp index 74e82652..f38fa62a 100644 --- a/src/controllers/completion/TabCompletionModel.cpp +++ b/src/controllers/completion/TabCompletionModel.cpp @@ -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); } diff --git a/src/controllers/completion/TabCompletionModel.hpp b/src/controllers/completion/TabCompletionModel.hpp index 21e06e3c..56cf313e 100644 --- a/src/controllers/completion/TabCompletionModel.hpp +++ b/src/controllers/completion/TabCompletionModel.hpp @@ -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 { diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp index 6ffaf498..e34e063b 100644 --- a/src/controllers/plugins/LuaAPI.cpp +++ b/src/controllers/plugins/LuaAPI.cpp @@ -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(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()) { diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index dfa95447..95a9bd19 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -1,14 +1,18 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS +# include + +# include 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 values{}; + + // exposed as hide_others + bool hideOthers{}; +}; + } // namespace chatterino::lua::api #endif diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp index 3477e4e3..4807f89f 100644 --- a/src/controllers/plugins/LuaUtilities.cpp +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -4,6 +4,7 @@ # include "common/Channel.hpp" # include "common/QLogging.hpp" # include "controllers/commands/CommandContext.hpp" +# include "controllers/plugins/LuaAPI.hpp" # include # include @@ -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{}; diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index f88f2a92..c7bd0270 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -2,14 +2,19 @@ #ifdef CHATTERINO_HAVE_PLUGINS +# include "common/QLogging.hpp" + # include # include # include # include +# include +# include # include # include # include +# include # include 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 +bool peek(lua_State *L, std::optional *out, StackIdx idx = -1) +{ + if (lua_isnil(L, idx)) + { + *out = std::nullopt; + return true; + } + + *out = T(); + return peek(L, out->operator->(), idx); +} + +template +bool peek(lua_State *L, std::vector *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 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 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 +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 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 diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index 456ac4ff..7d81609e 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -2,6 +2,8 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "Application.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" # include # include @@ -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; + std::optional 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::CompletionRequested) + .data()) + .toStdString() + .c_str()); + if (typ != LUA_TFUNCTION) + { + lua_pop(this->state_, 1); + return {}; + } + + // move + return std::make_optional>( + 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 ownedCommands; diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 9a3356e9..f3829ac2 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -19,6 +19,7 @@ # include # include +# include 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(L); lua_setfield(L, c2libIdx, "LogLevel"); + lua::pushEnumTable(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(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> &PluginController::plugins() return this->plugins_; } -}; // namespace chatterino +std::pair 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(errOrList)) + { + guard.handled(); + int err = std::get(errOrList); + qCDebug(chatterinoLua) + << "Got error from plugin " << pl->meta.name + << " while refreshing tab completion: " + << lua::humanErrorText(pl->state_, err); + continue; + } + + auto list = std::get(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 diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp index 9630e889..bc3eca73 100644 --- a/src/controllers/plugins/PluginController.hpp +++ b/src/controllers/plugins/PluginController.hpp @@ -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> &plugins() const; /** @@ -52,6 +53,10 @@ public: */ static bool isPluginEnabled(const QString &id); + std::pair 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> plugins_; }; -}; // namespace chatterino +} // namespace chatterino #endif diff --git a/src/widgets/helper/ResizingTextEdit.cpp b/src/widgets/helper/ResizingTextEdit.cpp index 8012e195..904cfba5 100644 --- a/src/widgets/helper/ResizingTextEdit.cpp +++ b/src/widgets/helper/ResizingTextEdit.cpp @@ -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 diff --git a/src/widgets/settingspages/PluginsPage.cpp b/src/widgets/settingspages/PluginsPage.cpp index 87966a81..d3beaa32 100644 --- a/src/widgets/settingspages/PluginsPage.cpp +++ b/src/widgets/settingspages/PluginsPage.cpp @@ -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_);