'use strict'; // Laravel route parser. Regex-based — pragmatic, not a full PHP AST. // Captures every Route::* declaration in routes/web.php and routes/api.php, // including groups with middleware/prefix/name/namespace and resource/apiResource. const path = require('path'); const fs = require('fs'); const php = require('./php-parse'); const { tryRead, listPhp, parseController, parseModel, parseMigration, parseMiddleware, listBlade } = php; // Extract use statements: `use App\Http\Controllers\UserController;` function parseUses(src) { const out = {}; const re = /use\s+([\\A-Za-z0-9_]+)(?:\s+as\s+([A-Za-z0-9_]+))?\s*;/g; let m; while ((m = re.exec(src))) { const fq = m[1].replace(/^\\/, ''); const alias = m[2] || fq.split('\\').pop(); out[alias] = fq; } return out; } // Tokenize a Route:: call arguments. Honors quotes, brackets, commas at top level. function tokenizeArgs(s) { const tokens = []; let depth = 0, cur = '', inStr = false, q = ''; for (let i = 0; i < s.length; i++) { const c = s[i]; if (inStr) { cur += c; if (c === '\\') { cur += s[++i] || ''; continue; } if (c === q) inStr = false; continue; } if (c === '"' || c === "'") { inStr = true; q = c; cur += c; continue; } if (c === '[' || c === '{' || c === '(') depth++; if (c === ']' || c === '}' || c === ')') depth--; if (c === ',' && depth === 0) { tokens.push(cur.trim()); cur = ''; continue; } cur += c; } if (cur.trim()) tokens.push(cur.trim()); return tokens; } // Parse `[Controller::class, 'action']` or `'Controller@action'` or Closure. function parseAction(arg, uses) { if (!arg) return null; arg = arg.trim(); if (/^function\s*\(/.test(arg) || /^fn\s*\(/.test(arg)) return { closure: true }; if (arg.startsWith('[') && arg.endsWith(']')) { const parts = tokenizeArgs(arg.slice(1, -1)); if (parts.length < 2) return null; const cls = parts[0].replace(/::class$/, '').trim(); const act = parts[1].replace(/^['"]|['"]$/g, '').trim(); const fq = uses[cls] || cls; return { controller: fq.split('\\').pop(), action: act, fqcn: fq }; } if (arg.includes('@')) { const [c, a] = arg.replace(/^['"]|['"]$/g, '').split('@'); const fq = uses[c] || c; return { controller: fq.split('\\').pop(), action: a, fqcn: fq }; } const fq = uses[arg.replace(/::class$/, '').trim()] || arg.replace(/::class$/, '').trim(); return { controller: fq.split('\\').pop(), action: null, fqcn: fq }; } // Find matching closing brace/paren starting at openIdx. function matchClose(src, openIdx, open, close) { let depth = 0, inStr = false, q = ''; for (let i = openIdx; i < src.length; i++) { const c = src[i]; if (inStr) { if (c === '\\') { i++; continue; } if (c === q) inStr = false; continue; } if (c === '"' || c === "'") { inStr = true; q = c; continue; } if (c === open) depth++; else if (c === close) { depth--; if (depth === 0) return i; } } return -1; } // Parse Route:: chains. Returns array of route objects. function parseRoutes(src, uses, ctx) { ctx = ctx || { middleware: [], prefix: '', name: '' }; const routes = []; const re = /Route::([a-zA-Z]+)\s*\(/g; let m; while ((m = re.exec(src))) { const method = m[1]; const argStart = m.index + m[0].length - 1; const argEnd = matchClose(src, argStart, '(', ')'); if (argEnd < 0) break; const argStr = src.slice(argStart + 1, argEnd); let after = argEnd + 1; const chain = { middleware: [], prefix: '', name: '', namespace: '' }; let isGroup = false; while (after < src.length) { const rest = src.slice(after); const cm = rest.match(/^->([a-zA-Z]+)\s*\(/); if (!cm) break; const cmStart = after + cm[0].length - 1; const cmEnd = matchClose(src, cmStart, '(', ')'); if (cmEnd < 0) break; const cmArg = src.slice(cmStart + 1, cmEnd); const fn = cm[1]; if (fn === 'middleware') { chain.middleware.push(...cmArg.replace(/^['"]|['"]$/g, '').split('|').filter(Boolean)); } else if (fn === 'prefix') { chain.prefix = cmArg.replace(/^['"]|['"]$/g, ''); } else if (fn === 'name') { chain.name = cmArg.replace(/^['"]|['"]$/g, ''); } else if (fn === 'namespace') { chain.namespace = cmArg.replace(/^['"]|['"]$/g, ''); } else if (fn === 'group') { isGroup = true; const bodyStart = cmArg.indexOf('{'); if (bodyStart >= 0) { const bodyEnd = matchClose(cmArg, bodyStart, '{', '}'); const inner = cmArg.slice(bodyStart + 1, bodyEnd >= 0 ? bodyEnd : cmArg.length); const childCtx = { middleware: [...ctx.middleware, ...chain.middleware], prefix: (ctx.prefix + '/' + chain.prefix).replace(/\/+/g, '/').replace(/\/$/, ''), name: (ctx.name + chain.name), }; routes.push(...parseRoutes(inner, uses, childCtx)); } after = cmEnd + 1; break; } after = cmEnd + 1; } // Advance regex past the consumed chain so the outer loop does not // re-scan the closure body of a group (which would duplicate inner routes). re.lastIndex = after; if (isGroup) continue; const args = tokenizeArgs(argStr); if (method === 'resource' || method === 'apiResource') { const resPath = (args[0] || '').replace(/^['"]|['"]$/g, ''); const act = parseAction(args[1], uses); const ctrl = act ? act.controller : 'Unknown'; const crud = method === 'resource' ? [ ['get', '', 'index'], ['get', '/create', 'create'], ['post', '', 'store'], ['get', '/{id}', 'show'], ['get', '/{id}/edit', 'edit'], ['put', '/{id}', 'update'], ['delete','/{id}', 'destroy'], ] : [ ['get', '', 'index'], ['post', '', 'store'], ['get', '/{id}', 'show'], ['put', '/{id}', 'update'], ['delete','/{id}', 'destroy'], ]; for (const [verb, suffix, action] of crud) { let p = resPath + suffix; p = (ctx.prefix + '/' + p).replace(/\/+/g, '/').replace(/\/$/, '') || '/'; routes.push({ method: verb.toUpperCase(), path: p, controller: ctrl, action, middleware: [...ctx.middleware, ...chain.middleware], name: ctx.name + resPath.replace('/', '.') + '.' + action, }); } continue; } if (!args[0]) continue; const rpath = (ctx.prefix + '/' + args[0].replace(/^['"]|['"]$/g, '')) .replace(/\/+/g, '/').replace(/\/$/, '') || '/'; const act = parseAction(args[1], uses); routes.push({ method: method.toUpperCase(), path: rpath, controller: act && act.controller ? act.controller : 'Closure', action: act && act.action ? act.action : null, closure: act && act.closure, middleware: [...ctx.middleware, ...chain.middleware], name: ctx.name + (args.find(a => a.startsWith('name:')) || '').replace(/name:\s*['"]|['"]$/g, ''), }); } return routes; } function discover(laravelPath) { const root = path.resolve(laravelPath); const routesDir = path.join(root, 'routes'); const ctrlDir = path.join(root, 'app', 'Http', 'Controllers'); const modelDir = path.join(root, 'app', 'Models'); const mwDir = path.join(root, 'app', 'Http', 'Middleware'); const migDir = path.join(root, 'database', 'migrations'); const viewDir = path.join(root, 'resources', 'views'); const webSrc = tryRead(path.join(routesDir, 'web.php')) || ''; const apiSrc = tryRead(path.join(routesDir, 'api.php')) || ''; const webUses = parseUses(webSrc); const apiUses = parseUses(apiSrc); return { root, routes: { web: parseRoutes(webSrc, webUses), api: parseRoutes(apiSrc, apiUses), }, controllers: listPhp(ctrlDir).map(parseController), models: listPhp(modelDir).map(parseModel), middleware: listPhp(mwDir).map(parseMiddleware), migrations: listPhp(migDir).map(parseMigration).filter(Boolean), views: listBlade(viewDir), }; } module.exports = { discover, parseRoutes, parseUses, parseAction, tokenizeArgs, matchClose, parseController, parseModel, parseMigration, parseMiddleware, listBlade, tryRead, };