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}