Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to elephc, a PHP-to-native compiler written in Rust.
Releases are listed newest first.

## [Unreleased]
- Object-subtype declaration defaults are now validated after class and interface schemas are complete: parameters, methods, and constructor-promoted properties may use an implementing class or subclass instance as the default for an interface/base-class type, while unrelated object defaults are still rejected with a type error.
- `htmlspecialchars()` / `htmlentities()` now accept the optional `$flags` and `$encoding` arguments (issue #506), and the `ENT_*` constants (`ENT_QUOTES`, `ENT_COMPAT`, `ENT_NOQUOTES`, `ENT_HTML401`, `ENT_HTML5`, `ENT_XHTML`, `ENT_XML1`, `ENT_SUBSTITUTE`, `ENT_IGNORE`) are defined with PHP's values. The escaper currently always applies `ENT_QUOTES` behavior.
- Static interface methods (PHP 8.3+): an interface may declare `public static function` signatures; a concrete implementing class must provide a compatible public static method (abstract classes may defer to a concrete child), dispatched by class with no vtable slot. `#[\Override]` is accepted on the static implementation, including when the interface is implemented by an abstract parent class.
- Deprecated `${var}` / `${expr}` string interpolation is now accepted by the lexer (issue #340), matching PHP 8.x's deprecated-but-working behavior.
Expand Down
7 changes: 6 additions & 1 deletion src/types/checker/driver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ use super::builtin_stdclass::inject_builtin_stdclass;
use super::builtin_user_filter::inject_builtin_user_filter;
use super::schema::{
build_class_info_recursive, build_enum_info, build_interface_info_recursive,
drop_unresolvable_attribute_arg_refs,
drop_unresolvable_attribute_arg_refs, validate_deferred_object_defaults,
};
use super::yield_validation::validate_yield_contexts;
use super::Checker;
Expand Down Expand Up @@ -256,6 +256,11 @@ pub(super) fn check_types_impl(
}
}
}
errors.extend(validate_deferred_object_defaults(
&checker,
&flattened_classes,
program,
));
// All class/interface/enum metadata now exists, so deferred symbolic
// attribute-argument references can be checked for resolvability. Drop any
// the EIR backend cannot lower (e.g. built-in `Attribute::TARGET_CLASS`) so
Expand Down
6 changes: 3 additions & 3 deletions src/types/checker/schema/classes/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ fn apply_static_property(
}

