diff --git a/extensions/pagetop-admin/src/component/admin_frame.rs b/extensions/pagetop-admin/src/component/admin_frame.rs index 8a63ed23..cbdfd371 100644 --- a/extensions/pagetop-admin/src/component/admin_frame.rs +++ b/extensions/pagetop-admin/src/component/admin_frame.rs @@ -68,16 +68,15 @@ impl Component for AdminFrame { } } +#[builder_impl] impl AdminFrame { /// Establece el título de la página. - #[builder_fn] pub fn with_title(mut self, title: Lc) -> Self { self.title = title; self } /// Añade un componente hijo al contenido de la página. - #[builder_fn] pub fn with_child(mut self, op: impl Into) -> Self { self.children.alter_child(op.into()); self diff --git a/extensions/pagetop-admin/src/component/config_form.rs b/extensions/pagetop-admin/src/component/config_form.rs index 304fb621..d8ea0da0 100644 --- a/extensions/pagetop-admin/src/component/config_form.rs +++ b/extensions/pagetop-admin/src/component/config_form.rs @@ -201,8 +201,10 @@ impl Component for ConfigForm { } } +#[builder_impl] impl ConfigForm { /// Crea el componente con el [`SettingsSchema`] dado. + #[builder_skip] pub fn with_schema(schema: SettingsSchema) -> Self { ConfigForm { schema: Some(schema), @@ -210,7 +212,6 @@ impl ConfigForm { } } - #[builder_fn] pub fn with_action_path(mut self, v: impl Into>) -> Self { if let Some(v) = v.into() { self.action_path = Some(v); @@ -218,6 +219,7 @@ impl ConfigForm { self } + #[builder_skip] pub(crate) fn with_saved(mut self, saved: bool, error: bool) -> Self { self.saved = saved; self.error = error; diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs index 0eb3028a..e2e38ee7 100644 --- a/extensions/pagetop-admin/src/settings.rs +++ b/extensions/pagetop-admin/src/settings.rs @@ -3,7 +3,7 @@ //! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`. use pagetop::datetime::Utc; -use pagetop::{Getters, builder_fn}; +use pagetop::{Getters, builder_impl}; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn, }; @@ -128,6 +128,7 @@ pub struct SettingField { default_value: Option, } +#[builder_impl] impl SettingField { /// Crea un campo de texto con nombre y etiqueta. pub fn text(name: impl Into, label: impl Into) -> Self { @@ -185,21 +186,18 @@ impl SettingField { } /// Establece si el campo es obligatorio. - #[builder_fn] pub fn with_required(mut self, required: bool) -> Self { self.required = required; self } /// Añade texto de ayuda bajo el campo. - #[builder_fn] pub fn with_help(mut self, text: impl Into) -> Self { self.help_text = Some(text.into()); self } /// Establece el valor por defecto (como valor JSON serializado). - #[builder_fn] pub fn with_default(mut self, value: &T) -> Self { self.default_value = serde_json::to_string(value).ok(); self @@ -215,6 +213,7 @@ pub struct SettingsSchema { fields: Vec, } +#[builder_impl] impl SettingsSchema { /// Crea un nuevo esquema vacío para el `scope` dado. pub fn new(scope: impl Into) -> Self { @@ -225,7 +224,6 @@ impl SettingsSchema { } /// Añade un campo al esquema. - #[builder_fn] pub fn with_field(mut self, field: SettingField) -> Self { self.fields.push(field); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/badge.rs b/extensions/pagetop-bootsier/src/theme/bs/badge.rs index bd3a40dc..ab058de7 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/badge.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/badge.rs @@ -18,15 +18,15 @@ const EXTRA_COLOR: &str = "bootsier.badge.color"; /// /// let badge = bs::Badge::labeled(Lc::n("Beta")).with_color(BootsierColors::Dark); /// ``` +#[builder_impl] pub trait BadgeBootsier { /// Fuerza un color de la paleta de Bootsier, ignorando el que le correspondería a la `Intent` /// del badge. `None` restablece el comportamiento por defecto (color derivado de la `Intent`). - #[builder_fn] fn with_color(self, color: impl Into>) -> Self; } +#[builder_impl] impl BadgeBootsier for Badge { - #[builder_fn] fn with_color(mut self, color: impl Into>) -> Self { match color.into() { Some(color) => self.alter_prop(PropsOp::set_extra(EXTRA_COLOR, color)), diff --git a/extensions/pagetop-bootsier/src/theme/bs/button.rs b/extensions/pagetop-bootsier/src/theme/bs/button.rs index 6581f95a..56ac3adb 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/button.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/button.rs @@ -33,36 +33,32 @@ const EXTRA_COLOR: &str = "bootsier.button.color"; /// .with_style(button::Style::Solid(Intent::Neutral)) /// .with_color(BootsierColors::Light); /// ``` +#[builder_impl] pub trait ButtonBootsier { /// Marca el botón como activo (`.active`, `aria-pressed="true"`). - #[builder_fn] fn with_active(self, active: bool) -> Self; /// Expande el botón al ancho completo de su contenedor (`w-100`). - #[builder_fn] fn with_full_width(self, full_width: bool) -> Self; /// Fuerza un color de la paleta de Bootsier, ignorando el que le correspondería a la `Intent` /// del botón. `None` restablece el comportamiento por defecto (color derivado de la `Intent`). /// Sin efecto si el estilo del botón es [`Style::Link`] o [`Style::None`]. - #[builder_fn] fn with_color(self, color: impl Into>) -> Self; } +#[builder_impl] impl ButtonBootsier for Button { - #[builder_fn] fn with_active(mut self, active: bool) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_ACTIVE, active)); self } - #[builder_fn] fn with_full_width(mut self, full_width: bool) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_FULL_WIDTH, full_width)); self } - #[builder_fn] fn with_color(mut self, color: impl Into>) -> Self { match color.into() { Some(color) => self.alter_prop(PropsOp::set_extra(EXTRA_COLOR, color)), diff --git a/extensions/pagetop-bootsier/src/theme/bs/container.rs b/extensions/pagetop-bootsier/src/theme/bs/container.rs index e86b5679..67526ab8 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/container.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/container.rs @@ -32,18 +32,18 @@ const EXTRA_WIDTH: &str = "bootsier.container.width"; /// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One))) /// .with_prop(PropsOp::add_classes(class::Rounded::new())); /// ``` +#[builder_impl] pub trait ContainerBootsier { /// Establece el comportamiento del ancho para el contenedor. /// /// Determina si el contenedor aplica los anchos máximos predefinidos para cada punto de /// ruptura, o si ocupa siempre el 100% del ancho disponible, o lo hace hasta un ancho máximo /// explícito. Ver [`Width`] para las variantes disponibles. - #[builder_fn] fn with_width(self, width: Width) -> Self; } +#[builder_impl] impl ContainerBootsier for Container { - #[builder_fn] fn with_width(mut self, width: Width) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_WIDTH, width)); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/dropdown.rs b/extensions/pagetop-bootsier/src/theme/bs/dropdown.rs index bbb08461..338a45fd 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/dropdown.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/dropdown.rs @@ -44,54 +44,46 @@ const EXTRA_MENU_POSITION: &str = "bootsier.dropdown.menu_position"; /// .with_item(bs::dropdown::Item::header(Lc::n("User session"))) /// .with_item(bs::dropdown::Item::button(Lc::n("Sign out"))); /// ``` +#[builder_impl] pub trait DropdownBootsier { /// Indica si el botón del menú está integrado en un grupo de botones. - #[builder_fn] fn with_button_grouped(self, grouped: bool) -> Self; /// Establece la política de cierre automático del menú desplegable. - #[builder_fn] fn with_auto_close(self, auto_close: AutoClose) -> Self; /// Establece la dirección de despliegue del menú. - #[builder_fn] fn with_direction(self, direction: Direction) -> Self; /// Configura la alineación horizontal (con posible comportamiento *responsive* adicional). - #[builder_fn] fn with_menu_align(self, align: MenuAlign) -> Self; /// Configura la posición del menú. - #[builder_fn] fn with_menu_position(self, position: MenuPosition) -> Self; } +#[builder_impl] impl DropdownBootsier for Dropdown { - #[builder_fn] fn with_button_grouped(mut self, grouped: bool) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_BUTTON_GROUPED, grouped)); self } - #[builder_fn] fn with_auto_close(mut self, auto_close: AutoClose) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_AUTO_CLOSE, auto_close)); self } - #[builder_fn] fn with_direction(mut self, direction: Direction) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_DIRECTION, direction)); self } - #[builder_fn] fn with_menu_align(mut self, align: MenuAlign) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_MENU_ALIGN, align)); self } - #[builder_fn] fn with_menu_position(mut self, position: MenuPosition) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_MENU_POSITION, position)); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs index 6de0698e..524b2fa4 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs @@ -22,18 +22,18 @@ const EXTRA_FLOATING_LABEL: &str = "bootsier.form.input.floating_label"; /// .with_placeholder(Lc::n("Enter your name")) /// .with_floating_label(true); /// ``` +#[builder_impl] pub trait InputBootsier { /// Establece si la etiqueta se muestra flotante sobre el campo. /// /// Cuando está activo, la etiqueta se superpone al campo y asciende al enfocarlo o cuando tiene /// contenido. Requiere que el campo tenga un atributo `placeholder` definido; si no se /// especifica, se fuerza `placeholder=""` antes del renderizado. - #[builder_fn] fn with_floating_label(self, floating: bool) -> Self; } +#[builder_impl] impl InputBootsier for Field { - #[builder_fn] fn with_floating_label(mut self, floating: bool) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/select.rs b/extensions/pagetop-bootsier/src/theme/bs/form/select.rs index 4759c4f8..7132ca26 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/select.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/select.rs @@ -24,6 +24,7 @@ const EXTRA_FLOATING_LABEL: &str = "bootsier.form.select.floating_label"; /// .with_item(bs::form::select::Item::new("es", Lc::n("Spanish"))) /// .with_item(bs::form::select::Item::new("en", Lc::n("English"))); /// ``` +#[builder_impl] pub trait SelectBootsier { /// Establece si la etiqueta se muestra flotante sobre el campo. /// @@ -33,12 +34,11 @@ pub trait SelectBootsier { /// Si se usa la etiqueta flotante, se anulan los valores establecidos con /// [`with_multiple()`](form::select::Field::with_multiple) y /// [`with_rows()`](form::select::Field::with_rows) antes del renderizado. - #[builder_fn] fn with_floating_label(self, floating: bool) -> Self; } +#[builder_impl] impl SelectBootsier for Field { - #[builder_fn] fn with_floating_label(mut self, floating: bool) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs b/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs index 264a6d59..8d5573a8 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs @@ -22,6 +22,7 @@ const EXTRA_FLOATING_LABEL: &str = "bootsier.form.textarea.floating_label"; /// .with_placeholder(Lc::n("Write here...")) /// .with_floating_label(true); /// ``` +#[builder_impl] pub trait TextareaBootsier { /// Establece si la etiqueta se muestra flotante sobre el campo. /// @@ -31,12 +32,11 @@ pub trait TextareaBootsier { /// /// Si se usa la etiqueta flotante, se anula el valor establecido con /// [`with_rows()`](form::Textarea::with_rows) antes del renderizado. - #[builder_fn] fn with_floating_label(self, floating: bool) -> Self; } +#[builder_impl] impl TextareaBootsier for Textarea { - #[builder_fn] fn with_floating_label(mut self, floating: bool) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/icon.rs b/extensions/pagetop-bootsier/src/theme/bs/icon.rs index 4fe0f8ec..ea4bb430 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/icon.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/icon.rs @@ -78,6 +78,7 @@ impl Component for Icon { } } +#[builder_impl] impl Icon { pub fn font() -> Self { Self::default().with_icon_kind(IconKind::Font(FontSize::default())) @@ -104,26 +105,22 @@ impl Icon { // **< Icon BUILDER >*************************************************************************** /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } - #[builder_fn] pub fn with_icon_kind(mut self, icon_kind: IconKind) -> Self { self.icon_kind = icon_kind; self } - #[builder_fn] pub fn with_aria_label(mut self, label: Lc) -> Self { self.aria_label.alter_value(label); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/nav.rs b/extensions/pagetop-bootsier/src/theme/bs/nav.rs index 13a9f3d3..ba2079ef 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/nav.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/nav.rs @@ -46,14 +46,14 @@ pub(crate) const EXTRA_IN_NAVBAR: &str = "bootsier.nav.in_navbar"; /// )) /// .with_item(bs::nav::Item::link_disabled(Lc::n("Disabled"), "#")); /// ``` +#[builder_impl] pub trait NavBootsier { /// Cambia el estilo del menú (*Tabs*, *Pills*, *Underline* o *Default*). - #[builder_fn] fn with_kind(self, kind: Kind) -> Self; } +#[builder_impl] impl NavBootsier for Nav { - #[builder_fn] fn with_kind(mut self, kind: Kind) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_KIND, kind)); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs index 26a651fe..82a0bff6 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs @@ -140,6 +140,7 @@ const EXTRA_EXPAND: &str = "bootsier.navbar.expand"; /// .with_item(bs::nav::Item::link(Lc::n("Stock"), "/stock")) /// )); /// ``` +#[builder_impl] pub trait NavbarBootsier { /// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**. fn offcanvas(oc: bs::Offcanvas) -> Self; @@ -151,14 +152,13 @@ pub trait NavbarBootsier { fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> Self; /// Define a partir de qué punto de ruptura la barra de navegación deja de colapsar. - #[builder_fn] fn with_expand(self, bp: BreakPoint) -> Self; /// Define dónde se mostrará la barra de navegación dentro del documento. - #[builder_fn] fn with_position(self, position: bs::navbar::Position) -> Self; } +#[builder_impl] impl NavbarBootsier for Navbar { fn offcanvas(oc: bs::Offcanvas) -> Self { let mut navbar = Self::new(); @@ -187,13 +187,11 @@ impl NavbarBootsier for Navbar { navbar } - #[builder_fn] fn with_expand(mut self, bp: BreakPoint) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_EXPAND, bp)); self } - #[builder_fn] fn with_position(mut self, position: bs::navbar::Position) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_POSITION, position)); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs index 207f756f..1dcabe91 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs @@ -90,25 +90,23 @@ impl Component for Offcanvas { } } +#[builder_impl] impl Offcanvas { // **< Offcanvas BUILDER >********************************************************************** /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } /// Establece el título del encabezado. - #[builder_fn] pub fn with_title(mut self, title: Lc) -> Self { self.title = title; self @@ -123,7 +121,6 @@ impl Offcanvas { /// Por ejemplo, con `BreakPoint::Lg`, será *offcanvas* en móviles y tabletas, y visible /// directamente en pantallas grandes. Por defecto usa `BreakPoint::None` para que sea /// *offcanvas* siempre. - #[builder_fn] pub fn with_breakpoint(mut self, bp: BreakPoint) -> Self { self.breakpoint = bp; self @@ -131,28 +128,24 @@ impl Offcanvas { /// Ajusta la capa de fondo del panel para definir su comportamiento al hacer clic fuera del /// panel. - #[builder_fn] pub fn with_backdrop(mut self, backdrop: bs::offcanvas::Backdrop) -> Self { self.backdrop = backdrop; self } /// Permite o bloquea el desplazamiento de la página principal mientras el panel está abierto. - #[builder_fn] pub fn with_body_scroll(mut self, scrolling: bs::offcanvas::BodyScroll) -> Self { self.body_scroll = scrolling; self } /// Indica desde qué borde de la ventana entra y se ancla el panel. - #[builder_fn] pub fn with_placement(mut self, placement: bs::offcanvas::Placement) -> Self { self.placement = placement; self } /// Fija el estado inicial del panel (oculto o visible al cargar). - #[builder_fn] pub fn with_visibility(mut self, visibility: bs::offcanvas::Visibility) -> Self { self.visibility = visibility; self @@ -160,7 +153,6 @@ impl Offcanvas { /// Añade un nuevo componente al panel o modifica la lista de componentes (`children`) con una /// operación [`ChildOp`]. - #[builder_fn] pub fn with_child(mut self, op: impl Into) -> Self { self.children.alter_child(op.into()); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/sidebar/item.rs b/extensions/pagetop-bootsier/src/theme/bs/sidebar/item.rs index 2305fdd0..49b34932 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/sidebar/item.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/sidebar/item.rs @@ -65,6 +65,7 @@ impl Component for Item { } } +#[builder_impl] impl Item { /// Crea un ítem de navegación con etiqueta, ruta e icono. /// @@ -82,21 +83,18 @@ impl Item { // **< Item BUILDER >*************************************************************************** /// Establece el texto localizable del ítem. - #[builder_fn] pub fn with_label(mut self, label: Lc) -> Self { self.label = label; self } /// Establece la ruta de destino del ítem. - #[builder_fn] pub fn with_route(mut self, route: impl Into>) -> Self { self.route = route.into(); self } /// Establece el nombre del icono de Bootstrap Icons (sin el prefijo `bi-`). - #[builder_fn] pub fn with_icon(mut self, icon: impl Into) -> Self { self.icon = icon.into(); self diff --git a/extensions/pagetop-bootsier/src/theme/bs/sidebar/section.rs b/extensions/pagetop-bootsier/src/theme/bs/sidebar/section.rs index 16fbb793..d4d7805f 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/sidebar/section.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/sidebar/section.rs @@ -40,6 +40,7 @@ impl Component for Section { } } +#[builder_impl] impl Section { /// Crea un encabezado de sección con el título indicado. pub fn titled(title: Lc) -> Self { @@ -49,7 +50,6 @@ impl Section { // **< Section BUILDER >************************************************************************ /// Establece el título localizable de la sección. - #[builder_fn] pub fn with_title(mut self, title: Lc) -> Self { self.title = title; self diff --git a/extensions/pagetop-menu/src/component/menu_block.rs b/extensions/pagetop-menu/src/component/menu_block.rs index afcd79b4..2bd24030 100644 --- a/extensions/pagetop-menu/src/component/menu_block.rs +++ b/extensions/pagetop-menu/src/component/menu_block.rs @@ -107,6 +107,7 @@ impl Component for MenuBlock { } } +#[builder_impl] impl MenuBlock { /// Crea un `MenuBlock` para el menú con el `machine_name` dado. pub fn with(menu_name: impl Into) -> Self { @@ -115,7 +116,6 @@ impl MenuBlock { block } - #[builder_fn] pub fn with_show_title(mut self, v: impl Into>) -> Self { if let Some(v) = v.into() { self.show_title = v; @@ -125,13 +125,11 @@ impl MenuBlock { /// Establece la profundidad máxima de nodos a incluir. El efectivo nunca supera `2`, por muy /// alto que sea el valor indicado (ver "Limitaciones conocidas" en [`MenuBlock`]). - #[builder_fn] pub fn with_max_depth(mut self, v: impl Into>) -> Self { self.max_depth = v.into(); self } - #[builder_fn] pub fn with_include_disabled(mut self, v: impl Into>) -> Self { if let Some(v) = v.into() { self.include_disabled = v; @@ -139,7 +137,6 @@ impl MenuBlock { self } - #[builder_fn] pub fn with_hide_when_empty(mut self, v: impl Into>) -> Self { if let Some(v) = v.into() { self.hide_when_empty = v; diff --git a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs index 9d9d95e2..c049883c 100644 --- a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs +++ b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs @@ -73,6 +73,7 @@ impl Component for MenuBreadcrumb { } } +#[builder_impl] impl MenuBreadcrumb { /// Crea un `MenuBreadcrumb` para el menú con el `machine_name` dado. pub fn with(menu_name: impl Into) -> Self { @@ -81,7 +82,6 @@ impl MenuBreadcrumb { bc } - #[builder_fn] pub fn with_include_current(mut self, v: impl Into>) -> Self { if let Some(v) = v.into() { self.include_current = v; diff --git a/extensions/pagetop-user/src/component/admin/admin_password_form.rs b/extensions/pagetop-user/src/component/admin/admin_password_form.rs index 85befebf..3c418660 100644 --- a/extensions/pagetop-user/src/component/admin/admin_password_form.rs +++ b/extensions/pagetop-user/src/component/admin/admin_password_form.rs @@ -43,22 +43,20 @@ impl Component for AdminPasswordForm { } } +#[builder_impl] impl AdminPasswordForm { // **< AdminPasswordForm BUILDER >************************************************************** - #[builder_fn] pub(crate) fn with_user_id(mut self, user_id: i32) -> Self { self.user_id = user_id; self } - #[builder_fn] pub(crate) fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self } - #[builder_fn] pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { self.waypoint = waypoint.into(); self diff --git a/extensions/pagetop-user/src/component/admin/role_form.rs b/extensions/pagetop-user/src/component/admin/role_form.rs index d8efba69..5bbfea89 100644 --- a/extensions/pagetop-user/src/component/admin/role_form.rs +++ b/extensions/pagetop-user/src/component/admin/role_form.rs @@ -98,52 +98,45 @@ impl Component for RoleForm { } } +#[builder_impl] impl RoleForm { // **< RoleForm BUILDER >*********************************************************************** - #[builder_fn] pub(crate) fn with_mode(mut self, mode: RoleFormMode) -> Self { self.mode = mode; self } - #[builder_fn] pub(crate) fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self } - #[builder_fn] pub(crate) fn with_role_id(mut self, role_id: impl Into>) -> Self { self.role_id = role_id.into(); self } - #[builder_fn] pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { self.waypoint = waypoint.into(); self } - #[builder_fn] pub(crate) fn with_machine_name(mut self, machine_name: impl Into) -> Self { self.machine_name = machine_name.into(); self } - #[builder_fn] pub(crate) fn with_label(mut self, label: impl Into) -> Self { self.label = label.into(); self } - #[builder_fn] pub(crate) fn with_description(mut self, description: impl Into) -> Self { self.description = description.into(); self } - #[builder_fn] pub(crate) fn with_weight(mut self, weight: i32) -> Self { self.weight = weight; self diff --git a/extensions/pagetop-user/src/component/admin/role_permissions_form.rs b/extensions/pagetop-user/src/component/admin/role_permissions_form.rs index 9263588e..ad1b237c 100644 --- a/extensions/pagetop-user/src/component/admin/role_permissions_form.rs +++ b/extensions/pagetop-user/src/component/admin/role_permissions_form.rs @@ -61,28 +61,25 @@ impl Component for RolePermissionsForm { } } +#[builder_impl] impl RolePermissionsForm { // **< RolePermissionsForm BUILDER >************************************************************ - #[builder_fn] pub(crate) fn with_role_id(mut self, role_id: i32) -> Self { self.role_id = role_id; self } - #[builder_fn] pub(crate) fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self } - #[builder_fn] pub(crate) fn with_groups(mut self, groups: PermissionGroups) -> Self { self.groups = groups; self } - #[builder_fn] pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { self.waypoint = waypoint.into(); self diff --git a/extensions/pagetop-user/src/component/admin/role_table.rs b/extensions/pagetop-user/src/component/admin/role_table.rs index 9c28ead8..8bd97db0 100644 --- a/extensions/pagetop-user/src/component/admin/role_table.rs +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -128,52 +128,45 @@ impl Component for RoleTable { } } +#[builder_impl] impl RoleTable { // **< RoleTable BUILDER >********************************************************************** - #[builder_fn] pub(crate) fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } - #[builder_fn] pub(crate) fn with_items(mut self, items: Vec) -> Self { self.items = items; self } - #[builder_fn] pub(crate) fn with_message(mut self, message: impl Into>) -> Self { self.message = message.into(); self } - #[builder_fn] pub(crate) fn with_sort(mut self, sort: RoleSortField) -> Self { self.sort = sort; self } - #[builder_fn] pub(crate) fn with_dir(mut self, dir: SortDir) -> Self { self.dir = dir; self } - #[builder_fn] pub(crate) fn with_page(mut self, page: u64) -> Self { self.page = page; self } - #[builder_fn] pub(crate) fn with_per_page(mut self, per_page: u64) -> Self { self.per_page = per_page; self } - #[builder_fn] pub(crate) fn with_total(mut self, total: u64) -> Self { self.total = total; self diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index 4ee68fcd..ecaf8217 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -121,76 +121,65 @@ impl Component for UserForm { } } +#[builder_impl] impl UserForm { // **< UserForm BUILDER >*********************************************************************** - #[builder_fn] pub(crate) fn with_mode(mut self, mode: UserFormMode) -> Self { self.mode = mode; self } - #[builder_fn] pub(crate) fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self } - #[builder_fn] pub(crate) fn with_user_id(mut self, user_id: impl Into>) -> Self { self.user_id = user_id.into(); self } - #[builder_fn] pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { self.waypoint = waypoint.into(); self } - #[builder_fn] pub(crate) fn with_username(mut self, username: impl Into) -> Self { self.username = username.into(); self } - #[builder_fn] pub(crate) fn with_email(mut self, email: impl Into) -> Self { self.email = email.into(); self } - #[builder_fn] pub(crate) fn with_display_name(mut self, display_name: impl Into) -> Self { self.display_name = display_name.into(); self } - #[builder_fn] pub(crate) fn with_language(mut self, language: impl Into) -> Self { self.language = language.into(); self } - #[builder_fn] pub(crate) fn with_timezone(mut self, timezone: impl Into) -> Self { self.timezone = timezone.into(); self } - #[builder_fn] pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self { self.roles = roles; self } - #[builder_fn] pub(crate) fn with_allow_admin_field(mut self, allow_admin_field: bool) -> Self { self.allow_admin_field = allow_admin_field; self } - #[builder_fn] pub(crate) fn with_is_admin(mut self, is_admin: bool) -> Self { self.is_admin = is_admin; self diff --git a/extensions/pagetop-user/src/component/admin/user_roles_form.rs b/extensions/pagetop-user/src/component/admin/user_roles_form.rs index cbf9e779..927c07b0 100644 --- a/extensions/pagetop-user/src/component/admin/user_roles_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_roles_form.rs @@ -43,28 +43,25 @@ impl Component for UserRolesForm { } } +#[builder_impl] impl UserRolesForm { // **< UserRolesForm BUILDER >****************************************************************** - #[builder_fn] pub(crate) fn with_user_id(mut self, user_id: i32) -> Self { self.user_id = user_id; self } - #[builder_fn] pub(crate) fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self } - #[builder_fn] pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self { self.roles = roles; self } - #[builder_fn] pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { self.waypoint = waypoint.into(); self diff --git a/extensions/pagetop-user/src/component/admin/user_table.rs b/extensions/pagetop-user/src/component/admin/user_table.rs index d8b67b17..b32315eb 100644 --- a/extensions/pagetop-user/src/component/admin/user_table.rs +++ b/extensions/pagetop-user/src/component/admin/user_table.rs @@ -100,52 +100,45 @@ impl Component for UserTable { } } +#[builder_impl] impl UserTable { // **< UserTable BUILDER >********************************************************************** - #[builder_fn] pub(crate) fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } - #[builder_fn] pub(crate) fn with_items(mut self, items: Vec) -> Self { self.items = items; self } - #[builder_fn] pub(crate) fn with_sort(mut self, sort: UserSortField) -> Self { self.sort = sort; self } - #[builder_fn] pub(crate) fn with_dir(mut self, dir: SortDir) -> Self { self.dir = dir; self } - #[builder_fn] pub(crate) fn with_query(mut self, query: impl Into>) -> Self { self.query = query.into(); self } - #[builder_fn] pub(crate) fn with_page(mut self, page: u64) -> Self { self.page = page; self } - #[builder_fn] pub(crate) fn with_per_page(mut self, per_page: u64) -> Self { self.per_page = per_page; self } - #[builder_fn] pub(crate) fn with_total(mut self, total: u64) -> Self { self.total = total; self diff --git a/extensions/pagetop-user/src/component/login_form.rs b/extensions/pagetop-user/src/component/login_form.rs index 7012331a..ec582d0b 100644 --- a/extensions/pagetop-user/src/component/login_form.rs +++ b/extensions/pagetop-user/src/component/login_form.rs @@ -97,14 +97,13 @@ fn links(allow_registration: bool) -> Html { }) } +#[builder_impl] impl LoginForm { - #[builder_fn] pub fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self } - #[builder_fn] pub fn with_waypoint(mut self, waypoint: impl Into) -> Self { self.waypoint = waypoint.into(); self diff --git a/extensions/pagetop-user/src/component/password_confirm.rs b/extensions/pagetop-user/src/component/password_confirm.rs index 93c445b1..480b2ca1 100644 --- a/extensions/pagetop-user/src/component/password_confirm.rs +++ b/extensions/pagetop-user/src/component/password_confirm.rs @@ -48,18 +48,17 @@ impl Component for PasswordConfirm { } } +#[builder_impl] impl PasswordConfirm { // **< PasswordConfirm BUILDER >******************************************************************** /// Establece la etiqueta del campo de contraseña (por defecto, "field-password"). - #[builder_fn] pub(crate) fn with_password_label(mut self, label: Lc) -> Self { self.password_label = label; self } /// Establece la etiqueta del campo de confirmación (por defecto, "field-confirm-password"). - #[builder_fn] pub(crate) fn with_confirm_label(mut self, label: Lc) -> Self { self.confirm_label = label; self diff --git a/extensions/pagetop-user/src/component/password_reset_confirm_form.rs b/extensions/pagetop-user/src/component/password_reset_confirm_form.rs index dc185c07..172af055 100644 --- a/extensions/pagetop-user/src/component/password_reset_confirm_form.rs +++ b/extensions/pagetop-user/src/component/password_reset_confirm_form.rs @@ -44,8 +44,8 @@ impl Component for PasswordResetConfirmForm { } } +#[builder_impl] impl PasswordResetConfirmForm { - #[builder_fn] pub fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self diff --git a/extensions/pagetop-user/src/component/password_reset_form.rs b/extensions/pagetop-user/src/component/password_reset_form.rs index 5e0bdb29..89bb3361 100644 --- a/extensions/pagetop-user/src/component/password_reset_form.rs +++ b/extensions/pagetop-user/src/component/password_reset_form.rs @@ -49,8 +49,8 @@ fn back_to_login() -> Html { }) } +#[builder_impl] impl PasswordResetForm { - #[builder_fn] pub fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self diff --git a/extensions/pagetop-user/src/component/register_form.rs b/extensions/pagetop-user/src/component/register_form.rs index 66ac153b..5eec2871 100644 --- a/extensions/pagetop-user/src/component/register_form.rs +++ b/extensions/pagetop-user/src/component/register_form.rs @@ -45,8 +45,8 @@ impl Component for RegisterForm { } } +#[builder_impl] impl RegisterForm { - #[builder_fn] pub fn with_error(mut self, error: impl Into>) -> Self { self.error = error.into(); self diff --git a/helpers/pagetop-macros/src/builder.rs b/helpers/pagetop-macros/src/builder.rs new file mode 100644 index 00000000..8c6bf696 --- /dev/null +++ b/helpers/pagetop-macros/src/builder.rs @@ -0,0 +1,449 @@ +//! Núcleo compartido de `#[builder_fn]` y `#[builder_impl]`. + +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned}; +use syn::spanned::Spanned; +use syn::{ + Attribute, Block, FnArg, Ident, ImplItem, ImplItemFn, ItemImpl, ItemTrait, Pat, ReturnType, + Signature, TraitItem, TraitItemFn, Type, Visibility, WhereClause, parse_quote, parse2, +}; + +// Genera el código común a `#[builder_fn]` y `#[builder_impl]` para un único método `with_...()`. +// +// Recibe las piezas ya extraídas de un `ImplItemFn` o de un `TraitItemFn`: firma, atributos, +// visibilidad (ausente en métodos de trait), cuerpo (ausente en la declaración de un método trait +// sin implementación por defecto) y si el receptor es de trait (`self`) o de impl (`mut self`). +fn expand_builder( + sig: &Signature, + attrs: &[Attribute], + vis: Option<&Visibility>, + body_opt: Option<&Block>, + is_trait: bool, +) -> TokenStream { + let with_name = sig.ident.clone(); + let with_name_str = sig.ident.to_string(); + + // Valida el nombre del método. + if !with_name_str.starts_with("with_") { + return quote_spanned! { + sig.ident.span() => compile_error!("expected a named `with_...()` method"); + }; + } + + // Sólo se exige `pub` en `impl` (en `trait` no aplica). + let vis_pub = match (is_trait, vis) { + (false, Some(v)) => quote! { #v }, + _ => quote! {}, + }; + + // Validaciones comunes. + if sig.asyncness.is_some() { + return quote_spanned! { + sig.asyncness.span() => compile_error!("`with_...()` cannot be `async`"); + }; + } + if sig.constness.is_some() { + return quote_spanned! { + sig.constness.span() => compile_error!("`with_...()` cannot be `const`"); + }; + } + if sig.abi.is_some() { + return quote_spanned! { + sig.abi.span() => compile_error!("`with_...()` cannot be `extern`"); + }; + } + if sig.unsafety.is_some() { + return quote_spanned! { + sig.unsafety.span() => compile_error!("`with_...()` cannot be `unsafe`"); + }; + } + + // En `impl` se exige exactamente `mut self`; y en `trait` se exige `self` (sin &). + let receiver_ok = match sig.inputs.first() { + Some(FnArg::Receiver(r)) => { + // Rechaza `self: SomeType`. + if r.colon_token.is_some() { + false + } else if is_trait { + // Exactamente `self` (sin &, sin mut). + r.reference.is_none() && r.mutability.is_none() + } else { + // Exactamente `mut self`. + r.reference.is_none() && r.mutability.is_some() + } + } + _ => false, + }; + if !receiver_ok { + let msg = if is_trait { + "expected `self` (not `mut self`, `&self` or `&mut self`) in trait method" + } else { + "expected first argument to be exactly `mut self`" + }; + let err = sig + .inputs + .first() + .map(|a| a.span()) + .unwrap_or(sig.ident.span()); + return quote_spanned! { + err => compile_error!(#msg); + }; + } + + // Valida que el método devuelve exactamente `Self`. + match &sig.output { + ReturnType::Type(_, ty) => match ty.as_ref() { + Type::Path(p) if p.qself.is_none() && p.path.is_ident("Self") => {} + _ => { + return quote_spanned! { + ty.span() => compile_error!("expected return type to be exactly `Self`"); + }; + } + }, + _ => { + return quote_spanned! { + sig.output.span() => compile_error!("expected return type to be exactly `Self`"); + }; + } + } + + // Genera el nombre del método `alter_...()`. + let stem = with_name_str.strip_prefix("with_").expect("validated"); + let alter_ident = Ident::new(&format!("alter_{stem}"), with_name.span()); + + // Extrae genéricos y cláusulas `where`. + let generics = &sig.generics; + let where_clause = &sig.generics.where_clause; + + // Extrae identificadores de los argumentos para la llamada (sin `mut` ni patrones complejos). + let args: Vec<_> = sig.inputs.iter().skip(1).collect(); + let call_idents: Vec = { + let mut v = Vec::new(); + for arg in sig.inputs.iter().skip(1) { + match arg { + FnArg::Typed(pat) => { + if let Pat::Ident(pat_ident) = pat.pat.as_ref() { + v.push(pat_ident.ident.clone()); + } else { + return quote_spanned! { + pat.pat.span() => compile_error!( + "each parameter must be a simple identifier, e.g. `value: T`" + ); + }; + } + } + _ => { + return quote_spanned! { + arg.span() => compile_error!("unexpected receiver in parameter list"); + }; + } + } + } + v + }; + + // Separa atributos de documentación y resto. + let mut doc_attrs = Vec::new(); + let mut other_attrs = Vec::new(); + let mut non_doc_or_inline_attrs = Vec::new(); + + for a in attrs.iter() { + let p = a.path(); + if p.is_ident("doc") { + doc_attrs.push(a.clone()); + } else { + other_attrs.push(a.clone()); + if !p.is_ident("inline") { + non_doc_or_inline_attrs.push(a.clone()); + } + } + } + + // Firma resumida de la función `alter_...()` para mostrarla en la doc de `with_...()`. + let alter_sig_tokens = if args.is_empty() { + // Sin argumentos sólo se muestra `&mut self` (puede que no tenga mucho sentido). + quote! { #vis_pub fn #alter_ident #generics (&mut self) -> &mut Self #where_clause } + } else { + // Con argumentos se muestra `&mut self, ...`. + quote! { #vis_pub fn #alter_ident #generics (&mut self, ...) -> &mut Self #where_clause } + }; + + // Normaliza espacios raros tipo `& mut`. + let alter_sig_str = alter_sig_tokens.to_string().replace("& mut", "&mut"); + + // Nombre de la función `alter_...()` como alias de búsqueda. + let alter_name_str = alter_ident.to_string(); + + // Texto introductorio para la documentación adicional de `with_...()`. + let with_alter_title = format!( + "# {} el método `{}()` generado por [`#[builder_fn]`](pagetop_macros::builder_fn)", + if doc_attrs.is_empty() { + "Añade" + } else { + "También añade" + }, + alter_name_str + ); + let with_alter_doc = concat!( + "Permite modificar la instancia (`&mut self`) con los mismos argumentos ", + "pero sin consumirla." + ); + + // Atributos completos que se aplican siempre a `with_...()`. + let with_prefix = quote! { + #(#other_attrs)* + #(#doc_attrs)* + #[doc(alias = #alter_name_str)] + #[doc = ""] + #[doc = #with_alter_title] + #[doc = #with_alter_doc] + #[doc = "```text"] + #[doc = #alter_sig_str] + #[doc = "```"] + }; + + // Genera el código final. + match body_opt { + None => { + quote! { + #with_prefix + fn #with_name #generics (self, #(#args),*) -> Self #where_clause; + + #(#non_doc_or_inline_attrs)* + #[doc(hidden)] + fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause; + } + } + Some(body) => { + // Si no se indicó ninguna forma de `inline`, fuerza `#[inline]` para `with_...()`. + let force_inline = if attrs.iter().any(|a| a.path().is_ident("inline")) { + quote! {} + } else { + quote! { #[inline] } + }; + + let with_fn = if is_trait { + // Un cuerpo por defecto se compila junto a la propia definición del trait, donde + // `Self` podría no ser `Sized`; a diferencia de una declaración sin cuerpo (rama + // `None`), aquí sí hace falta acotarlo explícitamente para poder devolver `Self` + // por valor. Se añade la cota sobre el `Punctuated` ya existente (en vez de + // concatenar tokens a mano) para que la coma se coloque bien incluso si el `where` + // original ya termina en una. + let with_where: WhereClause = match where_clause { + Some(wc) => { + let mut wc = wc.clone(); + wc.predicates.push(parse_quote!(Self: Sized)); + wc + } + None => parse_quote!(where Self: Sized), + }; + quote! { + #with_prefix + #force_inline + #vis_pub fn #with_name #generics (self, #(#args),*) -> Self #with_where { + let mut s = self; + s.#alter_ident(#(#call_idents),*); + s + } + } + } else { + quote! { + #with_prefix + #force_inline + #vis_pub fn #with_name #generics (mut self, #(#args),*) -> Self #where_clause { + self.#alter_ident(#(#call_idents),*); + self + } + } + }; + + quote! { + #with_fn + + #(#non_doc_or_inline_attrs)* + #[doc(hidden)] + #vis_pub fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause { + #body + } + } + } + } +} + +// Implementa `#[builder_fn]`: detecta si el ítem anotado es un método de `impl` o de `trait`, +// extrae sus piezas comunes y delega en `expand_builder`. +pub(crate) fn expand_fn(item: TokenStream) -> TokenStream { + enum Kind { + Impl(ImplItemFn), + Trait(TraitItemFn), + } + + // Detecta si estamos en `impl` o `trait`. + let kind = if let Ok(it) = parse2::(item.clone()) { + Kind::Impl(it) + } else if let Ok(tt) = parse2::(item.clone()) { + Kind::Trait(tt) + } else { + return quote! { + compile_error!("#[builder_fn] only supports methods in `impl` blocks or `trait` items"); + }; + }; + + // Extrae piezas comunes (sig, attrs, vis, bloque?, es_trait?). + let (sig, attrs, vis, body_opt, is_trait) = match &kind { + Kind::Impl(m) => (&m.sig, &m.attrs, Some(&m.vis), Some(&m.block), false), + Kind::Trait(t) => (&t.sig, &t.attrs, None, t.default.as_ref(), true), + }; + + expand_builder(sig, attrs, vis, body_opt, is_trait) +} + +// Comprueba si la lista de atributos contiene uno con el nombre dado. +fn has_attr(attrs: &[Attribute], name: &str) -> bool { + attrs.iter().any(|a| a.path().is_ident(name)) +} + +// Decide qué hacer con un único método durante el barrido de `#[builder_impl]`, sea de un `impl` +// o de un `trait`. Devuelve `None` si el método no es un `with_...()` a barrer (ni siquiera marcado +// con `#[builder_skip]`), en cuyo caso el llamador lo reemite intacto. +fn sweep_with_fn( + sig: &Signature, + attrs: &[Attribute], + vis: Option<&Visibility>, + body_opt: Option<&Block>, + is_trait: bool, +) -> Option { + if !sig.ident.to_string().starts_with("with_") { + return None; + } + let skip = has_attr(attrs, "builder_skip"); + // Se rechaza `with_...()` marcado a la vez con `#[builder_skip]` y `#[builder_fn]`. + if skip && has_attr(attrs, "builder_fn") { + return Some(quote_spanned! { + sig.ident.span() => compile_error!( + "`#[builder_skip]` and `#[builder_fn]` cannot be combined on the same method" + ); + }); + } + if skip { + return None; + } + // Descarta atributos auxiliares para no reprocesar ni dejar atributos desconocidos. + let clean: Vec = attrs + .iter() + .filter(|a| !a.path().is_ident("builder_fn") && !a.path().is_ident("builder_skip")) + .cloned() + .collect(); + Some(expand_builder(sig, &clean, vis, body_opt, is_trait)) +} + +// Implementa `#[builder_impl]`: aplica `expand_builder` a todos los métodos `with_...()` de un +// bloque `impl` o de una definición de `trait`, dejando el resto de ítems intactos. +pub(crate) fn expand_impl(item: TokenStream) -> TokenStream { + if let Ok(item_impl) = parse2::(item.clone()) { + return expand_item_impl(item_impl); + } + if let Ok(item_trait) = parse2::(item) { + return expand_item_trait(item_trait); + } + quote! { + compile_error!("#[builder_impl] only supports `impl` blocks or `trait` definitions"); + } +} + +fn expand_item_impl(item: ItemImpl) -> TokenStream { + let ItemImpl { + attrs, + defaultness, + unsafety, + impl_token, + generics, + trait_, + self_ty, + items, + .. + } = item; + + let mut out = Vec::new(); + + for it in items { + match it { + ImplItem::Fn(f) => { + match sweep_with_fn(&f.sig, &f.attrs, Some(&f.vis), Some(&f.block), false) { + Some(ts) => out.push(ts), + None => { + // Método no-builder (o `with_...()` marcado con `#[builder_skip]`): se + // reemite intacto, retirando siempre `#[builder_skip]` (atributo inerte). + let mut f = f; + f.attrs.retain(|a| !a.path().is_ident("builder_skip")); + out.push(quote! { #f }); + } + } + } + other => out.push(quote! { #other }), + } + } + + let (impl_generics, _type_generics, where_clause) = generics.split_for_impl(); + + // Reconstruye la parte `Trait for` si el impl es de trait. + let trait_ = trait_.map(|(bang, path, for_token)| quote! { #bang #path #for_token }); + + quote! { + #(#attrs)* + #defaultness #unsafety #impl_token #impl_generics #trait_ #self_ty #where_clause { + #(#out)* + } + } +} + +fn expand_item_trait(item: ItemTrait) -> TokenStream { + let ItemTrait { + attrs, + vis, + unsafety, + auto_token, + trait_token, + ident, + generics, + colon_token, + supertraits, + items, + .. + } = item; + + let mut out = Vec::new(); + + for it in items { + match it { + TraitItem::Fn(f) => { + match sweep_with_fn(&f.sig, &f.attrs, None, f.default.as_ref(), true) { + Some(ts) => out.push(ts), + None => { + // Método no-builder (o `with_...()` marcado con `#[builder_skip]`): se + // reemite intacto, retirando siempre `#[builder_skip]` (atributo inerte). + let mut f = f; + f.attrs.retain(|a| !a.path().is_ident("builder_skip")); + out.push(quote! { #f }); + } + } + } + other => out.push(quote! { #other }), + } + } + + // El propio nombre de la lista de genéricos (``) es el que lleva las cotas en una + // definición de trait, a diferencia de su uso como tipo; por eso se usa `impl_generics` y no + // `type_generics` para reconstruir `trait Nombre<...>`. + let (impl_generics, _type_generics, where_clause) = generics.split_for_impl(); + + quote! { + #(#attrs)* + #vis #unsafety #auto_token #trait_token #ident #impl_generics + #colon_token #supertraits + #where_clause + { + #(#out)* + } + } +} diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs index bb9aaa89..70c97e1a 100644 --- a/helpers/pagetop-macros/src/lib.rs +++ b/helpers/pagetop-macros/src/lib.rs @@ -34,12 +34,13 @@ cada proyecto PageTop. html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico" )] +mod builder; mod maud; mod smart_default; use proc_macro::TokenStream; -use quote::{quote, quote_spanned}; -use syn::{DeriveInput, ItemFn, parse_macro_input, spanned::Spanned}; +use quote::quote; +use syn::{DeriveInput, ItemFn, parse_macro_input}; /// Macro para escribir plantillas HTML (basada en [Maud](https://docs.rs/maud)). #[proc_macro] @@ -162,280 +163,84 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream { /// La documentación del método `with_...()` incluirá también la firma resumida del método /// `alter_...()` y un alias de búsqueda con su nombre, de tal manera que buscando `alter_...` en la /// documentación se mostrará la entrada del método `with_...()`. +/// +/// Para aplicar la misma transformación a todos los métodos `with_...()` de un `impl` de una sola +/// vez, usa [`#[builder_impl]`](builder_impl). #[proc_macro_attribute] pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream { - use syn::{FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type, parse2}; + builder::expand_fn(item.into()).into() +} - let ts: proc_macro2::TokenStream = item.clone().into(); - - enum Kind { - Impl(ImplItemFn), - Trait(TraitItemFn), - } - - // Detecta si estamos en `impl` o `trait`. - let kind = if let Ok(it) = parse2::(ts.clone()) { - Kind::Impl(it) - } else if let Ok(tt) = parse2::(ts.clone()) { - Kind::Trait(tt) - } else { - return quote! { - compile_error!("#[builder_fn] only supports methods in `impl` blocks or `trait` items"); - } - .into(); - }; - - // Extrae piezas comunes (sig, attrs, vis, bloque?, es_trait?). - let (sig, attrs, vis, body_opt, is_trait) = match &kind { - Kind::Impl(m) => (&m.sig, &m.attrs, Some(&m.vis), Some(&m.block), false), - Kind::Trait(t) => (&t.sig, &t.attrs, None, t.default.as_ref(), true), - }; - - let with_name = sig.ident.clone(); - let with_name_str = sig.ident.to_string(); - - // Valida el nombre del método. - if !with_name_str.starts_with("with_") { - return quote_spanned! { - sig.ident.span() => compile_error!("expected a named `with_...()` method"); - } - .into(); - } - - // Sólo se exige `pub` en `impl` (en `trait` no aplica). - let vis_pub = match (is_trait, vis) { - (false, Some(v)) => quote! { #v }, - _ => quote! {}, - }; - - // Validaciones comunes. - if sig.asyncness.is_some() { - return quote_spanned! { - sig.asyncness.span() => compile_error!("`with_...()` cannot be `async`"); - } - .into(); - } - if sig.constness.is_some() { - return quote_spanned! { - sig.constness.span() => compile_error!("`with_...()` cannot be `const`"); - } - .into(); - } - if sig.abi.is_some() { - return quote_spanned! { - sig.abi.span() => compile_error!("`with_...()` cannot be `extern`"); - } - .into(); - } - if sig.unsafety.is_some() { - return quote_spanned! { - sig.unsafety.span() => compile_error!("`with_...()` cannot be `unsafe`"); - } - .into(); - } - - // En `impl` se exige exactamente `mut self`; y en `trait` se exige `self` (sin &). - let receiver_ok = match sig.inputs.first() { - Some(FnArg::Receiver(r)) => { - // Rechaza `self: SomeType`. - if r.colon_token.is_some() { - false - } else if is_trait { - // Exactamente `self` (sin &, sin mut). - r.reference.is_none() && r.mutability.is_none() - } else { - // Exactamente `mut self`. - r.reference.is_none() && r.mutability.is_some() - } - } - _ => false, - }; - if !receiver_ok { - let msg = if is_trait { - "expected `self` (not `mut self`, `&self` or `&mut self`) in trait method" - } else { - "expected first argument to be exactly `mut self`" - }; - let err = sig - .inputs - .first() - .map(|a| a.span()) - .unwrap_or(sig.ident.span()); - return quote_spanned! { - err => compile_error!(#msg); - } - .into(); - } - - // Valida que el método devuelve exactamente `Self`. - match &sig.output { - ReturnType::Type(_, ty) => match ty.as_ref() { - Type::Path(p) if p.qself.is_none() && p.path.is_ident("Self") => {} - _ => { - return quote_spanned! { - ty.span() => compile_error!("expected return type to be exactly `Self`"); - } - .into(); - } - }, - _ => { - return quote_spanned! { - sig.output.span() => compile_error!("expected return type to be exactly `Self`"); - } - .into(); - } - } - - // Genera el nombre del método `alter_...()`. - let stem = with_name_str.strip_prefix("with_").expect("validated"); - let alter_ident = Ident::new(&format!("alter_{stem}"), with_name.span()); - - // Extrae genéricos y cláusulas `where`. - let generics = &sig.generics; - let where_clause = &sig.generics.where_clause; - - // Extrae identificadores de los argumentos para la llamada (sin `mut` ni patrones complejos). - let args: Vec<_> = sig.inputs.iter().skip(1).collect(); - let call_idents: Vec = { - let mut v = Vec::new(); - for arg in sig.inputs.iter().skip(1) { - match arg { - FnArg::Typed(pat) => { - if let Pat::Ident(pat_ident) = pat.pat.as_ref() { - v.push(pat_ident.ident.clone()); - } else { - return quote_spanned! { - pat.pat.span() => compile_error!( - "each parameter must be a simple identifier, e.g. `value: T`" - ); - } - .into(); - } - } - _ => { - return quote_spanned! { - arg.span() => compile_error!("unexpected receiver in parameter list"); - } - .into(); - } - } - } - v - }; - - // Separa atributos de documentación y resto. - let mut doc_attrs = Vec::new(); - let mut other_attrs = Vec::new(); - let mut non_doc_or_inline_attrs = Vec::new(); - - for a in attrs.iter() { - let p = a.path(); - if p.is_ident("doc") { - doc_attrs.push(a.clone()); - } else { - other_attrs.push(a.clone()); - if !p.is_ident("inline") { - non_doc_or_inline_attrs.push(a.clone()); - } - } - } - - // Firma resumida de la función `alter_...()` para mostrarla en la doc de `with_...()`. - let alter_sig_tokens = if args.is_empty() { - // Sin argumentos sólo se muestra `&mut self` (puede que no tenga mucho sentido). - quote! { #vis_pub fn #alter_ident #generics (&mut self) -> &mut Self #where_clause } - } else { - // Con argumentos se muestra `&mut self, ...`. - quote! { #vis_pub fn #alter_ident #generics (&mut self, ...) -> &mut Self #where_clause } - }; - - // Normaliza espacios raros tipo `& mut`. - let alter_sig_str = alter_sig_tokens.to_string().replace("& mut", "&mut"); - - // Nombre de la función `alter_...()` como alias de búsqueda. - let alter_name_str = alter_ident.to_string(); - - // Texto introductorio para la documentación adicional de `with_...()`. - let with_alter_title = format!( - "# {} el método `{}()` generado por [`#[builder_fn]`](pagetop_macros::builder_fn)", - if doc_attrs.is_empty() { - "Añade" - } else { - "También añade" - }, - alter_name_str - ); - let with_alter_doc = concat!( - "Permite modificar la instancia (`&mut self`) con los mismos argumentos ", - "pero sin consumirla." - ); - - // Atributos completos que se aplican siempre a `with_...()`. - let with_prefix = quote! { - #(#other_attrs)* - #(#doc_attrs)* - #[doc(alias = #alter_name_str)] - #[doc = ""] - #[doc = #with_alter_title] - #[doc = #with_alter_doc] - #[doc = "```text"] - #[doc = #alter_sig_str] - #[doc = "```"] - }; - - // Genera el código final. - let expanded = match body_opt { - None => { - quote! { - #with_prefix - fn #with_name #generics (self, #(#args),*) -> Self #where_clause; - - #(#non_doc_or_inline_attrs)* - #[doc(hidden)] - fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause; - } - } - Some(body) => { - // Si no se indicó ninguna forma de `inline`, fuerza `#[inline]` para `with_...()`. - let force_inline = if attrs.iter().any(|a| a.path().is_ident("inline")) { - quote! {} - } else { - quote! { #[inline] } - }; - - let with_fn = if is_trait { - quote! { - #with_prefix - #force_inline - #vis_pub fn #with_name #generics (self, #(#args),*) -> Self #where_clause { - let mut s = self; - s.#alter_ident(#(#call_idents),*); - s - } - } - } else { - quote! { - #with_prefix - #force_inline - #vis_pub fn #with_name #generics (mut self, #(#args),*) -> Self #where_clause { - self.#alter_ident(#(#call_idents),*); - self - } - } - }; - - quote! { - #with_fn - - #(#non_doc_or_inline_attrs)* - #[doc(hidden)] - #vis_pub fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause { - #body - } - } - } - }; - expanded.into() +/// Macro (*attribute*) que aplica [`#[builder_fn]`](builder_fn) a los métodos `with_` de un +/// `impl`/`trait`. +/// +/// Cada método que empiece por `with_` se transforma igual que si llevara `#[builder_fn]` +/// individualmente: se genera su correspondiente método `alter_...()` y se añade la misma +/// documentación. El resto de ítems del bloque (métodos que no empiecen por `with_`, constantes +/// asociadas, tipos, etc.) no se modifican. +/// +/// La política es estricta; si un método `with_...()` no cumple la firma esperada por +/// [`#[builder_fn]`](builder_fn) para su contexto, la macro emite el mismo error de compilación que +/// emitiría `#[builder_fn]` sobre ese método. Para excluir deliberadamente un método `with_...()`, +/// márcalo con `#[builder_skip]`; se mantendrá intacto, como cualquier otro método que no sea +/// *builder*. +/// +/// Un `#[builder_fn]` explícito sobre un método dentro de un bloque `#[builder_impl]` es +/// redundante pero inofensivo, no se expande dos veces. Combinar `#[builder_skip]` y +/// `#[builder_fn]` sobre el mismo método sí es un error de compilación porque la intención de ambos +/// atributos sí es contradictoria. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop_macros::builder_impl; +/// # #[derive(Default)] +/// # struct Example { a: Option, b: Option } +/// #[builder_impl] +/// impl Example { +/// pub fn with_a(mut self, value: impl Into) -> Self { +/// self.a = Some(value.into()); +/// self +/// } +/// +/// pub fn with_b(mut self, value: u32) -> Self { +/// self.b = Some(value); +/// self +/// } +/// +/// pub fn a(&self) -> Option<&str> { +/// self.a.as_deref() +/// } +/// } +/// +/// let example = Example::default().with_a("hello").with_b(42); +/// ``` +/// +/// genera, para `with_a` y `with_b`, el mismo par `with_.../alter_...` que produciría anotar cada +/// uno individualmente con [`#[builder_fn]`](builder_fn); `a()` se reemite sin modificar. +/// +/// Sobre una definición de `trait`, con receptor `self` (sin `mut`) en cada `with_...()`: +/// +/// ```rust,no_run +/// # use pagetop_macros::builder_impl; +/// #[builder_impl] +/// pub trait Example { +/// /// Sin cuerpo por defecto: sólo genera la declaración. +/// fn with_a(self, value: impl Into) -> Self; +/// +/// /// Con cuerpo por defecto: genera también la implementación, heredable sin redefinirla. +/// fn with_b(self, value: u32) -> Self { +/// self +/// } +/// } +/// ``` +/// +/// Un `with_...()` de trait con cuerpo por defecto añade `where Self: Sized` automáticamente. A +/// diferencia de una declaración sin cuerpo, éste se compila junto a la propia definición del +/// trait, donde `Self` podría no ser `Sized`, y Rust lo exige para poder devolverlo por valor. +#[proc_macro_attribute] +pub fn builder_impl(_: TokenStream, item: TokenStream) -> TokenStream { + builder::expand_impl(item.into()).into() } /// Define una función `main` asíncrona como punto de entrada de PageTop. diff --git a/src/base/component/badge.rs b/src/base/component/badge.rs index 1a7c45a0..3aee8b06 100644 --- a/src/base/component/badge.rs +++ b/src/base/component/badge.rs @@ -49,6 +49,7 @@ impl Component for Badge { } } +#[builder_impl] impl Badge { /// Crea un badge predeterminado (`Intent::default()`) con la etiqueta indicada. pub fn labeled(label: Lc) -> Self { @@ -115,28 +116,24 @@ impl Badge { // **< Badge BUILDER >************************************************************************** /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } /// Establece la etiqueta del badge. - #[builder_fn] pub fn with_label(mut self, label: Lc) -> Self { self.label = label; self } /// Establece la intención semántica del badge. - #[builder_fn] pub fn with_intent(mut self, intent: Intent) -> Self { self.intent = intent; self diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 2bf350df..2f01f01e 100644 --- a/src/base/component/block.rs +++ b/src/base/component/block.rs @@ -50,25 +50,23 @@ impl Component for Block { } } +#[builder_impl] impl Block { // **< Block BUILDER >************************************************************************** /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } /// Establece el título del bloque. - #[builder_fn] pub fn with_title(mut self, title: Lc) -> Self { self.title = title; self @@ -76,7 +74,6 @@ impl Block { /// Añade un nuevo componente al bloque o modifica la lista de componentes (`children`) con una /// operación [`ChildOp`]. - #[builder_fn] pub fn with_child(mut self, op: impl Into) -> Self { self.children.alter_child(op.into()); self diff --git a/src/base/component/brand.rs b/src/base/component/brand.rs index a1113ef3..913a24e1 100644 --- a/src/base/component/brand.rs +++ b/src/base/component/brand.rs @@ -77,39 +77,35 @@ impl Component for Brand { } } +#[builder_impl] impl Brand { // **< Brand BUILDER >************************************************************************** /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } /// Asigna o quita la imagen de marca. Si se pasa `None`, no se mostrará. - #[builder_fn] pub fn with_image(mut self, image: impl Into>) -> Self { self.image.alter_component(image); self } /// Establece el título de la identidad de marca. - #[builder_fn] pub fn with_title(mut self, title: Lc) -> Self { self.title = title; self } /// Define la ruta de destino. Si es `None`, la marca no será un enlace. - #[builder_fn] pub fn with_route(mut self, route: impl Into>) -> Self { self.route = route.into(); self diff --git a/src/base/component/breadcrumb/component.rs b/src/base/component/breadcrumb/component.rs index dca96e18..ce3bfa9e 100644 --- a/src/base/component/breadcrumb/component.rs +++ b/src/base/component/breadcrumb/component.rs @@ -67,25 +67,23 @@ impl Component for Breadcrumb { } } +#[builder_impl] impl Breadcrumb { // **< Breadcrumb BUILDER >********************************************************************* /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } /// Añade un nuevo elemento al final del breadcrumb. - #[builder_fn] pub fn with_crumb(mut self, crumb: breadcrumb::Crumb) -> Self { self.crumbs.push(crumb); self diff --git a/src/base/component/breadcrumb/crumb.rs b/src/base/component/breadcrumb/crumb.rs index b29ce92b..0352a76f 100644 --- a/src/base/component/breadcrumb/crumb.rs +++ b/src/base/component/breadcrumb/crumb.rs @@ -31,6 +31,7 @@ pub struct Crumb { is_current: bool, } +#[builder_impl] impl Crumb { /// Crea un elemento enlazado a la ruta indicada. pub fn new(label: Lc, route: impl Into) -> Self { @@ -66,14 +67,12 @@ impl Crumb { // **< Crumb BUILDER >************************************************************************** /// Establece el identificador único del elemento. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS o atributos HTML del elemento. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self diff --git a/src/base/component/button/component.rs b/src/base/component/button/component.rs index 34a1bb1f..805881d5 100644 --- a/src/base/component/button/component.rs +++ b/src/base/component/button/component.rs @@ -131,6 +131,7 @@ impl Component for Button { } } +#[builder_impl] impl Button { /// Crea un botón de **envío** (`type="submit"`). /// @@ -186,35 +187,30 @@ impl Button { // **< Button BUILDER >************************************************************************* /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] pub fn with_id(mut self, id: impl Into) -> Self { self.props.alter_id(id); self } /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); self } /// Establece el comportamiento del botón al activarse. - #[builder_fn] pub fn with_kind(mut self, kind: button::Kind) -> Self { self.kind = kind; self } /// Establece el tamaño visual del botón (usa [`button::Size::None`] para quitarlo). - #[builder_fn] pub fn with_size(mut self, size: button::Size) -> Self { self.size = size; self } /// Establece el estilo visual del botón (usa [`button::Style::None`] para quitarlo). - #[builder_fn] pub fn with_style(mut self, style: button::Style) -> Self { self.style = style; self @@ -224,7 +220,6 @@ impl Button { /// /// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par /// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado. - #[builder_fn] pub fn with_name(mut self, name: impl AsRef) -> Self { self.name.alter_name(name); self @@ -234,21 +229,18 @@ impl Button { /// /// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón /// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario. - #[builder_fn] pub fn with_value(mut self, value: impl AsRef) -> Self { self.value.alter_str(value); self } /// Establece la etiqueta visible del botón (usa [`Lc::none()`] para quitarla). - #[builder_fn] pub fn with_label(mut self, label: Lc) -> Self { self.label = label; self } /// Establece el texto emergente del botón (usa [`Lc::none()`] para quitarlo). - #[builder_fn] pub fn with_title(mut self, title: Lc) -> Self { self.title = title; self @@ -257,21 +249,18 @@ impl Button { /// Establece la ruta de destino y convierte el botón en enlace de navegación (``). /// Puedes usar un [`Route`] vacío (por defecto) para que vuelva a renderizarse como `