|
@@ -0,0 +1,112 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * Play Framework (Scala/Java) resolver.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Play declares HTTP routes in a dedicated `conf/routes` file (and included
|
|
|
|
|
+ * `conf/*.routes`), Rails-style:
|
|
|
|
|
+ *
|
|
|
|
|
+ * GET /computers controllers.Application.list(p: Int ?= 0)
|
|
|
|
|
+ * POST /computers controllers.Application.save
|
|
|
|
|
+ * GET /assets/*file controllers.Assets.versioned(path = "/public", file: Asset)
|
|
|
|
|
+ *
|
|
|
|
|
+ * The file is extensionless, so the file walk only indexes it because
|
|
|
|
|
+ * `isPlayRoutesFile` (grammars.ts) opts it in; it's processed through the
|
|
|
|
|
+ * no-grammar path and this resolver extracts the routes. Each route references
|
|
|
|
|
+ * its handler as `Controller.method` (the package prefix is dropped), resolved
|
|
|
|
|
+ * to the action method in the controller class.
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+import { Node } from '../../types';
|
|
|
|
|
+import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
|
|
|
|
+import { isPlayRoutesFile } from '../../extraction/grammars';
|
|
|
|
|
+
|
|
|
|
|
+const ROUTE_LINE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)\s+(.+)$/;
|
|
|
|
|
+const METHOD_KINDS = new Set(['method', 'function']);
|
|
|
|
|
+const CLASS_KINDS = new Set(['class']);
|
|
|
|
|
+
|
|
|
|
|
+export const playResolver: FrameworkResolver = {
|
|
|
|
|
+ name: 'play',
|
|
|
|
|
+ // `yaml` so this resolver runs on conf/routes (detectLanguage maps it to yaml);
|
|
|
|
|
+ // `scala`/`java` so it's active in Play projects of either language.
|
|
|
|
|
+ languages: ['scala', 'java', 'yaml'],
|
|
|
|
|
+
|
|
|
|
|
+ detect(context: ResolutionContext): boolean {
|
|
|
|
|
+ const buildSbt = context.readFile('build.sbt');
|
|
|
|
|
+ if (buildSbt && /playframework|"play"|sbt-plugin|PlayScala|PlayJava/i.test(buildSbt)) return true;
|
|
|
|
|
+ if (context.fileExists('conf/routes')) return true;
|
|
|
|
|
+ if (context.fileExists('conf/application.conf')) return true;
|
|
|
|
|
+ return false;
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // The handler is `Controller.method` (a class-qualified action), which names no
|
|
|
|
|
+ // bare declared symbol, so resolveOne's pre-filter could drop it — claim it.
|
|
|
|
|
+ claimsReference(name: string): boolean {
|
|
|
|
|
+ return /^[A-Za-z_]\w*\.[A-Za-z_]\w*$/.test(name);
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
|
|
|
|
+ const m = ref.referenceName.match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/);
|
|
|
|
|
+ if (!m) return null;
|
|
|
|
|
+ const [, className, methodName] = m;
|
|
|
|
|
+ const classNodes = context.getNodesByName(className!).filter((n) => CLASS_KINDS.has(n.kind));
|
|
|
|
|
+ for (const cls of classNodes) {
|
|
|
|
|
+ const method = context
|
|
|
|
|
+ .getNodesInFile(cls.filePath)
|
|
|
|
|
+ .find((n) => METHOD_KINDS.has(n.kind) && n.name === methodName);
|
|
|
|
|
+ if (method) {
|
|
|
|
|
+ return { original: ref, targetNodeId: method.id, confidence: 0.9, resolvedBy: 'framework' };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } {
|
|
|
|
|
+ if (!isPlayRoutesFile(filePath)) return { nodes: [], references: [] };
|
|
|
|
|
+ const nodes: Node[] = [];
|
|
|
|
|
+ const references: UnresolvedRef[] = [];
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+
|
|
|
|
|
+ const lines = content.split('\n');
|
|
|
|
|
+ for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
+ const line = lines[i]!.trim();
|
|
|
|
|
+ // Skip comments and `->` route includes (a sub-router mount, not an action).
|
|
|
|
|
+ if (!line || line.startsWith('#') || line.startsWith('->')) continue;
|
|
|
|
|
+ const m = line.match(ROUTE_LINE);
|
|
|
|
|
+ if (!m) continue;
|
|
|
|
|
+ const [, method, routePath, action] = m;
|
|
|
|
|
+
|
|
|
|
|
+ // action: `controllers.Application.list(p: Int ?= 0)` → drop args, keep the
|
|
|
|
|
+ // last `Controller.method` segment (package prefix is irrelevant for lookup).
|
|
|
|
|
+ const fqn = action!.split('(')[0]!.trim();
|
|
|
|
|
+ const parts = fqn.split('.').filter(Boolean);
|
|
|
|
|
+ if (parts.length < 2) continue;
|
|
|
|
|
+ const handlerRef = parts.slice(-2).join('.'); // Application.list
|
|
|
|
|
+
|
|
|
|
|
+ const lineNum = i + 1;
|
|
|
|
|
+ const routeNode: Node = {
|
|
|
|
|
+ id: `route:${filePath}:${lineNum}:${method}:${routePath}`,
|
|
|
|
|
+ kind: 'route',
|
|
|
|
|
+ name: `${method} ${routePath}`,
|
|
|
|
|
+ qualifiedName: `${filePath}::${method}:${routePath}`,
|
|
|
|
|
+ filePath,
|
|
|
|
|
+ startLine: lineNum,
|
|
|
|
|
+ endLine: lineNum,
|
|
|
|
|
+ startColumn: 0,
|
|
|
|
|
+ endColumn: 0,
|
|
|
|
|
+ language: 'scala',
|
|
|
|
|
+ updatedAt: now,
|
|
|
|
|
+ };
|
|
|
|
|
+ nodes.push(routeNode);
|
|
|
|
|
+ references.push({
|
|
|
|
|
+ fromNodeId: routeNode.id,
|
|
|
|
|
+ referenceName: handlerRef,
|
|
|
|
|
+ referenceKind: 'references',
|
|
|
|
|
+ line: lineNum,
|
|
|
|
|
+ column: 0,
|
|
|
|
|
+ filePath,
|
|
|
|
|
+ language: 'scala',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { nodes, references };
|
|
|
|
|
+ },
|
|
|
|
|
+};
|