* 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
( filePath: string, content: string )
| 224 | * canonical hook name, e.g. `hook_form_alter`. |
| 225 | */ |
| 226 | function extractDrupalHooks( |
| 227 | filePath: string, |
| 228 | content: string |
| 229 | ): { nodes: Node[]; references: UnresolvedRef[] } { |
| 230 | const references: UnresolvedRef[] = []; |
| 231 | |
| 232 | // Build a map of function name → 1-indexed line number for all top-level functions. |
| 233 | // This mirrors tree-sitter's line numbering so we can reconstruct node IDs. |
| 234 | const funcLineMap = new Map<string, number>(); |
| 235 | const funcDef = /^function\s+(\w+)\s*\(/gm; |
| 236 | let fm: RegExpExecArray | null; |
| 237 | while ((fm = funcDef.exec(content)) !== null) { |
| 238 | const name = fm[1]!; |
| 239 | if (!funcLineMap.has(name)) { |
| 240 | // line = number of newlines before match start + 1 |
| 241 | funcLineMap.set(name, content.slice(0, fm.index).split('\n').length); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | const emitHookRef = (hookName: string, funcName: string) => { |
| 246 | const lineNum = funcLineMap.get(funcName); |
| 247 | if (lineNum === undefined) return; |
| 248 | const nodeId = generateNodeId(filePath, 'function', funcName, lineNum); |
| 249 | references.push({ |
| 250 | fromNodeId: nodeId, |
| 251 | referenceName: hookName, |
| 252 | referenceKind: 'references', |
| 253 | line: lineNum, |
| 254 | column: 0, |
| 255 | filePath, |
| 256 | language: 'php', |
| 257 | }); |
| 258 | }; |
| 259 | |
| 260 | // Strategy A: docblock `Implements hook_X().` followed by function definition. |
| 261 | // The docblock and function may be separated by blank lines. |
| 262 | const docblockPattern = |
| 263 | /\/\*\*[\s\S]*?(?:@|\*\s+)Implements\s+(hook_\w+)\s*\(\)[\s\S]*?\*\/\s*\n(?:\s*\n)*function\s+(\w+)\s*\(/g; |
| 264 | const docblockMatched = new Set<string>(); |
| 265 | let match: RegExpExecArray | null; |
| 266 | while ((match = docblockPattern.exec(content)) !== null) { |
| 267 | const [, hookName, funcName] = match; |
| 268 | emitHookRef(hookName!, funcName!); |
| 269 | docblockMatched.add(funcName!); |
| 270 | } |
| 271 | |
| 272 | // Strategy B: fallback name-pattern matching for functions without docblocks. |
| 273 | // Only applies to functions whose name starts with {moduleName}_ and that were |
| 274 | // not already matched by Strategy A. |
| 275 | const moduleName = moduleNameFromPath(filePath); |
| 276 | if (moduleName) { |
| 277 | const prefix = moduleName + '_'; |
| 278 | for (const [funcName] of funcLineMap) { |
| 279 | if (docblockMatched.has(funcName)) continue; |
| 280 | if (!funcName.startsWith(prefix)) continue; |
| 281 | const hookSuffix = funcName.slice(prefix.length); |
| 282 | if (!hookSuffix) continue; |
| 283 | // Emit a reference to hook_{suffix} — the resolver will link it if the |
no test coverage detected