From 905bb8c2f381e4ea95cca13a113149724192fecf Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 00:39:22 +0200 Subject: [PATCH] feat: fingerprint Divi functions from source tokens Co-Authored-By: Claude Haiku 4.5 --- .../Divi/Release/SourceFingerprint.php | 185 ++++++++++++++++++ .../Divi/Release/SourceFingerprintTest.php | 131 +++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 src/Integrations/Divi/Release/SourceFingerprint.php create mode 100644 tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php diff --git a/src/Integrations/Divi/Release/SourceFingerprint.php b/src/Integrations/Divi/Release/SourceFingerprint.php new file mode 100644 index 0000000..75556de --- /dev/null +++ b/src/Integrations/Divi/Release/SourceFingerprint.php @@ -0,0 +1,185 @@ + $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 $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); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php b/tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php new file mode 100644 index 0000000..51d5b08 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php @@ -0,0 +1,131 @@ + 1 ); + } +} + +class Other { + public static function update( $request ) { + return array( 'b' => 2 ); + } +} +PHP; + + public function testFindsAFunctionInsideAFunctionExistsGuard(): void + { + $print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save'); + + self::assertNotNull($print); + self::assertSame(7, $print->startLine); + self::assertSame(13, $print->endLine); + self::assertStringStartsWith('function et_demo_save(', $print->source); + self::assertStringEndsWith('}', $print->source); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $print->hash); + } + + public function testClassConstantsAndInterpolationDoNotConfuseTheBraceCount(): void + { + $print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save'); + + self::assertNotNull($print); + self::assertStringContainsString('return Foo::class;', $print->source); + self::assertStringNotContainsString('class Controller', $print->source); + } + + public function testMethodIsFoundInItsOwnClassOnly(): void + { + $controller = SourceFingerprint::fromSource(self::SOURCE, 'update', 'ET\Builder\VisualBuilder\Controller'); + $other = SourceFingerprint::fromSource(self::SOURCE, 'update', 'Other'); + + self::assertNotNull($controller); + self::assertNotNull($other); + self::assertStringContainsString("'a' => 1", $controller->source); + self::assertStringContainsString("'b' => 2", $other->source); + self::assertNotSame($controller->hash, $other->hash); + self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'update'), 'a method is no top-level function'); + self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save', 'Controller')); + self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'missing')); + } + + public function testCommentsAndWhitespaceDoNotChangeTheHash(): void + { + $reformatted = str_replace( + ["\t\t\$value = ", "if ( \$id ) {"], + ["\t\t// Why: demo.\n\t\t\$value = ", "if (\$id)\n\t\t{"], + self::SOURCE + ); + + self::assertSame( + SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash, + SourceFingerprint::fromSource($reformatted, 'et_demo_save')?->hash + ); + } + + public function testACodeChangeChangesTheHash(): void + { + $changed = str_replace("'_et_demo'", "'_et_demo_v2'", self::SOURCE); + + self::assertNotSame( + SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash, + SourceFingerprint::fromSource($changed, 'et_demo_save')?->hash + ); + } + + public function testOfReadsTheFileAndThrowsForMissingFunctions(): void + { + $file = (string) tempnam(sys_get_temp_dir(), 'lit'); + file_put_contents($file, self::SOURCE); + + try { + self::assertSame( + SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash, + SourceFingerprint::of($file, 'et_demo_save')->hash + ); + try { + SourceFingerprint::of($file, 'update', 'Missing'); + self::fail('a missing method must throw'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Missing::update() not found', $e->getMessage()); + } + } finally { + unlink($file); + } + } + + public function testOfThrowsForAnUnreadableFile(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not readable'); + + SourceFingerprint::of('/nonexistent/lit-divi.php', 'et_demo_save'); + } +}