Skip to main content

cu29_log_derive/
lib.rs

1extern crate proc_macro;
2
3use cu29_intern_strs::intern_string;
4use cu29_log::CuLogLevel;
5use proc_macro::TokenStream;
6#[cfg(feature = "textlogs")]
7use proc_macro_crate::{FoundCrate, crate_name};
8#[cfg(feature = "textlogs")]
9use proc_macro2::Span;
10use proc_macro2::TokenStream as TokenStream2;
11use quote::quote;
12#[allow(unused)]
13use syn::Token;
14use syn::parse::Parser;
15#[cfg(any(feature = "textlogs", debug_assertions))]
16use syn::spanned::Spanned;
17use syn::{Expr, ExprLit, Lit};
18
19struct ParsedLogArgs<'a> {
20    context_expr: Option<&'a Expr>,
21    msg_expr: &'a Expr,
22    param_exprs: Vec<&'a Expr>,
23}
24
25fn parse_log_exprs(
26    input: TokenStream,
27) -> syn::Result<syn::punctuated::Punctuated<Expr, syn::Token![,]>> {
28    let parser = syn::punctuated::Punctuated::<Expr, syn::Token![,]>::parse_terminated;
29    parser.parse(input)
30}
31
32fn parse_log_args<'a>(
33    exprs: &'a syn::punctuated::Punctuated<Expr, syn::Token![,]>,
34) -> syn::Result<ParsedLogArgs<'a>> {
35    let Some(first_expr) = exprs.first() else {
36        return Err(syn::Error::new(
37            proc_macro2::Span::call_site(),
38            "Expected at least one expression",
39        ));
40    };
41
42    let (context_expr, msg_expr, msg_index) = if matches!(
43        first_expr,
44        Expr::Lit(ExprLit {
45            lit: Lit::Str(_),
46            ..
47        })
48    ) {
49        (None, first_expr, 0)
50    } else {
51        let Some(second_expr) = exprs.iter().nth(1) else {
52            return Err(syn::Error::new_spanned(
53                first_expr,
54                "Expected a string literal as the first argument, or as the second argument after a CuContext expression.",
55            ));
56        };
57        if matches!(
58            second_expr,
59            Expr::Lit(ExprLit {
60                lit: Lit::Str(_),
61                ..
62            })
63        ) {
64            (Some(first_expr), second_expr, 1)
65        } else {
66            return Err(syn::Error::new_spanned(
67                second_expr,
68                "Expected a string literal as the first argument, or as the second argument after a CuContext expression.",
69            ));
70        }
71    };
72
73    Ok(ParsedLogArgs {
74        context_expr,
75        msg_expr,
76        param_exprs: exprs.iter().skip(msg_index + 1).collect(),
77    })
78}
79
80/// Create reference of unused_variables to avoid warnings
81/// ex: let _ = &tmp;
82#[allow(unused)]
83fn reference_unused_variables(input: TokenStream) -> TokenStream {
84    // Attempt to parse the expressions to "use" them.
85    // This ensures variables passed to the macro are considered used by the compiler.
86    if let Ok(exprs) = parse_log_exprs(input.clone())
87        && let Ok(parsed) = parse_log_args(&exprs)
88    {
89        let mut var_usages = Vec::new();
90        if let Some(context_expr) = parsed.context_expr {
91            var_usages.push(quote::quote! { let _ = &#context_expr; });
92        }
93        for expr in parsed.param_exprs {
94            match expr {
95                syn::Expr::Assign(assign_expr) => {
96                    let value_expr = &assign_expr.right;
97                    var_usages.push(quote::quote! { let _ = &#value_expr; });
98                }
99                _ => {
100                    var_usages.push(quote::quote! { let _ = &#expr; });
101                }
102            }
103        }
104        return quote::quote! { { #(#var_usages;)* } }.into();
105    }
106
107    if let Ok(exprs) = parse_log_exprs(input.clone()) {
108        let mut var_usages = Vec::new();
109        for expr in exprs.iter() {
110            match expr {
111                // If the argument is an assignment (e.g., `foo = bar`),
112                // we need to ensure `bar` (the right-hand side) is "used".
113                syn::Expr::Assign(assign_expr) => {
114                    let value_expr = &assign_expr.right;
115                    var_usages.push(quote::quote! { let _ = &#value_expr; });
116                }
117                // Otherwise, for any other expression, ensure it's "used".
118                _ => {
119                    var_usages.push(quote::quote! { let _ = &#expr; });
120                }
121            }
122        }
123        // Return a block that contains these dummy "usages".
124        // If only a format string was passed, var_usages will be empty,
125        // resulting in an empty block `{}`, which is fine.
126        return quote::quote! { { #(#var_usages;)* } }.into();
127    }
128
129    // Fallback: if parsing fails for some reason, return an empty TokenStream.
130    // This might still lead to warnings if parsing failed but is better than panicking.
131    TokenStream::new()
132}
133
134/// Returns `true` if `s` is a valid Rust identifier (used to tell an implicit
135/// capture like `{hash}` apart from a positional `{}` / `{0}` placeholder).
136fn is_ident(s: &str) -> bool {
137    let mut chars = s.chars();
138    match chars.next() {
139        Some(c) if c == '_' || c.is_alphabetic() => {}
140        _ => return false,
141    }
142    chars.all(|c| c == '_' || c.is_alphanumeric())
143}
144
145/// Extract identifiers implicitly captured by inline format placeholders,
146/// mirroring std `format!` capture (Rust 1.58+): `{ident}` (or `{ident:spec}`)
147/// captures `ident` from the caller's scope. Returns names in order of first
148/// appearance. Escaped braces (`{{`, `}}`) and positional placeholders
149/// (`{}`, `{0}`) are ignored.
150fn extract_captured_idents(fmt: &str) -> Vec<String> {
151    let mut names = Vec::new();
152    let mut rest = fmt;
153    while let Some(open) = rest.find('{') {
154        if rest[open + 1..].starts_with('{') {
155            rest = &rest[open + 2..]; // escaped "{{"
156            continue;
157        }
158        let after = &rest[open + 1..];
159        let Some(close) = after.find('}') else { break };
160        // Only the part before a `:` format spec is the identifier.
161        let name = after[..close].split(':').next().unwrap_or("").trim();
162        if is_ident(name) && !names.iter().any(|n| n == name) {
163            names.push(name.to_string());
164        }
165        rest = &after[close + 1..];
166    }
167    names
168}
169
170/// Create a log entry at the specified log level.
171///
172/// This is the internal macro implementation used by all the logging macros.
173/// Users should use the public-facing macros: `debug!`, `info!`, `warning!`, `error!`, or `critical!`.
174#[allow(unused)]
175fn create_log_entry(input: TokenStream, level: CuLogLevel) -> TokenStream {
176    use quote::quote;
177    use syn::{Expr, ExprAssign, ExprLit, Lit, Token};
178
179    let exprs = match parse_log_exprs(input) {
180        Ok(exprs) => exprs,
181        Err(err) => return err.to_compile_error().into(),
182    };
183    let parsed = match parse_log_args(&exprs) {
184        Ok(parsed) => parsed,
185        Err(err) => return err.to_compile_error().into(),
186    };
187
188    #[cfg(not(feature = "std"))]
189    const STD: bool = false;
190    #[cfg(feature = "std")]
191    const STD: bool = true;
192
193    let msg_expr = parsed.msg_expr;
194    let (index, msg_str) = if let Expr::Lit(ExprLit {
195        lit: Lit::Str(msg), ..
196    }) = msg_expr
197    {
198        let s = msg.value();
199        let index = match intern_string(&s) {
200            Some(index) => index,
201            None => {
202                return syn::Error::new_spanned(msg_expr, "Failed to intern log string.")
203                    .to_compile_error()
204                    .into();
205            }
206        };
207        (index, s)
208    } else {
209        return syn::Error::new_spanned(
210            msg_expr,
211            "The first parameter of the argument needs to be a string literal.",
212        )
213        .to_compile_error()
214        .into();
215    };
216
217    let level_ident = match level {
218        CuLogLevel::Debug => quote! { Debug },
219        CuLogLevel::Info => quote! { Info },
220        CuLogLevel::Warning => quote! { Warning },
221        CuLogLevel::Error => quote! { Error },
222        CuLogLevel::Critical => quote! { Critical },
223    };
224
225    // Implicit inline capture (std `format!` style, Rust 1.58+): `{ident}` in the
226    // message captures `ident` from the caller's scope as a named param. Declared
227    // before `named_params` so the owned exprs outlive the borrows below.
228    let captured_exprs: Vec<Expr> = extract_captured_idents(&msg_str)
229        .iter()
230        .filter_map(|name| syn::parse_str::<Expr>(name).ok())
231        .collect();
232
233    // Partition unnamed vs named args (a = b treated as named)
234    let mut unnamed_params = Vec::<&Expr>::new();
235    let mut named_params = Vec::<(&Expr, &Expr)>::new();
236
237    for expr in parsed.param_exprs {
238        if let Expr::Assign(ExprAssign { left, right, .. }) = expr {
239            named_params.push((left, right));
240        } else {
241            unnamed_params.push(expr);
242        }
243    }
244
245    // Append implicitly-captured idents as named params. An explicit `name = value`
246    // for the same name wins and suppresses the capture (no duplicate param).
247    for expr in &captured_exprs {
248        let name = quote!(#expr).to_string();
249        let already_named = named_params
250            .iter()
251            .any(|(n, _)| quote!(#n).to_string() == name);
252        if !already_named {
253            named_params.push((expr, expr));
254        }
255    }
256
257    // Build the CuLogEntry population tokens
258    let unnamed_prints = unnamed_params.iter().map(|value| {
259        quote! {
260            let param = to_value(#value).expect("Failed to convert a parameter to a Value");
261            log_entry.add_param(ANONYMOUS, param);
262        }
263    });
264
265    let mut named_prints = Vec::with_capacity(named_params.len());
266    for (name, value) in &named_params {
267        let name_str = quote!(#name).to_string();
268        let idx = match intern_string(&name_str) {
269            Some(idx) => idx,
270            None => {
271                return syn::Error::new_spanned(name, "Failed to intern log parameter name.")
272                    .to_compile_error()
273                    .into();
274            }
275        };
276        named_prints.push(quote! {
277            let param = to_value(#value).expect("Failed to convert a parameter to a Value");
278            log_entry.add_param(#idx, param);
279        });
280    }
281
282    // ---------- For baremetal: build a defmt format literal and arg list ----------
283    // defmt line: "<msg> | a={:?}, b={:?}, arg0={:?} ..."
284    #[cfg(feature = "textlogs")]
285    let (defmt_fmt_lit, defmt_args_unnamed_ts, defmt_args_named_ts, defmt_available) = {
286        let defmt_fmt_lit = {
287            let mut s = msg_str.clone();
288            if !named_params.is_empty() {
289                s.push_str(" |");
290            }
291            for (name, _) in named_params.iter() {
292                let name_str = quote!(#name).to_string();
293                s.push(' ');
294                s.push_str(&name_str);
295                s.push_str("={:?}");
296            }
297            syn::LitStr::new(&s, msg_expr.span())
298        };
299
300        let defmt_args_unnamed_ts: Vec<TokenStream2> =
301            unnamed_params.iter().map(|e| quote! { #e }).collect();
302        let defmt_args_named_ts: Vec<TokenStream2> = named_params
303            .iter()
304            .map(|(_, rhs)| quote! { #rhs })
305            .collect();
306
307        let defmt_available = crate_name("defmt").is_ok();
308
309        (
310            defmt_fmt_lit,
311            defmt_args_unnamed_ts,
312            defmt_args_named_ts,
313            defmt_available,
314        )
315    };
316
317    #[cfg(feature = "textlogs")]
318    fn defmt_macro_path(level: CuLogLevel) -> TokenStream2 {
319        let macro_ident = match level {
320            CuLogLevel::Debug => quote! { defmt_debug },
321            CuLogLevel::Info => quote! { defmt_info },
322            CuLogLevel::Warning => quote! { defmt_warn },
323            CuLogLevel::Error => quote! { defmt_error },
324            CuLogLevel::Critical => quote! { defmt_error },
325        };
326
327        let (base, use_prelude) = match crate_name("cu29-log") {
328            Ok(FoundCrate::Name(name)) => {
329                let ident = proc_macro2::Ident::new(&name, Span::call_site());
330                (quote! { ::#ident }, false)
331            }
332            Ok(FoundCrate::Itself) => (quote! { crate }, false),
333            Err(_) => match crate_name("cu29") {
334                Ok(FoundCrate::Name(name)) => {
335                    let ident = proc_macro2::Ident::new(&name, Span::call_site());
336                    (quote! { ::#ident }, true)
337                }
338                Ok(FoundCrate::Itself) => (quote! { crate }, true),
339                Err(_) => (quote! { ::cu29_log }, false),
340            },
341        };
342
343        if use_prelude {
344            quote! { #base::prelude::#macro_ident }
345        } else {
346            quote! { #base::#macro_ident }
347        }
348    }
349
350    // Runtime logging path (unchanged)
351    #[cfg(not(debug_assertions))]
352    let log_stmt = quote! { let r = log(&mut log_entry); };
353
354    #[cfg(debug_assertions)]
355    let log_stmt: TokenStream2 = {
356        let keys: Vec<_> = named_params
357            .iter()
358            .map(|(name, _)| {
359                let name_str = quote!(#name).to_string();
360                syn::LitStr::new(&name_str, name.span())
361            })
362            .collect();
363        quote! {
364            let r = log_debug_mode(&mut log_entry, #msg_str, &[#(#keys),*]);
365        }
366    };
367
368    let error_handling: Option<TokenStream2> = Some(quote! {
369        if let Err(_e) = r {
370            let _ = &_e;
371        }
372    });
373
374    #[cfg(feature = "textlogs")]
375    let defmt_macro: TokenStream2 = defmt_macro_path(level);
376
377    #[cfg(feature = "textlogs")]
378    let maybe_inject_defmt: Option<TokenStream2> = if STD || !defmt_available {
379        None // defmt is only emitted in no-std builds.
380    } else {
381        Some(quote! {
382            #defmt_macro!(#defmt_fmt_lit, #(#defmt_args_unnamed_ts,)* #(#defmt_args_named_ts,)*);
383        })
384    };
385
386    #[cfg(not(feature = "textlogs"))]
387    let maybe_inject_defmt: Option<TokenStream2> = None; // defmt emission disabled
388
389    let maybe_inject_origin: Option<TokenStream2> = parsed.context_expr.map(|ctx_expr| {
390        quote! {
391            let __cu29_log_ctx = &#ctx_expr;
392            log_entry.set_origin(
393                Some(__cu29_log_ctx.cl_id()),
394                __cu29_log_ctx
395                    .current_component_id()
396                    .map(|component_id| component_id as u32),
397                __cu29_log_ctx.task_index().map(|task_index| task_index as u32),
398            );
399        }
400    });
401
402    // Emit both: defmt (conditionally) + Copper structured logging
403    quote! {{
404        let mut log_entry = CuLogEntry::new(#index, CuLogLevel::#level_ident);
405        #maybe_inject_origin
406        #(#unnamed_prints)*
407        #(#named_prints)*
408
409        #maybe_inject_defmt
410
411        #log_stmt
412        #error_handling
413    }}
414    .into()
415}
416
417/// Log a debug message as a Copper structured log entry.
418///
419/// Accepted forms:
420/// - `debug!("message {}", value)`
421/// - `debug!(ctx, "message {}", value)`
422///
423/// When a `CuContext` is passed as the first argument, the
424/// emitted structured log entry also captures the current Copper callback origin:
425/// - `culistid`
426/// - `component_id`
427/// - `task_index` when the callback is running inside a task
428///
429/// The message argument must be a string literal. Only `{}` placeholders are supported.
430/// Remaining arguments can be unnamed expressions or named fields written as `name = value`.
431///
432/// # Example
433/// ```ignore
434/// use cu29_log_derive::debug;
435/// let a = 1;
436/// let b = 2;
437/// debug!("a = {}, b = {}", my_value = a, b); // named and unnamed parameters
438///
439/// # fn run(ctx: &cu29::context::CuContext) {
440/// debug!(ctx, "processing {}", b); // same log plus runtime origin metadata
441/// # }
442/// ```
443///
444/// You can retrieve this data using the log_reader generated with your project and giving it the
445/// unified .copper log file and the string index file generated at compile time.
446///
447/// Note: In debug mode, the log will also be printed to the console. (ie slooow).
448/// In release mode, the log will be only be written to the unified logger.
449///
450/// This macro will be compiled out if the max log level is set to a level higher than Debug.
451#[cfg(any(feature = "log-level-debug", cu29_default_log_level_debug))]
452#[proc_macro]
453pub fn debug(input: TokenStream) -> TokenStream {
454    create_log_entry(input, CuLogLevel::Debug)
455}
456
457/// Log an info message as a Copper structured log entry.
458///
459/// Accepted forms:
460/// - `info!("message {}", value)`
461/// - `info!(ctx, "message {}", value)`
462///
463/// Passing a `CuContext` as the first argument records the
464/// current `culistid`, `component_id`, and `task_index` (when present) on the structured log
465/// entry. The message argument must be a string literal, and remaining arguments may be unnamed
466/// expressions or named fields written as `name = value`.
467///
468/// This macro will be compiled out if the max log level is set to a level higher than Info.
469#[cfg(any(
470    feature = "log-level-debug",
471    feature = "log-level-info",
472    cu29_default_log_level_debug,
473    cu29_default_log_level_info,
474))]
475#[proc_macro]
476pub fn info(input: TokenStream) -> TokenStream {
477    create_log_entry(input, CuLogLevel::Info)
478}
479
480/// Log a warning message as a Copper structured log entry.
481///
482/// Accepted forms:
483/// - `warning!("message {}", value)`
484/// - `warning!(ctx, "message {}", value)`
485///
486/// Passing a `CuContext` as the first argument records the
487/// current `culistid`, `component_id`, and `task_index` (when present) on the structured log
488/// entry. The message argument must be a string literal, and remaining arguments may be unnamed
489/// expressions or named fields written as `name = value`.
490///
491/// This macro will be compiled out if the max log level is set to a level higher than Warning.
492#[cfg(any(
493    feature = "log-level-debug",
494    feature = "log-level-info",
495    feature = "log-level-warning",
496    cu29_default_log_level_debug,
497    cu29_default_log_level_info,
498))]
499#[proc_macro]
500pub fn warning(input: TokenStream) -> TokenStream {
501    create_log_entry(input, CuLogLevel::Warning)
502}
503
504/// Log an error message as a Copper structured log entry.
505///
506/// Accepted forms:
507/// - `error!("message {}", value)`
508/// - `error!(ctx, "message {}", value)`
509///
510/// Passing a `CuContext` as the first argument records the
511/// current `culistid`, `component_id`, and `task_index` (when present) on the structured log
512/// entry. The message argument must be a string literal, and remaining arguments may be unnamed
513/// expressions or named fields written as `name = value`.
514///
515/// This macro will be compiled out if the max log level is set to a level higher than Error.
516#[cfg(any(
517    feature = "log-level-debug",
518    feature = "log-level-info",
519    feature = "log-level-warning",
520    feature = "log-level-error",
521    cu29_default_log_level_debug,
522    cu29_default_log_level_info,
523))]
524#[proc_macro]
525pub fn error(input: TokenStream) -> TokenStream {
526    create_log_entry(input, CuLogLevel::Error)
527}
528
529/// Log a critical message as a Copper structured log entry.
530///
531/// Accepted forms:
532/// - `critical!("message {}", value)`
533/// - `critical!(ctx, "message {}", value)`
534///
535/// Passing a `CuContext` as the first argument records the
536/// current `culistid`, `component_id`, and `task_index` (when present) on the structured log
537/// entry. The message argument must be a string literal, and remaining arguments may be unnamed
538/// expressions or named fields written as `name = value`.
539///
540/// This macro is always compiled in, regardless of the max log level setting.
541#[cfg(any(
542    feature = "log-level-debug",
543    feature = "log-level-info",
544    feature = "log-level-warning",
545    feature = "log-level-error",
546    feature = "log-level-critical",
547    cu29_default_log_level_debug,
548    cu29_default_log_level_info,
549))]
550#[proc_macro]
551pub fn critical(input: TokenStream) -> TokenStream {
552    create_log_entry(input, CuLogLevel::Critical)
553}
554
555// Provide empty implementations for macros that are compiled out
556#[cfg(not(any(feature = "log-level-debug", cu29_default_log_level_debug)))]
557#[proc_macro]
558pub fn debug(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
559    reference_unused_variables(input)
560}
561
562#[cfg(not(any(
563    feature = "log-level-debug",
564    feature = "log-level-info",
565    cu29_default_log_level_debug,
566    cu29_default_log_level_info,
567)))]
568#[proc_macro]
569pub fn info(input: TokenStream) -> TokenStream {
570    reference_unused_variables(input)
571}
572
573#[cfg(not(any(
574    feature = "log-level-debug",
575    feature = "log-level-info",
576    feature = "log-level-warning",
577    cu29_default_log_level_debug,
578    cu29_default_log_level_info,
579)))]
580#[proc_macro]
581pub fn warning(input: TokenStream) -> TokenStream {
582    reference_unused_variables(input)
583}
584
585#[cfg(not(any(
586    feature = "log-level-debug",
587    feature = "log-level-info",
588    feature = "log-level-warning",
589    feature = "log-level-error",
590    cu29_default_log_level_debug,
591    cu29_default_log_level_info,
592)))]
593#[proc_macro]
594pub fn error(input: TokenStream) -> TokenStream {
595    reference_unused_variables(input)
596}
597
598#[cfg(not(any(
599    feature = "log-level-debug",
600    feature = "log-level-info",
601    feature = "log-level-warning",
602    feature = "log-level-error",
603    feature = "log-level-critical",
604    cu29_default_log_level_debug,
605    cu29_default_log_level_info,
606)))]
607#[proc_macro]
608pub fn critical(input: TokenStream) -> TokenStream {
609    reference_unused_variables(input)
610}
611
612/// Interns a string
613/// For example:
614///
615/// let string_number: u32 = intern!("my string");
616///
617/// will store "my string" in the interned string db at compile time and return the index of the string.
618#[proc_macro]
619pub fn intern(input: TokenStream) -> TokenStream {
620    let expr = match syn::parse::<Expr>(input) {
621        Ok(expr) => expr,
622        Err(err) => return err.to_compile_error().into(),
623    };
624    let index = if let Expr::Lit(ExprLit {
625        lit: Lit::Str(msg), ..
626    }) = &expr
627    {
628        let msg = msg.value();
629        match intern_string(&msg) {
630            Some(index) => index,
631            None => {
632                return syn::Error::new_spanned(&expr, "Failed to intern log string.")
633                    .to_compile_error()
634                    .into();
635            }
636        }
637    } else {
638        return syn::Error::new_spanned(
639            &expr,
640            "The first parameter of the argument needs to be a string literal.",
641        )
642        .to_compile_error()
643        .into();
644    };
645    quote! { #index }.into()
646}
647
648#[cfg(test)]
649mod tests {
650    use super::{extract_captured_idents, is_ident};
651
652    #[test]
653    fn captures_simple_named_placeholder() {
654        assert_eq!(extract_captured_idents("Hash: {hash}"), vec!["hash"]);
655    }
656
657    #[test]
658    fn captures_identifier_before_format_spec() {
659        assert_eq!(
660            extract_captured_idents("{hash:?} {size:>5}"),
661            vec!["hash", "size"]
662        );
663    }
664
665    #[test]
666    fn ignores_positional_and_escaped_braces() {
667        assert!(extract_captured_idents("{} {0} {{not_a_capture}}").is_empty());
668    }
669
670    #[test]
671    fn mixes_positional_and_named_and_dedupes() {
672        assert_eq!(
673            extract_captured_idents("{} {hash} and again {hash}"),
674            vec!["hash"]
675        );
676    }
677
678    #[test]
679    fn is_ident_rejects_non_identifiers() {
680        assert!(is_ident("hash"));
681        assert!(is_ident("_x1"));
682        assert!(!is_ident("0"));
683        assert!(!is_ident(""));
684        assert!(!is_ident(":?"));
685    }
686}