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}