Skip to main content

spraypaint_macros/
lib.rs

1//! Procedural macros for the `spraypaint` crate.
2//!
3//! This crate is not intended to be used directly; import from `spraypaint` instead.
4
5use proc_macro::TokenStream;
6use proc_macro2::Span;
7use quote::quote;
8use syn::{
9    parse::{Parse, ParseStream},
10    parse_macro_input, LitStr, Token,
11};
12
13// ── Public macro entrypoints ──────────────────────────────────────────────────
14
15/// Print styled text to stdout with a trailing newline.
16///
17/// # Syntax
18/// ```text
19/// paint!("template string")
20/// paint!(inline, "template string")   // no trailing newline
21/// paint!(stderr, "template string")   // print to stderr with newline
22/// ```
23///
24/// # Template Format
25/// ```text
26/// paint!("{red.bold Error:} something went wrong");
27/// paint!("Hello {green.italic world}!");
28/// paint!("{blue Welcome to {bold.underline spraypaint}}");
29///
30/// let name = "world";
31/// paint!("Hello {green.bold {name}}!");
32/// ```
33#[proc_macro]
34pub fn paint(input: TokenStream) -> TokenStream {
35    let input = parse_macro_input!(input as PaintInput);
36    let parts = match parse_template(&input.template, input.template_span) {
37        Ok(p) => p,
38        Err(e) => return e.into_compile_error().into(),
39    };
40    let exprs = parts_to_exprs(&parts);
41
42    match input.mode {
43        PrintMode::Stdout => quote! {
44            {
45                use ::std::io::Write as _;
46                #( ::std::print!("{}", #exprs); )*
47                ::std::println!();
48            }
49        },
50        PrintMode::Inline => quote! {
51            {
52                use ::std::io::Write as _;
53                #( ::std::print!("{}", #exprs); )*
54                let _ = ::std::io::stdout().flush();
55            }
56        },
57        PrintMode::Stderr => quote! {
58            {
59                #( ::std::eprint!("{}", #exprs); )*
60                ::std::eprintln!();
61            }
62        },
63    }
64    .into()
65}
66
67/// Return an owned `String` with ANSI styling applied (does not print).
68///
69/// Use this when you need to pass styled text to a logger, format string, etc.
70///
71/// # Example
72/// ```rust,ignore
73/// use spraypaint::styled;
74///
75/// let msg = styled!("{red.bold Error:} something went wrong");
76/// eprintln!("{msg}");
77/// ```
78#[proc_macro]
79pub fn styled(input: TokenStream) -> TokenStream {
80    let lit = parse_macro_input!(input as LitStr);
81    let span = lit.span();
82    let template = lit.value();
83
84    let parts = match parse_template(&template, span) {
85        Ok(p) => p,
86        Err(e) => return e.into_compile_error().into(),
87    };
88    let exprs = parts_to_exprs(&parts);
89
90    quote! {
91        {
92            use ::std::fmt::Write as _;
93            let mut __buf = ::std::string::String::new();
94            #( ::std::write!(__buf, "{}", #exprs)
95                .expect("fmt::Write to String is infallible"); )*
96            __buf
97        }
98    }
99    .into()
100}
101
102// ── Input structs ─────────────────────────────────────────────────────────────
103
104enum PrintMode {
105    Stdout,
106    Inline,
107    Stderr,
108}
109
110struct PaintInput {
111    mode: PrintMode,
112    template: String,
113    template_span: Span,
114}
115
116impl Parse for PaintInput {
117    fn parse(input: ParseStream) -> syn::Result<Self> {
118        if input.peek(syn::Ident) && !input.peek2(Token![,]) {
119            // Single ident with no comma: must be the template (treat as error below
120            // if it's not a string literal). Fall through.
121        }
122        if input.peek(syn::Ident) {
123            let ident: syn::Ident = input.parse()?;
124            let _: Token![,] = input.parse()?;
125            let lit: LitStr = input.parse()?;
126            let mode = match ident.to_string().as_str() {
127                "inline" => PrintMode::Inline,
128                "stderr" => PrintMode::Stderr,
129                other => {
130                    return Err(syn::Error::new(
131                        ident.span(),
132                        format!("unknown paint! mode `{other}`; expected `inline` or `stderr`"),
133                    ))
134                }
135            };
136            return Ok(PaintInput {
137                mode,
138                template: lit.value(),
139                template_span: lit.span(),
140            });
141        }
142        let lit: LitStr = input.parse()?;
143        Ok(PaintInput {
144            mode: PrintMode::Stdout,
145            template: lit.value(),
146            template_span: lit.span(),
147        })
148    }
149}
150
151// ── Template AST ──────────────────────────────────────────────────────────────
152
153#[derive(Debug)]
154enum Part {
155    /// A plain string literal fragment.
156    Literal(String),
157    /// A `{style1.style2 content}` block.
158    Styled {
159        styles: Vec<String>,
160        inner: Vec<Part>,
161    },
162    /// A `{expr}` interpolation (arbitrary Rust expression).
163    Expr(String),
164}
165
166// ── Template parser ───────────────────────────────────────────────────────────
167
168fn parse_template(template: &str, span: Span) -> syn::Result<Vec<Part>> {
169    let chars: Vec<char> = template.chars().collect();
170    let mut parser = Parser {
171        chars,
172        pos: 0,
173        span,
174    };
175    parser.parse_sequence(false)
176}
177
178struct Parser {
179    chars: Vec<char>,
180    pos: usize,
181    span: Span,
182}
183
184impl Parser {
185    fn peek(&self) -> Option<char> {
186        self.chars.get(self.pos).copied()
187    }
188
189    fn advance(&mut self) -> Option<char> {
190        let c = self.chars.get(self.pos).copied();
191        if c.is_some() {
192            self.pos += 1;
193        }
194        c
195    }
196
197    /// Parse a sequence of parts until EOF or (if `stop=true`) a closing `}`.
198    fn parse_sequence(&mut self, stop_at_close: bool) -> syn::Result<Vec<Part>> {
199        let mut parts = Vec::new();
200        let mut literal = String::new();
201
202        loop {
203            match self.peek() {
204                None => break,
205                Some('}') if stop_at_close => {
206                    self.advance();
207                    break;
208                }
209                Some('{') => {
210                    if !literal.is_empty() {
211                        parts.push(Part::Literal(std::mem::take(&mut literal)));
212                    }
213                    self.advance(); // consume '{'
214                    parts.push(self.parse_brace()?);
215                }
216                Some(c) => {
217                    literal.push(c);
218                    self.advance();
219                }
220            }
221        }
222
223        if !literal.is_empty() {
224            parts.push(Part::Literal(literal));
225        }
226        Ok(parts)
227    }
228
229    /// Parse the content after an opening `{`.
230    fn parse_brace(&mut self) -> syn::Result<Part> {
231        // Peek ahead to determine whether this is a style spec or a raw expression.
232        // Strategy: read the first "word" (up to whitespace or end of brace group).
233        let save = self.pos;
234        let first_word = self.read_word();
235
236        if !first_word.is_empty() && looks_like_style_spec(&first_word) {
237            // Parse style spec tokens.
238            let styles = parse_style_spec(&first_word, self.span)?;
239            // Skip optional single space between spec and content.
240            if self.peek() == Some(' ') {
241                self.advance();
242            }
243            let inner = self.parse_sequence(true)?;
244            Ok(Part::Styled { styles, inner })
245        } else {
246            // Raw Rust expression: restore position and consume until matching `}`.
247            self.pos = save;
248            let expr = self.read_until_close_brace()?;
249            Ok(Part::Expr(expr.trim().to_string()))
250        }
251    }
252
253    /// Read contiguous non-whitespace, non-brace characters (the style spec).
254    fn read_word(&mut self) -> String {
255        let mut word = String::new();
256        while let Some(c) = self.peek() {
257            if c.is_whitespace() || c == '{' || c == '}' {
258                break;
259            }
260            word.push(c);
261            self.advance();
262        }
263        word
264    }
265
266    /// Consume until the matching `}` (handling nested braces), return the content.
267    fn read_until_close_brace(&mut self) -> syn::Result<String> {
268        let mut s = String::new();
269        let mut depth = 1usize;
270        loop {
271            match self.advance() {
272                None => {
273                    return Err(syn::Error::new(
274                        self.span,
275                        "unclosed `{` in paint! template",
276                    ));
277                }
278                Some('{') => {
279                    depth += 1;
280                    s.push('{');
281                }
282                Some('}') => {
283                    depth -= 1;
284                    if depth == 0 {
285                        break;
286                    }
287                    s.push('}');
288                }
289                Some(c) => s.push(c),
290            }
291        }
292        Ok(s)
293    }
294}
295
296// ── Style spec helpers ────────────────────────────────────────────────────────
297
298/// Return true if every dot-separated token in `spec` is a known style name.
299fn looks_like_style_spec(spec: &str) -> bool {
300    spec.split('.').all(|t| {
301        let t = t.trim();
302        is_known_style(t) || t.starts_with("rgb(") || t.starts_with("hex(")
303    })
304}
305
306fn is_known_style(token: &str) -> bool {
307    matches!(
308        token,
309        "black"
310            | "red"
311            | "green"
312            | "yellow"
313            | "blue"
314            | "magenta"
315            | "cyan"
316            | "white"
317            | "bright_black"
318            | "bright_red"
319            | "bright_green"
320            | "bright_yellow"
321            | "bright_blue"
322            | "bright_magenta"
323            | "bright_cyan"
324            | "bright_white"
325            | "on_black"
326            | "on_red"
327            | "on_green"
328            | "on_yellow"
329            | "on_blue"
330            | "on_magenta"
331            | "on_cyan"
332            | "on_white"
333            | "on_bright_black"
334            | "on_bright_red"
335            | "on_bright_green"
336            | "on_bright_yellow"
337            | "on_bright_blue"
338            | "on_bright_magenta"
339            | "on_bright_cyan"
340            | "on_bright_white"
341            | "bold"
342            | "dim"
343            | "italic"
344            | "underline"
345            | "blink"
346            | "blink_fast"
347            | "reverse"
348            | "hidden"
349            | "strikethrough"
350    )
351}
352
353fn parse_style_spec(spec: &str, span: Span) -> syn::Result<Vec<String>> {
354    let mut result = Vec::new();
355    for token in spec.split('.') {
356        let token = token.trim();
357        if token.is_empty() {
358            continue;
359        }
360        if is_known_style(token) || token.starts_with("rgb(") || token.starts_with("hex(") {
361            result.push(token.to_string());
362        } else {
363            return Err(syn::Error::new(
364                span,
365                format!(
366                    "unknown style `{token}` in paint! template\n\
367                     hint: valid colors are red, green, blue, yellow, magenta, cyan, white, black\n\
368                     hint: valid attributes are bold, dim, italic, underline, strikethrough, reverse"
369                ),
370            ));
371        }
372    }
373    Ok(result)
374}
375
376// ── Code generation ───────────────────────────────────────────────────────────
377
378fn parts_to_exprs(parts: &[Part]) -> Vec<proc_macro2::TokenStream> {
379    parts.iter().map(part_to_expr).collect()
380}
381
382fn part_to_expr(part: &Part) -> proc_macro2::TokenStream {
383    match part {
384        Part::Literal(s) => {
385            quote! { #s }
386        }
387
388        Part::Expr(e) => {
389            let tokens: proc_macro2::TokenStream = match e.parse() {
390                Ok(t) => t,
391                Err(lex_err) => {
392                    let msg =
393                        format!("invalid expression `{e}` in paint!/styled! template: {lex_err}");
394                    return quote! { { compile_error!(#msg); "" } };
395                }
396            };
397            quote! { &::std::format!("{}", #tokens) }
398        }
399
400        Part::Styled { styles, inner } => {
401            let inner_exprs = parts_to_exprs(inner);
402            let method_chain = build_method_chain(styles);
403
404            // Collect inner parts into an owned String, then apply the style chain.
405            // `String` implements `Colorize`, so chaining methods returns `Styled<String>`,
406            // which implements `Display` -- safe to pass to `print!`.
407            quote! {
408                {
409                    use ::std::fmt::Write as _;
410                    use ::spraypaint::Colorize as _;
411                    let mut __inner = ::std::string::String::new();
412                    #( ::std::write!(__inner, "{}", #inner_exprs)
413                        .expect("fmt::Write to String is infallible"); )*
414                    __inner #method_chain
415                }
416            }
417        }
418    }
419}
420
421/// Build a dot-chained method call sequence, e.g. `.red().bold().italic()`.
422fn build_method_chain(styles: &[String]) -> proc_macro2::TokenStream {
423    let mut chain = quote! {};
424    for style in styles {
425        if let Some(inner) = style.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
426            let parts: Vec<&str> = inner.split(',').collect();
427            if parts.len() == 3 {
428                if let (Ok(r), Ok(g), Ok(b)) = (
429                    parts[0].trim().parse::<u8>(),
430                    parts[1].trim().parse::<u8>(),
431                    parts[2].trim().parse::<u8>(),
432                ) {
433                    chain = quote! { #chain .rgb(#r, #g, #b) };
434                    continue;
435                }
436            }
437        }
438        if let Some(inner) = style.strip_prefix("hex(").and_then(|s| s.strip_suffix(')')) {
439            chain = quote! { #chain .hex(#inner) };
440            continue;
441        }
442        let ident = proc_macro2::Ident::new(style, Span::call_site());
443        chain = quote! { #chain .#ident() };
444    }
445    chain
446}