skir-php-generator 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maxim Kerstens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Skir PHP Generator
2
+
3
+ Generates framework-agnostic PHP data objects for Skir schemas.
4
+
5
+ Generated PHP code uses `php-skir/runtime` for dense JSON serialization. If you use generated RPC clients, install `php-skir/client` as well.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install --save-dev skir-php-generator
11
+ composer require php-skir/runtime
12
+ ```
13
+
14
+ For generated typed RPC clients:
15
+
16
+ ```bash
17
+ composer require php-skir/client
18
+ ```
19
+
20
+ ## Releasing
21
+
22
+ Create a GitHub release for the version in `package.json`. The release workflow reruns type checks, build, package validation, and tests before publishing to npm with provenance. It expects an `NPM_TOKEN` repository secret.
23
+
24
+ ## Usage with Skir
25
+
26
+ Add the generator to `skir.yml`:
27
+
28
+ ```yaml
29
+ generators:
30
+ php:
31
+ package: skir-php-generator
32
+ output: generated/php
33
+ config:
34
+ namespace: App\Skir
35
+ ```
36
+
37
+ Then run the Skir generator command for your project.
38
+
39
+ ## Generated PHP
40
+
41
+ The generator emits readonly PHP classes for Skir structs and enum wrapper classes for Skir enums. Generated classes expose:
42
+
43
+ - `skirType()` for runtime type descriptors.
44
+ - `toArray()` and `fromArray()` for PHP array conversion.
45
+ - `toDenseJson()` and `fromDenseJson()` for dense JSON payloads.
46
+ - `toSkirValue()` and `fromSkirValue()` on generated enum classes.
47
+
48
+ SkirRPC methods are emitted in `SkirMethods.php` as `MethodDescriptor` instances. The generator also emits a module-scoped method enum such as `AdminSkirMethod.php` for attribute-based server routing.
49
+
50
+ When a module defines SkirRPC methods, the generator also emits `SkirRpcClient.php`. It wraps `Skir\Client\SkirClient` and exposes typed methods:
51
+
52
+ ```php
53
+ use App\Skir\Admin\SkirRpcClient;
54
+ use Skir\Client\SkirClient as TransportSkirClient;
55
+
56
+ $client = new SkirRpcClient(new TransportSkirClient('https://example.com/skir'));
57
+ $user = $client->getUser($request);
58
+ ```
59
+
60
+ For servers, the generator emits `AdminSkirMethod.php`, `AbstractSkirProcedures.php`, `SkirProcedures.php`, and `SkirProcedureProvider.php`.
61
+
62
+ The recommended Laravel server path is to use the generated method enum with `php-skir/server` controller routing:
63
+
64
+ ```php
65
+ use App\Skir\Admin\AdminSkirMethod;
66
+ use App\Skir\Admin\GetUserRequest;
67
+ use App\Skir\Admin\User;
68
+ use Skir\Server\Attributes\SkirMethod;
69
+ use Skir\Server\SkirContext;
70
+
71
+ final class UserController
72
+ {
73
+ #[SkirMethod(AdminSkirMethod::GetUser)]
74
+ public function get(GetUserRequest $request, SkirContext $context): User
75
+ {
76
+ return new User(
77
+ userId: $request->userId,
78
+ name: 'Maxim',
79
+ );
80
+ }
81
+ }
82
+ ```
83
+
84
+ Register the controller on a Skir endpoint:
85
+
86
+ ```php
87
+ use App\Skir\Controllers\UserController;
88
+ use Illuminate\Support\Facades\Route;
89
+ use Skir\Server\Facades\Skir;
90
+
91
+ Route::skirRpc('/api/skir', [
92
+ Skir::controller(UserController::class),
93
+ ]);
94
+ ```
95
+
96
+ The method enum resolves back to `SkirMethods::getUser()`, so the Skir schema remains the source of truth while your IDE can autocomplete enum cases.
97
+
98
+ The abstract procedure and interface/provider pair remain available as lower-level compatibility APIs.
99
+
100
+ ## Namespaces and modules
101
+
102
+ The configured namespace defaults to `App\Skir`. Module directories become PHP subnamespaces and output directories:
103
+
104
+ ```text
105
+ admin/users.skir -> App\Skir\Admin
106
+ ```
107
+
108
+ When two generated records would otherwise use the same PHP class name in the same namespace, the generator prefixes the class with the module basename to keep output deterministic.
109
+
110
+ ## Current scope
111
+
112
+ This package generates framework-agnostic PHP DTOs, method descriptors, typed client adapters, and Laravel Skir server procedure adapters. Laravel-specific data objects and server routing live in separate packages.
@@ -0,0 +1,65 @@
1
+ export interface PhpGeneratorConfig {
2
+ readonly namespace?: string;
3
+ }
4
+ export interface PhpGeneratorInput {
5
+ readonly config?: PhpGeneratorConfig;
6
+ readonly modules: readonly SkirModule[];
7
+ readonly recordMap?: ReadonlyMap<string, SkirRecordLocation>;
8
+ }
9
+ export interface SkirModule {
10
+ readonly path: string;
11
+ readonly records?: readonly (SkirRecord | SkirRecordLocation)[];
12
+ readonly methods?: readonly SkirMethod[];
13
+ }
14
+ export interface SkirRecordLocation {
15
+ readonly kind: "record-location";
16
+ readonly record: SkirRecord;
17
+ readonly recordAncestors?: readonly SkirRecord[];
18
+ readonly modulePath?: string;
19
+ }
20
+ export interface SkirRecord {
21
+ readonly kind?: string;
22
+ readonly key?: unknown;
23
+ readonly name: string | SkirToken;
24
+ readonly recordType?: "struct" | "enum";
25
+ readonly fields?: readonly SkirField[];
26
+ readonly removedNumbers?: readonly number[];
27
+ readonly phpClassName?: string;
28
+ }
29
+ export type SkirField = {
30
+ readonly kind: "field";
31
+ readonly name: string | SkirToken;
32
+ readonly number: number;
33
+ readonly type?: SkirType;
34
+ } | {
35
+ readonly kind: "removed";
36
+ readonly number: number;
37
+ };
38
+ export type SkirType = string | {
39
+ readonly kind: string;
40
+ readonly primitive?: string;
41
+ readonly item?: SkirType;
42
+ readonly other?: SkirType;
43
+ readonly key?: unknown;
44
+ readonly name?: string | SkirToken;
45
+ readonly nameParts?: readonly SkirRecordNamePart[];
46
+ readonly recordType?: "struct" | "enum";
47
+ };
48
+ export interface SkirToken {
49
+ readonly text: string;
50
+ }
51
+ export type SkirRecordNamePart = string | SkirToken | {
52
+ readonly token: string | SkirToken;
53
+ };
54
+ export interface SkirMethod {
55
+ readonly kind: "method";
56
+ readonly name: string | SkirToken;
57
+ readonly number: number;
58
+ readonly requestType?: SkirType;
59
+ readonly responseType?: SkirType;
60
+ }
61
+ export interface GeneratedFile {
62
+ readonly path: string;
63
+ readonly code: string;
64
+ }
65
+ export declare function generatePhpFiles(input: PhpGeneratorInput): GeneratedFile[];
@@ -0,0 +1,968 @@
1
+ export function generatePhpFiles(input) {
2
+ const namespace = input.config?.namespace ?? "App\\Skir";
3
+ const classNames = buildClassNameRegistry(namespace, input.modules);
4
+ const methodGroups = new Map();
5
+ const recordFiles = [];
6
+ for (const module of input.modules) {
7
+ const context = outputContextForModule(namespace, module, input.recordMap, classNames);
8
+ recordFiles.push(...(module.records ?? [])
9
+ .map((record, index) => normalizeRecord(record, module, index, classNames))
10
+ .filter((record) => isStruct(record) || isEnum(record))
11
+ .map((record) => isEnum(record)
12
+ ? generateEnumFile(context, record)
13
+ : generateStructFile(context, record)));
14
+ const methods = module.methods ?? [];
15
+ if (methods.length > 0) {
16
+ const groupKey = `${context.namespace}\n${context.pathPrefix}`;
17
+ const existingGroup = methodGroups.get(groupKey);
18
+ if (existingGroup !== undefined) {
19
+ existingGroup.methods.push(...methods);
20
+ }
21
+ else {
22
+ methodGroups.set(groupKey, {
23
+ context,
24
+ methods: [...methods],
25
+ });
26
+ }
27
+ }
28
+ }
29
+ return [
30
+ ...recordFiles,
31
+ ...Array.from(methodGroups.values()).flatMap((group) => [
32
+ generateMethodsFile(group.context, group.methods),
33
+ generateMethodEnumFile(group.context, group.methods),
34
+ generateClientFile(group.context, group.methods),
35
+ generateProceduresFile(group.context, group.methods),
36
+ generateAbstractProceduresFile(group.context, group.methods),
37
+ generateProcedureProviderFile(group.context, group.methods),
38
+ ]),
39
+ ];
40
+ }
41
+ function generateStructFile(context, record) {
42
+ const className = classNameForRecord(record);
43
+ const fields = collectStructFields(record);
44
+ const runtimeImports = [
45
+ "Skir\\Runtime\\DenseJson",
46
+ "Skir\\Runtime\\Field",
47
+ "Skir\\Runtime\\Type",
48
+ ];
49
+ const fileContext = fileOutputContext(context, className, runtimeImports);
50
+ const constructor = generateConstructor(fields, fileContext);
51
+ const skirType = generateSkirType(record, fileContext);
52
+ const fromArray = generateFromArray(className, fields, fileContext);
53
+ const classMembers = [
54
+ constructor,
55
+ skirType,
56
+ generateToArray(fields, fileContext),
57
+ fromArray,
58
+ generateToDenseJson(),
59
+ generateFromDenseJson(className),
60
+ ].filter((member) => member !== null);
61
+ return {
62
+ path: outputPath(context, `${className}.php`),
63
+ code: [
64
+ "<?php",
65
+ "",
66
+ "declare(strict_types=1);",
67
+ "",
68
+ `namespace ${context.namespace};`,
69
+ "",
70
+ ...generateUseStatements(fileContext, runtimeImports),
71
+ "",
72
+ `final readonly class ${className}`,
73
+ "{",
74
+ ...classMembers.flatMap((member, index) => index === 0
75
+ ? [indent(member)]
76
+ : ["", indent(member)]),
77
+ "}",
78
+ "",
79
+ ].join("\n"),
80
+ };
81
+ }
82
+ function generateConstructor(fields, context) {
83
+ if (fields.length === 0) {
84
+ return null;
85
+ }
86
+ return [
87
+ "public function __construct(",
88
+ ...fields.map((field) => ` public ${phpType(field.type, context)} $${toPropertyName(field.name)},`),
89
+ ") {}",
90
+ ].join("\n");
91
+ }
92
+ function generateSkirType(record, context) {
93
+ const entries = collectDeclarations(record, "string")
94
+ .map((declaration) => {
95
+ if (isRemovedDeclaration(declaration)) {
96
+ return ` Field::removed(${declaration.number}),`;
97
+ }
98
+ return ` Field::value('${declaration.name}', ${declaration.number}, ${runtimeTypeExpression(declaration.type ?? "string", context)}),`;
99
+ })
100
+ .join("\n");
101
+ return [
102
+ "public static function skirType(): Type",
103
+ "{",
104
+ " return Type::struct([",
105
+ entries,
106
+ " ]);",
107
+ "}",
108
+ ].join("\n");
109
+ }
110
+ function generateToArray(fields, context) {
111
+ return [
112
+ "/** @return array<string, mixed> */",
113
+ "public function toArray(): array",
114
+ "{",
115
+ " return [",
116
+ ...fields.map((field) => {
117
+ const property = toPropertyName(field.name);
118
+ return ` '${field.name}' => ${valueToArrayExpression(field.type, `$this->${property}`, context)},`;
119
+ }),
120
+ " ];",
121
+ "}",
122
+ ].join("\n");
123
+ }
124
+ function generateFromArray(className, fields, context) {
125
+ return [
126
+ "/** @param array<string, mixed> $data */",
127
+ `public static function fromArray(array $data): ${className}`,
128
+ "{",
129
+ " return new self(",
130
+ ...fields.map((field) => ` ${toPropertyName(field.name)}: ${valueFromArrayExpression(field.type, `$data['${field.name}']`, context)},`),
131
+ " );",
132
+ "}",
133
+ ].join("\n");
134
+ }
135
+ function generateToDenseJson() {
136
+ return [
137
+ "public function toDenseJson(): string",
138
+ "{",
139
+ " return DenseJson::toJson(self::skirType(), $this->toArray());",
140
+ "}",
141
+ ].join("\n");
142
+ }
143
+ function generateFromDenseJson(className) {
144
+ return [
145
+ `public static function fromDenseJson(string $json): ${className}`,
146
+ "{",
147
+ " return self::fromArray(DenseJson::fromJson(self::skirType(), $json));",
148
+ "}",
149
+ ].join("\n");
150
+ }
151
+ function generateEnumFile(context, record) {
152
+ const className = classNameForRecord(record);
153
+ const variants = collectDeclarations(record);
154
+ const runtimeImports = [
155
+ "Skir\\Runtime\\DenseJson",
156
+ "Skir\\Runtime\\EnumValue",
157
+ "Skir\\Runtime\\Type",
158
+ "Skir\\Runtime\\Variant",
159
+ ];
160
+ const fileContext = fileOutputContext(context, className, runtimeImports);
161
+ const constructors = generateEnumConstructors(variants, fileContext);
162
+ const skirType = generateEnumSkirType(record, fileContext);
163
+ return {
164
+ path: outputPath(context, `${className}.php`),
165
+ code: [
166
+ "<?php",
167
+ "",
168
+ "declare(strict_types=1);",
169
+ "",
170
+ `namespace ${context.namespace};`,
171
+ "",
172
+ ...generateUseStatements(fileContext, runtimeImports),
173
+ "",
174
+ `final readonly class ${className}`,
175
+ "{",
176
+ indent("private function __construct(private EnumValue $value) {}"),
177
+ "",
178
+ indent(constructors),
179
+ "",
180
+ indent(skirType),
181
+ "",
182
+ indent(generateEnumAccessors()),
183
+ "",
184
+ indent(generateEnumToSkirValue()),
185
+ "",
186
+ indent(generateEnumFromSkirValue(className)),
187
+ "",
188
+ indent(generateEnumToDenseValue()),
189
+ "",
190
+ indent(generateEnumFromDenseValue(className)),
191
+ "",
192
+ indent(generateEnumToDenseJson()),
193
+ "",
194
+ indent(generateEnumFromDenseJson(className)),
195
+ "}",
196
+ "",
197
+ ].join("\n"),
198
+ };
199
+ }
200
+ function generateEnumConstructors(variants, context) {
201
+ return variants
202
+ .filter((variant) => !isRemovedDeclaration(variant))
203
+ .map((variant) => {
204
+ if (variant.type === undefined) {
205
+ return [
206
+ `public static function ${toPropertyName(variant.name)}(): self`,
207
+ "{",
208
+ ` return new self(EnumValue::constant('${variant.name}'));`,
209
+ "}",
210
+ ].join("\n");
211
+ }
212
+ return [
213
+ `public static function ${toPropertyName(variant.name)}(${phpType(variant.type, context)} $value): self`,
214
+ "{",
215
+ ` return new self(EnumValue::wrapper('${variant.name}', $value));`,
216
+ "}",
217
+ ].join("\n");
218
+ })
219
+ .join("\n\n");
220
+ }
221
+ function generateEnumSkirType(record, context) {
222
+ const entries = collectDeclarations(record)
223
+ .map((declaration) => {
224
+ if (isRemovedDeclaration(declaration)) {
225
+ return null;
226
+ }
227
+ if (declaration.type === undefined) {
228
+ return ` Variant::constant('${declaration.name}', ${declaration.number}),`;
229
+ }
230
+ return ` Variant::wrapper('${declaration.name}', ${declaration.number}, ${runtimeTypeExpression(declaration.type, context)}),`;
231
+ })
232
+ .filter((entry) => entry !== null)
233
+ .join("\n");
234
+ return [
235
+ "public static function skirType(): Type",
236
+ "{",
237
+ " return Type::enum([",
238
+ entries,
239
+ " ]);",
240
+ "}",
241
+ ].join("\n");
242
+ }
243
+ function generateEnumAccessors() {
244
+ return [
245
+ "public function name(): string",
246
+ "{",
247
+ " return $this->value->name;",
248
+ "}",
249
+ "",
250
+ "public function payload(): mixed",
251
+ "{",
252
+ " return $this->value->value;",
253
+ "}",
254
+ ].join("\n");
255
+ }
256
+ function generateEnumToSkirValue() {
257
+ return [
258
+ "public function toSkirValue(): EnumValue",
259
+ "{",
260
+ " return $this->value;",
261
+ "}",
262
+ ].join("\n");
263
+ }
264
+ function generateEnumFromSkirValue(className) {
265
+ return [
266
+ `public static function fromSkirValue(EnumValue $value): ${className}`,
267
+ "{",
268
+ " return new self($value);",
269
+ "}",
270
+ ].join("\n");
271
+ }
272
+ function generateEnumToDenseValue() {
273
+ return [
274
+ "/** @return int|array<int, mixed> */",
275
+ "public function toDenseValue(): int|array",
276
+ "{",
277
+ " return DenseJson::encode(self::skirType(), $this->value);",
278
+ "}",
279
+ ].join("\n");
280
+ }
281
+ function generateEnumFromDenseValue(className) {
282
+ return [
283
+ "/** @param int|array<int, mixed> $value */",
284
+ `public static function fromDenseValue(int|array $value): ${className}`,
285
+ "{",
286
+ " return new self(DenseJson::decode(self::skirType(), $value));",
287
+ "}",
288
+ ].join("\n");
289
+ }
290
+ function generateEnumToDenseJson() {
291
+ return [
292
+ "public function toDenseJson(): string",
293
+ "{",
294
+ " return DenseJson::toJson(self::skirType(), $this->value);",
295
+ "}",
296
+ ].join("\n");
297
+ }
298
+ function generateEnumFromDenseJson(className) {
299
+ return [
300
+ `public static function fromDenseJson(string $json): ${className}`,
301
+ "{",
302
+ " return new self(DenseJson::fromJson(self::skirType(), $json));",
303
+ "}",
304
+ ].join("\n");
305
+ }
306
+ function generateMethodsFile(context, methods) {
307
+ const runtimeImports = [
308
+ "Skir\\Runtime\\MethodDescriptor",
309
+ ];
310
+ const fileContext = fileOutputContext(context, "SkirMethods", runtimeImports);
311
+ const descriptors = methods.map((method) => generateMethodDescriptor(method, fileContext)).join("\n\n");
312
+ return {
313
+ path: outputPath(context, "SkirMethods.php"),
314
+ code: [
315
+ "<?php",
316
+ "",
317
+ "declare(strict_types=1);",
318
+ "",
319
+ `namespace ${context.namespace};`,
320
+ "",
321
+ ...generateUseStatements(fileContext, runtimeImports),
322
+ "",
323
+ "final readonly class SkirMethods",
324
+ "{",
325
+ indent(generateAllMethods(methods)),
326
+ "",
327
+ indent(descriptors),
328
+ "}",
329
+ "",
330
+ ].join("\n"),
331
+ };
332
+ }
333
+ function generateMethodEnumFile(context, methods) {
334
+ const runtimeImports = [
335
+ "Skir\\Runtime\\MethodDescriptor",
336
+ "Skir\\Server\\Contracts\\SkirMethodReference",
337
+ ];
338
+ const className = methodEnumClassName(context);
339
+ const fileContext = fileOutputContext(context, className, runtimeImports);
340
+ const cases = methods.map((method) => `case ${toClassName(tokenText(method.name))};`).join("\n");
341
+ const matches = methods
342
+ .map((method) => `self::${toClassName(tokenText(method.name))} => SkirMethods::${toPropertyName(tokenText(method.name))}(),`)
343
+ .join("\n");
344
+ return {
345
+ path: outputPath(context, `${className}.php`),
346
+ code: [
347
+ "<?php",
348
+ "",
349
+ "declare(strict_types=1);",
350
+ "",
351
+ `namespace ${context.namespace};`,
352
+ "",
353
+ ...generateUseStatements(fileContext, runtimeImports),
354
+ "",
355
+ `enum ${className} implements SkirMethodReference`,
356
+ "{",
357
+ indent(cases),
358
+ "",
359
+ indent([
360
+ "public function descriptor(): MethodDescriptor",
361
+ "{",
362
+ " return match ($this) {",
363
+ indent(indent(matches)),
364
+ " };",
365
+ "}",
366
+ ].join("\n")),
367
+ "}",
368
+ "",
369
+ ].join("\n"),
370
+ };
371
+ }
372
+ function generateAllMethods(methods) {
373
+ return [
374
+ "/** @return array<string, MethodDescriptor> */",
375
+ "public static function all(): array",
376
+ "{",
377
+ " return [",
378
+ ...methods.map((method) => ` '${tokenText(method.name)}' => self::${toPropertyName(tokenText(method.name))}(),`),
379
+ " ];",
380
+ "}",
381
+ ].join("\n");
382
+ }
383
+ function generateMethodDescriptor(method, context) {
384
+ return [
385
+ `public static function ${toPropertyName(tokenText(method.name))}(): MethodDescriptor`,
386
+ "{",
387
+ " return new MethodDescriptor(",
388
+ ` name: '${tokenText(method.name)}',`,
389
+ ` number: ${method.number},`,
390
+ ` requestType: ${runtimeTypeExpression(method.requestType ?? "string", context)},`,
391
+ ` responseType: ${runtimeTypeExpression(method.responseType ?? "string", context)},`,
392
+ " );",
393
+ "}",
394
+ ].join("\n");
395
+ }
396
+ function generateClientFile(context, methods) {
397
+ const runtimeImports = [
398
+ "Skir\\Client\\SkirClient",
399
+ ];
400
+ const fileContext = fileOutputContext(context, "SkirRpcClient", runtimeImports);
401
+ const clientMethods = methods.map((method) => generateClientMethod(method, fileContext)).join("\n\n");
402
+ return {
403
+ path: outputPath(context, "SkirRpcClient.php"),
404
+ code: [
405
+ "<?php",
406
+ "",
407
+ "declare(strict_types=1);",
408
+ "",
409
+ `namespace ${context.namespace};`,
410
+ "",
411
+ ...generateUseStatements(fileContext, runtimeImports),
412
+ "",
413
+ "final readonly class SkirRpcClient",
414
+ "{",
415
+ indent(generateClientConstructor()),
416
+ "",
417
+ indent(clientMethods),
418
+ "}",
419
+ "",
420
+ ].join("\n"),
421
+ };
422
+ }
423
+ function generateClientConstructor() {
424
+ return [
425
+ "public function __construct(",
426
+ " private SkirClient $client,",
427
+ ") {}",
428
+ ].join("\n");
429
+ }
430
+ function generateClientMethod(method, context) {
431
+ const methodName = toPropertyName(tokenText(method.name));
432
+ const requestType = method.requestType ?? "string";
433
+ const responseType = method.responseType ?? "string";
434
+ const request = valueToArrayExpression(requestType, "$request", context);
435
+ const response = valueFromArrayExpression(responseType, "$response", context);
436
+ return [
437
+ `public function ${methodName}(${phpType(requestType, context)} $request): ${phpType(responseType, context)}`,
438
+ "{",
439
+ ` $response = $this->client->invoke(SkirMethods::${methodName}(), ${request});`,
440
+ "",
441
+ ` return ${response};`,
442
+ "}",
443
+ ].join("\n");
444
+ }
445
+ function generateProceduresFile(context, methods) {
446
+ const runtimeImports = [
447
+ "Skir\\Server\\SkirContext",
448
+ ];
449
+ const fileContext = fileOutputContext(context, "SkirProcedures", runtimeImports);
450
+ const procedureMethods = methods.map((method) => generateProcedureMethod(method, fileContext)).join("\n\n");
451
+ return {
452
+ path: outputPath(context, "SkirProcedures.php"),
453
+ code: [
454
+ "<?php",
455
+ "",
456
+ "declare(strict_types=1);",
457
+ "",
458
+ `namespace ${context.namespace};`,
459
+ "",
460
+ ...generateUseStatements(fileContext, runtimeImports),
461
+ "",
462
+ "interface SkirProcedures",
463
+ "{",
464
+ indent(procedureMethods),
465
+ "}",
466
+ "",
467
+ ].join("\n"),
468
+ };
469
+ }
470
+ function generateProcedureMethod(method, context) {
471
+ const methodName = toPropertyName(tokenText(method.name));
472
+ const requestType = method.requestType ?? "string";
473
+ const responseType = method.responseType ?? "string";
474
+ return `public function ${methodName}(${phpType(requestType, context)} $request, SkirContext $context): ${phpType(responseType, context)};`;
475
+ }
476
+ function generateAbstractProceduresFile(context, methods) {
477
+ const runtimeImports = [
478
+ "Skir\\Server\\ProcedureProvider",
479
+ "Skir\\Server\\SkirContext",
480
+ "Skir\\Server\\SkirServer",
481
+ ];
482
+ const fileContext = fileOutputContext(context, "AbstractSkirProcedures", runtimeImports);
483
+ const procedureMethods = methods.map((method) => generateAbstractProcedureMethod(method, fileContext)).join("\n\n");
484
+ const registrations = methods.map((method) => generateProcedureRegistration(method, fileContext, "$this")).join("\n\n");
485
+ return {
486
+ path: outputPath(context, "AbstractSkirProcedures.php"),
487
+ code: [
488
+ "<?php",
489
+ "",
490
+ "declare(strict_types=1);",
491
+ "",
492
+ `namespace ${context.namespace};`,
493
+ "",
494
+ ...generateUseStatements(fileContext, runtimeImports),
495
+ "",
496
+ "abstract class AbstractSkirProcedures implements ProcedureProvider",
497
+ "{",
498
+ indent(procedureMethods),
499
+ "",
500
+ indent([
501
+ "public function register(SkirServer $server): void",
502
+ "{",
503
+ indent(registrations),
504
+ "}",
505
+ ].join("\n")),
506
+ "}",
507
+ "",
508
+ ].join("\n"),
509
+ };
510
+ }
511
+ function generateAbstractProcedureMethod(method, context) {
512
+ const methodName = toPropertyName(tokenText(method.name));
513
+ const requestType = method.requestType ?? "string";
514
+ const responseType = method.responseType ?? "string";
515
+ return `abstract public function ${methodName}(${phpType(requestType, context)} $request, SkirContext $context): ${phpType(responseType, context)};`;
516
+ }
517
+ function generateProcedureProviderFile(context, methods) {
518
+ const runtimeImports = [
519
+ "Skir\\Server\\ProcedureProvider",
520
+ "Skir\\Server\\SkirContext",
521
+ "Skir\\Server\\SkirServer",
522
+ ];
523
+ const fileContext = fileOutputContext(context, "SkirProcedureProvider", runtimeImports);
524
+ const registrations = methods.map((method) => generateProcedureRegistration(method, fileContext)).join("\n\n");
525
+ return {
526
+ path: outputPath(context, "SkirProcedureProvider.php"),
527
+ code: [
528
+ "<?php",
529
+ "",
530
+ "declare(strict_types=1);",
531
+ "",
532
+ `namespace ${context.namespace};`,
533
+ "",
534
+ ...generateUseStatements(fileContext, runtimeImports),
535
+ "",
536
+ "final readonly class SkirProcedureProvider implements ProcedureProvider",
537
+ "{",
538
+ indent(generateProcedureProviderConstructor()),
539
+ "",
540
+ indent([
541
+ "public function register(SkirServer $server): void",
542
+ "{",
543
+ indent(registrations),
544
+ "}",
545
+ ].join("\n")),
546
+ "}",
547
+ "",
548
+ ].join("\n"),
549
+ };
550
+ }
551
+ function generateProcedureProviderConstructor() {
552
+ return [
553
+ "public function __construct(",
554
+ " private SkirProcedures $procedures,",
555
+ ") {}",
556
+ ].join("\n");
557
+ }
558
+ function generateProcedureRegistration(method, context, handlerTarget = "$this->procedures") {
559
+ const methodName = toPropertyName(tokenText(method.name));
560
+ const requestType = method.requestType ?? "string";
561
+ const responseType = method.responseType ?? "string";
562
+ const request = valueFromArrayExpression(requestType, "$request", context);
563
+ const response = valueToArrayExpression(responseType, "$response", context);
564
+ return [
565
+ `$server->addMethod(SkirMethods::${methodName}(), function (mixed $request, SkirContext $context): mixed {`,
566
+ ` $response = ${handlerTarget}->${methodName}(${request}, $context);`,
567
+ "",
568
+ ` return ${response};`,
569
+ "});",
570
+ ].join("\n");
571
+ }
572
+ function collectStructFields(record) {
573
+ return collectDeclarations(record, "string")
574
+ .filter((field) => !isRemovedDeclaration(field))
575
+ .map((field) => ({
576
+ name: field.name,
577
+ number: field.number,
578
+ type: field.type ?? "string",
579
+ }));
580
+ }
581
+ function collectDeclarations(record, defaultMissingType) {
582
+ const declarations = [];
583
+ for (const field of record.fields ?? []) {
584
+ if (field.kind === "removed") {
585
+ declarations.push({ kind: "removed", number: field.number });
586
+ continue;
587
+ }
588
+ declarations.push({
589
+ name: tokenText(field.name),
590
+ number: field.number,
591
+ type: field.type ?? defaultMissingType,
592
+ });
593
+ }
594
+ for (const removedNumber of record.removedNumbers ?? []) {
595
+ if (!declarations.some((declaration) => declaration.number === removedNumber)) {
596
+ declarations.push({ kind: "removed", number: removedNumber });
597
+ }
598
+ }
599
+ return declarations.sort((left, right) => left.number - right.number);
600
+ }
601
+ function isStruct(record) {
602
+ return record.recordType === "struct" || record.kind === "struct";
603
+ }
604
+ function isEnum(record) {
605
+ return record.recordType === "enum" || record.kind === "enum";
606
+ }
607
+ function buildClassNameRegistry(rootNamespace, modules) {
608
+ const candidates = modules.flatMap((module) => (module.records ?? []).map((record, index) => {
609
+ const modulePath = modulePathForRecord(module, record);
610
+ const baseClassName = "record" in record
611
+ ? classNameForRecordLocation(record)
612
+ : classNameForRecord(record);
613
+ return {
614
+ identity: recordIdentity(module, record, index),
615
+ recordKey: recordKeyForRecord(record),
616
+ namespace: outputContextForModulePath(rootNamespace, modulePath).namespace,
617
+ modulePath,
618
+ baseClassName,
619
+ };
620
+ }));
621
+ const candidatesByClass = new Map();
622
+ for (const candidate of candidates) {
623
+ const groupKey = `${candidate.namespace}\\${candidate.baseClassName}`;
624
+ const existingCandidates = candidatesByClass.get(groupKey);
625
+ if (existingCandidates !== undefined) {
626
+ existingCandidates.push(candidate);
627
+ }
628
+ else {
629
+ candidatesByClass.set(groupKey, [candidate]);
630
+ }
631
+ }
632
+ const namesByIdentity = new Map();
633
+ const namesByRecordKey = new Map();
634
+ for (const groupedCandidates of candidatesByClass.values()) {
635
+ for (const candidate of groupedCandidates) {
636
+ const className = groupedCandidates.length > 1
637
+ ? `${moduleClassPrefix(candidate.modulePath)}${candidate.baseClassName}`
638
+ : candidate.baseClassName;
639
+ namesByIdentity.set(candidate.identity, className);
640
+ if (candidate.recordKey !== undefined) {
641
+ namesByRecordKey.set(candidate.recordKey, className);
642
+ }
643
+ }
644
+ }
645
+ return {
646
+ namesByIdentity,
647
+ namesByRecordKey,
648
+ };
649
+ }
650
+ function modulePathForRecord(module, record) {
651
+ if ("record" in record) {
652
+ return record.modulePath ?? module.path;
653
+ }
654
+ return module.path;
655
+ }
656
+ function recordIdentity(module, record, index) {
657
+ return recordKeyForRecord(record) ?? `${module.path}:${index}`;
658
+ }
659
+ function recordKeyForRecord(record) {
660
+ const recordKey = "record" in record
661
+ ? record.record.key
662
+ : record.key;
663
+ return typeof recordKey === "string" ? recordKey : undefined;
664
+ }
665
+ function classNameFromRegistry(classNames, identity, recordKey) {
666
+ if (classNames === undefined) {
667
+ return undefined;
668
+ }
669
+ if (typeof recordKey === "string") {
670
+ const className = classNames.namesByRecordKey.get(recordKey);
671
+ if (className !== undefined) {
672
+ return className;
673
+ }
674
+ }
675
+ if (identity !== undefined) {
676
+ return classNames.namesByIdentity.get(identity);
677
+ }
678
+ return undefined;
679
+ }
680
+ function moduleClassPrefix(modulePath) {
681
+ const moduleFileName = modulePath.split("/").at(-1) ?? modulePath;
682
+ const moduleName = moduleFileName.replace(/\.skir$/, "");
683
+ return toClassName(moduleName);
684
+ }
685
+ function normalizeRecord(record, module, index, classNames) {
686
+ if ("record" in record) {
687
+ return {
688
+ ...record.record,
689
+ phpClassName: classNameFromRegistry(classNames, recordIdentity(module, record, index), record.record.key) ?? classNameForRecordLocation(record),
690
+ };
691
+ }
692
+ return {
693
+ ...record,
694
+ phpClassName: classNameFromRegistry(classNames, recordIdentity(module, record, index), record.key) ?? record.phpClassName,
695
+ };
696
+ }
697
+ function isRemovedDeclaration(declaration) {
698
+ return "kind" in declaration && declaration.kind === "removed";
699
+ }
700
+ function phpType(type, context) {
701
+ const kind = typeKind(type);
702
+ if (kind === "bool") {
703
+ return "bool";
704
+ }
705
+ if (kind === "int32" || kind === "timestamp") {
706
+ return "int";
707
+ }
708
+ if (kind === "int64" || kind === "hash64") {
709
+ return "int|string";
710
+ }
711
+ if (kind === "float32" || kind === "float64") {
712
+ return "float";
713
+ }
714
+ if (kind === "string" || kind === "bytes") {
715
+ return "string";
716
+ }
717
+ if (kind === "array") {
718
+ return "array";
719
+ }
720
+ if (kind === "optional") {
721
+ return "?".concat(phpType(optionalInnerType(type), context));
722
+ }
723
+ if (kind === "record") {
724
+ return recordTypeClassName(type, context);
725
+ }
726
+ return "mixed";
727
+ }
728
+ function valueToArrayExpression(type, expression, context) {
729
+ const kind = typeKind(type);
730
+ if (kind === "record") {
731
+ if (isEnumRecordReference(type, context)) {
732
+ return `${expression}->toSkirValue()`;
733
+ }
734
+ return `${expression}->toArray()`;
735
+ }
736
+ if (kind === "optional") {
737
+ const innerType = optionalInnerType(type);
738
+ if (typeKind(innerType) === "record" || typeKind(innerType) === "array") {
739
+ return `${expression} === null ? null : ${valueToArrayExpression(innerType, expression, context)}`;
740
+ }
741
+ return expression;
742
+ }
743
+ if (kind === "array") {
744
+ const itemType = arrayItemType(type);
745
+ if (typeKind(itemType) === "record" || typeKind(itemType) === "optional" || typeKind(itemType) === "array") {
746
+ return `array_map(fn (mixed $item): mixed => ${valueToArrayExpression(itemType, "$item", context)}, ${expression})`;
747
+ }
748
+ }
749
+ return expression;
750
+ }
751
+ function valueFromArrayExpression(type, expression, context) {
752
+ const kind = typeKind(type);
753
+ if (kind === "record") {
754
+ if (isEnumRecordReference(type, context)) {
755
+ return `${recordTypeClassName(type, context)}::fromSkirValue(${expression})`;
756
+ }
757
+ return `${recordTypeClassName(type, context)}::fromArray(${expression})`;
758
+ }
759
+ if (kind === "optional") {
760
+ const innerType = optionalInnerType(type);
761
+ if (typeKind(innerType) === "record" || typeKind(innerType) === "array") {
762
+ return `${expression} === null ? null : ${valueFromArrayExpression(innerType, expression, context)}`;
763
+ }
764
+ return expression;
765
+ }
766
+ if (kind === "array") {
767
+ const itemType = arrayItemType(type);
768
+ if (typeKind(itemType) === "record" || typeKind(itemType) === "optional" || typeKind(itemType) === "array") {
769
+ return `array_map(fn (mixed $item): mixed => ${valueFromArrayExpression(itemType, "$item", context)}, ${expression})`;
770
+ }
771
+ }
772
+ return expression;
773
+ }
774
+ function runtimeTypeExpression(type, context) {
775
+ const kind = typeKind(type);
776
+ if (kind === "array") {
777
+ return `Type::array(${runtimeTypeExpression(arrayItemType(type), context)})`;
778
+ }
779
+ if (kind === "optional") {
780
+ return `Type::optional(${runtimeTypeExpression(optionalInnerType(type), context)})`;
781
+ }
782
+ if (kind === "record") {
783
+ return `${recordTypeClassName(type, context)}::skirType()`;
784
+ }
785
+ return `Type::${kind}()`;
786
+ }
787
+ function typeKind(type) {
788
+ if (typeof type === "string") {
789
+ return type;
790
+ }
791
+ if (type.kind === "primitive") {
792
+ return type.primitive ?? "string";
793
+ }
794
+ return type.kind;
795
+ }
796
+ function arrayItemType(type) {
797
+ if (typeof type !== "string" && type.kind === "array" && type.item !== undefined) {
798
+ return type.item;
799
+ }
800
+ return "string";
801
+ }
802
+ function optionalInnerType(type) {
803
+ if (typeof type !== "string" && type.kind === "optional" && type.other !== undefined) {
804
+ return type.other;
805
+ }
806
+ return "string";
807
+ }
808
+ function recordTypeClassName(type, context) {
809
+ if (typeof type === "string") {
810
+ throw new Error("String primitive types cannot be used as record references.");
811
+ }
812
+ if (typeof type.key === "string" && context.recordMap !== undefined) {
813
+ const recordLocation = context.recordMap.get(type.key);
814
+ if (recordLocation !== undefined) {
815
+ return classNameForRecordReference(context, recordLocation);
816
+ }
817
+ }
818
+ if (type.name !== undefined) {
819
+ return toClassName(tokenText(type.name));
820
+ }
821
+ if (type.nameParts !== undefined && type.nameParts.length > 0) {
822
+ return classNameFromParts(type.nameParts.map((part) => recordNamePartText(part)));
823
+ }
824
+ throw new Error("Skir record reference is missing a name.");
825
+ }
826
+ function isEnumRecordReference(type, context) {
827
+ if (typeof type === "string") {
828
+ return false;
829
+ }
830
+ if (type.kind !== "record") {
831
+ return false;
832
+ }
833
+ if (type.recordType === "enum") {
834
+ return true;
835
+ }
836
+ if (typeof type.key !== "string") {
837
+ return false;
838
+ }
839
+ return context.recordMap?.get(type.key)?.record.recordType === "enum";
840
+ }
841
+ function classNameForRecord(record) {
842
+ return record.phpClassName ?? toClassName(tokenText(record.name));
843
+ }
844
+ function classNameForRecordReference(context, recordLocation) {
845
+ const className = classNameFromRegistry(context.classNames, undefined, recordLocation.record.key) ?? classNameForRecordLocation(recordLocation);
846
+ const recordContext = outputContextForModulePath(context.rootNamespace, recordLocation.modulePath ?? "", context.recordMap, context.classNames);
847
+ if (recordContext.namespace === context.namespace) {
848
+ return className;
849
+ }
850
+ return importedClassName(context, `${recordContext.namespace}\\${className}`);
851
+ }
852
+ function outputContextForModule(rootNamespace, module, recordMap, classNames) {
853
+ return outputContextForModulePath(rootNamespace, module.path, recordMap, classNames);
854
+ }
855
+ function outputContextForModulePath(rootNamespace, modulePath, recordMap, classNames) {
856
+ const directoryParts = modulePath
857
+ .split("/")
858
+ .slice(0, -1)
859
+ .map((part) => toClassName(part))
860
+ .filter((part) => part !== "");
861
+ if (directoryParts.length === 0) {
862
+ return {
863
+ rootNamespace,
864
+ namespace: rootNamespace,
865
+ pathPrefix: "",
866
+ recordMap,
867
+ classNames,
868
+ };
869
+ }
870
+ return {
871
+ rootNamespace,
872
+ namespace: [rootNamespace, ...directoryParts].join("\\"),
873
+ pathPrefix: directoryParts.join("/"),
874
+ recordMap,
875
+ classNames,
876
+ };
877
+ }
878
+ function outputPath(context, fileName) {
879
+ if (context.pathPrefix === "") {
880
+ return fileName;
881
+ }
882
+ return `${context.pathPrefix}/${fileName}`;
883
+ }
884
+ function methodEnumClassName(context) {
885
+ if (context.pathPrefix === "") {
886
+ return "SkirMethod";
887
+ }
888
+ return `${context.pathPrefix.split("/").map((part) => toClassName(part)).join("")}SkirMethod`;
889
+ }
890
+ function fileOutputContext(context, className, runtimeImports) {
891
+ const reservedNames = new Set([
892
+ className,
893
+ ...runtimeImports.map((importName) => shortClassName(importName)),
894
+ ]);
895
+ return {
896
+ ...context,
897
+ imports: {
898
+ reservedNames,
899
+ imports: new Map(),
900
+ },
901
+ };
902
+ }
903
+ function generateUseStatements(context, runtimeImports) {
904
+ const generatedImports = Array.from(context.imports?.imports.values() ?? []).sort();
905
+ return [
906
+ ...runtimeImports,
907
+ ...generatedImports,
908
+ ].map((importName) => `use ${importName};`);
909
+ }
910
+ function importedClassName(context, fullyQualifiedClassName) {
911
+ const shortName = shortClassName(fullyQualifiedClassName);
912
+ if (context.imports === undefined) {
913
+ return `\\${fullyQualifiedClassName}`;
914
+ }
915
+ if (context.imports.reservedNames.has(shortName)) {
916
+ return `\\${fullyQualifiedClassName}`;
917
+ }
918
+ const existingImport = context.imports.imports.get(shortName);
919
+ if (existingImport === undefined) {
920
+ context.imports.imports.set(shortName, fullyQualifiedClassName);
921
+ return shortName;
922
+ }
923
+ if (existingImport === fullyQualifiedClassName) {
924
+ return shortName;
925
+ }
926
+ return `\\${fullyQualifiedClassName}`;
927
+ }
928
+ function shortClassName(fullyQualifiedClassName) {
929
+ return fullyQualifiedClassName.split("\\").at(-1) ?? fullyQualifiedClassName;
930
+ }
931
+ function classNameForRecordLocation(record) {
932
+ if (record.recordAncestors !== undefined && record.recordAncestors.length > 0) {
933
+ return classNameFromParts(record.recordAncestors.map((ancestor) => tokenText(ancestor.name)));
934
+ }
935
+ return toClassName(tokenText(record.record.name));
936
+ }
937
+ function classNameFromParts(parts) {
938
+ return parts.map((part) => toClassName(part)).join("");
939
+ }
940
+ function recordNamePartText(part) {
941
+ if (typeof part === "string") {
942
+ return part;
943
+ }
944
+ if ("token" in part) {
945
+ return tokenText(part.token);
946
+ }
947
+ return tokenText(part);
948
+ }
949
+ function toClassName(name) {
950
+ return name
951
+ .split(/[_\-\s]+/)
952
+ .filter(Boolean)
953
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
954
+ .join("");
955
+ }
956
+ function toPropertyName(name) {
957
+ const className = toClassName(name);
958
+ return className.charAt(0).toLowerCase() + className.slice(1);
959
+ }
960
+ function tokenText(token) {
961
+ return typeof token === "string" ? token : token.text;
962
+ }
963
+ function indent(code) {
964
+ return code
965
+ .split("\n")
966
+ .map((line) => (line === "" ? line : ` ${line}`))
967
+ .join("\n");
968
+ }
@@ -0,0 +1,16 @@
1
+ import { type CodeGenerator } from "skir-internal";
2
+ import { z } from "zod";
3
+ import { generatePhpFiles } from "./generator.js";
4
+ declare const Config: z.ZodObject<{
5
+ namespace: z.ZodDefault<z.ZodString>;
6
+ }, z.core.$strict>;
7
+ type Config = z.infer<typeof Config>;
8
+ declare class PhpGenerator implements CodeGenerator<Config> {
9
+ readonly id = "skir-php-generator";
10
+ readonly configType: z.ZodObject<{
11
+ namespace: z.ZodDefault<z.ZodString>;
12
+ }, z.core.$strict>;
13
+ generateCode(input: CodeGenerator.Input<Config>): CodeGenerator.Output;
14
+ }
15
+ export declare const GENERATOR: PhpGenerator;
16
+ export { generatePhpFiles };
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ import { generatePhpFiles } from "./generator.js";
3
+ const Config = z.strictObject({
4
+ namespace: z.string().default("App\\Skir"),
5
+ });
6
+ class PhpGenerator {
7
+ id = "skir-php-generator";
8
+ configType = Config;
9
+ generateCode(input) {
10
+ return {
11
+ files: generatePhpFiles(input),
12
+ };
13
+ }
14
+ }
15
+ export const GENERATOR = new PhpGenerator();
16
+ export { generatePhpFiles };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "skir-php-generator",
3
+ "version": "0.1.0",
4
+ "description": "Skir code generator for standard PHP data objects.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Maxim Kerstens",
9
+ "email": "maxim.kerstens@gmail.com"
10
+ },
11
+ "homepage": "https://github.com/php-skir/skir-php-generator#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/php-skir/skir-php-generator.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/php-skir/skir-php-generator/issues"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "php",
24
+ "rpc",
25
+ "skir",
26
+ "skir-generator"
27
+ ],
28
+ "exports": {
29
+ ".": "./dist/index.js"
30
+ },
31
+ "types": "./dist/index.d.ts",
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json",
37
+ "prepack": "npm run build",
38
+ "pack:dry-run": "npm pack --dry-run --cache .npm-cache",
39
+ "test": "vitest run",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit"
41
+ },
42
+ "dependencies": {
43
+ "skir-internal": "^0.2.21",
44
+ "zod": "^4.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "skir": "^1.2.19",
48
+ "typescript": "^5.9.0",
49
+ "vitest": "^4.0.0"
50
+ }
51
+ }