use convert_case::{Case, Casing};
use darling::FromMeta;
use darling::util::SpannedValue;
use quote::{ToTokens, quote};
use syn::Ident;
pub fn ident_to_php_name(ident: &Ident) -> String {
let name = ident.to_string();
name.strip_prefix("r#").unwrap_or(&name).to_string()
}
pub fn reject_php_attrs(attrs: &[syn::Attribute], context: &str) -> Result<(), syn::Error> {
match attrs.iter().find(|attr| attr.path().is_ident("php")) {
Some(attr) => {
Err(crate::err!(attr.meta => "`#[php(...)]` options are not supported on {context}."))
}
None => Ok(()),
}
}
const PHP_RESERVED_KEYWORDS: &[&str] = &[
"__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",
"__CLASS__",
"__DIR__",
"__FILE__",
"__FUNCTION__",
"__LINE__",
"__METHOD__",
"__NAMESPACE__",
"__TRAIT__",
"self",
"parent",
];
const PHP_METHOD_FORBIDDEN_KEYWORDS: &[&str] = &["__halt_compiler"];
const PHP_FUNCTION_ALLOWED_KEYWORDS: &[&str] = &["readonly"];
const PHP_TYPE_KEYWORDS: &[&str] = &[
"bool", "false", "float", "int", "iterable", "mixed", "never", "null", "object", "string",
"true", "void",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhpNameContext {
Class,
Interface,
Enum,
EnumCase,
Function,
Method,
Constant,
Property,
}
impl PhpNameContext {
fn description(self) -> &'static str {
match self {
Self::Class => "class",
Self::Interface => "interface",
Self::Enum => "enum",
Self::EnumCase => "enum case",
Self::Function => "function",
Self::Method => "method",
Self::Constant => "constant",
Self::Property => "property",
}
}
}
fn is_php_type_keyword(name: &str) -> bool {
let lower = name.to_lowercase();
PHP_TYPE_KEYWORDS
.iter()
.any(|&kw| kw.to_lowercase() == lower)
}
pub fn is_php_reserved_keyword(name: &str) -> bool {
let lower = name.to_lowercase();
PHP_RESERVED_KEYWORDS
.iter()
.any(|&kw| kw.to_lowercase() == lower)
}
fn is_php_method_forbidden_keyword(name: &str) -> bool {
let lower = name.to_lowercase();
PHP_METHOD_FORBIDDEN_KEYWORDS
.iter()
.any(|&kw| kw.to_lowercase() == lower)
}
fn is_php_function_allowed_keyword(name: &str) -> bool {
let lower = name.to_lowercase();
PHP_FUNCTION_ALLOWED_KEYWORDS
.iter()
.any(|&kw| kw.to_lowercase() == lower)
}
pub fn validate_php_name(
name: &str,
context: PhpNameContext,
span: proc_macro2::Span,
) -> Result<(), syn::Error> {
let is_reserved = is_php_reserved_keyword(name);
let is_type = is_php_type_keyword(name);
let is_method_forbidden = is_php_method_forbidden_keyword(name);
let is_function_allowed = is_php_function_allowed_keyword(name);
let is_forbidden = match context {
PhpNameContext::Class
| PhpNameContext::Interface
| PhpNameContext::Enum
| PhpNameContext::EnumCase => is_reserved || is_type,
PhpNameContext::Function => is_reserved && !is_function_allowed,
PhpNameContext::Method | PhpNameContext::Constant | PhpNameContext::Property => {
is_method_forbidden
}
};
if is_forbidden {
return Err(syn::Error::new(
span,
format!(
"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
),
));
}
Ok(())
}
#[derive(Debug)]
pub struct NameSet {
fold_ascii_case: bool,
names: Vec<String>,
}
impl NameSet {
pub fn case_insensitive() -> Self {
Self {
fold_ascii_case: true,
names: Vec::new(),
}
}
pub fn case_sensitive() -> Self {
Self {
fold_ascii_case: false,
names: Vec::new(),
}
}
pub fn insert(&mut self, name: &str) -> std::result::Result<(), String> {
let same = |earlier: &&String| {
if self.fold_ascii_case {
earlier.eq_ignore_ascii_case(name)
} else {
*earlier == name
}
};
if let Some(earlier) = self.names.iter().find(same) {
return Err(earlier.clone());
}
self.names.push(name.to_owned());
Ok(())
}
}
const MAGIC_METHOD: [&str; 17] = [
"__construct",
"__destruct",
"__call",
"__call_static",
"__get",
"__set",
"__isset",
"__unset",
"__sleep",
"__wakeup",
"__serialize",
"__unserialize",
"__to_string",
"__invoke",
"__set_state",
"__clone",
"__debug_info",
];
#[derive(Debug, Clone, Copy, PartialEq, FromMeta)]
pub enum Visibility {
#[darling(rename = "public")]
Public,
#[darling(rename = "private")]
Private,
#[darling(rename = "protected")]
Protected,
}
impl Visibility {
pub fn flag_tokens(self, flags_ty: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
let variant = match self {
Visibility::Public => quote! { Public },
Visibility::Protected => quote! { Protected },
Visibility::Private => quote! { Private },
};
quote! { #flags_ty::#variant }
}
}
impl ToTokens for Visibility {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
match self {
Visibility::Public => quote! { ::ext_php_rs::flags::MethodFlags::Public },
Visibility::Protected => quote! { ::ext_php_rs::flags::MethodFlags::Protected },
Visibility::Private => quote! { ::ext_php_rs::flags::MethodFlags::Private },
}
.to_tokens(tokens);
}
}
pub trait Rename {
fn rename(&self, rule: RenameRule) -> String;
}
pub trait MethodRename: Rename {
fn rename_method(&self, rule: RenameRule) -> String;
}
#[derive(FromMeta, Debug, Default)]
#[darling(default, and_then = Self::exclusive)]
pub struct PhpRename {
name: Option<String>,
change_case: Option<SpannedValue<RenameRule>>,
}
impl PhpRename {
fn exclusive(self) -> darling::Result<Self> {
match (&self.name, &self.change_case) {
(Some(_), Some(change_case)) => Err(darling::Error::custom(
"`name` and `change_case` cannot be used together; `name` already sets the PHP name",
)
.with_span(&change_case.span())),
_ => Ok(self),
}
}
fn rule(&self, default: RenameRule) -> RenameRule {
self.change_case.as_deref().copied().unwrap_or(default)
}
pub fn rename(&self, name: impl AsRef<str>, default: RenameRule) -> String {
if let Some(name) = self.name.as_ref() {
name.clone()
} else {
name.as_ref().rename(self.rule(default))
}
}
pub fn rename_method(&self, name: impl AsRef<str>, default: RenameRule) -> String {
if let Some(name) = self.name.as_ref() {
name.clone()
} else {
name.as_ref().rename_method(self.rule(default))
}
}
}
#[derive(Debug, Copy, Clone, FromMeta, Default)]
pub enum RenameRule {
#[darling(rename = "none")]
None,
#[darling(rename = "camelCase")]
#[default]
Camel,
#[darling(rename = "snake_case")]
Snake,
#[darling(rename = "PascalCase")]
Pascal,
#[darling(rename = "UPPER_CASE")]
ScreamingSnake,
}
impl RenameRule {
fn rename(self, value: impl AsRef<str>) -> String {
match self {
Self::None => value.as_ref().to_string(),
Self::Camel => value.as_ref().to_case(Case::Camel),
Self::Pascal => value.as_ref().to_case(Case::Pascal),
Self::Snake => value.as_ref().to_case(Case::Snake),
Self::ScreamingSnake => value.as_ref().to_case(Case::Constant),
}
}
}
impl<T> Rename for T
where
T: ToString,
{
fn rename(&self, rule: RenameRule) -> String {
rule.rename(self.to_string())
}
}
impl<T> MethodRename for T
where
T: ToString + Rename,
{
fn rename_method(&self, rule: RenameRule) -> String {
let original = self.to_string();
match rule {
RenameRule::None => original,
_ => {
if MAGIC_METHOD.contains(&original.as_str()) {
match original.as_str() {
"__to_string" => "__toString".to_string(),
"__debug_info" => "__debugInfo".to_string(),
"__call_static" => "__callStatic".to_string(),
_ => original,
}
} else {
self.rename(rule)
}
}
}
}
}
#[cfg(test)]
mod tests {
use crate::parsing::{MethodRename, Rename};
use darling::FromMeta;
use darling::util::SpannedValue;
use proc_macro2::Span;
use super::{PhpRename, RenameRule};
#[test]
fn name_set_folds_ascii_case_for_methods() {
let mut methods = super::NameSet::case_insensitive();
assert_eq!(methods.insert("fooBar"), Ok(()));
assert_eq!(methods.insert("other"), Ok(()));
assert_eq!(methods.insert("FOOBAR"), Err("fooBar".to_string()));
assert_eq!(methods.insert("fooBär"), Ok(()));
}
#[test]
fn name_set_compares_constants_exactly() {
let mut constants = super::NameSet::case_sensitive();
assert_eq!(constants.insert("FOO"), Ok(()));
assert_eq!(constants.insert("foo"), Ok(()));
assert_eq!(constants.insert("FOO"), Err("FOO".to_string()));
}
#[test]
fn php_rename() {
let rename = PhpRename {
name: Some("test".to_string()),
change_case: None,
};
assert_eq!(rename.rename("testCase", RenameRule::Snake), "test");
assert_eq!(rename.rename("TestCase", RenameRule::Snake), "test");
assert_eq!(rename.rename("TEST_CASE", RenameRule::Snake), "test");
let rename = PhpRename {
name: None,
change_case: Some(SpannedValue::new(
RenameRule::ScreamingSnake,
Span::call_site(),
)),
};
assert_eq!(rename.rename("testCase", RenameRule::Snake), "TEST_CASE");
assert_eq!(rename.rename("TestCase", RenameRule::Snake), "TEST_CASE");
assert_eq!(rename.rename("TEST_CASE", RenameRule::Snake), "TEST_CASE");
let rename = PhpRename {
name: None,
change_case: None,
};
assert_eq!(rename.rename("testCase", RenameRule::Snake), "test_case");
assert_eq!(rename.rename("TestCase", RenameRule::Snake), "test_case");
assert_eq!(rename.rename("TEST_CASE", RenameRule::Snake), "test_case");
}
#[test]
fn name_and_change_case_are_exclusive() {
let parse = |tokens| {
PhpRename::from_list(&darling::ast::NestedMeta::parse_meta_list(tokens).unwrap())
};
assert!(parse(quote::quote! { name = "x", change_case = "snake_case" }).is_err());
assert!(parse(quote::quote! { name = "x" }).is_ok());
assert!(parse(quote::quote! { change_case = "snake_case" }).is_ok());
}
#[test]
fn name_and_change_case_are_exclusive_across_attributes() {
#[derive(darling::FromAttributes)]
#[darling(attributes(php))]
struct Attr {
#[darling(flatten)]
_rename: PhpRename,
}
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[php(name = "x")]),
syn::parse_quote!(#[php(change_case = "snake_case")]),
];
assert!(<Attr as darling::FromAttributes>::from_attributes(&attrs).is_err());
assert!(<Attr as darling::FromAttributes>::from_attributes(&attrs[..1]).is_ok());
}
#[test]
fn php_rename_method() {
let rename = PhpRename {
name: Some("test".to_string()),
change_case: None,
};
assert_eq!(rename.rename_method("testCase", RenameRule::Snake), "test");
assert_eq!(rename.rename_method("TestCase", RenameRule::Snake), "test");
assert_eq!(rename.rename_method("TEST_CASE", RenameRule::Snake), "test");
let rename = PhpRename {
name: None,
change_case: Some(SpannedValue::new(
RenameRule::ScreamingSnake,
Span::call_site(),
)),
};
assert_eq!(
rename.rename_method("testCase", RenameRule::Snake),
"TEST_CASE"
);
assert_eq!(
rename.rename_method("TestCase", RenameRule::Snake),
"TEST_CASE"
);
assert_eq!(
rename.rename_method("TEST_CASE", RenameRule::Snake),
"TEST_CASE"
);
let rename = PhpRename {
name: None,
change_case: None,
};
assert_eq!(
rename.rename_method("testCase", RenameRule::Snake),
"test_case"
);
assert_eq!(
rename.rename_method("TestCase", RenameRule::Snake),
"test_case"
);
assert_eq!(
rename.rename_method("TEST_CASE", RenameRule::Snake),
"test_case"
);
}
#[test]
fn rename_magic_method() {
for &(magic, expected) in &[
("__construct", "__construct"),
("__destruct", "__destruct"),
("__call", "__call"),
("__call_static", "__callStatic"),
("__get", "__get"),
("__set", "__set"),
("__isset", "__isset"),
("__unset", "__unset"),
("__sleep", "__sleep"),
("__wakeup", "__wakeup"),
("__serialize", "__serialize"),
("__unserialize", "__unserialize"),
("__to_string", "__toString"),
("__invoke", "__invoke"),
("__set_state", "__set_state"),
("__clone", "__clone"),
("__debug_info", "__debugInfo"),
] {
assert_eq!(magic, magic.rename_method(RenameRule::None));
assert_eq!(
magic,
PhpRename {
name: None,
change_case: Some(SpannedValue::new(RenameRule::None, Span::call_site()))
}
.rename_method(magic, RenameRule::ScreamingSnake)
);
assert_eq!(expected, magic.rename_method(RenameRule::Camel));
assert_eq!(
expected,
PhpRename {
name: None,
change_case: Some(SpannedValue::new(RenameRule::Camel, Span::call_site()))
}
.rename_method(magic, RenameRule::ScreamingSnake)
);
assert_eq!(expected, magic.rename_method(RenameRule::Pascal));
assert_eq!(
expected,
PhpRename {
name: None,
change_case: Some(SpannedValue::new(RenameRule::Pascal, Span::call_site()))
}
.rename_method(magic, RenameRule::ScreamingSnake)
);
assert_eq!(expected, magic.rename_method(RenameRule::Snake));
assert_eq!(
expected,
PhpRename {
name: None,
change_case: Some(SpannedValue::new(RenameRule::Snake, Span::call_site()))
}
.rename_method(magic, RenameRule::ScreamingSnake)
);
assert_eq!(expected, magic.rename_method(RenameRule::ScreamingSnake));
assert_eq!(
expected,
PhpRename {
name: None,
change_case: Some(SpannedValue::new(
RenameRule::ScreamingSnake,
Span::call_site()
))
}
.rename_method(magic, RenameRule::Camel)
);
}
}
#[test]
fn rename_method() {
let &(original, camel, snake, pascal, screaming_snake) =
&("get_name", "getName", "get_name", "GetName", "GET_NAME");
assert_eq!(original, original.rename_method(RenameRule::None));
assert_eq!(camel, original.rename_method(RenameRule::Camel));
assert_eq!(pascal, original.rename_method(RenameRule::Pascal));
assert_eq!(snake, original.rename_method(RenameRule::Snake));
assert_eq!(
screaming_snake,
original.rename_method(RenameRule::ScreamingSnake)
);
}
#[test]
fn rename() {
let &(original, camel, snake, pascal, screaming_snake) =
&("get_name", "getName", "get_name", "GetName", "GET_NAME");
assert_eq!(original, original.rename(RenameRule::None));
assert_eq!(camel, original.rename(RenameRule::Camel));
assert_eq!(pascal, original.rename(RenameRule::Pascal));
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;
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");
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;
assert!(is_php_reserved_keyword("class"));
assert!(is_php_reserved_keyword("function"));
assert!(is_php_reserved_keyword("match"));
assert!(is_php_reserved_keyword("CLASS"));
assert!(is_php_reserved_keyword("FUNCTION"));
assert!(!is_php_reserved_keyword("void"));
assert!(!is_php_reserved_keyword("true"));
assert!(!is_php_reserved_keyword("bool"));
assert!(!is_php_reserved_keyword("MyClass"));
assert!(!is_php_reserved_keyword("foo"));
}
#[test]
fn test_validate_php_name_rejects_reserved_keyword() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
let result = validate_php_name("class", PhpNameContext::Class, Span::call_site());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("is a reserved keyword in PHP"));
}
#[test]
fn test_validate_php_name_rejects_type_keyword_for_class() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
let result = validate_php_name("void", PhpNameContext::Class, Span::call_site());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("is a reserved keyword in PHP"));
}
#[test]
fn test_validate_php_name_rejects_type_keyword_for_enum_case() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
let result = validate_php_name("true", PhpNameContext::EnumCase, Span::call_site());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("is a reserved keyword in PHP"));
let result = validate_php_name("false", PhpNameContext::EnumCase, Span::call_site());
assert!(result.is_err());
}
#[test]
fn test_validate_php_name_allows_type_keyword_for_method() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("void", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("true", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("bool", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("int", PhpNameContext::Method, Span::call_site()).unwrap();
}
#[test]
fn test_validate_php_name_allows_type_keyword_for_function() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("void", PhpNameContext::Function, Span::call_site()).unwrap();
}
#[test]
fn test_validate_php_name_allows_type_keyword_for_constant() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("void", PhpNameContext::Constant, Span::call_site()).unwrap();
}
#[test]
fn test_validate_php_name_allows_resource_and_numeric_for_class() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("resource", PhpNameContext::Class, Span::call_site()).unwrap();
validate_php_name("numeric", PhpNameContext::Class, Span::call_site()).unwrap();
}
#[test]
fn test_validate_php_name_allows_reserved_keywords_for_method() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("new", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("default", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("class", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("function", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("match", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("if", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("while", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("for", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("return", PhpNameContext::Method, Span::call_site()).unwrap();
validate_php_name("static", PhpNameContext::Method, Span::call_site()).unwrap();
}
#[test]
fn test_validate_php_name_rejects_halt_compiler_for_method() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
let result =
validate_php_name("__halt_compiler", PhpNameContext::Method, Span::call_site());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("is a reserved keyword in PHP"));
}
#[test]
fn test_validate_php_name_allows_reserved_keywords_for_constant() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("new", PhpNameContext::Constant, Span::call_site()).unwrap();
validate_php_name("default", PhpNameContext::Constant, Span::call_site()).unwrap();
validate_php_name("class", PhpNameContext::Constant, Span::call_site()).unwrap();
}
#[test]
fn test_validate_php_name_allows_reserved_keywords_for_property() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("new", PhpNameContext::Property, Span::call_site()).unwrap();
validate_php_name("default", PhpNameContext::Property, Span::call_site()).unwrap();
validate_php_name("class", PhpNameContext::Property, Span::call_site()).unwrap();
}
#[test]
fn test_validate_php_name_rejects_reserved_keywords_for_function() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
let result = validate_php_name("new", PhpNameContext::Function, Span::call_site());
assert!(result.is_err());
let result = validate_php_name("default", PhpNameContext::Function, Span::call_site());
assert!(result.is_err());
let result = validate_php_name("class", PhpNameContext::Function, Span::call_site());
assert!(result.is_err());
let result = validate_php_name("match", PhpNameContext::Function, Span::call_site());
assert!(result.is_err());
}
#[test]
fn test_validate_php_name_allows_readonly_for_function() {
use super::{PhpNameContext, validate_php_name};
use proc_macro2::Span;
validate_php_name("readonly", PhpNameContext::Function, Span::call_site()).unwrap();
}
}