hblank-macros 0.1.1

Macros for Hblank component fixtures
Documentation
//! Procedural macros for Hblank.

use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{
    Attribute, Data, DeriveInput, Error, Expr, ExprLit, Fields, FnArg, ItemFn, Lit, LitStr, Meta,
    Type, parse_macro_input,
};

#[proc_macro_derive(HblankProps, attributes(hblank))]
pub fn derive_hblank_props(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    expand_hblank_props(input)
        .unwrap_or_else(Error::into_compile_error)
        .into()
}

fn expand_hblank_props(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let name = input.ident;
    let Data::Struct(data) = input.data else {
        return Err(Error::new_spanned(
            name,
            "HblankProps can only be derived for structs with named fields",
        ));
    };
    let Fields::Named(fields) = data.fields else {
        return Err(Error::new_spanned(
            name,
            "HblankProps requires named fields",
        ));
    };

    let mut definitions = Vec::with_capacity(fields.named.len());
    let mut readers = Vec::with_capacity(fields.named.len());
    let mut writers = Vec::with_capacity(fields.named.len());

    for field in fields.named {
        let ident = field
            .ident
            .ok_or_else(|| Error::new_spanned(&field.ty, "HblankProps requires named fields"))?;
        let ty = field.ty;
        let id = ident.to_string();
        let label = field_label(&field.attrs)?.unwrap_or_else(|| humanize(&id));
        let docs = docs(&field.attrs);

        definitions.push(quote! {
            ::hblank::ControlDefinition {
                id: #id,
                label: #label,
                docs: #docs,
                kind: <#ty as ::hblank::__private::ControlField>::KIND,
            }
        });
        readers.push(quote! {
            #id => Some(<#ty as ::hblank::__private::ControlField>::to_control_value(&self.#ident))
        });
        writers.push(quote! {
            #id => <#ty as ::hblank::__private::ControlField>::set_control_value(
                &mut self.#ident,
                #id,
                value,
            )
        });
    }

    Ok(quote! {
        impl ::hblank::HblankProps for #name {
            fn definitions(&self) -> &'static [::hblank::ControlDefinition] {
                const DEFINITIONS: &[::hblank::ControlDefinition] = &[
                    #(#definitions),*
                ];
                DEFINITIONS
            }

            fn control_value(&self, id: &str) -> Option<::hblank::ControlValue> {
                match id {
                    #(#readers,)*
                    _ => None,
                }
            }

            fn set_control(
                &mut self,
                id: &str,
                value: ::hblank::ControlValue,
            ) -> Result<(), ::hblank::ControlError> {
                match id {
                    #(#writers,)*
                    _ => Err(::hblank::ControlError::UnknownControl(id.to_owned())),
                }
            }

            fn clone_box(&self) -> Box<dyn ::hblank::HblankProps> {
                Box::new(self.clone())
            }

            fn as_any(&self) -> &dyn ::std::any::Any {
                self
            }
        }
    })
}

#[proc_macro_derive(HblankEnum, attributes(hblank))]
pub fn derive_hblank_enum(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    expand_hblank_enum(input)
        .unwrap_or_else(Error::into_compile_error)
        .into()
}

fn expand_hblank_enum(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let name = input.ident;
    let Data::Enum(data) = input.data else {
        return Err(Error::new_spanned(
            name,
            "HblankEnum can only be derived for enums",
        ));
    };

    let mut variants = Vec::with_capacity(data.variants.len());
    let mut names = Vec::with_capacity(data.variants.len());
    for variant in data.variants {
        if !matches!(variant.fields, Fields::Unit) {
            return Err(Error::new_spanned(
                variant,
                "HblankEnum variants cannot contain data",
            ));
        }
        let ident = variant.ident;
        let label = field_label(&variant.attrs)?.unwrap_or_else(|| humanize(&ident.to_string()));
        variants.push(ident);
        names.push(label);
    }

    Ok(quote! {
        impl ::hblank::HblankEnum for #name {
            const VARIANTS: &'static [&'static str] = &[#(#names),*];

            fn variant_name(&self) -> &'static str {
                match self {
                    #(Self::#variants => #names),*
                }
            }

            fn from_variant_name(value: &str) -> Option<Self> {
                match value {
                    #(#names => Some(Self::#variants),)*
                    _ => None,
                }
            }
        }
    })
}

#[derive(Default)]
struct FixtureArgs {
    id: Option<LitStr>,
    title: Option<LitStr>,
    group: Option<LitStr>,
}

#[proc_macro_attribute]
pub fn fixture(args: TokenStream, input: TokenStream) -> TokenStream {
    let mut fixture_args = FixtureArgs::default();
    let parser = syn::meta::parser(|meta| {
        if meta.path.is_ident("id") {
            fixture_args.id = Some(meta.value()?.parse()?);
        } else if meta.path.is_ident("title") {
            fixture_args.title = Some(meta.value()?.parse()?);
        } else if meta.path.is_ident("group") {
            fixture_args.group = Some(meta.value()?.parse()?);
        } else {
            return Err(meta.error("expected one of: id, title, group"));
        }
        Ok(())
    });
    syn::parse_macro_input!(args with parser);
    let function = parse_macro_input!(input as ItemFn);
    expand_fixture(fixture_args, &function)
        .unwrap_or_else(Error::into_compile_error)
        .into()
}

fn expand_fixture(args: FixtureArgs, function: &ItemFn) -> syn::Result<proc_macro2::TokenStream> {
    if function.sig.asyncness.is_some() {
        return Err(Error::new_spanned(
            &function.sig,
            "Hblank fixtures must render synchronously",
        ));
    }
    if function.sig.inputs.len() != 3 {
        return Err(Error::new_spanned(
            &function.sig.inputs,
            "Hblank fixtures take exactly (&Props, &mut gpui::Window, &mut gpui::App)",
        ));
    }

    let first = function
        .sig
        .inputs
        .first()
        .ok_or_else(|| Error::new_spanned(&function.sig, "missing props argument"))?;
    let FnArg::Typed(first) = first else {
        return Err(Error::new_spanned(
            first,
            "the first fixture argument must be &Props",
        ));
    };
    let Type::Reference(props_reference) = first.ty.as_ref() else {
        return Err(Error::new_spanned(
            &first.ty,
            "the first fixture argument must be &Props",
        ));
    };
    if props_reference.mutability.is_some() {
        return Err(Error::new_spanned(
            &first.ty,
            "fixture props are immutable; mutate them through harness controls",
        ));
    }
    let props_type = props_reference.elem.as_ref();
    let function_name = &function.sig.ident;
    let builder_name = format_ident!("__hblank_build_{}", function_name);
    let function_docs = docs(&function.attrs);
    let title = args.title.unwrap_or_else(|| {
        LitStr::new(&humanize(&function_name.to_string()), function_name.span())
    });
    let group = args
        .group
        .map_or_else(|| quote!(module_path!()), |group| quote!(#group));
    let id = args.id.map_or_else(
        || quote!(concat!(module_path!(), "::", stringify!(#function_name))),
        |id| quote!(#id),
    );

    Ok(quote! {
        #function

        #[doc(hidden)]
        fn #builder_name() -> ::hblank::FixtureDefinition {
            fn render(
                props: &dyn ::hblank::HblankProps,
                window: &mut ::hblank::gpui::Window,
                cx: &mut ::hblank::gpui::App,
            ) -> ::hblank::gpui::AnyElement {
                let props = props
                    .as_any()
                    .downcast_ref::<#props_type>()
                    .expect("Hblank fixture received the wrong props type");
                ::hblank::gpui::IntoElement::into_any_element(
                    #function_name(props, window, cx),
                )
            }

            ::hblank::FixtureDefinition::new(
                ::hblank::FixtureMetadata {
                    id: #id,
                    title: #title,
                    group: #group,
                    docs: #function_docs,
                    source: file!(),
                    line: line!(),
                },
                Box::new(<#props_type as ::std::default::Default>::default()),
                render,
            )
        }

        ::hblank::__private::inventory::submit! {
            ::hblank::FixtureRegistration { build: #builder_name }
        }
    })
}

fn docs(attributes: &[Attribute]) -> String {
    attributes
        .iter()
        .filter_map(|attribute| {
            if !attribute.path().is_ident("doc") {
                return None;
            }
            let Meta::NameValue(name_value) = &attribute.meta else {
                return None;
            };
            let Expr::Lit(ExprLit {
                lit: Lit::Str(value),
                ..
            }) = &name_value.value
            else {
                return None;
            };
            Some(value.value().trim().to_owned())
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn field_label(attributes: &[Attribute]) -> syn::Result<Option<String>> {
    let mut label = None;
    for attribute in attributes {
        if !attribute.path().is_ident("hblank") {
            continue;
        }
        attribute.parse_nested_meta(|meta| {
            if meta.path.is_ident("label") {
                let value: LitStr = meta.value()?.parse()?;
                label = Some(value.value());
                Ok(())
            } else {
                Err(meta.error("expected label = \"\""))
            }
        })?;
    }
    Ok(label)
}

fn humanize(identifier: &str) -> String {
    let mut output = String::with_capacity(identifier.len() + 4);
    let mut previous_lowercase = false;
    for (index, character) in identifier.chars().enumerate() {
        if character == '_' || character == '-' {
            if !output.ends_with(' ') {
                output.push(' ');
            }
            previous_lowercase = false;
            continue;
        }
        if character.is_uppercase() && previous_lowercase {
            output.push(' ');
        }
        if index == 0 {
            output.extend(character.to_uppercase());
        } else {
            output.push(character);
        }
        previous_lowercase = character.is_lowercase();
    }
    output
}