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    /// `"defs.Mod"`: every module served by this resolver must be assignable to that type.
37    expect_type: Option<String>,
38    /// With `expect_type`: every declared field of the record must be non-nil at run time.
39    require_fields: bool,
40    /// Extra dirs the checker may search for `require`s (e.g. where `defs.tl` lives).
41    checker_paths: Vec<PathBuf>,
42    /// Module names served here that `expect_type` / `require_fields` skip.
43    exclude: Vec<String>,
44    /// When set, `expect_type` / `require_fields` apply to this module name only.
45    only_module: Option<String>,
46}
47
48impl TealResolver {
49    /// Strict sandbox (no symlinks out of `root`).
50    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InitError> {
51        let root = root.into();
52        Ok(Self {
53            sandbox: Box::new(FsSandbox::new(&root)?),
54            root: Some(root),
55            path_added: AtomicBool::new(false),
56            module_separator: '.',
57            expect_type: None,
58            require_fields: false,
59            checker_paths: Vec::new(),
60            exclude: Vec::new(),
61            only_module: None,
62        })
63    }
64
65    /// Sandbox that follows symlinks directly under `root` (linked package roots).
66    pub fn new_symlink_aware(root: impl Into<PathBuf>) -> Result<Self, InitError> {
67        let root = root.into();
68        Ok(Self {
69            sandbox: Box::new(SymlinkAwareSandbox::new(&root)?),
70            root: Some(root),
71            path_added: AtomicBool::new(false),
72            module_separator: '.',
73            expect_type: None,
74            require_fields: false,
75            checker_paths: Vec::new(),
76            exclude: Vec::new(),
77            only_module: None,
78        })
79    }
80
81    /// Custom sandbox. Pass `root` so the Teal checker can also see the tree when
82    /// resolving `require`s inside `.tl` files (it searches `package.path`).
83    pub fn with_sandbox(sandbox: impl SandboxedFs + 'static, root: Option<PathBuf>) -> Self {
84        Self {
85            sandbox: Box::new(sandbox),
86            root,
87            path_added: AtomicBool::new(false),
88            module_separator: '.',
89            expect_type: None,
90            require_fields: false,
91            checker_paths: Vec::new(),
92            exclude: Vec::new(),
93            only_module: None,
94        }
95    }
96
97    pub fn with_module_separator(mut self, sep: char) -> Self {
98        self.module_separator = sep;
99        self
100    }
101
102    /// Require every `.tl` module served here to be assignable to `type_path`, written
103    /// as `"<module>.<Type>"` (e.g. `"defs.Mod"`, where `defs.tl` / `defs.d.tl` declares
104    /// `Mod`). A module that does not satisfy it fails at `require` time even if it never
105    /// annotates its own return value.
106    ///
107    /// What this catches is what Teal's record assignability catches: a field of the
108    /// **wrong type** (`hp = "lots"` for `hp: integer`). On its own it does **not** catch
109    /// a **missing** field: every Teal record field is nilable, so `{ name = "x" }`
110    /// satisfies `Mod` with `monsters` absent. Add [`require_fields`](Self::require_fields)
111    /// to reject that at run time, or nil-guard optional data on the host side.
112    pub fn expect_type(mut self, type_path: impl Into<String>) -> Self {
113        self.expect_type = Some(type_path.into());
114        self
115    }
116
117    /// With [`expect_type`](Self::expect_type): after the type check, every field the
118    /// record declares must be present (non-nil) in the loaded module, or the `require`
119    /// fails naming the missing fields. Use for contracts where every field is mandatory;
120    /// contracts with optional fields should keep the default and nil-guard instead.
121    pub fn require_fields(mut self) -> Self {
122        self.require_fields = true;
123        self
124    }
125
126    /// Let the Teal checker also search `dir` when resolving `require`s inside served
127    /// modules (and the module named by `expect_type`). The sandbox root is always
128    /// searched; add the project `src/` here when `defs.tl` lives there.
129    pub fn with_checker_path(mut self, dir: impl Into<PathBuf>) -> Self {
130        self.checker_paths.push(dir.into());
131        self
132    }
133
134    /// Modules (by `require` name) served here that are *not* held to `expect_type` /
135    /// `require_fields`: an SDK the host writes into the same dir, for instance. The
136    /// module that declares the expected type is always exempt.
137    pub fn exclude_modules(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
138        self.exclude.extend(names.into_iter().map(Into::into));
139        self
140    }
141
142    /// Hold only this module name to `expect_type` / `require_fields`; everything else
143    /// served here is type-checked as usual but not against the contract.
144    pub fn only_module(mut self, name: impl Into<String>) -> Self {
145        self.only_module = Some(name.into());
146        self
147    }
148
149    /// Does the contract (`expect_type` / `require_fields`) apply to `name`?
150    fn held(&self, name: &str) -> bool {
151        if self.expect_type.is_none() || self.exclude.iter().any(|e| e == name) {
152            return false;
153        }
154        self.only_module.as_deref().is_none_or(|m| m == name)
155    }
156
157    /// Resolvers for one `[[contract]]` of `htl.toml`: one per concrete contract dir
158    /// (a `dir` with `*` expands to every subdirectory), each with `expect_type(type)`
159    /// (and `require_fields()` when set), the contract's `exclude` / `module`, and
160    /// `root` + `root/src` visible to the checker. `root` is the directory holding
161    /// `htl.toml`. The `contract-unenforced` lint of `htl check` recognises this call.
162    pub fn for_contract(root: &Path, c: &crate::config::Contract) -> Result<Vec<Self>, InitError> {
163        c.dirs(root).into_iter().map(|d| Self::for_contract_dir(root, &d, c)).collect()
164    }
165
166    /// One resolver for the concrete contract directory `dir` (see [`for_contract`](Self::for_contract)).
167    pub fn for_contract_dir(root: &Path, dir: &Path, c: &crate::config::Contract) -> Result<Self, InitError> {
168        let mut r = Self::new_symlink_aware(dir)?
169            .expect_type(c.type_path.clone())
170            .exclude_modules(c.exclude.iter().cloned())
171            .with_checker_path(root)
172            .with_checker_path(root.join("src"));
173        if let Some(m) = &c.module {
174            r = r.only_module(m.clone());
175        }
176        if c.require_fields {
177            r = r.require_fields();
178        }
179        Ok(r)
180    }
181
182    /// Declared fields of the expected record that are nil in `value`.
183    fn missing_fields(&self, h: &Table, value: &Value) -> mlua::Result<Vec<String>> {
184        let (Some(tp), true) = (&self.expect_type, self.require_fields) else { return Ok(Vec::new()) };
185        let f: Function = h.get("record_fields")?;
186        let names: Option<Vec<String>> = f
187            .call::<Option<Table>>(tp.as_str())?
188            .map(|t| t.sequence_values::<String>().collect::<mlua::Result<_>>())
189            .transpose()?;
190        let Some(names) = names else {
191            return Err(mlua::Error::external(format!(
192                "TealResolver::require_fields: record type {tp:?} not found by the checker"
193            )));
194        };
195        let Value::Table(t) = value else {
196            return Ok(names); // not a table at all: everything is missing
197        };
198        let mut missing = Vec::new();
199        for n in names {
200            if matches!(t.get::<Value>(n.as_str())?, Value::Nil) {
201                missing.push(n);
202            }
203        }
204        Ok(missing)
205    }
206
207    /// Check `local m: <T> = require("<name>")` against the checker; `None` when it holds.
208    fn expectation_errors(&self, h: &Table, name: &str) -> mlua::Result<Option<Vec<String>>> {
209        let Some(tp) = &self.expect_type else { return Ok(None) };
210        let (module, _) = tp.split_once('.').ok_or_else(|| {
211            mlua::Error::external(format!(
212                "TealResolver::expect_type: expected \"<module>.<Type>\", got {tp:?}"
213            ))
214        })?;
215        // The module that declares the type is not itself held to it.
216        if name == module {
217            return Ok(None);
218        }
219        let stub = format!(
220            "local {module} = require(\"{module}\")\nlocal m: {tp} = require(\"{name}\")\nreturn m\n"
221        );
222        // Fresh checker env per stub: several resolvers may serve a module of the same
223        // name (one per contract dir) and must not share a cached type for it.
224        let check: Function = h.get("check_stub")?;
225        let errors: Table = check.call((stub.as_str(), format!("<expect {tp} for module '{name}'>")))?;
226        let msgs: Vec<String> = errors.sequence_values::<String>().collect::<mlua::Result<_>>()?;
227        Ok(if msgs.is_empty() { None } else { Some(msgs) })
228    }
229
230    fn prelude(lua: &Lua) -> mlua::Result<Table> {
231        lua.named_registry_value::<Table>(PRELUDE_REGISTRY_KEY)
232            .map_err(|_| mlua::Error::external(
233                "htl::pkg::TealResolver: this Lua has no htl prelude (create it with Htl::new / Htl::from_lua)",
234            ))
235    }
236
237    /// The checker resolves `require`s inside `.tl` via `package.path`; make sure the
238    /// root is visible there (once).
239    fn ensure_checker_path(&self, lua: &Lua, h: &Table) -> mlua::Result<()> {
240        if self.path_added.swap(true, Ordering::Relaxed) {
241            return Ok(());
242        }
243        let f: Function = h.get("add_path")?;
244        if let Some(root) = &self.root {
245            f.call::<()>(root.to_string_lossy().as_ref())?;
246        }
247        for p in &self.checker_paths {
248            if p.is_dir() {
249                f.call::<()>(p.to_string_lossy().as_ref())?;
250            }
251        }
252        let _ = lua;
253        Ok(())
254    }
255
256    fn has_lua_sibling(&self, relative: &str) -> bool {
257        for cand in [format!("{relative}.lua"), format!("{relative}/init.lua")] {
258            if let Ok(Some(_)) = self.sandbox.read(Path::new(&cand)) {
259                return true;
260            }
261        }
262        false
263    }
264
265    fn load_teal(&self, lua: &Lua, h: &Table, src: &str, resolved: &Path, name: &str) -> mlua::Result<Value> {
266        let gen_fn: Function = h.get("gen_string")?;
267        let (code, info): (Option<String>, Table) = gen_fn.call((src, resolved.to_string_lossy().as_ref()))?;
268        let Some(code) = code else {
269            let errors: Table = info.get("errors")?;
270            let msgs: Vec<String> = errors.sequence_values::<String>().collect::<mlua::Result<_>>()?;
271            return Err(mlua::Error::external(TealResolveError::TypeCheck {
272                module: name.to_string(),
273                errors: msgs,
274            }));
275        };
276        if self.held(name)
277            && let Some(errs) = self.expectation_errors(h, name)?
278        {
279            return Err(mlua::Error::external(TealResolveError::Expectation {
280                module: name.to_string(),
281                expected: self.expect_type.clone().unwrap_or_default(),
282                errors: errs,
283            }));
284        }
285        let chunk = lua
286            .load(code)
287            .set_name(format!("@{}", resolved.display()))
288            .into_function()?;
289        chunk.call::<Value>((name, resolved.to_string_lossy().as_ref()))
290    }
291}
292
293// ---------------------------------------------------------------- Project (mlua-pkg.toml)
294
295/// An `mlua-pkg.toml` project: where the manifest, lockfile and vendored deps live.
296///
297/// The pkgs dir follows mlua-pkg's own rule, evaluated against the manifest's
298/// directory: `MLUA_PKG_DIR` env > `<root>/target/mlua-pkgs` when `<root>/target`
299/// exists > `<root>/.mlua-pkgs`.
300#[derive(Debug, Clone)]
301pub struct Project {
302    pub root: PathBuf,
303    pub manifest: PathBuf,
304    pub lockfile: PathBuf,
305    pub pkgs_dir: PathBuf,
306    pub vendored: PathBuf,
307    /// Parent directories of `target_dir` deps (physically vendored copies declared in
308    /// the manifest, e.g. `target_dir = "lua/lshape"` -> `<root>/lua`), so
309    /// `require("lshape")` resolves to `<root>/lua/lshape/init.*` like a vendored dep.
310    pub target_dirs: Vec<PathBuf>,
311}
312
313pub const MANIFEST_NAME: &str = "mlua-pkg.toml";
314pub const LOCKFILE_NAME: &str = "mlua-pkg.lock";
315
316impl Project {
317    /// Walk up from `start` (a file or directory) looking for `mlua-pkg.toml`.
318    pub fn find(start: &Path) -> Option<Self> {
319        let mut dir = if start.is_dir() { start.to_path_buf() } else { crate::parent_dir(start) };
320        if let Ok(abs) = std::fs::canonicalize(&dir) {
321            dir = abs;
322        }
323        loop {
324            let manifest = dir.join(MANIFEST_NAME);
325            if manifest.is_file() {
326                return Some(Self::at(&dir));
327            }
328            if !dir.pop() {
329                return None;
330            }
331        }
332    }
333
334    /// Project rooted at `root` (must contain `mlua-pkg.toml`; not checked here).
335    pub fn at(root: &Path) -> Self {
336        let pkgs_dir = match std::env::var("MLUA_PKG_DIR") {
337            Ok(p) if !p.is_empty() => PathBuf::from(p),
338            _ if root.join("target").is_dir() => root.join("target").join("mlua-pkgs"),
339            _ => root.join(".mlua-pkgs"),
340        };
341        let manifest = root.join(MANIFEST_NAME);
342        // `target_dir` deps: collect the parent of each declared copy target. A manifest
343        // that fails to parse contributes nothing here (mlua-pkg itself reports it).
344        let mut target_dirs: Vec<PathBuf> = Vec::new();
345        if let Ok(m) = mlua_pkg::manifest::Manifest::from_path(&manifest) {
346            for dep in m.deps.values() {
347                if let Some(td) = &dep.target_dir {
348                    let abs = root.join(td);
349                    let parent = abs.parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf());
350                    if !target_dirs.contains(&parent) {
351                        target_dirs.push(parent);
352                    }
353                }
354            }
355        }
356        Self {
357            root: root.to_path_buf(),
358            manifest,
359            lockfile: root.join(LOCKFILE_NAME),
360            vendored: pkgs_dir.join("vendored"),
361            pkgs_dir,
362            target_dirs,
363        }
364    }
365
366    /// `true` once `mlua-pkg install` has produced the lockfile.
367    pub fn installed(&self) -> bool {
368        self.lockfile.is_file()
369    }
370
371    /// Resolver for `.tl` / `.d.tl` inside vendored deps (symlink-aware, like
372    /// `VendoredResolver`). Creates the vendored dir if it does not exist yet.
373    pub fn teal_resolver(&self) -> Result<TealResolver, InitError> {
374        let _ = std::fs::create_dir_all(&self.vendored);
375        TealResolver::new_symlink_aware(&self.vendored)
376    }
377
378    /// mlua-pkg's own resolver for plain `.lua` inside vendored deps.
379    pub fn vendored_resolver(&self) -> anyhow::Result<mlua_pkg::resolvers::VendoredResolver> {
380        if self.installed() {
381            Ok(mlua_pkg::resolvers::VendoredResolver::from_lockfile(&self.lockfile, &self.vendored)?)
382        } else {
383            let _ = std::fs::create_dir_all(&self.vendored);
384            Ok(mlua_pkg::resolvers::VendoredResolver::new(&self.vendored)?)
385        }
386    }
387
388    /// Registry with the project's deps: Teal first, then plain Lua. Add your
389    /// `NativeResolver`s *before* calling `install` if Teal code declares them in `.d.tl`.
390    pub fn registry(&self) -> anyhow::Result<mlua_pkg::Registry> {
391        let mut reg = mlua_pkg::Registry::new();
392        reg.add(self.teal_resolver()?);
393        reg.add(self.vendored_resolver()?);
394        for d in &self.target_dirs {
395            if d.is_dir() {
396                reg.add(TealResolver::new(d)?);
397                reg.add(mlua_pkg::resolvers::FsResolver::new(d)?);
398            }
399        }
400        Ok(reg)
401    }
402}
403
404/// One [`TealResolver`] per `[[contract]]` in `htl.toml`, in declaration order, so the
405/// host and `htl check` enforce the same contracts from the same source. `root` is the
406/// directory holding `htl.toml` (the path [`HtlConfig::find`](crate::config::HtlConfig::find)
407/// returns, minus the file name). Add them to a `Registry` before the plain resolvers.
408pub fn contract_resolvers(root: &Path, cfg: &crate::config::HtlConfig) -> Result<Vec<TealResolver>, InitError> {
409    let mut out = Vec::new();
410    for c in &cfg.contract {
411        for mut r in TealResolver::for_contract(root, c)? {
412            for p in cfg.search_paths(root) {
413                r = r.with_checker_path(p);
414            }
415            out.push(r);
416        }
417    }
418    Ok(out)
419}
420
421impl crate::Htl {
422    /// Make the project's vendored deps visible to the Teal checker and to the
423    /// prelude's strict searcher (`htl run` / `htl test` without a Registry).
424    pub fn apply_project(&self, p: &Project) -> anyhow::Result<()> {
425        let _ = std::fs::create_dir_all(&p.vendored);
426        self.add_path(&p.vendored)?;
427        for d in &p.target_dirs {
428            self.add_path(d)?;
429        }
430        // The project's own modules: `<root>/src` (the scaffold layout) so a script anywhere
431        // in the project resolves them the same way `tests/` does.
432        let src = p.root.join("src");
433        if src.is_dir() {
434            self.add_path(&src)?;
435        }
436        Ok(())
437    }
438}
439
440/// Error raised when a `.tl` module fails the type check at `require` time.
441#[derive(Debug)]
442pub enum TealResolveError {
443    TypeCheck { module: String, errors: Vec<String> },
444    /// The module type-checks on its own but is not assignable to the resolver's
445    /// [`expect_type`](TealResolver::expect_type).
446    Expectation { module: String, expected: String, errors: Vec<String> },
447    /// [`require_fields`](TealResolver::require_fields): declared fields absent at run time.
448    MissingFields { module: String, expected: String, fields: Vec<String> },
449    Read { module: String, source: ReadError },
450}
451
452impl std::fmt::Display for TealResolveError {
453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        match self {
455            Self::TypeCheck { module, errors } => {
456                write!(f, "Teal type check failed for module '{module}':")?;
457                for e in errors {
458                    write!(f, "\n  {e}")?;
459                }
460                Ok(())
461            }
462            Self::Expectation { module, expected, errors } => {
463                write!(f, "module '{module}' does not satisfy {expected}:")?;
464                for e in errors {
465                    write!(f, "\n  {e}")?;
466                }
467                write!(
468                    f,
469                    "\n  hint: annotate the returned table in the module (`local m: {expected} = {{ ... }}  return m`) \
470                     to get field-level errors with line numbers"
471                )
472            }
473            Self::MissingFields { module, expected, fields } => write!(
474                f,
475                "module '{module}' is missing required field(s) of {expected}: {} (every field of that record must be non-nil)",
476                fields.join(", ")
477            ),
478            Self::Read { module, source } => write!(f, "reading module '{module}': {source}"),
479        }
480    }
481}
482
483impl std::error::Error for TealResolveError {}
484
485/// Is `name` registered in `package.preload` (host-provided implementation)?
486fn preloaded(lua: &Lua, name: &str) -> mlua::Result<bool> {
487    let package: Table = lua.globals().get("package")?;
488    let preload: Table = package.get("preload")?;
489    Ok(!matches!(preload.get::<Value>(name)?, Value::Nil))
490}
491
492impl Resolver for TealResolver {
493    fn resolve(&self, lua: &Lua, name: &str) -> Option<mlua::Result<Value>> {
494        let relative = name.replace(self.module_separator, "/");
495        // Flat packages: `<name>/<name>.tl` stands in for `<name>/init.tl`.
496        let last = relative.rsplit('/').next().unwrap_or(&relative).to_string();
497        let candidates = [
498            (format!("{relative}.tl"), false),
499            (format!("{relative}/init.tl"), false),
500            (format!("{relative}/{last}.tl"), false),
501            (format!("{relative}.d.tl"), true),
502        ];
503        let h = match Self::prelude(lua) {
504            Ok(h) => h,
505            Err(e) => return Some(Err(e)),
506        };
507        if let Err(e) = self.ensure_checker_path(lua, &h) {
508            return Some(Err(e));
509        }
510        for (candidate, type_only) in &candidates {
511            match self.sandbox.read(Path::new(candidate)) {
512                Ok(Some(file)) => {
513                    if *type_only {
514                        // A `.d.tl` may describe a plain `.lua` served by a later resolver
515                        // (FsResolver / VendoredResolver): step aside if one is present.
516                        // Native modules must be registered *before* this resolver.
517                        if self.has_lua_sibling(&relative) {
518                            return None;
519                        }
520                        // ... or that the host registered in `package.preload` (a Rust
521                        // `#[host_module]`, `Htl::preload_value`). The Registry's searcher
522                        // runs *before* Lua's preload searcher, so this is the only chance.
523                        match preloaded(lua, name) {
524                            Ok(true) => return None,
525                            Ok(false) => {}
526                            Err(e) => return Some(Err(e)),
527                        }
528                        // Declaration-only module: nothing to run. Hand require a table whose
529                        // lookups explain that the implementation lives elsewhere.
530                        return Some(
531                            h.get::<Function>("type_only_module")
532                                .and_then(|f| f.call::<Value>((name, file.resolved_path.to_string_lossy().as_ref()))),
533                        );
534                    }
535                    let loaded = match self.load_teal(lua, &h, &file.content, &file.resolved_path, name) {
536                        Ok(v) => v,
537                        Err(e) => return Some(Err(e)),
538                    };
539                    if !self.held(name) {
540                        return Some(Ok(loaded));
541                    }
542                    match self.missing_fields(&h, &loaded) {
543                        Ok(m) if m.is_empty() => return Some(Ok(loaded)),
544                        Ok(missing) => {
545                            return Some(Err(mlua::Error::external(TealResolveError::MissingFields {
546                                module: name.to_string(),
547                                expected: self.expect_type.clone().unwrap_or_default(),
548                                fields: missing,
549                            })));
550                        }
551                        Err(e) => return Some(Err(e)),
552                    }
553                }
554                Ok(None) => continue,
555                Err(source) => {
556                    return Some(Err(mlua::Error::external(TealResolveError::Read {
557                        module: name.to_string(),
558                        source,
559                    })));
560                }
561            }
562        }
563        None
564    }
565}