mlua_pkg/resolvers.rs
1//! Resolver implementations: leaves and combinators.
2//!
3//! # Leaf Resolvers
4//!
5//! Terminal resolvers that directly produce values.
6//!
7//! | Resolver | Source | Match condition | Use case |
8//! |----------|--------|----------------|----------|
9//! | [`MemoryResolver`] | `HashMap<String, String>` | Name is registered | `include_str!` embedding, preload |
10//! | [`NativeResolver`] | `Fn(&Lua) -> Result<Value>` | Name is registered | Build tables from Rust (`@std/*`, etc.) |
11//! | [`FsResolver`] | Filesystem | File exists | Sandboxed, `init.lua` fallback |
12//! | [`AssetResolver`] | Filesystem | Known extension + file exists | Auto-convert non-Lua resources (JSON->Table, etc.) |
13//!
14//! # Combinators
15//!
16//! Resolvers that compose other Resolvers. Since they implement the [`Resolver`] trait,
17//! they can be added to a Registry just like leaves, and combinators can nest.
18//!
19//! | Combinator | Behavior | Use case |
20//! |------------|----------|----------|
21//! | [`PrefixResolver`] | `"prefix.rest"` -> strip prefix -> delegate to inner Resolver | Namespace mounting |
22//!
23//! # Composition patterns
24//!
25//! ```text
26//! Registry (Chain)
27//! +- NativeResolver @std/http -> Rust factory
28//! +- PrefixResolver("game", ...) game.xxx -> delegate to inner Resolver
29//! | +- FsResolver(game_dir/) xxx -> game_dir/xxx.lua
30//! +- FsResolver(scripts/) game -> scripts/game/init.lua
31//! | lib.utils -> scripts/lib/utils.lua
32//! +- AssetResolver(assets/) config.json -> JSON parse -> Table
33//! ```
34//!
35//! [`PrefixResolver`] acts as a namespace mount point.
36//! `require("game")` (init.lua) is handled by the outer [`FsResolver`],
37//! while `require("game.engine")` is handled by [`PrefixResolver`].
38//! Responsibilities are clearly separated.
39
40use std::collections::HashMap;
41use std::path::{Path, PathBuf};
42
43use mlua::{Lua, LuaSerdeExt, Result, Value};
44
45use crate::sandbox::{FsSandbox, InitError, ReadError, SandboxedFs, SymlinkAwareSandbox};
46use crate::{ResolveError, Resolver};
47
48type NativeFactory = Box<dyn Fn(&Lua) -> Result<Value> + Send + Sync>;
49
50/// Domain conversion from ReadError to ResolveError.
51///
52/// Attaches module name domain context to infrastructure-layer errors
53/// that occur during `resolve()` execution.
54///
55/// `sanitized_path` should be a relative path within the sandbox.
56/// Absolute paths generated inside FsSandbox (containing host OS information)
57/// are replaced with relative paths during conversion to prevent leaking
58/// to the Lua side.
59fn read_to_resolve_error(err: ReadError, name: &str, sanitized_path: &Path) -> ResolveError {
60 match err {
61 ReadError::Traversal { .. } => ResolveError::PathTraversal {
62 name: name.to_owned(),
63 },
64 ReadError::Io { source, .. } => ResolveError::Io {
65 path: sanitized_path.to_path_buf(),
66 source,
67 },
68 }
69}
70
71// -- MemoryResolver --
72
73/// Resolver that holds Lua source strings in memory.
74///
75/// Makes modules embedded via `include_str!` or dynamically generated
76/// sources available through `require`.
77///
78/// Cross-module `require` chains also work
79/// (delegated to other Resolvers via the Registry).
80///
81/// ```rust
82/// use mlua_pkg::resolvers::MemoryResolver;
83///
84/// let r = MemoryResolver::new()
85/// .add("mylib", "return { version = 1 }")
86/// .add("mylib.utils", "return { helper = true }");
87/// ```
88pub struct MemoryResolver {
89 modules: HashMap<String, String>,
90}
91
92impl Default for MemoryResolver {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98impl MemoryResolver {
99 pub fn new() -> Self {
100 Self {
101 modules: HashMap::new(),
102 }
103 }
104
105 /// Register a module. Duplicate names are overwritten.
106 pub fn add(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
107 self.modules.insert(name.into(), source.into());
108 self
109 }
110}
111
112impl Resolver for MemoryResolver {
113 fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
114 let source = self.modules.get(name)?;
115 Some(lua.load(source.as_str()).set_name(name).eval())
116 }
117}
118
119// -- NativeResolver --
120
121/// Resolver that builds Lua Values directly from Rust functions.
122///
123/// Provides native modules like `@std/http`.
124/// Since the factory function returns a Lua Value, table construction
125/// and function registration are fully controlled on the Rust side.
126///
127/// ```rust
128/// use mlua_pkg::resolvers::NativeResolver;
129/// use mlua::Value;
130///
131/// let r = NativeResolver::new().add("@std/version", |lua| {
132/// lua.create_string("1.0.0").map(Value::String)
133/// });
134/// ```
135pub struct NativeResolver {
136 modules: HashMap<String, NativeFactory>,
137}
138
139impl Default for NativeResolver {
140 fn default() -> Self {
141 Self::new()
142 }
143}
144
145impl NativeResolver {
146 pub fn new() -> Self {
147 Self {
148 modules: HashMap::new(),
149 }
150 }
151
152 /// Register a native module.
153 pub fn add(
154 mut self,
155 name: impl Into<String>,
156 factory: impl Fn(&Lua) -> Result<Value> + Send + Sync + 'static,
157 ) -> Self {
158 self.modules.insert(name.into(), Box::new(factory));
159 self
160 }
161}
162
163impl Resolver for NativeResolver {
164 fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
165 let factory = self.modules.get(name)?;
166 Some(factory(lua))
167 }
168}
169
170// -- FsResolver --
171
172/// Sandboxed filesystem Resolver.
173///
174/// Resolves `require("lib.helper")` to `{root}/lib/helper.lua`.
175/// Converts module separator to path separator and searches in order:
176///
177/// 1. `{root}/{name}.{extension}`
178/// 2. `{root}/{name}/{init_name}.{extension}`
179///
180/// Defaults to [`LuaConvention::LUA54`](crate::LuaConvention::LUA54).
181/// Use [`with_convention()`](FsResolver::with_convention) for bulk changes,
182/// or individual methods for partial overrides.
183///
184/// I/O goes through the [`SandboxedFs`] trait. Use [`with_sandbox`](FsResolver::with_sandbox)
185/// to inject test mocks or alternative backends.
186///
187/// # Errors
188///
189/// `new()` returns [`InitError::RootNotFound`] if the root does not exist.
190pub struct FsResolver {
191 sandbox: Box<dyn SandboxedFs>,
192 extension: String,
193 init_name: String,
194 module_separator: char,
195}
196
197impl FsResolver {
198 /// Build an FsResolver backed by the real filesystem.
199 ///
200 /// Uses [`FsSandbox`], which rejects any path whose canonical form
201 /// escapes `root`. A symlink under `root` that points outside it is an
202 /// escape by that rule, so package layouts built from symlinks (including
203 /// the one `mlua-pkg install` writes by default) need
204 /// [`new_symlink_aware`](Self::new_symlink_aware) instead.
205 ///
206 /// | Root contains | Constructor |
207 /// |---------------|-------------|
208 /// | Only real files and directories | `new` |
209 /// | Symlinks to external package sources | [`new_symlink_aware`](Self::new_symlink_aware) |
210 /// | Untrusted input (TOCTOU matters) | [`with_sandbox`](Self::with_sandbox) + [`CapSandbox`](crate::sandbox::CapSandbox) |
211 pub fn new(root: impl Into<PathBuf>) -> std::result::Result<Self, InitError> {
212 let fs = FsSandbox::new(root)?;
213 Ok(Self::with_sandbox(fs))
214 }
215
216 /// Build an FsResolver that follows symlinks located directly under `root`.
217 ///
218 /// Uses [`SymlinkAwareSandbox`]: targets of those symlinks become
219 /// additional allowed roots, so `require("pkg")` resolves through
220 /// `root/pkg -> /elsewhere/pkg/init.lua` instead of failing with
221 /// [`ResolveError::PathTraversal`](crate::ResolveError::PathTraversal).
222 ///
223 /// Use this whenever the root is populated by a linking package manager
224 /// (`mlua-pkg install`, `npm link`, `alc_pkg_link`) or contains
225 /// development convenience symlinks.
226 ///
227 /// ```rust,no_run
228 /// use mlua_pkg::resolvers::FsResolver;
229 ///
230 /// let resolver = FsResolver::new_symlink_aware("./blocks")?;
231 /// # Ok::<(), mlua_pkg::sandbox::InitError>(())
232 /// ```
233 ///
234 /// # Errors
235 ///
236 /// Returns [`InitError::RootNotFound`] if `root` does not exist.
237 pub fn new_symlink_aware(root: impl Into<PathBuf>) -> std::result::Result<Self, InitError> {
238 let fs = SymlinkAwareSandbox::new(root)?;
239 Ok(Self::with_sandbox(fs))
240 }
241
242 /// Inject an arbitrary [`SandboxedFs`] implementation.
243 pub fn with_sandbox(sandbox: impl SandboxedFs + 'static) -> Self {
244 let conv = crate::LuaConvention::default();
245 Self {
246 sandbox: Box::new(sandbox),
247 extension: conv.extension.to_owned(),
248 init_name: conv.init_name.to_owned(),
249 module_separator: conv.module_separator,
250 }
251 }
252
253 /// Apply a [`LuaConvention`](crate::LuaConvention) in bulk.
254 pub fn with_convention(self, conv: crate::LuaConvention) -> Self {
255 Self {
256 extension: conv.extension.to_owned(),
257 init_name: conv.init_name.to_owned(),
258 module_separator: conv.module_separator,
259 ..self
260 }
261 }
262
263 /// Change the file extension (default: `lua`).
264 pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
265 self.extension = ext.into();
266 self
267 }
268
269 /// Change the package entry point filename (default: `init`).
270 ///
271 /// `require("pkg")` resolves to `pkg/{init_name}.{extension}`.
272 pub fn with_init_name(mut self, name: impl Into<String>) -> Self {
273 self.init_name = name.into();
274 self
275 }
276
277 /// Change the module name separator (default: `.`).
278 ///
279 /// `require("a{sep}b")` is converted to `a/b.{extension}`.
280 pub fn with_module_separator(mut self, sep: char) -> Self {
281 self.module_separator = sep;
282 self
283 }
284}
285
286impl Resolver for FsResolver {
287 fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
288 let relative = name.replace(self.module_separator, "/");
289
290 let candidates = [
291 PathBuf::from(format!("{relative}.{}", self.extension)),
292 PathBuf::from(format!("{relative}/{}.{}", self.init_name, self.extension)),
293 ];
294
295 for candidate in &candidates {
296 match self.sandbox.read(candidate) {
297 Ok(Some(file)) => {
298 let source_name = candidate.display().to_string();
299 return Some(lua.load(file.content).set_name(source_name).eval());
300 }
301 Ok(None) => continue,
302 Err(e) => {
303 return Some(Err(mlua::Error::external(read_to_resolve_error(
304 e, name, candidate,
305 ))));
306 }
307 }
308 }
309
310 None
311 }
312}
313
314// -- AssetResolver --
315
316type AssetParserFn = Box<dyn Fn(&Lua, &str) -> Result<Value> + Send + Sync>;
317
318/// Resolver that registers parsers by extension and auto-converts non-Lua resources.
319///
320/// Parsers are registered per extension via `.parser()`.
321/// For unregistered extensions, no I/O is performed and `None` is returned.
322///
323/// Filenames are treated literally (no dot-to-path conversion).
324///
325/// # Built-in parsers
326///
327/// | Factory function | Conversion |
328/// |-----------------|------------|
329/// | [`json_parser()`] | Parse with `serde_json` -> Lua Table |
330/// | [`text_parser()`] | Return as-is as Lua String |
331///
332/// # Examples
333///
334/// ```rust
335/// use mlua_pkg::resolvers::{AssetResolver, json_parser, text_parser};
336///
337/// # fn example() -> Result<(), mlua_pkg::sandbox::InitError> {
338/// let resolver = AssetResolver::new("./assets")?
339/// .parser("json", json_parser())
340/// .parser("sql", text_parser())
341/// .parser("css", text_parser());
342/// # Ok(())
343/// # }
344/// ```
345///
346/// Custom parsers can also be registered as closures:
347///
348/// ```rust
349/// use mlua_pkg::resolvers::{AssetResolver, json_parser};
350///
351/// # fn example() -> Result<(), mlua_pkg::sandbox::InitError> {
352/// let resolver = AssetResolver::new("./assets")?
353/// .parser("json", json_parser())
354/// .parser("csv", |lua, content| {
355/// // Split by lines and convert to a Lua table
356/// let t = lua.create_table()?;
357/// for (i, line) in content.lines().enumerate() {
358/// t.set(i + 1, lua.create_string(line)?)?;
359/// }
360/// Ok(mlua::Value::Table(t))
361/// });
362/// # Ok(())
363/// # }
364/// ```
365///
366/// I/O goes through the [`SandboxedFs`] trait. Use [`with_sandbox`](AssetResolver::with_sandbox)
367/// to inject test mocks or alternative backends.
368///
369/// # Design decision: why extension keys are `String`
370///
371/// Parser registration uses `HashMap<String, BoxFn>`.
372/// String keys are chosen over enums to prioritize extensibility (Open/Closed),
373/// allowing users to freely register custom parsers for any extension.
374///
375/// Impact of a typo: `parsers.get(ext)` returns `None` -> safely falls through to the
376/// next Resolver. No panic/UB occurs. Setup code is small, so typos surface immediately in tests.
377///
378/// # Errors
379///
380/// `new()` returns [`InitError::RootNotFound`] if the root does not exist.
381pub struct AssetResolver {
382 sandbox: Box<dyn SandboxedFs>,
383 parsers: HashMap<String, AssetParserFn>,
384}
385
386impl AssetResolver {
387 /// Build an AssetResolver backed by the real filesystem.
388 ///
389 /// Uses [`FsSandbox`]. See [`FsResolver::new`] for how the sandbox choice
390 /// interacts with symlinked roots.
391 pub fn new(root: impl Into<PathBuf>) -> std::result::Result<Self, InitError> {
392 let fs = FsSandbox::new(root)?;
393 Ok(Self::with_sandbox(fs))
394 }
395
396 /// Build an AssetResolver that follows symlinks located directly under `root`.
397 ///
398 /// Asset counterpart of [`FsResolver::new_symlink_aware`]; use it when
399 /// asset directories are linked in rather than copied.
400 ///
401 /// # Errors
402 ///
403 /// Returns [`InitError::RootNotFound`] if `root` does not exist.
404 pub fn new_symlink_aware(root: impl Into<PathBuf>) -> std::result::Result<Self, InitError> {
405 let fs = SymlinkAwareSandbox::new(root)?;
406 Ok(Self::with_sandbox(fs))
407 }
408
409 /// Inject an arbitrary [`SandboxedFs`] implementation.
410 pub fn with_sandbox(sandbox: impl SandboxedFs + 'static) -> Self {
411 Self {
412 sandbox: Box::new(sandbox),
413 parsers: HashMap::new(),
414 }
415 }
416
417 /// Register a parser for an extension. Duplicate extensions are overwritten.
418 pub fn parser(
419 mut self,
420 ext: impl Into<String>,
421 f: impl Fn(&Lua, &str) -> Result<Value> + Send + Sync + 'static,
422 ) -> Self {
423 self.parsers.insert(ext.into(), Box::new(f));
424 self
425 }
426}
427
428/// JSON -> Lua Table parser.
429///
430/// Parses with `serde_json` and converts to a Lua Table via [`LuaSerdeExt::to_value`].
431/// Returns [`ResolveError::AssetParse`] on parse failure.
432pub fn json_parser() -> impl Fn(&Lua, &str) -> Result<Value> + Send + Sync {
433 |lua, content| {
434 let json: serde_json::Value = serde_json::from_str(content).map_err(|e| {
435 mlua::Error::external(ResolveError::AssetParse {
436 source: Box::new(e),
437 })
438 })?;
439 lua.to_value(&json)
440 }
441}
442
443/// Text -> Lua String parser.
444///
445/// Returns the file content as-is as a Lua String.
446/// Use for `.txt`, `.sql`, `.html`, `.css`, etc.
447pub fn text_parser() -> impl Fn(&Lua, &str) -> Result<Value> + Send + Sync {
448 |lua, content| lua.create_string(content).map(Value::String)
449}
450
451impl Resolver for AssetResolver {
452 fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
453 let ext = Path::new(name).extension()?.to_str()?;
454 let parser = self.parsers.get(ext)?;
455
456 let asset_path = Path::new(name);
457 let file = match self.sandbox.read(asset_path) {
458 Ok(Some(file)) => file,
459 Ok(None) => return None,
460 Err(e) => {
461 return Some(Err(mlua::Error::external(read_to_resolve_error(
462 e, name, asset_path,
463 ))));
464 }
465 };
466
467 Some(parser(lua, &file.content))
468 }
469}
470
471// -- PrefixResolver --
472
473/// Combinator that routes to an inner Resolver by name prefix.
474///
475/// Receives `require("{prefix}{sep}{rest}")`, strips the prefix and separator,
476/// and delegates `{rest}` to the inner Resolver.
477/// Returns `None` for names that don't match the prefix.
478///
479/// # Match rules
480///
481/// | Input | prefix="sm", sep='.' | Result |
482/// |-------|---------------------|--------|
483/// | `"sm.helper"` | Strip `"sm."` -> `"helper"` | Delegate to inner Resolver |
484/// | `"sm.ui.btn"` | Strip `"sm."` -> `"ui.btn"` | Delegate to inner Resolver (multi-level) |
485/// | `"sm"` | No separator -> no match | `None` (handled by outer Resolver) |
486/// | `"smtp"` | Does not start with `"sm."` | `None` |
487/// | `"other.x"` | Prefix mismatch | `None` |
488///
489/// # Design intent
490///
491/// `require("sm")` (package root = init.lua) is **outside** PrefixResolver's scope.
492/// The outer [`FsResolver`] handles it via init.lua fallback.
493/// This clearly separates responsibilities:
494///
495/// - **PrefixResolver**: `sm.xxx` -> submodules within the namespace
496/// - **FsResolver**: `sm` -> `sm/init.lua` (package entry point)
497///
498/// # Composition example
499///
500/// ```rust
501/// use mlua_pkg::{Registry, resolvers::*};
502/// use mlua::Lua;
503///
504/// let lua = Lua::new();
505/// let mut reg = Registry::new();
506///
507/// // "game.xxx" -> resolve within game_modules/
508/// reg.add(PrefixResolver::new("game",
509/// MemoryResolver::new()
510/// .add("engine", "return { version = 2 }")
511/// .add("utils", "return { helper = true }")));
512///
513/// // "game" -> init.lua provided directly via MemoryResolver
514/// reg.add(MemoryResolver::new()
515/// .add("game", "return { name = 'game' }"));
516///
517/// reg.install(&lua).unwrap();
518///
519/// // require("game.engine") -> PrefixResolver -> MemoryResolver("engine")
520/// // require("game") -> MemoryResolver("game")
521/// ```
522pub struct PrefixResolver {
523 prefix: String,
524 separator: char,
525 inner: Box<dyn Resolver>,
526}
527
528impl PrefixResolver {
529 /// Build a prefix router with `.` separator.
530 ///
531 /// `require("{prefix}.{rest}")` -> `inner.resolve("{rest}")`
532 pub fn new(prefix: impl Into<String>, inner: impl Resolver + 'static) -> Self {
533 Self {
534 prefix: prefix.into(),
535 separator: crate::LuaConvention::default().module_separator,
536 inner: Box::new(inner),
537 }
538 }
539
540 /// Apply a [`LuaConvention`](crate::LuaConvention) in bulk.
541 pub fn with_convention(mut self, conv: crate::LuaConvention) -> Self {
542 self.separator = conv.module_separator;
543 self
544 }
545
546 /// Change the separator (default: `.`).
547 pub fn with_separator(mut self, separator: char) -> Self {
548 self.separator = separator;
549 self
550 }
551}
552
553impl Resolver for PrefixResolver {
554 fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
555 let mut prefix_with_sep = String::with_capacity(self.prefix.len() + 1);
556 prefix_with_sep.push_str(&self.prefix);
557 prefix_with_sep.push(self.separator);
558
559 let rest = name.strip_prefix(&prefix_with_sep)?;
560 self.inner.resolve(lua, rest)
561 }
562}
563
564// -- VendoredResolver --
565
566/// Resolver that exposes the `.mlua-pkgs/vendored/` directory to `require`.
567///
568/// `VendoredResolver` is a thin wrapper over [`FsResolver`] rooted at the
569/// `vendored_root` directory (typically `.mlua-pkgs/vendored/`).
570///
571/// Each package in that directory is expected to be a symlink (or real
572/// directory) created by the `mlua-pkg install` CLI. For example,
573/// `require("foo")` resolves to `vendored/foo/init.lua`, and
574/// `require("foo.bar")` resolves to `vendored/foo/bar.lua`.
575///
576/// # Responsibilities
577///
578/// - **This resolver reads** — it does not create symlinks or directories.
579/// - **The CLI creates** — `mlua-pkg install` is responsible for populating
580/// `.mlua-pkgs/vendored/` before this resolver is used.
581///
582/// # Construction
583///
584/// | Constructor | Use when |
585/// |------------|----------|
586/// | [`VendoredResolver::from_lockfile`] | Normal usage: lockfile path + vendored root |
587/// | [`VendoredResolver::new`] | Low-level: vendored root already exists and is populated |
588///
589/// # Errors
590///
591/// `new()` returns [`InitError::RootNotFound`] if `vendored_root` does not exist.
592/// `from_lockfile()` returns [`PkgError::MissingLockfile`] if the lockfile is absent,
593/// or [`PkgError::SameNameConflict`] if duplicate package names are found.
594pub struct VendoredResolver {
595 inner: FsResolver,
596 /// Package name → `entry` (relative to the package root) for packages
597 /// whose `require` root is not the root itself. Filled from the
598 /// lockfile by [`from_lock`](Self::from_lock); empty for [`new`](Self::new).
599 entries: HashMap<String, PathBuf>,
600}
601
602// FsResolver wraps a sandbox that is not Debug; implement manually.
603impl std::fmt::Debug for VendoredResolver {
604 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605 f.debug_struct("VendoredResolver").finish_non_exhaustive()
606 }
607}
608
609impl VendoredResolver {
610 /// Low-level constructor: wrap an existing `vendored_root` directory.
611 ///
612 /// Does **not** read a lockfile, so it knows no per-package `entry`:
613 /// every `vendored/<name>` is treated as the `require` root itself.
614 /// `mlua-pkg install` links `vendored/<name>` to the **package root**,
615 /// so packages whose entry is a subdirectory (`src/`, `lua/`, …) only
616 /// resolve through [`from_lockfile`](Self::from_lockfile) /
617 /// [`from_lock`](Self::from_lock).
618 ///
619 /// # Errors
620 ///
621 /// Returns [`InitError::RootNotFound`] if `vendored_root` does not exist.
622 pub fn new(
623 vendored_root: impl Into<PathBuf>,
624 ) -> std::result::Result<Self, crate::sandbox::InitError> {
625 let sandbox = SymlinkAwareSandbox::new(vendored_root)?;
626 let inner = FsResolver::with_sandbox(sandbox);
627 Ok(Self {
628 inner,
629 entries: HashMap::new(),
630 })
631 }
632
633 /// Map `foo.bar` to the module path under the package root:
634 /// `foo.<entry>.bar` when `foo` has a non-trivial entry, `foo.bar`
635 /// otherwise. (Entry components must not contain the module
636 /// separator `.`.)
637 fn rewrite(&self, name: &str) -> Option<String> {
638 let (head, rest) = match name.split_once('.') {
639 Some((h, r)) => (h, Some(r)),
640 None => (name, None),
641 };
642 let entry = self.entries.get(head)?;
643 let entry_dots: Vec<String> = entry
644 .components()
645 .map(|c| c.as_os_str().to_string_lossy().into_owned())
646 .collect();
647 let entry_dots = entry_dots.join(".");
648 Some(match rest {
649 Some(r) => format!("{head}.{entry_dots}.{r}"),
650 None => format!("{head}.{entry_dots}"),
651 })
652 }
653
654 /// Normal constructor: read `lockfile_path` and wrap `vendored_root`.
655 ///
656 /// Reads and validates the lockfile, then constructs an [`FsResolver`]
657 /// rooted at `vendored_root`. For each package in the lockfile, emits a
658 /// `tracing::warn!` if the corresponding `vendored_root/<name>` symlink or
659 /// directory is absent (the CLI has not yet installed that package).
660 /// Resolution will simply return `None` for missing packages at runtime,
661 /// matching normal `FsResolver` miss behaviour.
662 ///
663 /// `vendored_root` is created automatically if it does not exist, to avoid
664 /// requiring a prior `mlua-pkg install` just to construct the resolver.
665 ///
666 /// # Errors
667 ///
668 /// | Error | Condition |
669 /// |-------|-----------|
670 /// | [`PkgError::MissingLockfile`] | `lockfile_path` does not exist |
671 /// | [`PkgError::LockfileParse`] | Invalid TOML in the lockfile |
672 /// | [`PkgError::SameNameConflict`] | Duplicate package names in the lockfile |
673 /// | [`PkgError::Io`] | I/O failure while creating `vendored_root` or reading the lockfile |
674 pub fn from_lockfile(
675 lockfile_path: impl AsRef<Path>,
676 vendored_root: impl AsRef<Path>,
677 ) -> std::result::Result<Self, crate::PkgError> {
678 let lockfile = crate::lockfile::Lockfile::read(lockfile_path)?;
679 Self::from_lock(&lockfile, vendored_root)
680 }
681
682 /// Same as [`from_lockfile`](Self::from_lockfile) but with an in-memory
683 /// [`Lockfile`](crate::lockfile::Lockfile) — for callers that already
684 /// hold the value (e.g. from an
685 /// [`InstallReport`](crate::ops::InstallReport)-driven flow) and do not
686 /// want the resolver to re-read `mlua-pkg.lock`.
687 ///
688 /// # Errors
689 ///
690 /// - [`PkgError::Io`](crate::PkgError::Io) when `vendored_root` cannot
691 /// be created or opened.
692 pub fn from_lock(
693 lockfile: &crate::lockfile::Lockfile,
694 vendored_root: impl AsRef<Path>,
695 ) -> std::result::Result<Self, crate::PkgError> {
696 let vendored_root = vendored_root.as_ref();
697
698 // Auto-create vendored_root if absent — callers should not need to run
699 // `mlua-pkg install` just to get a working resolver skeleton.
700 if !vendored_root.exists() {
701 std::fs::create_dir_all(vendored_root)?;
702 }
703
704 // Warn for each package whose vendored entry is absent.
705 // Broken symlinks are handled via symlink_metadata (does not follow
706 // the target), so even a dangling symlink counts as "present".
707 for pkg in &lockfile.pkg {
708 let entry = vendored_root.join(&pkg.name);
709 if std::fs::symlink_metadata(&entry).is_err() {
710 // No tracing dependency — use eprintln as a lightweight warning.
711 // A future revision may switch to `tracing`; for now this is informational.
712 eprintln!(
713 "mlua-pkg: vendored/{} not found — run `mlua-pkg install`",
714 pkg.name
715 );
716 }
717 }
718
719 // Construct the inner FsResolver backed by SymlinkAwareSandbox so that
720 // directory symlinks under vendored_root (created by `mlua-pkg install`)
721 // are followed and their targets are accessible. If vendored_root was
722 // just created it is empty; that is fine — resolve() will return None
723 // for all names until `mlua-pkg install` populates the symlinks.
724 let sandbox = SymlinkAwareSandbox::new(vendored_root).map_err(|e| {
725 // InitError::RootNotFound after we just created it would be unusual,
726 // but surface it as an Io error to keep PkgError self-contained.
727 crate::PkgError::Io {
728 source: std::io::Error::new(
729 std::io::ErrorKind::NotFound,
730 format!("vendored root init error: {e}"),
731 ),
732 }
733 })?;
734 let inner = FsResolver::with_sandbox(sandbox);
735
736 // `vendored/<name>` is the package root; remember where the
737 // `require` root sits inside it.
738 let entries = lockfile
739 .pkg
740 .iter()
741 .filter(|p| !p.entry.as_os_str().is_empty() && p.entry != Path::new("."))
742 .map(|p| (p.name.clone(), p.entry.clone()))
743 .collect();
744
745 Ok(Self { inner, entries })
746 }
747}
748
749impl Resolver for VendoredResolver {
750 /// Delegate resolution to the inner [`FsResolver`], inserting the
751 /// package's `entry` after its name.
752 ///
753 /// With `entry = "src"` for `foo`: `require("foo")` resolves to
754 /// `vendored/foo/src/init.lua` (or `vendored/foo/src.lua`), and
755 /// `require("foo.bar")` to `vendored/foo/src/bar.lua`. With a trivial
756 /// entry (`"."`) the name is used as is: `vendored/foo/init.lua`,
757 /// `vendored/foo/bar.lua`.
758 fn resolve(&self, lua: &Lua, name: &str) -> Option<mlua::Result<mlua::Value>> {
759 match self.rewrite(name) {
760 Some(rewritten) => self.inner.resolve(lua, &rewritten),
761 None => self.inner.resolve(lua, name),
762 }
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769 use crate::sandbox::{FileContent, ReadError};
770
771 /// Asserts that `resolve()` returns `Some(Ok(value))` and returns the value.
772 fn must_resolve(resolver: &dyn Resolver, lua: &Lua, name: &str) -> Value {
773 match resolver.resolve(lua, name) {
774 Some(Ok(v)) => v,
775 Some(Err(e)) => panic!("resolve('{name}') returned Err: {e}"),
776 None => panic!("resolve('{name}') returned None"),
777 }
778 }
779
780 /// Asserts that `resolve()` returns `Some(Err(_))` and returns the error message.
781 fn must_resolve_err(resolver: &dyn Resolver, lua: &Lua, name: &str) -> String {
782 match resolver.resolve(lua, name) {
783 Some(Err(e)) => e.to_string(),
784 Some(Ok(_)) => panic!("resolve('{name}') returned Ok, expected Err"),
785 None => panic!("resolve('{name}') returned None, expected Some(Err)"),
786 }
787 }
788
789 /// Extracts a table field from a Value.
790 fn get_field<V: mlua::FromLua>(value: &Value, key: impl mlua::IntoLua) -> V {
791 value
792 .as_table()
793 .expect("expected Table value")
794 .get::<V>(key)
795 .expect("table field access failed")
796 }
797
798 /// Mock sandbox for I/O-free testing.
799 struct MockSandbox {
800 files: HashMap<PathBuf, String>,
801 }
802
803 impl MockSandbox {
804 fn new() -> Self {
805 Self {
806 files: HashMap::new(),
807 }
808 }
809
810 fn with_file(mut self, path: impl Into<PathBuf>, content: &str) -> Self {
811 self.files.insert(path.into(), content.to_owned());
812 self
813 }
814 }
815
816 impl SandboxedFs for MockSandbox {
817 fn read(&self, relative: &Path) -> std::result::Result<Option<FileContent>, ReadError> {
818 match self.files.get(relative) {
819 Some(content) => Ok(Some(FileContent {
820 content: content.clone(),
821 resolved_path: relative.to_path_buf(),
822 })),
823 None => Ok(None),
824 }
825 }
826 }
827
828 #[test]
829 fn fs_resolver_dot_to_path_conversion() {
830 let mock = MockSandbox::new().with_file("lib/helper.lua", "return { name = 'mocked' }");
831 let resolver = FsResolver::with_sandbox(mock);
832
833 let lua = mlua::Lua::new();
834 let value = must_resolve(&resolver, &lua, "lib.helper");
835 assert_eq!(get_field::<String>(&value, "name"), "mocked");
836 }
837
838 #[test]
839 fn fs_resolver_init_lua_fallback() {
840 let mock = MockSandbox::new().with_file("mypkg/init.lua", "return { from_init = true }");
841 let resolver = FsResolver::with_sandbox(mock);
842
843 let lua = mlua::Lua::new();
844 let value = must_resolve(&resolver, &lua, "mypkg");
845 assert!(get_field::<bool>(&value, "from_init"));
846 }
847
848 #[test]
849 fn fs_resolver_miss_returns_none() {
850 let mock = MockSandbox::new();
851 let resolver = FsResolver::with_sandbox(mock);
852
853 let lua = mlua::Lua::new();
854 assert!(resolver.resolve(&lua, "nonexistent").is_none());
855 }
856
857 #[test]
858 fn fs_resolver_custom_extension() {
859 let mock = MockSandbox::new().with_file("lib/helper.luau", "return { name = 'luau_mod' }");
860 let resolver = FsResolver::with_sandbox(mock).with_extension("luau");
861
862 let lua = mlua::Lua::new();
863 let value = must_resolve(&resolver, &lua, "lib.helper");
864 assert_eq!(get_field::<String>(&value, "name"), "luau_mod");
865 }
866
867 #[test]
868 fn fs_resolver_custom_init_name() {
869 let mock = MockSandbox::new().with_file("mypkg/mod.lua", "return { from_mod = true }");
870 let resolver = FsResolver::with_sandbox(mock).with_init_name("mod");
871
872 let lua = mlua::Lua::new();
873 let value = must_resolve(&resolver, &lua, "mypkg");
874 assert!(get_field::<bool>(&value, "from_mod"));
875 }
876
877 #[test]
878 fn fs_resolver_custom_extension_ignores_default() {
879 // .lua is not resolved when .luau is configured
880 let mock = MockSandbox::new().with_file("helper.lua", "return 'wrong'");
881 let resolver = FsResolver::with_sandbox(mock).with_extension("luau");
882
883 let lua = mlua::Lua::new();
884 assert!(resolver.resolve(&lua, "helper").is_none());
885 }
886
887 #[test]
888 fn fs_resolver_with_convention_luau() {
889 let mock = MockSandbox::new()
890 .with_file("lib/helper.luau", "return { name = 'luau' }")
891 .with_file("pkg/init.luau", "return { pkg = true }");
892 let resolver = FsResolver::with_sandbox(mock).with_convention(crate::LuaConvention::LUAU);
893
894 let lua = mlua::Lua::new();
895
896 let value = must_resolve(&resolver, &lua, "lib.helper");
897 assert_eq!(get_field::<String>(&value, "name"), "luau");
898
899 let value = must_resolve(&resolver, &lua, "pkg");
900 assert!(get_field::<bool>(&value, "pkg"));
901 }
902
903 #[test]
904 fn convention_then_override() {
905 // Partial override via individual method after with_convention
906 let mock = MockSandbox::new().with_file("pkg/mod.luau", "return { ok = true }");
907 let resolver = FsResolver::with_sandbox(mock)
908 .with_convention(crate::LuaConvention::LUAU)
909 .with_init_name("mod");
910
911 let lua = mlua::Lua::new();
912 let value = must_resolve(&resolver, &lua, "pkg");
913 assert!(get_field::<bool>(&value, "ok"));
914 }
915
916 #[test]
917 fn lua_convention_default_is_lua54() {
918 assert_eq!(crate::LuaConvention::default(), crate::LuaConvention::LUA54);
919 }
920
921 #[test]
922 fn asset_resolver_json_to_table() {
923 let mock = MockSandbox::new().with_file("config.json", r#"{"port": 8080}"#);
924 let resolver = AssetResolver::with_sandbox(mock).parser("json", json_parser());
925
926 let lua = mlua::Lua::new();
927 let value = must_resolve(&resolver, &lua, "config.json");
928 assert_eq!(get_field::<i32>(&value, "port"), 8080);
929 }
930
931 #[test]
932 fn asset_resolver_text_to_string() {
933 let mock = MockSandbox::new().with_file("query.sql", "SELECT 1");
934 let resolver = AssetResolver::with_sandbox(mock).parser("sql", text_parser());
935
936 let lua = mlua::Lua::new();
937 let value = must_resolve(&resolver, &lua, "query.sql");
938 let s: String = lua.unpack(value).expect("unpack String failed");
939 assert_eq!(s, "SELECT 1");
940 }
941
942 #[test]
943 fn asset_resolver_unregistered_ext_returns_none() {
944 let mock = MockSandbox::new().with_file("data.xyz", "stuff");
945 let resolver = AssetResolver::with_sandbox(mock).parser("json", json_parser());
946
947 let lua = mlua::Lua::new();
948 assert!(resolver.resolve(&lua, "data.xyz").is_none());
949 }
950
951 #[test]
952 fn asset_resolver_no_ext_returns_none() {
953 let mock = MockSandbox::new();
954 let resolver = AssetResolver::with_sandbox(mock);
955
956 let lua = mlua::Lua::new();
957 assert!(resolver.resolve(&lua, "noext").is_none());
958 }
959
960 #[test]
961 fn asset_resolver_custom_parser() {
962 let mock = MockSandbox::new().with_file("data.csv", "a,b,c");
963 let resolver = AssetResolver::with_sandbox(mock).parser("csv", |lua, content| {
964 let t = lua.create_table()?;
965 for (i, field) in content.split(',').enumerate() {
966 t.set(i + 1, lua.create_string(field)?)?;
967 }
968 Ok(Value::Table(t))
969 });
970
971 let lua = mlua::Lua::new();
972 let value = must_resolve(&resolver, &lua, "data.csv");
973 assert_eq!(get_field::<String>(&value, 1), "a");
974 }
975
976 // -- I/O error propagation tests --
977
978 /// Mock sandbox that returns I/O errors for all reads.
979 struct IoErrorSandbox {
980 kind: std::io::ErrorKind,
981 }
982
983 impl SandboxedFs for IoErrorSandbox {
984 fn read(&self, relative: &Path) -> std::result::Result<Option<FileContent>, ReadError> {
985 Err(ReadError::Io {
986 path: relative.to_path_buf(),
987 source: std::io::Error::new(self.kind, "mock I/O error"),
988 })
989 }
990 }
991
992 #[test]
993 fn fs_resolver_propagates_io_error() {
994 let resolver = FsResolver::with_sandbox(IoErrorSandbox {
995 kind: std::io::ErrorKind::PermissionDenied,
996 });
997
998 let lua = mlua::Lua::new();
999 let msg = must_resolve_err(&resolver, &lua, "anything");
1000 assert!(
1001 msg.contains("I/O error"),
1002 "expected ResolveError::Io message: {msg}"
1003 );
1004 }
1005
1006 #[test]
1007 fn asset_resolver_propagates_io_error() {
1008 let resolver = AssetResolver::with_sandbox(IoErrorSandbox {
1009 kind: std::io::ErrorKind::PermissionDenied,
1010 })
1011 .parser("json", json_parser());
1012
1013 let lua = mlua::Lua::new();
1014 let msg = must_resolve_err(&resolver, &lua, "data.json");
1015 assert!(
1016 msg.contains("I/O error"),
1017 "expected ResolveError::Io message: {msg}"
1018 );
1019 }
1020
1021 // -- PrefixResolver tests --
1022
1023 #[test]
1024 fn prefix_strips_and_delegates() {
1025 let inner = MemoryResolver::new().add("helper", "return 'from helper'");
1026 let resolver = PrefixResolver::new("sm", inner);
1027
1028 let lua = mlua::Lua::new();
1029 let value = must_resolve(&resolver, &lua, "sm.helper");
1030 let s: String = lua.unpack(value).expect("unpack String failed");
1031 assert_eq!(s, "from helper");
1032 }
1033
1034 #[test]
1035 fn prefix_non_matching_returns_none() {
1036 let inner = MemoryResolver::new().add("helper", "return 'x'");
1037 let resolver = PrefixResolver::new("sm", inner);
1038
1039 let lua = mlua::Lua::new();
1040 assert!(resolver.resolve(&lua, "other.helper").is_none());
1041 }
1042
1043 #[test]
1044 fn prefix_exact_match_without_separator_returns_none() {
1045 let inner = MemoryResolver::new().add("helper", "return 'x'");
1046 let resolver = PrefixResolver::new("sm", inner);
1047
1048 let lua = mlua::Lua::new();
1049 // "sm" alone is outside PrefixResolver's scope (handled by outer Resolver)
1050 assert!(resolver.resolve(&lua, "sm").is_none());
1051 }
1052
1053 #[test]
1054 fn prefix_no_substring_match() {
1055 let inner = MemoryResolver::new().add("tp", "return 'x'");
1056 let resolver = PrefixResolver::new("sm", inner);
1057
1058 let lua = mlua::Lua::new();
1059 // "smtp" is not "sm" + "." + "tp"
1060 assert!(resolver.resolve(&lua, "smtp").is_none());
1061 }
1062
1063 #[test]
1064 fn prefix_custom_separator() {
1065 let inner = MemoryResolver::new().add("http", "return 'http mod'");
1066 let resolver = PrefixResolver::new("@std", inner).with_separator('/');
1067
1068 let lua = mlua::Lua::new();
1069 let value = must_resolve(&resolver, &lua, "@std/http");
1070 let s: String = lua.unpack(value).expect("unpack String failed");
1071 assert_eq!(s, "http mod");
1072 }
1073
1074 #[test]
1075 fn prefix_nested_name() {
1076 let mock = MockSandbox::new().with_file("ui/button.lua", "return { name = 'button' }");
1077 let resolver = PrefixResolver::new("game", FsResolver::with_sandbox(mock));
1078
1079 let lua = mlua::Lua::new();
1080 // "game.ui.button" -> strip "game." -> "ui.button" -> FsResolver: ui/button.lua
1081 let value = must_resolve(&resolver, &lua, "game.ui.button");
1082 assert_eq!(get_field::<String>(&value, "name"), "button");
1083 }
1084
1085 #[test]
1086 fn prefix_inner_miss_returns_none() {
1087 let inner = MemoryResolver::new().add("helper", "return 'x'");
1088 let resolver = PrefixResolver::new("sm", inner);
1089
1090 let lua = mlua::Lua::new();
1091 // "sm.nonexistent" -> strip -> "nonexistent" -> inner returns None -> None
1092 assert!(resolver.resolve(&lua, "sm.nonexistent").is_none());
1093 }
1094
1095 // -- VendoredResolver tests --
1096
1097 /// Write a one-pkg lockfile TOML to `dir/mlua-pkg.lock` and return the path.
1098 fn write_vendored_lockfile(dir: &Path, pkg_name: &str, entry: &str) -> PathBuf {
1099 let content = format!(
1100 "version = 1\n\n[[pkg]]\nname = {pkg_name:?}\nsource = \"git+https://github.com/x/{pkg_name}\"\nsha = \"{sha}\"\nentry = {entry:?}\n",
1101 sha = "a".repeat(40),
1102 );
1103 let path = dir.join("mlua-pkg.lock");
1104 std::fs::write(&path, content).unwrap();
1105 path
1106 }
1107
1108 // TC 1: lockfile not found → MissingLockfile
1109 #[test]
1110 fn vendored_from_lockfile_missing_returns_error() {
1111 let tmp = tempfile::tempdir().unwrap();
1112 let lockfile = tmp.path().join("nonexistent.lock");
1113 let vendored = tmp.path().join("vendored");
1114
1115 let err = VendoredResolver::from_lockfile(&lockfile, &vendored).unwrap_err();
1116 assert!(
1117 matches!(err, crate::PkgError::MissingLockfile { .. }),
1118 "expected MissingLockfile, got: {err}"
1119 );
1120 }
1121
1122 // TC 2: lockfile 1 pkg + vendored/foo (dir with init.lua) → require("foo") resolves
1123 #[test]
1124 fn vendored_resolver_single_pkg_init_lua() {
1125 let tmp = tempfile::tempdir().unwrap();
1126 let vendored = tmp.path().join("vendored");
1127 let lockfile = write_vendored_lockfile(tmp.path(), "foo", ".");
1128
1129 // Simulate `mlua-pkg install`: create vendored/foo/ with init.lua
1130 let foo_dir = vendored.join("foo");
1131 std::fs::create_dir_all(&foo_dir).unwrap();
1132 std::fs::write(foo_dir.join("init.lua"), "return { pkg = 'foo' }").unwrap();
1133
1134 let resolver = VendoredResolver::from_lockfile(&lockfile, &vendored).unwrap();
1135 let lua = mlua::Lua::new();
1136
1137 let value = must_resolve(&resolver, &lua, "foo");
1138 assert_eq!(get_field::<String>(&value, "pkg"), "foo");
1139 }
1140
1141 // TC 2b: lockfile entry "src" → vendored/foo is the package root and
1142 // require("foo") / require("foo.bar") go through foo/src/.
1143 #[test]
1144 fn vendored_resolver_inserts_lockfile_entry_after_package_name() {
1145 let tmp = tempfile::tempdir().unwrap();
1146 let vendored = tmp.path().join("vendored");
1147 let lockfile = write_vendored_lockfile(tmp.path(), "foo", "src");
1148
1149 let src = vendored.join("foo").join("src");
1150 std::fs::create_dir_all(&src).unwrap();
1151 std::fs::write(src.join("init.lua"), "return { pkg = 'foo-src' }").unwrap();
1152 std::fs::write(src.join("bar.lua"), "return { sub = 'bar-src' }").unwrap();
1153 // A sibling of the entry (types/) must not be reachable as a module.
1154 std::fs::create_dir_all(vendored.join("foo").join("types")).unwrap();
1155 std::fs::write(
1156 vendored.join("foo/types/init.lua"),
1157 "return { pkg = 'types' }",
1158 )
1159 .unwrap();
1160
1161 let resolver = VendoredResolver::from_lockfile(&lockfile, &vendored).unwrap();
1162 let lua = mlua::Lua::new();
1163
1164 let value = must_resolve(&resolver, &lua, "foo");
1165 assert_eq!(get_field::<String>(&value, "pkg"), "foo-src");
1166 let value = must_resolve(&resolver, &lua, "foo.bar");
1167 assert_eq!(get_field::<String>(&value, "sub"), "bar-src");
1168 assert!(
1169 resolver.resolve(&lua, "foo.types").is_none(),
1170 "entry siblings are outside the require root"
1171 );
1172 }
1173
1174 // TC 3: require("foo.bar") → vendored/foo/bar.lua (FsResolver dot-to-path)
1175 #[test]
1176 fn vendored_resolver_dot_to_path_sub_module() {
1177 let tmp = tempfile::tempdir().unwrap();
1178 let vendored = tmp.path().join("vendored");
1179 let lockfile = write_vendored_lockfile(tmp.path(), "foo", ".");
1180
1181 let foo_dir = vendored.join("foo");
1182 std::fs::create_dir_all(&foo_dir).unwrap();
1183 std::fs::write(foo_dir.join("bar.lua"), "return { sub = 'bar' }").unwrap();
1184
1185 let resolver = VendoredResolver::from_lockfile(&lockfile, &vendored).unwrap();
1186 let lua = mlua::Lua::new();
1187
1188 // "foo.bar" → FsResolver: dot-separator → foo/bar.lua
1189 let value = must_resolve(&resolver, &lua, "foo.bar");
1190 assert_eq!(get_field::<String>(&value, "sub"), "bar");
1191 }
1192
1193 // TC 4: VendoredResolver::new low-level constructor works with existing dir
1194 #[test]
1195 fn vendored_new_with_existing_dir() {
1196 let tmp = tempfile::tempdir().unwrap();
1197 let vendored = tmp.path().join("vendored");
1198 std::fs::create_dir_all(&vendored).unwrap();
1199
1200 // Write a pkg file directly into vendored root
1201 std::fs::write(vendored.join("mypkg.lua"), "return 'direct'").unwrap();
1202
1203 let resolver = VendoredResolver::new(&vendored).unwrap();
1204 let lua = mlua::Lua::new();
1205
1206 let value = must_resolve(&resolver, &lua, "mypkg");
1207 let s: String = lua.unpack(value).unwrap();
1208 assert_eq!(s, "direct");
1209 }
1210
1211 // TC 5: VendoredResolver is Send + Sync (compile-time check)
1212 #[test]
1213 fn vendored_resolver_is_send_sync() {
1214 fn assert_send_sync<T: Send + Sync>() {}
1215 assert_send_sync::<VendoredResolver>();
1216 }
1217}