Skip to main content

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}