diff --git a/phpcs-sniffs/Formidable/Sniffs/Commenting/AddMissingParamTypeFromCallsSniff.php b/phpcs-sniffs/Formidable/Sniffs/Commenting/AddMissingParamTypeFromCallsSniff.php new file mode 100644 index 0000000000..214864811a --- /dev/null +++ b/phpcs-sniffs/Formidable/Sniffs/Commenting/AddMissingParamTypeFromCallsSniff.php @@ -0,0 +1,516 @@ +getTokens(); + $filename = $phpcsFile->getFilename(); + + // Reset cache if we're processing a new file. + if ( $this->currentFile !== $filename ) { + $this->currentFile = $filename; + $this->functionDefinitions = array(); + } + + // Get function name. + $functionNamePtr = $phpcsFile->findNext( T_STRING, $stackPtr + 1 ); + + if ( false === $functionNamePtr ) { + return; + } + + $functionName = $tokens[ $functionNamePtr ]['content']; + + // Check if function has a docblock. + $docblock = $this->findDocblock( $phpcsFile, $stackPtr ); + + if ( false === $docblock ) { + return; + } + + // Get function parameters. + $params = $this->getParameters( $phpcsFile, $stackPtr ); + + if ( empty( $params ) ) { + return; + } + + // Get existing @param tags with their types. + $existingParams = $this->getExistingParamTags( $phpcsFile, $docblock ); + + // Find parameters that are missing @param comments entirely. + $missingParamComments = array(); + + foreach ( $params as $index => $param ) { + if ( ! isset( $existingParams[ $param['name'] ] ) ) { + $missingParamComments[ $index ] = $param; + } + } + + // Only proceed if there are parameters missing @param comments. + if ( empty( $missingParamComments ) ) { + return; + } + + // Determine if this is a method or a function. + $isMethod = false; + $className = ''; + + $classPtr = $phpcsFile->findPrevious( array( T_CLASS, T_TRAIT ), $stackPtr - 1 ); + + if ( false !== $classPtr ) { + $classNamePtr = $phpcsFile->findNext( T_STRING, $classPtr + 1 ); + + if ( false !== $classNamePtr ) { + $isMethod = true; + $className = $tokens[ $classNamePtr ]['content']; + } + } + + // Find all call sites for this function in the file. + $callSiteTypes = $this->findCallSiteTypes( $phpcsFile, $functionName, $isMethod, $className, count( $params ) ); + + if ( empty( $callSiteTypes ) ) { + return; + } + + // Check each missing parameter for literal types from call sites. + $paramsToAdd = array(); + + foreach ( $missingParamComments as $index => $param ) { + $paramName = $param['name']; + + if ( ! isset( $callSiteTypes[ $index ] ) ) { + continue; + } + + $literalTypes = array_unique( $callSiteTypes[ $index ] ); + + if ( ! empty( $literalTypes ) ) { + $paramsToAdd[ $paramName ] = array( + 'types' => $literalTypes, + 'index' => $index, + ); + } + } + + if ( empty( $paramsToAdd ) ) { + return; + } + + // Report and fix each missing @param. + foreach ( $paramsToAdd as $paramName => $info ) { + $typeString = implode( '|', $info['types'] ); + + $fix = $phpcsFile->addFixableError( + 'Missing @param %s %s (detected from call site literals)', + $docblock, + 'MissingParamFromCalls', + array( $typeString, $paramName ) + ); + + if ( $fix ) { + $this->addParamTag( $phpcsFile, $docblock, $typeString, $paramName ); + } + } + } + + /** + * Find the docblock for a function. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The function token position. + * + * @return false|int The docblock opener position, or false. + */ + private function findDocblock( File $phpcsFile, $stackPtr ) { + $tokens = $phpcsFile->getTokens(); + + $ignore = array( + T_WHITESPACE, + T_STATIC, + T_PUBLIC, + T_PRIVATE, + T_PROTECTED, + T_ABSTRACT, + T_FINAL, + ); + + $prev = $phpcsFile->findPrevious( $ignore, $stackPtr - 1, null, true ); + + if ( false !== $prev && $tokens[ $prev ]['code'] === T_DOC_COMMENT_CLOSE_TAG ) { + return $tokens[ $prev ]['comment_opener']; + } + + return false; + } + + /** + * Get function parameters. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The function token position. + * + * @return array Array of parameter info. + */ + private function getParameters( File $phpcsFile, $stackPtr ) { + $tokens = $phpcsFile->getTokens(); + $params = array(); + + if ( ! isset( $tokens[ $stackPtr ]['parenthesis_opener'] ) ) { + return $params; + } + + $opener = $tokens[ $stackPtr ]['parenthesis_opener']; + $closer = $tokens[ $stackPtr ]['parenthesis_closer']; + + for ( $i = $opener + 1; $i < $closer; $i++ ) { + if ( $tokens[ $i ]['code'] === T_VARIABLE ) { + $params[] = array( + 'name' => $tokens[ $i ]['content'], + 'token' => $i, + ); + } + } + + return $params; + } + + /** + * Get existing @param tags from docblock with their types. + * + * @param File $phpcsFile The file being scanned. + * @param int $docblock The docblock opener position. + * + * @return array Associative array of param name => array('type' => string, 'token' => int). + */ + private function getExistingParamTags( File $phpcsFile, $docblock ) { + $tokens = $phpcsFile->getTokens(); + $existing = array(); + + if ( ! isset( $tokens[ $docblock ]['comment_closer'] ) ) { + return $existing; + } + + $closer = $tokens[ $docblock ]['comment_closer']; + + for ( $i = $docblock; $i < $closer; $i++ ) { + if ( $tokens[ $i ]['code'] !== T_DOC_COMMENT_TAG ) { + continue; + } + + if ( $tokens[ $i ]['content'] !== '@param' ) { + continue; + } + + // Find the type and parameter name in this @param line. + for ( $j = $i + 1; $j < $closer; $j++ ) { + if ( $tokens[ $j ]['code'] === T_DOC_COMMENT_STRING ) { + $content = $tokens[ $j ]['content']; + + // Parse "type $varName description" format. + if ( preg_match( '/^([^\s]+)\s+(\$\w+)/', $content, $matches ) ) { + $existing[ $matches[2] ] = array( + 'type' => $matches[1], + 'token' => $j, + ); + } + break; + } + + if ( $tokens[ $j ]['code'] === T_DOC_COMMENT_TAG ) { + break; + } + } + } + + return $existing; + } + + /** + * Find all call sites for a function and determine literal types passed. + * + * @param File $phpcsFile The file being scanned. + * @param string $functionName The function name to search for. + * @param bool $isMethod Whether this is a method. + * @param string $className The class name if this is a method. + * @param int $paramCount The number of parameters. + * + * @return array Array of parameter index => array of literal types. + */ + private function findCallSiteTypes( File $phpcsFile, $functionName, $isMethod, $className, $paramCount ) { + $tokens = $phpcsFile->getTokens(); + $types = array(); + $fileEnd = count( $tokens ); + + for ( $i = 0; $i < $fileEnd; $i++ ) { + if ( $tokens[ $i ]['code'] !== T_STRING ) { + continue; + } + + if ( $tokens[ $i ]['content'] !== $functionName ) { + continue; + } + + // Check if this is a function call (followed by parenthesis). + $nextNonWhitespace = $phpcsFile->findNext( T_WHITESPACE, $i + 1, null, true ); + + if ( false === $nextNonWhitespace || $tokens[ $nextNonWhitespace ]['code'] !== T_OPEN_PARENTHESIS ) { + continue; + } + + // For methods, check if it's called on the right class or $this. + if ( $isMethod ) { + $prevNonWhitespace = $phpcsFile->findPrevious( T_WHITESPACE, $i - 1, null, true ); + + if ( false === $prevNonWhitespace ) { + continue; + } + + $prevCode = $tokens[ $prevNonWhitespace ]['code']; + + // Must be preceded by -> or ::. + if ( $prevCode !== T_OBJECT_OPERATOR && $prevCode !== T_NULLSAFE_OBJECT_OPERATOR && $prevCode !== T_DOUBLE_COLON ) { + continue; + } + + // For ::, check if it's self::, static::, or ClassName::. + if ( $prevCode === T_DOUBLE_COLON ) { + $classRef = $phpcsFile->findPrevious( T_WHITESPACE, $prevNonWhitespace - 1, null, true ); + + if ( false === $classRef ) { + continue; + } + + $classRefContent = $tokens[ $classRef ]['content']; + + if ( $classRefContent !== 'self' && $classRefContent !== 'static' && $classRefContent !== $className ) { + continue; + } + } + } + + // Get the arguments passed to this call. + $openParen = $nextNonWhitespace; + $closeParen = $tokens[ $openParen ]['parenthesis_closer']; + + $argTypes = $this->getArgumentLiteralTypes( $phpcsFile, $openParen, $closeParen ); + + foreach ( $argTypes as $argIndex => $argType ) { + if ( null === $argType ) { + continue; + } + + if ( ! isset( $types[ $argIndex ] ) ) { + $types[ $argIndex ] = array(); + } + + if ( ! in_array( $argType, $types[ $argIndex ], true ) ) { + $types[ $argIndex ][] = $argType; + } + } + } + + return $types; + } + + /** + * Get the literal types of arguments in a function call. + * + * @param File $phpcsFile The file being scanned. + * @param int $openParen The opening parenthesis position. + * @param int $closeParen The closing parenthesis position. + * + * @return array Array of argument index => literal type (or null if not a literal). + */ + private function getArgumentLiteralTypes( File $phpcsFile, $openParen, $closeParen ) { + $tokens = $phpcsFile->getTokens(); + $argTypes = array(); + $argIndex = 0; + $depth = 0; + + for ( $i = $openParen + 1; $i < $closeParen; $i++ ) { + $code = $tokens[ $i ]['code']; + $content = $tokens[ $i ]['content']; + + // Track nested parentheses/brackets. + if ( in_array( $code, array( T_OPEN_PARENTHESIS, T_OPEN_SHORT_ARRAY, T_OPEN_SQUARE_BRACKET ), true ) ) { + ++$depth; + } elseif ( in_array( $code, array( T_CLOSE_PARENTHESIS, T_CLOSE_SHORT_ARRAY, T_CLOSE_SQUARE_BRACKET ), true ) ) { + --$depth; + } + + // Comma at depth 0 means next argument. + if ( $code === T_COMMA && $depth === 0 ) { + ++$argIndex; + continue; + } + + // Skip if we're inside nested structure. + if ( $depth > 0 ) { + continue; + } + + // Skip whitespace. + if ( $code === T_WHITESPACE ) { + continue; + } + + // Only record if we haven't set a type for this argument yet. + if ( isset( $argTypes[ $argIndex ] ) ) { + continue; + } + + // Determine literal type. + $literalType = $this->getLiteralType( $code, $content ); + $argTypes[ $argIndex ] = $literalType; + } + + return $argTypes; + } + + /** + * Get the type of a literal token. + * + * @param int $code The token code. + * @param string $content The token content. + * + * @return string|null The literal type, or null if not a recognized literal. + */ + private function getLiteralType( $code, $content ) { + // String literals. + if ( $code === T_CONSTANT_ENCAPSED_STRING ) { + return 'string'; + } + + // Integer literals. + if ( $code === T_LNUMBER ) { + return 'int'; + } + + // Float literals. + if ( $code === T_DNUMBER ) { + return 'float'; + } + + // Array literals. + if ( $code === T_ARRAY || $code === T_OPEN_SHORT_ARRAY ) { + return 'array'; + } + + // Boolean literals. + if ( $code === T_TRUE || $code === T_FALSE ) { + return 'bool'; + } + + // Null. + if ( $code === T_NULL ) { + return 'null'; + } + + return null; + } + + /** + * Add a @param tag to the docblock. + * + * @param File $phpcsFile The file being scanned. + * @param int $docblock The docblock opener position. + * @param string $type The type string. + * @param string $paramName The parameter name. + * + * @return void + */ + private function addParamTag( File $phpcsFile, $docblock, $type, $paramName ) { + $tokens = $phpcsFile->getTokens(); + + if ( ! isset( $tokens[ $docblock ]['comment_closer'] ) ) { + return; + } + + $closer = $tokens[ $docblock ]['comment_closer']; + + // Find the last @param tag to insert after. + $lastParamEnd = null; + + for ( $i = $docblock; $i < $closer; $i++ ) { + if ( $tokens[ $i ]['code'] !== T_DOC_COMMENT_TAG ) { + continue; + } + + if ( $tokens[ $i ]['content'] === '@param' ) { + // Find the end of this @param's content (the T_DOC_COMMENT_STRING). + for ( $j = $i + 1; $j < $closer; $j++ ) { + if ( $tokens[ $j ]['code'] === T_DOC_COMMENT_STRING ) { + $lastParamEnd = $j; + break; + } + + if ( $tokens[ $j ]['code'] === T_DOC_COMMENT_TAG ) { + break; + } + } + } + } + + if ( null === $lastParamEnd ) { + // No existing @param tags - don't add (let other sniffs handle this). + return; + } + + // Append to the last @param's content string. + $existingContent = $tokens[ $lastParamEnd ]['content']; + $newContent = $existingContent . "\n\t * @param " . $type . ' ' . $paramName; + + $phpcsFile->fixer->beginChangeset(); + $phpcsFile->fixer->replaceToken( $lastParamEnd, $newContent ); + $phpcsFile->fixer->endChangeset(); + } +} diff --git a/phpcs-sniffs/Formidable/ruleset.xml b/phpcs-sniffs/Formidable/ruleset.xml index 55391ce7c5..c4d97aeb47 100644 --- a/phpcs-sniffs/Formidable/ruleset.xml +++ b/phpcs-sniffs/Formidable/ruleset.xml @@ -73,4 +73,5 @@ + diff --git a/tests/phpunit/emails/test_FrmEmail.php b/tests/phpunit/emails/test_FrmEmail.php index e8abb64a89..34d5edbbb9 100644 --- a/tests/phpunit/emails/test_FrmEmail.php +++ b/tests/phpunit/emails/test_FrmEmail.php @@ -451,6 +451,8 @@ protected function create_entry( $form ) { /** * @param array $expected * @param array $mock_email + * @param string $cc_status + * @param string $bcc_status */ protected function check_recipients( $expected, $mock_email, $cc_status = 'yes_cc', $bcc_status = 'yes_bcc' ) { $this->assertSame( $expected['to'], $mock_email['to'], 'To does not match expected.' ); diff --git a/tests/phpunit/entries/test_FrmShowEntryShortcode.php b/tests/phpunit/entries/test_FrmShowEntryShortcode.php index 42bd0e0159..cf36da8510 100644 --- a/tests/phpunit/entries/test_FrmShowEntryShortcode.php +++ b/tests/phpunit/entries/test_FrmShowEntryShortcode.php @@ -824,6 +824,7 @@ protected function table_footer() { /** * @param array $atts + * @param string $field_key * * @return string */ @@ -865,6 +866,7 @@ protected function two_cell_table_row_for_value( $label, $field_value, $atts ) { /** * @param array $atts + * @param string $field_key * * @return string */ @@ -884,6 +886,7 @@ protected function one_cell_table_row( $field_key, $atts ) { /** * @param array $atts + * @param string $field_key * * @return string */ @@ -906,6 +909,7 @@ protected function label_and_value_plain_text_row( $field_key, $atts ) { /** * @param array $atts + * @param string $field_key * * @return string */