diff --git a/crates/macros/src/class.rs b/crates/macros/src/class.rs index 4264d22c5..05224555a 100644 --- a/crates/macros/src/class.rs +++ b/crates/macros/src/class.rs @@ -5,7 +5,7 @@ use quote::{TokenStreamExt, quote}; use syn::{Attribute, Expr, Fields, ItemStruct}; use crate::helpers::get_docs; -use crate::parsing::{PhpRename, RenameRule}; +use crate::parsing::{PhpNameContext, PhpRename, RenameRule, ident_to_php_name, validate_php_name}; use crate::prelude::*; #[derive(FromAttributes, Debug, Default)] @@ -44,7 +44,10 @@ impl ToTokens for ClassEntryAttribute { pub fn parser(mut input: ItemStruct) -> Result { let attr = StructAttributes::from_attributes(&input.attrs)?; let ident = &input.ident; - let name = attr.rename.rename(ident.to_string(), RenameRule::Pascal); + let name = attr + .rename + .rename(ident_to_php_name(ident), RenameRule::Pascal); + validate_php_name(&name, PhpNameContext::Class); let docs = get_docs(&attr.attrs)?; input.attrs.retain(|attr| !attr.path().is_ident("php")); @@ -110,9 +113,12 @@ struct Property<'a> { impl Property<'_> { pub fn name(&self) -> String { - self.attr + let name = self + .attr .rename - .rename(self.ident.to_string(), RenameRule::Camel) + .rename(ident_to_php_name(self.ident), RenameRule::Camel); + validate_php_name(&name, PhpNameContext::Property); + name } } diff --git a/crates/macros/src/constant.rs b/crates/macros/src/constant.rs index bd7262f5e..637e59ad6 100644 --- a/crates/macros/src/constant.rs +++ b/crates/macros/src/constant.rs @@ -4,7 +4,7 @@ use quote::{format_ident, quote}; use syn::ItemConst; use crate::helpers::get_docs; -use crate::parsing::{PhpRename, RenameRule}; +use crate::parsing::{PhpNameContext, PhpRename, RenameRule, ident_to_php_name, validate_php_name}; use crate::prelude::*; const INTERNAL_CONST_DOC_PREFIX: &str = "_internal_const_docs_"; @@ -25,7 +25,8 @@ pub fn parser(mut item: ItemConst) -> Result { let name = attr .rename - .rename(item.ident.to_string(), RenameRule::ScreamingSnake); + .rename(ident_to_php_name(&item.ident), RenameRule::ScreamingSnake); + validate_php_name(&name, PhpNameContext::Constant); let name_ident = format_ident!("{INTERNAL_CONST_NAME_PREFIX}{}", item.ident); let docs = get_docs(&attr.attrs)?; diff --git a/crates/macros/src/enum_.rs b/crates/macros/src/enum_.rs index 910f05cf2..366067226 100644 --- a/crates/macros/src/enum_.rs +++ b/crates/macros/src/enum_.rs @@ -8,7 +8,9 @@ use syn::{Fields, Ident, ItemEnum, Lit}; use crate::{ helpers::get_docs, - parsing::{PhpRename, RenameRule, Visibility}, + parsing::{ + PhpNameContext, PhpRename, RenameRule, Visibility, ident_to_php_name, validate_php_name, + }, prelude::*, }; @@ -84,7 +86,7 @@ pub fn parser(mut input: ItemEnum) -> Result { cases.push(EnumCase { ident: variant.ident.clone(), name: variant_attr.rename.rename( - variant.ident.to_string(), + ident_to_php_name(&variant.ident), php_attr.rename_cases.unwrap_or(RenameRule::Pascal), ), attrs: variant_attr, @@ -137,7 +139,10 @@ impl<'a> Enum<'a> { flags: Option, discriminant_type: DiscriminantType, ) -> Self { - let name = attrs.rename.rename(ident.to_string(), RenameRule::Pascal); + let name = attrs + .rename + .rename(ident_to_php_name(ident), RenameRule::Pascal); + validate_php_name(&name, PhpNameContext::Enum); Self { ident, diff --git a/crates/macros/src/extern_.rs b/crates/macros/src/extern_.rs index 8718b2a71..b9d997322 100644 --- a/crates/macros/src/extern_.rs +++ b/crates/macros/src/extern_.rs @@ -5,6 +5,7 @@ use syn::{ spanned::Spanned as _, token::Unsafe, }; +use crate::parsing::ident_to_php_name; use crate::prelude::*; pub fn parser(input: ItemForeignMod) -> Result { @@ -27,7 +28,7 @@ fn parse_function(mut func: ForeignItemFn) -> Result { let Signature { ident, .. } = &sig; - let name = ident.to_string(); + let name = ident_to_php_name(ident); let params = sig .inputs .iter() diff --git a/crates/macros/src/function.rs b/crates/macros/src/function.rs index bcc4865cc..932e012ca 100644 --- a/crates/macros/src/function.rs +++ b/crates/macros/src/function.rs @@ -7,7 +7,9 @@ use syn::spanned::Spanned as _; use syn::{Expr, FnArg, GenericArgument, ItemFn, PatType, PathArguments, Type, TypePath}; use crate::helpers::get_docs; -use crate::parsing::{PhpRename, RenameRule, Visibility}; +use crate::parsing::{ + PhpNameContext, PhpRename, RenameRule, Visibility, ident_to_php_name, validate_php_name, +}; use crate::prelude::*; use crate::syn_ext::DropLifetimes; @@ -44,15 +46,11 @@ pub fn parser(mut input: ItemFn) -> Result { let docs = get_docs(&php_attr.attrs)?; - let func = Function::new( - &input.sig, - php_attr - .rename - .rename(input.sig.ident.to_string(), RenameRule::Snake), - args, - php_attr.optional, - docs, - ); + let func_name = php_attr + .rename + .rename(ident_to_php_name(&input.sig.ident), RenameRule::Snake); + validate_php_name(&func_name, PhpNameContext::Function); + let func = Function::new(&input.sig, func_name, args, php_attr.optional, docs); let function_impl = func.php_function_impl(); Ok(quote! { @@ -625,7 +623,7 @@ impl TypedArg<'_> { /// Returns a token stream containing the `Arg` definition to be passed to /// `ext-php-rs`. fn arg_builder(&self) -> TokenStream { - let name = self.name.to_string(); + let name = ident_to_php_name(self.name); let ty = self.clean_ty(); let null = if self.nullable { Some(quote! { .allow_null() }) diff --git a/crates/macros/src/impl_.rs b/crates/macros/src/impl_.rs index 1d9687149..c2e3385f0 100644 --- a/crates/macros/src/impl_.rs +++ b/crates/macros/src/impl_.rs @@ -8,7 +8,9 @@ use syn::{Expr, Ident, ItemImpl}; use crate::constant::PhpConstAttribute; use crate::function::{Args, CallType, Function, MethodReceiver}; use crate::helpers::get_docs; -use crate::parsing::{PhpRename, RenameRule, Visibility}; +use crate::parsing::{ + PhpNameContext, PhpRename, RenameRule, Visibility, ident_to_php_name, validate_php_name, +}; use crate::prelude::*; /// Method types. @@ -187,7 +189,8 @@ impl<'a> ParsedImpl<'a> { let attr = PhpConstAttribute::from_attributes(&c.attrs)?; let name = attr .rename - .rename(c.ident.to_string(), self.change_constant_case); + .rename(ident_to_php_name(&c.ident), self.change_constant_case); + validate_php_name(&name, PhpNameContext::Constant); let docs = get_docs(&attr.attrs)?; c.attrs.retain(|attr| !attr.path().is_ident("php")); @@ -199,9 +202,11 @@ impl<'a> ParsedImpl<'a> { } syn::ImplItem::Fn(method) => { let attr = PhpFunctionImplAttribute::from_attributes(&method.attrs)?; - let name = attr - .rename - .rename_method(method.sig.ident.to_string(), self.change_method_case); + let name = attr.rename.rename_method( + ident_to_php_name(&method.sig.ident), + self.change_method_case, + ); + validate_php_name(&name, PhpNameContext::Method); let docs = get_docs(&attr.attrs)?; method.attrs.retain(|attr| !attr.path().is_ident("php")); diff --git a/crates/macros/src/interface.rs b/crates/macros/src/interface.rs index 6e9a54a4f..1fd76392f 100644 --- a/crates/macros/src/interface.rs +++ b/crates/macros/src/interface.rs @@ -11,7 +11,9 @@ use quote::{ToTokens, format_ident, quote}; use syn::{Expr, Ident, ItemTrait, Path, TraitItem, TraitItemConst, TraitItemFn}; use crate::impl_::{FnBuilder, MethodModifier}; -use crate::parsing::{PhpRename, RenameRule, Visibility}; +use crate::parsing::{ + PhpNameContext, PhpRename, RenameRule, Visibility, ident_to_php_name, validate_php_name, +}; use crate::prelude::*; const INTERNAL_INTERFACE_NAME_PREFIX: &str = "PhpInterface"; @@ -196,7 +198,10 @@ impl<'a> Parse<'a, InterfaceData<'a>> for ItemTrait { fn parse(&'a mut self) -> Result> { let attrs = TraitAttributes::from_attributes(&self.attrs)?; let ident = &self.ident; - let name = attrs.rename.rename(ident.to_string(), RenameRule::Pascal); + let name = attrs + .rename + .rename(ident_to_php_name(ident), RenameRule::Pascal); + validate_php_name(&name, PhpNameContext::Interface); let docs = get_docs(&attrs.attrs)?; self.attrs.clean_php(); let interface_name = format_ident!("{INTERNAL_INTERFACE_NAME_PREFIX}{ident}"); @@ -277,16 +282,12 @@ fn parse_trait_item_fn( modifiers.insert(MethodModifier::Static); } - let f = Function::new( - &fn_item.sig, - php_attr.rename.rename( - fn_item.sig.ident.to_string(), - change_case.unwrap_or(RenameRule::Camel), - ), - args, - php_attr.optional, - docs, + let method_name = php_attr.rename.rename( + ident_to_php_name(&fn_item.sig.ident), + change_case.unwrap_or(RenameRule::Camel), ); + validate_php_name(&method_name, PhpNameContext::Method); + let f = Function::new(&fn_item.sig, method_name, args, php_attr.optional, docs); if php_attr.constructor.is_present() { Ok(MethodKind::Constructor(f)) @@ -336,9 +337,10 @@ fn parse_trait_item_const( let attr = PhpConstAttribute::from_attributes(&const_item.attrs)?; let name = attr.rename.rename( - const_item.ident.to_string(), + ident_to_php_name(&const_item.ident), change_case.unwrap_or(RenameRule::ScreamingSnake), ); + validate_php_name(&name, PhpNameContext::Constant); let docs = get_docs(&attr.attrs)?; const_item.attrs.clean_php(); diff --git a/crates/macros/src/parsing.rs b/crates/macros/src/parsing.rs index 849654a8f..e4839a6f2 100644 --- a/crates/macros/src/parsing.rs +++ b/crates/macros/src/parsing.rs @@ -1,6 +1,213 @@ use convert_case::{Case, Casing}; use darling::FromMeta; use quote::{ToTokens, quote}; +use syn::Ident; + +/// Converts a Rust identifier to its PHP-compatible name. +/// +/// This function strips the `r#` prefix from raw identifiers, since that prefix +/// is Rust-specific syntax for using reserved keywords as identifiers. +/// +/// # Examples +/// +/// ```ignore +/// use syn::parse_quote; +/// let ident: Ident = parse_quote!(r#as); +/// assert_eq!(ident_to_php_name(&ident), "as"); +/// +/// let ident: Ident = parse_quote!(normal_name); +/// assert_eq!(ident_to_php_name(&ident), "normal_name"); +/// ``` +pub fn ident_to_php_name(ident: &Ident) -> String { + let name = ident.to_string(); + name.strip_prefix("r#").unwrap_or(&name).to_string() +} + +/// PHP reserved keywords that cannot be used as class, interface, trait, enum, +/// or function names. +/// +/// See: +const PHP_RESERVED_KEYWORDS: &[&str] = &[ + // Keywords + "__halt_compiler", + "abstract", + "and", + "array", + "as", + "break", + "callable", + "case", + "catch", + "class", + "clone", + "const", + "continue", + "declare", + "default", + "die", + "do", + "echo", + "else", + "elseif", + "empty", + "enum", + "enddeclare", + "endfor", + "endforeach", + "endif", + "endswitch", + "endwhile", + "eval", + "exit", + "extends", + "final", + "finally", + "fn", + "for", + "foreach", + "function", + "global", + "goto", + "if", + "implements", + "include", + "include_once", + "instanceof", + "insteadof", + "interface", + "isset", + "list", + "match", + "namespace", + "new", + "or", + "print", + "private", + "protected", + "public", + "readonly", + "require", + "require_once", + "return", + "static", + "switch", + "throw", + "trait", + "try", + "unset", + "use", + "var", + "while", + "xor", + "yield", + "yield from", + // Compile-time constants + "__CLASS__", + "__DIR__", + "__FILE__", + "__FUNCTION__", + "__LINE__", + "__METHOD__", + "__NAMESPACE__", + "__TRAIT__", + // Reserved classes (case-insensitive check needed) + "self", + "parent", +]; + +/// Type keywords that are reserved for class/interface/enum names but CAN be +/// used as method, function, constant, or property names in PHP. +const PHP_TYPE_KEYWORDS: &[&str] = &[ + "bool", "false", "float", "int", "iterable", "mixed", "never", "null", "numeric", "object", + "resource", "string", "true", "void", +]; + +/// The context in which a PHP name is being used. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhpNameContext { + /// A class name (e.g., `class Foo {}`) + Class, + /// An interface name (e.g., `interface Foo {}`) + Interface, + /// An enum name (e.g., `enum Foo {}`) + Enum, + /// A function name (e.g., `function foo() {}`) + Function, + /// A method name (e.g., `public function foo() {}`) + Method, + /// A constant name (e.g., `const FOO = 1;`) + Constant, + /// A property name (e.g., `public $foo;`) + Property, +} + +impl PhpNameContext { + fn description(self) -> &'static str { + match self { + Self::Class => "class", + Self::Interface => "interface", + Self::Enum => "enum", + Self::Function => "function", + Self::Method => "method", + Self::Constant => "constant", + Self::Property => "property", + } + } +} + +/// Checks if a name is a PHP type keyword (case-insensitive). +/// +/// Type keywords like `void`, `bool`, `int`, etc. are reserved for type declarations +/// but CAN be used as method, function, constant, or property names in PHP. +fn is_php_type_keyword(name: &str) -> bool { + let lower = name.to_lowercase(); + PHP_TYPE_KEYWORDS + .iter() + .any(|&kw| kw.to_lowercase() == lower) +} + +/// Checks if a name is a PHP reserved keyword (case-insensitive). +pub fn is_php_reserved_keyword(name: &str) -> bool { + let lower = name.to_lowercase(); + PHP_RESERVED_KEYWORDS + .iter() + .any(|&kw| kw.to_lowercase() == lower) +} + +/// Validates that a PHP name is not a reserved keyword. +/// +/// The validation is context-aware: +/// - For class, interface, and enum names: both reserved keywords AND type keywords are checked +/// - For method, function, constant, and property names: only reserved keywords are checked +/// (type keywords like `void`, `bool`, etc. are allowed) +/// +/// # Panics +/// +/// Panics with a descriptive error message if the name is a reserved keyword in the given context. +pub fn validate_php_name(name: &str, context: PhpNameContext) { + let is_reserved = is_php_reserved_keyword(name); + let is_type = is_php_type_keyword(name); + + // Type keywords are only forbidden for class/interface/enum names + let is_forbidden = match context { + PhpNameContext::Class | PhpNameContext::Interface | PhpNameContext::Enum => { + is_reserved || is_type + } + PhpNameContext::Function + | PhpNameContext::Method + | PhpNameContext::Constant + | PhpNameContext::Property => is_reserved, + }; + + assert!( + !is_forbidden, + "Cannot use '{}' as a PHP {} name: '{}' is a reserved keyword in PHP. \ + Consider using a different name or the #[php(name = \"...\")] attribute to specify an alternative PHP name.", + name, + context.description(), + name + ); +} const MAGIC_METHOD: [&str; 17] = [ "__construct", @@ -333,4 +540,89 @@ mod tests { assert_eq!(snake, original.rename(RenameRule::Snake)); assert_eq!(screaming_snake, original.rename(RenameRule::ScreamingSnake)); } + + #[test] + fn ident_to_php_name_strips_raw_prefix() { + use super::ident_to_php_name; + use syn::parse_quote; + + // Raw identifier should have r# prefix stripped + let raw_ident: syn::Ident = parse_quote!(r#as); + assert_eq!(ident_to_php_name(&raw_ident), "as"); + + let raw_ident: syn::Ident = parse_quote!(r#match); + assert_eq!(ident_to_php_name(&raw_ident), "match"); + + let raw_ident: syn::Ident = parse_quote!(r#type); + assert_eq!(ident_to_php_name(&raw_ident), "type"); + + // Normal identifiers should be unchanged + let normal_ident: syn::Ident = parse_quote!(normal_name); + assert_eq!(ident_to_php_name(&normal_ident), "normal_name"); + + let normal_ident: syn::Ident = parse_quote!(foo); + assert_eq!(ident_to_php_name(&normal_ident), "foo"); + } + + #[test] + fn test_is_php_reserved_keyword() { + use super::is_php_reserved_keyword; + + // Hard keywords should be detected + assert!(is_php_reserved_keyword("class")); + assert!(is_php_reserved_keyword("function")); + assert!(is_php_reserved_keyword("match")); + + // Case-insensitive + assert!(is_php_reserved_keyword("CLASS")); + assert!(is_php_reserved_keyword("FUNCTION")); + + // Type keywords are NOT in the reserved list (they're in PHP_TYPE_KEYWORDS) + assert!(!is_php_reserved_keyword("void")); + assert!(!is_php_reserved_keyword("true")); + assert!(!is_php_reserved_keyword("bool")); + + // Non-keywords should pass + assert!(!is_php_reserved_keyword("MyClass")); + assert!(!is_php_reserved_keyword("foo")); + } + + #[test] + #[should_panic(expected = "is a reserved keyword in PHP")] + fn test_validate_php_name_rejects_reserved_keyword() { + use super::{PhpNameContext, validate_php_name}; + validate_php_name("class", PhpNameContext::Class); + } + + #[test] + #[should_panic(expected = "is a reserved keyword in PHP")] + fn test_validate_php_name_rejects_type_keyword_for_class() { + use super::{PhpNameContext, validate_php_name}; + // Type keywords like 'void' cannot be used as class names + validate_php_name("void", PhpNameContext::Class); + } + + #[test] + fn test_validate_php_name_allows_type_keyword_for_method() { + use super::{PhpNameContext, validate_php_name}; + // Type keywords like 'void' CAN be used as method names in PHP + validate_php_name("void", PhpNameContext::Method); + validate_php_name("true", PhpNameContext::Method); + validate_php_name("bool", PhpNameContext::Method); + validate_php_name("int", PhpNameContext::Method); + } + + #[test] + fn test_validate_php_name_allows_type_keyword_for_function() { + use super::{PhpNameContext, validate_php_name}; + // Type keywords CAN be used as function names in PHP + validate_php_name("void", PhpNameContext::Function); + } + + #[test] + fn test_validate_php_name_allows_type_keyword_for_constant() { + use super::{PhpNameContext, validate_php_name}; + // Type keywords CAN be used as constant names in PHP + validate_php_name("void", PhpNameContext::Constant); + } } diff --git a/crates/macros/src/zval.rs b/crates/macros/src/zval.rs index ddfaf7800..46c34b0d2 100644 --- a/crates/macros/src/zval.rs +++ b/crates/macros/src/zval.rs @@ -6,6 +6,7 @@ use syn::{ LifetimeParam, TypeGenerics, Variant, WhereClause, punctuated::Punctuated, token::Where, }; +use crate::parsing::ident_to_php_name; use crate::prelude::*; pub fn parser(input: DeriveInput) -> Result { @@ -106,7 +107,7 @@ fn parse_struct( let ident = field.ident.as_ref().ok_or_else(|| { err!(field => "Fields require names when using the `#[derive(ZvalConvert)]` macro on a struct.") })?; - let field_name = ident.to_string(); + let field_name = ident_to_php_name(ident); Ok(quote! { obj.set_property(#field_name, self.#ident)?; @@ -121,7 +122,7 @@ fn parse_struct( let ident = field.ident.as_ref().ok_or_else(|| { err!(field => "Fields require names when using the `#[derive(ZvalConvert)]` macro on a struct.") })?; - let field_name = ident.to_string(); + let field_name = ident_to_php_name(ident); Ok(quote! { #ident: obj.get_property(#field_name)?, diff --git a/tests/src/integration/class/class.php b/tests/src/integration/class/class.php index d6c6ee6c1..7e5128b8a 100644 --- a/tests/src/integration/class/class.php +++ b/tests/src/integration/class/class.php @@ -46,8 +46,8 @@ $classReflection = new ReflectionClass(TestClassMethodVisibility::class); assert($classReflection->getMethod('__construct')->isPrivate()); -assert($classReflection->getMethod('private')->isPrivate()); -assert($classReflection->getMethod('protected')->isProtected()); +assert($classReflection->getMethod('privateMethod')->isPrivate()); +assert($classReflection->getMethod('protectedMethod')->isProtected()); $classReflection = new ReflectionClass(TestClassProtectedConstruct::class); assert($classReflection->getMethod('__construct')->isProtected()); diff --git a/tests/src/integration/class/mod.rs b/tests/src/integration/class/mod.rs index 3c3a2bb0c..e0489a180 100644 --- a/tests/src/integration/class/mod.rs +++ b/tests/src/integration/class/mod.rs @@ -155,12 +155,12 @@ impl TestClassMethodVisibility { } #[php(vis = "private")] - fn private() -> u32 { + fn private_method() -> u32 { 3 } #[php(vis = "protected")] - fn protected() -> u32 { + fn protected_method() -> u32 { 3 } }