diff --git a/CHANGELOG.md b/CHANGELOG.md index abc341c09..2e651f74c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- CodeGraph now understands Drupal's service container and plugin definitions. Service definitions in `*.services.yml` are linked to the PHP class that implements them and to the other services they depend on, so `codegraph_explore` can trace Drupal's dependency injection — which was previously invisible. Plugin definitions are also picked up by their plugin id, covering both the Drupal 11 PHP 8 attribute style (`#[Block(...)]`, `#[FieldType(...)]`, …) and the legacy docblock annotation style (`@Block(...)`, `@FieldType(...)`, …), each linked to the class it decorates. + ## [1.1.6] - 2026-06-30 diff --git a/__tests__/drupal.test.ts b/__tests__/drupal.test.ts index c4f4421e9..0c99db968 100644 --- a/__tests__/drupal.test.ts +++ b/__tests__/drupal.test.ts @@ -528,6 +528,461 @@ describe('drupalResolver.resolve', () => { }); }); +// --------------------------------------------------------------------------- +// extract() — services.yml (DI container wiring) +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — services.yml', () => { + const services = ` +services: + + # A custom service. + weller_localization.domain_alias: + class: Drupal\\weller_localization\\DomainAlias + arguments: [ '@domain.negotiator' ] + + # Overrides a core service. + path_alias.manager: + class: Drupal\\weller_localization\\AliasManager + arguments: ['@path_alias.repository', '@language_manager'] + tags: + - { name: backend_overridable } +`; + + it('emits a component node for each service id', () => { + const { nodes } = drupalResolver.extract!( + 'weller_localization/weller_localization.services.yml', + services, + ); + const names = nodes.map((n) => n.name); + expect(names).toContain('weller_localization.domain_alias'); + expect(names).toContain('path_alias.manager'); + expect(nodes.every((n) => n.kind === 'component')).toBe(true); + }); + + it('sets qualifiedName to filePath::service:', () => { + const { nodes } = drupalResolver.extract!( + 'weller_localization/weller_localization.services.yml', + services, + ); + const node = nodes.find((n) => n.name === 'weller_localization.domain_alias'); + expect(node!.qualifiedName).toBe( + 'weller_localization/weller_localization.services.yml::service:weller_localization.domain_alias', + ); + }); + + it('emits a references edge from the service to its class FQCN', () => { + const { nodes, references } = drupalResolver.extract!( + 'weller_localization/weller_localization.services.yml', + services, + ); + const svc = nodes.find((n) => n.name === 'weller_localization.domain_alias')!; + const classRef = references.find( + (r) => r.fromNodeId === svc.id && r.referenceName === 'Drupal\\weller_localization\\DomainAlias', + ); + expect(classRef).toBeDefined(); + expect(classRef!.referenceKind).toBe('references'); + }); + + it('emits service→service references for @arguments', () => { + const { nodes, references } = drupalResolver.extract!( + 'weller_localization/weller_localization.services.yml', + services, + ); + const svc = nodes.find((n) => n.name === 'path_alias.manager')!; + const argRefs = references.filter((r) => r.fromNodeId === svc.id); + const argNames = argRefs.map((r) => r.referenceName); + // class ref + the two @service args (without the @) + expect(argNames).toContain('path_alias.repository'); + expect(argNames).toContain('language_manager'); + }); + + it('does not treat the top-level "services" key as a service id', () => { + const { nodes } = drupalResolver.extract!('m/m.services.yml', services); + expect(nodes.map((n) => n.name)).not.toContain('services'); + }); + + it('returns empty result for a services.yml with no services', () => { + const { nodes, references } = drupalResolver.extract!( + 'm/m.services.yml', + 'services:\n', + ); + expect(nodes).toHaveLength(0); + expect(references).toHaveLength(0); + }); +}); + +describe('drupalResolver.resolve — services', () => { + it('resolves a bare service-class FQCN to the class node', () => { + const classNode = { + id: 'class:da', + kind: 'class' as const, + name: 'DomainAlias', + qualifiedName: 'DomainAlias', + filePath: 'weller_localization/src/DomainAlias.php', + language: 'php' as const, + startLine: 1, + endLine: 10, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + const ctx = makeContext({ + getNodesByName: (name) => (name === 'DomainAlias' ? [classNode] : []), + }); + const ref = { + fromNodeId: 'service:x', + referenceName: 'Drupal\\weller_localization\\DomainAlias', + referenceKind: 'references' as const, + line: 1, + column: 0, + filePath: 'weller_localization/weller_localization.services.yml', + language: 'yaml' as const, + }; + const resolved = drupalResolver.resolve(ref, ctx); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('class:da'); + }); + + it('resolves a service-argument id to the referenced service node', () => { + const svcNode = { + id: 'component:svc-repo', + kind: 'component' as const, + name: 'path_alias.repository', + qualifiedName: 'm/m.services.yml::service:path_alias.repository', + filePath: 'm/m.services.yml', + language: 'yaml' as const, + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + const ctx = makeContext({ + getNodesByName: (name) => (name === 'path_alias.repository' ? [svcNode] : []), + }); + const ref = { + fromNodeId: 'component:svc-mgr', + referenceName: 'path_alias.repository', + referenceKind: 'references' as const, + line: 1, + column: 0, + filePath: 'm/m.services.yml', + language: 'yaml' as const, + }; + const resolved = drupalResolver.resolve(ref, ctx); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('component:svc-repo'); + }); +}); + +// --------------------------------------------------------------------------- +// extract()/resolve() — services.yml edge cases (code-review regressions) +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — services.yml edge cases', () => { + it('parses @service arguments written as a multi-line block list', () => { + const src = ` +services: + my.service: + class: Drupal\\my\\Service + arguments: + - '@path_alias.repository' + - '@language_manager' +`; + const { nodes, references } = drupalResolver.extract!('m/m.services.yml', src); + const svc = nodes.find((n) => n.name === 'my.service')!; + const argNames = references.filter((r) => r.fromNodeId === svc.id).map((r) => r.referenceName); + expect(argNames).toContain('path_alias.repository'); + expect(argNames).toContain('language_manager'); + }); + + it('ignores Symfony/Drupal DI directive keys (_defaults, _instanceof)', () => { + const src = ` +services: + _defaults: + autowire: true + _instanceof: + Drupal\\Foo: ~ + my.service: + class: Drupal\\my\\Service +`; + const { nodes } = drupalResolver.extract!('m/m.services.yml', src); + const names = nodes.map((n) => n.name); + expect(names).toContain('my.service'); + expect(names).not.toContain('_defaults'); + expect(names).not.toContain('_instanceof'); + }); + + it('recognizes a service id that carries a trailing comment', () => { + const src = ` +services: + my.service: # the main service + class: Drupal\\my\\Service +`; + const { nodes } = drupalResolver.extract!('m/m.services.yml', src); + expect(nodes.map((n) => n.name)).toContain('my.service'); + }); + + it('does not capture @tokens from a trailing comment on the arguments line', () => { + const src = ` +services: + my.service: + class: Drupal\\my\\Service + arguments: ['@real.dep'] # see @bogus.from.comment +`; + const { nodes, references } = drupalResolver.extract!('m/m.services.yml', src); + const svc = nodes.find((n) => n.name === 'my.service')!; + const argNames = references.filter((r) => r.fromNodeId === svc.id).map((r) => r.referenceName); + expect(argNames).toContain('real.dep'); + expect(argNames).not.toContain('bogus.from.comment'); + }); +}); + +describe('drupalResolver.resolve — services edge cases', () => { + const svcNode = { + id: 'component:svc-db', + kind: 'component' as const, + name: 'database', + qualifiedName: 'm/m.services.yml::service:database', + filePath: 'm/m.services.yml', + language: 'yaml' as const, + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + + it('resolves a dotless core-service argument id (e.g. "database")', () => { + const ctx = makeContext({ + getNodesByName: (name) => (name === 'database' ? [svcNode] : []), + }); + const ref = { + fromNodeId: 'component:caller', + referenceName: 'database', + referenceKind: 'references' as const, + line: 1, + column: 0, + filePath: 'm/m.services.yml', + language: 'yaml' as const, + }; + const resolved = drupalResolver.resolve(ref, ctx); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('component:svc-db'); + }); + + it('does NOT hijack a dotted entity-handler route ref to a same-named service', () => { + // A route's `_entity_form: node.default` previously stayed unresolved; it must not + // now mis-resolve to a DI service component that happens to share the dotted name. + const svc = { ...svcNode, id: 'component:nd', name: 'node.default', qualifiedName: 'm/m.services.yml::service:node.default' }; + const ctx = makeContext({ + getNodesByName: (name) => (name === 'node.default' ? [svc] : []), + }); + const ref = { + fromNodeId: 'route:x', + referenceName: 'node.default', + referenceKind: 'references' as const, + line: 1, + column: 0, + filePath: 'mymodule/mymodule.routing.yml', // NOT a services.yml + language: 'yaml' as const, + }; + expect(drupalResolver.resolve(ref, ctx)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extract() — plugin definitions (PHP 8 attributes + legacy annotations) +// --------------------------------------------------------------------------- + +describe('drupalResolver.extract — plugin attributes (Drupal 11)', () => { + it('emits a plugin component for a multi-line #[Block(...)] attribute', () => { + const src = ` n.name === 'shortcuts'); + expect(plugin).toBeDefined(); + expect(plugin!.kind).toBe('component'); + expect(plugin!.qualifiedName).toContain('plugin:Block:shortcuts'); + // references edge plugin → annotated class + const ref = references.find( + (r) => r.fromNodeId === plugin!.id && r.referenceName === 'ShortcutsBlock', + ); + expect(ref).toBeDefined(); + }); + + it('emits a plugin component for a single-line attribute with single quotes', () => { + const src = ` n.name === 'layout_builder_test_state'); + expect(plugin).toBeDefined(); + expect(plugin!.qualifiedName).toContain('plugin:SectionStorage:layout_builder_test_state'); + }); + + it('emits a plugin component for a #[FieldType(...)] attribute', () => { + const src = ` n.name === 'list_string')!; + expect(plugin.qualifiedName).toContain('plugin:FieldType:list_string'); + expect( + references.some((r) => r.fromNodeId === plugin.id && r.referenceName === 'ListStringItem'), + ).toBe(true); + }); + + it('ignores non-plugin attributes like #[DataProvider(...)]', () => { + const src = ` { + it('emits a plugin component for an @Block(...) annotation', () => { + const src = ` n.name === 'weller_hero'); + expect(plugin).toBeDefined(); + expect(plugin!.kind).toBe('component'); + expect(plugin!.qualifiedName).toContain('plugin:Block:weller_hero'); + expect( + references.some((r) => r.fromNodeId === plugin!.id && r.referenceName === 'Hero'), + ).toBe(true); + }); + + it('emits a plugin component for an @FieldType(...) annotation', () => { + const src = ` n.name === 'social_links_field'); + expect(plugin).toBeDefined(); + expect(plugin!.qualifiedName).toContain('plugin:FieldType:social_links_field'); + }); + + it('does not treat the nested @Translation() as a plugin', () => { + const src = ` n.name)).not.toContain('Translation'); + expect(nodes).toHaveLength(1); + }); + + it('captures the id when it appears AFTER a nested annotation (label first)', () => { + // Drupal core commonly orders `label = @Translation(...)` before `id = ...`. + const src = ` n.name === 'list_string'); + expect(plugin).toBeDefined(); + expect(plugin!.qualifiedName).toContain('plugin:FieldType:list_string'); + }); + + it('does not bind a stray plugin-type token to a far-away class', () => { + // A plugin-shaped token deep in a method body, with the next class far below it, + // must not produce a plugin edge (the proximity bound rejects the distant class). + const filler = '\n // padding padding padding padding padding padding\n'.repeat(20); + const src = ``), with a `references` + * edge to its `class:` FQCN and additional `references` edges to each `@other.service` + * argument. This wires Drupal's dependency-injection container into the graph. + * + * 5. **Plugin definitions** — scans `.php` files for Drupal plugin definitions, both + * Drupal 11 PHP 8 attributes (`#[Block(id: 'foo')]`, `#[FieldType(...)]`, …) and the + * legacy docblock annotations (`@Block(id = "foo")`, `@FieldType(...)`, …). Each becomes + * a `component` node (`qualifiedName` = `filePath::plugin::`) with a + * `references` edge to the annotated/attributed class. + * * ## Design decisions (review in future iterations) * * - Hook graph resolution (v1): hook references are stored as UnresolvedRef pointing to the @@ -29,9 +40,20 @@ * `codegraph_search("form_alter")`. Full hook-node creation (virtual nodes for every hook) * is deferred to a future iteration. * - * - Services / plugins (out of scope for v1): `*.services.yml` service definitions and plugin - * annotations (`@Block`, `@FormElement`, etc.) are not extracted. Add a TODO below when - * ready to implement. + * - Service / plugin node kind: there is no dedicated `service` or `plugin` NodeKind, so + * both reuse the generic `component` kind (the catch-all for framework-registered units, + * like routes do with `route`). They stay distinguishable by their `qualifiedName` + * prefix (`service:` vs `plugin::`). + * + * - Service id shapes: service ids are matched as `[A-Za-z][\w.]*` keys, which excludes DI + * directive keys (`_defaults`, `_instanceof` — leading `_`) and the rare class-FQCN-as-id + * shorthand (`Drupal\My\Service: ~` — contains `\`). The FQCN-as-id form is a known, + * uncommon gap left unhandled on purpose. + * + * - Plugin attributes vs annotations: Drupal 11 is attribute-first (`#[Block(...)]`) but a + * large body of contrib/legacy code still uses docblock annotations (`@Block(...)`). Both + * are parsed. Only the `id` is captured (the one stable, cross-version identifier); other + * attribute/annotation arguments (`admin_label`, `label`, …) are intentionally ignored. * * - Twig templates (out of scope for v1): `.twig` files are tracked as file nodes but no * symbol extraction is performed (no tree-sitter Twig grammar). Implement when a Twig @@ -39,9 +61,6 @@ * * ## 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. @@ -198,6 +217,363 @@ function extractDrupalRoutes( return { nodes, references }; } +// --------------------------------------------------------------------------- +// Service definition helpers (*.services.yml) +// --------------------------------------------------------------------------- + +/** + * Extract service-container definitions from a Drupal `*.services.yml` file. + * + * Drupal services YAML format: + * + * services: + * my_module.foo: + * class: Drupal\my_module\Foo + * arguments: ['@other.service', '@another.service'] + * tags: + * - { name: backend_overridable } + * + * For each service id we emit a `component` node and: + * - a `references` edge to its `class:` FQCN (resolved to the class node), and + * - a `references` edge to each `@service` argument (resolved to that service node). + * + * Parsed with the same line-based shape as `extractDrupalRoutes` — service ids sit at a + * single, fixed indent under the top-level `services:` key. + */ +function extractDrupalServices( + filePath: string, + content: string +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + + const lines = content.split('\n'); + + // Indent of the service-id keys (the first key under `services:`). Drupal uses 2 spaces + // but we detect it so an oddly-formatted file still parses. + let inServices = false; + let serviceIndent: number | null = null; + + let current: { node: Node } | null = null; + + const indentOf = (line: string): number => line.length - line.trimStart().length; + + // Strip a trailing YAML comment from a value, respecting that a `#` inside quotes is + // literal. Service ids/args don't contain quoted `#`, so a simple "first unquoted #" cut + // is enough and keeps us from capturing @tokens that live in an explanatory comment. + const stripComment = (value: string): string => { + let inSingle = false; + let inDouble = false; + for (let j = 0; j < value.length; j++) { + const c = value[j]!; + if (c === "'" && !inDouble) inSingle = !inSingle; + else if (c === '"' && !inSingle) inDouble = !inDouble; + else if (c === '#' && !inSingle && !inDouble) return value.slice(0, j); + } + return value; + }; + + const pushArgRefs = (fromId: string, lineNum: number, raw: string) => { + // raw is the inside of `arguments: [...]` (or a single value). Pull every '@service' + // token, ignoring anything in a trailing comment. + const argMatches = stripComment(raw).match(/@[?]?[\w.]+/g); + if (!argMatches) return; + for (const a of argMatches) { + const svcId = a.replace(/^@[?]?/, ''); + if (!svcId) continue; + references.push({ + fromNodeId: fromId, + referenceName: svcId, + referenceKind: 'references', + line: lineNum, + column: 0, + filePath, + language: 'yaml', + }); + } + }; + + // A service-id key: `my_module.foo:` (value-less, optional trailing comment) at the + // service indent. Directive keys begin with `_` (`_defaults`, `_instanceof`) and class + // FQCN keys contain `\` — neither is a service id, so the `\w`-anchored pattern excludes + // both. (FQCN-as-id service shorthand is a known, rare gap — see the header docblock.) + const SERVICE_ID = /^([A-Za-z][\w.]*):\s*(#.*)?$/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const indent = indentOf(line); + + // Top-level `services:` key. + if (indent === 0) { + inServices = /^services:\s*$/.test(trimmed); + serviceIndent = null; + current = null; + continue; + } + if (!inServices) continue; + + // The first indented line under `services:` fixes the service-id indent (whether that + // first key is value-less or carries an inline value). + if (serviceIndent === null) { + serviceIndent = indent; + } + + // A service-id line at the service indent. + const idMatch = indent === serviceIndent ? trimmed.match(SERVICE_ID) : null; + if (idMatch) { + const id = idMatch[1]!; + const node: Node = { + id: `component:${filePath}:${i + 1}:service:${id}`, + kind: 'component', + name: id, + qualifiedName: `${filePath}::service:${id}`, + filePath, + startLine: i + 1, + endLine: i + 1, + startColumn: 0, + endColumn: 0, + language: 'yaml', + updatedAt: now, + }; + nodes.push(node); + current = { node }; + continue; + } + + if (!current) continue; + + // class: Drupal\...\Foo + const classMatch = trimmed.match(/^class:\s*['"]?\\?([^'"#\n]+?)['"]?\s*(?:#.*)?$/); + if (classMatch) { + references.push({ + fromNodeId: current.node.id, + referenceName: classMatch[1]!.trim(), + referenceKind: 'references', + line: current.node.startLine, + column: 0, + filePath, + language: 'yaml', + }); + continue; + } + + // arguments: — two forms: + // inline: arguments: ['@a', '@b'] + // block list: arguments: + // - '@a' + // - '@b' + const argsMatch = trimmed.match(/^arguments:\s*(.*)$/); + if (argsMatch) { + const inline = argsMatch[1]!.trim(); + if (inline && !inline.startsWith('#')) { + // Inline form (or a single scalar value). + pushArgRefs(current.node.id, current.node.startLine, inline); + } else { + // Block-list form: consume the following deeper `- '@svc'` lines. + const argsIndent = indent; + for (let k = i + 1; k < lines.length; k++) { + const next = lines[k]!; + const nextTrim = next.trim(); + if (!nextTrim || nextTrim.startsWith('#')) continue; + if (indentOf(next) <= argsIndent) break; // back out to a sibling/parent key + if (nextTrim.startsWith('-')) { + pushArgRefs(current.node.id, current.node.startLine, nextTrim.slice(1)); + } + i = k; // advance the outer loop past consumed lines + } + } + continue; + } + } + + return { nodes, references }; +} + +// --------------------------------------------------------------------------- +// Plugin definition helpers (PHP 8 attributes + legacy annotations) +// --------------------------------------------------------------------------- + +/** + * Plugin-attribute / -annotation names we treat as Drupal plugin definitions. Drupal has + * dozens of plugin types; this is the common set seen across core + contrib. The list keeps + * us from mistaking unrelated PHP attributes (e.g. PHPUnit's `#[DataProvider]`, `#[Group]`) + * for plugins. A name not on this list is ignored — silent-and-correct beats false plugins. + */ +const DRUPAL_PLUGIN_TYPES = new Set([ + 'Action', + 'Block', + 'Condition', + 'Constraint', + 'ConfigEntityType', + 'ContentEntityType', + 'DataParser', + 'DataType', + 'EntityType', + 'EntityReferenceSelection', + 'FieldFormatter', + 'FieldType', + 'FieldWidget', + 'Filter', + 'FormElement', + 'JsonLdEntity', + 'JsonLdSource', + 'MigrateSource', + 'MigrateProcessPlugin', + 'MigrateDestination', + 'Mail', + 'Menu', + 'QueueWorker', + 'RenderElement', + 'RestResource', + 'SearchApiProcessor', + 'SectionStorage', + 'UrlGenerator', + 'ViewsArgument', + 'ViewsField', + 'ViewsFilter', + 'ViewsSort', +]); + +/** + * A plugin definition decorates the class declaration immediately following it (only + * blank lines, `use` statements, or other attributes intervene). Anything farther than + * this is not the decorated class — binding to it would be a wrong-target edge. The + * window is generous enough for a stack of sibling attributes plus a short `use` list. + */ +const PLUGIN_CLASS_MAX_GAP = 600; + +/** + * Find the name of the `class`/`interface`/`trait`/`enum` declared right after `fromIndex` + * in `content`, but only within `PLUGIN_CLASS_MAX_GAP` characters. Returns `{ name, line }` + * or null when no class declaration sits close enough — which keeps a plugin-shaped token + * found inside a method body or a string from binding to an unrelated later class. + */ +function findFollowingClass( + content: string, + fromIndex: number +): { name: string; line: number } | null { + const re = /\b(?:final\s+|abstract\s+|readonly\s+)*(?:class|interface|trait|enum)\s+(\w+)/g; + re.lastIndex = fromIndex; + const m = re.exec(content); + if (!m) return null; + if (m.index - fromIndex > PLUGIN_CLASS_MAX_GAP) return null; + return { name: m[1]!, line: content.slice(0, m.index).split('\n').length }; +} + +/** + * Given the index of the `(` that opens a plugin attribute/annotation argument list, + * return the substring between it and its MATCHING close paren (handling nested parens + * like `@Translation(...)` or `new TranslatableMarkup(...)`), plus the index just past the + * close. Returns null when the parens are unbalanced. A hand-rolled balanced scan is needed + * because a lazy `\)`-anchored regex stops at the first nested close and would miss an `id` + * that appears after a nested annotation (`label = @Translation(...), id = "x"`). + */ +function readBalancedParens( + content: string, + openIndex: number +): { body: string; endIndex: number } | null { + let depth = 0; + for (let j = openIndex; j < content.length; j++) { + const c = content[j]!; + if (c === '(') depth++; + else if (c === ')') { + depth--; + if (depth === 0) return { body: content.slice(openIndex + 1, j), endIndex: j + 1 }; + } + } + return null; +} + +/** + * Extract Drupal plugin definitions from a PHP file. Handles both: + * a. PHP 8 attributes: `#[Block(id: 'foo')]` / `#[FieldType(id: "bar", ...)]` + * b. Legacy annotations: `@Block(id = "foo")` / `@FieldType(id = "bar", ...)` in a docblock + * + * Only the plugin `id` is captured (the stable cross-version identifier). Each definition + * emits a `component` node and a `references` edge to the class it decorates. + */ +function extractDrupalPlugins( + filePath: string, + content: string +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + const seen = new Set(); + + const emit = (pluginType: string, id: string, matchIndex: number, searchFrom: number) => { + if (!DRUPAL_PLUGIN_TYPES.has(pluginType)) return; + const cls = findFollowingClass(content, searchFrom); + if (!cls) return; + const line = content.slice(0, matchIndex).split('\n').length; + const dedupeKey = `${pluginType}:${id}:${cls.name}`; + if (seen.has(dedupeKey)) return; + seen.add(dedupeKey); + + const node: Node = { + id: `component:${filePath}:${line}:plugin:${pluginType}:${id}`, + kind: 'component', + name: id, + qualifiedName: `${filePath}::plugin:${pluginType}:${id}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: 0, + language: 'php', + updatedAt: now, + }; + nodes.push(node); + references.push({ + fromNodeId: node.id, + referenceName: cls.name, + referenceKind: 'references', + line: cls.line, + column: 0, + filePath, + language: 'php', + }); + }; + + // (a) PHP 8 attributes: #[PluginType( ... id: 'foo' ... )] + // The id may use single or double quotes; the attribute may span multiple lines and + // contain nested calls (`new TranslatableMarkup(...)`), so the body is read by + // balanced-paren scan rather than a lazy regex. + const attrOpen = /#\[\s*(\w+)\s*\(/g; + let am: RegExpExecArray | null; + while ((am = attrOpen.exec(content)) !== null) { + const type = am[1]!; + if (!DRUPAL_PLUGIN_TYPES.has(type)) continue; + const parens = readBalancedParens(content, attrOpen.lastIndex - 1); + if (!parens) continue; + const idMatch = parens.body.match(/\bid:\s*['"]([^'"]+)['"]/); + if (!idMatch) continue; + emit(type, idMatch[1]!, am.index, parens.endIndex); + } + + // (b) Legacy docblock annotations: @PluginType( ... id = "foo" ... ) + // The body is read by balanced-paren scan so an `id` that follows a nested annotation + // (`label = @Translation(...), id = "x"`) is still captured. `@Translation(...)` and + // other nested annotations are not plugin types, so they're skipped before scanning. + const annotationOpen = /@(\w+)\s*\(/g; + let nm: RegExpExecArray | null; + while ((nm = annotationOpen.exec(content)) !== null) { + const type = nm[1]!; + if (!DRUPAL_PLUGIN_TYPES.has(type)) continue; + const parens = readBalancedParens(content, annotationOpen.lastIndex - 1); + if (!parens) continue; + const idMatch = parens.body.match(/\bid\s*=\s*['"]([^'"]+)['"]/); + if (!idMatch) continue; + emit(type, idMatch[1]!, nm.index, parens.endIndex); + } + + return { nodes, references }; +} + // --------------------------------------------------------------------------- // Hook detection helpers // --------------------------------------------------------------------------- @@ -381,6 +757,27 @@ export const drupalResolver: FrameworkResolver = { } } + // Service-argument id (`path_alias.repository`, `database`, `entity_type.manager`): a + // bare service name emitted from a *.services.yml `arguments:` list. Resolve to the + // matching service `component` node so the DI graph links service → service. + // + // Scoped to refs that ORIGINATE in a *.services.yml file: routing.yml entity-handler + // refs (`_entity_form: node.default`) are also bare dotted names, and resolving those + // to a same-named DI service would be a wrong-target flow (silent beats wrong). + if ( + ref.filePath.endsWith('.services.yml') && + !name.includes('\\') && + !name.includes('::') && + /^[\w.]+$/.test(name) + ) { + const svc = context + .getNodesByName(name) + .find((n) => n.kind === 'component' && n.qualifiedName.includes('::service:')); + if (svc) { + return { original: ref, targetNodeId: svc.id, confidence: 0.85, resolvedBy: 'framework' }; + } + } + // hook_X — find any function whose name ends in _{hookSuffix} in a hook file if (name.startsWith('hook_')) { const hookSuffix = name.slice(5); // strip 'hook_' @@ -405,8 +802,17 @@ export const drupalResolver: FrameworkResolver = { return extractDrupalRoutes(filePath, content); } + if (filePath.endsWith('.services.yml')) { + return extractDrupalServices(filePath, content); + } + if (isDrupalHookFile(filePath) || filePath.endsWith('.php')) { - return extractDrupalHooks(filePath, content); + const hooks = extractDrupalHooks(filePath, content); + const plugins = extractDrupalPlugins(filePath, content); + return { + nodes: [...hooks.nodes, ...plugins.nodes], + references: [...hooks.references, ...plugins.references], + }; } return { nodes: [], references: [] };