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;
19#[cfg(feature = "dts")]
20pub mod dts;
21#[cfg(feature = "pkg")]
22pub mod pkg;
23pub mod teal;
24pub mod testing;
25
26/// Registry key under which the prelude table is stored (lets `pkg::TealResolver`
27/// reach the compiler from a bare `&Lua`).
28pub(crate) const PRELUDE_REGISTRY_KEY: &str = "htl.prelude";
29
30const TL_SRC: &str = include_str!("../vendor/tl.lua");
31const LINT_SRC: &str = include_str!("lint.lua");
32const FMT_SRC: &str = include_str!("fmt.lua");
33const PRELUDE: &str = include_str!("prelude.lua");
34
35/// Teal version vendored into this crate.
36pub const TEAL_VERSION: &str = "0.24.8";
37
38/// Result of type-checking one `.tl` file.
39#[derive(Debug, Clone, Default)]
40pub struct CheckInfo {
41    /// `file:line:col: message` for syntax and type errors.
42    pub errors: Vec<String>,
43    /// `file:line:col: message` for warnings (non-fatal).
44    pub warnings: Vec<String>,
45    /// Files pulled in via `require` during checking (`.tl` / `.d.tl` / `.lua`).
46    pub deps: Vec<PathBuf>,
47    /// htl lint findings (`nil-index`, `enum-exhaustive`). Advisory unless the caller
48    /// promotes them (`htl check --strict`, `include_tl!`).
49    pub lints: Vec<String>,
50}
51
52impl CheckInfo {
53    pub fn ok(&self) -> bool {
54        self.errors.is_empty()
55    }
56
57    /// `true` when there are no errors, warnings or lints.
58    pub fn clean(&self) -> bool {
59        self.errors.is_empty() && self.warnings.is_empty() && self.lints.is_empty()
60    }
61}
62
63/// An mlua state with the Teal compiler loaded.
64pub struct Htl {
65    lua: Lua,
66    h: Table,
67}
68
69impl Htl {
70    /// New state. Uses `Lua::unsafe_new` so stripped bytecode bundles can be loaded.
71    pub fn new() -> Result<Self> {
72        // SAFETY: we accept binary chunks only from bundles we produced ourselves.
73        let lua = unsafe { Lua::unsafe_new() };
74        Self::from_lua(lua)
75    }
76
77    /// Attach the Teal compiler to an existing Lua state (the host's own `Lua`).
78    pub fn from_lua(lua: Lua) -> Result<Self> {
79        let tl_loader: Function = lua
80            .load(TL_SRC)
81            .set_name("=tl.lua")
82            .into_function()
83            .context("compiling vendored tl.lua")?;
84        let lint_loader: Function = lua
85            .load(LINT_SRC)
86            .set_name("=htl-lint")
87            .into_function()
88            .context("compiling htl lint.lua")?;
89        let package: Table = lua.globals().get("package")?;
90        let preload: Table = package.get("preload")?;
91        let fmt_loader: Function = lua
92            .load(FMT_SRC)
93            .set_name("=htl-fmt")
94            .into_function()
95            .context("compiling htl fmt.lua")?;
96        preload.set("tl", tl_loader)?;
97        preload.set("htl.lint", lint_loader)?;
98        preload.set("htl.fmt", fmt_loader)?;
99        let h: Table = lua
100            .load(PRELUDE)
101            .set_name("=htl-prelude")
102            .eval()
103            .context("loading htl prelude")?;
104        lua.set_named_registry_value(PRELUDE_REGISTRY_KEY, h.clone())?;
105        Ok(Self { lua, h })
106    }
107
108    pub fn lua(&self) -> &Lua {
109        &self.lua
110    }
111
112    /// Type-check one file.
113    pub fn check(&self, file: &Path) -> Result<CheckInfo> {
114        let f: Function = self.h.get("check")?;
115        let t: Table = f.call(path_str(file))?;
116        read_checkinfo(&t)
117    }
118
119    /// Type-check and generate Lua source. `None` code means errors (see `CheckInfo`).
120    pub fn gen_lua(&self, file: &Path) -> Result<(Option<String>, CheckInfo)> {
121        let f: Function = self.h.get("gen")?;
122        let (code, t): (Option<String>, Table) = f.call(path_str(file))?;
123        Ok((code, read_checkinfo(&t)?))
124    }
125
126    /// Configure lint rules: `"+no-any,-shadow-local"` on top of the defaults.
127    pub fn configure_lints(&self, spec: &str) -> Result<()> {
128        let f: Function = self.h.get("set_lints")?;
129        let (ok, err): (Option<bool>, Option<String>) = f.call(spec)?;
130        if ok.unwrap_or(false) {
131            Ok(())
132        } else {
133            bail!("{}", err.unwrap_or_else(|| "invalid lint spec".into()))
134        }
135    }
136
137    /// Names of all lint rules (enabled or not).
138    pub fn lint_rules(&self) -> Result<Vec<String>> {
139        let f: Function = self.h.get("lint_rules")?;
140        let t: Table = f.call(())?;
141        Ok(t.sequence_values::<String>().collect::<mlua::Result<_>>()?)
142    }
143
144    /// Format a `.tl` file (whitespace-only formatter). Returns the formatted text.
145    pub fn format_file(&self, file: &Path, indent: usize) -> Result<String> {
146        let f: Function = self.h.get("format")?;
147        let (out, err): (Option<String>, Option<String>) = f.call((path_str(file), indent))?;
148        out.ok_or_else(|| anyhow!("{}", err.unwrap_or_else(|| "format failed".into())))
149    }
150
151    /// Prepend `dir/?.tl;dir/?/init.tl` to `package.path` (Teal resolves requires through it).
152    pub fn add_path(&self, dir: &Path) -> Result<()> {
153        let f: Function = self.h.get("add_path")?;
154        f.call::<()>(path_str(dir))?;
155        Ok(())
156    }
157
158    /// Install the strict `.tl` searcher: `require` of a `.tl` with type errors fails.
159    pub fn install_searcher(&self) -> Result<()> {
160        let f: Function = self.h.get("install_searcher")?;
161        f.call::<()>(())?;
162        Ok(())
163    }
164
165    /// Register generated Lua source under a module name (`package.preload`).
166    pub fn preload(&self, name: &str, lua_src: &str) -> Result<()> {
167        let loader = self
168            .lua
169            .load(lua_src)
170            .set_name(format!("={name}"))
171            .into_function()
172            .with_context(|| format!("compiling preloaded module {name}"))?;
173        self.preload_table()?.set(name, loader)?;
174        Ok(())
175    }
176
177    /// Register stripped bytecode (e.g. from `include_tl_bytes!`) under a module name.
178    pub fn preload_bytes(&self, name: &str, bytecode: &[u8]) -> Result<()> {
179        let loader = self
180            .lua
181            .load(bytecode)
182            .set_name(format!("={name}"))
183            .set_mode(ChunkMode::Binary)
184            .into_function()
185            .with_context(|| format!("loading bytecode for module {name}"))?;
186        self.preload_table()?.set(name, loader)?;
187        Ok(())
188    }
189
190    /// Execute stripped bytecode with `...` = args.
191    pub fn exec_bytes(&self, bytecode: &[u8], chunk_name: &str, args: &[String]) -> Result<()> {
192        let f = self
193            .lua
194            .load(bytecode)
195            .set_name(chunk_name)
196            .set_mode(ChunkMode::Binary)
197            .into_function()?;
198        let va: Variadic<String> = args.iter().cloned().collect();
199        f.call::<()>(va)?;
200        Ok(())
201    }
202
203    /// Register a ready-made value (typically a Rust-built table) as a module.
204    pub fn preload_value(&self, name: &str, value: impl mlua::IntoLua) -> Result<()> {
205        let value = value.into_lua(&self.lua)?;
206        let loader = self
207            .lua
208            .create_function(move |_, ()| Ok(value.clone()))?;
209        self.preload_table()?.set(name, loader)?;
210        Ok(())
211    }
212
213    fn preload_table(&self) -> Result<Table> {
214        let package: Table = self.lua.globals().get("package")?;
215        Ok(package.get("preload")?)
216    }
217
218    /// Set the global `arg` table like the `lua` CLI does.
219    pub fn set_arg(&self, script: &str, args: &[String]) -> Result<()> {
220        let t = self.lua.create_table()?;
221        t.set(0, script)?;
222        for (i, a) in args.iter().enumerate() {
223            t.set(i + 1, a.as_str())?;
224        }
225        self.lua.globals().set("arg", t)?;
226        Ok(())
227    }
228
229    /// Execute Lua source with `...` = args.
230    pub fn exec(&self, lua_src: &str, chunk_name: &str, args: &[String]) -> Result<()> {
231        let f = self
232            .lua
233            .load(lua_src)
234            .set_name(chunk_name)
235            .into_function()?;
236        let va: Variadic<String> = args.iter().cloned().collect();
237        f.call::<()>(va)?;
238        Ok(())
239    }
240
241    /// Check + gen + run a `.tl` script. If the check fails the script is not run and the
242    /// returned `CheckInfo` carries the errors. Runtime errors come back as `Err`.
243    pub fn run_file(&self, file: &Path, args: &[String]) -> Result<CheckInfo> {
244        self.add_path(&parent_dir(file))?;
245        self.install_searcher()?;
246        self.set_arg(&file.to_string_lossy(), args)?;
247        let (code, ci) = self.gen_lua(file)?;
248        let Some(code) = code else { return Ok(ci) };
249        self.exec(&code, &format!("@{}", file.display()), args)?;
250        Ok(ci)
251    }
252
253    /// Compile Lua source to stripped bytecode (Lua 5.4 format of this build).
254    pub fn compile(&self, name: &str, lua_src: &str) -> Result<Vec<u8>> {
255        let f = self
256            .lua
257            .load(lua_src)
258            .set_name(format!("={name}"))
259            .into_function()
260            .with_context(|| format!("compiling generated Lua for {name}"))?;
261        Ok(f.dump(true))
262    }
263
264    /// Install a searcher serving modules from a bundle.
265    pub fn install_bundle(&self, b: &bundle::Bundle) -> Result<()> {
266        let modules = b.modules.clone();
267        let searcher = self.lua.create_function(move |lua, name: String| {
268            match modules.iter().find(|(n, _)| *n == name) {
269                Some((_, bc)) => {
270                    let f = lua
271                        .load(bc.as_slice())
272                        .set_name(format!("={name}"))
273                        .set_mode(ChunkMode::Binary)
274                        .into_function()?;
275                    Ok(Value::Function(f))
276                }
277                None => Ok(Value::String(
278                    lua.create_string(format!("\n\tno bundled module '{name}'"))?,
279                )),
280            }
281        })?;
282        let package: Table = self.lua.globals().get("package")?;
283        let searchers: Table = package.get("searchers")?;
284        searchers.raw_insert(2, searcher)?;
285        Ok(())
286    }
287
288    /// Install the bundle and run its entry module with `...` = args.
289    pub fn run_bundle(&self, b: &bundle::Bundle, args: &[String]) -> Result<()> {
290        let entry_bc = b
291            .modules
292            .iter()
293            .find(|(n, _)| *n == b.entry)
294            .map(|(_, bc)| bc.clone())
295            .ok_or_else(|| anyhow!("entry module '{}' not in bundle", b.entry))?;
296        self.install_bundle(b)?;
297        self.set_arg(&b.entry, args)?;
298        let main: Function = self
299            .lua
300            .load(entry_bc.as_slice())
301            .set_name(format!("={}", b.entry))
302            .set_mode(ChunkMode::Binary)
303            .into_function()?;
304        let va: Variadic<String> = args.iter().cloned().collect();
305        main.call::<()>(va)?;
306        Ok(())
307    }
308}
309
310fn path_str(p: &Path) -> String {
311    p.to_string_lossy().into_owned()
312}
313
314/// Write `text` to `path` only if the content differs. Returns `true` when written.
315/// Used by the derive macros to emit `.d.tl` files without churning cargo's fingerprints.
316pub fn write_if_changed(path: &Path, text: &str) -> std::io::Result<bool> {
317    if let Ok(cur) = std::fs::read_to_string(path)
318        && cur == text
319    {
320        return Ok(false);
321    }
322    if let Some(dir) = path.parent() {
323        std::fs::create_dir_all(dir)?;
324    }
325    std::fs::write(path, text)?;
326    Ok(true)
327}
328
329/// Parent directory of a file, `.` when the path has none.
330pub fn parent_dir(file: &Path) -> PathBuf {
331    let dir = file.parent().unwrap_or(Path::new("."));
332    if dir.as_os_str().is_empty() { PathBuf::from(".") } else { dir.to_path_buf() }
333}
334
335fn read_checkinfo(t: &Table) -> Result<CheckInfo> {
336    let seq = |key: &str| -> Result<Vec<String>> {
337        let inner: Table = t.get(key)?;
338        Ok(inner.sequence_values::<String>().collect::<mlua::Result<_>>()?)
339    };
340    Ok(CheckInfo {
341        errors: seq("errors")?,
342        warnings: seq("warnings")?,
343        deps: seq("deps")?.into_iter().map(PathBuf::from).collect(),
344        lints: seq("lints")?,
345    })
346}
347
348/// `true` for `foo.tl` but not `foo.d.tl`.
349pub fn is_tl_source(p: &Path) -> bool {
350    let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
351    p.is_file() && name.ends_with(".tl") && !name.ends_with(".d.tl")
352}
353
354/// Collect `.tl` sources from files and directories (sorted, recursive).
355pub fn collect_tl(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
356    let mut out = Vec::new();
357    for p in paths {
358        if p.is_dir() {
359            for e in walkdir::WalkDir::new(p).sort_by_file_name() {
360                let e = e?;
361                if is_tl_source(e.path()) {
362                    out.push(e.path().to_path_buf());
363                }
364            }
365        } else if p.is_file() {
366            out.push(p.clone());
367        } else {
368            bail!("no such file or directory: {}", p.display());
369        }
370    }
371    Ok(out)
372}
373
374/// `root/foo/bar.tl` -> `foo.bar`, `root/foo/init.tl` -> `foo`.
375pub fn module_name(root: &Path, file: &Path) -> Result<String> {
376    let rel = file.strip_prefix(root)?.with_extension("");
377    let mut parts: Vec<String> = rel
378        .components()
379        .map(|c| c.as_os_str().to_string_lossy().into_owned())
380        .collect();
381    if parts.last().map(|s| s == "init").unwrap_or(false) {
382        parts.pop();
383    }
384    if parts.is_empty() {
385        bail!("cannot derive module name for {}", file.display());
386    }
387    Ok(parts.join("."))
388}