fix(php): resolve function imports by declaring file

This commit is contained in:
Eva 2026-07-14 12:43:54 +07:00
parent f0f316a7b2
commit 5407747c67
9 changed files with 91 additions and 6 deletions

View file

@ -93,6 +93,7 @@ export interface FinalizeHooks {
targetRaw: string,
fromFile: string,
workspaceIndex: WorkspaceIndex,
parsedImport?: ParsedImport,
): string | readonly string[] | null;
/**
@ -348,7 +349,12 @@ function makeEdgeDrafts(
];
}
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
const targetFile = hooks.resolveImportTarget(
parsed.targetRaw ?? '',
file.filePath,
workspace,
parsed,
);
// Edge is unresolvable at the file level — mark unresolved now.
if (targetFile === null) {

View file

@ -105,6 +105,9 @@ export type ParsedImport =
readonly localName: string;
readonly importedName: string;
readonly targetRaw: string;
/** Provider-specific imported symbol category when module and symbol
* namespaces have distinct resolution rules (for example PHP). */
readonly importedSymbolKind?: 'type' | 'function' | 'const';
/**
* Set by providers when `targetRaw` already names the imported symbol
* rather than only its containing module. Consumers that compose
@ -127,6 +130,8 @@ export type ParsedImport =
readonly alias: string;
readonly targetRaw: string;
/** See the same field on the `named` variant. */
readonly importedSymbolKind?: 'type' | 'function' | 'const';
/** See the same field on the `named` variant. */
readonly targetIncludesImportedName?: boolean;
}
/**

View file

@ -22,9 +22,11 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const';
type PhpImportedSymbolKind = 'type' | 'function' | 'const';
interface PhpImportSpec {
readonly kind: PhpImportKind;
readonly symbolKind: PhpImportedSymbolKind;
/** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */
readonly source: string;
/** Local binding name — last source segment for plain imports, the
@ -119,6 +121,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport
if (alias !== '') {
return {
kind: 'alias',
symbolKind: symbolKindFor(qualifier),
source,
name: alias,
alias,
@ -130,6 +133,7 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport
return {
kind: qualifier,
symbolKind: symbolKindFor(qualifier),
source,
name: lastSegment(source),
atNode: clause,
@ -214,6 +218,7 @@ function parseInnerClause(
if (alias !== '') {
return {
kind: 'alias',
symbolKind: symbolKindFor(qualifier),
source,
name: alias,
alias,
@ -225,6 +230,7 @@ function parseInnerClause(
return {
kind: qualifier,
symbolKind: symbolKindFor(qualifier),
source,
name: lastSegment(innerPath),
atNode: clause,
@ -237,6 +243,7 @@ function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMat
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.symbol-kind': syntheticCapture('@import.symbol-kind', spec.atNode, spec.symbolKind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
@ -254,6 +261,12 @@ function lastSegment(path: string): string {
return parts[parts.length - 1] ?? path;
}
function symbolKindFor(kind: PhpImportKind): PhpImportedSymbolKind {
if (kind === 'function') return 'function';
if (kind === 'const') return 'const';
return 'type';
}
/** Find the first named child with a given node type. */
function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {

View file

@ -16,6 +16,7 @@
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js';
import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
import type { ComposerConfig } from '../../language-config.js';
import { readFileSync } from 'node:fs';
@ -117,6 +118,7 @@ export function resolvePhpImportTargetInternal(
_fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
context?: ImportResolutionContext,
): string | null {
if (targetRaw === '') return null;
@ -129,7 +131,7 @@ export function resolvePhpImportTargetInternal(
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFiles];
return resolvePhpImportInternal(
const resolved = resolvePhpImportInternal(
targetRaw,
composerConfig,
allFiles,
@ -137,4 +139,32 @@ export function resolvePhpImportTargetInternal(
allFileList,
undefined,
);
const parsedImport = context?.parsedImport;
const symbolKind =
parsedImport?.kind === 'named' || parsedImport?.kind === 'alias'
? parsedImport.importedSymbolKind
: undefined;
if (resolved === null || (symbolKind !== 'function' && symbolKind !== 'const')) return resolved;
const importedName = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).at(-1);
if (importedName === undefined) return resolved;
const normalizedResolved = resolved.replace(/\\/g, '/');
const resolvedDirectory = normalizedResolved.slice(0, normalizedResolved.lastIndexOf('/') + 1);
const expectedType = symbolKind === 'function' ? 'Function' : 'Variable';
const declaringFiles = context.parsedFiles.filter((parsed) => {
const normalizedPath = parsed.filePath.replace(/\\/g, '/');
if (!normalizedPath.startsWith(resolvedDirectory)) return false;
if (normalizedPath.slice(resolvedDirectory.length).includes('/')) return false;
return parsed.localDefs.some((def) => {
if (def.type !== expectedType) return false;
const simpleName = (def.qualifiedName ?? '').split(/[\\.]/).at(-1);
return simpleName === importedName;
});
});
if (declaringFiles.length > 1) return null;
return declaringFiles.length === 1 ? declaringFiles[0].filePath : resolved;
}

View file

@ -20,12 +20,19 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
const sourceCap = captures['@import.source'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const symbolKindCap = captures['@import.symbol-kind'];
const kind = kindCap?.text;
if (kind === undefined || sourceCap === undefined) return null;
const source = sourceCap.text.trim();
if (source === '') return null;
const importedSymbolKind =
symbolKindCap?.text === 'function' || symbolKindCap?.text === 'const'
? symbolKindCap.text
: kind === 'function' || kind === 'const'
? kind
: 'type';
switch (kind) {
case 'namespace': {
@ -39,6 +46,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
localName,
importedName: localName,
targetRaw: source,
importedSymbolKind,
};
}
case 'alias': {
@ -53,6 +61,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
importedName,
alias,
targetRaw: source,
importedSymbolKind,
};
}
case 'function': {
@ -64,6 +73,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
localName,
importedName: localName,
targetRaw: source,
importedSymbolKind,
};
}
case 'const': {
@ -74,6 +84,7 @@ export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null
localName,
importedName: localName,
targetRaw: source,
importedSymbolKind,
};
}
default:

