From a2870563d779914306da0ae8f90a41dfeb574011 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 30 Oct 2026 18:53:26 -0500 Subject: [PATCH] feat(drupal): services, plugin definitions and #[Hook] methods are part of the graph (#3118) Drupal wires most of a module by id, attribute and folder, so services, plugins and OOP hooks were missing from the graph, and a call through the service container stopped at `\Drupal::service()`. Built on PR #1077, the stronger base, with what's sound in PR #301: - *.services.yml: a component node per service, linked to its class and to the services it is given, in every place Symfony takes one (arguments, calls, factory, parent, decorates, aliases), class-named services included. Event subscribers are services and come with it. - A call on what the container hands out, `\Drupal::service('x')->m()` or `$container->get('x')->m()`, links to the method on that service's class, its own or inherited. Neither PR had this; it's what connects the services to a flow. - Plugins: an attribute or docblock annotation with an id, on a class under src/Plugin, src/Entity or src/Element, read by folder as Drupal's discovery does, rather than by the allowlists in both PRs. Those miss every contrib type: in farmOS and Commerce, LogType, AssetType, CommerceCondition and the like are a large share of the definitions. - `#[Hook('x')]` on a method, stacked, or on a class naming a method or meaning __invoke, from PR #301, reading the method's id from the attribute's line. Tree-sitter counts attributes as part of the method, so PR #301's id, taken from the `function` line, attached to no node. Left behind from PR #301: guessing a plugin's base class by name, which fell back to any class containing the type's name; matching a hook to any method of that name; and a stray CODEGRAPH_MCP_TRACE change to the proxy. Two things the A/B caught: - Named by bare id, as in PR #1077, a plugin or service answered for the class of that name: `new Currency()` landed on the plugin `Currency`, and `use Drupal\x\Y;` on the service `Drupal\x\Y`. Nodes are now `service:` and `:`, which no symbol can be, and search still finds them by id. - A FQCN resolved by its last segment, so `use Drupal\commerce_price\Price` also linked to the unrelated Element\Price. The namespace now decides first, and services use it alone. Validation: - drupal-services.test.ts, 25 tests, on the kernel and the wasm path; 21 fail with main's resolver. drupal.test.ts is unchanged and passes. - A/B on main cd04b21a: drupal/drupal 555acb12 (a 1,599-file subtree of 10 modules and core/lib), farmOS 95f2c4d8, Commerce 643bb396, GraphQL a1dc10f8, Islandora a518aea2. Edge classes: `+references framework` service to class or service, and plugin to class; `+calls framework` through the container (192, 19, 61, 0, 5); `+references exact-match` #[Hook] method to the hook (58, 47, 0, 0, 0); `-imports framework` (326, 80, 111, 0, 0). No other edge moves. - Site by site: every container call read against its line, 277 right; every #[Hook] edge, 105 right, no hook reference dangling; every service-to-class edge matches the id's namespace, 395 right; every plugin reaches its class in its own file, 718 right. - All 517 removed import edges pointed at the wrong class, and the class the `use` names is linked after. - Plugin files with no node are a class-constant id, tests, and helpers. - Flow: commerce_cart_cron -> Cron::run, which main can't connect. - Controls flask 49943b5a and gin 0f09c3a9 are byte-identical. - Index CPU, median of 3 interleaved, with a suite running: commerce -2.5%, farmOS -0.9%, flask -5.9%, gin +2.7%: noise. Left out: an id given as a class constant; `getSubscribedEvents()` to the methods it names; and PHP interface dispatch, which is why a call on an injected `FooInterface` still ends at the interface: the implementer pass doesn't run for PHP, and that's a language-level change of its own. Reimplements PR #1077 by @mikedamoiseau and the OOP hooks of PR #301 by @marcelovani. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 2 +- __tests__/drupal-services.test.ts | 402 ++++++++++++++++++++++++ src/resolution/frameworks/drupal.ts | 466 +++++++++++++++++++++++++++- 4 files changed, 854 insertions(+), 18 deletions(-) create mode 100644 __tests__/drupal-services.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2a1d801..6b25569cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - New `codegraph cycles` command lists the groups of files that import each other in a circle, as text or `--json`, and exits 1 when it finds any, so it works as a pre-commit or CI check (#888). Thanks @jiangyq9 and @goutham80808. - `codegraph init --local-only` keeps `.codegraph/` out of git entirely, so nothing of CodeGraph's shows in `git status`, and it works on a project that's already set up, leaving a `.codegraph/.gitignore` you've edited or committed alone (#910). Thanks @jadchene, @jiangyq9, @Wood-Q and @walterf3. - CodeIgniter 3 projects are now understood: every controller method and `routes.php` entry becomes a route, HMVC modules included, and calls through what `$this->load` loads, like `$this->user_model->find($id)`, link to the method they reach, so `codegraph_explore` can follow a request from its route through the model and into the view. Thanks @darkspock. +- In Drupal, services from `*.services.yml`, plugin definitions (attributes and docblock annotations, a contrib module's own plugin types included) and `#[Hook]` methods are now part of the graph, and a call through `\Drupal::service('x')` or `$container->get('x')` links to the method on that service's class. Thanks @mikedamoiseau and @marcelovani. ### Fixes @@ -248,6 +249,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Ruby, a call to a method whose name ends in `!` or `?` now links when its receiver's class is known, like `writer.flush!` or `writer.ready?` after `writer = CacheWriter.new`, or `CacheWriter.create!(…)` on the class. A method called straight on a new object, like `CacheWriter.new(key).write_cache` or `Billing::Ledger.new.charge!`, now links to that class's method or to one it inherits. Before, all of these linked nothing. The class is looked up the way Ruby does it, from the namespaces around the call outward, and one from outside your project, like rack's `Request` used inside `Rack::Protection`, links nothing rather than a project class of the same name. Re-index Ruby projects after upgrading. Thanks @ricobl. - In Ruby, a call on an instance variable, like `@writer.flush`, now links to the method of the class the variable holds when the class's `initialize` sets it to a new object of one class, like `@writer = CacheWriter.new`. Before, no call on an instance variable linked. A variable that `initialize` sets to anything else, or to objects of two classes, stays unlinked rather than guessed. Re-index Ruby projects after upgrading. - In PHP, a class written through a namespace alias in a different case than its `use` statement, like `new field\FirstName()` after `use App\Fields as Field;`, now links to that class, since PHP reads namespace names without regard to case. Before, the name was read relative to the file's own namespace and stayed unlinked. Re-index PHP projects after upgrading. Thanks @danusha2345. (#2369) +- In Drupal, an import of a class whose short name another class shares, like `use Drupal\commerce_price\Price`, no longer also links to that other class. - In C++, a call through a class template's specialization written with its arguments, like fmtlib/fmt's `formatter::format(42, ctx)`, no longer links to a member defined for another specialization, like a test's `formatter::format`, and when the template declares no such member, as for `formatter::parse(ctx)`, the call is no longer matched to another class's method of that name, like `native_formatter::parse`. A call to the specialization a member is defined for, and to a template's own members, links as before. Re-index C++ projects after upgrading. - In C++, a function is now named by its declarator, never by something written in the clauses after its parameters. nlohmann/json's constructor `basic_json(CompatibleType&& val) noexcept(noexcept(JSONSerializer::to_json(…)))` was missing from the index, replaced by a method `JSONSerializer::to_json` that doesn't exist and that other `JSONSerializer::to_json(…)` calls linked to; a method declared with `throw(std::bad_alloc)` was indexed as `std::bad_alloc`, and a function with a trailing `requires std::integral` as `std::integral`. Re-index C++ projects after upgrading. - In C and C++, a header or source file named like a test, such as protocolbuffers/protobuf's `conformance_test.h` and `test_runner.h`, now counts as part of the code that `#include`s it, directly or through other headers. Before, calls from that code into it were dropped, so protobuf's conformance suites' `suite_.ReportFailure(…)` and `RunValidInputTest(…)` calls, and its unit tests' calls to the shared `TestUtil` helpers, linked to nothing. A test file that only tests include is still kept out of reach of the rest of your code. Re-index C and C++ projects after upgrading. diff --git a/README.md b/README.md index 51e6d16b2..27c5f5aa9 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Express** | `app.get(...)`, `router.post(...)` with middleware chains | | **NestJS** | `@Controller` + `@Get/@Post/...` (with `RouterModule` prefixes, `setGlobalPrefix` and URI versioning), GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` | | **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax | -| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` | +| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` and `#[Hook]` methods; `*.services.yml` services, and calls through `\Drupal::service()`; plugin definitions, as attributes or annotations | | **CodeIgniter 3** | Every public controller method by convention, `$route[...]` in `routes.php`, HMVC modules; `$this->load` models, libraries and views | | **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax | | **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods | diff --git a/__tests__/drupal-services.test.ts b/__tests__/drupal-services.test.ts new file mode 100644 index 000000000..225c3dc1c --- /dev/null +++ b/__tests__/drupal-services.test.ts @@ -0,0 +1,402 @@ +/** + * Drupal's dependency injection, plugin definitions and OOP hooks: what Drupal + * wires by id, attribute and folder rather than by a call the parser can see. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CodeGraph } from '../src'; +import { drupalResolver } from '../src/resolution/frameworks/drupal'; + +const extract = (file: string, source: string) => drupalResolver.extract!(file, source); + +describe('a services file', () => { + const file = 'modules/shop/shop.services.yml'; + const refs = (source: string, service: string): string[] => { + const { nodes, references } = extract(file, source); + const node = nodes.find((n) => n.name === `service:${service}`)!; + return references.filter((r) => r.fromNodeId === node.id).map((r) => r.referenceName); + }; + + it('makes a node per service, named by its id, with its class', () => { + const { nodes } = extract(file, `services: + shop.cart: + class: Drupal\\shop\\Cart + shop.quoted: + class: '\\Drupal\\shop\\Quoted' +`); + expect(nodes.map((n) => [n.kind, n.name, n.qualifiedName, n.signature, n.startLine])).toEqual([ + ['component', 'service:shop.cart', `${file}::service:shop.cart`, 'Drupal\\shop\\Cart', 2], + ['component', 'service:shop.quoted', `${file}::service:shop.quoted`, 'Drupal\\shop\\Quoted', 4], + ]); + }); + + it('links the class and every service a definition is given, wherever it is given', () => { + expect(refs(`services: + shop.cart: + class: Drupal\\shop\\Cart + parent: shop.base + decorates: shop.old_cart + arguments: ['@database', '@?logger.channel.shop', '%shop.setting%', '@@literal'] + factory: ['@shop.factory', create] + calls: + - [setLogger, ['@logger.factory']] + tags: + - { name: event_subscriber } +`, 'shop.cart')).toEqual([ + 'drupal:fqcn:Drupal\\shop\\:Cart', + 'drupal:service:shop.base', + 'drupal:service:shop.old_cart', + 'drupal:service:database', + 'drupal:service:logger.channel.shop', + 'drupal:service:shop.factory', + 'drupal:service:logger.factory', + ]); + }); + + it('reads a block list of arguments, and leaves a comment out', () => { + expect(refs(`services: + shop.cart: + class: Drupal\\shop\\Cart + arguments: + - '@database' # not '@logger.factory' + # - '@cache.default' + - "@current_user" +`, 'shop.cart')).toEqual(['drupal:fqcn:Drupal\\shop\\:Cart', 'drupal:service:database', 'drupal:service:current_user']); + }); + + it('reads an alias, in both forms, and a class-named service as its own class', () => { + const source = `services: + shop.alias: '@shop.cart' + shop.long_alias: + alias: shop.cart + Drupal\\shop\\Autowired: ~ + Drupal\\shop\\CartInterface: '@shop.cart' + shop.inline: { class: Drupal\\shop\\Inline, arguments: ['@database'] } +`; + expect(refs(source, 'shop.alias')).toEqual(['drupal:service:shop.cart']); + expect(refs(source, 'shop.long_alias')).toEqual(['drupal:service:shop.cart']); + expect(refs(source, 'Drupal\\shop\\Autowired')).toEqual(['drupal:fqcn:Drupal\\shop\\:Autowired']); + expect(refs(source, 'Drupal\\shop\\CartInterface')).toEqual(['drupal:service:shop.cart']); + expect(refs(source, 'shop.inline')).toEqual(['drupal:fqcn:Drupal\\shop\\:Inline', 'drupal:service:database']); + }); + + it('skips what isn\'t a service', () => { + const { nodes } = extract(file, `parameters: + shop.setting: true +services: + _defaults: + autowire: true + _instanceof: + Drupal\\shop\\Thing: + tags: [thing] + shop.cart: + class: Drupal\\shop\\Cart +`); + expect(nodes.map((n) => n.name)).toEqual(['service:shop.cart']); + expect(extract(file, '').nodes).toEqual([]); + }); +}); + +describe('a plugin definition', () => { + const plugins = (file: string, source: string): [string, string][] => + extract(file, source).nodes.map((n) => [n.name, n.qualifiedName.replace(`${file}::`, '')]); + + it('is read from an attribute, multi-line or not, with the id named or first', () => { + expect(plugins('src/Plugin/Block/A.php', ` { + expect(plugins('src/Plugin/Block/D.php', ` { + expect(plugins('src/Element/E.php', ' { + expect(plugins('modules/log/src/Plugin/Log/LogType/Harvest.php', " { + const { nodes, references } = extract('src/Plugin/Block/A.php', " { + expect(plugins('src/Service/A.php', " { + const hooks = (source: string): string[] => + extract('src/Hook/ShopHooks.php', source).references.map((r) => `${r.referenceName}@${r.line}`); + + it('links the method it marks, from the line the attribute starts on, where the method\'s node starts', () => { + expect(hooks(` { + expect(hooks(` { + expect(hooks(` { + expect(hooks(` { + let dir: string; + let cg: CodeGraph; + + const write = (rel: string, content: string): void => { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + }; + const outgoing = (name: string, kind: string): string[] => { + const node = cg.getNodesByName(name).find((n) => n.kind === kind); + if (!node) throw new Error(`no ${kind} ${name}`); + return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name).sort(); + }; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-services-')); + write('composer.json', JSON.stringify({ name: 'drupal/shop', type: 'drupal-module' })); + write('shop.info.yml', 'name: Shop\ntype: module\n'); + write('shop.services.yml', `services: + shop.cart: + class: Drupal\\shop\\Cart + arguments: ['@shop.prices', '@database'] + shop.prices: + class: Drupal\\shop\\Prices + shop.subscriber: + class: Drupal\\shop\\EventSubscriber\\CartSubscriber + tags: + - { name: event_subscriber } + Drupal\\shop\\Autowired: ~ + shop.elsewhere: + class: Drupal\\elsewhere\\Prices +`); + write('src/Cart.php', `total(); + \\Drupal::service('shop.cart')->clear(); + $container->get('shop.prices')->total(); + \\Drupal::service('no.such.service')->total(); + \\Drupal::service('shop.cart')->missing(); + } +} +`); + write('src/Plugin/Block/CartBlock.php', ` { + cg.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('links a service to its class, by namespace, and to the services it is given', () => { + expect(outgoing('service:shop.cart', 'component')).toEqual(['Cart', 'service:shop.prices']); + const prices = cg.getNodesByName('service:shop.prices').find((n) => n.kind === 'component')!; + const target = cg.getOutgoingEdges(prices.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!)[0]!; + expect(target.filePath).toBe('src/Prices.php'); + }); + + it('leaves a service alone whose class isn\'t indexed, whatever shares its short name', () => { + expect(outgoing('service:shop.elsewhere', 'component')).toEqual([]); + }); + + it('links a class-named service and an event subscriber to their classes', () => { + expect(outgoing('service:Drupal\\shop\\Autowired', 'component')).toEqual(['Autowired']); + expect(outgoing('service:shop.subscriber', 'component')).toEqual(['CartSubscriber']); + }); + + it('follows a call through the container to the method on the service\'s class', () => { + const run = cg.getNodesByName('run').find((n) => n.kind === 'method')!; + expect(cg.getCallees(run.id).map((c) => c.node.qualifiedName).sort()).toEqual([ + 'Drupal\\shop::Cart::total', + 'Drupal\\shop::CartBase::clear', + 'Drupal\\shop::Prices::total', + ]); + }); + + it('keeps a service or plugin id from answering for the class of that name', () => { + const rates = cg.getNodesByName('Rates').find((n) => n.kind === 'class')!; + const make = cg.getNodesByName('make').find((n) => n.kind === 'method')!; + expect(cg.getOutgoingEdges(make.id).filter((e) => e.kind === 'instantiates')).toEqual([]); + const imported = cg.getNodesInFile('src/Rates.php').map((n) => n.id) + .flatMap((id) => cg.getOutgoingEdges(id)).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!); + expect(imported.map((n) => n.kind)).not.toContain('component'); + expect(imported.map((n) => n.name)).toContain('Autowired'); + expect(rates).toBeDefined(); + }); + + it('finds a service and a plugin by the id written in the project', () => { + expect(cg.searchNodes('shop.prices').map((r) => r.node.name)).toContain('service:shop.prices'); + expect(cg.searchNodes('shop_cart').map((r) => r.node.name)).toContain('Block:shop_cart'); + }); + + it('links a plugin to its class, from an attribute and from an annotation', () => { + expect(outgoing('Block:shop_cart', 'component')).toEqual(['CartBlock']); + expect(outgoing('Block:shop_old', 'component')).toEqual(['OldBlock']); + }); + + it('links a #[Hook] method to the hook, when the index defines it', () => { + expect(outgoing('ready', 'method')).toEqual(['hook_shop_ready']); + }); + + it('does not take another module\'s function for a hook the index doesn\'t define', () => { + expect(outgoing('formAlter', 'method')).toEqual([]); + }); +}); + +describe('a sync of a Drupal module', () => { + it('links a service when its class arrives', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-sync-')); + const write = (rel: string, content: string): void => { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + }; + write('composer.json', JSON.stringify({ name: 'drupal/shop', type: 'drupal-module' })); + write('shop.services.yml', 'services:\n shop.cart:\n class: Drupal\\shop\\Cart\n'); + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + const service = (): string[] => { + const node = cg.getNodesByName('service:shop.cart').find((n) => n.kind === 'component')!; + return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name); + }; + expect(service()).toEqual([]); + + write('src/Cart.php', '` (the class in `signature`), with `references` edges to its class and to the + * services it is given. A class-named service (`Drupal\x\Y: ~`) is its own class. The prefix + * is load-bearing: an id or a plugin's id is a free-form string, and a node named `Currency` + * or `Drupal\x\Y` would answer every `new Currency()` and `use Drupal\x\Y;` in the project. + * A call on what the container hands out, + * `\Drupal::service('x')->run()` or `$container->get('x')->run()`, links to the method on + * that service's class. + * + * 5. **Plugins** — a class under `src/Plugin/`, `src/Entity/` or `src/Element/` carrying an + * attribute or a docblock annotation with an `id` is a `component` node named + * `:` with a `references` edge to the class. + * The folder is what Drupal's plugin discovery goes by, so a contrib module's own plugin + * types need no list. * * ## Design decisions (review in future iterations) * @@ -29,19 +45,12 @@ * `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. - * * - 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 * grammar WASM is available. * * ## 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. @@ -50,6 +59,8 @@ import { generateNodeId } from '../../extraction/tree-sitter-helpers'; import { Node } from '../../types'; import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types'; +import { makeLineAt } from '../synth-utils'; +import { matchBracket } from './object-literal'; // --------------------------------------------------------------------------- // Helpers @@ -289,6 +300,396 @@ function extractDrupalHooks( return { nodes: [], references }; } +// --------------------------------------------------------------------------- +// Services (*.services.yml) +// --------------------------------------------------------------------------- + +/** A reference to a service by id, to a class by the file that declares it, and by its FQCN: answered only here. */ +const SERVICE_REF = 'drupal:service:'; +const CLASS_REF = 'drupal:class:'; +const FQCN_REF = 'drupal:fqcn:'; + +/** `Drupal\x\:Y`: the colon parks a failed reference under the class's short name, which is what a sync looks for. */ +function fqcnRef(fqcn: string): string { + const slash = fqcn.lastIndexOf('\\'); + return `${FQCN_REF}${fqcn.slice(0, slash + 1)}:${fqcn.slice(slash + 1)}`; +} + +const SERVICE_ID = /^(?:'([^']+)'|"([^"]+)"|([A-Za-z_\\][\w.\\-]*))\s*:\s*(.*)$/; + +/** `'@a'`, `'@?a'` and `"@a"`, each a service; `'@@a'` is a literal string. */ +const SERVICE_ARGUMENT = /(?:^|[\s[,'"])@\??([A-Za-z_\\][\w.\\-]*)/g; + +/** Cut a trailing comment from a YAML line: the first `#` outside quotes after a space. */ +function withoutYamlComment(line: string): string { + let quote = ''; + for (let i = 0; i < line.length; i++) { + const c = line[i]!; + if (quote) { + if (c === quote) quote = ''; + } else if (c === "'" || c === '"') quote = c; + else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]!))) return line.slice(0, i); + } + return line; +} + +/** + * Service definitions from a `*.services.yml` file. Line by line, as the routes are: + * ids sit at one indent under the top-level `services:`, and what a service is given + * sits below its id, in any of the places Symfony takes one (`arguments`, `calls`, + * `factory`, `parent`, `decorates`, an alias). + */ +function extractDrupalServices( + filePath: string, + content: string +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + let inServices = false; + let idIndent = -1; + let current: Node | null = null; + let given = new Set(); + + const reference = (from: Node, referenceName: string): void => { + references.push({ + fromNodeId: from.id, + referenceName, + referenceKind: 'references', + line: from.startLine, + column: 0, + filePath, + language: 'yaml', + }); + }; + const give = (id: string): void => { + if (!current || given.has(id) || `service:${id}` === current.name) return; + given.add(id); + reference(current, `${SERVICE_REF}${id}`); + }; + const giveAll = (text: string): void => { + SERVICE_ARGUMENT.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = SERVICE_ARGUMENT.exec(text)) !== null) give(m[1]!); + }; + const setClass = (fqcn: string): void => { + if (!current || current.signature) return; + current.signature = fqcn; + reference(current, fqcnRef(fqcn)); + }; + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const text = withoutYamlComment(lines[i]!).trimEnd(); + const trimmed = text.trim(); + if (!trimmed) continue; + const indent = text.length - text.trimStart().length; + if (indent === 0) { + inServices = /^services\s*:/.test(trimmed); + idIndent = -1; + current = null; + continue; + } + if (!inServices) continue; + if (idIndent < 0) idIndent = indent; + + if (indent === idIndent) { + current = null; + const id = SERVICE_ID.exec(trimmed); + if (!id) continue; + const name = id[1] ?? id[2] ?? id[3]!; + // `_defaults` and `_instanceof` say how services are built, not what they are. + if (name.startsWith('_')) continue; + const fqcn = name.replace(/^\\+/, ''); + current = { + id: `component:${filePath}:${i + 1}:service:${name}`, + kind: 'component', + name: `service:${name}`, + qualifiedName: `${filePath}::service:${name}`, + filePath, + startLine: i + 1, + endLine: i + 1, + startColumn: 0, + endColumn: 0, + language: 'yaml', + updatedAt: now, + }; + given = new Set(); + nodes.push(current); + const value = id[4]!; + const inlineClass = /\bclass\s*:\s*['"]?([\w\\]+)/.exec(value)?.[1]; + if (inlineClass) setClass(inlineClass.replace(/^\\+/, '')); + else if (fqcn.includes('\\') && !/^['"]?@/.test(value)) setClass(fqcn); + giveAll(value); + continue; + } + if (!current) continue; + + const field = /^(class|parent|alias|decorates)\s*:\s*['"]?([^'"\s]+)/.exec(trimmed); + if (field) { + if (field[1] === 'class') setClass(field[2]!.replace(/^\\+/, '')); + else give(field[2]!.replace(/^@/, '')); + continue; + } + giveAll(trimmed); + } + return { nodes, references }; +} + +// --------------------------------------------------------------------------- +// Attributes: OOP hooks and plugin definitions +// --------------------------------------------------------------------------- + +/** Where Drupal's plugin discovery and entity types look, by module-relative folder. */ +const PLUGIN_FILE = /(?:^|\/)src\/(?:Plugin|Entity|Element)\//; +const TEST_FILE = /(?:^|\/)tests?\//; + +/** Attributes PHP and PHPUnit put on a class, which a string in them doesn't make a plugin. */ +const NOT_PLUGIN_ATTRIBUTES = new Set(['Attribute', 'AllowDynamicProperties', 'Deprecated', 'Group', 'Hook']); + +const ATTRIBUTE_START = /^[ \t]*#\[/gm; + +/** What a run of attributes sits on, once the modifiers are past it. */ +const DECLARATION = /(?:(?:abstract|final|readonly|public|protected|private|static)\s+)*(?:function\s+&?\s*([A-Za-z_]\w*)\s*\(|class\s+([A-Za-z_]\w*))/y; + +interface Attribute { + /** The last segment of its name: `Hook`, `Block`. */ + name: string; + /** What is between its parentheses. */ + args: string; +} + +interface AttributedDeclaration { + attributes: Attribute[]; + /** The line the attributes start on: tree-sitter counts them as part of what they mark, so a node's id does too. */ + line: number; + offset: number; + method?: string; + className?: string; +} + +/** The offset of the next code after `at`: past whitespace and comments. */ +function skipTrivia(s: string, at: number): number { + let i = at; + for (;;) { + while (i < s.length && /\s/.test(s[i]!)) i++; + if (s.startsWith('//', i) || (s[i] === '#' && s[i + 1] !== '[')) { + const end = s.indexOf('\n', i); + if (end < 0) return s.length; + i = end; + } else if (s.startsWith('/*', i)) { + const end = s.indexOf('*/', i); + if (end < 0) return s.length; + i = end + 2; + } else return i; + } +} + +/** + * Every declaration that carries attributes. Read from the source, not from + * `stripCommentsForRegex`, to which a `#[` is a comment like any other `#`. + */ +function attributedDeclarations(content: string): AttributedDeclaration[] { + const out: AttributedDeclaration[] = []; + const lineOf = makeLineAt(content, 1); + ATTRIBUTE_START.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ATTRIBUTE_START.exec(content)) !== null) { + const start = m.index + m[0].length - 2; + const attributes: Attribute[] = []; + let at = start; + while (content.startsWith('#[', at)) { + const close = matchBracket(content, at + 1); + if (close < 0) break; + const body = /^\s*\\?([\w\\]+)\s*(\()?/.exec(content.slice(at + 2, close)); + if (body) { + const open = at + 2 + body[0].length - 1; + const end = body[2] ? matchBracket(content, open) : -1; + attributes.push({ name: body[1]!.split('\\').pop()!, args: end > 0 ? content.slice(open + 1, end) : '' }); + } + at = skipTrivia(content, close + 1); + } + ATTRIBUTE_START.lastIndex = Math.max(at, m.index + m[0].length); + DECLARATION.lastIndex = at; + const decl = DECLARATION.exec(content); + if (!decl) continue; + out.push({ attributes, line: lineOf(start), offset: at, method: decl[1], className: decl[2] }); + } + return out; +} + +/** The first string an attribute's arguments open with, by position or as `name:`. */ +function firstArgument(args: string, name: string): string | null { + return new RegExp(`^\\s*(?:${name}\\s*:\\s*)?['"]([^'"]+)['"]`).exec(args)?.[1] ?? null; +} + +/** `#[Hook('x')]` on a method, or on a class, where it names the method or means `__invoke`. */ +function oopHookReferences(filePath: string, content: string, declarations: readonly AttributedDeclaration[]): UnresolvedRef[] { + const references: UnresolvedRef[] = []; + const emit = (hook: string, method: string, line: number): void => { + references.push({ + fromNodeId: generateNodeId(filePath, 'method', method, line), + referenceName: `hook_${hook}`, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'php', + }); + }; + const lineOf = makeLineAt(content, 1); + for (const decl of declarations) { + for (const attribute of decl.attributes) { + if (attribute.name !== 'Hook') continue; + const hook = firstArgument(attribute.args, 'hook'); + if (!hook) continue; + if (decl.method) { + emit(hook, decl.method, decl.line); + continue; + } + const method = /\bmethod\s*:\s*['"](\w+)['"]/.exec(attribute.args)?.[1] ?? '__invoke'; + const at = new RegExp(`\\bfunction\\s+&?\\s*${method}\\s*\\(`).exec(content.slice(decl.offset)); + if (!at) continue; + const offset = decl.offset + at.index; + const marked = declarations.find((other) => other.method === method && other.offset <= offset && offset - other.offset < 200); + emit(hook, method, marked?.line ?? lineOf(offset)); + } + } + return references; +} + +/** A class's plugin definitions: its attributes and its docblock's annotations, each with an `id`. */ +function pluginDefinitions(content: string, declarations: readonly AttributedDeclaration[]): { type: string; id: string; className: string; line: number }[] { + const out: { type: string; id: string; className: string; line: number }[] = []; + const seen = new Set(); + const add = (type: string, id: string, className: string, line: number): void => { + const key = `${type}:${id}:${className}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ type, id, className, line }); + }; + for (const decl of declarations) { + if (!decl.className) continue; + for (const attribute of decl.attributes) { + if (NOT_PLUGIN_ATTRIBUTES.has(attribute.name)) continue; + const id = firstArgument(attribute.args, 'id') ?? /\bid\s*:\s*['"]([^'"]+)['"]/.exec(attribute.args)?.[1]; + if (id) add(attribute.name, id, decl.className, decl.line); + } + } + const lineOf = makeLineAt(content, 1); + const doc = /\/\*\*((?:(?!\*\/)[\s\S])*)\*\/\s*(?:#\[[^\n]*\n\s*)*(?:(?:abstract|final|readonly)\s+)*class\s+([A-Za-z_]\w*)/g; + let m: RegExpExecArray | null; + while ((m = doc.exec(content)) !== null) { + const bodyAt = m.index + 3; + const annotation = /^[ \t]*\*[ \t]*@([A-Z]\w*)\s*\(/gm; + annotation.lastIndex = 0; + let a: RegExpExecArray | null; + while ((a = annotation.exec(m[1]!)) !== null) { + const open = a.index + a[0].length - 1; + const close = matchBracket(m[1]!, open); + if (close < 0) continue; + const body = m[1]!.slice(open, close); + const id = /(?:^|[\s,(])id\s*=\s*"([^"]+)"/.exec(body)?.[1] ?? /^\(\s*"([^"]+)"/.exec(body)?.[1]; + if (id) add(a[1]!, id, m[2]!, lineOf(bodyAt + a.index + a[0].indexOf('@'))); + annotation.lastIndex = close; + } + } + return out; +} + +function extractDrupalPlugins( + filePath: string, + content: string, + declarations: readonly AttributedDeclaration[] +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + if (!PLUGIN_FILE.test(filePath) || TEST_FILE.test(filePath)) return { nodes, references }; + const now = Date.now(); + for (const { type, id, className, line } of pluginDefinitions(content, declarations)) { + const node: Node = { + id: `component:${filePath}:${line}:plugin:${type}:${id}`, + kind: 'component', + name: `${type}:${id}`, + qualifiedName: `${filePath}::plugin:${type}:${id}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: 0, + language: 'php', + updatedAt: now, + }; + nodes.push(node); + references.push({ + fromNodeId: node.id, + referenceName: `${CLASS_REF}${filePath}#${className}`, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'php', + }); + } + return { nodes, references }; +} + +// --------------------------------------------------------------------------- +// Resolving through the service container +// --------------------------------------------------------------------------- + +/** `$container->get('x')->run()`, where extraction keeps the id in the name. */ +const CONTAINER_CALL = /^(?:this->)?container->get\('([\w.\\-]+)'\)\.(\w+)$/; +/** `\Drupal::service('x')->run()`, where it doesn't: the id is read from the line. */ +const DRUPAL_SERVICE_CALL = /^\\?Drupal::service\(\)\.(\w+)$/; + +/** The class a FQCN names, by its namespace. */ +function classByFqcn(fqcn: string, context: ResolutionContext): Node | undefined { + const clean = fqcn.replace(/^\\+/, ''); + const slash = clean.lastIndexOf('\\'); + if (slash <= 0) return undefined; + const qualified = `${clean.slice(0, slash)}::${clean.slice(slash + 1)}`; + return context.getNodesByQualifiedName(qualified).find((n) => n.kind === 'class'); +} + +function serviceNode(id: string, context: ResolutionContext): Node | undefined { + return context.getNodesByName(`service:${id}`).find((n) => n.kind === 'component'); +} + +function serviceClass(id: string, context: ResolutionContext): Node | undefined { + const service = serviceNode(id, context); + return service?.signature ? classByFqcn(service.signature, context) : undefined; +} + +/** A method by name on a class, its own or an inherited one. */ +function methodOf(cls: Node, name: string, context: ResolutionContext): Node | undefined { + const seen = new Set(); + let level: Node[] = [cls]; + for (let depth = 0; depth < 6 && level.length > 0; depth++) { + const next: Node[] = []; + for (const type of level) { + if (seen.has(type.id)) continue; + seen.add(type.id); + const own = context.getNodesInFile(type.filePath).find( + (n) => n.kind === 'method' && n.name === name && n.qualifiedName.startsWith(`${type.qualifiedName}::`) + ); + if (own) return own; + next.push(...(context.getSupertypeNodes?.(type.id) ?? [])); + } + level = next; + } + return undefined; +} + +/** The service the `\Drupal::service('x')->method()` on this reference's line asks for. */ +function serviceOnLine(ref: UnresolvedRef, method: string, context: ResolutionContext): string | null { + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? ''; + for (const m of line.matchAll(/Drupal::service\(\s*['"]([\w.\\-]+)['"]\s*\)\s*->\s*(\w+)/g)) { + if (m[2] === method) return m[1]!; + } + return null; +} + // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- @@ -305,8 +706,11 @@ export const drupalResolver: FrameworkResolver = { claimsReference(name: string): boolean { return ( name.startsWith('hook_') || + name.startsWith('drupal:') || name.includes('\\') || - /^[A-Za-z_]\w*::?\w+$/.test(name) + /^[A-Za-z_]\w*::?\w+$/.test(name) || + CONTAINER_CALL.test(name) || + DRUPAL_SERVICE_CALL.test(name) ); }, @@ -351,6 +755,24 @@ export const drupalResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { const name = ref.referenceName; + const resolved = (target: Node | undefined, confidence: number): ResolvedRef | null => + target ? { original: ref, targetNodeId: target.id, confidence, resolvedBy: 'framework' } : null; + + if (name.startsWith(SERVICE_REF)) return resolved(serviceNode(name.slice(SERVICE_REF.length), context), 0.9); + if (name.startsWith(FQCN_REF)) return resolved(classByFqcn(name.slice(FQCN_REF.length).replace(':', ''), context), 0.9); + if (name.startsWith(CLASS_REF)) { + const [file, className] = name.slice(CLASS_REF.length).split('#'); + return resolved(context.getNodesInFile(file!).find((n) => n.kind === 'class' && n.name === className), 0.95); + } + + const onContainer = CONTAINER_CALL.exec(name); + const onDrupal = onContainer ? null : DRUPAL_SERVICE_CALL.exec(name); + if (onContainer || onDrupal) { + const method = (onContainer?.[2] ?? onDrupal?.[1])!; + const id = onContainer?.[1] ?? serviceOnLine(ref, method, context); + const cls = id ? serviceClass(id, context) : undefined; + return resolved(cls && methodOf(cls, method, context), 0.9); + } // _controller: '\Drupal\module\...\ClassName::methodName' (double colon) or the // single-colon controller-service form '\Drupal\...\ClassName:methodName'. @@ -371,18 +793,18 @@ export const drupalResolver: FrameworkResolver = { // _form / _entity_form: '\Drupal\module\...\ClassName' (bare FQCN, no method) if (name.includes('\\') && !name.includes(':')) { + // The namespace first: one class name can sit in several, as a plugin's + // attribute and annotation classes do. const className = lastSegment(name); - if (className) { - const classNodes = context.getNodesByName(className); - const cls = classNodes.find((n) => n.kind === 'class'); - if (cls) { - return { original: ref, targetNodeId: cls.id, confidence: 0.85, resolvedBy: 'framework' }; - } + const cls = classByFqcn(name, context) ?? (className ? context.getNodesByName(className).find((n) => n.kind === 'class') : undefined); + if (cls) { + return { original: ref, targetNodeId: cls.id, confidence: 0.85, resolvedBy: 'framework' }; } } // hook_X — find any function whose name ends in _{hookSuffix} in a hook file - if (name.startsWith('hook_')) { + // A method only has #[Hook] to go by: another module's function is no answer to it. + if (name.startsWith('hook_') && !ref.fromNodeId.startsWith('method:')) { const hookSuffix = name.slice(5); // strip 'hook_' const candidates = context.getNodesByKind('function').filter( (n) => n.name.endsWith(`_${hookSuffix}`) && isDrupalHookFile(n.filePath) @@ -405,8 +827,18 @@ 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 declarations = content.includes('#[') ? attributedDeclarations(content) : []; + const plugins = extractDrupalPlugins(filePath, content, declarations); + return { + nodes: plugins.nodes, + references: [...hooks.references, ...oopHookReferences(filePath, content, declarations), ...plugins.references], + }; } return { nodes: [], references: [] };