glastree_on_gitea
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
use PhpParser\Node;
|
||||
|
||||
/**
|
||||
* Completion analysis result.
|
||||
*
|
||||
* Shared state for the completion pipeline.
|
||||
*
|
||||
* The analyzer establishes the initial context, refiners may narrow it, and
|
||||
* later stages reuse the same request metadata and parse-derived hints.
|
||||
*/
|
||||
class AnalysisResult
|
||||
{
|
||||
public int $kinds;
|
||||
public string $prefix;
|
||||
public ?string $leftSide;
|
||||
public ?Node $leftSideNode;
|
||||
/** @var string[] Fully-qualified class names (supports union types) */
|
||||
public array $leftSideTypes;
|
||||
public $leftSideValue;
|
||||
public string $input;
|
||||
/** @var array Tokenized input */
|
||||
public array $tokens;
|
||||
/** @var array Raw readline callback metadata, if available */
|
||||
public array $readlineInfo;
|
||||
/** @var bool Whether php-parser successfully parsed the input */
|
||||
public bool $parseSucceeded;
|
||||
|
||||
/**
|
||||
* @param string|string[]|null $leftSideTypes
|
||||
*/
|
||||
public function __construct(
|
||||
int $kinds,
|
||||
string $prefix = '',
|
||||
?string $leftSide = null,
|
||||
$leftSideTypes = [],
|
||||
$leftSideValue = null,
|
||||
array $tokens = [],
|
||||
string $input = '',
|
||||
?Node $leftSideNode = null,
|
||||
array $readlineInfo = [],
|
||||
bool $parseSucceeded = false
|
||||
) {
|
||||
$this->kinds = $kinds;
|
||||
$this->prefix = $prefix;
|
||||
$this->leftSide = $leftSide;
|
||||
$this->leftSideNode = $leftSideNode;
|
||||
$this->leftSideTypes = (array) $leftSideTypes;
|
||||
$this->leftSideValue = $leftSideValue;
|
||||
$this->tokens = $tokens;
|
||||
$this->input = $input;
|
||||
$this->readlineInfo = $readlineInfo;
|
||||
$this->parseSucceeded = $parseSucceeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy with updated completion context for a later pipeline stage.
|
||||
*/
|
||||
public function withContext(int $kinds, string $prefix = '', ?string $leftSide = null, ?Node $leftSideNode = null): self
|
||||
{
|
||||
// Types and value are cleared because CompletionEngine re-resolves
|
||||
// them after all refiners have run.
|
||||
$copy = clone $this;
|
||||
$copy->kinds = $kinds;
|
||||
$copy->prefix = $prefix;
|
||||
$copy->leftSide = $leftSide;
|
||||
$copy->leftSideNode = $leftSideNode;
|
||||
$copy->leftSideTypes = [];
|
||||
$copy->leftSideValue = null;
|
||||
|
||||
return $copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
use PhpParser\Node as AstNode;
|
||||
use Psy\CodeCleaner;
|
||||
use Psy\Completion\Refiner\AnalysisRefinerInterface;
|
||||
use Psy\Completion\Refiner\CommandSyntaxRefiner;
|
||||
use Psy\Completion\Refiner\PartialInputRefiner;
|
||||
use Psy\Completion\Source\CatalogSource;
|
||||
use Psy\Completion\Source\ClassConstantSource;
|
||||
use Psy\Completion\Source\KeywordSource;
|
||||
use Psy\Completion\Source\MagicMethodSource;
|
||||
use Psy\Completion\Source\MagicPropertySource;
|
||||
use Psy\Completion\Source\MethodSource;
|
||||
use Psy\Completion\Source\NamespaceSource;
|
||||
use Psy\Completion\Source\ObjectMethodSource;
|
||||
use Psy\Completion\Source\ObjectPropertySource;
|
||||
use Psy\Completion\Source\PropertySource;
|
||||
use Psy\Completion\Source\SourceInterface;
|
||||
use Psy\Completion\Source\StaticMethodSource;
|
||||
use Psy\Completion\Source\StaticPropertySource;
|
||||
use Psy\Completion\Source\VariableSource;
|
||||
use Psy\Context;
|
||||
use Psy\Readline\Interactive\Helper\DebugLog;
|
||||
|
||||
/**
|
||||
* Completion pipeline coordinator.
|
||||
*
|
||||
* Each stage focuses on one job: parse the input, refine the context, then
|
||||
* collect candidates from applicable sources.
|
||||
*/
|
||||
class CompletionEngine
|
||||
{
|
||||
private Context $context;
|
||||
private ContextAnalyzer $analyzer;
|
||||
private TypeResolver $typeResolver;
|
||||
private SymbolCatalog $symbolCatalog;
|
||||
|
||||
/** @var SourceInterface[] */
|
||||
private array $sources = [];
|
||||
|
||||
/** @var AnalysisRefinerInterface[] */
|
||||
private array $refiners = [];
|
||||
|
||||
public function __construct(Context $context, ?CodeCleaner $cleaner = null, ?SymbolCatalog $symbolCatalog = null)
|
||||
{
|
||||
$this->context = $context;
|
||||
$this->analyzer = new ContextAnalyzer($cleaner);
|
||||
$this->typeResolver = new TypeResolver($context, $cleaner);
|
||||
$this->symbolCatalog = $symbolCatalog ?? new SymbolCatalog();
|
||||
$this->addRefiner(new PartialInputRefiner());
|
||||
$this->addRefiner(new CommandSyntaxRefiner());
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the standard PsySH completion source set.
|
||||
*
|
||||
* @param SourceInterface[] $additionalSources Pre-initialized sources to include
|
||||
*/
|
||||
public function registerDefaultSources(array $additionalSources = []): void
|
||||
{
|
||||
// Context-aware sources.
|
||||
$this->addSource(new VariableSource($this->context));
|
||||
$this->addSource(new ObjectMethodSource());
|
||||
$this->addSource(new ObjectPropertySource());
|
||||
|
||||
// Static reflection sources.
|
||||
$this->addSource(new MethodSource());
|
||||
$this->addSource(new PropertySource());
|
||||
$this->addSource(new StaticMethodSource());
|
||||
$this->addSource(new StaticPropertySource());
|
||||
$this->addSource(new ClassConstantSource());
|
||||
|
||||
// Docblock magic sources.
|
||||
$this->addSource(new MagicMethodSource());
|
||||
$this->addSource(new MagicPropertySource());
|
||||
|
||||
// Symbol sources (shared symbol catalog snapshot cache).
|
||||
$this->addSource(new CatalogSource(CompletionKind::CLASS_NAME, [$this->symbolCatalog, 'getClasses'], $this->symbolCatalog));
|
||||
$this->addSource(new CatalogSource(CompletionKind::INTERFACE_NAME, [$this->symbolCatalog, 'getInterfaces'], $this->symbolCatalog));
|
||||
$this->addSource(new CatalogSource(CompletionKind::TRAIT_NAME, [$this->symbolCatalog, 'getTraits'], $this->symbolCatalog));
|
||||
$this->addSource(new CatalogSource(CompletionKind::ATTRIBUTE_NAME, [$this->symbolCatalog, 'getAttributeClasses'], $this->symbolCatalog));
|
||||
$this->addSource(new CatalogSource(CompletionKind::FUNCTION_NAME, [$this->symbolCatalog, 'getFunctions'], $this->symbolCatalog));
|
||||
$this->addSource(new CatalogSource(CompletionKind::CONSTANT, [$this->symbolCatalog, 'getConstants'], $this->symbolCatalog));
|
||||
$this->addSource(new NamespaceSource($this->symbolCatalog));
|
||||
|
||||
// Additional pre-initialized sources.
|
||||
foreach ($additionalSources as $source) {
|
||||
$this->addSource($source);
|
||||
}
|
||||
|
||||
// Generic sources.
|
||||
$this->addSource(new KeywordSource());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a completion source.
|
||||
*/
|
||||
public function addSource(SourceInterface $source): void
|
||||
{
|
||||
if (!\in_array($source, $this->sources, true)) {
|
||||
$this->sources[] = $source;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an analysis refiner.
|
||||
*
|
||||
* Refiners translate broad parser output into the narrower completion lanes
|
||||
* that sources consume.
|
||||
*/
|
||||
public function addRefiner(AnalysisRefinerInterface $refiner): void
|
||||
{
|
||||
if (!\in_array($refiner, $this->refiners, true)) {
|
||||
$this->refiners[] = $refiner;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completions for a normalized request.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getCompletions(CompletionRequest $request): array
|
||||
{
|
||||
$start = \microtime(true);
|
||||
DebugLog::log('Completion', 'START', [
|
||||
'mode' => $request->getMode(),
|
||||
'input' => $request->getBuffer(),
|
||||
'cursor' => $request->getCursor(),
|
||||
]);
|
||||
|
||||
$analysis = $this->analyzer->analyze(
|
||||
$request->getBuffer(),
|
||||
$request->getCursor(),
|
||||
$request->getReadlineInfo()
|
||||
);
|
||||
foreach ($this->refiners as $refiner) {
|
||||
$analysis = $refiner->refine($analysis);
|
||||
}
|
||||
DebugLog::log('Completion', 'ANALYZED', [
|
||||
'kinds' => $analysis->kinds,
|
||||
'prefix' => $analysis->prefix,
|
||||
'leftSide' => $analysis->leftSide ?? 'null',
|
||||
]);
|
||||
|
||||
$leftSide = $analysis->leftSide;
|
||||
if ($leftSide !== null) {
|
||||
$leftSideNode = $analysis->leftSideNode;
|
||||
$analysis->leftSideTypes = $leftSideNode instanceof AstNode
|
||||
? $this->typeResolver->resolveNodeTypes($leftSideNode, $leftSide)
|
||||
: $this->typeResolver->resolveTypes($leftSide);
|
||||
$analysis->leftSideValue = $this->typeResolver->resolveValue($leftSide);
|
||||
DebugLog::log('Completion', 'RESOLVED_TYPES', [
|
||||
'types' => empty($analysis->leftSideTypes) ? 'none' : \implode('|', $analysis->leftSideTypes),
|
||||
'has_value' => $analysis->leftSideValue !== null,
|
||||
]);
|
||||
}
|
||||
|
||||
$results = $this->collectFromSources($analysis);
|
||||
$results = \array_values(\array_unique(\array_filter($results, fn ($match) => $match !== '' && $match !== null)));
|
||||
|
||||
if ($analysis->prefix !== '' && !empty($results)) {
|
||||
$before = \count($results);
|
||||
$results = FuzzyMatcher::filter($analysis->prefix, $results);
|
||||
DebugLog::log('Completion', 'FUZZY_FILTER', [
|
||||
'before' => $before,
|
||||
'after' => \count($results),
|
||||
]);
|
||||
}
|
||||
|
||||
$latencyMs = (\microtime(true) - $start) * 1000;
|
||||
DebugLog::log('Completion', 'METRICS', [
|
||||
'latency_ms' => \round($latencyMs, 2),
|
||||
'results' => \count($results),
|
||||
]);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect completions from all applicable sources.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function collectFromSources(AnalysisResult $analysis): array
|
||||
{
|
||||
$completions = [];
|
||||
|
||||
foreach ($this->sources as $source) {
|
||||
if (!$source->appliesToKind($analysis->kinds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceCompletions = $source->getCompletions($analysis);
|
||||
if (!empty($sourceCompletions)) {
|
||||
DebugLog::log('Completion', 'SOURCE_MATCHED', [
|
||||
'source' => \get_class($source),
|
||||
'count' => \count($sourceCompletions),
|
||||
]);
|
||||
|
||||
$completions = \array_merge($completions, $sourceCompletions);
|
||||
}
|
||||
}
|
||||
|
||||
return $completions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
/**
|
||||
* Completion kind bitmask constants.
|
||||
*
|
||||
* Defines the syntactic kinds where completion is being requested.
|
||||
* Multiple kinds can be combined using bitwise OR to represent
|
||||
* positions where multiple types of completions are valid.
|
||||
*
|
||||
* Example:
|
||||
* $kinds = CompletionKind::CLASS_NAME | CompletionKind::FUNCTION_NAME;
|
||||
*/
|
||||
class CompletionKind
|
||||
{
|
||||
// Special
|
||||
public const NONE = 0;
|
||||
public const UNKNOWN = 1 << 0;
|
||||
|
||||
// Variables
|
||||
public const VARIABLE = 1 << 1; // $foo|
|
||||
|
||||
// Object members (instance)
|
||||
public const OBJECT_METHOD = 1 << 2; // $foo->method()|
|
||||
public const OBJECT_PROPERTY = 1 << 3; // $foo->property|
|
||||
|
||||
// Static members (class)
|
||||
public const STATIC_METHOD = 1 << 4; // Foo::method()|
|
||||
public const STATIC_PROPERTY = 1 << 5; // Foo::$property|
|
||||
public const CLASS_CONSTANT = 1 << 6; // Foo::CONSTANT|
|
||||
|
||||
// Type names
|
||||
public const CLASS_NAME = 1 << 7; // new Foo|, extends Foo|
|
||||
public const INTERFACE_NAME = 1 << 8; // implements Bar|
|
||||
public const TRAIT_NAME = 1 << 9; // use SomeTrait| (inside class)
|
||||
public const ATTRIBUTE_NAME = 1 << 10; // #[Deprecated|] (PHP 8+)
|
||||
|
||||
// Global symbols
|
||||
public const FUNCTION_NAME = 1 << 11; // foo|()
|
||||
public const CONSTANT = 1 << 12; // CONST|
|
||||
public const NAMESPACE = 1 << 13; // namespace Foo\|, use Foo\|
|
||||
|
||||
// PsySH-specific
|
||||
public const COMMAND = 1 << 14; // ls|, doc|
|
||||
public const COMMAND_OPTION = 1 << 15; // ls --option|, ls -a|
|
||||
|
||||
// PHP keywords
|
||||
public const KEYWORD = 1 << 16; // echo|, isset|
|
||||
|
||||
// Advanced (future)
|
||||
public const NAMED_PARAMETER = 1 << 17; // foo(name: |) (PHP 8+)
|
||||
public const ARRAY_KEY = 1 << 18; // $array['key'|]
|
||||
public const COMMAND_ARGUMENT = 1 << 19; // config set verbosity|
|
||||
|
||||
// Common combinations for ambiguous contexts
|
||||
public const OBJECT_MEMBER = self::OBJECT_METHOD | self::OBJECT_PROPERTY; // $foo->|
|
||||
public const STATIC_MEMBER = self::STATIC_METHOD | self::STATIC_PROPERTY | self::CLASS_CONSTANT; // Foo::|
|
||||
public const TYPE_NAME = self::CLASS_NAME | self::INTERFACE_NAME; // Type hints, return types
|
||||
public const CLASS_LIKE = self::CLASS_NAME | self::INTERFACE_NAME | self::TRAIT_NAME; // Any class-like structure
|
||||
public const CALLABLE = self::FUNCTION_NAME | self::CLASS_NAME;
|
||||
public const SYMBOL = self::FUNCTION_NAME | self::CLASS_LIKE | self::CONSTANT;
|
||||
|
||||
// Contexts where the input might be a shell command rather than PHP code
|
||||
public const COMMAND_ELIGIBLE = self::UNKNOWN | self::SYMBOL | self::KEYWORD;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
/**
|
||||
* Normalized completion request.
|
||||
*
|
||||
* Uses the full buffer and an absolute cursor position so callers can request
|
||||
* completions from any position, including multiline input.
|
||||
*/
|
||||
class CompletionRequest
|
||||
{
|
||||
public const MODE_TAB = 'tab';
|
||||
public const MODE_SUGGESTION = 'suggestion';
|
||||
|
||||
private string $buffer;
|
||||
private int $cursor;
|
||||
private string $mode;
|
||||
/** @var array Raw callback metadata from the caller, if any */
|
||||
private array $readlineInfo;
|
||||
|
||||
public function __construct(string $buffer, int $cursor, string $mode = self::MODE_TAB, array $readlineInfo = [])
|
||||
{
|
||||
$this->buffer = $buffer;
|
||||
$this->cursor = $this->normalizeCursor($buffer, $cursor);
|
||||
$this->mode = $mode;
|
||||
$this->readlineInfo = $readlineInfo;
|
||||
}
|
||||
|
||||
public function getBuffer(): string
|
||||
{
|
||||
return $this->buffer;
|
||||
}
|
||||
|
||||
public function getCursor(): int
|
||||
{
|
||||
return $this->cursor;
|
||||
}
|
||||
|
||||
public function getMode(): string
|
||||
{
|
||||
return $this->mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw readline callback metadata associated with this request.
|
||||
*/
|
||||
public function getReadlineInfo(): array
|
||||
{
|
||||
return $this->readlineInfo;
|
||||
}
|
||||
|
||||
private function normalizeCursor(string $buffer, int $cursor): int
|
||||
{
|
||||
$length = \mb_strlen($buffer);
|
||||
|
||||
return \max(0, \min($cursor, $length));
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
use PhpParser\Error as PhpParserError;
|
||||
use PhpParser\Node;
|
||||
use PhpParser\Node\Expr\ClassConstFetch;
|
||||
use PhpParser\Node\Expr\ConstFetch;
|
||||
use PhpParser\Node\Expr\FuncCall;
|
||||
use PhpParser\Node\Expr\MethodCall;
|
||||
use PhpParser\Node\Expr\New_;
|
||||
use PhpParser\Node\Expr\NullsafeMethodCall;
|
||||
use PhpParser\Node\Expr\NullsafePropertyFetch;
|
||||
use PhpParser\Node\Expr\PropertyFetch;
|
||||
use PhpParser\Node\Expr\StaticCall;
|
||||
use PhpParser\Node\Expr\StaticPropertyFetch;
|
||||
use PhpParser\Node\Expr\Variable;
|
||||
use PhpParser\Node\Identifier;
|
||||
use PhpParser\Node\Name;
|
||||
use PhpParser\NodeTraverser;
|
||||
use PhpParser\Parser;
|
||||
use PhpParser\PrettyPrinter\Standard as Printer;
|
||||
use Psy\CodeCleaner;
|
||||
use Psy\ParserFactory;
|
||||
|
||||
/**
|
||||
* Analyzes input to determine completion context.
|
||||
*
|
||||
* This stage provides the parser-derived starting point for completion. Its
|
||||
* job is to describe the PHP syntax at the cursor, not to decide every higher-
|
||||
* level completion mode built on top of that syntax.
|
||||
*/
|
||||
class ContextAnalyzer
|
||||
{
|
||||
private Parser $parser;
|
||||
private Printer $printer;
|
||||
private ?CodeCleaner $cleaner;
|
||||
|
||||
public function __construct(?CodeCleaner $cleaner = null)
|
||||
{
|
||||
$this->parser = (new ParserFactory())->createParser();
|
||||
$this->printer = new Printer();
|
||||
$this->cleaner = $cleaner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze input and return the coarse parser-derived context.
|
||||
*/
|
||||
public function analyze(string $input, int $cursor, array $readlineInfo = []): AnalysisResult
|
||||
{
|
||||
// Cursor is in code-point units, so use mb_substr
|
||||
$inputToCursor = \mb_substr($input, 0, $cursor);
|
||||
$cursorAtEnd = ($cursor >= \mb_strlen($input));
|
||||
$analysis = $this->tryParse($inputToCursor, $cursorAtEnd);
|
||||
$parseSucceeded = $analysis !== null;
|
||||
$analysis = $analysis ?? new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
$analysis->parseSucceeded = $parseSucceeded;
|
||||
|
||||
$analysis->input = $inputToCursor;
|
||||
$analysis->tokens = @\token_get_all('<?php '.$inputToCursor);
|
||||
$analysis->readlineInfo = $readlineInfo;
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse input and analyze the resulting AST.
|
||||
*/
|
||||
private function tryParse(string $input, bool $cursorAtEnd): ?AnalysisResult
|
||||
{
|
||||
$code = '<?php '.$input;
|
||||
|
||||
// Try with semicolon first (most common case), then without
|
||||
try {
|
||||
$stmts = $this->parser->parse($code.';');
|
||||
} catch (PhpParserError $e) {
|
||||
try {
|
||||
$stmts = $this->parser->parse($code);
|
||||
} catch (PhpParserError $e2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($stmts)) {
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
}
|
||||
|
||||
$visitor = new DeepestNodeVisitor();
|
||||
$traverser = new NodeTraverser();
|
||||
$traverser->addVisitor($visitor);
|
||||
$traverser->traverse($stmts);
|
||||
|
||||
$node = $visitor->getDeepestNode();
|
||||
if ($node === null) {
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
}
|
||||
|
||||
// Trailing whitespace means the user has moved past this token
|
||||
if ($cursorAtEnd && $input !== \rtrim($input)) {
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
}
|
||||
|
||||
return $this->analyzeNode($node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a specific AST node to determine completion context.
|
||||
*/
|
||||
private function analyzeNode(Node $node): AnalysisResult
|
||||
{
|
||||
// $foo->bar, $foo->bar(), $foo?->bar, $foo?->bar()
|
||||
if (
|
||||
$node instanceof MethodCall
|
||||
|| $node instanceof PropertyFetch
|
||||
|| $node instanceof NullsafeMethodCall
|
||||
|| $node instanceof NullsafePropertyFetch
|
||||
) {
|
||||
$leftSide = $this->extractExpression($node->var);
|
||||
$prefix = $node->name instanceof Identifier ? $node->name->name : '';
|
||||
$result = new AnalysisResult(CompletionKind::OBJECT_MEMBER, $prefix, $leftSide);
|
||||
$result->leftSideNode = $node->var;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Foo::bar(), Foo::BAR, Foo::$bar
|
||||
if (
|
||||
$node instanceof StaticCall
|
||||
|| $node instanceof ClassConstFetch
|
||||
|| $node instanceof StaticPropertyFetch
|
||||
) {
|
||||
$leftSide = $this->extractExpression($node->class);
|
||||
$prefix = $node->name instanceof Identifier ? $node->name->name : '';
|
||||
$result = new AnalysisResult(CompletionKind::STATIC_MEMBER, $prefix, $leftSide);
|
||||
$result->leftSideNode = $node->class;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// new Foo
|
||||
if ($node instanceof New_) {
|
||||
$prefix = $node->class instanceof Name ? $node->class->toString() : '';
|
||||
|
||||
return new AnalysisResult(CompletionKind::CLASS_NAME, $prefix);
|
||||
}
|
||||
|
||||
// $foo
|
||||
if ($node instanceof Variable) {
|
||||
$prefix = \is_string($node->name) ? $node->name : '';
|
||||
|
||||
return new AnalysisResult(CompletionKind::VARIABLE, $prefix);
|
||||
}
|
||||
|
||||
// foo()
|
||||
if ($node instanceof FuncCall && $node->name instanceof Name) {
|
||||
return new AnalysisResult(CompletionKind::FUNCTION_NAME, $node->name->toString());
|
||||
}
|
||||
|
||||
// FOO (could be a constant, function, or class reference)
|
||||
if ($node instanceof ConstFetch && $node->name instanceof Name) {
|
||||
return new AnalysisResult(CompletionKind::SYMBOL, $node->name->toString());
|
||||
}
|
||||
|
||||
if ($node instanceof Identifier) {
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, $node->name);
|
||||
}
|
||||
|
||||
// Bare name: foo or Foo\Bar
|
||||
if ($node instanceof Name) {
|
||||
return new AnalysisResult(CompletionKind::SYMBOL, $node->toString());
|
||||
}
|
||||
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a string representation of an expression.
|
||||
*
|
||||
* Uses the printer to convert the AST node back to code.
|
||||
*/
|
||||
private function extractExpression($expr): string
|
||||
{
|
||||
if ($expr instanceof Variable && \is_string($expr->name)) {
|
||||
return '$'.$expr->name;
|
||||
}
|
||||
|
||||
if ($expr instanceof Name) {
|
||||
return $this->resolveClassName($expr);
|
||||
}
|
||||
|
||||
if ($expr instanceof Node\Expr) {
|
||||
return $this->printer->prettyPrintExpr($expr);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a class name using CodeCleaner's namespace context.
|
||||
*
|
||||
* This ensures we use the same use statements and namespace as the code
|
||||
* being executed.
|
||||
*/
|
||||
private function resolveClassName(Name $name): string
|
||||
{
|
||||
if ($this->cleaner === null) {
|
||||
return $name->toString();
|
||||
}
|
||||
|
||||
return $this->cleaner->resolveClassName($name->toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\Node\Stmt\Expression;
|
||||
use PhpParser\NodeTraverser;
|
||||
use PhpParser\NodeVisitorAbstract;
|
||||
|
||||
/**
|
||||
* Visitor to find the top-level expression node in the statement.
|
||||
*
|
||||
* We want the outermost expression (PropertyFetch, MethodCall, New_, etc.)
|
||||
* not their child nodes. For a statement like `$baz->format;`, we want the
|
||||
* PropertyFetch node, not the Variable node inside it.
|
||||
*/
|
||||
class DeepestNodeVisitor extends NodeVisitorAbstract
|
||||
{
|
||||
private ?Node $targetNode = null;
|
||||
|
||||
public function enterNode(Node $node)
|
||||
{
|
||||
// If this is an Expression statement, grab its expression
|
||||
if ($node instanceof Expression) {
|
||||
$this->targetNode = $node->expr;
|
||||
|
||||
// Don't traverse into the expression - we have what we need
|
||||
// @phan-suppress-next-line PhanDeprecatedClassConstant - keep compat with php-parser 4.x baseline
|
||||
return NodeTraverser::DONT_TRAVERSE_CHILDREN;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getDeepestNode(): ?Node
|
||||
{
|
||||
return $this->targetNode;
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
/**
|
||||
* Fuzzy matching utility for tab completion.
|
||||
*
|
||||
* Matches candidates where the search string's characters appear in order,
|
||||
* similar to Fish shell and modern IDE fuzzy completion.
|
||||
*
|
||||
* Examples:
|
||||
* - "asum" matches "array_sum"
|
||||
* - "stl" matches "strtolower"
|
||||
* - "AE" matches "ArrayException" (case-insensitive)
|
||||
*/
|
||||
class FuzzyMatcher
|
||||
{
|
||||
/**
|
||||
* Filter candidates using fuzzy matching.
|
||||
*
|
||||
* Returns candidates where all characters in the search string appear
|
||||
* in order within the candidate string (case-insensitive).
|
||||
*
|
||||
* Results are sorted by match quality (exact prefix matches first,
|
||||
* then by how early the match starts).
|
||||
*
|
||||
* @param string $search Search string (e.g., "asum")
|
||||
* @param string[] $candidates Array of candidates to filter
|
||||
*
|
||||
* @return string[] Filtered and sorted candidates
|
||||
*/
|
||||
public static function filter(string $search, array $candidates): array
|
||||
{
|
||||
if ($search === '') {
|
||||
$sorted = $candidates;
|
||||
\sort($sorted);
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$score = self::match($search, $candidate);
|
||||
if ($score !== null) {
|
||||
$matches[] = ['candidate' => $candidate, 'score' => $score];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (lower is better), then alphabetically
|
||||
\usort($matches, function ($a, $b) {
|
||||
if ($a['score'] !== $b['score']) {
|
||||
return $a['score'] <=> $b['score'];
|
||||
}
|
||||
|
||||
return \strcmp($a['candidate'], $b['candidate']);
|
||||
});
|
||||
|
||||
return \array_column($matches, 'candidate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if search matches candidate and return a quality score.
|
||||
*
|
||||
* Returns null if no match, or a numeric score where lower is better.
|
||||
* Score factors:
|
||||
* - Exact prefix match gets lowest score (best)
|
||||
* - Earlier matches get better scores
|
||||
* - Consecutive character matches get bonus
|
||||
* - First character must match at start or after a word boundary
|
||||
*
|
||||
* @return int|null Match score (lower is better), or null if no match
|
||||
*/
|
||||
private static function match(string $search, string $candidate): ?int
|
||||
{
|
||||
$searchLen = \strlen($search);
|
||||
$candidateLen = \strlen($candidate);
|
||||
|
||||
if ($searchLen === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$searchLower = \strtolower($search);
|
||||
$candidateLower = \strtolower($candidate);
|
||||
|
||||
// Check for exact prefix match first (best score)
|
||||
if (\strpos($candidateLower, $searchLower) === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if it contains the search as a substring (very good score)
|
||||
$substringPos = \strpos($candidateLower, $searchLower);
|
||||
if ($substringPos !== false) {
|
||||
// Only match if substring starts at a word boundary
|
||||
if ($substringPos === 0 || self::isWordBoundary($candidate[$substringPos - 1])) {
|
||||
return $substringPos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy match: characters must appear in order
|
||||
// First character MUST match at start or after a word boundary
|
||||
$searchIdx = 0;
|
||||
$candidateIdx = 0;
|
||||
$lastMatchIdx = -1;
|
||||
$firstMatchIdx = null;
|
||||
$consecutiveMatches = 0;
|
||||
$firstCharFound = false;
|
||||
|
||||
while ($searchIdx < $searchLen && $candidateIdx < $candidateLen) {
|
||||
if ($searchLower[$searchIdx] === $candidateLower[$candidateIdx]) {
|
||||
// For the first character, ensure it's at a word boundary
|
||||
if ($searchIdx === 0) {
|
||||
if ($candidateIdx === 0 || self::isWordBoundary($candidate[$candidateIdx - 1])) {
|
||||
$firstCharFound = true;
|
||||
} else {
|
||||
// First char not at word boundary, skip it
|
||||
$candidateIdx++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($firstMatchIdx === null) {
|
||||
$firstMatchIdx = $candidateIdx;
|
||||
}
|
||||
|
||||
// Track consecutive matches
|
||||
if ($candidateIdx === $lastMatchIdx + 1) {
|
||||
$consecutiveMatches++;
|
||||
}
|
||||
|
||||
$lastMatchIdx = $candidateIdx;
|
||||
$searchIdx++;
|
||||
}
|
||||
$candidateIdx++;
|
||||
}
|
||||
|
||||
// Did we match all search characters, and did the first char match at a word boundary?
|
||||
if ($searchIdx < $searchLen || !$firstCharFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Score: position of first match + distance between matches - consecutive bonus
|
||||
// Lower score is better
|
||||
$score = 100 + $firstMatchIdx + ($lastMatchIdx - $firstMatchIdx) - ($consecutiveMatches * 10);
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character is a word boundary.
|
||||
*
|
||||
* Word boundaries include: underscore, space, dash, slash, backslash, and other non-alphanumeric chars.
|
||||
*/
|
||||
private static function isWordBoundary(string $char): bool
|
||||
{
|
||||
return !\ctype_alnum($char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if search string matches candidate (fuzzy).
|
||||
*
|
||||
* @return bool True if all characters in search appear in order in candidate
|
||||
*/
|
||||
public static function matches(string $search, string $candidate): bool
|
||||
{
|
||||
return self::match($search, $candidate) !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Refiner;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
|
||||
/**
|
||||
* Narrows parser output into a more useful completion lane.
|
||||
*
|
||||
* Refiners handle context decisions that depend on more than the parser's
|
||||
* immediate syntactic view of the input.
|
||||
*/
|
||||
interface AnalysisRefinerInterface
|
||||
{
|
||||
/**
|
||||
* Refine the analysis result before sources are queried.
|
||||
*/
|
||||
public function refine(AnalysisResult $analysis): AnalysisResult;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Refiner;
|
||||
|
||||
use Psy\Command\Command;
|
||||
use Psy\CommandArgumentCompletionAware;
|
||||
use Psy\CommandAware;
|
||||
use Psy\CommandMapTrait;
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Hands command tails to command-owned completion once shell syntax is known.
|
||||
*
|
||||
* It resolves which command owns the current tail so argument completion can
|
||||
* follow command-specific rules and vocabulary.
|
||||
*/
|
||||
class CommandContextRefiner implements AnalysisRefinerInterface, CommandAware
|
||||
{
|
||||
use CommandMapTrait;
|
||||
|
||||
/**
|
||||
* @param Command[] $commands Array of PsySH commands
|
||||
*/
|
||||
public function __construct(array $commands)
|
||||
{
|
||||
$this->setCommands($commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refine(AnalysisResult $analysis): AnalysisResult
|
||||
{
|
||||
if (!$this->supportsRefinement($analysis)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
if (!\preg_match('/^\s*([^\s]+)(\s+.*)$/s', $analysis->input, $matches)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
$commandName = $matches[1];
|
||||
$command = $this->commandMap[$commandName] ?? null;
|
||||
|
||||
if (!$command instanceof CommandArgumentCompletionAware) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
if (!$command->supportsArgumentCompletion($analysis)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
return $analysis->withContext(CompletionKind::COMMAND_ARGUMENT, $analysis->prefix, $commandName);
|
||||
}
|
||||
|
||||
private function supportsRefinement(AnalysisResult $analysis): bool
|
||||
{
|
||||
if (($analysis->kinds & CompletionKind::COMMAND_OPTION) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($analysis->kinds & CompletionKind::COMMAND_ELIGIBLE) !== 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Refiner;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Recognizes shell-shaped command input before generic code sources run.
|
||||
*
|
||||
* It classifies bare command heads and option tokens so the rest of the
|
||||
* pipeline can treat shell commands as their own completion mode.
|
||||
*/
|
||||
class CommandSyntaxRefiner implements AnalysisRefinerInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refine(AnalysisResult $analysis): AnalysisResult
|
||||
{
|
||||
if (($analysis->kinds & CompletionKind::COMMAND_ELIGIBLE) === 0) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
$trimmed = \rtrim($analysis->input);
|
||||
|
||||
if (\preg_match('/^([a-z][a-z0-9-]*)\s+.*?(-{1,2}[\w-]*)$/', $trimmed, $matches)) {
|
||||
return $analysis->withContext(CompletionKind::COMMAND_OPTION, $matches[2], $matches[1]);
|
||||
}
|
||||
|
||||
if ($analysis->input !== $trimmed) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
if (!\preg_match('/^([a-z][a-z0-9-]*)$/', $trimmed, $matches)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
return $analysis->withContext(
|
||||
CompletionKind::COMMAND | CompletionKind::KEYWORD | CompletionKind::SYMBOL,
|
||||
$matches[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Refiner;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Recovers useful completion lanes when valid PHP syntax is not yet available.
|
||||
*
|
||||
* This refiner keeps completion responsive while the user is still in the
|
||||
* middle of typing an expression the parser cannot fully classify yet.
|
||||
*/
|
||||
class PartialInputRefiner implements AnalysisRefinerInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refine(AnalysisResult $analysis): AnalysisResult
|
||||
{
|
||||
if (!$analysis->parseSucceeded) {
|
||||
$partial = $this->analyzePartialInput($analysis->input, $analysis->tokens);
|
||||
|
||||
return $analysis->withContext($partial->kinds, $partial->prefix, $partial->leftSide, $partial->leftSideNode);
|
||||
}
|
||||
|
||||
if ($analysis->kinds !== CompletionKind::UNKNOWN) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
$partial = $this->analyzePartialInput($analysis->input, $analysis->tokens);
|
||||
|
||||
return $this->shouldPreferPartialAnalysis($partial)
|
||||
? $analysis->withContext($partial->kinds, $partial->prefix, $partial->leftSide, $partial->leftSideNode)
|
||||
: $analysis;
|
||||
}
|
||||
|
||||
private function analyzePartialInput(string $input, array $tokens = []): AnalysisResult
|
||||
{
|
||||
$trimmed = \rtrim($input);
|
||||
$hasTrailingSpace = $input !== $trimmed;
|
||||
|
||||
if (\preg_match('/\bnew\s+([\w\\\\]*)$/i', $input, $matches)) {
|
||||
return new AnalysisResult(CompletionKind::CLASS_NAME, $matches[1]);
|
||||
}
|
||||
|
||||
if (\preg_match('/^.*[;\{\}]\s*(\w+)$/', $trimmed, $matches)) {
|
||||
if (!\preg_match('/(?:->|\?->)\w*$/', $trimmed) && !\preg_match('/::\w*$/', $trimmed)) {
|
||||
if ($hasTrailingSpace) {
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
}
|
||||
|
||||
return new AnalysisResult(CompletionKind::KEYWORD | CompletionKind::SYMBOL, $matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (\preg_match('/(\$\w+)(?:->|\?->)([\w]*)$/', $trimmed, $matches)) {
|
||||
return new AnalysisResult(CompletionKind::OBJECT_MEMBER, $matches[2], $matches[1]);
|
||||
}
|
||||
|
||||
if (\preg_match('/([\w\\\\]*\\\\)$/', $trimmed, $matches)) {
|
||||
return new AnalysisResult(CompletionKind::SYMBOL | CompletionKind::NAMESPACE, $matches[1]);
|
||||
}
|
||||
|
||||
if (\preg_match('/([\w\\\\]+)::\$(\w*)$/', $trimmed, $matches)) {
|
||||
return new AnalysisResult(CompletionKind::STATIC_MEMBER, $matches[2], $matches[1]);
|
||||
}
|
||||
|
||||
if (\preg_match('/([\w\\\\]+)::([\w]*)$/', $trimmed, $matches)) {
|
||||
return new AnalysisResult(CompletionKind::STATIC_MEMBER, $matches[2], $matches[1]);
|
||||
}
|
||||
|
||||
$tokenizedObjectMember = $this->analyzeTokenizedObjectMemberAccess($input, $tokens);
|
||||
if ($tokenizedObjectMember !== null) {
|
||||
return $tokenizedObjectMember;
|
||||
}
|
||||
|
||||
if (\preg_match('/\$(\w*)$/', $trimmed, $matches)) {
|
||||
return new AnalysisResult(CompletionKind::VARIABLE, $matches[1]);
|
||||
}
|
||||
|
||||
if (\preg_match('/([\w\\\\]+)$/', $trimmed, $matches)) {
|
||||
if ($hasTrailingSpace) {
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
}
|
||||
|
||||
return new AnalysisResult(CompletionKind::SYMBOL, $matches[1]);
|
||||
}
|
||||
|
||||
return new AnalysisResult(CompletionKind::UNKNOWN, '');
|
||||
}
|
||||
|
||||
private function shouldPreferPartialAnalysis(AnalysisResult $partialAnalysis): bool
|
||||
{
|
||||
return \in_array($partialAnalysis->kinds, [
|
||||
CompletionKind::VARIABLE,
|
||||
CompletionKind::OBJECT_MEMBER,
|
||||
CompletionKind::STATIC_MEMBER,
|
||||
CompletionKind::CLASS_NAME,
|
||||
CompletionKind::SYMBOL | CompletionKind::NAMESPACE,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function analyzeTokenizedObjectMemberAccess(string $input, array $tokens): ?AnalysisResult
|
||||
{
|
||||
$entries = $this->flattenTokens($tokens);
|
||||
if ($entries === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lastIndex = $this->findPreviousSignificantTokenIndex($entries, \count($entries) - 1);
|
||||
if ($lastIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prefix = '';
|
||||
$operatorIndex = $lastIndex;
|
||||
|
||||
if ($this->isIdentifierToken($entries[$lastIndex])) {
|
||||
$prefix = $entries[$lastIndex]['text'];
|
||||
$operatorIndex = $this->findPreviousSignificantTokenIndex($entries, $lastIndex - 1);
|
||||
if ($operatorIndex === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->isObjectAccessOperatorToken($entries[$operatorIndex]['token'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$leftEndIndex = $this->findPreviousSignificantTokenIndex($entries, $operatorIndex - 1);
|
||||
if ($leftEndIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$leftStartIndex = $this->findExpressionStartIndex($entries, $leftEndIndex);
|
||||
if ($leftStartIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$leftSide = \mb_substr(
|
||||
$input,
|
||||
$entries[$leftStartIndex]['start'],
|
||||
$entries[$leftEndIndex]['end'] - $entries[$leftStartIndex]['start']
|
||||
);
|
||||
$leftSide = $this->normalizeLeftExpression($leftSide);
|
||||
|
||||
if ($leftSide === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AnalysisResult(CompletionKind::OBJECT_MEMBER, $prefix, $leftSide);
|
||||
}
|
||||
|
||||
private function flattenTokens(array $tokens): array
|
||||
{
|
||||
$entries = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$text = \is_array($token) ? $token[1] : $token;
|
||||
|
||||
if (\is_array($token) && $token[0] === \T_OPEN_TAG) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$length = \mb_strlen($text);
|
||||
$entries[] = [
|
||||
'token' => $token,
|
||||
'text' => $text,
|
||||
'start' => $position,
|
||||
'end' => $position + $length,
|
||||
];
|
||||
$position += $length;
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function findPreviousSignificantTokenIndex(array $entries, int $start): ?int
|
||||
{
|
||||
for ($i = $start; $i >= 0; $i--) {
|
||||
$token = $entries[$i]['token'];
|
||||
|
||||
if (\is_array($token) && \in_array($token[0], [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findExpressionStartIndex(array $entries, int $endIndex): ?int
|
||||
{
|
||||
$parenDepth = 0;
|
||||
$bracketDepth = 0;
|
||||
$braceDepth = 0;
|
||||
|
||||
for ($i = $endIndex; $i >= 0; $i--) {
|
||||
$token = $entries[$i]['token'];
|
||||
$text = $entries[$i]['text'];
|
||||
|
||||
if (!\is_array($token)) {
|
||||
if ($text === ')') {
|
||||
$parenDepth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($text === '(') {
|
||||
if ($parenDepth > 0) {
|
||||
$parenDepth--;
|
||||
continue;
|
||||
}
|
||||
} elseif ($text === ']') {
|
||||
$bracketDepth++;
|
||||
continue;
|
||||
} elseif ($text === '[') {
|
||||
if ($bracketDepth > 0) {
|
||||
$bracketDepth--;
|
||||
continue;
|
||||
}
|
||||
} elseif ($text === '}') {
|
||||
$braceDepth++;
|
||||
continue;
|
||||
} elseif ($text === '{') {
|
||||
if ($braceDepth > 0) {
|
||||
$braceDepth--;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parenDepth === 0 && $bracketDepth === 0 && $braceDepth === 0 && $this->isExpressionBoundaryToken($token)) {
|
||||
return $this->findNextNonWhitespaceTokenIndex($entries, $i + 1);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($parenDepth === 0 && $bracketDepth === 0 && $braceDepth === 0 && $this->isExpressionBoundaryToken($token)) {
|
||||
return $this->findNextNonWhitespaceTokenIndex($entries, $i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->findNextNonWhitespaceTokenIndex($entries, 0);
|
||||
}
|
||||
|
||||
private function findNextNonWhitespaceTokenIndex(array $entries, int $start): ?int
|
||||
{
|
||||
for ($i = $start; $i < \count($entries); $i++) {
|
||||
$token = $entries[$i]['token'];
|
||||
|
||||
if (\is_array($token) && \in_array($token[0], [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isExpressionBoundaryToken($token): bool
|
||||
{
|
||||
if (!\is_array($token)) {
|
||||
return \in_array($token, [';', '{', '}', ',', '=', '+', '-', '*', '/', '%', '.', '?', ':', '!', '&', '|', '^', '<', '>'], true);
|
||||
}
|
||||
|
||||
return \in_array($token[0], [
|
||||
\T_DOUBLE_ARROW,
|
||||
\T_BOOLEAN_AND,
|
||||
\T_BOOLEAN_OR,
|
||||
\T_LOGICAL_AND,
|
||||
\T_LOGICAL_OR,
|
||||
\T_LOGICAL_XOR,
|
||||
\T_COALESCE,
|
||||
\T_RETURN,
|
||||
\T_ECHO,
|
||||
\T_PRINT,
|
||||
\T_THROW,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function normalizeLeftExpression(string $expression): string
|
||||
{
|
||||
$expression = \trim($expression);
|
||||
|
||||
while ($this->isWrappedInParentheses($expression)) {
|
||||
$expression = \trim(\substr($expression, 1, -1));
|
||||
}
|
||||
|
||||
return $expression;
|
||||
}
|
||||
|
||||
private function isWrappedInParentheses(string $expression): bool
|
||||
{
|
||||
if (\strlen($expression) < 2 || $expression[0] !== '(' || \substr($expression, -1) !== ')') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$depth = 0;
|
||||
$length = \strlen($expression);
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
if ($expression[$i] === '(') {
|
||||
$depth++;
|
||||
} elseif ($expression[$i] === ')') {
|
||||
$depth--;
|
||||
}
|
||||
|
||||
if ($depth === 0 && $i < $length - 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $depth === 0;
|
||||
}
|
||||
|
||||
private function isIdentifierToken(array $entry): bool
|
||||
{
|
||||
if (!\is_array($entry['token'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \in_array($entry['token'][0], [
|
||||
\T_STRING,
|
||||
\defined('T_NAME_QUALIFIED') ? \T_NAME_QUALIFIED : \T_STRING,
|
||||
\defined('T_NAME_FULLY_QUALIFIED') ? \T_NAME_FULLY_QUALIFIED : \T_STRING,
|
||||
\defined('T_NAME_RELATIVE') ? \T_NAME_RELATIVE : \T_STRING,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function isObjectAccessOperatorToken($token): bool
|
||||
{
|
||||
if (!\is_array($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($token[0] === \T_OBJECT_OPERATOR) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return \defined('T_NULLSAFE_OBJECT_OPERATOR') && $token[0] === \T_NULLSAFE_OBJECT_OPERATOR;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\SymbolCatalog;
|
||||
|
||||
/**
|
||||
* Catalog-backed completion source.
|
||||
*
|
||||
* Provides completions for symbol types backed by SymbolCatalog (classes,
|
||||
* interfaces, traits, functions, constants).
|
||||
*/
|
||||
class CatalogSource implements SourceInterface
|
||||
{
|
||||
private int $kind;
|
||||
private SymbolCatalog $catalog;
|
||||
|
||||
/** @var callable */
|
||||
private $catalogMethod;
|
||||
|
||||
/**
|
||||
* @param int $kind CompletionKind bitmask to match
|
||||
* @param callable $catalogMethod Callable that takes a SymbolCatalog and returns string[]
|
||||
*/
|
||||
public function __construct(int $kind, callable $catalogMethod, ?SymbolCatalog $catalog = null)
|
||||
{
|
||||
$this->kind = $kind;
|
||||
$this->catalogMethod = $catalogMethod;
|
||||
$this->catalog = $catalog ?? new SymbolCatalog();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & $this->kind) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$prefix = $analysis->prefix;
|
||||
$all = ($this->catalogMethod)($this->catalog);
|
||||
|
||||
// If we have a prefix, try prefix matching first
|
||||
if ($prefix !== '') {
|
||||
$lowerPrefix = \strtolower($prefix);
|
||||
$matches = [];
|
||||
|
||||
foreach ($all as $name) {
|
||||
if (\strpos(\strtolower($name), $lowerPrefix) === 0) {
|
||||
$matches[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found enough prefix matches, return those (sorted)
|
||||
if (\count($matches) >= 10) {
|
||||
\sort($matches);
|
||||
|
||||
return $matches;
|
||||
}
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Class constant completion source.
|
||||
*
|
||||
* Provides completions for class constants using reflection.
|
||||
* Handles Class::CONSTANT, self::CONSTANT, parent::CONSTANT, static::CONSTANT.
|
||||
*/
|
||||
class ClassConstantSource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::CLASS_CONSTANT) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
if (empty($analysis->leftSideTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$constants = [];
|
||||
foreach ($analysis->leftSideTypes as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($reflection->getReflectionConstants() as $constant) {
|
||||
if ($constant->isPublic()) {
|
||||
$constants[] = $constant->getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($constants));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Command\Command;
|
||||
use Psy\CommandArgumentCompletionAware;
|
||||
use Psy\CommandAware;
|
||||
use Psy\CommandMapTrait;
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Command positional argument completion source.
|
||||
*
|
||||
* Delegates argument-tail completion to commands that explicitly opt in.
|
||||
*/
|
||||
class CommandArgumentSource implements CommandAware, SourceInterface
|
||||
{
|
||||
use CommandMapTrait;
|
||||
|
||||
/**
|
||||
* @param Command[] $commands Array of PsySH commands
|
||||
*/
|
||||
public function __construct(array $commands)
|
||||
{
|
||||
$this->setCommands($commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::COMMAND_ARGUMENT) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$commandName = \is_string($analysis->leftSide) ? $analysis->leftSide : null;
|
||||
$command = $commandName !== null ? ($this->commandMap[$commandName] ?? null) : null;
|
||||
|
||||
if (!$command instanceof CommandArgumentCompletionAware) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// CommandContextRefiner already verified supportsArgumentCompletion()
|
||||
// before setting COMMAND_ARGUMENT kind.
|
||||
return $command->getArgumentCompletions($analysis);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Command\Command;
|
||||
use Psy\CommandAware;
|
||||
use Psy\CommandMapTrait;
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
use Symfony\Component\Console\Input\StringInput;
|
||||
|
||||
/**
|
||||
* Command option/argument completion source.
|
||||
*
|
||||
* Provides completions for PsySH command options (e.g., --option, -o) and arguments.
|
||||
*/
|
||||
class CommandOptionSource implements CommandAware, SourceInterface
|
||||
{
|
||||
use CommandMapTrait;
|
||||
|
||||
/**
|
||||
* @param Command[] $commands Array of PsySH commands
|
||||
*/
|
||||
public function __construct(array $commands)
|
||||
{
|
||||
$this->setCommands($commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::COMMAND_OPTION) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$commandName = $analysis->leftSide;
|
||||
if (!\is_string($commandName) || !isset($this->commandMap[$commandName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$command = $this->commandMap[$commandName];
|
||||
$input = $this->createInput($analysis);
|
||||
$matches = [];
|
||||
|
||||
foreach ($command->getDefinition()->getOptions() as $option) {
|
||||
$longName = '--'.$option->getName();
|
||||
$shortName = $option->getShortcut() !== null ? '-'.$option->getShortcut() : null;
|
||||
|
||||
if (!$option->isArray() && $this->isOptionUsed($input, $analysis, $longName, $shortName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = $longName;
|
||||
|
||||
if ($shortName !== null) {
|
||||
$matches[] = $shortName;
|
||||
}
|
||||
}
|
||||
|
||||
\sort($matches);
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a StringInput for token scanning, or null if input is empty/unparseable.
|
||||
*/
|
||||
private function createInput(AnalysisResult $analysis): ?StringInput
|
||||
{
|
||||
if ($analysis->input === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new StringInput($analysis->input);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an option has already been used in the input.
|
||||
*/
|
||||
private function isOptionUsed(?StringInput $input, AnalysisResult $analysis, string $longName, ?string $shortName): bool
|
||||
{
|
||||
if ($input === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$names = [$longName];
|
||||
if ($shortName !== null) {
|
||||
$names[] = $shortName;
|
||||
}
|
||||
|
||||
if ($input->hasParameterOption($names, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// hasParameterOption handles --long, --long=val, and -o as the
|
||||
// first short option in a group, but not combined short options
|
||||
// like -l in -al. Fall back to a simple string match for that.
|
||||
if ($shortName !== null && \preg_match('/(^|\s)-\w*'.\preg_quote($shortName[1], '/').'/', $analysis->input)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Command\Command;
|
||||
use Psy\CommandAware;
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* PsySH command completion source.
|
||||
*
|
||||
* Provides completions for PsySH commands (e.g., ls, doc, show).
|
||||
*/
|
||||
class CommandSource implements CommandAware, SourceInterface
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $commandNames = [];
|
||||
|
||||
/**
|
||||
* @param Command[] $commands Array of PsySH commands
|
||||
*/
|
||||
public function __construct(array $commands)
|
||||
{
|
||||
$this->setCommands($commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set commands for completion.
|
||||
*
|
||||
* @param Command[] $commands
|
||||
*/
|
||||
public function setCommands(array $commands): void
|
||||
{
|
||||
$names = [];
|
||||
foreach ($commands as $command) {
|
||||
$names[] = $command->getName();
|
||||
foreach ($command->getAliases() as $alias) {
|
||||
$names[] = $alias;
|
||||
}
|
||||
}
|
||||
|
||||
\sort($names);
|
||||
$this->commandNames = $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::COMMAND) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
return $this->commandNames;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
use Psy\Readline\Interactive\Input\History;
|
||||
|
||||
/**
|
||||
* History-based completion source.
|
||||
*
|
||||
* Provides completion candidates from command history at the start of input.
|
||||
*/
|
||||
class HistorySource implements SourceInterface
|
||||
{
|
||||
private History $history;
|
||||
|
||||
public function __construct(History $history)
|
||||
{
|
||||
$this->history = $history;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::COMMAND) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
if ($analysis->prefix === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Newest first, deduplicated
|
||||
$commands = $this->history->search('', false);
|
||||
|
||||
return \array_values(\array_unique($commands));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* PHP keyword completion source.
|
||||
*
|
||||
* Provides completions for function-like PHP keywords (echo, isset, etc.).
|
||||
*/
|
||||
class KeywordSource implements SourceInterface
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $keywords = [
|
||||
'array',
|
||||
'clone',
|
||||
'declare',
|
||||
'die',
|
||||
'echo',
|
||||
'empty',
|
||||
'eval',
|
||||
'exit',
|
||||
'fn',
|
||||
'include',
|
||||
'include_once',
|
||||
'isset',
|
||||
'list',
|
||||
'print',
|
||||
'require',
|
||||
'require_once',
|
||||
'unset',
|
||||
'yield',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
if (\PHP_VERSION_ID >= 80000) {
|
||||
$this->keywords[] = 'match';
|
||||
\sort($this->keywords);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::KEYWORD) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
return $this->keywords;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
use Psy\Util\Docblock;
|
||||
|
||||
/**
|
||||
* Magic method completion source.
|
||||
*
|
||||
* Provides completions for magic methods declared via @method docblock tags.
|
||||
*/
|
||||
class MagicMethodSource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & (CompletionKind::OBJECT_METHOD | CompletionKind::STATIC_METHOD)) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$types = $analysis->leftSideTypes;
|
||||
|
||||
if (empty($types) && $analysis->leftSideValue !== null && \is_object($analysis->leftSideValue)) {
|
||||
$types = [\get_class($analysis->leftSideValue)];
|
||||
}
|
||||
|
||||
if (empty($types)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$staticOnly = ($analysis->kinds & CompletionKind::STATIC_METHOD) !== 0
|
||||
&& ($analysis->kinds & CompletionKind::OBJECT_METHOD) === 0;
|
||||
|
||||
$methods = [];
|
||||
foreach ($types as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (Docblock::getMagicMethods($reflection) as $method) {
|
||||
if ($staticOnly && !$method->isStatic()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$methods[] = $method->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($methods));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
use Psy\Util\Docblock;
|
||||
|
||||
/**
|
||||
* Magic property completion source.
|
||||
*
|
||||
* Provides completions for magic properties declared via docblock tags.
|
||||
*/
|
||||
class MagicPropertySource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & (CompletionKind::OBJECT_PROPERTY | CompletionKind::STATIC_PROPERTY)) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$types = $analysis->leftSideTypes;
|
||||
|
||||
if (empty($types) && $analysis->leftSideValue !== null && \is_object($analysis->leftSideValue)) {
|
||||
$types = [\get_class($analysis->leftSideValue)];
|
||||
}
|
||||
|
||||
if (empty($types)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$properties = [];
|
||||
foreach ($types as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (Docblock::getMagicProperties($reflection) as $property) {
|
||||
$properties[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($properties));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
use Psy\TabCompletion\Matcher\AbstractMatcher;
|
||||
use Psy\TabCompletion\Matcher\ClassAttributesMatcher;
|
||||
use Psy\TabCompletion\Matcher\ClassMethodsMatcher;
|
||||
use Psy\TabCompletion\Matcher\ClassNamesMatcher;
|
||||
use Psy\TabCompletion\Matcher\CommandsMatcher;
|
||||
use Psy\TabCompletion\Matcher\ConstantsMatcher;
|
||||
use Psy\TabCompletion\Matcher\FunctionsMatcher;
|
||||
use Psy\TabCompletion\Matcher\KeywordsMatcher;
|
||||
use Psy\TabCompletion\Matcher\MagicMethodsMatcher;
|
||||
use Psy\TabCompletion\Matcher\MagicPropertiesMatcher;
|
||||
use Psy\TabCompletion\Matcher\ObjectAttributesMatcher;
|
||||
use Psy\TabCompletion\Matcher\ObjectMethodsMatcher;
|
||||
use Psy\TabCompletion\Matcher\VariablesMatcher;
|
||||
|
||||
/**
|
||||
* Compatibility adapter for legacy TabCompletion matchers.
|
||||
*
|
||||
* Wraps one or more legacy AbstractMatcher instances and adapts them to work
|
||||
* with the CompletionEngine system, so users with custom matchers can continue
|
||||
* using them alongside the new AST-based completion sources.
|
||||
*
|
||||
* Default matchers that have been superseded by new-style completion sources
|
||||
* are automatically filtered out to avoid duplicate results.
|
||||
*/
|
||||
class MatcherAdapterSource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* Matchers superseded by new-style completion sources.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const SUPERSEDED_MATCHERS = [
|
||||
ClassAttributesMatcher::class,
|
||||
ClassMethodsMatcher::class,
|
||||
ClassNamesMatcher::class,
|
||||
CommandsMatcher::class,
|
||||
ConstantsMatcher::class,
|
||||
FunctionsMatcher::class,
|
||||
KeywordsMatcher::class,
|
||||
MagicMethodsMatcher::class,
|
||||
MagicPropertiesMatcher::class,
|
||||
ObjectAttributesMatcher::class,
|
||||
ObjectMethodsMatcher::class,
|
||||
VariablesMatcher::class,
|
||||
];
|
||||
|
||||
/** @var AbstractMatcher[] */
|
||||
private array $matchers;
|
||||
|
||||
/**
|
||||
* @param AbstractMatcher[] $matchers Legacy matchers to wrap
|
||||
*/
|
||||
public function __construct(array $matchers)
|
||||
{
|
||||
$this->matchers = \array_filter(
|
||||
$matchers,
|
||||
fn ($matcher) => !\in_array(\get_class($matcher), self::SUPERSEDED_MATCHERS, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
// Legacy matchers don't understand command argument contexts.
|
||||
return ($kinds & ~CompletionKind::COMMAND_ARGUMENT) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$tokens = $this->convertToTokens($analysis);
|
||||
$info = $this->buildReadlineInfo($analysis);
|
||||
|
||||
$matches = [];
|
||||
foreach ($this->matchers as $matcher) {
|
||||
if ($matcher->hasMatched($tokens)) {
|
||||
$matches = \array_merge($matches, $matcher->getMatches($tokens, $info));
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AnalysisResult to token array for legacy matchers.
|
||||
*
|
||||
* Legacy matchers expect tokens from token_get_all(). We reconstruct
|
||||
* a reasonable approximation from the analysis result.
|
||||
*
|
||||
* @return array Token array compatible with legacy matchers
|
||||
*/
|
||||
private function convertToTokens(AnalysisResult $analysis): array
|
||||
{
|
||||
if (!empty($analysis->tokens)) {
|
||||
return $this->filterTokens($analysis->tokens);
|
||||
}
|
||||
|
||||
// Reconstruct a token array from the analysis result
|
||||
$tokens = [];
|
||||
|
||||
if ($analysis->leftSide !== null) {
|
||||
$leftTokens = \token_get_all('<?php '.$analysis->leftSide);
|
||||
$tokens = \array_merge($tokens, \array_slice($leftTokens, 1));
|
||||
}
|
||||
|
||||
if (($analysis->kinds & CompletionKind::OBJECT_MEMBER) !== 0) {
|
||||
$tokens[] = [\T_OBJECT_OPERATOR, '->', 0];
|
||||
} elseif (($analysis->kinds & CompletionKind::STATIC_MEMBER) !== 0) {
|
||||
$tokens[] = [\T_DOUBLE_COLON, '::', 0];
|
||||
}
|
||||
|
||||
if ($analysis->prefix !== '') {
|
||||
$tokens[] = [\T_STRING, $analysis->prefix, 0];
|
||||
}
|
||||
|
||||
return $this->filterTokens($tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tokens to remove whitespace (legacy matcher expectation).
|
||||
*
|
||||
* @return array Filtered token array
|
||||
*/
|
||||
private function filterTokens(array $tokens): array
|
||||
{
|
||||
$filtered = \array_filter($tokens, fn ($token) => !AbstractMatcher::tokenIs($token, AbstractMatcher::T_WHITESPACE));
|
||||
|
||||
return \array_values($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build readline_info array for legacy matchers.
|
||||
*
|
||||
* @return array readline_info-like array
|
||||
*/
|
||||
private function buildReadlineInfo(AnalysisResult $analysis): array
|
||||
{
|
||||
if ($analysis->readlineInfo !== []) {
|
||||
return $analysis->readlineInfo;
|
||||
}
|
||||
|
||||
$lineBuffer = '';
|
||||
if ($analysis->leftSide !== null) {
|
||||
$lineBuffer .= $analysis->leftSide;
|
||||
|
||||
if (($analysis->kinds & CompletionKind::OBJECT_MEMBER) !== 0) {
|
||||
$lineBuffer .= '->';
|
||||
} elseif (($analysis->kinds & CompletionKind::STATIC_MEMBER) !== 0) {
|
||||
$lineBuffer .= '::';
|
||||
}
|
||||
}
|
||||
$lineBuffer .= $analysis->prefix;
|
||||
|
||||
return [
|
||||
'line_buffer' => $lineBuffer,
|
||||
'point' => \strlen($lineBuffer),
|
||||
'end' => \strlen($lineBuffer),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* A completion source for object methods.
|
||||
*
|
||||
* Provides method completions when completing after `->` operator.
|
||||
*/
|
||||
class MethodSource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::OBJECT_METHOD) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
if (empty($analysis->leftSideTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allCompletions = [];
|
||||
|
||||
foreach ($analysis->leftSideTypes as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$allCompletions[] = $method->getName();
|
||||
}
|
||||
}
|
||||
|
||||
// Return all methods (fuzzy matching in CompletionEngine will filter)
|
||||
$allCompletions = \array_unique($allCompletions);
|
||||
\sort($allCompletions);
|
||||
|
||||
return $allCompletions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
use Psy\Completion\SymbolCatalog;
|
||||
|
||||
/**
|
||||
* Namespace completion source.
|
||||
*
|
||||
* Provides completions for namespaces extracted from declared classes.
|
||||
*/
|
||||
class NamespaceSource implements SourceInterface
|
||||
{
|
||||
private SymbolCatalog $catalog;
|
||||
|
||||
public function __construct(?SymbolCatalog $catalog = null)
|
||||
{
|
||||
$this->catalog = $catalog ?? new SymbolCatalog();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::NAMESPACE) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$prefix = $analysis->prefix;
|
||||
|
||||
$namespaces = $this->catalog->getNamespaces();
|
||||
|
||||
// Filter by prefix (case-insensitive)
|
||||
$matches = [];
|
||||
$lowerPrefix = \strtolower($prefix);
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
if ($lowerPrefix === '' || \strpos(\strtolower($namespace), $lowerPrefix) === 0) {
|
||||
$matches[] = $namespace;
|
||||
}
|
||||
}
|
||||
|
||||
\sort($matches);
|
||||
|
||||
return $matches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Object method completion source.
|
||||
*
|
||||
* Provides completions for methods of objects using runtime reflection.
|
||||
* Prefers actual object instances when available, falls back to static
|
||||
* reflection on the class type.
|
||||
*
|
||||
* Suppresses magic methods until the user types `__`.
|
||||
*/
|
||||
class ObjectMethodSource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::OBJECT_METHOD) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
$includeMagic = \strpos($analysis->prefix, '__') === 0;
|
||||
|
||||
// Prefer runtime value if available
|
||||
if ($analysis->leftSideValue !== null && \is_object($analysis->leftSideValue)) {
|
||||
return $this->filterMethods(\get_class_methods($analysis->leftSideValue), $includeMagic);
|
||||
}
|
||||
|
||||
if (empty($analysis->leftSideTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$methods = [];
|
||||
foreach ($analysis->leftSideTypes as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
$methods[] = $method->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->filterMethods(\array_unique($methods), $includeMagic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter method names, hiding magic methods unless explicitly requested.
|
||||
*
|
||||
* @param string[] $methods
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function filterMethods(array $methods, bool $includeMagic): array
|
||||
{
|
||||
if (!$includeMagic) {
|
||||
$methods = \array_filter($methods, fn ($method) => \strpos($method, '__') !== 0);
|
||||
}
|
||||
|
||||
return \array_values($methods);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Object property completion source.
|
||||
*
|
||||
* Provides completions for properties of objects using runtime reflection.
|
||||
* Prefers actual object instances when available, falls back to static
|
||||
* reflection on the class type.
|
||||
*/
|
||||
class ObjectPropertySource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::OBJECT_PROPERTY) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
if ($analysis->leftSideValue !== null && \is_object($analysis->leftSideValue)) {
|
||||
return \array_keys(\get_object_vars($analysis->leftSideValue));
|
||||
}
|
||||
|
||||
if (empty($analysis->leftSideTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$properties = [];
|
||||
foreach ($analysis->leftSideTypes as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
|
||||
$properties[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($properties));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* A completion source for object properties.
|
||||
*
|
||||
* Provides property completions when completing after `->` operator.
|
||||
*/
|
||||
class PropertySource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::OBJECT_PROPERTY) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
if (empty($analysis->leftSideTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allCompletions = [];
|
||||
|
||||
foreach ($analysis->leftSideTypes as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC);
|
||||
|
||||
foreach ($properties as $property) {
|
||||
$allCompletions[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
// Return all properties (fuzzy matching in CompletionEngine will filter)
|
||||
$allCompletions = \array_unique($allCompletions);
|
||||
\sort($allCompletions);
|
||||
|
||||
return $allCompletions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
|
||||
/**
|
||||
* Completion source interface.
|
||||
*
|
||||
* Sources provide completion candidates for specific contexts (variables,
|
||||
* methods, classes, etc.). The CompletionEngine applies fuzzy matching to
|
||||
* the candidates returned by sources.
|
||||
*
|
||||
* Filtering Strategies:
|
||||
*
|
||||
* Sources use different strategies based on their candidate set size:
|
||||
*
|
||||
* Small sources (commands, variables, keywords):
|
||||
* - Return ALL candidates regardless of prefix
|
||||
* - CompletionEngine handles all filtering
|
||||
*
|
||||
* Large sources (classes, functions, constants):
|
||||
* - If prefix matches ≥10 candidates: return prefix-filtered subset
|
||||
* - Otherwise: return ALL candidates for fuzzy matching
|
||||
* - Prevents overwhelming fuzzy matcher with huge sets
|
||||
*
|
||||
* Both strategies ensure fuzzy matching is applied consistently by the engine.
|
||||
*/
|
||||
interface SourceInterface
|
||||
{
|
||||
/**
|
||||
* Check if this source applies to the given completion kinds.
|
||||
*
|
||||
* Uses bitwise AND to check if any of the kinds in the bitmask match.
|
||||
*
|
||||
* @param int $kinds Bitmask of CompletionKind constants
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool;
|
||||
|
||||
/**
|
||||
* Get completion candidates for the analyzed input.
|
||||
*
|
||||
* Sources may use analysis->prefix for optimization but are not required
|
||||
* to filter by it. CompletionEngine applies fuzzy matching to all results.
|
||||
*
|
||||
* @return string[] Array of completion strings
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Static method completion source.
|
||||
*
|
||||
* Provides completions for static methods using reflection.
|
||||
* Handles Class::method(), self::method(), parent::method(), static::method().
|
||||
*
|
||||
* Suppresses magic methods until the user types `__`.
|
||||
*/
|
||||
class StaticMethodSource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::STATIC_METHOD) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
if (empty($analysis->leftSideTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$includeMagic = \strpos($analysis->prefix, '__') === 0;
|
||||
|
||||
$methods = [];
|
||||
foreach ($analysis->leftSideTypes as $type) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($type);
|
||||
} catch (\ReflectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
if ($method->isStatic()) {
|
||||
$methods[] = $method->getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$methods = \array_unique($methods);
|
||||
|
||||
if (!$includeMagic) {
|
||||
$methods = \array_filter($methods, fn ($method) => \strpos($method, '__') !== 0);
|
||||
}
|
||||
|
||||
return \array_values($methods);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
|
||||
/**
|
||||
* Static property completion source.
|
||||
*
|
||||
* Provides completions for static properties using reflection.
|
||||
* Handles Class::$property, self::$property, parent::$property, static::$property.
|
||||
*/
|
||||
class StaticPropertySource implements SourceInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::STATIC_PROPERTY) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
if (empty($analysis->leftSideTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allProperties = [];
|
||||
foreach ($analysis->leftSideTypes as $type) {
|
||||
$properties = $this->getStaticPropertiesFromClass($type);
|
||||
$allProperties = \array_merge($allProperties, $properties);
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($allProperties));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get static properties from a class using reflection.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function getStaticPropertiesFromClass(string $className): array
|
||||
{
|
||||
try {
|
||||
$reflection = new \ReflectionClass($className);
|
||||
$properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC);
|
||||
$staticProperties = \array_filter($properties, fn ($p) => $p->isStatic());
|
||||
|
||||
return \array_values(\array_map(fn ($p) => $p->getName(), $staticProperties));
|
||||
} catch (\ReflectionException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion\Source;
|
||||
|
||||
use Psy\Completion\AnalysisResult;
|
||||
use Psy\Completion\CompletionKind;
|
||||
use Psy\Context;
|
||||
|
||||
/**
|
||||
* Variable completion source.
|
||||
*
|
||||
* Provides completions for variables in the current scope.
|
||||
*/
|
||||
class VariableSource implements SourceInterface
|
||||
{
|
||||
private Context $context;
|
||||
|
||||
public function __construct(Context $context)
|
||||
{
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function appliesToKind(int $kinds): bool
|
||||
{
|
||||
return ($kinds & CompletionKind::VARIABLE) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getCompletions(AnalysisResult $analysis): array
|
||||
{
|
||||
// Always return all variables (typically small set, fuzzy matching will handle filtering)
|
||||
$variables = \array_keys($this->context->getAll());
|
||||
\sort($variables);
|
||||
|
||||
return $variables;
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
/**
|
||||
* Cached runtime symbol catalog.
|
||||
*
|
||||
* Snapshot data is refreshed when declared symbol counts change.
|
||||
*/
|
||||
class SymbolCatalog
|
||||
{
|
||||
private string $version = '';
|
||||
/** @var string[][] Cached symbol lists, keyed by catalog method */
|
||||
private array $snapshot = [];
|
||||
|
||||
/**
|
||||
* Get a version identifier for cache invalidation.
|
||||
*/
|
||||
public function getVersion(): string
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getClasses(): array
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->snapshot['classes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getInterfaces(): array
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->snapshot['interfaces'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getTraits(): array
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->snapshot['traits'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->snapshot['functions'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getConstants(): array
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->snapshot['constants'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getNamespaces(): array
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->snapshot['namespaces'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAttributeClasses(): array
|
||||
{
|
||||
$this->refreshIfNeeded();
|
||||
|
||||
return $this->snapshot['attributes'];
|
||||
}
|
||||
|
||||
private function refreshIfNeeded(): void
|
||||
{
|
||||
$classes = \get_declared_classes();
|
||||
$interfaces = \get_declared_interfaces();
|
||||
$traits = \get_declared_traits();
|
||||
$functions = \get_defined_functions();
|
||||
$constants = \array_keys(\get_defined_constants());
|
||||
|
||||
$version = \implode(':', [
|
||||
\count($classes),
|
||||
\count($interfaces),
|
||||
\count($traits),
|
||||
\count($functions['user']),
|
||||
\count($functions['internal']),
|
||||
\count($constants),
|
||||
]);
|
||||
|
||||
if ($this->version === $version && !empty($this->snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allFunctions = \array_merge($functions['user'], $functions['internal']);
|
||||
\sort($classes);
|
||||
\sort($interfaces);
|
||||
\sort($traits);
|
||||
\sort($allFunctions);
|
||||
\sort($constants);
|
||||
|
||||
$namespaces = $this->extractNamespaces($classes, $interfaces, $traits);
|
||||
\sort($namespaces);
|
||||
|
||||
$attributes = $this->extractAttributes($classes);
|
||||
\sort($attributes);
|
||||
|
||||
$this->snapshot = [
|
||||
'classes' => $classes,
|
||||
'interfaces' => $interfaces,
|
||||
'traits' => $traits,
|
||||
'functions' => $allFunctions,
|
||||
'constants' => $constants,
|
||||
'namespaces' => $namespaces,
|
||||
'attributes' => $attributes,
|
||||
];
|
||||
$this->version = $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $classes
|
||||
* @param string[] $interfaces
|
||||
* @param string[] $traits
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractNamespaces(array $classes, array $interfaces, array $traits): array
|
||||
{
|
||||
$classLikes = \array_merge($classes, $interfaces, $traits);
|
||||
$namespaces = [];
|
||||
|
||||
foreach ($classLikes as $className) {
|
||||
$parts = \explode('\\', $className);
|
||||
if (\count($parts) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
\array_pop($parts);
|
||||
|
||||
$namespace = '';
|
||||
foreach ($parts as $part) {
|
||||
if ($namespace !== '') {
|
||||
$namespace .= '\\';
|
||||
}
|
||||
$namespace .= $part;
|
||||
$namespaces[$namespace] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return \array_keys($namespaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $classes
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractAttributes(array $classes): array
|
||||
{
|
||||
if (\PHP_VERSION_ID < 80000 || !\class_exists('Attribute', false)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
foreach ($classes as $className) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($className);
|
||||
if (!\method_exists($reflection, 'getAttributes')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// @phan-suppress-next-line PhanUndeclaredMethod - available on PHP 8+, guarded by method_exists
|
||||
$classAttributes = $reflection->getAttributes('Attribute');
|
||||
if (!empty($classAttributes)) {
|
||||
$attributes[] = $className;
|
||||
}
|
||||
} catch (\ReflectionException $e) {
|
||||
// Skip classes that cannot be reflected.
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
}
|
||||
+946
@@ -0,0 +1,946 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Psy Shell.
|
||||
*
|
||||
* (c) 2012-2026 Justin Hileman
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Psy\Completion;
|
||||
|
||||
use PhpParser\Error as PhpParserError;
|
||||
use PhpParser\Node;
|
||||
use PhpParser\Node\Expr\MethodCall;
|
||||
use PhpParser\Node\Expr\NullsafeMethodCall;
|
||||
use PhpParser\Node\Expr\NullsafePropertyFetch;
|
||||
use PhpParser\Node\Expr\PropertyFetch;
|
||||
use PhpParser\Node\Expr\StaticCall;
|
||||
use PhpParser\Node\Expr\StaticPropertyFetch;
|
||||
use PhpParser\Node\Expr\Variable;
|
||||
use PhpParser\Node\Identifier;
|
||||
use PhpParser\Node\Name;
|
||||
use PhpParser\Parser;
|
||||
use Psy\CodeCleaner;
|
||||
use Psy\Context;
|
||||
use Psy\ParserFactory;
|
||||
use Psy\Util\Docblock;
|
||||
|
||||
/**
|
||||
* Resolves types from expressions.
|
||||
*
|
||||
* Uses runtime reflection (via Context) to determine types of variables
|
||||
* and expressions for type-aware completions.
|
||||
*/
|
||||
class TypeResolver
|
||||
{
|
||||
private const SCALAR_TYPES = [
|
||||
'null', 'bool', 'boolean', 'int', 'integer', 'float', 'double',
|
||||
'string', 'array', 'object', 'resource', 'mixed', 'void',
|
||||
'callable', 'iterable', 'never',
|
||||
];
|
||||
|
||||
private Context $context;
|
||||
private ?CodeCleaner $cleaner;
|
||||
private Parser $parser;
|
||||
|
||||
public function __construct(Context $context, ?CodeCleaner $cleaner = null)
|
||||
{
|
||||
$this->context = $context;
|
||||
$this->cleaner = $cleaner;
|
||||
$this->parser = (new ParserFactory())->createParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all possible types of an expression (supports union types).
|
||||
*
|
||||
* For expressions that resolve to a union type (e.g., Address|City),
|
||||
* returns all non-scalar types in the union.
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
public function resolveTypes(string $expression): array
|
||||
{
|
||||
$expression = \trim($expression);
|
||||
|
||||
if ($expression === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parsedTypes = $this->resolveTypesFromParsedExpression($expression);
|
||||
if (!empty($parsedTypes)) {
|
||||
return $parsedTypes;
|
||||
}
|
||||
|
||||
$cleanedExpression = $this->cleanIncompleteExpression($expression);
|
||||
if ($cleanedExpression === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($cleanedExpression !== $expression) {
|
||||
$cleanedTypes = $this->resolveTypesFromParsedExpression($cleanedExpression);
|
||||
if (!empty($cleanedTypes)) {
|
||||
return $cleanedTypes;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cleanedExpression[0] === '$') {
|
||||
$type = $this->resolveVariableType($cleanedExpression);
|
||||
|
||||
if ($type !== null && !$this->isScalarOrNull($type)) {
|
||||
return [$type];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Special keywords must come before isClassName check
|
||||
if (\in_array(\strtolower($cleanedExpression), ['self', 'static', 'parent'])) {
|
||||
$type = $this->resolveSpecialKeyword($cleanedExpression);
|
||||
|
||||
return $type !== null ? [$type] : [];
|
||||
}
|
||||
|
||||
if ($this->isClassName($cleanedExpression)) {
|
||||
return [$this->resolveClassName($cleanedExpression)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all possible types from an AST node.
|
||||
*
|
||||
* Falls back to string parsing for tokenizer-only contexts where we don't
|
||||
* have a parseable node.
|
||||
*
|
||||
* @param Node $node Parsed expression node
|
||||
* @param string|null $fallbackExpression Fallback expression for non-parse cases
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
public function resolveNodeTypes(Node $node, ?string $fallbackExpression = null): array
|
||||
{
|
||||
$types = $this->resolveExpressionTypes($node);
|
||||
if (!empty($types)) {
|
||||
return $types;
|
||||
}
|
||||
|
||||
if ($fallbackExpression !== null && $fallbackExpression !== '') {
|
||||
return $this->resolveTypes($fallbackExpression);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actual runtime value of an expression.
|
||||
*
|
||||
* Currently only resolves simple variables ($foo). Method chains and
|
||||
* complex expressions are not evaluated to avoid side effects.
|
||||
*
|
||||
* @return mixed The actual value, or null if it can't be resolved
|
||||
*/
|
||||
public function resolveValue(string $expression)
|
||||
{
|
||||
$expression = \trim($expression);
|
||||
|
||||
if ($expression === '' || \strpos($expression, '$') !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$varName = \ltrim($expression, '$');
|
||||
|
||||
try {
|
||||
return $this->context->get($varName);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up incomplete/malformed expressions to extract the completable part.
|
||||
*
|
||||
* Handles cases like:
|
||||
* - $user| -> $user
|
||||
* - $user . " -> $user
|
||||
* - [$user -> $user
|
||||
* - if (true) { $user -> $user
|
||||
* - $x + $user -> $user
|
||||
*
|
||||
* Uses tokenization to find the last complete variable or chain expression.
|
||||
*
|
||||
* @return string Cleaned expression (may be empty if nothing extractable)
|
||||
*/
|
||||
private function cleanIncompleteExpression(string $expression): string
|
||||
{
|
||||
if (\strpos($expression, '$') === false) {
|
||||
return $expression;
|
||||
}
|
||||
|
||||
// For method chains, check if they parse cleanly first. If so, return
|
||||
// as-is and let resolution handle incomplete identifiers via union types.
|
||||
if (\strpos($expression, '->') !== false) {
|
||||
try {
|
||||
$this->parser->parse('<?php '.$expression.';');
|
||||
|
||||
return $expression;
|
||||
} catch (PhpParserError $e) {
|
||||
// Parse failed, needs cleaning
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = @\token_get_all('<?php '.$expression);
|
||||
if (empty($tokens)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
\array_shift($tokens);
|
||||
|
||||
$variables = [];
|
||||
|
||||
for ($i = 0; $i < \count($tokens); $i++) {
|
||||
$token = $tokens[$i];
|
||||
|
||||
if (!(\is_array($token) && $token[0] === \T_VARIABLE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$varStart = $i;
|
||||
$chainEnd = $i;
|
||||
$hasIncompleteCall = false;
|
||||
|
||||
// Walk forward through -> chains
|
||||
for ($j = $i + 1; $j < \count($tokens); $j++) {
|
||||
$nextToken = $tokens[$j];
|
||||
|
||||
if (\is_array($nextToken) && $nextToken[0] === \T_WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isObjectAccessOperatorToken($nextToken)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Found ->, look for identifier
|
||||
for ($k = $j + 1; $k < \count($tokens); $k++) {
|
||||
$afterArrow = $tokens[$k];
|
||||
|
||||
if (\is_array($afterArrow) && $afterArrow[0] === \T_WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(\is_array($afterArrow) && $afterArrow[0] === \T_STRING)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$chainEnd = $k;
|
||||
|
||||
// Check for parenthesized call
|
||||
for ($m = $k + 1; $m < \count($tokens); $m++) {
|
||||
$parenToken = $tokens[$m];
|
||||
|
||||
if (\is_array($parenToken) && $parenToken[0] === \T_WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($parenToken === '(') {
|
||||
$depth = 1;
|
||||
$foundClose = false;
|
||||
for ($n = $m + 1; $n < \count($tokens); $n++) {
|
||||
if ($tokens[$n] === '(') {
|
||||
$depth++;
|
||||
} elseif ($tokens[$n] === ')') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
$chainEnd = $n;
|
||||
$foundClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$foundClose) {
|
||||
$hasIncompleteCall = true;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$i = $chainEnd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$variables[] = [
|
||||
'start' => $varStart,
|
||||
'end' => $chainEnd,
|
||||
'hasIncompleteCall' => $hasIncompleteCall,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($variables)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lastVar = \end($variables);
|
||||
$extractedTokens = \array_slice($tokens, $lastVar['start'], $lastVar['end'] - $lastVar['start'] + 1);
|
||||
|
||||
$parts = [];
|
||||
$strippedIncompleteCall = $lastVar['hasIncompleteCall'];
|
||||
|
||||
foreach ($extractedTokens as $token) {
|
||||
if (\is_array($token)) {
|
||||
$parts[] = $token[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop at incomplete opening paren (no matching close)
|
||||
if ($token === '(') {
|
||||
$hasClose = false;
|
||||
foreach ($extractedTokens as $t) {
|
||||
if ($t === ')') {
|
||||
$hasClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasClose) {
|
||||
$strippedIncompleteCall = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$parts[] = $token;
|
||||
}
|
||||
|
||||
$result = \trim(\implode('', $parts));
|
||||
|
||||
// Complete stripped incomplete calls so they parse as method calls
|
||||
if ($strippedIncompleteCall && $result !== '' && \substr($result, -2) !== '()') {
|
||||
$result .= '()';
|
||||
}
|
||||
|
||||
if ($result !== '' && $this->isFollowedByNonCompletableOperator($expression, $result)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an expression and resolve types directly from its AST.
|
||||
*
|
||||
* Returns null if the expression cannot be parsed.
|
||||
*
|
||||
* @return string[]|null
|
||||
*/
|
||||
private function resolveTypesFromParsedExpression(string $expression): ?array
|
||||
{
|
||||
try {
|
||||
$stmts = $this->parser->parse('<?php '.$expression.';');
|
||||
} catch (PhpParserError $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($stmts) || !$stmts[0] instanceof Node\Stmt\Expression) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->resolveExpressionTypes($stmts[0]->expr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a token is -> or ?->.
|
||||
*
|
||||
* @param mixed $token Token from token_get_all
|
||||
*/
|
||||
private function isObjectAccessOperatorToken($token): bool
|
||||
{
|
||||
if (!\is_array($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($token[0] === \T_OBJECT_OPERATOR) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (\defined('T_NULLSAFE_OBJECT_OPERATOR')) {
|
||||
/** @var int $nullsafeToken */
|
||||
$nullsafeToken = \constant('T_NULLSAFE_OBJECT_OPERATOR');
|
||||
|
||||
return $token[0] === $nullsafeToken;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identifier looks like an incomplete identifier (for tab completion).
|
||||
*
|
||||
* Short identifiers or common prefixes are likely incomplete during tab completion.
|
||||
*
|
||||
* @param string $identifier The identifier to check
|
||||
*
|
||||
* @return bool True if likely incomplete
|
||||
*/
|
||||
private function looksLikeIncompleteIdentifier(string $identifier): bool
|
||||
{
|
||||
// Very short identifiers (1-5 chars) are often incomplete
|
||||
if (\strlen($identifier) <= 5) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common method prefixes that might be incomplete (6-7 chars)
|
||||
// e.g., "getCit" (6 chars) vs "nonExistent" (11 chars)
|
||||
$incompletePrefixes = ['get', 'set', 'is', 'has', 'can', 'find'];
|
||||
|
||||
foreach ($incompletePrefixes as $prefix) {
|
||||
if (\stripos($identifier, $prefix) === 0 && \strlen($identifier) <= 7) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an extracted expression is followed by operators that mean
|
||||
* we shouldn't provide type completion.
|
||||
*
|
||||
* E.g., "$user . \""; user is concatenating, not completing
|
||||
*
|
||||
* @param string $original Original full expression
|
||||
* @param string $extracted Extracted variable/chain
|
||||
*
|
||||
* @return bool True if followed by non-completable operator
|
||||
*/
|
||||
private function isFollowedByNonCompletableOperator(string $original, string $extracted): bool
|
||||
{
|
||||
$pos = \strpos($original, $extracted);
|
||||
if ($pos === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$afterExtracted = \ltrim(\substr($original, $pos + \strlen($extracted)));
|
||||
|
||||
if ($afterExtracted === '' || \strpos($afterExtracted, '->') === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nonCompletableOps = ['|', '.', '+', '-', '*', '/', '%', '&', '^', '~', '<', '>', '=', '!', '?', ':'];
|
||||
|
||||
return \in_array($afterExtracted[0], $nonCompletableOps, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all possible types of an AST expression node (supports union types).
|
||||
*
|
||||
* This is the core method that walks the AST tree and resolves types.
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function resolveExpressionTypes($expr): array
|
||||
{
|
||||
if (
|
||||
$expr instanceof MethodCall
|
||||
|| $expr instanceof PropertyFetch
|
||||
|| $expr instanceof NullsafeMethodCall
|
||||
|| $expr instanceof NullsafePropertyFetch
|
||||
) {
|
||||
return $this->resolveObjectMemberTypes($expr);
|
||||
}
|
||||
|
||||
if ($expr instanceof StaticCall) {
|
||||
$classTypes = $this->resolveExpressionTypes($expr->class);
|
||||
$methodName = $expr->name instanceof Identifier ? $expr->name->name : null;
|
||||
|
||||
if (empty($classTypes) || $methodName === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->resolveTypesAcrossClasses($classTypes, fn ($class) => $this->resolveMethodReturnTypes($class, $methodName));
|
||||
}
|
||||
|
||||
if ($expr instanceof StaticPropertyFetch) {
|
||||
$classTypes = $this->resolveExpressionTypes($expr->class);
|
||||
$propertyName = $expr->name instanceof Identifier ? $expr->name->name : null;
|
||||
|
||||
if ($propertyName === null && $expr->name instanceof Variable && \is_string($expr->name->name)) {
|
||||
$propertyName = $expr->name->name;
|
||||
}
|
||||
|
||||
if (empty($classTypes) || $propertyName === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->resolveTypesAcrossClasses($classTypes, fn ($class) => $this->resolvePropertyTypes($class, $propertyName));
|
||||
}
|
||||
|
||||
if ($expr instanceof Variable && \is_string($expr->name)) {
|
||||
$type = $this->resolveVariableType('$'.$expr->name);
|
||||
|
||||
if ($type === null || $this->isScalarOrNull($type)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$type];
|
||||
}
|
||||
|
||||
if ($expr instanceof Name) {
|
||||
$className = $expr->toString();
|
||||
|
||||
if (\in_array(\strtolower($className), ['self', 'static', 'parent'])) {
|
||||
$type = $this->resolveSpecialKeyword($className);
|
||||
|
||||
return $type !== null ? [$type] : [];
|
||||
}
|
||||
|
||||
return [$this->resolveClassName($className)];
|
||||
}
|
||||
|
||||
if ($expr instanceof Node\Expr\Ternary) {
|
||||
// Short ternary ($x ?: $y) uses the condition as the "if" branch
|
||||
$ifTypes = $expr->if !== null
|
||||
? $this->resolveExpressionTypes($expr->if)
|
||||
: $this->resolveExpressionTypes($expr->cond);
|
||||
|
||||
$elseTypes = $expr->else !== null
|
||||
? $this->resolveExpressionTypes($expr->else)
|
||||
: [];
|
||||
|
||||
return \array_values(\array_unique(\array_merge($ifTypes, $elseTypes)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve types for an object member access (method call or property fetch).
|
||||
*
|
||||
* Handles the common pattern of resolving the object's types, looking up the
|
||||
* member, and falling back to parent types for incomplete identifiers during
|
||||
* tab completion.
|
||||
*
|
||||
* @param MethodCall|PropertyFetch|NullsafeMethodCall|NullsafePropertyFetch $expr
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function resolveObjectMemberTypes($expr): array
|
||||
{
|
||||
$objectTypes = $this->resolveExpressionTypes($expr->var);
|
||||
if (empty($objectTypes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$memberName = $expr->name instanceof Identifier ? $expr->name->name : null;
|
||||
if ($memberName === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$isMethod = $expr instanceof MethodCall || $expr instanceof NullsafeMethodCall;
|
||||
$reflectionCheck = $isMethod ? 'hasMethod' : 'hasProperty';
|
||||
|
||||
$memberExists = false;
|
||||
foreach ($objectTypes as $objectType) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($objectType);
|
||||
if ($reflection->$reflectionCheck($memberName)) {
|
||||
$memberExists = true;
|
||||
break;
|
||||
}
|
||||
} catch (\ReflectionException $e) {
|
||||
// Class doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
$resolver = $isMethod
|
||||
? fn ($class) => $this->resolveMethodReturnTypes($class, $memberName)
|
||||
: fn ($class) => $this->resolvePropertyTypes($class, $memberName);
|
||||
|
||||
$resolvedTypes = $this->resolveTypesAcrossClasses($objectTypes, $resolver);
|
||||
|
||||
// For tab completion: if member doesn't exist and looks incomplete,
|
||||
// return the parent object types so completion can suggest matching members
|
||||
if (empty($resolvedTypes) && !$memberExists && $this->looksLikeIncompleteIdentifier($memberName)) {
|
||||
return $objectTypes;
|
||||
}
|
||||
|
||||
return $resolvedTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve types by applying a resolver callback across multiple class names.
|
||||
*
|
||||
* @param string[] $classTypes Array of class names to resolve across
|
||||
* @param callable $resolver Callback that takes a class name and returns string[]
|
||||
*
|
||||
* @return string[] Array of unique fully-qualified class names
|
||||
*/
|
||||
private function resolveTypesAcrossClasses(array $classTypes, callable $resolver): array
|
||||
{
|
||||
$types = [];
|
||||
foreach ($classTypes as $classType) {
|
||||
$types = \array_merge($types, $resolver($classType));
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($types));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all possible return types of a method (supports union types).
|
||||
*
|
||||
* @param string $className Fully-qualified class name
|
||||
* @param string $methodName Method name
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function resolveMethodReturnTypes(string $className, string $methodName): array
|
||||
{
|
||||
try {
|
||||
$reflection = new \ReflectionClass($className);
|
||||
} catch (\ReflectionException $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$reflection->hasMethod($methodName)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$method = $reflection->getMethod($methodName);
|
||||
} catch (\ReflectionException $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($method->hasReturnType()) {
|
||||
$returnType = $method->getReturnType();
|
||||
if (!$returnType instanceof \ReflectionType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractClassNamesFromType($returnType, $reflection);
|
||||
}
|
||||
|
||||
return $this->resolveReturnTypesFromDocblock($method, $reflection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all possible types of a property (supports union types).
|
||||
*
|
||||
* @param string $className Fully-qualified class name
|
||||
* @param string $propertyName Property name
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function resolvePropertyTypes(string $className, string $propertyName): array
|
||||
{
|
||||
try {
|
||||
$reflection = new \ReflectionClass($className);
|
||||
} catch (\ReflectionException $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$reflection->hasProperty($propertyName)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$property = $reflection->getProperty($propertyName);
|
||||
} catch (\ReflectionException $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($property->hasType()) {
|
||||
$type = $property->getType();
|
||||
if (!$type instanceof \ReflectionType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractClassNamesFromType($type, $reflection);
|
||||
}
|
||||
|
||||
return $this->resolvePropertyTypesFromDocblock($property, $reflection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all class names from a ReflectionType (handles all composite types).
|
||||
*
|
||||
* Recursively processes:
|
||||
* - Named types: Returns class name if not builtin
|
||||
* - Union types (A|B): Returns all class names from union members
|
||||
* - Intersection types (A&B): Returns all class names (completion shows both)
|
||||
* - DNF types ((A&B)|C): Returns all class names from nested types
|
||||
*
|
||||
* For completion purposes, we're liberal: any mentioned class is a valid
|
||||
* completion candidate, even if the actual runtime type is more restrictive.
|
||||
*
|
||||
* @param \ReflectionType $type The reflection type to extract from
|
||||
* @param \ReflectionClass $classContext Class context for resolving relative names
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function extractClassNamesFromType(\ReflectionType $type, \ReflectionClass $classContext): array
|
||||
{
|
||||
if ($type instanceof \ReflectionNamedType) {
|
||||
if ($type->isBuiltin()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$this->resolveTypeName($type->getName(), $classContext)];
|
||||
}
|
||||
|
||||
$compositeTypes = $this->getCompositeTypes($type);
|
||||
if (!empty($compositeTypes)) {
|
||||
$types = [];
|
||||
foreach ($compositeTypes as $compositeType) {
|
||||
$types = \array_merge($types, $this->extractClassNamesFromType($compositeType, $classContext));
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($types));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely fetch child types for composite reflection types (union/intersection/DNF).
|
||||
*
|
||||
* @return \ReflectionType[]
|
||||
*/
|
||||
private function getCompositeTypes(\ReflectionType $type): array
|
||||
{
|
||||
if (!\method_exists($type, 'getTypes')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// @phan-suppress-next-line PhanUndeclaredMethod, available for composite types, guarded by method_exists
|
||||
$types = $type->getTypes();
|
||||
|
||||
if (!\is_array($types)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \array_values(\array_filter($types, fn ($candidate) => $candidate instanceof \ReflectionType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a type name in the context of a class (handle relative names).
|
||||
*
|
||||
* @param string $typeName Type name (might be relative: "Foo" or absolute: "\Foo\Bar")
|
||||
* @param \ReflectionClass $classContext The class context for resolving relative names
|
||||
*
|
||||
* @return string Fully-qualified class name
|
||||
*/
|
||||
private function resolveTypeName(string $typeName, \ReflectionClass $classContext): string
|
||||
{
|
||||
if (\in_array(\strtolower($typeName), ['self', 'static'])) {
|
||||
return $classContext->getName();
|
||||
}
|
||||
|
||||
if (\strtolower($typeName) === 'parent') {
|
||||
$parent = $classContext->getParentClass();
|
||||
|
||||
return $parent ? $parent->getName() : $typeName;
|
||||
}
|
||||
|
||||
if ($typeName[0] === '\\') {
|
||||
return \ltrim($typeName, '\\');
|
||||
}
|
||||
|
||||
// PHP ReflectionType returns FQN without leading backslash
|
||||
if (\class_exists($typeName) || \interface_exists($typeName) || \trait_exists($typeName)) {
|
||||
return $typeName;
|
||||
}
|
||||
|
||||
$namespace = $classContext->getNamespaceName();
|
||||
if ($namespace) {
|
||||
$fqn = $namespace.'\\'.$typeName;
|
||||
if (\class_exists($fqn) || \interface_exists($fqn) || \trait_exists($fqn)) {
|
||||
return $fqn;
|
||||
}
|
||||
}
|
||||
|
||||
return $typeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve return types from @return docblock (supports union types).
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function resolveReturnTypesFromDocblock(\ReflectionMethod $method, \ReflectionClass $classContext): array
|
||||
{
|
||||
$docblock = new Docblock($method);
|
||||
|
||||
if (empty($docblock->tags['return'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$returnTag = $docblock->tags['return'][0];
|
||||
if (!isset($returnTag['type'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parseDocblockTypes($returnTag['type'], $classContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve property types from @var docblock (supports union types).
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function resolvePropertyTypesFromDocblock(\ReflectionProperty $property, \ReflectionClass $classContext): array
|
||||
{
|
||||
$docblock = new Docblock($property);
|
||||
|
||||
if (empty($docblock->tags['var'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$varTag = $docblock->tags['var'][0];
|
||||
if (!isset($varTag['type'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parseDocblockTypes($varTag['type'], $classContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a docblock type string and resolve to fully-qualified class names (supports unions).
|
||||
*
|
||||
* Handles:
|
||||
* - Simple types: "Foo", "\\Foo\\Bar"
|
||||
* - Nullable types: "?Foo"
|
||||
* - Union types: "Foo|Bar|null" (returns ALL non-scalar types)
|
||||
* - Array types: "Foo[]" (returns empty since we can't determine array element type)
|
||||
* - Generic types: "array<Foo>" or "Collection<Foo>" (extracts Foo)
|
||||
*
|
||||
* @param string $typeString The type from docblock
|
||||
* @param \ReflectionClass $classContext Context for resolving relative names
|
||||
*
|
||||
* @return string[] Array of fully-qualified class names
|
||||
*/
|
||||
private function parseDocblockTypes(string $typeString, \ReflectionClass $classContext): array
|
||||
{
|
||||
$typeString = \trim($typeString);
|
||||
if ($typeString === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($typeString[0] === '?') {
|
||||
$typeString = \substr($typeString, 1);
|
||||
}
|
||||
|
||||
if (\strpos($typeString, '|') !== false) {
|
||||
$result = [];
|
||||
foreach (\explode('|', $typeString) as $type) {
|
||||
$type = \trim($type);
|
||||
if (!$this->isScalarOrNull($type)) {
|
||||
$result = \array_merge($result, $this->parseDocblockTypes($type, $classContext));
|
||||
}
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($result));
|
||||
}
|
||||
|
||||
if (\substr($typeString, -2) === '[]') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (\preg_match('/^[\w\\\\]+<(.+)>$/', $typeString, $matches)) {
|
||||
return $this->parseDocblockTypes($matches[1], $classContext);
|
||||
}
|
||||
|
||||
if ($this->isScalarOrNull($typeString)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$this->resolveTypeName($typeString, $classContext)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a type string represents a scalar or null.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isScalarOrNull(string $type): bool
|
||||
{
|
||||
return \in_array(\strtolower($type), self::SCALAR_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve type of a variable.
|
||||
*/
|
||||
private function resolveVariableType(string $variable): ?string
|
||||
{
|
||||
$varName = \ltrim($variable, '$');
|
||||
|
||||
try {
|
||||
$value = $this->context->get($varName);
|
||||
|
||||
return \is_object($value) ? \get_class($value) : \gettype($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a class name (handle namespaces and use statements).
|
||||
*/
|
||||
private function resolveClassName(string $name): string
|
||||
{
|
||||
if (\strpos($name, '\\') === 0) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
if ($this->cleaner !== null) {
|
||||
$resolved = $this->cleaner->resolveClassName($name);
|
||||
if ($resolved !== $name) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve special keywords (self, static, parent).
|
||||
*/
|
||||
private function resolveSpecialKeyword(string $keyword): ?string
|
||||
{
|
||||
$keyword = \strtolower($keyword);
|
||||
|
||||
$boundClass = $this->context->getBoundClass();
|
||||
if ($boundClass === null) {
|
||||
$boundObject = $this->context->getBoundObject();
|
||||
$boundClass = $boundObject !== null ? \get_class($boundObject) : null;
|
||||
}
|
||||
|
||||
if ($boundClass === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($keyword === 'self' || $keyword === 'static') {
|
||||
return $boundClass;
|
||||
}
|
||||
|
||||
if ($keyword === 'parent') {
|
||||
$parent = (new \ReflectionClass($boundClass))->getParentClass();
|
||||
|
||||
return $parent ? $parent->getName() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a class name.
|
||||
*/
|
||||
private function isClassName(string $name): bool
|
||||
{
|
||||
return \preg_match('/^[\\\\a-zA-Z_][\\\\a-zA-Z0-9_]*$/', $name) === 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user