diff --git a/docs/capabilities.md b/docs/capabilities.md index 3b1dbc321de..0490b4da1fc 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -239,6 +239,7 @@ Auto-generated by `script/capability-matrix.php`. Do not edit by hand. | `sha1` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `shell_exec` | yes | yes | yes | standard | | | `shuffle` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | +| `similar_text` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `sin` | yes | yes | yes | standard | | | `sizeof` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `sleep` | yes | yes | yes | standard | JIT PHPT | diff --git a/docs/stdlib-jit-audit.md b/docs/stdlib-jit-audit.md index 3f5efa27c7b..887ef419e3b 100644 --- a/docs/stdlib-jit-audit.md +++ b/docs/stdlib-jit-audit.md @@ -167,6 +167,7 @@ _None — all JIT `call()` builtins are lowered or deferred._ - `krsort` — `ext/standard/krsort_.php` - `lcfirst` — `ext/standard/lcfirst.php` - `levenshtein` — `ext/standard/levenshtein.php` +- `similar_text` — `ext/standard/similar_text.php` - `soundex` — `ext/standard/soundex.php` - `log` — `ext/standard/log.php` - `lstat` — `ext/standard/lstat_.php` diff --git a/ext/standard/Module.php b/ext/standard/Module.php index e4bead56cea..20f28f8bae2 100755 --- a/ext/standard/Module.php +++ b/ext/standard/Module.php @@ -57,6 +57,7 @@ public function getFunctions(): array new chr(), new strcmp(), new levenshtein(), + new similar_text(), new soundex(), new metaphone(), new strnatcmp(), diff --git a/ext/standard/VmString.php b/ext/standard/VmString.php index e02e1a39290..b272ffb1d55 100644 --- a/ext/standard/VmString.php +++ b/ext/standard/VmString.php @@ -598,6 +598,95 @@ public static function levenshtein( return $prev[$len2]; } + /** + * similar_text() — PHP-compatible Oliver algorithm (issue #2445). + */ + public static function similar_text(string $string1, string $string2, ?float &$percent = null): int + { + $len1 = self::byteLength($string1); + $len2 = self::byteLength($string2); + if ($len1 > 255 || $len2 > 255) { + throw new \ValueError( + 'similar_text(): Argument #1 ($string1) or #2 ($string2) must be less than 256 characters' + ); + } + if (0 === $len1 && 0 === $len2) { + if (null !== $percent) { + $percent = 0.0; + } + + return 0; + } + $sim = self::similarChar($string1, $len1, $string2, $len2); + if (null !== $percent) { + $percent = $sim * 200.0 / ($len1 + $len2); + } + + return $sim; + } + + private static function similarStr( + string $txt1, + int $len1, + string $txt2, + int $len2, + int &$pos1, + int &$pos2, + int &$max, + int &$count + ): void { + $max = 0; + $count = 0; + for ($p = 0; $p < $len1; ++$p) { + for ($q = 0; $q < $len2; ++$q) { + $l = 0; + while ( + $p + $l < $len1 + && $q + $l < $len2 + && $txt1[$p + $l] === $txt2[$q + $l] + ) { + ++$l; + } + if ($l > $max) { + $max = $l; + ++$count; + $pos1 = $p; + $pos2 = $q; + } + } + } + } + + private static function similarChar(string $txt1, int $len1, string $txt2, int $len2): int + { + $pos1 = 0; + $pos2 = 0; + $max = 0; + $count = 0; + self::similarStr($txt1, $len1, $txt2, $len2, $pos1, $pos2, $max, $count); + $sum = $max; + if ($sum > 0) { + if ($pos1 > 0 && $pos2 > 0 && $count > 1) { + $sum += self::similarChar( + substr($txt1, 0, $pos1), + $pos1, + substr($txt2, 0, $pos2), + $pos2 + ); + } + if ($pos1 + $max < $len1 && $pos2 + $max < $len2) { + $sum += self::similarChar( + substr($txt1, $pos1 + $max), + $len1 - $pos1 - $max, + substr($txt2, $pos2 + $max), + $len2 - $pos2 - $max + ); + } + } + + return $sum; + } + /** * metaphone() — PHP-compatible Metaphone on ASCII letters (issue #2423). */ diff --git a/ext/standard/similar_text.php b/ext/standard/similar_text.php new file mode 100644 index 00000000000..70c0daa61fe --- /dev/null +++ b/ext/standard/similar_text.php @@ -0,0 +1,70 @@ +calledArgs); + if ($argc < 2 || $argc > 3) { + throw new \LogicException('similar_text() accepts two or three arguments in this compiler build'); + } + $a = $frame->calledArgs[0]->resolveIndirect(); + $b = $frame->calledArgs[1]->resolveIndirect(); + if (Variable::TYPE_STRING !== $a->type || Variable::TYPE_STRING !== $b->type) { + throw new \LogicException('similar_text() requires two strings in this compiler build'); + } + $s1 = $a->toString(); + $s2 = $b->toString(); + if (3 === $argc) { + $percent = 0.0; + $sim = VmString::similar_text($s1, $s2, $percent); + $frame->calledArgs[2]->resolveIndirect()->float($percent); + } else { + $sim = VmString::similar_text($s1, $s2); + } + if (null === $frame->returnVar) { + return; + } + $frame->returnVar->int($sim); + } + + public Context $context; + + public function call(Context $context, JITVariable ...$args): Value + { + $this->context = $context; + if (\count($args) !== 2) { + throw new \LogicException( + 'similar_text() JIT/AOT only supports two arguments in this compiler build; use bin/vm.php for &$percent' + ); + } + StringSimilarText::ensureLinked($context); + $p0 = $this->stringDataPtr($context, $this->jitString($context, $args[0], 'similar_text() argument #1')); + $p1 = $this->stringDataPtr($context, $this->jitString($context, $args[1], 'similar_text() argument #2')); + $fn = $context->lookupFunction('phpc_similar_text'); + $i64 = $context->getTypeFromString('int64'); + $raw = $context->builder->call($fn, $p0, $p1); + + return $context->builder->sExt($raw, $i64); + } +} diff --git a/lib/AOT/Linker.php b/lib/AOT/Linker.php index 106a2578c79..8ace059008d 100644 --- a/lib/AOT/Linker.php +++ b/lib/AOT/Linker.php @@ -21,6 +21,7 @@ final class Linker __DIR__.'/runtime/phpc_strnatcasecmp.c', __DIR__.'/runtime/phpc_substr_compare.c', __DIR__.'/runtime/phpc_levenshtein.c', + __DIR__.'/runtime/phpc_similar_text.c', __DIR__.'/runtime/phpc_soundex.c', __DIR__.'/runtime/phpc_metaphone.c', __DIR__.'/runtime/phpc_str_getcsv.c', diff --git a/lib/AOT/runtime/builtin_function_names.inc b/lib/AOT/runtime/builtin_function_names.inc index af77eaf4dbe..e26c12a04c1 100644 --- a/lib/AOT/runtime/builtin_function_names.inc +++ b/lib/AOT/runtime/builtin_function_names.inc @@ -230,6 +230,7 @@ static const char *phpc_builtin_functions[] = { "setrawcookie", "sha1", "shell_exec", + "similar_text", "shuffle", "sin", "sizeof", diff --git a/lib/AOT/runtime/phpc_similar_text.c b/lib/AOT/runtime/phpc_similar_text.c new file mode 100644 index 00000000000..8adddc523e5 --- /dev/null +++ b/lib/AOT/runtime/phpc_similar_text.c @@ -0,0 +1,94 @@ +/* + * similar_text() runtime for VM/JIT/AOT (issue #2445). + * PHP-compatible Oliver algorithm; byte-oriented; max 255 bytes per string. + */ + +#include +#include +#include + +#define PHPC_SIM_MAX_LEN 255 + +static void phpc_similar_str( + const char *txt1, + size_t len1, + const char *txt2, + size_t len2, + size_t *pos1, + size_t *pos2, + size_t *max, + size_t *count +) +{ + const char *p; + const char *q; + const char *end1 = txt1 + len1; + const char *end2 = txt2 + len2; + size_t l; + + *max = 0; + *count = 0; + for (p = txt1; p < end1; p++) { + for (q = txt2; q < end2; q++) { + for (l = 0; (p + l < end1) && (q + l < end2) && (p[l] == q[l]); l++) { + } + if (l > *max) { + *max = l; + *count += 1; + *pos1 = (size_t) (p - txt1); + *pos2 = (size_t) (q - txt2); + } + } + } +} + +static size_t phpc_similar_char(const char *txt1, size_t len1, const char *txt2, size_t len2) +{ + size_t sum; + size_t pos1 = 0; + size_t pos2 = 0; + size_t max = 0; + size_t count = 0; + + phpc_similar_str(txt1, len1, txt2, len2, &pos1, &pos2, &max, &count); + sum = max; + if (sum) { + if (pos1 && pos2 && count > 1) { + sum += phpc_similar_char(txt1, pos1, txt2, pos2); + } + if ((pos1 + max < len1) && (pos2 + max < len2)) { + sum += phpc_similar_char( + txt1 + pos1 + max, + len1 - pos1 - max, + txt2 + pos2 + max, + len2 - pos2 - max + ); + } + } + + return sum; +} + +int phpc_similar_text(const char *s1, const char *s2) +{ + size_t len1; + size_t len2; + + if (NULL == s1) { + s1 = ""; + } + if (NULL == s2) { + s2 = ""; + } + + len1 = strlen(s1); + len2 = strlen(s2); + if (len1 > PHPC_SIM_MAX_LEN || len2 > PHPC_SIM_MAX_LEN) { + return -1; + } + if (0 == len1 && 0 == len2) { + return 0; + } + + return (int) phpc_similar_char(s1, len1, s2, len2); +} diff --git a/lib/BuiltinParamNames.php b/lib/BuiltinParamNames.php index 275d79d0199..91cfcffe2b2 100644 --- a/lib/BuiltinParamNames.php +++ b/lib/BuiltinParamNames.php @@ -13,6 +13,7 @@ final class BuiltinParamNames private const MAP = [ 'strlen' => ['string'], 'parse_str' => ['string', 'array'], + 'similar_text' => ['string1', 'string2', 'percent'], ]; /** diff --git a/lib/JIT/Builtin/StringSimilarText.php b/lib/JIT/Builtin/StringSimilarText.php new file mode 100644 index 00000000000..ecfb39f93b8 --- /dev/null +++ b/lib/JIT/Builtin/StringSimilarText.php @@ -0,0 +1,195 @@ +loadType) { + $fn = $context->module->getNamedFunction('phpc_similar_text'); + if (null === $fn) { + $fn = self::declareStandalone($context); + } + $context->registerFunction('phpc_similar_text', $fn); + + return; + } + + $probe = $context->module->getNamedFunction('phpc_similar_text'); + if (null !== $probe && $probe->countBasicBlocks() > 0) { + self::registerLinkedRuntime($context); + + return; + } + + $bitcode = self::ensureBitcode(); + $data = file_get_contents($bitcode); + if (false === $data || '' === $data) { + throw new \LogicException('Failed to read phpc_similar_text JIT bitcode: '.$bitcode); + } + $buffer = $context->llvm->createMemoryBufferWithString($data, 'phpc_similar_text.bc'); + $runtimeModule = $buffer->parseBitcode($context->context); + if (!$context->module->link($runtimeModule)) { + throw new \LogicException('Failed to link phpc_similar_text JIT runtime bitcode'); + } + + self::registerLinkedRuntime($context); + } + + private static function declareStandalone(Context $context) + { + $ptr = $context->getTypeFromString('char*'); + $i64 = $context->getTypeFromString('int64'); + $ft = $context->context->functionType($i64, false, $ptr, $ptr); + + return $context->module->addFunction('phpc_similar_text', $ft); + } + + private static function registerLinkedRuntime(Context $context): void + { + $fn = $context->module->getNamedFunction('phpc_similar_text'); + if (null === $fn) { + throw new \LogicException('phpc_similar_text missing after phpc_similar_text bitcode link'); + } + $context->registerFunction('phpc_similar_text', $fn); + } + + private static function ensureBitcode(): string + { + $source = realpath(self::RUNTIME_SOURCE); + if (false === $source || !is_file($source)) { + throw new \LogicException('phpc_similar_text runtime source not found: '.self::RUNTIME_SOURCE); + } + + $compiler = self::resolveCompiler(); + $cacheDir = sys_get_temp_dir().'/phpc-jit-runtime'; + if (!is_dir($cacheDir) && !mkdir($cacheDir, 0777, true) && !is_dir($cacheDir)) { + throw new \LogicException('Cannot create JIT runtime cache: '.$cacheDir); + } + + $cache = $cacheDir.'/'.basename($source, '.c').'-'.substr( + sha1($source.filemtime($source).$compiler.'host'), + 0, + 16 + ).'.bc'; + if (is_file($cache) && filemtime($cache) >= filemtime($source)) { + return $cache; + } + + $includes = self::hostLibcIncludeFlags(); + $cmd = escapeshellarg($compiler) + .' -emit-llvm -c -fPIC -O2'.$includes.' ' + .escapeshellarg($source).' -o '.escapeshellarg($cache).' 2>&1'; + $output = shell_exec($cmd); + if (!is_file($cache)) { + throw new \LogicException( + 'Failed to compile phpc_similar_text JIT bitcode: '.trim((string) $output) + ); + } + + return $cache; + } + + private static function resolveCompiler(): string + { + $llvmDir = getenv('PHP_COMPILER_LLVM_PATH'); + if (false !== $llvmDir && '' !== $llvmDir) { + foreach (['clang-9', 'clang'] as $name) { + $candidate = $llvmDir.'/'.$name; + if (is_executable($candidate)) { + return $candidate; + } + } + } + + foreach (['clang-9', 'clang', 'gcc', 'cc'] as $name) { + $path = trim((string) shell_exec('command -v '.escapeshellarg($name).' 2>/dev/null')); + if ('' !== $path) { + return $path; + } + } + + throw new \LogicException('No C compiler found for phpc_similar_text JIT runtime bitcode'); + } + + private static function hostLibcIncludeFlags(): string + { + $flags = ''; + foreach (self::discoverSystemIncludeDirs() as $dir) { + $flags .= ' -isystem '.escapeshellarg($dir); + } + if ('' === $flags && is_file('/usr/include/stdio.h')) { + $flags = ' -isystem /usr/include'; + } + + return $flags; + } + + /** + * @return list + */ + private static function discoverSystemIncludeDirs(): array + { + $dirs = []; + foreach (['gcc', 'cc', 'clang'] as $compiler) { + $path = trim((string) shell_exec('command -v '.escapeshellarg($compiler).' 2>/dev/null')); + if ('' === $path) { + continue; + } + $verbose = shell_exec( + escapeshellarg($path).' -E -Wp,-v -xc /dev/null 2>&1' + ); + if (!is_string($verbose)) { + continue; + } + $capture = false; + foreach (explode("\n", $verbose) as $line) { + if (str_contains($line, '#include <...> search starts here:')) { + $capture = true; + + continue; + } + if ($capture) { + if (str_contains($line, 'End of search list')) { + break; + } + $dir = trim($line); + if ('' !== $dir && is_dir($dir)) { + $dirs[$dir] = true; + } + } + } + if ([] !== $dirs) { + break; + } + } + + if ([] === $dirs) { + foreach (['/usr/include', '/usr/include/x86_64-linux-gnu'] as $fallback) { + if (is_dir($fallback)) { + $dirs[$fallback] = true; + } + } + } + + return array_keys($dirs); + } +} diff --git a/test/compliance/SimilarTextJITTest.php b/test/compliance/SimilarTextJITTest.php new file mode 100644 index 00000000000..e1497fd8ffe --- /dev/null +++ b/test/compliance/SimilarTextJITTest.php @@ -0,0 +1,26 @@ + self::parsePHPT( + __DIR__.'/cases/stdlib/similar_text_jit.phpt', + 'similar_text_jit.phpt' + ); + } + + public function setUp(): void + { + $this->BIN = realpath(__DIR__.'/../../bin/jit.php'); + } +} diff --git a/test/compliance/SimilarTextVMTest.php b/test/compliance/SimilarTextVMTest.php new file mode 100644 index 00000000000..e07c5cc3632 --- /dev/null +++ b/test/compliance/SimilarTextVMTest.php @@ -0,0 +1,26 @@ + self::parsePHPT( + __DIR__.'/cases/stdlib/similar_text.phpt', + 'similar_text.phpt' + ); + } + + public function setUp(): void + { + $this->BIN = realpath(__DIR__.'/../../bin/vm.php'); + } +} diff --git a/test/compliance/cases/stdlib/similar_text.phpt b/test/compliance/cases/stdlib/similar_text.phpt new file mode 100644 index 00000000000..6c9330526ce --- /dev/null +++ b/test/compliance/cases/stdlib/similar_text.phpt @@ -0,0 +1,30 @@ +--TEST-- +stdlib similar_text() (#2445) +--FILE-- +assertSame(6, $this->runSimilarText('Hello World', 'Hello PHP')); + } + + public function testPercentByReference(): void + { + $percent = 0.0; + $sim = $this->runSimilarText('Hello World', 'Hello PHP', $percent); + $this->assertSame(6, $sim); + $this->assertEqualsWithDelta(60.0, $percent, 0.0001); + } + + public function testEmptyStrings(): void + { + $this->assertSame(0, $this->runSimilarText('', '')); + } + + private function runSimilarText(string $a, string $b, ?float &$percent = null): int + { + $runtime = new Runtime(); + $fn = new similar_text(); + $frame = $fn->getFrame($runtime->vmContext); + $args = [new VMVariable(), new VMVariable()]; + $args[0]->string($a); + $args[1]->string($b); + if (null !== $percent) { + $ref = new VMVariable(); + $holder = new VMVariable(); + $holder->float(0.0); + $ref->indirect($holder); + $args[] = $ref; + } + $frame->calledArgs = $args; + $frame->returnVar = new VMVariable(); + $fn->execute($frame); + if (null !== $percent && 3 === \count($args)) { + $percent = $args[2]->resolveIndirect()->toFloat(); + } + + return $frame->returnVar->toInt(); + } +}