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 +21 -0
- package/README.md +112 -0
- package/dist/generator.d.ts +65 -0
- package/dist/generator.js +968 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/package.json +51 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|