diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 22:53:09 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 22:53:22 +0900 |
| commit | 6f3802fd9f39c4e5847d130b4417b5cdfb66972d (patch) | |
| tree | 166cca2cf0645d280bfa376a513a049c70241dea /crates/mozart-console-macros/src/codegen.rs | |
| parent | 1d33728151b282949e7e14646e722d7775de4453 (diff) | |
| download | php-mozart-6f3802fd9f39c4e5847d130b4417b5cdfb66972d.tar.gz php-mozart-6f3802fd9f39c4e5847d130b4417b5cdfb66972d.tar.zst php-mozart-6f3802fd9f39c4e5847d130b4417b5cdfb66972d.zip | |
refactor(console): add console_format! proc macro and migrate all commands
Introduce a Symfony Console-style tag macro that replaces verbose
patterns like `console::info(&format!("text {name}"))` with
`console_format!("<info>text {name}</info>")`. Supports all 6 tag
types (info, comment, error, question, highlight, warning) with
format argument distribution across multiple tagged segments.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-console-macros/src/codegen.rs')
| -rw-r--r-- | crates/mozart-console-macros/src/codegen.rs | 155 |
1 files changed, 155 insertions, 0 deletions
diff --git a/crates/mozart-console-macros/src/codegen.rs b/crates/mozart-console-macros/src/codegen.rs new file mode 100644 index 0000000..11e37f9 --- /dev/null +++ b/crates/mozart-console-macros/src/codegen.rs @@ -0,0 +1,155 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Expr; +use syn::punctuated::Punctuated; + +use crate::parser::Segment; + +/// Returns true if the string contains any format placeholders (`{}`, `{name}`, `{0}`, `{:<10}`, etc.) +/// but not escaped braces `{{` or `}}`. +fn has_placeholders(s: &str) -> bool { + let mut chars = s.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '{' { + match chars.peek() { + Some('{') => { + chars.next(); // skip escaped + } + _ => return true, + } + } else if ch == '}' && chars.peek() == Some(&'}') { + chars.next(); // skip escaped + } + } + false +} + +/// Count implicit positional placeholders (`{}` and `{:spec}`) in a format string. +/// Named (`{name}`) and numbered (`{0}`) placeholders are NOT counted +/// since they don't consume positional arguments. +fn count_positional_placeholders(s: &str) -> usize { + let mut count = 0; + let mut chars = s.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '{' { + match chars.peek() { + Some('{') => { + chars.next(); // escaped + } + Some('}') => { + // `{}` — implicit positional + count += 1; + chars.next(); + } + Some(':') => { + // `{:spec}` — implicit positional with format spec + count += 1; + for c in chars.by_ref() { + if c == '}' { + break; + } + } + } + Some(c) if c.is_ascii_digit() => { + // `{0}`, `{0:spec}` — explicit positional, skip + for c in chars.by_ref() { + if c == '}' { + break; + } + } + } + _ => { + // `{name}` or `{name:spec}` — named, skip + for c in chars.by_ref() { + if c == '}' { + break; + } + } + } + } + } else if ch == '}' && chars.peek() == Some(&'}') { + chars.next(); + } + } + count +} + +pub fn generate( + segments: &[Segment], + extra_args: &Punctuated<Expr, syn::Token![,]>, +) -> TokenStream { + // Single segment: pass all extra args + if segments.len() == 1 { + return generate_single(&segments[0], extra_args); + } + + // Multiple segments: distribute positional args across segments + let mut pos = 0usize; + let mut seg_bindings = Vec::new(); + let mut seg_idents = Vec::new(); + + for (i, segment) in segments.iter().enumerate() { + let content = segment_content(segment); + let n = count_positional_placeholders(content); + let end = (pos + n).min(extra_args.len()); + let slice: Punctuated<Expr, syn::Token![,]> = extra_args + .iter() + .skip(pos) + .take(end - pos) + .cloned() + .collect(); + pos = end; + + let ident = quote::format_ident!("__seg{}", i); + let expr = generate_single(segment, &slice); + seg_bindings.push(quote! { let #ident = #expr; }); + seg_idents.push(ident); + } + + // Build a format string with one `{}` per segment + let fmt_str = seg_idents.iter().map(|_| "{}").collect::<Vec<_>>().join(""); + + quote! { + { + #(#seg_bindings)* + ::std::format!(#fmt_str, #(#seg_idents),*) + } + } +} + +fn segment_content(segment: &Segment) -> &str { + match segment { + Segment::Plain(s) => s, + Segment::Tagged { content, .. } => content, + } +} + +fn generate_single(segment: &Segment, args: &Punctuated<Expr, syn::Token![,]>) -> TokenStream { + match segment { + Segment::Plain(text) => { + if has_placeholders(text) { + let lit = proc_macro2::Literal::string(text); + quote! { ::std::format!(#lit, #args) } + } else { + quote! { ::std::string::String::from(#text) } + } + } + Segment::Tagged { tag, content } => { + let func = quote::format_ident!("{}", tag); + if has_placeholders(content) { + let lit = proc_macro2::Literal::string(content); + quote! { + ::std::string::ToString::to_string( + &::mozart_core::console::#func(&::std::format!(#lit, #args)) + ) + } + } else { + quote! { + ::std::string::ToString::to_string( + &::mozart_core::console::#func(#content) + ) + } + } + } + } +} |
