Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10952,6 +10952,52 @@ add_executable(headerapp include/headers.hpp)
expect(dashboard?.entrypoints[0]?.route).toBe("/{tenant}/dashboard");
});

it("maps Laravel array-style route group prefixes", async () => {
const root = await fixtureRoot("clawpatch-laravel-array-group-prefix-");
await writeFixture(
root,
"composer.json",
JSON.stringify(
{
name: "acme/array-group-prefix",
require: {
php: "^8.3",
"laravel/framework": "^13.0",
},
},
null,
2,
),
);
await writeFixture(
root,
"routes/web.php",
"<?php\n" +
"use App\\Http\\Controllers\\UserController;\n" +
'Route::group(["prefix" => "admin"], function () {\n' +
' Route::get("/users", UserController::class);\n' +
"});\n",
);
await writeFixture(
root,
"app/Http/Controllers/UserController.php",
"<?php\nnamespace App\\Http\\Controllers;\nfinal class UserController {}\n",
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const userController = result.features.find(
(feature) => feature.entrypoints[0]?.path === "app/Http/Controllers/UserController.php",
);

expect(userController?.entrypoints[0]?.route).toBe("/admin/users");
expect(userController?.summary).toContain("GET /admin/users");
expect(userController?.contextFiles).toContainEqual({
path: "routes/web.php",
reason: "route definition",
});
});

it("maps Laravel controller route groups", async () => {
const root = await fixtureRoot("clawpatch-laravel-controller-groups-");
await writeFixture(
Expand Down
109 changes: 109 additions & 0 deletions src/mappers/laravel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,12 +411,46 @@ async function laravelRoutes(root: string): Promise<RouteRef[]> {
if (route !== null) {
routes.push(route);
}
routes.push(...routeGroupRoutes(file, imports, calls));
routes.push(...controllerGroupRoutes(file, imports, calls));
}
}
return routes;
}

function routeGroupRoutes(
file: string,
imports: Map<string, string>,
calls: RouteCall[],
): RouteRef[] {
const routes: RouteRef[] = [];
const groupIndex = calls.findIndex((call) => call.name === "group");
const groupCall = groupIndex < 0 ? undefined : calls[groupIndex];
if (groupCall === undefined) {
return routes;
}
const groupAttributePrefixes = routeGroupAttributePrefixes(groupCall);
if (groupAttributePrefixes.length === 0) {
return routes;
}
const body = closureBody(groupCall.args[1] ?? "");
if (body === null) {
return routes;
}
const groupPrefixes = [
...fileDefaultRoutePrefixes(file),
...routePrefixesFromCalls(calls.slice(0, groupIndex)),
...groupAttributePrefixes,
];
for (const statement of routeStatements(body)) {
const route = routeFromCalls(file, imports, parseRouteCalls(statement), groupPrefixes);
if (route !== null) {
routes.push(route);
}
}
return routes;
}

function controllerGroupRoutes(
file: string,
imports: Map<string, string>,
Expand Down Expand Up @@ -707,6 +741,81 @@ function routePrefixesFromCalls(calls: RouteCall[]): string[] {
.filter((prefix) => prefix !== null);
}

function routeGroupAttributePrefixes(call: RouteCall): string[] {
const attributes = arrayArgs(call.args[0] ?? "");
if (attributes === null) {
return [];
}
const prefix = arrayLiteralStringValue(attributes, "prefix");
return prefix === null ? [] : [prefix];
}

function arrayLiteralStringValue(entries: string[], key: string): string | null {
for (const entry of entries) {
const pair = splitTopLevelKeyValue(entry);
if (pair === null) {
continue;
}
const entryKey = stringLiteralValue(pair[0]);
if (entryKey !== key) {
continue;
}
const value = stringLiteralValue(pair[1]);
if (value !== null) {
return value;
}
}
return null;
}

function splitTopLevelKeyValue(source: string): [string, string] | null {
let quote: string | null = null;
let escaped = false;
let parens = 0;
let brackets = 0;
let braces = 0;
for (let index = 0; index < source.length - 1; index += 1) {
const char = source[index];
if (char === undefined) {
continue;
}
if (quote !== null) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === String.fromCharCode(39) || char === String.fromCharCode(34)) {
quote = char;
} else if (char === "(") {
parens += 1;
} else if (char === ")") {
parens = Math.max(0, parens - 1);
} else if (char === "[") {
brackets += 1;
} else if (char === "]") {
brackets = Math.max(0, brackets - 1);
} else if (char === "{") {
braces += 1;
} else if (char === "}") {
braces = Math.max(0, braces - 1);
} else if (
char === "=" &&
source[index + 1] === ">" &&
parens === 0 &&
brackets === 0 &&
braces === 0
) {
return [source.slice(0, index).trim(), source.slice(index + 2).trim()];
}
}
return null;
}

function arrayArgs(source: string): string[] | null {
const trimmed = source.trim();
if (!trimmed.startsWith("[")) {
Expand Down