Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
186 lines
6.6 KiB
PHP
186 lines
6.6 KiB
PHP
<?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);
|
||
}
|
||
}
|