ruby.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. /**
  2. * Ruby Framework Resolver
  3. *
  4. * Handles Ruby on Rails patterns.
  5. */
  6. import { Node } from '../../types';
  7. import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
  8. import { stripCommentsForRegex } from '../strip-comments';
  9. export const railsResolver: FrameworkResolver = {
  10. name: 'rails',
  11. languages: ['ruby'],
  12. // `controller#action` route refs name no declared symbol, so resolveOne's
  13. // pre-filter would drop them before resolve() runs. Claim them (like the django
  14. // `_iterable_class` hook) so they reach Pattern 0.
  15. claimsReference(name: string): boolean {
  16. return /^[\w/]+#\w+$/.test(name);
  17. },
  18. detect(context: ResolutionContext): boolean {
  19. // Check for Gemfile with rails
  20. const gemfile = context.readFile('Gemfile');
  21. if (gemfile && gemfile.includes("'rails'")) {
  22. return true;
  23. }
  24. // Check for config/application.rb (Rails signature)
  25. if (context.fileExists('config/application.rb')) {
  26. return true;
  27. }
  28. // Check for typical Rails directory structure
  29. return (
  30. context.fileExists('app/controllers/application_controller.rb') ||
  31. context.fileExists('config/routes.rb')
  32. );
  33. },
  34. resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
  35. // Pattern 0: route action `controller#action` (from RESTful `resources` or an
  36. // explicit route) → the action method in that controller. Precise — avoids the
  37. // bare-`action` ambiguity (every controller has an `index`/`show`).
  38. const ca = ref.referenceName.match(/^([\w/]+)#(\w+)$/);
  39. if (ca) {
  40. const result = resolveControllerAction(ca[1]!, ca[2]!, context);
  41. if (result) {
  42. return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework' };
  43. }
  44. return null;
  45. }
  46. // Pattern 1: Model references (ActiveRecord)
  47. if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
  48. const result = resolveModel(ref.referenceName, context);
  49. if (result) {
  50. return {
  51. original: ref,
  52. targetNodeId: result,
  53. confidence: 0.8,
  54. resolvedBy: 'framework',
  55. };
  56. }
  57. }
  58. // Pattern 2: Controller references
  59. if (ref.referenceName.endsWith('Controller')) {
  60. const result = resolveController(ref.referenceName, context);
  61. if (result) {
  62. return {
  63. original: ref,
  64. targetNodeId: result,
  65. confidence: 0.85,
  66. resolvedBy: 'framework',
  67. };
  68. }
  69. }
  70. // Pattern 3: Helper references
  71. if (ref.referenceName.endsWith('Helper')) {
  72. const result = resolveHelper(ref.referenceName, context);
  73. if (result) {
  74. return {
  75. original: ref,
  76. targetNodeId: result,
  77. confidence: 0.8,
  78. resolvedBy: 'framework',
  79. };
  80. }
  81. }
  82. // Pattern 4: Service/Job references
  83. if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Job')) {
  84. const result = resolveService(ref.referenceName, context);
  85. if (result) {
  86. return {
  87. original: ref,
  88. targetNodeId: result,
  89. confidence: 0.8,
  90. resolvedBy: 'framework',
  91. };
  92. }
  93. }
  94. return null;
  95. },
  96. extract(filePath, content) {
  97. if (!filePath.endsWith('.rb')) return { nodes: [], references: [] };
  98. const nodes: Node[] = [];
  99. const references: UnresolvedRef[] = [];
  100. const now = Date.now();
  101. const safe = stripCommentsForRegex(content, 'ruby');
  102. // get/post/put/patch/delete/match '/path', to: 'controller#action'
  103. // Also: get '/path' => 'controller#action'
  104. const routeRegex = /\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]/g;
  105. let match: RegExpExecArray | null;
  106. while ((match = routeRegex.exec(safe)) !== null) {
  107. const [, method, routePath, ctrl, action] = match;
  108. const line = safe.slice(0, match.index).split('\n').length;
  109. const upper = method!.toUpperCase();
  110. const routeNode: Node = {
  111. id: `route:${filePath}:${line}:${upper}:${routePath}`,
  112. kind: 'route',
  113. name: `${upper} ${routePath}`,
  114. qualifiedName: `${filePath}::route:${routePath}`,
  115. filePath,
  116. startLine: line,
  117. endLine: line,
  118. startColumn: 0,
  119. endColumn: match[0].length,
  120. language: 'ruby',
  121. updatedAt: now,
  122. };
  123. nodes.push(routeNode);
  124. references.push({
  125. fromNodeId: routeNode.id,
  126. referenceName: `${ctrl}#${action}`, // precise controller#action, not bare action
  127. referenceKind: 'references',
  128. line,
  129. column: 0,
  130. filePath,
  131. language: 'ruby',
  132. });
  133. }
  134. // RESTful resources: `resources :articles` / `resource :user` (the dominant
  135. // Rails routing) generate a controller action per REST verb. The old resolver
  136. // only saw explicit `get '/x' => 'c#a'` routes, so resource-routed apps had
  137. // ZERO route nodes. Expand each into its actions → `controller#action` refs.
  138. const resRegex = /\b(resources?)\s+:(\w+)([^\n]*)/g;
  139. while ((match = resRegex.exec(safe)) !== null) {
  140. const plural = match[1] === 'resources';
  141. const resName = match[2]!;
  142. const tail = match[3] || '';
  143. let actions = plural ? PLURAL_ACTIONS : SINGULAR_ACTIONS;
  144. const only = tail.match(/only:\s*\[([^\]]*)\]/);
  145. const except = tail.match(/except:\s*\[([^\]]*)\]/);
  146. const symList = (s: string) => new Set(s.split(',').map((x) => x.trim().replace(/^:/, '')));
  147. if (only) { const s = symList(only[1]!); actions = actions.filter((a) => s.has(a)); }
  148. else if (except) { const s = symList(except[1]!); actions = actions.filter((a) => !s.has(a)); }
  149. // `resources :articles` → ArticlesController; `resource :user` → UsersController.
  150. const ctrl = plural ? resName : pluralize(resName);
  151. const line = safe.slice(0, match.index).split('\n').length;
  152. for (const action of actions) {
  153. const spec = RESTFUL_ROUTES[action]!;
  154. const path = spec.path(resName);
  155. const routeNode: Node = {
  156. id: `route:${filePath}:${line}:${spec.method}:${ctrl}#${action}`,
  157. kind: 'route',
  158. name: `${spec.method} ${path}`,
  159. qualifiedName: `${filePath}::route:${ctrl}#${action}`,
  160. filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length,
  161. language: 'ruby', updatedAt: now,
  162. };
  163. nodes.push(routeNode);
  164. references.push({
  165. fromNodeId: routeNode.id,
  166. referenceName: `${ctrl}#${action}`,
  167. referenceKind: 'references',
  168. line, column: 0, filePath, language: 'ruby',
  169. });
  170. }
  171. }
  172. return { nodes, references };
  173. },
  174. };
  175. // Helper functions
  176. // RESTful action → HTTP verb + path. `resources` gets all seven; a singular
  177. // `resource` omits `index`.
  178. const RESTFUL_ROUTES: Record<string, { method: string; path: (r: string) => string }> = {
  179. index: { method: 'GET', path: (r) => `/${r}` },
  180. create: { method: 'POST', path: (r) => `/${r}` },
  181. new: { method: 'GET', path: (r) => `/${r}/new` },
  182. show: { method: 'GET', path: (r) => `/${r}/:id` },
  183. edit: { method: 'GET', path: (r) => `/${r}/:id/edit` },
  184. update: { method: 'PATCH', path: (r) => `/${r}/:id` },
  185. destroy: { method: 'DELETE', path: (r) => `/${r}/:id` },
  186. };
  187. const PLURAL_ACTIONS = ['index', 'create', 'new', 'show', 'edit', 'update', 'destroy'];
  188. const SINGULAR_ACTIONS = ['create', 'new', 'show', 'edit', 'update', 'destroy'];
  189. /** Naive ActiveSupport-style pluralize — covers the common resource names. */
  190. function pluralize(w: string): string {
  191. if (/[^aeiou]y$/.test(w)) return w.slice(0, -1) + 'ies';
  192. if (/(s|x|z|ch|sh)$/.test(w)) return w + 'es';
  193. return w + 's';
  194. }
  195. /** snake_case → CamelCase (`user_profiles` → `UserProfiles`). */
  196. function camelize(s: string): string {
  197. return s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
  198. }
  199. /** Resolve a `controller#action` route ref to the action method in that controller. */
  200. function resolveControllerAction(ctrlPath: string, action: string, context: ResolutionContext): string | null {
  201. // Rails convention: `articles` → app/controllers/articles_controller.rb.
  202. const direct = `app/controllers/${ctrlPath}_controller.rb`;
  203. if (context.fileExists(direct)) {
  204. const m = context.getNodesInFile(direct).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action);
  205. if (m) return m.id;
  206. }
  207. // Fall back: controller class by name, then the action method in its file.
  208. const cls = camelize(ctrlPath.split('/').pop()!) + 'Controller';
  209. for (const ctrl of context.getNodesByName(cls).filter((n) => n.kind === 'class')) {
  210. const m = context.getNodesInFile(ctrl.filePath).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action);
  211. if (m) return m.id;
  212. }
  213. return null;
  214. }
  215. function resolveModel(name: string, context: ResolutionContext): string | null {
  216. // Try direct file path lookup first (Rails convention: CamelCase -> snake_case.rb)
  217. const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
  218. const possiblePaths = [
  219. `app/models/${snakeName}.rb`,
  220. `app/models/concerns/${snakeName}.rb`,
  221. ];
  222. for (const modelPath of possiblePaths) {
  223. if (context.fileExists(modelPath)) {
  224. const nodes = context.getNodesInFile(modelPath);
  225. const modelNode = nodes.find(
  226. (n) => n.kind === 'class' && n.name === name
  227. );
  228. if (modelNode) {
  229. return modelNode.id;
  230. }
  231. }
  232. }
  233. // Fall back to name-based lookup
  234. const candidates = context.getNodesByName(name);
  235. const modelNode = candidates.find(
  236. (n) => n.kind === 'class' && n.filePath.includes('app/models/')
  237. );
  238. if (modelNode) return modelNode.id;
  239. return null;
  240. }
  241. function resolveController(name: string, context: ResolutionContext): string | null {
  242. // Try direct file path lookup first
  243. const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
  244. const possiblePaths = [
  245. `app/controllers/${snakeName}.rb`,
  246. `app/controllers/api/${snakeName}.rb`,
  247. `app/controllers/api/v1/${snakeName}.rb`,
  248. ];
  249. for (const controllerPath of possiblePaths) {
  250. if (context.fileExists(controllerPath)) {
  251. const nodes = context.getNodesInFile(controllerPath);
  252. const controllerNode = nodes.find(
  253. (n) => n.kind === 'class' && n.name === name
  254. );
  255. if (controllerNode) {
  256. return controllerNode.id;
  257. }
  258. }
  259. }
  260. // Fall back to name-based lookup
  261. const candidates = context.getNodesByName(name);
  262. const controllerNode = candidates.find(
  263. (n) => n.kind === 'class' && n.filePath.includes('controllers/')
  264. );
  265. if (controllerNode) return controllerNode.id;
  266. return null;
  267. }
  268. function resolveHelper(name: string, context: ResolutionContext): string | null {
  269. const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
  270. const helperPath = `app/helpers/${snakeName}.rb`;
  271. if (context.fileExists(helperPath)) {
  272. const nodes = context.getNodesInFile(helperPath);
  273. const helperNode = nodes.find(
  274. (n) => n.kind === 'module' && n.name === name
  275. );
  276. if (helperNode) {
  277. return helperNode.id;
  278. }
  279. }
  280. return null;
  281. }
  282. function resolveService(name: string, context: ResolutionContext): string | null {
  283. const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
  284. const possiblePaths = [
  285. `app/services/${snakeName}.rb`,
  286. `app/jobs/${snakeName}.rb`,
  287. `app/workers/${snakeName}.rb`,
  288. ];
  289. for (const servicePath of possiblePaths) {
  290. if (context.fileExists(servicePath)) {
  291. const nodes = context.getNodesInFile(servicePath);
  292. const serviceNode = nodes.find(
  293. (n) => n.kind === 'class' && n.name === name
  294. );
  295. if (serviceNode) {
  296. return serviceNode.id;
  297. }
  298. }
  299. }
  300. return null;
  301. }