View file

@ -354,8 +354,8 @@ const phpScopeResolver: ScopeResolver = {
languageProvider: phpProvider,
importEdgeReason: 'php-scope: use',
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig),
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) =>
resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig, context),
loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath),

View file

@ -273,6 +273,7 @@ import type {
Callsite,
ConstraintContext,
ParsedFile,
ParsedImport,
ReferenceSite,
ScopeId,
SupportedLanguages,
@ -302,6 +303,11 @@ export type ReceiverMemberResolution =
| { readonly kind: 'resolved'; readonly definition: SymbolDefinition }
| { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] };
export interface ImportResolutionContext {
readonly parsedFiles: readonly ParsedFile[];
readonly parsedImport?: ParsedImport;
}
/** Re-exported for ScopeResolver consumers — same shape as
* `RegistryProviders.constraintCompatibility`'s third parameter. */
export type { ConstraintContext } from 'gitnexus-shared';
@ -340,12 +346,18 @@ export interface ScopeResolver {
* orchestrator). TypeScript uses this to thread `tsconfig.json` path
* aliases through to the standard resolver. Languages that don't
* need any extra config ignore the parameter.
*
* `context.parsedFiles` is the complete, read-only language workspace. It is
* optional so resolvers that only need paths retain their existing shape.
* `context.parsedImport` is the exact import being finalized. PHP uses both
* when a PSR-4 import names a function instead of a file.
*/
resolveImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
context?: ImportResolutionContext,
): string | readonly string[] | null;
/**

View file

@ -561,8 +561,11 @@ export function runScopeResolution(
const resolutionConfig = input.resolutionConfig;
const finalized = finalizeScopeModel(parsedFiles, {
hooks: {
resolveImportTarget: (targetRaw, fromFile) =>
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) =>
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, {
parsedFiles,
parsedImport,
}),
expandsWildcardTo: (targetModuleScope) =>
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
mergeBindings: (existing, incoming, scopeId) =>

View file

@ -1621,6 +1621,11 @@ describe('PHP cross-file binding propagation', () => {
(e) => e.sourceFilePath.includes('Main') && e.targetFilePath.includes('UserFactory'),
);
expect(edge).toBeDefined();
const unrelatedEdge = imports.find(
(e) => e.sourceFilePath.includes('Main') && e.targetFilePath.endsWith('/Models/User.php'),
);
expect(unrelatedEdge).toBeUndefined();
});
it('resolves $u->save() in run() to User#save via cross-file return type propagation', () => {