@@ -153,7 +153,10 @@ public function __construct() {
153153 TokenKind::FloatReservedWord, TokenKind::IntReservedWord, TokenKind::StringReservedWord,
154154 TokenKind::ObjectReservedWord, TokenKind::NullReservedWord, TokenKind::FalseReservedWord,
155155 TokenKind::TrueReservedWord, TokenKind::IterableReservedWord, TokenKind::MixedReservedWord,
156- TokenKind::VoidReservedWord, TokenKind::NeverReservedWord]; // TODO update spec
156+ TokenKind::VoidReservedWord, TokenKind::NeverReservedWord,
157+ // Legacy type aliases (invalid in actual code, but parser should accept them for error recovery)
158+ TokenKind::BooleanReservedWord, TokenKind::IntegerReservedWord, TokenKind::DoubleReservedWord,
159+ TokenKind::RealReservedWord, TokenKind::BinaryReservedWord]; // TODO update spec
157160 $ this ->returnTypeDeclarationTokens = \array_merge ([TokenKind::StaticKeyword], $ this ->parameterTypeDeclarationTokens );
158161 }
159162
@@ -204,11 +207,55 @@ public function parseSourceFile(string $fileContents, ?string $uri = null) : Sou
204207 $ this ->sourceFile ->endOfFileToken = $ this ->eat1 (TokenKind::EndOfFileToken);
205208 $ this ->advanceToken ();
206209
210+ // Check for unterminated comments in the trivia before EOF
211+ $ this ->checkForUnterminatedComment ($ sourceFile );
212+
207213 $ sourceFile ->parent = null ;
208214
209215 return $ sourceFile ;
210216 }
211217
218+ private function checkForUnterminatedComment ($ sourceFile ) {
219+ // Check if the trivia before EOF contains an unterminated /* or /** comment
220+ $ eofToken = $ sourceFile ->endOfFileToken ;
221+ $ trivia = substr ($ sourceFile ->fileContents , $ eofToken ->fullStart , $ eofToken ->start - $ eofToken ->fullStart );
222+
223+ // Look for /* or /** that doesn't have a matching */
224+ // We need to make sure it's not inside a single-line comment
225+ if (preg_match ('/\/\*\*?(?:(?!\*\/).)*$/s ' , $ trivia , $ matches , PREG_OFFSET_CAPTURE )) {
226+ // Found potential unterminated comment - verify it's not in a // comment
227+ $ commentStart = $ eofToken ->fullStart + $ matches [0 ][1 ];
228+
229+ // Check if this /* appears after a // on the same line
230+ // Get the line containing the /* by looking back to the last newline
231+ // Clamp the offset to avoid ValueError when $commentStart is near the beginning
232+ $ offset = $ commentStart - strlen ($ sourceFile ->fileContents ) - 1 ;
233+ $ offset = max ($ offset , -strlen ($ sourceFile ->fileContents ));
234+ $ lastNewline = strrpos ($ sourceFile ->fileContents , "\n" , $ offset );
235+ $ lineStart = $ lastNewline !== false ? $ lastNewline + 1 : 0 ;
236+ $ currentLine = substr ($ sourceFile ->fileContents , $ lineStart , $ commentStart - $ lineStart + 2 ); // +2 to include /*
237+
238+ // If there's a // before /* on the same line, it's inside a single-line comment
239+ $ doubleSlashPos = strpos ($ currentLine , '// ' );
240+ $ slashStarPos = strpos ($ currentLine , '/* ' );
241+ if ($ doubleSlashPos !== false && $ doubleSlashPos < $ slashStarPos ) {
242+ return ; // The /* is inside a // comment
243+ }
244+
245+ $ commentText = $ matches [0 ][0 ];
246+
247+ // Find the line number where the comment starts
248+ $ lineNumber = 1 + substr_count (substr ($ sourceFile ->fileContents , 0 , $ commentStart ), "\n" );
249+
250+ $ sourceFile ->unterminatedCommentDiagnostic = new Diagnostic (
251+ DiagnosticKind::Error,
252+ "Unterminated comment starting line $ lineNumber " ,
253+ $ commentStart ,
254+ strlen ($ commentText )
255+ );
256+ }
257+ }
258+
212259 private function reset () {
213260 $ this ->advanceToken ();
214261
@@ -664,6 +711,10 @@ private function parseClassElementFn() {
664711 case TokenKind::UseKeyword:
665712 return $ this ->parseTraitUseClause ($ parentNode );
666713
714+ case TokenKind::CaseKeyword:
715+ // enum case in a class - error but parse for recovery
716+ return $ this ->parseEnumCaseDeclaration ($ parentNode );
717+
667718 case TokenKind::AttributeToken:
668719 return $ this ->parseAttributeStatement ($ parentNode );
669720
@@ -857,7 +908,7 @@ private function parseParameterFn() {
857908 // PHP 8.5+ allows final/readonly to precede visibility, so we need to parse all modifiers first
858909 // then extract visibility from the modifiers list
859910 $ parameter ->visibilityToken = $ this ->eatOptional ([TokenKind::PublicKeyword, TokenKind::ProtectedKeyword, TokenKind::PrivateKeyword]);
860- $ parameter ->setVisibilityToken = $ this ->eatOptional ([TokenKind::ProtectedSetKeyword, TokenKind::PrivateSetKeyword]);
911+ $ parameter ->setVisibilityToken = $ this ->eatOptional ([TokenKind::PublicSetKeyword, TokenKind:: ProtectedSetKeyword, TokenKind::PrivateSetKeyword]);
861912 $ parameter ->modifiers = $ this ->parseParameterModifiers () ?: null ;
862913
863914 // If visibilityToken is null but we have modifiers, check if there's a visibility modifier
@@ -887,6 +938,16 @@ private function parseParameterFn() {
887938 array_pop ($ parameter ->typeDeclarationList ->children );
888939 $ parameter ->byRefToken = array_pop ($ parameter ->typeDeclarationList ->children );
889940 }
941+ // Check for invalid ?Type|OtherType syntax (nullable with union types)
942+ if ($ parameter ->questionToken ) {
943+ foreach ($ children as $ index => $ child ) {
944+ if ($ child instanceof Token && $ child ->kind === TokenKind::BarToken) {
945+ // Mark the BarToken as a SkippedToken to generate a diagnostic error
946+ $ parameter ->typeDeclarationList ->children [$ index ] = new SkippedToken ($ child );
947+ break ;
948+ }
949+ }
950+ }
890951 } elseif ($ parameter ->questionToken ) {
891952 // TODO ParameterType?
892953 $ parameter ->typeDeclarationList = new MissingToken (TokenKind::PropertyType, $ this ->token ->fullStart );
@@ -1097,6 +1158,9 @@ private function isClassMemberDeclarationStart(Token $token) {
10971158
10981159 case TokenKind::UseKeyword:
10991160
1161+ // case (enum case in class - error but accept for recovery)
1162+ case TokenKind::CaseKeyword:
1163+
11001164 // attributes
11011165 case TokenKind::AttributeToken:
11021166 return true ;
@@ -1205,6 +1269,9 @@ private function isExpressionStartFn() {
12051269 case TokenKind::YieldKeyword:
12061270 case TokenKind::YieldFromKeyword:
12071271
1272+ // throw-expression (PHP 8.0+)
1273+ case TokenKind::ThrowKeyword:
1274+
12081275 // object-creation-expression
12091276 case TokenKind::NewKeyword:
12101277 case TokenKind::CloneKeyword:
@@ -1584,6 +1651,11 @@ private function isModifier($token): bool {
15841651 case TokenKind::ProtectedKeyword:
15851652 case TokenKind::PrivateKeyword:
15861653
1654+ // asymmetric visibility (PHP 8.4+)
1655+ case TokenKind::PrivateSetKeyword:
1656+ case TokenKind::ProtectedSetKeyword:
1657+ case TokenKind::PublicSetKeyword:
1658+
15871659 // static-modifier
15881660 case TokenKind::StaticKeyword:
15891661
@@ -1688,6 +1760,11 @@ private function isParameterStartFn() {
16881760 case TokenKind::FinalKeyword:
16891761 case TokenKind::AttributeToken:
16901762
1763+ // asymmetric visibility (PHP 8.4+)
1764+ case TokenKind::PublicSetKeyword:
1765+ case TokenKind::ProtectedSetKeyword:
1766+ case TokenKind::PrivateSetKeyword:
1767+
16911768 // dnf types (A&B)|C
16921769 case TokenKind::OpenParenToken:
16931770 return true ;
@@ -3349,7 +3426,12 @@ private function parseObjectCreationExpression($parentNode) {
33493426 }
33503427
33513428 // PHP 8.4: new without parenthesis - allow chaining directly after new expression
3352- if ($ this ->getCurrentToken ()->kind === TokenKind::ArrowToken) {
3429+ $ tokenKind = $ this ->getCurrentToken ()->kind ;
3430+ if ($ tokenKind === TokenKind::ArrowToken ||
3431+ $ tokenKind === TokenKind::QuestionArrowToken ||
3432+ $ tokenKind === TokenKind::OpenBracketToken ||
3433+ $ tokenKind === TokenKind::OpenParenToken ||
3434+ $ tokenKind === TokenKind::ColonColonToken) {
33533435 return $ this ->parsePostfixExpressionRest ($ objectCreationExpression );
33543436 }
33553437
@@ -3510,7 +3592,16 @@ private function parsePropertyDeclaration($parentNode, $modifiers, $questionToke
35103592 $ propertyDeclaration = new PropertyDeclaration ();
35113593 $ propertyDeclaration ->parent = $ parentNode ;
35123594
3513- $ propertyDeclaration ->modifiers = $ modifiers ;
3595+ // Extract setVisibilityToken from modifiers (PHP 8.4+)
3596+ $ filteredModifiers = [];
3597+ foreach ($ modifiers as $ modifier ) {
3598+ if ($ modifier ->kind === TokenKind::PublicSetKeyword || $ modifier ->kind === TokenKind::PrivateSetKeyword || $ modifier ->kind === TokenKind::ProtectedSetKeyword) {
3599+ $ propertyDeclaration ->setVisibilityToken = $ modifier ;
3600+ } else {
3601+ $ filteredModifiers [] = $ modifier ;
3602+ }
3603+ }
3604+ $ propertyDeclaration ->modifiers = $ filteredModifiers ?: null ;
35143605 $ propertyDeclaration ->questionToken = $ questionToken ;
35153606 if ($ typeDeclarationList ) {
35163607 $ propertyDeclaration ->typeDeclarationList = $ typeDeclarationList ;
@@ -3648,6 +3739,9 @@ private function parsePropertyHook(PropertyHookList $parentNode): ?PropertyHook
36483739 $ hook ->arrowToken = $ this ->eat1 (TokenKind::DoubleArrowToken);
36493740 $ hook ->expression = $ this ->parseExpression ($ hook );
36503741 $ hook ->semicolon = $ this ->eatOptional1 (TokenKind::SemicolonToken) ?? new MissingToken (TokenKind::SemicolonToken, $ this ->getCurrentToken ()->fullStart );
3742+ } elseif ($ this ->checkToken (TokenKind::SemicolonToken)) {
3743+ // Abstract hook (interface or abstract class)
3744+ $ hook ->semicolon = $ this ->eat1 (TokenKind::SemicolonToken);
36513745 } else {
36523746 $ hook ->compoundStatement = $ this ->parseCompoundStatement ($ hook );
36533747 }
@@ -3727,7 +3821,43 @@ private function isInterfaceMemberDeclarationStart(Token $token) {
37273821
37283822 case TokenKind::FunctionKeyword:
37293823
3824+ // trait use (not allowed but parse for error recovery)
3825+ case TokenKind::UseKeyword:
3826+
37303827 case TokenKind::AttributeToken:
3828+
3829+ // PHP 8.4: interface property hooks without explicit visibility
3830+ case TokenKind::QuestionToken: // nullable type: ?Foo $bar
3831+ case TokenKind::VariableName: // no type: $bar
3832+
3833+ // Type tokens that can start a property declaration
3834+ case TokenKind::Name: // qualified names: Foo $bar
3835+ case TokenKind::BackslashToken: // fully qualified: \Foo $bar
3836+ case TokenKind::NamespaceKeyword: // relative: namespace\Foo $bar
3837+ case TokenKind::OpenParenToken: // DNF types: (A&B)|C $bar
3838+
3839+ // Built-in type keywords
3840+ case TokenKind::ArrayKeyword:
3841+ case TokenKind::CallableKeyword:
3842+ case TokenKind::BoolReservedWord:
3843+ case TokenKind::FloatReservedWord:
3844+ case TokenKind::IntReservedWord:
3845+ case TokenKind::StringReservedWord:
3846+ case TokenKind::ObjectReservedWord:
3847+ case TokenKind::NullReservedWord:
3848+ case TokenKind::FalseReservedWord:
3849+ case TokenKind::TrueReservedWord:
3850+ case TokenKind::IterableReservedWord:
3851+ case TokenKind::MixedReservedWord:
3852+ case TokenKind::VoidReservedWord:
3853+ case TokenKind::NeverReservedWord:
3854+
3855+ // Legacy type aliases (for error recovery)
3856+ case TokenKind::BooleanReservedWord:
3857+ case TokenKind::IntegerReservedWord:
3858+ case TokenKind::DoubleReservedWord:
3859+ case TokenKind::RealReservedWord:
3860+ case TokenKind::BinaryReservedWord:
37313861 return true ;
37323862 }
37333863 return false ;
@@ -3745,14 +3875,28 @@ private function parseInterfaceElementFn() {
37453875 case TokenKind::FunctionKeyword:
37463876 return $ this ->parseMethodDeclaration ($ parentNode , $ modifiers );
37473877
3878+ case TokenKind::QuestionToken:
3879+ // PHP 8.4: property hooks in interfaces
3880+ return $ this ->parseRemainingPropertyDeclarationOrMissingMemberDeclaration (
3881+ $ parentNode ,
3882+ $ modifiers ,
3883+ $ this ->eat1 (TokenKind::QuestionToken)
3884+ );
3885+
3886+ case TokenKind::VariableName:
3887+ // PHP 8.4: property hooks in interfaces
3888+ return $ this ->parsePropertyDeclaration ($ parentNode , $ modifiers );
3889+
3890+ case TokenKind::UseKeyword:
3891+ // Trait use in interface - not allowed in PHP but parse for error recovery
3892+ return $ this ->parseTraitUseClause ($ parentNode );
3893+
37483894 case TokenKind::AttributeToken:
37493895 return $ this ->parseAttributeStatement ($ parentNode );
37503896
37513897 default :
3752- $ missingInterfaceMemberDeclaration = new MissingMemberDeclaration ();
3753- $ missingInterfaceMemberDeclaration ->parent = $ parentNode ;
3754- $ missingInterfaceMemberDeclaration ->modifiers = $ modifiers ;
3755- return $ missingInterfaceMemberDeclaration ;
3898+ // PHP 8.4: property hooks in interfaces - handle type declarations
3899+ return $ this ->parseRemainingPropertyDeclarationOrMissingMemberDeclaration ($ parentNode , $ modifiers );
37563900 }
37573901 };
37583902 }
0 commit comments