property_test.rs

  1use proc_macro2::TokenStream;
  2use quote::{ToTokens, format_ident, quote, quote_spanned};
  3use syn::{
  4    Expr, FnArg, Ident, ItemFn, MetaNameValue, Token, Type,
  5    parse::{Parse, ParseStream},
  6    parse2,
  7    punctuated::Punctuated,
  8    spanned::Spanned,
  9    token::Comma,
 10};
 11
 12pub fn test(args: TokenStream, item: TokenStream) -> TokenStream {
 13    let item_span = item.span();
 14    let Ok(func) = parse2::<ItemFn>(item) else {
 15        return quote_spanned! { item_span =>
 16            compile_error!("#[gpui::property_test] must be placed on a function");
 17        };
 18    };
 19
 20    let args = match parse2::<Args>(args) {
 21        Ok(args) => args,
 22        Err(e) => return e.to_compile_error(),
 23    };
 24
 25    let test_name = func.sig.ident.clone();
 26    let inner_fn_name = format_ident!("__{test_name}");
 27    let outer_fn_attributes = &func.attrs;
 28
 29    let parsed_args = parse_args(func.sig.inputs, &test_name);
 30
 31    let inner_body = func.block;
 32    let inner_arg_decls = parsed_args.inner_fn_decl_args;
 33    let asyncness = func.sig.asyncness;
 34
 35    let inner_fn = quote! {
 36        let #inner_fn_name = #asyncness move |#inner_arg_decls| #inner_body;
 37    };
 38
 39    let arg_errors = parsed_args.errors;
 40    let proptest_args = parsed_args.proptest_args;
 41    let inner_args = parsed_args.inner_fn_args;
 42    let cx_vars = parsed_args.cx_vars;
 43    let cx_teardowns = parsed_args.cx_teardowns;
 44
 45    let proptest_args = quote! {
 46        #[strategy = ::gpui::seed_strategy()] __seed: u64,
 47        #proptest_args
 48    };
 49
 50    let run_test_body = match &asyncness {
 51        None => quote! {
 52            #cx_vars
 53            #inner_fn_name(#inner_args);
 54            #cx_teardowns
 55        },
 56        Some(_) => quote! {
 57            let foreground_executor = gpui::ForegroundExecutor::new(std::sync::Arc::new(dispatcher.clone()));
 58            #cx_vars
 59            foreground_executor.block_test(#inner_fn_name(#inner_args));
 60            #cx_teardowns
 61        },
 62    };
 63
 64    let fixed_macro_invocation = args.render();
 65
 66    quote! {
 67        #arg_errors
 68
 69        #fixed_macro_invocation
 70        #(#outer_fn_attributes)*
 71        fn #test_name(#proptest_args) {
 72            #inner_fn
 73
 74            ::gpui::run_test_once(
 75                __seed,
 76                Box::new(move |dispatcher| {
 77                    #run_test_body
 78                }),
 79            )
 80        }
 81    }
 82}
 83
 84struct Args {
 85    config: Option<Expr>,
 86    remaining_args: Vec<MetaNameValue>,
 87    errors: TokenStream,
 88}
 89
 90impl Args {
 91    /// By default, proptest uses random seeds unless `$PROPTEST_SEED` is set.
 92    /// Rather than managing both `$SEED` and `$PROPTEST_SEED`, we intercept
 93    /// `config = ...` tokens and add a call to `gpui::apply_seed_to_config`.
 94    fn render(&self) -> TokenStream {
 95        let user_provided_config = match &self.config {
 96            None => quote! { ::gpui::proptest::prelude::ProptestConfig::default() },
 97            Some(config) => config.into_token_stream(),
 98        };
 99
100        let fixed_config = quote!(::gpui::apply_seed_to_proptest_config(#user_provided_config));
101        let remaining_args = &self.remaining_args;
102        let errors = &self.errors;
103
104        quote! {
105            #errors
106            #[::gpui::proptest::property_test(
107                proptest_path = "::gpui::proptest",
108                config = #fixed_config,
109                #(#remaining_args,)*
110            )]
111        }
112    }
113}
114
115impl Parse for Args {
116    fn parse(input: ParseStream) -> syn::Result<Self> {
117        let pairs = Punctuated::<MetaNameValue, Token![,]>::parse_terminated(input)?;
118
119        let mut config = None;
120        let mut remaining_args = vec![];
121        let mut errors = quote!();
122
123        for pair in pairs {
124            match pair.path.get_ident().map(Ident::to_string).as_deref() {
125                Some("config") => config = Some(pair.value),
126                Some("proptest_path") => errors.extend(quote_spanned! {pair.span() =>
127                    compile_error!("`gpui::property_test` overrides the `proptest_path` parameter")
128                }),
129                _ => remaining_args.push(pair),
130            }
131        }
132
133        Ok(Self {
134            config,
135            remaining_args,
136            errors,
137        })
138    }
139}
140
141#[derive(Default)]
142struct ParsedArgs {
143    cx_vars: TokenStream,
144    cx_teardowns: TokenStream,
145    proptest_args: TokenStream,
146    errors: TokenStream,
147
148    // exprs passed at the call-site
149    inner_fn_args: TokenStream,
150    // args in the declaration
151    inner_fn_decl_args: TokenStream,
152}
153
154fn parse_args(args: Punctuated<FnArg, Comma>, test_name: &Ident) -> ParsedArgs {
155    let mut parsed = ParsedArgs::default();
156    let mut args = args.into_iter().collect();
157
158    remove_cxs(&mut parsed, &mut args, test_name);
159    remove_std_rng(&mut parsed, &mut args);
160    remove_background_executor(&mut parsed, &mut args);
161
162    // all remaining args forwarded to proptest's macro
163    parsed.proptest_args = quote!( #(#args),* );
164
165    parsed
166}
167
168fn remove_cxs(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>, test_name: &Ident) {
169    let mut ix = 0;
170    args.retain_mut(|arg| {
171        if !is_test_cx(arg) {
172            return true;
173        }
174
175        let cx_varname = format_ident!("cx_{ix}");
176        ix += 1;
177
178        parsed.cx_vars.extend(quote!(
179            let mut #cx_varname = gpui::TestAppContext::build(
180                dispatcher.clone(),
181                Some(stringify!(#test_name)),
182            );
183        ));
184        parsed.cx_teardowns.extend(quote!(
185            dispatcher.run_until_parked();
186            #cx_varname.executor().forbid_parking();
187            #cx_varname.quit();
188            dispatcher.run_until_parked();
189        ));
190
191        parsed.inner_fn_decl_args.extend(quote!(#arg,));
192        parsed.inner_fn_args.extend(quote!(&mut #cx_varname,));
193
194        false
195    });
196}
197
198fn remove_std_rng(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
199    args.retain_mut(|arg| {
200        if !is_std_rng(arg) {
201            return true;
202        }
203
204        parsed.errors.extend(quote_spanned! { arg.span() =>
205            compile_error!("`StdRng` is not allowed in a property test. Consider implementing `Arbitrary`, or implementing a custom `Strategy`. https://altsysrq.github.io/proptest-book/proptest/tutorial/strategy-basics.html");
206        });
207
208        false
209    });
210}
211
212fn remove_background_executor(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
213    args.retain_mut(|arg| {
214        if !is_background_executor(arg) {
215            return true;
216        }
217
218        parsed.inner_fn_decl_args.extend(quote!(#arg,));
219        parsed
220            .inner_fn_args
221            .extend(quote!(gpui::BackgroundExecutor::new(std::sync::Arc::new(
222                dispatcher.clone()
223            )),));
224
225        false
226    });
227}
228
229// Matches `&TestAppContext` or `&foo::bar::baz::TestAppContext`
230fn is_test_cx(arg: &FnArg) -> bool {
231    let FnArg::Typed(arg) = arg else {
232        return false;
233    };
234
235    let Type::Reference(ty) = &*arg.ty else {
236        return false;
237    };
238
239    let Type::Path(ty) = &*ty.elem else {
240        return false;
241    };
242
243    ty.path
244        .segments
245        .last()
246        .is_some_and(|seg| seg.ident == "TestAppContext")
247}
248
249fn is_std_rng(arg: &FnArg) -> bool {
250    is_path_with_last_segment(arg, "StdRng")
251}
252
253fn is_background_executor(arg: &FnArg) -> bool {
254    is_path_with_last_segment(arg, "BackgroundExecutor")
255}
256
257fn is_path_with_last_segment(arg: &FnArg, last_segment: &str) -> bool {
258    let FnArg::Typed(arg) = arg else {
259        return false;
260    };
261
262    let Type::Path(ty) = &*arg.ty else {
263        return false;
264    };
265
266    ty.path
267        .segments
268        .last()
269        .is_some_and(|seg| seg.ident == last_segment)
270}