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