use std::{
fs,
path::{Path, PathBuf},
};
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Ident, LitBool, LitStr, parse::Parse};
mod basic;
mod image;
mod svg;
use basic::{BinaryAsset, TextAsset};
use image::ImageAsset;
use svg::SvgAsset;
const RIBIR_BUNDLE_MODE_ENV: &str = "RIBIR_BUNDLE_MODE";
#[derive(Clone, Debug, Eq, PartialEq)]
enum BundleAssetMode {
None,
Debug,
Release,
}
impl BundleAssetMode {
fn from_env() -> syn::Result<Self> {
match std::env::var(RIBIR_BUNDLE_MODE_ENV) {
Ok(value) => match value.as_str() {
"" | "none" => Ok(Self::None),
"debug" => Ok(Self::Debug),
"release" | "1" => Ok(Self::Release),
other => Err(syn::Error::new(
proc_macro2::Span::call_site(),
format!(
"Invalid {RIBIR_BUNDLE_MODE_ENV} value '{other}', expected one of: none, debug, \
release"
),
)),
},
Err(_) => Ok(Self::None),
}
}
fn asset_dir_name(&self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Debug => Some("debug"),
Self::Release => Some("release"),
}
}
fn uses_bundle_runtime_path(&self) -> bool { !matches!(self, Self::None) }
}
pub fn gen_asset(input: TokenStream) -> TokenStream { gen_asset_internal(input, false) }
pub fn gen_include_asset(input: TokenStream) -> TokenStream { gen_asset_internal(input, true) }
pub(crate) trait Asset {
fn process(&self, ctx: &AssetContext) -> syn::Result<Option<Vec<u8>>>;
fn output_extension(&self) -> Option<&str> { None }
fn load_expr(&self, data_expr: TokenStream) -> TokenStream;
}
fn gen_asset_internal(input: TokenStream, embed: bool) -> TokenStream {
match syn::parse2::<AssetArgs>(input).and_then(|args| process_and_generate(args, embed)) {
Ok(ts) => ts,
Err(e) => e.to_compile_error(),
}
}
fn process_and_generate(args: AssetArgs, embed: bool) -> syn::Result<TokenStream> {
let ctx = prepare_asset_context(&args.input, embed)?;
generate_for_asset(args.asset.as_ref(), &ctx)
}
fn generate_for_asset(asset: &dyn Asset, ctx: &AssetContext) -> syn::Result<TokenStream> {
let output_path = match asset.output_extension() {
Some(ext) => ctx.abs_output.with_extension(ext),
None => ctx.abs_output.clone(),
};
let processed = if ctx.needs_update(&output_path) {
let data = asset.process(ctx)?;
match &data {
Some(bytes) => {
fs::write(&output_path, bytes).map_err(|e| ctx.error(format!("Write failed: {e}")))?;
}
None => {
fs::copy(&ctx.abs_input, &output_path)
.map_err(|e| ctx.error(format!("Copy failed: {e}")))?;
}
}
data
} else {
None };
let data_expr = if ctx.embed {
let bytes = match processed {
Some(data) => data,
None => fs::read(&output_path).map_err(|e| ctx.error(format!("Read cached: {e}")))?,
};
quote! { std::borrow::Cow::Borrowed(&[#(#bytes),*][..]) }
} else {
let path = ctx.runtime_path_tokens(asset.output_extension());
quote! { std::borrow::Cow::<[u8]>::Owned(std::fs::read(#path).expect("Failed to read asset")) }
};
if !ctx.embed {
append_to_manifest(
&ctx.abs_input.to_string_lossy(),
&output_path,
&ctx.relative_output_with_ext(asset.output_extension()),
ctx.input_span,
)?;
}
let abs_input = ctx.abs_input.to_string_lossy().into_owned();
let load = asset.load_expr(data_expr);
Ok(quote! {
{
const _: &[u8] = include_bytes!(#abs_input);
#load
}
})
}
pub struct AssetArgs {
pub input: LitStr,
asset: Box<dyn Asset>,
}
impl Parse for AssetArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let input_str = input.parse::<LitStr>()?;
let asset: Box<dyn Asset> = if input.parse::<syn::Token![,]>().is_ok() {
let type_str = input.parse::<LitStr>()?;
match type_str.value().to_lowercase().as_str() {
"text" => Box::new(TextAsset),
"svg" => {
let params = parse_key_value_params(input)?;
let inherit_fill = get_bool_param(¶ms, "inherit_fill", type_str.span())?;
let inherit_stroke = get_bool_param(¶ms, "inherit_stroke", type_str.span())?;
Box::new(SvgAsset { inherit_fill, inherit_stroke })
}
"image" => Box::new(ImageAsset),
_ => Box::new(BinaryAsset),
}
} else {
Box::new(BinaryAsset)
};
Ok(AssetArgs { input: input_str, asset })
}
}
enum ParamValue {
Bool(bool),
String(String),
}
fn parse_key_value_params(
input: syn::parse::ParseStream,
) -> syn::Result<std::collections::HashMap<String, ParamValue>> {
let mut params = std::collections::HashMap::new();
while input.parse::<syn::Token![,]>().is_ok() {
let key: Ident = input.parse()?;
input.parse::<syn::Token![=]>()?;
let value = if let Ok(b) = input.parse::<LitBool>() {
ParamValue::Bool(b.value)
} else if let Ok(s) = input.parse::<LitStr>() {
ParamValue::String(s.value())
} else {
return Err(syn::Error::new(key.span(), "Expected bool or string literal"));
};
params.insert(key.to_string(), value);
}
Ok(params)
}
fn get_bool_param(
params: &std::collections::HashMap<String, ParamValue>, name: &str, span: proc_macro2::Span,
) -> syn::Result<bool> {
params
.get(name)
.map(|v| match v {
ParamValue::Bool(b) => Ok(*b),
ParamValue::String(s) => {
Err(syn::Error::new(span, format!("Expected bool for `{name}`, got string: `{s}`")))
}
})
.transpose()
.map(|opt| opt.unwrap_or(false))
}
pub(crate) struct AssetContext {
pub input_path: String,
pub abs_input: PathBuf,
pub abs_output: PathBuf,
pub relative_output: String,
pub input_span: proc_macro2::Span,
bundle_mode: BundleAssetMode,
pub embed: bool,
}
impl AssetContext {
pub fn error(&self, msg: impl std::fmt::Display) -> syn::Error {
syn::Error::new(self.input_span, format!("{} for '{}'", msg, self.input_path))
}
pub fn needs_update(&self, output: &Path) -> bool {
match (self.abs_input.metadata(), output.metadata()) {
(Ok(i), Ok(o)) => match (i.modified(), o.modified()) {
(Ok(it), Ok(ot)) => it > ot,
_ => true,
},
_ => true,
}
}
pub fn relative_output_with_ext(&self, new_ext: Option<&str>) -> String {
match new_ext {
Some(ext) => Path::new(&self.relative_output)
.with_extension(ext)
.to_string_lossy()
.into_owned(),
None => self.relative_output.clone(),
}
}
pub fn runtime_path_tokens(&self, new_ext: Option<&str>) -> TokenStream {
let relative = self.relative_output_with_ext(new_ext);
if self.bundle_mode.uses_bundle_runtime_path() {
#[cfg(target_os = "macos")]
{
quote! {
std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("Resources")
.join(&#relative)
}
}
#[cfg(not(target_os = "macos"))]
{
quote! {
std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.join(&#relative)
}
}
} else {
let output = match new_ext {
Some(ext) => self.abs_output.with_extension(ext),
None => self.abs_output.clone(),
};
let abs = output.to_string_lossy().into_owned();
quote! { std::path::PathBuf::from(#abs) }
}
}
}
fn prepare_asset_context(input: &LitStr, embed: bool) -> syn::Result<AssetContext> {
let input_path = input.value();
let span = input.span();
let bundle_mode = BundleAssetMode::from_env()?;
let abs_input = resolve_caller_relative_path(&input_path, span)?;
if !abs_input.exists() {
return Err(syn::Error::new(span, format!("Asset not found: {abs_input:?}")));
}
if !abs_input.is_file() {
return Err(syn::Error::new(span, format!("Not a file: '{input_path}'")));
}
let filename = abs_input
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| syn::Error::new(span, "Invalid filename"))?;
let abs_input_str = abs_input.to_string_lossy();
let path_hash = hash_path(&abs_input_str);
let hashed_filename = format!("{path_hash}_{filename}");
let cargo_profile = bundle_mode
.asset_dir_name()
.map(str::to_owned)
.unwrap_or_else(|| std::env::var("PROFILE").unwrap_or_else(|_| "debug".into()));
let (manifest_path, workspace_opt) = get_workspace_base(span)?;
let root = workspace_opt.unwrap_or(manifest_path);
let base_target = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.map(|p| if p.is_absolute() { p } else { root.join(p) })
.unwrap_or_else(|| root.join("target"));
let target_assets_dir = base_target.join(cargo_profile).join("assets");
if !target_assets_dir.exists() {
fs::create_dir_all(&target_assets_dir)
.map_err(|e| syn::Error::new(span, format!("Failed to create dir: {e}")))?;
}
let abs_output = target_assets_dir.join(&hashed_filename);
let relative_output = format!("assets/{hashed_filename}");
Ok(AssetContext {
input_path,
abs_input,
abs_output,
relative_output,
input_span: span,
bundle_mode,
embed,
})
}
fn resolve_caller_relative_path(input_path: &str, span: proc_macro2::Span) -> syn::Result<PathBuf> {
let path = PathBuf::from(input_path);
if path.is_absolute() {
return Ok(path);
}
if input_path.starts_with("~/") || input_path == "~" {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| syn::Error::new(span, "Could not find home directory"))?;
let tail = if input_path.len() > 1 { &input_path[2..] } else { "" };
return Ok(PathBuf::from(home).join(tail));
}
let caller_file = span
.unwrap()
.local_file()
.ok_or_else(|| syn::Error::new(span, "Cannot get source file path from span"))?;
let caller_dir = caller_file
.parent()
.ok_or_else(|| syn::Error::new(span, format!("Invalid source path: {caller_file:?}")))?;
let resolved = caller_dir.join(input_path);
if resolved.is_absolute() {
return Ok(resolved);
}
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.map_err(|_| syn::Error::new(span, "CARGO_MANIFEST_DIR not set"))?;
let manifest_path = PathBuf::from(&manifest_dir);
let candidate = manifest_path.join(&resolved);
if candidate.exists() {
return Ok(candidate);
}
if let Some(workspace_root) = find_workspace_root(&manifest_path) {
let candidate = workspace_root.join(&resolved);
if candidate.exists() {
return Ok(candidate);
}
}
Ok(manifest_path.join(&resolved))
}
fn get_workspace_base(span: proc_macro2::Span) -> syn::Result<(PathBuf, Option<PathBuf>)> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.map_err(|_| syn::Error::new(span, "CARGO_MANIFEST_DIR not set"))?;
let manifest_path = PathBuf::from(&manifest_dir);
Ok((manifest_path.clone(), find_workspace_root(&manifest_path)))
}
fn hash_path(path: &str) -> String {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x100_0000_01b3;
let mut hash = FNV_OFFSET_BASIS;
for byte in path.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{hash:016x}")
}
fn append_to_manifest(
source_abs: &str, build_abs: &Path, bundle_relative: &str, span: proc_macro2::Span,
) -> syn::Result<()> {
use std::io::Write;
let path = build_abs
.parent()
.ok_or_else(|| syn::Error::new(span, "Invalid build path"))?
.join(".asset_manifest.txt");
let caller_file = span
.unwrap()
.local_file()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string());
let line = span.unwrap().start().line();
let col = span.unwrap().start().column();
let location = format!("{caller_file}:{line}:{col}");
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| syn::Error::new(span, format!("Manifest open failed: {e}")))?;
writeln!(file, "{source_abs} | {} | {bundle_relative} | {location}", build_abs.display())
.map_err(|e| syn::Error::new(span, format!("Manifest write failed: {e}")))?;
Ok(())
}
fn find_workspace_root(start: &Path) -> Option<PathBuf> {
let mut cur = Some(start);
while let Some(p) = cur {
let toml = p.join("Cargo.toml");
if toml.exists() && fs::read_to_string(&toml).is_ok_and(|c| c.contains("[workspace]")) {
return Some(p.to_path_buf());
}
cur = p.parent();
}
None
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::{BundleAssetMode, RIBIR_BUNDLE_MODE_ENV};
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_bundle_mode_env<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap();
let old = std::env::var(RIBIR_BUNDLE_MODE_ENV).ok();
match value {
Some(value) => unsafe { std::env::set_var(RIBIR_BUNDLE_MODE_ENV, value) },
None => unsafe { std::env::remove_var(RIBIR_BUNDLE_MODE_ENV) },
}
let result = f();
match old {
Some(value) => unsafe { std::env::set_var(RIBIR_BUNDLE_MODE_ENV, value) },
None => unsafe { std::env::remove_var(RIBIR_BUNDLE_MODE_ENV) },
}
result
}
#[test]
fn bundle_asset_mode_defaults_to_none() {
with_bundle_mode_env(None, || {
assert_eq!(BundleAssetMode::from_env().unwrap(), BundleAssetMode::None);
});
}
#[test]
fn bundle_asset_mode_accepts_explicit_profile_dirs() {
with_bundle_mode_env(Some("debug"), || {
assert_eq!(BundleAssetMode::from_env().unwrap(), BundleAssetMode::Debug);
});
with_bundle_mode_env(Some("release"), || {
assert_eq!(BundleAssetMode::from_env().unwrap(), BundleAssetMode::Release);
});
with_bundle_mode_env(Some("none"), || {
assert_eq!(BundleAssetMode::from_env().unwrap(), BundleAssetMode::None);
});
}
#[test]
fn bundle_asset_mode_rejects_unknown_values() {
with_bundle_mode_env(Some("bundle"), || {
let err = BundleAssetMode::from_env().unwrap_err();
assert!(
err
.to_string()
.contains("Invalid RIBIR_BUNDLE_MODE value")
);
});
}
}