parent
e3f580c161
commit
23a55e5258
@ -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): |
||||||
|
|
||||||
|
``` |
||||||
|
<root>/<name with dots as slashes>.lua |
||||||
|
<root>/<name with dots as slashes>/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" |
||||||
|
``` |
||||||
@ -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<PathBuf> = 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); |
||||||
|
} |
||||||
@ -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<PathBuf>) -> 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<RefCell<HashMap<String, String>>> = Rc::default(); |
||||||
|
// Canonical paths of modules currently executing, to catch require cycles.
|
||||||
|
let in_flight: Rc<RefCell<HashSet<String>>> = 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<Vec<PathBuf>>, |
||||||
|
resolved: Rc<RefCell<HashMap<String, String>>>, |
||||||
|
in_flight: Rc<RefCell<HashSet<String>>>, |
||||||
|
) -> LuaResult<(LuaValue, Option<String>)> { |
||||||
|
// 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<PathBuf> = Vec::new(); |
||||||
|
let mut misses: Vec<PathBuf> = 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<PathBuf> { |
||||||
|
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<RefCell<HashSet<String>>>, |
||||||
|
key: String, |
||||||
|
} |
||||||
|
|
||||||
|
impl Drop for InFlightGuard { |
||||||
|
fn drop(&mut self) { |
||||||
|
self.set.borrow_mut().remove(&self.key); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue