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 syn::visit_mut::{self, VisitMut};
11
12use crate::migrate_v2::rewriter::FileRewriter;
13
14/// `bare_ident` rule entry.
15pub struct Rule;
16
17impl FileRewriter for Rule {
18	fn name(&self) -> &'static str {
19		"bare_ident"
20	}
21
22	fn rewrite(&self, mut file: syn::File) -> syn::File {
23		PageMacroBodyVisitor.visit_file_mut(&mut file);
24		file
25	}
26}
27
28struct PageMacroBodyVisitor;
29
30impl VisitMut for PageMacroBodyVisitor {
31	fn visit_macro_mut(&mut self, m: &mut syn::Macro) {
32		// Only target the `page!` macro (and any qualified path ending in `page`).
33		if m.path
34			.segments
35			.last()
36			.map(|s| s.ident == "page")
37			.unwrap_or(false)
38		{
39			m.tokens = rewrite_page_body(m.tokens.clone());
40		}
41		visit_mut::visit_macro_mut(self, m);
42	}
43}
44
45/// Walks the token stream of a `page!` body. Inside brace-delimited groups,
46/// promotes any bare lowercase-leading identifier to a `{ident}` group.
47fn rewrite_page_body(input: TokenStream) -> TokenStream {
48	let mut out: Vec<TokenTree> = Vec::new();
49	for tt in input {
50		match tt {
51			TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
52				let inner = rewrite_brace_body(g.stream());
53				out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
54			}
55			other => out.push(other),
56		}
57	}
58	out.into_iter().collect()
59}
60
61/// Inside a `{ ... }` body, promote a bare ident to `{ident}` when it appears
62/// in child-node position.
63fn rewrite_brace_body(input: TokenStream) -> TokenStream {
64	let mut out: Vec<TokenTree> = Vec::new();
65	let mut iter = input.into_iter().peekable();
66
67	while let Some(tt) = iter.next() {
68		// Look for `Ident` followed by something that is NOT one of:
69		//   `{` — element body
70		//   `(` — component call or function call
71		//   `:` — attribute syntax (`class: "x"`)
72		//   `!` — macro call (`println!`)
73		//   `.` — method chain or field access
74		//   `,` — would already be the end of a value but still ambiguous
75		//   `::` — path continuation
76		// These all mean "not a bare expression in body position".
77		if let TokenTree::Ident(id) = &tt
78			&& starts_lowercase(&id.to_string())
79			&& !is_reserved_keyword(&id.to_string())
80		{
81			let is_followed_by_continuation = match iter.peek() {
82				Some(TokenTree::Group(g))
83					if matches!(
84						g.delimiter(),
85						Delimiter::Brace | Delimiter::Parenthesis | Delimiter::Bracket
86					) =>
87				{
88					true
89				}
90				Some(TokenTree::Punct(p)) if matches!(p.as_char(), ':' | '!' | '.' | ',') => true,
91				_ => false,
92			};
93
94			if !is_followed_by_continuation {
95				// Bare ident in body position — wrap.
96				let ident = id.clone();
97				let wrapped = quote! { #ident };
98				out.push(TokenTree::Group(Group::new(Delimiter::Brace, wrapped)));
99				continue;
100			}
101		}
102
103		// Recurse into any nested braced group (could be a child element body) —
104		// UNLESS the group is already in v2 expression-slot shape (`{ expr }`
105		// where the inner stream is itself wrapped in a single brace group).
106		// Recursing into such a body would re-wrap the inner ident on every
107		// pass, breaking idempotency.
108		if let TokenTree::Group(g) = &tt
109			&& g.delimiter() == Delimiter::Brace
110		{
111			if is_already_wrapped_expression_slot(&g.stream()) {
112				out.push(tt);
113			} else {
114				let inner = rewrite_brace_body(g.stream());
115				out.push(TokenTree::Group(Group::new(Delimiter::Brace, inner)));
116			}
117			continue;
118		}
119
120		out.push(tt);
121	}
122
123	out.into_iter().collect()
124}
125
126/// True when a brace body's entire contents are themselves a single brace
127/// group — i.e. the surrounding braces are already the v2 expression-slot
128/// wrapping (`{ {expr} }`) introduced by a prior run of this rule.
129fn is_already_wrapped_expression_slot(stream: &TokenStream) -> bool {
130	let mut iter = stream.clone().into_iter();
131	let first = iter.next();
132	let rest = iter.next();
133	match (first, rest) {
134		(Some(TokenTree::Group(g)), None) => g.delimiter() == Delimiter::Brace,
135		_ => false,
136	}
137}
138
139fn starts_lowercase(s: &str) -> bool {
140	s.chars()
141		.next()
142		.map(|c| c.is_ascii_lowercase())
143		.unwrap_or(false)
144}
145
146/// Rust reserved keywords that can legally appear at the head of an
147/// expression / control-flow construct inside a `page!` body. We must
148/// never wrap these in braces — `{ if } cond { ... }` is a parse error.
149fn is_reserved_keyword(s: &str) -> bool {
150	matches!(
151		s,
152		"if" | "else"
153			| "match" | "for"
154			| "while" | "loop"
155			| "let" | "return"
156			| "break" | "continue"
157			| "move" | "ref"
158			| "mut" | "async"
159			| "await" | "yield"
160			| "do" | "in"
161			| "as" | "where"
162			| "use" | "fn"
163			| "true" | "false"
164			| "self" | "Self"
165			| "super" | "crate"
166			| "impl" | "trait"
167			| "struct"
168			| "enum" | "type"
169			| "const" | "static"
170			| "pub" | "mod"
171			| "unsafe"
172			| "extern"
173	)
174}