Skip to main content

mlua_pkg/
lib.rs

1//! # mlua-pkg
2//!
3//! Composable Lua module loader built in Rust.
4//!
5//! # Design philosophy
6//!
7//! Lua's `require("name")` is a `name -> value` transformation.
8//! This crate defines that transformation as a **composable abstraction**,
9//! allowing multiple sources (memory, filesystem, Rust functions, assets)
10//! to be handled uniformly.
11//!
12//! # Resolution model
13//!
14//! ## Abstractions
15//!
16//! | Concept | Type | Role |
17//! |---------|------|------|
18//! | Resolution unit | [`Resolver`] | `name -> Option<Result<Value>>` |
19//! | Composition (Chain) | [`Registry`] | Resolvers in priority order, first match wins |
20//! | Composition (Prefix) | [`resolvers::PrefixResolver`] | Strip prefix and delegate to inner Resolver |
21//!
22//! Resolvers come in two kinds: **leaf** (directly produce values) and
23//! **combinator** (compose other Resolvers). Both implement the same
24//! [`Resolver`] trait, enabling infinite composition.
25//!
26//! ## Leaf Resolvers
27//!
28//! | Resolver | Source | Match condition |
29//! |----------|--------|----------------|
30//! | [`resolvers::MemoryResolver`] | `HashMap<String, String>` | Name is registered |
31//! | [`resolvers::NativeResolver`] | `Fn(&Lua) -> Result<Value>` | Name is registered |
32//! | [`resolvers::FsResolver`] | Filesystem | File exists |
33//! | [`resolvers::AssetResolver`] | Filesystem | Known extension + file exists |
34//!
35//! ## Combinators
36//!
37//! | Combinator | Behavior |
38//! |------------|----------|
39//! | [`Registry`] (Chain) | Try `[R1, R2, ..., Rn]` in order, adopt first `Some` |
40//! | [`resolvers::PrefixResolver`] | `"prefix.rest"` -> strip prefix -> delegate `"rest"` to inner Resolver |
41//!
42//! ## Resolution flow
43//!
44//! ```text
45//! require("name")
46//!   |
47//!   v
48//! package.searchers[1]  <- Registry inserts its hook here
49//!   |
50//!   +- Resolver A: resolve(lua, "name") -> None (not responsible)
51//!   +- Resolver B: resolve(lua, "name") -> Some(Ok(Value)) (first match wins)
52//!   |
53//!   v
54//! package.loaded["name"] = Value  <- Lua standard require auto-caches
55//! ```
56//!
57//! # Return value protocol
58//!
59//! | Return value | Meaning | Next Resolver |
60//! |-------------|---------|---------------|
61//! | `None` | Not this Resolver's responsibility | Tried |
62//! | `Some(Ok(value))` | Resolution succeeded | Skipped |
63//! | `Some(Err(e))` | Responsible but load failed | **Skipped** |
64//!
65//! `Some(Err)` intentionally does not fall through to the next Resolver.
66//! If a module was "found but broken", having another Resolver return
67//! something different would be a source of bugs.
68//!
69//! # Naming conventions
70//!
71//! | Name pattern | Example | Responsible Resolver |
72//! |-------------|---------|---------------------|
73//! | `@scope/name` | `@std/http` | [`resolvers::NativeResolver`] -- exact name match |
74//! | `prefix.name` | `game.engine` | [`resolvers::PrefixResolver`] -> delegates to inner Resolver |
75//! | `dot.separated` | `lib.helper` | [`resolvers::FsResolver`] -- `lib/helper.lua` |
76//! | `name.ext` | `config.json` | [`resolvers::AssetResolver`] -- auto-convert by extension |
77//!
78//! [`resolvers::FsResolver`] converts dot separators to path separators
79//! (`lib.helper` -> `lib/helper.lua`).
80//! [`resolvers::AssetResolver`] treats filenames literally
81//! (`config.json` -> `config.json`).
82//! The two naturally partition by the presence of a file extension.
83//!
84//! # Composition example
85//!
86//! ```text
87//! Registry (Chain)
88//! +- NativeResolver            @std/http  -> factory(lua)
89//! +- Prefix("sm", FsResolver)  sm.helper  -> strip -> helper.lua
90//! +- FsResolver(root/)         sm         -> sm/init.lua
91//! |                             lib.utils  -> lib/utils.lua
92//! +- AssetResolver              config.json -> parse -> Table
93//! ```
94//!
95//! [`resolvers::PrefixResolver`] acts as a namespace mount point.
96//! `require("sm")` (init.lua) is handled by the outer [`resolvers::FsResolver`],
97//! while `require("sm.helper")` is handled by [`resolvers::PrefixResolver`].
98//! Responsibilities are clearly separated.
99//!
100//! # Usage
101//!
102//! ```rust
103//! use mlua_pkg::{Registry, resolvers::*};
104//! use mlua::Lua;
105//!
106//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
107//! let lua = Lua::new();
108//! let mut reg = Registry::new();
109//!
110//! // 1st: Rust native modules (highest priority)
111//! reg.add(NativeResolver::new().add("@std/http", |lua| {
112//!     let t = lua.create_table()?;
113//!     t.set("version", 1)?;
114//!     Ok(mlua::Value::Table(t))
115//! }));
116//!
117//! // 2nd: Embedded Lua sources
118//! reg.add(MemoryResolver::new().add("utils", "return { pi = 3.14 }"));
119//!
120//! // 3rd: Filesystem (sandboxed)
121//! # let plugins = std::env::temp_dir().join("mlua_pkg_doctest_plugins");
122//! # std::fs::create_dir_all(&plugins)?;
123//! # let assets = std::env::temp_dir().join("mlua_pkg_doctest_assets");
124//! # std::fs::create_dir_all(&assets)?;
125//! reg.add(FsResolver::new(&plugins)?);
126//!
127//! // 4th: Assets (register parsers explicitly)
128//! reg.add(AssetResolver::new(&assets)?
129//!     .parser("json", json_parser())
130//!     .parser("sql", text_parser()));
131//! # std::fs::remove_dir_all(&plugins).ok();
132//! # std::fs::remove_dir_all(&assets).ok();
133//!
134//! reg.install(&lua)?;
135//!
136//! // Lua side: require("@std/http"), require("utils"), etc.
137//! # Ok(())
138//! # }
139//! ```
140//!
141//! # Lua integration
142//!
143//! [`Registry::install()`] inserts a hook at the front of Lua's
144//! `package.searchers` table. It takes priority over the standard
145//! `package.preload`, so registered Resolvers are tried first.
146//!
147//! Caching is delegated to Lua's standard `package.loaded`.
148//! On the second and subsequent `require` calls for the same module,
149//! Lua's cache hits and the Resolver is not invoked.
150//!
151//! # Error design
152//!
153//! | Error type | When raised | Defined in |
154//! |-----------|-------------|-----------|
155//! | [`ResolveError`] | During `resolve()` execution | This module |
156//! | [`sandbox::InitError`] | During `FsSandbox::new()` construction | [`sandbox`] |
157//! | [`sandbox::ReadError`] | During `SandboxedFs::read()` | [`sandbox`] |
158//!
159//! By separating construction-time and runtime errors at the type level,
160//! callers can choose the appropriate recovery strategy.
161
162pub mod resolvers;
163pub mod sandbox;
164
165use mlua::{Lua, Result, Value};
166use std::path::PathBuf;
167
168/// Configuration bundle for Lua dialect naming conventions.
169///
170/// Apply to [`FsResolver`](resolvers::FsResolver) and
171/// [`PrefixResolver`](resolvers::PrefixResolver) via `with_convention()`
172/// to prevent convention settings from scattering.
173///
174/// Individual `with_extension()` / `with_init_name()` / `with_separator()`
175/// methods remain available. Calling them after `with_convention()` overrides
176/// the corresponding field.
177///
178/// # Predefined conventions
179///
180/// | Constant | Extension | Init name | Separator |
181/// |----------|-----------|-----------|-----------|
182/// | [`LUA54`](Self::LUA54) | `lua` | `init` | `.` |
183/// | [`LUAU`](Self::LUAU) | `luau` | `init` | `.` |
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub struct LuaConvention {
186    /// File extension (`"lua"`, `"luau"`, etc.).
187    pub extension: &'static str,
188    /// Package entry point name (`"init"`, `"mod"`, etc.).
189    pub init_name: &'static str,
190    /// Module name separator. The `.` in `require("a.b")`.
191    pub module_separator: char,
192}
193
194impl LuaConvention {
195    /// Lua 5.4 standard convention.
196    pub const LUA54: Self = Self {
197        extension: "lua",
198        init_name: "init",
199        module_separator: '.',
200    };
201
202    /// Luau (Roblox Lua) convention.
203    pub const LUAU: Self = Self {
204        extension: "luau",
205        init_name: "init",
206        module_separator: '.',
207    };
208}
209
210impl Default for LuaConvention {
211    fn default() -> Self {
212        Self::LUA54
213    }
214}
215
216/// Error type for module resolution.
217///
218/// Structurally represents domain-specific errors that occur during `resolve()`.
219/// Converted to a Lua error via [`mlua::Error::external()`] and can be
220/// recovered on the caller side with `err.downcast_ref::<ResolveError>()`.
221///
222/// Construction-time errors (e.g. root directory not found) are returned as
223/// [`sandbox::InitError`] and are not included here.
224#[derive(Debug, thiserror::Error)]
225pub enum ResolveError {
226    /// Path access outside the sandbox detected.
227    #[error("path traversal blocked: {name}")]
228    PathTraversal { name: String },
229
230    /// Asset parse failure.
231    ///
232    /// Generalized to hold different error types per parser.
233    /// [`resolvers::json_parser()`] stores `serde_json::Error`;
234    /// custom parsers can store any error type.
235    #[error("asset parse error: {source}")]
236    AssetParse {
237        #[source]
238        source: Box<dyn std::error::Error + Send + Sync>,
239    },
240
241    /// File I/O error.
242    ///
243    /// Raised when a file exists but cannot be read
244    /// (e.g. permission denied, is a directory).
245    #[error("I/O error on {}: {source}", path.display())]
246    Io {
247        path: PathBuf,
248        source: std::io::Error,
249    },
250}
251
252/// Minimal abstraction for module resolution.
253///
254/// Receives `require(name)` and returns `Some(Result<Value>)` if this
255/// Resolver is responsible. Returns `None` if not.
256///
257/// # Return value protocol
258///
259/// - `None` = "unknown name". The next Resolver gets a chance.
260/// - `Some(Ok(v))` = resolution complete. This value is returned to Lua.
261/// - `Some(Err(e))` = "responsible but failed". Propagated immediately as an error.
262///
263/// # Example
264///
265/// ```rust
266/// use mlua_pkg::Resolver;
267/// use mlua::{Lua, Result, Value};
268///
269/// struct VersionResolver;
270///
271/// impl Resolver for VersionResolver {
272///     fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
273///         if name == "version" {
274///             Some(lua.create_string("1.0.0").map(Value::String))
275///         } else {
276///             None
277///         }
278///     }
279/// }
280/// ```
281pub trait Resolver: Send + Sync {
282    fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>>;
283}
284
285/// Chain combinator for [`Resolver`]. Registration order = priority order. First match wins.
286///
287/// `install()` inserts a hook at the front (index 1) of Lua's `package.searchers`
288/// table, routing all `require` calls through the registered Resolver chain.
289/// Takes priority over Lua's standard `package.preload`.
290///
291/// Caching is delegated to Lua's standard `package.loaded`.
292/// Resolvers do not need to manage their own cache.
293/// On the second and subsequent `require` for the same module, the Resolver is not called.
294///
295/// # Lua searcher protocol
296///
297/// The hook conforms to the Lua 5.4 searcher protocol:
298/// - If the searcher returns a `function`, `require` calls it as a loader
299/// - If the searcher returns a `string`, it is collected as a "not found" reason in the error message
300///
301/// The loader receives `(name, loader_data)` (per Lua 5.4 spec).
302///
303/// # Thread safety
304///
305/// `Registry` itself is `Send + Sync` (all Resolvers must be `Send + Sync`).
306/// After [`install()`](Registry::install), the Registry is wrapped in `Arc` and
307/// shared via a Lua closure.
308///
309/// Thread safety of the installed hook depends on the `mlua` feature configuration:
310///
311/// | mlua feature | `Lua` bounds | Implication |
312/// |-------------|-------------|-------------|
313/// | (default) | `!Send` | `Lua` is confined to one thread. The hook is never called concurrently. |
314/// | `send` | `Send + Sync` | `Lua` can be shared across threads. `Resolver: Send + Sync` ensures safe concurrent access. |
315///
316/// The `Send + Sync` bound on [`Resolver`] is required for forward compatibility
317/// with mlua's `send` feature. Without the `send` feature, `Lua` is `!Send` and
318/// the hook is inherently single-threaded.
319pub struct Registry {
320    resolvers: Vec<Box<dyn Resolver>>,
321}
322
323impl Default for Registry {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl Registry {
330    pub fn new() -> Self {
331        Self {
332            resolvers: Vec::new(),
333        }
334    }
335
336    /// Add a Resolver. Registration order = priority order.
337    pub fn add(&mut self, resolver: impl Resolver + 'static) -> &mut Self {
338        self.resolvers.push(Box::new(resolver));
339        self
340    }
341
342    /// Insert a hook at the front of `package.searchers`.
343    ///
344    /// Consumes `self` and shares it via `Arc`.
345    /// The Registry becomes immutable after install (Resolver priority is finalized).
346    ///
347    /// Returns an error if called more than once on the same Lua instance.
348    /// Multiple Registries coexisting in the same searchers table would make
349    /// priority order unpredictable, so this is intentionally prohibited.
350    pub fn install(self, lua: &Lua) -> Result<()> {
351        if lua.app_data_ref::<RegistryInstalled>().is_some() {
352            return Err(mlua::Error::runtime(
353                "Registry already installed on this Lua instance",
354            ));
355        }
356
357        let searchers: mlua::Table = lua
358            .globals()
359            .get::<mlua::Table>("package")?
360            .get("searchers")?;
361
362        let registry = std::sync::Arc::new(self);
363        let hook = lua.create_function(move |lua, name: String| {
364            for resolver in &registry.resolvers {
365                if let Some(result) = resolver.resolve(lua, &name) {
366                    let value = result?;
367                    let f = lua.create_function(move |_, (_name, _data): (String, Value)| {
368                        Ok(value.clone())
369                    })?;
370                    return Ok(Value::Function(f));
371                }
372            }
373            Ok(Value::String(
374                lua.create_string(format!("\n\tno resolver for '{name}'"))?,
375            ))
376        })?;
377
378        let len = searchers.raw_len();
379        for i in (1..=len).rev() {
380            let v: Value = searchers.raw_get(i)?;
381            searchers.raw_set(i + 1, v)?;
382        }
383        searchers.raw_set(1, hook)?;
384        lua.set_app_data(RegistryInstalled);
385
386        Ok(())
387    }
388}
389
390/// Marker for `install()` completion. Used to prevent double-install.
391struct RegistryInstalled;
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    struct Echo;
398
399    impl Resolver for Echo {
400        fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
401            if name == "echo" {
402                Some(lua.create_string("hello from echo").map(Value::String))
403            } else {
404                None
405            }
406        }
407    }
408
409    #[test]
410    fn require_hits_resolver() {
411        let lua = Lua::new();
412        let mut reg = Registry::new();
413        reg.add(Echo);
414        reg.install(&lua).unwrap();
415
416        let val: String = lua.load(r#"return require("echo")"#).eval().unwrap();
417        assert_eq!(val, "hello from echo");
418    }
419
420    #[test]
421    fn require_miss_falls_through() {
422        let lua = Lua::new();
423        let mut reg = Registry::new();
424        reg.add(Echo);
425        reg.install(&lua).unwrap();
426
427        let result: mlua::Result<Value> = lua.load(r#"return require("nope")"#).eval();
428        assert!(result.is_err());
429    }
430
431    #[test]
432    fn registry_default() {
433        let reg = Registry::default();
434        assert_eq!(reg.resolvers.len(), 0);
435    }
436
437    #[test]
438    fn double_install_rejected() {
439        let lua = Lua::new();
440
441        let reg1 = Registry::new();
442        reg1.install(&lua).unwrap();
443
444        let reg2 = Registry::new();
445        let err = reg2.install(&lua).unwrap_err();
446        assert!(
447            err.to_string().contains("already installed"),
448            "expected 'already installed' error, got: {err}"
449        );
450    }
451}