Skip to main content

htl_core/
lib.rs

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