55use PHP_CodeSniffer \Files \File ;
66use PHP_CodeSniffer \Sniffs \Sniff ;
77use PHP_CodeSniffer \Util \Tokens ;
8+ use SlevomatCodingStandard \Helpers \AnnotationHelper ;
9+ use SlevomatCodingStandard \Helpers \AttributeHelper ;
810use SlevomatCodingStandard \Helpers \ClassHelper ;
911use SlevomatCodingStandard \Helpers \DocCommentHelper ;
1012use SlevomatCodingStandard \Helpers \FixerHelper ;
1113use SlevomatCodingStandard \Helpers \FunctionHelper ;
14+ use SlevomatCodingStandard \Helpers \NamespaceHelper ;
1215use SlevomatCodingStandard \Helpers \PropertyHelper ;
1316use SlevomatCodingStandard \Helpers \SniffSettingsHelper ;
1417use SlevomatCodingStandard \Helpers \TokenHelper ;
1821use function array_key_exists ;
1922use function array_keys ;
2023use function array_merge ;
24+ use function array_shift ;
2125use function array_values ;
2226use function assert ;
2327use function implode ;
2428use function in_array ;
29+ use function ltrim ;
2530use function preg_replace ;
2631use function preg_split ;
2732use function sprintf ;
2833use function str_repeat ;
2934use function strtolower ;
35+ use function substr ;
36+ use const PREG_SPLIT_NO_EMPTY ;
3037use const T_ABSTRACT ;
38+ use const T_ATTRIBUTE_END ;
3139use const T_CLOSE_CURLY_BRACKET ;
3240use const T_CONST ;
3341use const T_ENUM_CASE ;
@@ -181,9 +189,15 @@ class ClassStructureSniff implements Sniff
181189 '__debuginfo ' => self ::GROUP_MAGIC_METHODS ,
182190 ];
183191
192+ /** @var array<string, string> */
193+ public array $ methodGroups = [];
194+
184195 /** @var list<string> */
185196 public array $ groups = [];
186197
198+ /** @var array<string, list<array{name: string|null, attributes: array<string>, annotations: array<string>}>>|null */
199+ private ?array $ normalizedMethodGroups = null ;
200+
187201 /** @var array<string, int>|null */
188202 private ?array $ normalizedGroups = null ;
189203
@@ -342,6 +356,12 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
342356 return self ::SPECIAL_METHODS [$ name ];
343357 }
344358
359+ $ methodGroup = $ this ->resolveMethodGroup ($ phpcsFile , $ pointer , $ name );
360+
361+ if ($ methodGroup !== null ) {
362+ return $ methodGroup ;
363+ }
364+
345365 $ visibility = $ this ->getVisibilityForToken ($ phpcsFile , $ pointer );
346366 $ isStatic = $ this ->isMemberStatic ($ phpcsFile , $ pointer );
347367 $ isFinal = $ this ->isMethodFinal ($ phpcsFile , $ pointer );
@@ -391,6 +411,109 @@ private function getGroupForToken(File $phpcsFile, int $pointer): string
391411 }
392412 }
393413
414+ private function resolveMethodGroup (File $ phpcsFile , int $ pointer , string $ method ): ?string
415+ {
416+ foreach ($ this ->getNormalizedMethodGroups () as $ group => $ methodRequirements ) {
417+ foreach ($ methodRequirements as $ methodRequirement ) {
418+ if ($ methodRequirement ['name ' ] !== null && $ method !== strtolower ($ methodRequirement ['name ' ])) {
419+ continue ;
420+ }
421+
422+ if (
423+ $ this ->hasRequiredAnnotations ($ phpcsFile , $ pointer , $ methodRequirement ['annotations ' ])
424+ && $ this ->hasRequiredAttributes ($ phpcsFile , $ pointer , $ methodRequirement ['attributes ' ])
425+ ) {
426+ return $ group ;
427+ }
428+ }
429+ }
430+
431+ return null ;
432+ }
433+
434+ /**
435+ * @param array<string> $requiredAnnotations
436+ */
437+ private function hasRequiredAnnotations (File $ phpcsFile , int $ pointer , array $ requiredAnnotations ): bool
438+ {
439+ if ($ requiredAnnotations === []) {
440+ return true ;
441+ }
442+
443+ $ annotations = [];
444+
445+ foreach (AnnotationHelper::getAnnotations ($ phpcsFile , $ pointer ) as $ annotation ) {
446+ $ annotations [$ annotation ->getName ()] = true ;
447+ }
448+
449+ foreach ($ requiredAnnotations as $ requiredAnnotation ) {
450+ if (!array_key_exists ('@ ' . $ requiredAnnotation , $ annotations )) {
451+ return false ;
452+ }
453+ }
454+
455+ return true ;
456+ }
457+
458+ /**
459+ * @param array<string> $requiredAttributes
460+ */
461+ private function hasRequiredAttributes (File $ phpcsFile , int $ pointer , array $ requiredAttributes ): bool
462+ {
463+ if ($ requiredAttributes === []) {
464+ return true ;
465+ }
466+
467+ $ attributesClassNames = $ this ->getAttributeClassNamesForToken ($ phpcsFile , $ pointer );
468+
469+ foreach ($ requiredAttributes as $ requiredAttribute ) {
470+ if (!array_key_exists (strtolower ($ requiredAttribute ), $ attributesClassNames )) {
471+ return false ;
472+ }
473+ }
474+
475+ return true ;
476+ }
477+
478+ /**
479+ * @return array<string, string>
480+ */
481+ private function getAttributeClassNamesForToken (File $ phpcsFile , int $ pointer ): array
482+ {
483+ $ tokens = $ phpcsFile ->getTokens ();
484+ $ attributePointer = null ;
485+ $ attributes = [];
486+
487+ while (true ) {
488+ $ attributeEndPointerCandidate = TokenHelper::findPrevious (
489+ $ phpcsFile ,
490+ [T_ATTRIBUTE_END , T_SEMICOLON , T_CLOSE_CURLY_BRACKET , T_OPEN_CURLY_BRACKET ],
491+ $ attributePointer ?? $ pointer - 1 ,
492+ );
493+
494+ if (
495+ $ attributeEndPointerCandidate === null
496+ || $ tokens [$ attributeEndPointerCandidate ]['code ' ] !== T_ATTRIBUTE_END
497+ ) {
498+ break ;
499+ }
500+
501+ $ attributePointer = $ tokens [$ attributeEndPointerCandidate ]['attribute_opener ' ];
502+
503+ foreach (AttributeHelper::getAttributes ($ phpcsFile , $ attributePointer ) as $ attribute ) {
504+ $ attributeClass = NamespaceHelper::resolveClassName (
505+ $ phpcsFile ,
506+ $ attribute ->getName (),
507+ $ attribute ->getStartPointer (),
508+ );
509+ $ attributeClass = ltrim ($ attributeClass , '\\' );
510+ $ attributes [strtolower ($ attributeClass )] = $ attributeClass ;
511+ }
512+ }
513+
514+ return $ attributes ;
515+ }
516+
394517 private function getVisibilityForToken (File $ phpcsFile , int $ pointer ): int
395518 {
396519 $ tokens = $ phpcsFile ->getTokens ();
@@ -551,6 +674,43 @@ private function removeBlankLinesAfterMember(File $phpcsFile, int $memberEndPoin
551674 return $ linesToRemove ;
552675 }
553676
677+ /**
678+ * @return array<string, list<array{name: string|null, attributes: array<string>, annotations: array<string>}>>
679+ */
680+ private function getNormalizedMethodGroups (): array
681+ {
682+ if ($ this ->normalizedMethodGroups === null ) {
683+ $ this ->normalizedMethodGroups = [];
684+ $ methodGroups = SniffSettingsHelper::normalizeAssociativeArray ($ this ->methodGroups );
685+
686+ foreach ($ methodGroups as $ group => $ groupDefinition ) {
687+ $ group = strtolower ((string ) $ group );
688+ $ this ->normalizedMethodGroups [$ group ] = [];
689+ $ methodDefinitions = preg_split ('~ \\s*, \\s*~ ' , (string ) $ groupDefinition , -1 , PREG_SPLIT_NO_EMPTY );
690+ /** @var list<string> $methodDefinitions */
691+ foreach ($ methodDefinitions as $ methodDefinition ) {
692+ $ tokens = preg_split ('~(?=[#@])~ ' , $ methodDefinition );
693+ /** @var list<string> $tokens */
694+ $ method = array_shift ($ tokens );
695+ $ methodRequirement = [
696+ 'name ' => $ method !== '' ? $ method : null ,
697+ 'attributes ' => [],
698+ 'annotations ' => [],
699+ ];
700+
701+ foreach ($ tokens as $ token ) {
702+ $ key = $ token [0 ] === '# ' ? 'attributes ' : 'annotations ' ;
703+ $ methodRequirement [$ key ][] = substr ($ token , 1 );
704+ }
705+
706+ $ this ->normalizedMethodGroups [$ group ][] = $ methodRequirement ;
707+ }
708+ }
709+ }
710+
711+ return $ this ->normalizedMethodGroups ;
712+ }
713+
554714 /**
555715 * @return array<string, int>
556716 */
@@ -589,18 +749,20 @@ private function getNormalizedGroups(): array
589749 self ::GROUP_MAGIC_METHODS ,
590750 ];
591751
752+ $ normalizedMethodGroups = $ this ->getNormalizedMethodGroups ();
592753 $ normalizedGroupsWithShortcuts = [];
593754 $ order = 1 ;
594755 foreach (SniffSettingsHelper::normalizeArray ($ this ->groups ) as $ groupsString ) {
595756 /** @var list<string> $groups */
596- $ groups = preg_split ('~ \\s*, \\s*~ ' , strtolower ($ groupsString ));
757+ $ groups = preg_split ('~ \\s*, \\s*~ ' , strtolower ($ groupsString ), - 1 , PREG_SPLIT_NO_EMPTY );
597758 foreach ($ groups as $ groupOrShortcut ) {
598759 $ groupOrShortcut = preg_replace ('~ \\s+~ ' , ' ' , $ groupOrShortcut );
599760
600761 if (
601762 !in_array ($ groupOrShortcut , $ supportedGroups , true )
602763 && !array_key_exists ($ groupOrShortcut , self ::SHORTCUTS )
603764 && $ groupOrShortcut !== self ::GROUP_INVOKE_METHOD
765+ && !array_key_exists ($ groupOrShortcut , $ normalizedMethodGroups )
604766 ) {
605767 throw new UnsupportedClassGroupException ($ groupOrShortcut );
606768 }
@@ -613,7 +775,11 @@ private function getNormalizedGroups(): array
613775
614776 $ normalizedGroups = [];
615777 foreach ($ normalizedGroupsWithShortcuts as $ groupOrShortcut => $ groupOrder ) {
616- if (in_array ($ groupOrShortcut , $ supportedGroups , true ) || $ groupOrShortcut === self ::GROUP_INVOKE_METHOD ) {
778+ if (
779+ in_array ($ groupOrShortcut , $ supportedGroups , true )
780+ || $ groupOrShortcut === self ::GROUP_INVOKE_METHOD
781+ || array_key_exists ($ groupOrShortcut , $ normalizedMethodGroups )
782+ ) {
617783 $ normalizedGroups [$ groupOrShortcut ] = $ groupOrder ;
618784 } else {
619785 foreach ($ this ->unpackShortcut ($ groupOrShortcut , $ supportedGroups ) as $ group ) {
@@ -629,12 +795,16 @@ private function getNormalizedGroups(): array
629795 }
630796 }
631797
632- if ($ normalizedGroups === []) {
798+ if ($ normalizedGroups === [] && $ normalizedMethodGroups === [] ) {
633799 $ normalizedGroups = array_flip ($ supportedGroups );
634800 } else {
635- $ missingGroups = array_values (array_diff ($ supportedGroups , array_keys ($ normalizedGroups )));
801+ $ missingGroups = array_diff (
802+ array_merge ($ supportedGroups , array_keys ($ normalizedMethodGroups )),
803+ array_keys ($ normalizedGroups ),
804+ );
805+
636806 if ($ missingGroups !== []) {
637- throw new MissingClassGroupsException ($ missingGroups );
807+ throw new MissingClassGroupsException (array_values ( $ missingGroups) );
638808 }
639809 }
640810
0 commit comments