feat(php): add PHP/PSR-4 import resolution with composer.json support

- Add .php, .phtml to EXTENSIONS array
- Add ComposerConfig interface and loadComposerConfig() (mirrors TsconfigPaths pattern)
- Add resolvePhpImport() with PSR-4 longest-match resolution + suffix fallback
- Wire PHP handling into processImports() and processImportsFromExtracted()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Güneş Bizim 2026-02-25 01:04:39 +03:00
parent 2164dc22f1
commit 804d975cd0

View file

@ -101,6 +101,37 @@ async function loadGoModulePath(repoRoot: string): Promise<GoModuleConfig | null
return null;
}
/** PHP Composer PSR-4 autoload config */
interface ComposerConfig {
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
psr4: Map<string, string>;
}
async function loadComposerConfig(repoRoot: string): Promise<ComposerConfig | null> {
try {
const composerPath = path.join(repoRoot, 'composer.json');
const raw = await fs.readFile(composerPath, 'utf-8');
const composer = JSON.parse(raw);
const psr4Raw = composer.autoload?.['psr-4'] ?? {};
const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {};
const merged = { ...psr4Raw, ...psr4Dev };
const psr4 = new Map<string, string>();
for (const [ns, dir] of Object.entries(merged)) {
const nsNorm = (ns as string).replace(/\\+$/, '');
const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, '');
psr4.set(nsNorm, dirNorm);
}
if (isDev) {
console.log(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
}
return { psr4 };
} catch {
return null;
}
}
// ============================================================================
// IMPORT PATH RESOLUTION
// ============================================================================
@ -122,6 +153,8 @@ const EXTENSIONS = [
'.go',
// Rust
'.rs', '/mod.rs',
// PHP
'.php', '.phtml',
];
/**
@ -551,6 +584,48 @@ function resolveGoPackage(
return matches;
}
// ============================================================================
// PHP PSR-4 IMPORT RESOLUTION
// ============================================================================
/**
* Resolve a PHP use-statement import path using PSR-4 mappings.
* e.g. "App\Http\Controllers\UserController" -> "app/Http/Controllers/UserController.php"
*/
function resolvePhpImport(
importPath: string,
composerConfig: ComposerConfig | null,
allFiles: Set<string>,
normalizedFileList: string[],
allFileList: string[],
index?: SuffixIndex,
): string | null {
// Normalize: replace backslashes with forward slashes
const normalized = importPath.replace(/\\/g, '/');
// Try PSR-4 resolution if composer.json was found
if (composerConfig) {
// Sort namespaces by length descending (longest match wins)
const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
for (const [nsPrefix, dirPrefix] of sorted) {
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
if (allFiles.has(filePath)) return filePath;
if (index) {
const result = index.getInsensitive(filePath);
if (result) return result;
}
}
}
}
// Fallback: suffix matching (works without composer.json)
const pathParts = normalized.split('/').filter(Boolean);
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
}
// ============================================================================
// MAIN IMPORT PROCESSOR
// ============================================================================
@ -581,6 +656,7 @@ export const processImports = async (
const effectiveRoot = repoRoot || '';
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
// Helper: add an IMPORTS edge + update import map
const addImportEdge = (filePath: string, resolvedPath: string) => {
@ -705,6 +781,15 @@ export const processImports = async (
// Fall through if no files found (package might be external)
}
// ---- PHP: handle namespace-based imports (use statements) ----
if (language === SupportedLanguages.PHP) {
const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index);
if (resolved) {
addImportEdge(file.path, resolved);
}
return;
}
// ---- Standard single-file resolution ----
const resolvedPath = resolveImportPath(
file.path,
@ -757,6 +842,7 @@ export const processImportsFromExtracted = async (
const effectiveRoot = repoRoot || '';
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
const addImportEdge = (filePath: string, resolvedPath: string) => {
const sourceId = generateId('File', filePath);
@ -856,6 +942,16 @@ export const processImportsFromExtracted = async (
}
}
// PHP: handle namespace-based imports (use statements)
if (language === SupportedLanguages.PHP) {
const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index);
if (resolved) {
resolveCache.set(cacheKey, resolved);
addImportEdge(filePath, resolved);
}
continue;
}
// Standard resolution (has its own internal cache)
const resolvedPath = resolveImportPath(
filePath,