Skip to main content

htl_core/
pkg.rs

1//! mlua-pkg integration: a [`TealResolver`] that serves `.tl` modules through
2//! mlua-pkg's `Registry`, so Teal sources sit in the same resolution chain as
3//! Rust-native modules, embedded Lua, vendored git deps and assets.
4//!
5//! ```text
6//! require("name")
7//!   Registry
8//!    ├─ NativeResolver   host_module userdata / Rust tables
9//!    ├─ TealResolver     name -> name.tl | name/init.tl  (check + gen + load)
10//!    │                   name -> name.d.tl              (type-only: empty table)
11//!    ├─ VendoredResolver mlua-pkg.toml git deps
12//!    └─ FsResolver       plain .lua
13//! ```
14//!
15//! The resolver must run on a `Lua` that an [`Htl`](crate::Htl) was attached to
16//! (`Htl::new` / `Htl::from_lua`); it finds the compiler through the Lua registry.
17//! Type errors are returned as `Some(Err)` so, per mlua-pkg's contract, a broken
18//! `.tl` never silently falls through to a later resolver.
19
20use crate::PRELUDE_REGISTRY_KEY;
21use mlua::{Function, Lua, Table, Value};
22use mlua_pkg::Resolver;
23use mlua_pkg::sandbox::{FsSandbox, InitError, ReadError, SandboxedFs, SymlinkAwareSandbox};
24use std::path::{Path, PathBuf};
25use std::sync::atomic::{AtomicBool, Ordering};
26
27pub use mlua_pkg;
28
29/// Resolves `require("a.b")` to `a/b.tl`, `a/b/init.tl`, or `a/b.d.tl` under a
30/// sandboxed root, type-checking and generating on the fly.
31pub struct TealResolver {
32    sandbox: Box<dyn SandboxedFs>,
33    root: Option<PathBuf>,
34    path_added: AtomicBool,
35    module_separator: char,
36}
37
38impl TealResolver {
39    /// Strict sandbox (no symlinks out of `root`).
40    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InitError> {
41        let root = root.into();
42        Ok(Self {
43            sandbox: Box::new(FsSandbox::new(&root)?),
44            root: Some(root),
45            path_added: AtomicBool::new(false),
46            module_separator: '.',
47        })
48    }
49
50    /// Sandbox that follows symlinks directly under `root` (linked package roots).
51    pub fn new_symlink_aware(root: impl Into<PathBuf>) -> Result<Self, InitError> {
52        let root = root.into();
53        Ok(Self {
54            sandbox: Box::new(SymlinkAwareSandbox::new(&root)?),
55            root: Some(root),
56            path_added: AtomicBool::new(false),
57            module_separator: '.',
58        })
59    }
60
61    /// Custom sandbox. Pass `root` so the Teal checker can also see the tree when
62    /// resolving `require`s inside `.tl` files (it searches `package.path`).
63    pub fn with_sandbox(sandbox: impl SandboxedFs + 'static, root: Option<PathBuf>) -> Self {
64        Self {
65            sandbox: Box::new(sandbox),
66            root,
67            path_added: AtomicBool::new(false),
68            module_separator: '.',
69        }
70    }
71
72    pub fn with_module_separator(mut self, sep: char) -> Self {
73        self.module_separator = sep;
74        self
75    }
76
77    fn prelude(lua: &Lua) -> mlua::Result<Table> {
78        lua.named_registry_value::<Table>(PRELUDE_REGISTRY_KEY)
79            .map_err(|_| mlua::Error::external(
80                "htl::pkg::TealResolver: this Lua has no htl prelude (create it with Htl::new / Htl::from_lua)",
81            ))
82    }
83
84    /// The checker resolves `require`s inside `.tl` via `package.path`; make sure the
85    /// root is visible there (once).
86    fn ensure_checker_path(&self, lua: &Lua, h: &Table) -> mlua::Result<()> {
87        if self.path_added.swap(true, Ordering::Relaxed) {
88            return Ok(());
89        }
90        if let Some(root) = &self.root {
91            let f: Function = h.get("add_path")?;
92            f.call::<()>(root.to_string_lossy().as_ref())?;
93        }
94        let _ = lua;
95        Ok(())
96    }
97
98    fn has_lua_sibling(&self, relative: &str) -> bool {
99        for cand in [format!("{relative}.lua"), format!("{relative}/init.lua")] {
100            if let Ok(Some(_)) = self.sandbox.read(Path::new(&cand)) {
101                return true;
102            }
103        }
104        false
105    }
106
107    fn load_teal(&self, lua: &Lua, h: &Table, src: &str, resolved: &Path, name: &str) -> mlua::Result<Value> {
108        let gen_fn: Function = h.get("gen_string")?;
109        let (code, info): (Option<String>, Table) = gen_fn.call((src, resolved.to_string_lossy().as_ref()))?;
110        let Some(code) = code else {
111            let errors: Table = info.get("errors")?;
112            let msgs: Vec<String> = errors.sequence_values::<String>().collect::<mlua::Result<_>>()?;
113            return Err(mlua::Error::external(TealResolveError::TypeCheck {
114                module: name.to_string(),
115                errors: msgs,
116            }));
117        };
118        let chunk = lua
119            .load(code)
120            .set_name(format!("@{}", resolved.display()))
121            .into_function()?;
122        chunk.call::<Value>((name, resolved.to_string_lossy().as_ref()))
123    }
124}
125
126// ---------------------------------------------------------------- Project (mlua-pkg.toml)
127
128/// An `mlua-pkg.toml` project: where the manifest, lockfile and vendored deps live.
129///
130/// The pkgs dir follows mlua-pkg's own rule, evaluated against the manifest's
131/// directory: `MLUA_PKG_DIR` env > `<root>/target/mlua-pkgs` when `<root>/target`
132/// exists > `<root>/.mlua-pkgs`.
133#[derive(Debug, Clone)]
134pub struct Project {
135    pub root: PathBuf,
136    pub manifest: PathBuf,
137    pub lockfile: PathBuf,
138    pub pkgs_dir: PathBuf,
139    pub vendored: PathBuf,
140}
141
142pub const MANIFEST_NAME: &str = "mlua-pkg.toml";
143pub const LOCKFILE_NAME: &str = "mlua-pkg.lock";
144
145impl Project {
146    /// Walk up from `start` (a file or directory) looking for `mlua-pkg.toml`.
147    pub fn find(start: &Path) -> Option<Self> {
148        let mut dir = if start.is_dir() { start.to_path_buf() } else { crate::parent_dir(start) };
149        if let Ok(abs) = std::fs::canonicalize(&dir) {
150            dir = abs;
151        }
152        loop {
153            let manifest = dir.join(MANIFEST_NAME);
154            if manifest.is_file() {
155                return Some(Self::at(&dir));
156            }
157            if !dir.pop() {
158                return None;
159            }
160        }
161    }
162
163    /// Project rooted at `root` (must contain `mlua-pkg.toml`; not checked here).
164    pub fn at(root: &Path) -> Self {
165        let pkgs_dir = match std::env::var("MLUA_PKG_DIR") {
166            Ok(p) if !p.is_empty() => PathBuf::from(p),
167            _ if root.join("target").is_dir() => root.join("target").join("mlua-pkgs"),
168            _ => root.join(".mlua-pkgs"),
169        };
170        Self {
171            root: root.to_path_buf(),
172            manifest: root.join(MANIFEST_NAME),
173            lockfile: root.join(LOCKFILE_NAME),
174            vendored: pkgs_dir.join("vendored"),
175            pkgs_dir,
176        }
177    }
178
179    /// `true` once `mlua-pkg install` has produced the lockfile.
180    pub fn installed(&self) -> bool {
181        self.lockfile.is_file()
182    }
183
184    /// Resolver for `.tl` / `.d.tl` inside vendored deps (symlink-aware, like
185    /// `VendoredResolver`). Creates the vendored dir if it does not exist yet.
186    pub fn teal_resolver(&self) -> Result<TealResolver, InitError> {
187        let _ = std::fs::create_dir_all(&self.vendored);
188        TealResolver::new_symlink_aware(&self.vendored)
189    }
190
191    /// mlua-pkg's own resolver for plain `.lua` inside vendored deps.
192    pub fn vendored_resolver(&self) -> anyhow::Result<mlua_pkg::resolvers::VendoredResolver> {
193        if self.installed() {
194            Ok(mlua_pkg::resolvers::VendoredResolver::from_lockfile(&self.lockfile, &self.vendored)?)
195        } else {
196            let _ = std::fs::create_dir_all(&self.vendored);
197            Ok(mlua_pkg::resolvers::VendoredResolver::new(&self.vendored)?)
198        }
199    }
200
201    /// Registry with the project's deps: Teal first, then plain Lua. Add your
202    /// `NativeResolver`s *before* calling `install` if Teal code declares them in `.d.tl`.
203    pub fn registry(&self) -> anyhow::Result<mlua_pkg::Registry> {
204        let mut reg = mlua_pkg::Registry::new();
205        reg.add(self.teal_resolver()?);
206        reg.add(self.vendored_resolver()?);
207        Ok(reg)
208    }
209}
210
211impl crate::Htl {
212    /// Make the project's vendored deps visible to the Teal checker and to the
213    /// prelude's strict searcher (`htl run` / `htl test` without a Registry).
214    pub fn apply_project(&self, p: &Project) -> anyhow::Result<()> {
215        let _ = std::fs::create_dir_all(&p.vendored);
216        self.add_path(&p.vendored)?;
217        Ok(())
218    }
219}
220
221/// Error raised when a `.tl` module fails the type check at `require` time.
222#[derive(Debug)]
223pub enum TealResolveError {
224    TypeCheck { module: String, errors: Vec<String> },
225    Read { module: String, source: ReadError },
226}
227
228impl std::fmt::Display for TealResolveError {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        match self {
231            Self::TypeCheck { module, errors } => {
232                write!(f, "Teal type check failed for module '{module}':")?;
233                for e in errors {
234                    write!(f, "\n  {e}")?;
235                }
236                Ok(())
237            }
238            Self::Read { module, source } => write!(f, "reading module '{module}': {source}"),
239        }
240    }
241}
242
243impl std::error::Error for TealResolveError {}
244
245impl Resolver for TealResolver {
246    fn resolve(&self, lua: &Lua, name: &str) -> Option<mlua::Result<Value>> {
247        let relative = name.replace(self.module_separator, "/");
248        let candidates = [
249            (format!("{relative}.tl"), false),
250            (format!("{relative}/init.tl"), false),
251            (format!("{relative}.d.tl"), true),
252        ];
253        let h = match Self::prelude(lua) {
254            Ok(h) => h,
255            Err(e) => return Some(Err(e)),
256        };
257        if let Err(e) = self.ensure_checker_path(lua, &h) {
258            return Some(Err(e));
259        }
260        for (candidate, type_only) in &candidates {
261            match self.sandbox.read(Path::new(candidate)) {
262                Ok(Some(file)) => {
263                    if *type_only {
264                        // A `.d.tl` may describe a plain `.lua` served by a later resolver
265                        // (FsResolver / VendoredResolver): step aside if one is present.
266                        // Native modules must be registered *before* this resolver.
267                        if self.has_lua_sibling(&relative) {
268                            return None;
269                        }
270                        // Declaration-only module: nothing to run, give require a table.
271                        return Some(lua.create_table().map(Value::Table));
272                    }
273                    return Some(self.load_teal(lua, &h, &file.content, &file.resolved_path, name));
274                }
275                Ok(None) => continue,
276                Err(source) => {
277                    return Some(Err(mlua::Error::external(TealResolveError::Read {
278                        module: name.to_string(),
279                        source,
280                    })));
281                }
282            }
283        }
284        None
285    }
286}