mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
fix(php): resolve function imports by declaring file
This commit is contained in:
parent
f0f316a7b2
commit
5407747c67
9 changed files with 91 additions and 6 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue