Skip to content
Closed
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
32 changes: 30 additions & 2 deletions src/ir_lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,10 @@ impl<'m, 'f> LoweringContext<'m, 'f> {
}

/// Returns a class or interface constant expression resolved with PHP lookup order.
///
/// After an exact miss at each level, retries case-insensitively and accepts a UNIQUE
/// fold-match: the lexer folds keyword spellings, so `X::MATCH` reaches lowering as
/// "match" while the table declares "MATCH" (mirrors the checker's scoped lookup).
pub(crate) fn scoped_constant_value(
&self,
class_name: &str,
Expand All @@ -305,7 +309,8 @@ impl<'m, 'f> LoweringContext<'m, 'f> {
let mut current = Some(class_name);
while let Some(name) = current {
if let Some(info) = self.classes.get(name) {
if let Some(value) = info.constants.get(const_name) {
if let Some(value) = constant_by_name_or_unique_fold(&info.constants, const_name)
{
return Some(value.clone());
}
current = info.parent.as_deref();
Expand Down Expand Up @@ -336,7 +341,8 @@ impl<'m, 'f> LoweringContext<'m, 'f> {
continue;
}
if let Some(info) = self.interfaces.get(&name) {
if let Some(value) = info.constants.get(const_name) {
if let Some(value) = constant_by_name_or_unique_fold(&info.constants, const_name)
{
return Some(value.clone());
}
queue.extend(info.parents.iter().cloned());
Expand Down Expand Up @@ -1516,3 +1522,25 @@ fn ref_cell_array_element_type(ty: &PhpType) -> Option<PhpType> {
_ => None,
}
}

/// Exact constant lookup with a UNIQUE case-insensitive fallback for keyword-spelled names
/// (the lexer folds keyword spellings, so `X::MATCH` reaches lowering as "match" while the
/// table declares "MATCH"). An ambiguous fold keeps the miss, mirroring the checker.
fn constant_by_name_or_unique_fold<'a>(
constants: &'a HashMap<String, crate::parser::ast::Expr>,
const_name: &str,
) -> Option<&'a crate::parser::ast::Expr> {
if let Some(value) = constants.get(const_name) {
return Some(value);
}
let mut fold_match = None;
for (name, value) in constants {
if name.eq_ignore_ascii_case(const_name) {
if fold_match.is_some() {
return None;
}
fold_match = Some(value);
}
}
fold_match
}
6 changes: 2 additions & 4 deletions src/parser/expr/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,10 @@ pub(super) fn parse_scoped_static_call(
*pos += 1;
method
}
Some(Token::Match) => {
*pos += 1;
"MATCH".to_string()
}
// PHP 8 allows semi-reserved keywords as static method / class-constant names
// (e.g. `Foo::self()`, `Foo::print`); `class` and `$var` are handled above.
// The lexer folds keyword spellings, so `Foo::Match`/`Foo::MATCH` both arrive as
// the bareword "match" — scoped-constant lookup retries case-insensitively.
Some(t) if crate::parser::keyword_name::bareword_name_from_token(t).is_some() => {
let name = crate::parser::keyword_name::bareword_name_from_token(t).unwrap();
*pos += 1;
Expand Down
6 changes: 2 additions & 4 deletions src/parser/expr/prefix_complex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,12 +731,10 @@ pub(super) fn parse_named_expr(
*pos += 1;
member
}
Some(Token::Match) => {
*pos += 1;
"MATCH".to_string()
}
// PHP 8 allows semi-reserved keywords as static method / class-constant names
// (e.g. `Foo::self()`, `Foo::print`); `class` and `$var` are handled above.
// The lexer folds keyword spellings, so `Foo::Match`/`Foo::MATCH` both arrive as
// the bareword "match" — scoped-constant lookup retries case-insensitively.
Some(t) if crate::parser::keyword_name::bareword_name_from_token(t).is_some() => {
let member = crate::parser::keyword_name::bareword_name_from_token(t).unwrap();
*pos += 1;
Expand Down
15 changes: 12 additions & 3 deletions src/parser/stmt/oop/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,20 @@ pub(in crate::parser::stmt) fn parse_class_like_body(
));
}
*pos += 1; // consume 'case'
// PHP 8 allows semi-reserved keywords as enum-case names (e.g. `case Default;`,
// `case Print;`), except `class`, which is reserved for the `Foo::class` fetch —
// the same rule as class-constant names.
let case_name = match tokens.get(*pos).map(|(t, _)| t) {
Some(Token::Identifier(name)) => {
let name = name.clone();
Some(Token::Class) => {
return Err(CompileError::new(
member_span,
"Cannot use 'class' as an enum case name",
))
}
Some(t) if crate::parser::keyword_name::bareword_name_from_token(t).is_some() => {
let n = crate::parser::keyword_name::bareword_name_from_token(t).unwrap();
*pos += 1;
name
n
}
_ => {
return Err(CompileError::new(
Expand Down
27 changes: 27 additions & 0 deletions src/types/checker/inference/expr/class_refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,27 @@ impl Checker {
// First: enum case access (`Color::Red`). Enums shadow classes for
// this syntax in PHP since 8.1. A name that is not a declared case is an enum *constant*
// (`Scale::FACTOR`), which is resolved through the class-constant table below.
//
// Keyword-spelled member names lose their source spelling — the lexer folds keywords
// case-insensitively, so `case Match` is DECLARED as "match" while a builtin table may
// declare "MATCH". After an exact miss, each lookup therefore retries
// case-insensitively and accepts a UNIQUE fold-match (PHP itself is case-sensitive
// here; this path is only reachable for spellings the lexer has already erased, and an
// ambiguous fold keeps the miss).
if let Some(enum_info) = self.enums.get(&class_name) {
if enum_info.cases.iter().any(|case| case.name == name) {
return self.infer_enum_case_type(&class_name, name, expr);
}
let folded: Vec<&str> = enum_info
.cases
.iter()
.map(|case| case.name.as_str())
.filter(|case| case.eq_ignore_ascii_case(name))
.collect();
if let [canonical] = folded[..] {
let canonical = canonical.to_string();
return self.infer_enum_case_type(&class_name, &canonical, expr);
}
}
// Walk parent chain to find a class constant.
let mut current_class = Some(class_name.clone());
Expand All @@ -87,6 +104,16 @@ impl Checker {
if let Some(value_expr) = info.constants.get(name).cloned() {
return self.infer_type(&value_expr, &TypeEnv::default());
}
let folded: Vec<String> = info
.constants
.keys()
.filter(|constant| constant.eq_ignore_ascii_case(name))
.cloned()
.collect();
if let [canonical] = &folded[..] {
let value_expr = info.constants[canonical.as_str()].clone();
return self.infer_type(&value_expr, &TypeEnv::default());
}
}
current_class = self.classes.get(cn).and_then(|i| i.parent.clone());
}
Expand Down
14 changes: 14 additions & 0 deletions tests/codegen/types/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,3 +833,17 @@ fn test_backed_int_enum_tryfrom_mixed() {
);
assert_eq!(out, "HighnullLow");
}

/// Keyword-named enum cases are ACCESSIBLE — the lexer folds keyword spellings case-insensitively
/// (both `case Match` and `E::Match` reach the checker as "match"), so the scoped-constant lookup
/// retries case-insensitively after an exact miss and accepts a unique fold-match. Values are
/// byte-identical to PHP 8.5; a genuinely undefined case still errors. Known limit: `->name` of a
/// KEYWORD-named case reports the folded spelling (the lexer discards the source lexeme) —
/// identifier-named cases like `Error` are unaffected.
#[test]
fn test_keyword_named_enum_case_access() {
let out = compile_and_run(
"<?php enum CardVariant: string { case Default = 'default'; case Error = 'error'; case Match = 'match'; } function pick(CardVariant $v): string { return $v->value; } echo pick(CardVariant::Error), ':', pick(CardVariant::Match), ':', pick(CardVariant::Default), ':', CardVariant::Error->name;",
);
assert_eq!(out, "error:match:default:Error");
}
11 changes: 11 additions & 0 deletions tests/error_tests/misc/classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,17 @@ fn test_error_const_named_class_is_rejected() {
);
}

/// Verifies that `class` is rejected as an enum-case name, even though other semi-reserved
/// keywords (`case Default;`, `case Print;`) are allowed — the same `Foo::class` reservation
/// that applies to class-constant names.
#[test]
fn test_error_enum_case_named_class_is_rejected() {
expect_error(
"<?php enum E { case class; }",
"Cannot use 'class' as an enum case name",
);
}

/// Verifies that a non-name token (an operator) after `->` is still rejected even though
/// semi-reserved keywords are now accepted as member names.
#[test]
Expand Down
Loading