beet_build 0.0.8

Codegen and compilation tooling for beet
use beet_core::prelude::*;
use heck::ToSnakeCase;
use quote::ToTokens;
use syn::Expr;
use syn::Item;

/// Call [`CodegenFile::build_and_write`] for every [`Changed<CodegenFile>`]
pub fn export_codegen(
	query: Populated<&CodegenFile, Changed<CodegenFile>>,
) -> bevy::prelude::Result {
	let num_files = query.iter().count();
	info!("Exporting {} codegen files...", num_files);
	for codegen_file in query.iter() {
		codegen_file.build_and_write()?;
	}
	Ok(())
}

/// Every codegen file is created via this struct. It contains
/// several utilities and standards for quality of life.
#[derive(Debug, Clone, PartialEq, Eq, Reflect, Component)]
#[reflect(Default, Component)]
pub struct CodegenFile {
	/// The output codegen file location.
	output: AbsPathBuf,
	/// As [`std::any::type_name`], which is used with [`TemplateSerde`], resolves to a named crate, we need to alias the current
	/// crate to match any internal types, setting this option will add `use crate as pkg_name`
	/// to the top of the file.
	pkg_name: Option<String>,
	/// All of the imports that must be included both globally and inside each
	/// inline module.
	/// These will not be erased when the file is regenerated.
	// it'd be nice to store these as a Vec<Item> but bevy reflect doesnt
	// support custom serialization at this stage
	imports: Vec<String>,
	/// List of all root level items to be included in the file.
	/// These are usually appended to as this struct is passed around.
	// it'd be nice to store these as a Vec<Item> but bevy reflect doesnt
	// support custom serialization at this stage
	items: Vec<String>,
}

impl Default for CodegenFile {
	fn default() -> Self {
		Self {
			output: WsPathBuf::new("src/codegen/mod.rs").into_abs(),
			pkg_name: None,
			imports: default(),
			items: default(),
		}
		.with_import(syn::parse_quote!(
			#[allow(unused_imports)]
			use beet::prelude::*;
		))
		.with_import(syn::parse_quote!(
			#[allow(unused_imports)]
			use crate::prelude::*;
		))
	}
}

impl CodegenFile {
	/// Create a new [`CodegenFile`] with the most common options.
	pub fn new(output: AbsPathBuf) -> Self {
		Self {
			output,
			..Default::default()
		}
	}

	/// Get the output path for this codegen file.
	pub fn output(&self) -> &AbsPathBuf { &self.output }
	/// Get the package name alias, if set.
	pub fn pkg_name(&self) -> Option<&String> { self.pkg_name.as_ref() }

	/// Get the snake_case name of this codegen file,
	/// if its a 'mod.rs' then the parent directory is used.
	pub fn name(&self) -> String {
		match self
			.output
			.file_stem()
			.expect("codegen output must have a file stem")
			.to_str()
			.expect("file stem must be valid UTF-8")
		{
			"mod" => self
				.output
				.parent()
				.expect("mod files must have a parent")
				.file_name()
				.expect("parent must have a file name")
				.to_str()
				.expect("file name must be valid UTF-8")
				.to_owned(),
			other => other.to_owned(),
		}
		.to_snake_case()
	}

	/// Clone the metadata of this codegen file, but change the output path
	/// and clears the items.
	pub fn clone_info(&self, output: AbsPathBuf) -> Self {
		Self {
			output,
			imports: self.imports.clone(),
			pkg_name: self.pkg_name.clone(),
			items: Vec::new(),
		}
	}

	pub fn with_pkg_name(mut self, pkg_name: impl Into<String>) -> Self {
		self.pkg_name = Some(pkg_name.into());
		self
	}

	pub fn with_import(mut self, item: Item) -> Self {
		self.imports.push(item.into_token_stream().to_string());
		self
	}
	/// Set the imports for this codegen file, replacing the default and a
	/// previously set imports.
	pub fn set_imports(mut self, items: Vec<Item>) -> Self {
		self.imports = items
			.iter()
			.map(|item| item.into_token_stream().to_string())
			.collect();
		self
	}


	pub fn output_dir(&self) -> Result<AbsPathBuf> {
		self.output
			.parent()
			.ok_or_else(|| bevyhow!("Output path must have a parent directory"))
	}
	pub fn clear_items(&mut self) { self.items.clear(); }

	pub fn add_item<T: Into<Item>>(&mut self, item: T) {
		self.items.push(item.into().into_token_stream().to_string());
	}

	fn imports_to_tokens(&self) -> Result<Vec<Item>, syn::Error> {
		self.imports
			.iter()
			.map(|s| syn::parse_str::<Item>(s))
			.collect::<Result<_, _>>()
	}
	fn items_to_tokens(&self) -> Result<Vec<Item>, syn::Error> {
		self.items
			.iter()
			.map(|s| syn::parse_str::<Item>(s))
			.collect::<Result<_, _>>()
	}

	pub fn build_output(&self) -> Result<syn::File> {
		let imports = self.imports_to_tokens()?;
		let crate_alias = self.crate_alias()?;

		let items = self.items_to_tokens()?;

		Ok(syn::parse_quote! {
			//! 🌱🌱🌱 This file has been auto generated by Beet.
			//! 🌱🌱🌱 Any changes will be overridden if the file is regenerated.
			#(#imports)*
			#crate_alias
			#(#items)*
		})
	}

	/// Builds the output file and writes it to the specified path
	/// if it has changed.
	pub fn build_and_write(&self) -> Result<()> {
		let output_tokens = self.build_output()?;
		// ideally we'd use rustfmt instead
		let output_str = prettyplease::unparse(&output_tokens);
		trace!("Exporting codegen file:\n{}", self.output.to_string_lossy());

		fs_ext::write_if_diff(&self.output, &output_str)?;
		Ok(())
	}

	// this is legacy from when client islands use std::any::type_name
	// we can remove it after scenes-as-islands
	fn crate_alias(&self) -> Result<Option<syn::Item>> {
		if let Some(pkg_name) = &self.pkg_name {
			let pkg_name: Expr = syn::parse_str(pkg_name)?;
			Ok(Some(syn::parse_quote! {
				#[allow(unused_imports)]
				use crate as #pkg_name;
			}))
		} else {
			Ok(None)
		}
	}
}



#[cfg(test)]
mod test {
	use crate::prelude::*;
	use quote::ToTokens;
	use beet_core::prelude::*;
	use syn::ItemFn;

	#[test]
	fn works() {
		let mut file = CodegenFile::default();
		file.add_item::<ItemFn>(syn::parse_quote! {
			fn test() {}
		});
		(&file.build_output().unwrap().to_token_stream().to_string())
			.xpect_contains("fn test () { }");
	}
}