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