diff --git a/src/codegen/lower_inst/objects.rs b/src/codegen/lower_inst/objects.rs index 1fc999ca5c..e627b515e9 100644 --- a/src/codegen/lower_inst/objects.rs +++ b/src/codegen/lower_inst/objects.rs @@ -783,7 +783,7 @@ fn lower_builtin_throwable_new( class_name: &str, class_id: u64, ) -> Result<()> { - if inst.operands.len() > 2 { + if inst.operands.len() > 3 { return Err(CodegenIrError::unsupported(format!( "{}::__construct with {} EIR operands", class_name, @@ -794,6 +794,9 @@ fn lower_builtin_throwable_new( preserve_throwable_for_init(ctx); emit_throwable_message_fields(ctx, inst.operands.first().copied())?; emit_throwable_code_field(ctx, inst.operands.get(1).copied())?; + // Operand 2 is PHP's `$previous`: its expression was evaluated (side effects preserved), + // but the compact throwable payload has no previous slot — `getPrevious()` is synthesized + // as null — so the value is intentionally not stored. restore_throwable_after_init(ctx); store_if_result(ctx, inst) } diff --git a/src/parser/stmt/mod.rs b/src/parser/stmt/mod.rs index 6befd9cda1..0b828b0633 100644 --- a/src/parser/stmt/mod.rs +++ b/src/parser/stmt/mod.rs @@ -502,6 +502,13 @@ pub(crate) fn parse_name( parts.push(name.clone()); *pos += 1; } + // `enum` is only a soft keyword: `Enum` is a legal class name and name segment + // (`new Enum`, `extends Enum`, `MabeEnum\Enum`). Statement-position `enum` + // dispatches to the enum-declaration parser before parse_name is consulted. + Some(Token::Enum) => { + parts.push("Enum".to_string()); + *pos += 1; + } _ if parts.is_empty() => return Err(CompileError::new(span, first_error)), _ => { return Err(CompileError::new( diff --git a/src/parser/stmt/namespace_use.rs b/src/parser/stmt/namespace_use.rs index e9ede8ebfa..1698530873 100644 --- a/src/parser/stmt/namespace_use.rs +++ b/src/parser/stmt/namespace_use.rs @@ -254,6 +254,35 @@ fn parse_group_use_items( /// the name kind is `FullyQualified`; otherwise it is `Unqualified` or `Qualified` /// depending on whether an intermediate `\ ` was seen. Stops when a trailing `\` /// followed by `{` is encountered (the opening brace is consumed by the caller). +/// Canonical import spelling for constant-like tokens the lexer eagerly tokenizes. +/// +/// `use const PHP_INT_MAX;` is legal PHP, but `PHP_INT_MAX` never reaches the parser as an +/// identifier — the lexer emits a dedicated token (expressions lower these to literals +/// directly, so the import itself is inert). Accepting them here keeps such use +/// declarations parseable. +fn token_as_import_name(token: &Token) -> Option { + let name = match token { + Token::PhpIntMax => "PHP_INT_MAX", + Token::PhpIntMin => "PHP_INT_MIN", + Token::PhpFloatMax => "PHP_FLOAT_MAX", + Token::PhpFloatMin => "PHP_FLOAT_MIN", + Token::PhpFloatEpsilon => "PHP_FLOAT_EPSILON", + Token::Inf => "INF", + Token::Nan => "NAN", + Token::MPi => "M_PI", + Token::ME => "M_E", + Token::MSqrt2 => "M_SQRT2", + Token::MPi2 => "M_PI_2", + Token::MPi4 => "M_PI_4", + Token::MLog2e => "M_LOG2E", + Token::MLog10e => "M_LOG10E", + Token::Stdin => "STDIN", + Token::Stdout => "STDOUT", + _ => return None, + }; + Some(name.to_string()) +} + fn parse_use_prefix( tokens: &[(Token, Span)], pos: &mut usize, @@ -272,6 +301,12 @@ fn parse_use_prefix( parts.push(name.clone()); *pos += 1; } + Some(token) if token_as_import_name(token).is_some() => { + if let Some(name) = token_as_import_name(token) { + parts.push(name); + } + *pos += 1; + } _ if parts.is_empty() => { return Err(CompileError::new( span, diff --git a/src/parser/stmt/oop/declarations.rs b/src/parser/stmt/oop/declarations.rs index bb1be87f66..539aa72663 100644 --- a/src/parser/stmt/oop/declarations.rs +++ b/src/parser/stmt/oop/declarations.rs @@ -39,6 +39,12 @@ pub(in crate::parser::stmt) fn parse_class_decl( *pos += 1; n } + // `enum` is only a soft keyword — `class Enum {}` is legal PHP (vendor precedent: + // marc-mabe/php-enum). The lexer always tokenizes the word, so accept it here. + Some(Token::Enum) => { + *pos += 1; + "Enum".to_string() + } _ => return Err(CompileError::new(span, "Expected class name after 'class'")), }; diff --git a/src/types/checker/builtin_types/exception.rs b/src/types/checker/builtin_types/exception.rs index 33523e1c0b..e8269a4ae6 100644 --- a/src/types/checker/builtin_types/exception.rs +++ b/src/types/checker/builtin_types/exception.rs @@ -70,6 +70,15 @@ pub(super) fn builtin_exception_constructor_method() -> ClassMethod { )), false, ), + // PHP's third parameter, `?Throwable $previous = null`. Accepted (positionally and + // as the `previous:` named argument) but not stored: the compact throwable payload + // has no previous slot and `getPrevious()` is already synthesized as null. + ( + "previous".to_string(), + None, + Some(Expr::new(ExprKind::Null, crate::span::Span::dummy())), + false, + ), ], variadic: None, variadic_type: None, diff --git a/src/types/checker/schema/classes/interfaces.rs b/src/types/checker/schema/classes/interfaces.rs index dcd5d55af3..23adb7d301 100644 --- a/src/types/checker/schema/classes/interfaces.rs +++ b/src/types/checker/schema/classes/interfaces.rs @@ -362,7 +362,28 @@ fn validate_interface_method( )?; } } + // Covariant self-return: PHP accepts an implementation returning a NARROWER type than the + // interface declares. The common shape — `withX(): static`/`self`/the class itself against + // an interface-typed return (PSR-7's immutability methods) — cannot go through + // `type_accepts`, because the class under validation is not registered in + // `checker.classes` yet (its interface list is invisible mid-construction). Accept it from + // the facts at hand: this function IS the proof that `class` implements `interface_name`, + // so a self-typed return conforms when the declared return is that interface (or one it + // extends), or any interface the class itself declares (or one those extend). + let self_return_conforms = match (&required_sig.return_type, &actual_sig.return_type) { + (PhpType::Object(expected_name), PhpType::Object(actual_name)) => { + actual_name == &class.name + && (expected_name == interface_name + || checker.interface_extends_interface(interface_name, expected_name) + || class.implements.iter().any(|declared| { + declared == expected_name + || checker.interface_extends_interface(declared, expected_name) + })) + } + _ => false, + }; if required_sig.declared_return + && !self_return_conforms && !declared_return_type_compatible( checker, &required_sig.return_type, diff --git a/src/types/checker/stmt_check/control_flow.rs b/src/types/checker/stmt_check/control_flow.rs index 92d4929d80..a1d67b9432 100644 --- a/src/types/checker/stmt_check/control_flow.rs +++ b/src/types/checker/stmt_check/control_flow.rs @@ -65,7 +65,16 @@ impl Checker { let arr_ty = self.infer_type_with_assignment_effects(array, env)?; if let PhpType::Array(elem_ty) = &arr_ty { if let Some(k) = key_var { - env.insert(k.clone(), PhpType::Int); + // A genuinely packed array has int keys; an UNKNOWN-element array (an + // `array`-hinted param/property, elements known only to phpdoc) may be + // associative at runtime, so its keys are Mixed (ward-http's + // `foreach ($headers as $name => $values)` with string keys). + let key_ty = if matches!(elem_ty.as_ref(), PhpType::Mixed) { + PhpType::Mixed + } else { + PhpType::Int + }; + env.insert(k.clone(), key_ty); self.clear_foreach_callable_metadata(k); } let value_ty = *elem_ty.clone(); diff --git a/tests/codegen/control_flow/functions.rs b/tests/codegen/control_flow/functions.rs index 799ca30a02..768c86d9d8 100644 --- a/tests/codegen/control_flow/functions.rs +++ b/tests/codegen/control_flow/functions.rs @@ -167,3 +167,14 @@ fn test_property_throw_guard_narrowing() { ); assert_eq!(out, "x"); } + +/// foreach KEYS over an unknown-element array (an `array`-hinted param — elements known only +/// to phpdoc) are Mixed, not Int: the value may be associative at runtime (header-map shape +/// with string keys into a `string $name` parameter). Byte-parity vs PHP 8.5. +#[test] +fn test_foreach_key_over_unknown_element_array() { + let out = compile_and_run( + "headers[$name] = $value; } public function all(array $headers): string { $out = ''; foreach ($headers as $name => $value) { $this->setHeader($name, $value); $out .= $name . '=' . $value . ';'; } return $out; } } function main(): void { echo (new H())->all(['a' => '1', 'b' => '2']); } main();", + ); + assert_eq!(out, "a=1;b=2;"); +} diff --git a/tests/codegen/exceptions.rs b/tests/codegen/exceptions.rs index 58e17e9f99..11bb421121 100644 --- a/tests/codegen/exceptions.rs +++ b/tests/codegen/exceptions.rs @@ -624,3 +624,15 @@ try { echo $c->foo(); echo 'no'; } catch (Error $e) { echo 'err'; } ); assert_eq!(out, "err"); } + +/// The builtin exception constructors accept PHP's third `$previous` parameter (positional or +/// the `previous:` named argument). It is not stored — `getPrevious()` remains synthesized as +/// null — but wrap-and-rethrow code compiles and the message/code round-trip is byte-identical +/// to PHP 8.5. +#[test] +fn test_exception_constructor_accepts_previous() { + let out = compile_and_run( + "getMessage(), $e->getCode(), previous: $e); } } catch (\\InvalidArgumentException $x) { return $x->getMessage() . '/' . $x->getCode(); } } echo f();", + ); + assert_eq!(out, "outer: inner/0"); +} diff --git a/tests/codegen/namespaces.rs b/tests/codegen/namespaces.rs index 5b5e8a7e93..a9a041d48e 100644 --- a/tests/codegen/namespaces.rs +++ b/tests/codegen/namespaces.rs @@ -480,3 +480,22 @@ echo \App\C\K::mk()->t->p; ); assert_eq!(out, "/"); } + +/// `use const PHP_INT_MAX;` — the lexer eagerly tokenizes such constants, so the +/// use-declaration parser must accept the dedicated tokens as import names (the import itself +/// is inert; expression uses lower to literals). +#[test] +fn test_use_const_of_lexer_tokenized_constant() { + let out = compile_and_run( + r#" 0 ? 'max' : '?', ':', PHP_INT_MIN < 0 ? 'min' : '?'; +"#, + ); + assert_eq!(out, "max:min"); +} diff --git a/tests/codegen/oop/interfaces.rs b/tests/codegen/oop/interfaces.rs index dbdf52168c..0a0e2975d9 100644 --- a/tests/codegen/oop/interfaces.rs +++ b/tests/codegen/oop/interfaces.rs @@ -282,3 +282,15 @@ echo implode(',', C::previews()); ); assert_eq!(out, "a,b"); } + +/// An implementation may return a NARROWER type than the interface declares — the PSR-7 shape +/// `withX(): static` (resolving to the class) against an interface-typed return. The class +/// under validation is mid-construction when conformance runs, so the covariance is proven +/// from the conformance context itself. Byte-parity vs PHP 8.5. +#[test] +fn test_interface_covariant_self_return() { + let out = compile_and_run( + "w() instanceof C ? 'ok' : 'no';", + ); + assert_eq!(out, "ok"); +} diff --git a/tests/codegen/oop/misc.rs b/tests/codegen/oop/misc.rs index 32b46055ca..0c9ac5b0d2 100644 --- a/tests/codegen/oop/misc.rs +++ b/tests/codegen/oop/misc.rs @@ -102,3 +102,14 @@ fn test_example_v017_trio_compiles_and_runs() { let out = compile_and_run(include_str!("../../../examples/v017-trio/main.php")); assert_eq!(out, "health:[ok]:missing"); } + +/// EC-10 (#493): `enum` is only a soft keyword — `class Enum {}` is legal PHP (vendor +/// precedent: marc-mabe/php-enum). The always-tokenizing lexer must not block the class +/// declaration. Byte-parity vs PHP 8.5. +#[test] +fn test_class_named_enum_declares() { + let out = compile_and_run( + "tag();", + ); + assert_eq!(out, "e"); +}