From 23a55e52587b96986683f371ba9707d5caf0d3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Mon, 6 Jul 2026 18:09:08 +0200 Subject: [PATCH] add require support --- .stylua.toml | 2 +- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 1 + docs/README.md | 4 +- docs/require.md | 111 ++++++++++++++ src/lua_tests/mod.rs | 1 + src/lua_tests/require_tests.rs | 262 +++++++++++++++++++++++++++++++++ src/main.rs | 52 ++++--- src/stdlib/mod.rs | 3 + src/stdlib/require.rs | 221 +++++++++++++++++++++++++++ 11 files changed, 646 insertions(+), 19 deletions(-) create mode 100644 docs/require.md create mode 100644 src/lua_tests/require_tests.rs create mode 100644 src/stdlib/require.rs diff --git a/.stylua.toml b/.stylua.toml index 902182b..7a3fad9 100644 --- a/.stylua.toml +++ b/.stylua.toml @@ -3,6 +3,6 @@ line_endings = "Unix" indent_type = "Spaces" indent_width = 4 quote_style = "AutoPreferDouble" -call_parentheses = "Always" +call_parentheses = "Input" # never allow `if x then y end` on one line — always expand after then/do collapse_simple_statement = "Never" diff --git a/Cargo.lock b/Cargo.lock index 58cedb8..833a2d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ dependencies = [ "chrono", "clap", "digest_auth", + "dotenvy", "env_logger", "futures", "log", @@ -381,6 +382,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "either" version = "1.16.0" diff --git a/Cargo.toml b/Cargo.toml index 6218634..059f307 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ chrono = "0.4.45" env_logger = "0.11" log = "0.4" clap = { version = "4.6.1", features = ["derive"] } +dotenvy = "0.15" futures = "0.3" mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]} # reqwest's default rustls provider (aws-lc-rs) null-derefs during the TLS diff --git a/README.md b/README.md index ec38e93..29ba8dd 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The intent is for the common case to be a single call rather than a multi-step s - A single self-contained binary. No LuaRocks, no make, no virtualenv. - Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library `a` provides is the supported way to reach the outside world. - A leading `#!` shebang line is skipped (after an optional UTF-8 BOM), so scripts can be made executable directly. Script files need not be valid UTF-8 — Lua source is byte-oriented. +- Scripts can be split into files with a sandboxed [`require`](docs/require.md): modules are looked up next to the main script (symlinks resolved, so an executable script symlinked into `~/.local/bin` finds its files) and in `$A_INCLUDE_PATHS`, which can also be set in a `.env` beside the script. ## Building diff --git a/docs/README.md b/docs/README.md index 28665ef..149d253 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,8 @@ # `a` standard library Reference docs for the library `a` adds on top of Lua 5.5. Every module below is -a **global** — there is no `require`; they are present in every script. +a **global**, present in every script with no setup (`require`-ing one by name +also works and returns the same table). ## Modules @@ -20,5 +21,6 @@ a **global** — there is no `require`; they are present in every script. | Page | What it covers | |------|----------------| | [Concurrency](concurrency.md) | How async calls suspend, and running work in parallel with `task.join`. | +| [Modules & `require`](require.md) | Splitting scripts into files: search roots, `A_INCLUDE_PATHS`, `.env`, caching, LSP setup. | See the top-level [README](../README.md) for what `a` is and how to build it. diff --git a/docs/require.md b/docs/require.md new file mode 100644 index 0000000..50ddfe8 --- /dev/null +++ b/docs/require.md @@ -0,0 +1,111 @@ +# Modules & `require` + +`a` provides a sandboxed `require` for splitting a script into multiple files. +It follows stock Lua's conventions — dot-separated module names, one execution +per module, cached results — so editors and the Lua language server understand +the imports without special setup. + +```lua +local helper = require "lib.helper" -- lib/helper.lua +local db = require "db" -- db.lua or db/init.lua +local mod, path = require "lib.helper" -- second value: the resolved file path +``` + +## Module names + +A name is one or more dot-separated segments; each segment may contain +`A–Z a–z 0–9 _ -`. Dots map to directory separators, so `require "db.migrations"` +looks for `db/migrations.lua`. Paths (`/`, `..`), absolute names, and empty +segments are rejected — a module can only ever be loaded from inside the +search roots, which keeps `require` compatible with the sandbox: it is the +only file access scripts have. + +## Search roots + +Names are resolved against a fixed list of directories, in order: + +1. **The main script's directory.** Symlinks to the script are resolved first, + so a script symlinked into `~/.local/bin` still loads modules (and its + `.env`, below) from next to the real file. In the REPL, the current working + directory is used instead. +2. **Each entry of `$A_INCLUDE_PATHS`**, colon-separated like `$PATH`. + +In every root, two candidates are tried (the Lua language server's default +templates): + +``` +/.lua +//init.lua +``` + +The first hit wins. If a later root (or the `init.lua` form) also matches, a +warning is logged about the shadowed file. When nothing matches, the error +lists every path that was tried, in the stock Lua format. + +## `.env` + +Before resolving anything, `a` loads a `.env` file from the main script's +real directory (the CWD in the REPL), if one exists. Variables already set in +the real environment take precedence. This is the intended place to set +`A_INCLUDE_PATHS` for a project: + +``` +# myproject/.env +A_INCLUDE_PATHS=/home/me/lua-libs:/opt/shared-lua +``` + +Since the script's symlink is resolved first, `myproject/main.lua` symlinked +as `~/.local/bin/mytool` still picks up `myproject/.env`. + +## Semantics + +- **A module file is a plain chunk.** Its `local`s are private to the file; it + shares globals with the rest of the program; whatever it returns is the + module's value (conventionally a table). Like stock Lua, the chunk receives + the module name and the resolved file path as `...`. +- **Executed once.** The return value is cached by the file's canonical path, + so two names or symlinks reaching the same file share one instance, and + `require` of an already-loaded module is just a table lookup. A module that + returns nothing is cached as `true`. +- **Return values.** `require` returns the module value and, for file modules, + the resolved path as a second value. +- **Built-ins.** `require "http"`, `require "utils"`, etc. return the same + table as the corresponding global, so code written for stock Lua's habits + works unchanged. +- **Cycles.** A require cycle (`a` requires `b` requires `a`) raises a + `circular require` error instead of recursing forever. +- **Errors.** If a module raises while loading, the error propagates to the + requiring code and nothing is cached — a later `require` retries the load. +- Module files may use the async stdlib (`http`, `os.sleep`, …) at top level, + and may start with a shebang and/or UTF-8 BOM, same as the main script. + +## Editor / LSP setup + +The resolution rules match the +[Lua language server](https://luals.github.io/) defaults, so a minimal +`.luarc.json` next to your project gives go-to-definition and type inference +across `require` boundaries: + +```json +{ + "runtime.version": "Lua 5.4", + "runtime.path": ["?.lua", "?/init.lua"], + "workspace.library": ["/home/me/lua-libs", "/opt/shared-lua"] +} +``` + +List the same directories in `workspace.library` as in `A_INCLUDE_PATHS`; +modules under the script's own directory are found via the workspace root. + +## Example layout + +``` +myproject/ +├── main.lua -- #!/usr/bin/env a; symlinked to ~/.local/bin/mytool +├── .env -- A_INCLUDE_PATHS=... +├── .luarc.json +└── lib/ + ├── helper.lua -- require "lib.helper" + └── db/ + └── init.lua -- require "lib.db" +``` diff --git a/src/lua_tests/mod.rs b/src/lua_tests/mod.rs index 7d501de..ee9b9c7 100644 --- a/src/lua_tests/mod.rs +++ b/src/lua_tests/mod.rs @@ -5,6 +5,7 @@ mod async_tests; mod core_tests; mod math_tests; mod os_tests; +mod require_tests; mod table_tests; mod utils_tests; diff --git a/src/lua_tests/require_tests.rs b/src/lua_tests/require_tests.rs new file mode 100644 index 0000000..1a26542 --- /dev/null +++ b/src/lua_tests/require_tests.rs @@ -0,0 +1,262 @@ +//! Tests for `require`: resolution, caching, isolation and failure modes. +//! Each test builds its own module tree under a unique temp directory and +//! installs `require` with that directory as a search root, the same way +//! `main.rs` does with the script's directory. + +use std::path::PathBuf; + +use mlua::Lua; + +use super::lua; + +/// A per-test module tree; removed on drop. +struct TempRoot(PathBuf); + +impl TempRoot { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("a-require-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TempRoot(dir) + } + + fn write(&self, rel: &str, contents: &str) { + let path = self.0.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, contents).unwrap(); + } +} + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn lua_with_roots(roots: &[&TempRoot]) -> Lua { + let lua = lua(); + let roots: Vec = roots.iter().map(|r| r.0.clone()).collect(); + crate::stdlib::require::install(&lua, roots).unwrap(); + lua +} + +#[tokio::test] +async fn test_require_returns_value_and_path() { + let root = TempRoot::new("value"); + root.write("mod.lua", "return { answer = 42 }"); + let lua = lua_with_roots(&[&root]); + + let (answer, path): (i64, String) = lua + .load(r#"local m, p = require "mod"; return m.answer, p"#) + .eval_async() + .await + .unwrap(); + assert_eq!(answer, 42); + assert!(path.ends_with("mod.lua"), "{path}"); +} + +#[tokio::test] +async fn test_require_caches_and_runs_once() { + let root = TempRoot::new("cache"); + root.write("counted.lua", "COUNT = (COUNT or 0) + 1\nreturn { n = COUNT }"); + let lua = lua_with_roots(&[&root]); + + let (same, count): (bool, i64) = lua + .load( + r#" + local a = require "counted" + local b = require "counted" + return rawequal(a, b), COUNT + "#, + ) + .eval_async() + .await + .unwrap(); + assert!(same, "both requires must return the same table"); + assert_eq!(count, 1, "module body must run exactly once"); +} + +#[tokio::test] +async fn test_require_dot_names_and_init_fallback() { + let root = TempRoot::new("dots"); + root.write("db/migrations.lua", r#"return "migrations""#); + root.write("pkg/init.lua", r#"return "pkg-init""#); + let lua = lua_with_roots(&[&root]); + + let (a, b): (String, String) = lua + .load(r#"return require "db.migrations", require "pkg""#) + .eval_async() + .await + .unwrap(); + assert_eq!(a, "migrations"); + assert_eq!(b, "pkg-init"); +} + +#[tokio::test] +async fn test_require_module_locals_stay_local() { + let root = TempRoot::new("scope"); + root.write("scoped.lua", "local secret = 'hidden'\nreturn true"); + let lua = lua_with_roots(&[&root]); + + let leaked: bool = lua + .load(r#"require "scoped"; return secret ~= nil"#) + .eval_async() + .await + .unwrap(); + assert!(!leaked, "module locals must not leak into globals"); +} + +#[tokio::test] +async fn test_require_passes_name_and_path_as_varargs() { + let root = TempRoot::new("varargs"); + root.write("who.lua", "local name, path = ...\nreturn { name = name, path = path }"); + let lua = lua_with_roots(&[&root]); + + let (name, path): (String, String) = lua + .load(r#"local m = require "who"; return m.name, m.path"#) + .eval_async() + .await + .unwrap(); + assert_eq!(name, "who"); + assert!(path.ends_with("who.lua"), "{path}"); +} + +#[tokio::test] +async fn test_require_rejects_malformed_names() { + let root = TempRoot::new("names"); + let lua = lua_with_roots(&[&root]); + + for name in ["", ".", "a..b", "../x", "a/b", "/etc/passwd", "a.b."] { + let err = lua + .load(format!("require {name:?}")) + .exec_async() + .await + .unwrap_err(); + assert!( + err.to_string().contains("invalid module name"), + "{name}: {err}" + ); + } +} + +#[tokio::test] +async fn test_require_not_found_lists_candidates() { + let root = TempRoot::new("notfound"); + let lua = lua_with_roots(&[&root]); + + let err = lua.load(r#"require "nope""#).exec_async().await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("module 'nope' not found"), "{msg}"); + assert!(msg.contains("nope.lua"), "{msg}"); + assert!(msg.contains("init.lua"), "{msg}"); +} + +#[tokio::test] +async fn test_require_circular_errors() { + let root = TempRoot::new("circular"); + root.write("aaa.lua", "return require 'bbb'"); + root.write("bbb.lua", "return require 'aaa'"); + let lua = lua_with_roots(&[&root]); + + let err = lua.load(r#"require "aaa""#).exec_async().await.unwrap_err(); + assert!(err.to_string().contains("circular require"), "{err}"); +} + +#[tokio::test] +async fn test_require_nil_result_stored_as_true() { + let root = TempRoot::new("nilret"); + root.write("sideeffect.lua", "MARK = 1"); + let lua = lua_with_roots(&[&root]); + + let (ret, again, mark): (bool, bool, i64) = lua + .load(r#"return require "sideeffect", require "sideeffect", MARK"#) + .eval_async() + .await + .unwrap(); + assert!(ret, "a module returning nothing must yield true"); + assert!(again, "...also on the cached path"); + assert_eq!(mark, 1); +} + +#[tokio::test] +async fn test_require_builtins_return_the_globals() { + let root = TempRoot::new("builtins"); + let lua = lua_with_roots(&[&root]); + + let all_same: bool = lua + .load( + r#" + for _, name in ipairs({"utils", "log", "sqlite", "http", "task", "math", "table", "string", "os"}) do + if not rawequal(require(name), _G[name]) then return false end + end + return true + "#, + ) + .eval_async() + .await + .unwrap(); + assert!(all_same, "require of a built-in must return its global table"); +} + +#[tokio::test] +async fn test_require_first_root_wins() { + let first = TempRoot::new("order1"); + let second = TempRoot::new("order2"); + first.write("dup.lua", "return 'first'"); + second.write("dup.lua", "return 'second'"); + let lua = lua_with_roots(&[&first, &second]); + + let got: String = lua.load(r#"return require "dup""#).eval_async().await.unwrap(); + assert_eq!(got, "first"); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_require_symlink_resolves_to_same_module() { + let root = TempRoot::new("symlink"); + root.write("orig.lua", "COUNT = (COUNT or 0) + 1\nreturn {}"); + std::os::unix::fs::symlink(root.0.join("orig.lua"), root.0.join("alias.lua")).unwrap(); + let lua = lua_with_roots(&[&root]); + + let (same, count): (bool, i64) = lua + .load(r#"return rawequal(require "orig", require "alias"), COUNT"#) + .eval_async() + .await + .unwrap(); + assert!(same, "symlinked names must resolve to one module instance"); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn test_require_module_may_use_async_stdlib() { + let root = TempRoot::new("async"); + root.write("sleepy.lua", "os.sleep(0.01)\nreturn 'awake'"); + let lua = lua_with_roots(&[&root]); + + let got: String = lua.load(r#"return require "sleepy""#).eval_async().await.unwrap(); + assert_eq!(got, "awake"); +} + +#[tokio::test] +async fn test_require_module_error_propagates_and_is_not_cached() { + let root = TempRoot::new("boom"); + root.write("boom.lua", "error('kaboom')"); + let lua = lua_with_roots(&[&root]); + + for _ in 0..2 { + // The second attempt must re-run the module (and fail the same way), + // not report a bogus circular require or return a cached value. + let err = lua.load(r#"require "boom""#).exec_async().await.unwrap_err(); + assert!(err.to_string().contains("kaboom"), "{err}"); + } +} + +#[tokio::test] +async fn test_require_module_with_shebang_and_bom() { + let root = TempRoot::new("shebang"); + root.write("sh.lua", "\u{FEFF}#!/usr/bin/env a\nreturn 7"); + let lua = lua_with_roots(&[&root]); + + let got: i64 = lua.load(r#"return require "sh""#).eval_async().await.unwrap(); + assert_eq!(got, 7); +} diff --git a/src/main.rs b/src/main.rs index bf85457..c675f45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::Parser; @@ -38,33 +38,51 @@ async fn run() -> Result<(), Box> { let lua = sandbox::create_sandboxed_lua()?; let Some(filepath) = args.filepath else { + // In the REPL, `.env` and modules are looked up from the CWD. + setup_require(&lua, &std::env::current_dir()?)?; return repl::run(&lua).await; }; + // Resolve symlinks first: installed scripts are typically symlinks (e.g. + // from ~/.local/bin), and both `.env` and the script's libraries live next + // to the real file. + let realpath = tokio::fs::canonicalize(&filepath) + .await + .map_err(|e| format!("a: cannot open {}: {e}", filepath.display()))?; + let script_dir = realpath.parent().unwrap_or(Path::new("/")); + setup_require(&lua, script_dir)?; + // Read as bytes, not a UTF-8 String: Lua source (and its string literals) are // byte strings, and stock Lua runs files that aren't valid UTF-8. - let source = tokio::fs::read(&filepath) + let source = tokio::fs::read(&realpath) .await .map_err(|e| format!("a: cannot read {}: {e}", filepath.display()))?; - // Strip a leading UTF-8 BOM, as stock Lua does, so a BOM before a shebang - // (common from Windows editors) doesn't reach the lexer. - let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&source); - - // Skip a leading shebang line, if present. The newline is kept so error - // messages still report correct line numbers. - let chunk: &[u8] = if source.first() == Some(&b'#') { - let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len()); - &source[nl..] - } else { - source - }; - - lua.load(chunk) + lua.load(stdlib::require::prepare_chunk(&source)) // "@" marks the chunk name as a file path in error messages / tracebacks - .set_name(format!("@{}", filepath.display())) + .set_name(format!("@{}", realpath.display())) .exec_async() .await?; Ok(()) } + +/// Prepare module loading rooted at `dir` — the real directory of the main +/// script, or the CWD in the REPL. Loads `dir/.env` into the process +/// environment (variables already set in the real environment win), then +/// installs `require` searching `dir` followed by the `$A_INCLUDE_PATHS` +/// entries (colon-separated, like `$PATH`). +fn setup_require(lua: &mlua::Lua, dir: &Path) -> Result<(), Box> { + match dotenvy::from_path(dir.join(".env")) { + Ok(()) => {} + Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("a: cannot load {}: {e}", dir.join(".env").display()).into()), + } + + let mut roots = vec![dir.to_path_buf()]; + if let Some(paths) = std::env::var_os("A_INCLUDE_PATHS") { + roots.extend(std::env::split_paths(&paths).filter(|p| !p.as_os_str().is_empty())); + } + stdlib::require::install(lua, roots)?; + Ok(()) +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 4c988a0..2fd3154 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -3,6 +3,9 @@ mod logging; mod lua_dump; mod math; mod os_ext; +// Installed separately from `install`, since it needs the module search roots +// which are only known once the script path (or REPL CWD) is. +pub(crate) mod require; mod sqlite; mod table; mod task; diff --git a/src/stdlib/require.rs b/src/stdlib/require.rs new file mode 100644 index 0000000..1815469 --- /dev/null +++ b/src/stdlib/require.rs @@ -0,0 +1,221 @@ +//! A sandboxed `require` for loading Lua modules from files. +//! +//! Module names use stock Lua dot notation (`require "db.migrations"`) and are +//! resolved against a fixed list of root directories — the main script's real +//! directory (the CWD in the REPL) followed by the entries of +//! `$A_INCLUDE_PATHS` — through the standard `?.lua` / `?/init.lua` templates. +//! Because a name may only contain identifier-like segments, a module can +//! never name a file outside the roots; `require` is the sandbox's only file +//! access. +//! +//! Each file is executed once and its return value cached by canonical path, +//! so repeated requires — even under different names or through symlinks — +//! return the same value. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::rc::Rc; + +use mlua::prelude::{LuaResult, LuaTable, LuaValue}; +use mlua::Lua; + +/// Globals pre-seeded into the module cache, so `require "http"` returns the +/// same table as the global: scripts read more portably and LSPs get an +/// anchor for the stdlib. Covers the project stdlib plus the stock libraries +/// the sandbox loads. +const BUILTINS: &[&str] = &[ + "utils", + "log", + "sqlite", + "http", + "task", + "coroutine", + "math", + "os", + "string", + "table", + "utf8", +]; + +/// Strip a leading UTF-8 BOM and a shebang line from Lua source, as stock Lua +/// does. The shebang's newline is kept so error messages still report correct +/// line numbers. Used for the main script and for every required file. +pub(crate) fn prepare_chunk(source: &[u8]) -> &[u8] { + let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(source); + if source.first() == Some(&b'#') { + let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len()); + &source[nl..] + } else { + source + } +} + +/// Install the global `require` function, searching `roots` in order. +pub(crate) fn install(lua: &Lua, roots: Vec) -> LuaResult<()> { + // Cache of loaded modules, held Lua-side so the values stay GC-anchored. + // Built-ins are keyed by name, file modules by canonical path; the key + // spaces cannot collide because canonical paths are absolute. + let loaded = lua.create_table()?; + for name in BUILTINS { + let value: LuaValue = lua.globals().get(*name)?; + if !value.is_nil() { + loaded.set(*name, value)?; + } + } + + let roots = Rc::new(roots); + // Module name -> canonical path it resolved to; repeated requires of the + // same name skip the filesystem probing. + let resolved: Rc>> = Rc::default(); + // Canonical paths of modules currently executing, to catch require cycles. + let in_flight: Rc>> = Rc::default(); + + let require = lua.create_async_function(move |lua, name: String| { + let loaded = loaded.clone(); + let roots = roots.clone(); + let resolved = resolved.clone(); + let in_flight = in_flight.clone(); + async move { require_impl(lua, name, loaded, roots, resolved, in_flight).await } + })?; + lua.globals().set("require", require) +} + +async fn require_impl( + lua: Lua, + name: String, + loaded: LuaTable, + roots: Rc>, + resolved: Rc>>, + in_flight: Rc>>, +) -> LuaResult<(LuaValue, Option)> { + // Built-in module? + let builtin: LuaValue = loaded.get(name.as_str())?; + if !builtin.is_nil() { + return Ok((builtin, None)); + } + + // A name that already resolved to a loaded file? + if let Some(key) = resolved.borrow().get(&name).cloned() { + let value: LuaValue = loaded.get(key.as_str())?; + if !value.is_nil() { + return Ok((value, Some(key))); + } + } + + let Some(rel) = module_relpath(&name) else { + return Err(mlua::Error::runtime(format!( + "invalid module name '{name}' (expected dot-separated segments of [A-Za-z0-9_-], like 'dir.mod')" + ))); + }; + + // Probe every root so a hit shadowed by an earlier one can be warned about. + let mut hits: Vec = Vec::new(); + let mut misses: Vec = Vec::new(); + for root in roots.iter() { + let base = root.join(&rel); + for candidate in [base.with_extension("lua"), base.join("init.lua")] { + match tokio::fs::metadata(&candidate).await { + Ok(meta) if meta.is_file() => hits.push(candidate), + _ => misses.push(candidate), + } + } + } + + let Some(found) = hits.first() else { + let mut msg = format!("module '{name}' not found:"); + for miss in &misses { + msg.push_str(&format!("\n\tno file '{}'", miss.display())); + } + return Err(mlua::Error::runtime(msg)); + }; + for shadowed in &hits[1..] { + log::warn!( + "require '{name}': using '{}', ignoring shadowed '{}'", + found.display(), + shadowed.display() + ); + } + + let real = tokio::fs::canonicalize(found).await.map_err(|e| { + mlua::Error::runtime(format!( + "require '{name}': cannot resolve '{}': {e}", + found.display() + )) + })?; + let key = real.display().to_string(); + + // The same file may already be loaded under another name or via a symlink. + let value: LuaValue = loaded.get(key.as_str())?; + if !value.is_nil() { + resolved.borrow_mut().insert(name, key.clone()); + return Ok((value, Some(key))); + } + + if !in_flight.borrow_mut().insert(key.clone()) { + return Err(mlua::Error::runtime(format!( + "circular require of module '{name}' ('{key}')" + ))); + } + // Clears the in-flight marker even when the load errors or is cancelled, + // so a failed module can be required again. + let _guard = InFlightGuard { + set: in_flight.clone(), + key: key.clone(), + }; + + let source = tokio::fs::read(&real) + .await + .map_err(|e| mlua::Error::runtime(format!("require '{name}': cannot read '{key}': {e}")))?; + + let value: LuaValue = lua + .load(prepare_chunk(&source)) + // "@" marks the chunk name as a file path in error messages / tracebacks + .set_name(format!("@{key}")) + // like stock Lua, the chunk receives the module name and file path as `...` + .call_async((name.as_str(), key.as_str())) + .await?; + + // A module that returns nothing is cached as `true`, as stock require does. + let value = if value.is_nil() { + LuaValue::Boolean(true) + } else { + value + }; + loaded.set(key.as_str(), value.clone())?; + resolved.borrow_mut().insert(name, key.clone()); + Ok((value, Some(key))) +} + +/// Convert a dot-separated module name into a relative path, or `None` when +/// the name is malformed. Restricting segments to identifier-like characters +/// is what keeps `require` inside its roots: `..`, `/` and absolute paths are +/// unrepresentable. +fn module_relpath(name: &str) -> Option { + if name.is_empty() { + return None; + } + let mut rel = PathBuf::new(); + for segment in name.split('.') { + let ok = !segment.is_empty() + && segment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); + if !ok { + return None; + } + rel.push(segment); + } + Some(rel) +} + +struct InFlightGuard { + set: Rc>>, + key: String, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.set.borrow_mut().remove(&self.key); + } +}