let ty = if let Some(declared_ty) = declared_ty {
checker.validate_declared_default_type(
checker.validate_schema_declared_default_type(
&declared_ty,
prop.default.as_ref(),
prop.span,
Expand Down Expand Up @@ -248,7 +248,7 @@ fn apply_instance_property(
}

let ty = if let Some(declared_ty) = resolve_property_declared_type(checker, &class.name, prop)? {
checker.validate_declared_default_type(
checker.validate_schema_declared_default_type(
&declared_ty,
prop.default.as_ref(),
prop.span,
Expand Down Expand Up @@ -341,7 +341,7 @@ fn apply_instance_property_redeclaration(
)?;

let ty = if let Some(declared_ty) = declared_ty {
checker.validate_declared_default_type(
checker.validate_schema_declared_default_type(
&declared_ty,
prop.default.as_ref(),
prop.span,
Expand Down
191 changes: 191 additions & 0 deletions src/types/checker/schema/defaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//! Purpose:
//! Revalidates object-typed declaration defaults after class-like schemas are complete.
//! Covers class/interface methods and declared instance/static property defaults.
//!
//! Called from:
//! - `crate::types::checker::driver::check_types_impl()` after enum schema construction.
//!
//! Key details:
//! - The initial schema pass cannot reliably resolve inheritance or interface relationships.
//! - Only deferred Object-to-Object pairs are revisited; scalar checks stay in the initial pass.

use crate::errors::CompileError;
use crate::names::php_symbol_key;
use crate::parser::ast::{Expr, Program, StmtKind};
use crate::types::{traits::FlattenedClass, FunctionSig, PhpType};

use super::super::{infer_expr_type_syntactic, Checker};

/// Validates every object-typed default deferred during class-like schema construction.
/// Returns all incompatibilities so the driver can aggregate them with other schema errors.
pub(crate) fn validate_deferred_object_defaults(
checker: &Checker,
flattened_classes: &[FlattenedClass],
program: &Program,
) -> Vec<CompileError> {
let mut errors = Vec::new();

for class in flattened_classes {
validate_class_defaults(checker, &class.name, &mut errors);
}
for stmt in program {
if let StmtKind::EnumDecl { name, .. } = &stmt.kind {
validate_class_defaults(checker, name, &mut errors);
}
}

for stmt in program {
let StmtKind::InterfaceDecl { name, .. } = &stmt.kind else {
continue;
};
let Some(interface_info) = checker.interfaces.get(name) else {
continue;
};
let interface_key = php_symbol_key(name);
for method_key in &interface_info.method_order {
let is_declared_here = interface_info
.method_declaring_interfaces
.get(method_key)
.is_some_and(|declaring| php_symbol_key(declaring) == interface_key);
if !is_declared_here {
continue;
}
let Some(signature) = interface_info.methods.get(method_key) else {
continue;
};
validate_signature_object_defaults(checker, signature, "Method", &mut errors);
}
}

errors
}

/// Revalidates local method and property defaults for one source-declared class or enum.
fn validate_class_defaults(checker: &Checker, class_name: &str, errors: &mut Vec<CompileError>) {
let Some(class_info) = checker.classes.get(class_name) else {
return;
};
validate_class_property_defaults(checker, class_name, class_info, errors);

for method in &class_info.method_decls {
let method_key = php_symbol_key(&method.name);
let signature = if method.is_static {
class_info.static_methods.get(&method_key)
} else {
class_info.methods.get(&method_key)
};
let Some(signature) = signature else {
continue;
};
validate_signature_object_defaults(checker, signature, "Method", errors);
}
}

/// Revalidates local declared instance and static property defaults for one class.
fn validate_class_property_defaults(
checker: &Checker,
class_name: &str,
class_info: &crate::types::ClassInfo,
errors: &mut Vec<CompileError>,
) {
for (index, (property_name, expected_ty)) in class_info.properties.iter().enumerate() {
let is_local_declared_property = class_info.declared_properties.contains(property_name)
&& class_info
.property_declaring_classes
.get(property_name)
.is_some_and(|declaring| declaring == class_name);
if !is_local_declared_property {
continue;
}
let Some(default) = class_info.defaults.get(index).and_then(Option::as_ref) else {
continue;
};
validate_object_default(
checker,
expected_ty,
default,
&format!("Property {}::${} default", class_name, property_name),
errors,
);
}

for (index, (property_name, expected_ty)) in class_info.static_properties.iter().enumerate() {
let is_local_declared_property = class_info
.declared_static_properties
.contains(property_name)
&& class_info
.static_property_declaring_classes
.get(property_name)
.is_some_and(|declaring| declaring == class_name);
if !is_local_declared_property {
continue;
}
let Some(default) = class_info
.static_defaults
.get(index)
.and_then(Option::as_ref)
else {
continue;
};
validate_object_default(
checker,
expected_ty,
default,
&format!("Static property {}::${} default", class_name, property_name),
errors,
);
}
}

/// Revalidates object defaults for declared parameters in one resolved callable signature.
fn validate_signature_object_defaults(
checker: &Checker,
signature: &FunctionSig,
callable_kind: &str,
errors: &mut Vec<CompileError>,
) {
for (index, ((param_name, expected_ty), default)) in signature
.params
.iter()
.zip(signature.defaults.iter())
.enumerate()
{
if !signature
.declared_params
.get(index)
.copied()
.unwrap_or(false)
{
continue;
}
let Some(default) = default.as_ref() else {
continue;
};
validate_object_default(
checker,
expected_ty,
default,
&format!("{} parameter ${}", callable_kind, param_name),
errors,
);
}
}

/// Checks one deferred default when both its declared and syntactic types are objects.
fn validate_object_default(
checker: &Checker,
expected_ty: &PhpType,
default: &Expr,
context: &str,
errors: &mut Vec<CompileError>,
) {
let default_ty = infer_expr_type_syntactic(default);
if !matches!(expected_ty, PhpType::Object(_)) || !matches!(default_ty, PhpType::Object(_)) {
return;
}
if let Err(error) =
checker.require_compatible_arg_type(expected_ty, &default_ty, default.span, context)
{
errors.extend(error.flatten());
}
}
2 changes: 2 additions & 0 deletions src/types/checker/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@

pub(crate) mod validation;
mod attribute_refs;
mod defaults;
mod interfaces;
mod classes;
mod enums;

pub(crate) use attribute_refs::drop_unresolvable_attribute_arg_refs;
pub(crate) use defaults::validate_deferred_object_defaults;
pub(crate) use interfaces::*;
pub(crate) use classes::*;
pub(crate) use enums::*;
2 changes: 1 addition & 1 deletion src/types/checker/schema/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub(crate) fn build_method_sig(
method.params.iter().zip(params.iter())
{
if type_ann.is_some() {
checker.validate_declared_default_type(
checker.validate_schema_declared_default_type(
resolved_ty,
default.as_ref(),
method.span,
Expand Down
20 changes: 20 additions & 0 deletions src/types/checker/type_compat/declarations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,26 @@ impl Checker {
Ok(())
}

/// Validates a declaration default while class-like schema metadata is still being built.
/// Object-to-object checks are deferred because inheritance and interface relationships are
/// incomplete during this phase; every other type pair is validated immediately.
pub(crate) fn validate_schema_declared_default_type(
&self,
expected_ty: &PhpType,
default_expr: Option<&Expr>,
span: crate::span::Span,
context: &str,
) -> Result<(), CompileError> {
if let Some(default_expr) = default_expr {
let default_ty = infer_expr_type_syntactic(default_expr);
if matches!(expected_ty, PhpType::Object(_)) && matches!(default_ty, PhpType::Object(_))
{
return Ok(());
}
}
self.validate_declared_default_type(expected_ty, default_expr, span, context)
}

/// Builds the initial parameter type list for a function declaration, resolving type hints,
/// validating defaults, and inferring types for untyped parameters. Adds variadic parameter
/// type as `PhpType::Array(Int)` if the function is variadic.
Expand Down
28 changes: 28 additions & 0 deletions tests/codegen/objects/constructor_promotion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,31 @@ echo $box->value;
);
assert_eq!(out, "7:9");
}

/// Verifies that an interface-typed promoted property accepts a concrete implementing-class
/// default after schema construction, and that the default instance dispatches normally.
#[test]
fn test_interface_promoted_property_accepts_object_default() {
let out = compile_and_run(
r#"<?php
interface I {
public function v(): string;
}
final class N implements I {
public function v(): string { return "n"; }
}
final class C {
public function __construct(public I $x = new N()) {}
public function read(I $x = new N()): string { return $x->v(); }
}
function read_value(I $x = new N()): string { return $x->v(); }
$c = new C();
echo $c->x->v();
echo ":";
echo $c->read();
echo ":";
echo read_value();
"#,
);
assert_eq!(out, "n:n:n");
}
15 changes: 15 additions & 0 deletions tests/error_tests/type_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,21 @@ $box = new Box("bad");
);
}

/// Verifies that an unrelated object default is rejected after class relationships are known.
#[test]
fn test_error_promoted_property_rejects_incompatible_object_default() {
expect_error(
r#"<?php
class Expected {}
class Unrelated {}
class Box {
public function __construct(public Expected $value = new Unrelated()) {}
}
"#,
"Method parameter $value expects Object(\"Expected\"), got Object(\"Unrelated\")",
);
}

/// Verifies that assigning an incompatible value to a static property is rejected.
/// Input: `class Box { public static int $count = 1; } Box::$count = "x";`
#[test]
Expand Down
Loading