Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/codegen/lower_inst/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down
7 changes: 7 additions & 0 deletions src/parser/stmt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 35 additions & 0 deletions src/parser/stmt/namespace_use.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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,
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/parser/stmt/oop/declarations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'")),
};

Expand Down
9 changes: 9 additions & 0 deletions src/types/checker/builtin_types/exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/types/checker/schema/classes/interfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/types/checker/stmt_check/control_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions tests/codegen/control_flow/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<?php final class H { private array $headers = []; public function setHeader(string $name, string $value): void { $this->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;");
}
12 changes: 12 additions & 0 deletions tests/codegen/exceptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<?php function f(): string { try { try { throw new \\ValueError('inner'); } catch (\\ValueError $e) { throw new \\InvalidArgumentException('outer: ' . $e->getMessage(), $e->getCode(), previous: $e); } } catch (\\InvalidArgumentException $x) { return $x->getMessage() . '/' . $x->getCode(); } } echo f();",
);
assert_eq!(out, "outer: inner/0");
}
19 changes: 19 additions & 0 deletions tests/codegen/namespaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<?php

namespace App;

use const PHP_INT_MAX;
use const PHP_INT_MIN;

echo PHP_INT_MAX > 0 ? 'max' : '?', ':', PHP_INT_MIN < 0 ? 'min' : '?';
"#,
);
assert_eq!(out, "max:min");
}
12 changes: 12 additions & 0 deletions tests/codegen/oop/interfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<?php interface I { public function w(): I; } final class C implements I { public function w(): static { return $this; } } echo (new C())->w() instanceof C ? 'ok' : 'no';",
);
assert_eq!(out, "ok");
}
11 changes: 11 additions & 0 deletions tests/codegen/oop/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<?php class Enum { public function tag(): string { return 'e'; } } echo (new Enum())->tag();",
);
assert_eq!(out, "e");
}
Loading