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 link;
21#[cfg(feature = "dts")]
22pub mod dts;
23#[cfg(feature = "pkg")]
24pub mod pkg;
25pub mod teal;
26pub mod testing;
27
28/// Registry key under which the prelude table is stored (lets `pkg::TealResolver`
29/// reach the compiler from a bare `&Lua`).
30pub(crate) const PRELUDE_REGISTRY_KEY: &str = "htl.prelude";
31
32const TL_SRC: &str = include_str!("../vendor/tl.lua");
33const LINT_SRC: &str = include_str!("lint.lua");
34const FMT_SRC: &str = include_str!("fmt.lua");
35const PRELUDE: &str = include_str!("prelude.lua");
36
37/// Teal version vendored into this crate.
38pub const TEAL_VERSION: &str = "0.24.8";
39
40/// Result of type-checking one `.tl` file.
41#[derive(Debug, Clone, Default)]
42pub struct CheckInfo {
43    /// `file:line:col: message` for syntax and type errors.
44    pub errors: Vec<String>,
45    /// `file:line:col: message` for warnings (non-fatal).
46    pub warnings: Vec<String>,
47    /// Files pulled in via `require` during checking (`.tl` / `.d.tl` / `.lua`).
48    pub deps: Vec<PathBuf>,
49    /// htl lint findings (`nil-index`, `enum-exhaustive`). Advisory unless the caller
50    /// promotes them (`htl check --strict`, `include_tl!`).
51    pub lints: Vec<String>,
52    /// Every `require("<literal>")` in the file and where the checker resolved it.
53    /// Input to [`require_cycles`].
54    pub requires: Vec<RequireSite>,
55}
56
57/// One literal `require` call in a checked file.
58#[derive(Debug, Clone)]
59pub struct RequireSite {
60    pub module: String,
61    /// Resolved file, `None` when the checker could not find it.
62    pub path: Option<PathBuf>,
63    pub line: usize,
64    pub col: usize,
65}
66
67/// Result of a static contract check (see [`Htl::contract_check`]).
68#[derive(Debug, Clone, Default)]
69pub struct ContractResult {
70    /// Type errors from `local m: <T> = require("<mod>")`.
71    pub errors: Vec<String>,
72    /// Declared fields absent from the module's returned table literal; `None` when the
73    /// return value is not a literal (not decidable statically).
74    pub missing: Option<Vec<String>>,
75    pub missing_at: (usize, usize),
76}
77
78impl Htl {
79    /// Make an `htl.toml` project's dirs visible to the checker: `root`, `root/src` and
80    /// `[check] paths`. `root` is the directory holding `htl.toml`.
81    pub fn apply_config(&self, root: &Path, cfg: &config::HtlConfig) -> Result<()> {
82        for p in cfg.search_paths(root) {
83            self.add_path(&p)?;
84        }
85        Ok(())
86    }
87
88    /// Static form of `TealResolver::expect_type` / `require_fields` for one module file:
89    /// `modname` is what a `require` would say (its stem), `type_path` is `"defs.Mod"`.
90    pub fn contract_check(&self, file: &Path, modname: &str, type_path: &str, require_fields: bool) -> Result<ContractResult> {
91        let f: Function = self.h.get("contract_check")?;
92        let t: Table = f.call((path_str(file), modname, type_path, require_fields))?;
93        let errors: Table = t.get("errors")?;
94        let errors = errors.sequence_values::<String>().collect::<mlua::Result<_>>()?;
95        let missing = match t.get::<Option<Table>>("missing")? {
96            Some(m) => Some(m.sequence_values::<String>().collect::<mlua::Result<Vec<_>>>()?),
97            None => None,
98        };
99        let missing_at = (
100            t.get::<Option<usize>>("missing_y")?.unwrap_or(1),
101            t.get::<Option<usize>>("missing_x")?.unwrap_or(1),
102        );
103        Ok(ContractResult { errors, missing, missing_at })
104    }
105}
106
107/// `contract` lint for one file: when `file` sits directly under a `[[contract]]` dir
108/// of `cfg` (relative to `root`, the directory holding `htl.toml`), check it against
109/// that contract statically. Returns lint lines (empty when no contract applies).
110pub fn contract_lints(h: &Htl, root: &Path, cfg: &config::HtlConfig, file: &Path) -> Result<Vec<String>> {
111    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
112    let file_abs = canon(file);
113    let mut out = Vec::new();
114    if !is_tl_source(&file_abs) {
115        return Ok(out);
116    }
117    let modname = file_abs.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
118    for c in &cfg.contract {
119        let Some(dir) = c.dirs(root).into_iter().map(|d| canon(&d)).find(|d| file_abs.parent() == Some(d.as_path()))
120        else {
121            continue;
122        };
123        if !c.applies_to(&modname) {
124            continue;
125        }
126        // Same visibility as `TealResolver::for_contract`: the contract dir, plus the
127        // project root, its `src/` and `[check] paths`.
128        h.add_path(&dir)?;
129        h.apply_config(root, cfg)?;
130        let r = h.contract_check(&file_abs, &modname, &c.type_path, c.require_fields)?;
131        for e in &r.errors {
132            // The stub's own "<contract ...>:L:C: " prefix says nothing useful; keep the message.
133            let msg = e.splitn(4, ':').last().unwrap_or(e).trim();
134            out.push(format!(
135                "{}:1:1: does not satisfy contract {} ({}): {msg} [htl contract]",
136                file.display(),
137                c.type_path,
138                c.dir
139            ));
140        }
141        if let Some(missing) = &r.missing
142            && !missing.is_empty()
143        {
144            out.push(format!(
145                "{}:{}:{}: returned table lacks declared field(s) of {}: {} [htl contract]",
146                file.display(),
147                r.missing_at.0,
148                r.missing_at.1,
149                c.type_path,
150                missing.join(", ")
151            ));
152        }
153    }
154    Ok(out)
155}
156
157/// `contract-unenforced` lint: a `[[contract]]` in `htl.toml` only becomes a run-time
158/// guarantee when the host builds its resolver with it. Scan the host crate's Rust
159/// sources (under `cargo_root`) for `expect_type("<type>")` (plus `require_fields()` when
160/// required) or for the config-driven `contract_resolvers(` / `for_contract(` helpers.
161/// No host crate (`cargo_root` = None) means a script-only project: nothing to enforce.
162pub fn contract_enforcement_lints(cfg: &config::HtlConfig, cfg_path: &Path, cargo_root: Option<&Path>) -> Vec<String> {
163    let mut out = Vec::new();
164    if cfg.contract.is_empty() {
165        return out;
166    }
167    let Some(root) = cargo_root else { return out };
168    let mut sources = String::new();
169    for sub in ["src", "examples", "tests", "benches"] {
170        let dir = root.join(sub);
171        if !dir.is_dir() {
172            continue;
173        }
174        for e in walkdir::WalkDir::new(&dir).into_iter().flatten() {
175            let p = e.path();
176            if p.is_file()
177                && p.extension().and_then(|s| s.to_str()) == Some("rs")
178                && let Ok(t) = std::fs::read_to_string(p)
179            {
180                sources.push_str(&t);
181                sources.push('\n');
182            }
183        }
184    }
185    // `for_contract_dir(` does not contain `for_contract(` as a substring: list it.
186    let by_config = ["contract_resolvers(", "for_contract(", "for_contract_dir("]
187        .iter()
188        .any(|api| sources.contains(api));
189    for c in &cfg.contract {
190        // A contract with nothing under it is not enforced by anyone; the dir may be
191        // populated later (glob dirs especially), so say nothing about the host.
192        if c.dirs(root_of(cfg_path)).is_empty() {
193            continue;
194        }
195        let by_hand = sources.contains(&format!("expect_type(\"{}\")", c.type_path));
196        let want_fields = if c.require_fields { ".require_fields()" } else { "" };
197        if !(by_config || by_hand) {
198            let by_hand_hint = if c.dir.contains('*') {
199                String::new() // one resolver per matched dir: not a one-liner by hand
200            } else {
201                format!("add TealResolver::new(\"{}\").expect_type(\"{}\"){} in the Rust host, or ", c.dir, c.type_path, want_fields)
202            };
203            out.push(format!(
204                "{}:1:1: contract `{}` -> {} is declared but the host does not enforce it: {}build resolvers with \
205                 htl::pkg::contract_resolvers(root, &config) [htl contract-unenforced]",
206                cfg_path.display(),
207                c.dir,
208                c.type_path,
209                by_hand_hint
210            ));
211        } else if c.require_fields && !by_config && !sources.contains("require_fields()") {
212            out.push(format!(
213                "{}:1:1: contract `{}` -> {} has require_fields = true but the host never calls .require_fields(): \
214                 missing fields will pass at run time [htl contract-unenforced]",
215                cfg_path.display(),
216                c.dir,
217                c.type_path
218            ));
219        }
220    }
221    out
222}
223
224fn root_of(cfg_path: &Path) -> &Path {
225    cfg_path.parent().unwrap_or(Path::new("."))
226}
227
228/// Cycles in the require graph of a set of checked files, one message per cycle,
229/// anchored at the first edge's call site. Teal types a circular require as an opaque
230/// `circular_require`, so a cycle shows up elsewhere as "cannot index" errors; naming
231/// the loop is the useful part. Files outside `infos` are treated as leaves.
232pub fn require_cycles(infos: &[(PathBuf, CheckInfo)]) -> Vec<String> {
233    use std::collections::{HashMap, HashSet};
234    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
235    let mut edges: HashMap<PathBuf, Vec<(PathBuf, &RequireSite)>> = HashMap::new();
236    let mut display: HashMap<PathBuf, PathBuf> = HashMap::new();
237    for (file, ci) in infos {
238        let from = canon(file);
239        display.insert(from.clone(), file.clone());
240        let list = edges.entry(from).or_default();
241        for r in &ci.requires {
242            if let Some(p) = &r.path {
243                list.push((canon(p), r));
244            }
245        }
246    }
247    let nodes: Vec<PathBuf> = {
248        let mut v: Vec<PathBuf> = edges.keys().cloned().collect();
249        v.sort();
250        v
251    };
252    let mut out = Vec::new();
253    let mut reported: HashSet<Vec<PathBuf>> = HashSet::new();
254    let mut state: HashMap<PathBuf, u8> = HashMap::new(); // 1 = on stack, 2 = done
255    let mut stack: Vec<(PathBuf, Option<&RequireSite>)> = Vec::new();
256
257    fn dfs<'a>(
258        node: PathBuf,
259        edges: &HashMap<PathBuf, Vec<(PathBuf, &'a RequireSite)>>,
260        state: &mut HashMap<PathBuf, u8>,
261        stack: &mut Vec<(PathBuf, Option<&'a RequireSite>)>,
262        reported: &mut HashSet<Vec<PathBuf>>,
263        display: &HashMap<PathBuf, PathBuf>,
264        out: &mut Vec<String>,
265    ) {
266        state.insert(node.clone(), 1);
267        if let Some(list) = edges.get(&node) {
268            for (to, site) in list {
269                match state.get(to).copied() {
270                    Some(1) => {
271                        // back edge: cycle = stack from `to` .. node, then back to `to`
272                        let start = stack.iter().position(|(n, _)| n == to).unwrap_or(0);
273                        let mut members: Vec<PathBuf> =
274                            stack[start..].iter().map(|(n, _)| n.clone()).chain(std::iter::once(node.clone())).collect();
275                        members.dedup();
276                        let mut key = members.clone();
277                        key.sort();
278                        if reported.insert(key) {
279                            let name = |p: &PathBuf| {
280                                display
281                                    .get(p)
282                                    .unwrap_or(p)
283                                    .file_name()
284                                    .map(|s| s.to_string_lossy().into_owned())
285                                    .unwrap_or_else(|| p.display().to_string())
286                            };
287                            let chain: Vec<String> = members.iter().map(name).chain(std::iter::once(name(to))).collect();
288                            let first_file = display.get(&members[0]).cloned().unwrap_or_else(|| members[0].clone());
289                            // anchor: the edge leaving the cycle's first member
290                            let anchor = stack.get(start + 1).and_then(|(_, s)| *s).unwrap_or(site);
291                            out.push(format!(
292                                "{}:{}:{}: require cycle: {} (Teal types the back edge as an opaque circular require; \
293                                 break it by moving shared types into a module both sides require) [htl require-cycle]",
294                                first_file.display(),
295                                anchor.line,
296                                anchor.col,
297                                chain.join(" -> ")
298                            ));
299                        }
300                    }
301                    Some(2) => {}
302                    _ => {
303                        stack.push((to.clone(), Some(site)));
304                        dfs(to.clone(), edges, state, stack, reported, display, out);
305                        stack.pop();
306                    }
307                }
308            }
309        }
310        state.insert(node, 2);
311    }
312
313    for n in nodes {
314        if !state.contains_key(&n) {
315            stack.push((n.clone(), None));
316            dfs(n, &edges, &mut state, &mut stack, &mut reported, &display, &mut out);
317            stack.pop();
318        }
319    }
320    out.sort();
321    out
322}
323
324impl CheckInfo {
325    pub fn ok(&self) -> bool {
326        self.errors.is_empty()
327    }
328
329    /// `true` when there are no errors, warnings or lints.
330    pub fn clean(&self) -> bool {
331        self.errors.is_empty() && self.warnings.is_empty() && self.lints.is_empty()
332    }
333}
334
335/// An mlua state with the Teal compiler loaded.
336pub struct Htl {
337    /// The program's state: `require`, preloads, `exec`, bundles.
338    lua: Lua,
339    /// The prelude table (checker API). Lives in `lua` unless this is a split state
340    /// made by [`with_checker`](Self::with_checker), where it belongs to the checker.
341    h: Table,
342    /// `true` when the checker is another Lua state (`with_checker`).
343    split: bool,
344}
345
346/// Checker prelude of another state, kept in a runtime state's app data so the
347/// mlua-pkg resolvers find their checker (`Htl::with_checker`).
348pub(crate) struct CheckerHandle(pub(crate) Table);
349
350const RUNTIME_REGISTRY_KEY: &str = "htl.runtime";
351
352/// The part of the prelude a runtime state needs when its checker lives elsewhere:
353/// the strict searcher (asking the checker through `gen`), the declaration-only
354/// module, and `package.path` bookkeeping.
355const RUNTIME_PRELUDE: &str = r#"
356local R = {}
357
358function R.type_only_module(module_name, decl_path)
359   return setmetatable({}, {
360      __index = function(_, key)
361         error(string.format(
362            "module '%s' is declaration-only here (%s): '%s' has no implementation on this path. " ..
363            "It must be provided by the host program (e.g. a Rust #[host_module] via cargo run) " ..
364            "or by a .tl/.lua module with that name.",
365            module_name, decl_path, tostring(key)), 2)
366      end,
367   })
368end
369
370-- gen(name) -> kind, a, b  (see resolve_for_require in the checker prelude)
371function R.install_searcher(gen)
372   table.insert(package.searchers, 2, function(module_name)
373      local kind, a, b = gen(module_name)
374      if kind == "code" then
375         local chunk, lerr = load(a, "@" .. b, "t")
376         if not chunk then
377            error("htl: generated Lua failed to load: " .. tostring(lerr), 0)
378         end
379         return function(modname) return chunk(modname, b) end, b
380      elseif kind == "type_only" then
381         return function() return R.type_only_module(module_name, a) end, a
382      end
383      return a
384   end)
385end
386
387function R.add_path(dir)
388   local templates = dir .. "/?.lua;" .. dir .. "/?/init.lua;" .. dir .. "/?/?.lua"
389   if package.path == nil or package.path == "" then
390      package.path = templates
391   else
392      package.path = templates .. ";" .. package.path
393   end
394end
395
396function R.reset_path()
397   package.path = ""
398end
399
400return R
401"#;
402
403impl Htl {
404    /// New state. Uses `Lua::unsafe_new` so stripped bytecode bundles can be loaded.
405    pub fn new() -> Result<Self> {
406        // SAFETY: we accept binary chunks only from bundles we produced ourselves.
407        let lua = unsafe { Lua::unsafe_new() };
408        Self::from_lua(lua)
409    }
410
411    /// A fresh program state that borrows `checker`'s compiler instead of loading its
412    /// own: modules `checker` has already type-checked and generated are served from
413    /// its store, so a run of many programs (the test runner: one state per file)
414    /// checks each module once. The program state itself is as isolated as
415    /// [`new`](Self::new): nothing but the checker is shared. The checker starts a new
416    /// program env for this state (module-name resolution is per program).
417    pub fn with_checker(checker: &Htl) -> Result<Self> {
418        // SAFETY: as in `new`.
419        let lua = unsafe { Lua::unsafe_new() };
420        let r: Table = lua
421            .load(RUNTIME_PRELUDE)
422            .set_name("=htl-runtime")
423            .eval()
424            .context("loading htl runtime prelude")?;
425        lua.set_named_registry_value(RUNTIME_REGISTRY_KEY, r)?;
426        lua.set_app_data(CheckerHandle(checker.h.clone()));
427        let begin: Function = checker.h.get("begin_program")?;
428        begin.call::<()>(())?;
429        Ok(Self { lua, h: checker.h.clone(), split: true })
430    }
431
432    fn runtime(&self) -> Result<Table> {
433        Ok(self.lua.named_registry_value::<Table>(RUNTIME_REGISTRY_KEY)?)
434    }
435
436    /// The checker's `package.path` (what `require` inside `.tl` resolves through).
437    pub fn search_path(&self) -> Result<String> {
438        let f: Function = self.h.get("get_path")?;
439        Ok(f.call(())?)
440    }
441
442    /// Restore a checker `package.path` taken with [`search_path`](Self::search_path).
443    pub fn set_search_path(&self, path: &str) -> Result<()> {
444        let f: Function = self.h.get("set_path")?;
445        f.call::<()>(path)?;
446        Ok(())
447    }
448
449    /// Attach the Teal compiler to an existing Lua state (the host's own `Lua`).
450    pub fn from_lua(lua: Lua) -> Result<Self> {
451        let tl_loader: Function = lua
452            .load(TL_SRC)
453            .set_name("=tl.lua")
454            .into_function()
455            .context("compiling vendored tl.lua")?;
456        let lint_loader: Function = lua
457            .load(LINT_SRC)
458            .set_name("=htl-lint")
459            .into_function()
460            .context("compiling htl lint.lua")?;
461        let package: Table = lua.globals().get("package")?;
462        let preload: Table = package.get("preload")?;
463        let fmt_loader: Function = lua
464            .load(FMT_SRC)
465            .set_name("=htl-fmt")
466            .into_function()
467            .context("compiling htl fmt.lua")?;
468        preload.set("tl", tl_loader)?;
469        preload.set("htl.lint", lint_loader)?;
470        preload.set("htl.fmt", fmt_loader)?;
471        let h: Table = lua
472            .load(PRELUDE)
473            .set_name("=htl-prelude")
474            .eval()
475            .context("loading htl prelude")?;
476        lua.set_named_registry_value(PRELUDE_REGISTRY_KEY, h.clone())?;
477        Ok(Self { lua, h, split: false })
478    }
479
480    pub fn lua(&self) -> &Lua {
481        &self.lua
482    }
483
484    /// Type-check one file.
485    pub fn check(&self, file: &Path) -> Result<CheckInfo> {
486        let f: Function = self.h.get("check")?;
487        let t: Table = f.call(path_str(file))?;
488        read_checkinfo(&t)
489    }
490
491    /// Type-check and generate Lua source. `None` code means errors (see `CheckInfo`).
492    pub fn gen_lua(&self, file: &Path) -> Result<(Option<String>, CheckInfo)> {
493        let f: Function = self.h.get("gen")?;
494        let (code, t): (Option<String>, Table) = f.call(path_str(file))?;
495        Ok((code, read_checkinfo(&t)?))
496    }
497
498    /// Configure lint rules: `"+no-any,-shadow-local"` on top of the defaults.
499    pub fn configure_lints(&self, spec: &str) -> Result<()> {
500        let f: Function = self.h.get("set_lints")?;
501        let (ok, err): (Option<bool>, Option<String>) = f.call(spec)?;
502        if ok.unwrap_or(false) {
503            Ok(())
504        } else {
505            bail!("{}", err.unwrap_or_else(|| "invalid lint spec".into()))
506        }
507    }
508
509    /// Names of all lint rules (enabled or not).
510    pub fn lint_rules(&self) -> Result<Vec<String>> {
511        let f: Function = self.h.get("lint_rules")?;
512        let t: Table = f.call(())?;
513        Ok(t.sequence_values::<String>().collect::<mlua::Result<_>>()?)
514    }
515
516    /// Format a `.tl` file (whitespace-only formatter). Returns the formatted text.
517    pub fn format_file(&self, file: &Path, indent: usize) -> Result<String> {
518        let f: Function = self.h.get("format")?;
519        let (out, err): (Option<String>, Option<String>) = f.call((path_str(file), indent))?;
520        out.ok_or_else(|| anyhow!("{}", err.unwrap_or_else(|| "format failed".into())))
521    }
522
523    /// Drop Lua's default search path (cwd-relative `./?.lua` etc.) so only directories
524    /// passed to [`add_path`](Self::add_path) are consulted by the checker and `require`.
525    pub fn reset_search_path(&self) -> Result<()> {
526        let f: Function = self.h.get("reset_path")?;
527        f.call::<()>(())?;
528        if self.split {
529            let f: Function = self.runtime()?.get("reset_path")?;
530            f.call::<()>(())?;
531        }
532        Ok(())
533    }
534
535    /// Search paths implied by where `file` sits in the scaffold layout: its own
536    /// directory, and for a file under `tests/` also the project root and `<root>/src`
537    /// (the test runner's rule, so `htl check tests` sees what `htl test` sees).
538    pub fn add_layout_paths(&self, file: &Path) -> Result<()> {
539        let dir = parent_dir(file);
540        self.add_path(&dir)?;
541        if dir.file_name().is_some_and(|n| n == "tests")
542            && let Some(root) = dir.parent()
543        {
544            self.add_path(root)?;
545            let src = root.join("src");
546            if src.is_dir() {
547                self.add_path(&src)?;
548            }
549        }
550        Ok(())
551    }
552
553    /// Prepend `dir/?.tl;dir/?/init.tl` to `package.path` (Teal resolves requires through it).
554    pub fn add_path(&self, dir: &Path) -> Result<()> {
555        let f: Function = self.h.get("add_path")?;
556        f.call::<()>(path_str(dir))?;
557        if self.split {
558            // The program state resolves plain `.lua` (and `.d.tl` siblings) itself.
559            let f: Function = self.runtime()?.get("add_path")?;
560            f.call::<()>(path_str(dir))?;
561        }
562        Ok(())
563    }
564
565    /// Install the strict `.tl` searcher: `require` of a `.tl` with type errors fails.
566    pub fn install_searcher(&self) -> Result<()> {
567        if self.split {
568            // The searcher runs in the program state and asks the checker for code.
569            let gen_fn: Function = self.h.get("gen_for_require")?;
570            let bridge = self.lua.create_function(move |_, name: String| {
571                let (kind, a, b): (String, Option<String>, Option<String>) = gen_fn.call(name)?;
572                Ok((kind, a, b))
573            })?;
574            let f: Function = self.runtime()?.get("install_searcher")?;
575            f.call::<()>(bridge)?;
576            return Ok(());
577        }
578        let f: Function = self.h.get("install_searcher")?;
579        f.call::<()>(())?;
580        Ok(())
581    }
582
583    /// Register generated Lua source under a module name (`package.preload`).
584    pub fn preload(&self, name: &str, lua_src: &str) -> Result<()> {
585        let loader = self
586            .lua
587            .load(lua_src)
588            .set_name(format!("={name}"))
589            .into_function()
590            .with_context(|| format!("compiling preloaded module {name}"))?;
591        self.preload_table()?.set(name, loader)?;
592        Ok(())
593    }
594
595    /// Register stripped bytecode (e.g. from `include_tl_bytes!`) under a module name.
596    pub fn preload_bytes(&self, name: &str, bytecode: &[u8]) -> Result<()> {
597        let loader = self
598            .lua
599            .load(bytecode)
600            .set_name(format!("={name}"))
601            .set_mode(ChunkMode::Binary)
602            .into_function()
603            .with_context(|| format!("loading bytecode for module {name}"))?;
604        self.preload_table()?.set(name, loader)?;
605        Ok(())
606    }
607
608    /// Execute stripped bytecode with `...` = args.
609    pub fn exec_bytes(&self, bytecode: &[u8], chunk_name: &str, args: &[String]) -> Result<()> {
610        let f = self
611            .lua
612            .load(bytecode)
613            .set_name(chunk_name)
614            .set_mode(ChunkMode::Binary)
615            .into_function()?;
616        let va: Variadic<String> = args.iter().cloned().collect();
617        f.call::<()>(va)?;
618        Ok(())
619    }
620
621    /// Register a ready-made value (typically a Rust-built table) as a module.
622    pub fn preload_value(&self, name: &str, value: impl mlua::IntoLua) -> Result<()> {
623        let value = value.into_lua(&self.lua)?;
624        let loader = self
625            .lua
626            .create_function(move |_, ()| Ok(value.clone()))?;
627        self.preload_table()?.set(name, loader)?;
628        Ok(())
629    }
630
631    fn preload_table(&self) -> Result<Table> {
632        let package: Table = self.lua.globals().get("package")?;
633        Ok(package.get("preload")?)
634    }
635
636    /// Set the global `arg` table like the `lua` CLI does.
637    pub fn set_arg(&self, script: &str, args: &[String]) -> Result<()> {
638        let t = self.lua.create_table()?;
639        t.set(0, script)?;
640        for (i, a) in args.iter().enumerate() {
641            t.set(i + 1, a.as_str())?;
642        }
643        self.lua.globals().set("arg", t)?;
644        Ok(())
645    }
646
647    /// Execute Lua source with `...` = args.
648    pub fn exec(&self, lua_src: &str, chunk_name: &str, args: &[String]) -> Result<()> {
649        let f = self
650            .lua
651            .load(lua_src)
652            .set_name(chunk_name)
653            .into_function()?;
654        let va: Variadic<String> = args.iter().cloned().collect();
655        f.call::<()>(va)?;
656        Ok(())
657    }
658
659    /// Check + gen + run a `.tl` script. If the check fails the script is not run and the
660    /// returned `CheckInfo` carries the errors. Runtime errors come back as `Err`.
661    pub fn run_file(&self, file: &Path, args: &[String]) -> Result<CheckInfo> {
662        self.add_path(&parent_dir(file))?;
663        self.install_searcher()?;
664        self.set_arg(&file.to_string_lossy(), args)?;
665        let (code, ci) = self.gen_lua(file)?;
666        let Some(code) = code else { return Ok(ci) };
667        self.exec(&code, &format!("@{}", file.display()), args)?;
668        Ok(ci)
669    }
670
671    /// Compile Lua source to stripped bytecode (Lua 5.4 format of this build).
672    pub fn compile(&self, name: &str, lua_src: &str) -> Result<Vec<u8>> {
673        self.compile_with(name, lua_src, true)
674    }
675
676    /// Compile to bytecode; `strip` drops debug info (line numbers, local and upvalue
677    /// names, and the chunk name: tracebacks then show the name given at load).
678    pub fn compile_with(&self, name: &str, lua_src: &str, strip: bool) -> Result<Vec<u8>> {
679        let f = self
680            .lua
681            .load(lua_src)
682            .set_name(format!("={name}"))
683            .into_function()
684            .with_context(|| format!("compiling generated Lua for {name}"))?;
685        Ok(f.dump(strip))
686    }
687
688    /// The Lua bytecode header this state produces (signature, version, format,
689    /// `LUAC_DATA`, sizes of Instruction / Integer / Number, endianness probes): what
690    /// another state must match to load this state's bytecode. Lua's own version byte
691    /// is the same for every 5.4.x, so bundles carry this instead.
692    pub fn fingerprint(&self) -> Result<Vec<u8>> {
693        let bc = self.compile_with("fp", "return 0", true)?;
694        // 4 signature + 1 version + 1 format + 6 LUAC_DATA + 3 sizes + 8 LUAC_INT + 8 LUAC_NUM
695        Ok(bc.iter().take(31).copied().collect())
696    }
697
698    /// Literal `require`s of a plain Lua source, resolved through the checker's path.
699    pub fn lua_requires(&self, src: &str, file: &Path) -> Result<Vec<RequireSite>> {
700        let f: Function = self.h.get("lua_requires")?;
701        let t: Table = f.call((src, path_str(file)))?;
702        read_requires(&t)
703    }
704
705    /// Where `require(name)` resolves for the checker (`.tl`, `.d.tl` or `.lua`), and
706    /// where a plain `.lua` implementation sits on the path (a `.d.tl` may only be
707    /// typing it). Either may be `None`.
708    pub fn resolve_module(&self, name: &str) -> Result<(Option<PathBuf>, Option<PathBuf>)> {
709        let f: Function = self.h.get("resolve_module")?;
710        let (found, lua): (Option<String>, Option<String>) = f.call(name)?;
711        Ok((found.map(PathBuf::from), lua.map(PathBuf::from)))
712    }
713
714    /// Install a searcher serving modules from a bundle.
715    pub fn install_bundle(&self, b: &bundle::Bundle) -> Result<()> {
716        // Bytecode from a Lua that disagrees with ours would fail with "bad binary
717        // format" somewhere inside the first require; say what differs instead.
718        if b.modules.iter().any(|m| m.kind == bundle::Kind::Bytecode) && !b.fingerprint.is_empty() {
719            let mine = self.fingerprint()?;
720            if mine != b.fingerprint {
721                bail!(
722                    "bundle bytecode was compiled for {} but this host runs {}; rebuild the bundle here, \
723                     or build it with --source",
724                    bundle::describe_fingerprint(&b.fingerprint),
725                    bundle::describe_fingerprint(&mine)
726                );
727            }
728        }
729        // Host-provided modules must already be registered, or the program's first
730        // require of them fails with a message that points at the wrong place.
731        let package: Table = self.lua.globals().get("package")?;
732        let preload: Table = package.get("preload")?;
733        let loaded: Table = package.get("loaded")?;
734        let missing: Vec<&String> = b
735            .host_modules
736            .iter()
737            .filter(|n| {
738                matches!(preload.get::<Value>(n.as_str()), Ok(Value::Nil))
739                    && matches!(loaded.get::<Value>(n.as_str()), Ok(Value::Nil))
740            })
741            .collect();
742        if !missing.is_empty() {
743            bail!(
744                "bundle expects host-provided module(s) {} (declared only by a .d.tl or [build] host at link \
745                 time): register them with preload / preload_value / htl_preload before running",
746                missing.iter().map(|m| format!("'{m}'")).collect::<Vec<_>>().join(", ")
747            );
748        }
749        let modules = b.modules.clone();
750        let searcher = self.lua.create_function(move |lua, name: String| {
751            match modules.iter().find(|m| m.name == name) {
752                Some(m) => {
753                    let chunk = lua.load(m.payload.as_slice()).set_name(format!("={name}"));
754                    let f = match m.kind {
755                        bundle::Kind::Bytecode => chunk.set_mode(ChunkMode::Binary).into_function()?,
756                        bundle::Kind::Source => chunk.set_mode(ChunkMode::Text).into_function()?,
757                    };
758                    // Like a file searcher: the loader gets (modname, origin).
759                    Ok((Value::Function(f), Value::String(lua.create_string(format!("bundle:{name}"))?)))
760                }
761                None => Ok((
762                    Value::String(lua.create_string(format!("\n\tno bundled module '{name}'"))?),
763                    Value::Nil,
764                )),
765            }
766        })?;
767        let searchers: Table = package.get("searchers")?;
768        // Right after `preload` (the host's modules), before the file searchers: a
769        // bundle is the program, not a hint; files on disk do not override it.
770        searchers.raw_insert(2, searcher)?;
771        Ok(())
772    }
773
774    /// Install the bundle and run its entry module with `...` = args.
775    pub fn run_bundle(&self, b: &bundle::Bundle, args: &[String]) -> Result<()> {
776        let entry = b
777            .module(&b.entry)
778            .cloned()
779            .ok_or_else(|| anyhow!("entry module '{}' not in bundle", b.entry))?;
780        self.install_bundle(b)?;
781        self.set_arg(&b.entry, args)?;
782        let chunk = self.lua.load(entry.payload.as_slice()).set_name(format!("={}", b.entry));
783        let main: Function = match entry.kind {
784            bundle::Kind::Bytecode => chunk.set_mode(ChunkMode::Binary).into_function()?,
785            bundle::Kind::Source => chunk.set_mode(ChunkMode::Text).into_function()?,
786        };
787        let va: Variadic<String> = args.iter().cloned().collect();
788        main.call::<()>(va)?;
789        Ok(())
790    }
791}
792
793fn path_str(p: &Path) -> String {
794    p.to_string_lossy().into_owned()
795}
796
797/// A user-facing message for an error that came out of running Lua: the innermost
798/// cause without Lua's `stack traceback:` block. A host function's `Err(e)` surfaces
799/// as `e`'s own text; a Lua `error("msg")` surfaces as `file:line: msg`.
800///
801/// ```text
802/// sgen: content/no-date.md: front matter: 'date' is required
803/// ```
804/// instead of that line followed by `stack traceback: [C]: in method 'pages' ...`.
805pub fn user_message(err: &anyhow::Error) -> String {
806    fn from_mlua(e: &mlua::Error) -> String {
807        match e {
808            mlua::Error::CallbackError { cause, .. } => from_mlua(cause),
809            mlua::Error::ExternalError(ext) => ext.to_string(),
810            mlua::Error::WithContext { cause, .. } => from_mlua(cause),
811            other => strip_traceback(&other.to_string()),
812        }
813    }
814    if let Some(e) = err.downcast_ref::<mlua::Error>() {
815        return from_mlua(e);
816    }
817    strip_traceback(&format!("{err:#}"))
818}
819
820/// Remove a trailing Lua `stack traceback:` section from an error text.
821pub fn strip_traceback(text: &str) -> String {
822    let cut = text.find("\nstack traceback:").unwrap_or(text.len());
823    text[..cut].trim_end().to_string()
824}
825
826/// Write `text` to `path` only if the content differs. Returns `true` when written.
827/// Used by the derive macros to emit `.d.tl` files without churning cargo's fingerprints.
828pub fn write_if_changed(path: &Path, text: &str) -> std::io::Result<bool> {
829    if let Ok(cur) = std::fs::read_to_string(path)
830        && cur == text
831    {
832        return Ok(false);
833    }
834    if let Some(dir) = path.parent() {
835        std::fs::create_dir_all(dir)?;
836    }
837    std::fs::write(path, text)?;
838    Ok(true)
839}
840
841/// Parent directory of a file, `.` when the path has none.
842pub fn parent_dir(file: &Path) -> PathBuf {
843    let dir = file.parent().unwrap_or(Path::new("."));
844    if dir.as_os_str().is_empty() { PathBuf::from(".") } else { dir.to_path_buf() }
845}
846
847fn read_checkinfo(t: &Table) -> Result<CheckInfo> {
848    let seq = |key: &str| -> Result<Vec<String>> {
849        let inner: Table = t.get(key)?;
850        Ok(inner.sequence_values::<String>().collect::<mlua::Result<_>>()?)
851    };
852    let requires = match t.get::<Table>("requires") {
853        Ok(list) => read_requires(&list)?,
854        Err(_) => Vec::new(),
855    };
856    Ok(CheckInfo {
857        errors: seq("errors")?,
858        warnings: seq("warnings")?,
859        deps: seq("deps")?.into_iter().map(PathBuf::from).collect(),
860        lints: seq("lints")?,
861        requires,
862    })
863}
864
865fn read_requires(list: &Table) -> Result<Vec<RequireSite>> {
866    let mut requires = Vec::new();
867    for r in list.sequence_values::<Table>() {
868        let r = r?;
869        requires.push(RequireSite {
870            module: r.get::<String>("name")?,
871            path: r.get::<Option<String>>("path")?.map(PathBuf::from),
872            line: r.get::<Option<usize>>("y")?.unwrap_or(0),
873            col: r.get::<Option<usize>>("x")?.unwrap_or(0),
874        });
875    }
876    Ok(requires)
877}
878
879/// `true` for `foo.tl` but not `foo.d.tl`.
880pub fn is_tl_source(p: &Path) -> bool {
881    let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
882    p.is_file() && name.ends_with(".tl") && !name.ends_with(".d.tl")
883}
884
885/// Directories never descended into when collecting sources under a root: build output,
886/// installed packages, VCS and tool state. A root passed explicitly is always walked.
887pub const SKIP_DIRS: &[&str] = &["target", "node_modules", ".mlua-pkgs", ".git"];
888
889/// `true` for a directory entry that source collection should not enter: a name in
890/// [`SKIP_DIRS`], any dot-directory, or the project's mlua-pkg directory (`pkgs_dir`,
891/// which `MLUA_PKG_DIR` can move somewhere unremarkable).
892pub fn is_skipped_dir(path: &Path, extra: &[PathBuf]) -> bool {
893    if !path.is_dir() {
894        return false;
895    }
896    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
897    if SKIP_DIRS.contains(&name) || (name.starts_with('.') && name.len() > 1) {
898        return true;
899    }
900    extra.iter().any(|e| same_dir(path, e))
901}
902
903fn same_dir(a: &Path, b: &Path) -> bool {
904    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
905        (Ok(x), Ok(y)) => x == y,
906        _ => a == b,
907    }
908}
909
910/// Extra directories to skip below `root`: the mlua-pkg package dir when `root` is
911/// inside an `mlua-pkg.toml` project (its vendored / cached sources are dependencies,
912/// not the project's own files).
913#[cfg(feature = "pkg")]
914pub fn project_skip_dirs(root: &Path) -> Vec<PathBuf> {
915    match pkg::Project::find(root) {
916        Some(p) => vec![p.pkgs_dir],
917        None => Vec::new(),
918    }
919}
920
921#[cfg(not(feature = "pkg"))]
922pub fn project_skip_dirs(_root: &Path) -> Vec<PathBuf> {
923    Vec::new()
924}
925
926/// Collect `.tl` sources from files and directories (sorted, recursive). Directories in
927/// [`SKIP_DIRS`], dot-directories and the project's package dir are not entered unless
928/// given as a root themselves.
929pub fn collect_tl(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
930    let mut out = Vec::new();
931    for p in paths {
932        if p.is_dir() {
933            let extra = project_skip_dirs(p);
934            let root = p.clone();
935            let walker = walkdir::WalkDir::new(p)
936                .sort_by_file_name()
937                .into_iter()
938                .filter_entry(move |e| e.path() == root || !is_skipped_dir(e.path(), &extra));
939            for e in walker {
940                let e = e?;
941                if is_tl_source(e.path()) {
942                    out.push(e.path().to_path_buf());
943                }
944            }
945        } else if p.is_file() {
946            out.push(p.clone());
947        } else {
948            bail!("no such file or directory: {}", p.display());
949        }
950    }
951    Ok(out)
952}
953
954/// `root/foo/bar.tl` -> `foo.bar`, `root/foo/init.tl` -> `foo`.
955pub fn module_name(root: &Path, file: &Path) -> Result<String> {
956    let rel = file.strip_prefix(root)?.with_extension("");
957    let mut parts: Vec<String> = rel
958        .components()
959        .map(|c| c.as_os_str().to_string_lossy().into_owned())
960        .collect();
961    if parts.last().map(|s| s == "init").unwrap_or(false) {
962        parts.pop();
963    }
964    if parts.is_empty() {
965        bail!("cannot derive module name for {}", file.display());
966    }
967    Ok(parts.join("."))
968}