|
| 1 | +import * as fs from "fs"; |
| 2 | +import * as path from "path"; |
| 3 | + |
| 4 | +// ── composer.json PSR-4 autoload parsing ───────────────────────────────────── |
| 5 | +// |
| 6 | +// The Laravel file generation feature needs to map between directories, |
| 7 | +// namespaces, and file paths the way Laravel's own generators do: to pre-fill a |
| 8 | +// namespace from a clicked explorer folder, and to resolve where a generated |
| 9 | +// class file will land (for opening it, and for the bundled-template fallback |
| 10 | +// when artisan cannot boot). All three need the project's PSR-4 roots. |
| 11 | +// |
| 12 | +// This is deliberately a small, self-contained reader. It never runs the app, |
| 13 | +// and nothing it produces is fed back into the language server; it only informs |
| 14 | +// the extension's own generation UI. |
| 15 | + |
| 16 | +/** A single PSR-4 autoload mapping: a namespace prefix and the directory it maps to. */ |
| 17 | +export interface Psr4Root { |
| 18 | + /** The namespace prefix without a trailing separator, e.g. `App`. */ |
| 19 | + namespace: string; |
| 20 | + /** The absolute directory the prefix maps to, e.g. `/project/app`. */ |
| 21 | + directory: string; |
| 22 | +} |
| 23 | + |
| 24 | +/** |
| 25 | + * Read the PSR-4 autoload roots declared in a folder's `composer.json`, merging |
| 26 | + * the `autoload` and `autoload-dev` sections. Returns an empty list when the |
| 27 | + * file is missing or unparsable, so callers degrade to no pre-fill rather than |
| 28 | + * failing. Roots are sorted with the longest namespace first so the most |
| 29 | + * specific prefix wins when several would match. |
| 30 | + */ |
| 31 | +export function loadPsr4Roots(folderFsPath: string): Psr4Root[] { |
| 32 | + let raw: string; |
| 33 | + try { |
| 34 | + raw = fs.readFileSync(path.join(folderFsPath, "composer.json"), "utf8"); |
| 35 | + } catch { |
| 36 | + return []; |
| 37 | + } |
| 38 | + |
| 39 | + let parsed: unknown; |
| 40 | + try { |
| 41 | + parsed = JSON.parse(raw); |
| 42 | + } catch { |
| 43 | + return []; |
| 44 | + } |
| 45 | + if (typeof parsed !== "object" || parsed === null) { |
| 46 | + return []; |
| 47 | + } |
| 48 | + |
| 49 | + const record = parsed as Record<string, unknown>; |
| 50 | + const roots: Psr4Root[] = []; |
| 51 | + for (const section of ["autoload", "autoload-dev"]) { |
| 52 | + const auto = record[section]; |
| 53 | + if (typeof auto !== "object" || auto === null) { |
| 54 | + continue; |
| 55 | + } |
| 56 | + const psr4 = (auto as Record<string, unknown>)["psr-4"]; |
| 57 | + if (typeof psr4 !== "object" || psr4 === null) { |
| 58 | + continue; |
| 59 | + } |
| 60 | + for (const [namespace, directory] of Object.entries(psr4 as Record<string, unknown>)) { |
| 61 | + const dir = firstDirectory(directory); |
| 62 | + if (dir === undefined) { |
| 63 | + continue; |
| 64 | + } |
| 65 | + roots.push({ |
| 66 | + namespace: namespace.replace(/\\+$/, ""), |
| 67 | + directory: path.resolve(folderFsPath, dir) |
| 68 | + }); |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + roots.sort((a, b) => b.namespace.length - a.namespace.length); |
| 73 | + return roots; |
| 74 | +} |
| 75 | + |
| 76 | +/** A PSR-4 mapping may point at a single directory or a list of them; take the first. */ |
| 77 | +function firstDirectory(value: unknown): string | undefined { |
| 78 | + if (typeof value === "string") { |
| 79 | + return value; |
| 80 | + } |
| 81 | + if (Array.isArray(value)) { |
| 82 | + const first = value.find((entry) => typeof entry === "string"); |
| 83 | + return typeof first === "string" ? first : undefined; |
| 84 | + } |
| 85 | + return undefined; |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * The namespace a directory lives in, according to the PSR-4 roots (e.g. |
| 90 | + * `/project/app/Models` → `App\Models`). Returns `undefined` when the directory |
| 91 | + * is not under any root. When several roots contain the directory, the deepest |
| 92 | + * (longest directory) wins so a nested root is preferred over its parent. |
| 93 | + */ |
| 94 | +export function directoryToNamespace(roots: Psr4Root[], dirFsPath: string): string | undefined { |
| 95 | + const target = path.resolve(dirFsPath); |
| 96 | + |
| 97 | + let best: Psr4Root | undefined; |
| 98 | + for (const root of roots) { |
| 99 | + if (isInside(root.directory, target) && (!best || root.directory.length > best.directory.length)) { |
| 100 | + best = root; |
| 101 | + } |
| 102 | + } |
| 103 | + if (!best) { |
| 104 | + return undefined; |
| 105 | + } |
| 106 | + |
| 107 | + const relative = path.relative(best.directory, target); |
| 108 | + if (relative === "") { |
| 109 | + return best.namespace; |
| 110 | + } |
| 111 | + const suffix = relative.split(path.sep).join("\\"); |
| 112 | + return best.namespace ? `${best.namespace}\\${suffix}` : suffix; |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * The file a fully-qualified class name maps to under the PSR-4 roots (e.g. |
| 117 | + * `App\Models\Post` → `/project/app/Models/Post.php`). Returns `undefined` when |
| 118 | + * no root's namespace prefixes the class. |
| 119 | + */ |
| 120 | +export function fqnToFilePath(roots: Psr4Root[], fqn: string): string | undefined { |
| 121 | + const normalized = fqn.replace(/^\\+/, ""); |
| 122 | + |
| 123 | + for (const root of roots) { |
| 124 | + if (root.namespace === "") { |
| 125 | + return path.join(root.directory, `${normalized.split("\\").join(path.sep)}.php`); |
| 126 | + } |
| 127 | + const prefix = `${root.namespace}\\`; |
| 128 | + if (normalized.startsWith(prefix)) { |
| 129 | + const relative = normalized.slice(prefix.length).split("\\").join(path.sep); |
| 130 | + return path.join(root.directory, `${relative}.php`); |
| 131 | + } |
| 132 | + } |
| 133 | + return undefined; |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * The application's root namespace, i.e. the PSR-4 namespace mapped to the `app/` |
| 138 | + * directory (`App` in a default Laravel install). This mirrors Laravel's |
| 139 | + * `$app->getNamespace()`, which the `make:*` commands use to qualify names. |
| 140 | + * Falls back to `App` when no mapping points at `app/`. |
| 141 | + */ |
| 142 | +export function appRootNamespace(roots: Psr4Root[], folderFsPath: string): string { |
| 143 | + const appDir = path.resolve(folderFsPath, "app"); |
| 144 | + for (const root of roots) { |
| 145 | + if (path.resolve(root.directory) === appDir) { |
| 146 | + return root.namespace; |
| 147 | + } |
| 148 | + } |
| 149 | + return "App"; |
| 150 | +} |
| 151 | + |
| 152 | +/** Whether `child` is `parent` itself or nested inside it. */ |
| 153 | +function isInside(parent: string, child: string): boolean { |
| 154 | + const relative = path.relative(parent, child); |
| 155 | + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); |
| 156 | +} |
0 commit comments