1use proc_macro::TokenStream;
4use quote::{format_ident, quote};
5use syn::{
6 Attribute, Data, DeriveInput, Error, Expr, ExprLit, Fields, FnArg, ItemFn, Lit, LitStr, Meta,
7 Path, Type,
8 parse::{Parse, ParseStream},
9 parse_macro_input,
10};
11
12#[proc_macro_derive(HblankProps, attributes(hblank))]
13pub fn derive_hblank_props(input: TokenStream) -> TokenStream {
14 let input = parse_macro_input!(input as DeriveInput);
15 expand_hblank_props(input)
16 .unwrap_or_else(Error::into_compile_error)
17 .into()
18}
19
20fn expand_hblank_props(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
21 let name = input.ident;
22 let Data::Struct(data) = input.data else {
23 return Err(Error::new_spanned(
24 name,
25 "HblankProps can only be derived for structs with named fields",
26 ));
27 };
28 let Fields::Named(fields) = data.fields else {
29 return Err(Error::new_spanned(
30 name,
31 "HblankProps requires named fields",
32 ));
33 };
34
35 let mut definitions = Vec::with_capacity(fields.named.len());
36 let mut readers = Vec::with_capacity(fields.named.len());
37 let mut writers = Vec::with_capacity(fields.named.len());
38
39 for field in fields.named {
40 let options = field_options(&field.attrs)?;
41 if options.skip {
42 continue;
43 }
44 let ident = field
45 .ident
46 .ok_or_else(|| Error::new_spanned(&field.ty, "HblankProps requires named fields"))?;
47 let ty = field.ty;
48 let id = ident.to_string();
49 let kind = control_kind(&ty, &options);
50 let label = options.label.unwrap_or_else(|| humanize(&id));
51 let docs = docs(&field.attrs);
52 let definition = quote! {
53 ::hblank::ControlDefinition {
54 id: #id,
55 label: #label,
56 docs: #docs,
57 kind: #kind,
58 }
59 };
60 let (reader, writer) =
61 control_accessors(&ty, &ident, &id, &definition, options.adapter.as_ref());
62
63 definitions.push(definition);
64 readers.push(reader);
65 writers.push(writer);
66 }
67
68 Ok(quote! {
69 impl ::hblank::HblankProps for #name {
70 fn definitions(&self) -> &'static [::hblank::ControlDefinition] {
71 const DEFINITIONS: &[::hblank::ControlDefinition] = &[
72 #(#definitions),*
73 ];
74 DEFINITIONS
75 }
76
77 fn control_value(&self, id: &str) -> Option<::hblank::ControlValue> {
78 match id {
79 #(#readers,)*
80 _ => None,
81 }
82 }
83
84 fn set_control(
85 &mut self,
86 id: &str,
87 value: ::hblank::ControlValue,
88 ) -> Result<(), ::hblank::ControlError> {
89 match id {
90 #(#writers,)*
91 _ => Err(::hblank::ControlError::UnknownControl(id.to_owned())),
92 }
93 }
94
95 fn clone_box(&self) -> Box<dyn ::hblank::HblankProps> {
96 Box::new(self.clone())
97 }
98
99 fn as_any(&self) -> &dyn ::std::any::Any {
100 self
101 }
102 }
103 })
104}
105
106#[proc_macro_derive(HblankEnum, attributes(hblank))]
107pub fn derive_hblank_enum(input: TokenStream) -> TokenStream {
108 let input = parse_macro_input!(input as DeriveInput);
109 expand_hblank_enum(input)
110 .unwrap_or_else(Error::into_compile_error)
111 .into()
112}
113
114fn expand_hblank_enum(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
115 let name = input.ident;
116 let Data::Enum(data) = input.data else {
117 return Err(Error::new_spanned(
118 name,
119 "HblankEnum can only be derived for enums",
120 ));
121 };
122
123 let mut variants = Vec::with_capacity(data.variants.len());
124 let mut names = Vec::with_capacity(data.variants.len());
125 for variant in data.variants {
126 if !matches!(variant.fields, Fields::Unit) {
127 return Err(Error::new_spanned(
128 variant,
129 "HblankEnum variants cannot contain data",
130 ));
131 }
132 let ident = variant.ident;
133 let label = field_label(&variant.attrs)?.unwrap_or_else(|| humanize(&ident.to_string()));
134 variants.push(ident);
135 names.push(label);
136 }
137
138 Ok(quote! {
139 impl ::hblank::HblankEnum for #name {
140 const VARIANTS: &'static [&'static str] = &[#(#names),*];
141
142 fn variant_name(&self) -> &'static str {
143 match self {
144 #(Self::#variants => #names),*
145 }
146 }
147
148 fn from_variant_name(value: &str) -> Option<Self> {
149 match value {
150 #(#names => Some(Self::#variants),)*
151 _ => None,
152 }
153 }
154 }
155 })
156}
157
158#[derive(Default)]
159struct ComponentArgs {
160 title: Option<LitStr>,
161 group: Option<LitStr>,
162 docs: Option<Path>,
163 handle: Option<Type>,
164}
165
166#[proc_macro_attribute]
167pub fn component(args: TokenStream, input: TokenStream) -> TokenStream {
168 let mut component_args = ComponentArgs::default();
169 let parser = syn::meta::parser(|meta| {
170 if meta.path.is_ident("title") {
171 component_args.title = Some(meta.value()?.parse()?);
172 } else if meta.path.is_ident("group") {
173 component_args.group = Some(meta.value()?.parse()?);
174 } else if meta.path.is_ident("docs") {
175 component_args.docs = Some(meta.value()?.parse()?);
176 } else if meta.path.is_ident("handle") {
177 component_args.handle = Some(meta.value()?.parse()?);
178 } else {
179 return Err(meta.error("expected one of: title, group, docs, handle"));
180 }
181 Ok(())
182 });
183 syn::parse_macro_input!(args with parser);
184 let function = parse_macro_input!(input as ItemFn);
185 expand_component(component_args, &function)
186 .unwrap_or_else(Error::into_compile_error)
187 .into()
188}
189
190fn expand_component(
191 args: ComponentArgs,
192 function: &ItemFn,
193) -> syn::Result<proc_macro2::TokenStream> {
194 validate_synchronous_non_generic(function, "components")?;
195 let props_type = render_props_type(function, "components")?;
196 let function_name = &function.sig.ident;
197 let module_name = format_ident!("__hblank_component_{}", function_name);
198 let function_docs = docs(&function.attrs);
199 let title = args.title.unwrap_or_else(|| {
200 LitStr::new(&humanize(&function_name.to_string()), function_name.span())
201 });
202 let group = args
203 .group
204 .map_or_else(|| quote!(module_path!()), |group| quote!(#group));
205 let doc_page = args
206 .docs
207 .map_or_else(|| quote!(), |docs| quote!(.with_docs(#docs())));
208 let handle_helper = args.handle.map_or_else(
209 || quote!(),
210 |handle| {
211 quote! {
212 pub(crate) fn render_with_handle(
213 props: &#props_type,
214 window: &mut ::hblank::gpui::Window,
215 cx: &mut ::hblank::gpui::App,
216 ) -> (::hblank::gpui::AnyElement, #handle) {
217 super::#function_name(props, window, cx).into_erased_parts()
218 }
219 }
220 },
221 );
222
223 Ok(quote! {
224 #function
225
226 #[doc(hidden)]
227 pub(crate) mod #module_name {
228 use super::*;
229
230 pub(crate) fn id() -> ::std::string::String {
231 ::hblank::canonical_source_id(file!(), stringify!(#function_name))
232 }
233
234 pub(crate) fn assert_props(_: &#props_type) {}
235
236 #handle_helper
237
238 pub(crate) fn build() -> ::hblank::ComponentDefinition {
239 fn render(
240 props: &dyn ::hblank::HblankProps,
241 window: &mut ::hblank::gpui::Window,
242 cx: &mut ::hblank::gpui::App,
243 ) -> ::hblank::gpui::AnyElement {
244 let props = props
245 .as_any()
246 .downcast_ref::<#props_type>()
247 .expect("Hblank component received the wrong props type");
248 ::hblank::gpui::IntoElement::into_any_element(
249 super::#function_name(props, window, cx),
250 )
251 }
252
253 ::hblank::ComponentDefinition::new::<#props_type>(
254 ::hblank::ComponentMetadata {
255 id: id(),
256 title: #title,
257 group: #group,
258 docs: #function_docs,
259 declaration: stringify!(#function),
260 source: file!(),
261 line: line!(),
262 },
263 render,
264 )
265 #doc_page
266 }
267 }
268
269 ::hblank::__private::inventory::submit! {
270 ::hblank::ComponentRegistration { build: #module_name::build }
271 }
272 })
273}
274
275struct RenderHandleInput {
276 component: Path,
277 props: Expr,
278 window: Expr,
279 cx: Expr,
280}
281
282impl Parse for RenderHandleInput {
283 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
284 let component = input.parse()?;
285 input.parse::<syn::Token![,]>()?;
286 let props = input.parse()?;
287 input.parse::<syn::Token![,]>()?;
288 let window = input.parse()?;
289 input.parse::<syn::Token![,]>()?;
290 let cx = input.parse()?;
291 Ok(Self {
292 component,
293 props,
294 window,
295 cx,
296 })
297 }
298}
299
300#[proc_macro]
301pub fn render_handle(input: TokenStream) -> TokenStream {
302 let input = parse_macro_input!(input as RenderHandleInput);
303 let props = input.props;
304 let window = input.window;
305 let cx = input.cx;
306 match component_module_path(&input.component) {
307 Ok(module) => quote!(#module::render_with_handle(#props, #window, #cx)).into(),
308 Err(error) => error.into_compile_error().into(),
309 }
310}
311
312struct CustomDocInput {
313 renderer: Path,
314 payload: Expr,
315}
316
317impl Parse for CustomDocInput {
318 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
319 let renderer = input.parse()?;
320 input.parse::<syn::Token![,]>()?;
321 let payload = input.parse()?;
322 Ok(Self { renderer, payload })
323 }
324}
325
326#[proc_macro_attribute]
327pub fn doc_block(args: TokenStream, input: TokenStream) -> TokenStream {
328 if !args.is_empty() {
329 return Error::new(
330 proc_macro2::Span::call_site(),
331 "Hblank custom doc blocks take no attributes",
332 )
333 .into_compile_error()
334 .into();
335 }
336 let function = parse_macro_input!(input as ItemFn);
337 let function_name = &function.sig.ident;
338 let module_name = format_ident!("__hblank_doc_block_{}", function_name);
339 quote! {
340 #function
341
342 #[doc(hidden)]
343 pub(crate) mod #module_name {
344 pub(crate) fn id() -> ::std::string::String {
345 concat!(module_path!(), "::", stringify!(#function_name)).to_owned()
346 }
347
348 pub(crate) const RENDER: ::hblank::CustomDocRenderer = super::#function_name;
349 }
350
351 ::hblank::__private::inventory::submit! {
352 ::hblank::CustomDocBlockRegistration {
353 id: concat!(module_path!(), "::", stringify!(#function_name)),
354 render: #module_name::RENDER,
355 }
356 }
357 }
358 .into()
359}
360
361#[proc_macro]
362pub fn custom_doc(input: TokenStream) -> TokenStream {
363 let input = parse_macro_input!(input as CustomDocInput);
364 let payload = input.payload;
365 match doc_block_module_path(&input.renderer) {
366 Ok(module) => quote!(::hblank::DocBlock::custom(#module::id(), #payload)).into(),
367 Err(error) => error.into_compile_error().into(),
368 }
369}
370
371#[proc_macro_attribute]
372pub fn theme_hook(args: TokenStream, input: TokenStream) -> TokenStream {
373 if !args.is_empty() {
374 return Error::new(
375 proc_macro2::Span::call_site(),
376 "Hblank theme hooks take no attributes",
377 )
378 .into_compile_error()
379 .into();
380 }
381 let function = parse_macro_input!(input as ItemFn);
382 let function_name = &function.sig.ident;
383 quote! {
384 #function
385
386 const _: ::hblank::ThemeHook = #function_name;
387
388 ::hblank::__private::inventory::submit! {
389 ::hblank::ThemeHookRegistration {
390 id: concat!(module_path!(), "::", stringify!(#function_name)),
391 apply: #function_name,
392 }
393 }
394 }
395 .into()
396}
397
398#[derive(Default)]
399struct FixtureArgs {
400 component: Option<Path>,
401 title: Option<LitStr>,
402}
403
404#[proc_macro]
405pub fn fixture_ref(input: TokenStream) -> TokenStream {
406 let fixture = parse_macro_input!(input as Path);
407 match fixture_module_path(&fixture) {
408 Ok(module) => quote!(#module::id()).into(),
409 Err(error) => error.into_compile_error().into(),
410 }
411}
412
413#[proc_macro_attribute]
414pub fn fixture(args: TokenStream, input: TokenStream) -> TokenStream {
415 let mut fixture_args = FixtureArgs::default();
416 let parser = syn::meta::parser(|meta| {
417 if meta.path.is_ident("component") {
418 fixture_args.component = Some(meta.value()?.parse()?);
419 } else if meta.path.is_ident("title") {
420 fixture_args.title = Some(meta.value()?.parse()?);
421 } else {
422 return Err(meta.error("expected one of: component, title"));
423 }
424 Ok(())
425 });
426 syn::parse_macro_input!(args with parser);
427 let function = parse_macro_input!(input as ItemFn);
428 expand_fixture(fixture_args, &function)
429 .unwrap_or_else(Error::into_compile_error)
430 .into()
431}
432
433fn expand_fixture(args: FixtureArgs, function: &ItemFn) -> syn::Result<proc_macro2::TokenStream> {
434 validate_synchronous_non_generic(function, "fixtures")?;
435 if !function.sig.inputs.is_empty() {
436 return Err(Error::new_spanned(
437 &function.sig.inputs,
438 "Hblank fixture variants take no arguments and return component props",
439 ));
440 }
441 let component = args.component.ok_or_else(|| {
442 Error::new_spanned(
443 &function.sig.ident,
444 "Hblank fixture variants require component = path::to::component",
445 )
446 })?;
447 let component_module = component_module_path(&component)?;
448 let function_name = &function.sig.ident;
449 let module_name = format_ident!("__hblank_fixture_{}", function_name);
450 let function_docs = docs(&function.attrs);
451 let title = args.title.unwrap_or_else(|| {
452 LitStr::new(&humanize(&function_name.to_string()), function_name.span())
453 });
454
455 Ok(quote! {
456 #function
457
458 #[doc(hidden)]
459 pub(crate) mod #module_name {
460 use super::*;
461
462 pub(crate) fn id() -> ::std::string::String {
463 ::hblank::canonical_source_id(file!(), stringify!(#function_name))
464 }
465
466 pub(crate) fn build() -> ::hblank::FixtureRegistrationData {
467 let defaults = super::#function_name();
468 #component_module::assert_props(&defaults);
469 ::hblank::FixtureRegistrationData::new(
470 ::hblank::FixtureRegistrationMetadata {
471 id: id(),
472 title: #title,
473 docs: #function_docs,
474 declaration: stringify!(#function),
475 source: file!(),
476 line: line!(),
477 },
478 #component_module::id(),
479 ::std::boxed::Box::new(defaults),
480 )
481 }
482 }
483
484 ::hblank::__private::inventory::submit! {
485 ::hblank::FixtureRegistration { build: #module_name::build }
486 }
487 })
488}
489
490fn validate_synchronous_non_generic(function: &ItemFn, subject: &str) -> syn::Result<()> {
491 if function.sig.asyncness.is_some() {
492 return Err(Error::new_spanned(
493 &function.sig,
494 format!("Hblank {subject} must be synchronous"),
495 ));
496 }
497 if !function.sig.generics.params.is_empty() {
498 return Err(Error::new_spanned(
499 &function.sig.generics,
500 format!("Hblank {subject} cannot be generic"),
501 ));
502 }
503 Ok(())
504}
505
506fn render_props_type<'a>(function: &'a ItemFn, subject: &str) -> syn::Result<&'a Type> {
507 if function.sig.inputs.len() != 3 {
508 return Err(Error::new_spanned(
509 &function.sig.inputs,
510 format!("Hblank {subject} take exactly (&Props, &mut gpui::Window, &mut gpui::App)"),
511 ));
512 }
513 let first = function
514 .sig
515 .inputs
516 .first()
517 .ok_or_else(|| Error::new_spanned(&function.sig, "missing props argument"))?;
518 let FnArg::Typed(first) = first else {
519 return Err(Error::new_spanned(
520 first,
521 format!("the first Hblank {subject} argument must be &Props"),
522 ));
523 };
524 let Type::Reference(props_reference) = first.ty.as_ref() else {
525 return Err(Error::new_spanned(
526 &first.ty,
527 format!("the first Hblank {subject} argument must be &Props"),
528 ));
529 };
530 if props_reference.mutability.is_some() {
531 return Err(Error::new_spanned(
532 &first.ty,
533 "component props are immutable; mutate them through harness controls",
534 ));
535 }
536 Ok(props_reference.elem.as_ref())
537}
538
539fn doc_block_module_path(renderer: &Path) -> syn::Result<Path> {
540 let mut module = renderer.clone();
541 let Some(last) = module.segments.last_mut() else {
542 return Err(Error::new_spanned(
543 renderer,
544 "doc block renderer path cannot be empty",
545 ));
546 };
547 last.ident = format_ident!("__hblank_doc_block_{}", last.ident);
548 Ok(module)
549}
550
551fn fixture_module_path(fixture: &Path) -> syn::Result<Path> {
552 let mut module = fixture.clone();
553 let Some(last) = module.segments.last_mut() else {
554 return Err(Error::new_spanned(fixture, "fixture path cannot be empty"));
555 };
556 last.ident = format_ident!("__hblank_fixture_{}", last.ident);
557 Ok(module)
558}
559
560fn component_module_path(component: &Path) -> syn::Result<Path> {
561 let mut module = component.clone();
562 let Some(last) = module.segments.last_mut() else {
563 return Err(Error::new_spanned(
564 component,
565 "component path cannot be empty",
566 ));
567 };
568 last.ident = format_ident!("__hblank_component_{}", last.ident);
569 Ok(module)
570}
571
572fn docs(attributes: &[Attribute]) -> String {
573 attributes
574 .iter()
575 .filter_map(|attribute| {
576 if !attribute.path().is_ident("doc") {
577 return None;
578 }
579 let Meta::NameValue(name_value) = &attribute.meta else {
580 return None;
581 };
582 let Expr::Lit(ExprLit {
583 lit: Lit::Str(value),
584 ..
585 }) = &name_value.value
586 else {
587 return None;
588 };
589 Some(value.value().trim().to_owned())
590 })
591 .collect::<Vec<_>>()
592 .join("\n")
593}
594
595#[derive(Default)]
596struct FieldOptions {
597 label: Option<String>,
598 skip: bool,
599 multiline: bool,
600 min: Option<f64>,
601 max: Option<f64>,
602 step: Option<f64>,
603 adapter: Option<Path>,
604}
605
606impl FieldOptions {
607 const fn has_number_constraints(&self) -> bool {
608 self.min.is_some() || self.max.is_some() || self.step.is_some()
609 }
610}
611
612fn control_kind(ty: &Type, options: &FieldOptions) -> proc_macro2::TokenStream {
613 let mut kind = options.adapter.as_ref().map_or_else(
614 || quote!(<#ty as ::hblank::__private::ControlField>::KIND),
615 |adapter| {
616 quote!(
617 <<#adapter as ::hblank::HblankControlAdapter<#ty>>::Value
618 as ::hblank::__private::ControlField>::KIND
619 )
620 },
621 );
622 if options.multiline {
623 kind = quote!((#kind).multiline());
624 }
625 if options.has_number_constraints() {
626 let min = option_f64(options.min);
627 let max = option_f64(options.max);
628 let step = options.step.unwrap_or(1.0);
629 kind = quote! {
630 (#kind).constrained(::hblank::NumberConstraints {
631 min: #min,
632 max: #max,
633 step: #step,
634 })
635 };
636 }
637 kind
638}
639
640fn control_accessors(
641 ty: &Type,
642 ident: &syn::Ident,
643 id: &str,
644 definition: &proc_macro2::TokenStream,
645 adapter: Option<&Path>,
646) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) {
647 adapter.map_or_else(
648 || {
649 (
650 quote! {
651 #id => Some(
652 <#ty as ::hblank::__private::ControlField>::to_control_value(&self.#ident)
653 )
654 },
655 quote! {
656 #id => {
657 let definition = #definition;
658 definition.validate(&value)?;
659 <#ty as ::hblank::__private::ControlField>::set_control_value(
660 &mut self.#ident,
661 #id,
662 value,
663 )
664 }
665 },
666 )
667 },
668 |adapter| {
669 (
670 quote! {
671 #id => {
672 let control = <#adapter as ::hblank::HblankControlAdapter<#ty>>::to_control(
673 &self.#ident,
674 );
675 Some(
676 <<#adapter as ::hblank::HblankControlAdapter<#ty>>::Value
677 as ::hblank::__private::ControlField>::to_control_value(&control)
678 )
679 }
680 },
681 quote! {
682 #id => {
683 let definition = #definition;
684 definition.validate(&value)?;
685 let mut control =
686 <#adapter as ::hblank::HblankControlAdapter<#ty>>::to_control(
687 &self.#ident,
688 );
689 <<#adapter as ::hblank::HblankControlAdapter<#ty>>::Value
690 as ::hblank::__private::ControlField>::set_control_value(
691 &mut control,
692 #id,
693 value,
694 )?;
695 <#adapter as ::hblank::HblankControlAdapter<#ty>>::apply_control(
696 &mut self.#ident,
697 control,
698 );
699 Ok(())
700 }
701 },
702 )
703 },
704 )
705}
706
707fn field_options(attributes: &[Attribute]) -> syn::Result<FieldOptions> {
708 let mut options = FieldOptions::default();
709 for attribute in attributes {
710 if !attribute.path().is_ident("hblank") {
711 continue;
712 }
713 attribute.parse_nested_meta(|meta| {
714 if meta.path.is_ident("label") {
715 let value: LitStr = meta.value()?.parse()?;
716 options.label = Some(value.value());
717 } else if meta.path.is_ident("skip") {
718 options.skip = true;
719 } else if meta.path.is_ident("multiline") {
720 options.multiline = true;
721 } else if meta.path.is_ident("min") {
722 options.min = Some(parse_number(meta.value()?.parse()?)?);
723 } else if meta.path.is_ident("max") {
724 options.max = Some(parse_number(meta.value()?.parse()?)?);
725 } else if meta.path.is_ident("step") {
726 options.step = Some(parse_number(meta.value()?.parse()?)?);
727 } else if meta.path.is_ident("adapter") {
728 options.adapter = Some(meta.value()?.parse()?);
729 } else {
730 return Err(
731 meta.error("expected one of: label, skip, multiline, min, max, step, adapter")
732 );
733 }
734 Ok(())
735 })?;
736 }
737 if let Some(step) = options.step
738 && (!step.is_finite() || step <= 0.0)
739 {
740 return Err(Error::new(
741 proc_macro2::Span::call_site(),
742 "control step must be finite and greater than zero",
743 ));
744 }
745 if options.min.is_some_and(|value| !value.is_finite())
746 || options.max.is_some_and(|value| !value.is_finite())
747 {
748 return Err(Error::new(
749 proc_macro2::Span::call_site(),
750 "control bounds must be finite",
751 ));
752 }
753 if let (Some(min), Some(max)) = (options.min, options.max)
754 && min > max
755 {
756 return Err(Error::new(
757 proc_macro2::Span::call_site(),
758 "control min cannot exceed max",
759 ));
760 }
761 Ok(options)
762}
763
764fn parse_number(expression: Expr) -> syn::Result<f64> {
765 match expression {
766 Expr::Lit(ExprLit {
767 lit: Lit::Int(value),
768 ..
769 }) => value.base10_parse(),
770 Expr::Lit(ExprLit {
771 lit: Lit::Float(value),
772 ..
773 }) => value.base10_parse(),
774 Expr::Unary(unary) if matches!(unary.op, syn::UnOp::Neg(_)) => {
775 Ok(-parse_number(*unary.expr)?)
776 }
777 expression => Err(Error::new_spanned(expression, "expected a numeric literal")),
778 }
779}
780
781fn option_f64(value: Option<f64>) -> proc_macro2::TokenStream {
782 value.map_or_else(|| quote!(None), |value| quote!(Some(#value)))
783}
784
785fn field_label(attributes: &[Attribute]) -> syn::Result<Option<String>> {
786 Ok(field_options(attributes)?.label)
787}
788
789fn humanize(identifier: &str) -> String {
790 let mut output = String::with_capacity(identifier.len() + 4);
791 let mut previous_lowercase = false;
792 for (index, character) in identifier.chars().enumerate() {
793 if character == '_' || character == '-' {
794 if !output.ends_with(' ') {
795 output.push(' ');
796 }
797 previous_lowercase = false;
798 continue;
799 }
800 if character.is_uppercase() && previous_lowercase {
801 output.push(' ');
802 }
803 if index == 0 {
804 output.extend(character.to_uppercase());
805 } else {
806 output.push(character);
807 }
808 previous_lowercase = character.is_lowercase();
809 }
810 output
811}