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:
Colby Mchenry
2026-09-29 16:03:02 +00:00
committed by GitHub
parent 2b9f2f47ef
commit ffced05e3c
3 changed files with 529 additions and 4 deletions
+3
View File
@@ -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
+244
View File
@@ -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();
}
});
});
+282 -4
View File
@@ -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`