aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-console-macros
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-22 22:53:09 +0900
committernsfisis <nsfisis@gmail.com>2026-02-22 22:53:22 +0900
commit6f3802fd9f39c4e5847d130b4417b5cdfb66972d (patch)
tree166cca2cf0645d280bfa376a513a049c70241dea /crates/mozart-console-macros
parent1d33728151b282949e7e14646e722d7775de4453 (diff)
downloadphp-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.toml15
-rw-r--r--crates/mozart-console-macros/src/codegen.rs155
-rw-r--r--crates/mozart-console-macros/src/lib.rs70
-rw-r--r--crates/mozart-console-macros/src/parser.rs249
-rw-r--r--crates/mozart-console-macros/tests/integration.rs94
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>");
+}