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}