Skip to main content

reinhardt_admin_cli/migrate_v2/rules/
bare_ident.rs

1//! Rule §6.1 (1): `tag { ident }` → `tag { {ident} }`.
2//!
3//! Conservative implementation — bails out on any token that could mean
4//! attribute, event, nested element, component call, or method chain. The
5//! resulting diff is the developer's review surface; missing transformations
6//! are acceptable and caught on a second pass.
7
8use proc_macro2::{Delimiter, Group, TokenStream, TokenTree};
9use quote::quote;
10use std::iter::Peekable;
11use syn::visit_mut::{self, VisitMut};
12
13use crate::migrate_v2::rewriter::FileRewriter;
14
15/// `bare_ident` rule entry.
16pub struct Rule;
17
18impl FileRewriter for Rule {
19	fn name(&self) -> &'static str {
20		"bare_ident"
21	}
22
23	fn rewrite(&self, mut file: syn::File) -> syn::File {
24		PageMacroBodyVisitor.visit_file_mut(&mut file);
25		file
26	}
27}
28
29struct PageMacroBodyVisitor;
30
31impl VisitMut for PageMacroBodyVisitor {
32	fn visit_macro_mut(&mut self, m: &mut syn::Macro) {
33		// Only target the `page!` macro (and any qualified path ending in `page`).
34		if m.path
35			.segments
36			.last()
37			.map(|s| s.ident == "page")
38			.unwrap_or(false)
39		{
40			m.tokens = rewrite_page_body(m.tokens.clone());
41		}
42		visit_mut::visit_macro_mut(self, m);
43	}
44}
45
46/// Walks the token stream of a `page!` body. Inside brace-delimited groups,
47/// promotes any bare lowercase-leading identifier to a `{ident}` group.
48fn rewrite_page_body(input: TokenStream) -> TokenStream {
49	let mut out: Vec<TokenTree> = Vec::new();
50	for tt in input {
51		match tt {
52			TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
53				let inner = rewrite_brace_body(g.stream());
54				out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
55			}
56			other => out.push(other),
57		}
58	}
59	out.into_iter().collect()
60}
61
62/// Inside a `{ ... }` body, promote a bare ident to `{ident}` when it appears
63/// in child-node position.
64fn rewrite_brace_body(input: TokenStream) -> TokenStream {
65	let mut out: Vec<TokenTree> = Vec::new();
66	let mut iter = input.into_iter().peekable();
67	let mut previous_token_can_own_brace_body = false;
68
69	while let Some(tt) = iter.next() {
70		if let TokenTree::Ident(id) = &tt {
71			match id.to_string().as_str() {
72				"if" | "for" | "while" => {
73					out.push(tt);
74					push_control_prefix_and_rewrite_body(&mut iter, &mut out);
75					previous_token_can_own_brace_body = false;
76					continue;
77				}
78				"match" => {
79					out.push(tt);
80					push_match_prefix_and_rewrite_body(&mut iter, &mut out);
81					previous_token_can_own_brace_body = false;
82					continue;
83				}
84				"let" => {
85					out.push(tt);
86					push_statement_until_semicolon(&mut iter, &mut out);
87					previous_token_can_own_brace_body = false;
88					continue;
89				}
90				"else" => {
91					out.push(tt);
92					if let Some(TokenTree::Ident(next)) = iter.peek()
93						&& next == "if"
94					{
95						let if_token = iter.next().expect("peeked token disappeared");
96						out.push(if_token);
97						push_control_prefix_and_rewrite_body(&mut iter, &mut out);
98					} else {
99						push_immediate_body_if_present(&mut iter, &mut out);
100					}
101					previous_token_can_own_brace_body = false;
102					continue;
103				}
104				"loop" | "unsafe" | "async" => {
105					out.push(tt);
106					push_immediate_body_if_present(&mut iter, &mut out);
107					previous_token_can_own_brace_body = false;
108					continue;
109				}
110				_ => {}
111			}
112		}
113
114		// Look for `Ident` followed by something that is NOT one of:
115		//   `{` — element body
116		//   `(` — component call or function call
117		//   `:` — attribute syntax (`class: "x"`)
118		//   `!` — macro call (`println!`)
119		//   `.` — method chain or field access
120		//   `,` — would already be the end of a value but still ambiguous
121		//   `::` — path continuation
122		// These all mean "not a bare expression in body position".
123		if let TokenTree::Ident(id) = &tt
124			&& starts_lowercase(&id.to_string())
125			&& !is_reserved_keyword(&id.to_string())
126		{
127			let is_followed_by_continuation = match iter.peek() {
128				Some(TokenTree::Group(g))
129					if matches!(
130						g.delimiter(),
131						Delimiter::Brace | Delimiter::Parenthesis | Delimiter::Bracket
132					) =>
133				{
134					true
135				}
136				Some(TokenTree::Punct(p)) if matches!(p.as_char(), ':' | '!' | '.' | ',') => true,
137				_ => false,
138			};
139
140			if !is_followed_by_continuation {
141				// Bare ident in body position — wrap.
142				let ident = id.clone();
143				let wrapped = quote! { #ident };
144				out.push(TokenTree::Group(Group::new(Delimiter::Brace, wrapped)));
145				previous_token_can_own_brace_body = false;
146				continue;
147			}
148		}
149
150		// Recurse into any nested braced group (could be a child element body) —
151		// UNLESS it is a standalone expression slot created by a prior run.
152		// Element/control-flow bodies are owned by the previous token, while
153		// expression slots appear as standalone brace groups in child position.
154		if let TokenTree::Group(g) = &tt
155			&& g.delimiter() == Delimiter::Brace
156		{
157			if previous_token_can_own_brace_body {
158				let inner = rewrite_brace_body(g.stream());
159				out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
160			} else {
161				out.push(tt);
162			}
163			previous_token_can_own_brace_body = false;
164			continue;
165		}
166
167		previous_token_can_own_brace_body = match &tt {
168			TokenTree::Ident(id) => !is_reserved_keyword(&id.to_string()),
169			TokenTree::Punct(p) => matches!(p.as_char(), ')' | ']'),
170			_ => false,
171		};
172		out.push(tt);
173	}
174
175	out.into_iter().collect()
176}
177
178fn push_control_prefix_and_rewrite_body(
179	iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
180	out: &mut Vec<TokenTree>,
181) {
182	for next in iter.by_ref() {
183		if let TokenTree::Group(g) = &next
184			&& g.delimiter() == Delimiter::Brace
185		{
186			let inner = rewrite_brace_body(g.stream());
187			out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
188			return;
189		}
190		out.push(next);
191	}
192}
193
194fn push_match_prefix_and_rewrite_body(
195	iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
196	out: &mut Vec<TokenTree>,
197) {
198	for next in iter.by_ref() {
199		if let TokenTree::Group(g) = &next
200			&& g.delimiter() == Delimiter::Brace
201		{
202			let inner = rewrite_match_body(g.stream());
203			out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
204			return;
205		}
206		out.push(next);
207	}
208}
209
210fn rewrite_match_body(input: TokenStream) -> TokenStream {
211	let mut out: Vec<TokenTree> = Vec::new();
212	let mut arm_value: Vec<TokenTree> = Vec::new();
213	let mut iter = input.into_iter().peekable();
214	let mut in_arm_value = false;
215
216	while let Some(tt) = iter.next() {
217		if !in_arm_value {
218			if is_fat_arrow_start(&tt, iter.peek()) {
219				out.push(tt);
220				if let Some(next) = iter.next() {
221					out.push(next);
222				}
223				in_arm_value = true;
224			} else {
225				out.push(tt);
226			}
227			continue;
228		}
229
230		if is_top_level_comma(&tt) {
231			out.extend(rewrite_brace_body(arm_value.into_iter().collect()));
232			arm_value = Vec::new();
233			out.push(tt);
234			in_arm_value = false;
235		} else {
236			arm_value.push(tt);
237		}
238	}
239
240	if in_arm_value {
241		out.extend(rewrite_brace_body(arm_value.into_iter().collect()));
242	}
243
244	out.into_iter().collect()
245}
246
247fn is_fat_arrow_start(tt: &TokenTree, next: Option<&TokenTree>) -> bool {
248	matches!(tt, TokenTree::Punct(p) if p.as_char() == '=')
249		&& matches!(next, Some(TokenTree::Punct(p)) if p.as_char() == '>')
250}
251
252fn is_top_level_comma(tt: &TokenTree) -> bool {
253	matches!(tt, TokenTree::Punct(p) if p.as_char() == ',')
254}
255
256fn push_statement_until_semicolon(
257	iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
258	out: &mut Vec<TokenTree>,
259) {
260	let mut initializer: Vec<TokenTree> = Vec::new();
261	let mut in_initializer = false;
262
263	for next in iter.by_ref() {
264		let is_semicolon = matches!(&next, TokenTree::Punct(p) if p.as_char() == ';');
265		let is_assignment = matches!(&next, TokenTree::Punct(p) if p.as_char() == '=');
266
267		if in_initializer {
268			if is_semicolon {
269				out.extend(rewrite_let_initializer(initializer.into_iter().collect()));
270				out.push(next);
271				return;
272			}
273			initializer.push(next);
274			continue;
275		}
276
277		out.push(next);
278		if is_assignment {
279			in_initializer = true;
280		}
281		if is_semicolon {
282			return;
283		}
284	}
285
286	if in_initializer {
287		out.extend(rewrite_let_initializer(initializer.into_iter().collect()));
288	}
289}
290
291fn rewrite_let_initializer(input: TokenStream) -> TokenStream {
292	let mut out: Vec<TokenTree> = Vec::new();
293	let mut previous_token_can_own_brace_body = false;
294
295	for tt in input {
296		match tt {
297			TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
298				let inner = if previous_token_can_own_brace_body {
299					rewrite_brace_body(g.stream())
300				} else {
301					rewrite_let_initializer(g.stream())
302				};
303				out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
304				previous_token_can_own_brace_body = false;
305			}
306			TokenTree::Group(g) => {
307				let delimiter = g.delimiter();
308				let inner = rewrite_let_initializer(g.stream());
309				out.push(TokenTree::Group(Group::new(delimiter, inner)));
310				previous_token_can_own_brace_body = false;
311			}
312			other => {
313				previous_token_can_own_brace_body = token_can_own_let_initializer_body(&other);
314				out.push(other);
315			}
316		}
317	}
318
319	out.into_iter().collect()
320}
321
322fn token_can_own_let_initializer_body(tt: &TokenTree) -> bool {
323	matches!(
324		tt,
325		TokenTree::Ident(id)
326			if starts_lowercase(&id.to_string()) && !is_reserved_keyword(&id.to_string())
327	)
328}
329
330fn push_immediate_body_if_present(
331	iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
332	out: &mut Vec<TokenTree>,
333) {
334	if let Some(TokenTree::Group(g)) = iter.peek()
335		&& g.delimiter() == Delimiter::Brace
336	{
337		let body = match iter.next() {
338			Some(TokenTree::Group(g)) => g,
339			_ => unreachable!("peek matched but next did not"),
340		};
341		let inner = rewrite_brace_body(body.stream());
342		out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
343	}
344}
345
346fn starts_lowercase(s: &str) -> bool {
347	s.chars()
348		.next()
349		.map(|c| c.is_ascii_lowercase())
350		.unwrap_or(false)
351}
352
353/// Rust reserved keywords that can legally appear at the head of an
354/// expression / control-flow construct inside a `page!` body. We must
355/// never wrap these in braces — `{ if } cond { ... }` is a parse error.
356fn is_reserved_keyword(s: &str) -> bool {
357	matches!(
358		s,
359		"if" | "else"
360			| "match" | "for"
361			| "while" | "loop"
362			| "let" | "return"
363			| "break" | "continue"
364			| "move" | "ref"
365			| "mut" | "async"
366			| "await" | "yield"
367			| "do" | "in"
368			| "as" | "where"
369			| "use" | "fn"
370			| "true" | "false"
371			| "self" | "Self"
372			| "super" | "crate"
373			| "impl" | "trait"
374			| "struct"
375			| "enum" | "type"
376			| "const" | "static"
377			| "pub" | "mod"
378			| "unsafe"
379			| "extern"
380	)
381}