Skip to content

Commit 8a3f06d

Browse files
committed
feat(class): readonly and final classes
1 parent 927568f commit 8a3f06d

File tree

17 files changed

+504
-18
lines changed

17 files changed

+504
-18
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ description = "Bindings for the Zend API to build PHP extensions natively in Rus
44
repository = "https://github.com/extphprs/ext-php-rs"
55
homepage = "https://ext-php.rs"
66
license = "MIT OR Apache-2.0"
7+
links = "ext-php-rs"
78
keywords = ["php", "ffi", "zend"]
89
version = "0.15.2"
910
authors = [

allowed_bindings.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ bind! {
224224
ZEND_ACC_PROPERTY_TYPES_RESOLVED,
225225
ZEND_ACC_PROTECTED,
226226
ZEND_ACC_PUBLIC,
227+
ZEND_ACC_READONLY_CLASS,
227228
ZEND_ACC_RESOLVED_INTERFACES,
228229
ZEND_ACC_RESOLVED_PARENT,
229230
ZEND_ACC_RETURN_REFERENCE,

build.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,10 @@ fn check_php_version(info: &PHPInfo) -> Result<()> {
406406

407407
for supported_version in version.supported_apis() {
408408
println!("cargo:rustc-cfg={}", supported_version.cfg_name());
409+
// Export metadata for dependent crates (available as DEP_EXT_PHP_RS_<KEY>)
410+
// Note: Using cargo:KEY=VALUE format for metadata (not cargo::metadata=)
411+
// because cargo::metadata= causes duplicate artifacts in workspace builds
412+
println!("cargo:{}=1", supported_version.cfg_name().to_uppercase());
409413
}
410414

411415
Ok(())
@@ -436,11 +440,10 @@ fn main() -> Result<()> {
436440
if env::var("DOCS_RS").is_ok() {
437441
println!("cargo:warning=docs.rs detected - using stub bindings");
438442
println!("cargo:rustc-cfg=php_debug");
439-
println!("cargo:rustc-cfg=php81");
440-
println!("cargo:rustc-cfg=php82");
441-
println!("cargo:rustc-cfg=php83");
442-
println!("cargo:rustc-cfg=php84");
443-
println!("cargo:rustc-cfg=php85");
443+
for version in ["php81", "php82", "php83", "php84", "php85"] {
444+
println!("cargo:rustc-cfg={version}");
445+
println!("cargo:{}=1", version.to_uppercase());
446+
}
444447
std::fs::copy("docsrs_bindings.rs", out_path)
445448
.expect("failed to copy docs.rs stub bindings to out directory");
446449
return Ok(());
@@ -469,9 +472,11 @@ fn main() -> Result<()> {
469472

470473
if info.debug()? {
471474
println!("cargo:rustc-cfg=php_debug");
475+
println!("cargo:PHP_DEBUG=1");
472476
}
473477
if info.thread_safety()? {
474478
println!("cargo:rustc-cfg=php_zts");
479+
println!("cargo:PHP_ZTS=1");
475480
}
476481
provider.print_extra_link_args()?;
477482

crates/macros/src/class.rs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ pub struct StructAttributes {
2020
modifier: Option<syn::Ident>,
2121
/// An expression of `ClassFlags` to be applied to the class.
2222
flags: Option<syn::Expr>,
23+
/// Whether the class is readonly (PHP 8.2+).
24+
/// Readonly classes have all properties implicitly readonly.
25+
#[darling(rename = "readonly")]
26+
readonly: Flag,
2327
extends: Option<ClassEntryAttribute>,
2428
#[darling(multiple)]
2529
implements: Vec<ClassEntryAttribute>,
@@ -64,6 +68,7 @@ pub fn parser(mut input: ItemStruct) -> Result<TokenStream> {
6468
&attr.implements,
6569
&fields,
6670
attr.flags.as_ref(),
71+
attr.readonly.is_present(),
6772
&docs,
6873
);
6974

@@ -141,6 +146,7 @@ fn generate_registered_class_impl(
141146
implements: &[ClassEntryAttribute],
142147
fields: &[Property],
143148
flags: Option<&syn::Expr>,
149+
readonly: bool,
144150
docs: &[String],
145151
) -> TokenStream {
146152
let modifier = modifier.option_tokens();
@@ -198,9 +204,46 @@ fn generate_registered_class_impl(
198204
}
199205
});
200206

201-
let flags = match flags {
202-
Some(flags) => flags.to_token_stream(),
203-
None => quote! { ::ext_php_rs::flags::ClassFlags::empty() }.to_token_stream(),
207+
// Generate flags expression, combining user-provided flags with readonly if
208+
// specified. Note: ReadonlyClass is only available on PHP 8.2+, so we emit
209+
// a compile error if readonly is used on earlier PHP versions.
210+
// The compile_error! is placed as a statement so the block still has a valid
211+
// ClassFlags return type for type checking (even though compilation fails).
212+
let flags = match (flags, readonly) {
213+
(Some(flags), true) => {
214+
// User provided flags + readonly
215+
quote! {
216+
{
217+
#[cfg(not(php82))]
218+
compile_error!("The `readonly` class attribute requires PHP 8.2 or later");
219+
220+
#[cfg(php82)]
221+
{
222+
::ext_php_rs::flags::ClassFlags::from_bits_retain(
223+
(#flags).bits() | ::ext_php_rs::flags::ClassFlags::ReadonlyClass.bits()
224+
)
225+
}
226+
#[cfg(not(php82))]
227+
{ #flags }
228+
}
229+
}
230+
}
231+
(Some(flags), false) => flags.to_token_stream(),
232+
(None, true) => {
233+
// Only readonly flag
234+
quote! {
235+
{
236+
#[cfg(not(php82))]
237+
compile_error!("The `readonly` class attribute requires PHP 8.2 or later");
238+
239+
#[cfg(php82)]
240+
{ ::ext_php_rs::flags::ClassFlags::ReadonlyClass }
241+
#[cfg(not(php82))]
242+
{ ::ext_php_rs::flags::ClassFlags::empty() }
243+
}
244+
}
245+
}
246+
(None, false) => quote! { ::ext_php_rs::flags::ClassFlags::empty() },
204247
};
205248

206249
let docs = quote! {

crates/macros/src/function.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -999,7 +999,8 @@ fn expr_to_php_stub(expr: &Expr) -> String {
999999
}
10001000
}
10011001

1002-
/// Returns true if the given type is nullable in PHP (i.e., it's an `Option<T>`).
1002+
/// Returns true if the given type is nullable in PHP (i.e., it's an
1003+
/// `Option<T>`).
10031004
///
10041005
/// Note: Having a default value does NOT make a type nullable. A parameter with
10051006
/// a default value is optional (can be omitted), but passing `null` explicitly

crates/macros/src/lib.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ extern crate proc_macro;
3939
/// struct name is kept the same. If no name is given, the name of the struct
4040
/// is used. Useful for namespacing classes.
4141
/// - `change_case` - Changes the case of the class name when exported to PHP.
42+
/// - `readonly` - Marks the class as readonly (PHP 8.2+). All properties in a
43+
/// readonly class are implicitly readonly.
44+
/// - `flags` - Sets class flags using `ClassFlags`, e.g. `#[php(flags =
45+
/// ClassFlags::Final)]` for a final class.
4246
/// - `#[php(extends(ce = ce_fn, stub = "ParentClass"))]` - Sets the parent
4347
/// class of the class. Can only be used once. `ce_fn` must be a function with
4448
/// the signature `fn() -> &'static ClassEntry`.
@@ -274,6 +278,118 @@ extern crate proc_macro;
274278
/// echo Counter::$count; // 2
275279
/// echo Counter::getCount(); // 2
276280
/// ```
281+
///
282+
/// ## Readonly Classes (PHP 8.2+)
283+
///
284+
/// PHP 8.2 introduced [readonly classes](https://www.php.net/manual/en/language.oop5.basic.php#language.oop5.basic.class.readonly),
285+
/// where all properties are implicitly readonly. You can create a readonly
286+
/// class using the `#[php(readonly)]` attribute:
287+
///
288+
/// ```rust,ignore
289+
/// # #![cfg_attr(windows, feature(abi_vectorcall))]
290+
/// # extern crate ext_php_rs;
291+
/// use ext_php_rs::prelude::*;
292+
///
293+
/// #[php_class]
294+
/// #[php(readonly)]
295+
/// pub struct ImmutablePoint {
296+
/// x: f64,
297+
/// y: f64,
298+
/// }
299+
///
300+
/// #[php_impl]
301+
/// impl ImmutablePoint {
302+
/// pub fn __construct(x: f64, y: f64) -> Self {
303+
/// Self { x, y }
304+
/// }
305+
///
306+
/// pub fn get_x(&self) -> f64 {
307+
/// self.x
308+
/// }
309+
///
310+
/// pub fn get_y(&self) -> f64 {
311+
/// self.y
312+
/// }
313+
///
314+
/// pub fn distance_from_origin(&self) -> f64 {
315+
/// (self.x * self.x + self.y * self.y).sqrt()
316+
/// }
317+
/// }
318+
///
319+
/// #[php_module]
320+
/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
321+
/// module.class::<ImmutablePoint>()
322+
/// }
323+
/// # fn main() {}
324+
/// ```
325+
///
326+
/// From PHP:
327+
///
328+
/// ```php
329+
/// $point = new ImmutablePoint(3.0, 4.0);
330+
/// echo $point->getX(); // 3.0
331+
/// echo $point->getY(); // 4.0
332+
/// echo $point->distanceFromOrigin(); // 5.0
333+
///
334+
/// // On PHP 8.2+, you can verify the class is readonly:
335+
/// $reflection = new ReflectionClass(ImmutablePoint::class);
336+
/// var_dump($reflection->isReadOnly()); // true
337+
/// ```
338+
///
339+
/// The `readonly` attribute is compatible with other class attributes:
340+
///
341+
/// ```rust,ignore
342+
/// # #![cfg_attr(windows, feature(abi_vectorcall))]
343+
/// # extern crate ext_php_rs;
344+
/// use ext_php_rs::prelude::*;
345+
/// use ext_php_rs::flags::ClassFlags;
346+
///
347+
/// // Readonly + Final class
348+
/// #[php_class]
349+
/// #[php(readonly)]
350+
/// #[php(flags = ClassFlags::Final)]
351+
/// pub struct FinalImmutableData {
352+
/// value: String,
353+
/// }
354+
/// # fn main() {}
355+
/// ```
356+
///
357+
/// **Note:** The `readonly` attribute requires PHP 8.2 or later. Using it when
358+
/// compiling against an earlier PHP version will result in a compile error.
359+
///
360+
/// ### Conditional Compilation for Multi-Version Support
361+
///
362+
/// If your extension needs to support both PHP 8.1 and PHP 8.2+, you can use
363+
/// conditional compilation to only enable readonly on supported versions.
364+
///
365+
/// First, add `ext-php-rs` as a build dependency in your `Cargo.toml`:
366+
///
367+
/// ```toml
368+
/// [build-dependencies]
369+
/// ext-php-rs = "0.15"
370+
/// ```
371+
///
372+
/// Then create a `build.rs` that registers the PHP version cfg flags:
373+
///
374+
/// ```rust,ignore
375+
/// fn main() {
376+
/// ext_php_rs::build::register_php_cfg_flags();
377+
/// }
378+
/// ```
379+
///
380+
/// Now you can use `#[cfg(php82)]` to conditionally apply the readonly
381+
/// attribute:
382+
///
383+
/// ```rust,ignore
384+
/// #[php_class]
385+
/// #[cfg_attr(php82, php(readonly))]
386+
/// pub struct MaybeReadonlyClass {
387+
/// value: String,
388+
/// }
389+
/// ```
390+
///
391+
/// This is **optional** - if your extension only targets PHP 8.2+, you can use
392+
/// `#[php(readonly)]` directly without any build script setup.
277393
// END DOCS FROM classes.md
278394
#[proc_macro_attribute]
279395
pub fn php_class(args: TokenStream, input: TokenStream) -> TokenStream {

crates/macros/src/parsing.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,9 @@ impl PhpNameContext {
163163

164164
/// Checks if a name is a PHP type keyword (case-insensitive).
165165
///
166-
/// Type keywords like `void`, `bool`, `int`, etc. are reserved for type declarations
167-
/// but CAN be used as method, function, constant, or property names in PHP.
166+
/// Type keywords like `void`, `bool`, `int`, etc. are reserved for type
167+
/// declarations but CAN be used as method, function, constant, or property
168+
/// names in PHP.
168169
fn is_php_type_keyword(name: &str) -> bool {
169170
let lower = name.to_lowercase();
170171
PHP_TYPE_KEYWORDS
@@ -183,13 +184,15 @@ pub fn is_php_reserved_keyword(name: &str) -> bool {
183184
/// Validates that a PHP name is not a reserved keyword.
184185
///
185186
/// The validation is context-aware:
186-
/// - For class, interface, enum, and enum case names: both reserved keywords AND type keywords are checked
187-
/// - For method, function, constant, and property names: only reserved keywords are checked
188-
/// (type keywords like `void`, `bool`, etc. are allowed)
187+
/// - For class, interface, enum, and enum case names: both reserved keywords
188+
/// AND type keywords are checked
189+
/// - For method, function, constant, and property names: only reserved keywords
190+
/// are checked (type keywords like `void`, `bool`, etc. are allowed)
189191
///
190192
/// # Errors
191193
///
192-
/// Returns a `syn::Error` if the name is a reserved keyword in the given context.
194+
/// Returns a `syn::Error` if the name is a reserved keyword in the given
195+
/// context.
193196
pub fn validate_php_name(
194197
name: &str,
195198
context: PhpNameContext,

docsrs_bindings.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ pub const ZEND_ACC_USE_GUARDS: u32 = 1073741824;
217217
pub const ZEND_ACC_CONSTANTS_UPDATED: u32 = 4096;
218218
pub const ZEND_ACC_NO_DYNAMIC_PROPERTIES: u32 = 8192;
219219
pub const ZEND_HAS_STATIC_IN_METHODS: u32 = 16384;
220+
pub const ZEND_ACC_READONLY_CLASS: u32 = 65536;
220221
pub const ZEND_ACC_RESOLVED_PARENT: u32 = 131072;
221222
pub const ZEND_ACC_RESOLVED_INTERFACES: u32 = 262144;
222223
pub const ZEND_ACC_UNRESOLVED_VARIANCE: u32 = 524288;

0 commit comments

Comments
 (0)