//! Proc-macro consumer of the component compiler: generates typed Rust from //! `.gdc` files. //! //! `include_component!(Counter)` resolves `Counter.gdc` through the crate's //! component search path — by default the `components/` directory under the //! crate root, overridable in Cargo.toml: //! //! ```toml //! [package.metadata.guiduck] //! component-path = ["components", "src/widgets"] //! ``` //! //! Components the file instantiates resolve through the same search path, //! so the app-side include mirrors component-to-component references //! exactly. `component_str!` takes the source inline, for tests. //! //! Widgets the crate implements in Rust are declared in `.gdw` manifests on a //! third search path, configured the same way and defaulting to `widgets/`: //! //! ```toml //! [package.metadata.guiduck] //! widget-path = ["widgets"] //! ``` //! //! The application configures this because a proc-macro cannot see another //! crate's items: nothing here can discover a `Widget` impl, so the crate //! points at the manifests that declare its interface instead. Every manifest //! on the path loads before any `.gdc` is read — the vocabulary decides what //! counts as a widget name at all. //! //! Compile errors in the `.gdc` render with file/line/col and a source //! excerpt inside the `compile_error!` message. mod codegen; mod discover; mod register; use proc_macro::TokenStream; use guiduck_component_core::resolve::{SearchPathResolver, load_widgets}; use guiduck_component_core::{ DEFAULT_ASSET_DIR, DEFAULT_COMPONENT_DIR, DEFAULT_WIDGET_DIR, ImportResolver, }; /// Register a Rust widget from its `.gdw` manifest, so a `.gdc` can name it and /// the runtime can build it. See [`register`] for the shape and rationale. /// /// ```ignore /// guiduck::register_widget!(NoteView, "widgets/note.gdw"); /// ``` #[proc_macro] pub fn register_widget(input: TokenStream) -> TokenStream { register::register_widget(input) } /// Register every framework builtin into the link-time registry, from the /// descriptor table. Invoked once inside `guiduck-core`; not for application /// use (`register_widget!` is that). #[proc_macro] pub fn register_builtins(input: TokenStream) -> TokenStream { register::register_builtins(input) } #[proc_macro] pub fn include_component(input: TokenStream) -> TokenStream { let name = match ident_argument(input) { Ok(name) => name, Err(message) => return compile_error(&message), }; let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") { Ok(dir) => std::path::PathBuf::from(dir), Err(_) => return compile_error("CARGO_MANIFEST_DIR is not set"), }; let dirs = match search_dirs(&manifest_dir) { Ok(dirs) => dirs, Err(message) => return compile_error(&message), }; let mut resolver = SearchPathResolver::new(dirs); // The widget vocabulary comes first: it decides what counts as a widget // name, so nothing can be parsed against it until it is complete. Every // manifest read lands in `visited` alongside the `.gdc` files, so editing // one rebuilds this expansion exactly the way editing a component does. let mut widget_path = match widget_dirs(&manifest_dir) { Ok(dirs) => dirs, Err(message) => return compile_error(&message), }; // A dependency's own widget manifests, found automatically: an app names a // dependency's widget in a `.gdc` with no configuration — no `widget-path` // pointing at it, no copy of its `.gdw`. The crate is in its own dependency // graph, so discovery reports the crate's own widget dir too; dedup so a dir // is loaded once (a genuine same-named widget in two *different* dirs still // conflicts, as it should). widget_path.extend(discover::dependency_widget_dirs(&manifest_dir)); widget_path = dedup_dirs(widget_path); let registry = match load_widgets(&widget_path, &mut resolver.visited) { Ok(registry) => registry, Err(message) => return compile_error(&message), }; // The named component itself resolves like any reference, so the // app-side include and component-to-component imports are one // mechanism; its file lands in `visited` for rebuild tracking too. let (display, source) = match resolver.resolve(&name) { Ok(found) => found, Err(why) => { return compile_error(&format!("cannot resolve component `{name}`: {why}")); } }; match guiduck_component_core::compile_with_widgets(&source, &mut resolver, ®istry) { Ok(compiled) => { if compiled.root.name != name { return compile_error(&format!( "{display} declares component `{}`, expected `{name}`", compiled.root.name )); } let track_paths: Vec = resolver .visited .iter() .map(|p| p.display().to_string()) .collect(); let search_dirs: Vec = resolver .dirs() .iter() .map(|d| d.display().to_string()) .collect(); // Assets resolve here, not in the compiler: the path is embedded // as an `include_bytes!` target, so editing the file rebuilds // this expansion exactly the way editing a `.gdc` does. let asset_path = match asset_dirs(&manifest_dir) { Ok(dirs) => dirs, Err(message) => return compile_error(&message), }; let assets = match resolve_assets(&compiled, &asset_path) { Ok(assets) => assets, Err(message) => return compile_error(&message), }; let asset_dirs: Vec = asset_path.iter().map(|d| d.display().to_string()).collect(); let widget_dirs: Vec = widget_path .iter() .map(|d| d.display().to_string()) .collect(); codegen::component( &compiled, &codegen::Host { track_paths: &track_paths, search_dirs: &search_dirs, widget_dirs: &widget_dirs, asset_dirs: &asset_dirs, assets: &assets, source_path: &display, }, ) .into() } Err(diagnostics) => { let rendered = guiduck_component_core::diagnostics::render(&diagnostics, &source, &display); compile_error(&format!("invalid component\n{rendered}")) } } } /// The crate's component search path, absolute: `component-path` from /// `[package.metadata.guiduck]` in Cargo.toml when present, else the /// default `components/` directory under the crate root. fn search_dirs(manifest_dir: &std::path::Path) -> Result, String> { configured_dirs(manifest_dir, "component-path", DEFAULT_COMPONENT_DIR) } /// The crate's asset search path, absolute: `asset-path` from /// `[package.metadata.guiduck]` when present, else the default `assets/` /// directory under the crate root. fn asset_dirs(manifest_dir: &std::path::Path) -> Result, String> { configured_dirs(manifest_dir, "asset-path", DEFAULT_ASSET_DIR) } /// The crate's widget-manifest search path, absolute: `widget-path` from /// `[package.metadata.guiduck]` when present, else the default `widgets/` /// directory under the crate root. fn widget_dirs(manifest_dir: &std::path::Path) -> Result, String> { configured_dirs(manifest_dir, "widget-path", DEFAULT_WIDGET_DIR) } fn configured_dirs( manifest_dir: &std::path::Path, key: &str, default: &str, ) -> Result, String> { let cargo_toml = manifest_dir.join("Cargo.toml"); let toml = std::fs::read_to_string(&cargo_toml) .map_err(|e| format!("cannot read {}: {e}", cargo_toml.display()))?; let configured = path_from_metadata(&toml, key).unwrap_or_else(|| vec![default.into()]); Ok(configured .into_iter() .map(|dir| manifest_dir.join(dir)) .collect()) } /// Every `(asset "…")` in the compiled component and its transitive /// children, resolved to an absolute path. /// /// Assets are ordinary property values, so this walks properties — nothing /// here is specific to the `image` widget, or to images. fn resolve_assets( compiled: &guiduck_component_core::ir::Compiled, dirs: &[std::path::PathBuf], ) -> Result, String> { use guiduck_component_core::ir::{IrPropValue, Literal}; let mut references: Vec<&str> = std::iter::once(&compiled.root) .chain(compiled.components.values()) .flat_map(|ir| ir.nodes.iter()) .filter_map(|node| node.widget.widget()) .flat_map(|widget| widget.props.iter()) .filter_map(|prop| match &prop.value { IrPropValue::Static(Literal::Asset(path)) => Some(path.as_str()), _ => None, }) .collect(); references.sort_unstable(); references.dedup(); references .into_iter() .map(|raw| { let found = guiduck_component_core::resolve::find_asset(dirs, raw) .map_err(|why| format!("cannot resolve asset `{raw}`: {why}"))?; Ok((raw.to_owned(), found.display().to_string())) }) .collect() } /// Extract ` = ["…", …]` from a `[package.metadata.guiduck]` section. /// Not a general TOML parser: the value must be a literal array of strings on /// the lines following the key (which covers the documented syntax); anything /// else means "not configured". fn path_from_metadata(toml: &str, key: &str) -> Option> { let mut in_section = false; let mut collecting = false; let mut dirs = Vec::new(); for line in toml.lines() { let line = line.trim(); if line.starts_with('[') { in_section = line == "[package.metadata.guiduck]"; continue; } if collecting || (in_section && line.starts_with(key)) { if !collecting && !line.contains('=') { continue; } collecting = true; let mut rest = line; while let Some(start) = rest.find('"') { let after = &rest[start + 1..]; let end = after.find('"')?; dirs.push(after[..end].to_owned()); rest = &after[end + 1..]; } if line.contains(']') { return Some(dirs); } } } None } #[proc_macro] pub fn component_str(input: TokenStream) -> TokenStream { let source = match string_argument(input) { Ok(source) => source, Err(message) => return compile_error(&message), }; // Inline sources have no crate context, so no component references // resolve. match guiduck_component_core::compile(&source) { Ok(root) => { let compiled = guiduck_component_core::ir::Compiled { root, components: Default::default(), }; // Inline sources have no crate context, so nothing resolves // against a search path: no component references, no assets, and // no widget manifests — hence the empty vocabulary. codegen::component( &compiled, &codegen::Host { track_paths: &[], search_dirs: &[], widget_dirs: &[], asset_dirs: &[], assets: &Default::default(), // An inline source has no file, so `mount` has // nothing to watch and always takes the compiled path. source_path: "", }, ) .into() } Err(diagnostics) => { let rendered = guiduck_component_core::diagnostics::render(&diagnostics, &source, ""); compile_error(&format!("invalid component\n{rendered}")) } } } /// `include_component!` takes exactly one bare component name. fn ident_argument(input: TokenStream) -> Result { let mut trees = input.into_iter(); let first = trees.next().ok_or_else(|| { "expected a component name, e.g. `include_component!(Counter)`".to_owned() })?; if trees.next().is_some() { return Err("expected exactly one component name".to_owned()); } match first { proc_macro::TokenTree::Ident(ident) => Ok(ident.to_string()), other => Err(format!( "expected a bare component name, e.g. `include_component!(Counter)`; found `{other}`" )), } } /// `component_str!` takes exactly one string literal. fn string_argument(input: TokenStream) -> Result { let mut trees = input.into_iter(); let first = trees .next() .ok_or_else(|| "expected a string literal argument".to_owned())?; if trees.next().is_some() { return Err("expected exactly one string literal argument".to_owned()); } match litrs::StringLit::try_from(&first) { Ok(lit) => Ok(lit.value().to_owned()), Err(_) => Err("expected a string literal".to_owned()), } } fn compile_error(message: &str) -> TokenStream { let message = proc_macro2::Literal::string(message); quote::quote! { ::core::compile_error!(#message); }.into() } /// Keep the first occurrence of each directory, comparing by canonical path so /// the same directory reached two ways collapses to one. fn dedup_dirs(dirs: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); dirs.into_iter() .filter(|dir| seen.insert(dir.canonicalize().unwrap_or_else(|_| dir.clone()))) .collect() } #[cfg(test)] mod tests { use super::path_from_metadata; #[test] fn metadata_component_path_parses() { assert_eq!( path_from_metadata("[package]\nname = \"x\"", "component-path"), None ); assert_eq!( path_from_metadata( "[package.metadata.guiduck]\ncomponent-path = [\"components\", \"src/widgets\"]", "component-path" ), Some(vec!["components".to_owned(), "src/widgets".to_owned()]) ); assert_eq!( path_from_metadata( "[package.metadata.guiduck]\ncomponent-path = [\n \"a\",\n \"b\",\n]\n[other]", "component-path" ), Some(vec!["a".to_owned(), "b".to_owned()]) ); // A same-named key in a different section is not ours. assert_eq!( path_from_metadata( "[package.metadata.wrong]\ncomponent-path = [\"x\"]", "component-path" ), None ); } #[test] fn metadata_asset_and_widget_paths_parse_through_the_same_reader() { // The three search paths are one mechanism keyed by name, so this is // really asserting they cannot drift apart. assert_eq!( path_from_metadata( "[package.metadata.guiduck]\nasset-path = [\"assets\", \"vendor/icons\"]", "asset-path" ), Some(vec!["assets".to_owned(), "vendor/icons".to_owned()]) ); assert_eq!( path_from_metadata( "[package.metadata.guiduck]\nwidget-path = [\"widgets\", \"vendor/widgets\"]", "widget-path" ), Some(vec!["widgets".to_owned(), "vendor/widgets".to_owned()]) ); // Each key sees only its own entry. let all = "[package.metadata.guiduck]\ncomponent-path = [\"c\"]\n\ asset-path = [\"a\"]\nwidget-path = [\"w\"]"; assert_eq!( path_from_metadata(all, "component-path"), Some(vec!["c".to_owned()]) ); assert_eq!( path_from_metadata(all, "asset-path"), Some(vec!["a".to_owned()]) ); assert_eq!( path_from_metadata(all, "widget-path"), Some(vec!["w".to_owned()]) ); } }