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
11use crate::bundle::{Bundle, Kind, Module};
12use crate::{CheckInfo, Htl, RequireSite};
13use anyhow::{Context, Result};
14use std::collections::{BTreeSet, HashSet, VecDeque};
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone, Default)]
18pub struct LinkOptions {
19    /// Keep debug info (line numbers, local names) in bytecode. Off = stripped.
20    pub debug: bool,
21    /// Store generated Lua source instead of bytecode (portable across Lua builds).
22    pub source: bool,
23    /// Modules to include even if no literal `require` reaches them.
24    pub extra: Vec<String>,
25    /// Modules the host provides at run time (besides those declared only by a `.d.tl`).
26    pub host: Vec<String>,
27}
28
29/// One linked module: where it came from and how it was stored.
30#[derive(Debug, Clone)]
31pub struct LinkedModule {
32    pub name: String,
33    pub path: PathBuf,
34    pub typed: bool,
35}
36
37#[derive(Debug, Default)]
38pub struct Linked {
39    bundle: Bundle,
40    pub modules: Vec<LinkedModule>,
41    pub host_modules: Vec<String>,
42    /// Type errors and unresolved requires. A module with a type error is *absent* from
43    /// the bundle, so the bundle is only handed out ([`bundle`](Self::bundle)) when this
44    /// is empty: a program missing a module dies at its first `require`, far from here.
45    pub errors: Vec<String>,
46    pub lints: Vec<String>,
47    pub checks: Vec<(PathBuf, CheckInfo)>,
48}
49
50impl Linked {
51    /// `true` when every module linked cleanly (lints are not errors here).
52    pub fn ok(&self) -> bool {
53        self.errors.is_empty()
54    }
55
56    /// The bundle, or every error that makes it incomplete.
57    pub fn bundle(&self) -> Result<&Bundle> {
58        if self.errors.is_empty() {
59            Ok(&self.bundle)
60        } else {
61            Err(self.error())
62        }
63    }
64
65    pub fn into_bundle(self) -> Result<Bundle> {
66        if self.errors.is_empty() {
67            Ok(self.bundle)
68        } else {
69            Err(self.error())
70        }
71    }
72
73    fn error(&self) -> anyhow::Error {
74        anyhow::anyhow!(
75            "link failed with {} error(s):\n  {}",
76            self.errors.len(),
77            self.errors.join("\n  ")
78        )
79    }
80
81    /// Every file the bundle was built from (entry, modules, and what the checker read
82    /// for them, e.g. `.d.tl`s): what a build script or macro should watch for changes.
83    pub fn inputs(&self) -> Vec<PathBuf> {
84        let mut out: Vec<PathBuf> = self.modules.iter().map(|m| m.path.clone()).collect();
85        for (_, ci) in &self.checks {
86            out.extend(ci.deps.iter().cloned());
87        }
88        out.sort();
89        out.dedup();
90        out
91    }
92}
93
94/// Link `entry` (a `.tl` file) and everything it requires. The checker's search path
95/// must already cover the project (`add_path` / `apply_project` / `apply_config`).
96pub fn link(h: &Htl, entry: &Path, opts: &LinkOptions) -> Result<Linked> {
97    let mut out = Linked::default();
98    let entry_name = entry
99        .file_stem()
100        .and_then(|s| s.to_str())
101        .map(str::to_string)
102        .unwrap_or_else(|| "main".into());
103    let host_declared: HashSet<String> = opts.host.iter().cloned().collect();
104    let mut host: BTreeSet<String> = BTreeSet::new();
105    let mut queued: HashSet<String> = HashSet::new();
106    let mut queue: VecDeque<(String, PathBuf)> = VecDeque::new();
107    queue.push_back((entry_name.clone(), entry.to_path_buf()));
108    queued.insert(entry_name.clone());
109    for name in &opts.extra {
110        match classify(h, name, None)? {
111            Target::File(p) => {
112                if queued.insert(name.clone()) {
113                    queue.push_back((name.clone(), p));
114                }
115            }
116            Target::Host => {
117                host.insert(name.clone());
118            }
119            Target::Missing => out.errors.push(format!(
120                "extra module '{name}' not found on the search path"
121            )),
122        }
123    }
124
125    while let Some((name, path)) = queue.pop_front() {
126        let typed = path.extension().is_none_or(|e| e != "lua");
127        let (code, requires) = if typed {
128            let (code, ci) = h.gen_lua(&path)?;
129            out.errors.extend(ci.errors.iter().cloned());
130            out.lints.extend(ci.lints.iter().cloned());
131            let reqs = ci.requires.clone();
132            out.checks.push((path.clone(), ci));
133            (code, reqs)
134        } else {
135            let src = std::fs::read_to_string(&path)
136                .with_context(|| format!("reading {}", path.display()))?;
137            let reqs = h.lua_requires(&src, &path)?;
138            (Some(src), reqs)
139        };
140        for r in &requires {
141            if queued.contains(&r.module) || host.contains(&r.module) {
142                continue;
143            }
144            match classify(h, &r.module, r.path.as_deref())? {
145                Target::File(p) => {
146                    queued.insert(r.module.clone());
147                    queue.push_back((r.module.clone(), p));
148                }
149                Target::Host => {
150                    host.insert(r.module.clone());
151                }
152                Target::Missing if host_declared.contains(&r.module) => {
153                    host.insert(r.module.clone());
154                }
155                Target::Missing => out.errors.push(unresolved(&path, r)),
156            }
157        }
158        let Some(code) = code else { continue };
159        let payload = if opts.source {
160            Module {
161                name: name.clone(),
162                kind: Kind::Source,
163                payload: code.into_bytes(),
164            }
165        } else {
166            let bc = h.compile_with(&name, &code, !opts.debug)?;
167            Module {
168                name: name.clone(),
169                kind: Kind::Bytecode,
170                payload: bc,
171            }
172        };
173        out.bundle.modules.push(payload);
174        out.modules.push(LinkedModule { name, path, typed });
175    }
176
177    out.host_modules = host.iter().cloned().collect();
178    out.bundle.entry = entry_name;
179    out.bundle.htl_version = env!("CARGO_PKG_VERSION").to_string();
180    out.bundle.host_modules = out.host_modules.clone();
181    if !opts.source {
182        out.bundle.fingerprint = h.fingerprint()?;
183    }
184    Ok(out)
185}
186
187fn is_decl(p: &Path) -> bool {
188    p.to_string_lossy().ends_with(".d.tl")
189}
190
191fn unresolved(from: &Path, r: &RequireSite) -> String {
192    format!(
193        "{}:{}:{}: require(\"{}\") is not on the search path: nothing to bundle. If the host \
194         provides it, declare it in a `{}.d.tl` or list it under `[build] host` in htl.toml; \
195         if it is reached only through a dynamic require, list it under `[build] extra`",
196        from.display(),
197        r.line,
198        r.col,
199        r.module,
200        r.module.replace('.', "/")
201    )
202}
203
204enum Target {
205    /// A file to bundle (`.tl` typed, or a plain `.lua`).
206    File(PathBuf),
207    /// Declared only (`.d.tl` with no `.lua` behind it): the host provides it.
208    Host,
209    Missing,
210}
211
212/// What a `require(name)` points at for the linker. `found` is the checker's own
213/// resolution when already known (a require site); otherwise it is looked up.
214fn classify(h: &Htl, name: &str, found: Option<&Path>) -> Result<Target> {
215    let (found, lua) = match found {
216        Some(p) => (Some(p.to_path_buf()), None),
217        None => h.resolve_module(name)?,
218    };
219    let Some(p) = found else {
220        return Ok(Target::Missing);
221    };
222    if !is_decl(&p) {
223        return Ok(Target::File(p));
224    }
225    // A declaration: is there a `.lua` implementation on the path behind it (a vendored
226    // dependency typed by a `.d.tl`)? Then that is what gets bundled.
227    let lua = match lua {
228        Some(l) => Some(l),
229        None => h.resolve_module(name)?.1,
230    };
231    Ok(match lua {
232        Some(l) => Target::File(l),
233        None => Target::Host,
234    })
235}