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        if let Ok(t) = lua.named_registry_value::<Table>(PRELUDE_REGISTRY_KEY) {
232            return Ok(t);
233        }
234        // A runtime state whose checker lives in another Lua (`Htl::with_checker`).
235        if let Some(c) = lua.app_data_ref::<crate::CheckerHandle>() {
236            return Ok(c.0.clone());
237        }
238        Err(mlua::Error::external(
239            "htl::pkg::TealResolver: this Lua has no htl prelude (create it with Htl::new / Htl::from_lua)",
240        ))
241    }
242
243    /// The checker resolves `require`s inside `.tl` via `package.path`; make sure the
244    /// root is visible there (once).
245    fn ensure_checker_path(&self, lua: &Lua, h: &Table) -> mlua::Result<()> {
246        if self.path_added.swap(true, Ordering::Relaxed) {
247            return Ok(());
248        }
249        let f: Function = h.get("add_path")?;
250        if let Some(root) = &self.root {
251            f.call::<()>(root.to_string_lossy().as_ref())?;
252        }
253        for p in &self.checker_paths {
254            if p.is_dir() {
255                f.call::<()>(p.to_string_lossy().as_ref())?;
256            }
257        }
258        let _ = lua;
259        Ok(())
260    }
261
262    fn has_lua_sibling(&self, relative: &str) -> bool {
263        for cand in [format!("{relative}.lua"), format!("{relative}/init.lua")] {
264            if let Ok(Some(_)) = self.sandbox.read(Path::new(&cand)) {
265                return true;
266            }
267        }
268        false
269    }
270
271    fn load_teal(&self, lua: &Lua, h: &Table, src: &str, resolved: &Path, name: &str) -> mlua::Result<Value> {
272        let gen_fn: Function = h.get("gen_string")?;
273        let (code, info): (Option<String>, Table) = gen_fn.call((src, resolved.to_string_lossy().as_ref()))?;
274        let Some(code) = code else {
275            let errors: Table = info.get("errors")?;
276            let msgs: Vec<String> = errors.sequence_values::<String>().collect::<mlua::Result<_>>()?;
277            return Err(mlua::Error::external(TealResolveError::TypeCheck {
278                module: name.to_string(),
279                errors: msgs,
280            }));
281        };
282        if self.held(name)
283            && let Some(errs) = self.expectation_errors(h, name)?
284        {
285            return Err(mlua::Error::external(TealResolveError::Expectation {
286                module: name.to_string(),
287                expected: self.expect_type.clone().unwrap_or_default(),
288                errors: errs,
289            }));
290        }
291        let chunk = lua
292            .load(code)
293            .set_name(format!("@{}", resolved.display()))
294            .into_function()?;
295        chunk.call::<Value>((name, resolved.to_string_lossy().as_ref()))
296    }
297}
298
299// ---------------------------------------------------------------- Project (mlua-pkg.toml)
300
301/// An `mlua-pkg.toml` project: where the manifest, lockfile and vendored deps live.
302///
303/// The pkgs dir follows mlua-pkg's own rule, evaluated against the manifest's
304/// directory: `MLUA_PKG_DIR` env > `<root>/target/mlua-pkgs` when `<root>/target`
305/// exists > `<root>/.mlua-pkgs`.
306#[derive(Debug, Clone)]
307pub struct Project {
308    pub root: PathBuf,
309    pub manifest: PathBuf,
310    pub lockfile: PathBuf,
311    pub pkgs_dir: PathBuf,
312    pub vendored: PathBuf,
313    /// Parent directories of `target_dir` deps (physically vendored copies declared in
314    /// the manifest, e.g. `target_dir = "lua/lshape"` -> `<root>/lua`), so
315    /// `require("lshape")` resolves to `<root>/lua/lshape/init.*` like a vendored dep.
316    pub target_dirs: Vec<PathBuf>,
317}
318
319pub const MANIFEST_NAME: &str = "mlua-pkg.toml";
320pub const LOCKFILE_NAME: &str = "mlua-pkg.lock";
321
322impl Project {
323    /// Walk up from `start` (a file or directory) looking for `mlua-pkg.toml`.
324    pub fn find(start: &Path) -> Option<Self> {
325        let mut dir = if start.is_dir() { start.to_path_buf() } else { crate::parent_dir(start) };
326        if let Ok(abs) = std::fs::canonicalize(&dir) {
327            dir = abs;
328        }
329        loop {
330            let manifest = dir.join(MANIFEST_NAME);
331            if manifest.is_file() {
332                return Some(Self::at(&dir));
333            }
334            if !dir.pop() {
335                return None;
336            }
337        }
338    }
339
340    /// Project rooted at `root` (must contain `mlua-pkg.toml`; not checked here).
341    pub fn at(root: &Path) -> Self {
342        let pkgs_dir = match std::env::var("MLUA_PKG_DIR") {
343            Ok(p) if !p.is_empty() => PathBuf::from(p),
344            _ if root.join("target").is_dir() => root.join("target").join("mlua-pkgs"),
345            _ => root.join(".mlua-pkgs"),
346        };
347        let manifest = root.join(MANIFEST_NAME);
348        // `target_dir` deps: collect the parent of each declared copy target. A manifest
349        // that fails to parse contributes nothing here (mlua-pkg itself reports it).
350        let mut target_dirs: Vec<PathBuf> = Vec::new();
351        if let Ok(m) = mlua_pkg::manifest::Manifest::from_path(&manifest) {
352            for dep in m.deps.values() {
353                if let Some(td) = &dep.target_dir {
354                    let abs = root.join(td);
355                    let parent = abs.parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf());
356                    if !target_dirs.contains(&parent) {
357                        target_dirs.push(parent);
358                    }
359                }
360            }
361        }
362        Self {
363            root: root.to_path_buf(),
364            manifest,
365            lockfile: root.join(LOCKFILE_NAME),
366            vendored: pkgs_dir.join("vendored"),
367            pkgs_dir,
368            target_dirs,
369        }
370    }
371
372    /// `true` once `mlua-pkg install` has produced the lockfile.
373    pub fn installed(&self) -> bool {
374        self.lockfile.is_file()
375    }
376
377    /// Resolver for `.tl` / `.d.tl` inside vendored deps (symlink-aware, like
378    /// `VendoredResolver`). Creates the vendored dir if it does not exist yet.
379    pub fn teal_resolver(&self) -> Result<TealResolver, InitError> {
380        let _ = std::fs::create_dir_all(&self.vendored);
381        TealResolver::new_symlink_aware(&self.vendored)
382    }
383
384    /// mlua-pkg's own resolver for plain `.lua` inside vendored deps.
385    pub fn vendored_resolver(&self) -> anyhow::Result<mlua_pkg::resolvers::VendoredResolver> {
386        if self.installed() {
387            Ok(mlua_pkg::resolvers::VendoredResolver::from_lockfile(&self.lockfile, &self.vendored)?)
388        } else {
389            let _ = std::fs::create_dir_all(&self.vendored);
390            Ok(mlua_pkg::resolvers::VendoredResolver::new(&self.vendored)?)
391        }
392    }
393
394    /// Registry with the project's deps: Teal first, then plain Lua. Add your
395    /// `NativeResolver`s *before* calling `install` if Teal code declares them in `.d.tl`.
396    pub fn registry(&self) -> anyhow::Result<mlua_pkg::Registry> {
397        let mut reg = mlua_pkg::Registry::new();
398        reg.add(self.teal_resolver()?);
399        reg.add(self.vendored_resolver()?);
400        for d in &self.target_dirs {
401            if d.is_dir() {
402                reg.add(TealResolver::new(d)?);
403                reg.add(mlua_pkg::resolvers::FsResolver::new(d)?);
404            }
405        }
406        Ok(reg)
407    }
408}
409
410/// One [`TealResolver`] per `[[contract]]` in `htl.toml`, in declaration order, so the
411/// host and `htl check` enforce the same contracts from the same source. `root` is the
412/// directory holding `htl.toml` (the path [`HtlConfig::find`](crate::config::HtlConfig::find)
413/// returns, minus the file name). Add them to a `Registry` before the plain resolvers.
414pub fn contract_resolvers(root: &Path, cfg: &crate::config::HtlConfig) -> Result<Vec<TealResolver>, InitError> {
415    let mut out = Vec::new();
416    for c in &cfg.contract {
417        for mut r in TealResolver::for_contract(root, c)? {
418            for p in cfg.search_paths(root) {
419                r = r.with_checker_path(p);
420            }
421            out.push(r);
422        }
423    }
424    Ok(out)
425}
426
427impl crate::Htl {
428    /// Make the project's vendored deps visible to the Teal checker and to the
429    /// prelude's strict searcher (`htl run` / `htl test` without a Registry).
430    pub fn apply_project(&self, p: &Project) -> anyhow::Result<()> {
431        let _ = std::fs::create_dir_all(&p.vendored);
432        self.add_path(&p.vendored)?;
433        for d in &p.target_dirs {
434            self.add_path(d)?;
435        }
436        // The project's own modules: `<root>/src` (the scaffold layout) so a script anywhere
437        // in the project resolves them the same way `tests/` does.
438        let src = p.root.join("src");
439        if src.is_dir() {
440            self.add_path(&src)?;
441        }
442        Ok(())
443    }
444}
445
446/// Error raised when a `.tl` module fails the type check at `require` time.
447#[derive(Debug)]
448pub enum TealResolveError {
449    TypeCheck { module: String, errors: Vec<String> },
450    /// The module type-checks on its own but is not assignable to the resolver's
451    /// [`expect_type`](TealResolver::expect_type).
452    Expectation { module: String, expected: String, errors: Vec<String> },
453    /// [`require_fields`](TealResolver::require_fields): declared fields absent at run time.
454    MissingFields { module: String, expected: String, fields: Vec<String> },
455    Read { module: String, source: ReadError },
456}
457
458impl std::fmt::Display for TealResolveError {
459    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460        match self {
461            Self::TypeCheck { module, errors } => {
462                write!(f, "Teal type check failed for module '{module}':")?;
463                for e in errors {
464                    write!(f, "\n  {e}")?;
465                }
466                Ok(())
467            }
468            Self::Expectation { module, expected, errors } => {
469                write!(f, "module '{module}' does not satisfy {expected}:")?;
470                for e in errors {
471                    write!(f, "\n  {e}")?;
472                }
473                write!(
474                    f,
475                    "\n  hint: annotate the returned table in the module (`local m: {expected} = {{ ... }}  return m`) \
476                     to get field-level errors with line numbers"
477                )
478            }
479            Self::MissingFields { module, expected, fields } => write!(
480                f,
481                "module '{module}' is missing required field(s) of {expected}: {} (every field of that record must be non-nil)",
482                fields.join(", ")
483            ),
484            Self::Read { module, source } => write!(f, "reading module '{module}': {source}"),
485        }
486    }
487}
488
489impl std::error::Error for TealResolveError {}
490
491/// Is `name` registered in `package.preload` (host-provided implementation)?
492fn preloaded(lua: &Lua, name: &str) -> mlua::Result<bool> {
493    let package: Table = lua.globals().get("package")?;
494    let preload: Table = package.get("preload")?;
495    Ok(!matches!(preload.get::<Value>(name)?, Value::Nil))
496}
497
498impl Resolver for TealResolver {
499    fn resolve(&self, lua: &Lua, name: &str) -> Option<mlua::Result<Value>> {
500        let relative = name.replace(self.module_separator, "/");
501        // Flat packages: `<name>/<name>.tl` stands in for `<name>/init.tl`.
502        let last = relative.rsplit('/').next().unwrap_or(&relative).to_string();
503        let candidates = [
504            (format!("{relative}.tl"), false),
505            (format!("{relative}/init.tl"), false),
506            (format!("{relative}/{last}.tl"), false),
507            (format!("{relative}.d.tl"), true),
508        ];
509        let h = match Self::prelude(lua) {
510            Ok(h) => h,
511            Err(e) => return Some(Err(e)),
512        };
513        if let Err(e) = self.ensure_checker_path(lua, &h) {
514            return Some(Err(e));
515        }
516        for (candidate, type_only) in &candidates {
517            match self.sandbox.read(Path::new(candidate)) {
518                Ok(Some(file)) => {
519                    if *type_only {
520                        // A `.d.tl` may describe a plain `.lua` served by a later resolver
521                        // (FsResolver / VendoredResolver): step aside if one is present.
522                        // Native modules must be registered *before* this resolver.
523                        if self.has_lua_sibling(&relative) {
524                            return None;
525                        }
526                        // ... or that the host registered in `package.preload` (a Rust
527                        // `#[host_module]`, `Htl::preload_value`). The Registry's searcher
528                        // runs *before* Lua's preload searcher, so this is the only chance.
529                        match preloaded(lua, name) {
530                            Ok(true) => return None,
531                            Ok(false) => {}
532                            Err(e) => return Some(Err(e)),
533                        }
534                        // Declaration-only module: nothing to run. Hand require a table whose
535                        // lookups explain that the implementation lives elsewhere.
536                        return Some(
537                            h.get::<Function>("type_only_module")
538                                .and_then(|f| f.call::<Value>((name, file.resolved_path.to_string_lossy().as_ref()))),
539                        );
540                    }
541                    let loaded = match self.load_teal(lua, &h, &file.content, &file.resolved_path, name) {
542                        Ok(v) => v,
543                        Err(e) => return Some(Err(e)),
544                    };
545                    if !self.held(name) {
546                        return Some(Ok(loaded));
547                    }
548                    match self.missing_fields(&h, &loaded) {
549                        Ok(m) if m.is_empty() => return Some(Ok(loaded)),
550                        Ok(missing) => {
551                            return Some(Err(mlua::Error::external(TealResolveError::MissingFields {
552                                module: name.to_string(),
553                                expected: self.expect_type.clone().unwrap_or_default(),
554                                fields: missing,
555                            })));
556                        }
557                        Err(e) => return Some(Err(e)),
558                    }
559                }
560                Ok(None) => continue,
561                Err(source) => {
562                    return Some(Err(mlua::Error::external(TealResolveError::Read {
563                        module: name.to_string(),
564                        source,
565                    })));
566                }
567            }
568        }
569        None
570    }
571}