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 | |
| 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')
| -rw-r--r-- | crates/mozart-console-macros/Cargo.toml | 15 | ||||
| -rw-r--r-- | crates/mozart-console-macros/src/codegen.rs | 155 | ||||
| -rw-r--r-- | crates/mozart-console-macros/src/lib.rs | 70 | ||||
| -rw-r--r-- | crates/mozart-console-macros/src/parser.rs | 249 | ||||
| -rw-r--r-- | crates/mozart-console-macros/tests/integration.rs | 94 |
5 files changed, 583 insertions, 0 deletions
diff --git a/crates/mozart-console-macros/Cargo.toml b/crates/mozart-console-macros/Cargo.toml new file mode 100644 index 0000000..19a2fe5 --- /dev/null +++ b/crates/mozart-console-macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mozart-console-macros" +version.workspace = true +edition.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true + +[dev-dependencies] +mozart-core.workspace = true 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) + ) + } + } + } + } +} diff --git a/crates/mozart-console-macros/src/lib.rs b/crates/mozart-console-macros/src/lib.rs new file mode 100644 index 0000000..3af6f82 --- /dev/null +++ b/crates/mozart-console-macros/src/lib.rs @@ -0,0 +1,70 @@ +mod codegen; +mod parser; + +use proc_macro::TokenStream; +use syn::Expr; +use syn::punctuated::Punctuated; + +/// Format a string with Symfony Console-style tags. +/// +/// Supported tags: `<info>`, `<comment>`, `<error>`, `<question>`, `<highlight>`, `<warning>`. +/// +/// # Examples +/// +/// ```ignore +/// // Single tagged segment +/// console_format!("<info>All packages are up to date.</info>") +/// +/// // With format arguments +/// console_format!("<info>Removing {name} from require-dev</info>") +/// +/// // Mixed tags +/// console_format!("<info>{}</info> : <comment>{}</comment>", label, value) +/// +/// // Plain text (equivalent to format!) +/// console_format!("plain text {}", x) +/// ``` +#[proc_macro] +pub fn console_format(input: TokenStream) -> TokenStream { + let input2: proc_macro2::TokenStream = input.into(); + match console_format_impl(input2) { + Ok(tokens) => tokens.into(), + Err(err) => err.into_compile_error().into(), + } +} + +fn console_format_impl( + input: proc_macro2::TokenStream, +) -> Result<proc_macro2::TokenStream, syn::Error> { + let args: ConsoleFormatArgs = syn::parse2(input)?; + let segments = parser::parse_format_string(&args.format_str) + .map_err(|msg| syn::Error::new(args.format_str_span, msg))?; + Ok(codegen::generate(&segments, &args.extra_args)) +} + +struct ConsoleFormatArgs { + format_str: String, + format_str_span: proc_macro2::Span, + extra_args: Punctuated<Expr, syn::Token![,]>, +} + +impl syn::parse::Parse for ConsoleFormatArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let lit: syn::LitStr = input.parse()?; + let format_str = lit.value(); + let format_str_span = lit.span(); + + let extra_args = if input.peek(syn::Token![,]) { + input.parse::<syn::Token![,]>()?; + Punctuated::parse_terminated(input)? + } else { + Punctuated::new() + }; + + Ok(ConsoleFormatArgs { + format_str, + format_str_span, + extra_args, + }) + } +} diff --git a/crates/mozart-console-macros/src/parser.rs b/crates/mozart-console-macros/src/parser.rs new file mode 100644 index 0000000..a854967 --- /dev/null +++ b/crates/mozart-console-macros/src/parser.rs @@ -0,0 +1,249 @@ +const KNOWN_TAGS: &[&str] = &[ + "info", + "comment", + "error", + "question", + "highlight", + "warning", +]; + +#[derive(Debug, Clone, PartialEq)] +pub enum Segment { + Plain(String), + Tagged { tag: String, content: String }, +} + +pub fn parse_format_string(input: &str) -> Result<Vec<Segment>, String> { + let mut segments: Vec<Segment> = Vec::new(); + let mut chars = input.char_indices().peekable(); + let mut plain_buf = String::new(); + + while let Some(&(i, ch)) = chars.peek() { + if ch == '<' { + // Try to match an opening tag + if let Some((tag, after_tag)) = try_parse_open_tag(input, i) { + // Flush plain buffer + if !plain_buf.is_empty() { + segments.push(Segment::Plain(std::mem::take(&mut plain_buf))); + } + + // Advance past the opening tag + while chars.peek().is_some_and(|&(j, _)| j < after_tag) { + chars.next(); + } + + // Collect content until closing tag + let closing = format!("</{tag}>"); + let content_start = after_tag; + let Some(close_pos) = input[content_start..].find(&closing) else { + return Err(format!("unclosed <{tag}> tag")); + }; + let content_end = content_start + close_pos; + let content = &input[content_start..content_end]; + + // Check for nested tags + if contains_known_tag(content) { + return Err(format!("nested tags are not supported inside <{tag}>")); + } + + segments.push(Segment::Tagged { + tag: tag.to_string(), + content: content.to_string(), + }); + + // Advance past the closing tag + let after_close = content_end + closing.len(); + while chars.peek().is_some_and(|&(j, _)| j < after_close) { + chars.next(); + } + } else { + // Not a known tag, treat as literal + plain_buf.push(ch); + chars.next(); + } + } else { + plain_buf.push(ch); + chars.next(); + } + } + + if !plain_buf.is_empty() { + segments.push(Segment::Plain(plain_buf)); + } + + Ok(segments) +} + +/// Try to parse an opening tag like `<info>` at position `pos`. +/// Returns `(tag_name, byte_index_after_closing_angle)` on success. +fn try_parse_open_tag(input: &str, pos: usize) -> Option<(&str, usize)> { + let rest = &input[pos + 1..]; // skip '<' + // Must not start with '/' + if rest.starts_with('/') { + return None; + } + let end = rest.find('>')?; + let tag_name = &rest[..end]; + if KNOWN_TAGS.contains(&tag_name) { + Some((tag_name, pos + 1 + end + 1)) + } else { + None + } +} + +/// Check if a string contains any known opening tag (for nesting detection). +fn contains_known_tag(s: &str) -> bool { + for tag in KNOWN_TAGS { + if s.contains(&format!("<{tag}>")) { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_text_only() { + let result = parse_format_string("hello world").unwrap(); + assert_eq!(result, vec![Segment::Plain("hello world".into())]); + } + + #[test] + fn single_tag() { + let result = parse_format_string("<info>hello</info>").unwrap(); + assert_eq!( + result, + vec![Segment::Tagged { + tag: "info".into(), + content: "hello".into() + }] + ); + } + + #[test] + fn tag_with_placeholder() { + let result = parse_format_string("<info>Removing {name}</info>").unwrap(); + assert_eq!( + result, + vec![Segment::Tagged { + tag: "info".into(), + content: "Removing {name}".into() + }] + ); + } + + #[test] + fn multiple_tags() { + let result = parse_format_string("<info>{}</info> : <comment>{}</comment>").unwrap(); + assert_eq!( + result, + vec![ + Segment::Tagged { + tag: "info".into(), + content: "{}".into() + }, + Segment::Plain(" : ".into()), + Segment::Tagged { + tag: "comment".into(), + content: "{}".into() + }, + ] + ); + } + + #[test] + fn all_tag_types() { + for tag in KNOWN_TAGS { + let input = format!("<{tag}>text</{tag}>"); + let result = parse_format_string(&input).unwrap(); + assert_eq!( + result, + vec![Segment::Tagged { + tag: tag.to_string(), + content: "text".into() + }] + ); + } + } + + #[test] + fn unknown_tag_treated_as_literal() { + let result = parse_format_string("<bold>text</bold>").unwrap(); + assert_eq!(result, vec![Segment::Plain("<bold>text</bold>".into())]); + } + + #[test] + fn unclosed_tag_error() { + let result = parse_format_string("<info>text"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("unclosed")); + } + + #[test] + fn nested_tag_error() { + let result = parse_format_string("<info><comment>text</comment></info>"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("nested")); + } + + #[test] + fn escaped_braces() { + let result = parse_format_string("<info>{{literal}}</info>").unwrap(); + assert_eq!( + result, + vec![Segment::Tagged { + tag: "info".into(), + content: "{{literal}}".into() + }] + ); + } + + #[test] + fn adjacent_tags() { + let result = parse_format_string("<info>a</info><comment>b</comment>").unwrap(); + assert_eq!( + result, + vec![ + Segment::Tagged { + tag: "info".into(), + content: "a".into() + }, + Segment::Tagged { + tag: "comment".into(), + content: "b".into() + }, + ] + ); + } + + #[test] + fn plain_before_and_after_tag() { + let result = parse_format_string("before <info>middle</info> after").unwrap(); + assert_eq!( + result, + vec![ + Segment::Plain("before ".into()), + Segment::Tagged { + tag: "info".into(), + content: "middle".into() + }, + Segment::Plain(" after".into()), + ] + ); + } + + #[test] + fn empty_content_tag() { + let result = parse_format_string("<info></info>").unwrap(); + assert_eq!( + result, + vec![Segment::Tagged { + tag: "info".into(), + content: String::new() + }] + ); + } +} diff --git a/crates/mozart-console-macros/tests/integration.rs b/crates/mozart-console-macros/tests/integration.rs new file mode 100644 index 0000000..36a4e03 --- /dev/null +++ b/crates/mozart-console-macros/tests/integration.rs @@ -0,0 +1,94 @@ +use mozart_core::console_format; + +#[test] +fn plain_text_no_tags() { + let result = console_format!("hello world"); + assert_eq!(result, "hello world"); +} + +#[test] +fn plain_text_with_format_args() { + let x = 42; + let result = console_format!("value is {}", x); + assert_eq!(result, "value is 42"); +} + +#[test] +fn single_info_tag() { + // The output should contain the text (colored), verify it contains the raw text + let result = console_format!("<info>done</info>"); + assert!(result.contains("done"), "expected 'done' in: {result}"); +} + +#[test] +fn single_tag_with_format_arg() { + let name = "foo"; + let result = console_format!("<info>Removing {name}</info>"); + assert!( + result.contains("Removing foo"), + "expected 'Removing foo' in: {result}" + ); +} + +#[test] +fn multiple_tags() { + let label = "pkg"; + let version = "1.0"; + let result = console_format!("<info>{}</info> : <comment>{}</comment>", label, version); + assert!(result.contains("pkg"), "expected 'pkg' in: {result}"); + assert!(result.contains("1.0"), "expected '1.0' in: {result}"); + assert!(result.contains(" : "), "expected ' : ' in: {result}"); +} + +#[test] +fn comment_tag() { + let result = console_format!("<comment>note</comment>"); + assert!(result.contains("note")); +} + +#[test] +fn error_tag() { + let result = console_format!("<error>fail</error>"); + assert!(result.contains("fail")); +} + +#[test] +fn question_tag() { + let result = console_format!("<question>ask</question>"); + assert!(result.contains("ask")); +} + +#[test] +fn highlight_tag() { + let result = console_format!("<highlight>important</highlight>"); + assert!(result.contains("important")); +} + +#[test] +fn warning_tag() { + let result = console_format!("<warning>caution</warning>"); + assert!(result.contains("caution")); +} + +#[test] +fn escaped_braces() { + let result = console_format!("<info>{{literal}}</info>"); + assert!( + result.contains("{literal}"), + "expected '{{literal}}' in: {result}" + ); +} + +#[test] +fn tag_with_plain_before_after() { + let result = console_format!("before <info>middle</info> after"); + assert!(result.contains("before ")); + assert!(result.contains("middle")); + assert!(result.contains(" after")); +} + +#[test] +fn unknown_tag_is_literal() { + let result = console_format!("<bold>text</bold>"); + assert_eq!(result, "<bold>text</bold>"); +} |
