Skip to main content

reinhardt_admin_cli/migrate_v2/rules/
use_effect_deps.rs

1//! Rule ยง6.1 (4): add an explicit dependency list to hooks that omit it.
2//!
3//! Tracked reads are migrated to `deps![...]` in source order. When the
4//! callback expression cannot be identified, the rule retains the explicit
5//! `compile_error!` review marker used by the original v2 migration.
6
7use std::collections::{HashMap, HashSet};
8
9use quote::quote;
10use syn::visit::{self, Visit};
11use syn::visit_mut::{self, VisitMut};
12
13use crate::migrate_v2::rewriter::FileRewriter;
14
15/// `use_effect_deps` rule entry.
16pub struct Rule;
17
18impl FileRewriter for Rule {
19	fn name(&self) -> &'static str {
20		"use_effect_deps"
21	}
22
23	fn rewrite(&self, mut file: syn::File) -> syn::File {
24		rewrite_scope(&mut file.items);
25		file
26	}
27}
28
29#[derive(Clone, Copy, PartialEq, Eq)]
30struct HookSpec {
31	name: &'static str,
32	callback_index: usize,
33	deps_index: usize,
34	omitted_arity: usize,
35	explicit_arity: usize,
36}
37
38const HOOK_SPECS: &[HookSpec] = &[
39	HookSpec {
40		name: "use_effect",
41		callback_index: 0,
42		deps_index: 1,
43		omitted_arity: 1,
44		explicit_arity: 2,
45	},
46	HookSpec {
47		name: "use_layout_effect",
48		callback_index: 0,
49		deps_index: 1,
50		omitted_arity: 1,
51		explicit_arity: 2,
52	},
53	HookSpec {
54		name: "use_retained_effect",
55		callback_index: 0,
56		deps_index: 1,
57		omitted_arity: 1,
58		explicit_arity: 2,
59	},
60	HookSpec {
61		name: "use_retained_layout_effect",
62		callback_index: 0,
63		deps_index: 1,
64		omitted_arity: 1,
65		explicit_arity: 2,
66	},
67	HookSpec {
68		name: "use_memo",
69		callback_index: 0,
70		deps_index: 1,
71		omitted_arity: 1,
72		explicit_arity: 2,
73	},
74	HookSpec {
75		name: "use_callback",
76		callback_index: 0,
77		deps_index: 1,
78		omitted_arity: 1,
79		explicit_arity: 2,
80	},
81	HookSpec {
82		name: "use_callback_with",
83		callback_index: 0,
84		deps_index: 1,
85		omitted_arity: 1,
86		explicit_arity: 2,
87	},
88	HookSpec {
89		name: "use_resource",
90		callback_index: 0,
91		deps_index: 1,
92		omitted_arity: 1,
93		explicit_arity: 2,
94	},
95	HookSpec {
96		name: "use_resource_with_key",
97		callback_index: 1,
98		deps_index: 2,
99		omitted_arity: 2,
100		explicit_arity: 3,
101	},
102];
103
104#[derive(Clone)]
105struct UseEntry {
106	original: Vec<syn::Ident>,
107	bound: syn::Ident,
108	attrs: Vec<syn::Attribute>,
109}
110
111#[derive(Clone)]
112struct GeneratedDepsImport {
113	facade: syn::Path,
114	attrs: Vec<syn::Attribute>,
115}
116
117struct HookResolution {
118	spec: HookSpec,
119	imports: Option<Vec<GeneratedDepsImport>>,
120}
121
122struct HookVisitor {
123	generated_imports: Vec<GeneratedDepsImport>,
124	imports: Vec<UseEntry>,
125	local_modules: HashSet<String>,
126}
127
128fn rewrite_scope(items: &mut Vec<syn::Item>) {
129	let imports = collect_imports(items);
130	let local_modules = items
131		.iter()
132		.filter_map(|item| match item {
133			syn::Item::Mod(module) => Some(module.ident.to_string()),
134			_ => None,
135		})
136		.collect();
137	let mut visitor = HookVisitor {
138		generated_imports: Vec::new(),
139		imports: imports.clone(),
140		local_modules,
141	};
142	for item in items.iter_mut() {
143		visitor.visit_item_mut(item);
144	}
145	if !visitor.generated_imports.is_empty() {
146		let insertion = items
147			.iter()
148			.position(|item| matches!(item, syn::Item::Use(_)))
149			.unwrap_or(0);
150		let unconditional_facades = visitor
151			.generated_imports
152			.iter()
153			.filter(|generated| generated.attrs.is_empty())
154			.map(|generated| facade_key(&generated.facade))
155			.collect::<HashSet<_>>();
156		let mut inserted = 0;
157		for generated in visitor.generated_imports {
158			if !generated.attrs.is_empty()
159				&& unconditional_facades.contains(&facade_key(&generated.facade))
160			{
161				continue;
162			}
163			if has_deps_import(&imports, &generated.facade, &generated.attrs) {
164				continue;
165			}
166			let attrs = generated.attrs;
167			let facade = generated.facade;
168			let import = syn::parse2(quote!(#(#attrs)* use #facade::deps;))
169				.expect("generated deps import must parse");
170			items.insert(insertion + inserted, import);
171			inserted += 1;
172		}
173	}
174	for item in items.iter_mut() {
175		if let syn::Item::Mod(module) = item
176			&& let Some((_, nested)) = &mut module.content
177		{
178			rewrite_scope(nested);
179		}
180	}
181}
182
183impl VisitMut for HookVisitor {
184	fn visit_item_mod_mut(&mut self, _module: &mut syn::ItemMod) {}
185
186	fn visit_block_mut(&mut self, block: &mut syn::Block) {
187		let previous_imports = self.imports.clone();
188		let mut local_imports = Vec::new();
189		for stmt in &block.stmts {
190			if let syn::Stmt::Item(syn::Item::Use(item_use)) = stmt {
191				flatten_use_tree(&item_use.tree, &[], &item_use.attrs, &mut local_imports);
192			}
193		}
194		for local in local_imports {
195			self.imports.retain(|entry| entry.bound != local.bound);
196			self.imports.push(local);
197		}
198		visit_mut::visit_block_mut(self, block);
199		self.imports = previous_imports;
200	}
201
202	fn visit_expr_call_mut(&mut self, call: &mut syn::ExprCall) {
203		if let Some(resolution) = self.resolve_hook(call) {
204			let spec = resolution.spec;
205			if call.args.len() == spec.omitted_arity {
206				let callback = call
207					.args
208					.iter()
209					.nth(spec.callback_index)
210					.expect("hook callback must exist");
211				let deps = resolution
212					.imports
213					.as_ref()
214					.and_then(|_| dependency_list(callback));
215				if deps.is_some()
216					&& let Some(imports) = &resolution.imports
217				{
218					for import in imports {
219						self.record_generated_import(import.facade.clone(), import.attrs.clone());
220					}
221				}
222				call.args
223					.insert(spec.deps_index, deps.unwrap_or_else(review_marker));
224			} else if call.args.len() == spec.explicit_arity
225				&& let Some(deps) = tuple_dependency_list(
226					call.args
227						.iter()
228						.nth(spec.deps_index)
229						.expect("hook dependencies must exist"),
230				) {
231				if let Some(imports) = &resolution.imports {
232					for import in imports {
233						self.record_generated_import(import.facade.clone(), import.attrs.clone());
234					}
235					*call
236						.args
237						.iter_mut()
238						.nth(spec.deps_index)
239						.expect("hook dependencies must exist") = deps;
240				} else {
241					*call
242						.args
243						.iter_mut()
244						.nth(spec.deps_index)
245						.expect("hook dependencies must exist") = review_marker();
246				}
247			}
248		}
249		visit_mut::visit_expr_call_mut(self, call);
250	}
251}
252
253impl HookVisitor {
254	fn record_generated_import(&mut self, facade: syn::Path, attrs: Vec<syn::Attribute>) {
255		let key = import_key(&facade, &attrs);
256		if self
257			.generated_imports
258			.iter()
259			.all(|generated| import_key(&generated.facade, &generated.attrs) != key)
260		{
261			self.generated_imports
262				.push(GeneratedDepsImport { facade, attrs });
263		}
264	}
265
266	fn resolve_hook(&self, call: &syn::ExprCall) -> Option<HookResolution> {
267		let syn::Expr::Path(path) = &*call.func else {
268			return None;
269		};
270		let segments: Vec<_> = path
271			.path
272			.segments
273			.iter()
274			.map(|segment| segment.ident.clone())
275			.collect();
276		let called = segments.last()?;
277		if segments.len() == 1 {
278			let candidates = self
279				.imports
280				.iter()
281				.filter(|entry| entry.bound == *called)
282				.filter_map(|entry| {
283					let original = entry.original.last()?;
284					let spec = hook_spec(&original.to_string())?;
285					let import = facade_from_segments(&entry.original)
286						.filter(|_| !self.is_local_facade(&entry.original))
287						.map(|facade| GeneratedDepsImport {
288							facade,
289							attrs: entry.attrs.clone(),
290						});
291					Some((spec, import))
292				})
293				.collect::<Vec<_>>();
294			if let Some((spec, _)) = candidates.first() {
295				let spec = *spec;
296				if candidates.iter().any(|(candidate, _)| *candidate != spec) {
297					return Some(HookResolution {
298						spec: select_hook_spec(&candidates, call.args.len()),
299						imports: None,
300					});
301				}
302				let matching = candidates
303					.into_iter()
304					.filter(|(candidate, _)| *candidate == spec)
305					.collect::<Vec<_>>();
306				let imports = matching
307					.iter()
308					.map(|(_, import)| import.clone())
309					.collect::<Option<Vec<_>>>();
310				return Some(HookResolution { spec, imports });
311			}
312			hook_spec(&called.to_string())?;
313			return None;
314		}
315		let mut candidates = Vec::new();
316		for (index, segment) in segments.iter().enumerate().take(segments.len() - 1) {
317			for entry in self.imports.iter().filter(|entry| entry.bound == *segment) {
318				let mut resolved = entry.original.clone();
319				resolved.extend(segments.iter().skip(index + 1).cloned());
320				let Some(original) = resolved.last() else {
321					continue;
322				};
323				let Some(spec) = hook_spec(&original.to_string()) else {
324					continue;
325				};
326				let import = facade_from_segments(&resolved)
327					.filter(|_| !self.is_local_facade(&resolved))
328					.map(|facade| GeneratedDepsImport {
329						facade,
330						attrs: entry.attrs.clone(),
331					});
332				candidates.push((spec, import));
333			}
334		}
335		if let Some((spec, _)) = candidates.first() {
336			let spec = *spec;
337			if candidates.iter().any(|(candidate, _)| *candidate != spec) {
338				return Some(HookResolution {
339					spec: select_hook_spec(&candidates, call.args.len()),
340					imports: None,
341				});
342			}
343			let matching = candidates
344				.into_iter()
345				.filter(|(candidate, _)| *candidate == spec)
346				.collect::<Vec<_>>();
347			let imports = matching
348				.iter()
349				.map(|(_, import)| import.clone())
350				.collect::<Option<Vec<_>>>();
351			return Some(HookResolution { spec, imports });
352		}
353		let spec = hook_spec(&called.to_string())?;
354		let facade = facade_from_segments(&segments).filter(|_| !self.is_local_facade(&segments));
355		Some(HookResolution {
356			spec,
357			imports: facade.map(|facade| {
358				vec![GeneratedDepsImport {
359					facade,
360					attrs: Vec::new(),
361				}]
362			}),
363		})
364	}
365
366	fn is_local_facade(&self, segments: &[syn::Ident]) -> bool {
367		let Some(first) = segments.first() else {
368			return false;
369		};
370		if first == "crate" {
371			return segments
372				.iter()
373				.any(|segment| self.local_modules.contains(&segment.to_string()));
374		}
375		self.local_modules.contains(&first.to_string())
376	}
377}
378
379fn hook_spec(name: &str) -> Option<HookSpec> {
380	HOOK_SPECS.iter().copied().find(|spec| spec.name == name)
381}
382
383fn select_hook_spec(
384	candidates: &[(HookSpec, Option<GeneratedDepsImport>)],
385	arity: usize,
386) -> HookSpec {
387	candidates
388		.iter()
389		.find(|(spec, _)| spec.omitted_arity == arity || spec.explicit_arity == arity)
390		.map(|(spec, _)| *spec)
391		.unwrap_or(candidates[0].0)
392}
393
394fn facade_from_segments(segments: &[syn::Ident]) -> Option<syn::Path> {
395	if segments.is_empty() {
396		return None;
397	}
398	if segments
399		.first()
400		.is_some_and(|ident| ident == "self" || ident == "super")
401	{
402		return None;
403	}
404	let end = segments
405		.iter()
406		.position(|ident| ident == "reactive")
407		.unwrap_or(segments.len() - 1);
408	if end == 0 {
409		return None;
410	}
411	let facade = &segments[..end];
412	syn::parse2(quote!(#(#facade)::* )).ok()
413}
414
415fn tuple_dependency_list(deps: &syn::Expr) -> Option<syn::Expr> {
416	let syn::Expr::Tuple(tuple) = deps else {
417		return None;
418	};
419	let dependencies = tuple.elems.iter().map(strip_clone);
420	Some(
421		syn::parse2(quote!(deps![#(#dependencies),*]))
422			.expect("generated dependency list must parse"),
423	)
424}
425
426fn strip_clone(expr: &syn::Expr) -> &syn::Expr {
427	if let syn::Expr::MethodCall(call) = expr
428		&& call.method == "clone"
429		&& call.args.is_empty()
430	{
431		&call.receiver
432	} else {
433		expr
434	}
435}
436
437fn dependency_list(callback: &syn::Expr) -> Option<syn::Expr> {
438	let callback = strip_parens(callback);
439	let (closure, aliases) = match callback {
440		syn::Expr::Closure(closure) => (closure, HashMap::new()),
441		syn::Expr::Block(block) => {
442			let closure = block.block.stmts.last().and_then(stmt_closure)?;
443			(
444				closure,
445				clone_aliases(&block.block.stmts[..block.block.stmts.len() - 1]),
446			)
447		}
448		_ => return None,
449	};
450	let mut reads = OuterTrackedReadVisitor::default();
451	for input in &closure.inputs {
452		collect_pattern_idents(input, &mut reads.shadowed);
453	}
454	reads.visit_expr(&closure.body);
455	if reads.nested_tracked_read || reads.shadowed_tracked_read {
456		return None;
457	}
458	let mut dependencies = Vec::new();
459	let mut seen = HashSet::new();
460	for read in reads.reads {
461		let dependency = resolve_alias(&read, &aliases)?;
462		let key = quote!(#dependency).to_string();
463		if seen.insert(key) {
464			dependencies.push(dependency);
465		}
466	}
467	Some(
468		syn::parse2(quote!(deps![#(#dependencies),*]))
469			.expect("generated dependency list must parse"),
470	)
471}
472
473fn review_marker() -> syn::Expr {
474	syn::parse_quote! {
475		compile_error!(
476			"manouche-v2 codemod: add explicit deps list here, e.g. `deps![count]`"
477		)
478	}
479}
480
481#[derive(Default)]
482struct OuterTrackedReadVisitor {
483	reads: Vec<String>,
484	seen: HashSet<String>,
485	shadowed: HashSet<String>,
486	nested_tracked_read: bool,
487	shadowed_tracked_read: bool,
488}
489
490impl<'ast> Visit<'ast> for OuterTrackedReadVisitor {
491	fn visit_expr_closure(&mut self, closure: &'ast syn::ExprClosure) {
492		let mut nested = AnyTrackedReadVisitor::default();
493		nested.visit_expr_closure(closure);
494		self.nested_tracked_read |= nested.found;
495	}
496
497	fn visit_expr_method_call(&mut self, call: &'ast syn::ExprMethodCall) {
498		let method = call.method.to_string();
499		let tracked_read = (matches!(method.as_str(), "get" | "into_value")
500			&& call.args.is_empty())
501			|| (method == "with" && call.args.len() == 1);
502		if tracked_read && let Some(ident) = base_ident_of(&call.receiver) {
503			let ident = ident.to_string();
504			if self.shadowed.contains(&ident) {
505				self.shadowed_tracked_read = true;
506			} else if self.seen.insert(ident.clone()) {
507				self.reads.push(ident);
508			}
509		}
510		visit::visit_expr_method_call(self, call);
511	}
512
513	fn visit_expr_for_loop(&mut self, expr: &'ast syn::ExprForLoop) {
514		collect_pattern_idents(&expr.pat, &mut self.shadowed);
515		visit::visit_expr_for_loop(self, expr);
516	}
517
518	fn visit_arm(&mut self, arm: &'ast syn::Arm) {
519		collect_pattern_idents(&arm.pat, &mut self.shadowed);
520		visit::visit_arm(self, arm);
521	}
522
523	fn visit_expr_let(&mut self, expr: &'ast syn::ExprLet) {
524		collect_pattern_idents(&expr.pat, &mut self.shadowed);
525		visit::visit_expr_let(self, expr);
526	}
527
528	fn visit_local(&mut self, local: &'ast syn::Local) {
529		collect_pattern_idents(&local.pat, &mut self.shadowed);
530		visit::visit_local(self, local);
531	}
532}
533
534#[derive(Default)]
535struct AnyTrackedReadVisitor {
536	found: bool,
537}
538
539impl<'ast> Visit<'ast> for AnyTrackedReadVisitor {
540	fn visit_expr_method_call(&mut self, call: &'ast syn::ExprMethodCall) {
541		if is_tracked_read(call) {
542			self.found = true;
543		}
544		visit::visit_expr_method_call(self, call);
545	}
546}
547
548fn is_tracked_read(call: &syn::ExprMethodCall) -> bool {
549	let method = call.method.to_string();
550	(matches!(method.as_str(), "get" | "into_value") && call.args.is_empty())
551		|| (method == "with" && call.args.len() == 1)
552}
553
554fn strip_parens(mut expr: &syn::Expr) -> &syn::Expr {
555	while let syn::Expr::Paren(paren) = expr {
556		expr = &paren.expr;
557	}
558	expr
559}
560
561fn stmt_closure(stmt: &syn::Stmt) -> Option<&syn::ExprClosure> {
562	let syn::Stmt::Expr(expr, _) = stmt else {
563		return None;
564	};
565	let syn::Expr::Closure(closure) = strip_parens(expr) else {
566		return None;
567	};
568	Some(closure)
569}
570
571fn clone_aliases(stmts: &[syn::Stmt]) -> HashMap<String, syn::Expr> {
572	let mut aliases = HashMap::new();
573	for stmt in stmts {
574		let syn::Stmt::Local(local) = stmt else {
575			continue;
576		};
577		let mut bindings = HashSet::new();
578		collect_pattern_idents(&local.pat, &mut bindings);
579		let syn::Pat::Ident(alias) = &local.pat else {
580			invalidate_shadowed_aliases(&mut aliases, &bindings);
581			for binding in bindings {
582				aliases.remove(&binding);
583			}
584			continue;
585		};
586		let alias_name = alias.ident.to_string();
587		let Some(init) = &local.init else {
588			invalidate_shadowed_aliases(&mut aliases, &bindings);
589			aliases.remove(&alias_name);
590			continue;
591		};
592		let syn::Expr::MethodCall(call) = strip_parens(&init.expr) else {
593			invalidate_shadowed_aliases(&mut aliases, &bindings);
594			aliases.remove(&alias_name);
595			continue;
596		};
597		if call.method != "clone" || !call.args.is_empty() {
598			invalidate_shadowed_aliases(&mut aliases, &bindings);
599			aliases.remove(&alias_name);
600			continue;
601		}
602		let Some(receiver) = safe_clone_place(&call.receiver) else {
603			invalidate_shadowed_aliases(&mut aliases, &bindings);
604			aliases.remove(&alias_name);
605			continue;
606		};
607		let receiver_was_self = base_ident_of(&receiver).is_some_and(|ident| ident == &alias.ident);
608		let receiver = resolve_place(&receiver, &aliases, &mut Vec::new());
609		let receiver = receiver.filter(|resolved| {
610			base_ident_of(resolved).is_none_or(|ident| ident != &alias.ident) || receiver_was_self
611		});
612		if let Some(receiver) = receiver {
613			invalidate_shadowed_aliases(&mut aliases, &bindings);
614			aliases.insert(alias_name, receiver);
615		} else {
616			invalidate_shadowed_aliases(&mut aliases, &bindings);
617			aliases.remove(&alias.ident.to_string());
618		}
619	}
620	aliases
621}
622
623fn invalidate_shadowed_aliases(
624	aliases: &mut HashMap<String, syn::Expr>,
625	bindings: &HashSet<String>,
626) {
627	let stale = aliases
628		.iter()
629		.filter_map(|(name, expr)| {
630			base_ident_of(expr)
631				.is_some_and(|base| bindings.contains(&base.to_string()))
632				.then_some(name.clone())
633		})
634		.collect::<Vec<_>>();
635	for name in stale {
636		aliases.remove(&name);
637	}
638}
639
640fn safe_clone_place(expr: &syn::Expr) -> Option<syn::Expr> {
641	match strip_parens(expr) {
642		syn::Expr::Path(path) if path.qself.is_none() && !path.path.segments.is_empty() => {
643			Some(syn::Expr::Path(path.clone()))
644		}
645		syn::Expr::Field(field) => {
646			let mut field = field.clone();
647			field.base = Box::new(safe_clone_place(&field.base)?);
648			Some(syn::Expr::Field(field))
649		}
650		_ => None,
651	}
652}
653
654fn resolve_alias(name: &str, aliases: &HashMap<String, syn::Expr>) -> Option<syn::Expr> {
655	let mut stack = vec![name.to_owned()];
656	let expr = aliases.get(name)?;
657	resolve_alias_place(expr, aliases, &mut stack)
658}
659
660fn resolve_alias_place(
661	expr: &syn::Expr,
662	aliases: &HashMap<String, syn::Expr>,
663	stack: &mut Vec<String>,
664) -> Option<syn::Expr> {
665	match strip_parens(expr) {
666		syn::Expr::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => {
667			let ident = path.path.segments.first()?.ident.to_string();
668			let Some(alias) = aliases.get(&ident) else {
669				return Some(syn::Expr::Path(path.clone()));
670			};
671			if stack.last().is_some_and(|current| current == &ident) {
672				return Some(syn::Expr::Path(path.clone()));
673			}
674			if stack.iter().any(|current| current == &ident) {
675				return None;
676			}
677			stack.push(ident);
678			let resolved = resolve_alias_place(alias, aliases, stack);
679			stack.pop();
680			resolved
681		}
682		_ => Some(strip_parens(expr).clone()),
683	}
684}
685
686fn resolve_place(
687	expr: &syn::Expr,
688	aliases: &HashMap<String, syn::Expr>,
689	stack: &mut Vec<String>,
690) -> Option<syn::Expr> {
691	match strip_parens(expr) {
692		syn::Expr::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => {
693			let ident = path.path.segments.first()?.ident.to_string();
694			let Some(alias) = aliases.get(&ident) else {
695				return Some(syn::Expr::Path(path.clone()));
696			};
697			if stack.last().is_some_and(|current| current == &ident) {
698				return Some(syn::Expr::Path(path.clone()));
699			}
700			if stack.iter().any(|current| current == &ident) {
701				return None;
702			}
703			stack.push(ident);
704			let resolved = resolve_place(alias, aliases, stack);
705			stack.pop();
706			resolved
707		}
708		syn::Expr::Path(path) if path.qself.is_none() && !path.path.segments.is_empty() => {
709			Some(syn::Expr::Path(path.clone()))
710		}
711		syn::Expr::Field(field) => {
712			let mut field = field.clone();
713			field.base = Box::new(resolve_place(&field.base, aliases, stack)?);
714			Some(syn::Expr::Field(field))
715		}
716		_ => None,
717	}
718}
719
720fn base_ident_of(expr: &syn::Expr) -> Option<&syn::Ident> {
721	match expr {
722		syn::Expr::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => {
723			Some(&path.path.segments[0].ident)
724		}
725		syn::Expr::MethodCall(call) => base_ident_of(&call.receiver),
726		syn::Expr::Field(field) => base_ident_of(&field.base),
727		syn::Expr::Paren(paren) => base_ident_of(&paren.expr),
728		syn::Expr::Reference(reference) => base_ident_of(&reference.expr),
729		syn::Expr::Try(try_expr) => base_ident_of(&try_expr.expr),
730		_ => None,
731	}
732}
733
734fn collect_pattern_idents(pattern: &syn::Pat, bindings: &mut HashSet<String>) {
735	match pattern {
736		syn::Pat::Ident(ident) => {
737			bindings.insert(ident.ident.to_string());
738			if let Some((_, subpat)) = &ident.subpat {
739				collect_pattern_idents(subpat, bindings);
740			}
741		}
742		syn::Pat::Tuple(tuple) => {
743			for element in &tuple.elems {
744				collect_pattern_idents(element, bindings);
745			}
746		}
747		syn::Pat::TupleStruct(tuple_struct) => {
748			for element in &tuple_struct.elems {
749				collect_pattern_idents(element, bindings);
750			}
751		}
752		syn::Pat::Struct(struct_pattern) => {
753			for field in &struct_pattern.fields {
754				collect_pattern_idents(&field.pat, bindings);
755			}
756		}
757		syn::Pat::Reference(reference) => collect_pattern_idents(&reference.pat, bindings),
758		syn::Pat::Type(typed) => collect_pattern_idents(&typed.pat, bindings),
759		syn::Pat::Or(or_pattern) => {
760			for case in &or_pattern.cases {
761				collect_pattern_idents(case, bindings);
762			}
763		}
764		syn::Pat::Slice(slice) => {
765			for element in &slice.elems {
766				collect_pattern_idents(element, bindings);
767			}
768		}
769		syn::Pat::Paren(paren) => collect_pattern_idents(&paren.pat, bindings),
770		_ => {}
771	}
772}
773
774fn collect_imports(items: &[syn::Item]) -> Vec<UseEntry> {
775	let mut imports = Vec::new();
776	for item in items {
777		if let syn::Item::Use(item_use) = item {
778			flatten_use_tree(&item_use.tree, &[], &item_use.attrs, &mut imports);
779		}
780	}
781	imports
782}
783
784fn flatten_use_tree(
785	tree: &syn::UseTree,
786	prefix: &[syn::Ident],
787	attrs: &[syn::Attribute],
788	imports: &mut Vec<UseEntry>,
789) {
790	match tree {
791		syn::UseTree::Path(path) => {
792			let mut next = prefix.to_vec();
793			next.push(path.ident.clone());
794			flatten_use_tree(&path.tree, &next, attrs, imports);
795		}
796		syn::UseTree::Name(name) => {
797			let mut original = prefix.to_vec();
798			original.push(name.ident.clone());
799			imports.push(UseEntry {
800				original,
801				bound: name.ident.clone(),
802				attrs: attrs.to_vec(),
803			});
804		}
805		syn::UseTree::Rename(rename) => {
806			let mut original = prefix.to_vec();
807			original.push(rename.ident.clone());
808			imports.push(UseEntry {
809				original,
810				bound: rename.rename.clone(),
811				attrs: attrs.to_vec(),
812			});
813		}
814		syn::UseTree::Group(group) => {
815			for tree in &group.items {
816				flatten_use_tree(tree, prefix, attrs, imports);
817			}
818		}
819		syn::UseTree::Glob(_) => {}
820	}
821}
822
823fn import_key(facade: &syn::Path, attrs: &[syn::Attribute]) -> String {
824	quote!(#facade #(#attrs)*).to_string()
825}
826
827fn facade_key(facade: &syn::Path) -> String {
828	quote!(#facade).to_string()
829}
830
831fn has_deps_import(imports: &[UseEntry], facade: &syn::Path, attrs: &[syn::Attribute]) -> bool {
832	let key = import_key(facade, attrs);
833	let facade_path_key = facade_key(facade);
834	imports.iter().any(|entry| {
835		entry.bound == "deps"
836			&& entry.original.last().is_some_and(|ident| ident == "deps")
837			&& facade_from_segments(&entry.original).is_some_and(|entry_facade| {
838				facade_key(&entry_facade) == facade_path_key
839					&& (entry.attrs.is_empty() || import_key(&entry_facade, &entry.attrs) == key)
840			})
841	})
842}
843
844#[cfg(test)]
845mod tests {
846	use super::*;
847
848	fn rewrite(source: &str) -> String {
849		let file = syn::parse_file(source).expect("test source must parse");
850		prettyplease::unparse(&Rule.rewrite(file))
851	}
852
853	fn compact(source: &str) -> String {
854		source.chars().filter(|ch| !ch.is_whitespace()).collect()
855	}
856
857	#[test]
858	fn hook_metadata_covers_all_supported_arities() {
859		let actual: Vec<_> = HOOK_SPECS
860			.iter()
861			.map(|spec| {
862				(
863					spec.name,
864					spec.callback_index,
865					spec.deps_index,
866					spec.omitted_arity,
867					spec.explicit_arity,
868				)
869			})
870			.collect();
871		assert_eq!(
872			actual,
873			vec![
874				("use_effect", 0, 1, 1, 2),
875				("use_layout_effect", 0, 1, 1, 2),
876				("use_retained_effect", 0, 1, 1, 2),
877				("use_retained_layout_effect", 0, 1, 1, 2),
878				("use_memo", 0, 1, 1, 2),
879				("use_callback", 0, 1, 1, 2),
880				("use_callback_with", 0, 1, 1, 2),
881				("use_resource", 0, 1, 1, 2),
882				("use_resource_with_key", 1, 2, 2, 3),
883			]
884		);
885	}
886
887	#[test]
888	fn keyed_resource_adds_or_converts_third_dependency_argument() {
889		let output = compact(&rewrite(
890			r#"
891use reinhardt_pages::reactive::use_resource_with_key;
892fn view(signal: Signal<i32>) {
893    let _ = use_resource_with_key("a", || async { Ok::<_, ()>(1) });
894    let _ = use_resource_with_key("b", || async { Ok::<_, ()>(2) }, ());
895    let _ = use_resource_with_key("c", || async { Ok::<_, ()>(3) }, (signal.clone(),));
896}
897"#,
898		));
899		assert!(output.contains("use_resource_with_key(\"a\",||async{Ok::<_,()>(1)},deps![]);"));
900		assert!(output.contains("use_resource_with_key(\"b\",||async{Ok::<_,()>(2)},deps![]);"));
901		assert!(
902			output.contains("use_resource_with_key(\"c\",||async{Ok::<_,()>(3)},deps![signal]);")
903		);
904	}
905
906	#[test]
907	fn explicit_dependency_matrix_converts_empty_single_and_multiple_tuples() {
908		let output = compact(&rewrite(
909			r#"
910use reinhardt_pages::reactive::hooks::use_effect;
911fn view(a: Signal<i32>, b: Signal<i32>) {
912    let _ = use_effect(|| {}, ());
913    let _ = use_effect(|| {}, (a.clone(),));
914    let _ = use_effect(|| {}, (a.clone(), b.clone()));
915}
916"#,
917		));
918		assert!(output.contains("use_effect(||{},deps![]);"));
919		assert!(output.contains("use_effect(||{},deps![a]);"));
920		assert!(output.contains("use_effect(||{},deps![a,b]);"));
921	}
922
923	#[test]
924	fn block_clone_alias_maps_back_to_outer_dependency() {
925		let output = compact(&rewrite(
926			r#"
927use reinhardt_pages::reactive::hooks::use_effect;
928fn view(original: Signal<i32>) {
929    let _ = use_effect({
930        let alias = original.clone();
931        move || { let _ = alias.get(); }
932    });
933}
934"#,
935		));
936		assert!(output.contains("deps![original]"));
937		assert!(!output.contains("deps![alias]"));
938	}
939
940	#[test]
941	fn unsafe_omitted_forms_keep_review_marker() {
942		let output = compact(&rewrite(
943			r#"
944use reinhardt_pages::reactive::hooks::use_effect;
945fn view(count: Signal<i32>) {
946    let _ = use_effect(move || { let _ = count.get(); });
947    let _ = use_effect(build_callback());
948    let _ = use_effect({
949        let alias = count.clone();
950        move || Some(move || { let _ = alias.get(); })
951    });
952}
953"#,
954		));
955		assert_eq!(output.matches("compile_error!").count(), 3);
956		assert_eq!(output.matches("deps![count]").count(), 3);
957		assert!(!output.contains("deps![alias]"));
958	}
959
960	#[test]
961	fn imports_deps_in_each_inline_module_scope() {
962		let output = rewrite(
963			r#"
964use reinhardt_pages::reactive::hooks::use_effect;
965fn root() { let _ = use_effect(|| {}); }
966mod nested {
967    use reinhardt_pages::reactive::hooks::use_effect;
968    fn view() { let _ = use_effect(|| {}); }
969}
970"#,
971		);
972		assert_eq!(output.matches("use reinhardt_pages::deps;").count(), 2);
973	}
974
975	#[test]
976	fn qualified_hook_preserves_full_facade_path() {
977		let output = compact(&rewrite(
978			r#"
979fn view() { let _ = reinhardt::pages::use_effect(|| {}); }
980"#,
981		));
982		assert!(output.contains("usereinhardt::pages::deps;"));
983		assert!(!output.contains("usereinhardt::deps;"));
984	}
985
986	#[test]
987	fn renamed_deps_import_does_not_satisfy_bare_macro_import() {
988		let output = rewrite(
989			r#"
990use reinhardt_pages::deps as hook_deps;
991use reinhardt_pages::use_effect;
992fn view() { let _ = use_effect(|| {}); }
993"#,
994		);
995		assert!(output.contains("use reinhardt_pages::deps as hook_deps;"));
996		assert!(output.contains("use reinhardt_pages::deps;"));
997	}
998
999	#[test]
1000	fn clone_alias_chain_resolves_to_the_outer_dependency() {
1001		let output = compact(&rewrite(
1002			r#"
1003use reinhardt_pages::use_effect;
1004fn view(count: Signal<i32>) {
1005    let _ = use_effect({
1006        let first = count.clone();
1007        let second = first.clone();
1008        move || { let _ = second.get(); }
1009    });
1010}
1011"#,
1012		));
1013		assert!(output.contains("deps![count]"));
1014		assert!(!output.contains("compile_error!"));
1015	}
1016
1017	#[test]
1018	fn clone_alias_chain_accepts_a_field_place() {
1019		let output = compact(&rewrite(
1020			r#"
1021use reinhardt_pages::use_effect;
1022fn view(state: State) {
1023    let _ = use_effect({
1024        let first = state.count.clone();
1025        let second = first.clone();
1026        move || { let _ = second.get(); }
1027    });
1028}
1029"#,
1030		));
1031		assert!(output.contains("deps![state.count]"));
1032		assert!(!output.contains("compile_error!"));
1033	}
1034
1035	#[test]
1036	fn same_name_clone_shadow_resolves_through_the_previous_alias() {
1037		let output = compact(&rewrite(
1038			r#"
1039use reinhardt_pages::use_effect;
1040fn view(count: Signal<i32>) {
1041    let _ = use_effect({
1042        let first = count.clone();
1043        let first = first.clone();
1044        move || { let _ = first.get(); }
1045    });
1046}
1047"#,
1048		));
1049		assert!(output.contains("deps![count]"));
1050		assert!(!output.contains("deps![first]"));
1051		assert!(!output.contains("compile_error!"));
1052	}
1053
1054	#[test]
1055	fn clone_aliases_snapshot_before_later_shadowing() {
1056		let output = compact(&rewrite(
1057			r#"
1058use reinhardt_pages::use_effect;
1059fn view(count: Signal<i32>, other: Signal<i32>) {
1060    let _ = use_effect({
1061        let first = count.clone();
1062        let second = first.clone();
1063        let first = other.clone();
1064        move || { let _ = second.get(); }
1065    });
1066}
1067"#,
1068		));
1069		assert!(output.contains("deps![count]"));
1070		assert!(!output.contains("},deps![other]"));
1071		assert!(!output.contains("compile_error!"));
1072	}
1073
1074	#[test]
1075	fn clone_alias_dependents_are_invalidated_by_base_shadowing() {
1076		let output = compact(&rewrite(
1077			r#"
1078use reinhardt_pages::use_effect;
1079fn view(state: Signal<i32>, other: Signal<i32>) {
1080    let _ = use_effect({
1081        let first = state.clone();
1082        let second = first.clone();
1083        let state = other.clone();
1084        move || { let _ = second.get(); }
1085    });
1086}
1087"#,
1088		));
1089		assert_eq!(output.matches("compile_error!").count(), 1);
1090		assert!(!output.contains("deps![other]"));
1091	}
1092
1093	#[test]
1094	fn field_alias_cycles_keep_the_review_marker() {
1095		let output = compact(&rewrite(
1096			r#"
1097use reinhardt_pages::use_effect;
1098fn view() {
1099    let _ = use_effect({
1100        let first = second.field.clone();
1101        let second = first.clone();
1102        move || { let _ = first.get(); }
1103    });
1104}
1105"#,
1106		));
1107		assert_eq!(output.matches("compile_error!").count(), 1);
1108		assert!(!output.contains("deps![second.field]"));
1109	}
1110
1111	#[test]
1112	fn field_clone_aliases_snapshot_before_base_shadowing() {
1113		let output = compact(&rewrite(
1114			r#"
1115use reinhardt_pages::use_effect;
1116fn view(state: State, other: Signal<i32>) {
1117    let _ = use_effect({
1118        let first = state.count.clone();
1119        let state = other.clone();
1120        move || { let _ = first.get(); }
1121    });
1122}
1123"#,
1124		));
1125		assert!(!output.contains("deps![other.count]"));
1126		assert_eq!(output.matches("compile_error!").count(), 1);
1127	}
1128
1129	#[test]
1130	fn side_effecting_or_indexed_clone_receivers_keep_review_markers() {
1131		let output = compact(&rewrite(
1132			r#"
1133use reinhardt_pages::use_effect;
1134fn view(signals: Vec<Signal<i32>>) {
1135    let _ = use_effect({
1136        let signal = make_signal().clone();
1137        move || { let _ = signal.get(); }
1138    });
1139    let _ = use_effect({
1140        let signal = signals[0].clone();
1141        move || { let _ = signal.get(); }
1142    });
1143}
1144"#,
1145		));
1146		assert_eq!(output.matches("compile_error!").count(), 2);
1147	}
1148
1149	#[test]
1150	fn cyclic_clone_aliases_keep_the_review_marker() {
1151		let output = compact(&rewrite(
1152			r#"
1153use reinhardt_pages::use_effect;
1154fn view() {
1155    let _ = use_effect({
1156        let first = second.clone();
1157        let second = first.clone();
1158        move || { let _ = second.get(); }
1159    });
1160}
1161"#,
1162		));
1163		assert_eq!(output.matches("compile_error!").count(), 1);
1164	}
1165
1166	#[test]
1167	fn module_alias_hook_call_restores_the_full_facade() {
1168		let output = compact(&rewrite(
1169			r#"
1170use reinhardt_pages::reactive::hooks as hooks;
1171fn view() { let _ = hooks::use_effect(|| {}); }
1172"#,
1173		));
1174		assert!(output.contains("usereinhardt_pages::deps;"));
1175		assert!(output.contains("hooks::use_effect(||{},deps![]);"));
1176	}
1177
1178	#[test]
1179	fn module_name_and_self_qualified_hook_calls_restore_the_facade() {
1180		let output = compact(&rewrite(
1181			r#"
1182use reinhardt_pages::reactive::hooks;
1183fn one() { let _ = hooks::use_effect(|| {}); }
1184fn two() { let _ = self::hooks::use_effect(|| {}); }
1185"#,
1186		));
1187		assert_eq!(output.matches("usereinhardt_pages::deps;").count(), 1);
1188		assert_eq!(output.matches("deps![]").count(), 2);
1189	}
1190
1191	#[test]
1192	fn generated_deps_import_inherits_hook_import_cfg() {
1193		let output = rewrite(
1194			r#"
1195#[cfg(wasm)]
1196use reinhardt_pages::reactive::hooks as hooks;
1197fn view() { let _ = hooks::use_effect(|| {}); }
1198"#,
1199		);
1200		assert!(output.contains("#[cfg(wasm)]\nuse reinhardt_pages::deps;"));
1201	}
1202
1203	#[test]
1204	fn generated_deps_imports_deduplicate_by_cfg_and_path() {
1205		let output = rewrite(
1206			r#"
1207#[cfg(wasm)]
1208use reinhardt_pages::reactive::hooks as wasm_hooks;
1209#[cfg(native)]
1210use reinhardt_pages::reactive::hooks as native_hooks;
1211fn view() {
1212    let _ = wasm_hooks::use_effect(|| {});
1213    let _ = wasm_hooks::use_layout_effect(|| {});
1214    let _ = native_hooks::use_effect(|| {});
1215}
1216"#,
1217		);
1218		assert_eq!(
1219			output
1220				.matches("#[cfg(wasm)]\nuse reinhardt_pages::deps;")
1221				.count(),
1222			1
1223		);
1224		assert_eq!(
1225			output
1226				.matches("#[cfg(native)]\nuse reinhardt_pages::deps;")
1227				.count(),
1228			1
1229		);
1230	}
1231
1232	#[test]
1233	fn unconditional_deps_import_supersedes_cfg_variant() {
1234		let output = rewrite(
1235			r#"
1236#[cfg(feature = "foo")]
1237use foo::reactive::hooks as wasm_hooks;
1238use foo::reactive::hooks as native_hooks;
1239fn view(signal: Signal<i32>) {
1240    let _ = wasm_hooks::use_effect({
1241        let signal = signal.clone();
1242        move || { let _ = signal.get(); }
1243    });
1244    let _ = native_hooks::use_effect({
1245        let signal = signal.clone();
1246        move || { let _ = signal.get(); }
1247    });
1248}
1249"#,
1250		);
1251		assert_eq!(output.matches("use foo::deps;").count(), 1);
1252		assert!(!output.contains("#[cfg(feature = \"foo\")]\nuse foo::deps;"));
1253	}
1254
1255	#[test]
1256	fn same_bound_hook_imports_generate_deps_for_each_cfg_path() {
1257		let output = rewrite(
1258			r#"
1259#[cfg(feature = "foo")]
1260use foo::reactive::hooks::use_effect;
1261#[cfg(not(feature = "foo"))]
1262use bar::reactive::hooks::use_effect;
1263fn view(signal: Signal<i32>) {
1264    let _ = use_effect({
1265        let signal = signal.clone();
1266        move || { let _ = signal.get(); }
1267    });
1268}
1269"#,
1270		);
1271		assert!(output.contains("#[cfg(feature = \"foo\")]\nuse foo::deps;"));
1272		assert!(output.contains("#[cfg(not(feature = \"foo\"))]\nuse bar::deps;"));
1273		assert_eq!(output.matches("deps![signal]").count(), 1);
1274	}
1275
1276	#[test]
1277	fn ambiguous_same_bound_hook_specs_keep_a_review_marker() {
1278		let output = rewrite(
1279			r#"
1280#[cfg(feature = "foo")]
1281use foo::reactive::hooks::use_effect as hook;
1282#[cfg(not(feature = "foo"))]
1283use bar::reactive::hooks::use_resource as hook;
1284fn view(signal: Signal<i32>) {
1285    let _ = hook({
1286        let signal = signal.clone();
1287        move || { let _ = signal.get(); }
1288    });
1289}
1290"#,
1291		);
1292		assert!(!output.contains("use foo::deps;"));
1293		assert!(!output.contains("use bar::deps;"));
1294		assert!(output.contains("compile_error!"));
1295	}
1296
1297	#[test]
1298	fn shadowed_clone_alias_keeps_the_review_marker() {
1299		let output = compact(&rewrite(
1300			r#"
1301use reinhardt_pages::use_effect;
1302fn view(count: Signal<i32>) {
1303    let _ = use_effect({
1304        let alias = count.clone();
1305        let alias = make_signal();
1306        move || { let _ = alias.get(); }
1307    });
1308}
1309"#,
1310		));
1311		assert_eq!(output.matches("compile_error!").count(), 1);
1312		assert!(!output.contains(",deps![count]);"));
1313	}
1314
1315	#[test]
1316	fn closure_local_shadow_keeps_the_review_marker() {
1317		let output = compact(&rewrite(
1318			r#"
1319use reinhardt_pages::use_effect;
1320fn view(count: Signal<i32>) {
1321    let _ = use_effect({
1322        let alias = count.clone();
1323        move || {
1324            let alias = make_signal();
1325            let _ = alias.get();
1326        }
1327    });
1328}
1329"#,
1330		));
1331		assert_eq!(output.matches("compile_error!").count(), 1);
1332		assert!(!output.contains(",deps![count]);"));
1333	}
1334
1335	#[test]
1336	fn closure_destructure_shadow_keeps_the_review_marker() {
1337		let output = compact(&rewrite(
1338			r#"
1339use reinhardt_pages::use_effect;
1340fn view(count: Signal<i32>) {
1341    let _ = use_effect({
1342        let alias = count.clone();
1343        move || {
1344            let (alias, _) = make_pair();
1345            let _ = alias.get();
1346        }
1347    });
1348}
1349"#,
1350		));
1351		assert_eq!(output.matches("compile_error!").count(), 1);
1352		assert!(!output.contains(",deps![count]);"));
1353	}
1354
1355	#[test]
1356	fn control_flow_pattern_shadow_keeps_the_review_marker() {
1357		let output = compact(&rewrite(
1358			r#"
1359use reinhardt_pages::use_effect;
1360fn view(count: Signal<i32>) {
1361    let _ = use_effect({
1362        let alias = count.clone();
1363        move || {
1364            for alias in make_iter() { let _ = alias.get(); }
1365            if let Some(alias) = make_option() { let _ = alias.get(); }
1366            match make_option() {
1367                Some(alias) => { let _ = alias.get(); }
1368                None => {}
1369            }
1370        }
1371    });
1372}
1373"#,
1374		));
1375		assert_eq!(output.matches("compile_error!").count(), 1);
1376		assert!(!output.contains(",deps![count]);"));
1377	}
1378
1379	#[test]
1380	fn relative_reexport_does_not_generate_an_unresolved_deps_import() {
1381		let output = rewrite(
1382			r#"
1383mod parent {
1384    use reinhardt_pages::use_effect;
1385    mod child {
1386        use super::use_effect;
1387        fn view(signal: Signal<i32>) {
1388            let _ = use_effect(|| { let _ = signal.get(); });
1389        }
1390    }
1391}
1392"#,
1393		);
1394		assert!(!output.contains("use super::deps;"));
1395		assert!(output.contains("compile_error!"));
1396	}
1397
1398	#[test]
1399	fn relative_reexport_tuple_is_replaced_with_a_review_marker() {
1400		let output = compact(&rewrite(
1401			r#"
1402mod parent {
1403    use reinhardt_pages::use_effect;
1404    mod child {
1405        use super::use_effect;
1406        fn view(signal: Signal<i32>) {
1407            let _ = use_effect(|| {}, (signal.clone(),));
1408        }
1409    }
1410}
1411"#,
1412		));
1413		assert!(!output.contains("use super::deps;"));
1414		assert!(!output.contains("(signal.clone(),)"));
1415		assert!(output.contains("compile_error!"));
1416	}
1417
1418	#[test]
1419	fn unresolved_bare_hook_with_explicit_dependencies_is_not_rewritten() {
1420		let output = compact(&rewrite(
1421			r#"
1422fn use_effect<F, D>(callback: F, dependencies: D) {}
1423fn view(signal: Signal<i32>) {
1424    use_effect(|| {}, (signal.clone(),));
1425}
1426"#,
1427		));
1428
1429		assert!(output.contains("use_effect(||{},(signal.clone(),));"));
1430		assert!(!output.contains("use reinhardt_pages::deps;"));
1431	}
1432
1433	#[test]
1434	fn unresolved_bare_hook_with_omitted_dependencies_is_not_rewritten() {
1435		let output = compact(&rewrite(
1436			r#"
1437fn use_effect<F>(callback: F) {}
1438fn view() {
1439    use_effect(|| {});
1440}
1441"#,
1442		));
1443
1444		assert!(output.contains("use_effect(||{});"));
1445		assert!(!output.contains("reinhardt_pages::deps"));
1446	}
1447
1448	#[test]
1449	fn local_module_hook_does_not_generate_an_unresolved_deps_import() {
1450		let output = rewrite(
1451			r#"
1452mod custom {}
1453use custom::reactive::hooks::use_effect;
1454fn view(signal: Signal<i32>) {
1455    let _ = use_effect(|| { let _ = signal.get(); });
1456}
1457"#,
1458		);
1459		assert!(!output.contains("use custom::deps;"));
1460		assert!(output.contains("compile_error!"));
1461	}
1462
1463	#[test]
1464	fn crate_local_module_hook_does_not_generate_an_unresolved_deps_import() {
1465		let output = rewrite(
1466			r#"
1467mod custom {}
1468use crate::custom::reactive::hooks::use_effect;
1469fn view(signal: Signal<i32>) {
1470    let _ = use_effect(|| { let _ = signal.get(); });
1471}
1472"#,
1473		);
1474		assert!(!output.contains("use crate::custom::deps;"));
1475		assert!(output.contains("compile_error!"));
1476	}
1477
1478	#[test]
1479	fn nested_crate_local_module_hook_does_not_generate_an_unresolved_deps_import() {
1480		let output = rewrite(
1481			r#"
1482mod parent {
1483    mod custom {}
1484    use crate::parent::custom::reactive::hooks::use_effect;
1485    fn view(signal: Signal<i32>) {
1486        let _ = use_effect(|| { let _ = signal.get(); });
1487    }
1488}
1489"#,
1490		);
1491		assert!(!output.contains("use crate::parent::custom::deps;"));
1492		assert!(output.contains("compile_error!"));
1493	}
1494
1495	#[test]
1496	fn function_local_hook_import_does_not_fall_back_to_reinhardt_pages() {
1497		let output = rewrite(
1498			r#"
1499mod custom {}
1500fn view(signal: Signal<i32>) {
1501    use custom::reactive::hooks::use_effect;
1502    let _ = use_effect({
1503        let signal = signal.clone();
1504        move || { let _ = signal.get(); }
1505    });
1506    let _ = use_effect(|| {}, (signal.clone(),));
1507}
1508"#,
1509		);
1510		assert!(!output.contains("use reinhardt_pages::deps;"));
1511		assert!(!output.contains("use custom::deps;"));
1512		assert_eq!(output.matches("compile_error!").count(), 2);
1513		assert!(!output.contains("(signal.clone(),)"));
1514	}
1515}