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}