diff --git a/src/ir_lower/context.rs b/src/ir_lower/context.rs index 6bce5860a6..809c8117f4 100644 --- a/src/ir_lower/context.rs +++ b/src/ir_lower/context.rs @@ -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, @@ -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(); @@ -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()); @@ -1516,3 +1522,25 @@ fn ref_cell_array_element_type(ty: &PhpType) -> Option { _ => 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, + 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 +} diff --git a/src/parser/expr/calls.rs b/src/parser/expr/calls.rs index 441432cd74..0c10298f5b 100644 --- a/src/parser/expr/calls.rs +++ b/src/parser/expr/calls.rs @@ -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; diff --git a/src/parser/expr/prefix_complex.rs b/src/parser/expr/prefix_complex.rs index d79c3db462..3ee49f4967 100644 --- a/src/parser/expr/prefix_complex.rs +++ b/src/parser/expr/prefix_complex.rs @@ -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; diff --git a/src/parser/stmt/oop/body.rs b/src/parser/stmt/oop/body.rs index caa280b3da..5de179abfe 100644 --- a/src/parser/stmt/oop/body.rs +++ b/src/parser/stmt/oop/body.rs @@ -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( diff --git a/src/types/checker/inference/expr/class_refs.rs b/src/types/checker/inference/expr/class_refs.rs index dc95c9ce78..f9a2313a49 100644 --- a/src/types/checker/inference/expr/class_refs.rs +++ b/src/types/checker/inference/expr/class_refs.rs @@ -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()); @@ -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 = 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()); } diff --git a/tests/codegen/types/enums.rs b/tests/codegen/types/enums.rs index 0a5b9ff6bf..00c82d39d7 100644 --- a/tests/codegen/types/enums.rs +++ b/tests/codegen/types/enums.rs @@ -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( + "value; } echo pick(CardVariant::Error), ':', pick(CardVariant::Match), ':', pick(CardVariant::Default), ':', CardVariant::Error->name;", + ); + assert_eq!(out, "error:match:default:Error"); +} diff --git a/tests/error_tests/misc/classes.rs b/tests/error_tests/misc/classes.rs index 3561064468..7ca468d103 100644 --- a/tests/error_tests/misc/classes.rs +++ b/tests/error_tests/misc/classes.rs @@ -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( + "` is still rejected even though /// semi-reserved keywords are now accepted as member names. #[test]