drupal.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. /**
  2. * Tests for Drupal framework resolver.
  3. *
  4. * Unit tests cover drupalResolver.detect(), extract() (routes + hooks), and resolve().
  5. * Integration tests use a real CodeGraph instance with a temporary Drupal project layout.
  6. */
  7. import * as fs from 'fs';
  8. import * as os from 'os';
  9. import * as path from 'path';
  10. import { afterEach, beforeAll, describe, expect, it } from 'vitest';
  11. import { CodeGraph } from '../src';
  12. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  13. import { drupalResolver } from '../src/resolution/frameworks/drupal';
  14. import type { ResolutionContext } from '../src/resolution/types';
  15. // ---------------------------------------------------------------------------
  16. // Helpers
  17. // ---------------------------------------------------------------------------
  18. function makeContext(
  19. overrides: Partial<ResolutionContext> = {},
  20. ): ResolutionContext {
  21. return {
  22. getNodesInFile: () => [],
  23. getNodesByName: () => [],
  24. getNodesByQualifiedName: () => [],
  25. getNodesByKind: () => [],
  26. fileExists: () => false,
  27. readFile: () => null,
  28. getProjectRoot: () => '/project',
  29. getAllFiles: () => [],
  30. getNodesByLowerName: () => [],
  31. getImportMappings: () => [],
  32. ...overrides,
  33. };
  34. }
  35. // ---------------------------------------------------------------------------
  36. // detect()
  37. // ---------------------------------------------------------------------------
  38. describe('drupalResolver.detect', () => {
  39. it('returns true when composer.json has a drupal/ dependency', () => {
  40. const ctx = makeContext({
  41. readFile: (f) =>
  42. f === 'composer.json'
  43. ? JSON.stringify({
  44. require: {
  45. 'drupal/core-recommended': '~10.5',
  46. 'drush/drush': '^13',
  47. },
  48. })
  49. : null,
  50. });
  51. expect(drupalResolver.detect(ctx)).toBe(true);
  52. });
  53. it('returns true when drupal/ dependency is in require-dev', () => {
  54. const ctx = makeContext({
  55. readFile: (f) =>
  56. f === 'composer.json'
  57. ? JSON.stringify({ 'require-dev': { 'drupal/core': '^10' } })
  58. : null,
  59. });
  60. expect(drupalResolver.detect(ctx)).toBe(true);
  61. });
  62. it('returns false when composer.json has no drupal/ dependencies', () => {
  63. const ctx = makeContext({
  64. readFile: (f) =>
  65. f === 'composer.json'
  66. ? JSON.stringify({
  67. require: { 'laravel/framework': '^10', php: '>=8.1' },
  68. })
  69. : null,
  70. });
  71. expect(drupalResolver.detect(ctx)).toBe(false);
  72. });
  73. it('returns false when composer.json is absent', () => {
  74. const ctx = makeContext({ readFile: () => null });
  75. expect(drupalResolver.detect(ctx)).toBe(false);
  76. });
  77. it('returns false when composer.json is malformed JSON', () => {
  78. const ctx = makeContext({ readFile: () => '{ bad json' });
  79. expect(drupalResolver.detect(ctx)).toBe(false);
  80. });
  81. });
  82. // ---------------------------------------------------------------------------
  83. // extract() — routing.yml
  84. // ---------------------------------------------------------------------------
  85. describe('drupalResolver.extract — routing.yml', () => {
  86. const routing = `
  87. mymodule.example:
  88. path: '/mymodule/example'
  89. defaults:
  90. _controller: '\\Drupal\\mymodule\\Controller\\MyController::build'
  91. _title: 'Example page'
  92. requirements:
  93. _permission: 'access content'
  94. `;
  95. it('emits a route node for each YAML route', () => {
  96. const { nodes } = drupalResolver.extract!(
  97. 'mymodule/mymodule.routing.yml',
  98. routing,
  99. );
  100. expect(nodes).toHaveLength(1);
  101. expect(nodes[0]!.kind).toBe('route');
  102. expect(nodes[0]!.name).toBe('/mymodule/example');
  103. });
  104. it('sets qualifiedName to filePath::routeName', () => {
  105. const { nodes } = drupalResolver.extract!(
  106. 'mymodule/mymodule.routing.yml',
  107. routing,
  108. );
  109. expect(nodes[0]!.qualifiedName).toBe(
  110. 'mymodule/mymodule.routing.yml::mymodule.example',
  111. );
  112. });
  113. it('emits a references edge to the controller FQCN', () => {
  114. const { references } = drupalResolver.extract!(
  115. 'mymodule/mymodule.routing.yml',
  116. routing,
  117. );
  118. expect(references).toHaveLength(1);
  119. expect(references[0]!.referenceName).toBe(
  120. '\\Drupal\\mymodule\\Controller\\MyController::build',
  121. );
  122. expect(references[0]!.referenceKind).toBe('references');
  123. });
  124. it('emits a references edge to a _form handler', () => {
  125. const src = `
  126. mymodule.settings_form:
  127. path: '/admin/config/mymodule'
  128. defaults:
  129. _form: '\\Drupal\\mymodule\\Form\\SettingsForm'
  130. _title: 'MyModule settings'
  131. requirements:
  132. _permission: 'administer site configuration'
  133. `;
  134. const { nodes, references } = drupalResolver.extract!(
  135. 'mymodule/mymodule.routing.yml',
  136. src,
  137. );
  138. expect(nodes).toHaveLength(1);
  139. expect(references[0]!.referenceName).toBe(
  140. '\\Drupal\\mymodule\\Form\\SettingsForm',
  141. );
  142. });
  143. it('handles multiple routes in one file', () => {
  144. const src = `
  145. mod.page_one:
  146. path: '/page-one'
  147. defaults:
  148. _controller: '\\Drupal\\mod\\Controller\\PageController::one'
  149. requirements:
  150. _permission: 'access content'
  151. mod.page_two:
  152. path: '/page-two'
  153. defaults:
  154. _controller: '\\Drupal\\mod\\Controller\\PageController::two'
  155. requirements:
  156. _permission: 'access content'
  157. `;
  158. const { nodes, references } = drupalResolver.extract!(
  159. 'mod/mod.routing.yml',
  160. src,
  161. );
  162. expect(nodes).toHaveLength(2);
  163. expect(nodes.map((n) => n.name)).toContain('/page-one');
  164. expect(nodes.map((n) => n.name)).toContain('/page-two');
  165. expect(references).toHaveLength(2);
  166. });
  167. it('skips commented-out lines', () => {
  168. const src = `
  169. mod.page:
  170. path: '/page'
  171. defaults:
  172. #_controller: '\\Drupal\\mod\\Controller\\Old::build'
  173. _controller: '\\Drupal\\mod\\Controller\\New::build'
  174. requirements:
  175. _permission: 'access content'
  176. `;
  177. const { references } = drupalResolver.extract!('mod/mod.routing.yml', src);
  178. expect(references).toHaveLength(1);
  179. expect(references[0]!.referenceName).toContain('New');
  180. });
  181. it('includes HTTP methods in the route node name when present', () => {
  182. const src = `
  183. mod.api:
  184. path: '/api/resource'
  185. defaults:
  186. _controller: '\\Drupal\\mod\\Controller\\ApiController::get'
  187. methods: [GET, POST]
  188. requirements:
  189. _permission: 'access content'
  190. `;
  191. const { nodes } = drupalResolver.extract!('mod/mod.routing.yml', src);
  192. expect(nodes[0]!.name).toContain('GET');
  193. expect(nodes[0]!.name).toContain('POST');
  194. });
  195. it('returns empty result for non-routing-yml files', () => {
  196. const { nodes, references } = drupalResolver.extract!(
  197. 'mymodule.module',
  198. '<?php\n',
  199. );
  200. // Module files go through hook detection, not route extraction
  201. expect(nodes).toHaveLength(0);
  202. });
  203. it('returns empty result for files with no valid routes', () => {
  204. const { nodes, references } = drupalResolver.extract!(
  205. 'some.routing.yml',
  206. '# empty\n',
  207. );
  208. expect(nodes).toHaveLength(0);
  209. expect(references).toHaveLength(0);
  210. });
  211. });
  212. // ---------------------------------------------------------------------------
  213. // extract() — hook detection in .module files
  214. // ---------------------------------------------------------------------------
  215. describe('drupalResolver.extract — hook detection', () => {
  216. it('detects hook implementation via docblock (Strategy A)', () => {
  217. const src = `<?php
  218. /**
  219. * Implements hook_form_alter().
  220. */
  221. function mymodule_form_alter(&$form, $form_state, $form_id) {
  222. // ...
  223. }
  224. `;
  225. const { references } = drupalResolver.extract!(
  226. 'web/modules/custom/mymodule/mymodule.module',
  227. src,
  228. );
  229. const hookRef = references.find(
  230. (r) => r.referenceName === 'hook_form_alter',
  231. );
  232. expect(hookRef).toBeDefined();
  233. expect(hookRef!.referenceKind).toBe('references');
  234. });
  235. it('detects hook implementation via name pattern (Strategy B)', () => {
  236. const src = `<?php
  237. function mymodule_views_data() {
  238. return [];
  239. }
  240. `;
  241. const { references } = drupalResolver.extract!(
  242. 'web/modules/custom/mymodule/mymodule.module',
  243. src,
  244. );
  245. const hookRef = references.find(
  246. (r) => r.referenceName === 'hook_views_data',
  247. );
  248. expect(hookRef).toBeDefined();
  249. });
  250. it('does not emit a hook ref for non-hook helper functions', () => {
  251. // 'other_module_helper' doesn't start with 'mymodule_', so no hook ref
  252. const src = `<?php
  253. function other_module_helper() {}
  254. `;
  255. const { references } = drupalResolver.extract!(
  256. 'web/modules/custom/mymodule/mymodule.module',
  257. src,
  258. );
  259. expect(references).toHaveLength(0);
  260. });
  261. it('detects hooks in .install files', () => {
  262. const src = `<?php
  263. /**
  264. * Implements hook_schema().
  265. */
  266. function mymodule_schema() {
  267. return [];
  268. }
  269. `;
  270. const { references } = drupalResolver.extract!(
  271. 'web/modules/custom/mymodule/mymodule.install',
  272. src,
  273. );
  274. const hookRef = references.find((r) => r.referenceName === 'hook_schema');
  275. expect(hookRef).toBeDefined();
  276. });
  277. it('detects hooks in .theme files', () => {
  278. const src = `<?php
  279. /**
  280. * Implements hook_preprocess_node().
  281. */
  282. function mytheme_preprocess_node(&$variables) {}
  283. `;
  284. const { references } = drupalResolver.extract!(
  285. 'web/themes/custom/mytheme/mytheme.theme',
  286. src,
  287. );
  288. const hookRef = references.find(
  289. (r) => r.referenceName === 'hook_preprocess_node',
  290. );
  291. expect(hookRef).toBeDefined();
  292. });
  293. it('does not duplicate refs when both docblock and name pattern match', () => {
  294. // Strategy A matches first and adds to docblockMatched set;
  295. // Strategy B skips already-matched functions.
  296. const src = `<?php
  297. /**
  298. * Implements hook_form_alter().
  299. */
  300. function mymodule_form_alter(&$form, $form_state, $form_id) {}
  301. `;
  302. const { references } = drupalResolver.extract!(
  303. 'web/modules/custom/mymodule/mymodule.module',
  304. src,
  305. );
  306. const hookRefs = references.filter(
  307. (r) => r.referenceName === 'hook_form_alter',
  308. );
  309. expect(hookRefs).toHaveLength(1);
  310. });
  311. });
  312. // ---------------------------------------------------------------------------
  313. // resolve()
  314. // ---------------------------------------------------------------------------
  315. describe('drupalResolver.resolve', () => {
  316. it('resolves a _controller FQCN with ::method to the method node', () => {
  317. const methodNode = {
  318. id: 'method:abc123',
  319. kind: 'method' as const,
  320. name: 'build',
  321. qualifiedName: 'MyController::build',
  322. filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
  323. language: 'php' as const,
  324. startLine: 10,
  325. endLine: 20,
  326. startColumn: 0,
  327. endColumn: 0,
  328. updatedAt: 0,
  329. };
  330. const classNode = {
  331. id: 'class:def456',
  332. kind: 'class' as const,
  333. name: 'MyController',
  334. qualifiedName: 'MyController',
  335. filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
  336. language: 'php' as const,
  337. startLine: 5,
  338. endLine: 30,
  339. startColumn: 0,
  340. endColumn: 0,
  341. updatedAt: 0,
  342. };
  343. const ctx = makeContext({
  344. getNodesByName: (name) => (name === 'MyController' ? [classNode] : []),
  345. getNodesInFile: () => [classNode, methodNode],
  346. });
  347. const ref = {
  348. fromNodeId: 'route:x',
  349. referenceName: '\\Drupal\\mymodule\\Controller\\MyController::build',
  350. referenceKind: 'references' as const,
  351. line: 1,
  352. column: 0,
  353. filePath: 'mymodule.routing.yml',
  354. language: 'yaml' as const,
  355. };
  356. const resolved = drupalResolver.resolve(ref, ctx);
  357. expect(resolved).not.toBeNull();
  358. expect(resolved!.targetNodeId).toBe('method:abc123');
  359. expect(resolved!.confidence).toBeGreaterThanOrEqual(0.85);
  360. });
  361. it('resolves a _form FQCN (no ::method) to the class node', () => {
  362. const classNode = {
  363. id: 'class:form123',
  364. kind: 'class' as const,
  365. name: 'SettingsForm',
  366. qualifiedName: 'SettingsForm',
  367. filePath: 'web/modules/custom/mymodule/src/Form/SettingsForm.php',
  368. language: 'php' as const,
  369. startLine: 1,
  370. endLine: 50,
  371. startColumn: 0,
  372. endColumn: 0,
  373. updatedAt: 0,
  374. };
  375. const ctx = makeContext({
  376. getNodesByName: (name) => (name === 'SettingsForm' ? [classNode] : []),
  377. });
  378. const ref = {
  379. fromNodeId: 'route:x',
  380. referenceName: '\\Drupal\\mymodule\\Form\\SettingsForm',
  381. referenceKind: 'references' as const,
  382. line: 1,
  383. column: 0,
  384. filePath: 'mymodule.routing.yml',
  385. language: 'yaml' as const,
  386. };
  387. const resolved = drupalResolver.resolve(ref, ctx);
  388. expect(resolved).not.toBeNull();
  389. expect(resolved!.targetNodeId).toBe('class:form123');
  390. });
  391. it('returns null when the target class cannot be found', () => {
  392. const ctx = makeContext({ getNodesByName: () => [] });
  393. const ref = {
  394. fromNodeId: 'route:x',
  395. referenceName: '\\Drupal\\mymodule\\Controller\\Missing::method',
  396. referenceKind: 'references' as const,
  397. line: 1,
  398. column: 0,
  399. filePath: 'mymodule.routing.yml',
  400. language: 'yaml' as const,
  401. };
  402. expect(drupalResolver.resolve(ref, ctx)).toBeNull();
  403. });
  404. });
  405. // ---------------------------------------------------------------------------
  406. // End-to-end integration test
  407. // ---------------------------------------------------------------------------
  408. beforeAll(async () => {
  409. await initGrammars();
  410. await loadAllGrammars();
  411. });
  412. describe('Drupal end-to-end — route node linked to controller method', () => {
  413. let tmpDir: string | undefined;
  414. afterEach(() => {
  415. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  416. tmpDir = undefined;
  417. });
  418. it('creates a route→controller edge from routing.yml to PHP class', async () => {
  419. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-'));
  420. // Minimal composer.json to trigger Drupal detection
  421. fs.writeFileSync(
  422. path.join(tmpDir, 'composer.json'),
  423. JSON.stringify({ require: { 'drupal/core-recommended': '~10.5' } }),
  424. );
  425. // Module directory structure
  426. const modDir = path.join(tmpDir, 'web', 'modules', 'custom', 'my_module');
  427. fs.mkdirSync(path.join(modDir, 'src', 'Controller'), { recursive: true });
  428. // routing.yml
  429. fs.writeFileSync(
  430. path.join(modDir, 'my_module.routing.yml'),
  431. [
  432. 'my_module.hello:',
  433. " path: '/hello'",
  434. ' defaults:',
  435. " _controller: '\\Drupal\\my_module\\Controller\\HelloController::build'",
  436. " _title: 'Hello'",
  437. ' requirements:',
  438. " _permission: 'access content'",
  439. ].join('\n') + '\n',
  440. );
  441. // PHP controller
  442. fs.writeFileSync(
  443. path.join(modDir, 'src', 'Controller', 'HelloController.php'),
  444. [
  445. '<?php',
  446. 'namespace Drupal\\my_module\\Controller;',
  447. 'use Drupal\\Core\\Controller\\ControllerBase;',
  448. 'class HelloController extends ControllerBase {',
  449. ' public function build() {',
  450. " return ['#markup' => 'Hello'];",
  451. ' }',
  452. '}',
  453. ].join('\n') + '\n',
  454. );
  455. const cg = CodeGraph.initSync(tmpDir);
  456. await cg.indexAll();
  457. // Route node must exist
  458. const routes = cg.getNodesByKind('route');
  459. expect(routes.length).toBeGreaterThan(0);
  460. const route = routes.find((n) => n.name.includes('/hello'));
  461. expect(route).toBeDefined();
  462. // Controller method must be indexed
  463. const methods = cg.getNodesByKind('method');
  464. const buildMethod = methods.find((n) => n.name === 'build');
  465. expect(buildMethod).toBeDefined();
  466. // Edge: route → build method (or class fallback)
  467. const edges = cg.getOutgoingEdges(route!.id);
  468. expect(edges.length).toBeGreaterThan(0);
  469. cg.close();
  470. });
  471. });