forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdrupal.ts
More file actions
373 lines (333 loc) · 13.5 KB
/
drupal.ts
File metadata and controls
373 lines (333 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
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: [] };
},
};