Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions doc/reference/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1896,6 +1896,21 @@ When searching the index exclude records whose fully qualified names match any o

**Default**: ``[]``

.. _param_indexer.searcher_semi_fuzzy:


``indexer.searcher_semi_fuzzy``
"""""""""""""""""""""""""""""""


Type: boolean


How to match short names: by default only the leading part is matched (case insensitive). If true, the leading parts of subsequent subwords also match (camel/underscore, case sensitive). For example `InEx` and `index` match `IndexerExtension` but `inex` does not, `arw` matches `array_walk`.


**Default**: ``false``


.. _ObjectRendererExtension:

Expand Down
8 changes: 5 additions & 3 deletions lib/Indexer/Adapter/ReferenceFinder/IndexedNameSearcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@

class IndexedNameSearcher implements NameSearcher
{
public function __construct(private SearchClient $client)
{
public function __construct(
private SearchClient $client,
private bool $semiFuzzy,
) {
}

/**
Expand All @@ -30,7 +32,7 @@ public function search(string $name, ?string $type = null): Generator

$fullyQualified = str_starts_with($name, '\\');

$criteria = $fullyQualified ? Criteria::fqnBeginsWith(substr($name, 1)) : Criteria::shortNameBeginsWith($name);
$criteria = $fullyQualified ? Criteria::fqnBeginsWith(substr($name, 1)) : Criteria::shortNameMatchesTo($name, $this->semiFuzzy);

$typeCriteria = $this->resolveTypeCriteria($type);

Expand Down
7 changes: 6 additions & 1 deletion lib/Indexer/Extension/IndexerExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class IndexerExtension implements Extension
public const PARAM_IMPLEMENTATIONS_DEEP_REFERENCES = 'indexer.implementation_finder.deep';
public const PARAM_STUB_PATHS = 'indexer.stub_paths';
public const PARAM_SUPPORTED_EXTENSIONS = 'indexer.supported_extensions';
public const PARAM_SEARCHER_SEMI_FUZZY = 'indexer.searcher_semi_fuzzy';
public const TAG_WATCHER = 'indexer.watcher';
private const SERVICE_INDEXER_EXCLUDE_PATTERNS = 'indexer.exclude_patterns';
private const SERVICE_INDEXER_INCLUDE_PATTERNS = 'indexer.include_patterns';
Expand Down Expand Up @@ -101,6 +102,7 @@ public function configure(Resolver $schema): void
self::PARAM_IMPLEMENTATIONS_DEEP_REFERENCES => true,
self::PARAM_SUPPORTED_EXTENSIONS => ['php', 'phar'],
self::PARAM_SEARCH_INCLUDE_PATTERNS => [],
self::PARAM_SEARCHER_SEMI_FUZZY => false,
]);
$schema->setDescriptions([
self::PARAM_ENABLED_WATCHERS => 'List of allowed watchers. The first watcher that supports the current system will be used',
Expand All @@ -117,6 +119,7 @@ public function configure(Resolver $schema): void
self::PARAM_IMPLEMENTATIONS_DEEP_REFERENCES => 'Recurse over class implementations to resolve all class implementations (not just the classes directly implementing the subject)',
self::PARAM_SUPPORTED_EXTENSIONS => 'File extensions (e.g. `php`) for files that should be indexed',
self::PARAM_SEARCH_INCLUDE_PATTERNS => 'When searching the index exclude records whose fully qualified names match any of these regex patterns (use to exclude suggestions from search results). Namespace separators must be escaped as `\\\\\\\\` for example `^Foo\\\\\\\\` to include all namespaces whose first segment is `Foo`',
self::PARAM_SEARCHER_SEMI_FUZZY => 'How to match short names: by default only the leading part is matched (case insensitive). If true, the leading parts of subsequent subwords also match (camel/underscore, case sensitive). For example `InEx` and `index` match `IndexerExtension` but `inex` does not, `arw` matches `array_walk`.',
]);
$schema->setTypes([
self::PARAM_ENABLED_WATCHERS => 'array',
Expand All @@ -133,6 +136,7 @@ public function configure(Resolver $schema): void
self::PARAM_IMPLEMENTATIONS_DEEP_REFERENCES => 'boolean',
self::PARAM_SUPPORTED_EXTENSIONS => 'array',
self::PARAM_SEARCH_INCLUDE_PATTERNS => 'array',
self::PARAM_SEARCHER_SEMI_FUZZY => 'boolean',
]);
}

Expand Down Expand Up @@ -296,7 +300,8 @@ private function registerReferenceFinderAdapters(ContainerBuilder $container): v

$container->register(IndexedNameSearcher::class, function (Container $container) {
return new IndexedNameSearcher(
$container->get(SearchClient::class)
$container->get(SearchClient::class),
$container->parameter(self::PARAM_SEARCHER_SEMI_FUZZY)->bool(),
);
}, [ ReferenceFinderExtension::TAG_NAME_SEARCHER => []]);
}
Expand Down
6 changes: 6 additions & 0 deletions lib/Indexer/Model/Query/Criteria.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Phpactor\Indexer\Model\Query\Criteria\FileAbsolutePathBeginsWith;
use Phpactor\Indexer\Model\Query\Criteria\HasFlags;
use Phpactor\Indexer\Model\Query\Criteria\IsClassType;
use Phpactor\Indexer\Model\Query\Criteria\ShortNameMatchesTo;
use Phpactor\Indexer\Model\Query\Criteria\ShortNameContains;
use Phpactor\Indexer\Model\Query\Criteria\ExactShortName;
use Phpactor\Indexer\Model\Query\Criteria\FqnBeginsWith;
Expand All @@ -32,6 +33,11 @@ public static function shortNameBeginsWith(string $name): ShortNameBeginsWith
return new ShortNameBeginsWith($name);
}

public static function shortNameMatchesTo(string $name, bool $semiFuzzy): ShortNameMatchesTo
{
return new ShortNameMatchesTo($name, $semiFuzzy);
}

public static function fqnBeginsWith(string $name): FqnBeginsWith
{
return new FqnBeginsWith($name);
Expand Down
65 changes: 65 additions & 0 deletions lib/Indexer/Model/Query/Criteria/ShortNameMatchesTo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Phpactor\Indexer\Model\Query\Criteria;

use Phpactor\Indexer\Model\Query\Criteria;
use Phpactor\Indexer\Model\Record;
use Phpactor\Indexer\Model\Record\HasShortName;

class ShortNameMatchesTo extends Criteria
{
public function __construct(
private string $name,
private bool $semiFuzzy
) {
}

public function isSatisfiedBy(Record $record): bool
{
if (!$record instanceof HasShortName) {
return false;
}

if (!$this->name) {
return false;
}

if (str_starts_with(mb_strtolower($record->shortName()), mb_strtolower($this->name))) {
return true;
}

if (false === $this->semiFuzzy) {
return false;
}

return $this->semiFuzzySearch($this->name, $record->shortName());
}

private function semiFuzzySearch(string $search, string $subject): bool
{
$index = -1;

foreach (mb_str_split($search) as $char) {
$newIndex = mb_strpos($subject, $char, $index + 1);

if (false === $newIndex) {
return false;
}

if ($newIndex === $index + 1 || ctype_upper($char) || $char === '_') {
$index = $newIndex;
continue;
}

$underscoreIndex = mb_strpos($subject, '_', $index + 1);

if (false === $underscoreIndex || $newIndex !== $underscoreIndex + 1) {
return false;
}

$index = $newIndex;
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function testSearcherWithAbsolute(): void
$this->workspace()->put('project/Barfoo.php', '<?php namespace Foo; class Foobar {}');
$agent = $this->indexAgent();
$agent->indexer()->getJob()->run();
$searcher = new IndexedNameSearcher($agent->search());
$searcher = new IndexedNameSearcher($agent->search(), false);

$results = iterator_to_array($searcher->search('\Foo'));

Expand All @@ -42,7 +42,7 @@ public function testSearcher(): void
$this->workspace()->put('project/Foobar.php', '<?php class Foobar {}');
$agent = $this->indexAgent();
$agent->indexer()->getJob()->run();
$searcher = new IndexedNameSearcher($agent->search());
$searcher = new IndexedNameSearcher($agent->search(), false);

foreach ($searcher->search('Foo') as $result) {
assert($result instanceof NameSearchResult);
Expand All @@ -57,7 +57,7 @@ public function testSearcherForInterface(): void
$this->workspace()->put('project/Foobar.php', '<?php interface Foobar {}');
$agent = $this->indexAgent();
$agent->indexer()->getJob()->run();
$searcher = new IndexedNameSearcher($agent->search());
$searcher = new IndexedNameSearcher($agent->search(), false);

foreach ($searcher->search('Foo', NameSearcherType::INTERFACE) as $result) {
assert($result instanceof NameSearchResult);
Expand All @@ -75,7 +75,7 @@ public function testSearcherForEnum(): void
$this->workspace()->put('project/Foobar.php', '<?php enum Foobar {}');
$agent = $this->indexAgent();
$agent->indexer()->getJob()->run();
$searcher = new IndexedNameSearcher($agent->search());
$searcher = new IndexedNameSearcher($agent->search(), false);

foreach ($searcher->search('Foo', NameSearcherType::ENUM) as $result) {
assert($result instanceof NameSearchResult);
Expand All @@ -93,7 +93,7 @@ public function testSearcherForTrait(): void
$this->workspace()->put('project/Foobar.php', '<?php trait Foobar {}');
$agent = $this->indexAgent();
$agent->indexer()->getJob()->run();
$searcher = new IndexedNameSearcher($agent->search());
$searcher = new IndexedNameSearcher($agent->search(), false);

foreach ($searcher->search('Foo', NameSearcherType::TRAIT) as $result) {
assert($result instanceof NameSearchResult);
Expand All @@ -119,7 +119,7 @@ public function testSearcherForAttribute(string $query, string $type, array $exp

$agent = $this->indexAgent();
$agent->indexer()->getJob()->run();
$searcher = new IndexedNameSearcher($agent->search());
$searcher = new IndexedNameSearcher($agent->search(), false);

$resultPaths = [];
$offset = 1 + mb_strlen($this->workspace()->path());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Phpactor\Indexer\Tests\Benchmark\Model\Query\Criteria;

use Generator;
use PhpBench\Benchmark\Metadata\Annotations\ParamProviders;
use PhpBench\Benchmark\Metadata\Annotations\Revs;
use Phpactor\Indexer\Model\Query\Criteria\ShortNameMatchesTo;
use Phpactor\Indexer\Model\Query\Criteria\ShortNameBeginsWith;
use Phpactor\Indexer\Model\Record\ClassRecord;

class ShortNameMatchesToBench
{
/**
* @ParamProviders("provideSearch")
* @Revs(1000)
* @Iterations(5)
* @param array{string, string} $data
*/
public function benchBeginsWith(array $data): void
{
$criteria = new ShortNameBeginsWith($data[0]);

$record = ClassRecord::fromName($data[1]);

$criteria->isSatisfiedBy($record);
}

/**
* @ParamProviders("provideSearch")
* @Revs(1000)
* @Iterations(5)
* @param array{string, string} $data
*/
public function benchShortNameMatchesTo(array $data): void
{
$criteria = new ShortNameMatchesTo($data[0], true);

$record = ClassRecord::fromName($data[1]);

$criteria->isSatisfiedBy($record);
}

/**
* @return Generator<string,array{string,string}>
*/
public function provideSearch(): Generator
{
yield 'leading substring' => ['Bag', 'Foobar\\Bagno'];
yield 'empty search' => ['', 'Foobar\\Bagno'];
yield 'subsequence' => ['bgn', 'Foobar\\Bagno'];
yield 'multibyte' => ['☠😼', 'Foobar\\😼☠k😼'];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Phpactor\Indexer\Tests\Unit\Model\Query\Criteria;

use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Phpactor\Indexer\Model\Query\Criteria\ShortNameMatchesTo;
use Phpactor\Indexer\Model\Record\ClassRecord;

class ShortNameMatchesToTest extends TestCase
{
#[DataProvider('provideSearch')]
public function testLeadingOnly(string $name, string $path, bool $expectedLeading, bool $expectedFuzzy): void
{
$record = ClassRecord::fromName($path);
self::assertSame($expectedLeading, (new ShortNameMatchesTo($name, false))->isSatisfiedBy($record));
self::assertSame($expectedFuzzy, (new ShortNameMatchesTo($name, true))->isSatisfiedBy($record));
}

/**
* @return Generator<string,array{string,string,bool,bool}>
*/
public static function provideSearch(): Generator
{
yield 'empty search' => ['', 'Foobar\\Bagno', false, false];
yield 'no match' => ['Barfoo', 'Foobar\\Bazfoo', false, false];
yield 'matches exact' => ['Barfoo', 'Foobar\\Barfoo', true, true];
yield 'substring' => ['Bag', 'Foobar\\Bagno', true, true];
yield 'subsequence' => ['bgn', 'Foobar\\Bagno', false, false];
yield 'negative camel 1' => ['Shame', 'ShortNameBeginsWith', false, false];
yield 'tolower leading' => ['short', 'ShortNameBeginsWith', true, true];
yield 'camel 1' => ['ShBeg', 'ShortNameBeginsWith', false, true];
yield 'camel 2' => ['hBeg', 'ShortNameBeginsWith', false, false];
yield 'camel 3' => ['BegWit', 'ShortNameBeginsWith', false, true];
yield 'camel only upper' => ['SBW', 'ShortNameBeginsWith', false, true];
yield 'underscore in subject and phrase' => ['fil_g_c', 'file_get_contents', false, true];
yield 'underscore only in subject' => ['filgc', 'file_get_contents', false, true];
yield 'underscore in subject, negative' => ['fits', 'file_get_contents', false, false];
yield 'multibyte' => ['😼☠', 'Foobar\\😼☠k😼', true, true];
yield 'lower first' => ['gNT', 'getDescendantNodesAndTokens', false, true];
yield 'only upper in subject' => ['tr', 'TARGET_CLASS', false, false];
yield 'only upper in subject 2' => ['tc', 'TARGET_CLASS', false, false];
}
}