diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
index f0a6bc5a55..8a46cdb28e 100644
--- a/.php-cs-fixer.php
+++ b/.php-cs-fixer.php
@@ -19,7 +19,6 @@
'no_useless_else' => true,
'no_superfluous_elseif' => true,
'elseif' => true,
- 'phpdoc_add_missing_param_annotation' => true,
'no_extra_blank_lines' => true,
'no_trailing_whitespace' => true,
'no_whitespace_in_blank_line' => true,
diff --git a/classes/helpers/FrmAppHelper.php b/classes/helpers/FrmAppHelper.php
index 4e39c26811..78851d3c0d 100644
--- a/classes/helpers/FrmAppHelper.php
+++ b/classes/helpers/FrmAppHelper.php
@@ -1167,6 +1167,8 @@ private static function allowed_html( $allowed ) {
/**
* @since 2.05.03
+ *
+ * @return array
*/
private static function safe_html() {
$allow_class = array(
diff --git a/classes/helpers/FrmEntriesListHelper.php b/classes/helpers/FrmEntriesListHelper.php
index a7121f6fc8..b157de81c1 100644
--- a/classes/helpers/FrmEntriesListHelper.php
+++ b/classes/helpers/FrmEntriesListHelper.php
@@ -17,6 +17,9 @@ class FrmEntriesListHelper extends FrmListHelper {
*/
public $total_items = 0;
+ /**
+ * @param array $args
+ */
public function __construct( $args ) {
parent::__construct( $args );
$this->screen->set_screen_reader_content(
diff --git a/classes/helpers/FrmFormsListHelper.php b/classes/helpers/FrmFormsListHelper.php
index fdf5a85983..0277cfc59c 100644
--- a/classes/helpers/FrmFormsListHelper.php
+++ b/classes/helpers/FrmFormsListHelper.php
@@ -12,6 +12,9 @@ class FrmFormsListHelper extends FrmListHelper {
public $total_items = 0;
+ /**
+ * @param array $args
+ */
public function __construct( $args ) {
$this->status = self::get_param( array( 'param' => 'form_type' ) );
diff --git a/classes/helpers/FrmListHelper.php b/classes/helpers/FrmListHelper.php
index d633ac5fe7..f70ae754c0 100644
--- a/classes/helpers/FrmListHelper.php
+++ b/classes/helpers/FrmListHelper.php
@@ -162,6 +162,9 @@ public function ajax_user_can() {
return current_user_can( 'administrator' );
}
+ /**
+ * @return array
+ */
public function get_columns() {
return array();
}
diff --git a/classes/models/FrmMigrate.php b/classes/models/FrmMigrate.php
index ccb064bafc..f122bf9823 100644
--- a/classes/models/FrmMigrate.php
+++ b/classes/models/FrmMigrate.php
@@ -390,6 +390,9 @@ private function migrate_data( $old_db_version ) {
}
}
+ /**
+ * @return bool
+ */
public function uninstall() {
if ( ! current_user_can( 'administrator' ) ) {
$frm_settings = FrmAppHelper::get_settings();
diff --git a/classes/models/FrmOnSubmitAction.php b/classes/models/FrmOnSubmitAction.php
index a8f1d956e2..afba42dc46 100644
--- a/classes/models/FrmOnSubmitAction.php
+++ b/classes/models/FrmOnSubmitAction.php
@@ -50,6 +50,9 @@ public function form( $instance, $args = array() ) {
include FrmAppHelper::plugin_path() . '/classes/views/frm-form-actions/on_submit_settings.php';
}
+ /**
+ * @return array
+ */
public function get_defaults() {
return array(
'success_action' => FrmOnSubmitHelper::get_default_action_type(),
diff --git a/classes/models/FrmSpamCheckUseWPComments.php b/classes/models/FrmSpamCheckUseWPComments.php
index 420fee2d43..5d856c102c 100644
--- a/classes/models/FrmSpamCheckUseWPComments.php
+++ b/classes/models/FrmSpamCheckUseWPComments.php
@@ -13,6 +13,9 @@
class FrmSpamCheckUseWPComments extends FrmSpamCheck {
+ /**
+ * @return bool
+ */
protected function check() {
$spam_comments = get_comments(
array(
diff --git a/classes/models/FrmSpamCheckWPDisallowedWords.php b/classes/models/FrmSpamCheckWPDisallowedWords.php
index 7068ba17b2..82a0535283 100644
--- a/classes/models/FrmSpamCheckWPDisallowedWords.php
+++ b/classes/models/FrmSpamCheckWPDisallowedWords.php
@@ -13,6 +13,9 @@
class FrmSpamCheckWPDisallowedWords extends FrmSpamCheck {
+ /**
+ * @return bool
+ */
public function check() {
$mod_keys = trim( $this->get_disallowed_words() );
diff --git a/classes/models/fields/FrmFieldNumber.php b/classes/models/fields/FrmFieldNumber.php
index 0596b4d306..8a6711324e 100644
--- a/classes/models/fields/FrmFieldNumber.php
+++ b/classes/models/fields/FrmFieldNumber.php
@@ -59,6 +59,9 @@ protected function add_extra_html_atts( $args, &$input_html ) {
$this->add_min_max( $args, $input_html );
}
+ /**
+ * @param array $args
+ */
public function validate( $args ) {
$errors = array();
diff --git a/classes/models/fields/FrmFieldText.php b/classes/models/fields/FrmFieldText.php
index 3449867868..bd9bb9bbe8 100644
--- a/classes/models/fields/FrmFieldText.php
+++ b/classes/models/fields/FrmFieldText.php
@@ -39,6 +39,9 @@ protected function field_settings_for_type() {
);
}
+ /**
+ * @param array $args
+ */
public function validate( $args ) {
$errors = parent::validate( $args );
$max_length = intval( FrmField::get_option( $this->field, 'max' ) );
diff --git a/classes/models/fields/FrmFieldUrl.php b/classes/models/fields/FrmFieldUrl.php
index 0f947476cb..dc09eea21d 100644
--- a/classes/models/fields/FrmFieldUrl.php
+++ b/classes/models/fields/FrmFieldUrl.php
@@ -65,6 +65,9 @@ protected function fill_default_atts( &$atts ) {
}
}
+ /**
+ * @param array $args
+ */
public function validate( $args ) {
$value = $args['value'];
diff --git a/classes/models/fields/FrmFieldUserID.php b/classes/models/fields/FrmFieldUserID.php
index 1ec0b2c0b8..e7a7f38fb5 100644
--- a/classes/models/fields/FrmFieldUserID.php
+++ b/classes/models/fields/FrmFieldUserID.php
@@ -48,6 +48,9 @@ protected function include_form_builder_file() {
return FrmAppHelper::plugin_path() . '/classes/views/frm-fields/back-end/field-user-id.php';
}
+ /**
+ * @param array $args
+ */
public function prepare_field_html( $args ) {
$args = $this->fill_display_field_values( $args );
$value = $this->get_field_value( $args );
@@ -71,6 +74,11 @@ protected function get_field_value( $args ) {
return is_numeric( $this->field['value'] ) || $posted_value || $updating ? $this->field['value'] : $user_ID;
}
+ /**
+ * @param array $args
+ *
+ * @return array
+ */
public function validate( $args ) {
// phpcs:ignore Universal.Operators.StrictComparisons
if ( '' == $args['value'] ) {
diff --git a/phpcs-sniffs/Formidable/Sniffs/Commenting/AddMissingDocblockSniff.php b/phpcs-sniffs/Formidable/Sniffs/Commenting/AddMissingDocblockSniff.php
new file mode 100644
index 0000000000..d745997289
--- /dev/null
+++ b/phpcs-sniffs/Formidable/Sniffs/Commenting/AddMissingDocblockSniff.php
@@ -0,0 +1,771 @@
+getTokens();
+
+ // Skip if no scope (abstract method, interface method).
+ if ( ! isset( $tokens[ $stackPtr ]['scope_opener'] ) || ! isset( $tokens[ $stackPtr ]['scope_closer'] ) ) {
+ return;
+ }
+
+ // Skip if function is inside a class that extends another class.
+ if ( $this->isInChildClass( $phpcsFile, $stackPtr ) ) {
+ return;
+ }
+
+ // Check if function already has a docblock.
+ $existingDocblock = $this->findDocblock( $phpcsFile, $stackPtr );
+
+ if ( false !== $existingDocblock ) {
+ // Has docblock - check for missing @param or @return.
+ $this->processMissingAnnotations( $phpcsFile, $stackPtr, $existingDocblock );
+ return;
+ }
+
+ // No docblock - determine what we can add with certainty.
+ $params = $this->getParameters( $phpcsFile, $stackPtr );
+ $returnType = $this->detectCertainReturnType( $phpcsFile, $stackPtr );
+
+ // Get certain param types.
+ $certainParams = $this->getCertainParamTypes( $phpcsFile, $stackPtr, $params );
+
+ // Only add docblock if we have something certain to add.
+ if ( empty( $certainParams ) && null === $returnType ) {
+ return;
+ }
+
+ $fix = $phpcsFile->addFixableError(
+ 'Missing docblock for function.',
+ $stackPtr,
+ 'MissingDocblock'
+ );
+
+ if ( $fix ) {
+ $this->addDocblock( $phpcsFile, $stackPtr, $params, $certainParams, $returnType );
+ }
+ }
+
+ /**
+ * Process existing docblock for missing annotations.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The function token position.
+ * @param int $docblock The docblock opener position.
+ *
+ * @return void
+ */
+ private function processMissingAnnotations( File $phpcsFile, $stackPtr, $docblock ) {
+ $tokens = $phpcsFile->getTokens();
+
+ $params = $this->getParameters( $phpcsFile, $stackPtr );
+ $certainParams = $this->getCertainParamTypes( $phpcsFile, $stackPtr, $params );
+ $existingParams = $this->getExistingParamTags( $phpcsFile, $docblock );
+ $hasReturnTag = $this->hasReturnTag( $phpcsFile, $docblock );
+ $returnType = $this->detectCertainReturnType( $phpcsFile, $stackPtr );
+
+ // Check for missing @param tags.
+ $missingParams = array();
+
+ foreach ( $certainParams as $paramName => $paramType ) {
+ if ( ! isset( $existingParams[ $paramName ] ) ) {
+ $missingParams[ $paramName ] = $paramType;
+ }
+ }
+
+ // Check for missing @return tag.
+ $missingReturn = ( ! $hasReturnTag && null !== $returnType );
+
+ if ( empty( $missingParams ) && ! $missingReturn ) {
+ return;
+ }
+
+ $message = 'Missing';
+
+ if ( ! empty( $missingParams ) ) {
+ $message .= ' @param for: ' . implode( ', ', array_keys( $missingParams ) );
+ }
+
+ if ( $missingReturn ) {
+ $message .= ( ! empty( $missingParams ) ? ' and' : '' ) . ' @return ' . $returnType;
+ }
+
+ $fix = $phpcsFile->addFixableError(
+ $message,
+ $docblock,
+ 'MissingAnnotation'
+ );
+
+ if ( $fix ) {
+ $this->addMissingAnnotations( $phpcsFile, $docblock, $missingParams, $missingReturn ? $returnType : null );
+ }
+ }
+
+ /**
+ * 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 certain param types based on usage or naming.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The function token position.
+ * @param array $params The function parameters.
+ *
+ * @return array Associative array of param name => type.
+ */
+ private function getCertainParamTypes( File $phpcsFile, $stackPtr, $params ) {
+ $tokens = $phpcsFile->getTokens();
+ $certainTypes = array();
+
+ if ( empty( $params ) ) {
+ return $certainTypes;
+ }
+
+ $scopeOpener = $tokens[ $stackPtr ]['scope_opener'];
+ $scopeCloser = $tokens[ $stackPtr ]['scope_closer'];
+
+ foreach ( $params as $param ) {
+ $paramName = $param['name'];
+
+ // Skip if param is checked with is_array() - type is uncertain.
+ if ( $this->hasIsArrayCheck( $phpcsFile, $paramName, $scopeOpener, $scopeCloser ) ) {
+ continue;
+ }
+
+ // Check if param name is in our known array names.
+ if ( in_array( $paramName, $this->arrayParamNames, true ) ) {
+ $certainTypes[ $paramName ] = 'array';
+ continue;
+ }
+
+ // Check if param is used with [] syntax in function body.
+ if ( $this->isUsedAsArray( $phpcsFile, $paramName, $scopeOpener, $scopeCloser ) ) {
+ $certainTypes[ $paramName ] = 'array';
+ }
+ }
+
+ return $certainTypes;
+ }
+
+ /**
+ * Check if a variable is checked with is_array().
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param string $varName The variable name.
+ * @param int $scopeOpener The function scope opener.
+ * @param int $scopeCloser The function scope closer.
+ *
+ * @return bool
+ */
+ private function hasIsArrayCheck( File $phpcsFile, $varName, $scopeOpener, $scopeCloser ) {
+ $tokens = $phpcsFile->getTokens();
+
+ for ( $i = $scopeOpener + 1; $i < $scopeCloser; $i++ ) {
+ if ( $tokens[ $i ]['code'] !== T_STRING ) {
+ continue;
+ }
+
+ if ( $tokens[ $i ]['content'] !== 'is_array' ) {
+ continue;
+ }
+
+ // Check if followed by ( $varName ).
+ $openParen = $phpcsFile->findNext( T_WHITESPACE, $i + 1, $scopeCloser, true );
+
+ if ( false === $openParen || $tokens[ $openParen ]['code'] !== T_OPEN_PARENTHESIS ) {
+ continue;
+ }
+
+ $varToken = $phpcsFile->findNext( T_WHITESPACE, $openParen + 1, $scopeCloser, true );
+
+ if ( false !== $varToken && $tokens[ $varToken ]['code'] === T_VARIABLE && $tokens[ $varToken ]['content'] === $varName ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a variable is used as an array (with [] syntax).
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param string $varName The variable name.
+ * @param int $scopeOpener The function scope opener.
+ * @param int $scopeCloser The function scope closer.
+ *
+ * @return bool
+ */
+ private function isUsedAsArray( File $phpcsFile, $varName, $scopeOpener, $scopeCloser ) {
+ $tokens = $phpcsFile->getTokens();
+
+ for ( $i = $scopeOpener + 1; $i < $scopeCloser; $i++ ) {
+ if ( $tokens[ $i ]['code'] !== T_VARIABLE ) {
+ continue;
+ }
+
+ if ( $tokens[ $i ]['content'] !== $varName ) {
+ continue;
+ }
+
+ // Check if followed by [.
+ $next = $phpcsFile->findNext( T_WHITESPACE, $i + 1, $scopeCloser, true );
+
+ if ( false !== $next && $tokens[ $next ]['code'] === T_OPEN_SQUARE_BRACKET ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Detect certain return type from function body.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The function token position.
+ *
+ * @return string|null The certain return type, or null if uncertain.
+ */
+ private function detectCertainReturnType( File $phpcsFile, $stackPtr ) {
+ $tokens = $phpcsFile->getTokens();
+
+ $scopeOpener = $tokens[ $stackPtr ]['scope_opener'];
+ $scopeCloser = $tokens[ $stackPtr ]['scope_closer'];
+
+ $returnTypes = array();
+ $hasUncertainType = false;
+ $current = $scopeOpener;
+
+ while ( $current < $scopeCloser ) {
+ $return = $phpcsFile->findNext( T_RETURN, $current + 1, $scopeCloser );
+
+ if ( false === $return ) {
+ break;
+ }
+
+ // Skip if inside nested closure/function.
+ if ( $this->isInsideNestedScope( $phpcsFile, $return, $stackPtr ) ) {
+ $current = $return;
+ continue;
+ }
+
+ $type = $this->getReturnValueType( $phpcsFile, $return, $scopeCloser );
+
+ if ( null !== $type ) {
+ $returnTypes[] = $type;
+ } else {
+ $hasUncertainType = true;
+ }
+
+ $current = $return;
+ }
+
+ if ( empty( $returnTypes ) ) {
+ return null;
+ }
+
+ // All returns must be the same certain type.
+ $uniqueTypes = array_unique( $returnTypes );
+
+ if ( count( $uniqueTypes ) === 1 ) {
+ $type = $uniqueTypes[0];
+
+ // Skip int if there's an uncertain return type (e.g., return $variable).
+ if ( 'int' === $type && $hasUncertainType ) {
+ return null;
+ }
+
+ return $type;
+ }
+
+ // Multiple types - check if they're compatible.
+ // For now, only return if all are the same.
+ return null;
+ }
+
+ /**
+ * Get the type of a return value.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $returnPtr The return token position.
+ * @param int $scopeCloser The function scope closer.
+ *
+ * @return string|null The type if certain, null otherwise.
+ */
+ private function getReturnValueType( File $phpcsFile, $returnPtr, $scopeCloser ) {
+ $tokens = $phpcsFile->getTokens();
+
+ // Find the semicolon that ends this return statement.
+ $semicolon = $phpcsFile->findNext( T_SEMICOLON, $returnPtr + 1, $scopeCloser );
+
+ if ( false === $semicolon ) {
+ return null;
+ }
+
+ // Check if this return contains a ternary.
+ $ternaryThen = $phpcsFile->findNext( T_INLINE_THEN, $returnPtr + 1, $semicolon );
+
+ if ( false !== $ternaryThen ) {
+ return $this->getTernaryReturnType( $phpcsFile, $ternaryThen, $semicolon );
+ }
+
+ $next = $phpcsFile->findNext( T_WHITESPACE, $returnPtr + 1, $semicolon, true );
+
+ if ( false === $next ) {
+ return null;
+ }
+
+ return $this->getSimpleValueType( $phpcsFile, $next, $semicolon );
+ }
+
+ /**
+ * Get the type from a ternary expression if both branches return the same type.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $ternaryThen The position of the ? token.
+ * @param int $semicolon The position of the semicolon.
+ *
+ * @return string|null The type if both branches match, null otherwise.
+ */
+ private function getTernaryReturnType( File $phpcsFile, $ternaryThen, $semicolon ) {
+ $tokens = $phpcsFile->getTokens();
+
+ // Find the : that separates the two branches.
+ $ternaryElse = $phpcsFile->findNext( T_INLINE_ELSE, $ternaryThen + 1, $semicolon );
+
+ if ( false === $ternaryElse ) {
+ return null;
+ }
+
+ // Get the "then" value (between ? and :).
+ $thenValue = $phpcsFile->findNext( T_WHITESPACE, $ternaryThen + 1, $ternaryElse, true );
+
+ if ( false === $thenValue ) {
+ return null;
+ }
+
+ // Get the "else" value (between : and ;).
+ $elseValue = $phpcsFile->findNext( T_WHITESPACE, $ternaryElse + 1, $semicolon, true );
+
+ if ( false === $elseValue ) {
+ return null;
+ }
+
+ $thenType = $this->getSimpleValueType( $phpcsFile, $thenValue, $ternaryElse );
+ $elseType = $this->getSimpleValueType( $phpcsFile, $elseValue, $semicolon );
+
+ // Only return type if both branches have the same certain type.
+ if ( null !== $thenType && $thenType === $elseType ) {
+ return $thenType;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the type of a simple value token.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $valuePtr The value token position.
+ * @param int $endPtr The end boundary.
+ *
+ * @return string|null The type if certain, null otherwise.
+ */
+ private function getSimpleValueType( File $phpcsFile, $valuePtr, $endPtr ) {
+ $tokens = $phpcsFile->getTokens();
+ $code = $tokens[ $valuePtr ]['code'];
+
+ // Hardcoded string.
+ if ( $code === T_CONSTANT_ENCAPSED_STRING ) {
+ return 'string';
+ }
+
+ // Hardcoded array.
+ if ( $code === T_ARRAY || $code === T_OPEN_SHORT_ARRAY ) {
+ return 'array';
+ }
+
+ // Hardcoded boolean - only if it's the only thing being returned.
+ if ( $code === T_TRUE || $code === T_FALSE ) {
+ // Make sure the next non-whitespace is the end boundary.
+ $afterBool = $phpcsFile->findNext( T_WHITESPACE, $valuePtr + 1, $endPtr, true );
+
+ if ( false === $afterBool ) {
+ return 'bool';
+ }
+
+ // There's more after the bool (like a comparison), skip.
+ return null;
+ }
+
+ // Hardcoded integer.
+ if ( $code === T_LNUMBER ) {
+ return 'int';
+ }
+
+ // Hardcoded float.
+ if ( $code === T_DNUMBER ) {
+ return 'float';
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a function is inside a class that extends another class.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The function token position.
+ *
+ * @return bool
+ */
+ private function isInChildClass( File $phpcsFile, $stackPtr ) {
+ $tokens = $phpcsFile->getTokens();
+
+ // Find the class this function belongs to.
+ if ( ! isset( $tokens[ $stackPtr ]['conditions'] ) ) {
+ return false;
+ }
+
+ foreach ( $tokens[ $stackPtr ]['conditions'] as $scopePtr => $scopeType ) {
+ if ( $scopeType !== T_CLASS ) {
+ continue;
+ }
+
+ // Found the class - check if it extends another class.
+ $extendsToken = $phpcsFile->findNext( T_EXTENDS, $scopePtr, $tokens[ $scopePtr ]['scope_opener'] );
+
+ if ( false !== $extendsToken ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a token is inside a nested closure or function.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $tokenPtr The token to check.
+ * @param int $functionPtr The outer function token.
+ *
+ * @return bool
+ */
+ private function isInsideNestedScope( File $phpcsFile, $tokenPtr, $functionPtr ) {
+ $tokens = $phpcsFile->getTokens();
+
+ if ( isset( $tokens[ $tokenPtr ]['conditions'] ) ) {
+ foreach ( $tokens[ $tokenPtr ]['conditions'] as $scopePtr => $scopeType ) {
+ if ( $scopePtr !== $functionPtr && in_array( $scopeType, array( T_CLOSURE, T_FUNCTION ), true ) ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get existing @param tags from docblock.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $docblock The docblock opener position.
+ *
+ * @return array Associative array of param name => true.
+ */
+ 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 parameter name in this @param line.
+ for ( $j = $i + 1; $j < $closer; $j++ ) {
+ if ( $tokens[ $j ]['code'] === T_DOC_COMMENT_STRING ) {
+ // Extract variable name from the string.
+ if ( preg_match( '/(\$\w+)/', $tokens[ $j ]['content'], $matches ) ) {
+ $existing[ $matches[1] ] = true;
+ }
+ break;
+ }
+
+ if ( $tokens[ $j ]['code'] === T_DOC_COMMENT_TAG ) {
+ break;
+ }
+ }
+ }
+
+ return $existing;
+ }
+
+ /**
+ * Check if docblock has @return tag.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $docblock The docblock opener position.
+ *
+ * @return bool
+ */
+ private function hasReturnTag( File $phpcsFile, $docblock ) {
+ $tokens = $phpcsFile->getTokens();
+
+ if ( ! isset( $tokens[ $docblock ]['comment_closer'] ) ) {
+ return false;
+ }
+
+ $closer = $tokens[ $docblock ]['comment_closer'];
+
+ for ( $i = $docblock; $i < $closer; $i++ ) {
+ if ( $tokens[ $i ]['code'] === T_DOC_COMMENT_TAG && $tokens[ $i ]['content'] === '@return' ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Add a new docblock to a function.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The function token position.
+ * @param array $params All function parameters.
+ * @param array $certainParams Certain param types.
+ * @param string|null $returnType The return type, or null.
+ *
+ * @return void
+ */
+ private function addDocblock( File $phpcsFile, $stackPtr, $params, $certainParams, $returnType ) {
+ $tokens = $phpcsFile->getTokens();
+ $fixer = $phpcsFile->fixer;
+
+ // Find the line start.
+ $lineStart = $stackPtr;
+
+ while ( $lineStart > 0 && $tokens[ $lineStart - 1 ]['line'] === $tokens[ $stackPtr ]['line'] ) {
+ $lineStart--;
+ }
+
+ // Get indentation.
+ $indent = '';
+
+ if ( $tokens[ $lineStart ]['code'] === T_WHITESPACE ) {
+ $indent = $tokens[ $lineStart ]['content'];
+ }
+
+ // Build docblock.
+ $docblock = $indent . "/**\n";
+
+ // Add @param tags for certain types only.
+ foreach ( $params as $param ) {
+ $paramName = $param['name'];
+
+ if ( isset( $certainParams[ $paramName ] ) ) {
+ $docblock .= $indent . " * @param " . $certainParams[ $paramName ] . " " . $paramName . "\n";
+ }
+ }
+
+ // Add @return if certain.
+ if ( null !== $returnType ) {
+ if ( ! empty( $certainParams ) ) {
+ $docblock .= $indent . " *\n";
+ }
+ $docblock .= $indent . " * @return " . $returnType . "\n";
+ }
+
+ $docblock .= $indent . " */\n";
+
+ $fixer->beginChangeset();
+ $fixer->addContentBefore( $lineStart, $docblock );
+ $fixer->endChangeset();
+ }
+
+ /**
+ * Add missing annotations to existing docblock.
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $docblock The docblock opener position.
+ * @param array $missingParams Missing param types.
+ * @param string|null $returnType Missing return type, or null.
+ *
+ * @return void
+ */
+ private function addMissingAnnotations( File $phpcsFile, $docblock, $missingParams, $returnType ) {
+ $tokens = $phpcsFile->getTokens();
+ $fixer = $phpcsFile->fixer;
+
+ $closer = $tokens[ $docblock ]['comment_closer'];
+
+ // Find the last line before closing.
+ $lastContentLine = $tokens[ $closer ]['line'] - 1;
+
+ // Get indentation from docblock opener.
+ $indent = '';
+ $lineStart = $docblock;
+
+ while ( $lineStart > 0 && $tokens[ $lineStart - 1 ]['line'] === $tokens[ $docblock ]['line'] ) {
+ $lineStart--;
+ }
+
+ if ( $tokens[ $lineStart ]['code'] === T_WHITESPACE ) {
+ $indent = $tokens[ $lineStart ]['content'];
+ }
+
+ // Find position to insert (before the closing tag).
+ $insertBefore = $closer;
+
+ // Look for existing @return or @param to insert after.
+ $lastTag = null;
+
+ for ( $i = $docblock; $i < $closer; $i++ ) {
+ if ( $tokens[ $i ]['code'] === T_DOC_COMMENT_TAG ) {
+ $lastTag = $i;
+ }
+ }
+
+ $fixer->beginChangeset();
+
+ $content = '';
+
+ // Add missing @param tags.
+ foreach ( $missingParams as $paramName => $paramType ) {
+ $content .= "\n" . $indent . " * @param " . $paramType . " " . $paramName;
+ }
+
+ // Add missing @return.
+ if ( null !== $returnType ) {
+ if ( ! empty( $missingParams ) ) {
+ $content .= "\n" . $indent . " *";
+ }
+ $content .= "\n" . $indent . " * @return " . $returnType;
+ }
+
+ // Find the token just before the closing */ to insert after.
+ $insertAfter = $closer - 1;
+
+ while ( $insertAfter > $docblock && $tokens[ $insertAfter ]['code'] === T_DOC_COMMENT_WHITESPACE ) {
+ $insertAfter--;
+ }
+
+ $fixer->addContent( $insertAfter, $content );
+ $fixer->endChangeset();
+ }
+}
diff --git a/phpcs-sniffs/Formidable/ruleset.xml b/phpcs-sniffs/Formidable/ruleset.xml
index 71f0a3d615..4c7ede4a6f 100644
--- a/phpcs-sniffs/Formidable/ruleset.xml
+++ b/phpcs-sniffs/Formidable/ruleset.xml
@@ -70,4 +70,5 @@