Skip to main content

htl_core/
lib.rs

1//! htl: Teal, hidden.
2//!
3//! Embeds the Teal compiler (`tl.lua`) into an mlua state so `.tl` sources can be
4//! type-checked, generated and executed without any external toolchain.
5//!
6//! - [`Htl::check`] / [`Htl::gen_lua`]: type-check and generate Lua from a `.tl` file
7//! - [`Htl::install_searcher`]: strict `require` for `.tl` (type errors abort the require)
8//! - [`Htl::preload`]: register generated Lua (e.g. from `include_tl!`) under a module name
9//! - [`bundle`]: stripped-bytecode bundles produced by `htl build`
10//!
11//! Two libraries ship inside the binary rather than on a project's search path, and both
12//! are installed the same way — a `package.preload` entry for the run, a `.d.tl` under
13//! [`lib_dir`] for the checker: `htl.test` ([`Htl::install_test_lib`], `describe` / `it` /
14//! `expect`) and, with the `std` feature, `std.*` — mlua-batteries' modules under the
15//! namespace that crate leaves to its host. The method that installs them is named and
16//! linked below when the feature that compiles it is on; a link to an item that is not
17//! compiled is a broken one.
18#![cfg_attr(
19    feature = "std",
20    doc = "
21//! That method is [`Htl::install_std`]."
22)]
23// Every public item here is `htl`'s public API: that crate is `pub use htl_core::*;`, and
24// `missing_docs` fires where an item is defined rather than where it is re-exported — so
25// the ratchet `htl` took in #224 does nothing for the half a reader actually meets unless
26// it is here too. It arrives with the change that took the count to zero, which is the
27// only moment it costs nothing and the only one at which it is true.
28#![deny(missing_docs)]
29
30pub use mlua;
31
32use anyhow::{Context, Result, anyhow, bail};
33use mlua::chunk::ChunkMode;
34use mlua::{Function, Lua, Table, Value, Variadic};
35use std::path::{Path, PathBuf};
36use std::sync::OnceLock;
37
38pub mod build_target;
39pub mod bundle;
40pub mod cache;
41#[cfg(feature = "dts")]
42pub mod cexport;
43pub mod config;
44pub mod contract;
45#[cfg(feature = "dts")]
46pub mod dep_dts;
47pub mod diagnostic;
48#[cfg(feature = "dts")]
49pub mod dts;
50#[cfg(feature = "ffi")]
51pub mod ffi;
52pub mod fix;
53// The rules there are, and which of them a run has on. Both halves of htl report under
54// these names, so the list is here rather than in `lint.lua`, which is one of the halves.
55pub mod link;
56pub mod lint;
57#[cfg(feature = "pkg")]
58pub mod pkg;
59// The project layer: a walk over many files, the run cache under it, and the decisions
60// both `htl check` and a macro expansion make about that store. It reaches the mlua-pkg
61// project a file belongs to and the Cargo package around it, so it asks for the two
62// features that provide them; every consumer that has a project to check has both.
63#[cfg(all(feature = "pkg", feature = "dts"))]
64pub mod project;
65// What one module name resolves to, and what that hides. Reads the project the same way
66// the project layer does — the installed deps, the config's search paths, the notes `htl
67// dts` leaves under `types/<crate>/` — so it carries the same features.
68#[cfg(all(feature = "pkg", feature = "dts"))]
69pub mod resolve;
70// `std.*`: mlua-batteries, preloaded and declared the way `htl.test` is. Its own module
71// rather than a corner of `testing.rs` because the two libraries are unrelated apart from
72// how they are installed, and that part they share through `lib_dir`. Named for the crate
73// and not for the namespace: a module called `std` at the crate root would shadow `::std`
74// in every path this file writes.
75#[cfg(feature = "std")]
76pub mod batteries;
77pub mod teal;
78pub mod testing;
79// The complement of the require closure: what no entry reaches. On the project layer,
80// whose check hands it the graph, so it carries that layer's features.
81#[cfg(all(feature = "pkg", feature = "dts"))]
82pub mod unused;
83
84pub use build_target::BuildTarget;
85pub use diagnostic::{Diagnostic, Severity};
86pub use teal::Strict;
87
88/// Registry key under which the prelude table is stored (lets `pkg::TealResolver`
89/// reach the compiler from a bare `&Lua`).
90pub(crate) const PRELUDE_REGISTRY_KEY: &str = "htl.prelude";
91
92const TL_SRC: &str = include_str!("../vendor/tl.lua");
93const LINT_SRC: &str = include_str!("lint.lua");
94const FMT_SRC: &str = include_str!("fmt.lua");
95const PRELUDE: &str = include_str!("prelude.lua");
96
97/// A hash of the Lua the checker is made of: the vendored `tl`, the lints, the formatter
98/// and the prelude. Two builds with the same value generate the same Lua for the same
99/// input, whatever else differs about them.
100///
101/// The run cache stamps its entries with this ([`cache`]). The CLI also stamps them with
102/// its own binary, which moves on every rebuild; inside a proc macro the binary is
103/// `rustc`, which does not move when htl does, and this is what tells those entries apart
104/// from a checker that no longer exists.
105pub fn checker_identity() -> &'static str {
106    static ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
107    ID.get_or_init(|| {
108        let mut h = blake3::Hasher::new();
109        for src in [TL_SRC, LINT_SRC, FMT_SRC, PRELUDE] {
110            h.update(src.as_bytes());
111            h.update(b"\0");
112        }
113        h.finalize().to_hex().to_string()
114    })
115}
116
117/// Teal version vendored into this crate.
118pub const TEAL_VERSION: &str = "0.24.8";
119
120/// Result of type-checking one `.tl` file.
121#[derive(Debug, Clone, Default)]
122pub struct CheckInfo {
123    /// `file:line:col: message` for syntax and type errors.
124    pub errors: Vec<String>,
125    /// `file:line:col: message` for warnings (non-fatal).
126    pub warnings: Vec<String>,
127    /// Files pulled in via `require` during checking (`.tl` / `.d.tl` / `.lua`).
128    pub deps: Vec<PathBuf>,
129    /// htl lint findings (`nil-index`, `enum-exhaustive`). Advisory unless the caller
130    /// promotes them (`htl check --strict`, `include_tl!`).
131    pub lints: Vec<String>,
132    /// Every `require("<literal>")` in the file and where the checker resolved it.
133    /// Input to [`require_cycles`].
134    pub requires: Vec<RequireSite>,
135    /// `error_fixes[i]` is the fix for `errors[i]`, when the error has one.
136    pub error_fixes: Vec<Option<Fix>>,
137    /// `lint_fixes[i]` is the fix for `lints[i]`, when the lint has one.
138    pub lint_fixes: Vec<Option<Fix>>,
139    /// Type errors in the modules this check pulled in through `require`, transitively,
140    /// each dependency once. Not in `errors`, and not what [`ok`](Self::ok) answers: the
141    /// file itself checked, and generates; it is the `require` of that module that will
142    /// raise at run time ([`Htl::install_searcher`]), which is why a caller reporting on a
143    /// project treats these as errors too (`htl check`, `include_tl!`).
144    pub dependency_errors: Vec<DependencyError>,
145}
146
147/// A type error in a module a check reached through `require` (see
148/// [`CheckInfo::dependency_errors`]).
149///
150/// The checker checks a required module into the same environment and hands the
151/// requirer its *type*; the module's own errors stay with the module's result. This is
152/// that result's error, said against the file that required it, so a report can name
153/// both — a dependency is only ever checked through a `require`, since its sources are
154/// not the project's to walk.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct DependencyError {
157    /// The file the error is in, as the checker found it on the search path.
158    pub file: PathBuf,
159    /// The file whose `require` (direct or through another dependency) pulled it in:
160    /// the first one on this check's walk.
161    pub required_by: PathBuf,
162    /// `file:line:col: message`, formatted as the file's own errors are.
163    pub text: String,
164}
165
166/// How safely a [`Fix`] can be applied without a human looking at it.
167///
168/// Serializes as its [`as_str`](Applicability::as_str) name, which is what a stored fix
169/// and `--format json` both carry.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
171#[serde(rename_all = "lowercase")]
172pub enum Applicability {
173    /// The rewrite does not change what the program does at run time.
174    Safe,
175    /// It may; applied only when asked (`htl fix --unsafe`).
176    Unsafe,
177    /// Shown, never applied (a placeholder to fill, a choice to make).
178    Suggest,
179}
180
181impl Applicability {
182    /// The lowercase word this is stored and printed as — the one spelling that crosses
183    /// between a run, `--format json`, and the cached fix a later run reads back.
184    pub fn as_str(self) -> &'static str {
185        match self {
186            Applicability::Safe => "safe",
187            Applicability::Unsafe => "unsafe",
188            Applicability::Suggest => "suggest",
189        }
190    }
191}
192
193/// One text replacement: `[start, end)` in 1-based line / byte-column coordinates;
194/// an insertion has `end == start`.
195#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
196pub struct Edit {
197    /// First line of the range to replace, counted from 1.
198    pub line: usize,
199    /// First byte-column, counted from 1. Bytes rather than characters, because that is
200    /// what the checker reports and what an applier slices with.
201    pub col: usize,
202    /// Line the range ends on. Equal to [`line`](Self::line) for an edit within one line.
203    pub end_line: usize,
204    /// Byte-column the range ends at, exclusive — so the character at `end_col` survives.
205    /// Equal to [`col`](Self::col) for an insertion, which replaces nothing.
206    pub end_col: usize,
207    /// What goes in the range's place. Empty deletes it.
208    pub text: String,
209}
210
211/// A mechanical rewrite attached to a diagnostic (see [`fix`]).
212///
213/// Serializes as [`cache::FixJson`] does, since the two describe the same thing and the
214/// store reads back what `--format json` prints.
215#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
216pub struct Fix {
217    /// Whether `htl fix` may apply this without being asked twice.
218    pub applicability: Applicability,
219    /// The rewrite, as one or more replacements. A fix is all of them or none: they are
220    /// applied together, because a rewrite that lands half-way is worse than one that did
221    /// not land.
222    pub edits: Vec<Edit>,
223}
224
225/// One literal `require` call in a checked file.
226#[derive(Debug, Clone)]
227pub struct RequireSite {
228    /// The name as the call spells it, before any separator or entry mapping.
229    pub module: String,
230    /// Resolved file, `None` when the checker could not find it.
231    pub path: Option<PathBuf>,
232    /// Line of the `require` call, counted from 1.
233    pub line: usize,
234    /// Byte-column of the call, counted from 1.
235    pub col: usize,
236}
237
238/// A named function of a `.tl` file, for coverage (see [`Htl::coverage_spans`]).
239///
240/// The body is `line + 1 ..= last - 1`, strictly between the two: defining a function
241/// runs both ends of it, so neither says whether the function was ever entered. A
242/// never-called `function m.f()` spanning lines 12..15 comes back from the line hook
243/// with 12 and 15 executed and 13, 14 not. Functions with nothing in between (one
244/// line, or an empty body) have no such span and are not reported at all.
245#[derive(Debug, Clone)]
246pub struct FunctionSpan {
247    /// As the source writes it: `f`, `M.f`, `M:f`.
248    pub name: String,
249    /// The line the function is declared on.
250    pub line: usize,
251    /// The line its `end` is on. Always at least `line + 2`.
252    pub last: usize,
253}
254
255/// What one parse gives a coverage report: the statement ranges, and the functions
256/// those ranges sit in. See [`Htl::coverage_spans`].
257pub type CoverageSpans = (Vec<(usize, usize)>, Vec<FunctionSpan>);
258
259/// What a file on the search path is, for [`Htl::module_candidates`]. The three the
260/// searchers try, in the order they try them: a `.tl` source beats a `.d.tl` declaration
261/// wherever the two sit, and a plain `.lua` is what is left when neither is reachable.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
263#[serde(rename_all = "lowercase")]
264pub enum ModuleKind {
265    /// A `.tl` the checker compiles and the program runs — the only kind that is both.
266    Source,
267    /// A `.d.tl`: types with no implementation. Requiring one at run time gets an empty
268    /// table, which is why a module that resolves to a declaration and nothing else
269    /// type-checks and then fails.
270    Declaration,
271    /// A plain `.lua`, which the checker has nothing to say about. What is left when
272    /// neither of the other two is reachable.
273    Lua,
274}
275
276impl ModuleKind {
277    fn of(s: &str) -> Self {
278        match s {
279            "source" => Self::Source,
280            "declaration" => Self::Declaration,
281            _ => Self::Lua,
282        }
283    }
284
285    /// As a report says it.
286    pub fn as_str(self) -> &'static str {
287        match self {
288            Self::Source => "source",
289            Self::Declaration => "declaration",
290            Self::Lua => "lua",
291        }
292    }
293}
294
295impl std::fmt::Display for ModuleKind {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        f.write_str(self.as_str())
298    }
299}
300
301/// One file `require(name)` could have resolved to. See [`Htl::module_candidates`].
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct ModuleCandidate {
304    /// The file itself.
305    pub path: PathBuf,
306    /// Which of the three it is, which is what decides whether it wins over the ones
307    /// found after it.
308    pub kind: ModuleKind,
309    /// The search-path directory it was found under.
310    pub dir: PathBuf,
311}
312
313/// Result of a static contract check (see [`Htl::contract_check`]).
314#[derive(Debug, Clone, Default)]
315pub struct ContractResult {
316    /// Type errors from `local m: <T> = require("<mod>")`.
317    pub errors: Vec<String>,
318    /// Declared fields absent from the module's returned table literal; `None` when the
319    /// return value is not a literal (not decidable statically).
320    pub missing: Option<Vec<String>>,
321    /// Line and column of the returned table literal, for the report about
322    /// [`missing`](Self::missing) to point at. `(1, 1)` when the checker gave no position,
323    /// so a message always has somewhere to point rather than none.
324    pub missing_at: (usize, usize),
325    /// Names `require_fields` asked for that the contract type does not declare. The
326    /// config is wrong about the type, which is a different finding from a module that
327    /// fails the contract, and no module can fix it.
328    pub bad_require_fields: Vec<String>,
329}
330
331impl Htl {
332    /// Make an `htl.toml` project's dirs visible to the checker: `root`, `root/src` and
333    /// `[check] paths`. `root` is the directory holding `htl.toml`.
334    pub fn apply_config(&self, root: &Path, cfg: &config::HtlConfig) -> Result<()> {
335        self.add_search_paths(&cfg.search_paths(root))
336    }
337
338    /// Put `dirs` on the search path so they are consulted **in the order given** — the
339    /// order [`search_paths`](config::HtlConfig::search_paths) documents, and the one a
340    /// reader assumes from a list. [`add_path`](Self::add_path) prepends, so adding the
341    /// list front to back would leave its last entry first; this adds it back to front.
342    ///
343    /// It decides one thing: which of two declarations of the same module is read. A
344    /// `.tl` source beats a `.d.tl` wherever the two sit, so until neither is a source
345    /// the order is invisible.
346    pub fn add_search_paths(&self, dirs: &[PathBuf]) -> Result<()> {
347        for p in dirs.iter().rev() {
348            self.add_path(p)?;
349        }
350        Ok(())
351    }
352
353    /// Static form of `TealResolver::expect_type` / `require_fields` for one module file:
354    /// `modname` is what a `require` would say (its stem), `type_path` is `"defs.Mod"`.
355    pub fn contract_check(
356        &self,
357        file: &Path,
358        modname: &str,
359        type_path: &str,
360        require_fields: &config::RequireFields,
361    ) -> Result<ContractResult> {
362        let f: Function = self.h.get("contract_check")?;
363        // `true` for "everything the type declares", the list itself when it names them.
364        let wanted = match require_fields.named() {
365            Some(names) => mlua::Value::Table(self.lua().create_sequence_from(names.to_vec())?),
366            None => mlua::Value::Boolean(require_fields.is_on()),
367        };
368        let t: Table = f.call((path_str(file), modname, type_path, wanted))?;
369        let errors: Table = t.get("errors")?;
370        let errors = errors
371            .sequence_values::<String>()
372            .collect::<mlua::Result<_>>()?;
373        let missing = match t.get::<Option<Table>>("missing")? {
374            Some(m) => Some(
375                m.sequence_values::<String>()
376                    .collect::<mlua::Result<Vec<_>>>()?,
377            ),
378            None => None,
379        };
380        let missing_at = (
381            t.get::<Option<usize>>("missing_y")?.unwrap_or(1),
382            t.get::<Option<usize>>("missing_x")?.unwrap_or(1),
383        );
384        let bad_require_fields = match t.get::<Option<Table>>("bad_require_fields")? {
385            Some(b) => b
386                .sequence_values::<String>()
387                .collect::<mlua::Result<Vec<_>>>()?,
388            None => Vec::new(),
389        };
390        Ok(ContractResult {
391            errors,
392            missing,
393            missing_at,
394            bad_require_fields,
395        })
396    }
397}
398
399/// `contract` lint for one file: when `file` sits directly under the directory a
400/// contract holds (relative to `root`, the directory holding `htl.toml`), check it
401/// against that contract statically. Returns lint lines (empty when none applies).
402///
403/// `contracts` comes from [`contract::resolve`], which reads the `---@contract` markers;
404/// resolving once per run rather than once per file is the caller's job.
405pub fn contract_lints(
406    h: &Htl,
407    root: &Path,
408    cfg: &config::HtlConfig,
409    contracts: &[contract::Resolved],
410    file: &Path,
411) -> Result<Vec<String>> {
412    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
413    let file_abs = canon(file);
414    let mut out = Vec::new();
415    if !is_tl_source(&file_abs) {
416        return Ok(out);
417    }
418    let modname = file_abs
419        .file_stem()
420        .and_then(|s| s.to_str())
421        .unwrap_or("")
422        .to_string();
423    for c in contracts {
424        let Some(dir) = c
425            .dirs(root)
426            .into_iter()
427            .map(|d| canon(&d))
428            .find(|d| file_abs.parent() == Some(d.as_path()))
429        else {
430            continue;
431        };
432        if !c.applies_to(&modname) {
433            continue;
434        }
435        // Same visibility as `TealResolver::for_contract`: the contract dir, plus what
436        // `HtlConfig::search_paths` gives (the project root, its `src/` and `types/`,
437        // then `[check] paths`). Both sides go through that one function.
438        h.add_path(&dir)?;
439        h.apply_config(root, cfg)?;
440        let r = h.contract_check(&file_abs, &modname, &c.type_path, &c.require_fields)?;
441        if !r.bad_require_fields.is_empty() {
442            // A `---@required` the checker cannot see as a field of the record: the
443            // marker is on something else, and no module under the dir can satisfy it.
444            out.push(format!(
445                "{}:{}:1: ---@required on field(s) {} does not declare: {} [htl contract]",
446                c.declared_in.display(),
447                c.declared_at,
448                c.type_path,
449                r.bad_require_fields.join(", ")
450            ));
451            continue;
452        }
453        for e in &r.errors {
454            // The stub's own "<contract ...>:L:C: " prefix says nothing useful; keep the
455            // message. The same reading of a diagnostic's text every other caller makes.
456            let msg = diagnostic::position(e)
457                .map_or(e.as_str(), |(_, _, _, msg)| msg)
458                .trim();
459            out.push(format!(
460                "{}:1:1: does not satisfy contract {} ({}): {msg} [htl contract]",
461                file.display(),
462                c.type_path,
463                c.dir
464            ));
465        }
466        if let Some(missing) = &r.missing
467            && !missing.is_empty()
468        {
469            out.push(format!(
470                "{}:{}:{}: returned table lacks declared field(s) of {}: {} [htl contract]",
471                file.display(),
472                r.missing_at.0,
473                r.missing_at.1,
474                c.type_path,
475                missing.join(", ")
476            ));
477        }
478    }
479    Ok(out)
480}
481
482/// `duplicate-declaration` lint: a module `file` requires resolved to a `.d.tl` while
483/// another `.d.tl` for the same module was reachable further along the search path. One
484/// was read and the other was not, decided by position, and until now nothing said so —
485/// the case this catches is a host publishing a declaration into a project that also
486/// keeps a hand-written one for the same module.
487///
488/// Only declarations collide. A `.tl` source beats every `.d.tl` wherever the two sit
489/// (`prelude.lua` searches sources across the whole path first), so a require that
490/// landed on a source is not reported, and neither is a module declared once.
491///
492/// A require that landed on a source is where the other lint here lives.
493/// `host-module-shadowed`: `host_modules` are the names the surrounding crate registers
494/// in `package.preload` (from `#[host_module]`, scanned without a build), and Lua
495/// consults preload before any path searcher. So when a require of one of those names
496/// resolved to a file, the check read the file and the run will load the host: what was
497/// checked is not what runs, and the program fails at the first call of anything the two
498/// do not share. Both halves of that are already in hand at this point — the name the
499/// host registers, and the path the checker read — which is why it is asked here.
500///
501/// A require of a host module name that landed on a `.d.tl` is not reported: a
502/// declaration is how a host module is given types at all, and `htl dts` writes exactly
503/// that file, so the two agree by construction.
504///
505/// Call it with the search path the file was checked under: the answer depends on it.
506pub fn declaration_conflict_lints(
507    h: &Htl,
508    file: &Path,
509    info: &CheckInfo,
510    host_modules: &[String],
511) -> Result<Vec<String>> {
512    let f: Function = h.h.get("declaration_sites")?;
513    let mut out = Vec::new();
514    let mut seen: Vec<&str> = Vec::new();
515    for site in &info.requires {
516        let Some(read) = site.path.as_ref() else {
517            continue;
518        };
519        // One report per module, not one per `require` of it.
520        if seen.contains(&site.module.as_str()) {
521            continue;
522        }
523        if !is_declaration(read) {
524            if host_modules.contains(&site.module) {
525                seen.push(&site.module);
526                out.push(format!(
527                    "{}:{}:{}: {} is a host module of this crate and also {}: the check \
528                     reads the file, the run loads the host — package.preload is consulted \
529                     before any path searcher, so what is checked here is not what runs \
530                     [htl host-module-shadowed]",
531                    file.display(),
532                    site.line,
533                    site.col,
534                    site.module,
535                    read.display(),
536                ));
537            }
538            continue;
539        }
540        let sites: Vec<String> = f
541            .call::<Table>(site.module.as_str())?
542            .sequence_values::<String>()
543            .collect::<mlua::Result<_>>()?;
544        let shadowed: Vec<&str> = sites
545            .iter()
546            .map(|s| s.as_str())
547            .filter(|s| !same_file(Path::new(s), read))
548            .collect();
549        if shadowed.is_empty() {
550            continue;
551        }
552        seen.push(&site.module);
553        out.push(format!(
554            "{}:{}:{}: {} is declared more than once on the search path: {} is read, {} {} not [htl duplicate-declaration]",
555            file.display(),
556            site.line,
557            site.col,
558            site.module,
559            read.display(),
560            shadowed.join(" and "),
561            if shadowed.len() == 1 { "is" } else { "are" },
562        ));
563    }
564    Ok(out)
565}
566
567/// `contract-unenforced` lint: a contract only becomes a run-time guarantee when the
568/// host builds its resolver from it. Scan the host crate's Rust sources (under
569/// `cargo_root`) for `contract_resolvers(`. No host crate (`cargo_root` = None) means a
570/// script-only project: nothing to enforce.
571///
572/// One call to look for, not four. `contract_resolvers(root, &config)` is what the README
573/// documents and what keeps the host and `htl check` reading the same markers; a resolver
574/// assembled by hand from `expect_type` / `require_fields` now has to restate what the
575/// record already says, so recognising it would be recognising the drift this lint
576/// exists to prevent. Enforcement the scan cannot see at all — a Lua-side validator, a
577/// resolver in a sibling crate, generated code, or a resolver built by hand — is what
578/// `[[contract]] enforced_by` is for: it names the file the enforcement lives in, and
579/// that contract is then not held to the scan. The file has to exist, which is what
580/// separates the key from a per-contract off switch, and a name that points at nothing is
581/// reported under this same rule whether or not the call was found.
582pub fn contract_enforcement_lints(
583    cfg_path: &Path,
584    contracts: &[contract::Resolved],
585    cargo_root: Option<&Path>,
586) -> Vec<String> {
587    let mut out = Vec::new();
588    if contracts.is_empty() {
589        return out;
590    }
591    let Some(root) = cargo_root else { return out };
592    let mut sources = String::new();
593    for sub in ["src", "examples", "tests", "benches"] {
594        let dir = root.join(sub);
595        if !dir.is_dir() {
596            continue;
597        }
598        for e in walkdir::WalkDir::new(&dir).into_iter().flatten() {
599            let p = e.path();
600            if p.is_file()
601                && p.extension().and_then(|s| s.to_str()) == Some("rs")
602                && let Ok(t) = std::fs::read_to_string(p)
603            {
604                sources.push_str(&t);
605                sources.push('\n');
606            }
607        }
608    }
609    let by_config = sources.contains("contract_resolvers(");
610    for c in contracts {
611        // A contract with nothing under it is not enforced by anyone; the dir may be
612        // populated later (glob dirs especially), so say nothing about the host.
613        if c.dirs(root_of(cfg_path)).is_empty() {
614            continue;
615        }
616        match &c.enforced_by {
617            // The path is the whole of what makes `enforced_by` a claim rather than an
618            // off switch, so it is checked whether or not the scan found the call: a name
619            // that points at nothing is a broken statement either way.
620            Some(p) => {
621                let at = config::resolve_path(root_of(cfg_path), p);
622                if !at.exists() {
623                    out.push(format!(
624                        "{}:1:1: contract {} -> {} says it is enforced by {:?}, and there \
625                         is no such file: name where the enforcement lives, or drop the \
626                         key and let the scan look for \
627                         htl::pkg::contract_resolvers(root, &config) \
628                         [htl contract-unenforced]",
629                        cfg_path.display(),
630                        c.dir,
631                        c.type_path,
632                        p,
633                    ));
634                }
635            }
636            None if !by_config => out.push(format!(
637                "{}:{}:1: contract {} -> {} is declared but the host does not enforce it: \
638                 build resolvers with htl::pkg::contract_resolvers(root, &config), or say \
639                 where it is enforced with [[contract]] enforced_by \
640                 [htl contract-unenforced]",
641                c.declared_in.display(),
642                c.declared_at,
643                c.dir,
644                c.type_path,
645            )),
646            None => {}
647        }
648    }
649    out
650}
651
652fn root_of(cfg_path: &Path) -> &Path {
653    cfg_path.parent().unwrap_or(Path::new("."))
654}
655
656/// Cycles in the require graph of a set of checked files, one message per cycle,
657/// anchored at the first edge's call site. Teal types a circular require as an opaque
658/// `circular_require`, so a cycle shows up elsewhere as "cannot index" errors; naming
659/// the loop is the useful part. Files outside `infos` are treated as leaves.
660pub fn require_cycles(infos: &[(PathBuf, CheckInfo)]) -> Vec<String> {
661    use std::collections::{HashMap, HashSet};
662    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
663    let mut edges: HashMap<PathBuf, Vec<(PathBuf, &RequireSite)>> = HashMap::new();
664    let mut display: HashMap<PathBuf, PathBuf> = HashMap::new();
665    for (file, ci) in infos {
666        let from = canon(file);
667        display.insert(from.clone(), file.clone());
668        let list = edges.entry(from).or_default();
669        for r in &ci.requires {
670            if let Some(p) = &r.path {
671                list.push((canon(p), r));
672            }
673        }
674    }
675    let nodes: Vec<PathBuf> = {
676        let mut v: Vec<PathBuf> = edges.keys().cloned().collect();
677        v.sort();
678        v
679    };
680    let mut out = Vec::new();
681    let mut reported: HashSet<Vec<PathBuf>> = HashSet::new();
682    let mut state: HashMap<PathBuf, u8> = HashMap::new(); // 1 = on stack, 2 = done
683    let mut stack: Vec<(PathBuf, Option<&RequireSite>)> = Vec::new();
684
685    fn dfs<'a>(
686        node: PathBuf,
687        edges: &HashMap<PathBuf, Vec<(PathBuf, &'a RequireSite)>>,
688        state: &mut HashMap<PathBuf, u8>,
689        stack: &mut Vec<(PathBuf, Option<&'a RequireSite>)>,
690        reported: &mut HashSet<Vec<PathBuf>>,
691        display: &HashMap<PathBuf, PathBuf>,
692        out: &mut Vec<String>,
693    ) {
694        state.insert(node.clone(), 1);
695        if let Some(list) = edges.get(&node) {
696            for (to, site) in list {
697                match state.get(to).copied() {
698                    Some(1) => {
699                        // back edge: cycle = stack from `to` .. node, then back to `to`
700                        let start = stack.iter().position(|(n, _)| n == to).unwrap_or(0);
701                        let mut members: Vec<PathBuf> = stack[start..]
702                            .iter()
703                            .map(|(n, _)| n.clone())
704                            .chain(std::iter::once(node.clone()))
705                            .collect();
706                        members.dedup();
707                        let mut key = members.clone();
708                        key.sort();
709                        if reported.insert(key) {
710                            let name = |p: &PathBuf| {
711                                display
712                                    .get(p)
713                                    .unwrap_or(p)
714                                    .file_name()
715                                    .map(|s| s.to_string_lossy().into_owned())
716                                    .unwrap_or_else(|| p.display().to_string())
717                            };
718                            let chain: Vec<String> = members
719                                .iter()
720                                .map(name)
721                                .chain(std::iter::once(name(to)))
722                                .collect();
723                            let first_file = display
724                                .get(&members[0])
725                                .cloned()
726                                .unwrap_or_else(|| members[0].clone());
727                            // anchor: the edge leaving the cycle's first member
728                            let anchor = stack.get(start + 1).and_then(|(_, s)| *s).unwrap_or(site);
729                            out.push(format!(
730                                "{}:{}:{}: require cycle: {} (Teal types the back edge as an opaque circular require; \
731                                 break it by moving shared types into a module both sides require) [htl require-cycle]",
732                                first_file.display(),
733                                anchor.line,
734                                anchor.col,
735                                chain.join(" -> ")
736                            ));
737                        }
738                    }
739                    Some(2) => {}
740                    _ => {
741                        stack.push((to.clone(), Some(site)));
742                        dfs(to.clone(), edges, state, stack, reported, display, out);
743                        stack.pop();
744                    }
745                }
746            }
747        }
748        state.insert(node, 2);
749    }
750
751    for n in nodes {
752        if !state.contains_key(&n) {
753            stack.push((n.clone(), None));
754            dfs(
755                n,
756                &edges,
757                &mut state,
758                &mut stack,
759                &mut reported,
760                &display,
761                &mut out,
762            );
763            stack.pop();
764        }
765    }
766    out.sort();
767    out
768}
769
770impl CheckInfo {
771    /// `true` when nothing failed the check — errors only. Warnings and lints are the
772    /// caller's to promote ([`clean`](Self::clean) is the stricter question), so this is
773    /// what decides whether generated code may be run.
774    pub fn ok(&self) -> bool {
775        self.errors.is_empty()
776    }
777
778    /// `true` when there are no errors, warnings or lints.
779    pub fn clean(&self) -> bool {
780        self.errors.is_empty() && self.warnings.is_empty() && self.lints.is_empty()
781    }
782
783    /// Everything this check found about the file itself, structured, in the order the
784    /// text output says it: warnings, then lints, then errors.
785    ///
786    /// Errors in what the file *required* are not here — they belong to the module they
787    /// are in, and it is the reporting caller that decides how to say them
788    /// ([`dependency_errors`](Self::dependency_errors)).
789    pub fn diagnostics(&self) -> Vec<Diagnostic> {
790        let mut out = self.warning_diagnostics();
791        out.extend(self.lint_diagnostics());
792        out.extend(self.error_diagnostics());
793        out
794    }
795
796    /// [`errors`](Self::errors) with their positions and their fixes.
797    pub fn error_diagnostics(&self) -> Vec<Diagnostic> {
798        parsed(Severity::Error, &self.errors, &self.error_fixes)
799    }
800
801    /// [`warnings`](Self::warnings) with their positions. Warnings carry no fix.
802    pub fn warning_diagnostics(&self) -> Vec<Diagnostic> {
803        parsed(Severity::Warning, &self.warnings, &[])
804    }
805
806    /// [`lints`](Self::lints) with their positions, their rule names and their fixes.
807    pub fn lint_diagnostics(&self) -> Vec<Diagnostic> {
808        parsed(Severity::Lint, &self.lints, &self.lint_fixes)
809    }
810}
811
812/// `texts[i]` parsed, with `fixes[i]` attached when there is one.
813fn parsed(severity: Severity, texts: &[String], fixes: &[Option<Fix>]) -> Vec<Diagnostic> {
814    texts
815        .iter()
816        .enumerate()
817        .map(|(i, text)| {
818            let mut d = Diagnostic::parse(severity, text);
819            d.fix = fixes.get(i).and_then(|f| f.clone());
820            d
821        })
822        .collect()
823}
824
825/// An mlua state with the Teal compiler loaded.
826pub struct Htl {
827    /// The program's state: `require`, preloads, `exec`, bundles.
828    lua: Lua,
829    /// The prelude table (checker API). Lives in `lua` unless this is a split state
830    /// made by [`with_checker`](Self::with_checker), where it belongs to the checker.
831    h: Table,
832    /// `true` when the checker is another Lua state (`with_checker`).
833    split: bool,
834}
835
836/// Checker prelude of another state, kept in a runtime state's app data so the
837/// mlua-pkg resolvers find their checker (`Htl::with_checker`).
838pub(crate) struct CheckerHandle(pub(crate) Table);
839
840const RUNTIME_REGISTRY_KEY: &str = "htl.runtime";
841
842/// Registry key under which a state remembers, per bundle entry, which `package.preload`
843/// names that bundle wrote (`Htl::bundle_record`).
844const BUNDLE_REGISTRY_KEY: &str = "htl.bundles";
845
846/// What [`Htl::replace_bundle`] did, so a host can say it rather than guess.
847///
848/// Three lists because three things happen to a name, and a host that logs "reloaded" for
849/// all of them is hiding the two that matter: a module that went away for good, and one
850/// whose live value was deliberately spared.
851///
852/// A module both bundles carry appears in `dropped` *and* in `added` — which is what
853/// happened to it: the old one was taken out of `package.loaded`, and the new one is what
854/// the next `require` will evaluate. Nothing is in both `dropped` and `kept`.
855#[derive(Debug, Clone, Default, PartialEq, Eq)]
856pub struct Replaced {
857    /// Names the old bundle had installed that are now out of `package.preload` and
858    /// `package.loaded`: the next `require` of one evaluates whatever answers it now, and
859    /// for a name the new bundle does not carry there may be nothing left to answer.
860    pub dropped: Vec<String>,
861    /// Names from `keep` that the old bundle had actually installed, and whose evaluated
862    /// value is still in `package.loaded`. Shorter than the `keep` that was asked for when
863    /// a name in it was never this bundle's — the host's own module, or a typo — which is
864    /// the only report of that.
865    pub kept: Vec<String>,
866    /// Names the new bundle wrote into `package.preload`. Not what it carries: a name the
867    /// host had registered first is still the host's and is not here.
868    pub added: Vec<String>,
869}
870
871/// The part of the prelude a runtime state needs when its checker lives elsewhere:
872/// the strict searcher (asking the checker through `gen`), the declaration-only
873/// module, and `package.path` bookkeeping.
874const RUNTIME_PRELUDE: &str = r#"
875local R = {}
876
877function R.type_only_module(module_name, decl_path)
878   return setmetatable({}, {
879      __index = function(_, key)
880         error(string.format(
881            "module '%s' is declaration-only here (%s): '%s' has no implementation on this path. " ..
882            "It must be provided by the host program (e.g. a Rust #[host_module] via cargo run) " ..
883            "or by a .tl/.lua module with that name.",
884            module_name, decl_path, tostring(key)), 2)
885      end,
886   })
887end
888
889-- gen(name) -> kind, a, b  (see resolve_for_require in the checker prelude)
890function R.install_searcher(gen)
891   table.insert(package.searchers, 2, function(module_name)
892      local kind, a, b = gen(module_name)
893      if kind == "code" then
894         local chunk, lerr = load(a, "@" .. b, "t")
895         if not chunk then
896            error("htl: generated Lua failed to load: " .. tostring(lerr), 0)
897         end
898         return function(modname) return chunk(modname, b) end, b
899      elseif kind == "type_only" then
900         return function() return R.type_only_module(module_name, a) end, a
901      end
902      return a
903   end)
904end
905
906-- Put already-generated Lua in front of the searcher for one module name.
907--
908-- `package.preload` is searcher position 1 and R.install_searcher puts htl's at 2, so a
909-- preloaded module is never asked of the searcher — which is the point: asking would check
910-- and generate it again. Loaded the same way the searcher would have loaded it, so the
911-- module sees the same chunk name and the same arguments.
912function R.preload_generated(module_name, code, filename)
913   -- Never displace what is already there. The test library and anything a host preloads are
914   -- put in package.preload by whoever owns them, and Lua generated from a `.tl` of the same
915   -- name is not the same module: preloading over `htl.test` gives the file a stand-in whose
916   -- `run()` reports nothing, and every test silently stops counting.
917   if package.preload[module_name] ~= nil then return end
918   local chunk, lerr = load(code, "@" .. filename, "t")
919   if not chunk then
920      error("htl: cached Lua failed to load: " .. tostring(lerr), 0)
921   end
922   package.preload[module_name] = function(modname) return chunk(modname, filename) end
923end
924
925function R.add_path(dir)
926   local templates = dir .. "/?.lua;" .. dir .. "/?/init.lua;" .. dir .. "/?/?.lua"
927   if package.path == nil or package.path == "" then
928      package.path = templates
929   else
930      package.path = templates .. ";" .. package.path
931   end
932end
933
934function R.reset_path()
935   package.path = ""
936end
937
938-- Line coverage: which lines of which chunk ran. Lua's line hook is per thread, so
939-- code that runs inside a coroutine the test creates is not seen.
940local cov = nil
941function R.coverage_start()
942   cov = {}
943   -- The line event is the hot path. One "S" lookup per function (cached by the
944   -- function object) instead of per line; a call/return-event stack was measured
945   -- slower on a call-heavy suite, since calls are almost as frequent as lines there.
946   local srcs = setmetatable({}, { __mode = "k" })
947   local getinfo = debug.getinfo
948   debug.sethook(function(_, line)
949      local fi = getinfo(2, "f")
950      local func = fi and fi.func
951      if func == nil then return end
952      local t = srcs[func]
953      if t == nil then
954         local si = getinfo(2, "S")
955         local src = si and si.source
956         t = false
957         if src then
958            t = cov[src]
959            if not t then
960               t = {}
961               cov[src] = t
962            end
963         end
964         srcs[func] = t
965      end
966      if t then t[line] = true end
967   end, "l")
968end
969
970function R.coverage_stop()
971   debug.sethook()
972   local out = {}
973   for src, lines in pairs(cov or {}) do
974      local list = {}
975      for l in pairs(lines) do list[#list + 1] = l end
976      table.sort(list)
977      out[#out + 1] = { source = src, lines = list }
978   end
979   cov = nil
980   return out
981end
982
983return R
984"#;
985
986impl Htl {
987    /// New state. Uses `Lua::unsafe_new` so stripped bytecode bundles can be loaded.
988    pub fn new() -> Result<Self> {
989        // SAFETY: we accept binary chunks only from bundles we produced ourselves.
990        let lua = unsafe { Lua::unsafe_new() };
991        Self::from_lua(lua)
992    }
993
994    /// A fresh program state that borrows `checker`'s compiler instead of loading its
995    /// own: modules `checker` has already type-checked and generated are served from
996    /// its store, so a run of many programs (the test runner: one state per file)
997    /// checks each module once. The program state itself is as isolated as
998    /// [`new`](Self::new): nothing but the checker is shared. The checker starts a new
999    /// program env for this state (module-name resolution is per program).
1000    pub fn with_checker(checker: &Htl) -> Result<Self> {
1001        // SAFETY: as in `new`.
1002        let lua = unsafe { Lua::unsafe_new() };
1003        Self::with_checker_lua(checker, lua)
1004    }
1005
1006    /// [`with_checker`](Self::with_checker) with the program state supplied.
1007    ///
1008    /// This is the constructor for a host that decides what the program state is made of
1009    /// — which standard libraries it opens (`Lua::unsafe_new_with`), what its allocator
1010    /// is bounded to (`Lua::set_memory_limit`), what hook counts its instructions
1011    /// (`Lua::set_global_hook`) — while the checker keeps running on a state of its own,
1012    /// with whatever it needs. Every such limit is mlua's and is set on `lua` by the
1013    /// host; htl adds none of its own and puts nothing in the way of them.
1014    ///
1015    /// What htl itself needs from `lua`: `package` (the searcher and `preload`) and the
1016    /// base library's `load`; `debug`, only for [`coverage_start`](Self::coverage_start).
1017    /// A state that will load bundles has to come from `unsafe_new_with`: mlua's safe
1018    /// `new_with` refuses binary chunks, which is what a bundle is.
1019    pub fn with_checker_lua(checker: &Htl, lua: Lua) -> Result<Self> {
1020        let r: Table = lua
1021            .load(RUNTIME_PRELUDE)
1022            .set_name("=htl-runtime")
1023            .eval()
1024            .context("loading htl runtime prelude")?;
1025        lua.set_named_registry_value(RUNTIME_REGISTRY_KEY, r)?;
1026        lua.set_app_data(CheckerHandle(checker.h.clone()));
1027        let begin: Function = checker.h.get("begin_program")?;
1028        begin.call::<()>(())?;
1029        Ok(Self {
1030            lua,
1031            h: checker.h.clone(),
1032            split: true,
1033        })
1034    }
1035
1036    fn runtime(&self) -> Result<Table> {
1037        Ok(self
1038            .lua
1039            .named_registry_value::<Table>(RUNTIME_REGISTRY_KEY)?)
1040    }
1041
1042    /// Put Lua this checker generated for a `.tl` module in front of the searcher, in a
1043    /// program state.
1044    ///
1045    /// Distinct from [`preload`](Self::preload), which registers a source string as a module:
1046    /// this loads the way the searcher would have, so the module sees the same chunk name and
1047    /// the same arguments as if it had been generated during the run.
1048    ///
1049    /// Without it, a `require` in running code asks the searcher, which checks and generates
1050    /// the module then and there. With it, the module is already present. The two are the
1051    /// same thing only if `code` is what this checker would generate now — the caller's
1052    /// promise, and the reason anything serving this has to invalidate on the module's own
1053    /// content.
1054    pub fn preload_generated(&self, name: &str, code: &str, file: &Path) -> Result<()> {
1055        let f: Function = self.runtime()?.get("preload_generated")?;
1056        f.call::<()>((name, code, path_str(file)))?;
1057        Ok(())
1058    }
1059
1060    /// Start recording which lines of which chunk run in the program state (a state
1061    /// made by [`with_checker`](Self::with_checker)). Lua's line hook is per thread:
1062    /// code inside coroutines the program creates is not seen.
1063    pub fn coverage_start(&self) -> Result<()> {
1064        let f: Function = self.runtime()?.get("coverage_start")?;
1065        f.call::<()>(())?;
1066        Ok(())
1067    }
1068
1069    /// Stop recording; `(chunk source, sorted executed lines)` per chunk. Sources are as
1070    /// Lua names them: `@<path>` for files loaded by the searcher and the entry.
1071    pub fn coverage_stop(&self) -> Result<Vec<(String, Vec<usize>)>> {
1072        let f: Function = self.runtime()?.get("coverage_stop")?;
1073        let t: Table = f.call(())?;
1074        let mut out = Vec::new();
1075        for e in t.sequence_values::<Table>() {
1076            let e = e?;
1077            let source: String = e.get("source")?;
1078            let lines: Table = e.get("lines")?;
1079            out.push((
1080                source,
1081                lines
1082                    .sequence_values::<usize>()
1083                    .collect::<mlua::Result<_>>()?,
1084            ));
1085        }
1086        Ok(out)
1087    }
1088
1089    /// Statements of a `.tl` file as `(first line, last line)` ranges: what a coverage
1090    /// report counts as executable. A statement counts as executed when any line of its
1091    /// range ran (Lua attributes a multi-line statement's instructions to several lines).
1092    pub fn executable_ranges(&self, file: &Path) -> Result<Vec<(usize, usize)>> {
1093        Ok(self.coverage_spans(file)?.0)
1094    }
1095
1096    /// The statement ranges of [`executable_ranges`](Self::executable_ranges) and the
1097    /// file's named functions, from one parse: a coverage report wants both, and the
1098    /// second is what lets it say *which function* the missed statements belong to.
1099    pub fn coverage_spans(&self, file: &Path) -> Result<CoverageSpans> {
1100        let f: Function = self.h.get("executable_ranges")?;
1101        let (ranges, funcs): (Option<Table>, Option<Table>) = f.call(path_str(file))?;
1102        let Some(ranges) = ranges else {
1103            return Ok((Vec::new(), Vec::new()));
1104        };
1105        let mut out = Vec::new();
1106        for r in ranges.sequence_values::<Table>() {
1107            let r = r?;
1108            out.push((r.get::<usize>(1)?, r.get::<usize>(2)?));
1109        }
1110        let mut fns = Vec::new();
1111        if let Some(funcs) = funcs {
1112            for f in funcs.sequence_values::<Table>() {
1113                let f = f?;
1114                fns.push(FunctionSpan {
1115                    name: f.get("name")?,
1116                    line: f.get("y")?,
1117                    last: f.get("last")?,
1118                });
1119            }
1120        }
1121        Ok((out, fns))
1122    }
1123
1124    /// The checker's `package.path` (what `require` inside `.tl` resolves through).
1125    pub fn search_path(&self) -> Result<String> {
1126        let f: Function = self.h.get("get_path")?;
1127        Ok(f.call(())?)
1128    }
1129
1130    /// Restore a checker `package.path` taken with [`search_path`](Self::search_path).
1131    pub fn set_search_path(&self, path: &str) -> Result<()> {
1132        let f: Function = self.h.get("set_path")?;
1133        f.call::<()>(path)?;
1134        Ok(())
1135    }
1136
1137    /// Attach the Teal compiler to an existing Lua state (the host's own `Lua`).
1138    pub fn from_lua(lua: Lua) -> Result<Self> {
1139        let tl_loader: Function = lua
1140            .load(TL_SRC)
1141            .set_name("=tl.lua")
1142            .into_function()
1143            .context("compiling vendored tl.lua")?;
1144        let lint_loader: Function = lua
1145            .load(LINT_SRC)
1146            .set_name("=htl-lint")
1147            .into_function()
1148            .context("compiling htl lint.lua")?;
1149        let package: Table = lua.globals().get("package")?;
1150        let preload: Table = package.get("preload")?;
1151        let fmt_loader: Function = lua
1152            .load(FMT_SRC)
1153            .set_name("=htl-fmt")
1154            .into_function()
1155            .context("compiling htl fmt.lua")?;
1156        preload.set("tl", tl_loader)?;
1157        preload.set("htl.lint", lint_loader)?;
1158        preload.set("htl.fmt", fmt_loader)?;
1159        let h: Table = lua
1160            .load(PRELUDE)
1161            .set_name("=htl-prelude")
1162            .eval()
1163            .context("loading htl prelude")?;
1164        lua.set_named_registry_value(PRELUDE_REGISTRY_KEY, h.clone())?;
1165        let this = Self {
1166            lua,
1167            h,
1168            split: false,
1169        };
1170        // The defaults come from the registry, and this is where a state gets them: the
1171        // Lua side holds no rule list of its own, so a state nobody configures would
1172        // otherwise run no lints at all.
1173        this.select_lints(&lint::Selection::default())?;
1174        Ok(this)
1175    }
1176
1177    /// The Lua state this `Htl` runs programs in.
1178    ///
1179    /// Not always the one the checker is in: [`with_checker`](Self::with_checker) makes a
1180    /// fresh state for the program and leaves the prelude in the checker's. So a value
1181    /// built from this state must not be handed to a function that came from the other —
1182    /// that is `Lua instance passed Value created from a different main Lua state`.
1183    pub fn lua(&self) -> &Lua {
1184        &self.lua
1185    }
1186
1187    /// Type-check one file.
1188    pub fn check(&self, file: &Path) -> Result<CheckInfo> {
1189        let f: Function = self.h.get("check")?;
1190        let t: Table = f.call(path_str(file))?;
1191        read_checkinfo(&t)
1192    }
1193
1194    /// Check what is on disk right now, ignoring the store and not adding to it.
1195    ///
1196    /// [`check`](Self::check) serves a module the checker already knows from its store, and
1197    /// the underlying `tl.check_file` returns early when the environment has the file
1198    /// loaded. That is what makes checking a project fast, and it is wrong for a caller that
1199    /// has just written the file: the answer describes the version from before the write.
1200    /// `htl fix` writes and then measures, and was reverting correct fixes because of it.
1201    ///
1202    /// Nothing is stored either, because the caller may be about to put the file back —
1203    /// leaving the result behind would have the store describing a file that no longer says
1204    /// that.
1205    ///
1206    /// Slower than `check`: a cold environment re-checks the modules this file requires.
1207    ///
1208    /// The two options it differs from `check` by are set in the prelude rather than in a
1209    /// table built here, for the reason [`set_deps`](Self::set_deps) gives: `h` is not
1210    /// always in `self.lua`, and a table that crossed that line would raise.
1211    pub fn check_written(&self, file: &Path) -> Result<CheckInfo> {
1212        let f: Function = self.h.get("check_written")?;
1213        let t: Table = f.call(path_str(file))?;
1214        read_checkinfo(&t)
1215    }
1216
1217    /// Type-check and generate Lua source. `None` code means errors (see `CheckInfo`).
1218    pub fn gen_lua(&self, file: &Path) -> Result<(Option<String>, CheckInfo)> {
1219        let f: Function = self.h.get("gen")?;
1220        let (code, t): (Option<String>, Table) = f.call(path_str(file))?;
1221        Ok((code, read_checkinfo(&t)?))
1222    }
1223
1224    /// Configure lint rules: `"+no-any,-shadow-local"` on top of the defaults.
1225    ///
1226    /// The spec is resolved against [`lint::RULES`], so a name the project layer reports
1227    /// under is a name this takes; an unknown one is `unknown lint rule: <item>`.
1228    pub fn configure_lints(&self, spec: &str) -> Result<()> {
1229        self.select_lints(&lint::Selection::parse(spec)?)
1230    }
1231
1232    /// Hand the checker a selection resolved elsewhere — what a caller that also has to
1233    /// ask about the project-layer rules has in hand ([`lint::Lints`]), so that the file
1234    /// rules and the project rules of one run come from one resolution of one spec.
1235    ///
1236    /// Two selections cross, one per producer on the Lua side: the rules `lint.lua`
1237    /// implements, which it runs from, and Teal's warning kinds, which the prelude filters
1238    /// the checker's warnings by as it collects them. Neither keeps defaults of its own.
1239    ///
1240    /// Each side crosses as the names that are on and the names that are off, and the
1241    /// table is built on the other side — for the reason [`set_deps`](Self::set_deps)
1242    /// gives, and it applies here the harder way: `h` is not always in `self.lua`
1243    /// ([`with_checker`](Self::with_checker) keeps the prelude in the checker's), and a
1244    /// table made here and passed there is `Lua instance passed Value created from a
1245    /// different main Lua state`. Both lists, not just the on ones, because absent and
1246    /// `false` are not the same answer to the prelude: a Teal warning kind is said unless
1247    /// its entry is exactly `false`.
1248    pub fn select_lints(&self, sel: &lint::Selection) -> Result<()> {
1249        let split = |side| {
1250            let (mut on, mut off) = (Vec::new(), Vec::new());
1251            for (name, is_on) in sel.of_side(side) {
1252                if is_on { &mut on } else { &mut off }.push(name.to_string());
1253            }
1254            (on, off)
1255        };
1256        let (lua_on, lua_off) = split(lint::Side::Lua);
1257        let (tl_on, tl_off) = split(lint::Side::Tl);
1258        let f: Function = self.h.get("set_lints")?;
1259        f.call::<()>((lua_on, lua_off, tl_on, tl_off))?;
1260        Ok(())
1261    }
1262
1263    /// Tell the checker which dependencies the project installed, by name.
1264    ///
1265    /// Read by the rules that are about a library the project has rather than about its own
1266    /// code — `htlx-available`, which is silent in a project without htl-x — and by nothing
1267    /// else. Called by [`Htl::apply_project`](crate::pkg::Project) with what the lockfile
1268    /// linked; a state nobody calls it on has none, which is the answer a run outside a
1269    /// project should get.
1270    /// The names cross as a sequence and the set is built on the other side, rather than
1271    /// as a table built here. `h` is not always in `self.lua` — a split state
1272    /// ([`with_checker`](Self::with_checker)) keeps the prelude in the checker's — and a
1273    /// table made in one state and passed to a function in another is
1274    /// `Lua instance passed Value created from a different main Lua state`. A `Vec` is
1275    /// converted by the call itself, in the state the function belongs to.
1276    pub fn set_deps(&self, names: &[String]) -> Result<()> {
1277        let f: Function = self.h.get("set_deps")?;
1278        f.call::<()>(names.to_vec())?;
1279        Ok(())
1280    }
1281
1282    /// Names of all lint rules (enabled or not), the project layer's among them.
1283    pub fn lint_rules(&self) -> Result<Vec<String>> {
1284        Ok(lint::rule_names().into_iter().map(str::to_string).collect())
1285    }
1286
1287    /// The rules `lint.lua` implements, as it knows them. The registry is
1288    /// [`lint::RULES`]; this is the list to hold it to (`tests/lint_registry.rs`).
1289    pub fn lua_lint_rules(&self) -> Result<Vec<String>> {
1290        let f: Function = self.h.get("lint_rules")?;
1291        let t: Table = f.call(())?;
1292        Ok(t.sequence_values::<String>().collect::<mlua::Result<_>>()?)
1293    }
1294
1295    /// Format a `.tl` file (whitespace-only formatter). Returns the formatted text.
1296    pub fn format_file(&self, file: &Path, indent: usize) -> Result<String> {
1297        let f: Function = self.h.get("format")?;
1298        let (out, err): (Option<String>, Option<String>) = f.call((path_str(file), indent))?;
1299        out.ok_or_else(|| anyhow!("{}", err.unwrap_or_else(|| "format failed".into())))
1300    }
1301
1302    /// Drop Lua's default search path (cwd-relative `./?.lua` etc.) so only directories
1303    /// passed to [`add_path`](Self::add_path) are consulted by the checker and `require`.
1304    pub fn reset_search_path(&self) -> Result<()> {
1305        let f: Function = self.h.get("reset_path")?;
1306        f.call::<()>(())?;
1307        if self.split {
1308            let f: Function = self.runtime()?.get("reset_path")?;
1309            f.call::<()>(())?;
1310        }
1311        Ok(())
1312    }
1313
1314    /// Search paths implied by where `file` sits in the scaffold layout, in the order
1315    /// they are consulted: its own directory first, and for a file under `tests/` then
1316    /// the project root and `<root>/src` (the test runner's rule, so `htl check tests`
1317    /// sees what `htl test` sees).
1318    pub fn add_layout_paths(&self, file: &Path) -> Result<()> {
1319        let dir = parent_dir(file);
1320        let mut dirs = vec![dir.clone()];
1321        if dir.file_name().is_some_and(|n| n == "tests")
1322            && let Some(root) = dir.parent()
1323        {
1324            dirs.push(root.to_path_buf());
1325            let src = root.join("src");
1326            if src.is_dir() {
1327                dirs.push(src);
1328            }
1329        }
1330        self.add_search_paths(&dirs)
1331    }
1332
1333    /// Prepend `dir/?.tl;dir/?/init.tl` to `package.path` (Teal resolves requires through it).
1334    pub fn add_path(&self, dir: &Path) -> Result<()> {
1335        let f: Function = self.h.get("add_path")?;
1336        f.call::<()>(path_str(dir))?;
1337        if self.split {
1338            // The program state resolves plain `.lua` (and `.d.tl` siblings) itself.
1339            let f: Function = self.runtime()?.get("add_path")?;
1340            f.call::<()>(path_str(dir))?;
1341        }
1342        Ok(())
1343    }
1344
1345    /// Install the strict `.tl` searcher: `require` of a `.tl` with type errors fails.
1346    pub fn install_searcher(&self) -> Result<()> {
1347        if self.split {
1348            // The searcher runs in the program state and asks the checker for code.
1349            //
1350            // Both states are in hand here and nothing crosses: `bridge` is built in
1351            // `self.lua` and handed to `runtime()`, which is a table out of `self.lua`'s
1352            // own registry. The checker's `gen_fn` is only ever *called* — its arguments
1353            // and results are Rust values on the way through, which is what a value has to
1354            // be to pass between two states.
1355            let gen_fn: Function = self.h.get("gen_for_require")?;
1356            let bridge = self.lua.create_function(move |_, name: String| {
1357                let (kind, a, b): (String, Option<String>, Option<String>) = gen_fn.call(name)?;
1358                Ok((kind, a, b))
1359            })?;
1360            let f: Function = self.runtime()?.get("install_searcher")?;
1361            f.call::<()>(bridge)?;
1362            return Ok(());
1363        }
1364        let f: Function = self.h.get("install_searcher")?;
1365        f.call::<()>(())?;
1366        Ok(())
1367    }
1368
1369    /// Register generated Lua source under a module name (`package.preload`).
1370    ///
1371    /// The chunk is named after the `.tl` a `require` of this name would have found —
1372    /// `foo.bar` becomes `@foo/bar.tl` — because that name is what a run-time failure
1373    /// shows, and a reader who has only the output needs something to open. Use
1374    /// [`Htl::preload_at`] when the source sits somewhere else (`@scripts/util.tl`), or
1375    /// when there is no file at all and a bare label is the honest answer (`=htl.test`).
1376    pub fn preload(&self, name: &str, lua_src: &str) -> Result<()> {
1377        self.preload_at(name, &module_chunk_name(name), lua_src)
1378    }
1379
1380    /// [`Htl::preload`] with the chunk name spelled out, the way [`Htl::exec`] takes one.
1381    /// `@<path>` is a source location and is what a host with a file should pass;
1382    /// `=<label>` is a literal label, for a module no file backs.
1383    pub fn preload_at(&self, name: &str, chunk_name: &str, lua_src: &str) -> Result<()> {
1384        let loader = self
1385            .lua
1386            .load(lua_src)
1387            .set_name(chunk_name)
1388            .into_function()
1389            .with_context(|| format!("compiling preloaded module {name}"))?;
1390        self.preload_table()?.set(name, loader)?;
1391        Ok(())
1392    }
1393
1394    /// Register stripped bytecode (e.g. from `include_tl_bytes!`) under a module name.
1395    ///
1396    /// A chunk name is worth less here than it is to [`Htl::preload`], and the reason is
1397    /// worth knowing before reading a failure from an embedded module: a compiled chunk
1398    /// carries its own name, given when it was compiled, and `lua_load`'s name is used
1399    /// only for the messages loading itself produces. Stripping drops the carried name
1400    /// along with the line numbers, so every frame from a stripped payload reads `?` —
1401    /// `?: in function 'sample.greet'`. Running the `.tl` under `htl run` or `htl test`
1402    /// is where those frames are; a bundle keeps them with `htl build --debug`.
1403    pub fn preload_bytes(&self, name: &str, bytecode: &[u8]) -> Result<()> {
1404        let loader = self
1405            .lua
1406            .load(bytecode)
1407            .set_name(module_chunk_name(name))
1408            .set_mode(ChunkMode::Binary)
1409            .into_function()
1410            .with_context(|| format!("loading bytecode for module {name}"))?;
1411        self.preload_table()?.set(name, loader)?;
1412        Ok(())
1413    }
1414
1415    /// Execute stripped bytecode with `...` = args.
1416    pub fn exec_bytes(&self, bytecode: &[u8], chunk_name: &str, args: &[String]) -> Result<()> {
1417        let f = self
1418            .lua
1419            .load(bytecode)
1420            .set_name(chunk_name)
1421            .set_mode(ChunkMode::Binary)
1422            .into_function()?;
1423        let va: Variadic<String> = args.iter().cloned().collect();
1424        f.call::<()>(va)?;
1425        Ok(())
1426    }
1427
1428    /// Register a ready-made value (typically a Rust-built table) as a module.
1429    pub fn preload_value(&self, name: &str, value: impl mlua::IntoLua) -> Result<()> {
1430        let value = value.into_lua(&self.lua)?;
1431        let loader = self.lua.create_function(move |_, ()| Ok(value.clone()))?;
1432        self.preload_table()?.set(name, loader)?;
1433        Ok(())
1434    }
1435
1436    fn preload_table(&self) -> Result<Table> {
1437        let package: Table = self.lua.globals().get("package")?;
1438        Ok(package.get("preload")?)
1439    }
1440
1441    /// Set the global `arg` table like the `lua` CLI does.
1442    pub fn set_arg(&self, script: &str, args: &[String]) -> Result<()> {
1443        let t = self.lua.create_table()?;
1444        t.set(0, script)?;
1445        for (i, a) in args.iter().enumerate() {
1446            t.set(i + 1, a.as_str())?;
1447        }
1448        self.lua.globals().set("arg", t)?;
1449        Ok(())
1450    }
1451
1452    /// Make arithmetic on a string a run-time error instead of a conversion.
1453    ///
1454    /// Lua 5.4 reads `"10" + 1` as `11`: the string library's metatable carries `__add`
1455    /// and the other seven arithmetic metamethods, and each one converts its string
1456    /// operands and retries. Checked Teal never gets there: the checker refuses the
1457    /// expression on a `string`, and on an `any` too. It happens in what the checker did
1458    /// not see — the far side of a cast (`(v as integer) + 1` where `v` came from
1459    /// `std.json.decode` or `arg` as `"10"`), a function `load` built from a string, Lua
1460    /// source a host handed to [`exec`](Self::exec) — and there the conversion is
1461    /// silent. This removes the eight from the string metatable of the program state, so
1462    /// the same expression fails as `attempt to perform arithmetic on a string value`,
1463    /// naming the operand.
1464    ///
1465    /// What it does not cover, because Lua does those elsewhere: `10 .. ""` (number to
1466    /// string under concatenation is in the VM, behind Lua's `LUA_NOCVTN2S` build flag,
1467    /// which is the vendored Lua's to set); `"10" < "9"` (a string comparison, true, and
1468    /// not a conversion); and `tonumber` / `math.tointeger`, which convert because they
1469    /// were asked to. `__index` stays, so `s:upper()` and every other string method are
1470    /// untouched.
1471    ///
1472    /// Opt-in, for a host's `preload` beside `install_std`; the CLI does not turn it on,
1473    /// since `htl run` and `htl test` run Teal the checker has passed. Calling it twice is
1474    /// the same as once. In a state that also holds the checker (the default; see
1475    /// [`with_checker`](Self::with_checker) for the split) the checker runs under it too,
1476    /// which it can: nothing in `tl` adds a string to a number.
1477    pub fn strict_strings(&self) -> Result<()> {
1478        self.lua
1479            .load(
1480                r#"
1481local mt = getmetatable("")
1482for _, k in ipairs { "__add", "__sub", "__mul", "__div", "__mod", "__pow", "__unm", "__idiv" } do
1483   mt[k] = nil
1484end
1485"#,
1486            )
1487            .set_name("=strict_strings")
1488            .exec()
1489            .context("removing arithmetic metamethods from the string metatable")?;
1490        Ok(())
1491    }
1492
1493    /// Execute Lua source with `...` = args.
1494    pub fn exec(&self, lua_src: &str, chunk_name: &str, args: &[String]) -> Result<()> {
1495        let f = self
1496            .lua
1497            .load(lua_src)
1498            .set_name(chunk_name)
1499            .into_function()?;
1500        let va: Variadic<String> = args.iter().cloned().collect();
1501        f.call::<()>(va)?;
1502        Ok(())
1503    }
1504
1505    /// Check + gen + run a `.tl` script. If the check fails the script is not run and the
1506    /// returned `CheckInfo` carries the errors. Runtime errors come back as `Err`.
1507    pub fn run_file(&self, file: &Path, args: &[String]) -> Result<CheckInfo> {
1508        self.add_path(&parent_dir(file))?;
1509        self.install_searcher()?;
1510        self.set_arg(&file.to_string_lossy(), args)?;
1511        let (code, ci) = self.gen_lua(file)?;
1512        let Some(code) = code else { return Ok(ci) };
1513        self.exec(&code, &format!("@{}", file.display()), args)?;
1514        Ok(ci)
1515    }
1516
1517    /// Compile Lua source to stripped bytecode (Lua 5.4 format of this build).
1518    pub fn compile(&self, name: &str, lua_src: &str) -> Result<Vec<u8>> {
1519        self.compile_with(name, lua_src, true)
1520    }
1521
1522    /// Compile to bytecode; `strip` drops debug info (line numbers, local and upvalue
1523    /// names, and the chunk name: tracebacks then show the name given at load).
1524    pub fn compile_with(&self, name: &str, lua_src: &str, strip: bool) -> Result<Vec<u8>> {
1525        let f = self
1526            .lua
1527            .load(lua_src)
1528            .set_name(format!("={name}"))
1529            .into_function()
1530            .with_context(|| format!("compiling generated Lua for {name}"))?;
1531        Ok(f.dump(strip))
1532    }
1533
1534    /// The Lua bytecode header this state produces (signature, version, format,
1535    /// `LUAC_DATA`, sizes of Instruction / Integer / Number, endianness probes): what
1536    /// another state must match to load this state's bytecode. Lua's own version byte
1537    /// is the same for every 5.4.x, so bundles carry this instead.
1538    pub fn fingerprint(&self) -> Result<Vec<u8>> {
1539        let bc = self.compile_with("fp", "return 0", true)?;
1540        // 4 signature + 1 version + 1 format + 6 LUAC_DATA + 3 sizes + 8 LUAC_INT + 8 LUAC_NUM
1541        Ok(bc.iter().take(31).copied().collect())
1542    }
1543
1544    /// Literal `require`s of a plain Lua source, resolved through the checker's path.
1545    pub fn lua_requires(&self, src: &str, file: &Path) -> Result<Vec<RequireSite>> {
1546        let f: Function = self.h.get("lua_requires")?;
1547        let t: Table = f.call((src, path_str(file)))?;
1548        read_requires(&t)
1549    }
1550
1551    /// Where `require(name)` resolves for the checker (`.tl`, `.d.tl` or `.lua`), and
1552    /// where a plain `.lua` implementation sits on the path (a `.d.tl` may only be
1553    /// typing it). Either may be `None`.
1554    pub fn resolve_module(&self, name: &str) -> Result<(Option<PathBuf>, Option<PathBuf>)> {
1555        let f: Function = self.h.get("resolve_module")?;
1556        let (found, lua): (Option<String>, Option<String>) = f.call(name)?;
1557        Ok((found.map(PathBuf::from), lua.map(PathBuf::from)))
1558    }
1559
1560    /// Every file on the search path that could answer `require(name)`, in the order the
1561    /// searchers consult them — so the first is the one [`resolve_module`](Self::resolve_module)
1562    /// answers with, and the rest are what it hides.
1563    ///
1564    /// The same walk `declaration_sites` does for the `duplicate-declaration` lint, over
1565    /// all three kinds rather than declarations alone: a searcher answers with the first
1566    /// hit and says nothing about the others, and which of two files is read is decided by
1567    /// a position nobody wrote down. [`contract::resolve`] is what turns this into a report.
1568    pub fn module_candidates(&self, name: &str) -> Result<Vec<ModuleCandidate>> {
1569        let f: Function = self.h.get("module_candidates")?;
1570        let t: Table = f.call(name)?;
1571        let mut out = Vec::new();
1572        for c in t.sequence_values::<Table>() {
1573            let c = c?;
1574            out.push(ModuleCandidate {
1575                path: PathBuf::from(c.get::<String>("path")?),
1576                kind: ModuleKind::of(&c.get::<String>("kind")?),
1577                dir: PathBuf::from(c.get::<String>("dir")?),
1578            });
1579        }
1580        Ok(out)
1581    }
1582
1583    /// The directories the search path consults, in order. One entry per directory,
1584    /// however many `package.path` templates it contributes.
1585    pub fn search_path_dirs(&self) -> Result<Vec<PathBuf>> {
1586        let f: Function = self.h.get("search_dirs")?;
1587        let t: Table = f.call(())?;
1588        Ok(t.sequence_values::<String>()
1589            .collect::<mlua::Result<Vec<_>>>()?
1590            .into_iter()
1591            .map(PathBuf::from)
1592            .collect())
1593    }
1594
1595    /// The names each bundle wrote into `package.preload`, keyed by the bundle's entry.
1596    ///
1597    /// In the registry rather than in the `Htl`: it is a fact about the Lua state, and a
1598    /// `&Htl` is shared, so a `RefCell` here would be a second place to keep the same
1599    /// thing in step with. What it is for is [`replace_bundle`](Self::replace_bundle) — a
1600    /// bundle can take back the names it installed only if something remembers which
1601    /// those were, and which belonged to the host all along.
1602    fn bundle_record(&self) -> Result<Table> {
1603        if let Value::Table(t) = self
1604            .lua
1605            .named_registry_value::<Value>(BUNDLE_REGISTRY_KEY)?
1606        {
1607            return Ok(t);
1608        }
1609        let t = self.lua.create_table()?;
1610        self.lua
1611            .set_named_registry_value(BUNDLE_REGISTRY_KEY, t.clone())?;
1612        Ok(t)
1613    }
1614
1615    /// The two questions asked before a bundle touches the state, so that a caller that
1616    /// is about to disturb what is already there can ask them first
1617    /// ([`replace_bundle`](Self::replace_bundle) drops modules, and a refusal after that
1618    /// would leave the host with neither the old ones nor the new).
1619    ///
1620    /// Both are reads. Running it twice — once by the caller, once by
1621    /// [`install_bundle`](Self::install_bundle), which stays correct on its own — costs a
1622    /// chunk dump and two table lookups and answers the same either way: the names it
1623    /// checks for are the host's, and a replace never removes one of those.
1624    fn check_installable(&self, b: &bundle::Bundle) -> Result<()> {
1625        // Bytecode from a Lua that disagrees with ours would fail with "bad binary
1626        // format" somewhere inside the first require; say what differs instead.
1627        // The header cannot tell one 5.4.x from another, so the htl versions go in the
1628        // message too: they are the only record of which Lua produced each side.
1629        if b.modules.iter().any(|m| m.kind == bundle::Kind::Bytecode) && !b.fingerprint.is_empty() {
1630            let mine = self.fingerprint()?;
1631            if mine != b.fingerprint {
1632                let built_by = if b.htl_version.is_empty() {
1633                    "an htl that did not record its version".to_string()
1634                } else {
1635                    format!("htl {}", b.htl_version)
1636                };
1637                bail!(
1638                    "bundle bytecode was compiled for {} by {built_by}, but this host runs {} on htl {}; \
1639                     rebuild the bundle here, or build it with --source",
1640                    bundle::describe_fingerprint(&b.fingerprint),
1641                    bundle::describe_fingerprint(&mine),
1642                    env!("CARGO_PKG_VERSION")
1643                );
1644            }
1645        }
1646        // Host-provided modules must already be registered, or the program's first
1647        // require of them fails with a message that points at the wrong place.
1648        let package: Table = self.lua.globals().get("package")?;
1649        let preload: Table = package.get("preload")?;
1650        let loaded: Table = package.get("loaded")?;
1651        let missing: Vec<&String> = b
1652            .host_modules
1653            .iter()
1654            .filter(|n| {
1655                matches!(preload.get::<Value>(n.as_str()), Ok(Value::Nil))
1656                    && matches!(loaded.get::<Value>(n.as_str()), Ok(Value::Nil))
1657            })
1658            .collect();
1659        if !missing.is_empty() {
1660            bail!(
1661                "bundle expects host-provided module(s) {} (declared only by a .d.tl or [build] host at link \
1662                 time): register them with preload / preload_value / htl_preload before running",
1663                missing
1664                    .iter()
1665                    .map(|m| format!("'{m}'"))
1666                    .collect::<Vec<_>>()
1667                    .join(", ")
1668            );
1669        }
1670        Ok(())
1671    }
1672
1673    /// Install a searcher serving modules from a bundle.
1674    ///
1675    /// Idempotent, and deliberately so: a second call installs nothing, because every
1676    /// name is taken by the first. Putting a *newer* bundle into a state that is already
1677    /// running is [`replace_bundle`](Self::replace_bundle).
1678    pub fn install_bundle(&self, b: &bundle::Bundle) -> Result<()> {
1679        self.check_installable(b)?;
1680        let package: Table = self.lua.globals().get("package")?;
1681        let preload: Table = package.get("preload")?;
1682        // Bundled modules become `package.preload` entries: the same place a host puts
1683        // its own modules, so everything that already defers to preload (a `.d.tl`
1684        // stepping aside for the implementation, mlua-pkg resolvers ahead of Lua's
1685        // searchers) sees them without knowing about bundles. A name the host preloaded
1686        // first is left alone: the host wins. Loaders get (modname, ":preload:") as
1687        // Lua's preload searcher passes them.
1688        let mut written: Vec<String> = Vec::new();
1689        for m in &b.modules {
1690            if !matches!(preload.get::<Value>(m.name.as_str())?, Value::Nil) {
1691                continue;
1692            }
1693            let payload = m.payload.clone();
1694            let kind = m.kind;
1695            let name = m.name.clone();
1696            let loader =
1697                self.lua
1698                    .create_function(move |lua, (modname, origin): (String, Value)| {
1699                        let chunk = lua.load(payload.as_slice()).set_name(format!("={name}"));
1700                        let f = match kind {
1701                            bundle::Kind::Bytecode => {
1702                                chunk.set_mode(ChunkMode::Binary).into_function()?
1703                            }
1704                            bundle::Kind::Source => {
1705                                chunk.set_mode(ChunkMode::Text).into_function()?
1706                            }
1707                        };
1708                        f.call::<Value>((modname, origin))
1709                    })?;
1710            preload.set(m.name.as_str(), loader)?;
1711            written.push(m.name.clone());
1712        }
1713        // Only what this call wrote, and added to whatever the entry already had: a name
1714        // skipped above was the host's and is not this bundle's to take back, and a
1715        // second install of the same bundle writes nothing and must not erase the record
1716        // the first one made.
1717        let record = self.bundle_record()?;
1718        let names: Table = match record.get::<Value>(b.entry.as_str())? {
1719            Value::Table(t) => t,
1720            _ => {
1721                let t = self.lua.create_table()?;
1722                record.set(b.entry.as_str(), t.clone())?;
1723                t
1724            }
1725        };
1726        let already: Vec<String> = names
1727            .sequence_values::<String>()
1728            .collect::<mlua::Result<Vec<_>>>()?;
1729        for name in written {
1730            if !already.contains(&name) {
1731                names.push(name)?;
1732            }
1733        }
1734        Ok(())
1735    }
1736
1737    /// Put a newer bundle into a state that is already running: the modules the bundle
1738    /// recorded under the same entry go, `keep`'s loaded values stay, and the host's are
1739    /// untouched.
1740    ///
1741    /// Nothing is evaluated here. A dropped name is gone from `package.preload` and
1742    /// `package.loaded`, so the next `require` of it runs the new module; a name in
1743    /// `keep` keeps the value it already evaluated to, which is how a `world` or a `save`
1744    /// module carries state across the swap. The entry is not re-run either — what to do
1745    /// with it is the host's, and a frame loop holding a table asks for the entry again
1746    /// and swaps what it holds.
1747    ///
1748    /// A reference already taken is not reached by any of this. `local m = require
1749    /// "rules"` captured by a closure that is still running keeps the old table until that
1750    /// closure is gone. That is Lua, and no amount of bookkeeping here changes it.
1751    ///
1752    /// The bundle is checked before anything is dropped, so a refusal — a fingerprint
1753    /// that disagrees, a host module that was never registered — leaves the state as it
1754    /// was rather than holding neither bundle.
1755    pub fn replace_bundle(&self, b: &bundle::Bundle, keep: &[&str]) -> Result<Replaced> {
1756        self.check_installable(b)?;
1757        let package: Table = self.lua.globals().get("package")?;
1758        let preload: Table = package.get("preload")?;
1759        let loaded: Table = package.get("loaded")?;
1760        let record = self.bundle_record()?;
1761        let previous: Vec<String> = match record.get::<Value>(b.entry.as_str())? {
1762            Value::Table(t) => t
1763                .sequence_values::<String>()
1764                .collect::<mlua::Result<Vec<_>>>()?,
1765            _ => Vec::new(),
1766        };
1767        let (mut dropped, mut kept) = (Vec::new(), Vec::new());
1768        for name in &previous {
1769            // The preload entry goes either way: it is the old bundle's loader, and the
1770            // new bundle's belongs there. A kept name never reaches it — `package.loaded`
1771            // answers first — but if anything ever clears that, the next require should
1772            // find the module this state actually holds.
1773            preload.set(name.as_str(), Value::Nil)?;
1774            if keep.contains(&name.as_str()) {
1775                kept.push(name.clone());
1776            } else {
1777                loaded.set(name.as_str(), Value::Nil)?;
1778                dropped.push(name.clone());
1779            }
1780        }
1781        // Cleared, not merged into: a module the old bundle had and the new one does not
1782        // is gone, and a record that still named it would offer it to the next replace.
1783        record.set(b.entry.as_str(), Value::Nil)?;
1784        self.install_bundle(b)?;
1785        let added: Vec<String> = match record.get::<Value>(b.entry.as_str())? {
1786            Value::Table(t) => t
1787                .sequence_values::<String>()
1788                .collect::<mlua::Result<Vec<_>>>()?,
1789            _ => Vec::new(),
1790        };
1791        Ok(Replaced {
1792            dropped,
1793            kept,
1794            added,
1795        })
1796    }
1797
1798    /// Install the bundle and run its entry module with `...` = args.
1799    pub fn run_bundle(&self, b: &bundle::Bundle, args: &[String]) -> Result<()> {
1800        let entry = b
1801            .module(&b.entry)
1802            .cloned()
1803            .ok_or_else(|| anyhow!("entry module '{}' not in bundle", b.entry))?;
1804        self.install_bundle(b)?;
1805        self.set_arg(&b.entry, args)?;
1806        let chunk = self
1807            .lua
1808            .load(entry.payload.as_slice())
1809            .set_name(format!("={}", b.entry));
1810        let main: Function = match entry.kind {
1811            bundle::Kind::Bytecode => chunk.set_mode(ChunkMode::Binary).into_function()?,
1812            bundle::Kind::Source => chunk.set_mode(ChunkMode::Text).into_function()?,
1813        };
1814        let va: Variadic<String> = args.iter().cloned().collect();
1815        main.call::<()>(va)?;
1816        Ok(())
1817    }
1818}
1819
1820fn path_str(p: &Path) -> String {
1821    p.to_string_lossy().into_owned()
1822}
1823
1824/// The chunk name for a module registered without one: the `.tl` `require` would have
1825/// looked for, as a `@` source location. `htl.test` becomes `@htl/test.tl`, which is why
1826/// the test library asks for `=htl.test` instead — it ships inside the binary.
1827fn module_chunk_name(name: &str) -> String {
1828    format!("@{}.tl", name.replace('.', "/"))
1829}
1830
1831/// A message for the people an embedding host serves: the innermost cause without Lua's
1832/// `stack traceback:` block. A host function's `Err(e)` surfaces as `e`'s own text; a Lua
1833/// `error("msg")` surfaces as `file:line: msg`.
1834///
1835/// ```text
1836/// sgen: content/no-date.md: front matter: 'date' is required
1837/// ```
1838/// instead of that line followed by `stack traceback: [C]: in method 'pages' ...`.
1839///
1840/// This is the answer for a program whose users did not write the Teal and cannot act on
1841/// its frames — a static site generator telling an author which file is missing a date.
1842/// It is not the answer for whoever is developing the program: see
1843/// [`developer_message`], which is what `htl run` and `htl test` print.
1844pub fn user_message(err: &anyhow::Error) -> String {
1845    if let Some(e) = err.downcast_ref::<mlua::Error>() {
1846        return user_message_lua(e);
1847    }
1848    strip_traceback(&format!("{err:#}"))
1849}
1850
1851/// [`user_message`] for an error already held as mlua's own type, which is how a caller
1852/// that catches `mlua::Result` (the C ABI in `ffi`, say) has it.
1853pub fn user_message_lua(e: &mlua::Error) -> String {
1854    match e {
1855        mlua::Error::CallbackError { cause, .. } => user_message_lua(cause),
1856        mlua::Error::ExternalError(ext) => ext.to_string(),
1857        mlua::Error::WithContext { cause, .. } => user_message_lua(cause),
1858        other => strip_traceback(&other.to_string()),
1859    }
1860}
1861
1862/// A message for whoever is developing the program: [`user_message`]'s innermost cause,
1863/// followed by Lua's `stack traceback:` block when the error carries one.
1864///
1865/// ```text
1866/// depth.tl:8: attempt to index a nil value (local 'c')
1867/// stack traceback:
1868///     depth.tl:8: in function 'depth.field'
1869///     depth.tl:12: in function 'depth.describe'
1870///     boom.tl:3: in main chunk
1871/// ```
1872///
1873/// The innermost line says a value was nil; the frames say which caller passed it, and
1874/// they name Teal files and Teal lines because a generated chunk is loaded under its
1875/// source's own name. This is what `htl run` and `htl test` print. The frames are absent
1876/// only where the debug information is: stripped bytecode, which is what a bundle without
1877/// `--debug` and `include_tl_bytes!` both hold.
1878pub fn developer_message(err: &anyhow::Error) -> String {
1879    let head = user_message(err);
1880    let full = match err.downcast_ref::<mlua::Error>() {
1881        Some(e) => e.to_string(),
1882        None => format!("{err:#}"),
1883    };
1884    match traceback_block(&full) {
1885        Some(tb) => format!("{head}\n{tb}"),
1886        None => head,
1887    }
1888}
1889
1890/// The `stack traceback:` block of an error text, trimmed, without the newline before it.
1891fn traceback_block(text: &str) -> Option<&str> {
1892    let at = text.find("\nstack traceback:")?;
1893    Some(text[at + 1..].trim_end())
1894}
1895
1896/// Remove a trailing Lua `stack traceback:` section from an error text.
1897pub fn strip_traceback(text: &str) -> String {
1898    let cut = text.find("\nstack traceback:").unwrap_or(text.len());
1899    text[..cut].trim_end().to_string()
1900}
1901
1902/// Write `text` to `path` only if the content differs. Returns `true` when written.
1903/// Used by the derive macros to emit `.d.tl` files without churning cargo's fingerprints.
1904pub fn write_if_changed(path: &Path, text: &str) -> std::io::Result<bool> {
1905    if let Ok(cur) = std::fs::read_to_string(path)
1906        && cur == text
1907    {
1908        return Ok(false);
1909    }
1910    if let Some(dir) = path.parent() {
1911        std::fs::create_dir_all(dir)?;
1912    }
1913    std::fs::write(path, text)?;
1914    Ok(true)
1915}
1916
1917/// Everything the libraries inside the binary write under [`lib_dir`]: the path each file
1918/// takes below that directory, and its source, sorted so that the order the parts are
1919/// collected in is not part of the answer.
1920///
1921/// The feature branch is here rather than in either library because this is the union, and
1922/// the union is what the directory is named after. Each library owns the half it writes
1923/// ([`testing::declarations`], [`batteries::declarations`]) and writes exactly that half,
1924/// so the name and the contents cannot drift apart.
1925fn bundled_declarations() -> Vec<(String, String)> {
1926    let mut out = testing::declarations();
1927    #[cfg(feature = "std")]
1928    out.extend(batteries::declarations());
1929    out.sort();
1930    out
1931}
1932
1933/// A directory name for a set of declarations: the first sixteen hex characters of a
1934/// blake3 over every path and source in it.
1935///
1936/// Sixteen because this is read by a person — in `htl resolve`'s searched-order line, in a
1937/// listing of the temp directory — and sixty-four bits is already far past what telling a
1938/// handful of builds on one machine apart asks for. Each part is length-prefixed so that
1939/// two different lists cannot hash alike by running together: `("ab", "c")` and
1940/// `("a", "bc")` are different keys.
1941fn declarations_key(decls: &[(String, String)]) -> String {
1942    let mut h = blake3::Hasher::new();
1943    for (path, source) in decls {
1944        for part in [path.as_str(), source.as_str()] {
1945            h.update(&(part.len() as u64).to_le_bytes());
1946            h.update(part.as_bytes());
1947        }
1948    }
1949    h.finalize().to_hex()[..16].to_string()
1950}
1951
1952/// Where the libraries that ship inside the binary put their `.d.tl` so the checker can see
1953/// them: `<tmp>/htl-lib-<version>-<key>/`, with `htl/test.d.tl` and, under the `std`
1954/// feature, `std/*.d.tl` below it. The files are written on demand by the library that owns
1955/// them, only when their content changes.
1956///
1957/// The key is a hash over what this build would write, and not the version,
1958/// because the version does not tell two builds apart. `CARGO_PKG_VERSION` is the same on
1959/// the release and on every build from `main` after it, and those differ by exactly what
1960/// lands here: a binary with `std` writes `std/*.d.tl` that a binary without it cannot
1961/// preload, and one that found them on its search path type-checked a project against
1962/// modules it then failed to load (#220). Keyed by content, the two have different
1963/// directories and neither can see the other's; two builds that would write the same files
1964/// still share one, which is the case worth sharing.
1965///
1966/// The version stays in the name because that is what a person reading the path uses.
1967pub fn lib_dir() -> PathBuf {
1968    static DIR: OnceLock<PathBuf> = OnceLock::new();
1969    DIR.get_or_init(|| {
1970        let key = declarations_key(&bundled_declarations());
1971        std::env::temp_dir().join(format!("htl-lib-{}-{key}", env!("CARGO_PKG_VERSION")))
1972    })
1973    .clone()
1974}
1975
1976/// Parent directory of a file, `.` when the path has none.
1977pub fn parent_dir(file: &Path) -> PathBuf {
1978    let dir = file.parent().unwrap_or(Path::new("."));
1979    if dir.as_os_str().is_empty() {
1980        PathBuf::from(".")
1981    } else {
1982        dir.to_path_buf()
1983    }
1984}
1985
1986fn read_checkinfo(t: &Table) -> Result<CheckInfo> {
1987    let seq = |key: &str| -> Result<Vec<String>> {
1988        let inner: Table = t.get(key)?;
1989        Ok(inner
1990            .sequence_values::<String>()
1991            .collect::<mlua::Result<_>>()?)
1992    };
1993    let requires = match t.get::<Table>("requires") {
1994        Ok(list) => read_requires(&list)?,
1995        Err(_) => Vec::new(),
1996    };
1997    let errors = seq("errors")?;
1998    let lints = seq("lints")?;
1999    let error_fixes = read_fixes(t, "error_fixes", errors.len())?;
2000    let lint_fixes = read_fixes(t, "lint_fixes", lints.len())?;
2001    let dependency_errors = match t.get::<Table>("dependency_errors") {
2002        Ok(list) => read_dependency_errors(&list)?,
2003        Err(_) => Vec::new(),
2004    };
2005    Ok(CheckInfo {
2006        errors,
2007        warnings: seq("warnings")?,
2008        deps: seq("deps")?.into_iter().map(PathBuf::from).collect(),
2009        lints,
2010        requires,
2011        error_fixes,
2012        lint_fixes,
2013        dependency_errors,
2014    })
2015}
2016
2017fn read_dependency_errors(list: &Table) -> Result<Vec<DependencyError>> {
2018    let mut out = Vec::new();
2019    for e in list.sequence_values::<Table>() {
2020        let e = e?;
2021        out.push(DependencyError {
2022            file: PathBuf::from(e.get::<String>("file")?),
2023            required_by: PathBuf::from(e.get::<String>("required_by")?),
2024            text: e.get::<String>("text")?,
2025        });
2026    }
2027    Ok(out)
2028}
2029
2030/// `fixes[i]` is a fix table or `false`; missing entries are `None`.
2031fn read_fixes(t: &Table, key: &str, len: usize) -> Result<Vec<Option<Fix>>> {
2032    let mut out = vec![None; len];
2033    let Ok(list) = t.get::<Table>(key) else {
2034        return Ok(out);
2035    };
2036    for (i, slot) in out.iter_mut().enumerate() {
2037        let v: Value = list.get(i + 1)?;
2038        if let Value::Table(f) = v {
2039            let applicability = match f.get::<Option<String>>("applicability")?.as_deref() {
2040                Some("unsafe") => Applicability::Unsafe,
2041                Some("suggest") => Applicability::Suggest,
2042                _ => Applicability::Safe,
2043            };
2044            let mut edits = Vec::new();
2045            if let Ok(es) = f.get::<Table>("edits") {
2046                for e in es.sequence_values::<Table>() {
2047                    let e = e?;
2048                    edits.push(Edit {
2049                        line: e.get("line")?,
2050                        col: e.get("col")?,
2051                        end_line: e.get("end_line")?,
2052                        end_col: e.get("end_col")?,
2053                        text: e.get::<Option<String>>("text")?.unwrap_or_default(),
2054                    });
2055                }
2056            }
2057            *slot = Some(Fix {
2058                applicability,
2059                edits,
2060            });
2061        }
2062    }
2063    Ok(out)
2064}
2065
2066fn read_requires(list: &Table) -> Result<Vec<RequireSite>> {
2067    let mut requires = Vec::new();
2068    for r in list.sequence_values::<Table>() {
2069        let r = r?;
2070        requires.push(RequireSite {
2071            module: r.get::<String>("name")?,
2072            path: r.get::<Option<String>>("path")?.map(PathBuf::from),
2073            line: r.get::<Option<usize>>("y")?.unwrap_or(0),
2074            col: r.get::<Option<usize>>("x")?.unwrap_or(0),
2075        });
2076    }
2077    Ok(requires)
2078}
2079
2080/// `true` for `foo.tl` but not `foo.d.tl`.
2081pub fn is_tl_source(p: &Path) -> bool {
2082    let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
2083    p.is_file() && name.ends_with(".tl") && !name.ends_with(".d.tl")
2084}
2085
2086/// The note `htl dts` writes beside the declarations it materialises from a dependency
2087/// crate, in `types/<crate>/`. The module that writes it is `dep_dts`, which the `dts`
2088/// feature compiles.
2089pub const DEP_TYPES_NOTE: &str = ".htl-dts";
2090
2091/// The immediate subdirectories of `types/` holding declarations materialised from a
2092/// dependency, in name order.
2093///
2094/// They go on the search path in their own right, so that a declaration keeps the module
2095/// name it was written under whatever the crate shipping it is called: `htl-mq`'s
2096/// `mq.d.tl` is `require("mq")`, not `require("htl-mq.mq")`. A directory a person laid out
2097/// under `types/` carries no note and goes on meaning what it has always meant — the path
2098/// below `types/` is the module name, as `socket/http.d.tl` is `require("socket.http")`.
2099pub fn materialised_types_dirs(types: &Path) -> Vec<PathBuf> {
2100    let Ok(entries) = std::fs::read_dir(types) else {
2101        return Vec::new();
2102    };
2103    let mut out: Vec<PathBuf> = entries
2104        .filter_map(Result::ok)
2105        .map(|e| e.path())
2106        .filter(|p| p.is_dir() && p.join(DEP_TYPES_NOTE).is_file())
2107        .collect();
2108    out.sort();
2109    out
2110}
2111
2112/// `true` for `foo.d.tl`: a declaration, with the implementation somewhere else.
2113pub fn is_declaration(p: &Path) -> bool {
2114    p.file_name()
2115        .and_then(|s| s.to_str())
2116        .is_some_and(|n| n.ends_with(".d.tl"))
2117}
2118
2119/// Directories never descended into when collecting sources under a root: build output,
2120/// installed packages, VCS and tool state. A root passed explicitly is always walked.
2121pub const SKIP_DIRS: &[&str] = &["target", "node_modules", ".mlua-pkgs", ".git"];
2122
2123/// `true` for a directory entry that source collection should not enter: a name in
2124/// [`SKIP_DIRS`], any dot-directory, or one of `extra` — named by path rather than by
2125/// name, for what the caller knows and a name cannot say.
2126pub fn is_skipped_dir(path: &Path, extra: &[PathBuf]) -> bool {
2127    if !path.is_dir() {
2128        return false;
2129    }
2130    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
2131    if SKIP_DIRS.contains(&name) || (name.starts_with('.') && name.len() > 1) {
2132        return true;
2133    }
2134    extra.iter().any(|e| same_file(path, e))
2135}
2136
2137/// The two paths name the same thing on disk, `..` and symlinks resolved. Falls back to
2138/// comparing them as written when either cannot be canonicalised (it does not exist).
2139pub(crate) fn same_file(a: &Path, b: &Path) -> bool {
2140    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
2141        (Ok(x), Ok(y)) => x == y,
2142        _ => a == b,
2143    }
2144}
2145
2146/// Extra directories to skip below `root`, when `root` is inside an `mlua-pkg.toml`
2147/// project: where it installed its deps, and each copy a `target_dir` dep put in the tree.
2148///
2149/// Both hold a dependency's own sources and tests rather than the project's. The copies
2150/// need saying because they are *in* the repo and committed — nothing about the path tells
2151/// one apart from the project's own code beside it, and only the manifest knows. `mlua-pkg
2152/// install` rewrites them every time it runs, so checking one reports someone else's
2153/// errors, formatting it writes a diff against upstream that the next install undoes, and
2154/// running its tests runs a dependency's suite. Go settled the same question the same way:
2155/// `./...` has excluded `vendor/` since 1.9.
2156///
2157/// A `patch_dir` dep is the other case and is not here: the project owns that copy, so
2158/// whether to walk it depends on what the walk is for ([`patched_dirs`]).
2159#[cfg(feature = "pkg")]
2160pub fn project_skip_dirs(root: &Path) -> Vec<PathBuf> {
2161    match pkg::Project::find(root) {
2162        Some(p) => {
2163            let mut out = vec![p.pkgs_dir];
2164            out.extend(p.vendored_copies);
2165            out
2166        }
2167        None => Vec::new(),
2168    }
2169}
2170
2171#[cfg(not(feature = "pkg"))]
2172pub fn project_skip_dirs(_root: &Path) -> Vec<PathBuf> {
2173    Vec::new()
2174}
2175
2176/// The `patch_dir` deps below `root`: a dependency's source taken into the tree, which the
2177/// project edits and commits (`htl pkg patch`).
2178///
2179/// Not in [`project_skip_dirs`], because whether to walk one depends on what the walk is
2180/// for. Its errors are the project's to fix, so `htl check` reports them; but the change
2181/// it holds is a diff against the revision it was taken from, so `htl fmt` would bury that
2182/// change under a reformatting of every file, and its `*_test.tl` are the dependency's
2183/// suite rather than the project's. Those two skip it, and pass this to
2184/// [`collect_tl_skipping`] / [`testing::discover_tests_skipping`] to say so.
2185#[cfg(feature = "pkg")]
2186pub fn patched_dirs(root: &Path) -> Vec<PathBuf> {
2187    match pkg::Project::find(root) {
2188        Some(p) => p.patch_dirs(),
2189        None => Vec::new(),
2190    }
2191}
2192
2193#[cfg(not(feature = "pkg"))]
2194pub fn patched_dirs(_root: &Path) -> Vec<PathBuf> {
2195    Vec::new()
2196}
2197
2198/// The directories a `require` in the project at `root` resolves its deps from: the
2199/// search directory of each `patch_dir` copy, the entry links under `.htl/modules`, and
2200/// the parents of `target_dir` copies — what [`Htl::apply_project`] puts on the path, in
2201/// the same order, listed whether or not they exist yet, for the cache's probes
2202/// ([`cache::search_dirs`]).
2203///
2204/// One list, read by the two that must agree. A patched dependency is the project's own
2205/// code and a person edits it there, so an entry replayed from the store while the copy
2206/// has moved on would be the wrong answer to a question the user just changed: the probe
2207/// over its entry directory is what catches a module appearing in or leaving the copy, as
2208/// the hash of a file the entry recorded catches a line changing inside one.
2209#[cfg(feature = "pkg")]
2210pub fn dependency_dirs(root: &Path) -> Vec<PathBuf> {
2211    match pkg::Project::find(root) {
2212        Some(p) => {
2213            let mut out = p.patch_search_dirs();
2214            out.push(p.entries);
2215            out.extend(p.target_dirs);
2216            out
2217        }
2218        None => Vec::new(),
2219    }
2220}
2221
2222#[cfg(not(feature = "pkg"))]
2223pub fn dependency_dirs(_root: &Path) -> Vec<PathBuf> {
2224    Vec::new()
2225}
2226
2227/// Collect `.tl` sources from files and directories (sorted, recursive). Directories in
2228/// [`SKIP_DIRS`], dot-directories and the project's package dir are not entered unless
2229/// given as a root themselves.
2230pub fn collect_tl(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
2231    collect_tl_skipping(paths, &[])
2232}
2233
2234/// [`collect_tl`], not entering `skip` either — directories named by path rather than by
2235/// name, for what the caller knows and a name cannot say ([`patched_dirs`]).
2236pub fn collect_tl_skipping(paths: &[PathBuf], skip: &[PathBuf]) -> Result<Vec<PathBuf>> {
2237    let mut out = Vec::new();
2238    for p in paths {
2239        if p.is_dir() {
2240            let mut extra = project_skip_dirs(p);
2241            extra.extend(skip.iter().cloned());
2242            let root = p.clone();
2243            let walker = walkdir::WalkDir::new(p)
2244                .sort_by_file_name()
2245                .into_iter()
2246                .filter_entry(move |e| e.path() == root || !is_skipped_dir(e.path(), &extra));
2247            for e in walker {
2248                let e = e?;
2249                if is_tl_source(e.path()) {
2250                    out.push(e.path().to_path_buf());
2251                }
2252            }
2253        } else if p.is_file() {
2254            out.push(p.clone());
2255        } else {
2256            bail!("no such file or directory: {}", p.display());
2257        }
2258    }
2259    Ok(out)
2260}
2261
2262/// `root/foo/bar.tl` -> `foo.bar`, `root/foo/init.tl` -> `foo`.
2263pub fn module_name(root: &Path, file: &Path) -> Result<String> {
2264    let rel = file.strip_prefix(root)?.with_extension("");
2265    let mut parts: Vec<String> = rel
2266        .components()
2267        .map(|c| c.as_os_str().to_string_lossy().into_owned())
2268        .collect();
2269    if parts.last().map(|s| s == "init").unwrap_or(false) {
2270        parts.pop();
2271    }
2272    if parts.is_empty() {
2273        bail!("cannot derive module name for {}", file.display());
2274    }
2275    Ok(parts.join("."))
2276}
2277
2278#[cfg(test)]
2279mod tests {
2280    use super::*;
2281
2282    fn decl(path: &str, source: &str) -> (String, String) {
2283        (path.to_string(), source.to_string())
2284    }
2285
2286    /// The reason the key exists: a build carrying one declaration more than another — a
2287    /// feature set, a newer mlua-batteries — lands somewhere else, so neither finds the
2288    /// other's files on its search path.
2289    #[test]
2290    fn a_different_set_of_declarations_is_a_different_key() {
2291        let base = vec![decl("htl/test.d.tl", "local record t end\nreturn t\n")];
2292        let mut more = base.clone();
2293        more.push(decl(
2294            "std/json.d.tl",
2295            "local record json end\nreturn json\n",
2296        ));
2297        assert_ne!(declarations_key(&base), declarations_key(&more));
2298
2299        // And a set of the same size whose content moved.
2300        let mut edited = base.clone();
2301        edited[0].1.push('\n');
2302        assert_ne!(declarations_key(&base), declarations_key(&edited));
2303    }
2304
2305    /// And the same set is the same key, so a build uses the directory it used last time
2306    /// and the files it wrote there are still its own.
2307    #[test]
2308    fn the_same_set_is_the_same_key() {
2309        let decls = vec![
2310            decl("htl/test.d.tl", "local record t end\nreturn t\n"),
2311            decl("std/json.d.tl", "local record json end\nreturn json\n"),
2312        ];
2313        assert_eq!(declarations_key(&decls), declarations_key(&decls.clone()));
2314    }
2315
2316    /// Length-prefixed: moving a character from a path into the source after it is a
2317    /// different set of files and reads as one.
2318    #[test]
2319    fn the_parts_cannot_run_together() {
2320        assert_ne!(
2321            declarations_key(&[decl("ab", "c")]),
2322            declarations_key(&[decl("a", "bc")])
2323        );
2324    }
2325
2326    /// The list is what this build writes: `htl.test`'s declaration whatever the features,
2327    /// and `std`'s exactly when the feature that installs them is on.
2328    #[test]
2329    fn the_list_holds_what_this_build_writes() {
2330        let decls = bundled_declarations();
2331        assert!(decls.iter().any(|(p, _)| p == "htl/test.d.tl"), "{decls:?}");
2332        assert_eq!(
2333            decls.iter().any(|(p, _)| p.starts_with("std/")),
2334            cfg!(feature = "std")
2335        );
2336    }
2337
2338    /// What the directory name is made of, and that asking twice gives one answer — the
2339    /// key is computed once and the path is a constant for the life of the process.
2340    #[test]
2341    fn the_directory_carries_the_version_and_the_key() {
2342        let dir = lib_dir();
2343        let name = dir.file_name().unwrap().to_string_lossy().into_owned();
2344        let prefix = format!("htl-lib-{}-", env!("CARGO_PKG_VERSION"));
2345        assert!(name.starts_with(&prefix), "{name}");
2346        assert_eq!(
2347            name[prefix.len()..],
2348            declarations_key(&bundled_declarations())
2349        );
2350        assert_eq!(dir, lib_dir());
2351    }
2352}