#include #include #include #include #include #include #include #include #include #include #include extern "C" { #include #include } using namespace Config::Lua; using namespace Config::Lua::Bindings; namespace Config::Lua { class CConfigManagerPluginLuaTestAccessor { public: static void initializeLuaState(CConfigManager& mgr, lua_State* L) { mgr.m_lua = L; lua_pushlightuserdata(L, &mgr); lua_setfield(L, LUA_REGISTRYINDEX, "hl_lua_manager"); } static void initializeOwnedLuaState(CConfigManager& mgr, const std::filesystem::path& mainConfigPath) { mgr.m_mainConfigPath = mainConfigPath.string(); mgr.m_configPaths.clear(); mgr.m_configPaths.emplace_back(mgr.m_mainConfigPath); mgr.reinitLuaState(); } static lua_State* luaState(CConfigManager& mgr) { return mgr.m_lua; } }; } namespace { class CLuaState { public: CLuaState() : m_lua(luaL_newstate()) { luaL_openlibs(m_lua); } ~CLuaState() { if (m_lua) lua_close(m_lua); } lua_State* get() const { return m_lua; } private: lua_State* m_lua = nullptr; }; int testPluginFn(lua_State* L) { lua_pushstring(L, "pong"); return 1; } class CTempDir { public: CTempDir() { const auto NOW = std::chrono::steady_clock::now().time_since_epoch().count(); m_path = std::filesystem::temp_directory_path() / std::format("hyprland-lua-require-{}", NOW); std::filesystem::create_directories(m_path); } ~CTempDir() { std::error_code ec; std::filesystem::remove_all(m_path, ec); } const std::filesystem::path& path() const { return m_path; } private: std::filesystem::path m_path; }; class CScopedCompositor { public: CScopedCompositor() : m_prevCompositor(std::move(g_pCompositor)), m_prevKeybindManager(std::move(Keybinds::mgr())) { g_pCompositor = makeUnique(true); Keybinds::mgr() = makeUnique(); } ~CScopedCompositor() { Keybinds::mgr() = std::move(m_prevKeybindManager); g_pCompositor = std::move(m_prevCompositor); } private: UP m_prevCompositor; UP m_prevKeybindManager; }; std::string luaString(const std::string& value) { std::string out = "\""; for (const auto& c : value) { if (c == '\\' || c == '"') out += '\\'; out += c; } out += '"'; return out; } void writeFile(const std::filesystem::path& path, const std::string& content) { std::filesystem::create_directories(path.parent_path()); std::ofstream file(path); file << content; } std::string normalizedPath(const std::filesystem::path& path) { return path.lexically_normal().string(); } std::string packagePath(lua_State* L) { lua_getglobal(L, "package"); lua_getfield(L, -1, "path"); std::string path; if (const auto* value = lua_tostring(L, -1); value) path = value; lua_pop(L, 2); return path; } void expectTracked(CConfigManager& mgr, const std::filesystem::path& path) { const auto& paths = mgr.getConfigPaths(); EXPECT_NE(std::ranges::find(paths, normalizedPath(path)), paths.end()); } } TEST(ConfigLuaBindingsInternal, parseDirectionAliases) { EXPECT_EQ(Internal::parseDirectionStr("left"), Math::DIRECTION_LEFT); EXPECT_EQ(Internal::parseDirectionStr("l"), Math::DIRECTION_LEFT); EXPECT_EQ(Internal::parseDirectionStr("right"), Math::DIRECTION_RIGHT); EXPECT_EQ(Internal::parseDirectionStr("r"), Math::DIRECTION_RIGHT); EXPECT_EQ(Internal::parseDirectionStr("up"), Math::DIRECTION_UP); EXPECT_EQ(Internal::parseDirectionStr("t"), Math::DIRECTION_UP); EXPECT_EQ(Internal::parseDirectionStr("down"), Math::DIRECTION_DOWN); EXPECT_EQ(Internal::parseDirectionStr("b"), Math::DIRECTION_DOWN); EXPECT_EQ(Internal::parseDirectionStr("???"), Math::DIRECTION_DEFAULT); } TEST(ConfigLuaBindingsInternal, parseToggleAliases) { EXPECT_EQ(Internal::parseToggleStr(""), Config::Actions::TOGGLE_ACTION_TOGGLE); EXPECT_EQ(Internal::parseToggleStr("toggle"), Config::Actions::TOGGLE_ACTION_TOGGLE); EXPECT_EQ(Internal::parseToggleStr("enable"), Config::Actions::TOGGLE_ACTION_ENABLE); EXPECT_EQ(Internal::parseToggleStr("on"), Config::Actions::TOGGLE_ACTION_ENABLE); EXPECT_EQ(Internal::parseToggleStr("disable"), Config::Actions::TOGGLE_ACTION_DISABLE); EXPECT_EQ(Internal::parseToggleStr("off"), Config::Actions::TOGGLE_ACTION_DISABLE); } TEST(ConfigLuaBindingsInternal, argStrConvertsStringsAndNumbers) { CLuaState S; const auto L = S.get(); lua_pushstring(L, "abc"); EXPECT_EQ(Internal::argStr(L, -1), "abc"); lua_pop(L, 1); lua_pushnumber(L, 42); EXPECT_EQ(Internal::argStr(L, -1), "42"); lua_pop(L, 1); } TEST(ConfigLuaBindingsInternal, tableOptHelpersReadOptionalFields) { CLuaState S; const auto L = S.get(); lua_createtable(L, 0, 5); lua_pushstring(L, "value"); lua_setfield(L, -2, "s"); lua_pushnumber(L, 5.5); lua_setfield(L, -2, "n"); lua_pushboolean(L, true); lua_setfield(L, -2, "b"); lua_pushstring(L, "not-number"); lua_setfield(L, -2, "n2"); lua_pushnil(L); lua_setfield(L, -2, "nilv"); EXPECT_EQ(Internal::tableOptStr(L, -1, "s").value_or(""), "value"); EXPECT_DOUBLE_EQ(Internal::tableOptNum(L, -1, "n").value_or(0), 5.5); EXPECT_EQ(Internal::tableOptBool(L, -1, "b").value_or(false), true); EXPECT_FALSE(Internal::tableOptNum(L, -1, "n2").has_value()); EXPECT_FALSE(Internal::tableOptStr(L, -1, "missing").has_value()); EXPECT_FALSE(Internal::tableOptBool(L, -1, "nilv").has_value()); lua_pop(L, 1); } TEST(ConfigLuaBindingsInternal, selectorHelpersAcceptStringAndNumberSelectors) { CLuaState S; const auto L = S.get(); lua_createtable(L, 0, 4); lua_pushstring(L, "DP-1"); lua_setfield(L, -2, "monitor"); lua_pushnumber(L, 7); lua_setfield(L, -2, "workspace"); lua_pushnumber(L, 1337); lua_setfield(L, -2, "window"); EXPECT_EQ(Internal::tableOptMonitorSelector(L, -1, "monitor", "test.fn").value_or(""), "DP-1"); EXPECT_EQ(Internal::tableOptWorkspaceSelector(L, -1, "workspace", "test.fn").value_or(""), "7"); EXPECT_EQ(Internal::tableOptWindowSelector(L, -1, "window", "test.fn").value_or(""), "1337"); EXPECT_FALSE(Internal::tableOptMonitorSelector(L, -1, "missing", "test.fn").has_value()); EXPECT_FALSE(Internal::tableOptWorkspaceSelector(L, -1, "missing", "test.fn").has_value()); EXPECT_FALSE(Internal::tableOptWindowSelector(L, -1, "missing", "test.fn").has_value()); EXPECT_EQ(Internal::requireTableFieldMonitorSelector(L, -1, "monitor", "test.fn"), "DP-1"); EXPECT_EQ(Internal::requireTableFieldWorkspaceSelector(L, -1, "workspace", "test.fn"), "7"); EXPECT_EQ(Internal::requireTableFieldWindowSelector(L, -1, "window", "test.fn"), "1337"); lua_pop(L, 1); } TEST(ConfigLuaBindingsInternal, pushWindowUpvalAcceptsNumberAndStringSelectors) { CLuaState S; const auto L = S.get(); lua_createtable(L, 0, 1); lua_pushnumber(L, 42); lua_setfield(L, -2, "window"); Internal::pushWindowUpval(L, -1); ASSERT_TRUE(lua_isstring(L, -1)); EXPECT_STREQ(lua_tostring(L, -1), "42"); lua_pop(L, 1); lua_pushstring(L, "0xabc"); lua_setfield(L, -2, "window"); Internal::pushWindowUpval(L, -1); ASSERT_TRUE(lua_isstring(L, -1)); EXPECT_STREQ(lua_tostring(L, -1), "0xabc"); lua_pop(L, 1); lua_pushnil(L); lua_setfield(L, -2, "window"); Internal::pushWindowUpval(L, -1); EXPECT_TRUE(lua_isnil(L, -1)); lua_pop(L, 1); lua_pop(L, 1); } TEST(ConfigLuaBindingsInternal, parseTableFieldMissingFieldAndPrefixedErrors) { CLuaState S; const auto L = S.get(); CLuaConfigInt parser(0); lua_newtable(L); auto err = Internal::parseTableField(L, -1, "required", parser); EXPECT_EQ(err.errorCode, PARSE_ERROR_BAD_VALUE); EXPECT_NE(err.message.find("missing required field"), std::string::npos); lua_pop(L, 1); lua_createtable(L, 0, 1); lua_pushstring(L, "bad"); lua_setfield(L, -2, "count"); err = Internal::parseTableField(L, -1, "count", parser); EXPECT_EQ(err.errorCode, PARSE_ERROR_BAD_TYPE); EXPECT_NE(err.message.find("field \"count\":"), std::string::npos); lua_pop(L, 1); } TEST(ConfigLuaBindingsInternal, pluginBindingIsTableWithLoadFunction) { CLuaState S; const auto L = S.get(); lua_newtable(L); Internal::registerConfigRuleBindings(L, nullptr); lua_getfield(L, -1, "plugin"); ASSERT_TRUE(lua_istable(L, -1)); lua_getfield(L, -1, "load"); EXPECT_TRUE(lua_isfunction(L, -1)); lua_pop(L, 1); lua_pop(L, 2); } TEST(ConfigLuaBindingsInternal, getMonitorsReadsOptionsFromArguments) { CLuaState state; const auto lua = state.get(); lua_newtable(lua); Internal::registerQueryBindings(lua); lua_setglobal(lua, "hl"); ASSERT_EQ(luaL_dostring(lua, R"( assert(type(hl.get_monitors()) == "table") assert(type(hl.get_monitors(nil)) == "table") assert(type(hl.get_monitors({})) == "table") assert(type(hl.get_monitors({ all = false })) == "table") assert(type(hl.get_monitors({ all = true })) == "table") assert(hl.get_monitors({ all = "true" }) == nil) assert(hl.get_monitors(true) == nil) )"), LUA_OK) << lua_tostring(lua, -1); } TEST(ConfigLuaBindingsInternal, deprecationNoticesOnlyIncludeUsedDeprecatedValues) { CScopedCompositor compositor; CLuaState state; const auto lua = state.get(); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeLuaState(mgr, lua); lua_newtable(lua); Internal::registerConfigRuleBindings(lua, &mgr); lua_setglobal(lua, "hl"); const auto HANDLE = reinterpret_cast(0x1BADB002); ASSERT_TRUE(mgr.registerPluginValue(HANDLE, makeShared("test:ordinary", "", 0)).has_value()); ASSERT_TRUE( mgr.registerPluginValue(HANDLE, makeShared("test:deprecated", "", 0, Config::Values::SIntValueOptions{.deprecationNotice = "use replacement"})) .has_value()); EXPECT_TRUE(mgr.deprecationNotices().empty()); ASSERT_EQ(luaL_dostring(lua, "hl.config({ test = { ordinary = 1 } })"), LUA_OK) << lua_tostring(lua, -1); EXPECT_TRUE(mgr.deprecationNotices().empty()); ASSERT_EQ(luaL_dostring(lua, "hl.config({ test = { deprecated = 1 } })"), LUA_OK) << lua_tostring(lua, -1); const auto notices = mgr.deprecationNotices(); ASSERT_EQ(notices.size(), 1); EXPECT_EQ(notices.front(), "test.deprecated: use replacement"); } TEST(ConfigLuaBindingsInternal, pluginLuaFnIsUnloadedWithoutDanglingCall) { CLuaState S; const auto L = S.get(); auto PREVCOMPOSITOR = std::move(g_pCompositor); g_pCompositor = makeUnique(true); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeLuaState(mgr, L); lua_newtable(L); Internal::registerConfigRuleBindings(L, &mgr); lua_setglobal(L, "hl"); const auto HANDLE = reinterpret_cast(0x1BADB002); const auto regResult = mgr.registerPluginLuaFunction(HANDLE, "demo", "ping", testPluginFn); ASSERT_TRUE(regResult.has_value()) << regResult.error(); ASSERT_EQ(luaL_dostring(L, R"( local f = hl.plugin.demo.ping assert(type(f) == "function") captured = f local v = f() assert(v == "pong") )"), LUA_OK); mgr.onPluginUnload(HANDLE); ASSERT_EQ(luaL_dostring(L, R"( assert(hl.plugin.demo == nil) )"), LUA_OK); ASSERT_EQ(luaL_dostring(L, R"( local ok, err = pcall(captured) assert(ok == false) assert(type(err) == "string") assert(string.find(err, "no longer available", 1, true) ~= nil) )"), LUA_OK); g_pCompositor = std::move(PREVCOMPOSITOR); } TEST(ConfigLuaRequire, absolutePathLoadsAndTracksFile) { CScopedCompositor compositor; CTempDir tmp; const auto mainConfig = tmp.path() / "hyprland.lua"; const auto module = tmp.path() / "absolute.lua"; writeFile(mainConfig, ""); writeFile(module, "return { value = 42 }"); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeOwnedLuaState(mgr, mainConfig); const auto L = CConfigManagerPluginLuaTestAccessor::luaState(mgr); const auto CODE = std::format("mod = require({})", luaString(module.string())); ASSERT_EQ(luaL_dostring(L, CODE.c_str()), LUA_OK) << lua_tostring(L, -1); lua_getglobal(L, "mod"); ASSERT_TRUE(lua_istable(L, -1)); lua_getfield(L, -1, "value"); EXPECT_EQ(lua_tointeger(L, -1), 42); lua_pop(L, 2); expectTracked(mgr, module); } TEST(ConfigLuaRequire, relativePathResolvesFromConfigDirectory) { CScopedCompositor compositor; CTempDir tmp; const auto mainConfig = tmp.path() / "hyprland.lua"; const auto module = tmp.path() / "modules" / "relative.lua"; writeFile(mainConfig, ""); writeFile(module, "return 'relative-ok'"); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeOwnedLuaState(mgr, mainConfig); const auto L = CConfigManagerPluginLuaTestAccessor::luaState(mgr); ASSERT_EQ(luaL_dostring(L, R"( mod = require("./modules/relative.lua") )"), LUA_OK) << lua_tostring(L, -1); lua_getglobal(L, "mod"); ASSERT_TRUE(lua_isstring(L, -1)); EXPECT_STREQ(lua_tostring(L, -1), "relative-ok"); lua_pop(L, 1); expectTracked(mgr, module); } TEST(ConfigLuaRequire, wildcardLoadsSortedTableAndTracksFilesAndDirectory) { CScopedCompositor compositor; CTempDir tmp; const auto mainConfig = tmp.path() / "hyprland.lua"; const auto modulesDir = tmp.path() / "modules"; const auto moduleA = modulesDir / "a.lua"; const auto moduleB = modulesDir / "b.lua"; writeFile(mainConfig, ""); writeFile(moduleB, "return 'b'"); writeFile(moduleA, "return 'a'"); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeOwnedLuaState(mgr, mainConfig); const auto L = CConfigManagerPluginLuaTestAccessor::luaState(mgr); ASSERT_EQ(luaL_dostring(L, R"( mods = require("./modules/*") assert(#mods == 2) assert(mods[1] == "a") assert(mods[2] == "b") )"), LUA_OK) << lua_tostring(L, -1); expectTracked(mgr, modulesDir); expectTracked(mgr, moduleA); expectTracked(mgr, moduleB); } TEST(ConfigLuaRequire, wildcardNoMatchIsCatchableError) { CScopedCompositor compositor; CTempDir tmp; const auto mainConfig = tmp.path() / "hyprland.lua"; writeFile(mainConfig, ""); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeOwnedLuaState(mgr, mainConfig); const auto L = CConfigManagerPluginLuaTestAccessor::luaState(mgr); ASSERT_EQ(luaL_dostring(L, R"( ok, err = pcall(require, "./missing/*") assert(ok == false) assert(type(err) == "string") assert(string.find(err, "module './missing/*' not found", 1, true) ~= nil) )"), LUA_OK) << lua_tostring(L, -1); } TEST(ConfigLuaRequire, normalModuleRequireStillUsesConfigDirectoryPackagePath) { CScopedCompositor compositor; CTempDir tmp; const auto mainConfig = tmp.path() / "hyprland.lua"; const auto module = tmp.path() / "colors.lua"; writeFile(mainConfig, ""); writeFile(module, "return 'normal-ok'"); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeOwnedLuaState(mgr, mainConfig); const auto L = CConfigManagerPluginLuaTestAccessor::luaState(mgr); ASSERT_EQ(luaL_dostring(L, R"( mod = require("colors") )"), LUA_OK) << lua_tostring(L, -1); lua_getglobal(L, "mod"); ASSERT_TRUE(lua_isstring(L, -1)); EXPECT_STREQ(lua_tostring(L, -1), "normal-ok"); lua_pop(L, 1); expectTracked(mgr, module); } TEST(ConfigLuaRequire, packagePathPreservesLuaDefaultsAfterConfigDirectory) { CScopedCompositor compositor; CLuaState defaultState; CTempDir tmp; const auto mainConfig = tmp.path() / "hyprland.lua"; writeFile(mainConfig, ""); const auto defaultPath = packagePath(defaultState.get()); ASSERT_FALSE(defaultPath.empty()); CConfigManager mgr; CConfigManagerPluginLuaTestAccessor::initializeOwnedLuaState(mgr, mainConfig); const auto configPath = std::format("{};{}", (tmp.path() / "?.lua").string(), (tmp.path() / "?/init.lua").string()); EXPECT_EQ(packagePath(CConfigManagerPluginLuaTestAccessor::luaState(mgr)), std::format("{};{}", configPath, defaultPath)); }