PluginProbe
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts / 2.7.9
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts v2.7.9
2.7.9 2.7.8 2.7.7 2.7.6 2.7.5 2.7.4 trunk 1.3 2.0.4 2.0.6 2.1.91 2.2.4 2.2.7 2.2.9 2.3.1 2.3.10 2.4.10 2.4.2 2.4.4 2.4.5 2.4.6 2.4.7 2.4.8 2.4.9 2.6.0 All 30 releases
← All changes | includes/class.code-validator.php +459 -59 2.7.7 → 2.7.9 View file →
@@ -15,8 +15,149 @@
15 15 */
16 16 class WINP_Code_Validator {
17 17
18 18 /**
19 + * Validate executable snippet code by evaluating it once.
20 + *
21 + * Runs the redeclaration preflight and then evaluates the code with output
22 + * discarded, reporting syntax errors, warnings and uncaught errors. The code
23 + * is executed in the current request, exactly as the snippet editor does.
24 + *
25 + * @param string $snippet_code Unslashed snippet code.
26 + * @param string $snippet_type Snippet type.
27 + * @return array{valid:bool,message:string} Validation result.
28 + */
29 + public static function validate_code( $snippet_code, $snippet_type ) {
30 + if ( empty( $snippet_code ) ) {
31 + return [
32 + 'valid' => true,
33 + 'message' => '',
34 + ];
35 + }
36 +
37 + $redeclaration_error = self::get_redeclaration_error( $snippet_code, $snippet_type );
38 + if ( null !== $redeclaration_error ) {
39 + return [
40 + 'valid' => false,
41 + 'message' => $redeclaration_error->getMessage(),
42 + ];
43 + }
44 +
45 + $validation_errors = [];
46 +
47 + // Set custom error handler to catch warnings and notices.
48 + set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
49 + function ( $errno, $errstr, $errfile, $errline ) use ( &$validation_errors ) {
50 + // Extract line number from eval'd code if present.
51 + if ( strpos( $errfile, "eval()'d code" ) !== false ) {
52 + // translators: %1$d is the line number, %2$s is the error message.
53 + $validation_errors[] = sprintf( __( 'Line %1$d: %2$s', 'insert-php' ), $errline, $errstr );
54 + } else {
55 + $validation_errors[] = $errstr;
56 + }
57 + return true; // Don't execute PHP internal error handler.
58 + }
59 + );
60 +
61 + ob_start();
62 +
63 + try {
64 + $result = WINP_SNIPPET_TYPE_UNIVERSAL === $snippet_type
65 + ? eval( '?> ' . $snippet_code . ' <?php ' )
66 + : eval( $snippet_code );
67 + } catch ( ParseError $e ) {
68 + ob_end_clean();
69 + restore_error_handler();
70 +
71 + return [
72 + 'valid' => false,
73 + // translators: %1$d is the line number, %2$s is the error message.
74 + 'message' => sprintf( __( 'Syntax error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
75 + ];
76 + } catch ( Throwable $e ) {
77 + ob_end_clean();
78 + restore_error_handler();
79 +
80 + // For fatal errors in eval'd code, report the actual line number.
81 + if ( strpos( $e->getFile(), "eval()'d code" ) !== false ) {
82 + return [
83 + 'valid' => false,
84 + // translators: %1$d is the line number, %2$s is the error message.
85 + 'message' => sprintf( __( 'Error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
86 + ];
87 + }
88 +
89 + return [
90 + 'valid' => false,
91 + // translators: %s is the error message.
92 + 'message' => sprintf( __( 'Error: %s', 'insert-php' ), $e->getMessage() ),
93 + ];
94 + }
95 +
96 + // Discard any output (echo/print statements are normal for snippets).
97 + ob_end_clean();
98 + restore_error_handler();
99 +
100 + if ( ! empty( $validation_errors ) ) {
101 + return [
102 + 'valid' => false,
103 + 'message' => implode( '<br>', $validation_errors ),
104 + ];
105 + }
106 +
107 + if ( false === $result ) {
108 + return [
109 + 'valid' => false,
110 + 'message' => __( 'The code contains syntax errors. Please review and fix them before saving.', 'insert-php' ),
111 + ];
112 + }
113 +
114 + return [
115 + 'valid' => true,
116 + 'message' => '',
117 + ];
118 + }
119 +
120 + /**
121 + * Check executable snippet code for syntax errors without running it.
122 + *
123 + * The tokenizer parses the code in TOKEN_PARSE mode and never executes it.
124 + * Errors PHP raises only when the code runs, such as a call to an undefined
125 + * function, are left to the check that runs when the snippet is activated.
126 + * Without the tokenizer extension nothing is checked and the result says so.
127 + *
128 + * @param string $snippet_code Snippet code, without the opening tag for PHP snippets.
129 + * @param string $snippet_type Snippet type.
130 + * @return array{valid:bool,checked:bool,message:string} Check result.
131 + */
132 + public static function check_syntax( string $snippet_code, $snippet_type ): array {
133 + if ( ! self::can_tokenize() ) {
134 + return [
135 + 'valid' => true,
136 + 'checked' => false,
137 + 'message' => '',
138 + ];
139 + }
140 +
141 + try {
142 + token_get_all( self::get_source( $snippet_code, $snippet_type ), TOKEN_PARSE );
143 + } catch ( CompileError $e ) {
144 + return [
145 + 'valid' => false,
146 + 'checked' => true,
147 + // translators: %1$d is the line number, %2$s is the error message.
148 + 'message' => sprintf( __( 'Syntax error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
149 + ];
150 + }
151 +
152 + return [
153 + 'valid' => true,
154 + 'checked' => true,
155 + 'message' => '',
156 + ];
157 + }
158 +
159 + /**
19 160 * Find a top-level function declaration that would be redeclared by eval().
20 161 *
21 162 * PHP terminates the request when eval() declares an already-loaded function,
22 163 * so this conflict has to be detected before evaluating the snippet.
@@ -25,14 +166,110 @@
25 166 * @param string $snippet_type Snippet type.
26 167 * @return array{name:string,line:int}|null Conflict details, or null when none is found.
27 168 */
28 169 public static function find_function_redeclaration( $snippet_code, $snippet_type ) {
29 - $source = WINP_SNIPPET_TYPE_UNIVERSAL === $snippet_type
30 - ? $snippet_code
31 - : '<?php ' . $snippet_code;
32 - $tokens = token_get_all( $source );
170 + $redeclaration = self::scan_redeclarations( (string) $snippet_code, $snippet_type, false );
171 + if ( null === $redeclaration ) {
172 + return null;
173 + }
33 174
34 - $brace_depth = 0;
175 + return [
176 + 'name' => $redeclaration['name'],
177 + 'line' => $redeclaration['line'],
178 + ];
179 + }
180 +
181 + /**
182 + * Find a top-level function, class, interface, trait or enum that would be redeclared by eval().
183 + *
184 + * PHP terminates the request when eval() declares a name that is already in
185 + * use, so this conflict has to be detected before evaluating the snippet.
186 + *
187 + * @param string $snippet_code Snippet code.
188 + * @param string $snippet_type Snippet type.
189 + * @return array{kind:string,name:string,line:int}|null Conflict details, or null when none is found.
190 + */
191 + public static function find_redeclaration( string $snippet_code, $snippet_type ): ?array {
192 + return self::scan_redeclarations( $snippet_code, $snippet_type, true );
193 + }
194 +
195 + /**
196 + * Describe a redeclaration found by find_redeclaration().
197 + *
198 + * @param array{kind:string,name:string,line:int} $redeclaration Conflict details.
199 + * @return string
200 + */
201 + public static function get_redeclaration_message( array $redeclaration ): string {
202 + switch ( $redeclaration['kind'] ) {
203 + case 'class':
204 + // translators: %s is the fully qualified class name.
205 + return sprintf( __( 'Cannot declare class %s, because the name is already in use. Rename the class or guard its declaration with class_exists().', 'insert-php' ), $redeclaration['name'] );
206 + case 'interface':
207 + // translators: %s is the fully qualified interface name.
208 + return sprintf( __( 'Cannot declare interface %s, because the name is already in use. Rename the interface or guard its declaration with interface_exists().', 'insert-php' ), $redeclaration['name'] );
209 + case 'trait':
210 + // translators: %s is the fully qualified trait name.
211 + return sprintf( __( 'Cannot declare trait %s, because the name is already in use. Rename the trait or guard its declaration with trait_exists().', 'insert-php' ), $redeclaration['name'] );
212 + case 'enum':
213 + // translators: %s is the fully qualified enum name.
214 + return sprintf( __( 'Cannot declare enum %s, because the name is already in use. Rename the enum or guard its declaration with enum_exists().', 'insert-php' ), $redeclaration['name'] );
215 + default:
216 + // translators: %s is the fully qualified function name.
217 + return sprintf( __( 'Cannot redeclare function %s(). Rename the function or guard its declaration with function_exists().', 'insert-php' ), $redeclaration['name'] );
218 + }
219 + }
220 +
221 + /**
222 + * Build the error for a top-level declaration the snippet would redeclare.
223 + *
224 + * @param string $snippet_code Snippet code.
225 + * @param string $snippet_type Snippet type.
226 + * @return ErrorException|null Error carrying the snippet line, or null when none is found.
227 + */
228 + public static function get_redeclaration_error( string $snippet_code, $snippet_type ): ?ErrorException {
229 + $redeclaration = self::find_redeclaration( $snippet_code, $snippet_type );
230 + if ( null === $redeclaration ) {
231 + return null;
232 + }
233 +
234 + return new ErrorException(
235 + // translators: %1$d is the line number, %2$s is the error message.
236 + sprintf( __( 'Line %1$d: %2$s', 'insert-php' ), $redeclaration['line'], self::get_redeclaration_message( $redeclaration ) ),
237 + 0,
238 + E_COMPILE_ERROR,
239 + __FILE__,
240 + $redeclaration['line']
241 + );
242 + }
243 +
244 + /**
245 + * Find the first top-level declaration whose name is already in use.
246 + *
247 + * Top-level functions are bound when eval() compiles the code, so a conflict
248 + * always ends the request. Classes, interfaces, traits and enums are declared
249 + * when execution reaches them, so they are not reported after a return, exit,
250 + * throw or goto outside any function or class body, which may guard them.
251 + * Declarations inside braces are conditional and are skipped, even when the
252 + * condition is always true. Class-like declarations inside alternative-syntax
253 + * blocks, as in if ( ... ): ... endif;, are skipped too, while functions there
254 + * are still reported. Nothing is reported without the tokenizer extension.
255 + *
256 + * @param string $snippet_code Snippet code.
257 + * @param string $snippet_type Snippet type.
258 + * @param bool $include_classes Whether class-like declarations are checked.
259 + * @return array{kind:string,name:string,line:int}|null Conflict details, or null when none is found.
260 + */
261 + private static function scan_redeclarations( string $snippet_code, $snippet_type, bool $include_classes ): ?array {
262 + if ( ! self::can_tokenize() ) {
263 + return null;
264 + }
265 +
266 + $tokens = token_get_all( self::get_source( $snippet_code, $snippet_type ) );
267 + $declaration_kinds = self::get_declaration_kinds();
268 + $blocks = []; // One entry per open brace, true when it opens a function or class body.
269 + $opens_body = false;
270 + $alternative_depth = 0;
271 + $may_stop = false;
35 272 $namespace = '';
36 273 $namespace_depth = 0;
37 274 $reading_namespace = false;
38 275 $namespace_name = '';
@@ -38,79 +275,116 @@
38 275 $namespace_name = '';
39 276 $declared = [];
40 277
41 278 foreach ( $tokens as $index => $token ) {
42 - if ( is_array( $token ) ) {
43 - if ( in_array( $token[0], [ T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ], true ) ) {
44 - ++$brace_depth;
45 - continue;
46 - }
279 + if ( ! is_array( $token ) ) {
280 + if ( $reading_namespace ) {
281 + $reading_namespace = false;
47 282
48 - if ( T_NAMESPACE === $token[0] ) {
49 - $reading_namespace = true;
50 - $namespace_name = '';
51 - continue;
52 - }
283 + if ( ';' === $token ) {
284 + $namespace = trim( $namespace_name, '\\' );
285 + $namespace_depth = 0;
286 + continue;
287 + }
53 288
54 - if ( $reading_namespace ) {
55 - if ( self::is_namespace_name_token( $token[0] ) ) {
56 - $namespace_name .= $token[1];
57 - } elseif ( ! in_array( $token[0], [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ], true ) ) {
58 - $reading_namespace = false;
59 - $namespace_name = '';
289 + if ( '{' !== $token ) {
290 + $namespace_name = '';
291 + continue;
60 292 }
61 - continue;
62 - }
63 293
64 - if ( T_FUNCTION !== $token[0] || $brace_depth !== $namespace_depth ) {
294 + $blocks[] = false;
295 + $namespace = trim( $namespace_name, '\\' );
296 + $namespace_depth = count( $blocks );
65 297 continue;
66 298 }
67 299
68 - $function_name = self::get_function_name( $tokens, $index );
69 - if ( null === $function_name ) {
70 - continue;
300 + if ( '{' === $token ) {
301 + $blocks[] = $opens_body;
302 + $opens_body = false;
303 + } elseif ( '}' === $token ) {
304 + array_pop( $blocks );
305 + if ( $namespace_depth > count( $blocks ) ) {
306 + $namespace = '';
307 + $namespace_depth = 0;
308 + }
309 + } elseif ( ';' === $token ) {
310 + // Abstract and interface methods have no body.
311 + $opens_body = false;
71 312 }
313 + continue;
314 + }
72 315
73 - $qualified_name = $namespace ? $namespace . '\\' . $function_name : $function_name;
74 - $normalized_name = strtolower( $qualified_name );
316 + $token_id = $token[0];
75 317
76 - if ( isset( $declared[ $normalized_name ] ) || function_exists( $qualified_name ) ) {
77 - return [
78 - 'name' => $qualified_name,
79 - 'line' => (int) $token[2],
80 - ];
81 - }
318 + if ( in_array( $token_id, [ T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ], true ) ) {
319 + $blocks[] = false;
320 + continue;
321 + }
82 322
83 - $declared[ $normalized_name ] = true;
323 + if ( T_NAMESPACE === $token_id ) {
324 + $reading_namespace = true;
325 + $namespace_name = '';
84 326 continue;
85 327 }
86 328
87 329 if ( $reading_namespace ) {
88 - if ( ';' === $token ) {
89 - $namespace = trim( $namespace_name, '\\' );
90 - $namespace_depth = 0;
330 + if ( self::is_namespace_name_token( $token_id ) ) {
331 + $namespace_name .= $token[1];
332 + } elseif ( ! self::is_insignificant( $token ) ) {
91 333 $reading_namespace = false;
92 - } elseif ( '{' === $token ) {
93 - ++$brace_depth;
94 - $namespace = trim( $namespace_name, '\\' );
95 - $namespace_depth = $brace_depth;
96 - $reading_namespace = false;
97 - } else {
98 - $reading_namespace = false;
99 334 $namespace_name = '';
100 335 }
101 336 continue;
102 337 }
103 338
104 - if ( '{' === $token ) {
105 - ++$brace_depth;
106 - } elseif ( '}' === $token ) {
107 - $brace_depth = max( 0, $brace_depth - 1 );
108 - if ( $namespace_depth > $brace_depth ) {
109 - $namespace = '';
110 - $namespace_depth = 0;
339 + if ( in_array( $token_id, [ T_RETURN, T_EXIT, T_THROW, T_GOTO ], true ) ) {
340 + $may_stop = $may_stop || ! in_array( true, $blocks, true );
341 + continue;
342 + }
343 +
344 + if ( in_array( $token_id, [ T_IF, T_WHILE, T_FOR, T_FOREACH, T_SWITCH ], true ) ) {
345 + if ( self::opens_alternative_block( $tokens, $index ) ) {
346 + ++$alternative_depth;
111 347 }
348 + continue;
112 349 }
350 +
351 + if ( in_array( $token_id, [ T_ENDIF, T_ENDWHILE, T_ENDFOR, T_ENDFOREACH, T_ENDSWITCH ], true ) ) {
352 + $alternative_depth = max( 0, $alternative_depth - 1 );
353 + continue;
354 + }
355 +
356 + if ( ! isset( $declaration_kinds[ $token_id ] ) || self::is_name_reference( $tokens, $index ) ) {
357 + continue;
358 + }
359 +
360 + // Named and anonymous functions and classes open their body at the next brace.
361 + $opens_body = true;
362 + $kind = $declaration_kinds[ $token_id ];
363 + $is_function = 'function' === $kind;
364 +
365 + if ( count( $blocks ) !== $namespace_depth || ( ! $is_function && ( ! $include_classes || $may_stop || $alternative_depth > 0 ) ) ) {
366 + continue;
367 + }
368 +
369 + $name = self::get_declared_name( $tokens, $index );
370 + if ( null === $name ) {
371 + continue;
372 + }
373 +
374 + $qualified_name = $namespace ? $namespace . '\\' . $name : $name;
375 + $symbol_key = ( $is_function ? 'function:' : 'class:' ) . strtolower( $qualified_name );
376 + $in_use = $is_function ? function_exists( $qualified_name ) : self::is_class_name_in_use( $qualified_name );
377 +
378 + if ( isset( $declared[ $symbol_key ] ) || $in_use ) {
379 + return [
380 + 'kind' => $kind,
381 + 'name' => $qualified_name,
382 + 'line' => (int) $token[2],
383 + ];
384 + }
385 +
386 + $declared[ $symbol_key ] = true;
113 387 }
114 388
115 389 return null;
116 390 }
@@ -115,8 +389,134 @@
115 389 return null;
116 390 }
117 391
118 392 /**
393 + * Check whether the tokenizer extension is available.
394 + *
395 + * @return bool
396 + */
397 + private static function can_tokenize(): bool {
398 + return function_exists( 'token_get_all' );
399 + }
400 +
401 + /**
402 + * Build the source the tokenizer reads: PHP snippets are stored without their opening tag.
403 + *
404 + * @param string $snippet_code Snippet code.
405 + * @param string $snippet_type Snippet type.
406 + * @return string
407 + */
408 + private static function get_source( string $snippet_code, $snippet_type ): string {
409 + return WINP_SNIPPET_TYPE_UNIVERSAL === $snippet_type ? $snippet_code : '<?php ' . $snippet_code;
410 + }
411 +
412 + /**
413 + * Map the declaration tokens to the kind of name they declare.
414 + *
415 + * @return array<int, string>
416 + */
417 + private static function get_declaration_kinds(): array {
418 + $kinds = [
419 + T_FUNCTION => 'function',
420 + T_CLASS => 'class',
421 + T_INTERFACE => 'interface',
422 + T_TRAIT => 'trait',
423 + ];
424 +
425 + if ( defined( 'T_ENUM' ) ) {
426 + $kinds[ constant( 'T_ENUM' ) ] = 'enum';
427 + }
428 +
429 + return $kinds;
430 + }
431 +
432 + /**
433 + * Check whether a class, interface, trait or enum with this name is already loaded.
434 + *
435 + * Autoloading is skipped: eval() only fails on names that are already declared.
436 + *
437 + * @param string $name Fully qualified name.
438 + * @return bool
439 + */
440 + private static function is_class_name_in_use( string $name ): bool {
441 + return class_exists( $name, false ) || interface_exists( $name, false ) || trait_exists( $name, false );
442 + }
443 +
444 + /**
445 + * Check whether a token is whitespace or a comment.
446 + *
447 + * @param array<int, int|string>|string $token Token.
448 + * @return bool
449 + */
450 + private static function is_insignificant( $token ): bool {
451 + return is_array( $token ) && in_array( $token[0], [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ], true );
452 + }
453 +
454 + /**
455 + * Check whether a declaration keyword only refers to a name, as in Example::class or use function example;.
456 + *
457 + * @param array<int, array<int, int|string>|string> $tokens Token stream.
458 + * @param int $index Keyword token index.
459 + * @return bool
460 + */
461 + private static function is_name_reference( array $tokens, int $index ): bool {
462 + $preceding = [ T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_USE ];
463 +
464 + if ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) ) {
465 + $preceding[] = constant( 'T_NULLSAFE_OBJECT_OPERATOR' );
466 + }
467 +
468 + for ( $previous = $index - 1; $previous >= 0; --$previous ) {
469 + if ( ! self::is_insignificant( $tokens[ $previous ] ) ) {
470 + return is_array( $tokens[ $previous ] ) && in_array( $tokens[ $previous ][0], $preceding, true );
471 + }
472 + }
473 +
474 + return false;
475 + }
476 +
477 + /**
478 + * Check whether a control structure uses the alternative syntax, as in if ( ... ): ... endif;.
479 + *
480 + * @param array<int, array<int, int|string>|string> $tokens Token stream.
481 + * @param int $index Control structure token index.
482 + * @return bool
483 + */
484 + private static function opens_alternative_block( array $tokens, int $index ): bool {
485 + $token_count = count( $tokens );
486 + $depth = 0;
487 +
488 + for ( $current = $index + 1; $current < $token_count; ++$current ) {
489 + $token = $tokens[ $current ];
490 +
491 + if ( self::is_insignificant( $token ) ) {
492 + continue;
493 + }
494 +
495 + if ( '(' === $token ) {
496 + ++$depth;
497 + continue;
498 + }
499 +
500 + if ( 0 === $depth ) {
501 + return false;
502 + }
503 +
504 + if ( ')' === $token && 0 === --$depth ) {
505 + for ( $next = $current + 1; $next < $token_count; ++$next ) {
506 + if ( ! self::is_insignificant( $tokens[ $next ] ) ) {
507 + return ':' === $tokens[ $next ];
508 + }
509 + }
510 +
511 + return false;
512 + }
513 + }
514 +
515 + return false;
516 + }
517 +
518 + /**
119 519 * Check whether a token can be part of a namespace name.
120 520 *
121 521 * @param int $token_id Token identifier.
122 522 * @return bool
@@ -133,22 +533,22 @@
133 533 return in_array( $token_id, $name_tokens, true );
134 534 }
135 535
136 536 /**
137 - * Get the name following a function token, ignoring whitespace and references.
537 + * Get the name following a declaration token, ignoring whitespace and references.
138 538 *
139 539 * @param array<int, array<int, int|string>|string> $tokens Token stream.
140 - * @param int $function_index Function token index.
141 - * @return string|null
540 + * @param int $declaration_index Declaration token index.
541 + * @return string|null Null for closures, anonymous classes and other unnamed uses.
142 542 */
143 - private static function get_function_name( $tokens, $function_index ) {
543 + private static function get_declared_name( $tokens, $declaration_index ) {
144 544 $token_count = count( $tokens );
145 545
146 - for ( $index = $function_index + 1; $index < $token_count; ++$index ) {
546 + for ( $index = $declaration_index + 1; $index < $token_count; ++$index ) {
147 547 $token = $tokens[ $index ];
148 548
149 549 if ( is_array( $token ) ) {
150 - if ( in_array( $token[0], [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ], true ) || '&' === $token[1] ) {
550 + if ( self::is_insignificant( $token ) || '&' === $token[1] ) {
151 551 continue;
152 552 }
153 553
154 554 return T_STRING === $token[0] ? (string) $token[1] : null;