Skip to main content

test_log_core/
lib.rs

1// Copyright (C) 2019-2026 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: (Apache-2.0 OR MIT)
3
4//! Core logic for the `test-log` procedural macro.
5
6use std::borrow::Cow;
7
8use proc_macro2::TokenStream as Tokens;
9
10use quote::quote;
11
12use syn::parse::Parse;
13use syn::Attribute;
14use syn::Expr;
15use syn::ItemFn;
16use syn::Lit;
17use syn::Meta;
18
19
20/// Parse `#[test_log(...)]` attributes from a function's attribute
21/// list, separating them from other attributes.
22fn parse_attrs(attrs: Vec<Attribute>) -> syn::Result<(AttributeArgs, Vec<Attribute>)> {
23  let mut attribute_args = AttributeArgs::default();
24  if cfg!(feature = "unstable") {
25    let mut ignored_attrs = vec![];
26    for attr in attrs {
27      let matched = attribute_args.try_parse_attr_single(&attr)?;
28      // Keep only attrs that didn't match the #[test_log(_)] syntax.
29      if !matched {
30        ignored_attrs.push(attr);
31      }
32    }
33
34    Ok((attribute_args, ignored_attrs))
35  } else {
36    Ok((attribute_args, attrs))
37  }
38}
39
40/// Check whether given attribute is a test attribute of forms:
41/// * `#[test]`
42/// * `#[core::prelude::*::test]` or `#[::core::prelude::*::test]`
43/// * `#[std::prelude::*::test]` or `#[::std::prelude::*::test]`
44fn is_test_attribute(attr: &Attribute) -> bool {
45  let path = match &attr.meta {
46    syn::Meta::Path(path) => path,
47    _ => return false,
48  };
49  let candidates = [
50    ["core", "prelude", "*", "test"],
51    ["std", "prelude", "*", "test"],
52  ];
53  if path.leading_colon.is_none()
54    && path.segments.len() == 1
55    && path.segments[0].arguments.is_none()
56    && path.segments[0].ident == "test"
57  {
58    return true;
59  } else if path.segments.len() != candidates[0].len() {
60    return false;
61  }
62  candidates.into_iter().any(|segments| {
63    path
64      .segments
65      .iter()
66      .zip(segments)
67      .all(|(segment, path)| segment.arguments.is_none() && (path == "*" || segment.ident == path))
68  })
69}
70
71
72/// Main expansion logic for `#[test_log::test]`.
73pub fn try_test(attr: Tokens, input: ItemFn) -> syn::Result<Tokens> {
74  let ItemFn {
75    attrs,
76    vis,
77    sig,
78    block,
79  } = input;
80
81  let (attribute_args, ignored_attrs) = parse_attrs(attrs)?;
82  let logging_init = expand_logging_init(&attribute_args);
83  let tracing_init = expand_tracing_init(&attribute_args);
84
85  let (inner_test, generated_test) = if attr.is_empty() {
86    let has_test = ignored_attrs.iter().any(is_test_attribute);
87    let generated_test = if has_test {
88      quote! {}
89    } else {
90      quote! { #[::core::prelude::v1::test]}
91    };
92    (quote! {}, generated_test)
93  } else {
94    (quote! { #[#attr] }, quote! {})
95  };
96
97  let result = quote! {
98    #inner_test
99    #(#ignored_attrs)*
100    #generated_test
101    #vis #sig {
102      // We put all initialization code into a separate module here in
103      // order to prevent potential ambiguities that could result in
104      // compilation errors. E.g., client code could use traits that
105      // could have methods that interfere with ones we use as part of
106      // initialization; with a `Foo` trait that is implemented for T
107      // and that contains a `map` (or similarly common named) method
108      // that could cause an ambiguity with `Iterator::map`, for
109      // example.
110      // The alternative would be to use fully qualified call syntax in
111      // all initialization code, but that's much harder to control.
112      mod init {
113        pub fn init() {
114          #logging_init
115          #tracing_init
116        }
117      }
118
119      init::init();
120
121      #block
122    }
123  };
124  Ok(result)
125}
126
127
128/// Parsed `#[test_log(...)]` attributes.
129#[derive(Debug, Default)]
130struct AttributeArgs {
131  /// The default log filter directive (e.g., `"debug"`).
132  default_log_filter: Option<Cow<'static, str>>,
133}
134
135impl AttributeArgs {
136  /// Try to parse a single `#[test_log(...)]` attribute.
137  fn try_parse_attr_single(&mut self, attr: &Attribute) -> syn::Result<bool> {
138    if !attr.path().is_ident("test_log") {
139      return Ok(false)
140    }
141
142    let nested_meta = attr.parse_args_with(Meta::parse)?;
143    let name_value = if let Meta::NameValue(name_value) = nested_meta {
144      name_value
145    } else {
146      return Err(syn::Error::new_spanned(
147        &nested_meta,
148        "Expected NameValue syntax, e.g. 'default_log_filter = \"debug\"'.",
149      ))
150    };
151
152    let ident = if let Some(ident) = name_value.path.get_ident() {
153      ident
154    } else {
155      return Err(syn::Error::new_spanned(
156        &name_value.path,
157        "Expected NameValue syntax, e.g. 'default_log_filter = \"debug\"'.",
158      ))
159    };
160
161    let arg_ref = if ident == "default_log_filter" {
162      &mut self.default_log_filter
163    } else {
164      return Err(syn::Error::new_spanned(
165        &name_value.path,
166        "Unrecognized attribute, see documentation for details.",
167      ))
168    };
169
170    if let Expr::Lit(lit) = &name_value.value {
171      if let Lit::Str(lit_str) = &lit.lit {
172        *arg_ref = Some(Cow::from(lit_str.value()));
173      }
174    }
175
176    // If we couldn't parse the value on the right-hand side because it was some
177    // unexpected type, e.g. #[test_log::log(default_log_filter=10)], return an error.
178    if arg_ref.is_none() {
179      return Err(syn::Error::new_spanned(
180        &name_value.value,
181        "Failed to parse value, expected a string",
182      ))
183    }
184
185    Ok(true)
186  }
187}
188
189
190/// Expand the initialization code for the `log` crate.
191#[cfg(all(feature = "log", not(feature = "trace")))]
192fn expand_logging_init(attribute_args: &AttributeArgs) -> Tokens {
193  let default_filter = attribute_args
194    .default_log_filter
195    .as_ref()
196    .unwrap_or(&Cow::Borrowed("info"));
197
198  quote! {
199    {
200      let _result = ::test_log::env_logger::builder()
201        .parse_env(
202          ::test_log::env_logger::Env::default()
203            .default_filter_or(#default_filter)
204        )
205        .target(::test_log::env_logger::Target::Stderr)
206        .is_test(true)
207        .try_init();
208    }
209  }
210}
211
212#[cfg(not(all(feature = "log", not(feature = "trace"))))]
213fn expand_logging_init(_attribute_args: &AttributeArgs) -> Tokens {
214  quote! {}
215}
216
217/// Expand the initialization code for the `tracing` crate.
218#[cfg(feature = "trace")]
219fn expand_tracing_init(attribute_args: &AttributeArgs) -> Tokens {
220  let env_filter = if let Some(default_log_filter) = &attribute_args.default_log_filter {
221    quote! {
222      ::test_log::tracing_subscriber::EnvFilter::builder()
223        .with_default_directive(
224          #default_log_filter
225            .parse()
226            .expect("test-log: default_log_filter must be valid")
227        )
228        .from_env_lossy()
229    }
230  } else {
231    quote! {
232      ::test_log::tracing_subscriber::EnvFilter::builder()
233        .with_default_directive(
234          ::test_log::tracing_subscriber::filter::LevelFilter::INFO.into()
235        )
236        .from_env_lossy()
237    }
238  };
239
240  quote! {
241    {
242      let __internal_event_filter = {
243        use ::test_log::tracing_subscriber::fmt::format::FmtSpan;
244
245        match ::std::env::var_os("RUST_LOG_SPAN_EVENTS") {
246          Some(mut value) => {
247            value.make_ascii_lowercase();
248            let value = value.to_str().expect("test-log: RUST_LOG_SPAN_EVENTS must be valid UTF-8");
249            value
250              .split(",")
251              .map(|filter| match filter.trim() {
252                "new" => FmtSpan::NEW,
253                "enter" => FmtSpan::ENTER,
254                "exit" => FmtSpan::EXIT,
255                "close" => FmtSpan::CLOSE,
256                "active" => FmtSpan::ACTIVE,
257                "full" => FmtSpan::FULL,
258                _ => panic!("test-log: RUST_LOG_SPAN_EVENTS must contain filters separated by `,`.\n\t\
259                  For example: `active` or `new,close`\n\t\
260                  Supported filters: new, enter, exit, close, active, full\n\t\
261                  Got: {}", value),
262              })
263              .fold(FmtSpan::NONE, |acc, filter| filter | acc)
264          },
265          None => FmtSpan::NONE,
266        }
267      };
268
269      let _ = ::test_log::tracing_subscriber::FmtSubscriber::builder()
270        .with_env_filter(#env_filter)
271        .with_span_events(__internal_event_filter)
272        .with_writer(::test_log::tracing_subscriber::fmt::TestWriter::with_stderr)
273        .try_init();
274    }
275  }
276}
277
278#[cfg(not(feature = "trace"))]
279fn expand_tracing_init(_attribute_args: &AttributeArgs) -> Tokens {
280  quote! {}
281}