Skip to content

Commit 9fde1b8

Browse files
kakserpomclaude
andcommitted
fix(macro): address PR review feedback extphprs#616
- Remove 'resource' and 'numeric' from PHP_TYPE_KEYWORDS as they are allowed for class names per PHP source code - Add EnumCase validation to prevent reserved keywords like 'true'/'false' from being used as enum case names - Replace assert! with syn::Error for proper macro error handling with span information for better error messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a79af4e commit 9fde1b8

File tree

7 files changed

+121
-58
lines changed

7 files changed

+121
-58
lines changed

crates/macros/src/class.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ pub fn parser(mut input: ItemStruct) -> Result<TokenStream> {
4747
let name = attr
4848
.rename
4949
.rename(ident_to_php_name(ident), RenameRule::Pascal);
50-
validate_php_name(&name, PhpNameContext::Class);
50+
validate_php_name(&name, PhpNameContext::Class, ident.span())?;
5151
let docs = get_docs(&attr.attrs)?;
5252
input.attrs.retain(|attr| !attr.path().is_ident("php"));
5353

@@ -97,7 +97,17 @@ fn parse_fields<'a>(fields: impl Iterator<Item = &'a mut syn::Field>) -> Result<
9797
let docs = get_docs(&attr.attrs)?;
9898
field.attrs.retain(|attr| !attr.path().is_ident("php"));
9999

100-
result.push(Property { ident, attr, docs });
100+
let name = attr
101+
.rename
102+
.rename(ident_to_php_name(ident), RenameRule::Camel);
103+
validate_php_name(&name, PhpNameContext::Property, ident.span())?;
104+
105+
result.push(Property {
106+
ident,
107+
name,
108+
attr,
109+
docs,
110+
});
101111
}
102112
}
103113

@@ -107,21 +117,11 @@ fn parse_fields<'a>(fields: impl Iterator<Item = &'a mut syn::Field>) -> Result<
107117
#[derive(Debug)]
108118
struct Property<'a> {
109119
pub ident: &'a syn::Ident,
120+
pub name: String,
110121
pub attr: PropAttributes,
111122
pub docs: Vec<String>,
112123
}
113124

