Skip to main content

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}