diff --git a/CHANGELOG.md b/CHANGELOG.md index b544414b..8543e639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.3] - 2026-05-22 + +### Added +- **Drupal: OOP hook variants fully supported.** The three class-level + `#[Hook]` placement styles introduced in Drupal 10.2+ are now detected: + - `#[Hook('name', method: 'methodName')]` above a class — emits a + `references` edge from the named method. + - `#[Hook('name')]` above a class that has `__invoke()` — emits a + `references` edge from `__invoke`. + - Multiple `#[Hook]` stacked on a single method or class — all hooks emit + individual edges (previously only the last one was captured). + Resolves [#300](https://github.com/colbymchenry/codegraph/issues/300). +- **Drupal: plugin annotation coverage expanded to 55 types.** The PHP 8 + attribute-style plugin annotations (adopted in Drupal 10.3/11.x) are + recognised for all major plugin categories: entity types + (`ContentEntityType`, `ConfigEntityType`), Views plugins (Display, Field, + Filter, Sort, Argument, Style, Row, Area, …), Migrate plugins (Source, + Process, Destination), REST resources, Layout, QueueWorker, and more. + Previously only 28 types were covered. + ## [0.9.2] - 2026-05-21 ### Added @@ -93,6 +113,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). find its bundle. The release pipeline now verifies every package reached the registry (and is idempotent), so a release can't pass green-but-broken again. +[0.9.3]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.3 [0.9.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2 [0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1 diff --git a/__tests__/drupal.test.ts b/__tests__/drupal.test.ts index fda5415b..cc7a7fdb 100644 --- a/__tests__/drupal.test.ts +++ b/__tests__/drupal.test.ts @@ -213,7 +213,7 @@ mod.api: }); it('returns empty result for non-routing-yml files', () => { - const { nodes, references } = drupalResolver.extract!( + const { nodes } = drupalResolver.extract!( 'mymodule.module', ' { }); }); +// --------------------------------------------------------------------------- +// extract() — #[Hook] attribute detection (OOP hooks, Drupal 10.2+) +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — #[Hook] attribute (OOP hooks)', () => { + it('detects a hook implemented as a class method via #[Hook] attribute', () => { + const src = ` r.referenceName === 'hook_entity_presave'); + expect(hookRef).toBeDefined(); + expect(hookRef!.referenceKind).toBe('references'); + }); + + it('detects multiple OOP hooks in the same class', () => { + const src = ` r.referenceName); + expect(hookNames).toContain('hook_form_alter'); + expect(hookNames).toContain('hook_node_presave'); + }); + + it('handles stacked attributes — preserves #[Hook] when another attribute follows', () => { + const src = ` r.referenceName === 'hook_user_login'); + expect(hookRef).toBeDefined(); + }); + + it('emits refs for ALL hooks when multiple #[Hook] are stacked on one method', () => { + const src = ` r.referenceName === 'hook_comment_insert')).toBeDefined(); + expect(references.find((r) => r.referenceName === 'hook_comment_update')).toBeDefined(); + }); + + it('detects class-level #[Hook] with method: parameter', () => { + const src = ` r.referenceName === 'hook_form_alter')).toBeDefined(); + }); + + it('detects class-level #[Hook] targeting __invoke()', () => { + const src = ` r.referenceName === 'hook_cron')).toBeDefined(); + }); + + it('detects multiple class-level #[Hook] attributes stacked on a class', () => { + const src = ` r.referenceName === 'hook_node_insert')).toBeDefined(); + expect(references.find((r) => r.referenceName === 'hook_node_update')).toBeDefined(); + }); + + it('handles blank line between #[Hook] and method definition', () => { + const src = ` r.referenceName === 'hook_cron')).toBeDefined(); + }); + + it('detects OOP hook in a plain .php file (not just .module)', () => { + const src = ` r.referenceName === 'hook_views_data')).toBeDefined(); + }); + + it('does not emit a hook ref for #[Hook] without a following function', () => { + const src = ` r.referenceName === 'hook_missing')).toHaveLength(0); + }); + + it('does not confuse #[Hook] with other #[] attributes on classes', () => { + const src = ` r.referenceName.startsWith('hook_'))).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// extract() — plugin annotation detection (pre-10.2 docblock style) +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — plugin annotations (@PluginType)', () => { + it('detects a Block plugin via docblock annotation', () => { + const src = ` r.referenceName === 'drupal:plugin:Block'); + expect(pluginRef).toBeDefined(); + expect(pluginRef!.referenceKind).toBe('decorates'); + }); + + it('detects a QueueWorker plugin via docblock annotation', () => { + const src = ` r.referenceName === 'drupal:plugin:QueueWorker')).toBeDefined(); + }); + + it('detects a Filter plugin via docblock annotation', () => { + const src = ` r.referenceName === 'drupal:plugin:Filter')).toBeDefined(); + }); + + it('does not emit a plugin ref for unrecognised docblock annotations', () => { + const src = ` r.referenceName.startsWith('drupal:plugin:'))).toHaveLength(0); + }); + + it('does not emit duplicate plugin refs when annotation is inside a long docblock', () => { + const src = ` r.referenceName === 'drupal:plugin:Block'); + expect(pluginRefs).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// extract() — PHP 8 attribute-style plugin detection (Drupal 10.2+) +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — PHP 8 attribute-style plugins (#[PluginType])', () => { + it('detects a Block plugin via PHP 8 attribute', () => { + const src = ` r.referenceName === 'drupal:plugin:Block'); + expect(pluginRef).toBeDefined(); + expect(pluginRef!.referenceKind).toBe('decorates'); + }); + + it('detects an Action plugin via PHP 8 attribute', () => { + const src = ` r.referenceName === 'drupal:plugin:Action')).toBeDefined(); + }); + + it('handles PHP 8 attribute on a single line', () => { + const src = ` r.referenceName === 'drupal:plugin:Block')).toBeDefined(); + }); + + it('does not confuse PHP 8 hook attributes with plugin attributes', () => { + const src = ` r.referenceName === 'hook_entity_presave')).toBeDefined(); + expect(references.filter((r) => r.referenceName.startsWith('drupal:plugin:'))).toHaveLength(0); + }); + + it('detects ContentEntityType plugin (Drupal 11.1+ entity definition)', () => { + const src = ` r.referenceName === 'drupal:plugin:ContentEntityType')).toBeDefined(); + }); + + it('detects RestResource plugin', () => { + const src = ` '/api/my-resource'], +)] +class MyResource extends ResourceBase {} +`; + const { references } = drupalResolver.extract!( + 'web/modules/custom/mymodule/src/Plugin/rest/resource/MyResource.php', + src, + ); + expect(references.find((r) => r.referenceName === 'drupal:plugin:RestResource')).toBeDefined(); + }); + + it('detects Layout plugin via docblock annotation', () => { + const src = ` r.referenceName === 'drupal:plugin:Layout')).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// extract() — Symfony event subscriber detection (*.services.yml) +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — event subscribers (*.services.yml)', () => { + it('emits a component node and references edge for an event_subscriber service', () => { + const src = ` +services: + mymodule.event_subscriber: + class: Drupal\\mymodule\\EventSubscriber\\MyEventSubscriber + tags: + - { name: event_subscriber } +`; + const { nodes, references } = drupalResolver.extract!( + 'web/modules/custom/mymodule/mymodule.services.yml', + src, + ); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.kind).toBe('component'); + expect(nodes[0]!.name).toBe('mymodule.event_subscriber'); + expect(references).toHaveLength(1); + expect(references[0]!.referenceName).toBe( + 'Drupal\\mymodule\\EventSubscriber\\MyEventSubscriber', + ); + expect(references[0]!.referenceKind).toBe('references'); + }); + + it('handles multiple event subscribers in one services.yml', () => { + const src = ` +services: + mymodule.subscriber_a: + class: Drupal\\mymodule\\EventSubscriber\\SubscriberA + tags: + - { name: event_subscriber } + + mymodule.subscriber_b: + class: Drupal\\mymodule\\EventSubscriber\\SubscriberB + tags: + - { name: event_subscriber } +`; + const { nodes } = drupalResolver.extract!( + 'web/modules/custom/mymodule/mymodule.services.yml', + src, + ); + expect(nodes).toHaveLength(2); + expect(nodes.map((n) => n.name)).toContain('mymodule.subscriber_a'); + expect(nodes.map((n) => n.name)).toContain('mymodule.subscriber_b'); + }); + + it('ignores services that are not tagged event_subscriber', () => { + const src = ` +services: + mymodule.some_service: + class: Drupal\\mymodule\\SomeService + tags: + - { name: cache_context } + + mymodule.subscriber: + class: Drupal\\mymodule\\EventSubscriber\\Sub + tags: + - { name: event_subscriber } +`; + const { nodes } = drupalResolver.extract!( + 'web/modules/custom/mymodule/mymodule.services.yml', + src, + ); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.name).toBe('mymodule.subscriber'); + }); + + it('ignores services without a class key', () => { + const src = ` +services: + mymodule.abstract_subscriber: + abstract: true + tags: + - { name: event_subscriber } +`; + const { nodes } = drupalResolver.extract!( + 'web/modules/custom/mymodule/mymodule.services.yml', + src, + ); + expect(nodes).toHaveLength(0); + }); + + it('returns empty results for a routing.yml passed as services.yml', () => { + const src = ` +mymodule.page: + path: '/page' + defaults: + _controller: '\\Drupal\\mymodule\\Controller\\PageController::build' +`; + const { nodes, references } = drupalResolver.extract!( + 'web/modules/custom/mymodule/mymodule.services.yml', + src, + ); + expect(nodes).toHaveLength(0); + expect(references).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// resolve() — plugin and event subscriber resolution +// --------------------------------------------------------------------------- + +describe('drupalResolver.resolve — plugin and subscriber resolution', () => { + it('resolves drupal:plugin:Block to a BlockBase class when indexed', () => { + const blockBase = { + id: 'class:blockbase', + kind: 'class' as const, + name: 'BlockBase', + qualifiedName: 'Drupal\\Core\\Block\\BlockBase', + filePath: 'core/lib/Drupal/Core/Block/BlockBase.php', + language: 'php' as const, + startLine: 1, endLine: 100, startColumn: 0, endColumn: 0, updatedAt: 0, + }; + const ctx = makeContext({ + getNodesByName: (name) => (name === 'BlockBase' ? [blockBase] : []), + }); + const ref = { + fromNodeId: 'class:myplugin', + referenceName: 'drupal:plugin:Block', + referenceKind: 'decorates' as const, + line: 10, column: 0, + filePath: 'mymodule/src/Plugin/Block/MyBlock.php', + language: 'php' as const, + }; + const resolved = drupalResolver.resolve(ref, ctx); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('class:blockbase'); + expect(resolved!.confidence).toBeGreaterThan(0.5); + }); + + it('returns null for drupal:plugin:Block when no base class is indexed', () => { + const ctx = makeContext({ getNodesByName: () => [], getNodesByKind: () => [] }); + const ref = { + fromNodeId: 'class:myplugin', + referenceName: 'drupal:plugin:Block', + referenceKind: 'decorates' as const, + line: 10, column: 0, + filePath: 'mymodule/src/Plugin/Block/MyBlock.php', + language: 'php' as const, + }; + expect(drupalResolver.resolve(ref, ctx)).toBeNull(); + }); + + it('resolves an event subscriber FQCN to the PHP class node', () => { + const subscriberClass = { + id: 'class:subscriber', + kind: 'class' as const, + name: 'MyEventSubscriber', + qualifiedName: 'Drupal\\mymodule\\EventSubscriber\\MyEventSubscriber', + filePath: 'web/modules/custom/mymodule/src/EventSubscriber/MyEventSubscriber.php', + language: 'php' as const, + startLine: 1, endLine: 50, startColumn: 0, endColumn: 0, updatedAt: 0, + }; + const ctx = makeContext({ + getNodesByName: (name) => (name === 'MyEventSubscriber' ? [subscriberClass] : []), + }); + const ref = { + fromNodeId: 'component:services', + referenceName: 'Drupal\\mymodule\\EventSubscriber\\MyEventSubscriber', + referenceKind: 'references' as const, + line: 3, column: 0, + filePath: 'mymodule.services.yml', + language: 'yaml' as const, + }; + const resolved = drupalResolver.resolve(ref, ctx); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('class:subscriber'); + }); +}); + // --------------------------------------------------------------------------- // End-to-end integration test // --------------------------------------------------------------------------- diff --git a/src/resolution/frameworks/drupal.ts b/src/resolution/frameworks/drupal.ts index 2049d264..8f76caf5 100644 --- a/src/resolution/frameworks/drupal.ts +++ b/src/resolution/frameworks/drupal.ts @@ -37,14 +37,29 @@ * symbol extraction is performed (no tree-sitter Twig grammar). Implement when a Twig * grammar WASM is available. * + * 4. **OOP hook detection** (`#[Hook]` attribute, Drupal 10.2+) — scans any `.php` file for + * methods decorated with `#[Hook('hookName')]` and emits the same `UnresolvedRef` → + * `hook_hookName` as procedural Strategy A. Handles stacked attributes and blank lines + * between the attribute and the method definition. + * + * 5. **Plugin declaration detection** — scans `.php` files for both: + * a. Docblock annotation style (pre-10.2): `@Block(id = "my_block", ...)` preceding a + * class definition. + * b. PHP 8 attribute style (10.2+): `#[Block(id: 'my_block', ...)]` preceding a class. + * Emits a `decorates` UnresolvedRef from the class node to a canonical plugin-type name + * `drupal:plugin:{PluginType}` (e.g. `drupal:plugin:Block`). Resolving one surfaces the + * plugin base class if it is indexed. + * + * 6. **Symfony event subscribers** — parses `*.services.yml` files for services tagged + * `event_subscriber`. Emits a `component` node for each service ID and a `references` edge + * to the PHP class. Resolves via the existing FQCN resolver (strips namespace, finds class). + * * ## TODOs for future iterations * - * - TODO: Extract service definitions from `*.services.yml` files (class → service-id edges). - * - TODO: Extract plugin annotations (`@Block`, `@FormElement`, `@Field`, etc.) from PHP - * docblocks and emit plugin nodes with references to the annotated class. * - TODO: Add Twig symbol extraction when a tree-sitter Twig grammar becomes available. * - TODO: Improve hook resolution: create virtual `hook_*` nodes so `codegraph_callers` * returns all implementations even when Drupal core is not indexed. + * - TODO: Extract all service definitions from `*.services.yml`, not just event_subscriber. */ import { generateNodeId } from '../../extraction/tree-sitter-helpers'; @@ -199,11 +214,79 @@ function extractDrupalRoutes( } // --------------------------------------------------------------------------- -// Hook detection helpers +// Constants // --------------------------------------------------------------------------- const HOOK_FILE_EXTENSIONS = ['.module', '.install', '.theme', '.inc']; +/** + * Known Drupal plugin type names used in both docblock annotations (`@Block(...)`) + * and PHP 8 attributes (`#[Block(...)]`). Covers all core plugin types converted to + * attributes as of Drupal 10.3/11.x (see drupal.org/project/drupal/issues/3396165). + */ +const DRUPAL_PLUGIN_TYPES = new Set([ + // Content/config entity types (Drupal 11.1+) + 'ContentEntityType', 'ConfigEntityType', 'EntityType', + // Block system + 'Block', + // CKEditor + 'CKEditor5Plugin', 'Editor', + // Condition / access + 'Condition', + // Constraint (Typed Data validation) + 'Constraint', 'DataType', + // Display variants + 'DisplayVariant', 'PageDisplayVariant', + // Entity reference + 'EntityReferenceSelection', + // Field API + 'Field', 'FieldFormatter', 'FieldWidget', 'FieldType', + // Filter + 'Filter', 'FormElement', 'RenderElement', + // Help + 'HelpSection', + // Image + 'ImageEffect', 'ImageToolkit', 'ImageToolkitOperation', + // Language + 'LanguageNegotiation', + // Layout / Section storage (Layout Builder) + 'Layout', 'SectionStorage', + // Mail + 'Mail', + // Media + 'MediaSource', + // Menu + 'Menu', 'MenuLink', + // Migrate + 'MigrateSource', 'MigrateProcess', 'MigrateProcessPlugin', + 'MigrateDestination', 'MigrateField', + // Miscellaneous + 'Archiver', + // Queue + 'QueueWorker', + // REST + 'RestResource', + // Search + 'SearchPlugin', + // Stream wrapper + 'StreamWrapper', + // Action + 'Action', + // Views — display plugins + 'ViewsDisplay', 'ViewsDisplayExtender', + // Views — field / sort / filter / argument + 'ViewsField', 'ViewsFilter', 'ViewsSort', 'ViewsArgument', + 'ViewsArgumentDefault', 'ViewsArgumentValidator', + // Views — area / row / style + 'ViewsArea', 'ViewsRow', 'ViewsStyle', + // Views — other + 'ViewsAccess', 'ViewsCache', 'ViewsExposedForm', + 'ViewsJoin', 'ViewsPager', 'ViewsQuery', + 'ViewsRelationship', 'ViewsWizard', + // Workflow + 'WorkflowType', +]); + function isDrupalHookFile(filePath: string): boolean { return HOOK_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext)); } @@ -289,6 +372,395 @@ function extractDrupalHooks( return { nodes: [], references }; } +// --------------------------------------------------------------------------- +// OOP hook detection (#[Hook] attribute, Drupal 10.2+) +// --------------------------------------------------------------------------- + +/** + * Scan any PHP file for OOP hook implementations using the `#[Hook]` attribute. + * + * Drupal 10.2+ supports two placement styles: + * + * **Method-level** (most common) — attribute directly above the method: + * #[Hook('entity_presave')] + * public function entityPresave(EntityInterface $entity): void { ... } + * + * **Class-level with `method:` param** — attribute on the class naming a method: + * #[Hook('form_alter', method: 'formAlter')] + * class MyHooks { + * public function formAlter(&$form, $state, $id): void { ... } + * } + * + * **Class-level with `__invoke()`** — attribute on the class; `__invoke` is the impl: + * #[Hook('cron')] + * class CronHandler { + * public function __invoke(): void { ... } + * } + * + * **Stacked attributes** — one method implements multiple hooks: + * #[Hook('comment_insert')] + * #[Hook('comment_update')] + * public function commentSave(CommentInterface $c): void { ... } + * + * Emits an UnresolvedRef per hook name → `hook_{name}` from the method node + * (kind='method') so `codegraph_callers` finds both procedural and OOP impls. + */ +function extractPhp8Hooks( + filePath: string, + content: string, +): { nodes: Node[]; references: UnresolvedRef[] } { + const references: UnresolvedRef[] = []; + const lines = content.split('\n'); + + // --- Method-level hooks (and stacked attributes on methods) --- + // Accumulate all pending hook names; reset when a method or non-preamble line is hit. + const pendingHooks: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + + // #[Hook('hookName')] or #[Hook('hookName', method: ..., module: ...)] + // Accumulate rather than overwrite so stacked hooks all get emitted. + const hookAttrMatch = trimmed.match(/^#\[Hook\(['"](\w+)['"]/); + if (hookAttrMatch) { + pendingHooks.push(hookAttrMatch[1]!); + continue; + } + + if (pendingHooks.length > 0) { + // Other PHP attributes (non-Hook) — keep waiting for the method + if (trimmed.startsWith('#[')) continue; + // Blank lines / comment lines — keep waiting + if (!trimmed || trimmed.startsWith('*') || trimmed.startsWith('//')) continue; + + // Method/function definition line (may have visibility modifiers before `function`) + const fnMatch = line.match(/function\s+(\w+)\s*\(/); + if (fnMatch) { + const methodName = fnMatch[1]!; + const lineNum = i + 1; + for (const hookName of pendingHooks) { + references.push({ + fromNodeId: generateNodeId(filePath, 'method', methodName, lineNum), + referenceName: `hook_${hookName}`, + referenceKind: 'references', + line: lineNum, + column: 0, + filePath, + language: 'php', + }); + } + } + // Consumed (or lost on class/statement line) — always reset + pendingHooks.length = 0; + } + } + + // --- Class-level hooks --- + // Detect #[Hook('name')] or #[Hook('name', method: 'methodName')] placed on a class. + // When method: is omitted the target is always __invoke(). + for (let i = 0; i < lines.length; i++) { + // Collect all #[Hook] attributes preceding the class keyword (may be stacked) + const classLevelHooks: Array<{ hookName: string; targetMethod: string }> = []; + let j = i; + while (j < lines.length) { + const t = lines[j]!.trim(); + const m = t.match(/^#\[Hook\(['"](\w+)['"]\s*(?:,\s*method:\s*['"](\w+)['"])?\s*\)\]/); + if (m) { + classLevelHooks.push({ hookName: m[1]!, targetMethod: m[2] ?? '__invoke' }); + j++; + continue; + } + // Non-Hook attributes or blank lines are fine preamble; other content stops collection + if (t.startsWith('#[') || !t || t.startsWith('//') || t.startsWith('*')) { + j++; + continue; + } + break; + } + + if (classLevelHooks.length === 0) continue; + + // Check that the first non-preamble line is a class definition + const classLine = lines[j]!; + if (!classLine || !/(?:^|\s)class\s+\w+/.test(classLine)) { + i = j; // advance past the non-matching block + continue; + } + + // Scan inside the class for each target method (track brace depth to stay in scope) + let braceDepth = 0; + let k = j; + while (k < lines.length) { + const kLine = lines[k]!; + braceDepth += (kLine.match(/\{/g) ?? []).length; + braceDepth -= (kLine.match(/\}/g) ?? []).length; + if (braceDepth <= 0 && k > j) break; // exited the class body + + const fnMatch = kLine.match(/function\s+(\w+)\s*\(/); + if (fnMatch) { + const foundMethod = fnMatch[1]!; + const lineNum = k + 1; + for (const { hookName, targetMethod } of classLevelHooks) { + if (foundMethod === targetMethod) { + references.push({ + fromNodeId: generateNodeId(filePath, 'method', foundMethod, lineNum), + referenceName: `hook_${hookName}`, + referenceKind: 'references', + line: lineNum, + column: 0, + filePath, + language: 'php', + }); + } + } + } + k++; + } + + i = j; // advance outer loop past the class-level attribute lines + } + + return { nodes: [], references }; +} + +// --------------------------------------------------------------------------- +// Plugin declaration detection (annotation + PHP 8 attribute) +// --------------------------------------------------------------------------- + +/** + * Scan a PHP file for Drupal plugin declarations and emit `decorates` edges + * from the class node to a canonical `drupal:plugin:{PluginType}` name. + * + * Handles two styles: + * + * **Annotation style (pre-Drupal 10.2)** + * /** + * * @Block(id = "my_block", admin_label = @Translation("My Block")) + * *\/ + * class MyBlock extends BlockBase { ... } + * + * **PHP 8 attribute style (Drupal 10.2+)** + * #[Block(id: 'my_block', admin_label: new TranslatableMarkup('My Block'))] + * class MyBlock extends BlockBase { ... } + * + * The `decorates` edge uses `drupal:plugin:{PluginType}` as the reference name + * so `codegraph_search("drupal:plugin:Block")` returns all Block plugin classes. + * The `resolve()` method attempts to link these to the plugin base class when it + * is indexed. + */ +function extractPluginDeclarations( + filePath: string, + content: string, +): { nodes: Node[]; references: UnresolvedRef[] } { + const references: UnresolvedRef[] = []; + const lines = content.split('\n'); + + let pendingPlugin: string | null = null; + let inDocblock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + + // --- Docblock annotation style --- + if (trimmed.startsWith('/**') || trimmed === '/**') { + inDocblock = true; + // New docblock resets any earlier pending plugin from a different docblock + pendingPlugin = null; + } + + if (inDocblock) { + // Look for @PluginType( inside the docblock + for (const pluginType of DRUPAL_PLUGIN_TYPES) { + if (trimmed.includes(`@${pluginType}(`)) { + pendingPlugin = pluginType; + break; + } + } + if (trimmed.includes('*/')) { + inDocblock = false; + // pendingPlugin (if set) carries forward to the next class definition + } + continue; + } + + // --- PHP 8 attribute style --- + if (trimmed.startsWith('#[')) { + for (const pluginType of DRUPAL_PLUGIN_TYPES) { + // Match #[Block(, #[Block , #[Block\n (multiline) + if (trimmed.startsWith(`#[${pluginType}(`) || trimmed === `#[${pluginType}]`) { + pendingPlugin = pluginType; + break; + } + } + } + + // --- Class definition following an annotation or attribute --- + if (pendingPlugin !== null) { + // Lines that are clearly part of a multi-line attribute body or harmless preamble — + // keep waiting. Closing brackets `)`, `)]`, `]` come from multi-line attrs like + // `#[Block(\n id: '...',\n)]`. + if (!trimmed || + trimmed.startsWith('//') || + trimmed.startsWith('#[') || + trimmed.startsWith('use ') || + trimmed.startsWith('*') || + /^[\)\]\},]/.test(trimmed)) { + continue; + } + + const classMatch = line.match(/^(?:\s*)(?:(?:abstract|final|readonly)\s+)*class\s+(\w+)/); + if (classMatch) { + const className = classMatch[1]!; + const lineNum = i + 1; + references.push({ + fromNodeId: generateNodeId(filePath, 'class', className, lineNum), + referenceName: `drupal:plugin:${pendingPlugin}`, + referenceKind: 'decorates', + line: lineNum, + column: 0, + filePath, + language: 'php', + }); + pendingPlugin = null; + continue; + } + + // Reset only on lines that look like top-level PHP statements (not class preamble) + if (/^(?:function\s|\$|return\b|echo\b|if\s*\(|for\s*\(|while\s*\()/.test(trimmed)) { + pendingPlugin = null; + } + // Otherwise keep waiting — could be attribute argument lines or blank preamble + } + } + + return { nodes: [], references }; +} + +// --------------------------------------------------------------------------- +// Symfony event subscriber detection (*.services.yml) +// --------------------------------------------------------------------------- + +/** + * Parse a Drupal `*.services.yml` file and emit `component` nodes for services + * tagged `event_subscriber`, each with a `references` edge to the PHP class. + * + * Example: + * + * services: + * mymodule.event_subscriber: + * class: Drupal\mymodule\EventSubscriber\MySubscriber + * tags: + * - { name: event_subscriber } + * + * Uses a line-by-line state machine rather than a full YAML parser to avoid + * pulling in a runtime dependency. Handles both compact `{ name: event_subscriber }` + * tag entries and expanded multi-line tag blocks. + */ +function extractServicesYml( + filePath: string, + content: string, +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + const lines = content.split('\n'); + + // State for the current service block + let currentServiceId: string | null = null; + let currentServiceLine = 0; + let currentClass: string | null = null; + let inTagsBlock = false; + let isEventSubscriber = false; + + const flushService = () => { + if (currentServiceId && currentClass && isEventSubscriber) { + const serviceNode: Node = { + id: `component:${filePath}:${currentServiceLine}:${currentServiceId}`, + kind: 'component', + name: currentServiceId, + qualifiedName: `${filePath}::${currentServiceId}`, + filePath, + startLine: currentServiceLine, + endLine: currentServiceLine, + startColumn: 0, + endColumn: 0, + language: 'yaml', + updatedAt: now, + }; + nodes.push(serviceNode); + references.push({ + fromNodeId: serviceNode.id, + referenceName: currentClass, + referenceKind: 'references', + line: currentServiceLine, + column: 0, + filePath, + language: 'yaml', + }); + } + currentServiceId = null; + currentClass = null; + inTagsBlock = false; + isEventSubscriber = false; + }; + + let inServicesBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (!line.trim() || line.trim().startsWith('#')) continue; + + // Top-level `services:` key + if (/^services\s*:/.test(line)) { + inServicesBlock = true; + continue; + } + + if (!inServicesBlock) continue; + + // Service ID: 2-space indented key ending with colon (e.g. ` mymodule.subscriber:`) + const serviceIdMatch = line.match(/^ (\S[^:]+):\s*$/); + if (serviceIdMatch) { + flushService(); + currentServiceId = serviceIdMatch[1]!.trim(); + currentServiceLine = i + 1; + inTagsBlock = false; + continue; + } + + if (!currentServiceId) continue; + + // ` class: Drupal\...\ClassName` + const classMatch = line.match(/^\s{4}class:\s*(\S+)/); + if (classMatch) { + currentClass = classMatch[1]!.trim(); + continue; + } + + // ` tags:` — marks start of the tags block + if (/^\s{4}tags\s*:/.test(line)) { + inTagsBlock = true; + continue; + } + + // Inside tags block: look for event_subscriber + if (inTagsBlock) { + if (line.includes('event_subscriber')) { + isEventSubscriber = true; + } + // A non-tag-indent line means we've left the tags block + if (!/^\s{6}/.test(line) && line.trim() && !line.trim().startsWith('-')) { + inTagsBlock = false; + } + } + } + + flushService(); + return { nodes, references }; +} + // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- @@ -340,19 +812,52 @@ export const drupalResolver: FrameworkResolver = { } } - // hook_X — find any function whose name ends in _{hookSuffix} in a hook file + // hook_X — find any function or method whose name ends in _{hookSuffix} if (name.startsWith('hook_')) { const hookSuffix = name.slice(5); // strip 'hook_' - const candidates = context.getNodesByKind('function').filter( + // Procedural implementations (functions in hook files) + const funcCandidates = context.getNodesByKind('function').filter( (n) => n.name.endsWith(`_${hookSuffix}`) && isDrupalHookFile(n.filePath) ); - if (candidates.length > 0) { - return { - original: ref, - targetNodeId: candidates[0]!.id, - confidence: 0.75, - resolvedBy: 'framework', - }; + if (funcCandidates.length > 0) { + return { original: ref, targetNodeId: funcCandidates[0]!.id, confidence: 0.75, resolvedBy: 'framework' }; + } + // OOP implementations via #[Hook] attribute (method name is arbitrary) + const methodCandidates = context.getNodesByKind('method').filter( + (n) => n.name.endsWith(`_${hookSuffix}`) || n.name === hookSuffix + ); + if (methodCandidates.length > 0) { + return { original: ref, targetNodeId: methodCandidates[0]!.id, confidence: 0.65, resolvedBy: 'framework' }; + } + } + + // drupal:plugin:{PluginType} — resolve to the plugin base class if indexed + const pluginTypeMatch = name.match(/^drupal:plugin:(\w+)$/); + if (pluginTypeMatch) { + const pluginType = pluginTypeMatch[1]!; + // Common base-class naming convention: {PluginType}Base (e.g. BlockBase, FilterBase) + const baseClassName = `${pluginType}Base`; + const baseClass = context.getNodesByName(baseClassName).find((n) => n.kind === 'class'); + if (baseClass) { + return { original: ref, targetNodeId: baseClass.id, confidence: 0.6, resolvedBy: 'framework' }; + } + // Fallback: any class whose name contains the plugin type + const fallback = context.getNodesByKind('class').find( + (n) => n.name.toLowerCase().includes(pluginType.toLowerCase()) && n.name !== pluginType + ); + if (fallback) { + return { original: ref, targetNodeId: fallback.id, confidence: 0.4, resolvedBy: 'framework' }; + } + } + + // event_subscriber class FQCN — resolve to the PHP class node + if (name.includes('\\') && !name.includes('::')) { + const className = lastSegment(name); + if (className) { + const cls = context.getNodesByName(className).find((n) => n.kind === 'class'); + if (cls) { + return { original: ref, targetNodeId: cls.id, confidence: 0.85, resolvedBy: 'framework' }; + } } } @@ -364,8 +869,18 @@ export const drupalResolver: FrameworkResolver = { return extractDrupalRoutes(filePath, content); } + if (filePath.endsWith('.services.yml')) { + return extractServicesYml(filePath, content); + } + if (isDrupalHookFile(filePath) || filePath.endsWith('.php')) { - return extractDrupalHooks(filePath, content); + const procedural = extractDrupalHooks(filePath, content); + const oop = extractPhp8Hooks(filePath, content); + const plugins = extractPluginDeclarations(filePath, content); + return { + nodes: [...procedural.nodes, ...oop.nodes, ...plugins.nodes], + references: [...procedural.references, ...oop.references, ...plugins.references], + }; } return { nodes: [], references: [] };