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