vite-plugin-ferry 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,96 +1,271 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { readFileSafe } from './file.js';
4
+ import { mapPhpTypeToTs } from './type-mapper.js';
5
+ // Import php-parser (CommonJS module with constructor)
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ const PhpParser = require('php-parser');
8
+ // Initialize the PHP parser (PHP 8+ only)
9
+ const parser = new PhpParser({
10
+ parser: {
11
+ extractDoc: true,
12
+ php8: true,
13
+ },
14
+ ast: {
15
+ withPositions: false,
16
+ },
17
+ });
2
18
  /**
3
- * Parse a PHP enum file and extract its definition.
19
+ * Parse PHP content and return the AST.
20
+ * Uses parseEval which doesn't require <?php tags or filenames.
4
21
  */
5
- export function parseEnumFile(enumPath) {
6
- const content = readFileSafe(enumPath);
7
- if (!content)
22
+ function parsePhp(content) {
23
+ try {
24
+ // Strip <?php tag if present (parseEval expects raw PHP code)
25
+ let code = content.trimStart();
26
+ if (code.startsWith('<?php')) {
27
+ code = code.slice(5);
28
+ }
29
+ else if (code.startsWith('<?')) {
30
+ code = code.slice(2);
31
+ }
32
+ return parser.parseEval(code);
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Walk all child nodes in an AST node.
40
+ */
41
+ function walkChildren(node, callback) {
42
+ const obj = node;
43
+ for (const key of Object.keys(obj)) {
44
+ const val = obj[key];
45
+ if (val && typeof val === 'object' && val.kind) {
46
+ if (callback(val))
47
+ return true;
48
+ }
49
+ else if (Array.isArray(val)) {
50
+ for (const item of val) {
51
+ if (item && typeof item === 'object' && item.kind) {
52
+ if (callback(item))
53
+ return true;
54
+ }
55
+ }
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+ /**
61
+ * Find a node by kind in the AST.
62
+ */
63
+ function findNodeByKind(ast, kind) {
64
+ if (ast.kind === kind)
65
+ return ast;
66
+ let result = null;
67
+ walkChildren(ast, (child) => {
68
+ const found = findNodeByKind(child, kind);
69
+ if (found) {
70
+ result = found;
71
+ return true;
72
+ }
73
+ return false;
74
+ });
75
+ return result;
76
+ }
77
+ /**
78
+ * Find all nodes of a specific kind in the AST.
79
+ */
80
+ function findAllNodesByKind(ast, kind) {
81
+ const results = [];
82
+ function walk(node) {
83
+ if (node.kind === kind) {
84
+ results.push(node);
85
+ }
86
+ walkChildren(node, (child) => {
87
+ walk(child);
88
+ return false;
89
+ });
90
+ }
91
+ walk(ast);
92
+ return results;
93
+ }
94
+ /**
95
+ * Extract string value from a PHP literal node.
96
+ */
97
+ function getStringValue(node) {
98
+ if (node.kind === 'string') {
99
+ return node.value;
100
+ }
101
+ if (node.kind === 'number') {
102
+ return String(node.value);
103
+ }
104
+ return null;
105
+ }
106
+ /**
107
+ * Parse PHP enum content and extract its definition.
108
+ * This is a pure function that takes PHP source code as input.
109
+ */
110
+ export function parseEnumContent(phpContent) {
111
+ const ast = parsePhp(phpContent);
112
+ if (!ast)
8
113
  return null;
9
- // Extract enum name and backing type
10
- const enumMatch = content.match(/enum\s+([A-Za-z0-9_]+)\s*(?:\:\s*([A-Za-z0-9_]+))?/);
11
- if (!enumMatch)
114
+ // Find the enum declaration
115
+ const enumNode = findNodeByKind(ast, 'enum');
116
+ if (!enumNode)
12
117
  return null;
13
- const name = enumMatch[1];
14
- const backing = enumMatch[2] ? enumMatch[2].toLowerCase() : null;
118
+ const name = typeof enumNode.name === 'string' ? enumNode.name : enumNode.name.name;
119
+ const backing = enumNode.valueType ? enumNode.valueType.name.toLowerCase() : null;
15
120
  // Extract enum cases
16
121
  const cases = [];
17
- const explicitCases = [...content.matchAll(/case\s+([A-Za-z0-9_]+)\s*=\s*'([^']*)'\s*;/g)];
18
- if (explicitCases.length) {
19
- for (const match of explicitCases) {
20
- cases.push({ key: match[1], value: match[2] });
122
+ const enumCases = findAllNodesByKind(enumNode, 'enumcase');
123
+ for (const enumCase of enumCases) {
124
+ // Name can be an Identifier or string
125
+ const key = typeof enumCase.name === 'string'
126
+ ? enumCase.name
127
+ : enumCase.name.name;
128
+ let value;
129
+ if (enumCase.value !== null && enumCase.value !== undefined) {
130
+ // Value is a String or Number node (types say string|number but runtime is Node)
131
+ const valueNode = enumCase.value;
132
+ if (typeof valueNode === 'object' && valueNode.kind) {
133
+ if (valueNode.kind === 'number') {
134
+ // php-parser returns number values as strings, convert to actual number
135
+ value = Number(valueNode.value);
136
+ }
137
+ else {
138
+ const extracted = getStringValue(valueNode);
139
+ value = extracted !== null ? extracted : key;
140
+ }
141
+ }
142
+ else {
143
+ value = String(enumCase.value);
144
+ }
21
145
  }
22
- }
23
- else {
24
- const implicitCases = [...content.matchAll(/case\s+([A-Za-z0-9_]+)\s*;/g)];
25
- for (const match of implicitCases) {
26
- cases.push({ key: match[1], value: match[1] });
146
+ else {
147
+ value = key;
27
148
  }
149
+ cases.push({ key, value });
28
150
  }
29
- // Parse getLabel() method if it exists
30
- const labelMethodMatch = content.match(/function\s+getLabel\s*\(\s*\)\s*:\s*string\s*\{[\s\S]*?return\s+match\s*\(\s*\$this\s*\)\s*\{([\s\S]*?)\};/);
31
- if (labelMethodMatch) {
32
- const matchBody = labelMethodMatch[1];
33
- const labelMatches = [...matchBody.matchAll(/self::([A-Za-z0-9_]+)\s*=>\s*'([^']*)'/g)];
34
- for (const labelMatch of labelMatches) {
35
- const caseKey = labelMatch[1];
36
- const labelValue = labelMatch[2];
37
- const enumCase = cases.find((c) => c.key === caseKey);
38
- if (enumCase) {
39
- enumCase.label = labelValue;
151
+ // Parse label() method if it exists
152
+ const methods = findAllNodesByKind(enumNode, 'method');
153
+ const labelMethod = methods.find((m) => {
154
+ const methodName = typeof m.name === 'string' ? m.name : m.name.name;
155
+ return methodName === 'label';
156
+ });
157
+ if (labelMethod && labelMethod.body) {
158
+ // Find match expression in the method
159
+ const matchNode = findNodeByKind(labelMethod.body, 'match');
160
+ if (matchNode && matchNode.arms) {
161
+ for (const arm of matchNode.arms) {
162
+ if (arm.conds) {
163
+ for (const cond of arm.conds) {
164
+ // Handle self::CASE_NAME
165
+ if (cond.kind === 'staticlookup') {
166
+ const lookup = cond;
167
+ const offset = lookup.offset;
168
+ const caseName = typeof offset === 'string'
169
+ ? offset
170
+ : offset.kind === 'identifier'
171
+ ? offset.name
172
+ : null;
173
+ if (caseName) {
174
+ const labelValue = getStringValue(arm.body);
175
+ if (labelValue !== null) {
176
+ const enumCase = cases.find((c) => c.key === caseName);
177
+ if (enumCase) {
178
+ enumCase.label = labelValue;
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
40
185
  }
41
186
  }
42
187
  }
43
188
  return { name, backing, cases };
44
189
  }
45
190
  /**
46
- * Parse PHP array pairs from a string like "'key' => 'value'".
191
+ * Extract key-value pairs from a PHP array node.
47
192
  */
48
- export function parsePhpArrayPairs(inside) {
193
+ function extractArrayPairs(arrayNode) {
49
194
  const pairs = {};
50
- const re = /["'](?<key>[A-Za-z0-9_]+)["']\s*=>\s*(?<val>[^,\n]+)/g;
51
- for (const m of inside.matchAll(re)) {
52
- let val = m.groups.val.trim();
53
- val = val.replace(/[,\s]*$/g, '');
54
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
55
- val = val.slice(1, -1);
56
- }
57
- if (val.endsWith('::class')) {
58
- val = val.slice(0, -7);
195
+ for (const item of arrayNode.items) {
196
+ if (item.kind === 'entry') {
197
+ const entry = item;
198
+ const key = entry.key ? getStringValue(entry.key) : null;
199
+ if (!key)
200
+ continue;
201
+ const value = entry.value;
202
+ let strValue = null;
203
+ if (value.kind === 'string' || value.kind === 'number') {
204
+ strValue = getStringValue(value);
205
+ }
206
+ else if (value.kind === 'staticlookup') {
207
+ // Handle Foo::class
208
+ const lookup = value;
209
+ const offset = lookup.offset;
210
+ if (offset &&
211
+ offset.kind === 'identifier' &&
212
+ offset.name === 'class') {
213
+ const what = lookup.what;
214
+ if (what.kind === 'name') {
215
+ strValue = what.name.replace(/^\\+/, '');
216
+ }
217
+ }
218
+ }
219
+ if (strValue !== null) {
220
+ pairs[key] = strValue;
221
+ }
59
222
  }
60
- pairs[m.groups.key] = val;
61
223
  }
62
224
  return pairs;
63
225
  }
64
226
  /**
65
- * Extract model casts from a PHP model file.
227
+ * Parse model casts from PHP model content.
228
+ * This is a pure function that takes PHP source code as input.
66
229
  */
67
- export function getModelCasts(modelPath) {
68
- const content = readFileSafe(modelPath);
69
- if (!content)
230
+ export function parseModelCasts(phpContent) {
231
+ const ast = parsePhp(phpContent);
232
+ if (!ast)
70
233
  return {};
71
- // Try protected $casts property
72
- const castsMatch = content.match(/protected\s+\$casts\s*=\s*\[([^\]]*)\]/s);
73
- if (castsMatch) {
74
- return parsePhpArrayPairs(castsMatch[1]);
75
- }
76
- // Try casts() method
77
- const castsMethodMatch = content.match(/function\s+casts\s*\([^)]*\)\s*\{[^}]*return\s*\[([^\]]*)\]/s);
78
- if (castsMethodMatch) {
79
- return parsePhpArrayPairs(castsMethodMatch[1]);
234
+ // Find the class
235
+ const classNode = findNodeByKind(ast, 'class');
236
+ if (!classNode)
237
+ return {};
238
+ // Look for protected $casts property
239
+ const propertyStatements = findAllNodesByKind(classNode, 'propertystatement');
240
+ for (const propStmt of propertyStatements) {
241
+ for (const prop of propStmt.properties) {
242
+ // prop.name can be a string or Identifier
243
+ const propName = typeof prop.name === 'string'
244
+ ? prop.name
245
+ : prop.name.name;
246
+ if (propName === 'casts' && prop.value && prop.value.kind === 'array') {
247
+ return extractArrayPairs(prop.value);
248
+ }
249
+ }
80
250
  }
81
- // Try class-based casts
82
- const matches = [...content.matchAll(/["'](?<key>[A-Za-z0-9_]+)["']\s*=>\s*(?<class>[A-Za-z0-9_\\]+)::class/g)];
83
- const res = {};
84
- for (const m of matches) {
85
- const k = m.groups.key;
86
- let v = m.groups.class;
87
- v = v.replace(/^\\+/, '');
88
- res[k] = v;
251
+ // Look for casts() method
252
+ const methods = findAllNodesByKind(classNode, 'method');
253
+ const castsMethod = methods.find((m) => {
254
+ const methodName = typeof m.name === 'string' ? m.name : m.name.name;
255
+ return methodName === 'casts';
256
+ });
257
+ if (castsMethod && castsMethod.body) {
258
+ // Find return statement with array
259
+ const returnNode = findNodeByKind(castsMethod.body, 'return');
260
+ if (returnNode && returnNode.expr && returnNode.expr.kind === 'array') {
261
+ return extractArrayPairs(returnNode.expr);
262
+ }
89
263
  }
90
- return res;
264
+ return {};
91
265
  }
92
266
  /**
93
- * Extract docblock array shape from PHP file content.
267
+ * Extract docblock array shape from PHP content.
268
+ * This is a pure function that takes PHP source code as input.
94
269
  */
95
270
  export function extractDocblockArrayShape(phpContent) {
96
271
  const match = phpContent.match(/@return\s+array\s*\{/s);
@@ -119,7 +294,9 @@ export function extractDocblockArrayShape(phpContent) {
119
294
  }
120
295
  if (endPos === null)
121
296
  return null;
122
- const inside = phpContent.slice(openBracePos + 1, endPos);
297
+ // Extract content and strip docblock asterisks from multiline format
298
+ let inside = phpContent.slice(openBracePos + 1, endPos);
299
+ inside = inside.replace(/^\s*\*\s?/gm, '');
123
300
  const pairs = {};
124
301
  let i = 0;
125
302
  while (i < inside.length) {
@@ -166,15 +343,289 @@ export function extractDocblockArrayShape(phpContent) {
166
343
  return pairs;
167
344
  }
168
345
  /**
169
- * Extract the return array block from a toArray() method in a PHP resource.
346
+ * Check if an AST node contains a whenLoaded call.
170
347
  */
171
- export function extractReturnArrayBlock(phpContent) {
172
- const match = phpContent.match(/function\s+toArray\s*\([^)]*\)\s*:\s*array\s*\{([\s\S]*?)\n\s*\}/);
173
- if (!match)
348
+ function containsWhenLoaded(node) {
349
+ if (node.kind === 'call') {
350
+ const call = node;
351
+ if (call.what.kind === 'propertylookup') {
352
+ const lookup = call.what;
353
+ const offset = lookup.offset;
354
+ const name = offset.kind === 'identifier' ? offset.name : null;
355
+ if (name === 'whenLoaded')
356
+ return true;
357
+ }
358
+ }
359
+ // Check arguments recursively
360
+ const obj = node;
361
+ for (const key of Object.keys(obj)) {
362
+ const val = obj[key];
363
+ if (val && typeof val === 'object') {
364
+ if (val.kind && containsWhenLoaded(val))
365
+ return true;
366
+ if (Array.isArray(val)) {
367
+ for (const item of val) {
368
+ if (item && item.kind && containsWhenLoaded(item))
369
+ return true;
370
+ }
371
+ }
372
+ }
373
+ }
374
+ return false;
375
+ }
376
+ /**
377
+ * Extract resource name from a static call like Resource::make() or Resource::collection().
378
+ */
379
+ function extractStaticCallResource(call) {
380
+ if (call.what.kind !== 'staticlookup')
381
+ return null;
382
+ const lookup = call.what;
383
+ if (lookup.what.kind !== 'name')
174
384
  return null;
175
- const body = match[1];
176
- const returnMatch = body.match(/return\s*\[\s*([\s\S]*?)\s*\];/);
177
- if (!returnMatch)
385
+ const resource = lookup.what.name;
386
+ const offset = lookup.offset;
387
+ const method = offset.kind === 'identifier' ? offset.name : null;
388
+ if (!method)
178
389
  return null;
179
- return returnMatch[1];
390
+ return { resource, method };
391
+ }
392
+ /**
393
+ * Extract resource name from a new expression like new Resource().
394
+ */
395
+ function extractNewResource(newExpr) {
396
+ if (newExpr.what.kind !== 'name')
397
+ return null;
398
+ return newExpr.what.name;
399
+ }
400
+ /**
401
+ * Extract property name from $this->resource->property.
402
+ */
403
+ function extractResourceProperty(node) {
404
+ if (node.kind !== 'propertylookup')
405
+ return null;
406
+ const lookup = node;
407
+ const what = lookup.what;
408
+ // Check for $this->resource
409
+ if (what.kind === 'propertylookup') {
410
+ const inner = what;
411
+ if (inner.what.kind === 'variable' && inner.what.name === 'this') {
412
+ const innerOffset = inner.offset;
413
+ const innerName = innerOffset.kind === 'identifier' ? innerOffset.name : null;
414
+ if (innerName === 'resource') {
415
+ const offset = lookup.offset;
416
+ return offset.kind === 'identifier' ? offset.name : null;
417
+ }
418
+ }
419
+ }
420
+ return null;
421
+ }
422
+ /**
423
+ * Map a PHP cast to a TypeScript type, potentially collecting enum references.
424
+ */
425
+ function mapCastToType(cast, enumsDir, collectedEnums) {
426
+ const original = cast;
427
+ // Try to find enum in app/Enums
428
+ const match = original.match(/([A-Za-z0-9_\\]+)$/);
429
+ const short = match ? match[1].replace(/^\\+/, '') : original;
430
+ const enumPath = join(enumsDir, `${short}.php`);
431
+ if (existsSync(enumPath)) {
432
+ const content = readFileSafe(enumPath);
433
+ if (content) {
434
+ const def = parseEnumContent(content);
435
+ if (def) {
436
+ collectedEnums[def.name] = def;
437
+ return def.name;
438
+ }
439
+ }
440
+ }
441
+ return mapPhpTypeToTs(cast);
442
+ }
443
+ /**
444
+ * Check if a resource file exists.
445
+ */
446
+ function resourceExists(resourceName, resourcesDir) {
447
+ if (!resourcesDir)
448
+ return true; // Trust the name if no dir provided
449
+ return existsSync(join(resourcesDir, `${resourceName}.php`));
450
+ }
451
+ /**
452
+ * Infer TypeScript type from an AST value node.
453
+ */
454
+ function inferTypeFromAstNode(node, key, options = {}) {
455
+ const { resourcesDir, modelsDir, enumsDir, docShape, collectedEnums = {}, resourceClass = '' } = options;
456
+ const optional = containsWhenLoaded(node);
457
+ // Use docblock type if available
458
+ if (docShape && docShape[key]) {
459
+ return { type: docShape[key], optional };
460
+ }
461
+ // Boolean heuristics from key name
462
+ const lowerKey = key.toLowerCase();
463
+ if (lowerKey.startsWith('is_') || lowerKey.startsWith('has_') || /^(is|has)[A-Z]/.test(key)) {
464
+ return { type: 'boolean', optional };
465
+ }
466
+ // Handle static calls: Resource::collection() or Resource::make()
467
+ if (node.kind === 'call') {
468
+ const call = node;
469
+ const staticInfo = extractStaticCallResource(call);
470
+ if (staticInfo) {
471
+ const { resource, method } = staticInfo;
472
+ // Collection or Collection::make returns any[]
473
+ if (resource === 'Collection') {
474
+ return { type: 'any[]', optional };
475
+ }
476
+ // Resource::collection or Resource::make returns Resource[]
477
+ if (method === 'collection' || method === 'make') {
478
+ if (resourceExists(resource, resourcesDir)) {
479
+ return { type: `${resource}[]`, optional };
480
+ }
481
+ return { type: 'any[]', optional };
482
+ }
483
+ }
484
+ // Check if it's a whenLoaded call without a wrapper resource
485
+ if (call.what.kind === 'propertylookup') {
486
+ const lookup = call.what;
487
+ const offset = lookup.offset;
488
+ const name = offset.kind === 'identifier' ? offset.name : null;
489
+ if (name === 'whenLoaded') {
490
+ // Try to find matching resource (only if resourcesDir is provided)
491
+ if (resourcesDir) {
492
+ const args = call.arguments;
493
+ if (args.length > 0 && args[0].kind === 'string') {
494
+ const relationName = args[0].value;
495
+ const candidate = `${relationName[0].toUpperCase()}${relationName.slice(1)}Resource`;
496
+ if (existsSync(join(resourcesDir, `${candidate}.php`))) {
497
+ return { type: candidate, optional: true };
498
+ }
499
+ }
500
+ }
501
+ return { type: 'Record<string, any>', optional: true };
502
+ }
503
+ }
504
+ }
505
+ // Handle new Resource()
506
+ if (node.kind === 'new') {
507
+ const newExpr = node;
508
+ const resource = extractNewResource(newExpr);
509
+ if (resource) {
510
+ if (resourceExists(resource, resourcesDir)) {
511
+ return { type: resource, optional };
512
+ }
513
+ return { type: 'any', optional };
514
+ }
515
+ return { type: 'any', optional };
516
+ }
517
+ // Handle $this->resource->property
518
+ const prop = extractResourceProperty(node);
519
+ if (prop) {
520
+ const lower = prop.toLowerCase();
521
+ // Boolean checks
522
+ if (lower.startsWith('is_') || lower.startsWith('has_') || /^(is|has)[A-Z]/.test(prop)) {
523
+ return { type: 'boolean', optional: false };
524
+ }
525
+ // IDs and UUIDs
526
+ if (prop === 'id' || prop.endsWith('_id') || lower === 'uuid' || prop.endsWith('Id')) {
527
+ return { type: 'string', optional: false };
528
+ }
529
+ // Check model casts
530
+ if (modelsDir && resourceClass) {
531
+ const modelCandidate = resourceClass.replace(/Resource$/, '');
532
+ const modelPath = join(modelsDir, `${modelCandidate}.php`);
533
+ if (existsSync(modelPath)) {
534
+ const modelContent = readFileSafe(modelPath);
535
+ if (modelContent) {
536
+ const casts = parseModelCasts(modelContent);
537
+ if (casts[prop]) {
538
+ const cast = casts[prop];
539
+ const trim = cast.trim();
540
+ const tsType = trim.startsWith('{') || trim.includes(':') || /array\s*\{/.test(trim)
541
+ ? trim
542
+ : mapCastToType(cast, enumsDir || '', collectedEnums);
543
+ return { type: tsType, optional: false };
544
+ }
545
+ }
546
+ }
547
+ }
548
+ // Timestamps
549
+ if (prop.endsWith('_at') || prop.endsWith('At')) {
550
+ return { type: 'string', optional: false };
551
+ }
552
+ return { type: 'string', optional: false };
553
+ }
554
+ // Handle nested arrays
555
+ if (node.kind === 'array') {
556
+ const arrayNode = node;
557
+ const nestedFields = parseArrayEntries(arrayNode.items, options);
558
+ if (Object.keys(nestedFields).length > 0) {
559
+ const props = Object.entries(nestedFields).map(([k, v]) => {
560
+ const opt = v.fieldInfo.optional ? '?' : '';
561
+ return `${k}${opt}: ${v.fieldInfo.type}`;
562
+ });
563
+ return { type: `{ ${props.join('; ')} }`, optional };
564
+ }
565
+ return { type: 'any[]', optional };
566
+ }
567
+ return { type: 'any', optional };
568
+ }
569
+ /**
570
+ * Parse array entries from AST array items.
571
+ */
572
+ function parseArrayEntries(items, options = {}) {
573
+ const result = {};
574
+ for (const item of items) {
575
+ if (item.kind !== 'entry')
576
+ continue;
577
+ const entry = item;
578
+ if (!entry.key)
579
+ continue;
580
+ const key = getStringValue(entry.key);
581
+ if (!key)
582
+ continue;
583
+ const fieldInfo = inferTypeFromAstNode(entry.value, key, options);
584
+ result[key] = { key, fieldInfo };
585
+ // Handle nested arrays
586
+ if (entry.value.kind === 'array') {
587
+ const nested = parseArrayEntries(entry.value.items, options);
588
+ if (Object.keys(nested).length > 0) {
589
+ result[key].nested = nested;
590
+ }
591
+ }
592
+ }
593
+ return result;
594
+ }
595
+ /**
596
+ * Parse resource fields from PHP content using AST.
597
+ * Returns null if parsing fails or no toArray method is found.
598
+ */
599
+ export function parseResourceFieldsAst(phpContent, options = {}) {
600
+ const ast = parsePhp(phpContent);
601
+ if (!ast)
602
+ return null;
603
+ // Find the class
604
+ const classNode = findNodeByKind(ast, 'class');
605
+ if (!classNode)
606
+ return null;
607
+ // Extract class name for model cast lookups
608
+ const className = typeof classNode.name === 'string'
609
+ ? classNode.name
610
+ : classNode.name.name;
611
+ // Find toArray method
612
+ const methods = findAllNodesByKind(classNode, 'method');
613
+ const toArrayMethod = methods.find((m) => {
614
+ const methodName = typeof m.name === 'string' ? m.name : m.name.name;
615
+ return methodName === 'toArray';
616
+ });
617
+ if (!toArrayMethod || !toArrayMethod.body)
618
+ return null;
619
+ // Find return statement with array
620
+ const returnNode = findNodeByKind(toArrayMethod.body, 'return');
621
+ if (!returnNode || !returnNode.expr || returnNode.expr.kind !== 'array')
622
+ return null;
623
+ const arrayNode = returnNode.expr;
624
+ const entries = parseArrayEntries(arrayNode.items, { ...options, resourceClass: className });
625
+ // Convert to flat field info
626
+ const result = {};
627
+ for (const [key, entry] of Object.entries(entries)) {
628
+ result[key] = entry.fieldInfo;
629
+ }
630
+ return result;
180
631
  }