A method called through a PHP fluent static factory — `ApiClient::for($c)->createOrder()`, the canonical Laravel per-credential/per-tenant client idiom — produced no `calls` edge: the receiver of `->createOrder` is the `Cls::for(...)` static call, whose result type was never recovered, so the edge was dropped and `codegraph_callers` returned nothing. Same shape as the C++ singleton/factory fix (#645), reusing its return_type column + the chained-call mechanism: - Capture PHP return types (getReturnType): `: self` / `: static` / `$this` stored as the `self` marker, a concrete `: Type` as its short name, primitives/unions dropped. - Encode the chained scoped-call receiver as `Cls::for().method` so the resolver can split it (PHP-gated, in extractCall). - New matchPhpCallChain: look up the factory's return type (`self` → the factory's own class; concrete → that class), then resolve AND validate the method on it — a wrong inference yields no edge, never a wrong one. EXTRACTION_VERSION 4->5 (re-index to populate PHP return types + chained edges). Validated on koel (1383 PHP files): node count identical (no explosion), 0 edges lost, +80 chained-call edges recovered; synthetic tests cover the self-factory, concrete-return, namespace, decoy, and absent-method cases. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
8.4 KiB
TypeScript
190 lines
8.4 KiB
TypeScript
import type { Node as SyntaxNode } from 'web-tree-sitter';
|
|
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
|
|
import type { LanguageExtractor } from '../tree-sitter-types';
|
|
|
|
// include / require (+ _once) expression node types. These carry the
|
|
// file→file dependency in procedural PHP, where `include`/`require` — not
|
|
// namespace `use` — is how a file pulls in another (issue #660).
|
|
const PHP_INCLUDE_TYPES = new Set([
|
|
'include_expression',
|
|
'include_once_expression',
|
|
'require_expression',
|
|
'require_once_expression',
|
|
]);
|
|
|
|
/**
|
|
* Extract a static string-literal path from a PHP include/require expression.
|
|
*
|
|
* Returns null for dynamic forms (`include $var`, `require __DIR__ . '/x'`,
|
|
* interpolated strings) — they have no resolvable compile-time path, which
|
|
* matches the issue's "static string literals (the common case)" scope.
|
|
*/
|
|
function phpStaticIncludePath(node: SyntaxNode, source: string): string | null {
|
|
// The path argument is the expression's first named child; the call-style
|
|
// form `require("x")` wraps it in a parenthesized_expression.
|
|
let arg: SyntaxNode | null = node.namedChild(0);
|
|
if (arg?.type === 'parenthesized_expression') arg = arg.namedChild(0);
|
|
if (!arg || (arg.type !== 'string' && arg.type !== 'encapsed_string')) return null;
|
|
// Pure literal only: any non-`string_content` child (interpolated variable,
|
|
// escape sequence, …) means the value isn't a static path.
|
|
const parts = arg.namedChildren;
|
|
if (parts.some((c: SyntaxNode) => c.type !== 'string_content')) return null;
|
|
const content = parts.find((c: SyntaxNode) => c.type === 'string_content');
|
|
return content ? getNodeText(content, source) : null;
|
|
}
|
|
|
|
/** PHP built-in return types that can't be a method receiver (so no class to chain on). */
|
|
const PHP_NON_CLASS_RETURN = new Set([
|
|
'array', 'string', 'int', 'integer', 'float', 'double', 'bool', 'boolean',
|
|
'void', 'mixed', 'never', 'null', 'false', 'true', 'object', 'callable',
|
|
'iterable', 'resource',
|
|
]);
|
|
|
|
/**
|
|
* A method/function's declared return type, normalized to the class a chained
|
|
* `->method()` could be called on (issue #608). `self` / `static` / `$this` are
|
|
* kept as the marker `self` and resolved to the declaring class at resolution
|
|
* time; a concrete type returns its short name; primitives / unions / nullable
|
|
* non-class types return undefined.
|
|
*/
|
|
function extractPhpReturnType(node: SyntaxNode, source: string): string | undefined {
|
|
let rt = getChildByField(node, 'return_type');
|
|
if (!rt) return undefined;
|
|
// Unwrap `?Type`. Union / intersection types are ambiguous — skip them.
|
|
if (rt.type === 'optional_type') rt = rt.namedChild(0) ?? rt;
|
|
if (!rt || rt.type === 'primitive_type') return undefined;
|
|
|
|
const nameNode = rt.type === 'named_type' ? (rt.namedChild(0) ?? rt) : rt;
|
|
const text = getNodeText(nameNode, source).trim().replace(/^\\/, '');
|
|
if (!text) return undefined;
|
|
const last = text.split('\\').pop() ?? text;
|
|
const lc = last.toLowerCase();
|
|
if (lc === 'self' || lc === 'static' || lc === 'this' || lc === '$this') return 'self';
|
|
if (PHP_NON_CLASS_RETURN.has(lc)) return undefined;
|
|
if (!/^[A-Za-z_]\w*$/.test(last)) return undefined; // union/intersection/complex
|
|
return last;
|
|
}
|
|
|
|
export const phpExtractor: LanguageExtractor = {
|
|
functionTypes: ['function_definition'],
|
|
classTypes: ['class_declaration', 'trait_declaration'],
|
|
methodTypes: ['method_declaration'],
|
|
interfaceTypes: ['interface_declaration'],
|
|
structTypes: [],
|
|
enumTypes: ['enum_declaration'],
|
|
enumMemberTypes: ['enum_case'],
|
|
typeAliasTypes: [],
|
|
importTypes: ['namespace_use_declaration', ...PHP_INCLUDE_TYPES],
|
|
callTypes: ['function_call_expression', 'member_call_expression', 'scoped_call_expression'],
|
|
variableTypes: ['const_declaration'],
|
|
fieldTypes: ['property_declaration'],
|
|
nameField: 'name',
|
|
bodyField: 'body',
|
|
paramsField: 'parameters',
|
|
returnField: 'return_type',
|
|
getReturnType: extractPhpReturnType,
|
|
classifyClassNode: (node) => {
|
|
return node.type === 'trait_declaration' ? 'trait' : 'class';
|
|
},
|
|
getVisibility: (node) => {
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (child?.type === 'visibility_modifier') {
|
|
const text = child.text;
|
|
if (text === 'public') return 'public';
|
|
if (text === 'private') return 'private';
|
|
if (text === 'protected') return 'protected';
|
|
}
|
|
}
|
|
return 'public'; // PHP defaults to public
|
|
},
|
|
isStatic: (node) => {
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (child?.type === 'static_modifier') return true;
|
|
}
|
|
return false;
|
|
},
|
|
visitNode: (node, ctx) => {
|
|
// Handle class constants: const_declaration inside classes
|
|
// These are skipped by the main visitor because variableTypes check excludes class-like contexts
|
|
if (node.type === 'const_declaration') {
|
|
const constElements = node.namedChildren.filter((c: SyntaxNode) => c.type === 'const_element');
|
|
for (const elem of constElements) {
|
|
const nameNode = elem.namedChildren.find((c: SyntaxNode) => c.type === 'name');
|
|
if (!nameNode) continue;
|
|
const name = getNodeText(nameNode, ctx.source);
|
|
ctx.createNode('constant', name, elem, {});
|
|
}
|
|
return true; // handled
|
|
}
|
|
|
|
// Handle trait usage: use TraitName, OtherTrait; inside classes
|
|
// Creates unresolved references that will be resolved to 'implements' edges
|
|
if (node.type === 'use_declaration') {
|
|
const names = node.namedChildren.filter((c: SyntaxNode) => c.type === 'name' || c.type === 'qualified_name');
|
|
const parentId = ctx.nodeStack.length > 0 ? ctx.nodeStack[ctx.nodeStack.length - 1] : undefined;
|
|
if (parentId) {
|
|
for (const nameNode of names) {
|
|
const traitName = getNodeText(nameNode, ctx.source);
|
|
ctx.addUnresolvedReference({
|
|
fromNodeId: parentId,
|
|
referenceName: traitName,
|
|
referenceKind: 'implements',
|
|
filePath: ctx.filePath,
|
|
line: node.startPosition.row + 1,
|
|
column: node.startPosition.column,
|
|
});
|
|
}
|
|
}
|
|
return true; // handled
|
|
}
|
|
|
|
return false;
|
|
},
|
|
// PHP `namespace Foo\Bar;` is file-level (like a Java/Kotlin package). Capturing
|
|
// it scopes every class under an `Foo\Bar::` qualified name, which is what makes
|
|
// `use` imports and same-named types (Laravel has 7+ `Factory` interfaces across
|
|
// namespaces) resolvable to the RIGHT definition instead of an arbitrary match.
|
|
packageTypes: ['namespace_definition'],
|
|
extractPackage: (node, source) => {
|
|
const nsName = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_name');
|
|
// Skip braced `namespace Foo { … }` (has a body) — file-level only.
|
|
const hasBody = node.namedChildren.some((c: SyntaxNode) => c.type === 'compound_statement' || c.type === 'declaration_list');
|
|
if (!nsName || hasBody) return null;
|
|
return getNodeText(nsName, source);
|
|
},
|
|
extractImport: (node, source) => {
|
|
const importText = source.substring(node.startIndex, node.endIndex).trim();
|
|
|
|
// include / require (+ _once): emit a file→file dependency. The path is a
|
|
// static string literal in the common case; dynamic forms resolve to null
|
|
// and are skipped (no import node, no edge).
|
|
if (PHP_INCLUDE_TYPES.has(node.type)) {
|
|
const includePath = phpStaticIncludePath(node, source);
|
|
return includePath ? { moduleName: includePath, signature: importText } : null;
|
|
}
|
|
|
|
// Check for grouped imports: use X\{A, B} - return null for core fallback
|
|
const namespacePrefix = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_name');
|
|
const useGroup = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_use_group');
|
|
if (namespacePrefix && useGroup) {
|
|
return null; // Grouped imports create multiple nodes - let core handle
|
|
}
|
|
|
|
// Single import - find namespace_use_clause
|
|
const useClause = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_use_clause');
|
|
if (useClause) {
|
|
const qualifiedName = useClause.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name');
|
|
if (qualifiedName) {
|
|
return { moduleName: getNodeText(qualifiedName, source), signature: importText };
|
|
}
|
|
const name = useClause.namedChildren.find((c: SyntaxNode) => c.type === 'name');
|
|
if (name) {
|
|
return { moduleName: getNodeText(name, source), signature: importText };
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
};
|