mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
feat(frameworks): CodeIgniter 3 routes, and calls through what $this->load loads (#3112)
A CodeIgniter 3 project had no routes in the graph and its flows stopped
at the controller: `$this->users_model->find($id)` goes through a property
nothing declares, so most such calls failed and the rest were guesses by
method name.
The resolver is detected by system/core/CodeIgniter.php, or by a routes
file naming a default controller, at any depth. It makes:
- a route per public method of a controller, implicit-public ones too,
at /<folder>/<class>/<method>, and HMVC modules with the module first;
- a route per $route entry in routes.php, verb-scoped or not, and the
default controller at /. `controller/method/$1` walks to the controller
as CodeIgniter does, and a variable in the method's place binds the
class;
- a `calls` edge for `$this->x->m()`, `$this->CI->x->m()` and
`$CI->x->m()` to the method on the class x holds, its own or inherited.
A class is read by name whatever the case, as PHP and the loader read
it, and an alias comes from the load that gives it;
- a `calls` edge from `$this->load->view('a/b')` to the view's file, where
the calls a template makes are recorded, so a request can be followed
to the end.
Differences from PR #167, each from the real apps: its controller test
is a list of base classes, which misses EA_Controller, Secure_Controller
and every other project's own; its property match is PascalCase only,
which misses `$this->providers_model` and `$this->mdl_invoices`, the form
two of the four apps use throughout; and it stopped at a file-level
`imports` edge to the class, so the flow still ended at the controller.
A reference it made to a method by name could land on a same-named method
in another controller, so a route's reference here names its own file.
Validation:
- codeigniter.test.ts, 21 tests, on the kernel and the wasm path; the 10
on a real index fail with the resolver unregistered.
- A/B on main cd04b21a, real CI3 apps: alextselegidis/easyappointments
f6c8a4c2 +217 routes, +1,229 edges; InvoicePlane e5e2529d (HMVC) +213,
+1,088; opensourcepos 3.3.9 c00ff28d +315, +1,428; ci-bonfire/Bonfire
3eaf6f88 +67, +515. Classes: `+references framework` route to handler,
`+calls framework` property call, and 2 `+calls test-request`. Nothing
removed. Those 2 come from an existing pass: a vendored SimpleTest file
calls `$this->get('http://www.lastcraft.com/')`, read as `/`, which now
exists.
- Every route binds: 217/217, 213/213, 315/315, 67/67.
- Site by site, every framework edge read against its source line and the
class the property holds: 1,012, 875, 1,113 and 446 edges, none wrong.
- A text scan of `->x->m(` where x names a project class: 967 of 967
linked, 855 of 961, 886 of 891, 379 of 420. What's left is methods the
class doesn't declare: InvoicePlane's are `where`, `update` and the like,
which MY_Model forwards to the database through __call.
- Flow: Booking::register -> Appointments_model::save, and
Appointments::store -> save -> validate.
- Controls flask 49943b5a and gin 0f09c3a9 are byte-identical.
- Index CPU, median of 3 interleaved, with a suite running: ospos +6.8%,
easyappointments -8.2%, flask -9.1%, gin -8.8%. The swing is noise; a
project that doesn't use CodeIgniter pays one path scan at detection.
Left out: routes.php entries a helper function adds, which only that
function's code says; CodeIgniter 4, a different layout.
Reimplements PR #167 by @darkspock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bea5e205f4
commit
002dd4d517
@@ -77,7 +77,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
|
||||
- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`.
|
||||
- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend).
|
||||
- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
|
||||
- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `<Link href>` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
|
||||
- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, CodeIgniter 3, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `<Link href>` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
|
||||
- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
|
||||
`dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed), `synthesized-hop.ts` (how a synthesized hop reads, in explore's Flow, the `codegraph_node` trail and ContextBuilder's call paths — a new synthesizer's wording goes here), `file-cycles.ts` (circular imports, in `codegraph cycles` and the viewer's map). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
|
||||
- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
|
||||
|
||||
@@ -28,6 +28,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- For contributors, the test suite now runs on Vitest 4 and working in this repository needs Node 22.12 or newer; installing and using CodeGraph doesn't change. Thanks @danusha2345.
|
||||
- New `codegraph cycles` command lists the groups of files that import each other in a circle, as text or `--json`, and exits 1 when it finds any, so it works as a pre-commit or CI check (#888). Thanks @jiangyq9 and @goutham80808.
|
||||
- `codegraph init --local-only` keeps `.codegraph/` out of git entirely, so nothing of CodeGraph's shows in `git status`, and it works on a project that's already set up, leaving a `.codegraph/.gitignore` you've edited or committed alone (#910). Thanks @jadchene, @jiangyq9, @Wood-Q and @walterf3.
|
||||
- CodeIgniter 3 projects are now understood: every controller method and `routes.php` entry becomes a route, HMVC modules included, and calls through what `$this->load` loads, like `$this->user_model->find($id)`, link to the method they reach, so `codegraph_explore` can follow a request from its route through the model and into the view. Thanks @darkspock.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -326,6 +326,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
|
||||
| **NestJS** | `@Controller` + `@Get/@Post/...` (with `RouterModule` prefixes, `setGlobalPrefix` and URI versioning), GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
|
||||
| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
|
||||
| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` |
|
||||
| **CodeIgniter 3** | Every public controller method by convention, `$route[...]` in `routes.php`, HMVC modules; `$this->load` models, libraries and views |
|
||||
| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
|
||||
| **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods |
|
||||
| **Play** | `GET`/`POST`/… verb routes in `conf/routes` → `Controller.method` actions (Scala + Java) |
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* CodeIgniter 3: routes by convention and from routes.php, and the calls that
|
||||
* go through the properties `$this->load` fills in.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { codeigniterResolver } from '../src/resolution/frameworks/codeigniter';
|
||||
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
|
||||
import type { ResolutionContext } from '../src/resolution/types';
|
||||
|
||||
const extract = (file: string, source: string) => codeigniterResolver.extract!(file, source);
|
||||
const routeNames = (file: string, source: string): string[] => extract(file, source).nodes.map((n) => n.name);
|
||||
|
||||
describe('routes from a controller', () => {
|
||||
const controller = `<?php
|
||||
class Blog extends MY_Controller {
|
||||
public function __construct() { parent::__construct(); }
|
||||
public function index() {}
|
||||
public function view($id) {}
|
||||
function comments() {}
|
||||
public static function feed() {}
|
||||
protected function guard() {}
|
||||
private function load_post() {}
|
||||
public function _remap() {}
|
||||
public function _hidden() {}
|
||||
}
|
||||
`;
|
||||
|
||||
it('takes every public method, an implicit one too, and keeps underscored ones off the web', () => {
|
||||
expect(routeNames('application/controllers/Blog.php', controller)).toEqual([
|
||||
'ANY /blog', 'ANY /blog/view', 'ANY /blog/comments', 'ANY /blog/feed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds the URL from the folder and lower-cases it', () => {
|
||||
expect(routeNames('application/controllers/admin/Blog.php', controller)).toContain('ANY /admin/blog/view');
|
||||
expect(routeNames('sub/dir/application/controllers/Blog.php', controller)).toContain('ANY /blog/view');
|
||||
});
|
||||
|
||||
it('reads HMVC modules, with the controller named after its module at the module\'s URL', () => {
|
||||
expect(routeNames('application/modules/blog/controllers/Blog.php', controller)).toContain('ANY /blog/view');
|
||||
expect(routeNames('application/modules/shop/controllers/Blog.php', controller)).toContain('ANY /shop/blog/view');
|
||||
expect(routeNames('bonfire/modules/shop/controllers/Blog.php', controller)).toContain('ANY /shop/blog/view');
|
||||
});
|
||||
|
||||
it('skips a file whose class is not the one it is named after, and an abstract one', () => {
|
||||
expect(routeNames('application/controllers/Other.php', controller)).toEqual([]);
|
||||
expect(routeNames('application/controllers/Base.php', '<?php\nabstract class Base extends CI_Controller {\n public function index() {}\n}\n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a method in a comment, and every other file', () => {
|
||||
expect(routeNames('application/controllers/Blog.php', '<?php\nclass Blog {\n // public function gone() {}\n /* public function older() {} */\n public function kept() {}\n}\n')).toEqual(['ANY /blog/kept']);
|
||||
expect(routeNames('application/models/Blog.php', controller)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routes from routes.php', () => {
|
||||
const file = 'application/config/routes.php';
|
||||
|
||||
it('reads a route, a verb-scoped one, and the default controller at /', () => {
|
||||
expect(routeNames(file, `<?php
|
||||
$route['default_controller'] = 'welcome';
|
||||
$route['404_override'] = 'errors/missing';
|
||||
$route['translate_uri_dashes'] = FALSE;
|
||||
$route['product/(:num)'] = 'catalog/lookup/$1';
|
||||
$route['api/users']['post'] = 'api/users/create';
|
||||
`)).toEqual(['ANY /', 'ANY /product/(:num)', 'POST /api/users']);
|
||||
});
|
||||
|
||||
it('skips a route in a comment, and one a closure computes', () => {
|
||||
expect(routeNames(file, `<?php
|
||||
// $route['old'] = 'old/index';
|
||||
$route['drinks/(:any)'] = function ($type) { return 'catalog/drinks/' . strtolower($type); };
|
||||
`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads a module\'s own routes file', () => {
|
||||
expect(routeNames('application/modules/shop/config/routes.php', "<?php\n$route['shop/sale'] = 'shop/items/sale';\n")).toEqual(['ANY /shop/sale']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detecting a CodeIgniter project', () => {
|
||||
const context = (files: Record<string, string>): ResolutionContext =>
|
||||
({
|
||||
fileExists: (f: string) => f in files,
|
||||
getAllFiles: () => Object.keys(files),
|
||||
readFile: (f: string) => files[f] ?? null,
|
||||
}) as unknown as ResolutionContext;
|
||||
|
||||
it('goes by the framework\'s own file, or by a routes file that names a default controller', () => {
|
||||
expect(codeigniterResolver.detect(context({ 'system/core/CodeIgniter.php': '' }))).toBe(true);
|
||||
expect(codeigniterResolver.detect(context({ 'application/config/routes.php': "<?php\n$route['default_controller'] = 'welcome';\n" }))).toBe(true);
|
||||
expect(codeigniterResolver.detect(context({ 'htdocs/application/config/routes.php': '<?php\n$route["default_controller"] = "welcome";\n' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('takes no other project\'s routes file for one', () => {
|
||||
expect(codeigniterResolver.detect(context({ 'application/config/routes.php': "<?php\nreturn ['home' => 'index'];\n" }))).toBe(false);
|
||||
expect(codeigniterResolver.detect(context({ 'app/Config/Routes.php': "<?php\n$routes->get('/', 'Home::index');\n" }))).toBe(false);
|
||||
expect(codeigniterResolver.detect(context({ 'routes/web.php': '<?php\n' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a CodeIgniter project, indexed', () => {
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
const write = (rel: string, content: string): void => {
|
||||
const full = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
};
|
||||
|
||||
const calls = (from: string, inFile: string): string[] => {
|
||||
const node = cg.getNodesInFile(inFile).find((n) => n.name === from && n.kind === 'method');
|
||||
if (!node) throw new Error(`no ${from} in ${inFile}`);
|
||||
return cg.getCallees(node.id).map((c) => `${c.node.qualifiedName} @${c.node.filePath}`).sort();
|
||||
};
|
||||
|
||||
const routeTarget = (name: string): string | undefined => {
|
||||
const route = cg.getNodesByKind('route').find((n) => n.name === name);
|
||||
if (!route) throw new Error(`no route ${name}`);
|
||||
return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)?.qualifiedName)[0];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-codeigniter-'));
|
||||
write('application/config/routes.php', `<?php
|
||||
$route['default_controller'] = 'welcome';
|
||||
$route['profile/(:num)'] = 'users/show/$1';
|
||||
$route['reports/(summary_:any)'] = 'admin/reports/$1';
|
||||
$route['sale'] = 'shop/items/sale';
|
||||
$route['board'] = 'dashboard';
|
||||
$route['gone'] = 'nowhere/index';
|
||||
`);
|
||||
write('application/controllers/Welcome.php', '<?php\nclass Welcome extends CI_Controller {\n public function index() {}\n}\n');
|
||||
write('application/controllers/Users.php', `<?php
|
||||
class Users extends MY_Controller {
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->load->model('user_model');
|
||||
$this->load->model('Order_model');
|
||||
$this->load->model('shop/mdl_items', 'items');
|
||||
$this->load->library('mailer');
|
||||
}
|
||||
public function show($id) {
|
||||
$user = $this->user_model->find($id);
|
||||
$this->user_model->save($user);
|
||||
$this->Order_model->find($id);
|
||||
$this->items->price($id);
|
||||
$this->mailer->welcome($user);
|
||||
$this->load->view('users/show', ['user' => $user]);
|
||||
}
|
||||
public function missing() {
|
||||
$this->user_model->no_such_method();
|
||||
$this->nothing_loaded->find();
|
||||
$this->db->get('users');
|
||||
}
|
||||
}
|
||||
`);
|
||||
write('application/controllers/admin/Reports.php', '<?php\nclass Reports extends MY_Controller {\n public function index() {}\n}\n');
|
||||
write('application/core/MY_Model.php', '<?php\nclass MY_Model extends CI_Model {\n public function save($row) { return $row; }\n}\n');
|
||||
write('application/models/User_model.php', '<?php\nclass User_model extends MY_Model {\n public function find($id) { return $id; }\n}\n');
|
||||
write('application/models/Order_model.php', '<?php\nclass Order_model extends MY_Model {\n public function find($id) { return $id; }\n}\n');
|
||||
write('application/libraries/Mailer.php', `<?php
|
||||
class Mailer {
|
||||
protected $CI;
|
||||
public function __construct() { $this->CI =& get_instance(); }
|
||||
public function welcome($user) {
|
||||
$this->CI->user_model->find($user);
|
||||
$CI =& get_instance();
|
||||
$CI->user_model->save($user);
|
||||
}
|
||||
}
|
||||
`);
|
||||
write('application/views/users/show.php', "<?php echo $this->mailer->welcome('x'); ?>\n");
|
||||
write('application/modules/shop/controllers/Items.php', `<?php
|
||||
class Items extends MX_Controller {
|
||||
public function sale() { return $this->mdl_items->price(1); }
|
||||
}
|
||||
`);
|
||||
write('application/modules/shop/models/Mdl_items.php', '<?php\nclass Mdl_Items extends MY_Model {\n public function price($id) { return $id; }\n}\n');
|
||||
write('application/modules/dashboard/controllers/Dashboard.php', '<?php\nclass Dashboard extends MX_Controller {\n public function index() {\n $this->load->view(\'overview\');\n }\n}\n');
|
||||
write('application/modules/dashboard/views/overview.php', '<p>overview</p>\n');
|
||||
cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('binds a convention route to its method, and a routes.php one to what it names', () => {
|
||||
expect(routeTarget('ANY /users/show')).toBe('Users::show');
|
||||
expect(routeTarget('ANY /shop/items/sale')).toBe('Items::sale');
|
||||
expect(routeTarget('ANY /dashboard')).toBe('Dashboard::index');
|
||||
expect(routeTarget('ANY /')).toBe('Welcome::index');
|
||||
expect(routeTarget('ANY /profile/(:num)')).toBe('Users::show');
|
||||
expect(routeTarget('ANY /sale')).toBe('Items::sale');
|
||||
expect(routeTarget('ANY /board')).toBe('Dashboard::index');
|
||||
});
|
||||
|
||||
it('binds a route whose method is a variable to the controller, and leaves one with no controller alone', () => {
|
||||
expect(routeTarget('ANY /reports/(summary_:any)')).toBe('Reports');
|
||||
expect(routeTarget('ANY /gone')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('links a call through a property to that class\'s method, not to any method of that name', () => {
|
||||
expect(calls('show', 'application/controllers/Users.php')).toEqual(expect.arrayContaining([
|
||||
'User_model::find @application/models/User_model.php',
|
||||
'Order_model::find @application/models/Order_model.php',
|
||||
]));
|
||||
});
|
||||
|
||||
it('follows a method up to the class it is inherited from', () => {
|
||||
expect(calls('show', 'application/controllers/Users.php')).toContain('MY_Model::save @application/core/MY_Model.php');
|
||||
});
|
||||
|
||||
it('reads a library\'s property, and a class whose case differs from its file', () => {
|
||||
expect(calls('show', 'application/controllers/Users.php')).toContain('Mailer::welcome @application/libraries/Mailer.php');
|
||||
expect(calls('sale', 'application/modules/shop/controllers/Items.php')).toEqual(['Mdl_Items::price @application/modules/shop/models/Mdl_items.php']);
|
||||
});
|
||||
|
||||
it('reads an alias from the load that gives it', () => {
|
||||
expect(calls('show', 'application/controllers/Users.php')).toContain('Mdl_Items::price @application/modules/shop/models/Mdl_items.php');
|
||||
});
|
||||
|
||||
it('reads a library\'s way to the controller, through get_instance()', () => {
|
||||
expect(calls('welcome', 'application/libraries/Mailer.php')).toEqual([
|
||||
'MY_Model::save @application/core/MY_Model.php',
|
||||
'User_model::find @application/models/User_model.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('links nothing for a method the class does not have, or a property nothing fills', () => {
|
||||
expect(calls('missing', 'application/controllers/Users.php')).toEqual([]);
|
||||
});
|
||||
|
||||
it('links a loaded view to its file, in the app or the caller\'s module, and on to what the view calls', () => {
|
||||
const show = cg.getNodesInFile('application/controllers/Users.php').find((n) => n.name === 'show')!;
|
||||
const view = cg.getCallees(show.id).find((c) => c.node.kind === 'file');
|
||||
expect(view?.node.filePath).toBe('application/views/users/show.php');
|
||||
expect(cg.getCallees(view!.node.id).map((c) => c.node.name)).toContain('welcome');
|
||||
|
||||
const index = cg.getNodesInFile('application/modules/dashboard/controllers/Dashboard.php').find((n) => n.name === 'index')!;
|
||||
expect(cg.getCallees(index.id).map((c) => c.node.filePath)).toEqual(['application/modules/dashboard/views/overview.php']);
|
||||
});
|
||||
|
||||
it('connects a request end to end for a question in the app\'s own names', () => {
|
||||
const flow = resolveNamedSymbolFlow(cg, 'Users.show Mailer.welcome User_model.find');
|
||||
expect(flow.chains[0]?.steps.map((step) => step.node.qualifiedName)).toEqual(['Users::show', 'Mailer::welcome', 'User_model::find']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a sync of a CodeIgniter project', () => {
|
||||
it('binds a route when its controller arrives, and a call when its model does', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-codeigniter-sync-'));
|
||||
const write = (rel: string, content: string): void => {
|
||||
const full = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
};
|
||||
write('application/config/routes.php', "<?php\n$route['default_controller'] = 'welcome';\n$route['read/(:num)'] = 'blog/view/$1';\n");
|
||||
write('application/controllers/Welcome.php', `<?php
|
||||
class Welcome extends CI_Controller {
|
||||
public function index() { $this->post_model->latest(); }
|
||||
}
|
||||
`);
|
||||
const cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
const route = (): string | undefined => {
|
||||
const node = cg.getNodesByKind('route').find((n) => n.name === 'ANY /read/(:num)')!;
|
||||
return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)?.qualifiedName)[0];
|
||||
};
|
||||
const index = (): string[] => {
|
||||
const node = cg.getNodesInFile('application/controllers/Welcome.php').find((n) => n.name === 'index')!;
|
||||
return cg.getCallees(node.id).map((c) => c.node.qualifiedName);
|
||||
};
|
||||
expect(route()).toBeUndefined();
|
||||
expect(index()).toEqual([]);
|
||||
|
||||
write('application/controllers/Blog.php', '<?php\nclass Blog extends CI_Controller {\n public function view($id) {}\n}\n');
|
||||
write('application/models/Post_model.php', '<?php\nclass Post_model extends CI_Model {\n public function latest() {}\n}\n');
|
||||
await cg.sync();
|
||||
expect(route()).toBe('Blog::view');
|
||||
expect(index()).toEqual(['Post_model::latest']);
|
||||
|
||||
cg.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -110,9 +110,9 @@ table; the resolver half is one `Astro.redirect` reader.
|
||||
|
||||
### 2. Server-rendered frameworks — a redirect is a transition, not just a response
|
||||
|
||||
**Fourteen frameworks** have route nodes and no navigation: Django, Flask,
|
||||
FastAPI, Express, NestJS, Laravel, Drupal, Rails, Spring, Play, Gin/chi/gorilla,
|
||||
Axum/actix/Rocket, ASP.NET, Vapor.
|
||||
**Fifteen frameworks** have route nodes and no navigation: Django, Flask,
|
||||
FastAPI, Express, NestJS, Laravel, CodeIgniter 3, Drupal, Rails, Spring, Play,
|
||||
Gin/chi/gorilla, Axum/actix/Rocket, ASP.NET, Vapor.
|
||||
|
||||
Be precise about what is missing. `redirect_to`, `HttpResponseRedirect`,
|
||||
`res.redirect`, PHP's `redirect()` are **already recognised as `response`
|
||||
@@ -129,6 +129,7 @@ gets no Screens picture at all today:
|
||||
| Rails | `redirect_to :dashboard`, `redirect_to users_path` | destinations are named helpers (`*_path`/`*_url`) generated from `routes.rb`, not literals |
|
||||
| Django | `redirect('profile')`, `reverse('profile')` | same — a route *name*, like Vue's `{ name }`, which `vue-router.ts` already shows how to index |
|
||||
| Laravel | `redirect()->route('home')`, `->view()` | route names again |
|
||||
| CodeIgniter 3 | `redirect('users/show')` | a controller path, which `routes.php` or the convention maps to a route |
|
||||
| Spring | `"redirect:/x"`, `RedirectView` | a literal inside a string return value |
|
||||
| ASP.NET | `RedirectToAction("Index", "Home")` | controller + action pair, not a path — needs the route table's reverse mapping |
|
||||
| Flask | `redirect(url_for('profile'))` | nested call; the name is `url_for`'s argument |
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* CodeIgniter 3 Framework Resolver
|
||||
*
|
||||
* A request reaches a controller by convention more often than through
|
||||
* `application/config/routes.php`: every public method of a class in
|
||||
* `application/controllers/` answers at `/<folder>/<class>/<method>`, so a
|
||||
* project's routes are mostly its controllers' methods, and both kinds become
|
||||
* `route` nodes bound to the method that serves them. HMVC keeps the same layout
|
||||
* per module (`modules/<module>/controllers/`), with the module as the first
|
||||
* segment, and `/<module>/<method>` for the controller named after it.
|
||||
*
|
||||
* From there the flow goes through properties nothing declares:
|
||||
* `$this->load->model('users_model')` puts a `Users_model` on the controller,
|
||||
* and `$this->users_model->find($id)`, or `$this->CI->users_model->find($id)` in
|
||||
* a library, calls it. A model is read by its class name whatever the case, as
|
||||
* PHP and the loader read it, so the call links to that class's own method or
|
||||
* one it inherits, and not to whichever `find` a lookup by name meets first.
|
||||
* `$this->load->view('users/list')` links to the view's file, where the calls a
|
||||
* template makes are recorded, so a request can be followed to the end.
|
||||
*/
|
||||
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
|
||||
const ROUTES_FILE = /(?:^|\/)application\/(?:modules\/[^/]+\/)?config\/(?:[^/]+\/)?routes\.php$/;
|
||||
const CONTROLLER_FILE = /^(?:.*?\/)?(?:application\/controllers|modules\/([^/]+)\/controllers)\/(.+)\.php$/;
|
||||
/** Where the loader finds what `$this->load` puts on a property. */
|
||||
const LOADABLE_FILE = /(?:^|\/)(?:models|libraries)\//;
|
||||
/** The module a file of an HMVC project belongs to. */
|
||||
const MODULE_OF = /(?:^|\/)modules\/([^/]+)\//;
|
||||
|
||||
/**
|
||||
* A handler reference: `ci:method:<file>|<method>` from a controller, and
|
||||
* `ci:route:<app root>|<uri>:<word>` from `routes.php`. The word is what a sync
|
||||
* looks a failed reference up by: a name the controller it waits for defines.
|
||||
*/
|
||||
const METHOD_REF = 'ci:method:';
|
||||
const ROUTE_REF = 'ci:route:';
|
||||
|
||||
/** `$this->x_model->find()`, as extraction names it; through `get_instance()` in a library: `$this->CI->x_model`, `$CI->x_model`. */
|
||||
const PROPERTY_CALL = /^(?:this->(?:_?ci->)?|_?ci->)([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/i;
|
||||
|
||||
/** Levels of `extends` a method is looked for through. */
|
||||
const MAX_ANCESTORS = 6;
|
||||
|
||||
const ROUTE_RE = /\$route\s*\[\s*['"]([^'"]+)['"]\s*\](?:\s*\[\s*['"]([A-Za-z]+)['"]\s*\])?\s*=\s*['"]([^'"]*)['"]/g;
|
||||
const CLASS_RE = /^[ \t]*((?:abstract\s+|final\s+)*)class\s+([A-Za-z_]\w*)/gm;
|
||||
const METHOD_RE = /^[ \t]*((?:(?:public|protected|private|static|final|abstract)\s+)*)function\s+&?\s*([A-Za-z_]\w*)\s*\(/gm;
|
||||
const MODEL_ALIAS_RE = /->\s*load\s*->\s*model\s*\(\s*['"]([\w/]+)['"]\s*,\s*['"](\w+)['"]/g;
|
||||
const LIBRARY_ALIAS_RE = /->\s*load\s*->\s*library\s*\(\s*['"]([\w/]+)['"]\s*,[^,()]*,\s*['"](\w+)['"]/g;
|
||||
const VIEW_RE = /->\s*load\s*->\s*view\s*\(\s*['"]([\w./-]+)['"]/;
|
||||
|
||||
interface ClassTable {
|
||||
/** What the table was built from: the context hands out one array until the graph changes. */
|
||||
source: Node[];
|
||||
/** Lower-cased class name → the models and libraries of that name. */
|
||||
loadable: Map<string, Node[]>;
|
||||
/** The lower-cased URI a controller answers at, with no leading slash → its classes, one per app. */
|
||||
controllers: Map<string, Node[]>;
|
||||
/** File → property → the class a load in that file gave that name to. */
|
||||
aliases: Map<string, Map<string, string>>;
|
||||
}
|
||||
|
||||
const tables = new WeakMap<ResolutionContext, ClassTable>();
|
||||
|
||||
function add(map: Map<string, Node[]>, key: string, node: Node): void {
|
||||
const list = map.get(key);
|
||||
if (list) list.push(node);
|
||||
else map.set(key, [node]);
|
||||
}
|
||||
|
||||
function classTable(context: ResolutionContext): ClassTable {
|
||||
const all = context.getNodesByKind('class');
|
||||
const cached = tables.get(context);
|
||||
if (cached && cached.source === all) return cached;
|
||||
const table: ClassTable = { source: all, loadable: new Map(), controllers: new Map(), aliases: new Map() };
|
||||
for (const node of all) {
|
||||
if (node.language !== 'php') continue;
|
||||
const url = controllerUrl(node.filePath);
|
||||
if (url !== null) add(table.controllers, url.slice(1), node);
|
||||
else if (LOADABLE_FILE.test(node.filePath)) add(table.loadable, node.name.toLowerCase(), node);
|
||||
}
|
||||
tables.set(context, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
/** The URL a controller's `index` answers at, or null for a file that isn't one. */
|
||||
function controllerUrl(filePath: string): string | null {
|
||||
const m = CONTROLLER_FILE.exec(filePath);
|
||||
if (!m) return null;
|
||||
const segments = m[2]!.toLowerCase().split('/');
|
||||
const module = m[1]?.toLowerCase();
|
||||
if (!module) return `/${segments.join('/')}`;
|
||||
return segments.length === 1 && segments[0] === module ? `/${module}` : `/${[module, ...segments].join('/')}`;
|
||||
}
|
||||
|
||||
function routeNode(filePath: string, line: number, verb: string, routePath: string, now: number): Node {
|
||||
return {
|
||||
id: `route:${filePath}:${line}:${verb}:${routePath}`,
|
||||
kind: 'route',
|
||||
name: `${verb} ${routePath}`,
|
||||
qualifiedName: `${filePath}::route:${verb}:${routePath}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language: 'php',
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Each public method of the class a controller file is named after, as a route. */
|
||||
function conventionRoutes(filePath: string, baseUrl: string, safe: string): { nodes: Node[]; references: UnresolvedRef[] } {
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const stem = filePath.slice(filePath.lastIndexOf('/') + 1, -'.php'.length).toLowerCase();
|
||||
CLASS_RE.lastIndex = 0;
|
||||
let cls: RegExpExecArray | null;
|
||||
while ((cls = CLASS_RE.exec(safe)) !== null) {
|
||||
if (cls[2]!.toLowerCase() === stem) break;
|
||||
}
|
||||
if (!cls || /abstract/.test(cls[1]!)) return { nodes, references };
|
||||
|
||||
const now = Date.now();
|
||||
const seen = new Set<string>();
|
||||
METHOD_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = METHOD_RE.exec(safe)) !== null) {
|
||||
const name = m[2]!;
|
||||
// A leading underscore is CodeIgniter's own way to keep a method off the web.
|
||||
if (/private|protected/.test(m[1]!) || name.startsWith('_')) continue;
|
||||
const routePath = name.toLowerCase() === 'index' ? baseUrl : `${baseUrl}/${name.toLowerCase()}`;
|
||||
if (seen.has(routePath)) continue;
|
||||
seen.add(routePath);
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const route = routeNode(filePath, line, 'ANY', routePath, now);
|
||||
nodes.push(route);
|
||||
references.push({
|
||||
fromNodeId: route.id,
|
||||
referenceName: `${METHOD_REF}${filePath}|${name}`,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language: 'php',
|
||||
});
|
||||
}
|
||||
return { nodes, references };
|
||||
}
|
||||
|
||||
/** The name the handler's method goes by, or its class's when only that is written. */
|
||||
function awaitedName(handler: string): string {
|
||||
const literal = handler.split('/').filter((segment) => segment && !segment.includes('$'));
|
||||
const last = literal[literal.length - 1] ?? handler;
|
||||
return literal.length > 1 ? last : last.charAt(0).toUpperCase() + last.slice(1);
|
||||
}
|
||||
|
||||
/** `$route['pattern'] = 'controller/method'`, verb-scoped or not, and the default controller at `/`. */
|
||||
function explicitRoutes(filePath: string, safe: string): { nodes: Node[]; references: UnresolvedRef[] } {
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const now = Date.now();
|
||||
const root = filePath.slice(0, filePath.indexOf('application/'));
|
||||
ROUTE_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ROUTE_RE.exec(safe)) !== null) {
|
||||
const [, pattern, verb, handler] = m;
|
||||
if (pattern === '404_override' || pattern === 'translate_uri_dashes' || !handler) continue;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const routePath = pattern === 'default_controller' ? '/' : `/${pattern!.replace(/^\/+/, '')}`;
|
||||
const route = routeNode(filePath, line, (verb ?? 'ANY').toUpperCase(), routePath, now);
|
||||
nodes.push(route);
|
||||
references.push({
|
||||
fromNodeId: route.id,
|
||||
referenceName: `${ROUTE_REF}${root}|${handler}:${awaitedName(handler!)}`,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language: 'php',
|
||||
});
|
||||
}
|
||||
return { nodes, references };
|
||||
}
|
||||
|
||||
/** A method by name on a class, its own or one it inherits. PHP doesn't mind the case. */
|
||||
function methodOf(cls: Node, name: string, context: ResolutionContext): Node | null {
|
||||
const lower = name.toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
let level: Node[] = [cls];
|
||||
for (let depth = 0; depth < MAX_ANCESTORS && level.length > 0; depth++) {
|
||||
const next: Node[] = [];
|
||||
for (const type of level) {
|
||||
if (seen.has(type.id)) continue;
|
||||
seen.add(type.id);
|
||||
const prefix = `${type.qualifiedName}::`.toLowerCase();
|
||||
const own = context.getNodesInFile(type.filePath).find(
|
||||
(n) => n.kind === 'method' && n.name.toLowerCase() === lower && n.qualifiedName.toLowerCase().startsWith(prefix)
|
||||
);
|
||||
if (own) return own;
|
||||
for (const parent of context.getSupertypeNodes?.(type.id) ?? []) {
|
||||
if (parent.kind === 'class') next.push(parent);
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The method the segment after a controller names, `index` when none does, and the class when a variable does. */
|
||||
function handlerIn(cls: Node, segments: readonly string[], at: number, context: ResolutionContext): Node {
|
||||
const segment = segments[at] ?? 'index';
|
||||
return (segment.includes('$') ? null : methodOf(cls, segment, context)) ?? cls;
|
||||
}
|
||||
|
||||
/** What `controller/method/$1` serves: the first run of segments that names a controller, then the method after it. */
|
||||
function routeHandler(root: string, uri: string, context: ResolutionContext): Node | null {
|
||||
const { controllers } = classTable(context);
|
||||
const segments = uri.split('/').filter(Boolean);
|
||||
const lower = segments.map((segment) => segment.toLowerCase());
|
||||
for (let i = lower.length; i > 0; i--) {
|
||||
const classes = controllers.get(lower.slice(0, i).join('/'));
|
||||
if (!classes) continue;
|
||||
const cls = classes.find((c) => c.filePath.startsWith(root)) ?? classes[0]!;
|
||||
return handlerIn(cls, segments, i, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The file the `$this->load->view('users/list')` on this line loads: the caller's module's, another module's, or the app's. */
|
||||
function viewFile(ref: UnresolvedRef, context: ResolutionContext): Node | null {
|
||||
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1];
|
||||
const view = line ? VIEW_RE.exec(line)?.[1] : undefined;
|
||||
if (!view) return null;
|
||||
const file = /\.\w+$/.test(view) ? view : `${view}.php`;
|
||||
const root = ref.filePath.slice(0, Math.max(ref.filePath.indexOf('application/'), 0));
|
||||
const module = MODULE_OF.exec(ref.filePath);
|
||||
const modules = module ? ref.filePath.slice(0, module.index + module[0].length - module[1]!.length - 1) : `${root}application/modules/`;
|
||||
const slash = file.indexOf('/');
|
||||
const candidates = [
|
||||
...(module ? [`${modules}${module[1]}/views/${file}`] : []),
|
||||
...(slash > 0 ? [`${modules}${file.slice(0, slash)}/views/${file.slice(slash + 1)}`] : []),
|
||||
`${root}application/views/${file}`,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const node = context.getNodesInFile(candidate).find((n) => n.kind === 'file');
|
||||
if (node) return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function aliasesIn(file: string, table: ClassTable, context: ResolutionContext): Map<string, string> {
|
||||
const known = table.aliases.get(file);
|
||||
if (known) return known;
|
||||
const aliases = new Map<string, string>();
|
||||
const source = context.readFile(file) ?? '';
|
||||
for (const re of [MODEL_ALIAS_RE, LIBRARY_ALIAS_RE]) {
|
||||
re.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(source)) !== null) aliases.set(m[2]!, m[1]!.split('/').pop()!.toLowerCase());
|
||||
}
|
||||
table.aliases.set(file, aliases);
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/** The class a property holds in `file`: by its own name, or by the alias a load there gave it. */
|
||||
function propertyClass(property: string, file: string, context: ResolutionContext): Node | null {
|
||||
const table = classTable(context);
|
||||
const alias = table.loadable.has(property.toLowerCase()) ? undefined : aliasesIn(file, table, context).get(property);
|
||||
const classes = table.loadable.get(alias ?? property.toLowerCase());
|
||||
if (!classes) return null;
|
||||
if (classes.length === 1) return classes[0]!;
|
||||
// One name in two HMVC modules: the caller's own, and no guess past that.
|
||||
const module = MODULE_OF.exec(file)?.[1];
|
||||
const own = module ? classes.filter((c) => MODULE_OF.exec(c.filePath)?.[1] === module) : [];
|
||||
return own.length === 1 ? own[0]! : null;
|
||||
}
|
||||
|
||||
export const codeigniterResolver: FrameworkResolver = {
|
||||
name: 'codeigniter',
|
||||
languages: ['php'],
|
||||
|
||||
// The handler references are this file's own, and a property call names no
|
||||
// declared symbol, so neither would get past the name pre-filter.
|
||||
claimsReference(name: string): boolean {
|
||||
return name.startsWith('ci:') || PROPERTY_CALL.test(name);
|
||||
},
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
if (context.fileExists('system/core/CodeIgniter.php')) return true;
|
||||
// `system/` is often a Composer package, and `application/` can sit below the
|
||||
// repository root. A CodeIgniter routes file names its default controller.
|
||||
const routes = context.getAllFiles().find((file) => file.endsWith('application/config/routes.php'));
|
||||
return routes !== undefined && /\$route\s*\[\s*['"]default_controller['"]\s*\]/.test(context.readFile(routes) ?? '');
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
const name = ref.referenceName;
|
||||
const resolved = (target: Node | null | undefined, confidence: number): ResolvedRef | null =>
|
||||
target ? { original: ref, targetNodeId: target.id, confidence, resolvedBy: 'framework' } : null;
|
||||
|
||||
if (name.startsWith(METHOD_REF)) {
|
||||
const [file, method] = name.slice(METHOD_REF.length).split('|');
|
||||
return resolved(context.getNodesInFile(file!).find((n) => n.kind === 'method' && n.name === method), 0.95);
|
||||
}
|
||||
if (name.startsWith(ROUTE_REF)) {
|
||||
const [root, handler] = name.slice(ROUTE_REF.length).split('|');
|
||||
return resolved(routeHandler(root!, handler!.slice(0, handler!.lastIndexOf(':')), context), 0.9);
|
||||
}
|
||||
const call = PROPERTY_CALL.exec(name);
|
||||
if (call) {
|
||||
if (call[1] === 'load' && call[2] === 'view') return resolved(viewFile(ref, context), 0.9);
|
||||
const cls = propertyClass(call[1]!, ref.filePath, context);
|
||||
return resolved(cls && methodOf(cls, call[2]!, context), 0.9);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } {
|
||||
const routes = ROUTES_FILE.test(filePath);
|
||||
const baseUrl = routes ? null : controllerUrl(filePath);
|
||||
if (!routes && baseUrl === null) return { nodes: [], references: [] };
|
||||
const safe = stripCommentsForRegex(content, 'php');
|
||||
return routes ? explicitRoutes(filePath, safe) : conventionRoutes(filePath, baseUrl!, safe);
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { FrameworkResolver, ResolutionContext } from '../types';
|
||||
import type { Language } from '../../types';
|
||||
import { codeigniterResolver } from './codeigniter';
|
||||
import { drupalResolver } from './drupal';
|
||||
import { laravelResolver } from './laravel';
|
||||
import { expressResolver } from './express';
|
||||
@@ -44,6 +45,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
|
||||
// PHP
|
||||
laravelResolver,
|
||||
drupalResolver,
|
||||
codeigniterResolver,
|
||||
// JavaScript/TypeScript
|
||||
expressResolver,
|
||||
nestjsResolver,
|
||||
@@ -176,6 +178,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void {
|
||||
}
|
||||
|
||||
// Re-export framework resolvers
|
||||
export { codeigniterResolver } from './codeigniter';
|
||||
export { drupalResolver } from './drupal';
|
||||
export { laravelResolver, FACADE_MAPPINGS } from './laravel';
|
||||
export { expressResolver } from './express';
|
||||
|
||||
Reference in New Issue
Block a user