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