true,
'message' => '',
];
}
$redeclaration_error = self::get_redeclaration_error( $snippet_code, $snippet_type );
if ( null !== $redeclaration_error ) {
return [
'valid' => false,
'message' => $redeclaration_error->getMessage(),
];
}
$validation_errors = [];
// Set custom error handler to catch warnings and notices.
set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
function ( $errno, $errstr, $errfile, $errline ) use ( &$validation_errors ) {
// Extract line number from eval'd code if present.
if ( strpos( $errfile, "eval()'d code" ) !== false ) {
// translators: %1$d is the line number, %2$s is the error message.
$validation_errors[] = sprintf( __( 'Line %1$d: %2$s', 'insert-php' ), $errline, $errstr );
} else {
$validation_errors[] = $errstr;
}
return true; // Don't execute PHP internal error handler.
}
);
ob_start();
try {
$result = WINP_SNIPPET_TYPE_UNIVERSAL === $snippet_type
? eval( '?> ' . $snippet_code . ' false,
// translators: %1$d is the line number, %2$s is the error message.
'message' => sprintf( __( 'Syntax error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
];
} catch ( Throwable $e ) {
ob_end_clean();
restore_error_handler();
// For fatal errors in eval'd code, report the actual line number.
if ( strpos( $e->getFile(), "eval()'d code" ) !== false ) {
return [
'valid' => false,
// translators: %1$d is the line number, %2$s is the error message.
'message' => sprintf( __( 'Error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
];
}
return [
'valid' => false,
// translators: %s is the error message.
'message' => sprintf( __( 'Error: %s', 'insert-php' ), $e->getMessage() ),
];
}
// Discard any output (echo/print statements are normal for snippets).
ob_end_clean();
restore_error_handler();
if ( ! empty( $validation_errors ) ) {
return [
'valid' => false,
'message' => implode( '
', $validation_errors ),
];
}
if ( false === $result ) {
return [
'valid' => false,
'message' => __( 'The code contains syntax errors. Please review and fix them before saving.', 'insert-php' ),
];
}
return [
'valid' => true,
'message' => '',
];
}
/**
* Check executable snippet code for syntax errors without running it.
*
* The tokenizer parses the code in TOKEN_PARSE mode and never executes it.
* Errors PHP raises only when the code runs, such as a call to an undefined
* function, are left to the check that runs when the snippet is activated.
* Without the tokenizer extension nothing is checked and the result says so.
*
* @param string $snippet_code Snippet code, without the opening tag for PHP snippets.
* @param string $snippet_type Snippet type.
* @return array{valid:bool,checked:bool,message:string} Check result.
*/
public static function check_syntax( string $snippet_code, $snippet_type ): array {
if ( ! self::can_tokenize() ) {
return [
'valid' => true,
'checked' => false,
'message' => '',
];
}
try {
token_get_all( self::get_source( $snippet_code, $snippet_type ), TOKEN_PARSE );
} catch ( CompileError $e ) {
return [
'valid' => false,
'checked' => true,
// translators: %1$d is the line number, %2$s is the error message.
'message' => sprintf( __( 'Syntax error on line %1$d: %2$s', 'insert-php' ), $e->getLine(), $e->getMessage() ),
];
}
return [
'valid' => true,
'checked' => true,
'message' => '',
];
}
/**
* Find a top-level function declaration that would be redeclared by eval().
*
* PHP terminates the request when eval() declares an already-loaded function,
* so this conflict has to be detected before evaluating the snippet.
*
* @param string $snippet_code Snippet code.
* @param string $snippet_type Snippet type.
* @return array{name:string,line:int}|null Conflict details, or null when none is found.
*/
public static function find_function_redeclaration( $snippet_code, $snippet_type ) {
$redeclaration = self::scan_redeclarations( (string) $snippet_code, $snippet_type, false );
if ( null === $redeclaration ) {
return null;
}
return [
'name' => $redeclaration['name'],
'line' => $redeclaration['line'],
];
}
/**
* Find a top-level function, class, interface, trait or enum that would be redeclared by eval().
*
* PHP terminates the request when eval() declares a name that is already in
* use, so this conflict has to be detected before evaluating the snippet.
*
* @param string $snippet_code Snippet code.
* @param string $snippet_type Snippet type.
* @return array{kind:string,name:string,line:int}|null Conflict details, or null when none is found.
*/
public static function find_redeclaration( string $snippet_code, $snippet_type ): ?array {
return self::scan_redeclarations( $snippet_code, $snippet_type, true );
}
/**
* Describe a redeclaration found by find_redeclaration().
*
* @param array{kind:string,name:string,line:int} $redeclaration Conflict details.
* @return string
*/
public static function get_redeclaration_message( array $redeclaration ): string {
switch ( $redeclaration['kind'] ) {
case 'class':
// translators: %s is the fully qualified class name.
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'] );
case 'interface':
// translators: %s is the fully qualified interface name.
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'] );
case 'trait':
// translators: %s is the fully qualified trait name.
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'] );
case 'enum':
// translators: %s is the fully qualified enum name.
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'] );
default:
// translators: %s is the fully qualified function name.
return sprintf( __( 'Cannot redeclare function %s(). Rename the function or guard its declaration with function_exists().', 'insert-php' ), $redeclaration['name'] );
}
}
/**
* Build the error for a top-level declaration the snippet would redeclare.
*
* @param string $snippet_code Snippet code.
* @param string $snippet_type Snippet type.
* @return ErrorException|null Error carrying the snippet line, or null when none is found.
*/
public static function get_redeclaration_error( string $snippet_code, $snippet_type ): ?ErrorException {
$redeclaration = self::find_redeclaration( $snippet_code, $snippet_type );
if ( null === $redeclaration ) {
return null;
}
return new ErrorException(
// translators: %1$d is the line number, %2$s is the error message.
sprintf( __( 'Line %1$d: %2$s', 'insert-php' ), $redeclaration['line'], self::get_redeclaration_message( $redeclaration ) ),
0,
E_COMPILE_ERROR,
__FILE__,
$redeclaration['line']
);
}
/**
* Find the first top-level declaration whose name is already in use.
*
* Top-level functions are bound when eval() compiles the code, so a conflict
* always ends the request. Classes, interfaces, traits and enums are declared
* when execution reaches them, so they are not reported after a return, exit,
* throw or goto outside any function or class body, which may guard them.
* Declarations inside braces are conditional and are skipped, even when the
* condition is always true. Class-like declarations inside alternative-syntax
* blocks, as in if ( ... ): ... endif;, are skipped too, while functions there
* are still reported. Nothing is reported without the tokenizer extension.
*
* @param string $snippet_code Snippet code.
* @param string $snippet_type Snippet type.
* @param bool $include_classes Whether class-like declarations are checked.
* @return array{kind:string,name:string,line:int}|null Conflict details, or null when none is found.
*/
private static function scan_redeclarations( string $snippet_code, $snippet_type, bool $include_classes ): ?array {
if ( ! self::can_tokenize() ) {
return null;
}
$tokens = token_get_all( self::get_source( $snippet_code, $snippet_type ) );
$declaration_kinds = self::get_declaration_kinds();
$blocks = []; // One entry per open brace, true when it opens a function or class body.
$opens_body = false;
$alternative_depth = 0;
$may_stop = false;
$namespace = '';
$namespace_depth = 0;
$reading_namespace = false;
$namespace_name = '';
$declared = [];
foreach ( $tokens as $index => $token ) {
if ( ! is_array( $token ) ) {
if ( $reading_namespace ) {
$reading_namespace = false;
if ( ';' === $token ) {
$namespace = trim( $namespace_name, '\\' );
$namespace_depth = 0;
continue;
}
if ( '{' !== $token ) {
$namespace_name = '';
continue;
}
$blocks[] = false;
$namespace = trim( $namespace_name, '\\' );
$namespace_depth = count( $blocks );
continue;
}
if ( '{' === $token ) {
$blocks[] = $opens_body;
$opens_body = false;
} elseif ( '}' === $token ) {
array_pop( $blocks );
if ( $namespace_depth > count( $blocks ) ) {
$namespace = '';
$namespace_depth = 0;
}
} elseif ( ';' === $token ) {
// Abstract and interface methods have no body.
$opens_body = false;
}
continue;
}
$token_id = $token[0];
if ( in_array( $token_id, [ T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ], true ) ) {
$blocks[] = false;
continue;
}
if ( T_NAMESPACE === $token_id ) {
$reading_namespace = true;
$namespace_name = '';
continue;
}
if ( $reading_namespace ) {
if ( self::is_namespace_name_token( $token_id ) ) {
$namespace_name .= $token[1];
} elseif ( ! self::is_insignificant( $token ) ) {
$reading_namespace = false;
$namespace_name = '';
}
continue;
}
if ( in_array( $token_id, [ T_RETURN, T_EXIT, T_THROW, T_GOTO ], true ) ) {
$may_stop = $may_stop || ! in_array( true, $blocks, true );
continue;
}
if ( in_array( $token_id, [ T_IF, T_WHILE, T_FOR, T_FOREACH, T_SWITCH ], true ) ) {
if ( self::opens_alternative_block( $tokens, $index ) ) {
++$alternative_depth;
}
continue;
}
if ( in_array( $token_id, [ T_ENDIF, T_ENDWHILE, T_ENDFOR, T_ENDFOREACH, T_ENDSWITCH ], true ) ) {
$alternative_depth = max( 0, $alternative_depth - 1 );
continue;
}
if ( ! isset( $declaration_kinds[ $token_id ] ) || self::is_name_reference( $tokens, $index ) ) {
continue;
}
// Named and anonymous functions and classes open their body at the next brace.
$opens_body = true;
$kind = $declaration_kinds[ $token_id ];
$is_function = 'function' === $kind;
if ( count( $blocks ) !== $namespace_depth || ( ! $is_function && ( ! $include_classes || $may_stop || $alternative_depth > 0 ) ) ) {
continue;
}
$name = self::get_declared_name( $tokens, $index );
if ( null === $name ) {
continue;
}
$qualified_name = $namespace ? $namespace . '\\' . $name : $name;
$symbol_key = ( $is_function ? 'function:' : 'class:' ) . strtolower( $qualified_name );
$in_use = $is_function ? function_exists( $qualified_name ) : self::is_class_name_in_use( $qualified_name );
if ( isset( $declared[ $symbol_key ] ) || $in_use ) {
return [
'kind' => $kind,
'name' => $qualified_name,
'line' => (int) $token[2],
];
}
$declared[ $symbol_key ] = true;
}
return null;
}
/**
* Check whether the tokenizer extension is available.
*
* @return bool
*/
private static function can_tokenize(): bool {
return function_exists( 'token_get_all' );
}
/**
* Build the source the tokenizer reads: PHP snippets are stored without their opening tag.
*
* @param string $snippet_code Snippet code.
* @param string $snippet_type Snippet type.
* @return string
*/
private static function get_source( string $snippet_code, $snippet_type ): string {
return WINP_SNIPPET_TYPE_UNIVERSAL === $snippet_type ? $snippet_code : '
*/
private static function get_declaration_kinds(): array {
$kinds = [
T_FUNCTION => 'function',
T_CLASS => 'class',
T_INTERFACE => 'interface',
T_TRAIT => 'trait',
];
if ( defined( 'T_ENUM' ) ) {
$kinds[ constant( 'T_ENUM' ) ] = 'enum';
}
return $kinds;
}
/**
* Check whether a class, interface, trait or enum with this name is already loaded.
*
* Autoloading is skipped: eval() only fails on names that are already declared.
*
* @param string $name Fully qualified name.
* @return bool
*/
private static function is_class_name_in_use( string $name ): bool {
return class_exists( $name, false ) || interface_exists( $name, false ) || trait_exists( $name, false );
}
/**
* Check whether a token is whitespace or a comment.
*
* @param array|string $token Token.
* @return bool
*/
private static function is_insignificant( $token ): bool {
return is_array( $token ) && in_array( $token[0], [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ], true );
}
/**
* Check whether a declaration keyword only refers to a name, as in Example::class or use function example;.
*
* @param array|string> $tokens Token stream.
* @param int $index Keyword token index.
* @return bool
*/
private static function is_name_reference( array $tokens, int $index ): bool {
$preceding = [ T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_USE ];
if ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) ) {
$preceding[] = constant( 'T_NULLSAFE_OBJECT_OPERATOR' );
}
for ( $previous = $index - 1; $previous >= 0; --$previous ) {
if ( ! self::is_insignificant( $tokens[ $previous ] ) ) {
return is_array( $tokens[ $previous ] ) && in_array( $tokens[ $previous ][0], $preceding, true );
}
}
return false;
}
/**
* Check whether a control structure uses the alternative syntax, as in if ( ... ): ... endif;.
*
* @param array|string> $tokens Token stream.
* @param int $index Control structure token index.
* @return bool
*/
private static function opens_alternative_block( array $tokens, int $index ): bool {
$token_count = count( $tokens );
$depth = 0;
for ( $current = $index + 1; $current < $token_count; ++$current ) {
$token = $tokens[ $current ];
if ( self::is_insignificant( $token ) ) {
continue;
}
if ( '(' === $token ) {
++$depth;
continue;
}
if ( 0 === $depth ) {
return false;
}
if ( ')' === $token && 0 === --$depth ) {
for ( $next = $current + 1; $next < $token_count; ++$next ) {
if ( ! self::is_insignificant( $tokens[ $next ] ) ) {
return ':' === $tokens[ $next ];
}
}
return false;
}
}
return false;
}
/**
* Check whether a token can be part of a namespace name.
*
* @param int $token_id Token identifier.
* @return bool
*/
private static function is_namespace_name_token( $token_id ) {
$name_tokens = [ T_STRING, T_NS_SEPARATOR ];
foreach ( [ 'T_NAME_QUALIFIED', 'T_NAME_FULLY_QUALIFIED' ] as $constant_name ) {
if ( defined( $constant_name ) ) {
$name_tokens[] = constant( $constant_name );
}
}
return in_array( $token_id, $name_tokens, true );
}
/**
* Get the name following a declaration token, ignoring whitespace and references.
*
* @param array|string> $tokens Token stream.
* @param int $declaration_index Declaration token index.
* @return string|null Null for closures, anonymous classes and other unnamed uses.
*/
private static function get_declared_name( $tokens, $declaration_index ) {
$token_count = count( $tokens );
for ( $index = $declaration_index + 1; $index < $token_count; ++$index ) {
$token = $tokens[ $index ];
if ( is_array( $token ) ) {
if ( self::is_insignificant( $token ) || '&' === $token[1] ) {
continue;
}
return T_STRING === $token[0] ? (string) $token[1] : null;
}
if ( '&' === $token ) {
continue;
}
return null;
}
return null;
}
}