Skip to content
This repository was archived by the owner on Mar 6, 2022. It is now read-only.

[POC][Improvement] completion handle aliases #33

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
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
7 changes: 4 additions & 3 deletions lib/Bridge/TolerantParser/ChainTolerantCompletor.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public function complete(TextDocument $source, ByteOffset $byteOffset): Generato
$truncatedSource = $this->truncateSource((string) $source, $byteOffset->toInt());

$node = $this->parser->parseSourceFile($truncatedSource)->getDescendantNodeAtPosition(
// the parser requires the byte offset, not the char offset
// use strlen because the parser requires the byte offset, not the char offset
// But we need to recalculate it because we removed trailing spaces when truncating
strlen($truncatedSource)
);

Expand Down Expand Up @@ -70,10 +71,10 @@ private function truncateSource(string $source, int $byteOffset): string
// ` will evaluate the Variable node as an expression node with a
// double variable `$\n $bar = `
$truncatedSource = substr($source, 0, $byteOffset);

// determine the last non-whitespace _character_ offset
$characterOffset = OffsetHelper::lastNonWhitespaceCharacterOffset($truncatedSource);

// truncate the source at the character offset
$truncatedSource = mb_substr($source, 0, $characterOffset);

Expand Down
159 changes: 159 additions & 0 deletions lib/Bridge/TolerantParser/ImportedNameSearcherCompletor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

namespace Phpactor\Completion\Bridge\TolerantParser;

use Generator;
use Microsoft\PhpParser\Node;
use Microsoft\PhpParser\Node\SourceFileNode;
use Microsoft\PhpParser\Parser;
use Microsoft\PhpParser\ResolvedName;
use Phpactor\Completion\Core\Completor\NameSearcherCompletor;
use Phpactor\Completion\Core\Suggestion;
use Phpactor\Completion\Core\Util\OffsetHelper;
use Phpactor\TextDocument\ByteOffset;
use Phpactor\TextDocument\TextDocument;

