feat: fingerprint Divi functions from source tokens
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Fingerprint of one Divi function or method (spec §4.2), read with token_get_all(): the file is never included,
|
||||
* so functions Divi loads only in some requests (theme-builder/api.php, admin-ajax only) are readable in every
|
||||
* request. The hash covers the tokens from "function" to the closing brace without comments and whitespace –
|
||||
* new comments or reformatting keep it, any code change changes it. $source is the verbatim text of those
|
||||
* tokens for the human diff (wp lit-mcp divi-check --diff).
|
||||
*/
|
||||
final class SourceFingerprint
|
||||
{
|
||||
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
|
||||
|
||||
private const TYPES = [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM];
|
||||
|
||||
private function __construct(
|
||||
public readonly string $hash,
|
||||
public readonly string $source,
|
||||
public readonly int $startLine,
|
||||
public readonly int $endLine
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws RuntimeException When the file is unreadable or holds no such function. */
|
||||
public static function of(string $file, string $function, ?string $class = null): self
|
||||
{
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading local PHP file, not remote content.
|
||||
$source = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
|
||||
if (! is_string($source)) {
|
||||
throw new RuntimeException(sprintf('%s is not readable', $file));
|
||||
}
|
||||
|
||||
return self::fromSource($source, $function, $class) ?? throw new RuntimeException(sprintf(
|
||||
'%s not found in %s',
|
||||
$class === null ? $function . '()' : self::shortName($class) . '::' . $function . '()',
|
||||
$file
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The function $function outside every class body, or – with $class – the method $function declared directly
|
||||
* in the class (trait, interface, enum) named like $class's last segment. The namespace is not compared: the
|
||||
* file path of the manifest fixes where to look.
|
||||
*/
|
||||
public static function fromSource(string $source, string $function, ?string $class = null): ?self
|
||||
{
|
||||
$tokens = token_get_all($source);
|
||||
$wanted = $class === null ? null : self::shortName($class);
|
||||
$depth = 0;
|
||||
/** @var list<array{0: int, 1: string}> $bodies Open class bodies: [brace depth, name]. */
|
||||
$bodies = [];
|
||||
$pending = null;
|
||||
|
||||
foreach ($tokens as $i => $token) {
|
||||
$id = is_array($token) ? $token[0] : null;
|
||||
$text = is_array($token) ? $token[1] : $token;
|
||||
|
||||
if ($id !== null && in_array($id, self::TYPES, true) && self::previousId($tokens, $i) !== T_DOUBLE_COLON) {
|
||||
$pending = self::nextName($tokens, $i) ?? '';
|
||||
continue;
|
||||
}
|
||||
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
|
||||
++$depth;
|
||||
if ($pending !== null && $text === '{') {
|
||||
$bodies[] = [$depth, $pending];
|
||||
$pending = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($text === '}') {
|
||||
if ($bodies !== [] && $bodies[count($bodies) - 1][0] === $depth) {
|
||||
array_pop($bodies);
|
||||
}
|
||||
--$depth;
|
||||
continue;
|
||||
}
|
||||
if ($id !== T_FUNCTION || self::previousId($tokens, $i) === T_USE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = self::nextName($tokens, $i);
|
||||
if ($name === null || strcasecmp($name, $function) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$body = $bodies === [] ? null : $bodies[count($bodies) - 1];
|
||||
$matches = $wanted === null
|
||||
? $body === null
|
||||
: $body !== null && $body[0] === $depth && strcasecmp($body[1], $wanted) === 0;
|
||||
if ($matches) {
|
||||
return self::capture($tokens, $i);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<int, mixed> $tokens */
|
||||
private static function capture(array $tokens, int $start): ?self
|
||||
{
|
||||
$startLine = (int) $tokens[$start][2];
|
||||
$parts = [];
|
||||
$verbatim = '';
|
||||
$depth = 0;
|
||||
$opened = false;
|
||||
$count = count($tokens);
|
||||
|
||||
for ($i = $start; $i < $count; ++$i) {
|
||||
$token = $tokens[$i];
|
||||
$id = is_array($token) ? $token[0] : null;
|
||||
$text = is_array($token) ? $token[1] : $token;
|
||||
$verbatim .= $text;
|
||||
if ($id === null || ! in_array($id, self::IGNORED, true)) {
|
||||
$parts[] = $text;
|
||||
}
|
||||
|
||||
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
|
||||
++$depth;
|
||||
$opened = true;
|
||||
} elseif ($text === '}') {
|
||||
--$depth;
|
||||
if ($opened && $depth === 0) {
|
||||
return new self(
|
||||
hash('sha256', implode(' ', $parts)),
|
||||
$verbatim,
|
||||
$startLine,
|
||||
$startLine + substr_count($verbatim, "\n")
|
||||
);
|
||||
}
|
||||
} elseif ($text === ';' && ! $opened) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The next name after $i (skipping whitespace, comments and "&"), null when something else follows. */
|
||||
private static function nextName(array $tokens, int $i): ?string
|
||||
{
|
||||
$count = count($tokens);
|
||||
for ($j = $i + 1; $j < $count; ++$j) {
|
||||
$token = $tokens[$j];
|
||||
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
|
||||
continue;
|
||||
}
|
||||
if ($token === '&' || (is_array($token) && $token[1] === '&')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return is_array($token) && $token[0] === T_STRING ? $token[1] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Token ID of the previous significant token, null for a single-character token or none. */
|
||||
private static function previousId(array $tokens, int $i): ?int
|
||||
{
|
||||
for ($j = $i - 1; $j >= 0; --$j) {
|
||||
$token = $tokens[$j];
|
||||
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return is_array($token) ? $token[0] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function shortName(string $class): string
|
||||
{
|
||||
$position = strrpos($class, '\\');
|
||||
|
||||
return $position === false ? $class : substr($class, $position + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user