From 560160720b184429fb5c776694d97861c2155af1 Mon Sep 17 00:00:00 2001 From: Chad Peppers Date: Sat, 11 Jul 2026 09:10:37 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20keyword-spelled=20member=20names=20?= =?UTF-8?q?=E2=80=94=20declarations=20and=20case-insensitive=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8 permits reserved keywords as enum-case names (`case Default;`, `case Print;`, `case List;`) and semi-reserved keywords as class-constant and enum-case member names, under the same rule that already applies to method names and `->` member access. Three fixes: - Enum-case name parsing accepts barewords via the shared `bareword_name_from_token` helper (as class-constant names already do), and rejects `class` (reserved for the `Foo::class` fetch) with a dedicated diagnostic. - Keyword-named member ACCESS resolves case-insensitively: the lexer folds keyword spellings, so `case Match` is declared as "match" while a builtin table may declare "MATCH". Drop the `Token::Match => "MATCH"` special cases and make the scoped-constant lookup retry case-insensitively after an exact miss, accepting a UNIQUE fold-match. PHP is case-sensitive here; the retry only fires for spellings the lexer already erased, and an ambiguous fold keeps the miss. - The same unique fold fallback is mirrored in the lowering's own scoped-constant resolution (`scoped_constant_value` / `interface_constant_value`) so `RegexIterator::MATCH` resolves. Byte-parity vs PHP 8.5 (values + identifier-named `->name`). Known limit: `->name` of a KEYWORD-named case reports the folded spelling — recovering the source lexeme needs spelling-preserving keyword tokens (follow-up). Co-Authored-By: Claude Fable 5 --- src/ir_lower/context.rs | 32 +++++++++++++++++-- src/parser/expr/calls.rs | 6 ++-- src/parser/expr/prefix_complex.rs | 6 ++-- src/parser/stmt/oop/body.rs | 15 +++++++-- .../checker/inference/expr/class_refs.rs | 27 ++++++++++++++++ tests/codegen/types/enums.rs | 14 ++++++++ tests/error_tests/misc/classes.rs | 11 +++++++ 7 files changed, 98 insertions(+), 13 deletions(-) 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]