1pub 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
27pub(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
36pub const TEAL_VERSION: &str = "0.24.8";
38
39#[derive(Debug, Clone, Default)]
41pub struct CheckInfo {
42 pub errors: Vec<String>,
44 pub warnings: Vec<String>,
46 pub deps: Vec<PathBuf>,
48 pub lints: Vec<String>,
51 pub requires: Vec<RequireSite>,
54}
55
56#[derive(Debug, Clone)]
58pub struct RequireSite {
59 pub module: String,
60 pub path: Option<PathBuf>,
62 pub line: usize,
63 pub col: usize,
64}
65
66#[derive(Debug, Clone, Default)]
68pub struct ContractResult {
69 pub errors: Vec<String>,
71 pub missing: Option<Vec<String>>,
74 pub missing_at: (usize, usize),
75}
76
77impl Htl {
78 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 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
106pub 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 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 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
156pub 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 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 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() } 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
227pub 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(); 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 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 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 pub fn clean(&self) -> bool {
330 self.errors.is_empty() && self.warnings.is_empty() && self.lints.is_empty()
331 }
332}
333
334pub struct Htl {
336 lua: Lua,
337 h: Table,
338}
339
340impl Htl {
341 pub fn new() -> Result<Self> {
343 let lua = unsafe { Lua::unsafe_new() };
345 Self::from_lua(lua)
346 }
347
348 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 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 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 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 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 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 pub fn reset_search_path(&self) -> Result<()> {
425 let f: Function = self.h.get("reset_path")?;
426 f.call::<()>(())?;
427 Ok(())
428 }
429
430 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 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 pub fn install_searcher(&self) -> Result<()> {
457 let f: Function = self.h.get("install_searcher")?;
458 f.call::<()>(())?;
459 Ok(())
460 }
461
462 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 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 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 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 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 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 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 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 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 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
611pub 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
634pub 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
640pub 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
655pub 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
687pub 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
693pub const SKIP_DIRS: &[&str] = &["target", "node_modules", ".mlua-pkgs", ".git"];
696
697pub 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#[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
734pub 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
762pub 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}