config/lua: allow wildcards or absolute paths in require (#15461)

This commit is contained in:
Vaxry
2026-07-17 14:27:54 +02:00
committed by GitHub
parent 164aed999d
commit 44d16e9bd1
10 changed files with 482 additions and 6 deletions
+3
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
return "absolute"
+1
View File
@@ -0,0 +1 @@
return "relative"
+1
View File
@@ -0,0 +1 @@
return "a"
+1
View File
@@ -0,0 +1 @@
return "b"
+12
View File
@@ -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);
}
+18 -1
View File
@@ -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,
})
+232 -4
View File
@@ -5,8 +5,10 @@
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <functional>
#include <fstream>
#include <glob.h>
#include <hyprutils/string/String.hpp>
#include <hyprutils/string/Numeric.hpp>
@@ -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<std::string> resolveExplicitLuaRequireFile(CConfigManager* mgr, const std::string& moduleName) {
std::vector<std::string> 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::vector<std::string>, 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<std::string> 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<CConfigManager*>(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<CConfigManager*>(lua_touserdata(L, lua_upvalueindex(2)));
self->m_configPaths.emplace_back(lua_tostring(L, -1));
trackConfigPath(self, lua_tostring(L, -1));
}
return 2;
},
+7 -1
View File
@@ -50,8 +50,14 @@ void CConfigWatcher::setWatchList(const std::vector<std::string>& 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,
});
+206
View File
@@ -7,6 +7,12 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <format>
#include <fstream>
extern "C" {
#include <lualib.h>
#include <lauxlib.h>
@@ -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<CCompositor>(true);
g_pKeybindManager = makeUnique<CKeybindManager>();
}
~CScopedCompositor() {
g_pKeybindManager = std::move(m_prevKeybindManager);
g_pCompositor = std::move(m_prevCompositor);
}
private:
UP<CCompositor> m_prevCompositor;
UP<CKeybindManager> 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);
}