From 44d16e9bd1272fc271100a35d9165fd51452a26f Mon Sep 17 00:00:00 2001 From: Vaxry <43317083+vaxerski@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:27:54 +0200 Subject: [PATCH] config/lua: allow wildcards or absolute paths in require (#15461) --- hyprtester/CMakeLists.txt | 3 + hyprtester/lua-require/absolute.lua | 1 + hyprtester/lua-require/relative.lua | 1 + hyprtester/lua-require/wildcard/a.lua | 1 + hyprtester/lua-require/wildcard/b.lua | 1 + hyprtester/src/tests/main/lua_config.cpp | 12 + hyprtester/test.lua | 19 +- src/config/lua/ConfigManager.cpp | 236 +++++++++++++++++++- src/config/shared/inotify/ConfigWatcher.cpp | 8 +- tests/config/lua/LuaBindingsInternal.cpp | 206 +++++++++++++++++ 10 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 hyprtester/lua-require/absolute.lua create mode 100644 hyprtester/lua-require/relative.lua create mode 100644 hyprtester/lua-require/wildcard/a.lua create mode 100644 hyprtester/lua-require/wildcard/b.lua create mode 100644 hyprtester/src/tests/main/lua_config.cpp diff --git a/hyprtester/CMakeLists.txt b/hyprtester/CMakeLists.txt index a9c63fcb0..5aa644c1c 100644 --- a/hyprtester/CMakeLists.txt +++ b/hyprtester/CMakeLists.txt @@ -27,6 +27,9 @@ install(TARGETS hyprtester) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/test.lua DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/hypr) +install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/lua-require + DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/hypr) + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/plugin/hyprtestplugin.so DESTINATION ${CMAKE_INSTALL_PREFIX}/lib) diff --git a/hyprtester/lua-require/absolute.lua b/hyprtester/lua-require/absolute.lua new file mode 100644 index 000000000..c536a30e2 --- /dev/null +++ b/hyprtester/lua-require/absolute.lua @@ -0,0 +1 @@ +return "absolute" diff --git a/hyprtester/lua-require/relative.lua b/hyprtester/lua-require/relative.lua new file mode 100644 index 000000000..64bfa9c51 --- /dev/null +++ b/hyprtester/lua-require/relative.lua @@ -0,0 +1 @@ +return "relative" diff --git a/hyprtester/lua-require/wildcard/a.lua b/hyprtester/lua-require/wildcard/a.lua new file mode 100644 index 000000000..9ee7f15f1 --- /dev/null +++ b/hyprtester/lua-require/wildcard/a.lua @@ -0,0 +1 @@ +return "a" diff --git a/hyprtester/lua-require/wildcard/b.lua b/hyprtester/lua-require/wildcard/b.lua new file mode 100644 index 000000000..5c06085ac --- /dev/null +++ b/hyprtester/lua-require/wildcard/b.lua @@ -0,0 +1 @@ +return "b" diff --git a/hyprtester/src/tests/main/lua_config.cpp b/hyprtester/src/tests/main/lua_config.cpp new file mode 100644 index 000000000..daee902ab --- /dev/null +++ b/hyprtester/src/tests/main/lua_config.cpp @@ -0,0 +1,12 @@ +#include "tests.hpp" +#include "../../shared.hpp" +#include "../../hyprctlCompat.hpp" + +TEST_CASE(luaRequire) { + constexpr auto EXPECTED = "absolute:relative:a:b"; + + EXPECT(getFromSocket("/repl return _G.hyprtester_lua_require_result"), EXPECTED); + + OK(getFromSocket("/reload")); + EXPECT(getFromSocket("/repl return _G.hyprtester_lua_require_result"), EXPECTED); +} diff --git a/hyprtester/test.lua b/hyprtester/test.lua index fe1b55150..4986cadf7 100644 --- a/hyprtester/test.lua +++ b/hyprtester/test.lua @@ -1,5 +1,23 @@ -- Hyprtester Lua config +local function config_dir() + local source = debug.getinfo(1, "S").source + local path = source:sub(1, 1) == "@" and source:sub(2) or source + + if path:sub(1, 1) ~= "/" then + path = os.getenv("PWD") .. "/" .. path + end + + return path:match("^(.*)/[^/]*$") or "." +end + +local requireAbsolute = require(config_dir() .. "/lua-require/absolute.lua") +local requireRelative = require("./lua-require/relative.lua") +local requireWildcard = require("./lua-require/wildcard/*") + +_G.hyprtester_lua_require_result = table.concat({ requireAbsolute, requireRelative, requireWildcard[1], requireWildcard[2] }, ":") +assert(_G.hyprtester_lua_require_result == "absolute:relative:a:b") + hl.monitor({ output = "HEADLESS-1", mode = "1920x1080@60", position = "auto-right", scale = "1" }) hl.monitor({ output = "HEADLESS-2", mode = "1920x1080@60", position = "auto-right", scale = "1" }) hl.monitor({ output = "HEADLESS-3", mode = "1920x1080@60", position = "auto-right", scale = "1" }) @@ -325,4 +343,3 @@ hl.layout.register("grid", { end end, }) - diff --git a/src/config/lua/ConfigManager.cpp b/src/config/lua/ConfigManager.cpp index 715c97828..571ef2bf0 100644 --- a/src/config/lua/ConfigManager.cpp +++ b/src/config/lua/ConfigManager.cpp @@ -5,8 +5,10 @@ #include #include +#include #include #include +#include #include #include @@ -37,6 +39,7 @@ #include "../../managers/eventLoop/EventLoopManager.hpp" #include "../../managers/input/trackpad/TrackpadGestures.hpp" #include "../../notification/NotificationOverlay.hpp" +#include "../../helpers/MiscFunctions.hpp" using namespace Config; using namespace Config::Lua; @@ -55,6 +58,211 @@ static bool isValidLuaIdentifier(const std::string& value) { return std::ranges::all_of(value, [](const char& c) { return std::isalnum(c) || c == '_'; }); } +static std::string normalizedConfigPath(const std::string& path) { + if (path.empty()) + return path; + + return std::filesystem::path(path).lexically_normal().string(); +} + +static void trackConfigPath(CConfigManager* mgr, const std::string& path) { + if (!mgr || path.empty()) + return; + + const auto NORMALIZED = normalizedConfigPath(path); + if (std::ranges::find(mgr->m_configPaths, NORMALIZED) == mgr->m_configPaths.end()) + mgr->m_configPaths.emplace_back(NORMALIZED); +} + +static bool isExplicitRequirePath(std::string_view moduleName) { + return moduleName.starts_with('/') || moduleName.starts_with("./") || moduleName.starts_with("../") || moduleName.starts_with("~/"); +} + +static bool hasGlobMeta(std::string_view value) { + return value.find_first_of("*?[") != std::string_view::npos; +} + +static std::string resolveRequirePath(CConfigManager* mgr, const std::string& rawPath) { + if (!mgr) + return rawPath; + + return absolutePath(rawPath, mgr->getMainConfigPath()); +} + +static std::optional resolveExplicitLuaRequireFile(CConfigManager* mgr, const std::string& moduleName) { + std::vector candidates; + + const auto BASE = resolveRequirePath(mgr, moduleName); + candidates.emplace_back(BASE); + + if (!BASE.ends_with(".lua")) + candidates.emplace_back(BASE + ".lua"); + + candidates.emplace_back((std::filesystem::path(BASE) / "init.lua").string()); + + for (const auto& candidate : candidates) { + std::error_code ec; + const auto STATUS = std::filesystem::status(candidate, ec); + if (!ec && std::filesystem::is_regular_file(STATUS)) + return normalizedConfigPath(candidate); + } + + return std::nullopt; +} + +static void trackWildcardParentDirectory(CConfigManager* mgr, const std::string& pattern) { + const auto META = pattern.find_first_of("*?["); + if (META == std::string::npos) + return; + + const auto SLASH = pattern.substr(0, META).find_last_of('/'); + if (SLASH == std::string::npos) + return; + + std::string parent = SLASH == 0 ? "/" : pattern.substr(0, SLASH); + if (parent.empty()) + parent = "."; + + std::error_code ec; + const auto STATUS = std::filesystem::status(parent, ec); + if (!ec && std::filesystem::is_directory(STATUS)) + trackConfigPath(mgr, parent); +} + +static std::expected, std::string> expandRequireWildcard(CConfigManager* mgr, const std::string& moduleName) { + const auto PATTERN = resolveRequirePath(mgr, moduleName); + trackWildcardParentDirectory(mgr, PATTERN); + + glob_t globBuf = {}; + const int GLOBRESULT = glob(PATTERN.c_str(), GLOB_TILDE, nullptr, &globBuf); + if (GLOBRESULT != 0) { + globfree(&globBuf); + if (GLOBRESULT == GLOB_NOMATCH) + return std::unexpected("found no match"); + if (GLOBRESULT == GLOB_ABORTED) + return std::unexpected("read error"); + return std::unexpected("out of memory"); + } + + std::vector paths; + for (size_t i = 0; i < globBuf.gl_pathc; ++i) { + std::string path = globBuf.gl_pathv[i]; + std::error_code ec; + const auto STATUS = std::filesystem::status(path, ec); + if (!ec && std::filesystem::is_regular_file(STATUS)) + paths.emplace_back(normalizedConfigPath(path)); + } + + globfree(&globBuf); + + std::ranges::sort(paths); + paths.erase(std::ranges::unique(paths).begin(), paths.end()); + + if (paths.empty()) + return std::unexpected("found no regular file matches"); + + return paths; +} + +static bool pushPackageLoaded(lua_State* L, const std::string& moduleName) { + const int stackTop = lua_gettop(L); + + lua_getglobal(L, "package"); + if (!lua_istable(L, -1)) { + lua_settop(L, stackTop); + return false; + } + + lua_getfield(L, -1, "loaded"); + if (!lua_istable(L, -1)) { + lua_settop(L, stackTop); + return false; + } + + lua_pushstring(L, moduleName.c_str()); + lua_gettable(L, -2); + if (!lua_toboolean(L, -1)) { + lua_settop(L, stackTop); + return false; + } + + lua_remove(L, stackTop + 2); // loaded + lua_remove(L, stackTop + 1); // package + return true; +} + +static void setPackageLoaded(lua_State* L, const std::string& moduleName, int valueIdx) { + const int absValueIdx = lua_absindex(L, valueIdx); + + lua_getglobal(L, "package"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return; + } + + lua_getfield(L, -1, "loaded"); + if (!lua_istable(L, -1)) { + lua_pop(L, 2); + return; + } + + lua_pushstring(L, moduleName.c_str()); + lua_pushvalue(L, absValueIdx); + lua_settable(L, -3); + lua_pop(L, 2); +} + +static int requireWildcard(lua_State* L, CConfigManager* mgr, const std::string& moduleName) { + if (pushPackageLoaded(L, moduleName)) + return 1; + + const auto PATHS = expandRequireWildcard(mgr, moduleName); + if (!PATHS) + return luaL_error(L, "module '%s' not found: wildcard %s", moduleName.c_str(), PATHS.error().c_str()); + + lua_newtable(L); + const int resultIdx = lua_gettop(L); + size_t resultI = 1; + + for (const auto& path : *PATHS) { + trackConfigPath(mgr, path); + + lua_pushvalue(L, lua_upvalueindex(1)); + lua_pushstring(L, path.c_str()); + + const int status = lua_pcall(L, 1, LUA_MULTRET, 0); + if (status == LUA_OK) { + const int nresults = lua_gettop(L) - resultIdx; + if (nresults > 0 && !lua_isnil(L, resultIdx + 1)) + lua_pushvalue(L, resultIdx + 1); + else + lua_pushboolean(L, true); + lua_rawseti(L, resultIdx, resultI++); + lua_pop(L, nresults); + continue; + } + + std::string err; + { + size_t len = 0; + const char* str = luaL_tolstring(L, -1, &len); + if (str) + err.assign(str, len); + lua_pop(L, 1); + } + lua_pop(L, 1); // error object + + if (mgr) + mgr->addError(std::format("require(\"{}\"): {}", path, err)); + + lua_newtable(L); + lua_rawseti(L, resultIdx, resultI++); + } + + setPackageLoaded(L, moduleName, resultIdx); + return 1; +} + static int pluginLuaFunctionDispatcher(lua_State* L) { auto* mgr = CConfigManager::fromLuaState(L); if (!mgr) @@ -94,8 +302,8 @@ static void trackRequiredLuaModulePath(lua_State* L, CConfigManager* mgr, const if (lua_pcall(L, 2, 2, 0) == LUA_OK && lua_isstring(L, -2)) { const auto* resolvedPath = lua_tostring(L, -2); - if (resolvedPath && std::ranges::find(mgr->m_configPaths, resolvedPath) == mgr->m_configPaths.end()) - mgr->m_configPaths.emplace_back(resolvedPath); + if (resolvedPath) + trackConfigPath(mgr, resolvedPath); } lua_settop(L, stackTop); @@ -108,6 +316,9 @@ static int safeLuaRequire(lua_State* L) { if (lua_isstring(L, 1)) moduleName = lua_tostring(L, 1); + if (isExplicitRequirePath(moduleName) && hasGlobMeta(moduleName)) + return requireWildcard(L, CConfigManager::fromLuaState(L), moduleName); + lua_pushvalue(L, lua_upvalueindex(1)); lua_insert(L, 1); @@ -349,12 +560,29 @@ void CConfigManager::reinitLuaState() { m_lua, [](lua_State* L) -> int { // upvalue 1: original searcher, upvalue 2: CConfigManager* + auto* self = sc(lua_touserdata(L, lua_upvalueindex(2))); + std::string moduleName; + if (lua_isstring(L, 1)) + moduleName = lua_tostring(L, 1); + + if (isExplicitRequirePath(moduleName) && !hasGlobMeta(moduleName)) { + const auto resolved = resolveExplicitLuaRequireFile(self, moduleName); + if (resolved) { + trackConfigPath(self, *resolved); + + if (luaL_loadfile(L, resolved->c_str()) != LUA_OK) + return luaL_error(L, "error loading module '%s' from file '%s':\n\t%s", moduleName.c_str(), resolved->c_str(), lua_tostring(L, -1)); + + lua_pushstring(L, resolved->c_str()); + return 2; + } + } + lua_pushvalue(L, lua_upvalueindex(1)); lua_pushvalue(L, 1); // module name lua_call(L, 1, 2); // -> loader?, filename? if (lua_isfunction(L, -2) && lua_isstring(L, -1)) { - auto* self = sc(lua_touserdata(L, lua_upvalueindex(2))); - self->m_configPaths.emplace_back(lua_tostring(L, -1)); + trackConfigPath(self, lua_tostring(L, -1)); } return 2; }, diff --git a/src/config/shared/inotify/ConfigWatcher.cpp b/src/config/shared/inotify/ConfigWatcher.cpp index 905d55c74..bcd710ffb 100644 --- a/src/config/shared/inotify/ConfigWatcher.cpp +++ b/src/config/shared/inotify/ConfigWatcher.cpp @@ -50,8 +50,14 @@ void CConfigWatcher::setWatchList(const std::vector& paths) { // add new paths for (const auto& path : paths) { + std::error_code ecDir; + const bool isDirectory = std::filesystem::is_directory(path, ecDir); + const uint32_t fileMask = IN_CLOSE_WRITE | IN_DONT_FOLLOW; + const uint32_t directoryMask = fileMask | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_MOVED_FROM; + const uint32_t mask = isDirectory ? directoryMask : fileMask; + m_watches.emplace_back(SInotifyWatch{ - .wd = inotify_add_watch(m_inotifyFd.get(), path.c_str(), IN_CLOSE_WRITE | IN_DONT_FOLLOW), + .wd = inotify_add_watch(m_inotifyFd.get(), path.c_str(), mask), .file = path, }); diff --git a/tests/config/lua/LuaBindingsInternal.cpp b/tests/config/lua/LuaBindingsInternal.cpp index d94ea2dc8..2b773c5af 100644 --- a/tests/config/lua/LuaBindingsInternal.cpp +++ b/tests/config/lua/LuaBindingsInternal.cpp @@ -7,6 +7,12 @@ #include +#include +#include +#include +#include +#include + extern "C" { #include #include @@ -23,6 +29,17 @@ namespace Config::Lua { 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; + } }; } @@ -50,6 +67,70 @@ namespace { 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(g_pKeybindManager)) { + g_pCompositor = makeUnique(true); + g_pKeybindManager = makeUnique(); + } + + ~CScopedCompositor() { + g_pKeybindManager = 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(); + } + + 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) { @@ -254,3 +335,128 @@ TEST(ConfigLuaBindingsInternal, pluginLuaFnIsUnloadedWithoutDanglingCall) { 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 = "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); +}