feat(frameworks): add Drupal 8/9/10/11 support (#271)
Detects Drupal projects via composer.json drupal/* deps; extracts routes from *.routing.yml (route nodes + references edges to controllers/forms/entity handlers) and Drupal hook implementations from .module/.install/.theme/.inc. Adds yaml/twig as file-level languages and excludes core/contrib by default. Resolves #268.
This commit is contained in:
@@ -7,6 +7,28 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
|
|||||||
This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
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).
|
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Framework support: Drupal 8/9/10/11** — CodeGraph now detects Drupal
|
||||||
|
projects (via a `drupal/*` dependency in `composer.json`) and adds three
|
||||||
|
levels of intelligence:
|
||||||
|
- **Route extraction**: `*.routing.yml` files emit a `route` node per route,
|
||||||
|
linked by a `references` edge to the `_controller`, `_form`, or
|
||||||
|
entity-handler class/method, so querying a controller method surfaces the
|
||||||
|
URL route that binds it.
|
||||||
|
- **Hook detection**: hook implementations in `.module`, `.install`, `.theme`,
|
||||||
|
and `.inc` files are detected via docblock (`Implements hook_X()`) with a
|
||||||
|
module-name-prefix fallback. Each emits a `references` edge to the canonical
|
||||||
|
`hook_X` name so `codegraph_callers("hook_form_alter")` returns every
|
||||||
|
implementation across modules.
|
||||||
|
- **Resolution**: `_controller`/`_form` FQCNs resolve to their PHP
|
||||||
|
class/method nodes.
|
||||||
|
New `yaml`/`twig` languages are tracked at the file level, the Drupal PHP
|
||||||
|
extensions (`.module`/`.install`/`.theme`/`.inc`) are indexed with the PHP
|
||||||
|
grammar, and `web/core`, `web/modules/contrib`, `web/themes/contrib` are
|
||||||
|
excluded by default. Resolves [#268](https://github.com/colbymchenry/codegraph/issues/268).
|
||||||
|
|
||||||
## [0.9.1] - 2026-05-21
|
## [0.9.1] - 2026-05-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ The gains scale with codebase size: on large repos the agent answers from the in
|
|||||||
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
|
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
|
||||||
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
|
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
|
||||||
| **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
|
| **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
|
||||||
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 13 frameworks |
|
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks |
|
||||||
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
|
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -141,6 +141,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
|
|||||||
| **Express** | `app.get(...)`, `router.post(...)` with middleware chains |
|
| **Express** | `app.get(...)`, `router.post(...)` with middleware chains |
|
||||||
| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
|
| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
|
||||||
| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
|
| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
|
||||||
|
| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` |
|
||||||
| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
|
| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
|
||||||
| **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods |
|
| **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods |
|
||||||
| **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` |
|
| **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` |
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Drupal framework resolver.
|
||||||
|
*
|
||||||
|
* Unit tests cover drupalResolver.detect(), extract() (routes + hooks), and resolve().
|
||||||
|
* Integration tests use a real CodeGraph instance with a temporary Drupal project layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { CodeGraph } from '../src';
|
||||||
|
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||||
|
import { drupalResolver } from '../src/resolution/frameworks/drupal';
|
||||||
|
import type { ResolutionContext } from '../src/resolution/types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeContext(
|
||||||
|
overrides: Partial<ResolutionContext> = {},
|
||||||
|
): ResolutionContext {
|
||||||
|
return {
|
||||||
|
getNodesInFile: () => [],
|
||||||
|
getNodesByName: () => [],
|
||||||
|
getNodesByQualifiedName: () => [],
|
||||||
|
getNodesByKind: () => [],
|
||||||
|
fileExists: () => false,
|
||||||
|
readFile: () => null,
|
||||||
|
getProjectRoot: () => '/project',
|
||||||
|
getAllFiles: () => [],
|
||||||
|
getNodesByLowerName: () => [],
|
||||||
|
getImportMappings: () => [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// detect()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('drupalResolver.detect', () => {
|
||||||
|
it('returns true when composer.json has a drupal/ dependency', () => {
|
||||||
|
const ctx = makeContext({
|
||||||
|
readFile: (f) =>
|
||||||
|
f === 'composer.json'
|
||||||
|
? JSON.stringify({
|
||||||
|
require: {
|
||||||
|
'drupal/core-recommended': '~10.5',
|
||||||
|
'drush/drush': '^13',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
expect(drupalResolver.detect(ctx)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when drupal/ dependency is in require-dev', () => {
|
||||||
|
const ctx = makeContext({
|
||||||
|
readFile: (f) =>
|
||||||
|
f === 'composer.json'
|
||||||
|
? JSON.stringify({ 'require-dev': { 'drupal/core': '^10' } })
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
expect(drupalResolver.detect(ctx)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when composer.json has no drupal/ dependencies', () => {
|
||||||
|
const ctx = makeContext({
|
||||||
|
readFile: (f) =>
|
||||||
|
f === 'composer.json'
|
||||||
|
? JSON.stringify({
|
||||||
|
require: { 'laravel/framework': '^10', php: '>=8.1' },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
expect(drupalResolver.detect(ctx)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when composer.json is absent', () => {
|
||||||
|
const ctx = makeContext({ readFile: () => null });
|
||||||
|
expect(drupalResolver.detect(ctx)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when composer.json is malformed JSON', () => {
|
||||||
|
const ctx = makeContext({ readFile: () => '{ bad json' });
|
||||||
|
expect(drupalResolver.detect(ctx)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extract() — routing.yml
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('drupalResolver.extract — routing.yml', () => {
|
||||||
|
const routing = `
|
||||||
|
mymodule.example:
|
||||||
|
path: '/mymodule/example'
|
||||||
|
defaults:
|
||||||
|
_controller: '\\Drupal\\mymodule\\Controller\\MyController::build'
|
||||||
|
_title: 'Example page'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('emits a route node for each YAML route', () => {
|
||||||
|
const { nodes } = drupalResolver.extract!(
|
||||||
|
'mymodule/mymodule.routing.yml',
|
||||||
|
routing,
|
||||||
|
);
|
||||||
|
expect(nodes).toHaveLength(1);
|
||||||
|
expect(nodes[0]!.kind).toBe('route');
|
||||||
|
expect(nodes[0]!.name).toBe('/mymodule/example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets qualifiedName to filePath::routeName', () => {
|
||||||
|
const { nodes } = drupalResolver.extract!(
|
||||||
|
'mymodule/mymodule.routing.yml',
|
||||||
|
routing,
|
||||||
|
);
|
||||||
|
expect(nodes[0]!.qualifiedName).toBe(
|
||||||
|
'mymodule/mymodule.routing.yml::mymodule.example',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a references edge to the controller FQCN', () => {
|
||||||
|
const { references } = drupalResolver.extract!(
|
||||||
|
'mymodule/mymodule.routing.yml',
|
||||||
|
routing,
|
||||||
|
);
|
||||||
|
expect(references).toHaveLength(1);
|
||||||
|
expect(references[0]!.referenceName).toBe(
|
||||||
|
'\\Drupal\\mymodule\\Controller\\MyController::build',
|
||||||
|
);
|
||||||
|
expect(references[0]!.referenceKind).toBe('references');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a references edge to a _form handler', () => {
|
||||||
|
const src = `
|
||||||
|
mymodule.settings_form:
|
||||||
|
path: '/admin/config/mymodule'
|
||||||
|
defaults:
|
||||||
|
_form: '\\Drupal\\mymodule\\Form\\SettingsForm'
|
||||||
|
_title: 'MyModule settings'
|
||||||
|
requirements:
|
||||||
|
_permission: 'administer site configuration'
|
||||||
|
`;
|
||||||
|
const { nodes, references } = drupalResolver.extract!(
|
||||||
|
'mymodule/mymodule.routing.yml',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
expect(nodes).toHaveLength(1);
|
||||||
|
expect(references[0]!.referenceName).toBe(
|
||||||
|
'\\Drupal\\mymodule\\Form\\SettingsForm',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple routes in one file', () => {
|
||||||
|
const src = `
|
||||||
|
mod.page_one:
|
||||||
|
path: '/page-one'
|
||||||
|
defaults:
|
||||||
|
_controller: '\\Drupal\\mod\\Controller\\PageController::one'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
|
mod.page_two:
|
||||||
|
path: '/page-two'
|
||||||
|
defaults:
|
||||||
|
_controller: '\\Drupal\\mod\\Controller\\PageController::two'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
`;
|
||||||
|
const { nodes, references } = drupalResolver.extract!(
|
||||||
|
'mod/mod.routing.yml',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
expect(nodes).toHaveLength(2);
|
||||||
|
expect(nodes.map((n) => n.name)).toContain('/page-one');
|
||||||
|
expect(nodes.map((n) => n.name)).toContain('/page-two');
|
||||||
|
expect(references).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips commented-out lines', () => {
|
||||||
|
const src = `
|
||||||
|
mod.page:
|
||||||
|
path: '/page'
|
||||||
|
defaults:
|
||||||
|
#_controller: '\\Drupal\\mod\\Controller\\Old::build'
|
||||||
|
_controller: '\\Drupal\\mod\\Controller\\New::build'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
`;
|
||||||
|
const { references } = drupalResolver.extract!('mod/mod.routing.yml', src);
|
||||||
|
expect(references).toHaveLength(1);
|
||||||
|
expect(references[0]!.referenceName).toContain('New');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes HTTP methods in the route node name when present', () => {
|
||||||
|
const src = `
|
||||||
|
mod.api:
|
||||||
|
path: '/api/resource'
|
||||||
|
defaults:
|
||||||
|
_controller: '\\Drupal\\mod\\Controller\\ApiController::get'
|
||||||
|
methods: [GET, POST]
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
`;
|
||||||
|
const { nodes } = drupalResolver.extract!('mod/mod.routing.yml', src);
|
||||||
|
expect(nodes[0]!.name).toContain('GET');
|
||||||
|
expect(nodes[0]!.name).toContain('POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty result for non-routing-yml files', () => {
|
||||||
|
const { nodes, references } = drupalResolver.extract!(
|
||||||
|
'mymodule.module',
|
||||||
|
'<?php\n',
|
||||||
|
);
|
||||||
|
// Module files go through hook detection, not route extraction
|
||||||
|
expect(nodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty result for files with no valid routes', () => {
|
||||||
|
const { nodes, references } = drupalResolver.extract!(
|
||||||
|
'some.routing.yml',
|
||||||
|
'# empty\n',
|
||||||
|
);
|
||||||
|
expect(nodes).toHaveLength(0);
|
||||||
|
expect(references).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extract() — hook detection in .module files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('drupalResolver.extract — hook detection', () => {
|
||||||
|
it('detects hook implementation via docblock (Strategy A)', () => {
|
||||||
|
const src = `<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements hook_form_alter().
|
||||||
|
*/
|
||||||
|
function mymodule_form_alter(&$form, $form_state, $form_id) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const { references } = drupalResolver.extract!(
|
||||||
|
'web/modules/custom/mymodule/mymodule.module',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
const hookRef = references.find(
|
||||||
|
(r) => r.referenceName === 'hook_form_alter',
|
||||||
|
);
|
||||||
|
expect(hookRef).toBeDefined();
|
||||||
|
expect(hookRef!.referenceKind).toBe('references');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects hook implementation via name pattern (Strategy B)', () => {
|
||||||
|
const src = `<?php
|
||||||
|
|
||||||
|
function mymodule_views_data() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const { references } = drupalResolver.extract!(
|
||||||
|
'web/modules/custom/mymodule/mymodule.module',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
const hookRef = references.find(
|
||||||
|
(r) => r.referenceName === 'hook_views_data',
|
||||||
|
);
|
||||||
|
expect(hookRef).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit a hook ref for non-hook helper functions', () => {
|
||||||
|
// 'other_module_helper' doesn't start with 'mymodule_', so no hook ref
|
||||||
|
const src = `<?php
|
||||||
|
function other_module_helper() {}
|
||||||
|
`;
|
||||||
|
const { references } = drupalResolver.extract!(
|
||||||
|
'web/modules/custom/mymodule/mymodule.module',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
expect(references).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects hooks in .install files', () => {
|
||||||
|
const src = `<?php
|
||||||
|
/**
|
||||||
|
* Implements hook_schema().
|
||||||
|
*/
|
||||||
|
function mymodule_schema() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const { references } = drupalResolver.extract!(
|
||||||
|
'web/modules/custom/mymodule/mymodule.install',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
const hookRef = references.find((r) => r.referenceName === 'hook_schema');
|
||||||
|
expect(hookRef).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects hooks in .theme files', () => {
|
||||||
|
const src = `<?php
|
||||||
|
/**
|
||||||
|
* Implements hook_preprocess_node().
|
||||||
|
*/
|
||||||
|
function mytheme_preprocess_node(&$variables) {}
|
||||||
|
`;
|
||||||
|
const { references } = drupalResolver.extract!(
|
||||||
|
'web/themes/custom/mytheme/mytheme.theme',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
const hookRef = references.find(
|
||||||
|
(r) => r.referenceName === 'hook_preprocess_node',
|
||||||
|
);
|
||||||
|
expect(hookRef).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate refs when both docblock and name pattern match', () => {
|
||||||
|
// Strategy A matches first and adds to docblockMatched set;
|
||||||
|
// Strategy B skips already-matched functions.
|
||||||
|
const src = `<?php
|
||||||
|
/**
|
||||||
|
* Implements hook_form_alter().
|
||||||
|
*/
|
||||||
|
function mymodule_form_alter(&$form, $form_state, $form_id) {}
|
||||||
|
`;
|
||||||
|
const { references } = drupalResolver.extract!(
|
||||||
|
'web/modules/custom/mymodule/mymodule.module',
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
const hookRefs = references.filter(
|
||||||
|
(r) => r.referenceName === 'hook_form_alter',
|
||||||
|
);
|
||||||
|
expect(hookRefs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolve()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('drupalResolver.resolve', () => {
|
||||||
|
it('resolves a _controller FQCN with ::method to the method node', () => {
|
||||||
|
const methodNode = {
|
||||||
|
id: 'method:abc123',
|
||||||
|
kind: 'method' as const,
|
||||||
|
name: 'build',
|
||||||
|
qualifiedName: 'MyController::build',
|
||||||
|
filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
|
||||||
|
language: 'php' as const,
|
||||||
|
startLine: 10,
|
||||||
|
endLine: 20,
|
||||||
|
startColumn: 0,
|
||||||
|
endColumn: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
const classNode = {
|
||||||
|
id: 'class:def456',
|
||||||
|
kind: 'class' as const,
|
||||||
|
name: 'MyController',
|
||||||
|
qualifiedName: 'MyController',
|
||||||
|
filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
|
||||||
|
language: 'php' as const,
|
||||||
|
startLine: 5,
|
||||||
|
endLine: 30,
|
||||||
|
startColumn: 0,
|
||||||
|
endColumn: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
const ctx = makeContext({
|
||||||
|
getNodesByName: (name) => (name === 'MyController' ? [classNode] : []),
|
||||||
|
getNodesInFile: () => [classNode, methodNode],
|
||||||
|
});
|
||||||
|
const ref = {
|
||||||
|
fromNodeId: 'route:x',
|
||||||
|
referenceName: '\\Drupal\\mymodule\\Controller\\MyController::build',
|
||||||
|
referenceKind: 'references' as const,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
filePath: 'mymodule.routing.yml',
|
||||||
|
language: 'yaml' as const,
|
||||||
|
};
|
||||||
|
const resolved = drupalResolver.resolve(ref, ctx);
|
||||||
|
expect(resolved).not.toBeNull();
|
||||||
|
expect(resolved!.targetNodeId).toBe('method:abc123');
|
||||||
|
expect(resolved!.confidence).toBeGreaterThanOrEqual(0.85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a _form FQCN (no ::method) to the class node', () => {
|
||||||
|
const classNode = {
|
||||||
|
id: 'class:form123',
|
||||||
|
kind: 'class' as const,
|
||||||
|
name: 'SettingsForm',
|
||||||
|
qualifiedName: 'SettingsForm',
|
||||||
|
filePath: 'web/modules/custom/mymodule/src/Form/SettingsForm.php',
|
||||||
|
language: 'php' as const,
|
||||||
|
startLine: 1,
|
||||||
|
endLine: 50,
|
||||||
|
startColumn: 0,
|
||||||
|
endColumn: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
const ctx = makeContext({
|
||||||
|
getNodesByName: (name) => (name === 'SettingsForm' ? [classNode] : []),
|
||||||
|
});
|
||||||
|
const ref = {
|
||||||
|
fromNodeId: 'route:x',
|
||||||
|
referenceName: '\\Drupal\\mymodule\\Form\\SettingsForm',
|
||||||
|
referenceKind: 'references' as const,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
filePath: 'mymodule.routing.yml',
|
||||||
|
language: 'yaml' as const,
|
||||||
|
};
|
||||||
|
const resolved = drupalResolver.resolve(ref, ctx);
|
||||||
|
expect(resolved).not.toBeNull();
|
||||||
|
expect(resolved!.targetNodeId).toBe('class:form123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the target class cannot be found', () => {
|
||||||
|
const ctx = makeContext({ getNodesByName: () => [] });
|
||||||
|
const ref = {
|
||||||
|
fromNodeId: 'route:x',
|
||||||
|
referenceName: '\\Drupal\\mymodule\\Controller\\Missing::method',
|
||||||
|
referenceKind: 'references' as const,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
filePath: 'mymodule.routing.yml',
|
||||||
|
language: 'yaml' as const,
|
||||||
|
};
|
||||||
|
expect(drupalResolver.resolve(ref, ctx)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// End-to-end integration test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initGrammars();
|
||||||
|
await loadAllGrammars();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Drupal end-to-end — route node linked to controller method', () => {
|
||||||
|
let tmpDir: string | undefined;
|
||||||
|
afterEach(() => {
|
||||||
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a route→controller edge from routing.yml to PHP class', async () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-'));
|
||||||
|
|
||||||
|
// Minimal composer.json to trigger Drupal detection
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, 'composer.json'),
|
||||||
|
JSON.stringify({ require: { 'drupal/core-recommended': '~10.5' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Module directory structure
|
||||||
|
const modDir = path.join(tmpDir, 'web', 'modules', 'custom', 'my_module');
|
||||||
|
fs.mkdirSync(path.join(modDir, 'src', 'Controller'), { recursive: true });
|
||||||
|
|
||||||
|
// routing.yml
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(modDir, 'my_module.routing.yml'),
|
||||||
|
[
|
||||||
|
'my_module.hello:',
|
||||||
|
" path: '/hello'",
|
||||||
|
' defaults:',
|
||||||
|
" _controller: '\\Drupal\\my_module\\Controller\\HelloController::build'",
|
||||||
|
" _title: 'Hello'",
|
||||||
|
' requirements:',
|
||||||
|
" _permission: 'access content'",
|
||||||
|
].join('\n') + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
// PHP controller
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(modDir, 'src', 'Controller', 'HelloController.php'),
|
||||||
|
[
|
||||||
|
'<?php',
|
||||||
|
'namespace Drupal\\my_module\\Controller;',
|
||||||
|
'use Drupal\\Core\\Controller\\ControllerBase;',
|
||||||
|
'class HelloController extends ControllerBase {',
|
||||||
|
' public function build() {',
|
||||||
|
" return ['#markup' => 'Hello'];",
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
].join('\n') + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const cg = CodeGraph.initSync(tmpDir);
|
||||||
|
await cg.indexAll();
|
||||||
|
|
||||||
|
// Route node must exist
|
||||||
|
const routes = cg.getNodesByKind('route');
|
||||||
|
expect(routes.length).toBeGreaterThan(0);
|
||||||
|
const route = routes.find((n) => n.name.includes('/hello'));
|
||||||
|
expect(route).toBeDefined();
|
||||||
|
|
||||||
|
// Controller method must be indexed
|
||||||
|
const methods = cg.getNodesByKind('method');
|
||||||
|
const buildMethod = methods.find((n) => n.name === 'build');
|
||||||
|
expect(buildMethod).toBeDefined();
|
||||||
|
|
||||||
|
// Edge: route → build method (or class fallback)
|
||||||
|
const edges = cg.getOutgoingEdges(route!.id);
|
||||||
|
expect(edges.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
cg.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ import * as path from 'path';
|
|||||||
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
|
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
|
||||||
import { Language } from '../types';
|
import { Language } from '../types';
|
||||||
|
|
||||||
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'unknown'>;
|
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'unknown'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WASM filename map — maps each language to its .wasm grammar file
|
* WASM filename map — maps each language to its .wasm grammar file
|
||||||
@@ -63,6 +63,16 @@ export const EXTENSION_MAP: Record<string, Language> = {
|
|||||||
'.hxx': 'cpp',
|
'.hxx': 'cpp',
|
||||||
'.cs': 'csharp',
|
'.cs': 'csharp',
|
||||||
'.php': 'php',
|
'.php': 'php',
|
||||||
|
// Drupal-specific PHP file extensions
|
||||||
|
'.module': 'php',
|
||||||
|
'.install': 'php',
|
||||||
|
'.theme': 'php',
|
||||||
|
'.inc': 'php',
|
||||||
|
// YAML (used for Drupal routing files; no symbol extraction, file-level tracking only)
|
||||||
|
'.yml': 'yaml',
|
||||||
|
'.yaml': 'yaml',
|
||||||
|
// Twig templates (file-level tracking only, no symbol extraction)
|
||||||
|
'.twig': 'twig',
|
||||||
'.rb': 'ruby',
|
'.rb': 'ruby',
|
||||||
'.rake': 'ruby',
|
'.rake': 'ruby',
|
||||||
'.swift': 'swift',
|
'.swift': 'swift',
|
||||||
@@ -215,6 +225,8 @@ export function isLanguageSupported(language: Language): boolean {
|
|||||||
if (language === 'svelte') return true; // custom extractor (script block delegation)
|
if (language === 'svelte') return true; // custom extractor (script block delegation)
|
||||||
if (language === 'vue') return true; // custom extractor (script block delegation)
|
if (language === 'vue') return true; // custom extractor (script block delegation)
|
||||||
if (language === 'liquid') return true; // custom regex extractor
|
if (language === 'liquid') return true; // custom regex extractor
|
||||||
|
if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver
|
||||||
|
if (language === 'twig') return true; // file-level tracking only
|
||||||
if (language === 'unknown') return false;
|
if (language === 'unknown') return false;
|
||||||
return language in WASM_GRAMMAR_FILES;
|
return language in WASM_GRAMMAR_FILES;
|
||||||
}
|
}
|
||||||
@@ -224,6 +236,7 @@ export function isLanguageSupported(language: Language): boolean {
|
|||||||
*/
|
*/
|
||||||
export function isGrammarLoaded(language: Language): boolean {
|
export function isGrammarLoaded(language: Language): boolean {
|
||||||
if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
|
if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
|
||||||
|
if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed
|
||||||
return languageCache.has(language);
|
return languageCache.has(language);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +314,8 @@ export function getLanguageDisplayName(language: Language): string {
|
|||||||
scala: 'Scala',
|
scala: 'Scala',
|
||||||
lua: 'Lua',
|
lua: 'Lua',
|
||||||
luau: 'Luau',
|
luau: 'Luau',
|
||||||
|
yaml: 'YAML',
|
||||||
|
twig: 'Twig',
|
||||||
unknown: 'Unknown',
|
unknown: 'Unknown',
|
||||||
};
|
};
|
||||||
return names[language] || language;
|
return names[language] || language;
|
||||||
|
|||||||
@@ -2535,6 +2535,11 @@ export function extractFromSource(
|
|||||||
// Use custom extractor for Liquid
|
// Use custom extractor for Liquid
|
||||||
const extractor = new LiquidExtractor(filePath, source);
|
const extractor = new LiquidExtractor(filePath, source);
|
||||||
result = extractor.extract();
|
result = extractor.extract();
|
||||||
|
} else if (detectedLanguage === 'yaml' || detectedLanguage === 'twig') {
|
||||||
|
// No symbol extraction — file is tracked at the file-record level only.
|
||||||
|
// Framework extractors (e.g. Drupal routing resolver) run below and may
|
||||||
|
// add route nodes / references for yaml files such as *.routing.yml.
|
||||||
|
result = { nodes: [], edges: [], unresolvedReferences: [], errors: [], durationMs: 0 };
|
||||||
} else if (
|
} else if (
|
||||||
detectedLanguage === 'pascal' &&
|
detectedLanguage === 'pascal' &&
|
||||||
(fileExtension === '.dfm' || fileExtension === '.fmx')
|
(fileExtension === '.dfm' || fileExtension === '.fmx')
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Drupal Framework Resolver
|
||||||
|
*
|
||||||
|
* Supports Drupal 8/9/10/11 (Composer-based projects). Drupal 7 is not supported.
|
||||||
|
*
|
||||||
|
* ## What this resolver does
|
||||||
|
*
|
||||||
|
* 1. **Detection** — reads composer.json and checks for any `drupal/*` dependency in
|
||||||
|
* `require` or `require-dev`.
|
||||||
|
*
|
||||||
|
* 2. **Route extraction** — parses `*.routing.yml` files and emits `route` nodes for each
|
||||||
|
* Drupal route, with `references` edges to the `_controller`, `_form`, or entity handler
|
||||||
|
* class/method.
|
||||||
|
*
|
||||||
|
* 3. **Hook detection** — scans `.module`, `.install`, `.theme`, and `.inc` files for Drupal
|
||||||
|
* hook implementations. Two strategies are used:
|
||||||
|
* a. Docblock: `@Implements hook_X()` → precise, no false positives.
|
||||||
|
* b. Name pattern: function `{moduleName}_{hookSuffix}()` → catches hooks without
|
||||||
|
* docblocks but may produce false positives on helper functions.
|
||||||
|
* Detected hooks emit an `UnresolvedRef` from the implementing function node to the
|
||||||
|
* canonical `hook_X` name, linking implementations to the hook when `codegraph_callers`
|
||||||
|
* is invoked.
|
||||||
|
*
|
||||||
|
* ## Design decisions (review in future iterations)
|
||||||
|
*
|
||||||
|
* - Hook graph resolution (v1): hook references are stored as UnresolvedRef pointing to the
|
||||||
|
* canonical `hook_X` name. If Drupal core is indexed, these will resolve to core hook
|
||||||
|
* definitions. Without core, they remain unresolved but are still searchable via
|
||||||
|
* `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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateNodeId } from '../../extraction/tree-sitter-helpers';
|
||||||
|
import { Node } from '../../types';
|
||||||
|
import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the last PHP namespace segment from a FQCN like `\Drupal\mymodule\Controller\Foo`.
|
||||||
|
* Returns `null` for strings that don't look like a FQCN.
|
||||||
|
*/
|
||||||
|
function lastSegment(fqcn: string): string | null {
|
||||||
|
const clean = fqcn.replace(/^\\+/, '').trim();
|
||||||
|
if (!clean.includes('\\')) return null;
|
||||||
|
const parts = clean.split('\\');
|
||||||
|
return parts[parts.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the Drupal module name from a file path.
|
||||||
|
* e.g. `web/modules/custom/my_module/my_module.module` → `my_module`
|
||||||
|
*/
|
||||||
|
function moduleNameFromPath(filePath: string): string | null {
|
||||||
|
const match = filePath.match(/\/([^/]+)\.[^./]+$/);
|
||||||
|
return match ? match[1]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Route extraction helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract route nodes and handler references from a Drupal `*.routing.yml` file.
|
||||||
|
*
|
||||||
|
* Drupal routing YAML format:
|
||||||
|
*
|
||||||
|
* route.name:
|
||||||
|
* path: '/some/path'
|
||||||
|
* defaults:
|
||||||
|
* _controller: '\Drupal\module\Controller\MyController::method'
|
||||||
|
* _form: '\Drupal\module\Form\MyForm'
|
||||||
|
* _title: 'Page title'
|
||||||
|
* requirements:
|
||||||
|
* _permission: 'access content'
|
||||||
|
* methods: [GET, POST] # optional
|
||||||
|
*/
|
||||||
|
function extractDrupalRoutes(
|
||||||
|
filePath: string,
|
||||||
|
content: string
|
||||||
|
): { nodes: Node[]; references: UnresolvedRef[] } {
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
const references: UnresolvedRef[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
type PendingRoute = { name: string; lineNum: number };
|
||||||
|
let pending: PendingRoute | null = null;
|
||||||
|
let currentPath: string | null = null;
|
||||||
|
let handlerRefs: string[] = [];
|
||||||
|
let methods: string[] = [];
|
||||||
|
|
||||||
|
const flushRoute = () => {
|
||||||
|
if (!pending || !currentPath) return;
|
||||||
|
|
||||||
|
const methodTag = methods.length > 0 ? ` [${methods.join(',')}]` : '';
|
||||||
|
const routeNode: Node = {
|
||||||
|
id: `route:${filePath}:${pending.lineNum}:${currentPath}`,
|
||||||
|
kind: 'route',
|
||||||
|
name: `${currentPath}${methodTag}`,
|
||||||
|
qualifiedName: `${filePath}::${pending.name}`,
|
||||||
|
filePath,
|
||||||
|
startLine: pending.lineNum,
|
||||||
|
endLine: pending.lineNum,
|
||||||
|
startColumn: 0,
|
||||||
|
endColumn: 0,
|
||||||
|
language: 'yaml',
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
nodes.push(routeNode);
|
||||||
|
|
||||||
|
for (const handler of handlerRefs) {
|
||||||
|
references.push({
|
||||||
|
fromNodeId: routeNode.id,
|
||||||
|
referenceName: handler,
|
||||||
|
referenceKind: 'references',
|
||||||
|
line: pending.lineNum,
|
||||||
|
column: 0,
|
||||||
|
filePath,
|
||||||
|
language: 'yaml',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
|
||||||
|
// Top-level route name: no leading whitespace, ends with a colon (no value after)
|
||||||
|
if (/^\S.*:\s*$/.test(line) && !/^\s/.test(line)) {
|
||||||
|
flushRoute();
|
||||||
|
pending = { name: trimmed.slice(0, -1).trim(), lineNum: i + 1 };
|
||||||
|
currentPath = null;
|
||||||
|
handlerRefs = [];
|
||||||
|
methods = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// path: '/some/path'
|
||||||
|
const pathMatch = trimmed.match(/^path:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
|
||||||
|
if (pathMatch) {
|
||||||
|
currentPath = pathMatch[1]!.trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _controller: '\Drupal\...\Class::method'
|
||||||
|
const controllerMatch = trimmed.match(/^_controller:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
|
||||||
|
if (controllerMatch) {
|
||||||
|
handlerRefs.push(controllerMatch[1]!.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _form: '\Drupal\...\Form\MyForm'
|
||||||
|
const formMatch = trimmed.match(/^_form:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
|
||||||
|
if (formMatch) {
|
||||||
|
handlerRefs.push(formMatch[1]!.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _entity_form / _entity_list / _entity_view: entity.type
|
||||||
|
const entityMatch = trimmed.match(/^_(entity_form|entity_list|entity_view):\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
|
||||||
|
if (entityMatch) {
|
||||||
|
handlerRefs.push(entityMatch[2]!.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// methods: [GET, POST] or methods: [GET]
|
||||||
|
const methodsMatch = trimmed.match(/^methods:\s*\[([^\]]+)\]/);
|
||||||
|
if (methodsMatch) {
|
||||||
|
methods = methodsMatch[1]!.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushRoute();
|
||||||
|
return { nodes, references };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook detection helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HOOK_FILE_EXTENSIONS = ['.module', '.install', '.theme', '.inc'];
|
||||||
|
|
||||||
|
function isDrupalHookFile(filePath: string): boolean {
|
||||||
|
return HOOK_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract hook implementation references from a Drupal PHP file.
|
||||||
|
*
|
||||||
|
* Strategy A (primary): look for docblocks containing `Implements hook_X().`
|
||||||
|
* followed immediately by the function definition. This is the Drupal coding
|
||||||
|
* standard and is precise.
|
||||||
|
*
|
||||||
|
* Strategy B (fallback): for functions whose name starts with `{moduleName}_`,
|
||||||
|
* treat the suffix as the hook name. Catches hooks without docblocks but may
|
||||||
|
* produce false positives on non-hook helper functions.
|
||||||
|
*
|
||||||
|
* Each detected hook emits an UnresolvedRef from the implementing function node
|
||||||
|
* (identified by computing the same ID tree-sitter would generate) to the
|
||||||
|
* canonical hook name, e.g. `hook_form_alter`.
|
||||||
|
*/
|
||||||
|
function extractDrupalHooks(
|
||||||
|
filePath: string,
|
||||||
|
content: string
|
||||||
|
): { nodes: Node[]; references: UnresolvedRef[] } {
|
||||||
|
const references: UnresolvedRef[] = [];
|
||||||
|
|
||||||
|
// Build a map of function name → 1-indexed line number for all top-level functions.
|
||||||
|
// This mirrors tree-sitter's line numbering so we can reconstruct node IDs.
|
||||||
|
const funcLineMap = new Map<string, number>();
|
||||||
|
const funcDef = /^function\s+(\w+)\s*\(/gm;
|
||||||
|
let fm: RegExpExecArray | null;
|
||||||
|
while ((fm = funcDef.exec(content)) !== null) {
|
||||||
|
const name = fm[1]!;
|
||||||
|
if (!funcLineMap.has(name)) {
|
||||||
|
// line = number of newlines before match start + 1
|
||||||
|
funcLineMap.set(name, content.slice(0, fm.index).split('\n').length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitHookRef = (hookName: string, funcName: string) => {
|
||||||
|
const lineNum = funcLineMap.get(funcName);
|
||||||
|
if (lineNum === undefined) return;
|
||||||
|
const nodeId = generateNodeId(filePath, 'function', funcName, lineNum);
|
||||||
|
references.push({
|
||||||
|
fromNodeId: nodeId,
|
||||||
|
referenceName: hookName,
|
||||||
|
referenceKind: 'references',
|
||||||
|
line: lineNum,
|
||||||
|
column: 0,
|
||||||
|
filePath,
|
||||||
|
language: 'php',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strategy A: docblock `Implements hook_X().` followed by function definition.
|
||||||
|
// The docblock and function may be separated by blank lines.
|
||||||
|
const docblockPattern =
|
||||||
|
/\/\*\*[\s\S]*?(?:@|\*\s+)Implements\s+(hook_\w+)\s*\(\)[\s\S]*?\*\/\s*\n(?:\s*\n)*function\s+(\w+)\s*\(/g;
|
||||||
|
const docblockMatched = new Set<string>();
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = docblockPattern.exec(content)) !== null) {
|
||||||
|
const [, hookName, funcName] = match;
|
||||||
|
emitHookRef(hookName!, funcName!);
|
||||||
|
docblockMatched.add(funcName!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy B: fallback name-pattern matching for functions without docblocks.
|
||||||
|
// Only applies to functions whose name starts with {moduleName}_ and that were
|
||||||
|
// not already matched by Strategy A.
|
||||||
|
const moduleName = moduleNameFromPath(filePath);
|
||||||
|
if (moduleName) {
|
||||||
|
const prefix = moduleName + '_';
|
||||||
|
for (const [funcName] of funcLineMap) {
|
||||||
|
if (docblockMatched.has(funcName)) continue;
|
||||||
|
if (!funcName.startsWith(prefix)) continue;
|
||||||
|
const hookSuffix = funcName.slice(prefix.length);
|
||||||
|
if (!hookSuffix) continue;
|
||||||
|
// Emit a reference to hook_{suffix} — the resolver will link it if the
|
||||||
|
// hook is defined somewhere in the indexed graph (e.g. Drupal core).
|
||||||
|
emitHookRef(`hook_${hookSuffix}`, funcName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: [], references };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolver
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const drupalResolver: FrameworkResolver = {
|
||||||
|
name: 'drupal',
|
||||||
|
languages: ['php', 'yaml'],
|
||||||
|
|
||||||
|
detect(context: ResolutionContext): boolean {
|
||||||
|
const composer = context.readFile('composer.json');
|
||||||
|
if (!composer) return false;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(composer) as { require?: Record<string, string>; 'require-dev'?: Record<string, string> };
|
||||||
|
const deps = { ...json.require, ...(json['require-dev'] ?? {}) };
|
||||||
|
return Object.keys(deps).some((k) => k.startsWith('drupal/'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||||
|
const name = ref.referenceName;
|
||||||
|
|
||||||
|
// _controller: '\Drupal\module\...\ClassName::methodName'
|
||||||
|
const controllerMatch = name.match(/^\\?(?:Drupal\\[^:]+\\)?([^\\:]+)::(\w+)$/);
|
||||||
|
if (controllerMatch) {
|
||||||
|
const [, className, methodName] = controllerMatch;
|
||||||
|
const classNodes = context.getNodesByName(className!);
|
||||||
|
for (const cls of classNodes) {
|
||||||
|
if (cls.kind !== 'class') continue;
|
||||||
|
const fileNodes = context.getNodesInFile(cls.filePath);
|
||||||
|
const method = fileNodes.find((n) => n.kind === 'method' && n.name === methodName);
|
||||||
|
if (method) {
|
||||||
|
return { original: ref, targetNodeId: method.id, confidence: 0.9, resolvedBy: 'framework' };
|
||||||
|
}
|
||||||
|
return { original: ref, targetNodeId: cls.id, confidence: 0.7, resolvedBy: 'framework' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _form / _entity_form: '\Drupal\module\...\ClassName' (no ::method)
|
||||||
|
if (name.includes('\\') && !name.includes('::')) {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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_'
|
||||||
|
const candidates = 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } {
|
||||||
|
if (filePath.endsWith('.routing.yml')) {
|
||||||
|
return extractDrupalRoutes(filePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDrupalHookFile(filePath) || filePath.endsWith('.php')) {
|
||||||
|
return extractDrupalHooks(filePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: [], references: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { FrameworkResolver, ResolutionContext } from '../types';
|
import { FrameworkResolver, ResolutionContext } from '../types';
|
||||||
import type { Language } from '../../types';
|
import type { Language } from '../../types';
|
||||||
|
import { drupalResolver } from './drupal';
|
||||||
import { laravelResolver } from './laravel';
|
import { laravelResolver } from './laravel';
|
||||||
import { expressResolver } from './express';
|
import { expressResolver } from './express';
|
||||||
import { nestjsResolver } from './nestjs';
|
import { nestjsResolver } from './nestjs';
|
||||||
@@ -26,6 +27,7 @@ import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
|
|||||||
const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
|
const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
|
||||||
// PHP
|
// PHP
|
||||||
laravelResolver,
|
laravelResolver,
|
||||||
|
drupalResolver,
|
||||||
// JavaScript/TypeScript
|
// JavaScript/TypeScript
|
||||||
expressResolver,
|
expressResolver,
|
||||||
nestjsResolver,
|
nestjsResolver,
|
||||||
@@ -105,6 +107,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-export framework resolvers
|
// Re-export framework resolvers
|
||||||
|
export { drupalResolver } from './drupal';
|
||||||
export { laravelResolver, FACADE_MAPPINGS } from './laravel';
|
export { laravelResolver, FACADE_MAPPINGS } from './laravel';
|
||||||
export { expressResolver } from './express';
|
export { expressResolver } from './express';
|
||||||
export { nestjsResolver } from './nestjs';
|
export { nestjsResolver } from './nestjs';
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export const LANGUAGES = [
|
|||||||
'scala',
|
'scala',
|
||||||
'lua',
|
'lua',
|
||||||
'luau',
|
'luau',
|
||||||
|
'yaml',
|
||||||
|
'twig',
|
||||||
'unknown',
|
'unknown',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -522,6 +524,15 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
|
|||||||
'**/*.cs',
|
'**/*.cs',
|
||||||
// PHP
|
// PHP
|
||||||
'**/*.php',
|
'**/*.php',
|
||||||
|
// Drupal-specific PHP extensions
|
||||||
|
'**/*.module',
|
||||||
|
'**/*.install',
|
||||||
|
'**/*.theme',
|
||||||
|
'**/*.inc',
|
||||||
|
// Drupal routing YAML
|
||||||
|
'**/*.routing.yml',
|
||||||
|
// Twig templates
|
||||||
|
'**/*.twig',
|
||||||
// Ruby
|
// Ruby
|
||||||
'**/*.rb',
|
'**/*.rb',
|
||||||
// Swift
|
// Swift
|
||||||
@@ -667,6 +678,11 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
|
|||||||
'**/storage/framework/**',
|
'**/storage/framework/**',
|
||||||
'**/bootstrap/cache/**',
|
'**/bootstrap/cache/**',
|
||||||
|
|
||||||
|
// Drupal - core and contrib are rarely customised; index only custom code
|
||||||
|
'**/web/core/**',
|
||||||
|
'**/web/modules/contrib/**',
|
||||||
|
'**/web/themes/contrib/**',
|
||||||
|
|
||||||
// Ruby
|
// Ruby
|
||||||
'**/.bundle/**',
|
'**/.bundle/**',
|
||||||
'**/tmp/cache/**',
|
'**/tmp/cache/**',
|
||||||
|
|||||||
Reference in New Issue
Block a user