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.
- package/README.md +246 -2
- package/dist/generators/enums.d.ts +3 -1
- package/dist/generators/enums.d.ts.map +1 -1
- package/dist/generators/enums.js +45 -52
- package/dist/generators/resources.d.ts +4 -6
- package/dist/generators/resources.d.ts.map +1 -1
- package/dist/generators/resources.js +35 -197
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -8
- package/dist/utils/php-parser.d.ts +29 -12
- package/dist/utils/php-parser.d.ts.map +1 -1
- package/dist/utils/php-parser.js +524 -73
- package/dist/utils/ts-generator.d.ts +67 -0
- package/dist/utils/ts-generator.d.ts.map +1 -0
- package/dist/utils/ts-generator.js +179 -0
- package/package.json +11 -3
package/dist/utils/php-parser.js
CHANGED
|
@@ -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
|
|
19
|
+
* Parse PHP content and return the AST.
|
|
20
|
+
* Uses parseEval which doesn't require <?php tags or filenames.
|
|
4
21
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
//
|
|
10
|
-
const
|
|
11
|
-
if (!
|
|
114
|
+
// Find the enum declaration
|
|
115
|
+
const enumNode = findNodeByKind(ast, 'enum');
|
|
116
|
+
if (!enumNode)
|
|
12
117
|
return null;
|
|
13
|
-
const name =
|
|
14
|
-
const backing =
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
191
|
+
* Extract key-value pairs from a PHP array node.
|
|
47
192
|
*/
|
|
48
|
-
|
|
193
|
+
function extractArrayPairs(arrayNode) {
|
|
49
194
|
const pairs = {};
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
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
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
230
|
+
export function parseModelCasts(phpContent) {
|
|
231
|
+
const ast = parsePhp(phpContent);
|
|
232
|
+
if (!ast)
|
|
70
233
|
return {};
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
return
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
264
|
+
return {};
|
|
91
265
|
}
|
|
92
266
|
/**
|
|
93
|
-
* Extract docblock array shape from PHP
|
|
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
|
-
|
|
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
|
-
*
|
|
346
|
+
* Check if an AST node contains a whenLoaded call.
|
|
170
347
|
*/
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
176
|
-
const
|
|
177
|
-
|
|
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
|
|
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
|
}
|