1use 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 pub debug: bool,
21 pub source: bool,
23 pub extra: Vec<String>,
25 pub host: Vec<String>,
27}
28
29#[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 pub errors: Vec<String>,
46 pub lints: Vec<String>,
47 pub checks: Vec<(PathBuf, CheckInfo)>,
48}
49
50impl Linked {
51 pub fn ok(&self) -> bool {
53 self.errors.is_empty()
54 }
55
56 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 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
94pub 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 File(PathBuf),
207 Host,
209 Missing,
210}
211
212fn 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 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}