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, &registry) {
        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()])
        );
    }
}