laravel.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. /**
  2. * Laravel Framework Resolver
  3. *
  4. * Handles Laravel-specific patterns for reference resolution.
  5. */
  6. import { Node } from '../../types';
  7. import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
  8. import { stripCommentsForRegex } from '../strip-comments';
  9. /**
  10. * Laravel facade mappings to underlying classes
  11. * Exported for potential use in facade resolution
  12. */
  13. export const FACADE_MAPPINGS: Record<string, string> = {
  14. Auth: 'Illuminate\\Auth\\AuthManager',
  15. Cache: 'Illuminate\\Cache\\CacheManager',
  16. Config: 'Illuminate\\Config\\Repository',
  17. DB: 'Illuminate\\Database\\DatabaseManager',
  18. Event: 'Illuminate\\Events\\Dispatcher',
  19. File: 'Illuminate\\Filesystem\\Filesystem',
  20. Gate: 'Illuminate\\Auth\\Access\\Gate',
  21. Hash: 'Illuminate\\Hashing\\HashManager',
  22. Log: 'Illuminate\\Log\\LogManager',
  23. Mail: 'Illuminate\\Mail\\Mailer',
  24. Queue: 'Illuminate\\Queue\\QueueManager',
  25. Redis: 'Illuminate\\Redis\\RedisManager',
  26. Request: 'Illuminate\\Http\\Request',
  27. Response: 'Illuminate\\Http\\Response',
  28. Route: 'Illuminate\\Routing\\Router',
  29. Session: 'Illuminate\\Session\\SessionManager',
  30. Storage: 'Illuminate\\Filesystem\\FilesystemManager',
  31. URL: 'Illuminate\\Routing\\UrlGenerator',
  32. Validator: 'Illuminate\\Validation\\Factory',
  33. View: 'Illuminate\\View\\Factory',
  34. };
  35. export const laravelResolver: FrameworkResolver = {
  36. name: 'laravel',
  37. languages: ['php'],
  38. detect(context: ResolutionContext): boolean {
  39. // Check for artisan file (Laravel signature)
  40. return context.fileExists('artisan') || context.fileExists('app/Http/Kernel.php');
  41. },
  42. // `Controller@method` route refs name no declared symbol, so resolveOne's
  43. // pre-filter would drop them before resolve() runs (Pattern 4). Claim them —
  44. // same hook the django ORM / Rails routing work needed.
  45. claimsReference(name: string): boolean {
  46. return /^[A-Za-z_][A-Za-z0-9_]*Controller@\w+$/.test(name);
  47. },
  48. resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
  49. // Pattern 1: Model::method() - Eloquent static calls
  50. const modelMatch = ref.referenceName.match(/^([A-Z][a-zA-Z]+)::(\w+)$/);
  51. if (modelMatch) {
  52. const [, className, methodName] = modelMatch;
  53. const result = resolveModelCall(className!, methodName!, context);
  54. if (result) {
  55. return {
  56. original: ref,
  57. targetNodeId: result,
  58. confidence: 0.85,
  59. resolvedBy: 'framework',
  60. };
  61. }
  62. }
  63. // Pattern 2: Facade calls - Auth::user(), Cache::get()
  64. const facadeMatch = ref.referenceName.match(/^(Auth|Cache|DB|Log|Mail|Queue|Session|Storage|Validator|Route|Request|Response)::(\w+)$/);
  65. if (facadeMatch) {
  66. // Facades typically resolve to external Laravel code
  67. // Mark as external but note the facade
  68. return null; // External, can't resolve to local node
  69. }
  70. // Pattern 3: Helper function calls - route(), view(), config()
  71. if (['route', 'view', 'config', 'env', 'app', 'abort', 'redirect', 'response', 'request', 'session', 'url', 'asset', 'mix'].includes(ref.referenceName)) {
  72. // These are Laravel helpers - external
  73. return null;
  74. }
  75. // Pattern 4: Controller method references
  76. const controllerMatch = ref.referenceName.match(/^([A-Z][a-zA-Z]+Controller)@(\w+)$/);
  77. if (controllerMatch) {
  78. const [, controller, method] = controllerMatch;
  79. const result = resolveControllerMethod(controller!, method!, context);
  80. if (result) {
  81. return {
  82. original: ref,
  83. targetNodeId: result,
  84. confidence: 0.9,
  85. resolvedBy: 'framework',
  86. };
  87. }
  88. }
  89. return null;
  90. },
  91. extract(filePath, content) {
  92. if (!filePath.endsWith('.php')) return { nodes: [], references: [] };
  93. const nodes: Node[] = [];
  94. const references: UnresolvedRef[] = [];
  95. const now = Date.now();
  96. const safe = stripCommentsForRegex(content, 'php');
  97. // Route::METHOD('/path', handler-expr)
  98. // handler-expr can be: [Class::class, 'method'] | 'Controller@method' | Closure | Class::class
  99. const routeRegex = /Route::(get|post|put|patch|delete|options|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g;
  100. let match: RegExpExecArray | null;
  101. while ((match = routeRegex.exec(safe)) !== null) {
  102. const [, method, routePath, handlerExpr] = match;
  103. const line = safe.slice(0, match.index).split('\n').length;
  104. const upper = method!.toUpperCase();
  105. const routeNode: Node = {
  106. id: `route:${filePath}:${line}:${upper}:${routePath}`,
  107. kind: 'route',
  108. name: `${upper} ${routePath}`,
  109. qualifiedName: `${filePath}::route:${routePath}`,
  110. filePath,
  111. startLine: line,
  112. endLine: line,
  113. startColumn: 0,
  114. endColumn: match[0].length,
  115. language: 'php',
  116. updatedAt: now,
  117. };
  118. nodes.push(routeNode);
  119. const handlerName = extractLaravelHandler(handlerExpr!);
  120. if (handlerName) {
  121. references.push({
  122. fromNodeId: routeNode.id,
  123. referenceName: handlerName,
  124. referenceKind: 'references',
  125. line,
  126. column: 0,
  127. filePath,
  128. language: 'php',
  129. });
  130. }
  131. }
  132. // Route::resource('name', Controller::class) / Route::apiResource('name', Controller::class)
  133. const resourceRegex = /Route::(resource|apiResource)\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*([^)]+))?\)/g;
  134. while ((match = resourceRegex.exec(safe)) !== null) {
  135. const [, _fn, resourceName, handlerExpr] = match;
  136. const line = safe.slice(0, match.index).split('\n').length;
  137. const routeNode: Node = {
  138. id: `route:${filePath}:${line}:RESOURCE:${resourceName}`,
  139. kind: 'route',
  140. name: `resource:${resourceName}`,
  141. qualifiedName: `${filePath}::route:${resourceName}`,
  142. filePath,
  143. startLine: line,
  144. endLine: line,
  145. startColumn: 0,
  146. endColumn: match[0].length,
  147. language: 'php',
  148. updatedAt: now,
  149. };
  150. nodes.push(routeNode);
  151. if (handlerExpr) {
  152. const controllerName = extractLaravelHandler(handlerExpr);
  153. if (controllerName) {
  154. references.push({
  155. fromNodeId: routeNode.id,
  156. referenceName: controllerName,
  157. referenceKind: 'imports',
  158. line,
  159. column: 0,
  160. filePath,
  161. language: 'php',
  162. });
  163. }
  164. }
  165. }
  166. return { nodes, references };
  167. },
  168. };
  169. /**
  170. * Parse a Laravel route handler expression and return the symbol to link.
  171. * - `[Class::class, 'method']` -> `method`
  172. * - `'Controller@method'` -> `method`
  173. * - `Class::class` -> `Class`
  174. * - anything else (closure etc) -> null
  175. */
  176. function extractLaravelHandler(expr: string): string | null {
  177. const trimmed = expr.trim();
  178. const short = (s: string) => s.split('\\').pop()!; // strip namespace
  179. // [Class::class, 'method'] → `Class@method` (PRECISE — keep the controller, so
  180. // common action names like `index`/`show` resolve to the RIGHT controller, not
  181. // whichever one name-matching happens to pick first).
  182. const tupleMatch = trimmed.match(/^\[\s*([A-Za-z_\\][\w\\]*)::class\s*,\s*['"]([^'"]+)['"]\s*\]/);
  183. if (tupleMatch) return `${short(tupleMatch[1]!)}@${tupleMatch[2]!}`;
  184. // 'Controller@method' (possibly namespaced) → `Controller@method`
  185. const atMatch = trimmed.match(/^['"]([^'"@]+)@([^'"]+)['"]$/);
  186. if (atMatch) return `${short(atMatch[1]!)}@${atMatch[2]!}`;
  187. // Class::class (Route::resource controller) → `Class`
  188. const classMatch = trimmed.match(/^([A-Za-z_\\][\w\\]*)::class/);
  189. if (classMatch) return short(classMatch[1]!);
  190. return null;
  191. }
  192. /**
  193. * Resolve a Model::method() call
  194. */
  195. function resolveModelCall(
  196. className: string,
  197. methodName: string,
  198. context: ResolutionContext
  199. ): string | null {
  200. // Try app/Models/ first (Laravel 8+)
  201. let modelPath = `app/Models/${className}.php`;
  202. if (context.fileExists(modelPath)) {
  203. const nodes = context.getNodesInFile(modelPath);
  204. // Look for the method in this class
  205. const methodNode = nodes.find(
  206. (n) => n.kind === 'method' && n.name === methodName
  207. );
  208. if (methodNode) {
  209. return methodNode.id;
  210. }
  211. // Return the class itself if method not found
  212. const classNode = nodes.find(
  213. (n) => n.kind === 'class' && n.name === className
  214. );
  215. if (classNode) {
  216. return classNode.id;
  217. }
  218. }
  219. // Try app/ (Laravel 7 and below)
  220. modelPath = `app/${className}.php`;
  221. if (context.fileExists(modelPath)) {
  222. const nodes = context.getNodesInFile(modelPath);
  223. const methodNode = nodes.find(
  224. (n) => n.kind === 'method' && n.name === methodName
  225. );
  226. if (methodNode) {
  227. return methodNode.id;
  228. }
  229. const classNode = nodes.find(
  230. (n) => n.kind === 'class' && n.name === className
  231. );
  232. if (classNode) {
  233. return classNode.id;
  234. }
  235. }
  236. return null;
  237. }
  238. /**
  239. * Resolve a Controller@method reference
  240. */
  241. function resolveControllerMethod(
  242. controller: string,
  243. method: string,
  244. context: ResolutionContext
  245. ): string | null {
  246. // Try app/Http/Controllers/
  247. const controllerPath = `app/Http/Controllers/${controller}.php`;
  248. if (context.fileExists(controllerPath)) {
  249. const nodes = context.getNodesInFile(controllerPath);
  250. const methodNode = nodes.find(
  251. (n) => n.kind === 'method' && n.name === method
  252. );
  253. if (methodNode) {
  254. return methodNode.id;
  255. }
  256. }
  257. // Try name-based lookup for namespaced controllers
  258. const controllerCandidates = context.getNodesByName(controller);
  259. for (const ctrl of controllerCandidates) {
  260. if (ctrl.kind === 'class' && ctrl.filePath.includes('Controllers')) {
  261. const nodesInFile = context.getNodesInFile(ctrl.filePath);
  262. const methodNode = nodesInFile.find(
  263. (n) => n.kind === 'method' && n.name === method
  264. );
  265. if (methodNode) {
  266. return methodNode.id;
  267. }
  268. }
  269. }
  270. return null;
  271. }