glastree_on_gitea

This commit is contained in:
2026-05-26 08:14:29 +02:00
commit 0bed099d05
9556 changed files with 1186307 additions and 0 deletions
+85
View File
@@ -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;
}
}
+218
View File
@@ -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;
}
}
+75
View File
@@ -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;
}
+68
View File
@@ -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
View File
@@ -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());
}
}
+48
View File
@@ -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
View File
@@ -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),
];
}
}
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}