mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 22:43:17 +08:00
Only a bracketed list (and the scoped reader's bare :sym) was read, so maybe's `resources :family_exports, only: %i[new create index]` drew all seven RESTful routes — four to actions the controller lacks, listed by the viewer as routes bound to nothing. Both route readers now share one option reader for every form Rails accepts. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
20 KiB
TypeScript
501 lines
20 KiB
TypeScript
/**
|
|
* Ruby Framework Resolver
|
|
*
|
|
* Handles Ruby on Rails patterns.
|
|
*/
|
|
|
|
import { Node } from '../../types';
|
|
import { FrameworkResolver, FrameworkExtractionResult, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
|
import { stripCommentsForRegex } from '../strip-comments';
|
|
|
|
/**
|
|
* A `resources` line's `only:` / `except:` action list, written any way Rails
|
|
* accepts it: `[:index, :show]`, `:show`, `%i[new create index]`, `%w(index
|
|
* show)`, `"show"`, or the older `:only => [...]`. Null when the option is
|
|
* absent. maybe's `only: %i[new create index]` was not read, so every
|
|
* resource drew all seven routes, four of them to actions that do not exist.
|
|
*/
|
|
function railsActionOption(tail: string, key: 'only' | 'except'): Set<string> | null {
|
|
const m = new RegExp(
|
|
String.raw`(?:\b${key}:|:${key}\s*=>)\s*(?:\[([^\]]*)\]|%[iIwW]\s*[\[(]([^\])]*)[\])]|:(\w+)|["'](\w+)["'])`
|
|
).exec(tail);
|
|
if (!m) return null;
|
|
if (m[1] !== undefined) {
|
|
return new Set(m[1].split(',').map((v) => v.trim().replace(/^:/, '').replace(/^["']|["']$/g, '')).filter(Boolean));
|
|
}
|
|
if (m[2] !== undefined) return new Set(m[2].trim().split(/\s+/).filter(Boolean));
|
|
return new Set([m[3] ?? m[4]!]);
|
|
}
|
|
|
|
export const railsResolver: FrameworkResolver = {
|
|
name: 'rails',
|
|
languages: ['ruby'],
|
|
|
|
// `controller#action` route refs name no declared symbol, so resolveOne's
|
|
// pre-filter would drop them before resolve() runs. Claim them (like the django
|
|
// `_iterable_class` hook) so they reach Pattern 0.
|
|
claimsReference(name: string): boolean {
|
|
return /^[\w/]+#\w+$/.test(name);
|
|
},
|
|
|
|
detect(context: ResolutionContext): boolean {
|
|
// Check for Gemfile with rails
|
|
const gemfile = context.readFile('Gemfile');
|
|
if (gemfile && /\bgem\s+["'](?:rails|railties)["']/.test(gemfile)) {
|
|
return true;
|
|
}
|
|
|
|
// Check for config/application.rb (Rails signature)
|
|
if (context.fileExists('config/application.rb')) {
|
|
return true;
|
|
}
|
|
|
|
// 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.getAllFiles().some((f) => /(?:^|\/)config\/routes\.rb$/.test(f))
|
|
);
|
|
},
|
|
|
|
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
|
// Pattern 0: route action `controller#action` (from RESTful `resources` or an
|
|
// explicit route) → the action method in that controller. Precise — avoids the
|
|
// bare-`action` ambiguity (every controller has an `index`/`show`).
|
|
const ca = ref.referenceName.match(/^([\w/]+)#(\w+)$/);
|
|
if (ca) {
|
|
const result = resolveControllerAction(ca[1]!, ca[2]!, context);
|
|
if (result) {
|
|
return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework' };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Pattern 1: Model references (ActiveRecord)
|
|
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
|
|
const result = resolveModel(ref.referenceName, context);
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.8,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 2: Controller references
|
|
if (ref.referenceName.endsWith('Controller')) {
|
|
const result = resolveController(ref.referenceName, context);
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.85,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 3: Helper references
|
|
if (ref.referenceName.endsWith('Helper')) {
|
|
const result = resolveHelper(ref.referenceName, context);
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.8,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 4: Service/Job references
|
|
if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Job')) {
|
|
const result = resolveService(ref.referenceName, context);
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.8,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
extract(filePath, content) {
|
|
if (!filePath.endsWith('.rb')) return { nodes: [], references: [] };
|
|
const nodes: Node[] = [];
|
|
const references: UnresolvedRef[] = [];
|
|
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;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = routeRegex.exec(safe)) !== null) {
|
|
const [, method, routePath, ctrl, action] = match;
|
|
const line = safe.slice(0, match.index).split('\n').length;
|
|
const upper = method!.toUpperCase();
|
|
const routeNode: Node = {
|
|
id: `route:${filePath}:${line}:${upper}:${routePath}`,
|
|
kind: 'route',
|
|
name: `${upper} ${routePath}`,
|
|
qualifiedName: `${filePath}::route:${routePath}`,
|
|
filePath,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: 0,
|
|
endColumn: match[0].length,
|
|
language: 'ruby',
|
|
updatedAt: now,
|
|
};
|
|
nodes.push(routeNode);
|
|
|
|
references.push({
|
|
fromNodeId: routeNode.id,
|
|
referenceName: `${ctrl}#${action}`, // precise controller#action, not bare action
|
|
referenceKind: 'references',
|
|
line,
|
|
column: 0,
|
|
filePath,
|
|
language: 'ruby',
|
|
});
|
|
}
|
|
|
|
// RESTful resources: `resources :articles` / `resource :user` (the dominant
|
|
// Rails routing) generate a controller action per REST verb. The old resolver
|
|
// only saw explicit `get '/x' => 'c#a'` routes, so resource-routed apps had
|
|
// ZERO route nodes. Expand each into its actions → `controller#action` refs.
|
|
const resRegex = /\b(resources?)\s+:(\w+)([^\n]*)/g;
|
|
while ((match = resRegex.exec(safe)) !== null) {
|
|
const plural = match[1] === 'resources';
|
|
const resName = match[2]!;
|
|
const tail = match[3] || '';
|
|
let actions = plural ? PLURAL_ACTIONS : SINGULAR_ACTIONS;
|
|
const only = railsActionOption(tail, 'only');
|
|
const except = railsActionOption(tail, 'except');
|
|
if (only) actions = actions.filter((a) => only.has(a));
|
|
else if (except) actions = actions.filter((a) => !except.has(a));
|
|
// `resources :articles` → ArticlesController; `resource :user` → UsersController.
|
|
const ctrl = plural ? resName : pluralize(resName);
|
|
const line = safe.slice(0, match.index).split('\n').length;
|
|
for (const action of actions) {
|
|
const spec = RESTFUL_ROUTES[action]!;
|
|
const path = spec.path(resName);
|
|
const routeNode: Node = {
|
|
id: `route:${filePath}:${line}:${spec.method}:${ctrl}#${action}`,
|
|
kind: 'route',
|
|
name: `${spec.method} ${path}`,
|
|
qualifiedName: `${filePath}::route:${ctrl}#${action}`,
|
|
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length,
|
|
language: 'ruby', updatedAt: now,
|
|
};
|
|
nodes.push(routeNode);
|
|
references.push({
|
|
fromNodeId: routeNode.id,
|
|
referenceName: `${ctrl}#${action}`,
|
|
referenceKind: 'references',
|
|
line, column: 0, filePath, language: 'ruby',
|
|
});
|
|
}
|
|
}
|
|
|
|
return { nodes, references };
|
|
},
|
|
};
|
|
|
|
// 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<string>();
|
|
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 = railsActionOption(tail, 'only');
|
|
const except = railsActionOption(tail, 'except');
|
|
if (only) actions = actions.filter((a) => only.has(a));
|
|
else if (except) actions = actions.filter((a) => !except.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, { method: string; path: (r: string) => string }> = {
|
|
index: { method: 'GET', path: (r) => `/${r}` },
|
|
create: { method: 'POST', path: (r) => `/${r}` },
|
|
new: { method: 'GET', path: (r) => `/${r}/new` },
|
|
show: { method: 'GET', path: (r) => `/${r}/:id` },
|
|
edit: { method: 'GET', path: (r) => `/${r}/:id/edit` },
|
|
update: { method: 'PATCH', path: (r) => `/${r}/:id` },
|
|
destroy: { method: 'DELETE', path: (r) => `/${r}/:id` },
|
|
};
|
|
const PLURAL_ACTIONS = ['index', 'create', 'new', 'show', 'edit', 'update', 'destroy'];
|
|
const SINGULAR_ACTIONS = ['create', 'new', 'show', 'edit', 'update', 'destroy'];
|
|
|
|
/** Naive ActiveSupport-style pluralize — covers the common resource names. */
|
|
function pluralize(w: string): string {
|
|
if (/[^aeiou]y$/.test(w)) return w.slice(0, -1) + 'ies';
|
|
if (/(s|x|z|ch|sh)$/.test(w)) return w + 'es';
|
|
return w + 's';
|
|
}
|
|
|
|
/** snake_case → CamelCase (`user_profiles` → `UserProfiles`). */
|
|
function camelize(s: string): string {
|
|
return s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
}
|
|
|
|
/** Resolve a `controller#action` route ref to the action method in that controller. */
|
|
function resolveControllerAction(ctrlPath: string, action: string, context: ResolutionContext): string | null {
|
|
// Rails convention: `articles` → app/controllers/articles_controller.rb.
|
|
const direct = `app/controllers/${ctrlPath}_controller.rb`;
|
|
if (context.fileExists(direct)) {
|
|
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 —
|
|
// 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';
|
|
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;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveModel(name: string, context: ResolutionContext): string | null {
|
|
// Try direct file path lookup first (Rails convention: CamelCase -> snake_case.rb)
|
|
const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
|
|
const possiblePaths = [
|
|
`app/models/${snakeName}.rb`,
|
|
`app/models/concerns/${snakeName}.rb`,
|
|
];
|
|
|
|
for (const modelPath of possiblePaths) {
|
|
if (context.fileExists(modelPath)) {
|
|
const nodes = context.getNodesInFile(modelPath);
|
|
const modelNode = nodes.find(
|
|
(n) => n.kind === 'class' && n.name === name
|
|
);
|
|
if (modelNode) {
|
|
return modelNode.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to name-based lookup
|
|
const candidates = context.getNodesByName(name);
|
|
const modelNode = candidates.find(
|
|
(n) => n.kind === 'class' && n.filePath.includes('app/models/')
|
|
);
|
|
if (modelNode) return modelNode.id;
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveController(name: string, context: ResolutionContext): string | null {
|
|
// Try direct file path lookup first
|
|
const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
|
|
const possiblePaths = [
|
|
`app/controllers/${snakeName}.rb`,
|
|
`app/controllers/api/${snakeName}.rb`,
|
|
`app/controllers/api/v1/${snakeName}.rb`,
|
|
];
|
|
|
|
for (const controllerPath of possiblePaths) {
|
|
if (context.fileExists(controllerPath)) {
|
|
const nodes = context.getNodesInFile(controllerPath);
|
|
const controllerNode = nodes.find(
|
|
(n) => n.kind === 'class' && n.name === name
|
|
);
|
|
if (controllerNode) {
|
|
return controllerNode.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to name-based lookup
|
|
const candidates = context.getNodesByName(name);
|
|
const controllerNode = candidates.find(
|
|
(n) => n.kind === 'class' && n.filePath.includes('controllers/')
|
|
);
|
|
if (controllerNode) return controllerNode.id;
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveHelper(name: string, context: ResolutionContext): string | null {
|
|
const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
|
|
const helperPath = `app/helpers/${snakeName}.rb`;
|
|
|
|
if (context.fileExists(helperPath)) {
|
|
const nodes = context.getNodesInFile(helperPath);
|
|
const helperNode = nodes.find(
|
|
(n) => n.kind === 'module' && n.name === name
|
|
);
|
|
if (helperNode) {
|
|
return helperNode.id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveService(name: string, context: ResolutionContext): string | null {
|
|
const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
|
|
const possiblePaths = [
|
|
`app/services/${snakeName}.rb`,
|
|
`app/jobs/${snakeName}.rb`,
|
|
`app/workers/${snakeName}.rb`,
|
|
];
|
|
|
|
for (const servicePath of possiblePaths) {
|
|
if (context.fileExists(servicePath)) {
|
|
const nodes = context.getNodesInFile(servicePath);
|
|
const serviceNode = nodes.find(
|
|
(n) => n.kind === 'class' && n.name === name
|
|
);
|
|
if (serviceNode) {
|
|
return serviceNode.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|