diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a00065..846de717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Rails routes are now read with their nesting: `namespace` and `scope` add their path and controller module, nested `resources` sit under their parent's `:id`, and `member` / `collection` blocks add their actions. A namespaced route now links to its own module's controller. Routes in a Rails engine's `config/routes.rb` are found too: solidus went from no routes to over 600, and mastodon's route-to-action links nearly tripled. - Flask routes registered with `add_url_rule(…)` are now found and linked to their view function or class-based view. So are routes registered through a project's own helper that passes a list of paths and a `view_func=`. flaskbb, which registers every view that way, went from no routes to over 100. - In Lua and Luau, a call through a standard or host library table (`string`, `table`, `io`, `vim`, `ngx`, busted's `assert`…) or a string method like `line:find(…)` no longer lands on the one project method with the same name. Functions the project itself defines on those tables still link. On kong, the specs' `assert.truthy(…)` and `assert.falsy(…)` used to land on a condition helper over 1,700 times; on telescope, `line:find(…)` on the picker's `find`. - In PHP, a call through a variable nothing types no longer lands on an unrelated class's method just because it's the only method with that name. The variable now needs to be named after the method's class, or after a class that inherits it (`$page->save()` where `Page` extends `Entity`). On guzzle, the tests' `$response->getHeaderLine()` used to land on a test double; on symfony/console, `$e->getMessage()` on a progress bar. diff --git a/__tests__/rails-scoped-routes.test.ts b/__tests__/rails-scoped-routes.test.ts new file mode 100644 index 00000000..29df4eab --- /dev/null +++ b/__tests__/rails-scoped-routes.test.ts @@ -0,0 +1,113 @@ +/** + * A Rails routes file is read with its blocks: `namespace :admin` prefixes + * paths and controllers, `scope` its path and module, a `resources … do` + * block nests children under `/:parent_id`, and `member` / `collection` + * blocks add actions. A Rails engine's routes (`Spree::Core::Engine.routes.draw` + * in `backend/config/routes.rb`) count too — solidus had no routes at all — + * and a namespaced route reaches its own module's controller. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rails-scoped-')); + const files: Record = { + 'Gemfile': 'source "https://rubygems.org"\ngem "rails", "~> 7.1"\n', + 'backend/config/routes.rb': `Spree::Core::Engine.routes.draw do + namespace :admin do + get "/search/users", to: "search#users", as: :search_users + resources :zones, only: [ + :index, + :show + ] + resources :products do + resources :images + member do + get :preview + end + collection do + post :update_positions + end + end + end + scope "/v2", module: "api" do + resource :account, only: [:show] + end + root to: "home#index" +end +`, + 'backend/app/controllers/spree/admin/zones_controller.rb': `module Spree + module Admin + class ZonesController < ResourceController + def show; end + end + end +end +`, + 'api/app/controllers/spree/api/zones_controller.rb': `module Spree + module Api + class ZonesController < BaseController + def index; end + def show; end + end + end +end +`, + 'backend/app/controllers/spree/admin/products_controller.rb': `module Spree + module Admin + class ProductsController < ResourceController + def preview; end + def update_positions; end + end + end +end +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const handlerOf = (name: string): string | undefined => { + const route = cg.getNodesByKind('route').find((r) => r.name === name); + if (!route) return undefined; + const target = cg.getOutgoingEdges(route.id).find((e) => e.kind === 'references'); + return target ? cg.getNode(target.target)!.filePath : ''; +}; + +describe('Rails routes with their nesting', () => { + it('prefixes namespaces and scopes, nests resources, adds member and collection actions', () => { + const names = cg.getNodesByKind('route').map((r) => r.name).sort(); + expect(names).toEqual(expect.arrayContaining([ + 'GET /admin/search/users', + 'GET /admin/zones', 'GET /admin/zones/:id', + 'GET /admin/products/:product_id/images', + 'GET /admin/products/:id/preview', + 'POST /admin/products/update_positions', + 'GET /v2/account', + 'GET /', + ])); + // `only:` written across lines still limits the actions. + expect(names).not.toContain('DELETE /admin/zones/:id'); + }); + + it('reaches the controller of the route’s own module, not another module’s namesake', () => { + expect(handlerOf('GET /admin/zones/:id')).toBe('backend/app/controllers/spree/admin/zones_controller.rb'); + // The admin controller inherits `index`; the API's `index` is not it. + expect(handlerOf('GET /admin/zones')).toBe(''); + expect(handlerOf('GET /admin/products/:id/preview')).toBe('backend/app/controllers/spree/admin/products_controller.rb'); + }); +}); diff --git a/src/resolution/frameworks/ruby.ts b/src/resolution/frameworks/ruby.ts index 9c9e5a68..85460393 100644 --- a/src/resolution/frameworks/ruby.ts +++ b/src/resolution/frameworks/ruby.ts @@ -5,7 +5,7 @@ */ import { Node } from '../../types'; -import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { FrameworkResolver, FrameworkExtractionResult, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; export const railsResolver: FrameworkResolver = { @@ -22,7 +22,7 @@ export const railsResolver: FrameworkResolver = { detect(context: ResolutionContext): boolean { // Check for Gemfile with rails const gemfile = context.readFile('Gemfile'); - if (gemfile && gemfile.includes("'rails'")) { + if (gemfile && /\bgem\s+["'](?:rails|railties)["']/.test(gemfile)) { return true; } @@ -31,10 +31,12 @@ export const railsResolver: FrameworkResolver = { return true; } - // Check for typical Rails directory structure + // Check for typical Rails directory structure — or a Rails engine's routes + // file anywhere (solidus keeps each engine's under `backend/config/`). return ( context.fileExists('app/controllers/application_controller.rb') || - context.fileExists('config/routes.rb') + context.fileExists('config/routes.rb') || + context.getAllFiles().some((f) => /(?:^|\/)config\/routes\.rb$/.test(f)) ); }, @@ -113,6 +115,12 @@ export const railsResolver: FrameworkResolver = { const now = Date.now(); const safe = stripCommentsForRegex(content, 'ruby'); + // A routes file is read with its blocks: `namespace`, `scope`, nested + // `resources … do`, `member` / `collection`. + if (/\broutes\.draw\b/.test(safe) || /(?:^|\/)config\/routes\//.test(filePath)) { + return extractScopedRailsRoutes(filePath, safe); + } + // get/post/put/patch/delete/match '/path', to: 'controller#action' // Also: get '/path' => 'controller#action' const routeRegex = /\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]/g; @@ -192,6 +200,136 @@ export const railsResolver: FrameworkResolver = { // Helper functions +interface RailsFrame { + kind: 'namespace' | 'scope' | 'resource' | 'member' | 'collection' | 'block'; + /** Path prefix routes written inside this frame get. */ + path: string; + /** Controller module prefix (`admin/`). */ + module: string; + /** For a resource: its controller, collection path and member path. */ + controller?: string; + collectionPath?: string; + memberPath?: string; +} + +/** + * A Rails routes file with its nesting: `namespace :admin` prefixes paths and + * controllers (`/admin/zones` → `admin/zones#index`), `scope` its path and + * module, a `resources :products do` block nests its children under + * `/products/:product_id`, and `member` / `collection` blocks add + * `/products/:id/preview` / `/products/search` actions. Each `do`, and each + * `if` / `unless` / `case` / `begin` line, opens a frame an `end` closes. + */ +function extractScopedRailsRoutes(filePath: string, safe: string): FrameworkExtractionResult { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + const seen = new Set(); + const stack: RailsFrame[] = [{ kind: 'block', path: '', module: '' }]; + const top = () => stack[stack.length - 1]!; + const join = (...parts: string[]) => ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/(.)\/$/, '$1'); + const sym = (s: string) => s.replace(/^:/, '').replace(/^["']|["']$/g, ''); + const option = (tail: string, key: string) => new RegExp(`\\b${key}:\\s*(:\\w+|["'][^"']*["'])`).exec(tail)?.[1]; + const emit = (method: string, path: string, target: string, line: number) => { + const id = `route:${filePath}:${line}:${method}:${path}:${target}`; + if (seen.has(id)) return; + seen.add(id); + nodes.push({ + id, kind: 'route', name: `${method} ${path}`, qualifiedName: `${filePath}::route:${target}`, + filePath, startLine: line, endLine: line, startColumn: 0, endColumn: 0, language: 'ruby', updatedAt: now, + }); + references.push({ fromNodeId: id, referenceName: target, referenceKind: 'references', line, column: 0, filePath, language: 'ruby' }); + }; + const lines = safe.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = i + 1; + let text = lines[i]!.trim(); + if (!text) continue; + // A statement whose `[` / `(` continues on the next lines (`only: [\n :a,\n :b ]`). + const open = (t: string) => (t.match(/[[(]/g)?.length ?? 0) - (t.match(/[\])]/g)?.length ?? 0); + while (open(text) > 0 && i + 1 < lines.length && !/\bdo\s*(?:\|[^|]*\|)?\s*$/.test(text)) text += ' ' + lines[++i]!.trim(); + if (/^end\b/.test(text)) { + if (stack.length > 1) stack.pop(); + continue; + } + const opens = /\bdo\s*(?:\|[^|]*\|)?\s*$/.test(text); + const frame = top(); + let pushed: RailsFrame | null = null; + let m: RegExpExecArray | null; + if ((m = /^namespace\s+(:\w+|["'][\w/-]+["'])(.*)$/.exec(text))) { + const name = sym(m[1]!); + const custom = option(m[2]!, 'path'); + pushed = { kind: 'namespace', path: join(frame.path, custom !== undefined ? sym(custom) : name), module: `${frame.module}${name}/` }; + } else if ((m = /^scope\b\s*\(?\s*(.*?)\)?\s*(?:do\b.*)?$/.exec(text)) && opens) { + const args = m[1]!; + const lead = /^(["'])([^"']*)\1/.exec(args)?.[2]; + const pathOpt = option(args, 'path'); + const moduleOpt = option(args, 'module'); + const path = lead ?? (pathOpt ? sym(pathOpt) : ''); + pushed = { kind: 'scope', path: join(frame.path, path), module: moduleOpt ? `${frame.module}${sym(moduleOpt)}/` : frame.module }; + } else if ((m = /^(resources?)\s+:(\w+)(.*)$/.exec(text))) { + const plural = m[1] === 'resources'; + const name = m[2]!; + const tail = m[3]!; + const segment = option(tail, 'path') ? sym(option(tail, 'path')!) : name; + const controller = frame.module + (option(tail, 'controller') ? sym(option(tail, 'controller')!) : plural ? name : pluralize(name)); + const collectionPath = join(frame.path, segment); + const memberPath = plural ? join(collectionPath, ':id') : collectionPath; + let actions = plural ? PLURAL_ACTIONS : SINGULAR_ACTIONS; + const only = /only:\s*(?:\[([^\]]*)\]|(:\w+))/.exec(tail); + const except = /except:\s*(?:\[([^\]]*)\]|(:\w+))/.exec(tail); + const symList = (x: RegExpExecArray) => new Set((x[1] ?? x[2] ?? '').split(',').map((v) => sym(v.trim()))); + if (only) { const keep = symList(only); actions = actions.filter((a) => keep.has(a)); } + else if (except) { const drop = symList(except); actions = actions.filter((a) => !drop.has(a)); } + for (const action of actions) { + const spec = RESTFUL_ROUTES[action]!; + const path = action === 'index' || action === 'create' ? collectionPath + : action === 'new' ? join(collectionPath, 'new') + : action === 'edit' ? join(memberPath, 'edit') : memberPath; + emit(spec.method, path, `${controller}#${action}`, line); + } + if (opens) { + const nested = plural ? join(collectionPath, `:${singularize(name)}_id`) : collectionPath; + pushed = { kind: 'resource', path: nested, module: frame.module, controller, collectionPath, memberPath }; + } + } else if (/^member\b/.test(text) && opens) { + pushed = { ...frame, kind: 'member' }; + } else if (/^collection\b/.test(text) && opens) { + pushed = { ...frame, kind: 'collection' }; + } else if ((m = /^root\s*\(?\s*(?:to:\s*)?["']([\w/]+)#(\w+)["']/.exec(text))) { + emit('GET', join(frame.path) || '/', `${frame.module}${m[1]}#${m[2]}`, line); + } else if ((m = /^(get|post|put|patch|delete|match)\s*\(?\s*(:\w+|["'][^"']*["'])(.*)$/.exec(text))) { + const method = m[1]!.toUpperCase() === 'MATCH' ? 'ANY' : m[1]!.toUpperCase(); + const raw = sym(m[2]!); + const tail = m[3]!; + const to = /(?:\bto:\s*|=>\s*)["']([\w/]+)#(\w+)["']/.exec(tail); + const resource = stack.slice().reverse().find((f) => f.kind === 'resource' || f.kind === 'member' || f.kind === 'collection'); + if (to) { + const base = frame.kind === 'member' ? frame.memberPath! : frame.kind === 'collection' ? frame.collectionPath! : frame.path; + emit(method, join(base, raw), `${frame.module}${to[1]}#${to[2]}`, line); + } else if (resource?.controller && /^[\w-]+$/.test(raw)) { + const action = sym(option(tail, 'action') ?? raw).replace(/-/g, '_'); + const base = frame.kind === 'collection' ? resource.collectionPath! : resource.memberPath!; + emit(method, join(base, raw), `${resource.controller}#${action}`, line); + } + } + if (pushed) { + if (opens) stack.push(pushed); + } else if (opens || /^(?:if|unless|case|begin|while|until)\b/.test(text)) { + stack.push({ ...frame, kind: 'block' }); + } + } + return { nodes, references }; +} + +/** Naive singularize for a resource's nested `:x_id` segment. */ +function singularize(w: string): string { + if (/ies$/.test(w)) return w.slice(0, -3) + 'y'; + if (/(ss|us)$/.test(w)) return w; + if (/(x|ch|sh|ses)$/.test(w) && w.endsWith('es')) return w.slice(0, -2); + return w.replace(/s$/, ''); +} + // RESTful action → HTTP verb + path. `resources` gets all seven; a singular // `resource` omits `index`. const RESTFUL_ROUTES: Record string }> = { @@ -226,9 +364,17 @@ function resolveControllerAction(ctrlPath: string, action: string, context: Reso const m = context.getNodesInFile(direct).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action); if (m) return m.id; } - // Fall back: controller class by name, then the action method in its file. + // Fall back: controller class by name, then the action method in its file — + // only the ones whose path ends with the route's module path when there are + // any (an engine's `spree/admin/zones_controller.rb` for `admin/zones`). const cls = camelize(ctrlPath.split('/').pop()!) + 'Controller'; - for (const ctrl of context.getNodesByName(cls).filter((n) => n.kind === 'class')) { + const suffix = `/${ctrlPath}_controller.rb`; + const all = context.getNodesByName(cls).filter((n) => n.kind === 'class'); + // A controller of the route's own module that inherits the action is not + // another module's same-named controller that defines it. + const own = ctrlPath.includes('/') ? all.filter((n) => ('/' + n.filePath).endsWith(suffix)) : []; + const classes = own.length > 0 ? own : all; + for (const ctrl of classes) { const m = context.getNodesInFile(ctrl.filePath).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action); if (m) return m.id; }