htl_core/link.rs
1//! Linking: the `require` closure of one entry file, as a [`Bundle`].
2//!
3//! Starting at the entry, every `require("<literal>")` is followed (only string
4//! literals: a `require(expr)` cannot be resolved statically, list its targets under
5//! `extra`). `.tl` modules are type-checked and generated; plain `.lua` modules (a
6//! vendored dependency, say) are taken as they are. A name that resolves only to a
7//! `.d.tl` declaration is recorded as host-provided, as is anything listed in `host`.
8//! Any other unresolved `require` is an error: the point of a bundle is that "module
9//! not found" happens here, not on the first `require` at the customer's machine.
10//!
11//! # The store
12//!
13//! Generating a module is the expensive part of linking — the Teal check behind it costs
14//! about a second per few thousand lines, against milliseconds for Lua's own compiler to
15//! turn the result into bytecode — and it is the part the run cache already answers for.
16//! [`link_with`] takes a [`LinkStore`]: for every typed module it asks the store for the
17//! module's `gen` entry (the one `htl test` writes and replays), and an entry whose inputs
18//! and probes still hold stands in for the check, its generated Lua for `gen_lua`. A miss
19//! generates as before and writes the entry, so `htl build`, `htl test` and the
20//! `include_bundle!` / `include_tl!` macros feed one another (#100).
21//!
22//! Bytecode is never stored: compiling it is cheap, and an entry that carried it would
23//! have to be keyed on the strip and debug flags and on the Lua the bytecode is for, for
24//! no saving. What a bundle contains — module order, fingerprint, host modules — is the
25//! same whether a module was generated or replayed.
26
27use crate::bundle::{Bundle, Kind, Module};
28use crate::cache::{self, Cache};
29use crate::{CheckInfo, Htl, RequireSite};
30use anyhow::{Context, Result};
31use std::collections::{BTreeSet, HashSet, VecDeque};
32use std::path::{Path, PathBuf};
33
34/// What a link is asked for beyond the entry file: how modules are stored, and which ones
35/// the walk would otherwise miss or must not take.
36///
37/// Owned and `Default`, and it crosses the proc-macro boundary as a value — the borrowed
38/// half of a link's inputs is [`LinkStore`].
39#[derive(Debug, Clone, Default)]
40pub struct LinkOptions {
41 /// Keep debug info (line numbers, local names) in bytecode. Off = stripped.
42 pub debug: bool,
43 /// Store generated Lua source instead of bytecode (portable across Lua builds).
44 pub source: bool,
45 /// Modules to include even if no literal `require` reaches them.
46 pub extra: Vec<String>,
47 /// Modules the host provides at run time, besides those declared only by a `.d.tl`.
48 /// A name here is left out of the bundle and not walked, whether or not a file on
49 /// the search path could answer it — a library that bundles its own module names it
50 /// here in the binary's bundle so the two do not carry it twice.
51 pub host: Vec<String>,
52}
53
54/// The run cache as the linker uses it: the store, plus what the store needs to key and
55/// to validate an entry and that only the caller knows.
56///
57/// A borrowed view rather than part of [`LinkOptions`], which is owned and `Default` and
58/// crosses the proc-macro boundary as a value; the store lives for one command or one
59/// macro expansion, the options may not.
60#[derive(Clone, Copy)]
61pub struct LinkStore<'a> {
62 /// The store itself: where a typed module's generated Lua and its check are looked up
63 /// before the checker is asked, and written back after.
64 pub cache: &'a Cache,
65 /// The lint selection in force, as [`cache::gen_key`] takes it: an entry generated
66 /// under different lints reports different lints, and must not be reused.
67 pub lint: Option<&'a str>,
68 /// The project root, for the directories a `require` could resolve in.
69 pub root: &'a Path,
70 /// The project's `htl.toml` (its path) and what it says: its `[check] paths` are
71 /// probed, and the file itself is an input of every entry, since its lint selection is.
72 pub config: Option<(&'a Path, &'a crate::config::HtlConfig)>,
73}
74
75impl LinkStore<'_> {
76 /// Files an entry depends on besides the module and what it required.
77 fn extra_inputs(&self) -> Vec<PathBuf> {
78 self.config
79 .map(|(file, _)| vec![file.to_path_buf()])
80 .unwrap_or_default()
81 }
82
83 /// Directories a `require` from `file` could resolve in, for the entry's probes.
84 fn probe_dirs(&self, file: &Path) -> Vec<PathBuf> {
85 let cfg = self.config.map(|(file, c)| (crate::parent_dir(file), c));
86 cache::search_dirs(
87 file,
88 self.root,
89 cfg.as_ref().map(|(dir, c)| (dir.as_path(), *c)),
90 )
91 }
92}
93
94/// One linked module: where it came from and how it was stored.
95#[derive(Debug, Clone)]
96pub struct LinkedModule {
97 /// The module name a `require` reaches it by, which is the name it takes in the
98 /// bundle — not its path.
99 pub name: String,
100 /// The file it was read from, for a report that wants to name something openable.
101 pub path: PathBuf,
102 /// `true` for a `.tl` that was checked and generated, `false` for a `.lua` taken as
103 /// it was. Only the typed ones have a store entry, so this is what
104 /// [`Linked::cached`] counts against.
105 pub typed: bool,
106}
107
108/// The result of a link: the bundle, and everything a reporter wants to say about how it
109/// was arrived at.
110///
111/// The bundle is private because an incomplete one must not escape — see
112/// [`errors`](Self::errors) and [`bundle`](Self::bundle).
113#[derive(Debug, Default)]
114pub struct Linked {
115 bundle: Bundle,
116 /// Every module the walk took, in the order it took them.
117 pub modules: Vec<LinkedModule>,
118 /// Names the host is expected to provide at run time, so the bundle records them as
119 /// its own requirements rather than carrying code for them.
120 pub host_modules: Vec<String>,
121 /// Type errors and unresolved requires. A module with a type error is *absent* from
122 /// the bundle, so the bundle is only handed out ([`bundle`](Self::bundle)) when this
123 /// is empty: a program missing a module dies at its first `require`, far from here.
124 pub errors: Vec<String>,
125 /// Lints from every module, which do not stop a bundle: whether they stop the *run*
126 /// is the caller's, and the caller is what knows about `strict`.
127 pub lints: Vec<String>,
128 /// The full check of each module, for a reader that wants more than the flattened
129 /// [`errors`](Self::errors) and [`lints`](Self::lints) — the requires, the
130 /// dependencies, the per-file verdict.
131 pub checks: Vec<(PathBuf, CheckInfo)>,
132 /// How many typed modules came from the store rather than the checker. Zero without
133 /// a store. The total to say it against is the typed count of [`modules`](Self::modules).
134 pub cached: usize,
135}
136
137impl Linked {
138 /// `true` when every module linked cleanly (lints are not errors here).
139 pub fn ok(&self) -> bool {
140 self.errors.is_empty()
141 }
142
143 /// The bundle, or every error that makes it incomplete.
144 pub fn bundle(&self) -> Result<&Bundle> {
145 if self.errors.is_empty() {
146 Ok(&self.bundle)
147 } else {
148 Err(self.error())
149 }
150 }
151
152 /// The bundle by value, on the same condition as [`bundle`](Self::bundle): for a
153 /// caller that writes it out and is done with the report around it.
154 pub fn into_bundle(self) -> Result<Bundle> {
155 if self.errors.is_empty() {
156 Ok(self.bundle)
157 } else {
158 Err(self.error())
159 }
160 }
161
162 fn error(&self) -> anyhow::Error {
163 anyhow::anyhow!(
164 "link failed with {} error(s):\n {}",
165 self.errors.len(),
166 self.errors.join("\n ")
167 )
168 }
169
170 /// Every file the bundle was built from (entry, modules, and what the checker read
171 /// for them, e.g. `.d.tl`s): what a build script or macro should watch for changes.
172 pub fn inputs(&self) -> Vec<PathBuf> {
173 let mut out: Vec<PathBuf> = self.modules.iter().map(|m| m.path.clone()).collect();
174 for (_, ci) in &self.checks {
175 out.extend(ci.deps.iter().cloned());
176 }
177 out.sort();
178 out.dedup();
179 out
180 }
181}
182
183/// Link `entry` (a `.tl` file) and everything it requires. The checker's search path
184/// must already cover the project (`add_path` / `apply_project` / `apply_config`).
185pub fn link(h: &Htl, entry: &Path, opts: &LinkOptions) -> Result<Linked> {
186 link_with(h, entry, opts, None)
187}
188
189/// [`link`], replaying from the run cache what it can (see the module doc).
190///
191/// With `store` = `None` this is `link`. With a store, a typed module whose `gen` entry
192/// still holds is taken from it — its generated Lua, and what checking it reported — and
193/// counted in [`Linked::cached`]; every other typed module is generated and its entry
194/// written. Nothing about the store can fail the link: an unreadable or stale entry is a
195/// generate, an unwritable store is a generate next time too.
196pub fn link_with(
197 h: &Htl,
198 entry: &Path,
199 opts: &LinkOptions,
200 store: Option<LinkStore<'_>>,
201) -> Result<Linked> {
202 let mut out = Linked::default();
203 let entry_name = entry_module_name(entry);
204 let host_declared: HashSet<String> = opts.host.iter().cloned().collect();
205 let mut host: BTreeSet<String> = BTreeSet::new();
206 let mut queued: HashSet<String> = HashSet::new();
207 let mut queue: VecDeque<(String, PathBuf)> = VecDeque::new();
208 queue.push_back((entry_name.clone(), entry.to_path_buf()));
209 queued.insert(entry_name.clone());
210 for name in &opts.extra {
211 match classify(h, name, None)? {
212 Target::File(p) => {
213 if queued.insert(name.clone()) {
214 queue.push_back((name.clone(), p));
215 }
216 }
217 Target::Host => {
218 host.insert(name.clone());
219 }
220 Target::Missing => out.errors.push(format!(
221 "extra module '{name}' not found on the search path"
222 )),
223 }
224 }
225
226 while let Some((name, path)) = queue.pop_front() {
227 let typed = path.extension().is_none_or(|e| e != "lua");
228 let (code, requires) = if typed {
229 let Generated {
230 code,
231 check: ci,
232 cached,
233 } = generate(h, &path, store)?;
234 if cached {
235 out.cached += 1;
236 }
237 out.errors.extend(ci.errors.iter().cloned());
238 out.lints.extend(ci.lints.iter().cloned());
239 let reqs = ci.requires.clone();
240 out.checks.push((path.clone(), ci));
241 (code, reqs)
242 } else {
243 let src = std::fs::read_to_string(&path)
244 .with_context(|| format!("reading {}", path.display()))?;
245 let reqs = h.lua_requires(&src, &path)?;
246 (Some(src), reqs)
247 };
248 for r in &requires {
249 if queued.contains(&r.module) || host.contains(&r.module) {
250 continue;
251 }
252 // A name the caller said the host provides is the host's before the search
253 // path is asked: a file that could answer it is not bundled and not walked.
254 if host_declared.contains(&r.module) {
255 host.insert(r.module.clone());
256 continue;
257 }
258 match classify(h, &r.module, r.path.as_deref())? {
259 Target::File(p) => {
260 queued.insert(r.module.clone());
261 queue.push_back((r.module.clone(), p));
262 }
263 Target::Host => {
264 host.insert(r.module.clone());
265 }
266 Target::Missing => out.errors.push(unresolved(&path, r)),
267 }
268 }
269 let Some(code) = code else { continue };
270 let payload = if opts.source {
271 Module {
272 name: name.clone(),
273 kind: Kind::Source,
274 payload: code.into_bytes(),
275 }
276 } else {
277 let bc = h.compile_with(&name, &code, !opts.debug)?;
278 Module {
279 name: name.clone(),
280 kind: Kind::Bytecode,
281 payload: bc,
282 }
283 };
284 out.bundle.modules.push(payload);
285 out.modules.push(LinkedModule { name, path, typed });
286 }
287
288 out.host_modules = host.iter().cloned().collect();
289 out.bundle.entry = entry_name;
290 out.bundle.htl_version = env!("CARGO_PKG_VERSION").to_string();
291 out.bundle.host_modules = out.host_modules.clone();
292 if !opts.source {
293 out.bundle.fingerprint = h.fingerprint()?;
294 }
295 Ok(out)
296}
297
298/// One typed module, generated or replayed: see [`generate`].
299#[derive(Debug)]
300pub struct Generated {
301 /// The Lua; `None` when checking produced errors (see [`CheckInfo`]).
302 pub code: Option<String>,
303 /// What checking said — carried whether or not there is code, and the half a replay
304 /// needs as much as the Lua: the lints, the requires and the dependencies come out of
305 /// here.
306 pub check: CheckInfo,
307 /// Whether it came from the store rather than the checker.
308 pub cached: bool,
309}
310
311/// One typed module's generated Lua and what checking it said: from the store when its
312/// `gen` entry still holds, else from the checker, and then into the store. What
313/// [`link_with`] does per module, and what `include_tl!` does for its one file.
314///
315/// A hit needs both halves of the entry — the Lua, and the structured check it came with —
316/// since the reader wants the lints, the requires and the dependencies out of the second.
317/// `htl test` writes both; an entry missing either is a miss rather than a partial replay.
318pub fn generate(h: &Htl, path: &Path, store: Option<LinkStore<'_>>) -> Result<Generated> {
319 let key = store.map(|s| cache::module_gen_key(path, s.lint));
320 if let (Some(s), Some(k)) = (store, &key)
321 && let Some(m) = s.cache.lookup(k)
322 && let (Some(code), Some(check)) = (&m.code, &m.check)
323 {
324 return Ok(Generated {
325 code: Some(code.clone()),
326 check: check.to_check(),
327 cached: true,
328 });
329 }
330 let (code, ci) = h.gen_lua(path)?;
331 // Only when there is code: a module that failed to check has nothing to bundle, and
332 // storing that would replay the failure as if it were a result.
333 if let (Some(s), Some(k), Some(code)) = (store, &key, &code) {
334 // `gen_lua` can come back without requires for a module the checker already holds
335 // — it serves the generated code without walking the file again — and the requires
336 // are what the next run's validation and the linker's own walk are built from. Ask
337 // the checker separately, but only when the file could have any: a leaf that never
338 // says `require` is most of a project, and a check per leaf is the wrong price.
339 let stored = if ci.requires.is_empty() && mentions_require(path) {
340 match h.check(path) {
341 Ok(c) if !c.requires.is_empty() => CheckInfo {
342 requires: c.requires,
343 ..ci.clone()
344 },
345 _ => ci.clone(),
346 }
347 } else {
348 ci.clone()
349 };
350 let m = cache::Module::generated(&stored, code.clone());
351 s.cache
352 .store_module(k, path, &s.extra_inputs(), &s.probe_dirs(path), &m);
353 }
354 Ok(Generated {
355 code,
356 check: ci,
357 cached: false,
358 })
359}
360
361/// Whether `path`'s source says `require` anywhere ([`cache::source_mentions_require`]);
362/// an unreadable file is taken to, which costs a check rather than a wrong entry.
363pub fn mentions_require(path: &Path) -> bool {
364 std::fs::read_to_string(path)
365 .map(|s| cache::source_mentions_require(&s))
366 .unwrap_or(true)
367}
368
369fn is_decl(p: &Path) -> bool {
370 p.to_string_lossy().ends_with(".d.tl")
371}
372
373fn unresolved(from: &Path, r: &RequireSite) -> String {
374 format!(
375 "{}:{}:{}: require(\"{}\") is not on the search path: nothing to bundle. If the host \
376 provides it, declare it in a `{}.d.tl` or list it under `[build] host` in htl.toml; \
377 if it is reached only through a dynamic require, list it under `[build] extra`",
378 from.display(),
379 r.line,
380 r.col,
381 r.module,
382 r.module.replace('.', "/")
383 )
384}
385
386enum Target {
387 /// A file to bundle (`.tl` typed, or a plain `.lua`).
388 File(PathBuf),
389 /// Declared only (`.d.tl` with no `.lua` behind it): the host provides it.
390 Host,
391 Missing,
392}
393
394/// What a `require(name)` points at for the linker. `found` is the checker's own
395/// resolution when already known (a require site); otherwise it is looked up.
396/// The module name an entry file answers to: its stem, except that `<dir>/init.tl` is
397/// the module `<dir>` — the name a `require` of it is written as, and so the name a
398/// bundle has to serve it under once a host has installed the bundle and a program asks
399/// for it. `htl build src/main.tl` is `main` as before; `include_bundle!("src/pkg/init.tl")`
400/// is `pkg`, not `init`.
401fn entry_module_name(entry: &Path) -> String {
402 let stem = entry.file_stem().and_then(|s| s.to_str());
403 match stem {
404 Some("init") => entry
405 .parent()
406 .and_then(|d| d.file_name())
407 .and_then(|s| s.to_str())
408 .map(str::to_string)
409 .unwrap_or_else(|| "init".into()),
410 Some(s) => s.to_string(),
411 None => "main".into(),
412 }
413}
414
415fn classify(h: &Htl, name: &str, found: Option<&Path>) -> Result<Target> {
416 let (found, lua) = match found {
417 Some(p) => (Some(p.to_path_buf()), None),
418 None => h.resolve_module(name)?,
419 };
420 let Some(p) = found else {
421 return Ok(Target::Missing);
422 };
423 if !is_decl(&p) {
424 return Ok(Target::File(p));
425 }
426 // A declaration: is there a `.lua` implementation on the path behind it (a vendored
427 // dependency typed by a `.d.tl`)? Then that is what gets bundled.
428 let lua = match lua {
429 Some(l) => Some(l),
430 None => h.resolve_module(name)?.1,
431 };
432 Ok(match lua {
433 Some(l) => Target::File(l),
434 None => Target::Host,
435 })
436}