Browse Source

feat(resolution): Play Framework conf/routes → controller routing (Scala/Java)

Play declares routes in an extensionless conf/routes file (GET /computers
controllers.Application.list(p: Int ?= 0)) the file walk never indexed
(isSourceFile requires an extension), so Play apps had 0 route nodes.

- grammars.ts: add isPlayRoutesFile (conf/routes + *.routes), opt it into
  isSourceFile, and map it to the no-grammar (yaml-style) path in
  detectLanguage so the framework resolver extracts it. Narrow match — only
  ADDS Play routes files, never affects other indexing.
- play.ts: a Play resolver — detect (build.sbt/conf), extract (parse each
  METHOD /path Controller.action(args) line, drop package + args), resolve
  (Controller.action → the action method in that controller class),
  claimsReference for the dotted Controller.action handler.

computer-database 0→8 routes, 7/8 resolved (the 1 unresolved is
controllers.Assets.versioned — Play's framework controller, external);
starter 0→4 (3/4). Flow connects request→route→controller→DAO. No-regression
(excalidraw 9,290 / suite unchanged). Tests: routes parse + `->` include
skipped, conf/routes file detection.

Frontier: SIRD programmatic routers (-> include + case GET(p"/x")) + Akka
actor message→handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 month ago
parent
commit
c423029d68

+ 39 - 0
__tests__/frameworks.test.ts

@@ -594,6 +594,45 @@ class OwnerController {
   });
   });
 });
 });
 
 
+import { playResolver } from '../src/resolution/frameworks/play';
+import { isSourceFile, isPlayRoutesFile } from '../src/extraction/grammars';
+
+describe('playResolver.extract (conf/routes)', () => {
+  it('extracts METHOD /path Controller.action routes, dropping the package + args', () => {
+    const src = `# Routes
+GET     /                    controllers.Application.index
+GET     /computers           controllers.Application.list(p: Int ?= 0, s: Int ?= 2)
+POST    /computers           controllers.Application.save
+-> /v1/posts                 v1.post.PostRouter
+`;
+    const { nodes, references } = playResolver.extract!('conf/routes', src);
+    expect(nodes.map((n) => n.name)).toEqual([
+      'GET /',
+      'GET /computers',
+      'POST /computers',
+    ]); // the `->` include is skipped
+    expect(references.map((r) => r.referenceName)).toEqual([
+      'Application.index',
+      'Application.list',
+      'Application.save',
+    ]);
+  });
+
+  it('only runs on Play routes files', () => {
+    expect(playResolver.extract!('app/Foo.scala', 'GET / controllers.X.y').nodes).toHaveLength(0);
+  });
+});
+
+describe('Play routes file detection', () => {
+  it('recognizes conf/routes (extensionless) and *.routes as source files', () => {
+    expect(isPlayRoutesFile('conf/routes')).toBe(true);
+    expect(isPlayRoutesFile('myapp/conf/routes')).toBe(true);
+    expect(isPlayRoutesFile('conf/admin.routes')).toBe(true);
+    expect(isSourceFile('conf/routes')).toBe(true);
+    expect(isPlayRoutesFile('src/routes.ts')).toBe(false);
+  });
+});
+
 import { goResolver } from '../src/resolution/frameworks/go';
 import { goResolver } from '../src/resolution/frameworks/go';
 
 
 describe('goResolver.extract', () => {
 describe('goResolver.extract', () => {

+ 17 - 0
src/extraction/grammars.ts

@@ -100,11 +100,25 @@ export const EXTENSION_MAP: Record<string, Language> = {
  * from EXTENSION_MAP so parser support and indexing selection never drift.
  * from EXTENSION_MAP so parser support and indexing selection never drift.
  */
  */
 export function isSourceFile(filePath: string): boolean {
 export function isSourceFile(filePath: string): boolean {
+  if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless
   const dot = filePath.lastIndexOf('.');
   const dot = filePath.lastIndexOf('.');
   if (dot < 0) return false;
   if (dot < 0) return false;
   return filePath.slice(dot).toLowerCase() in EXTENSION_MAP;
   return filePath.slice(dot).toLowerCase() in EXTENSION_MAP;
 }
 }
 
 
+/**
+ * Play Framework routes file: the extensionless `conf/routes` (and included
+ * `conf/*.routes`). No grammar — route extraction is done by the Play framework
+ * resolver, so it's processed through the no-grammar (`yaml`-style) path.
+ */
+export function isPlayRoutesFile(filePath: string): boolean {
+  return (
+    filePath === 'conf/routes' ||
+    filePath.endsWith('/conf/routes') ||
+    filePath.endsWith('.routes')
+  );
+}
+
 /**
 /**
  * Caches for loaded grammars and parsers
  * Caches for loaded grammars and parsers
  */
  */
@@ -208,6 +222,9 @@ export function getParser(language: Language): Parser | null {
  * Detect language from file extension
  * Detect language from file extension
  */
  */
 export function detectLanguage(filePath: string, source?: string): Language {
 export function detectLanguage(filePath: string, source?: string): Language {
+  // Play `conf/routes` has no grammar — route through the no-symbol path; the
+  // Play framework resolver extracts route nodes from it.
+  if (isPlayRoutesFile(filePath)) return 'yaml';
   const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
   const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
   const lang = EXTENSION_MAP[ext] || 'unknown';
   const lang = EXTENSION_MAP[ext] || 'unknown';
 
 

+ 3 - 0
src/resolution/frameworks/index.ts

@@ -16,6 +16,7 @@ import { vueResolver } from './vue';
 import { djangoResolver, flaskResolver, fastapiResolver } from './python';
 import { djangoResolver, flaskResolver, fastapiResolver } from './python';
 import { railsResolver } from './ruby';
 import { railsResolver } from './ruby';
 import { springResolver } from './java';
 import { springResolver } from './java';
+import { playResolver } from './play';
 import { goResolver } from './go';
 import { goResolver } from './go';
 import { rustResolver } from './rust';
 import { rustResolver } from './rust';
 import { aspnetResolver } from './csharp';
 import { aspnetResolver } from './csharp';
@@ -42,6 +43,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   railsResolver,
   railsResolver,
   // Java
   // Java
   springResolver,
   springResolver,
+  playResolver,
   // Go
   // Go
   goResolver,
   goResolver,
   // Rust
   // Rust
@@ -117,6 +119,7 @@ export { vueResolver } from './vue';
 export { djangoResolver, flaskResolver, fastapiResolver } from './python';
 export { djangoResolver, flaskResolver, fastapiResolver } from './python';
 export { railsResolver } from './ruby';
 export { railsResolver } from './ruby';
 export { springResolver } from './java';
 export { springResolver } from './java';
+export { playResolver } from './play';
 export { goResolver } from './go';
 export { goResolver } from './go';
 export { rustResolver } from './rust';
 export { rustResolver } from './rust';
 export { aspnetResolver } from './csharp';
 export { aspnetResolver } from './csharp';

+ 112 - 0
src/resolution/frameworks/play.ts

@@ -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 };
+  },
+};