mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 05:41:18 +08:00
fix(laravel): name routes by the path a request takes — leading slash, prefix groups, the /api mount (#2099)
Routes were named by the bare literal in the routes file, so BookStack's
api.php showed `GET pages` for `/api/pages` and koel's `Route::prefix('api')`
group showed `GET ping` for `/api/ping`. Beyond the label, a front-end
`fetch('/api/pages')` could never pair with a route recorded as `pages`.
- extract(): `Route::prefix()` chains and `Route::group(['prefix' => …])`
scope their routes, nested ones in order; a computed prefix is kept as a
`{prefix}` segment. Paths are joined the way Laravel joins them (leading
slash, no doubled or trailing slashes). qualifiedName now holds that
in-file path.
- postExtract(): finds where each routes file is mounted — a group whose
argument is the file, a require / include / loadRoutesFrom inside a
group, Laravel 11's `withRouting(api:, web:, apiPrefix:)` — composes the
prefixes down the include tree, and names every Laravel route from its
qualifiedName plus its file's prefix (idempotent; a removed mount is
undone). routes/api.php defaults to `/api` unless something mounts it or
a `withRouting(using:)` loader might. A file mounted at two prefixes stays
at its in-file path. Only files in routes/ or naming `routes/` /
`withRouting(` are read.
On BookStack all 80 api.php routes become `/api/…` (from its
RouteServiceProvider group); on koel all 118 become `/api/…`; no route is
left without a leading slash.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixes
|
||||
|
||||
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
|
||||
|
||||
## [1.6.1] - 2026-09-29
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Laravel routes are named by the path a request takes.
|
||||
*
|
||||
* Route files write paths without a leading slash (`Route::get('pages', …)`),
|
||||
* nest them in prefix groups (`Route::prefix('api')->group(…)`), and are
|
||||
* mounted under a prefix by the framework — `routes/api.php` is served under
|
||||
* `/api`. Names used to be the bare literal (`GET pages`, `GET ping`), which
|
||||
* was wrong on screen and kept a front-end `fetch('/api/pages')` from ever
|
||||
* pairing with its route.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { laravelResolver } from '../src/resolution/frameworks/laravel';
|
||||
|
||||
const extractNames = (src: string, file = 'routes/web.php') =>
|
||||
laravelResolver.extract!(file, `<?php\n${src}`).nodes.map((n) => n.name);
|
||||
|
||||
describe('Laravel route paths inside a routes file', () => {
|
||||
it('names a path written without a leading slash from the root', () => {
|
||||
expect(extractNames(`Route::get('pages', [PageController::class, 'index']);`)).toEqual(['GET /pages']);
|
||||
expect(extractNames(`Route::get('/', [HomeController::class, 'index']);`)).toEqual(['GET /']);
|
||||
});
|
||||
|
||||
it('applies prefix groups, nested ones in order, and only inside the group', () => {
|
||||
const src = `
|
||||
Route::prefix('api')->middleware('auth:sanctum')->group(static function (): void {
|
||||
Route::get('ping', static fn () => null);
|
||||
Route::prefix('v1/')->group(function () {
|
||||
Route::post('/songs', [SongController::class, 'store']);
|
||||
});
|
||||
Route::get('me', [ProfileController::class, 'show']);
|
||||
});
|
||||
Route::get('/after', [HomeController::class, 'after']);
|
||||
`;
|
||||
expect(extractNames(src)).toEqual(['GET /api/ping', 'POST /api/v1/songs', 'GET /api/me', 'GET /after']);
|
||||
});
|
||||
|
||||
it("reads a prefix from Route::group's array, and adds nothing for a middleware-only group", () => {
|
||||
const src = `
|
||||
Route::group(['middleware' => 'auth', 'prefix' => 'admin'], function () {
|
||||
Route::get('/', [AdminController::class, 'index']);
|
||||
Route::middleware('can:manage')->group(fn () => Route::get('users', [UserController::class, 'index']));
|
||||
});
|
||||
`;
|
||||
expect(extractNames(src)).toEqual(['GET /admin', 'GET /admin/users']);
|
||||
});
|
||||
|
||||
it('keeps a computed prefix as a {prefix} segment instead of dropping it', () => {
|
||||
const src = `
|
||||
Route::group(['prefix' => LaravelLocalization::setLocale()], function () {
|
||||
Route::get('about', [PageController::class, 'about']);
|
||||
});
|
||||
`;
|
||||
expect(extractNames(src)).toEqual(['GET /{prefix}/about']);
|
||||
});
|
||||
|
||||
it('prefixes a resource route, and records the in-file path in qualifiedName', () => {
|
||||
const { nodes } = laravelResolver.extract!('routes/web.php', `<?php
|
||||
Route::prefix('admin')->group(function () {
|
||||
Route::resource('photos', PhotoController::class);
|
||||
Route::get('stats', [StatsController::class, 'index']);
|
||||
});
|
||||
`);
|
||||
expect(nodes.map((n) => n.name)).toEqual(['GET /admin/stats', 'resource:admin/photos']);
|
||||
expect(nodes.map((n) => n.qualifiedName)).toEqual(['routes/web.php::route:/admin/stats', 'routes/web.php::route:/admin/photos']);
|
||||
});
|
||||
|
||||
it('ignores a group opened inside a comment', () => {
|
||||
const src = `
|
||||
// Route::prefix('old')->group(function () {
|
||||
Route::get('kept', [X::class, 'y']);
|
||||
`;
|
||||
expect(extractNames(src)).toEqual(['GET /kept']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Laravel route paths across files (the mount a routes file is served under)', () => {
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(files: Record<string, string>): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-laravel-paths-'));
|
||||
roots.push(root);
|
||||
const all: Record<string, string> = {
|
||||
artisan: '#!/usr/bin/env php\n<?php\n',
|
||||
'composer.json': JSON.stringify({ require: { 'laravel/framework': '^11.0' } }),
|
||||
'app/Http/Controllers/PageController.php': '<?php\nclass PageController { public function list() {} public function login() {} }\n',
|
||||
...files,
|
||||
};
|
||||
for (const [rel, content] of Object.entries(all)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
async function routeNames(root: string): Promise<string[]> {
|
||||
const cg = await CodeGraph.init(root, { index: true });
|
||||
try {
|
||||
return cg.getNodesByKind('route').map((n) => n.name).sort();
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
}
|
||||
|
||||
const API = "<?php\nRoute::get('pages', [PageController::class, 'list']);\n";
|
||||
const WEB = "<?php\nRoute::get('/login', [PageController::class, 'login']);\n";
|
||||
|
||||
it('serves routes/api.php under /api when nothing says otherwise', async () => {
|
||||
const root = project({ 'routes/api.php': API, 'routes/web.php': WEB });
|
||||
expect(await routeNames(root)).toEqual(['GET /api/pages', 'GET /login']);
|
||||
});
|
||||
|
||||
it("reads the RouteServiceProvider's prefix (Laravel ≤10)", async () => {
|
||||
const root = project({
|
||||
'routes/api.php': API,
|
||||
'routes/web.php': WEB,
|
||||
'app/Providers/RouteServiceProvider.php': `<?php
|
||||
class RouteServiceProvider {
|
||||
public function boot() {
|
||||
Route::middleware('api')->prefix('api/v1')->group(base_path('routes/api.php'));
|
||||
Route::middleware('web')->group(base_path('routes/web.php'));
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(await routeNames(root)).toEqual(['GET /api/v1/pages', 'GET /login']);
|
||||
});
|
||||
|
||||
it("reads a Route::group(['prefix' => …]) that requires the file (BookStack's shape)", async () => {
|
||||
const root = project({
|
||||
'routes/api.php': API,
|
||||
'app/App/Providers/RouteServiceProvider.php': `<?php
|
||||
class RouteServiceProvider {
|
||||
protected function mapApiRoutes() {
|
||||
Route::group(['middleware' => 'api', 'prefix' => 'api'], function ($router) {
|
||||
require base_path('routes/api.php');
|
||||
});
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(await routeNames(root)).toEqual(['GET /api/pages']);
|
||||
});
|
||||
|
||||
it("reads Laravel 11's withRouting(api:, apiPrefix:)", async () => {
|
||||
const root = project({
|
||||
'routes/api.php': API,
|
||||
'routes/web.php': WEB,
|
||||
'bootstrap/app.php': `<?php
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
apiPrefix: 'api/v2',
|
||||
health: '/up',
|
||||
)
|
||||
->create();
|
||||
`,
|
||||
});
|
||||
expect(await routeNames(root)).toEqual(['GET /api/v2/pages', 'GET /login']);
|
||||
});
|
||||
|
||||
it('composes prefixes down an include tree: a group mounting a file, and a plain require', async () => {
|
||||
const root = project({
|
||||
'routes/api.php': `<?php
|
||||
Route::get('pages', [PageController::class, 'list']);
|
||||
Route::prefix('v1')->group(base_path('routes/api/v1.php'));
|
||||
`,
|
||||
'routes/api/v1.php': "<?php\nRoute::get('songs', [PageController::class, 'list']);\n",
|
||||
'routes/web.php': `<?php\nRoute::get('/', [PageController::class, 'list']);\nrequire __DIR__.'/auth.php';\n`,
|
||||
'routes/auth.php': "<?php\nRoute::get('login', [PageController::class, 'login']);\n",
|
||||
});
|
||||
expect(await routeNames(root)).toEqual(['GET /', 'GET /api/pages', 'GET /api/v1/songs', 'GET /login']);
|
||||
});
|
||||
|
||||
it('leaves routes/api.php alone when withRouting(using:) loads routes by code it cannot read', async () => {
|
||||
const root = project({
|
||||
'routes/api.php': API,
|
||||
'bootstrap/app.php': `<?php
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(using: static function (): void { RouteLoader::load('api'); })
|
||||
->create();
|
||||
`,
|
||||
});
|
||||
expect(await routeNames(root)).toEqual(['GET /pages']);
|
||||
});
|
||||
|
||||
it('leaves a file mounted at two different prefixes at its in-file path', async () => {
|
||||
const root = project({
|
||||
'routes/shared.php': "<?php\nRoute::get('status', [PageController::class, 'list']);\n",
|
||||
'app/Providers/RouteServiceProvider.php': `<?php
|
||||
Route::prefix('a')->group(base_path('routes/shared.php'));
|
||||
Route::prefix('b')->group(base_path('routes/shared.php'));
|
||||
`,
|
||||
});
|
||||
expect(await routeNames(root)).toEqual(['GET /status']);
|
||||
});
|
||||
|
||||
it('is idempotent across syncs, and undoes a mount that is removed', async () => {
|
||||
const provider = 'app/Providers/RouteServiceProvider.php';
|
||||
const root = project({
|
||||
'routes/admin.php': "<?php\nRoute::get('users', [PageController::class, 'list']);\n",
|
||||
[provider]: "<?php\nRoute::prefix('admin')->group(base_path('routes/admin.php'));\n",
|
||||
});
|
||||
const cg = await CodeGraph.init(root, { index: true });
|
||||
try {
|
||||
const names = () => cg.getNodesByKind('route').map((n) => n.name);
|
||||
expect(names()).toEqual(['GET /admin/users']);
|
||||
fs.appendFileSync(path.join(root, 'app/Http/Controllers/PageController.php'), '// touched\n');
|
||||
await cg.sync();
|
||||
expect(names()).toEqual(['GET /admin/users']);
|
||||
fs.writeFileSync(path.join(root, provider), "<?php\nRoute::group([], base_path('routes/admin.php'));\n");
|
||||
await cg.sync();
|
||||
expect(names()).toEqual(['GET /users']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("pairs a front-end fetch('/api/pages') with the api.php route it reaches", async () => {
|
||||
const root = project({
|
||||
'routes/api.php': API,
|
||||
'resources/js/pages.js': "export async function loadPages() {\n return await fetch('/api/pages');\n}\n",
|
||||
'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
|
||||
});
|
||||
const cg = await CodeGraph.init(root, { index: true });
|
||||
try {
|
||||
const route = cg.getNodesByKind('route').find((n) => n.name === 'GET /api/pages');
|
||||
expect(route).toBeDefined();
|
||||
const loadPages = cg.getNodesByKind('function').find((n) => n.name === 'loadPages')!;
|
||||
const hops = cg.getCallees(loadPages.id).filter((c) => c.node.id === route!.id);
|
||||
expect(hops).toHaveLength(1);
|
||||
expect((hops[0]!.edge.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy).toBe('http-client');
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
* Handles Laravel-specific patterns for reference resolution.
|
||||
*/
|
||||
|
||||
import { posix } from 'path';
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
@@ -105,6 +106,11 @@ export const laravelResolver: FrameworkResolver = {
|
||||
const references: UnresolvedRef[] = [];
|
||||
const now = Date.now();
|
||||
const safe = stripCommentsForRegex(content, 'php');
|
||||
// `Route::prefix('admin')->group(…)` / `Route::group(['prefix' => …], …)`
|
||||
// scopes, so a route is named by the path a request takes inside this
|
||||
// file. A file mounted under a prefix elsewhere (`routes/api.php` under
|
||||
// `/api`) is composed in postExtract, from this in-file path.
|
||||
const groups = routeGroups(safe);
|
||||
|
||||
// Route::METHOD('/path', handler-expr)
|
||||
// handler-expr can be: [Class::class, 'method'] | 'Controller@method' | Closure | Class::class
|
||||
@@ -114,11 +120,12 @@ export const laravelResolver: FrameworkResolver = {
|
||||
const [, method, routePath, handlerExpr] = match;
|
||||
const line = safe.slice(0, match.index).split('\n').length;
|
||||
const upper = method!.toUpperCase();
|
||||
const inFile = joinRoutePath(prefixAt(groups, match.index), routePath!);
|
||||
const routeNode: Node = {
|
||||
id: `route:${filePath}:${line}:${upper}:${routePath}`,
|
||||
kind: 'route',
|
||||
name: `${upper} ${routePath}`,
|
||||
qualifiedName: `${filePath}::route:${routePath}`,
|
||||
name: `${upper} ${inFile}`,
|
||||
qualifiedName: `${filePath}::route:${inFile}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
@@ -148,11 +155,12 @@ export const laravelResolver: FrameworkResolver = {
|
||||
while ((match = resourceRegex.exec(safe)) !== null) {
|
||||
const [, _fn, resourceName, handlerExpr] = match;
|
||||
const line = safe.slice(0, match.index).split('\n').length;
|
||||
const inFile = joinRoutePath(prefixAt(groups, match.index), resourceName!);
|
||||
const routeNode: Node = {
|
||||
id: `route:${filePath}:${line}:RESOURCE:${resourceName}`,
|
||||
kind: 'route',
|
||||
name: `resource:${resourceName}`,
|
||||
qualifiedName: `${filePath}::route:${resourceName}`,
|
||||
name: `resource:${inFile.slice(1)}`,
|
||||
qualifiedName: `${filePath}::route:${inFile}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
@@ -181,8 +189,278 @@ export const laravelResolver: FrameworkResolver = {
|
||||
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
/**
|
||||
* Name each route by the path a request takes once its FILE is mounted.
|
||||
* Laravel serves `routes/api.php` under `/api` (the default
|
||||
* RouteServiceProvider and Laravel 11's `withRouting(api:)` both do), and
|
||||
* any routes file can be mounted under a prefix: `Route::prefix('v1')->
|
||||
* group(base_path('routes/v1.php'))`, `withRouting(api: …, apiPrefix:)`,
|
||||
* or a `require` inside a prefixed group. Mounts compose down the include
|
||||
* tree; a file reached at two different prefixes is left at its in-file
|
||||
* path, since one name cannot be two addresses.
|
||||
*
|
||||
* Every Laravel route is recomputed from its `qualifiedName` (the in-file
|
||||
* path, group prefixes included), so the pass is idempotent and a mount
|
||||
* that goes away is undone on the next run.
|
||||
*/
|
||||
postExtract(context: ResolutionContext): Node[] {
|
||||
const routes = context.getNodesByKind('route').filter(isLaravelRoute);
|
||||
if (routes.length === 0) return [];
|
||||
|
||||
const mounts: Array<{ source: string; target: string; prefix: string }> = [];
|
||||
let customLoader = false;
|
||||
for (const file of context.getAllFiles()) {
|
||||
if (!file.endsWith('.php')) continue;
|
||||
// A mount names a `routes/` path (base_path('routes/api.php'),
|
||||
// __DIR__.'/../routes/api.php'), is a routes file including a sibling
|
||||
// (`require __DIR__.'/auth.php'`), or is Laravel 11's `withRouting(`,
|
||||
// whose `using:` loader may name no path at all. Everything else is
|
||||
// skipped unread.
|
||||
const inRoutes = file.startsWith('routes/');
|
||||
if (!inRoutes && context.fileContains &&
|
||||
!MOUNT_NEEDLES.some((needle) => context.fileContains!(file, needle))) continue;
|
||||
const content = context.readFile(file);
|
||||
if (!content || (!inRoutes && !MOUNT_NEEDLES.some((needle) => content.includes(needle)))) continue;
|
||||
const safe = stripCommentsForRegex(content, 'php');
|
||||
const found = routeFileMounts(safe, file, context);
|
||||
mounts.push(...found.mounts);
|
||||
if (found.customLoader) customLoader = true;
|
||||
}
|
||||
|
||||
// Nothing mounts routes/api.php, and no custom loader might: Laravel's
|
||||
// own default is `/api`. Seeded before composing, so a file api.php
|
||||
// includes inherits it.
|
||||
const defaults = new Map<string, string>();
|
||||
const mounted = new Set(mounts.map((m) => m.target));
|
||||
if (!customLoader && !mounted.has(DEFAULT_API_FILE) && context.fileExists(DEFAULT_API_FILE)) {
|
||||
defaults.set(DEFAULT_API_FILE, '/api');
|
||||
}
|
||||
|
||||
// Compose prefixes down the include tree until nothing changes.
|
||||
let prefixOf = new Map(defaults);
|
||||
for (let round = 0; round < 8; round++) {
|
||||
const next = new Map(defaults);
|
||||
const ambiguous = new Set<string>();
|
||||
for (const { source, target, prefix } of mounts) {
|
||||
if (target === source) continue;
|
||||
const full = joinRoutePath(prefixOf.get(source) ?? '', prefix);
|
||||
const seen = next.get(target);
|
||||
if (seen !== undefined && seen !== full) ambiguous.add(target);
|
||||
else next.set(target, full);
|
||||
}
|
||||
for (const a of ambiguous) next.delete(a);
|
||||
let changed = next.size !== prefixOf.size;
|
||||
if (!changed) for (const [k, v] of next) if (prefixOf.get(k) !== v) changed = true;
|
||||
prefixOf = next;
|
||||
if (!changed) break;
|
||||
}
|
||||
const updates: Node[] = [];
|
||||
for (const route of routes) {
|
||||
const at = route.qualifiedName.indexOf(ROUTE_QN);
|
||||
const inFile = route.qualifiedName.slice(at + ROUTE_QN.length);
|
||||
const full = joinRoutePath(prefixOf.get(route.filePath) ?? '', inFile);
|
||||
const name = route.name.startsWith('resource:')
|
||||
? `resource:${full.slice(1)}`
|
||||
: `${route.name.slice(0, route.name.indexOf(' '))} ${full}`;
|
||||
if (name !== route.name) updates.push({ ...route, name });
|
||||
}
|
||||
return updates;
|
||||
},
|
||||
};
|
||||
|
||||
const ROUTE_QN = '::route:';
|
||||
const DEFAULT_API_FILE = 'routes/api.php';
|
||||
/** What a file outside `routes/` must contain to mount a routes file. */
|
||||
const MOUNT_NEEDLES = ['routes/', 'withRouting('];
|
||||
|
||||
/** A route this resolver made: its `qualifiedName` holds the in-file path. */
|
||||
function isLaravelRoute(n: Node): boolean {
|
||||
return n.language === 'php' && n.id.startsWith(`route:${n.filePath}:`) &&
|
||||
n.qualifiedName.startsWith(`${n.filePath}${ROUTE_QN}/`);
|
||||
}
|
||||
|
||||
/** A group's parenthesised arguments and the prefix it adds (`{prefix}` when not a literal). */
|
||||
interface RouteGroup {
|
||||
open: number;
|
||||
close: number;
|
||||
prefix: string;
|
||||
/** Argument text, split at the top level. */
|
||||
args: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every route group in a file: `Route::group([...], …)` and the `->group(…)`
|
||||
* that ends a `Route::…` chain (`Route::middleware('auth')->prefix('admin')->
|
||||
* group(…)`), with the prefix the chain or the array names. A prefix that is
|
||||
* not a string literal (`LaravelLocalization::setLocale()`) is kept as the
|
||||
* `{prefix}` segment it stands for, not dropped.
|
||||
*/
|
||||
function routeGroups(safe: string): RouteGroup[] {
|
||||
const groups: RouteGroup[] = [];
|
||||
const start = /\bRoute::(\w+)\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = start.exec(safe)) !== null) {
|
||||
let name = m[1]!;
|
||||
let open = m.index + m[0].length - 1;
|
||||
const prefixes: string[] = [];
|
||||
for (;;) {
|
||||
const close = matchParen(safe, open);
|
||||
if (close < 0) break;
|
||||
const args = splitArgs(safe.slice(open + 1, close));
|
||||
if (name === 'prefix') prefixes.push(literalOrPlaceholder(args[0] ?? ''));
|
||||
if (name === 'group') {
|
||||
// Route::group(['prefix' => 'admin', …], …) carries it in the array.
|
||||
if (prefixes.length === 0 && /^\s*\[/.test(args[0] ?? '')) {
|
||||
const inArray = /['"]prefix['"]\s*=>\s*([^,\]]+)/.exec(args[0]!);
|
||||
if (inArray) prefixes.push(literalOrPlaceholder(inArray[1]!));
|
||||
}
|
||||
groups.push({ open, close, prefix: joinRoutePath(...prefixes), args });
|
||||
break;
|
||||
}
|
||||
const next = /^\s*->\s*(\w+)\s*\(/.exec(safe.slice(close + 1, close + 200));
|
||||
if (!next) break;
|
||||
name = next[1]!;
|
||||
open = close + 1 + next[0].length - 1;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The composed prefix of every group whose arguments contain `at`, outermost first. */
|
||||
function prefixAt(groups: RouteGroup[], at: number): string {
|
||||
const enclosing = groups.filter((g) => g.open < at && at < g.close).sort((a, b) => a.open - b.open);
|
||||
return joinRoutePath(...enclosing.map((g) => g.prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes files this file mounts, each with the prefix a request carries
|
||||
* into it: a group whose argument is the file, a `require` / `include` /
|
||||
* `loadRoutesFrom` inside a group, and Laravel 11's `withRouting(api:, web:)`.
|
||||
* `customLoader` is set when `withRouting(using:)` loads routes by code this
|
||||
* pass cannot read.
|
||||
*/
|
||||
function routeFileMounts(
|
||||
safe: string,
|
||||
file: string,
|
||||
context: ResolutionContext
|
||||
): { mounts: Array<{ source: string; target: string; prefix: string }>; customLoader: boolean } {
|
||||
const mounts: Array<{ source: string; target: string; prefix: string }> = [];
|
||||
const groups = routeGroups(safe);
|
||||
const add = (expr: string, prefix: string) => {
|
||||
const target = resolvePhpPath(expr, file, context);
|
||||
if (target) mounts.push({ source: file, target, prefix });
|
||||
};
|
||||
|
||||
for (const g of groups) {
|
||||
// ->group(base_path('routes/v1.php')), Route::group([...], base_path(…))
|
||||
const body = g.args[g.args.length - 1] ?? '';
|
||||
if (!/^\s*(?:static\s+)?(?:function|fn)\b/.test(body)) add(body, prefixAt(groups, g.open + 1));
|
||||
}
|
||||
const load = /\b(?:require|include)(?:_once)?\b([^;]*);|\bloadRoutesFrom\s*\(([^;]*)\)\s*;/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = load.exec(safe)) !== null) {
|
||||
let expr = (m[1] ?? m[2] ?? '').trim();
|
||||
// `require(__DIR__.'/x.php')` — drop parens only when they wrap the whole expression.
|
||||
if (expr.startsWith('(') && matchParen(expr, 0) === expr.length - 1) expr = expr.slice(1, -1);
|
||||
add(expr, prefixAt(groups, m.index));
|
||||
}
|
||||
|
||||
let customLoader = false;
|
||||
const routing = /\bwithRouting\s*\(/g;
|
||||
while ((m = routing.exec(safe)) !== null) {
|
||||
const open = m.index + m[0].length - 1;
|
||||
const close = matchParen(safe, open);
|
||||
if (close < 0) continue;
|
||||
const named = new Map<string, string>();
|
||||
for (const arg of splitArgs(safe.slice(open + 1, close))) {
|
||||
const kv = /^\s*(\w+)\s*:\s*([\s\S]*)$/.exec(arg);
|
||||
if (kv) named.set(kv[1]!, kv[2]!);
|
||||
}
|
||||
if (named.has('using')) customLoader = true;
|
||||
const apiPrefix = named.has('apiPrefix') ? literalOrPlaceholder(named.get('apiPrefix')!) : 'api';
|
||||
for (const [key, prefix] of [['api', apiPrefix], ['web', '']] as const) {
|
||||
const value = named.get(key);
|
||||
if (!value) continue;
|
||||
const items = /^\s*\[/.test(value) ? splitArgs(value.trim().slice(1, -1)) : [value];
|
||||
for (const item of items) add(item, prefix);
|
||||
}
|
||||
}
|
||||
return { mounts, customLoader };
|
||||
}
|
||||
|
||||
/**
|
||||
* A PHP path expression naming a file in the project: `base_path('routes/x.php')`,
|
||||
* `__DIR__.'/../routes/x.php'`, `dirname(__DIR__).'/routes/x.php'` or a bare
|
||||
* `'routes/x.php'` literal. Null for anything computed, or not in the index.
|
||||
*/
|
||||
function resolvePhpPath(expr: string, file: string, context: ResolutionContext): string | null {
|
||||
const e = expr.trim();
|
||||
let rel: string | null = null;
|
||||
let m: RegExpExecArray | null;
|
||||
if ((m = /^base_path\s*\(\s*(['"])([^'"]+)\1\s*\)$/.exec(e))) rel = m[2]!;
|
||||
else if ((m = /^__DIR__\s*\.\s*(['"])([^'"]+)\1$/.exec(e))) rel = posix.join(posix.dirname(file), m[2]!);
|
||||
else if ((m = /^dirname\s*\(\s*__DIR__\s*\)\s*\.\s*(['"])([^'"]+)\1$/.exec(e))) rel = posix.join(posix.dirname(posix.dirname(file)), m[2]!);
|
||||
else if ((m = /^(['"])([^'"]+\.php)\1$/.exec(e))) rel = m[2]!;
|
||||
if (!rel) return null;
|
||||
const target = posix.normalize(rel).replace(/^(\.\/)+/, '').replace(/^\/+/, '');
|
||||
if (target.startsWith('..') || !target.endsWith('.php')) return null;
|
||||
return context.fileExists(target) ? target : null;
|
||||
}
|
||||
|
||||
/** `'admin'` → `admin`; anything that is not a plain string literal → `{prefix}`. */
|
||||
function literalOrPlaceholder(expr: string): string {
|
||||
const m = /^\s*(['"])([^'"]*)\1\s*$/.exec(expr);
|
||||
return m ? m[2]! : '{prefix}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Join route path segments the way Laravel does: `('api', 'v1/', '/users')` →
|
||||
* `/api/v1/users`. Leading and trailing slashes don't matter; the root is `/`.
|
||||
*/
|
||||
function joinRoutePath(...parts: string[]): string {
|
||||
const segments = parts.flatMap((p) => p.split('/')).filter((s) => s !== '');
|
||||
return '/' + segments.join('/');
|
||||
}
|
||||
|
||||
/** Index of the `)` closing the `(` at `open`, skipping PHP strings; -1 if unbalanced. */
|
||||
function matchParen(s: string, open: number): number {
|
||||
let depth = 0;
|
||||
for (let i = open; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"' || ch === "'") {
|
||||
for (i++; i < s.length && s[i] !== ch; i++) if (s[i] === '\\') i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')' && --depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Top-level comma split of an argument list; strings and brackets respected. */
|
||||
function splitArgs(args: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let from = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const ch = args[i];
|
||||
if (ch === '"' || ch === "'") {
|
||||
for (i++; i < args.length && args[i] !== ch; i++) if (args[i] === '\\') i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
||||
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
out.push(args.slice(from, i));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
const last = args.slice(from);
|
||||
if (last.trim() !== '' || out.length > 0) out.push(last);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Laravel route handler expression and return the symbol to link.
|
||||
* - `[Class::class, 'method']` -> `method`
|
||||
|
||||
Reference in New Issue
Block a user