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 anyhow::Context;
22use mlua::{Function, Lua, Table, Value};
23use mlua_pkg::Resolver;
24use mlua_pkg::sandbox::{FsSandbox, InitError, ReadError, SandboxedFs, SymlinkAwareSandbox};
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicBool, Ordering};
27
28pub use mlua_pkg;
29
30/// Resolves `require("a.b")` to `a/b.tl`, `a/b/init.tl`, or `a/b.d.tl` under a
31/// sandboxed root, type-checking and generating on the fly.
32pub struct TealResolver {
33    sandbox: Box<dyn SandboxedFs>,
34    root: Option<PathBuf>,
35    path_added: AtomicBool,
36    module_separator: char,
37    /// `"defs.Mod"`: every module served by this resolver must be assignable to that type.
38    expect_type: Option<String>,
39    /// With `expect_type`: which fields must be non-nil at run time. `All(false)` is off.
40    require_fields: crate::config::RequireFields,
41    /// Extra dirs the checker may search for `require`s (e.g. where `defs.tl` lives).
42    checker_paths: Vec<PathBuf>,
43    /// Module names served here that `expect_type` / `require_fields` skip.
44    exclude: Vec<String>,
45    /// When set, `expect_type` / `require_fields` apply to this module name only.
46    only_module: Option<String>,
47}
48
49impl TealResolver {
50    /// Strict sandbox (no symlinks out of `root`).
51    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InitError> {
52        let root = root.into();
53        Ok(Self {
54            sandbox: Box::new(FsSandbox::new(&root)?),
55            root: Some(root),
56            path_added: AtomicBool::new(false),
57            module_separator: '.',
58            expect_type: None,
59            require_fields: Default::default(),
60            checker_paths: Vec::new(),
61            exclude: Vec::new(),
62            only_module: None,
63        })
64    }
65
66    /// Sandbox that follows symlinks directly under `root` (linked package roots).
67    pub fn new_symlink_aware(root: impl Into<PathBuf>) -> Result<Self, InitError> {
68        let root = root.into();
69        Ok(Self {
70            sandbox: Box::new(SymlinkAwareSandbox::new(&root)?),
71            root: Some(root),
72            path_added: AtomicBool::new(false),
73            module_separator: '.',
74            expect_type: None,
75            require_fields: Default::default(),
76            checker_paths: Vec::new(),
77            exclude: Vec::new(),
78            only_module: None,
79        })
80    }
81
82    /// Custom sandbox. Pass `root` so the Teal checker can also see the tree when
83    /// resolving `require`s inside `.tl` files (it searches `package.path`).
84    pub fn with_sandbox(sandbox: impl SandboxedFs + 'static, root: Option<PathBuf>) -> Self {
85        Self {
86            sandbox: Box::new(sandbox),
87            root,
88            path_added: AtomicBool::new(false),
89            module_separator: '.',
90            expect_type: None,
91            require_fields: Default::default(),
92            checker_paths: Vec::new(),
93            exclude: Vec::new(),
94            only_module: None,
95        }
96    }
97
98    pub fn with_module_separator(mut self, sep: char) -> Self {
99        self.module_separator = sep;
100        self
101    }
102
103    /// Require every `.tl` module served here to be assignable to `type_path`, written
104    /// as `"<module>.<Type>"` (e.g. `"defs.Mod"`, where `defs.tl` / `defs.d.tl` declares
105    /// `Mod`). A module that does not satisfy it fails at `require` time even if it never
106    /// annotates its own return value.
107    ///
108    /// What this catches is what Teal's record assignability catches: a field of the
109    /// **wrong type** (`hp = "lots"` for `hp: integer`). On its own it does **not** catch
110    /// a **missing** field: every Teal record field is nilable, so `{ name = "x" }`
111    /// satisfies `Mod` with `monsters` absent. Add [`require_fields`](Self::require_fields)
112    /// to reject that at run time, or nil-guard optional data on the host side.
113    pub fn expect_type(mut self, type_path: impl Into<String>) -> Self {
114        self.expect_type = Some(type_path.into());
115        self
116    }
117
118    /// With [`expect_type`](Self::expect_type): after the type check, these fields must
119    /// be present (non-nil) in the loaded module, or the `require` fails naming the ones
120    /// that are absent.
121    ///
122    /// Naming them rather than taking all of them is what lets the type grow: the fields
123    /// listed here are the contract, and a field added to the record later is optional
124    /// until it is added here too. A name the record does not declare is an error at the
125    /// first `require`, not a line that quietly does nothing.
126    ///
127    /// [`require_all_fields`](Self::require_all_fields) is the every-field form, and the
128    /// static counterpart of both is `require_fields` in `[[contract]]`.
129    pub fn require_fields(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
130        self.require_fields =
131            crate::config::RequireFields::Named(names.into_iter().map(Into::into).collect());
132        self
133    }
134
135    /// With [`expect_type`](Self::expect_type): every field the record declares must be
136    /// present (non-nil) in the loaded module. Adding a field to the record makes every
137    /// module that predates it fail, which is what
138    /// [`require_fields`](Self::require_fields) exists to avoid; use this where the type
139    /// is settled, or where every field really is mandatory.
140    pub fn require_all_fields(mut self) -> Self {
141        self.require_fields = crate::config::RequireFields::All(true);
142        self
143    }
144
145    /// Let the Teal checker also search `dir` when resolving `require`s inside served
146    /// modules (and the module named by `expect_type`). The sandbox root is always
147    /// searched; add the project `src/` here when `defs.tl` lives there.
148    pub fn with_checker_path(mut self, dir: impl Into<PathBuf>) -> Self {
149        self.checker_paths.push(dir.into());
150        self
151    }
152
153    /// Modules (by `require` name) served here that are *not* held to `expect_type` /
154    /// `require_fields`: an SDK the host writes into the same dir, for instance. The
155    /// module that declares the expected type is always exempt.
156    pub fn exclude_modules(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
157        self.exclude.extend(names.into_iter().map(Into::into));
158        self
159    }
160
161    /// Hold only this module name to `expect_type` / `require_fields`; everything else
162    /// served here is type-checked as usual but not against the contract.
163    pub fn only_module(mut self, name: impl Into<String>) -> Self {
164        self.only_module = Some(name.into());
165        self
166    }
167
168    /// Does the contract (`expect_type` / `require_fields`) apply to `name`?
169    fn held(&self, name: &str) -> bool {
170        if self.expect_type.is_none() || self.exclude.iter().any(|e| e == name) {
171            return false;
172        }
173        self.only_module.as_deref().is_none_or(|m| m == name)
174    }
175
176    /// Resolvers for one `[[contract]]` of `htl.toml`: one per concrete contract dir
177    /// (a `dir` with `*` expands to every subdirectory), each with `expect_type(type)`
178    /// and the contract's `require_fields` as written, its `exclude` / `module`, and
179    /// the project's search paths visible to the checker
180    /// ([`search_paths`](crate::config::HtlConfig::search_paths): `root`, its `src/` and
181    /// `types/`, then `[check] paths`). `root` is the directory holding `htl.toml`. The
182    /// `contract-unenforced` lint of `htl check` recognises this call.
183    pub fn for_contract(
184        root: &Path,
185        cfg: &crate::config::HtlConfig,
186        c: &crate::contract::Resolved,
187    ) -> Result<Vec<Self>, InitError> {
188        c.dirs(root)
189            .into_iter()
190            .map(|d| Self::for_contract_dir(root, &d, cfg, c))
191            .collect()
192    }
193
194    /// One resolver for the concrete contract directory `dir` (see [`for_contract`](Self::for_contract)).
195    pub fn for_contract_dir(
196        root: &Path,
197        dir: &Path,
198        cfg: &crate::config::HtlConfig,
199        c: &crate::contract::Resolved,
200    ) -> Result<Self, InitError> {
201        let mut r = Self::new_symlink_aware(dir)?
202            .expect_type(c.type_path.clone())
203            .exclude_modules(c.exclude.iter().cloned());
204        // The same paths the `contract` lint checks through (`Htl::apply_config`), so a
205        // contract type declared in `types/` resolves in the run as well as in the check.
206        for p in cfg.search_paths(root) {
207            r = r.with_checker_path(p);
208        }
209        if let Some(m) = &c.module {
210            r = r.only_module(m.clone());
211        }
212        r.require_fields = c.require_fields.clone();
213        Ok(r)
214    }
215
216    /// Required fields of the expected record that are nil in `value`.
217    fn missing_fields(&self, h: &Table, value: &Value) -> mlua::Result<Vec<String>> {
218        let Some(tp) = &self.expect_type else {
219            return Ok(Vec::new());
220        };
221        if !self.require_fields.is_on() {
222            return Ok(Vec::new());
223        }
224        let f: Function = h.get("record_fields")?;
225        let declared: Option<Vec<String>> = f
226            .call::<Option<Table>>(tp.as_str())?
227            .map(|t| t.sequence_values::<String>().collect::<mlua::Result<_>>())
228            .transpose()?;
229        let Some(declared) = declared else {
230            return Err(mlua::Error::external(format!(
231                "TealResolver::require_fields: record type {tp:?} not found by the checker"
232            )));
233        };
234        // A listed name the record does not declare is a mistake in the host's own
235        // wiring; saying so beats holding modules to a field that cannot exist.
236        let names = match self.require_fields.named() {
237            None => declared,
238            Some(wanted) => {
239                let unknown: Vec<&str> = wanted
240                    .iter()
241                    .filter(|w| !declared.iter().any(|d| d == *w))
242                    .map(|w| w.as_str())
243                    .collect();
244                if !unknown.is_empty() {
245                    return Err(mlua::Error::external(format!(
246                        "TealResolver::require_fields names field(s) that {tp} does not declare: {}",
247                        unknown.join(", ")
248                    )));
249                }
250                wanted.to_vec()
251            }
252        };
253        let Value::Table(t) = value else {
254            return Ok(names); // not a table at all: everything is missing
255        };
256        let mut missing = Vec::new();
257        for n in names {
258            if matches!(t.get::<Value>(n.as_str())?, Value::Nil) {
259                missing.push(n);
260            }
261        }
262        Ok(missing)
263    }
264
265    /// Check `local m: <T> = require("<name>")` against the checker; `None` when it holds.
266    fn expectation_errors(&self, h: &Table, name: &str) -> mlua::Result<Option<Vec<String>>> {
267        let Some(tp) = &self.expect_type else {
268            return Ok(None);
269        };
270        let (module, _) = tp.split_once('.').ok_or_else(|| {
271            mlua::Error::external(format!(
272                "TealResolver::expect_type: expected \"<module>.<Type>\", got {tp:?}"
273            ))
274        })?;
275        // The module that declares the type is not itself held to it.
276        if name == module {
277            return Ok(None);
278        }
279        let stub = format!(
280            "local {module} = require(\"{module}\")\nlocal m: {tp} = require(\"{name}\")\nreturn m\n"
281        );
282        // Fresh checker env per stub: several resolvers may serve a module of the same
283        // name (one per contract dir) and must not share a cached type for it.
284        let check: Function = h.get("check_stub")?;
285        let errors: Table =
286            check.call((stub.as_str(), format!("<expect {tp} for module '{name}'>")))?;
287        let msgs: Vec<String> = errors
288            .sequence_values::<String>()
289            .collect::<mlua::Result<_>>()?;
290        Ok(if msgs.is_empty() { None } else { Some(msgs) })
291    }
292
293    fn prelude(lua: &Lua) -> mlua::Result<Table> {
294        if let Ok(t) = lua.named_registry_value::<Table>(PRELUDE_REGISTRY_KEY) {
295            return Ok(t);
296        }
297        // A runtime state whose checker lives in another Lua (`Htl::with_checker`).
298        if let Some(c) = lua.app_data_ref::<crate::CheckerHandle>() {
299            return Ok(c.0.clone());
300        }
301        Err(mlua::Error::external(
302            "htl::pkg::TealResolver: this Lua has no htl prelude (create it with Htl::new / Htl::from_lua)",
303        ))
304    }
305
306    /// The checker resolves `require`s inside `.tl` via `package.path`; make sure the
307    /// root is visible there (once).
308    fn ensure_checker_path(&self, lua: &Lua, h: &Table) -> mlua::Result<()> {
309        if self.path_added.swap(true, Ordering::Relaxed) {
310            return Ok(());
311        }
312        let f: Function = h.get("add_path")?;
313        // Back to front: `add_path` prepends, so this leaves the sandbox root consulted
314        // first (a module resolving its siblings) and the project's paths behind it, in
315        // the order `search_paths` states. Adding them front to back reversed both.
316        for p in self.checker_paths.iter().rev() {
317            if p.is_dir() {
318                f.call::<()>(p.to_string_lossy().as_ref())?;
319            }
320        }
321        if let Some(root) = &self.root {
322            f.call::<()>(root.to_string_lossy().as_ref())?;
323        }
324        let _ = lua;
325        Ok(())
326    }
327
328    fn has_lua_sibling(&self, relative: &str) -> bool {
329        for cand in [format!("{relative}.lua"), format!("{relative}/init.lua")] {
330            if let Ok(Some(_)) = self.sandbox.read(Path::new(&cand)) {
331                return true;
332            }
333        }
334        false
335    }
336
337    fn load_teal(
338        &self,
339        lua: &Lua,
340        h: &Table,
341        src: &str,
342        resolved: &Path,
343        name: &str,
344    ) -> mlua::Result<Value> {
345        let gen_fn: Function = h.get("gen_string")?;
346        let (code, info): (Option<String>, Table) =
347            gen_fn.call((src, resolved.to_string_lossy().as_ref()))?;
348        let Some(code) = code else {
349            let errors: Table = info.get("errors")?;
350            let msgs: Vec<String> = errors
351                .sequence_values::<String>()
352                .collect::<mlua::Result<_>>()?;
353            return Err(mlua::Error::external(TealResolveError::TypeCheck {
354                module: name.to_string(),
355                errors: msgs,
356            }));
357        };
358        if self.held(name)
359            && let Some(errs) = self.expectation_errors(h, name)?
360        {
361            return Err(mlua::Error::external(TealResolveError::Expectation {
362                module: name.to_string(),
363                expected: self.expect_type.clone().unwrap_or_default(),
364                errors: errs,
365            }));
366        }
367        let chunk = lua
368            .load(code)
369            .set_name(format!("@{}", resolved.display()))
370            .into_function()?;
371        chunk.call::<Value>((name, resolved.to_string_lossy().as_ref()))
372    }
373}
374
375// ---------------------------------------------------------------- Project (mlua-pkg.toml)
376
377/// An `mlua-pkg.toml` project: where the manifest, lockfile and installed deps live.
378///
379/// Installed deps go under [`pkgs_dir`] — `<root>/.htl/modules`, beside the check cache
380/// and regenerated the same way: from the manifest and the lockfile rather than from the
381/// project's own sources. Deps that are *committed* are the other thing, and they are
382/// declared: `target_dirs`.
383#[derive(Debug, Clone)]
384pub struct Project {
385    pub root: PathBuf,
386    pub manifest: PathBuf,
387    pub lockfile: PathBuf,
388    pub pkgs_dir: PathBuf,
389    /// `pkgs_dir/vendored`: one entry per installed dep, pointing at what mlua-pkg
390    /// fetched. The name is mlua-pkg's own and describes its layout, not htl's — what is
391    /// in there is installed and regenerated, while a copy that is committed to the repo
392    /// is a `target_dir` dep below.
393    pub vendored: PathBuf,
394    /// Parent directories of `target_dir` deps (physically vendored copies declared in
395    /// the manifest, e.g. `target_dir = "lua/lshape"` -> `<root>/lua`), so
396    /// `require("lshape")` resolves to `<root>/lua/lshape/init.*` like a vendored dep.
397    pub target_dirs: Vec<PathBuf>,
398    /// The `target_dir` copies themselves (`<root>/lua/lshape`), as against the parents
399    /// above.
400    ///
401    /// A copy is a dependency's source that happens to sit in the repo, and `mlua-pkg
402    /// install` rewrites it every time it runs — so it is not the project's to check,
403    /// format or take tests from, and editing one there does not survive the next install.
404    /// What that means for the walkers is in [`crate::project_skip_dirs`].
405    pub vendored_copies: Vec<PathBuf>,
406    /// The `patch_dir` deps: a dependency's source taken into the tree, and what the
407    /// manifest calls it. Unlike a `target_dir` copy, which install rewrites, this one is
408    /// the project's own code — [`Project::patch`] wrote it once and the project edits it
409    /// from then on. What that means for the walkers is in [`crate::patched_dirs`].
410    pub patches: Vec<Patched>,
411}
412
413/// A dependency the project took into its tree: the name the manifest declares it under,
414/// and the directory `patch_dir` points at, absolute.
415///
416/// The name is carried beside the directory because it is what a report says. htl's own
417/// layout puts mathx in `patches/mathx`, but the manifest may name any directory, and a
418/// type error in there is the dependency's name to report either way.
419#[derive(Debug, Clone)]
420pub struct Patched {
421    pub name: String,
422    pub dir: PathBuf,
423}
424
425/// What [`Project::add`] did: mlua-pkg's own report, and what htl carried across it.
426///
427/// `add` rewrites the whole `[deps.<name>]` entry, so a patch the entry declared would be
428/// dropped by it. `kept_patch_dir` is that key, put back — named here so the report can say
429/// it happened rather than leaving the manifest quietly different from what `add` wrote.
430#[derive(Debug, Clone)]
431pub struct AddDone {
432    pub report: mlua_pkg::ops::AddReport,
433    pub kept_patch_dir: Option<PathBuf>,
434}
435
436/// Where a patched dependency stands after an install: whether the copy is what the
437/// dependency resolves from, and the two revisions the answer rests on.
438///
439/// `in_use` is false when the directory is gone, when the lockfile records no base for it,
440/// or when the pin has moved on from that base — the dependency then resolves to the
441/// upstream revision, and the copy sits in the tree unused until it is refreshed or
442/// removed. See [`Project::patch_status`].
443#[derive(Debug, Clone)]
444pub struct PatchStatus {
445    pub name: String,
446    pub dir: PathBuf,
447    /// The revision the copy was taken from (`patch_base`), when the lockfile has one.
448    pub base: Option<String>,
449    /// The revision the pin resolves to, as the last install recorded it.
450    pub locked: Option<String>,
451    pub in_use: bool,
452}
453
454pub const MANIFEST_NAME: &str = mlua_pkg::project::MANIFEST_FILE_NAME;
455pub const LOCKFILE_NAME: &str = mlua_pkg::project::LOCKFILE_FILE_NAME;
456
457/// Where [`Project::patch`] puts a dependency it takes into the tree: `patches/<dep>`,
458/// beside the project's own sources rather than under [`pkgs_dir`]. One directory per
459/// dependency, named after it, so the path a diagnostic carries names the dependency it
460/// is in.
461pub const PATCHES_DIR: &str = "patches";
462
463/// Where a project's installed deps go: `<root>/.htl/modules`, always.
464///
465/// One directory, named in one place. htl does not read the location out of the
466/// environment and does not infer it from whether `target/` happens to exist — it decides
467/// it here and hands it to mlua-pkg when it runs one (`htl pkg`), so the installer and the
468/// checker cannot name different directories.
469///
470/// What goes on *inside* is mlua-pkg's: [`mlua_pkg::PkgDir`] derives `cache/` and
471/// `vendored/` from the base, and this returns one so htl does not spell that layout out a
472/// second time.
473pub fn pkgs_dir(root: &Path) -> mlua_pkg::PkgDir {
474    mlua_pkg::PkgDir::new(root.join(".htl").join("modules"))
475}
476
477impl Project {
478    /// Walk up from `start` (a file or directory) looking for `mlua-pkg.toml`.
479    pub fn find(start: &Path) -> Option<Self> {
480        let mut dir = if start.is_dir() {
481            start.to_path_buf()
482        } else {
483            crate::parent_dir(start)
484        };
485        if let Ok(abs) = std::fs::canonicalize(&dir) {
486            dir = abs;
487        }
488        loop {
489            let manifest = dir.join(MANIFEST_NAME);
490            if manifest.is_file() {
491                return Some(Self::at(&dir));
492            }
493            if !dir.pop() {
494                return None;
495            }
496        }
497    }
498
499    /// Project rooted at `root` (must contain `mlua-pkg.toml`; not checked here).
500    pub fn at(root: &Path) -> Self {
501        let inner = mlua_pkg::Project::in_dir(root, pkgs_dir(root));
502        let manifest = inner.manifest_path().to_path_buf();
503        // `target_dir` deps: the copy itself, and the parent `require` searches. `patch_dir`
504        // deps: the directory itself, which is what a walker is asked about. A manifest
505        // that fails to parse contributes nothing here (mlua-pkg itself reports it).
506        let mut target_dirs: Vec<PathBuf> = Vec::new();
507        let mut vendored_copies: Vec<PathBuf> = Vec::new();
508        let mut patches: Vec<Patched> = Vec::new();
509        if let Ok(m) = mlua_pkg::manifest::Manifest::from_path(&manifest) {
510            for (name, dep) in &m.deps {
511                if let Some(td) = &dep.target_dir {
512                    let abs = root.join(td);
513                    let parent = abs
514                        .parent()
515                        .map(Path::to_path_buf)
516                        .unwrap_or_else(|| root.to_path_buf());
517                    if !target_dirs.contains(&parent) {
518                        target_dirs.push(parent);
519                    }
520                    if !vendored_copies.contains(&abs) {
521                        vendored_copies.push(abs);
522                    }
523                }
524                if let Some(pd) = &dep.patch_dir {
525                    patches.push(Patched {
526                        name: name.clone(),
527                        dir: root.join(pd),
528                    });
529                }
530            }
531        }
532        Self {
533            root: root.to_path_buf(),
534            manifest,
535            lockfile: inner.lock_path().to_path_buf(),
536            vendored: inner.pkg_dir().vendored(),
537            pkgs_dir: inner.pkg_dir().base().to_path_buf(),
538            target_dirs,
539            vendored_copies,
540            patches,
541        }
542    }
543
544    /// Where the patched deps are, for a walker that only asks whether it may enter.
545    pub fn patch_dirs(&self) -> Vec<PathBuf> {
546        self.patches.iter().map(|p| p.dir.clone()).collect()
547    }
548
549    /// `true` once `mlua-pkg install` has produced the lockfile.
550    pub fn installed(&self) -> bool {
551        self.lockfile.is_file()
552    }
553
554    /// Resolver for `.tl` / `.d.tl` inside vendored deps (symlink-aware, like
555    /// `VendoredResolver`). Creates the vendored dir if it does not exist yet.
556    pub fn teal_resolver(&self) -> Result<TealResolver, InitError> {
557        let _ = std::fs::create_dir_all(&self.vendored);
558        TealResolver::new_symlink_aware(&self.vendored)
559    }
560
561    /// mlua-pkg's own resolver for plain `.lua` inside vendored deps.
562    pub fn vendored_resolver(&self) -> anyhow::Result<mlua_pkg::resolvers::VendoredResolver> {
563        if self.installed() {
564            Ok(mlua_pkg::resolvers::VendoredResolver::from_lockfile(
565                &self.lockfile,
566                &self.vendored,
567            )?)
568        } else {
569            let _ = std::fs::create_dir_all(&self.vendored);
570            Ok(mlua_pkg::resolvers::VendoredResolver::new(&self.vendored)?)
571        }
572    }
573
574    /// Registry with the project's deps: Teal first, then plain Lua. Add your
575    /// `NativeResolver`s *before* calling `install` if Teal code declares them in `.d.tl`.
576    pub fn registry(&self) -> anyhow::Result<mlua_pkg::Registry> {
577        let mut reg = mlua_pkg::Registry::new();
578        reg.add(self.teal_resolver()?);
579        reg.add(self.vendored_resolver()?);
580        for d in &self.target_dirs {
581            if d.is_dir() {
582                reg.add(TealResolver::new(d)?);
583                reg.add(mlua_pkg::resolvers::FsResolver::new(d)?);
584            }
585        }
586        Ok(reg)
587    }
588
589    /// Bring the declarations a dep publishes into the project's own `types/`.
590    ///
591    /// A dep that follows htl's own convention keeps its `.d.tl` under `types/` at its
592    /// package root, and that is outside the entry directory `vendored/<name>` points at —
593    /// so the checker never sees it, and the depending project writes the declaration
594    /// again by hand. Copying rather than widening the search path is what makes the
595    /// result survive a fresh clone: [`pkgs_dir`] is machine-local and empty until someone
596    /// installs, while `types/` is committed.
597    ///
598    /// A name `types/` already has is left alone and reported. Two libraries publishing a
599    /// module of the same name is a real situation, and there is no registry to arbitrate
600    /// it with, so the project decides rather than the last install winning.
601    pub fn sync_types(&self) -> anyhow::Result<TypesSync> {
602        let mut out = TypesSync::default();
603        if !self.installed() {
604            return Ok(out);
605        }
606        let lock = mlua_pkg::lockfile::Lockfile::read(&self.lockfile)?;
607        let dest = self.root.join("types");
608        for p in &lock.pkg {
609            let Some(root) = self.package_root(p) else {
610                continue;
611            };
612            copy_declarations(
613                &root.join("types"),
614                &dest,
615                &Origin {
616                    name: p.name.clone(),
617                    sha: p.sha.clone(),
618                    under: PathBuf::from("types"),
619                },
620                false,
621                &mut out,
622            )?;
623        }
624        Ok(out)
625    }
626
627    /// Copy one library's declarations out of teal-types into `types/`.
628    ///
629    /// teal-types is where the Teal ecosystem collects declarations for libraries that
630    /// ship none of their own, laid out as `types/<library>/<module>.d.tl`. Nothing there
631    /// ties a declaration to a version of the library it describes: the rocks are
632    /// versioned on their own count, declare no dependency on the library, and name no
633    /// revision of it. So the `.src` note beside each file is the whole of the record —
634    /// what was taken, and from which commit of the collection.
635    pub fn add_types(&self, library: &str, force: bool) -> anyhow::Result<TypesSync> {
636        let cache = pkgs_dir(&self.root).cache();
637        std::fs::create_dir_all(&cache)?;
638        let fetcher = mlua_pkg::fetcher::GitFetcher::new(cache);
639        let got = mlua_pkg::fetcher::Fetcher::fetch(
640            &fetcher,
641            &mlua_pkg::manifest::Dep {
642                git: TEAL_TYPES_GIT.to_string(),
643                tag: None,
644                rev: None,
645                branch: None,
646                entry: None,
647                target_dir: None,
648                patch_dir: None,
649                patch_drift: None,
650            },
651        )?;
652        self.add_types_from(&got.cache_path, library, &got.sha, force)
653    }
654
655    /// The same from a checkout already on disk, recording `sha` as the revision it is at.
656    pub fn add_types_from(
657        &self,
658        checkout: &Path,
659        library: &str,
660        sha: &str,
661        force: bool,
662    ) -> anyhow::Result<TypesSync> {
663        let under = Path::new("types").join(library);
664        let published = checkout.join(&under);
665        if !published.is_dir() {
666            anyhow::bail!("{}", no_such_library(checkout, library));
667        }
668        let mut out = TypesSync::default();
669        copy_declarations(
670            &published,
671            &self.root.join("types"),
672            &Origin {
673                name: TEAL_TYPES_NAME.to_string(),
674                sha: sha.to_string(),
675                under,
676            },
677            force,
678            &mut out,
679        )?;
680        Ok(out)
681    }
682
683    /// What mlua-pkg is handed to act on this project: htl's own directories, and the
684    /// manifest read from disk.
685    ///
686    /// The library reads neither the environment nor the working directory to decide where
687    /// packages go — it takes the [`mlua_pkg::PkgDir`] it is given — so [`pkgs_dir`] is the
688    /// only place that answer is written down, for the installer and the checker alike.
689    fn config(&self) -> mlua_pkg::Config {
690        mlua_pkg::Config::new(mlua_pkg::Project::in_dir(&self.root, pkgs_dir(&self.root)))
691    }
692
693    /// Fetch every dependency the manifest declares, and write the lockfile.
694    ///
695    /// The report says what each one resolved to and where it was placed, including
696    /// whether it came from a `patch_dir`; nothing is printed here. Declarations a
697    /// dependency publishes are a separate step ([`Project::sync_types`]) because they are
698    /// copied into the project rather than installed.
699    pub fn install(&self) -> anyhow::Result<mlua_pkg::ops::InstallReport> {
700        Ok(mlua_pkg::ops::install(&self.config())?)
701    }
702
703    /// Write a dependency into the manifest. `install` is what fetches it.
704    ///
705    /// mlua-pkg replaces the whole `[deps.<name>]` entry and `AddSpec` carries no
706    /// `patch_dir`, so adding a dependency that is already patched would drop the key that
707    /// binds `patches/<dep>` to it — the project would keep building, against upstream,
708    /// with the copy sitting unread in the tree. What the entry declared about its patch is
709    /// carried across and reported.
710    pub fn add(&self, spec: mlua_pkg::ops::AddSpec) -> anyhow::Result<AddDone> {
711        let name = spec.name.clone();
712        let previous = mlua_pkg::manifest::Manifest::from_path(&self.manifest)
713            .ok()
714            .and_then(|m| m.deps.get(&name).cloned());
715        let report = mlua_pkg::ops::add(&self.config(), spec)?;
716        let Some(dep) = previous else {
717            return Ok(AddDone {
718                report,
719                kept_patch_dir: None,
720            });
721        };
722        let Some(dir) = dep.patch_dir.clone() else {
723            return Ok(AddDone {
724                report,
725                kept_patch_dir: None,
726            });
727        };
728        set_dep_key(&self.manifest, &name, "patch_dir", &to_toml_path(&dir))?;
729        if let Some(drift) = dep.patch_drift {
730            let value = match drift {
731                mlua_pkg::manifest::PatchDrift::Warn => "warn",
732                mlua_pkg::manifest::PatchDrift::Error => "error",
733            };
734            set_dep_key(&self.manifest, &name, "patch_drift", value)?;
735        }
736        Ok(AddDone {
737            report,
738            kept_patch_dir: Some(dir),
739        })
740    }
741
742    /// Refresh dependencies, bump the pins that follow releases, and install what changed.
743    pub fn update(
744        &self,
745        opts: mlua_pkg::ops::UpdateOpts,
746    ) -> anyhow::Result<mlua_pkg::ops::UpdateReport> {
747        let mut report = mlua_pkg::ops::update(&self.config(), opts)?;
748        // mlua-pkg walks a map, so the same project reports its dependencies in a
749        // different order on every run. A report that is read by a person, and diffed
750        // against the last one, is sorted.
751        report.entries.sort_by(|a, b| a.0.cmp(&b.0));
752        Ok(report)
753    }
754
755    /// Remove cached packages the lockfile no longer refers to (`all`: the whole cache).
756    ///
757    /// Never touches what install placed under `vendored/`: a dangling link there is
758    /// repaired by the next install.
759    pub fn clean(&self, all: bool) -> anyhow::Result<mlua_pkg::ops::CleanReport> {
760        Ok(mlua_pkg::ops::clean(&self.config(), all)?)
761    }
762
763    /// Take a dependency's source into `patches/<dep>/`, where the project owns it.
764    ///
765    /// The whole package root is copied, so the dep's `types/` comes with it, and
766    /// `patch_dir` on that dependency in the manifest says which dependency the directory
767    /// stands in for. There is no patch file and nothing is applied: from here the
768    /// directory is the project's code, edited and committed with git like the rest of the
769    /// tree, and install resolves the dependency from it for as long as the pin still
770    /// resolves to the revision the copy was taken from (`patch_base` in the lockfile).
771    /// When the pin moves on, install uses the new revision, leaves the copy alone and
772    /// says so on every install until the patch is refreshed or removed.
773    ///
774    /// On a dependency that is already patched this refreshes the copy from the revision
775    /// the pin now resolves to and records that as the new base. The copy is overwritten
776    /// rather than merged — carrying the project's own change forward onto it is a merge
777    /// git performs, and it can only do that if the change is committed — so a directory
778    /// with uncommitted changes is refused unless `force`.
779    pub fn patch(&self, name: &str, force: bool) -> anyhow::Result<mlua_pkg::ops::PatchReport> {
780        let manifest = mlua_pkg::manifest::Manifest::from_path(&self.manifest)?;
781        let dep = manifest.deps.get(name).ok_or_else(|| {
782            anyhow::anyhow!(
783                "no dependency '{name}' in {}: `htl pkg patch` takes a name the manifest declares",
784                self.manifest.display()
785            )
786        })?;
787        // Where the copy goes. htl's own layout is `patches/<dep>`; a manifest that
788        // already names a directory keeps the one it names.
789        let declared = dep.patch_dir.is_some();
790        let rel = match &dep.patch_dir {
791            Some(p) => p.clone(),
792            None => PathBuf::from(format!("{PATCHES_DIR}/{name}")),
793        };
794        let dir = self.root.join(&rel);
795        if dir.exists() && !force {
796            refuse_if_uncommitted(&self.root, &rel)?;
797        }
798
799        let before = std::fs::read_to_string(&self.manifest)?;
800        if !declared {
801            set_dep_key(&self.manifest, name, "patch_dir", &to_toml_path(&rel))?;
802        }
803
804        // mlua-pkg does the copy and the bookkeeping: it fetches the pin, copies the
805        // package root into `patch_dir`, and records the commit it came from as
806        // `patch_base`. `force` there is the "directory already exists" refusal, which is
807        // the question already answered above against git rather than against the
808        // directory's existence.
809        let opts = mlua_pkg::ops::PatchOpts {
810            name: name.to_string(),
811            force: true,
812        };
813        match mlua_pkg::ops::patch(&self.config(), opts) {
814            Ok(report) => {
815                drop_dot_git(&report.patch_dir)?;
816                Ok(report)
817            }
818            Err(e) => {
819                // A `patch_dir` naming a directory that was never written turns every
820                // later install into a drift report, so the manifest goes back as it was.
821                if !declared {
822                    let _ = std::fs::write(&self.manifest, &before);
823                }
824                Err(e.into())
825            }
826        }
827    }
828
829    /// Where each patched dependency stands, read back from the manifest and the lockfile.
830    ///
831    /// A patch is bound to the revision it was taken from. Install compares the two itself
832    /// and falls back to upstream when they differ; this reads the same two values
833    /// afterwards so htl can say what happened in its own verbs — mlua-pkg's warning names
834    /// `mlua-pkg patch --force`, which skips the question htl asks git and leaves the
835    /// dependency's `.git` in the copy.
836    pub fn patch_status(&self) -> Vec<PatchStatus> {
837        let lock = mlua_pkg::lockfile::Lockfile::read(&self.lockfile).ok();
838        self.patches
839            .iter()
840            .map(|p| {
841                let locked = lock
842                    .as_ref()
843                    .and_then(|l| l.pkg.iter().find(|e| e.name == p.name));
844                let base = locked.and_then(|e| e.patch_base.clone());
845                let sha = locked.map(|e| e.sha.clone());
846                let in_use = p.dir.is_dir() && base.is_some() && base == sha;
847                PatchStatus {
848                    name: p.name.clone(),
849                    dir: p.dir.clone(),
850                    base,
851                    locked: sha,
852                    in_use,
853                }
854            })
855            .collect()
856    }
857
858    /// The package root behind `vendored/<name>`.
859    ///
860    /// That symlink points at the package root itself, and the lockfile's `entry` says
861    /// where below it `require` looks — so what a dep publishes beside its entry, `types/`
862    /// among it, is reached from here without subtracting the entry again. mlua-pkg moved
863    /// the symlink from the entry directory to the root in 0.11; a dep whose entry is
864    /// `src/` used to need the difference popped off and now must not.
865    fn package_root(&self, p: &mlua_pkg::lockfile::LockedPkg) -> Option<PathBuf> {
866        std::fs::canonicalize(self.vendored.join(&p.name)).ok()
867    }
868}
869
870/// Write one key onto `[deps.<name>]`, leaving the rest of the file as it was.
871///
872/// The manifest is a file a person wrote: its comments say why a dependency is pinned
873/// where it is, and its order is the order they put things in. `toml_edit` keeps both,
874/// where re-serialising the parsed manifest would not.
875fn set_dep_key(manifest: &Path, name: &str, key: &str, value: &str) -> anyhow::Result<()> {
876    let text = std::fs::read_to_string(manifest)?;
877    let mut doc = text.parse::<toml_edit::DocumentMut>()?;
878    let deps = doc
879        .get_mut("deps")
880        .and_then(|i| i.as_table_like_mut())
881        .with_context(|| format!("no [deps] table in {}", manifest.display()))?;
882    let entry = deps
883        .get_mut(name)
884        .and_then(|i| i.as_table_like_mut())
885        .with_context(|| format!("[deps.{name}] is not a table"))?;
886    entry.insert(key, toml_edit::value(value));
887    std::fs::write(manifest, doc.to_string())?;
888    Ok(())
889}
890
891/// A manifest-relative path as the manifest spells it: `/` on every platform, because the
892/// file is read on all of them.
893fn to_toml_path(p: &Path) -> String {
894    p.components()
895        .map(|c| c.as_os_str().to_string_lossy())
896        .collect::<Vec<_>>()
897        .join("/")
898}
899
900/// Take the dependency's own `.git` out of the copy.
901///
902/// The copy is made from a checkout, so it arrives with the repository it was checked out
903/// of. Left in place, git reads `patches/<dep>` as an embedded repository and records it as
904/// a gitlink — a commit id pointing at a repository nobody else has, with none of the files
905/// in this project's history. What the patch is for is the opposite of that: ordinary
906/// files, committed here, diffed and reviewed here.
907fn drop_dot_git(dir: &Path) -> anyhow::Result<()> {
908    let dot_git = dir.join(".git");
909    let meta = match std::fs::symlink_metadata(&dot_git) {
910        Ok(m) => m,
911        Err(_) => return Ok(()),
912    };
913    if meta.is_dir() {
914        std::fs::remove_dir_all(&dot_git)
915    } else {
916        // A worktree checkout has a `.git` file pointing elsewhere.
917        std::fs::remove_file(&dot_git)
918    }
919    .with_context(|| format!("removing {}", dot_git.display()))
920}
921
922/// Refuse to overwrite a patched copy that git has not been told about.
923///
924/// The refresh replaces the directory with the pinned upstream, and the project's own
925/// change survives that only through git: it is carried forward by merging the new copy
926/// with the history of the old one. A change git cannot see is a change that cannot be
927/// carried forward, so it is named here and the refresh does not happen.
928fn refuse_if_uncommitted(root: &Path, rel: &Path) -> anyhow::Result<()> {
929    match uncommitted(root, rel) {
930        Ok(changes) if changes.is_empty() => Ok(()),
931        Ok(changes) => {
932            let mut msg = format!(
933                "{} has uncommitted changes, and refreshing it from the pin overwrites \
934                 them. Commit them first — git is what carries them onto the refreshed \
935                 copy — or pass --force to discard them:",
936                rel.display()
937            );
938            for c in changes.iter().take(10) {
939                msg.push_str("\n  ");
940                msg.push_str(c);
941            }
942            if changes.len() > 10 {
943                msg.push_str(&format!("\n  and {} more", changes.len() - 10));
944            }
945            anyhow::bail!("{msg}")
946        }
947        Err(why) => anyhow::bail!(
948            "cannot tell whether {} has uncommitted changes ({why}), and refreshing it \
949             from the pin overwrites whatever is in it. Pass --force to refresh it anyway.",
950            rel.display()
951        ),
952    }
953}
954
955/// What `git status` reports under `rel`, one entry per line as it prints them.
956///
957/// Untracked files count: the question is what would be lost, and a file git was never
958/// told about is lost the same way an edited one is. `Err` is what could not be asked
959/// rather than what came back dirty — no `git` on PATH, or a tree that is not a
960/// repository. The pathspec is the manifest-relative one and the command runs at the
961/// project root, so git reads it the way it reads any path a person types there.
962fn uncommitted(root: &Path, rel: &Path) -> Result<Vec<String>, String> {
963    let out = std::process::Command::new("git")
964        .arg("-C")
965        .arg(root)
966        .args(["status", "--porcelain", "--"])
967        .arg(rel)
968        .output()
969        .map_err(|e| match e.kind() {
970            std::io::ErrorKind::NotFound => "no `git` on PATH".to_string(),
971            _ => e.to_string(),
972        })?;
973    if !out.status.success() {
974        let why = String::from_utf8_lossy(&out.stderr).trim().to_string();
975        return Err(if why.is_empty() {
976            format!("git exited {}", out.status)
977        } else {
978            why
979        });
980    }
981    Ok(String::from_utf8_lossy(&out.stdout)
982        .lines()
983        .map(|l| l.trim_end().to_string())
984        .collect())
985}
986
987/// Where the Teal ecosystem collects declarations for libraries that ship none of their
988/// own: `types/<library>/<module>.d.tl`, published to LuaRocks one library at a time as
989/// `<library>-tl-type`.
990pub const TEAL_TYPES_GIT: &str = "https://github.com/teal-language/teal-types";
991
992/// What the `.src` notes call it.
993const TEAL_TYPES_NAME: &str = "teal-types";
994
995/// What [`Project::sync_types`] and [`Project::add_types`] did: one entry per declaration
996/// they were offered.
997#[derive(Debug, Default)]
998pub struct TypesSync {
999    /// Written into `types/`, with what published it.
1000    pub written: Vec<(PathBuf, String)>,
1001    /// Left as it was, because `types/` already had that name — with what offered one too.
1002    pub taken: Vec<(PathBuf, String)>,
1003}
1004
1005/// Where a declaration came from, as the `.src` note beside it records it: what published
1006/// it, at which revision, and the path it had there.
1007struct Origin {
1008    name: String,
1009    sha: String,
1010    under: PathBuf,
1011}
1012
1013/// Copy every `.d.tl` under `from` into `to`, keeping the path below `from`.
1014///
1015/// Keeping it is what keeps the module name: `socket/http.d.tl` is
1016/// `require("socket.http")`, and flattening it into `types/http.d.tl` would rename the
1017/// module to one the library never had.
1018fn copy_declarations(
1019    from: &Path,
1020    to: &Path,
1021    origin: &Origin,
1022    force: bool,
1023    out: &mut TypesSync,
1024) -> anyhow::Result<()> {
1025    if !from.is_dir() {
1026        return Ok(());
1027    }
1028    let mut found: Vec<PathBuf> = walkdir::WalkDir::new(from)
1029        .into_iter()
1030        .filter_map(Result::ok)
1031        .filter(|e| e.file_type().is_file())
1032        .map(walkdir::DirEntry::into_path)
1033        .filter(|p| crate::is_declaration(p))
1034        .collect();
1035    found.sort();
1036    for src in found {
1037        let rel = src.strip_prefix(from).unwrap_or(&src).to_path_buf();
1038        let target = to.join(&rel);
1039        if target.exists() && !force {
1040            out.taken.push((target, origin.name.clone()));
1041            continue;
1042        }
1043        if let Some(parent) = target.parent() {
1044            std::fs::create_dir_all(parent)?;
1045        }
1046        std::fs::copy(&src, &target)?;
1047        // Beside it, the one thing the Lua ecosystem records nowhere: which revision of
1048        // what this declaration was taken from. Without it, staleness is not a question
1049        // anyone can ask.
1050        let mut note = target.clone().into_os_string();
1051        note.push(".src");
1052        std::fs::write(
1053            PathBuf::from(note),
1054            format!(
1055                "{} {} {}\n",
1056                origin.name,
1057                origin.sha,
1058                origin.under.join(&rel).display()
1059            ),
1060        )?;
1061        out.written.push((target, origin.name.clone()));
1062    }
1063    Ok(())
1064}
1065
1066/// What to say when the collection has no such library: the names it does have that look
1067/// related, or how many it holds at all — a list of every one of them is not an error
1068/// message.
1069fn no_such_library(checkout: &Path, library: &str) -> String {
1070    let mut names: Vec<String> = std::fs::read_dir(checkout.join("types"))
1071        .into_iter()
1072        .flatten()
1073        .filter_map(|e| e.ok())
1074        .filter(|e| e.path().is_dir())
1075        .map(|e| e.file_name().to_string_lossy().into_owned())
1076        .collect();
1077    names.sort();
1078    let near: Vec<&str> = names
1079        .iter()
1080        .filter(|n| n.contains(library) || library.contains(n.as_str()))
1081        .map(String::as_str)
1082        .collect();
1083    if near.is_empty() {
1084        format!(
1085            "teal-types has no declarations for `{library}` ({} libraries there)",
1086            names.len()
1087        )
1088    } else {
1089        format!(
1090            "teal-types has no declarations for `{library}` — it has {}",
1091            near.join(", ")
1092        )
1093    }
1094}
1095
1096/// One [`TealResolver`] per `[[contract]]` in `htl.toml`, in declaration order, so the
1097/// host and `htl check` enforce the same contracts from the same source. `root` is the
1098/// directory holding `htl.toml` (the path [`HtlConfig::find`](crate::config::HtlConfig::find)
1099/// returns, minus the file name). Add them to a `Registry` before the plain resolvers.
1100pub fn contract_resolvers(
1101    root: &Path,
1102    cfg: &crate::config::HtlConfig,
1103) -> Result<Vec<TealResolver>, InitError> {
1104    let (contracts, _) = crate::contract::resolve(root, cfg);
1105    let mut out = Vec::new();
1106    for c in &contracts {
1107        out.extend(TealResolver::for_contract(root, cfg, c)?);
1108    }
1109    Ok(out)
1110}
1111
1112impl crate::Htl {
1113    /// Make the project's vendored deps visible to the Teal checker and to the
1114    /// prelude's strict searcher (`htl run` / `htl test` without a Registry).
1115    pub fn apply_project(&self, p: &Project) -> anyhow::Result<()> {
1116        let _ = std::fs::create_dir_all(&p.vendored);
1117        self.add_path(&p.vendored)?;
1118        for d in &p.target_dirs {
1119            self.add_path(d)?;
1120        }
1121        // The project's own modules: `<root>/src` (the scaffold layout) so a script anywhere
1122        // in the project resolves them the same way `tests/` does.
1123        let src = p.root.join("src");
1124        if src.is_dir() {
1125            self.add_path(&src)?;
1126        }
1127        Ok(())
1128    }
1129}
1130
1131/// Error raised when a `.tl` module fails the type check at `require` time.
1132#[derive(Debug)]
1133pub enum TealResolveError {
1134    TypeCheck {
1135        module: String,
1136        errors: Vec<String>,
1137    },
1138    /// The module type-checks on its own but is not assignable to the resolver's
1139    /// [`expect_type`](TealResolver::expect_type).
1140    Expectation {
1141        module: String,
1142        expected: String,
1143        errors: Vec<String>,
1144    },
1145    /// [`require_fields`](TealResolver::require_fields): required fields absent at run time.
1146    MissingFields {
1147        module: String,
1148        expected: String,
1149        fields: Vec<String>,
1150    },
1151    Read {
1152        module: String,
1153        source: ReadError,
1154    },
1155}
1156
1157impl std::fmt::Display for TealResolveError {
1158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1159        match self {
1160            Self::TypeCheck { module, errors } => {
1161                write!(f, "Teal type check failed for module '{module}':")?;
1162                for e in errors {
1163                    write!(f, "\n  {e}")?;
1164                }
1165                Ok(())
1166            }
1167            Self::Expectation {
1168                module,
1169                expected,
1170                errors,
1171            } => {
1172                write!(f, "module '{module}' does not satisfy {expected}:")?;
1173                for e in errors {
1174                    write!(f, "\n  {e}")?;
1175                }
1176                write!(
1177                    f,
1178                    "\n  hint: annotate the returned table in the module (`local m: {expected} = {{ ... }}  return m`) \
1179                     to get field-level errors with line numbers"
1180                )
1181            }
1182            Self::MissingFields {
1183                module,
1184                expected,
1185                fields,
1186            } => write!(
1187                f,
1188                "module '{module}' is missing required field(s) of {expected}: {} (every field of that record must be non-nil)",
1189                fields.join(", ")
1190            ),
1191            Self::Read { module, source } => write!(f, "reading module '{module}': {source}"),
1192        }
1193    }
1194}
1195
1196impl std::error::Error for TealResolveError {}
1197
1198/// Is `name` registered in `package.preload` (host-provided implementation)?
1199fn preloaded(lua: &Lua, name: &str) -> mlua::Result<bool> {
1200    let package: Table = lua.globals().get("package")?;
1201    let preload: Table = package.get("preload")?;
1202    Ok(!matches!(preload.get::<Value>(name)?, Value::Nil))
1203}
1204
1205impl Resolver for TealResolver {
1206    fn resolve(&self, lua: &Lua, name: &str) -> Option<mlua::Result<Value>> {
1207        let relative = name.replace(self.module_separator, "/");
1208        // Flat packages: `<name>/<name>.tl` stands in for `<name>/init.tl`.
1209        let last = relative.rsplit('/').next().unwrap_or(&relative).to_string();
1210        let candidates = [
1211            (format!("{relative}.tl"), false),
1212            (format!("{relative}/init.tl"), false),
1213            (format!("{relative}/{last}.tl"), false),
1214            (format!("{relative}.d.tl"), true),
1215        ];
1216        let h = match Self::prelude(lua) {
1217            Ok(h) => h,
1218            Err(e) => return Some(Err(e)),
1219        };
1220        if let Err(e) = self.ensure_checker_path(lua, &h) {
1221            return Some(Err(e));
1222        }
1223        for (candidate, type_only) in &candidates {
1224            match self.sandbox.read(Path::new(candidate)) {
1225                Ok(Some(file)) => {
1226                    if *type_only {
1227                        // A `.d.tl` may describe a plain `.lua` served by a later resolver
1228                        // (FsResolver / VendoredResolver): step aside if one is present.
1229                        // Native modules must be registered *before* this resolver.
1230                        if self.has_lua_sibling(&relative) {
1231                            return None;
1232                        }
1233                        // ... or that the host registered in `package.preload` (a Rust
1234                        // `#[host_module]`, `Htl::preload_value`). The Registry's searcher
1235                        // runs *before* Lua's preload searcher, so this is the only chance.
1236                        match preloaded(lua, name) {
1237                            Ok(true) => return None,
1238                            Ok(false) => {}
1239                            Err(e) => return Some(Err(e)),
1240                        }
1241                        // Declaration-only module: nothing to run. Hand require a table whose
1242                        // lookups explain that the implementation lives elsewhere.
1243                        return Some(h.get::<Function>("type_only_module").and_then(|f| {
1244                            f.call::<Value>((name, file.resolved_path.to_string_lossy().as_ref()))
1245                        }));
1246                    }
1247                    let loaded =
1248                        match self.load_teal(lua, &h, &file.content, &file.resolved_path, name) {
1249                            Ok(v) => v,
1250                            Err(e) => return Some(Err(e)),
1251                        };
1252                    if !self.held(name) {
1253                        return Some(Ok(loaded));
1254                    }
1255                    match self.missing_fields(&h, &loaded) {
1256                        Ok(m) if m.is_empty() => return Some(Ok(loaded)),
1257                        Ok(missing) => {
1258                            return Some(Err(mlua::Error::external(
1259                                TealResolveError::MissingFields {
1260                                    module: name.to_string(),
1261                                    expected: self.expect_type.clone().unwrap_or_default(),
1262                                    fields: missing,
1263                                },
1264                            )));
1265                        }
1266                        Err(e) => return Some(Err(e)),
1267                    }
1268                }
1269                Ok(None) => continue,
1270                Err(source) => {
1271                    return Some(Err(mlua::Error::external(TealResolveError::Read {
1272                        module: name.to_string(),
1273                        source,
1274                    })));
1275                }
1276            }
1277        }
1278        None
1279    }
1280}