add require support

master
Ondřej Hruška 2 weeks ago
parent e3f580c161
commit 23a55e5258
  1. 2
      .stylua.toml
  2. 7
      Cargo.lock
  3. 1
      Cargo.toml
  4. 1
      README.md
  5. 4
      docs/README.md
  6. 111
      docs/require.md
  7. 1
      src/lua_tests/mod.rs
  8. 262
      src/lua_tests/require_tests.rs
  9. 52
      src/main.rs
  10. 3
      src/stdlib/mod.rs
  11. 221
      src/stdlib/require.rs

@ -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"

7
Cargo.lock generated

@ -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"

@ -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

@ -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

@ -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.

@ -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"
```

@ -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;

@ -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);
}

@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}

@ -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;

@ -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…
Cancel
Save