laravel-to-node / lib /parser.js
Shaurya2020's picture
Put lib/parser.js
c1611c8 verified
Raw History Blame Contribute Delete
8.47 kB
'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,
};