1
0
Эх сурвалжийг харах

feat(resolution): Drupal route-handler resolution (claimsReference, single-colon controllers, contrib detection)

The *.routing.yml extractor and _controller/_form resolver existed but two
gaps left most routes unlinked. Validated S/M/L: admin_toolbar 0→14 (14/14),
webform 144/208, drupal-core 536→731/836 (87%); canonical flow traverses
(getAnnouncements ← /admin/announcements_feed); node count unchanged.

- claimsReference: Drupal handler refs are FQCNs (\Drupal\…\Class::method),
  bare form classes (\…\SettingsForm), or single-colon controller-services
  (\…\Controller:method). Only the ::method shape survived resolveOne's
  pre-filter (its member is a known method name); the bare-FQCN forms and
  single-colon controllers were dropped before resolve() ran. Claim FQCN /
  Class:method / hook_* refs (same pattern as Rails controller#action).
- Single-colon controller match: broaden the controller regex from :: to
  :{1,2} and tighten the _form branch to !name.includes(':').
- Detection: detect() only checked composer `require` for a drupal/* dep, but
  a contrib module often has an empty require and is identified only by
  "name":"drupal/<m>" + "type":"drupal-module" (admin_toolbar → 0 routes).
  Broaden to composer name/type + a *.info.yml fallback.

Remaining unresolved is the entity-annotation handler frontier
(_entity_form: type.op) and OOP #[Hook] attributes (Drupal 11 moved ~all
procedural hooks to attribute methods — out of scope here). Tests: contrib
detection, *.info.yml fallback, claimsReference, single-colon controller.
Full suite green (787 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 сар өмнө
parent
commit
49fbc6c9c2

+ 91 - 0
__tests__/drupal.test.ts

@@ -87,6 +87,52 @@ describe('drupalResolver.detect', () => {
     const ctx = makeContext({ readFile: () => '{ bad json' });
     expect(drupalResolver.detect(ctx)).toBe(false);
   });
+
+  it('returns true for a contrib module with empty require (composer name/type)', () => {
+    const ctx = makeContext({
+      readFile: (f) =>
+        f === 'composer.json'
+          ? JSON.stringify({
+              name: 'drupal/admin_toolbar',
+              type: 'drupal-module',
+              require: {},
+            })
+          : null,
+    });
+    expect(drupalResolver.detect(ctx)).toBe(true);
+  });
+
+  it('returns true via the *.info.yml fallback when composer.json is absent', () => {
+    const ctx = makeContext({
+      readFile: () => null,
+      getAllFiles: () => [
+        'mymodule/mymodule.info.yml',
+        'mymodule/mymodule.routing.yml',
+      ],
+    });
+    expect(drupalResolver.detect(ctx)).toBe(true);
+  });
+
+  it('returns false for a stray *.info.yml with no Drupal PHP/route file', () => {
+    const ctx = makeContext({
+      readFile: () => null,
+      getAllFiles: () => ['some/unrelated.info.yml'],
+    });
+    expect(drupalResolver.detect(ctx)).toBe(false);
+  });
+});
+
+describe('drupalResolver.claimsReference', () => {
+  it('claims FQCN handler refs and hook names the pre-filter would drop', () => {
+    expect(drupalResolver.claimsReference!('\\Drupal\\m\\Form\\SettingsForm')).toBe(true);
+    expect(drupalResolver.claimsReference!('\\Drupal\\m\\Controller\\C:setNoJsCookie')).toBe(true);
+    expect(drupalResolver.claimsReference!('hook_form_alter')).toBe(true);
+  });
+
+  it('does not claim ordinary identifiers or entity-handler dotted refs', () => {
+    expect(drupalResolver.claimsReference!('someHelperFunction')).toBe(false);
+    expect(drupalResolver.claimsReference!('comment.default')).toBe(false);
+  });
 });
 
 // ---------------------------------------------------------------------------
@@ -435,6 +481,51 @@ describe('drupalResolver.resolve', () => {
     };
     expect(drupalResolver.resolve(ref, ctx)).toBeNull();
   });
+
+  it('resolves a single-colon controller-service ref (Class:method)', () => {
+    const methodNode = {
+      id: 'method:nojs1',
+      kind: 'method' as const,
+      name: 'setNoJsCookie',
+      qualifiedName: 'BigPipeController::setNoJsCookie',
+      filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
+      language: 'php' as const,
+      startLine: 10,
+      endLine: 20,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: 0,
+    };
+    const classNode = {
+      id: 'class:nojs2',
+      kind: 'class' as const,
+      name: 'BigPipeController',
+      qualifiedName: 'BigPipeController',
+      filePath: 'core/modules/big_pipe/src/Controller/BigPipeController.php',
+      language: 'php' as const,
+      startLine: 5,
+      endLine: 30,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: 0,
+    };
+    const ctx = makeContext({
+      getNodesByName: (name) => (name === 'BigPipeController' ? [classNode] : []),
+      getNodesInFile: () => [classNode, methodNode],
+    });
+    const ref = {
+      fromNodeId: 'route:x',
+      referenceName: '\\Drupal\\big_pipe\\Controller\\BigPipeController:setNoJsCookie',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 0,
+      filePath: 'big_pipe.routing.yml',
+      language: 'yaml' as const,
+    };
+    const resolved = drupalResolver.resolve(ref, ctx);
+    expect(resolved).not.toBeNull();
+    expect(resolved!.targetNodeId).toBe('method:nojs1');
+  });
 });
 
 // ---------------------------------------------------------------------------

+ 52 - 11
src/resolution/frameworks/drupal.ts

@@ -297,23 +297,64 @@ export const drupalResolver: FrameworkResolver = {
   name: 'drupal',
   languages: ['php', 'yaml'],
 
+  // Drupal route handlers are FQCNs (`\Drupal\…\Class::method`, the single-colon
+  // controller-service form `\Drupal\…\Class:method`, or a bare `\…\FormClass`)
+  // and hook refs are canonical `hook_*` names — none match a declared symbol, so
+  // resolveOne's pre-filter would drop them before resolve() runs. Claim the
+  // shapes resolve() handles (mirrors the Rails `controller#action` claim).
+  claimsReference(name: string): boolean {
+    return (
+      name.startsWith('hook_') ||
+      name.includes('\\') ||
+      /^[A-Za-z_]\w*::?\w+$/.test(name)
+    );
+  },
+
   detect(context: ResolutionContext): boolean {
+    // Primary: composer.json identifies a Drupal project/module/theme/profile.
+    // A contrib module often has an EMPTY `require` (no `drupal/*` dep) but still
+    // declares `"name": "drupal/<module>"` and `"type": "drupal-module"`, so check
+    // those too — checking deps alone misses every standalone contrib module.
     const composer = context.readFile('composer.json');
-    if (!composer) return false;
-    try {
-      const json = JSON.parse(composer) as { require?: Record<string, string>; 'require-dev'?: Record<string, string> };
-      const deps = { ...json.require, ...(json['require-dev'] ?? {}) };
-      return Object.keys(deps).some((k) => k.startsWith('drupal/'));
-    } catch {
-      return false;
+    if (composer) {
+      try {
+        const json = JSON.parse(composer) as {
+          name?: string;
+          type?: string;
+          require?: Record<string, string>;
+          'require-dev'?: Record<string, string>;
+        };
+        if (typeof json.name === 'string' && json.name.startsWith('drupal/')) return true;
+        if (typeof json.type === 'string' && json.type.startsWith('drupal-')) return true;
+        const deps = { ...json.require, ...(json['require-dev'] ?? {}) };
+        if (Object.keys(deps).some((k) => k.startsWith('drupal/'))) return true;
+      } catch {
+        // malformed composer.json — fall through to file-based detection
+      }
     }
+
+    // Fallback (composer-less module, or a non-Drupal composer.json): the
+    // unmistakable Drupal signature is a `*.info.yml` manifest alongside a
+    // Drupal PHP/route file. Require both so a stray `.info.yml` elsewhere
+    // doesn't trigger a false positive.
+    const files = context.getAllFiles();
+    const hasInfoYml = files.some((f) => f.endsWith('.info.yml'));
+    if (!hasInfoYml) return false;
+    return files.some(
+      (f) =>
+        f.endsWith('.routing.yml') ||
+        f.endsWith('.module') ||
+        f.endsWith('.install') ||
+        f.endsWith('.theme')
+    );
   },
 
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
     const name = ref.referenceName;
 
-    // _controller: '\Drupal\module\...\ClassName::methodName'
-    const controllerMatch = name.match(/^\\?(?:Drupal\\[^:]+\\)?([^\\:]+)::(\w+)$/);
+    // _controller: '\Drupal\module\...\ClassName::methodName' (double colon) or the
+    // single-colon controller-service form '\Drupal\...\ClassName:methodName'.
+    const controllerMatch = name.match(/^\\?(?:Drupal\\[^:]+\\)?([^\\:]+):{1,2}(\w+)$/);
     if (controllerMatch) {
       const [, className, methodName] = controllerMatch;
       const classNodes = context.getNodesByName(className!);
@@ -328,8 +369,8 @@ export const drupalResolver: FrameworkResolver = {
       }
     }
 
-    // _form / _entity_form: '\Drupal\module\...\ClassName'  (no ::method)
-    if (name.includes('\\') && !name.includes('::')) {
+    // _form / _entity_form: '\Drupal\module\...\ClassName'  (bare FQCN, no method)
+    if (name.includes('\\') && !name.includes(':')) {
       const className = lastSegment(name);
       if (className) {
         const classNodes = context.getNodesByName(className);