/**
* Replace the suggestions from the decorated decorator by new ones based on the impor table.
*/
final class ImportedNameSearcherCompletor implements NameSearcherCompletor
{
/**
* @var NameSearcherCompletor
*/
private $decorated;

/**
* @var Parser
*/
private $parser;

public function __construct(NameSearcherCompletor $decorated, Parser $parser = null)
{
$this->decorated = $decorated;
$this->parser = $parser ?: new Parser();
}

/**
* {@inheritDoc}
*/
public function complete(TextDocument $source, ByteOffset $byteOffset, string $name = null): Generator
{
$importTable = $this->getClassImportTableAtPosition($source, $byteOffset);
$suggestions = $this->decorated->complete($source, $byteOffset, $name);

foreach ($suggestions as $suggestion) {
$resolvedSuggestions = $this->resolveAliasSuggestions($importTable, $suggestion);

// Trick to avoid any BC break when converting to an array
// https://www.php.net/manual/fr/language.generators.syntax.php#control-structures.yield.from
foreach ($resolvedSuggestions as $resolvedSuggestion) {
yield $resolvedSuggestion;
}
}

return $suggestions->getReturn();
}

private function truncateSource(string $source, int $byteOffset): string
{
// truncate source at byte offset - we don't want the rest of the source
// file contaminating the completion (for example `$foo($<>\n $bar =
// ` will evaluate the Variable node as an expression node with a
// double variable `$\n $bar = `
$truncatedSource = substr($source, 0, $byteOffset);

// determine the last non-whitespace _character_ offset
$characterOffset = OffsetHelper::lastNonWhitespaceCharacterOffset($truncatedSource);

// truncate the source at the character offset
$truncatedSource = mb_substr($source, 0, $characterOffset);

return $truncatedSource;
}

/**
* Add suggestions when a class is already imported with an alias or when a relative name is abailable.
*
* Will update the suggestion to remove the import_name option if already imported.
* Will add a suggestion if the class is imported under an alias.
* Will add a suggestion if part of the namespace is imported (i.e. ORM\Column is a relative name).
*
* @param ResolvedName[] $importTable
*
* @return Suggestion[]
*/
private function resolveAliasSuggestions(array $importTable, Suggestion $suggestion): array
{
if (Suggestion::TYPE_CLASS !== $suggestion->type()) {
return [$suggestion];
}

$suggestionFqcn = $suggestion->nameImport();
$originalName = $suggestion->name();
$originalSnippet = $suggestion->snippet();
$suggestions = [];

foreach ($importTable as $alias => $resolvedName) {
$importFqcn = $resolvedName->getFullyQualifiedNameText();

if ($suggestionFqcn === $importFqcn && $originalName === $alias) {
// Ignore the original suggestion, another completor already retruns it
continue;
}

if (0 !== strpos($suggestionFqcn, $importFqcn)) {
// Ignore imported name that are not part of the one from suggestion
continue;
}

$name = $alias.substr($suggestionFqcn, strlen($importFqcn));
$suggestions[$alias] = $suggestion->withoutNameImport()->withName($name);

if ($originalSnippet && $originalName !== $name) {
$snippet = str_replace($originalName, $name, $originalSnippet);
$suggestions[$alias] = $suggestions[$alias]->withSnippet($snippet);
}
}

return array_values($suggestions);
}

/**
* @return ResolvedName[]
*/
private function getClassImportTableAtPosition(TextDocument $source, ByteOffset $byteOffset): array
{
// We only need the closest node to retrieve the import table
// It's not a big deal if it's not the completed node as long as it has
// the same import table
$node = $this->getClosestNodeAtPosition(
$this->parser->parseSourceFile((string) $source),
$byteOffset->toInt(),
);

try {
[$importTable] = $node->getImportTablesForCurrentScope();
} catch (\Exception $e) {
// If the node does not have an import table (SourceFileNode for example)
$importTable = [];
}

return $importTable;
}

private function getClosestNodeAtPosition(SourceFileNode $sourceFileNode, int $position): Node
{
$lastNode = $sourceFileNode;
/** @var Node $node */
foreach ($sourceFileNode->getDescendantNodes() as $node) {
if ($position < $node->getFullStart()) {
return $lastNode;
}

$lastNode = $node;
}

return $lastNode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@
use Phpactor\TextDocument\ByteOffset;
use Phpactor\TextDocument\TextDocument;

class NameSearcherCompletor extends CoreNameSearcherCompletor implements TolerantCompletor
final class NameSearcherCompletor implements TolerantCompletor
{
/**
* @var CoreNameSearcherCompletor
*/
private $nameCompletor;

public function __construct(CoreNameSearcherCompletor $nameCompletor)
{
$this->nameCompletor = $nameCompletor;
}

/**
* {@inheritDoc}
*/
public function complete(Node $node, TextDocument $source, ByteOffset $offset): Generator
{
$suggestions = $this->completeName($node->getText());
$suggestions = $this->nameCompletor->complete($source, $offset, $node->getText());

yield from $suggestions;

Expand Down
2 changes: 1 addition & 1 deletion lib/Bridge/TolerantParser/TolerantCompletor.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
interface TolerantCompletor
{
/**
* @return Generator & iterable<Suggestion>
* @return Suggestion[]|Generator<int, Suggestion, null, bool>
*/
public function complete(Node $node, TextDocument $source, ByteOffset $offset): Generator;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
use Phpactor\Completion\Core\Completor\NameSearcherCompletor;
use Phpactor\Completion\Core\Suggestion;
use Phpactor\Completion\Core\Util\OffsetHelper;
use Phpactor\ReferenceFinder\NameSearcher;
use Phpactor\ReferenceFinder\Search\NameSearchResult;
use Phpactor\TextDocument\ByteOffset;
use Phpactor\TextDocument\TextDocument;
use Phpactor\WorseReflection\Core\Exception\NotFound;
use Phpactor\WorseReflection\Reflector;

class DoctrineAnnotationCompletor extends NameSearcherCompletor implements Completor
final class DoctrineAnnotationCompletor implements Completor
{
/**
* @var NameSearcherCompletor
*/
private $nameCompletor;

/**
* @var Reflector
*/
Expand All @@ -30,12 +33,11 @@ class DoctrineAnnotationCompletor extends NameSearcherCompletor implements Compl
private $parser;

public function __construct(
NameSearcher $nameSearcher,
NameSearcherCompletor $nameCompletor,
Reflector $reflector,
Parser $parser = null
) {
parent::__construct($nameSearcher);

$this->nameCompletor = $nameCompletor;
$this->reflector = $reflector;
$this->parser = $parser ?: new Parser();
}
Expand All @@ -48,7 +50,7 @@ public function complete(TextDocument $source, ByteOffset $byteOffset): Generato
$node = $this->findNodeForPhpdocAtPosition(
$sourceNodeFile,
// the parser requires the byte offset, not the char offset
strlen($truncatedSource)
strlen($truncatedSource),
);

if (!$node) {
Expand All @@ -61,26 +63,19 @@ public function complete(TextDocument $source, ByteOffset $byteOffset): Generato
return true;
}

$suggestions = $this->completeName($annotation);
$suggestions = $this->nameCompletor->complete($source, $byteOffset, $annotation);

foreach ($suggestions as $suggestion) {
if (!$this->isAnAnnotation($suggestion)) {
continue;
}

yield $suggestion;
yield $suggestion->withSnippet($suggestion->name().'($1)$0');
}

return $suggestions->getReturn();
}

protected function createSuggestionOptions(NameSearchResult $result): array
{
return array_merge(parent::createSuggestionOptions($result), [
'snippet' => (string) $result->name()->head() .'($1)$0',
]);
}

private function truncateSource(string $source, int $byteOffset): string
{
// truncate source at byte offset - we don't want the rest of the source
Expand Down Expand Up @@ -117,7 +112,7 @@ private function findNodeForPhpdocAtPosition(SourceFileNode $sourceNodeFile, int
private function isAnAnnotation(Suggestion $suggestion): bool
{
try {
$reflectionClass = $this->reflector->reflectClass($suggestion->classImport());
$reflectionClass = $this->reflector->reflectClass($suggestion->shortDescription());
$docblock = $reflectionClass->docblock();

return false !== strpos($docblock->raw(), '@Annotation');
Expand Down
61 changes: 61 additions & 0 deletions lib/Core/Completor/CoreNameSearcherCompletor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace Phpactor\Completion\Core\Completor;

use Generator;
use Phpactor\Completion\Core\Suggestion;
use Phpactor\ReferenceFinder\NameSearcher;
use Phpactor\ReferenceFinder\Search\NameSearchResult;
use Phpactor\TextDocument\ByteOffset;
use Phpactor\TextDocument\TextDocument;

final class CoreNameSearcherCompletor implements NameSearcherCompletor
{
/**
* @var NameSearcher
*/
protected $nameSearcher;

public function __construct(NameSearcher $nameSearcher)
{
$this->nameSearcher = $nameSearcher;
}

/**
* {@inheritDoc}
*/
public function complete(
TextDocument $source,
ByteOffset $byteOffset,
string $name = null
): Generator {
if (!$name) {
return true;
}

foreach ($this->nameSearcher->search($name) as $result) {
$fqcn = $result->name();

yield Suggestion::createWithOptions($fqcn->head(), [
'type' => $this->suggestionType($result),
'short_description' => (string) $fqcn,
'name_import' => (string) $fqcn,
]);
}

return true;
}

protected function suggestionType(NameSearchResult $result): ?string
{
if ($result->type()->isClass()) {
return Suggestion::TYPE_CLASS;
}

if ($result->type()->isFunction()) {
return Suggestion::TYPE_FUNCTION;
}

return null;
}
}
Loading