114-
impl Property<'_> {
115-
pub fn name(&self) -> String {
116-
let name = self
117-
.attr
118-
.rename
119-
.rename(ident_to_php_name(self.ident), RenameRule::Camel);
120-
validate_php_name(&name, PhpNameContext::Property);
121-
name
122-
}
123-
}
124-
125125
/// Generates an implementation of `RegisteredClass` for struct `ident`.
126126
#[allow(clippy::too_many_arguments)]
127127
fn generate_registered_class_impl(
@@ -137,7 +137,7 @@ fn generate_registered_class_impl(
137137
let modifier = modifier.option_tokens();
138138

139139
let fields = fields.iter().map(|prop| {
140-
let name = prop.name();
140+
let name = &prop.name;
141141
let ident = prop.ident;
142142
let flags = prop
143143
.attr

crates/macros/src/constant.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub fn parser(mut item: ItemConst) -> Result<TokenStream> {
2626
let name = attr
2727
.rename
2828
.rename(ident_to_php_name(&item.ident), RenameRule::ScreamingSnake);
29-
validate_php_name(&name, PhpNameContext::Constant);
29+
validate_php_name(&name, PhpNameContext::Constant, item.ident.span())?;
3030
let name_ident = format_ident!("{INTERNAL_CONST_NAME_PREFIX}{}", item.ident);
3131

3232
let docs = get_docs(&attr.attrs)?;

crates/macros/src/enum_.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,15 @@ pub fn parser(mut input: ItemEnum) -> Result<TokenStream> {
8383
bail!(variant => "Discriminant must be specified for all enum cases, found: {:?}", variant);
8484
}
8585

86+
let case_name = variant_attr.rename.rename(
87+
ident_to_php_name(&variant.ident),
88+
php_attr.rename_cases.unwrap_or(RenameRule::Pascal),
89+
);
90+
validate_php_name(&case_name, PhpNameContext::EnumCase, variant.ident.span())?;
91+
8692
cases.push(EnumCase {
8793
ident: variant.ident.clone(),
88-
name: variant_attr.rename.rename(
89-
ident_to_php_name(&variant.ident),
90-
php_attr.rename_cases.unwrap_or(RenameRule::Pascal),
91-
),
94+
name: case_name,
9295
attrs: variant_attr,
9396
discriminant,
9497
docs,
@@ -110,7 +113,7 @@ pub fn parser(mut input: ItemEnum) -> Result<TokenStream> {
110113
cases,
111114
None, // TODO: Implement flags support
112115
discriminant_type,
113-
);
116+
)?;
114117

115118
Ok(quote! {
116119
#[allow(dead_code)]
@@ -138,20 +141,20 @@ impl<'a> Enum<'a> {
138141
cases: Vec<EnumCase>,
139142
flags: Option<String>,
140143
discriminant_type: DiscriminantType,
141-
) -> Self {
144+
) -> Result<Self> {
142145
let name = attrs
143146
.rename
144147
.rename(ident_to_php_name(ident), RenameRule::Pascal);
145-
validate_php_name(&name, PhpNameContext::Enum);
148+
validate_php_name(&name, PhpNameContext::Enum, ident.span())?;
146149

147-
Self {
150+
Ok(Self {
148151
ident,
149152
name,
150153
discriminant_type,
151154
docs,
152155
cases,
153156
flags,
154-
}
157+
})
155158
}
156159

157160
fn registered_class(&self) -> TokenStream {

crates/macros/src/function.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub fn parser(mut input: ItemFn) -> Result<TokenStream> {
4949
let func_name = php_attr
5050
.rename
5151
.rename(ident_to_php_name(&input.sig.ident), RenameRule::Snake);
52-
validate_php_name(&func_name, PhpNameContext::Function);
52+
validate_php_name(&func_name, PhpNameContext::Function, input.sig.ident.span())?;
5353
let func = Function::new(&input.sig, func_name, args, php_attr.optional, docs);
5454
let function_impl = func.php_function_impl();
5555

crates/macros/src/impl_.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ impl<'a> ParsedImpl<'a> {
190190
let name = attr
191191
.rename
192192
.rename(ident_to_php_name(&c.ident), self.change_constant_case);
193-
validate_php_name(&name, PhpNameContext::Constant);
193+
validate_php_name(&name, PhpNameContext::Constant, c.ident.span())?;
194194
let docs = get_docs(&attr.attrs)?;
195195
c.attrs.retain(|attr| !attr.path().is_ident("php"));
196196

@@ -206,7 +206,7 @@ impl<'a> ParsedImpl<'a> {
206206
ident_to_php_name(&method.sig.ident),
207207
self.change_method_case,
208208
);
209-
validate_php_name(&name, PhpNameContext::Method);
209+
validate_php_name(&name, PhpNameContext::Method, method.sig.ident.span())?;
210210
let docs = get_docs(&attr.attrs)?;
211211
method.attrs.retain(|attr| !attr.path().is_ident("php"));
212212

crates/macros/src/interface.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ impl<'a> Parse<'a, InterfaceData<'a>> for ItemTrait {
201201
let name = attrs
202202
.rename
203203
.rename(ident_to_php_name(ident), RenameRule::Pascal);
204-
validate_php_name(&name, PhpNameContext::Interface);
204+
validate_php_name(&name, PhpNameContext::Interface, ident.span())?;
205205
let docs = get_docs(&attrs.attrs)?;
206206
self.attrs.clean_php();
207207
let interface_name = format_ident!("{INTERNAL_INTERFACE_NAME_PREFIX}{ident}");
@@ -286,7 +286,11 @@ fn parse_trait_item_fn(
286286
ident_to_php_name(&fn_item.sig.ident),
287287
change_case.unwrap_or(RenameRule::Camel),
288288
);
289-
validate_php_name(&method_name, PhpNameContext::Method);
289+
validate_php_name(
290+
&method_name,
291+
PhpNameContext::Method,
292+
fn_item.sig.ident.span(),
293+
)?;
290294
let f = Function::new(&fn_item.sig, method_name, args, php_attr.optional, docs);
291295

292296
if php_attr.constructor.is_present() {
@@ -340,7 +344,7 @@ fn parse_trait_item_const(
340344
ident_to_php_name(&const_item.ident),
341345
change_case.unwrap_or(RenameRule::ScreamingSnake),
342346
);
343-
validate_php_name(&name, PhpNameContext::Constant);
347+
validate_php_name(&name, PhpNameContext::Constant, const_item.ident.span())?;
344348
let docs = get_docs(&attr.attrs)?;
345349
const_item.attrs.clean_php();
346350

crates/macros/src/parsing.rs

Lines changed: 84 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,12 @@ const PHP_RESERVED_KEYWORDS: &[&str] = &[
117117

118118
/// Type keywords that are reserved for class/interface/enum names but CAN be
119119
/// used as method, function, constant, or property names in PHP.
120+
///
121+
/// Note: `resource` and `numeric` are NOT in this list because PHP allows them
122+
/// as class names. See: <https://github.com/php/php-src/blob/master/Zend/zend_compile.c>
120123
const PHP_TYPE_KEYWORDS: &[&str] = &[
121-
"bool", "false", "float", "int", "iterable", "mixed", "never", "null", "numeric", "object",
122-
"resource", "string", "true", "void",
124+
"bool", "false", "float", "int", "iterable", "mixed", "never", "null", "object", "string",
125+
"true", "void",
123126
];
124127

125128
/// The context in which a PHP name is being used.
@@ -131,6 +134,8 @@ pub enum PhpNameContext {
131134
Interface,
132135
/// An enum name (e.g., `enum Foo {}`)
133136
Enum,
137+
/// An enum case name (e.g., `case Foo;`)
138+
EnumCase,
134139
/// A function name (e.g., `function foo() {}`)
135140
Function,
136141
/// A method name (e.g., `public function foo() {}`)
@@ -147,6 +152,7 @@ impl PhpNameContext {
147152
Self::Class => "class",
148153
Self::Interface => "interface",
149154
Self::Enum => "enum",
155+
Self::EnumCase => "enum case",
150156
Self::Function => "function",
151157
Self::Method => "method",
152158
Self::Constant => "constant",
@@ -177,36 +183,47 @@ pub fn is_php_reserved_keyword(name: &str) -> bool {
177183
/// Validates that a PHP name is not a reserved keyword.
178184
///
179185
/// The validation is context-aware:
180-
/// - For class, interface, and enum names: both reserved keywords AND type keywords are checked
186+
/// - For class, interface, enum, and enum case names: both reserved keywords AND type keywords are checked
181187
/// - For method, function, constant, and property names: only reserved keywords are checked
182188
/// (type keywords like `void`, `bool`, etc. are allowed)
183189
///
184-
/// # Panics
190+
/// # Errors
185191
///
186-
/// Panics with a descriptive error message if the name is a reserved keyword in the given context.
187-
pub fn validate_php_name(name: &str, context: PhpNameContext) {
192+
/// Returns a `syn::Error` if the name is a reserved keyword in the given context.
193+
pub fn validate_php_name(
194+
name: &str,
195+
context: PhpNameContext,
196+
span: proc_macro2::Span,
197+
) -> Result<(), syn::Error> {
188198
let is_reserved = is_php_reserved_keyword(name);
189199
let is_type = is_php_type_keyword(name);
190200

191-
// Type keywords are only forbidden for class/interface/enum names
201+
// Type keywords are forbidden for class/interface/enum/enum case names
192202
let is_forbidden = match context {
193-
PhpNameContext::Class | PhpNameContext::Interface | PhpNameContext::Enum => {
194-
is_reserved || is_type
195-
}
203+
PhpNameContext::Class
204+
| PhpNameContext::Interface
205+
| PhpNameContext::Enum
206+
| PhpNameContext::EnumCase => is_reserved || is_type,
196207
PhpNameContext::Function
197208
| PhpNameContext::Method
198209
| PhpNameContext::Constant
199210
| PhpNameContext::Property => is_reserved,
200211
};
201212

202-
assert!(
203-
!is_forbidden,
204-
"Cannot use '{}' as a PHP {} name: '{}' is a reserved keyword in PHP. \
205-
Consider using a different name or the #[php(name = \"...\")] attribute to specify an alternative PHP name.",
206-
name,
207-
context.description(),
208-
name
209-
);
213+
if is_forbidden {
214+
return Err(syn::Error::new(
215+
span,
216+
format!(
217+
"cannot use '{}' as a PHP {} name: '{}' is a reserved keyword in PHP. \
218+
Consider using a different name or the #[php(name = \"...\")] attribute to specify an alternative PHP name.",
219+
name,
220+
context.description(),
221+
name
222+
),
223+
));
224+
}
225+
226+
Ok(())
210227
}
211228

212229
const MAGIC_METHOD: [&str; 17] = [
@@ -588,41 +605,80 @@ mod tests {
588605
}
589606

590607
#[test]
591-
#[should_panic(expected = "is a reserved keyword in PHP")]
592608
fn test_validate_php_name_rejects_reserved_keyword() {
593609
use super::{PhpNameContext, validate_php_name};
594-
validate_php_name("class", PhpNameContext::Class);
610+
use proc_macro2::Span;
611+
612+
let result = validate_php_name("class", PhpNameContext::Class, Span::call_site());
613+
assert!(result.is_err());
614+
let err = result.unwrap_err();
615+
assert!(err.to_string().contains("is a reserved keyword in PHP"));
595616
}
596617

597618
#[test]
598-
#[should_panic(expected = "is a reserved keyword in PHP")]
599619
fn test_validate_php_name_rejects_type_keyword_for_class() {
600620
use super::{PhpNameContext, validate_php_name};
621+
use proc_macro2::Span;
622+
601623
// Type keywords like 'void' cannot be used as class names
602-
validate_php_name("void", PhpNameContext::Class);
624+
let result = validate_php_name("void", PhpNameContext::Class, Span::call_site());
625+
assert!(result.is_err());
626+
let err = result.unwrap_err();
627+
assert!(err.to_string().contains("is a reserved keyword in PHP"));
628+
}
629+
630+
#[test]
631+
fn test_validate_php_name_rejects_type_keyword_for_enum_case() {
632+
use super::{PhpNameContext, validate_php_name};
633+
use proc_macro2::Span;
634+
635+
// Type keywords like 'true' cannot be used as enum case names
636+
let result = validate_php_name("true", PhpNameContext::EnumCase, Span::call_site());
637+
assert!(result.is_err());
638+
let err = result.unwrap_err();
639+
assert!(err.to_string().contains("is a reserved keyword in PHP"));
640+
641+
let result = validate_php_name("false", PhpNameContext::EnumCase, Span::call_site());
642+
assert!(result.is_err());
603643
}
604644

605645
#[test]
606646
fn test_validate_php_name_allows_type_keyword_for_method() {
607647
use super::{PhpNameContext, validate_php_name};
648+
use proc_macro2::Span;
649+
608650
// Type keywords like 'void' CAN be used as method names in PHP
609-
validate_php_name("void", PhpNameContext::Method);
610-
validate_php_name("true", PhpNameContext::Method);
611-
validate_php_name("bool", PhpNameContext::Method);
612-
validate_php_name("int", PhpNameContext::Method);
651+
validate_php_name("void", PhpNameContext::Method, Span::call_site()).unwrap();
652+
validate_php_name("true", PhpNameContext::Method, Span::call_site()).unwrap();
653+
validate_php_name("bool", PhpNameContext::Method, Span::call_site()).unwrap();
654+
validate_php_name("int", PhpNameContext::Method, Span::call_site()).unwrap();
613655
}
614656

615657
#[test]
616658
fn test_validate_php_name_allows_type_keyword_for_function() {
617659
use super::{PhpNameContext, validate_php_name};
660+
use proc_macro2::Span;
661+
618662
// Type keywords CAN be used as function names in PHP
619-
validate_php_name("void", PhpNameContext::Function);
663+
validate_php_name("void", PhpNameContext::Function, Span::call_site()).unwrap();
620664
}
621665

622666
#[test]
623667
fn test_validate_php_name_allows_type_keyword_for_constant() {
624668
use super::{PhpNameContext, validate_php_name};
669+
use proc_macro2::Span;
670+
625671
// Type keywords CAN be used as constant names in PHP
626-
validate_php_name("void", PhpNameContext::Constant);
672+
validate_php_name("void", PhpNameContext::Constant, Span::call_site()).unwrap();
673+
}
674+
675+
#[test]
676+
fn test_validate_php_name_allows_resource_and_numeric_for_class() {
677+
use super::{PhpNameContext, validate_php_name};
678+
use proc_macro2::Span;
679+
680+
// 'resource' and 'numeric' are NOT reserved for class names in PHP
681+
validate_php_name("resource", PhpNameContext::Class, Span::call_site()).unwrap();
682+
validate_php_name("numeric", PhpNameContext::Class, Span::call_site()).unwrap();
627683
}
628684
}

0 commit comments

Comments
 (0)