diff --git a/AGENTS.md b/AGENTS.md index 1b13c4511..a9658fa53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` — 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 `` — 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0c603b5..524b51f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0c10d51cb..47ef4a9aa 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/__tests__/codeigniter.test.ts b/__tests__/codeigniter.test.ts new file mode 100644 index 000000000..bf6d2a933 --- /dev/null +++ b/__tests__/codeigniter.test.ts @@ -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 = ` { + 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', ' { + expect(routeNames('application/controllers/Blog.php', ' { + const file = 'application/config/routes.php'; + + it('reads a route, a verb-scoped one, and the default controller at /', () => { + expect(routeNames(file, ` { + expect(routeNames(file, ` { + expect(routeNames('application/modules/shop/config/routes.php', " { + const context = (files: Record): 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': " { + expect(codeigniterResolver.detect(context({ 'application/config/routes.php': " 'index'];\n" }))).toBe(false); + expect(codeigniterResolver.detect(context({ 'app/Config/Routes.php': "get('/', 'Home::index');\n" }))).toBe(false); + expect(codeigniterResolver.detect(context({ 'routes/web.php': ' { + 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', `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', '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', "mailer->welcome('x'); ?>\n"); + write('application/modules/shop/controllers/Items.php', `mdl_items->price(1); } +} +`); + write('application/modules/shop/models/Mdl_items.php', 'load->view(\'overview\');\n }\n}\n'); + write('application/modules/dashboard/views/overview.php', '

overview

\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', "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', '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 | diff --git a/src/resolution/frameworks/codeigniter.ts b/src/resolution/frameworks/codeigniter.ts new file mode 100644 index 000000000..0ee8587c8 --- /dev/null +++ b/src/resolution/frameworks/codeigniter.ts @@ -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 `///`, 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//controllers/`), with the module as the first + * segment, and `//` 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:|` from a controller, and + * `ci:route:|:` 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; + /** The lower-cased URI a controller answers at, with no leading slash → its classes, one per app. */ + controllers: Map; + /** File → property → the class a load in that file gave that name to. */ + aliases: Map>; +} + +const tables = new WeakMap(); + +function add(map: Map, 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(); + 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(); + 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 { + const known = table.aliases.get(file); + if (known) return known; + const aliases = new Map(); + 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); + }, +}; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index da8f7b0ec..ae9df9e13 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -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';