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    lua: Lua,
337    h: Table,
338}
339
340impl Htl {
341    /// New state. Uses `Lua::unsafe_new` so stripped bytecode bundles can be loaded.
342    pub fn new() -> Result<Self> {
343        // SAFETY: we accept binary chunks only from bundles we produced ourselves.
344        let lua = unsafe { Lua::unsafe_new() };
345        Self::from_lua(lua)
346    }
347
348    /// Attach the Teal compiler to an existing Lua state (the host's own `Lua`).
349    pub fn from_lua(lua: Lua) -> Result<Self> {
350        let tl_loader: Function = lua
351            .load(TL_SRC)
352            .set_name("=tl.lua")
353            .into_function()
354            .context("compiling vendored tl.lua")?;
355        let lint_loader: Function = lua
356            .load(LINT_SRC)
357            .set_name("=htl-lint")
358            .into_function()
359            .context("compiling htl lint.lua")?;
360        let package: Table = lua.globals().get("package")?;
361        let preload: Table = package.get("preload")?;
362        let fmt_loader: Function = lua
363            .load(FMT_SRC)
364            .set_name("=htl-fmt")
365            .into_function()
366            .context("compiling htl fmt.lua")?;
367        preload.set("tl", tl_loader)?;
368        preload.set("htl.lint", lint_loader)?;
369        preload.set("htl.fmt", fmt_loader)?;
370        let h: Table = lua
371            .load(PRELUDE)
372            .set_name("=htl-prelude")
373            .eval()
374            .context("loading htl prelude")?;
375        lua.set_named_registry_value(PRELUDE_REGISTRY_KEY, h.clone())?;
376        Ok(Self { lua, h })
377    }
378
379    pub fn lua(&self) -> &Lua {
380        &self.lua
381    }
382
383    /// Type-check one file.
384    pub fn check(&self, file: &Path) -> Result<CheckInfo> {
385        let f: Function = self.h.get("check")?;
386        let t: Table = f.call(path_str(file))?;
387        read_checkinfo(&t)
388    }
389
390    /// Type-check and generate Lua source. `None` code means errors (see `CheckInfo`).
391    pub fn gen_lua(&self, file: &Path) -> Result<(Option<String>, CheckInfo)> {
392        let f: Function = self.h.get("gen")?;
393        let (code, t): (Option<String>, Table) = f.call(path_str(file))?;
394        Ok((code, read_checkinfo(&t)?))
395    }
396
397    /// Configure lint rules: `"+no-any,-shadow-local"` on top of the defaults.
398    pub fn configure_lints(&self, spec: &str) -> Result<()> {
399        let f: Function = self.h.get("set_lints")?;
400        let (ok, err): (Option<bool>, Option<String>) = f.call(spec)?;
401        if ok.unwrap_or(false) {
402            Ok(())
403        } else {
404            bail!("{}", err.unwrap_or_else(|| "invalid lint spec".into()))
405        }
406    }
407
408    /// Names of all lint rules (enabled or not).
409    pub fn lint_rules(&self) -> Result<Vec<String>> {
410        let f: Function = self.h.get("lint_rules")?;
411        let t: Table = f.call(())?;
412        Ok(t.sequence_values::<String>().collect::<mlua::Result<_>>()?)
413    }
414
415    /// Format a `.tl` file (whitespace-only formatter). Returns the formatted text.
416    pub fn format_file(&self, file: &Path, indent: usize) -> Result<String> {
417        let f: Function = self.h.get("format")?;
418        let (out, err): (Option<String>, Option<String>) = f.call((path_str(file), indent))?;
419        out.ok_or_else(|| anyhow!("{}", err.unwrap_or_else(|| "format failed".into())))
420    }
421
422    /// Drop Lua's default search path (cwd-relative `./?.lua` etc.) so only directories
423    /// passed to [`add_path`](Self::add_path) are consulted by the checker and `require`.
424    pub fn reset_search_path(&self) -> Result<()> {
425        let f: Function = self.h.get("reset_path")?;
426        f.call::<()>(())?;
427        Ok(())
428    }
429
430    /// Search paths implied by where `file` sits in the scaffold layout: its own
431    /// directory, and for a file under `tests/` also the project root and `<root>/src`
432    /// (the test runner's rule, so `htl check tests` sees what `htl test` sees).
433    pub fn add_layout_paths(&self, file: &Path) -> Result<()> {
434        let dir = parent_dir(file);
435        self.add_path(&dir)?;
436        if dir.file_name().is_some_and(|n| n == "tests")
437            && let Some(root) = dir.parent()
438        {
439            self.add_path(root)?;
440            let src = root.join("src");
441            if src.is_dir() {
442                self.add_path(&src)?;
443            }
444        }
445        Ok(())
446    }
447
448    /// Prepend `dir/?.tl;dir/?/init.tl` to `package.path` (Teal resolves requires through it).
449    pub fn add_path(&self, dir: &Path) -> Result<()> {
450        let f: Function = self.h.get("add_path")?;
451        f.call::<()>(path_str(dir))?;
452        Ok(())
453    }
454
455    /// Install the strict `.tl` searcher: `require` of a `.tl` with type errors fails.
456    pub fn install_searcher(&self) -> Result<()> {
457        let f: Function = self.h.get("install_searcher")?;
458        f.call::<()>(())?;
459        Ok(())
460    }
461
462    /// Register generated Lua source under a module name (`package.preload`).
463    pub fn preload(&self, name: &str, lua_src: &str) -> Result<()> {
464        let loader = self
465            .lua
466            .load(lua_src)
467            .set_name(format!("={name}"))
468            .into_function()
469            .with_context(|| format!("compiling preloaded module {name}"))?;
470        self.preload_table()?.set(name, loader)?;
471        Ok(())
472    }
473
474    /// Register stripped bytecode (e.g. from `include_tl_bytes!`) under a module name.
475    pub fn preload_bytes(&self, name: &str, bytecode: &[u8]) -> Result<()> {
476        let loader = self
477            .lua
478            .load(bytecode)
479            .set_name(format!("={name}"))
480            .set_mode(ChunkMode::Binary)
481            .into_function()
482            .with_context(|| format!("loading bytecode for module {name}"))?;
483        self.preload_table()?.set(name, loader)?;
484        Ok(())
485    }
486
487    /// Execute stripped bytecode with `...` = args.
488    pub fn exec_bytes(&self, bytecode: &[u8], chunk_name: &str, args: &[String]) -> Result<()> {
489        let f = self
490            .lua
491            .load(bytecode)
492            .set_name(chunk_name)
493            .set_mode(ChunkMode::Binary)
494            .into_function()?;
495        let va: Variadic<String> = args.iter().cloned().collect();
496        f.call::<()>(va)?;
497        Ok(())
498    }
499
500    /// Register a ready-made value (typically a Rust-built table) as a module.
501    pub fn preload_value(&self, name: &str, value: impl mlua::IntoLua) -> Result<()> {
502        let value = value.into_lua(&self.lua)?;
503        let loader = self
504            .lua
505            .create_function(move |_, ()| Ok(value.clone()))?;
506        self.preload_table()?.set(name, loader)?;
507        Ok(())
508    }
509
510    fn preload_table(&self) -> Result<Table> {
511        let package: Table = self.lua.globals().get("package")?;
512        Ok(package.get("preload")?)
513    }
514
515    /// Set the global `arg` table like the `lua` CLI does.
516    pub fn set_arg(&self, script: &str, args: &[String]) -> Result<()> {
517        let t = self.lua.create_table()?;
518        t.set(0, script)?;
519        for (i, a) in args.iter().enumerate() {
520            t.set(i + 1, a.as_str())?;
521        }
522        self.lua.globals().set("arg", t)?;
523        Ok(())
524    }
525
526    /// Execute Lua source with `...` = args.
527    pub fn exec(&self, lua_src: &str, chunk_name: &str, args: &[String]) -> Result<()> {
528        let f = self
529            .lua
530            .load(lua_src)
531            .set_name(chunk_name)
532            .into_function()?;
533        let va: Variadic<String> = args.iter().cloned().collect();
534        f.call::<()>(va)?;
535        Ok(())
536    }
537
538    /// Check + gen + run a `.tl` script. If the check fails the script is not run and the
539    /// returned `CheckInfo` carries the errors. Runtime errors come back as `Err`.
540    pub fn run_file(&self, file: &Path, args: &[String]) -> Result<CheckInfo> {
541        self.add_path(&parent_dir(file))?;
542        self.install_searcher()?;
543        self.set_arg(&file.to_string_lossy(), args)?;
544        let (code, ci) = self.gen_lua(file)?;
545        let Some(code) = code else { return Ok(ci) };
546        self.exec(&code, &format!("@{}", file.display()), args)?;
547        Ok(ci)
548    }
549
550    /// Compile Lua source to stripped bytecode (Lua 5.4 format of this build).
551    pub fn compile(&self, name: &str, lua_src: &str) -> Result<Vec<u8>> {
552        let f = self
553            .lua
554            .load(lua_src)
555            .set_name(format!("={name}"))
556            .into_function()
557            .with_context(|| format!("compiling generated Lua for {name}"))?;
558        Ok(f.dump(true))
559    }
560
561    /// Install a searcher serving modules from a bundle.
562    pub fn install_bundle(&self, b: &bundle::Bundle) -> Result<()> {
563        let modules = b.modules.clone();
564        let searcher = self.lua.create_function(move |lua, name: String| {
565            match modules.iter().find(|(n, _)| *n == name) {
566                Some((_, bc)) => {
567                    let f = lua
568                        .load(bc.as_slice())
569                        .set_name(format!("={name}"))
570                        .set_mode(ChunkMode::Binary)
571                        .into_function()?;
572                    Ok(Value::Function(f))
573                }
574                None => Ok(Value::String(
575                    lua.create_string(format!("\n\tno bundled module '{name}'"))?,
576                )),
577            }
578        })?;
579        let package: Table = self.lua.globals().get("package")?;
580        let searchers: Table = package.get("searchers")?;
581        searchers.raw_insert(2, searcher)?;
582        Ok(())
583    }
584
585    /// Install the bundle and run its entry module with `...` = args.
586    pub fn run_bundle(&self, b: &bundle::Bundle, args: &[String]) -> Result<()> {
587        let entry_bc = b
588            .modules
589            .iter()
590            .find(|(n, _)| *n == b.entry)
591            .map(|(_, bc)| bc.clone())
592            .ok_or_else(|| anyhow!("entry module '{}' not in bundle", b.entry))?;
593        self.install_bundle(b)?;
594        self.set_arg(&b.entry, args)?;
595        let main: Function = self
596            .lua
597            .load(entry_bc.as_slice())
598            .set_name(format!("={}", b.entry))
599            .set_mode(ChunkMode::Binary)
600            .into_function()?;
601        let va: Variadic<String> = args.iter().cloned().collect();
602        main.call::<()>(va)?;
603        Ok(())
604    }
605}
606
607fn path_str(p: &Path) -> String {
608    p.to_string_lossy().into_owned()
609}
610
611/// A user-facing message for an error that came out of running Lua: the innermost
612/// cause without Lua's `stack traceback:` block. A host function's `Err(e)` surfaces
613/// as `e`'s own text; a Lua `error("msg")` surfaces as `file:line: msg`.
614///
615/// ```text
616/// sgen: content/no-date.md: front matter: 'date' is required
617/// ```
618/// instead of that line followed by `stack traceback: [C]: in method 'pages' ...`.
619pub fn user_message(err: &anyhow::Error) -> String {
620    fn from_mlua(e: &mlua::Error) -> String {
621        match e {
622            mlua::Error::CallbackError { cause, .. } => from_mlua(cause),
623            mlua::Error::ExternalError(ext) => ext.to_string(),
624            mlua::Error::WithContext { cause, .. } => from_mlua(cause),
625            other => strip_traceback(&other.to_string()),
626        }
627    }
628    if let Some(e) = err.downcast_ref::<mlua::Error>() {
629        return from_mlua(e);
630    }
631    strip_traceback(&format!("{err:#}"))
632}
633
634/// Remove a trailing Lua `stack traceback:` section from an error text.
635pub fn strip_traceback(text: &str) -> String {
636    let cut = text.find("\nstack traceback:").unwrap_or(text.len());
637    text[..cut].trim_end().to_string()
638}
639
640/// Write `text` to `path` only if the content differs. Returns `true` when written.
641/// Used by the derive macros to emit `.d.tl` files without churning cargo's fingerprints.
642pub fn write_if_changed(path: &Path, text: &str) -> std::io::Result<bool> {
643    if let Ok(cur) = std::fs::read_to_string(path)
644        && cur == text
645    {
646        return Ok(false);
647    }
648    if let Some(dir) = path.parent() {
649        std::fs::create_dir_all(dir)?;
650    }
651    std::fs::write(path, text)?;
652    Ok(true)
653}
654
655/// Parent directory of a file, `.` when the path has none.
656pub fn parent_dir(file: &Path) -> PathBuf {
657    let dir = file.parent().unwrap_or(Path::new("."));
658    if dir.as_os_str().is_empty() { PathBuf::from(".") } else { dir.to_path_buf() }
659}
660
661fn read_checkinfo(t: &Table) -> Result<CheckInfo> {
662    let seq = |key: &str| -> Result<Vec<String>> {
663        let inner: Table = t.get(key)?;
664        Ok(inner.sequence_values::<String>().collect::<mlua::Result<_>>()?)
665    };
666    let mut requires = Vec::new();
667    if let Ok(list) = t.get::<Table>("requires") {
668        for r in list.sequence_values::<Table>() {
669            let r = r?;
670            requires.push(RequireSite {
671                module: r.get::<String>("name")?,
672                path: r.get::<Option<String>>("path")?.map(PathBuf::from),
673                line: r.get::<Option<usize>>("y")?.unwrap_or(0),
674                col: r.get::<Option<usize>>("x")?.unwrap_or(0),
675            });
676        }
677    }
678    Ok(CheckInfo {
679        errors: seq("errors")?,
680        warnings: seq("warnings")?,
681        deps: seq("deps")?.into_iter().map(PathBuf::from).collect(),
682        lints: seq("lints")?,
683        requires,
684    })
685}
686
687/// `true` for `foo.tl` but not `foo.d.tl`.
688pub fn is_tl_source(p: &Path) -> bool {
689    let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
690    p.is_file() && name.ends_with(".tl") && !name.ends_with(".d.tl")
691}
692
693/// Directories never descended into when collecting sources under a root: build output,
694/// installed packages, VCS and tool state. A root passed explicitly is always walked.
695pub const SKIP_DIRS: &[&str] = &["target", "node_modules", ".mlua-pkgs", ".git"];
696
697/// `true` for a directory entry that source collection should not enter: a name in
698/// [`SKIP_DIRS`], any dot-directory, or the project's mlua-pkg directory (`pkgs_dir`,
699/// which `MLUA_PKG_DIR` can move somewhere unremarkable).
700pub fn is_skipped_dir(path: &Path, extra: &[PathBuf]) -> bool {
701    if !path.is_dir() {
702        return false;
703    }
704    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
705    if SKIP_DIRS.contains(&name) || (name.starts_with('.') && name.len() > 1) {
706        return true;
707    }
708    extra.iter().any(|e| same_dir(path, e))
709}
710
711fn same_dir(a: &Path, b: &Path) -> bool {
712    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
713        (Ok(x), Ok(y)) => x == y,
714        _ => a == b,
715    }
716}
717
718/// Extra directories to skip below `root`: the mlua-pkg package dir when `root` is
719/// inside an `mlua-pkg.toml` project (its vendored / cached sources are dependencies,
720/// not the project's own files).
721#[cfg(feature = "pkg")]
722pub fn project_skip_dirs(root: &Path) -> Vec<PathBuf> {
723    match pkg::Project::find(root) {
724        Some(p) => vec![p.pkgs_dir],
725        None => Vec::new(),
726    }
727}
728
729#[cfg(not(feature = "pkg"))]
730pub fn project_skip_dirs(_root: &Path) -> Vec<PathBuf> {
731    Vec::new()
732}
733
734/// Collect `.tl` sources from files and directories (sorted, recursive). Directories in
735/// [`SKIP_DIRS`], dot-directories and the project's package dir are not entered unless
736/// given as a root themselves.
737pub fn collect_tl(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
738    let mut out = Vec::new();
739    for p in paths {
740        if p.is_dir() {
741            let extra = project_skip_dirs(p);
742            let root = p.clone();
743            let walker = walkdir::WalkDir::new(p)
744                .sort_by_file_name()
745                .into_iter()
746                .filter_entry(move |e| e.path() == root || !is_skipped_dir(e.path(), &extra));
747            for e in walker {
748                let e = e?;
749                if is_tl_source(e.path()) {
750                    out.push(e.path().to_path_buf());
751                }
752            }
753        } else if p.is_file() {
754            out.push(p.clone());
755        } else {
756            bail!("no such file or directory: {}", p.display());
757        }
758    }
759    Ok(out)
760}
761
762/// `root/foo/bar.tl` -> `foo.bar`, `root/foo/init.tl` -> `foo`.
763pub fn module_name(root: &Path, file: &Path) -> Result<String> {
764    let rel = file.strip_prefix(root)?.with_extension("");
765    let mut parts: Vec<String> = rel
766        .components()
767        .map(|c| c.as_os_str().to_string_lossy().into_owned())
768        .collect();
769    if parts.last().map(|s| s == "init").unwrap_or(false) {
770        parts.pop();
771    }
772    if parts.is_empty() {
773        bail!("cannot derive module name for {}", file.display());
774    }
775    Ok(parts.join("."))
776}