Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
113 lines
4.0 KiB
PHP
113 lines
4.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Integrations\Divi\Release;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
/**
|
|
* Finds where PHP code touches Divi (DiviBoundaryTest, spec §3): calls of et_* functions, ET\… / ET_Core… /
|
|
* ET_Builder… classes (also as strings), ET_* constants (also as strings) and the strings 'et_…'/'_et_…' (post
|
|
* types, meta keys, options, function names for function_exists()). Comments are ignored; method calls named
|
|
* et_* ($x->et_a(), Foo::et_b()) are not Divi's.
|
|
*/
|
|
final class DiviTouches
|
|
{
|
|
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
|
|
|
|
private const NOT_A_CALL = [
|
|
T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST,
|
|
];
|
|
|
|
/** @return list<array{kind: string, name: string, line: int}> */
|
|
public static function inFile(string $path): array
|
|
{
|
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local PHP source, not a remote request.
|
|
$source = file_get_contents($path);
|
|
|
|
return is_string($source) ? self::inSource($source) : [];
|
|
}
|
|
|
|
/** @return list<array{kind: string, name: string, line: int}> */
|
|
public static function inSource(string $source): array
|
|
{
|
|
$tokens = array_values(array_filter(
|
|
token_get_all($source),
|
|
static fn (mixed $token): bool => ! is_array($token) || ! in_array($token[0], self::IGNORED, true)
|
|
));
|
|
|
|
$touches = [];
|
|
foreach ($tokens as $i => $token) {
|
|
if (! is_array($token)) {
|
|
continue;
|
|
}
|
|
$touch = match ($token[0]) {
|
|
T_STRING, T_NAME_FULLY_QUALIFIED => self::name($tokens, $i, ltrim($token[1], '\\')),
|
|
T_NAME_QUALIFIED => str_starts_with($token[1], 'ET\\') ? ['class', $token[1]] : null,
|
|
T_CONSTANT_ENCAPSED_STRING => self::literal($token[1]),
|
|
default => null,
|
|
};
|
|
if ($touch !== null) {
|
|
$touches[] = ['kind' => $touch[0], 'name' => $touch[1], 'line' => (int) $token[2]];
|
|
}
|
|
}
|
|
|
|
return $touches;
|
|
}
|
|
|
|
/** Whether inSource() reports $name when the code uses it (DiviBoundaryTest's unused-entry check). */
|
|
public static function detects(string $name): bool
|
|
{
|
|
return self::classify($name) !== null || preg_match('/^_?et_[a-z0-9_]*$/', $name) === 1;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $tokens
|
|
* @return array{0: string, 1: string}|null
|
|
*/
|
|
private static function name(array $tokens, int $i, string $name): ?array
|
|
{
|
|
$classified = self::classify($name);
|
|
if ($classified !== null) {
|
|
return $classified;
|
|
}
|
|
if (preg_match('/^et_[a-z0-9_]*$/', $name) !== 1 || ($tokens[$i + 1] ?? null) !== '(') {
|
|
return null;
|
|
}
|
|
$previous = $tokens[$i - 1] ?? null;
|
|
|
|
return is_array($previous) && in_array($previous[0], self::NOT_A_CALL, true) ? null : ['function', $name];
|
|
}
|
|
|
|
/** @return array{0: string, 1: string}|null */
|
|
private static function literal(string $text): ?array
|
|
{
|
|
$value = substr($text, 1, -1);
|
|
if ($text[0] === "'") {
|
|
$value = str_replace(['\\\\', "\\'"], ['\\', "'"], $value);
|
|
}
|
|
$value = ltrim($value, '\\');
|
|
|
|
$classified = self::classify($value);
|
|
if ($classified !== null) {
|
|
return $classified;
|
|
}
|
|
|
|
return preg_match('/^_?et_[a-z0-9_]*$/', $value) === 1 ? ['string', $value] : null;
|
|
}
|
|
|
|
/** @return array{0: string, 1: string}|null A Divi class or constant name. */
|
|
private static function classify(string $name): ?array
|
|
{
|
|
if (
|
|
preg_match('/^ET\\\\[A-Za-z0-9_\\\\]+$/', $name) === 1
|
|
|| preg_match('/^ET_(?:Core|Builder)[A-Za-z0-9_]*$/', $name) === 1
|
|
) {
|
|
return ['class', $name];
|
|
}
|
|
|
|
return preg_match('/^ET_[A-Z0-9_]+$/', $name) === 1 ? ['constant', $name] : null;
|
|
}
|
|
}
|