lib.rs
raw
//! 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<String> = resolver
.visited
.iter()
.map(|p| p.display().to_string())
.collect();
let search_dirs: Vec<String> = 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<String> =
asset_path.iter().map(|d| d.display().to_string()).collect();
let widget_dirs: Vec<String> = 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<Vec<std::path::PathBuf>, 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<Vec<std::path::PathBuf>, 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<Vec<std::path::PathBuf>, String> {
configured_dirs(manifest_dir, "widget-path", DEFAULT_WIDGET_DIR)
}
fn configured_dirs(
manifest_dir: &std::path::Path,
key: &str,
default: &str,
) -> Result<Vec<std::path::PathBuf>, 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<std::collections::BTreeMap<String, String>, 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 `<key> = ["…", …]` 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<Vec<String>> {
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, "<inline>");
compile_error(&format!("invalid component\n{rendered}"))
}
}
}
/// `include_component!` takes exactly one bare component name.
fn ident_argument(input: TokenStream) -> Result<String, String> {
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<String, String> {
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<std::path::PathBuf>) -> Vec<std::path::PathBuf> {
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()])
);
}
}