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;
19#[cfg(feature = "dts")]
20pub mod dts;
21#[cfg(feature = "pkg")]
22pub mod pkg;
23pub mod teal;
24pub mod testing;
25
26pub(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
35pub const TEAL_VERSION: &str = "0.24.8";
37
38#[derive(Debug, Clone, Default)]
40pub struct CheckInfo {
41 pub errors: Vec<String>,
43 pub warnings: Vec<String>,
45 pub deps: Vec<PathBuf>,
47 pub lints: Vec<String>,
50 pub requires: Vec<RequireSite>,
53}
54
55#[derive(Debug, Clone)]
57pub struct RequireSite {
58 pub module: String,
59 pub path: Option<PathBuf>,
61 pub line: usize,
62 pub col: usize,
63}
64
65pub fn require_cycles(infos: &[(PathBuf, CheckInfo)]) -> Vec<String> {
70 use std::collections::{HashMap, HashSet};
71 let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
72 let mut edges: HashMap<PathBuf, Vec<(PathBuf, &RequireSite)>> = HashMap::new();
73 let mut display: HashMap<PathBuf, PathBuf> = HashMap::new();
74 for (file, ci) in infos {
75 let from = canon(file);
76 display.insert(from.clone(), file.clone());
77 let list = edges.entry(from).or_default();
78 for r in &ci.requires {
79 if let Some(p) = &r.path {
80 list.push((canon(p), r));
81 }
82 }
83 }
84 let nodes: Vec<PathBuf> = {
85 let mut v: Vec<PathBuf> = edges.keys().cloned().collect();
86 v.sort();
87 v
88 };
89 let mut out = Vec::new();
90 let mut reported: HashSet<Vec<PathBuf>> = HashSet::new();
91 let mut state: HashMap<PathBuf, u8> = HashMap::new(); let mut stack: Vec<(PathBuf, Option<&RequireSite>)> = Vec::new();
93
94 fn dfs<'a>(
95 node: PathBuf,
96 edges: &HashMap<PathBuf, Vec<(PathBuf, &'a RequireSite)>>,
97 state: &mut HashMap<PathBuf, u8>,
98 stack: &mut Vec<(PathBuf, Option<&'a RequireSite>)>,
99 reported: &mut HashSet<Vec<PathBuf>>,
100 display: &HashMap<PathBuf, PathBuf>,
101 out: &mut Vec<String>,
102 ) {
103 state.insert(node.clone(), 1);
104 if let Some(list) = edges.get(&node) {
105 for (to, site) in list {
106 match state.get(to).copied() {
107 Some(1) => {
108 let start = stack.iter().position(|(n, _)| n == to).unwrap_or(0);
110 let mut members: Vec<PathBuf> =
111 stack[start..].iter().map(|(n, _)| n.clone()).chain(std::iter::once(node.clone())).collect();
112 members.dedup();
113 let mut key = members.clone();
114 key.sort();
115 if reported.insert(key) {
116 let name = |p: &PathBuf| {
117 display
118 .get(p)
119 .unwrap_or(p)
120 .file_name()
121 .map(|s| s.to_string_lossy().into_owned())
122 .unwrap_or_else(|| p.display().to_string())
123 };
124 let chain: Vec<String> = members.iter().map(name).chain(std::iter::once(name(to))).collect();
125 let first_file = display.get(&members[0]).cloned().unwrap_or_else(|| members[0].clone());
126 let anchor = stack.get(start + 1).and_then(|(_, s)| *s).unwrap_or(site);
128 out.push(format!(
129 "{}:{}:{}: require cycle: {} (Teal types the back edge as an opaque circular require; \
130 break it by moving shared types into a module both sides require) [htl require-cycle]",
131 first_file.display(),
132 anchor.line,
133 anchor.col,
134 chain.join(" -> ")
135 ));
136 }
137 }
138 Some(2) => {}
139 _ => {
140 stack.push((to.clone(), Some(site)));
141 dfs(to.clone(), edges, state, stack, reported, display, out);
142 stack.pop();
143 }
144 }
145 }
146 }
147 state.insert(node, 2);
148 }
149
150 for n in nodes {
151 if !state.contains_key(&n) {
152 stack.push((n.clone(), None));
153 dfs(n, &edges, &mut state, &mut stack, &mut reported, &display, &mut out);
154 stack.pop();
155 }
156 }
157 out.sort();
158 out
159}
160
161impl CheckInfo {
162 pub fn ok(&self) -> bool {
163 self.errors.is_empty()
164 }
165
166 pub fn clean(&self) -> bool {
168 self.errors.is_empty() && self.warnings.is_empty() && self.lints.is_empty()
169 }
170}
171
172pub struct Htl {
174 lua: Lua,
175 h: Table,
176}
177
178impl Htl {
179 pub fn new() -> Result<Self> {
181 let lua = unsafe { Lua::unsafe_new() };
183 Self::from_lua(lua)
184 }
185
186 pub fn from_lua(lua: Lua) -> Result<Self> {
188 let tl_loader: Function = lua
189 .load(TL_SRC)
190 .set_name("=tl.lua")
191 .into_function()
192 .context("compiling vendored tl.lua")?;
193 let lint_loader: Function = lua
194 .load(LINT_SRC)
195 .set_name("=htl-lint")
196 .into_function()
197 .context("compiling htl lint.lua")?;
198 let package: Table = lua.globals().get("package")?;
199 let preload: Table = package.get("preload")?;
200 let fmt_loader: Function = lua
201 .load(FMT_SRC)
202 .set_name("=htl-fmt")
203 .into_function()
204 .context("compiling htl fmt.lua")?;
205 preload.set("tl", tl_loader)?;
206 preload.set("htl.lint", lint_loader)?;
207 preload.set("htl.fmt", fmt_loader)?;
208 let h: Table = lua
209 .load(PRELUDE)
210 .set_name("=htl-prelude")
211 .eval()
212 .context("loading htl prelude")?;
213 lua.set_named_registry_value(PRELUDE_REGISTRY_KEY, h.clone())?;
214 Ok(Self { lua, h })
215 }
216
217 pub fn lua(&self) -> &Lua {
218 &self.lua
219 }
220
221 pub fn check(&self, file: &Path) -> Result<CheckInfo> {
223 let f: Function = self.h.get("check")?;
224 let t: Table = f.call(path_str(file))?;
225 read_checkinfo(&t)
226 }
227
228 pub fn gen_lua(&self, file: &Path) -> Result<(Option<String>, CheckInfo)> {
230 let f: Function = self.h.get("gen")?;
231 let (code, t): (Option<String>, Table) = f.call(path_str(file))?;
232 Ok((code, read_checkinfo(&t)?))
233 }
234
235 pub fn configure_lints(&self, spec: &str) -> Result<()> {
237 let f: Function = self.h.get("set_lints")?;
238 let (ok, err): (Option<bool>, Option<String>) = f.call(spec)?;
239 if ok.unwrap_or(false) {
240 Ok(())
241 } else {
242 bail!("{}", err.unwrap_or_else(|| "invalid lint spec".into()))
243 }
244 }
245
246 pub fn lint_rules(&self) -> Result<Vec<String>> {
248 let f: Function = self.h.get("lint_rules")?;
249 let t: Table = f.call(())?;
250 Ok(t.sequence_values::<String>().collect::<mlua::Result<_>>()?)
251 }
252
253 pub fn format_file(&self, file: &Path, indent: usize) -> Result<String> {
255 let f: Function = self.h.get("format")?;
256 let (out, err): (Option<String>, Option<String>) = f.call((path_str(file), indent))?;
257 out.ok_or_else(|| anyhow!("{}", err.unwrap_or_else(|| "format failed".into())))
258 }
259
260 pub fn reset_search_path(&self) -> Result<()> {
263 let f: Function = self.h.get("reset_path")?;
264 f.call::<()>(())?;
265 Ok(())
266 }
267
268 pub fn add_layout_paths(&self, file: &Path) -> Result<()> {
272 let dir = parent_dir(file);
273 self.add_path(&dir)?;
274 if dir.file_name().is_some_and(|n| n == "tests")
275 && let Some(root) = dir.parent()
276 {
277 self.add_path(root)?;
278 let src = root.join("src");
279 if src.is_dir() {
280 self.add_path(&src)?;
281 }
282 }
283 Ok(())
284 }
285
286 pub fn add_path(&self, dir: &Path) -> Result<()> {
288 let f: Function = self.h.get("add_path")?;
289 f.call::<()>(path_str(dir))?;
290 Ok(())
291 }
292
293 pub fn install_searcher(&self) -> Result<()> {
295 let f: Function = self.h.get("install_searcher")?;
296 f.call::<()>(())?;
297 Ok(())
298 }
299
300 pub fn preload(&self, name: &str, lua_src: &str) -> Result<()> {
302 let loader = self
303 .lua
304 .load(lua_src)
305 .set_name(format!("={name}"))
306 .into_function()
307 .with_context(|| format!("compiling preloaded module {name}"))?;
308 self.preload_table()?.set(name, loader)?;
309 Ok(())
310 }
311
312 pub fn preload_bytes(&self, name: &str, bytecode: &[u8]) -> Result<()> {
314 let loader = self
315 .lua
316 .load(bytecode)
317 .set_name(format!("={name}"))
318 .set_mode(ChunkMode::Binary)
319 .into_function()
320 .with_context(|| format!("loading bytecode for module {name}"))?;
321 self.preload_table()?.set(name, loader)?;
322 Ok(())
323 }
324
325 pub fn exec_bytes(&self, bytecode: &[u8], chunk_name: &str, args: &[String]) -> Result<()> {
327 let f = self
328 .lua
329 .load(bytecode)
330 .set_name(chunk_name)
331 .set_mode(ChunkMode::Binary)
332 .into_function()?;
333 let va: Variadic<String> = args.iter().cloned().collect();
334 f.call::<()>(va)?;
335 Ok(())
336 }
337
338 pub fn preload_value(&self, name: &str, value: impl mlua::IntoLua) -> Result<()> {
340 let value = value.into_lua(&self.lua)?;
341 let loader = self
342 .lua
343 .create_function(move |_, ()| Ok(value.clone()))?;
344 self.preload_table()?.set(name, loader)?;
345 Ok(())
346 }
347
348 fn preload_table(&self) -> Result<Table> {
349 let package: Table = self.lua.globals().get("package")?;
350 Ok(package.get("preload")?)
351 }
352
353 pub fn set_arg(&self, script: &str, args: &[String]) -> Result<()> {
355 let t = self.lua.create_table()?;
356 t.set(0, script)?;
357 for (i, a) in args.iter().enumerate() {
358 t.set(i + 1, a.as_str())?;
359 }
360 self.lua.globals().set("arg", t)?;
361 Ok(())
362 }
363
364 pub fn exec(&self, lua_src: &str, chunk_name: &str, args: &[String]) -> Result<()> {
366 let f = self
367 .lua
368 .load(lua_src)
369 .set_name(chunk_name)
370 .into_function()?;
371 let va: Variadic<String> = args.iter().cloned().collect();
372 f.call::<()>(va)?;
373 Ok(())
374 }
375
376 pub fn run_file(&self, file: &Path, args: &[String]) -> Result<CheckInfo> {
379 self.add_path(&parent_dir(file))?;
380 self.install_searcher()?;
381 self.set_arg(&file.to_string_lossy(), args)?;
382 let (code, ci) = self.gen_lua(file)?;
383 let Some(code) = code else { return Ok(ci) };
384 self.exec(&code, &format!("@{}", file.display()), args)?;
385 Ok(ci)
386 }
387
388 pub fn compile(&self, name: &str, lua_src: &str) -> Result<Vec<u8>> {
390 let f = self
391 .lua
392 .load(lua_src)
393 .set_name(format!("={name}"))
394 .into_function()
395 .with_context(|| format!("compiling generated Lua for {name}"))?;
396 Ok(f.dump(true))
397 }
398
399 pub fn install_bundle(&self, b: &bundle::Bundle) -> Result<()> {
401 let modules = b.modules.clone();
402 let searcher = self.lua.create_function(move |lua, name: String| {
403 match modules.iter().find(|(n, _)| *n == name) {
404 Some((_, bc)) => {
405 let f = lua
406 .load(bc.as_slice())
407 .set_name(format!("={name}"))
408 .set_mode(ChunkMode::Binary)
409 .into_function()?;
410 Ok(Value::Function(f))
411 }
412 None => Ok(Value::String(
413 lua.create_string(format!("\n\tno bundled module '{name}'"))?,
414 )),
415 }
416 })?;
417 let package: Table = self.lua.globals().get("package")?;
418 let searchers: Table = package.get("searchers")?;
419 searchers.raw_insert(2, searcher)?;
420 Ok(())
421 }
422
423 pub fn run_bundle(&self, b: &bundle::Bundle, args: &[String]) -> Result<()> {
425 let entry_bc = b
426 .modules
427 .iter()
428 .find(|(n, _)| *n == b.entry)
429 .map(|(_, bc)| bc.clone())
430 .ok_or_else(|| anyhow!("entry module '{}' not in bundle", b.entry))?;
431 self.install_bundle(b)?;
432 self.set_arg(&b.entry, args)?;
433 let main: Function = self
434 .lua
435 .load(entry_bc.as_slice())
436 .set_name(format!("={}", b.entry))
437 .set_mode(ChunkMode::Binary)
438 .into_function()?;
439 let va: Variadic<String> = args.iter().cloned().collect();
440 main.call::<()>(va)?;
441 Ok(())
442 }
443}
444
445fn path_str(p: &Path) -> String {
446 p.to_string_lossy().into_owned()
447}
448
449pub fn user_message(err: &anyhow::Error) -> String {
458 fn from_mlua(e: &mlua::Error) -> String {
459 match e {
460 mlua::Error::CallbackError { cause, .. } => from_mlua(cause),
461 mlua::Error::ExternalError(ext) => ext.to_string(),
462 mlua::Error::WithContext { cause, .. } => from_mlua(cause),
463 other => strip_traceback(&other.to_string()),
464 }
465 }
466 if let Some(e) = err.downcast_ref::<mlua::Error>() {
467 return from_mlua(e);
468 }
469 strip_traceback(&format!("{err:#}"))
470}
471
472pub fn strip_traceback(text: &str) -> String {
474 let cut = text.find("\nstack traceback:").unwrap_or(text.len());
475 text[..cut].trim_end().to_string()
476}
477
478pub fn write_if_changed(path: &Path, text: &str) -> std::io::Result<bool> {
481 if let Ok(cur) = std::fs::read_to_string(path)
482 && cur == text
483 {
484 return Ok(false);
485 }
486 if let Some(dir) = path.parent() {
487 std::fs::create_dir_all(dir)?;
488 }
489 std::fs::write(path, text)?;
490 Ok(true)
491}
492
493pub fn parent_dir(file: &Path) -> PathBuf {
495 let dir = file.parent().unwrap_or(Path::new("."));
496 if dir.as_os_str().is_empty() { PathBuf::from(".") } else { dir.to_path_buf() }
497}
498
499fn read_checkinfo(t: &Table) -> Result<CheckInfo> {
500 let seq = |key: &str| -> Result<Vec<String>> {
501 let inner: Table = t.get(key)?;
502 Ok(inner.sequence_values::<String>().collect::<mlua::Result<_>>()?)
503 };
504 let mut requires = Vec::new();
505 if let Ok(list) = t.get::<Table>("requires") {
506 for r in list.sequence_values::<Table>() {
507 let r = r?;
508 requires.push(RequireSite {
509 module: r.get::<String>("name")?,
510 path: r.get::<Option<String>>("path")?.map(PathBuf::from),
511 line: r.get::<Option<usize>>("y")?.unwrap_or(0),
512 col: r.get::<Option<usize>>("x")?.unwrap_or(0),
513 });
514 }
515 }
516 Ok(CheckInfo {
517 errors: seq("errors")?,
518 warnings: seq("warnings")?,
519 deps: seq("deps")?.into_iter().map(PathBuf::from).collect(),
520 lints: seq("lints")?,
521 requires,
522 })
523}
524
525pub fn is_tl_source(p: &Path) -> bool {
527 let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
528 p.is_file() && name.ends_with(".tl") && !name.ends_with(".d.tl")
529}
530
531pub const SKIP_DIRS: &[&str] = &["target", "node_modules", ".mlua-pkgs", ".git"];
534
535pub fn is_skipped_dir(path: &Path, extra: &[PathBuf]) -> bool {
539 if !path.is_dir() {
540 return false;
541 }
542 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
543 if SKIP_DIRS.contains(&name) || (name.starts_with('.') && name.len() > 1) {
544 return true;
545 }
546 extra.iter().any(|e| same_dir(path, e))
547}
548
549fn same_dir(a: &Path, b: &Path) -> bool {
550 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
551 (Ok(x), Ok(y)) => x == y,
552 _ => a == b,
553 }
554}
555
556#[cfg(feature = "pkg")]
560pub fn project_skip_dirs(root: &Path) -> Vec<PathBuf> {
561 match pkg::Project::find(root) {
562 Some(p) => vec![p.pkgs_dir],
563 None => Vec::new(),
564 }
565}
566
567#[cfg(not(feature = "pkg"))]
568pub fn project_skip_dirs(_root: &Path) -> Vec<PathBuf> {
569 Vec::new()
570}
571
572pub fn collect_tl(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
576 let mut out = Vec::new();
577 for p in paths {
578 if p.is_dir() {
579 let extra = project_skip_dirs(p);
580 let root = p.clone();
581 let walker = walkdir::WalkDir::new(p)
582 .sort_by_file_name()
583 .into_iter()
584 .filter_entry(move |e| e.path() == root || !is_skipped_dir(e.path(), &extra));
585 for e in walker {
586 let e = e?;
587 if is_tl_source(e.path()) {
588 out.push(e.path().to_path_buf());
589 }
590 }
591 } else if p.is_file() {
592 out.push(p.clone());
593 } else {
594 bail!("no such file or directory: {}", p.display());
595 }
596 }
597 Ok(out)
598}
599
600pub fn module_name(root: &Path, file: &Path) -> Result<String> {
602 let rel = file.strip_prefix(root)?.with_extension("");
603 let mut parts: Vec<String> = rel
604 .components()
605 .map(|c| c.as_os_str().to_string_lossy().into_owned())
606 .collect();
607 if parts.last().map(|s| s == "init").unwrap_or(false) {
608 parts.pop();
609 }
610 if parts.is_empty() {
611 bail!("cannot derive module name for {}", file.display());
612 }
613 Ok(parts.join("."))
614}