class map. * * @var array|null */ private static $map = null; /** * Scan PHP source text. Never throws. * * @param string $source PHP source. * @return array List of findings sorted by line. */ public static function scan_source( string $source ): array { try { return self::run( $source ); } catch ( Throwable $e ) { return array(); } } /** * Read and scan one file. Never throws. * * @param string $path File path. * @return array findings and skipped. */ public static function scan_file( string $path ): array { $res = array( 'findings' => array(), 'skipped' => '', ); if ( ! function_exists( 'token_get_all' ) ) { $res['skipped'] = 'the PHP tokenizer extension is not available'; return $res; } $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable files are reported, not fatal. if ( false === $size || ! is_file( $path ) ) { $res['skipped'] = 'could not be read'; return $res; } if ( $size > self::MAX_FILE_BYTES ) { $res['skipped'] = 'file larger than 2 MB'; return $res; } $src = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local read, errors reported below. if ( false === $src ) { $res['skipped'] = 'could not be read'; return $res; } try { $res['findings'] = self::run( $src ); } catch ( RuntimeException $e ) { $res['skipped'] = 'not enough memory to scan this file'; } catch ( Throwable $e ) { $res['skipped'] = 'could not be parsed'; } return $res; } /** * Chunk loop: tokenise, detect, advance to the cut point. * * @param string $src Source. * @return array Findings. * @throws RuntimeException When the chunk does not fit in memory_limit. */ private static function run( string $src ): array { if ( ! function_exists( 'token_get_all' ) ) { return array(); } $ix = self::index(); $len = strlen( $src ); $st = array( 'out' => array(), 'calls' => array(), 'decl' => array(), 'ns' => false, 'imp' => array(), 'enum' => array(), 'bd' => 0, 'pe' => false, 'guard' => array(), ); $off = 0; $line = 1; $take = max( 16, (int) self::$chunk ); while ( $off < $len ) { $room = self::room(); $t = min( $take, $room ); $last_chunk = ( $off + $t >= $len ); $part = $last_chunk ? ( $off ? substr( $src, $off ) : $src ) : substr( $src, $off, $t ); $lx = self::lex( ( $off ? '= $room ) { throw new RuntimeException( 'memory' ); } $take *= 2; continue; } self::detect( $lx, $ix, $st ); if ( $last_chunk ) { break; } $used = $lx['cutpos'] - ( $off ? 6 : 0 ); $line += substr_count( $part, "\n", 0, $used ); $off += $used; } return self::finish( $st, $src, $ix['R'] ); } /** * Bytes of source that can be tokenised in one go without exhausting memory_limit. * * @return int * @throws RuntimeException When less than 4 MB of memory is left. */ private static function room(): int { $lim = (string) ini_get( 'memory_limit' ); if ( '' === $lim || '-1' === $lim ) { return PHP_INT_MAX; } $b = (int) $lim; $u = strtoupper( substr( $lim, -1 ) ); if ( 'G' === $u ) { $b *= 1073741824; } elseif ( 'M' === $u ) { $b *= 1048576; } elseif ( 'K' === $u ) { $b *= 1024; } if ( $b <= 0 ) { return PHP_INT_MAX; } $free = (int) ( $b * 0.85 ) - memory_get_usage(); if ( $free < 4194304 ) { throw new RuntimeException( 'memory' ); } return (int) ( $free / 70 ); } /** * Token id => class. 0 ignore, 1 name, 2 variable, 3 string, 4 number, 5 cast, 6 backslash, * 7 heredoc start, 8 heredoc end, 9 curly open, 11 close tag, 12 attribute, 13 nullsafe, * 14 arrow, 16 ampersand, 20 open tag. * * @return array */ private static function map(): array { $m = array(); $c = array( 'T_WHITESPACE' => 0, 'T_COMMENT' => 0, 'T_DOC_COMMENT' => 0, 'T_INLINE_HTML' => 0, 'T_ENCAPSED_AND_WHITESPACE' => 0, 'T_BAD_CHARACTER' => 0, 'T_STRING' => 1, 'T_NAME_QUALIFIED' => 1, 'T_NAME_FULLY_QUALIFIED' => 1, 'T_NAME_RELATIVE' => 1, 'T_VARIABLE' => 2, 'T_CONSTANT_ENCAPSED_STRING' => 3, 'T_LNUMBER' => 4, 'T_DNUMBER' => 4, 'T_BOOL_CAST' => 5, 'T_INT_CAST' => 5, 'T_DOUBLE_CAST' => 5, 'T_STRING_CAST' => 5, 'T_ARRAY_CAST' => 5, 'T_OBJECT_CAST' => 5, 'T_NS_SEPARATOR' => 6, 'T_START_HEREDOC' => 7, 'T_END_HEREDOC' => 8, 'T_CURLY_OPEN' => 9, 'T_DOLLAR_OPEN_CURLY_BRACES' => 9, 'T_CLOSE_TAG' => 11, 'T_ATTRIBUTE' => 12, 'T_NULLSAFE_OBJECT_OPERATOR' => 13, 'T_OBJECT_OPERATOR' => 14, 'T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG' => 16, 'T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG' => 16, 'T_OPEN_TAG' => 20, 'T_OPEN_TAG_WITH_ECHO' => 20, ); foreach ( $c as $name => $class ) { if ( defined( $name ) ) { $m[ constant( $name ) ] = $class; } } self::$map = $m; return $m; } /** * Tokenise one chunk and normalise: names merged, attributes, ?-> and & unified, strings * and comments dropped. Arrays are parallel: kind, text, line. Index 0 is a ';' sentinel. * Kinds: i name, k keyword (lower case), v variable, s string literal, n number, c cast, * b backtick, o operator or punctuation. * * @param string $code PHP source for this chunk, in PHP mode when it is not the first. * @param int $dl Lines before this chunk. * @param bool $last_chunk True for the last chunk. * @return array */ private static function lex( string $code, int $dl, bool $last_chunk ): array { $raw = @token_get_all( $code ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the tokenizer may warn on broken files. if ( PHP_VERSION_ID < 80000 && false !== strpos( $code, '#[' ) ) { $hit = array(); $o = 0; foreach ( $raw as $tk ) { if ( is_array( $tk ) ) { if ( T_COMMENT === $tk[0] && '#[' === substr( $tk[1], 0, 2 ) ) { $hit[] = $o; } $o += strlen( $tk[1] ); } else { $o += strlen( $tk ); } } if ( $hit ) { foreach ( $hit as $o ) { $code[ $o ] = '\\'; } $raw = @token_get_all( $code ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. } } $tokmap = null === self::$map ? self::map() : self::$map; $kind = array( 'o' ); $text = array( ';' ); $lines = array( 0 ); $n = 1; $line = 1 + $dl; $adj = -1; $php = false; $sd = 0; $top = -1; $stk = array(); $pd = 0; $cand = -1; $cn = 0; $skip = false; foreach ( $raw as $ri => $tk ) { if ( is_array( $tk ) ) { $id = $tk[0]; $tx = $tk[1]; $a = isset( $tokmap[ $id ] ) ? $tokmap[ $id ] : 99; if ( 0 === $a ) { $line = $tk[2] + $dl + substr_count( $tx, "\n" ); $adj = -1; continue; } if ( 0 === $top ) { if ( 9 === $a ) { $stk[ $sd++ ] = 1; $top = 1; } elseif ( 8 === $a ) { --$sd; $top = $sd ? $stk[ $sd - 1 ] : -1; } $line = $tk[2] + $dl + substr_count( $tx, "\n" ); continue; } if ( 3 !== $a && 7 !== $a && 11 !== $a && 20 !== $a ) { $line = $tk[2] + $dl; } switch ( $a ) { case 1: if ( $adj >= 0 && T_STRING === $id && 'i' === $kind[ $adj ] && '\\' === substr( $text[ $adj ], -1 ) ) { $text[ $adj ] .= $tx; break; } $kind[ $n ] = 'i'; $text[ $n ] = $tx; $lines[ $n ] = $line; $adj = $n++; break; case 2: $kind[ $n ] = 'v'; $text[ $n ] = $tx; $lines[ $n ] = $line; $adj = $n++; break; case 3: $kind[ $n ] = 's'; $text[ $n ] = $tx; $lines[ $n ] = $tk[2] + $dl; $line = $lines[ $n ] + substr_count( $tx, "\n" ); $adj = $n++; break; case 4: $kind[ $n ] = 'n'; $text[ $n ] = $tx; $lines[ $n ] = $line; $adj = $n++; break; case 5: $kind[ $n ] = 'c'; $text[ $n ] = $tx; $lines[ $n ] = $line; $adj = $n++; break; case 6: if ( PHP_VERSION_ID < 80000 && isset( $raw[ $ri + 1 ] ) && '[' === $raw[ $ri + 1 ] ) { $kind[ $n ] = 'o'; $text[ $n ] = '#['; $lines[ $n ] = $line; $adj = $n++; ++$pd; $skip = true; break; } if ( $adj >= 0 && 'i' === $kind[ $adj ] ) { $text[ $adj ] .= '\\'; break; } if ( $adj >= 0 && 'namespace' === $text[ $adj ] ) { $kind[ $adj ] = 'i'; $text[ $adj ] = 'namespace\\'; break; } $kind[ $n ] = 'i'; $text[ $n ] = '\\'; $lines[ $n ] = $line; $adj = $n++; break; case 7: $stk[ $sd++ ] = 0; $top = 0; $line = $tk[2] + $dl + substr_count( $tx, "\n" ); $adj = -1; break; case 11: $php = false; $kind[ $n ] = 'o'; $text[ $n ] = ';'; $lines[ $n ] = $tk[2] + $dl; $line = $lines[ $n ] + substr_count( $tx, "\n" ); $adj = $n++; break; case 12: $kind[ $n ] = 'o'; $text[ $n ] = '#['; $lines[ $n ] = $line; $adj = $n++; ++$pd; break; case 13: $kind[ $n ] = 'o'; $text[ $n ] = '?->'; $lines[ $n ] = $line; $adj = $n++; break; case 14: if ( PHP_VERSION_ID < 80000 && $adj >= 0 && '?' === $text[ $adj ] && 'o' === $kind[ $adj ] ) { $text[ $adj ] = '?->'; break; } $kind[ $n ] = 'o'; $text[ $n ] = '->'; $lines[ $n ] = $line; $adj = $n++; break; case 16: $kind[ $n ] = 'o'; $text[ $n ] = '&'; $lines[ $n ] = $line; $adj = $n++; break; case 20: $php = true; $line = $tk[2] + $dl + substr_count( $tx, "\n" ); $adj = -1; break; default: $c0 = $tx[0]; if ( ctype_alpha( $c0 ) || '_' === $c0 ) { $kind[ $n ] = 'k'; $text[ $n ] = strtolower( $tx ); } else { $kind[ $n ] = 'o'; $text[ $n ] = $tx; } $lines[ $n ] = $line; $adj = $n++; } continue; } // Single-character token. if ( $skip ) { $skip = false; if ( '[' === $tk ) { $adj = -1; continue; } } if ( 0 === $top ) { if ( '"' === $tk || '`' === $tk ) { --$sd; $top = $sd ? $stk[ $sd - 1 ] : -1; } continue; } if ( '"' === $tk || '`' === $tk ) { if ( '`' === $tk ) { $kind[ $n ] = 'b'; $text[ $n ] = '`'; $lines[ $n ] = $line; ++$n; } $stk[ $sd++ ] = 0; $top = 0; $adj = -1; continue; } if ( $top > 0 ) { if ( '{' === $tk ) { $stk[ $sd - 1 ] = ++$top; } elseif ( '}' === $tk ) { if ( 1 === $top ) { --$sd; $top = $sd ? $stk[ $sd - 1 ] : -1; continue; } $stk[ $sd - 1 ] = --$top; } } $kind[ $n ] = 'o'; $text[ $n ] = $tk; $lines[ $n ] = $line; $adj = $n; if ( ';' === $tk || '}' === $tk ) { if ( 0 === $sd && 0 === $pd && $php ) { $cand = $ri; $cn = $n; } } elseif ( '(' === $tk || '[' === $tk ) { ++$pd; } elseif ( ( ')' === $tk || ']' === $tk ) && $pd > 0 ) { --$pd; } ++$n; } $cutpos = 0; if ( ! $last_chunk ) { if ( $cand >= 0 ) { for ( $q = 0; $q <= $cand; $q++ ) { $cutpos += strlen( is_array( $raw[ $q ] ) ? $raw[ $q ][1] : $raw[ $q ] ); } $kind = array_slice( $kind, 0, $cn + 1 ); $text = array_slice( $text, 0, $cn + 1 ); $lines = array_slice( $lines, 0, $cn + 1 ); $n = $cn + 1; } } $last = $lines[ $n - 1 ]; for ( $q = 0; $q < 3; $q++ ) { $kind[ $n + $q ] = 'o'; $text[ $n + $q ] = ';'; $lines[ $n + $q ] = $last; } return array( 'K' => $kind, 'T' => $text, 'L' => $lines, 'n' => $n, 'cut' => $last_chunk ? 0 : $cand, 'cutpos' => $cutpos, ); } /** * Lookup tables compiled from the rule table. * * @return array */ private static function index(): array { if ( null !== self::$ix ) { return self::$ix; } $ix = array( 'R' => Hxpc_Rules::all(), 'F' => array(), 'FLT' => array(), 'CO' => array(), 'CC' => array(), 'NW' => array(), 'EX' => array(), 'INI' => array(), 'CAST' => array(), 'FLAG' => array(), ); foreach ( $ix['R'] as $id => $r ) { $t = $r['trigger']; $when = isset( $t['when'] ) ? $t['when'] : null; foreach ( isset( $t['func'] ) ? $t['func'] : array() as $f ) { $ix['F'][ strtolower( $f ) ][] = array( $id, $when ); } foreach ( isset( $t['func_lt'] ) ? $t['func_lt'] : array() as $f => $c ) { $ix['FLT'][ strtolower( $f ) ][] = array( $id, $c ); } foreach ( isset( $t['const'] ) ? $t['const'] : array() as $c ) { $ix['CO'][ $c ] = $id; } foreach ( isset( $t['cconst'] ) ? $t['cconst'] : array() as $cl => $cs ) { foreach ( $cs as $c ) { $ix['CC'][ strtolower( $cl ) ][ $c ] = $id; } } foreach ( isset( $t['new'] ) ? $t['new'] : array() as $c ) { $ix['NW'][ strtolower( $c ) ][] = array( $id, $when ); } foreach ( isset( $t['extends'] ) ? $t['extends'] : array() as $c ) { $ix['EX'][ strtolower( $c ) ] = $id; } foreach ( isset( $t['ini'] ) ? $t['ini'] : array() as $d => $spec ) { $ix['INI'][ $d ] = array( $id, $spec ); } foreach ( isset( $t['cast'] ) ? $t['cast'] : array() as $c ) { $ix['CAST'][ $c ] = $id; } foreach ( array( 'backtick', 'case_semicolon', 'class_name', 'nullable' ) as $flag ) { if ( isset( $t[ $flag ] ) ) { $ix['FLAG'][ $flag ] = $id; } } } self::$ix = $ix; return $ix; } /** * Walk the normalised tokens of one chunk and record findings in $st. * * @param array $lx Output of lex(). * @param array $ix Rule index. * @param array $st Scan state, updated in place. */ private static function detect( array $lx, array $ix, array &$st ): void { $kind = $lx['K']; $text = $lx['T']; $lines = $lx['L']; $n = $lx['n']; $fn_map = $ix['F']; $fn_lt = $ix['FLT']; $const_map = $ix['CO']; $cclass_map = $ix['CC']; $new_map = $ix['NW']; $ext_map = $ix['EX']; $flag = $ix['FLAG']; $noc = array_flip( array( 'const', 'function', 'class', 'interface', 'trait', 'enum', 'new', 'extends', 'implements', 'instanceof', 'namespace', 'use', 'goto', 'as', 'insteadof', 'catch' ) ); for ( $i = 1; $i < $n; $i++ ) { $k = $kind[ $i ]; if ( 'o' === $k ) { $t = $text[ $i ]; if ( '{' === $t ) { ++$st['bd']; if ( $st['pe'] ) { $st['enum'][] = $st['bd']; $st['pe'] = false; } } elseif ( '}' === $t ) { if ( $st['enum'] && end( $st['enum'] ) === $st['bd'] ) { array_pop( $st['enum'] ); } if ( $st['bd'] > 0 ) { --$st['bd']; } } continue; } $t = $text[ $i ]; if ( 'i' === $k && 'i' === $kind[ $i + 1 ] && 'enum' === strtolower( $t ) ) { $k = 'k'; $t = 'enum'; } if ( 'i' === $k ) { $pv = $text[ $i - 1 ]; if ( '->' === $pv || '?->' === $pv || '::' === $pv ) { continue; } $nx = $text[ $i + 1 ]; if ( '\\' === $t[0] ) { $fq = true; $g = substr( $t, 1 ); } else { $fq = false; $g = $t; } if ( '' === $g || false !== strpos( $g, '\\' ) ) { continue; } if ( '(' === $nx ) { if ( 'function' === $pv || ( '&' === $pv && 'function' === $text[ $i - 2 ] ) ) { continue; } $lc = strtolower( $g ); if ( 'new' === $pv ) { $cl = self::cls( $g, $fq, $st ); if ( '' !== $cl && isset( $new_map[ $cl ] ) ) { $a = self::args( $kind, $text, $i + 1, $n ); foreach ( $new_map[ $cl ] as $e ) { if ( null === $e[1] || ( $a && self::when( $e[1], $a, $kind, $text ) ) ) { $st['out'][] = self::item( $e[0], $lines[ $i ], $g, $ix ); } } } continue; } if ( isset( $fn_map[ $lc ] ) || isset( $fn_lt[ $lc ] ) || 'ini_set' === $lc || 'ini_alter' === $lc || 'function_exists' === $lc || 'defined' === $lc || 'extension_loaded' === $lc || 'class_exists' === $lc ) { self::on_call( $lc, $g, $i, $kind, $text, $lines, $n, $ix, $st ); } continue; } if ( '::' === $nx ) { $cl = self::cls( $g, $fq, $st ); if ( '' !== $cl && isset( $cclass_map[ $cl ] ) && 'i' === $kind[ $i + 2 ] && '(' !== $text[ $i + 3 ] && isset( $cclass_map[ $cl ][ $text[ $i + 2 ] ] ) ) { $st['out'][] = self::item( $cclass_map[ $cl ][ $text[ $i + 2 ] ], $lines[ $i ], $g . '::' . $text[ $i + 2 ], $ix ); } continue; } if ( 'extends' === $pv ) { $cl = self::cls( $g, $fq, $st ); if ( '' !== $cl && isset( $ext_map[ $cl ] ) ) { $st['out'][] = self::item( $ext_map[ $cl ], $lines[ $i ], $g, $ix, 'x:' . $cl ); } continue; } if ( isset( $const_map[ $g ] ) && ! isset( $noc[ $pv ] ) && '\\' !== $nx && ! ( ':' === $nx && ( '(' === $pv || ',' === $pv ) ) && ! ( 'case' === $pv && $st['enum'] && end( $st['enum'] ) === $st['bd'] ) ) { $st['out'][] = self::item( $const_map[ $g ], $lines[ $i ], $g, $ix, 'c:' . $g ); } continue; } if ( 'k' === $k ) { switch ( $t ) { case 'function': case 'fn': self::params( $kind, $text, $lines, $i, $n, $ix, $st ); break; case 'case': if ( $st['enum'] && end( $st['enum'] ) === $st['bd'] ) { break; } if ( isset( $flag['case_semicolon'] ) ) { $d = 0; $q = 0; for ( $j = $i + 1; $j < $n; $j++ ) { if ( 'o' !== $kind[ $j ] ) { continue; } $tj = $text[ $j ]; if ( '(' === $tj || '[' === $tj || '{' === $tj || '#[' === $tj ) { ++$d; } elseif ( ')' === $tj || ']' === $tj || '}' === $tj ) { if ( 0 === $d ) { break; } --$d; } elseif ( 0 === $d ) { if ( '?' === $tj ) { ++$q; } elseif ( ':' === $tj ) { if ( 0 === $q ) { break; } --$q; } elseif ( ';' === $tj ) { $st['out'][] = self::item( $flag['case_semicolon'], $lines[ $i ], '', $ix ); break; } } } } break; case 'default': if ( ';' === $text[ $i + 1 ] && isset( $flag['case_semicolon'] ) ) { $st['out'][] = self::item( $flag['case_semicolon'], $lines[ $i ], '', $ix ); } break; case 'class': case 'interface': case 'trait': case 'enum': $pv = $text[ $i - 1 ]; if ( '::' !== $pv && '->' !== $pv && '?->' !== $pv && 'i' === $kind[ $i + 1 ] ) { if ( '_' === $text[ $i + 1 ] && isset( $flag['class_name'] ) ) { $st['out'][] = self::item( $flag['class_name'], $lines[ $i + 1 ], '', $ix ); } if ( 'enum' === $t ) { $st['pe'] = true; } } break; case 'namespace': if ( 'i' === $kind[ $i + 1 ] || '{' === $text[ $i + 1 ] ) { $st['ns'] = 'i' === $kind[ $i + 1 ]; $st['imp'] = array(); } break; case 'use': $pv = $text[ $i - 1 ]; $nx = $text[ $i + 1 ]; if ( ( ';' !== $pv && '{' !== $pv && '}' !== $pv ) || '(' === $nx || 'function' === $nx || 'const' === $nx ) { break; } $pre = ''; for ( $j = $i + 1; $j < $n && ';' !== $text[ $j ]; $j++ ) { if ( '}' === $text[ $j ] ) { $pre = ''; } if ( 'i' !== $kind[ $j ] ) { continue; } $full = strtolower( ltrim( $text[ $j ], '\\' ) ); if ( '{' === $text[ $j + 1 ] && '\\' === substr( $full, -1 ) ) { $pre = $full; ++$j; continue; } $full = $pre . $full; if ( 'as' === $text[ $j + 1 ] && 'i' === $kind[ $j + 2 ] ) { $alias = strtolower( $text[ $j + 2 ] ); $j += 2; } else { $bs = strrpos( $full, '\\' ); $alias = false === $bs ? $full : substr( $full, $bs + 1 ); } $st['imp'][ $alias ] = $full; } break; } continue; } if ( 'c' === $k ) { $cn = strtolower( str_replace( array( '(', ')', ' ', "\t", "\r", "\n" ), '', $t ) ); if ( isset( $ix['CAST'][ $cn ] ) ) { $st['out'][] = self::item( $ix['CAST'][ $cn ], $lines[ $i ], '(' . $cn . ')', $ix ); } continue; } if ( 'b' === $k && isset( $flag['backtick'] ) ) { $st['out'][] = self::item( $flag['backtick'], $lines[ $i ], '', $ix ); } } } /** * Lower-case name of the global class a written name refers to, or '' when it is not a global class. * * @param string $g Name without a leading backslash. * @param bool $fq True when it was written with a leading backslash. * @param array $st State (namespace and imports seen so far). * @return string */ private static function cls( string $g, bool $fq, array $st ): string { $lc = strtolower( $g ); if ( $fq ) { return $lc; } if ( isset( $st['imp'][ $lc ] ) ) { return false === strpos( $st['imp'][ $lc ], '\\' ) ? $st['imp'][ $lc ] : ''; } return $st['ns'] ? '' : $lc; } /** * One raw finding item. * * @param string $rule Rule id. * @param int $line Line. * @param string $name Name as written. * @param array $ix Rule index. * @param string $key Guard key (f:function, c:CONSTANT, x:class) used to drop guarded uses of removed features. * @return array */ private static function item( string $rule, int $line, string $name, array $ix, string $key = '' ): array { return array( 'rule' => $rule, 'line' => $line, 'what' => str_replace( '{name}', $name, $ix['R'][ $rule ]['what'] ), 'fix' => '', 'key' => $key, ); } /** * A call to a function that has call-site rules. * * @param string $lc Lower-case name. * @param string $g Name as written. * @param int $i Token index of the name. * @param array $kind Kinds. * @param array $text Texts. * @param array $lines Lines. * @param int $n Token count. * @param array $ix Rule index. * @param array $st State. */ private static function on_call( string $lc, string $g, int $i, array $kind, array $text, array $lines, int $n, array $ix, array &$st ): void { $a = null; if ( 'function_exists' === $lc || 'defined' === $lc || 'extension_loaded' === $lc || 'class_exists' === $lc ) { $a = self::args( $kind, $text, $i + 1, $n ); if ( $a && $a['n'] >= 1 ) { $v = self::lit_str( $kind, $text, $a['rg'][0][0], $a['rg'][0][1] ); if ( null !== $v ) { $us = strpos( $v, '_' ); if ( 'function_exists' === $lc && false !== $us ) { $st['guard'][ 'p:' . strtolower( substr( ltrim( $v, '\\' ), 0, $us + 1 ) ) ] = true; } $pre = array( 'function_exists' => 'f:', 'defined' => 'c:', 'extension_loaded' => 'e:', 'class_exists' => 'x:', ); $st['guard'][ $pre[ $lc ] . ( 'defined' === $lc ? $v : strtolower( ltrim( $v, '\\' ) ) ) ] = true; } } return; } if ( isset( $ix['F'][ $lc ] ) ) { foreach ( $ix['F'][ $lc ] as $e ) { if ( null !== $e[1] ) { if ( null === $a ) { $a = self::args( $kind, $text, $i + 1, $n ); } if ( ! $a || ! self::when( $e[1], $a, $kind, $text ) ) { continue; } } $st['calls'][] = array( $lc, self::item( $e[0], $lines[ $i ], $g, $ix, 'f:' . $lc ) ); } } if ( isset( $ix['FLT'][ $lc ] ) ) { if ( null === $a ) { $a = self::args( $kind, $text, $i + 1, $n ); } if ( $a && ! $a['unk'] && $a['n'] >= 1 ) { foreach ( $ix['FLT'][ $lc ] as $e ) { if ( $a['n'] < $e[1] ) { $st['calls'][] = array( $lc, self::item( $e[0], $lines[ $i ], $g, $ix, 'f:' . $lc ) ); } } } } if ( ( 'ini_set' === $lc || 'ini_alter' === $lc ) && $ix['INI'] ) { if ( null === $a ) { $a = self::args( $kind, $text, $i + 1, $n ); } if ( $a && ! $a['unk'] && $a['n'] >= 2 ) { $d = self::lit_str( $kind, $text, $a['rg'][0][0], $a['rg'][0][1] ); if ( null !== $d && isset( $ix['INI'][ $d ] ) ) { $v = self::lit_val( $kind, $text, $a['rg'][1][0], $a['rg'][1][1] ); if ( null !== $v ) { $sp = $ix['INI'][ $d ][1]; if ( isset( $sp['bool_ne'] ) ) { $hit = self::to_bool( $v ) !== $sp['bool_ne'][0]; } elseif ( isset( $sp['ne'] ) ) { $hit = true; foreach ( $sp['ne'] as $dv ) { if ( $v === $dv || ( is_numeric( $v ) && is_numeric( $dv ) && (float) $v === (float) $dv ) ) { $hit = false; } } } else { $hit = in_array( $v, $sp['in'], true ); } if ( $hit ) { $st['calls'][] = array( $lc, self::item( $ix['INI'][ $d ][0], $lines[ $i ], $d, $ix ) ); } } } } } } /** * Split call arguments. $o is the index of the opening parenthesis. * * @param array $kind Kinds. * @param array $text Texts. * @param int $o Index of "(". * @param int $n Token count. * @return array|null n, rg (list of start,end), unk (spread or named arguments). Null when unbalanced. */ private static function args( array $kind, array $text, int $o, int $n ): ?array { $d = 0; $rg = array(); $s = $o + 1; for ( $i = $o; $i < $n; $i++ ) { if ( 'o' !== $kind[ $i ] ) { continue; } $t = $text[ $i ]; if ( '(' === $t || '[' === $t || '{' === $t || '#[' === $t ) { ++$d; } elseif ( ')' === $t || ']' === $t || '}' === $t ) { --$d; if ( 0 === $d ) { if ( $i > $s ) { $rg[] = array( $s, $i ); } $unk = false; foreach ( $rg as $r ) { if ( '...' === $text[ $r[0] ] || ( 'i' === $kind[ $r[0] ] && ':' === $text[ $r[0] + 1 ] ) ) { $unk = true; } } return array( 'n' => count( $rg ), 'rg' => $rg, 'unk' => $unk, ); } } elseif ( ',' === $t && 1 === $d ) { $rg[] = array( $s, $i ); $s = $i + 1; } } return null; } /** * Check a "when" list: any one alternative may match. * * @param array $when Alternatives. * @param array $a Parsed arguments. * @param array $kind Kinds. * @param array $text Texts. * @return bool */ private static function when( array $when, array $a, array $kind, array $text ): bool { if ( $a['unk'] ) { return false; } foreach ( $when as $alt ) { if ( isset( $alt['argc'] ) && ( $a['n'] < $alt['argc'][0] || ( null !== $alt['argc'][1] && $a['n'] > $alt['argc'][1] ) ) ) { continue; } $ok = true; foreach ( isset( $alt['arg'] ) ? $alt['arg'] : array() as $pos => $spec ) { if ( $pos > $a['n'] || ! self::spec( $spec, $a['rg'][ $pos - 1 ][0], $a['rg'][ $pos - 1 ][1], $kind, $text ) ) { $ok = false; break; } } if ( $ok ) { return true; } } return false; } /** * Does the argument tokens $s..$e-1 satisfy one of the spec alternatives? * * @param array $spec Spec, key => values. * @param int $s First token. * @param int $e End (exclusive). * @param array $kind Kinds. * @param array $text Texts. * @return bool */ private static function spec( array $spec, int $s, int $e, array $kind, array $text ): bool { $len = $e - $s; foreach ( $spec as $key => $val ) { switch ( $key ) { case 'const': if ( 1 === $len && 'i' === $kind[ $s ] && in_array( ltrim( $text[ $s ], '\\' ), $val, true ) ) { return true; } break; case 'int': if ( 1 === $len && 'n' === $kind[ $s ] && ctype_digit( $text[ $s ] ) && in_array( (int) $text[ $s ], $val, true ) ) { return true; } break; case 'null': if ( 1 === $len && 'i' === $kind[ $s ] && 'null' === strtolower( ltrim( $text[ $s ], '\\' ) ) ) { return true; } break; case 'neg': if ( 2 === $len && '-' === $text[ $s ] && 'n' === $kind[ $s + 1 ] ) { return true; } break; case 'int_out': $neg = ( 2 === $len && '-' === $text[ $s ] ); if ( ( 1 === $len || $neg ) && 'n' === $kind[ $e - 1 ] && ctype_digit( $text[ $e - 1 ] ) ) { $num = $neg ? - (int) $text[ $e - 1 ] : (int) $text[ $e - 1 ]; if ( $num < $val[0] || $num > $val[1] ) { return true; } } break; case 'str_any': if ( 1 === $len && 's' === $kind[ $s ] ) { return true; } break; case 'str': case 'stri': $v = self::lit_str( $kind, $text, $s, $e ); if ( null !== $v && in_array( 'stri' === $key ? strtolower( $v ) : $v, $val, true ) ) { return true; } break; case 'str_len_ne': $v = self::lit_str( $kind, $text, $s, $e ); if ( null !== $v && strlen( $v ) !== $val ) { return true; } break; case 'has_const': for ( $q = $s; $q < $e; $q++ ) { if ( 'i' === $kind[ $q ] && in_array( ltrim( $text[ $q ], '\\' ), $val, true ) ) { return true; } } break; } } return false; } /** * Value of a plain string literal argument, or null. * * @param array $kind Kinds. * @param array $text Texts. * @param int $s First token. * @param int $e End (exclusive). * @return string|null */ private static function lit_str( array $kind, array $text, int $s, int $e ): ?string { if ( 1 !== $e - $s || 's' !== $kind[ $s ] ) { return null; } $x = $text[ $s ]; $q = $x[0]; if ( "'" === $q ) { return str_replace( array( '\\\\', "\\'" ), array( '\\', "'" ), substr( $x, 1, -1 ) ); } if ( '"' === $q ) { $in = substr( $x, 1, -1 ); return ( false === strpbrk( $in, '\\${' ) ) ? $in : null; } return null; } /** * Boolean value of an INI string the way PHP reads it: on, yes, true, or a non-zero number. * * @param string $v Value. * @return bool */ private static function to_bool( string $v ): bool { $l = strtolower( trim( $v ) ); return 'on' === $l || 'yes' === $l || 'true' === $l || 0 !== (int) $l; } /** * Literal value of an INI argument as a string, or null when it is not a literal. * * @param array $kind Kinds. * @param array $text Texts. * @param int $s First token. * @param int $e End (exclusive). * @return string|null */ private static function lit_val( array $kind, array $text, int $s, int $e ): ?string { $len = $e - $s; if ( 's' === $kind[ $s ] ) { return self::lit_str( $kind, $text, $s, $e ); } if ( 1 === $len && 'n' === $kind[ $s ] ) { return ctype_digit( $text[ $s ] ) ? (string) (int) $text[ $s ] : $text[ $s ]; } if ( 2 === $len && '-' === $text[ $s ] && 'n' === $kind[ $s + 1 ] && ctype_digit( $text[ $s + 1 ] ) ) { return '-' . (int) $text[ $s + 1 ]; } if ( 1 === $len && 'i' === $kind[ $s ] ) { $w = strtolower( ltrim( $text[ $s ], '\\' ) ); if ( 'true' === $w ) { return '1'; } if ( 'false' === $w || 'null' === $w ) { return ''; } } return null; } /** * Parameter list of a function, method, closure or arrow function (index $i is "function" or "fn"). * * @param array $kind Kinds. * @param array $text Texts. * @param array $lines Lines. * @param int $i Token index of the keyword. * @param int $n Token count. * @param array $ix Rule index. * @param array $st State. */ private static function params( array $kind, array $text, array $lines, int $i, int $n, array $ix, array &$st ): void { $p = $i + 1; if ( '&' === $text[ $p ] ) { ++$p; } if ( 'function' === $text[ $i ] && ( 'i' === $kind[ $p ] || 'k' === $kind[ $p ] ) && '(' === $text[ $p + 1 ] ) { if ( ! in_array( $text[ $i - 1 ], array( 'public', 'protected', 'private', 'static', 'abstract', 'final', 'var' ), true ) ) { $st['decl'][ strtolower( ltrim( $text[ $p ], '\\' ) ) ] = true; } ++$p; } if ( '(' !== $text[ $p ] ) { return; } ++$p; $want = isset( $ix['FLAG']['nullable'] ); while ( $p < $n ) { while ( '#[' === $text[ $p ] ) { $p = self::skip_group( $text, $p, $n ); } $promo = false; while ( ( 'k' === $kind[ $p ] || 'i' === $kind[ $p ] ) && in_array( strtolower( $text[ $p ] ), array( 'public', 'protected', 'private', 'readonly', 'final' ), true ) ) { $promo = true; ++$p; if ( '(' === $text[ $p ] && 'set' === strtolower( $text[ $p + 1 ] ) && ')' === $text[ $p + 2 ] ) { $p += 3; } } $ty = array(); $d = 0; for ( ; $p < $n; $p++ ) { $t = $text[ $p ]; if ( 'v' === $kind[ $p ] || '...' === $t || ( '&' === $t && ( 'v' === $kind[ $p + 1 ] || '...' === $text[ $p + 1 ] ) ) ) { break; } if ( '(' === $t ) { ++$d; } elseif ( ')' === $t ) { if ( 0 === $d ) { break; } --$d; } elseif ( ',' === $t || ';' === $t || '{' === $t || '=' === $t ) { break; } $ty[] = $p; } if ( '&' === $text[ $p ] ) { ++$p; } if ( '...' === $text[ $p ] ) { ++$p; } if ( 'v' !== $kind[ $p ] ) { return; } $varname = $text[ $p ]; $vl = $lines[ $p ]; ++$p; $null = false; if ( '=' === $text[ $p ] ) { $s = $p + 1; $d = 0; for ( $q = $s; $q < $n; $q++ ) { if ( 'o' !== $kind[ $q ] ) { continue; } $t = $text[ $q ]; if ( '(' === $t || '[' === $t || '{' === $t || '#[' === $t ) { ++$d; } elseif ( ')' === $t || ']' === $t || '}' === $t ) { if ( 0 === $d ) { break; } --$d; } elseif ( ',' === $t && 0 === $d ) { break; } } $e = $q; while ( $e - $s >= 3 && '(' === $text[ $s ] && ')' === $text[ $e - 1 ] ) { ++$s; --$e; } $null = ( 1 === $e - $s && 'i' === $kind[ $s ] && 'null' === strtolower( ltrim( $text[ $s ], '\\' ) ) ); $p = $q; } if ( $want && $null && $ty && ! $promo ) { self::nullable( $kind, $text, $ty, $varname, $vl, $ix, $st ); } if ( ',' === $text[ $p ] ) { ++$p; if ( ')' === $text[ $p ] ) { return; } continue; } return; } } /** * Record a finding when the type of a parameter with a null default does not allow null. * * @param array $kind Kinds. * @param array $text Texts. * @param array $ty Type token indexes. * @param string $varname Variable with the dollar sign. * @param int $vl Line of the variable. * @param array $ix Rule index. * @param array $st State. */ private static function nullable( array $kind, array $text, array $ty, string $varname, int $vl, array $ix, array &$st ): void { $txt = ''; foreach ( $ty as $q ) { $w = strtolower( ltrim( $text[ $q ], '\\' ) ); if ( '?' === $text[ $q ] || 'null' === $w || 'mixed' === $w ) { return; } $txt .= $text[ $q ]; } $rule = $ix['FLAG']['nullable']; $name = ltrim( $varname, '$' ); $nt = ( 1 === count( $ty ) ) ? '?' . $txt : $txt . '|null'; $st['out'][] = array( 'rule' => $rule, 'line' => $vl, 'what' => 'Parameter $' . $name . ' has type ' . $txt . ' and default null.', 'fix' => 'Write ' . $nt . ' $' . $name . ' = null.', 'key' => '', ); } /** * Index after the "#[ ... ]" group that starts at $p. * * @param array $text Texts. * @param int $p Index of "#[". * @param int $n Token count. * @return int */ private static function skip_group( array $text, int $p, int $n ): int { $d = 0; for ( ; $p < $n; $p++ ) { if ( '#[' === $text[ $p ] || '[' === $text[ $p ] ) { ++$d; } elseif ( ']' === $text[ $p ] ) { --$d; if ( 0 === $d ) { return $p + 1; } } } return $n; } /** * Turn raw items into findings: drop calls to functions the file declares, drop guarded uses of removed features (never for rules that depend on arguments: function_exists() does not vouch for them), remove duplicates, * sort by line, add the rule fields and the source line. * * @param array $st State. * @param string $src Source. * @param array $rule_defs Rules. * @return array */ private static function finish( array $st, string $src, array $rule_defs ): array { $items = array(); foreach ( array_merge( $st['out'], array_column( $st['calls'], 1 ) ) as $it ) { $rule = $rule_defs[ $it['rule'] ]; if ( 'removed' === $rule['level'] && '' !== $it['key'] && ! isset( $rule['trigger']['when'] ) && ! isset( $rule['trigger']['func_lt'] ) && ( isset( $st['guard'][ $it['key'] ] ) || ( isset( $rule['guard_ext'] ) && ( isset( $st['guard'][ 'e:' . $rule['guard_ext'] ] ) || isset( $st['guard'][ 'p:' . $rule['guard_ext'] . '_' ] ) ) ) ) ) { continue; } $items[] = $it; } if ( $st['decl'] ) { $decl = $st['decl']; $items = array_values( array_filter( $items, function ( $it ) use ( $decl ) { return 0 !== strpos( $it['key'], 'f:' ) || ! isset( $decl[ substr( $it['key'], 2 ) ] ); } ) ); } if ( ! $items ) { return array(); } $seen = array(); $rows = array(); foreach ( $items as $it ) { $key = $it['rule'] . '|' . $it['line'] . '|' . $it['what']; if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $it['seq'] = count( $rows ); $rows[] = $it; } usort( $rows, function ( $a, $b ) { return $a['line'] === $b['line'] ? $a['seq'] - $b['seq'] : $a['line'] - $b['line']; } ); $code = array(); $pos = 0; $cur = 1; foreach ( $rows as $r ) { $w = $r['line']; if ( isset( $code[ $w ] ) ) { continue; } while ( $cur < $w ) { $q = strpos( $src, "\n", $pos ); if ( false === $q ) { break; } $pos = $q + 1; ++$cur; } $q = strpos( $src, "\n", $pos ); $code[ $w ] = self::tidy( substr( $src, $pos, ( false === $q ? strlen( $src ) : $q ) - $pos ) ); } $out = array(); foreach ( $rows as $r ) { $d = $rule_defs[ $r['rule'] ]; $out[] = array( 'rule' => $r['rule'], 'line' => $r['line'], 'since' => $d['since'], 'level' => $d['level'], 'title' => $d['title'], 'what' => self::tidy( $r['what'] ), 'fix' => self::tidy( '' !== $r['fix'] ? $r['fix'] : $d['fix'] ), 'doc' => $d['doc'], 'code' => isset( $code[ $r['line'] ] ) ? $code[ $r['line'] ] : '', ); } return $out; } /** * Make a source-derived string safe for JSON and HTML output: one line, valid UTF-8, at most 160 bytes. * * @param string $s Text. * @return string */ private static function tidy( string $s ): string { $s = trim( (string) preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $s ) ); if ( ! preg_match( '//u', $s ) ) { $s = (string) preg_replace( '/[\x80-\xFF]/', '?', $s ); } if ( strlen( $s ) > 160 ) { $s = substr( $s, 0, 157 ); while ( '' !== $s && ! preg_match( '//u', $s ) ) { $s = substr( $s, 0, -1 ); } $s = rtrim( $s ) . '...'; } return $s; } }