Completed
Push — add/cs-package ( b423a9...64f35d )
by
unknown
21:19 queued 14:28
created

is_previously_documented()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 2
dl 0
loc 9
rs 9.9666
c 0
b 0
f 0
1
<?php
2
/**
3
 * This sniff verifies a WordPress hook function has a preceding docblock.
4
 *
5
 * @package   automattic/jetpack-coding-standards
6
 */
7
8
namespace Automattic\Jetpack\CodingStandards\Sniffs\InlineDocs;
9
10
use WordPressCS\WordPress\AbstractFunctionRestrictionsSniff;
11
use PHP_CodeSniffer\Util\Tokens;
12
13
/**
14
 * Class HooksMustHaveDocblockSniff
15
 *
16
 * @package Automattic\Jetpack\CodingStandards\Sniffs\InlineDocs
17
 */
18
class HooksMustHaveDocblockSniff extends AbstractFunctionRestrictionsSniff {
19
20
	/**
21
	 * Array of WordPress hook execution functions.
22
	 *
23
	 * @var array WordPress hook execution function name => filter or action.
24
	 */
25
	protected $hook_functions = array(
26
		'apply_filters'            => 'filter',
27
		'apply_filters_ref_array'  => 'filter',
28
		'apply_filters_deprecated' => 'filter',
29
		'do_action'                => 'action',
30
		'do_action_ref_array'      => 'action',
31
		'do_action_deprecated'     => 'action',
32
	);
33
34
	/**
35
	 * Groups of functions to restrict.
36
	 *
37
	 * Example: groups => array(
38
	 *  'lambda' => array(
39
	 *      'type'      => 'error' | 'warning',
40
	 *      'message'   => 'Use anonymous functions instead please!',
41
	 *      'functions' => array( 'file_get_contents', 'create_function' ),
42
	 *  )
43
	 * )
44
	 *
45
	 * @return array
46
	 */
47
	public function getGroups() {
48
		return array(
49
			'hooks' => array(
50
				'functions' => array_keys( $this->hook_functions ),
51
			),
52
		);
53
	}
54
55
	/**
56
	 * Process a matched token.
57
	 *
58
	 * @since 1.0.0 Logic split off from the `process_token()` method.
59
	 *
60
	 * @param int    $stack_ptr       The position of the current token in the stack.
61
	 * @param string $group_name      The name of the group which was matched.
62
	 * @param string $matched_content The token content (function name) which was matched.
63
	 *
64
	 * @return int|void Integer stack pointer to skip forward or void to continue
65
	 *                  normal file processing.
66
	 */
67
	public function process_matched_token( $stack_ptr, $group_name, $matched_content ) {
68
69
		if ( ! $this->verify_valid_match( $stack_ptr ) ) {
70
			return;
71
		}
72
73
		$previous_comment = $this->return_previous_comment( $stack_ptr );
74
75
		if ( false !== $previous_comment ) {
76
			/*
77
			 * Check to determine if there is a comment immediately preceding the function call.
78
			 */
79
			if ( ( $this->tokens[ $previous_comment ]['line'] + 1 ) !== $this->tokens[ $stack_ptr ]['line'] ) {
80
				$this->phpcsFile->addError(
81
					'The inline documentation for a hook must be on the line immediately before the function call.',
82
					$stack_ptr,
83
					'DocMustBePreceding'
84
				);
85
			}
86
87
			/*
88
			 * Check that the comment starts is a docblock.
89
			 */
90
			if ( \T_DOC_COMMENT_CLOSE_TAG !== $this->tokens[ $previous_comment ]['code'] ) {
91
				$this->phpcsFile->addError(
92
					'Hooks must include a docblock with /** formatting */',
93
					$stack_ptr,
94
					'NoDocblockFound'
95
				);
96
				// return; Do we need to return here? Can we keep going?
97
			}
98
99
			/*
100
			 * Process docblock tags.
101
			 */
102
			$comment_end   = $previous_comment;
103
			$comment_start = $this->return_comment_start( $comment_end );
104
			$has           = array(
105
				'since' => false,
106
			);
107
108
			// The comment isn't a docblock or is documented elsewhere, so we're going to stop here.
109
			if ( ! $comment_start || $this->is_previously_documented( $comment_start, $comment_end ) ) {
110
				return;
111
			}
112
113
			foreach ( $this->tokens[ $comment_start ]['comment_tags'] as $tag ) {
114
				// Is the next tag of the docblock the "@since" tag?
115
				if ( '@since' === $this->tokens[ $tag ]['content'] ) {
116
					$has['since'] = true;
117
					// Find the next string, which will be the text after the @since.
118
					$string = $this->phpcsFile->findNext( T_DOC_COMMENT_STRING, $tag, $comment_end );
119
					// If it is false, there is no text or if the text is on the another line, error.
120
					if ( false === $string || $this->tokens[ $string ]['line'] !== $this->tokens[ $tag ]['line'] ) {
121
						$this->phpcsFile->addError( 'Since tag must have a value.', $tag, 'EmptySince' );
122
					} elseif ( ! preg_match( '\'/^\d+\.\d+\.\d+/\'', $string ) ) { // Requires X.Y.Z. Trailing 0 is needed for a major release.
123
						$this->phpcsFile->addError( 'Since tag must have a X.Y.Z. version number.', $tag, 'InvalidSince' );
124
					}
125
				}
126
			}
127
128 View Code Duplication
			foreach ( $has as $name => $present ) {
129
				if ( ! $present ) {
130
					$this->phpcsFile->addError( 'Hook documentation is missing a tag: ' . $name, $comment_start, 'No' . ucfirst( $name ) );
131
				}
132
			}
133
		}
134
	}
135
136
	/**
137
	 * Helper function to identify the comment previous to a pointer reference.
138
	 *
139
	 * @param int $stack_ptr       The position of the token in the stack.
140
	 */
141
	protected function return_previous_comment( $stack_ptr ) {
142
		return $this->phpcsFile->findPrevious( Tokens::$commentTokens, ( $stack_ptr - 1 ) );
143
	}
144
145
	/**
146
	 * Returns the starting comment reference when passed an end reference.
147
	 *
148
	 * Used to help set bounds for searching through a docblock.
149
	 *
150
	 * @param int $end       The position of the ending token in the stack.
151
	 */
152
	protected function return_comment_start( $end ) {
153
		return ( isset( $this->tokens[ $end ]['comment_opener'] ) ) ? $this->tokens[ $end ]['comment_opener'] : false;
154
	}
155
156
	/**
157
	 * Determines if a filter docblock is referencing a complete docblock elsewhere.
158
	 *
159
	 * @param int $start       The position of the starting token in the stack.
160
	 * @param int $end       The position of the ending token in the stack.
161
	 */
162
	protected function is_previously_documented( $start, $end ) {
163
		$string = $this->phpcsFile->findNext( T_DOC_COMMENT_STRING, $start, $end );
164
		// If the call is documented elsewhere, stop here.
165
		if ( 0 === strpos( $this->tokens[ $string ]['content'], 'This filter is documented in' ) ) {
166
			return true;
167
		}
168
169
		return false;
170
	}
171
172
	/**
173
	 * Verifies the match is valid and worthy of continued processing.
174
	 *
175
	 * @param int $stack_ptr       The position of the token in the stack.
176
	 *
177
	 * @return bool True for valid.
178
	 */
179
	protected function verify_valid_match( $stack_ptr ) {
180
		$func_open_paren_token = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $stack_ptr + 1 ), null, true );
181
		if ( false === $func_open_paren_token
182
			|| \T_OPEN_PARENTHESIS !== $this->tokens[ $func_open_paren_token ]['code']
183
			|| ! isset( $this->tokens[ $func_open_paren_token ]['parenthesis_closer'] )
184
		) {
185
			// Live coding, parse error or not a function call.
186
			return false;
187
		}
188
189
		return true;
190
	}
191
}
192