diff --git a/CHANGELOG.md b/CHANGELOG.md index c13683b8c..a953fe773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/laravel-route-paths.test.ts b/__tests__/laravel-route-paths.test.ts new file mode 100644 index 000000000..9c4286ce9 --- /dev/null +++ b/__tests__/laravel-route-paths.test.ts @@ -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, ` 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', `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 { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-laravel-paths-')); + roots.push(root); + const all: Record = { + artisan: '#!/usr/bin/env php\n { + const cg = await CodeGraph.init(root, { index: true }); + try { + return cg.getNodesByKind('route').map((n) => n.name).sort(); + } finally { + cg.close(); + } + } + + const API = " { + 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': `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': ` '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': `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': `group(base_path('routes/api/v1.php')); +`, + 'routes/api/v1.php': " { + const root = project({ + 'routes/api.php': API, + 'bootstrap/app.php': `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': "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': "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), " { + 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(); + } + }); +}); diff --git a/src/resolution/frameworks/laravel.ts b/src/resolution/frameworks/laravel.ts index 1356adf5d..1a8ea1874 100644 --- a/src/resolution/frameworks/laravel.ts +++ b/src/resolution/frameworks/laravel.ts @@ -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(); + 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(); + 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(); + 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`