use crate::bundle::{Bundle, Kind, Module};
use crate::cache::{self, Cache};
use crate::{CheckInfo, Htl, RequireSite};
use anyhow::{Context, Result};
use std::collections::{BTreeSet, HashSet, VecDeque};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct LinkOptions {
pub debug: bool,
pub source: bool,
pub extra: Vec<String>,
pub host: Vec<String>,
}
#[derive(Clone, Copy)]
pub struct LinkStore<'a> {
pub cache: &'a Cache,
pub lint: Option<&'a str>,
pub root: &'a Path,
pub config: Option<(&'a Path, &'a crate::config::HtlConfig)>,
}
impl LinkStore<'_> {
fn extra_inputs(&self) -> Vec<PathBuf> {
self.config
.map(|(file, _)| vec![file.to_path_buf()])
.unwrap_or_default()
}
fn probe_dirs(&self, file: &Path) -> Vec<PathBuf> {
let cfg = self.config.map(|(file, c)| (crate::parent_dir(file), c));
cache::search_dirs(
file,
self.root,
cfg.as_ref().map(|(dir, c)| (dir.as_path(), *c)),
)
}
}
#[derive(Debug, Clone)]
pub struct LinkedModule {
pub name: String,
pub path: PathBuf,
pub typed: bool,
}
#[derive(Debug, Default)]
pub struct Linked {
bundle: Bundle,
pub modules: Vec<LinkedModule>,
pub host_modules: Vec<String>,
pub errors: Vec<String>,
pub lints: Vec<String>,
pub checks: Vec<(PathBuf, CheckInfo)>,
pub cached: usize,
}
impl Linked {
pub fn ok(&self) -> bool {
self.errors.is_empty()
}
pub fn bundle(&self) -> Result<&Bundle> {
if self.errors.is_empty() {
Ok(&self.bundle)
} else {
Err(self.error())
}
}
pub fn into_bundle(self) -> Result<Bundle> {
if self.errors.is_empty() {
Ok(self.bundle)
} else {
Err(self.error())
}
}
fn error(&self) -> anyhow::Error {
anyhow::anyhow!(
"link failed with {} error(s):\n {}",
self.errors.len(),
self.errors.join("\n ")
)
}
pub fn inputs(&self) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = self.modules.iter().map(|m| m.path.clone()).collect();
for (_, ci) in &self.checks {
out.extend(ci.deps.iter().cloned());
}
out.sort();
out.dedup();
out
}
}
pub fn link(h: &Htl, entry: &Path, opts: &LinkOptions) -> Result<Linked> {
link_with(h, entry, opts, None)
}
pub fn link_with(
h: &Htl,
entry: &Path,
opts: &LinkOptions,
store: Option<LinkStore<'_>>,
) -> Result<Linked> {
let mut out = Linked::default();
let entry_name = entry_module_name(entry);
let host_declared: HashSet<String> = opts.host.iter().cloned().collect();
let mut host: BTreeSet<String> = BTreeSet::new();
let mut queued: HashSet<String> = HashSet::new();
let mut queue: VecDeque<(String, PathBuf)> = VecDeque::new();
queue.push_back((entry_name.clone(), entry.to_path_buf()));
queued.insert(entry_name.clone());
for name in &opts.extra {
match classify(h, name, None)? {
Target::File(p) => {
if queued.insert(name.clone()) {
queue.push_back((name.clone(), p));
}
}
Target::Host => {
host.insert(name.clone());
}
Target::Missing => out.errors.push(format!(
"extra module '{name}' not found on the search path"
)),
}
}
while let Some((name, path)) = queue.pop_front() {
let typed = path.extension().is_none_or(|e| e != "lua");
let (code, requires) = if typed {
let Generated {
code,
check: ci,
cached,
} = generate(h, &path, store)?;
if cached {
out.cached += 1;
}
out.errors.extend(ci.errors.iter().cloned());
out.lints.extend(ci.lints.iter().cloned());
let reqs = ci.requires.clone();
out.checks.push((path.clone(), ci));
(code, reqs)
} else {
let src = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let reqs = h.lua_requires(&src, &path)?;
(Some(src), reqs)
};
for r in &requires {
if queued.contains(&r.module) || host.contains(&r.module) {
continue;
}
if host_declared.contains(&r.module) {
host.insert(r.module.clone());
continue;
}
match classify(h, &r.module, r.path.as_deref())? {
Target::File(p) => {
queued.insert(r.module.clone());
queue.push_back((r.module.clone(), p));
}
Target::Host => {
host.insert(r.module.clone());
}
Target::Missing => out.errors.push(unresolved(&path, r)),
}
}
let Some(code) = code else { continue };
let payload = if opts.source {
Module {
name: name.clone(),
kind: Kind::Source,
payload: code.into_bytes(),
}
} else {
let bc = h.compile_with(&name, &code, !opts.debug)?;
Module {
name: name.clone(),
kind: Kind::Bytecode,
payload: bc,
}
};
out.bundle.modules.push(payload);
out.modules.push(LinkedModule { name, path, typed });
}
out.host_modules = host.iter().cloned().collect();
out.bundle.entry = entry_name;
out.bundle.htl_version = env!("CARGO_PKG_VERSION").to_string();
out.bundle.host_modules = out.host_modules.clone();
if !opts.source {
out.bundle.fingerprint = h.fingerprint()?;
}
Ok(out)
}
#[derive(Debug)]
pub struct Generated {
pub code: Option<String>,
pub check: CheckInfo,
pub cached: bool,
}
pub fn generate(h: &Htl, path: &Path, store: Option<LinkStore<'_>>) -> Result<Generated> {
let key = store.map(|s| cache::module_gen_key(path, s.lint));
if let (Some(s), Some(k)) = (store, &key)
&& let Some(m) = s.cache.lookup(k)
&& let (Some(code), Some(check)) = (&m.code, &m.check)
{
return Ok(Generated {
code: Some(code.clone()),
check: check.to_check(),
cached: true,
});
}
let (code, ci) = h.gen_lua(path)?;
if let (Some(s), Some(k), Some(code)) = (store, &key, &code) {
let stored = if ci.requires.is_empty() && mentions_require(path) {
match h.check(path) {
Ok(c) if !c.requires.is_empty() => CheckInfo {
requires: c.requires,
..ci.clone()
},
_ => ci.clone(),
}
} else {
ci.clone()
};
let m = cache::Module::generated(&stored, code.clone());
s.cache
.store_module(k, path, &s.extra_inputs(), &s.probe_dirs(path), &m);
}
Ok(Generated {
code,
check: ci,
cached: false,
})
}
pub fn mentions_require(path: &Path) -> bool {
std::fs::read_to_string(path)
.map(|s| cache::source_mentions_require(&s))
.unwrap_or(true)
}
fn is_decl(p: &Path) -> bool {
p.to_string_lossy().ends_with(".d.tl")
}
fn unresolved(from: &Path, r: &RequireSite) -> String {
format!(
"{}:{}:{}: require(\"{}\") is not on the search path: nothing to bundle. If the host \
provides it, declare it in a `{}.d.tl` or list it under `[build] host` in htl.toml; \
if it is reached only through a dynamic require, list it under `[build] extra`",
from.display(),
r.line,
r.col,
r.module,
r.module.replace('.', "/")
)
}
enum Target {
File(PathBuf),
Host,
Missing,
}
fn entry_module_name(entry: &Path) -> String {
let stem = entry.file_stem().and_then(|s| s.to_str());
match stem {
Some("init") => entry
.parent()
.and_then(|d| d.file_name())
.and_then(|s| s.to_str())
.map(str::to_string)
.unwrap_or_else(|| "init".into()),
Some(s) => s.to_string(),
None => "main".into(),
}
}
fn classify(h: &Htl, name: &str, found: Option<&Path>) -> Result<Target> {
let (found, lua) = match found {
Some(p) => (Some(p.to_path_buf()), None),
None => h.resolve_module(name)?,
};
let Some(p) = found else {
return Ok(Target::Missing);
};
if !is_decl(&p) {
return Ok(Target::File(p));
}
let lua = match lua {
Some(l) => Some(l),
None => h.resolve_module(name)?.1,
};
Ok(match lua {
Some(l) => Target::File(l),
None => Target::Host,
})
}