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    /// The character in a module name that stands for a directory boundary. `.` by
99    /// default, as `require("a.b")` writes it.
100    ///
101    /// It is a setting rather than a constant because the name a host registers a module
102    /// under is the host's to choose, and one that uses `/` or `::` still has to reach
103    /// `a/b.tl` on disk. Only the separator moves: the candidate list built from it
104    /// (`.tl`, `/init.tl`, `/<last>.tl`, `.d.tl`) is the same whatever it is.
105    pub fn with_module_separator(mut self, sep: char) -> Self {
106        self.module_separator = sep;
107        self
108    }
109
110    /// Require every `.tl` module served here to be assignable to `type_path`, written
111    /// as `"<module>.<Type>"` (e.g. `"defs.Mod"`, where `defs.tl` / `defs.d.tl` declares
112    /// `Mod`). A module that does not satisfy it fails at `require` time even if it never
113    /// annotates its own return value.
114    ///
115    /// What this catches is what Teal's record assignability catches: a field of the
116    /// **wrong type** (`hp = "lots"` for `hp: integer`). On its own it does **not** catch
117    /// a **missing** field: every Teal record field is nilable, so `{ name = "x" }`
118    /// satisfies `Mod` with `monsters` absent. Add [`require_fields`](Self::require_fields)
119    /// to reject that at run time, or nil-guard optional data on the host side.
120    pub fn expect_type(mut self, type_path: impl Into<String>) -> Self {
121        self.expect_type = Some(type_path.into());
122        self
123    }
124
125    /// With [`expect_type`](Self::expect_type): after the type check, these fields must
126    /// be present (non-nil) in the loaded module, or the `require` fails naming the ones
127    /// that are absent.
128    ///
129    /// Naming them rather than taking all of them is what lets the type grow: the fields
130    /// listed here are the contract, and a field added to the record later is optional
131    /// until it is added here too. A name the record does not declare is an error at the
132    /// first `require`, not a line that quietly does nothing.
133    ///
134    /// [`require_all_fields`](Self::require_all_fields) is the every-field form, and the
135    /// static counterpart of both is `require_fields` in `[[contract]]`.
136    pub fn require_fields(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
137        self.require_fields =
138            crate::config::RequireFields::Named(names.into_iter().map(Into::into).collect());
139        self
140    }
141
142    /// With [`expect_type`](Self::expect_type): every field the record declares must be
143    /// present (non-nil) in the loaded module. Adding a field to the record makes every
144    /// module that predates it fail, which is what
145    /// [`require_fields`](Self::require_fields) exists to avoid; use this where the type
146    /// is settled, or where every field really is mandatory.
147    pub fn require_all_fields(mut self) -> Self {
148        self.require_fields = crate::config::RequireFields::All(true);
149        self
150    }
151
152    /// Let the Teal checker also search `dir` when resolving `require`s inside served
153    /// modules (and the module named by `expect_type`). The sandbox root is always
154    /// searched; add the project `src/` here when `defs.tl` lives there.
155    pub fn with_checker_path(mut self, dir: impl Into<PathBuf>) -> Self {
156        self.checker_paths.push(dir.into());
157        self
158    }
159
160    /// Modules (by `require` name) served here that are *not* held to `expect_type` /
161    /// `require_fields`: an SDK the host writes into the same dir, for instance. The
162    /// module that declares the expected type is always exempt.
163    pub fn exclude_modules(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
164        self.exclude.extend(names.into_iter().map(Into::into));
165        self
166    }
167
168    /// Hold only this module name to `expect_type` / `require_fields`; everything else
169    /// served here is type-checked as usual but not against the contract.
170    pub fn only_module(mut self, name: impl Into<String>) -> Self {
171        self.only_module = Some(name.into());
172        self
173    }
174
175    /// Does the contract (`expect_type` / `require_fields`) apply to `name`?
176    fn held(&self, name: &str) -> bool {
177        if self.expect_type.is_none() || self.exclude.iter().any(|e| e == name) {
178            return false;
179        }
180        self.only_module.as_deref().is_none_or(|m| m == name)
181    }
182
183    /// Resolvers for one `[[contract]]` of `htl.toml`: one per concrete contract dir
184    /// (a `dir` with `*` expands to every subdirectory), each with `expect_type(type)`
185    /// and the contract's `require_fields` as written, its `exclude` / `module`, and
186    /// the project's search paths visible to the checker
187    /// ([`search_paths`](crate::config::HtlConfig::search_paths): `root`, its `src/` and
188    /// `types/`, then `[check] paths`). `root` is the directory holding `htl.toml`. The
189    /// `contract-unenforced` lint of `htl check` recognises this call.
190    pub fn for_contract(
191        root: &Path,
192        cfg: &crate::config::HtlConfig,
193        c: &crate::contract::Resolved,
194    ) -> Result<Vec<Self>, InitError> {
195        c.dirs(root)
196            .into_iter()
197            .map(|d| Self::for_contract_dir(root, &d, cfg, c))
198            .collect()
199    }
200
201    /// One resolver for the concrete contract directory `dir` (see [`for_contract`](Self::for_contract)).
202    pub fn for_contract_dir(
203        root: &Path,
204        dir: &Path,
205        cfg: &crate::config::HtlConfig,
206        c: &crate::contract::Resolved,
207    ) -> Result<Self, InitError> {
208        let mut r = Self::new_symlink_aware(dir)?
209            .expect_type(c.type_path.clone())
210            .exclude_modules(c.exclude.iter().cloned());
211        // The same paths the `contract` lint checks through (`Htl::apply_config`), so a
212        // contract type declared in `types/` resolves in the run as well as in the check.
213        for p in cfg.search_paths(root) {
214            r = r.with_checker_path(p);
215        }
216        if let Some(m) = &c.module {
217            r = r.only_module(m.clone());
218        }
219        r.require_fields = c.require_fields.clone();
220        Ok(r)
221    }
222
223    /// Required fields of the expected record that are nil in `value`.
224    fn missing_fields(&self, h: &Table, value: &Value) -> mlua::Result<Vec<String>> {
225        let Some(tp) = &self.expect_type else {
226            return Ok(Vec::new());
227        };
228        if !self.require_fields.is_on() {
229            return Ok(Vec::new());
230        }
231        let f: Function = h.get("record_fields")?;
232        let declared: Option<Vec<String>> = f
233            .call::<Option<Table>>(tp.as_str())?
234            .map(|t| t.sequence_values::<String>().collect::<mlua::Result<_>>())
235            .transpose()?;
236        let Some(declared) = declared else {
237            return Err(mlua::Error::external(format!(
238                "TealResolver::require_fields: record type {tp:?} not found by the checker"
239            )));
240        };
241        // A listed name the record does not declare is a mistake in the host's own
242        // wiring; saying so beats holding modules to a field that cannot exist.
243        let names = match self.require_fields.named() {
244            None => declared,
245            Some(wanted) => {
246                let unknown: Vec<&str> = wanted
247                    .iter()
248                    .filter(|w| !declared.iter().any(|d| d == *w))
249                    .map(|w| w.as_str())
250                    .collect();
251                if !unknown.is_empty() {
252                    return Err(mlua::Error::external(format!(
253                        "TealResolver::require_fields names field(s) that {tp} does not declare: {}",
254                        unknown.join(", ")
255                    )));
256                }
257                wanted.to_vec()
258            }
259        };
260        let Value::Table(t) = value else {
261            return Ok(names); // not a table at all: everything is missing
262        };
263        let mut missing = Vec::new();
264        for n in names {
265            if matches!(t.get::<Value>(n.as_str())?, Value::Nil) {
266                missing.push(n);
267            }
268        }
269        Ok(missing)
270    }
271
272    /// Check `local m: <T> = require("<name>")` against the checker; `None` when it holds.
273    fn expectation_errors(&self, h: &Table, name: &str) -> mlua::Result<Option<Vec<String>>> {
274        let Some(tp) = &self.expect_type else {
275            return Ok(None);
276        };
277        let (module, _) = tp.split_once('.').ok_or_else(|| {
278            mlua::Error::external(format!(
279                "TealResolver::expect_type: expected \"<module>.<Type>\", got {tp:?}"
280            ))
281        })?;
282        // The module that declares the type is not itself held to it.
283        if name == module {
284            return Ok(None);
285        }
286        let stub = format!(
287            "local {module} = require(\"{module}\")\nlocal m: {tp} = require(\"{name}\")\nreturn m\n"
288        );
289        // Fresh checker env per stub: several resolvers may serve a module of the same
290        // name (one per contract dir) and must not share a cached type for it.
291        let check: Function = h.get("check_stub")?;
292        let errors: Table =
293            check.call((stub.as_str(), format!("<expect {tp} for module '{name}'>")))?;
294        let msgs: Vec<String> = errors
295            .sequence_values::<String>()
296            .collect::<mlua::Result<_>>()?;
297        Ok(if msgs.is_empty() { None } else { Some(msgs) })
298    }
299
300    fn prelude(lua: &Lua) -> mlua::Result<Table> {
301        if let Ok(t) = lua.named_registry_value::<Table>(PRELUDE_REGISTRY_KEY) {
302            return Ok(t);
303        }
304        // A runtime state whose checker lives in another Lua (`Htl::with_checker`).
305        if let Some(c) = lua.app_data_ref::<crate::CheckerHandle>() {
306            return Ok(c.0.clone());
307        }
308        Err(mlua::Error::external(
309            "htl::pkg::TealResolver: this Lua has no htl prelude (create it with Htl::new / Htl::from_lua)",
310        ))
311    }
312
313    /// The checker resolves `require`s inside `.tl` via `package.path`; make sure the
314    /// root is visible there (once).
315    fn ensure_checker_path(&self, lua: &Lua, h: &Table) -> mlua::Result<()> {
316        if self.path_added.swap(true, Ordering::Relaxed) {
317            return Ok(());
318        }
319        let f: Function = h.get("add_path")?;
320        // Back to front: `add_path` prepends, so this leaves the sandbox root consulted
321        // first (a module resolving its siblings) and the project's paths behind it, in
322        // the order `search_paths` states. Adding them front to back reversed both.
323        for p in self.checker_paths.iter().rev() {
324            if p.is_dir() {
325                f.call::<()>(p.to_string_lossy().as_ref())?;
326            }
327        }
328        if let Some(root) = &self.root {
329            f.call::<()>(root.to_string_lossy().as_ref())?;
330        }
331        let _ = lua;
332        Ok(())
333    }
334
335    fn has_lua_sibling(&self, relative: &str) -> bool {
336        for cand in [format!("{relative}.lua"), format!("{relative}/init.lua")] {
337            if let Ok(Some(_)) = self.sandbox.read(Path::new(&cand)) {
338                return true;
339            }
340        }
341        false
342    }
343
344    fn load_teal(
345        &self,
346        lua: &Lua,
347        h: &Table,
348        src: &str,
349        resolved: &Path,
350        name: &str,
351    ) -> mlua::Result<Value> {
352        let gen_fn: Function = h.get("gen_string")?;
353        let (code, info): (Option<String>, Table) =
354            gen_fn.call((src, resolved.to_string_lossy().as_ref()))?;
355        let Some(code) = code else {
356            let errors: Table = info.get("errors")?;
357            let msgs: Vec<String> = errors
358                .sequence_values::<String>()
359                .collect::<mlua::Result<_>>()?;
360            return Err(mlua::Error::external(TealResolveError::TypeCheck {
361                module: name.to_string(),
362                errors: msgs,
363            }));
364        };
365        if self.held(name)
366            && let Some(errs) = self.expectation_errors(h, name)?
367        {
368            return Err(mlua::Error::external(TealResolveError::Expectation {
369                module: name.to_string(),
370                expected: self.expect_type.clone().unwrap_or_default(),
371                errors: errs,
372            }));
373        }
374        let chunk = lua
375            .load(code)
376            .set_name(format!("@{}", resolved.display()))
377            .into_function()?;
378        chunk.call::<Value>((name, resolved.to_string_lossy().as_ref()))
379    }
380}
381
382// ---------------------------------------------------------------- Project (mlua-pkg.toml)
383
384/// An `mlua-pkg.toml` project: where the manifest, lockfile and installed deps live.
385///
386/// Installed deps go under [`pkgs_dir`] — `<root>/.htl/modules`, beside the check cache
387/// and regenerated the same way: from the manifest and the lockfile rather than from the
388/// project's own sources. Deps that are *committed* are the other thing, and they are
389/// declared: `target_dirs`.
390#[derive(Debug, Clone)]
391pub struct Project {
392    /// The directory holding `mlua-pkg.toml`, and what every other path here is derived
393    /// from. Canonicalised when [`Project::find`] walked up to it, so two starting points
394    /// under the same project produce the same paths.
395    pub root: PathBuf,
396    /// `<root>/mlua-pkg.toml`. Recorded even when it does not parse: [`Project::at`] takes
397    /// what it can from a broken manifest and leaves the reporting of it to mlua-pkg, so a
398    /// project with a syntax error still has a root and a cache directory to name.
399    pub manifest: PathBuf,
400    /// `<root>/mlua-pkg.lock`. Its presence is the whole of [`installed`](Self::installed):
401    /// a lockfile is what `mlua-pkg install` writes last, so a project that has one has
402    /// deps to resolve and a project that does not has nothing under [`entries`](Self::entries)
403    /// to find.
404    pub lockfile: PathBuf,
405    /// `<root>/.htl/modules`: everything installed, under the same `.htl` the check cache
406    /// lives in, because both are regenerated from the manifest rather than written by
407    /// hand and both are what a `.gitignore` excludes in one line.
408    pub pkgs_dir: PathBuf,
409    /// `pkgs_dir/vendored`: one link per installed dep, pointing at the **package root**
410    /// mlua-pkg fetched (or at the patched copy standing in for it). The name is
411    /// mlua-pkg's own and describes its layout, not htl's — what is in there is installed
412    /// and regenerated, while a copy that is committed to the repo is a `target_dir` dep
413    /// below. This is the root a dep publishes beside its code from (`types/`), and not
414    /// where `require` looks: that is [`entries`](Self::entries).
415    pub vendored: PathBuf,
416    /// `pkgs_dir/entries`: one link per installed dep, pointing at the dep's `entry`
417    /// directory below the root — `../vendored/<name>/<entry>` — so `require("<name>.x")`
418    /// finds `<entry>/x.tl` under it. htl writes these from the lockfile
419    /// ([`Project::link_entries`]); mlua-pkg places the root and records the entry, and
420    /// applies it by rewriting the module name in its own `.lua` resolver, which a checker
421    /// resolving through `package.path` cannot do. A directory of links is the same fact in
422    /// the form a path can express, and the one directory the `.tl` side searches.
423    pub entries: PathBuf,
424    /// Parent directories of `target_dir` deps (physically vendored copies declared in
425    /// the manifest, e.g. `target_dir = "lua/lshape"` -> `<root>/lua`), so
426    /// `require("lshape")` resolves to `<root>/lua/lshape/init.*` like a vendored dep.
427    pub target_dirs: Vec<PathBuf>,
428    /// The `target_dir` copies themselves (`<root>/lua/lshape`), as against the parents
429    /// above.
430    ///
431    /// A copy is a dependency's source that happens to sit in the repo, and `mlua-pkg
432    /// install` rewrites it every time it runs — so it is not the project's to check,
433    /// format or take tests from, and editing one there does not survive the next install.
434    /// What that means for the walkers is in [`crate::project_skip_dirs`].
435    pub vendored_copies: Vec<PathBuf>,
436    /// The `patch_dir` deps: a dependency's source taken into the tree, and what the
437    /// manifest calls it. Unlike a `target_dir` copy, which install rewrites, this one is
438    /// the project's own code — [`Project::patch`] wrote it once and the project edits it
439    /// from then on. What that means for the walkers is in [`crate::patched_dirs`].
440    pub patches: Vec<Patched>,
441}
442
443/// A dependency the project took into its tree: the name the manifest declares it under,
444/// the directory `patch_dir` points at, and the directory inside it that `require` reads.
445/// Both paths absolute.
446///
447/// The name is carried beside the directory because it is what a report says. htl's own
448/// layout puts mathx in `patches/mathx`, but the manifest may name any directory, and a
449/// type error in there is the dependency's name to report either way.
450#[derive(Debug, Clone)]
451pub struct Patched {
452    /// The `[deps]` key, which is also the module name `require` reaches the dependency
453    /// by — and so the name a diagnostic in the copy is reported under.
454    pub name: String,
455    /// Where `patch_dir` points, made absolute against the project root. The manifest
456    /// writes it relative; a walker asked whether it may enter a directory needs the
457    /// absolute form.
458    pub dir: PathBuf,
459    /// `<dir>/<entry>`: the dependency's own require root inside the copy, the same
460    /// directory `entries/<name>` is a link to. `require("<name>.x")` reads `x.tl` from
461    /// here. How it is arrived at is `patch_entry`'s to say, below; what goes on the
462    /// search path is [`search_dir`](Self::search_dir).
463    pub entry: PathBuf,
464}
465
466impl Patched {
467    /// The directory to put on the search path so that the copy answers to the
468    /// dependency's name.
469    ///
470    /// A directory on the path is consulted as `<dir>/<module>`, `<dir>/<module>/init`
471    /// and `<dir>/<module>/<module>` (the three templates `add_path` writes), with the
472    /// dots of the module name as separators. So the directory that resolves a
473    /// dependency exactly as `.htl/modules/entries` does is the one holding
474    /// [`entry`](Self::entry) *as a child named after the dependency* — which is what
475    /// the link `entries/<name>` is, made out of a name and a directory rather than
476    /// found as one.
477    ///
478    /// There is such a directory whenever the entry is named after the dependency:
479    /// `src/<name>` and `lua/<name>`, the layout most packages have, and the copy root
480    /// itself for a package whose entry is `.`, since `htl pkg patch` writes
481    /// `patches/<dep>`. Its parent is the answer, and every name then resolves to the
482    /// file an install would have resolved it to.
483    ///
484    /// A flat package — `entry = "src"` holding `<name>.tl` beside its other modules —
485    /// has no such directory anywhere, because nothing in the copy is named after the
486    /// dependency. The entry itself is the answer there: `require("<name>")` reads
487    /// `<entry>/<name>.tl`, which is the file the link resolves it to as well, and
488    /// `require("<name>.sub")` does not resolve, since the link reaches that at
489    /// `<entry>/sub.tl` and no directory on a path reaches it as `<name>/sub`. Such a
490    /// dependency needs its link, and an install is what writes one.
491    ///
492    /// Both go on the path before everything else, which puts them *last* in it
493    /// ([`Htl::apply_project`](crate::Htl::apply_project)): a name the copy answers is
494    /// one the project's own sources, the entry links and every other dependency have
495    /// already declined, so the flat case's extra names cannot shadow anything.
496    pub fn search_dir(&self) -> PathBuf {
497        match (self.entry.file_name(), self.entry.parent()) {
498            (Some(f), Some(up)) if f == std::ffi::OsStr::new(&self.name) => up.to_path_buf(),
499            _ => self.entry.clone(),
500        }
501    }
502}
503
504/// Which directory inside a patched copy `require` reads it from.
505///
506/// `over` is the entry somebody recorded for this dependency — the lockfile's when an
507/// install has run, else the `entry` the project's own `[deps.<name>]` overrides it with;
508/// mlua-pkg gives the dependency's manifest precedence to the consumer, and the lockfile
509/// is that decision already made. It is joined without asking whether the directory
510/// exists, because a search path lists what a name *would* resolve through, and a tarball
511/// is read before anything in it is built.
512///
513/// With nothing recorded — a fresh clone whose `mlua-pkg.lock` is not committed, or the
514/// copy `cargo package` verifies when it is not — the copy answers for itself: its own
515/// `mlua-pkg.toml` `[package].entry`, which `htl pkg patch` copied along with the sources,
516/// and failing that mlua-pkg's own fallback chain through [`mlua_pkg::resolve_entry`]
517/// (`src/`, then `lua/`, then the root). The chain is mlua-pkg's rule, called rather than
518/// restated, so the directory htl searches and the directory an install would have linked
519/// cannot drift apart. `resolve_entry` picks the first candidate that exists and errors
520/// when none do; a `patch_dir` naming a directory nobody wrote is that error, and the
521/// answer is the directory itself — a path on the search path that resolves nothing, which
522/// is what the situation is.
523fn patch_entry(dir: &Path, over: Option<&Path>) -> PathBuf {
524    if let Some(e) = over {
525        return mlua_pkg::lockfile::join_entry(dir, e);
526    }
527    if let Ok(m) = mlua_pkg::manifest::Manifest::from_path(dir.join(MANIFEST_NAME))
528        && let Some(e) = m.package.entry
529    {
530        return mlua_pkg::lockfile::join_entry(dir, &e);
531    }
532    mlua_pkg::resolve_entry(dir, None).unwrap_or_else(|_| dir.to_path_buf())
533}
534
535/// What [`Project::add`] did: mlua-pkg's own report, and what htl carried across it.
536///
537/// `add` rewrites the whole `[deps.<name>]` entry, so a patch the entry declared would be
538/// dropped by it. `kept_patch_dir` is that key, put back — named here so the report can say
539/// it happened rather than leaving the manifest quietly different from what `add` wrote.
540#[derive(Debug, Clone)]
541pub struct AddDone {
542    /// What mlua-pkg's own `add` returned, passed through unchanged so a caller reads the
543    /// same report it would have got without htl in the way.
544    pub report: mlua_pkg::ops::AddReport,
545    /// The `patch_dir` the entry had before `add` rewrote it, when there was one. `None`
546    /// means nothing was carried across — either the entry declared no patch, or the
547    /// dependency is new.
548    pub kept_patch_dir: Option<PathBuf>,
549}
550
551/// What [`Project::patch`] did: mlua-pkg's own report, and what htl took back out of the
552/// copy.
553///
554/// The copy is made from a checkout rather than from a published archive, so it arrives
555/// with the repository around the package. What was removed is named here rather than
556/// happening quietly: the directory is about to be committed, and a file the author of the
557/// dependency can see upstream and the patcher cannot find in `patches/<dep>` is a
558/// difference worth one line of output.
559#[derive(Debug, Clone)]
560pub struct PatchDone {
561    /// What mlua-pkg's own `patch` returned, passed through unchanged: where the copy is,
562    /// whether the directory was created or rebuilt, and the revision it came from.
563    pub report: mlua_pkg::ops::PatchReport,
564    /// The dot-entries removed from the copy's root, by name and sorted. Empty when the
565    /// repository had nothing of its own beside the package — which is most of the time,
566    /// and why the report says this only when there is something to say.
567    pub dropped: Vec<String>,
568}
569
570/// Where a patched dependency stands after an install: whether the copy is what the
571/// dependency resolves from, and the two revisions the answer rests on.
572///
573/// `in_use` is false when the directory is gone, when the lockfile records no base for it,
574/// or when the pin has moved on from that base — the dependency then resolves to the
575/// upstream revision, and the copy sits in the tree unused until it is refreshed or
576/// removed. See [`Project::patch_status`].
577#[derive(Debug, Clone)]
578pub struct PatchStatus {
579    /// The `[deps]` key the patched dependency is declared under.
580    pub name: String,
581    /// The copy in the tree, absolute — reported whether or not it is [`in_use`](Self::in_use),
582    /// since "the directory is there and nothing reads it" is the finding worth printing.
583    pub dir: PathBuf,
584    /// The revision the copy was taken from (`patch_base`), when the lockfile has one.
585    pub base: Option<String>,
586    /// The revision the pin resolves to, as the last install recorded it.
587    pub locked: Option<String>,
588    /// Whether the copy is what the dependency resolves from. False when the directory is
589    /// gone, when there is no recorded `base`, or when `base` and `locked` have diverged —
590    /// the three ways a patch stops being the thing in use, told apart by the two fields
591    /// above rather than by a second enum.
592    pub in_use: bool,
593}
594
595/// The manifest's file name, taken from mlua-pkg rather than spelled here, so htl and the
596/// tool that writes the file cannot disagree about what it is called.
597pub const MANIFEST_NAME: &str = mlua_pkg::project::MANIFEST_FILE_NAME;
598/// The lockfile's file name, from mlua-pkg for the same reason as [`MANIFEST_NAME`].
599pub const LOCKFILE_NAME: &str = mlua_pkg::project::LOCKFILE_FILE_NAME;
600
601/// Where [`Project::patch`] puts a dependency it takes into the tree: `patches/<dep>`,
602/// beside the project's own sources rather than under [`pkgs_dir`]. One directory per
603/// dependency, named after it, so the path a diagnostic carries names the dependency it
604/// is in.
605pub const PATCHES_DIR: &str = "patches";
606
607/// Where a project's installed deps go: `<root>/.htl/modules`, always.
608///
609/// One directory, named in one place. htl does not read the location out of the
610/// environment and does not infer it from whether `target/` happens to exist — it decides
611/// it here and hands it to mlua-pkg when it runs one (`htl pkg`), so the installer and the
612/// checker cannot name different directories.
613///
614/// What goes on *inside* is mlua-pkg's: [`mlua_pkg::PkgDir`] derives `cache/` and
615/// `vendored/` from the base, and this returns one so htl does not spell that layout out a
616/// second time. The one directory htl adds beside them is [`ENTRIES_DIR`].
617pub fn pkgs_dir(root: &Path) -> mlua_pkg::PkgDir {
618    mlua_pkg::PkgDir::new(root.join(".htl").join("modules"))
619}
620
621/// The directory under [`pkgs_dir`] that holds one link per installed dep at that dep's
622/// `entry` — where `require` looks. See [`Project::entries`].
623pub const ENTRIES_DIR: &str = "entries";
624
625/// The project that owns `dir`, when `dir` is a dependency's directory rather than a
626/// project of its own.
627///
628/// Walks up from `dir` for a manifest that declares it — a `patch_dir` the project edits,
629/// or a `target_dir` copy install rewrites — and answers with that project's root, or
630/// with its owner in turn when the copy is itself inside another copy. `None` when
631/// nothing above claims it.
632///
633/// One question, asked by [`Project::find`] and by
634/// [`HtlConfig::find`](crate::config::HtlConfig::find), because a directory that is not a
635/// project must not become one for either of them: the enclosing manifest decides, and a
636/// manifest that came along in the copy does not.
637pub(crate) fn owning_project(dir: &Path) -> Option<PathBuf> {
638    let mut up = dir.to_path_buf();
639    while up.pop() {
640        if up.join(MANIFEST_NAME).is_file() && Project::at(&up).declares(dir) {
641            return Some(owning_project(&up).unwrap_or(up));
642        }
643    }
644    None
645}
646
647impl Project {
648    /// Walk up from `start` (a file or directory) looking for `mlua-pkg.toml`.
649    ///
650    /// **The nearest manifest is not always the project.** `htl pkg patch` copies a
651    /// dependency's whole package root into `patches/<dep>/`, its own `mlua-pkg.toml`
652    /// among the files, so a file inside a patched dependency has a manifest above it
653    /// belonging to the dependency and another above that belonging to the project doing
654    /// the patching. The one that declared the copy is the project: the walk stops at the
655    /// first manifest but then asks `owning_project` whether anything above claims that
656    /// directory, and takes the answer. Nothing claims it and the first hit stands — a
657    /// dependency checked out on its own is its own project.
658    ///
659    /// Whoever the root is gets the `.htl/`: the store, the installed deps, the entry
660    /// links. A patch directory that was a root of its own collected a second one inside
661    /// the project's tree, which is the nested `.htl/` of #267.
662    pub fn find(start: &Path) -> Option<Self> {
663        let mut dir = if start.is_dir() {
664            start.to_path_buf()
665        } else {
666            crate::parent_dir(start)
667        };
668        if let Ok(abs) = std::fs::canonicalize(&dir) {
669            dir = abs;
670        }
671        loop {
672            let manifest = dir.join(MANIFEST_NAME);
673            if manifest.is_file() {
674                let root = owning_project(&dir).unwrap_or(dir);
675                return Some(Self::at(&root));
676            }
677            if !dir.pop() {
678                return None;
679            }
680        }
681    }
682
683    /// Is `dir` inside one of the dependency directories this project declares — a
684    /// `patch_dir` it owns, or a `target_dir` copy install writes? Canonical paths on both
685    /// sides: one comes from a manifest, the other from a walk.
686    fn declares(&self, dir: &Path) -> bool {
687        self.patches
688            .iter()
689            .map(|p| p.dir.clone())
690            .chain(self.vendored_copies.iter().cloned())
691            .any(|d| dir.starts_with(std::fs::canonicalize(&d).unwrap_or(d)))
692    }
693
694    /// Project rooted at `root` (must contain `mlua-pkg.toml`; not checked here).
695    pub fn at(root: &Path) -> Self {
696        let inner = mlua_pkg::Project::in_dir(root, pkgs_dir(root));
697        let manifest = inner.manifest_path().to_path_buf();
698        // `target_dir` deps: the copy itself, and the parent `require` searches. `patch_dir`
699        // deps: the directory itself, which is what a walker is asked about. A manifest
700        // that fails to parse contributes nothing here (mlua-pkg itself reports it).
701        let mut target_dirs: Vec<PathBuf> = Vec::new();
702        let mut vendored_copies: Vec<PathBuf> = Vec::new();
703        let mut patches: Vec<Patched> = Vec::new();
704        if let Ok(m) = mlua_pkg::manifest::Manifest::from_path(&manifest) {
705            // The lockfile, and only for a manifest that patches something: it is the one
706            // place an `entry` is recorded once an install has run, and every other
707            // project would be paying a file read for an answer it has no question for.
708            let locked = m
709                .deps
710                .values()
711                .any(|d| d.patch_dir.is_some())
712                .then(|| mlua_pkg::lockfile::Lockfile::read(inner.lock_path()).ok())
713                .flatten();
714            for (name, dep) in &m.deps {
715                if let Some(td) = &dep.target_dir {
716                    let abs = root.join(td);
717                    let parent = abs
718                        .parent()
719                        .map(Path::to_path_buf)
720                        .unwrap_or_else(|| root.to_path_buf());
721                    if !target_dirs.contains(&parent) {
722                        target_dirs.push(parent);
723                    }
724                    if !vendored_copies.contains(&abs) {
725                        vendored_copies.push(abs);
726                    }
727                }
728                if let Some(pd) = &dep.patch_dir {
729                    let dir = root.join(pd);
730                    let over = locked
731                        .as_ref()
732                        .and_then(|l| l.pkg.iter().find(|p| &p.name == name))
733                        .map(|p| p.entry.clone())
734                        .or_else(|| dep.entry.clone());
735                    let entry = patch_entry(&dir, over.as_deref());
736                    patches.push(Patched {
737                        name: name.clone(),
738                        dir,
739                        entry,
740                    });
741                }
742            }
743        }
744        Self {
745            root: root.to_path_buf(),
746            manifest,
747            lockfile: inner.lock_path().to_path_buf(),
748            vendored: inner.pkg_dir().vendored(),
749            entries: inner.pkg_dir().base().join(ENTRIES_DIR),
750            pkgs_dir: inner.pkg_dir().base().to_path_buf(),
751            target_dirs,
752            vendored_copies,
753            patches,
754        }
755    }
756
757    /// Where the patched deps are, for a walker that only asks whether it may enter.
758    pub fn patch_dirs(&self) -> Vec<PathBuf> {
759        self.patches.iter().map(|p| p.dir.clone()).collect()
760    }
761
762    /// Where a `require` searches the patched deps: one directory per patch, at its
763    /// [`search_dir`](Patched::search_dir).
764    ///
765    /// The search path and the cache's probe list are the same list, and this is it
766    /// ([`Htl::apply_project`](crate::Htl::apply_project),
767    /// [`crate::dependency_dirs`]). Nothing here is asked to exist: a `patch_dir` the
768    /// manifest names and nobody has written yet is a directory a name resolves nothing
769    /// through, and the probe over it is what notices when it arrives.
770    pub fn patch_search_dirs(&self) -> Vec<PathBuf> {
771        self.patches.iter().map(Patched::search_dir).collect()
772    }
773
774    /// `true` once `mlua-pkg install` has produced the lockfile.
775    pub fn installed(&self) -> bool {
776        self.lockfile.is_file()
777    }
778
779    /// Resolver for `.tl` / `.d.tl` inside installed deps (symlink-aware, like
780    /// `VendoredResolver`), rooted at [`entries`](Self::entries) so a dep's `entry` is
781    /// applied. Writes any link the lockfile calls for that is not there yet, and creates
782    /// the directory if it does not exist.
783    ///
784    /// Under build scratch ([`crate::cache::scratch_root`]) it writes neither: a copy that
785    /// carries no `.htl/` has no directory to root a resolver at, and this is the
786    /// `InitError::RootNotFound` of a missing root rather than a write that would make
787    /// cargo refuse the tarball. Nothing on the checking path comes through here — the
788    /// checker and the macros take [`crate::Htl::apply_project`], which puts the same
789    /// directory on the search path and does not mind that it is absent.
790    pub fn teal_resolver(&self) -> Result<TealResolver, InitError> {
791        let _ = self.link_entries();
792        if crate::cache::scratch_root(&self.root).is_none() {
793            let _ = std::fs::create_dir_all(&self.entries);
794        }
795        TealResolver::new_symlink_aware(&self.entries)
796    }
797
798    /// Write `entries/<name>` → `../vendored/<name>/<entry>` for every package the lockfile
799    /// records, and remove a link there the lockfile no longer names.
800    ///
801    /// Idempotent and cheap: a link that already points where it should is left alone. It
802    /// runs after every install, and again from [`teal_resolver`](Self::teal_resolver) and
803    /// [`crate::Htl::apply_project`], so a project installed by an htl that did not write
804    /// these works after upgrading without a reinstall. Returns the names linked, in
805    /// lockfile order; no lockfile is no packages, not an error.
806    ///
807    /// The link is relative so that it follows `vendored/<name>` wherever install points
808    /// that — at the cache, or at a `patch_dir` copy — rather than pinning a revision of
809    /// its own. An entry of `"."` gets a link too, to the root: one layout, not two.
810    ///
811    /// **Under build scratch it repairs nothing**, and the reason it must not is
812    /// [`crate::cache::scratch_root`]'s to state. It still reads the lockfile and still
813    /// answers with the names, which is all the caller wanted from it there: the tarball
814    /// carries `mlua-pkg.lock` and no `.htl/`, so the names are known and the links are
815    /// not htl's to write into a tree cargo is verifying byte for byte (#267).
816    pub fn link_entries(&self) -> anyhow::Result<Vec<String>> {
817        if !self.installed() {
818            return Ok(Vec::new());
819        }
820        let lock = mlua_pkg::lockfile::Lockfile::read(&self.lockfile)?;
821        if crate::cache::scratch_root(&self.root).is_some() {
822            return Ok(lock.pkg.iter().map(|p| p.name.clone()).collect());
823        }
824        std::fs::create_dir_all(&self.entries)
825            .with_context(|| format!("creating {}", self.entries.display()))?;
826        let mut names = Vec::new();
827        for p in &lock.pkg {
828            let mut target = PathBuf::from("..").join("vendored").join(&p.name);
829            if !(p.entry.as_os_str().is_empty() || p.entry == Path::new(".")) {
830                target.push(&p.entry);
831            }
832            let link = self.entries.join(&p.name);
833            match std::fs::symlink_metadata(&link) {
834                Ok(m) if m.file_type().is_symlink() => {
835                    if std::fs::read_link(&link).ok().as_deref() == Some(target.as_path()) {
836                        names.push(p.name.clone());
837                        continue;
838                    }
839                    remove_link(&link)?;
840                }
841                Ok(_) => anyhow::bail!(
842                    "{} is not a link: htl writes that directory from the lockfile, and \
843                     something else put a file there",
844                    link.display()
845                ),
846                Err(_) => {}
847            }
848            make_link(&target, &link)?;
849            names.push(p.name.clone());
850        }
851        // A dependency dropped from the manifest leaves its link behind otherwise, and a
852        // `require` of it would then keep working until the cache was cleaned.
853        if let Ok(rd) = std::fs::read_dir(&self.entries) {
854            for e in rd.flatten() {
855                let is_link = e.file_type().map(|t| t.is_symlink()).unwrap_or(false);
856                let name = e.file_name().to_string_lossy().into_owned();
857                if is_link && !names.contains(&name) {
858                    remove_link(&e.path())?;
859                }
860            }
861        }
862        Ok(names)
863    }
864
865    /// mlua-pkg's own resolver for plain `.lua` inside vendored deps.
866    pub fn vendored_resolver(&self) -> anyhow::Result<mlua_pkg::resolvers::VendoredResolver> {
867        if self.installed() {
868            Ok(mlua_pkg::resolvers::VendoredResolver::from_lockfile(
869                &self.lockfile,
870                &self.vendored,
871            )?)
872        } else {
873            let _ = std::fs::create_dir_all(&self.vendored);
874            Ok(mlua_pkg::resolvers::VendoredResolver::new(&self.vendored)?)
875        }
876    }
877
878    /// Registry with the project's deps: Teal first, then plain Lua. Add your
879    /// `NativeResolver`s *before* calling `install` if Teal code declares them in `.d.tl`.
880    pub fn registry(&self) -> anyhow::Result<mlua_pkg::Registry> {
881        let mut reg = mlua_pkg::Registry::new();
882        reg.add(self.teal_resolver()?);
883        reg.add(self.vendored_resolver()?);
884        for d in &self.target_dirs {
885            if d.is_dir() {
886                reg.add(TealResolver::new(d)?);
887                reg.add(mlua_pkg::resolvers::FsResolver::new(d)?);
888            }
889        }
890        Ok(reg)
891    }
892
893    /// Bring the declarations a dep publishes into the project's own `types/`.
894    ///
895    /// A dep that follows htl's own convention keeps its `.d.tl` under `types/` at its
896    /// package root, and that is outside the entry directory `require` looks in
897    /// (`entries/<name>`) — so the checker never sees it, and the depending project writes
898    /// the declaration again by hand. Copying rather than widening the search path is what makes the
899    /// result survive a fresh clone: [`pkgs_dir`] is machine-local and empty until someone
900    /// installs, while `types/` is committed.
901    ///
902    /// A name `types/` already has is left alone and reported. Two libraries publishing a
903    /// module of the same name is a real situation, and there is no registry to arbitrate
904    /// it with, so the project decides rather than the last install winning.
905    pub fn sync_types(&self) -> anyhow::Result<TypesSync> {
906        let mut out = TypesSync::default();
907        if !self.installed() {
908            return Ok(out);
909        }
910        let lock = mlua_pkg::lockfile::Lockfile::read(&self.lockfile)?;
911        let dest = self.root.join("types");
912        for p in &lock.pkg {
913            let Some(root) = self.package_root(p) else {
914                continue;
915            };
916            copy_declarations(
917                &root.join("types"),
918                &dest,
919                &Origin {
920                    name: p.name.clone(),
921                    sha: p.sha.clone(),
922                    under: PathBuf::from("types"),
923                },
924                false,
925                &mut out,
926            )?;
927        }
928        Ok(out)
929    }
930
931    /// Copy one library's declarations out of teal-types into `types/`.
932    ///
933    /// teal-types is where the Teal ecosystem collects declarations for libraries that
934    /// ship none of their own, laid out as `types/<library>/<module>.d.tl`. Nothing there
935    /// ties a declaration to a version of the library it describes: the rocks are
936    /// versioned on their own count, declare no dependency on the library, and name no
937    /// revision of it. So the `.src` note beside each file is the whole of the record —
938    /// what was taken, and from which commit of the collection.
939    pub fn add_types(&self, library: &str, force: bool) -> anyhow::Result<TypesSync> {
940        let cache = pkgs_dir(&self.root).cache();
941        std::fs::create_dir_all(&cache)?;
942        let fetcher = mlua_pkg::fetcher::GitFetcher::new(cache);
943        let got = mlua_pkg::fetcher::Fetcher::fetch(
944            &fetcher,
945            &mlua_pkg::manifest::Dep {
946                git: TEAL_TYPES_GIT.to_string(),
947                tag: None,
948                rev: None,
949                branch: None,
950                entry: None,
951                target_dir: None,
952                patch_dir: None,
953                patch_drift: None,
954            },
955        )?;
956        self.add_types_from(&got.cache_path, library, &got.sha, force)
957    }
958
959    /// The same from a checkout already on disk, recording `sha` as the revision it is at.
960    pub fn add_types_from(
961        &self,
962        checkout: &Path,
963        library: &str,
964        sha: &str,
965        force: bool,
966    ) -> anyhow::Result<TypesSync> {
967        let under = Path::new("types").join(library);
968        let published = checkout.join(&under);
969        if !published.is_dir() {
970            anyhow::bail!("{}", no_such_library(checkout, library));
971        }
972        let mut out = TypesSync::default();
973        copy_declarations(
974            &published,
975            &self.root.join("types"),
976            &Origin {
977                name: TEAL_TYPES_NAME.to_string(),
978                sha: sha.to_string(),
979                under,
980            },
981            force,
982            &mut out,
983        )?;
984        Ok(out)
985    }
986
987    /// What mlua-pkg is handed to act on this project: htl's own directories, and the
988    /// manifest read from disk.
989    ///
990    /// The library reads neither the environment nor the working directory to decide where
991    /// packages go — it takes the [`mlua_pkg::PkgDir`] it is given — so [`pkgs_dir`] is the
992    /// only place that answer is written down, for the installer and the checker alike.
993    fn config(&self) -> mlua_pkg::Config {
994        mlua_pkg::Config::new(mlua_pkg::Project::in_dir(&self.root, pkgs_dir(&self.root)))
995    }
996
997    /// Fetch every dependency the manifest declares, and write the lockfile.
998    ///
999    /// The report says what each one resolved to and where it was placed, including
1000    /// whether it came from a `patch_dir`; nothing is printed here. Declarations a
1001    /// dependency publishes are a separate step ([`Project::sync_types`]) because they are
1002    /// copied into the project rather than installed.
1003    pub fn install(&self) -> anyhow::Result<mlua_pkg::ops::InstallReport> {
1004        let report = mlua_pkg::ops::install(&self.config())?;
1005        // The roots are placed and the lockfile written: now the links `require` reads.
1006        self.link_entries()?;
1007        Ok(report)
1008    }
1009
1010    /// Write a dependency into the manifest. `install` is what fetches it.
1011    ///
1012    /// mlua-pkg replaces the whole `[deps.<name>]` entry and `AddSpec` carries no
1013    /// `patch_dir`, so adding a dependency that is already patched would drop the key that
1014    /// binds `patches/<dep>` to it — the project would keep building, against upstream,
1015    /// with the copy sitting unread in the tree. What the entry declared about its patch is
1016    /// carried across and reported.
1017    pub fn add(&self, spec: mlua_pkg::ops::AddSpec) -> anyhow::Result<AddDone> {
1018        let name = spec.name.clone();
1019        let previous = mlua_pkg::manifest::Manifest::from_path(&self.manifest)
1020            .ok()
1021            .and_then(|m| m.deps.get(&name).cloned());
1022        let report = mlua_pkg::ops::add(&self.config(), spec)?;
1023        let Some(dep) = previous else {
1024            return Ok(AddDone {
1025                report,
1026                kept_patch_dir: None,
1027            });
1028        };
1029        let Some(dir) = dep.patch_dir.clone() else {
1030            return Ok(AddDone {
1031                report,
1032                kept_patch_dir: None,
1033            });
1034        };
1035        set_dep_key(&self.manifest, &name, "patch_dir", &to_toml_path(&dir))?;
1036        if let Some(drift) = dep.patch_drift {
1037            let value = match drift {
1038                mlua_pkg::manifest::PatchDrift::Warn => "warn",
1039                mlua_pkg::manifest::PatchDrift::Error => "error",
1040            };
1041            set_dep_key(&self.manifest, &name, "patch_drift", value)?;
1042        }
1043        Ok(AddDone {
1044            report,
1045            kept_patch_dir: Some(dir),
1046        })
1047    }
1048
1049    /// Refresh dependencies, bump the pins that follow releases, and install what changed.
1050    pub fn update(
1051        &self,
1052        opts: mlua_pkg::ops::UpdateOpts,
1053    ) -> anyhow::Result<mlua_pkg::ops::UpdateReport> {
1054        let mut report = mlua_pkg::ops::update(&self.config(), opts)?;
1055        self.link_entries()?;
1056        // mlua-pkg walks a map, so the same project reports its dependencies in a
1057        // different order on every run. A report that is read by a person, and diffed
1058        // against the last one, is sorted.
1059        report.entries.sort_by(|a, b| a.0.cmp(&b.0));
1060        Ok(report)
1061    }
1062
1063    /// Remove cached packages the lockfile no longer refers to (`all`: the whole cache).
1064    ///
1065    /// Never touches what install placed under `vendored/`: a dangling link there is
1066    /// repaired by the next install.
1067    pub fn clean(&self, all: bool) -> anyhow::Result<mlua_pkg::ops::CleanReport> {
1068        Ok(mlua_pkg::ops::clean(&self.config(), all)?)
1069    }
1070
1071    /// Take a dependency's source into `patches/<dep>/`, where the project owns it.
1072    ///
1073    /// The whole package root is copied, so the dep's `types/` comes with it, minus the
1074    /// dot-entries at its root, which are the repository the package was checked out of
1075    /// rather than the package — [`PatchDone::dropped`] names the ones that were there.
1076    /// `patch_dir` on that dependency in the manifest says which dependency the directory
1077    /// stands in for. There is no patch file and nothing is applied: from here the
1078    /// directory is the project's code, edited and committed with git like the rest of the
1079    /// tree, and install resolves the dependency from it for as long as the pin still
1080    /// resolves to the revision the copy was taken from (`patch_base` in the lockfile).
1081    /// When the pin moves on, install uses the new revision, leaves the copy alone and
1082    /// says so on every install until the patch is refreshed or removed.
1083    ///
1084    /// On a dependency that is already patched this refreshes the copy from the revision
1085    /// the pin now resolves to and records that as the new base. The copy is overwritten
1086    /// rather than merged — carrying the project's own change forward onto it is a merge
1087    /// git performs, and it can only do that if the change is committed — so a directory
1088    /// with uncommitted changes is refused unless `force`.
1089    pub fn patch(&self, name: &str, force: bool) -> anyhow::Result<PatchDone> {
1090        let manifest = mlua_pkg::manifest::Manifest::from_path(&self.manifest)?;
1091        let dep = manifest.deps.get(name).ok_or_else(|| {
1092            anyhow::anyhow!(
1093                "no dependency '{name}' in {}: `htl pkg patch` takes a name the manifest declares",
1094                self.manifest.display()
1095            )
1096        })?;
1097        // Where the copy goes. htl's own layout is `patches/<dep>`; a manifest that
1098        // already names a directory keeps the one it names.
1099        let declared = dep.patch_dir.is_some();
1100        let rel = match &dep.patch_dir {
1101            Some(p) => p.clone(),
1102            None => PathBuf::from(format!("{PATCHES_DIR}/{name}")),
1103        };
1104        let dir = self.root.join(&rel);
1105        if dir.exists() && !force {
1106            refuse_if_uncommitted(&self.root, &rel)?;
1107        }
1108
1109        let before = std::fs::read_to_string(&self.manifest)?;
1110        if !declared {
1111            set_dep_key(&self.manifest, name, "patch_dir", &to_toml_path(&rel))?;
1112        }
1113
1114        // mlua-pkg does the copy and the bookkeeping: it fetches the pin, copies the
1115        // package root into `patch_dir`, and records the commit it came from as
1116        // `patch_base`. `force` there is the "directory already exists" refusal, which is
1117        // the question already answered above against git rather than against the
1118        // directory's existence.
1119        let opts = mlua_pkg::ops::PatchOpts {
1120            name: name.to_string(),
1121            force: true,
1122        };
1123        match mlua_pkg::ops::patch(&self.config(), opts) {
1124            Ok(report) => {
1125                let dropped = drop_dot_entries(&report.patch_dir)?;
1126                Ok(PatchDone { report, dropped })
1127            }
1128            Err(e) => {
1129                // A `patch_dir` naming a directory that was never written turns every
1130                // later install into a drift report, so the manifest goes back as it was.
1131                if !declared {
1132                    let _ = std::fs::write(&self.manifest, &before);
1133                }
1134                Err(e.into())
1135            }
1136        }
1137    }
1138
1139    /// Where each patched dependency stands, read back from the manifest and the lockfile.
1140    ///
1141    /// A patch is bound to the revision it was taken from. Install compares the two itself
1142    /// and falls back to upstream when they differ; this reads the same two values
1143    /// afterwards so htl can say what happened in its own verbs — mlua-pkg's warning names
1144    /// `mlua-pkg patch --force`, which skips the question htl asks git and leaves the
1145    /// upstream repository's dot-entries in the copy.
1146    pub fn patch_status(&self) -> Vec<PatchStatus> {
1147        let lock = mlua_pkg::lockfile::Lockfile::read(&self.lockfile).ok();
1148        self.patches
1149            .iter()
1150            .map(|p| {
1151                let locked = lock
1152                    .as_ref()
1153                    .and_then(|l| l.pkg.iter().find(|e| e.name == p.name));
1154                let base = locked.and_then(|e| e.patch_base.clone());
1155                let sha = locked.map(|e| e.sha.clone());
1156                let in_use = p.dir.is_dir() && base.is_some() && base == sha;
1157                PatchStatus {
1158                    name: p.name.clone(),
1159                    dir: p.dir.clone(),
1160                    base,
1161                    locked: sha,
1162                    in_use,
1163                }
1164            })
1165            .collect()
1166    }
1167
1168    /// The package root behind `vendored/<name>`.
1169    ///
1170    /// That symlink points at the package root itself, and the lockfile's `entry` says
1171    /// where below it `require` looks — so what a dep publishes beside its entry, `types/`
1172    /// among it, is reached from here without subtracting the entry again. mlua-pkg moved
1173    /// the symlink from the entry directory to the root in 0.11; a dep whose entry is
1174    /// `src/` used to need the difference popped off and now must not.
1175    fn package_root(&self, p: &mlua_pkg::lockfile::LockedPkg) -> Option<PathBuf> {
1176        std::fs::canonicalize(self.vendored.join(&p.name)).ok()
1177    }
1178}
1179
1180/// A directory link at `link` pointing at `target`, as written (relative stays relative).
1181fn make_link(target: &Path, link: &Path) -> anyhow::Result<()> {
1182    #[cfg(unix)]
1183    let r = std::os::unix::fs::symlink(target, link);
1184    #[cfg(windows)]
1185    let r = std::os::windows::fs::symlink_dir(target, link);
1186    r.with_context(|| format!("linking {} -> {}", link.display(), target.display()))
1187}
1188
1189/// Remove a link, and only a link: the caller has checked what is there.
1190fn remove_link(link: &Path) -> anyhow::Result<()> {
1191    #[cfg(unix)]
1192    let r = std::fs::remove_file(link);
1193    #[cfg(windows)]
1194    let r = std::fs::remove_dir(link).or_else(|_| std::fs::remove_file(link));
1195    r.with_context(|| format!("removing the link {}", link.display()))
1196}
1197
1198/// Write one key onto `[deps.<name>]`, leaving the rest of the file as it was.
1199///
1200/// The manifest is a file a person wrote: its comments say why a dependency is pinned
1201/// where it is, and its order is the order they put things in. `toml_edit` keeps both,
1202/// where re-serialising the parsed manifest would not.
1203fn set_dep_key(manifest: &Path, name: &str, key: &str, value: &str) -> anyhow::Result<()> {
1204    let text = std::fs::read_to_string(manifest)?;
1205    let mut doc = text.parse::<toml_edit::DocumentMut>()?;
1206    let deps = doc
1207        .get_mut("deps")
1208        .and_then(|i| i.as_table_like_mut())
1209        .with_context(|| format!("no [deps] table in {}", manifest.display()))?;
1210    let entry = deps
1211        .get_mut(name)
1212        .and_then(|i| i.as_table_like_mut())
1213        .with_context(|| format!("[deps.{name}] is not a table"))?;
1214    entry.insert(key, toml_edit::value(value));
1215    std::fs::write(manifest, doc.to_string())?;
1216    Ok(())
1217}
1218
1219/// A manifest-relative path as the manifest spells it: `/` on every platform, because the
1220/// file is read on all of them.
1221fn to_toml_path(p: &Path) -> String {
1222    p.components()
1223        .map(|c| c.as_os_str().to_string_lossy())
1224        .collect::<Vec<_>>()
1225        .join("/")
1226}
1227
1228/// Take the upstream repository's own dot-entries out of the copy's root, and name them.
1229///
1230/// The copy is made from a checkout rather than from a published archive, so it arrives
1231/// with the repository around the package: `.git`, the CI workflows under `.github`, the
1232/// ignore rules, whatever tool state (`.htl`, `.mlua-pkgs`) and OS litter (`.DS_Store`) the
1233/// checkout happened to hold. None of it is the dependency's source, and each kind of it
1234/// costs the project that is about to commit the directory something:
1235///
1236/// - `.git` — git reads `patches/<dep>` as an embedded repository and records it as a
1237///   gitlink, a commit id pointing at a repository nobody else has, with none of the files
1238///   in this project's history.
1239/// - `.github` — a workflow under `patches/` is inert (GitHub only runs the ones at the
1240///   repository root) but it is still a workflow file, and the gates that watch
1241///   `.github/workflows/*` — review rules, secret scanners, branch protection — fire on it.
1242/// - `.gitignore` — a second ignore file inside the tree, whose rules were written for a
1243///   different repository, silently drops files from this project's own commits. That is
1244///   not a hypothesis: it is what cargo's vendored copies do to their consumers
1245///   (rust-lang/cargo#13607), and the lesson cargo draws is that a copy landing inside
1246///   somebody else's repository must not carry ignore rules with it.
1247/// - the rest — [`crate::is_skipped_dir`] already refuses to descend into a dot-directory,
1248///   so anything else here would be committed, reviewed and never read.
1249///
1250/// Root level only. Below the copy's root a dot-entry belongs to the package the way any
1251/// other file there does, and htl does not know which ones the dependency needs. The same
1252/// reasoning keeps `htl.toml` and `mlua-pkg.toml`: they are the package's, they are what
1253/// says where its entry is, and install reads them from the copy.
1254///
1255/// This is a denylist of one shape rather than an allowlist of names, which is the
1256/// narrowest rule that closes the whole class — npm's named denylist has to grow a name
1257/// every time an ecosystem invents a dotfile, and `.github` is still not on it. There is no
1258/// flag to keep them: somebody who wants the repository clones the repository.
1259fn drop_dot_entries(dir: &Path) -> anyhow::Result<Vec<String>> {
1260    let mut dropped = Vec::new();
1261    let entries = std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))?;
1262    for entry in entries {
1263        let entry = entry.with_context(|| format!("reading {}", dir.display()))?;
1264        let name = entry.file_name().to_string_lossy().into_owned();
1265        if !name.starts_with('.') {
1266            continue;
1267        }
1268        let path = entry.path();
1269        // `file_type` here is the directory entry's, so a symlink is a symlink rather than
1270        // what it points at — and a link is unlinked, never followed and emptied. (A
1271        // worktree checkout's `.git` is a plain file pointing elsewhere; it goes the same
1272        // way.)
1273        let ft = entry
1274            .file_type()
1275            .with_context(|| format!("reading {}", path.display()))?;
1276        if ft.is_dir() {
1277            std::fs::remove_dir_all(&path)
1278        } else {
1279            std::fs::remove_file(&path)
1280        }
1281        .with_context(|| format!("removing {}", path.display()))?;
1282        dropped.push(name);
1283    }
1284    // read_dir's order is the filesystem's. What a report prints, and what a test asserts,
1285    // is sorted.
1286    dropped.sort();
1287    Ok(dropped)
1288}
1289
1290/// Refuse to overwrite a patched copy that git has not been told about.
1291///
1292/// The refresh replaces the directory with the pinned upstream, and the project's own
1293/// change survives that only through git: it is carried forward by merging the new copy
1294/// with the history of the old one. A change git cannot see is a change that cannot be
1295/// carried forward, so it is named here and the refresh does not happen.
1296fn refuse_if_uncommitted(root: &Path, rel: &Path) -> anyhow::Result<()> {
1297    match uncommitted(root, rel) {
1298        Ok(changes) if changes.is_empty() => Ok(()),
1299        Ok(changes) => {
1300            let mut msg = format!(
1301                "{} has uncommitted changes, and refreshing it from the pin overwrites \
1302                 them. Commit them first — git is what carries them onto the refreshed \
1303                 copy — or pass --force to discard them:",
1304                rel.display()
1305            );
1306            for c in changes.iter().take(10) {
1307                msg.push_str("\n  ");
1308                msg.push_str(c);
1309            }
1310            if changes.len() > 10 {
1311                msg.push_str(&format!("\n  and {} more", changes.len() - 10));
1312            }
1313            anyhow::bail!("{msg}")
1314        }
1315        Err(why) => anyhow::bail!(
1316            "cannot tell whether {} has uncommitted changes ({why}), and refreshing it \
1317             from the pin overwrites whatever is in it. Pass --force to refresh it anyway.",
1318            rel.display()
1319        ),
1320    }
1321}
1322
1323/// What `git status` reports under `rel`, one entry per line as it prints them.
1324///
1325/// Untracked files count: the question is what would be lost, and a file git was never
1326/// told about is lost the same way an edited one is. `Err` is what could not be asked
1327/// rather than what came back dirty — no `git` on PATH, or a tree that is not a
1328/// repository. The pathspec is the manifest-relative one and the command runs at the
1329/// project root, so git reads it the way it reads any path a person types there.
1330fn uncommitted(root: &Path, rel: &Path) -> Result<Vec<String>, String> {
1331    let out = std::process::Command::new("git")
1332        .arg("-C")
1333        .arg(root)
1334        .args(["status", "--porcelain", "--"])
1335        .arg(rel)
1336        .output()
1337        .map_err(|e| match e.kind() {
1338            std::io::ErrorKind::NotFound => "no `git` on PATH".to_string(),
1339            _ => e.to_string(),
1340        })?;
1341    if !out.status.success() {
1342        let why = String::from_utf8_lossy(&out.stderr).trim().to_string();
1343        return Err(if why.is_empty() {
1344            format!("git exited {}", out.status)
1345        } else {
1346            why
1347        });
1348    }
1349    Ok(String::from_utf8_lossy(&out.stdout)
1350        .lines()
1351        .map(|l| l.trim_end().to_string())
1352        .collect())
1353}
1354
1355/// Where the Teal ecosystem collects declarations for libraries that ship none of their
1356/// own: `types/<library>/<module>.d.tl`, published to LuaRocks one library at a time as
1357/// `<library>-tl-type`.
1358pub const TEAL_TYPES_GIT: &str = "https://github.com/teal-language/teal-types";
1359
1360/// What the `.src` notes call it.
1361const TEAL_TYPES_NAME: &str = "teal-types";
1362
1363/// What [`Project::sync_types`] and [`Project::add_types`] did: one entry per declaration
1364/// they were offered.
1365#[derive(Debug, Default)]
1366pub struct TypesSync {
1367    /// Written into `types/`, with what published it.
1368    pub written: Vec<(PathBuf, String)>,
1369    /// Left as it was, because `types/` already had that name — with what offered one too.
1370    pub taken: Vec<(PathBuf, String)>,
1371}
1372
1373/// Where a declaration came from, as the `.src` note beside it records it: what published
1374/// it, at which revision, and the path it had there.
1375struct Origin {
1376    name: String,
1377    sha: String,
1378    under: PathBuf,
1379}
1380
1381/// Copy every `.d.tl` under `from` into `to`, keeping the path below `from`.
1382///
1383/// Keeping it is what keeps the module name: `socket/http.d.tl` is
1384/// `require("socket.http")`, and flattening it into `types/http.d.tl` would rename the
1385/// module to one the library never had.
1386fn copy_declarations(
1387    from: &Path,
1388    to: &Path,
1389    origin: &Origin,
1390    force: bool,
1391    out: &mut TypesSync,
1392) -> anyhow::Result<()> {
1393    if !from.is_dir() {
1394        return Ok(());
1395    }
1396    let mut found: Vec<PathBuf> = walkdir::WalkDir::new(from)
1397        .into_iter()
1398        .filter_map(Result::ok)
1399        .filter(|e| e.file_type().is_file())
1400        .map(walkdir::DirEntry::into_path)
1401        .filter(|p| crate::is_declaration(p))
1402        .collect();
1403    found.sort();
1404    for src in found {
1405        let rel = src.strip_prefix(from).unwrap_or(&src).to_path_buf();
1406        let target = to.join(&rel);
1407        if target.exists() && !force {
1408            out.taken.push((target, origin.name.clone()));
1409            continue;
1410        }
1411        if let Some(parent) = target.parent() {
1412            std::fs::create_dir_all(parent)?;
1413        }
1414        std::fs::copy(&src, &target)?;
1415        // Beside it, the one thing the Lua ecosystem records nowhere: which revision of
1416        // what this declaration was taken from. Without it, staleness is not a question
1417        // anyone can ask.
1418        let mut note = target.clone().into_os_string();
1419        note.push(".src");
1420        std::fs::write(
1421            PathBuf::from(note),
1422            format!(
1423                "{} {} {}\n",
1424                origin.name,
1425                origin.sha,
1426                origin.under.join(&rel).display()
1427            ),
1428        )?;
1429        out.written.push((target, origin.name.clone()));
1430    }
1431    Ok(())
1432}
1433
1434/// What to say when the collection has no such library: the names it does have that look
1435/// related, or how many it holds at all — a list of every one of them is not an error
1436/// message.
1437fn no_such_library(checkout: &Path, library: &str) -> String {
1438    let mut names: Vec<String> = std::fs::read_dir(checkout.join("types"))
1439        .into_iter()
1440        .flatten()
1441        .filter_map(|e| e.ok())
1442        .filter(|e| e.path().is_dir())
1443        .map(|e| e.file_name().to_string_lossy().into_owned())
1444        .collect();
1445    names.sort();
1446    let near: Vec<&str> = names
1447        .iter()
1448        .filter(|n| n.contains(library) || library.contains(n.as_str()))
1449        .map(String::as_str)
1450        .collect();
1451    if near.is_empty() {
1452        format!(
1453            "teal-types has no declarations for `{library}` ({} libraries there)",
1454            names.len()
1455        )
1456    } else {
1457        format!(
1458            "teal-types has no declarations for `{library}` — it has {}",
1459            near.join(", ")
1460        )
1461    }
1462}
1463
1464/// One [`TealResolver`] per `[[contract]]` in `htl.toml`, in declaration order, so the
1465/// host and `htl check` enforce the same contracts from the same source. `root` is the
1466/// directory holding `htl.toml` (the path [`HtlConfig::find`](crate::config::HtlConfig::find)
1467/// returns, minus the file name). Add them to a `Registry` before the plain resolvers.
1468pub fn contract_resolvers(
1469    root: &Path,
1470    cfg: &crate::config::HtlConfig,
1471) -> Result<Vec<TealResolver>, InitError> {
1472    let (contracts, _) = crate::contract::resolve(root, cfg);
1473    let mut out = Vec::new();
1474    for c in &contracts {
1475        out.extend(TealResolver::for_contract(root, cfg, c)?);
1476    }
1477    Ok(out)
1478}
1479
1480impl crate::Htl {
1481    /// Make the project's installed deps visible to the Teal checker and to the
1482    /// prelude's strict searcher (`htl run` / `htl test` without a Registry).
1483    ///
1484    /// The directory on the path is [`Project::entries`], where each dep is reached at its
1485    /// `entry`; the links are written first if the lockfile calls for any that are missing.
1486    ///
1487    /// **A `patch_dir` dependency is on the path in its own right**, at
1488    /// [`Project::patch_search_dirs`]. The copy is committed and the manifest names it,
1489    /// so the two together are the whole of what a `require` of that dependency needs:
1490    /// no install, no link, no network, and nothing that has to exist outside what a
1491    /// clone or a tarball carries. That is the arrangement `cargo vendor` and Go's
1492    /// `vendor/` settled on — the copy plus the manifest naming it is the source of
1493    /// truth, and its presence is what turns the network off — and htl's reason for it
1494    /// is the one #266 found: `.htl/` is gitignored, so the copy `cargo package`
1495    /// verifies has the patch and the manifest and no links at all, and every `require`
1496    /// of the dependency failed there with `module not found`.
1497    ///
1498    /// Those directories go on first and are therefore consulted last, after the links,
1499    /// the `target_dir` copies and the project's own `src/`. A checkout that has
1500    /// installed resolves exactly what it resolved before — the link and the copy are
1501    /// the same files, and the link still answers first — so what this adds is an answer
1502    /// where there was none.
1503    ///
1504    /// Except under build scratch, where this writes nothing at all — the rule and the
1505    /// reason are [`crate::cache::scratch_root`]'s. What it does there it does read-only:
1506    /// the directories go on the search path whether or not they exist (a path that
1507    /// resolves nothing is what a tarball with no `.htl/` means, and the project's own
1508    /// `src/` is still there), and the dependency names still come from the lockfile.
1509    pub fn apply_project(&self, p: &Project) -> anyhow::Result<()> {
1510        let installed = p.link_entries()?;
1511        // The names, for the rules that are about a library the project has rather than
1512        // about its own code (`htlx-available`). The lockfile's rather than the manifest's:
1513        // a dependency nothing installed is one `require` cannot reach, and advice to use
1514        // it would be advice to fail a check.
1515        self.set_deps(&installed)?;
1516        // First, which is to say last: `add_path` prepends, so what goes on here is what
1517        // the path consults after everything below. A checkout that has installed
1518        // resolves through its links exactly as it did before, and the copy answers where
1519        // there are none — a tarball, a clone nobody has installed in yet.
1520        for d in p.patch_search_dirs() {
1521            self.add_path(&d)?;
1522        }
1523        if crate::cache::scratch_root(&p.root).is_none() {
1524            let _ = std::fs::create_dir_all(&p.entries);
1525        }
1526        self.add_path(&p.entries)?;
1527        for d in &p.target_dirs {
1528            self.add_path(d)?;
1529        }
1530        // The project's own modules: `<root>/src` (the scaffold layout) so a script anywhere
1531        // in the project resolves them the same way `tests/` does.
1532        let src = p.root.join("src");
1533        if src.is_dir() {
1534            self.add_path(&src)?;
1535        }
1536        Ok(())
1537    }
1538}
1539
1540/// Error raised when a `.tl` module fails the type check at `require` time.
1541///
1542/// Every variant carries `module` — the name that was required, not the path it resolved
1543/// to — because that is the name the `require` in the caller's source spells, and the
1544/// caller is where the mistake is read from. All four are returned as `Some(Err)` so the
1545/// `Registry` stops rather than falling through to a later resolver: a `.tl` that does not
1546/// check must not be quietly replaced by a `.lua` of the same name.
1547#[derive(Debug)]
1548pub enum TealResolveError {
1549    /// The module does not type-check on its own terms.
1550    TypeCheck {
1551        /// The name that was required.
1552        module: String,
1553        /// The checker's errors, one per line as it reported them.
1554        errors: Vec<String>,
1555    },
1556    /// The module type-checks on its own but is not assignable to the resolver's
1557    /// [`expect_type`](TealResolver::expect_type).
1558    Expectation {
1559        /// The name that was required.
1560        module: String,
1561        /// The type path the resolver demands, as `expect_type` was given it.
1562        expected: String,
1563        /// Why the assignment failed. The message adds a hint to annotate the returned
1564        /// table, because these errors are about the whole value and carry no line of
1565        /// their own until the module names its type.
1566        errors: Vec<String>,
1567    },
1568    /// [`require_fields`](TealResolver::require_fields): required fields absent at run time.
1569    MissingFields {
1570        /// The name that was required.
1571        module: String,
1572        /// The type whose fields were demanded — the same `expect_type`, since
1573        /// `require_fields` only applies alongside it.
1574        expected: String,
1575        /// The fields that were nil. Named rather than counted: every Teal record field is
1576        /// nilable, so which ones are missing is the whole of what the type check could
1577        /// not say.
1578        fields: Vec<String>,
1579    },
1580    /// The file could not be read through the sandbox — outside the root, or gone between
1581    /// the resolver finding it and opening it.
1582    Read {
1583        /// The name that was required.
1584        module: String,
1585        /// What the sandbox refused or failed on.
1586        source: ReadError,
1587    },
1588}
1589
1590impl std::fmt::Display for TealResolveError {
1591    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1592        match self {
1593            Self::TypeCheck { module, errors } => {
1594                write!(f, "Teal type check failed for module '{module}':")?;
1595                for e in errors {
1596                    write!(f, "\n  {e}")?;
1597                }
1598                Ok(())
1599            }
1600            Self::Expectation {
1601                module,
1602                expected,
1603                errors,
1604            } => {
1605                write!(f, "module '{module}' does not satisfy {expected}:")?;
1606                for e in errors {
1607                    write!(f, "\n  {e}")?;
1608                }
1609                write!(
1610                    f,
1611                    "\n  hint: annotate the returned table in the module (`local m: {expected} = {{ ... }}  return m`) \
1612                     to get field-level errors with line numbers"
1613                )
1614            }
1615            Self::MissingFields {
1616                module,
1617                expected,
1618                fields,
1619            } => write!(
1620                f,
1621                "module '{module}' is missing required field(s) of {expected}: {} (every field of that record must be non-nil)",
1622                fields.join(", ")
1623            ),
1624            Self::Read { module, source } => write!(f, "reading module '{module}': {source}"),
1625        }
1626    }
1627}
1628
1629impl std::error::Error for TealResolveError {}
1630
1631/// Is `name` registered in `package.preload` (host-provided implementation)?
1632fn preloaded(lua: &Lua, name: &str) -> mlua::Result<bool> {
1633    let package: Table = lua.globals().get("package")?;
1634    let preload: Table = package.get("preload")?;
1635    Ok(!matches!(preload.get::<Value>(name)?, Value::Nil))
1636}
1637
1638impl Resolver for TealResolver {
1639    fn resolve(&self, lua: &Lua, name: &str) -> Option<mlua::Result<Value>> {
1640        let relative = name.replace(self.module_separator, "/");
1641        // Flat packages: `<name>/<name>.tl` stands in for `<name>/init.tl`.
1642        let last = relative.rsplit('/').next().unwrap_or(&relative).to_string();
1643        let candidates = [
1644            (format!("{relative}.tl"), false),
1645            (format!("{relative}/init.tl"), false),
1646            (format!("{relative}/{last}.tl"), false),
1647            (format!("{relative}.d.tl"), true),
1648        ];
1649        let h = match Self::prelude(lua) {
1650            Ok(h) => h,
1651            Err(e) => return Some(Err(e)),
1652        };
1653        if let Err(e) = self.ensure_checker_path(lua, &h) {
1654            return Some(Err(e));
1655        }
1656        for (candidate, type_only) in &candidates {
1657            match self.sandbox.read(Path::new(candidate)) {
1658                Ok(Some(file)) => {
1659                    if *type_only {
1660                        // A `.d.tl` may describe a plain `.lua` served by a later resolver
1661                        // (FsResolver / VendoredResolver): step aside if one is present.
1662                        // Native modules must be registered *before* this resolver.
1663                        if self.has_lua_sibling(&relative) {
1664                            return None;
1665                        }
1666                        // ... or that the host registered in `package.preload` (a Rust
1667                        // `#[host_module]`, `Htl::preload_value`). The Registry's searcher
1668                        // runs *before* Lua's preload searcher, so this is the only chance.
1669                        match preloaded(lua, name) {
1670                            Ok(true) => return None,
1671                            Ok(false) => {}
1672                            Err(e) => return Some(Err(e)),
1673                        }
1674                        // Declaration-only module: nothing to run. Hand require a table whose
1675                        // lookups explain that the implementation lives elsewhere.
1676                        return Some(h.get::<Function>("type_only_module").and_then(|f| {
1677                            f.call::<Value>((name, file.resolved_path.to_string_lossy().as_ref()))
1678                        }));
1679                    }
1680                    let loaded =
1681                        match self.load_teal(lua, &h, &file.content, &file.resolved_path, name) {
1682                            Ok(v) => v,
1683                            Err(e) => return Some(Err(e)),
1684                        };
1685                    if !self.held(name) {
1686                        return Some(Ok(loaded));
1687                    }
1688                    match self.missing_fields(&h, &loaded) {
1689                        Ok(m) if m.is_empty() => return Some(Ok(loaded)),
1690                        Ok(missing) => {
1691                            return Some(Err(mlua::Error::external(
1692                                TealResolveError::MissingFields {
1693                                    module: name.to_string(),
1694                                    expected: self.expect_type.clone().unwrap_or_default(),
1695                                    fields: missing,
1696                                },
1697                            )));
1698                        }
1699                        Err(e) => return Some(Err(e)),
1700                    }
1701                }
1702                Ok(None) => continue,
1703                Err(source) => {
1704                    return Some(Err(mlua::Error::external(TealResolveError::Read {
1705                        module: name.to_string(),
1706                        source,
1707                    })));
1708                }
1709            }
1710        }
1711        None
1712    }
1713}