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 error;
163pub mod fetcher;
164pub mod lockfile;
165pub mod manifest;
166pub mod resolvers;
167pub mod sandbox;
168pub mod version;
169
170pub use error::PkgError;
171
172use mlua::{Lua, Result, Value};
173use std::path::{Path, PathBuf};
174
175/// Resolve the Lua `require` entry point directory for a cached package.
176///
177/// Applies the entry fallback chain in order:
178///
179/// 1. If `override_entry` is `Some(p)`, check `cache_path.join(p)` only.
180/// If it is not a directory, return [`PkgError::EntryNotFound`] immediately
181/// (the override is explicit, so fallback would be surprising).
182/// 2. Otherwise, try the default candidates in order:
183/// - `cache_path/src/`
184/// - `cache_path/lua/`
185/// - `cache_path/` itself (`.`)
186///
187/// Returns the first candidate that is a directory, or
188/// [`PkgError::EntryNotFound`] if none exist.
189///
190/// # Notes
191///
192/// This function is used by the `install` CLI (ST5) to determine the symlink
193/// target for each vendored package. [`resolvers::VendoredResolver`] itself does
194/// not call this function — the lockfile already carries the resolved `entry`
195/// field, and the CLI's symlink points `vendored/<name>` at
196/// `../cache/…/<sha>/<entry>` before the resolver is constructed.
197///
198/// # Errors
199///
200/// Returns [`PkgError::EntryNotFound`] when no candidate directory exists.
201///
202/// # Example
203///
204/// ```rust,no_run
205/// use mlua_pkg::resolve_entry;
206/// use std::path::Path;
207///
208/// let entry = resolve_entry(Path::new("/cache/mypkg/abc123"), None)?;
209/// // => /cache/mypkg/abc123/src (if that directory exists)
210/// # Ok::<(), mlua_pkg::PkgError>(())
211/// ```
212pub fn resolve_entry(
213 cache_path: &Path,
214 override_entry: Option<&Path>,
215) -> std::result::Result<PathBuf, PkgError> {
216 let candidates: Vec<PathBuf> = match override_entry {
217 Some(e) => vec![cache_path.join(e)],
218 None => vec![
219 cache_path.join("src"),
220 cache_path.join("lua"),
221 cache_path.to_path_buf(),
222 ],
223 };
224
225 for c in &candidates {
226 if c.is_dir() {
227 return Ok(c.clone());
228 }
229 }
230
231 Err(PkgError::EntryNotFound {
232 name: cache_path.display().to_string(),
233 attempted: candidates,
234 })
235}
236
237/// Configuration bundle for Lua dialect naming conventions.
238///
239/// Apply to [`FsResolver`](resolvers::FsResolver) and
240/// [`PrefixResolver`](resolvers::PrefixResolver) via `with_convention()`
241/// to prevent convention settings from scattering.
242///
243/// Individual `with_extension()` / `with_init_name()` / `with_separator()`
244/// methods remain available. Calling them after `with_convention()` overrides
245/// the corresponding field.
246///
247/// # Predefined conventions
248///
249/// | Constant | Extension | Init name | Separator |
250/// |----------|-----------|-----------|-----------|
251/// | [`LUA54`](Self::LUA54) | `lua` | `init` | `.` |
252/// | [`LUAU`](Self::LUAU) | `luau` | `init` | `.` |
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub struct LuaConvention {
255 /// File extension (`"lua"`, `"luau"`, etc.).
256 pub extension: &'static str,
257 /// Package entry point name (`"init"`, `"mod"`, etc.).
258 pub init_name: &'static str,
259 /// Module name separator. The `.` in `require("a.b")`.
260 pub module_separator: char,
261}
262
263impl LuaConvention {
264 /// Lua 5.4 standard convention.
265 pub const LUA54: Self = Self {
266 extension: "lua",
267 init_name: "init",
268 module_separator: '.',
269 };
270
271 /// Luau (Roblox Lua) convention.
272 pub const LUAU: Self = Self {
273 extension: "luau",
274 init_name: "init",
275 module_separator: '.',
276 };
277}
278
279impl Default for LuaConvention {
280 fn default() -> Self {
281 Self::LUA54
282 }
283}
284
285/// Error type for module resolution.
286///
287/// Structurally represents domain-specific errors that occur during `resolve()`.
288/// Converted to a Lua error via [`mlua::Error::external()`] and can be
289/// recovered on the caller side with `err.downcast_ref::<ResolveError>()`.
290///
291/// Construction-time errors (e.g. root directory not found) are returned as
292/// [`sandbox::InitError`] and are not included here.
293#[derive(Debug, thiserror::Error)]
294pub enum ResolveError {
295 /// Path access outside the sandbox detected.
296 #[error("path traversal blocked: {name}")]
297 PathTraversal { name: String },
298
299 /// Asset parse failure.
300 ///
301 /// Generalized to hold different error types per parser.
302 /// [`resolvers::json_parser()`] stores `serde_json::Error`;
303 /// custom parsers can store any error type.
304 #[error("asset parse error: {source}")]
305 AssetParse {
306 #[source]
307 source: Box<dyn std::error::Error + Send + Sync>,
308 },
309
310 /// File I/O error.
311 ///
312 /// Raised when a file exists but cannot be read
313 /// (e.g. permission denied, is a directory).
314 #[error("I/O error on {}: {source}", path.display())]
315 Io {
316 path: PathBuf,
317 source: std::io::Error,
318 },
319}
320
321/// Minimal abstraction for module resolution.
322///
323/// Receives `require(name)` and returns `Some(Result<Value>)` if this
324/// Resolver is responsible. Returns `None` if not.
325///
326/// # Return value protocol
327///
328/// - `None` = "unknown name". The next Resolver gets a chance.
329/// - `Some(Ok(v))` = resolution complete. This value is returned to Lua.
330/// - `Some(Err(e))` = "responsible but failed". Propagated immediately as an error.
331///
332/// # Example
333///
334/// ```rust
335/// use mlua_pkg::Resolver;
336/// use mlua::{Lua, Result, Value};
337///
338/// struct VersionResolver;
339///
340/// impl Resolver for VersionResolver {
341/// fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
342/// if name == "version" {
343/// Some(lua.create_string("1.0.0").map(Value::String))
344/// } else {
345/// None
346/// }
347/// }
348/// }
349/// ```
350pub trait Resolver: Send + Sync {
351 fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>>;
352}
353
354/// Chain combinator for [`Resolver`]. Registration order = priority order. First match wins.
355///
356/// `install()` inserts a hook at the front (index 1) of Lua's `package.searchers`
357/// table, routing all `require` calls through the registered Resolver chain.
358/// Takes priority over Lua's standard `package.preload`.
359///
360/// Caching is delegated to Lua's standard `package.loaded`.
361/// Resolvers do not need to manage their own cache.
362/// On the second and subsequent `require` for the same module, the Resolver is not called.
363///
364/// # Lua searcher protocol
365///
366/// The hook conforms to the Lua 5.4 searcher protocol:
367/// - If the searcher returns a `function`, `require` calls it as a loader
368/// - If the searcher returns a `string`, it is collected as a "not found" reason in the error message
369///
370/// The loader receives `(name, loader_data)` (per Lua 5.4 spec).
371///
372/// # Thread safety
373///
374/// `Registry` itself is `Send + Sync` (all Resolvers must be `Send + Sync`).
375/// After [`install()`](Registry::install), the Registry is wrapped in `Arc` and
376/// shared via a Lua closure.
377///
378/// Thread safety of the installed hook depends on the `mlua` feature configuration:
379///
380/// | mlua feature | `Lua` bounds | Implication |
381/// |-------------|-------------|-------------|
382/// | (default) | `!Send` | `Lua` is confined to one thread. The hook is never called concurrently. |
383/// | `send` | `Send + Sync` | `Lua` can be shared across threads. `Resolver: Send + Sync` ensures safe concurrent access. |
384///
385/// The `Send + Sync` bound on [`Resolver`] is required for forward compatibility
386/// with mlua's `send` feature. Without the `send` feature, `Lua` is `!Send` and
387/// the hook is inherently single-threaded.
388pub struct Registry {
389 resolvers: Vec<Box<dyn Resolver>>,
390}
391
392impl Default for Registry {
393 fn default() -> Self {
394 Self::new()
395 }
396}
397
398impl Registry {
399 pub fn new() -> Self {
400 Self {
401 resolvers: Vec::new(),
402 }
403 }
404
405 /// Add a Resolver. Registration order = priority order.
406 pub fn add(&mut self, resolver: impl Resolver + 'static) -> &mut Self {
407 self.resolvers.push(Box::new(resolver));
408 self
409 }
410
411 /// Insert a hook at the front of `package.searchers`.
412 ///
413 /// Consumes `self` and shares it via `Arc`.
414 /// The Registry becomes immutable after install (Resolver priority is finalized).
415 ///
416 /// Returns an error if called more than once on the same Lua instance.
417 /// Multiple Registries coexisting in the same searchers table would make
418 /// priority order unpredictable, so this is intentionally prohibited.
419 pub fn install(self, lua: &Lua) -> Result<()> {
420 if lua.app_data_ref::<RegistryInstalled>().is_some() {
421 return Err(mlua::Error::runtime(
422 "Registry already installed on this Lua instance",
423 ));
424 }
425
426 let searchers: mlua::Table = lua
427 .globals()
428 .get::<mlua::Table>("package")?
429 .get("searchers")?;
430
431 let registry = std::sync::Arc::new(self);
432 let hook = lua.create_function(move |lua, name: String| {
433 for resolver in ®istry.resolvers {
434 if let Some(result) = resolver.resolve(lua, &name) {
435 let value = result?;
436 let f = lua.create_function(move |_, (_name, _data): (String, Value)| {
437 Ok(value.clone())
438 })?;
439 return Ok(Value::Function(f));
440 }
441 }
442 Ok(Value::String(
443 lua.create_string(format!("\n\tno resolver for '{name}'"))?,
444 ))
445 })?;
446
447 let len = searchers.raw_len();
448 for i in (1..=len).rev() {
449 let v: Value = searchers.raw_get(i)?;
450 searchers.raw_set(i + 1, v)?;
451 }
452 searchers.raw_set(1, hook)?;
453 lua.set_app_data(RegistryInstalled);
454
455 Ok(())
456 }
457}
458
459/// Marker for `install()` completion. Used to prevent double-install.
460struct RegistryInstalled;
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 struct Echo;
467
468 impl Resolver for Echo {
469 fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
470 if name == "echo" {
471 Some(lua.create_string("hello from echo").map(Value::String))
472 } else {
473 None
474 }
475 }
476 }
477
478 #[test]
479 fn require_hits_resolver() {
480 let lua = Lua::new();
481 let mut reg = Registry::new();
482 reg.add(Echo);
483 reg.install(&lua).unwrap();
484
485 let val: String = lua.load(r#"return require("echo")"#).eval().unwrap();
486 assert_eq!(val, "hello from echo");
487 }
488
489 #[test]
490 fn require_miss_falls_through() {
491 let lua = Lua::new();
492 let mut reg = Registry::new();
493 reg.add(Echo);
494 reg.install(&lua).unwrap();
495
496 let result: mlua::Result<Value> = lua.load(r#"return require("nope")"#).eval();
497 assert!(result.is_err());
498 }
499
500 #[test]
501 fn registry_default() {
502 let reg = Registry::default();
503 assert_eq!(reg.resolvers.len(), 0);
504 }
505
506 #[test]
507 fn double_install_rejected() {
508 let lua = Lua::new();
509
510 let reg1 = Registry::new();
511 reg1.install(&lua).unwrap();
512
513 let reg2 = Registry::new();
514 let err = reg2.install(&lua).unwrap_err();
515 assert!(
516 err.to_string().contains("already installed"),
517 "expected 'already installed' error, got: {err}"
518 );
519 }
520
521 // -- resolve_entry helper tests --
522
523 // TC 4: entry fallback — src/ takes priority when it exists as a dir
524 #[test]
525 fn vendored_resolve_entry_src_priority() {
526 let tmp = tempfile::tempdir().unwrap();
527 let cache = tmp.path();
528
529 // Create src/ and lua/ sub-directories; resolve_entry should pick src/ first.
530 std::fs::create_dir_all(cache.join("src")).unwrap();
531 std::fs::create_dir_all(cache.join("lua")).unwrap();
532
533 let result = resolve_entry(cache, None).unwrap();
534 assert_eq!(result, cache.join("src"));
535 }
536
537 // TC 4b: fallback to lua/ when src/ is absent
538 #[test]
539 fn vendored_resolve_entry_lua_fallback() {
540 let tmp = tempfile::tempdir().unwrap();
541 let cache = tmp.path();
542
543 std::fs::create_dir_all(cache.join("lua")).unwrap();
544
545 let result = resolve_entry(cache, None).unwrap();
546 assert_eq!(result, cache.join("lua"));
547 }
548
549 // TC 4c: override entry is respected and src/ is not tried
550 #[test]
551 fn vendored_resolve_entry_override_respected() {
552 let tmp = tempfile::tempdir().unwrap();
553 let cache = tmp.path();
554
555 // "src/" exists but we override to "lib/"
556 std::fs::create_dir_all(cache.join("src")).unwrap();
557 std::fs::create_dir_all(cache.join("lib")).unwrap();
558
559 let result = resolve_entry(cache, Some(Path::new("lib"))).unwrap();
560 assert_eq!(result, cache.join("lib"));
561 }
562
563 // TC 5: all candidates absent → EntryNotFound
564 #[test]
565 fn vendored_resolve_entry_all_absent_returns_entry_not_found() {
566 let tmp = tempfile::tempdir().unwrap();
567 // cache dir exists but has no src/, lua/, or meaningful root
568 // (the root itself is a dir, so the last fallback `cache_path` would succeed)
569 // To test EntryNotFound we need all three to fail. Root is the tmp dir itself.
570 // Make a sub-path that does NOT exist as a directory.
571 let non_dir = tmp.path().join("no_such_dir");
572 // non_dir does not exist at all, so c.is_dir() = false for all candidates
573 // candidates: non_dir/src, non_dir/lua, non_dir itself (non-existent)
574
575 let err = resolve_entry(&non_dir, None).unwrap_err();
576 assert!(
577 matches!(err, PkgError::EntryNotFound { .. }),
578 "expected EntryNotFound, got: {err}"
579 );
580 }
581
582 // TC 5b: override entry absent → EntryNotFound immediately (no fallback)
583 #[test]
584 fn vendored_resolve_entry_override_absent_no_fallback() {
585 let tmp = tempfile::tempdir().unwrap();
586 let cache = tmp.path();
587
588 // src/ exists but override points to nonexistent "custom/"
589 std::fs::create_dir_all(cache.join("src")).unwrap();
590
591 let err = resolve_entry(cache, Some(Path::new("custom"))).unwrap_err();
592 assert!(
593 matches!(err, PkgError::EntryNotFound { .. }),
594 "expected EntryNotFound when override is absent, got: {err}"
595 );
596 }
597}