skir-php-generator 0.1.0 → 0.3.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/dist/index.d.ts CHANGED
@@ -1,16 +1,12 @@
1
1
  import { type CodeGenerator } from "skir-internal";
2
- import { z } from "zod";
2
+ import { GeneratorConfig } from "./config.js";
3
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> {
4
+ declare class PhpGenerator implements CodeGenerator<GeneratorConfig> {
9
5
  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;
6
+ readonly configType: CodeGenerator<GeneratorConfig>["configType"];
7
+ generateCode(input: CodeGenerator.Input<GeneratorConfig>): CodeGenerator.Output;
14
8
  }
15
9
  export declare const GENERATOR: PhpGenerator;
16
10
  export { generatePhpFiles };
11
+ export { StandardPhpTarget } from "./target.js";
12
+ export default GENERATOR;
package/dist/index.js CHANGED
@@ -1,11 +1,8 @@
1
- import { z } from "zod";
1
+ import { GeneratorConfig, GENERATOR_MODULE } from "./config.js";
2
2
  import { generatePhpFiles } from "./generator.js";
3
- const Config = z.strictObject({
4
- namespace: z.string().default("App\\Skir"),
5
- });
6
3
  class PhpGenerator {
7
- id = "skir-php-generator";
8
- configType = Config;
4
+ id = GENERATOR_MODULE;
5
+ configType = GeneratorConfig;
9
6
  generateCode(input) {
10
7
  return {
11
8
  files: generatePhpFiles(input),
@@ -14,3 +11,5 @@ class PhpGenerator {
14
11
  }
15
12
  export const GENERATOR = new PhpGenerator();
16
13
  export { generatePhpFiles };
14
+ export { StandardPhpTarget } from "./target.js";
15
+ export default GENERATOR;
@@ -0,0 +1,17 @@
1
+ import type { GeneratedFile } from "./generator.js";
2
+ export declare const SERVER_MANIFEST_VERSION = 1;
3
+ export interface ServerManifestMethod {
4
+ readonly name: string;
5
+ readonly enumCase: string;
6
+ readonly phpMethod: string;
7
+ readonly requestType: string;
8
+ readonly requestClass: string | null;
9
+ readonly responseType: string;
10
+ readonly responseClass: string | null;
11
+ }
12
+ export interface ServerManifestModule {
13
+ readonly name: string;
14
+ readonly methodEnum: string;
15
+ readonly methods: readonly ServerManifestMethod[];
16
+ }
17
+ export declare function generateServerManifestFile(generator: string, modules: readonly ServerManifestModule[]): GeneratedFile;
@@ -0,0 +1,11 @@
1
+ export const SERVER_MANIFEST_VERSION = 1;
2
+ export function generateServerManifestFile(generator, modules) {
3
+ return {
4
+ path: "skir-server-manifest.json",
5
+ code: `${JSON.stringify({
6
+ version: SERVER_MANIFEST_VERSION,
7
+ generator,
8
+ modules,
9
+ }, null, 2)}\n`,
10
+ };
11
+ }
@@ -0,0 +1,20 @@
1
+ import { type GeneratedFile, type NormalizedRecord, type NormalizedType, type PhpTargetAdapter, type RenderContext, type StructRenderRequest } from "@php-skir/generator-core";
2
+ export declare class StandardPhpTarget implements PhpTargetAdapter {
3
+ readonly id = "skir-php-generator";
4
+ recordClassName(record: NormalizedRecord): string;
5
+ renderStruct({ record, context }: StructRenderRequest): GeneratedFile;
6
+ phpType(type: NormalizedType, context: RenderContext): string;
7
+ toSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
8
+ fromSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
9
+ clientResponseExpression(type: NormalizedType, expression: string, context: RenderContext): string;
10
+ manifestObjectClass(type: NormalizedType, context: RenderContext): string | null;
11
+ private renderConstructor;
12
+ private renderSkirType;
13
+ private renderToArray;
14
+ private renderFromArray;
15
+ private renderToDenseJson;
16
+ private renderFromDenseJson;
17
+ private renderToReadableJson;
18
+ private renderToBytes;
19
+ private renderFromBytes;
20
+ }
package/dist/target.js ADDED
@@ -0,0 +1,317 @@
1
+ import { importClass, importClassAs, indent, renderPhpFile, toClassName, toPhpNamespaceSegment, toPropertyName, } from "@php-skir/generator-core";
2
+ import { GENERATOR_MODULE } from "./config.js";
3
+ export class StandardPhpTarget {
4
+ id = GENERATOR_MODULE;
5
+ recordClassName(record) {
6
+ return toClassName(record.qualifiedName);
7
+ }
8
+ renderStruct({ record, context }) {
9
+ if (record.recordType !== "struct") {
10
+ throw new Error(`Cannot render non-struct record ${record.identity} as a struct.`);
11
+ }
12
+ const className = classNameForRecord(record, context);
13
+ const binaryFormat = importClass(context.imports, "Skir\\Runtime\\BinaryFormat");
14
+ const denseJson = importClass(context.imports, "Skir\\Runtime\\DenseJson");
15
+ const fieldClass = importClass(context.imports, "Skir\\Runtime\\Field");
16
+ const readableJson = importClass(context.imports, "Skir\\Runtime\\ReadableJson");
17
+ const typeClass = importClass(context.imports, "Skir\\Runtime\\Type");
18
+ const fields = record.fields.filter(isStructField);
19
+ const constructor = this.renderConstructor(fields, context);
20
+ const members = [
21
+ constructor,
22
+ this.renderSkirType(record, context, fieldClass, typeClass),
23
+ this.renderToArray(fields, context),
24
+ this.renderFromArray(className, fields, context),
25
+ this.renderToDenseJson(denseJson),
26
+ this.renderFromDenseJson(className, denseJson),
27
+ this.renderToReadableJson(readableJson),
28
+ this.renderToBytes(binaryFormat),
29
+ this.renderFromBytes(className, binaryFormat),
30
+ ].filter((member) => member !== null);
31
+ const body = [
32
+ `final readonly class ${className}`,
33
+ "{",
34
+ ...members.flatMap((member, index) => index === 0
35
+ ? [indent(member)]
36
+ : ["", indent(member)]),
37
+ "}",
38
+ ].join("\n");
39
+ return {
40
+ path: outputPath(context, `${className}.php`),
41
+ code: renderPhpFile({
42
+ namespace: context.namespace,
43
+ imports: renderStandardUseStatements(context),
44
+ body,
45
+ }),
46
+ };
47
+ }
48
+ phpType(type, context) {
49
+ if (type.kind === "bool") {
50
+ return "bool";
51
+ }
52
+ if (type.kind === "int32" || type.kind === "timestamp") {
53
+ return "int";
54
+ }
55
+ if (type.kind === "int64" || type.kind === "hash64") {
56
+ return "int|string";
57
+ }
58
+ if (type.kind === "float32" || type.kind === "float64") {
59
+ return "float";
60
+ }
61
+ if (type.kind === "string" || type.kind === "bytes") {
62
+ return "string";
63
+ }
64
+ if (type.kind === "array") {
65
+ return "array";
66
+ }
67
+ if (type.kind === "optional") {
68
+ return nullablePhpType(this.phpType(type.inner, context));
69
+ }
70
+ if (type.kind === "record") {
71
+ return recordTypeClassName(type, context);
72
+ }
73
+ return "mixed";
74
+ }
75
+ toSkirExpression(type, expression, context) {
76
+ if (type.kind === "record") {
77
+ return type.recordType === "enum"
78
+ ? `${expression}->toSkirValue()`
79
+ : `${expression}->toArray()`;
80
+ }
81
+ if (type.kind === "optional") {
82
+ if (type.inner.kind === "record" || type.inner.kind === "array") {
83
+ return `${expression} === null ? null : ${this.toSkirExpression(type.inner, expression, context)}`;
84
+ }
85
+ return expression;
86
+ }
87
+ if (type.kind === "array") {
88
+ if (type.item.kind === "record"
89
+ || type.item.kind === "optional"
90
+ || type.item.kind === "array") {
91
+ return `array_map(fn (mixed $item): mixed => ${this.toSkirExpression(type.item, "$item", context)}, ${expression})`;
92
+ }
93
+ }
94
+ return expression;
95
+ }
96
+ fromSkirExpression(type, expression, context) {
97
+ if (type.kind === "record") {
98
+ const className = recordTypeClassName(type, context);
99
+ return type.recordType === "enum"
100
+ ? `${className}::fromSkirValue(${expression})`
101
+ : `${className}::fromArray(${expression})`;
102
+ }
103
+ if (type.kind === "optional") {
104
+ if (type.inner.kind === "record" || type.inner.kind === "array") {
105
+ return `${expression} === null ? null : ${this.fromSkirExpression(type.inner, expression, context)}`;
106
+ }
107
+ return expression;
108
+ }
109
+ if (type.kind === "array") {
110
+ if (type.item.kind === "record"
111
+ || type.item.kind === "optional"
112
+ || type.item.kind === "array") {
113
+ return `array_map(fn (mixed $item): mixed => ${this.fromSkirExpression(type.item, "$item", context)}, ${expression})`;
114
+ }
115
+ }
116
+ return expression;
117
+ }
118
+ clientResponseExpression(type, expression, context) {
119
+ return this.fromSkirExpression(type, expression, context);
120
+ }
121
+ manifestObjectClass(type, context) {
122
+ if (type.kind !== "record") {
123
+ return null;
124
+ }
125
+ return fullyQualifiedRecordClassName(type, context);
126
+ }
127
+ renderConstructor(fields, context) {
128
+ if (fields.length === 0) {
129
+ return null;
130
+ }
131
+ return [
132
+ "public function __construct(",
133
+ ...fields.map((field) => (` public ${this.phpType(field.type, context)} $${toPropertyName(field.name)},`)),
134
+ ") {}",
135
+ ].join("\n");
136
+ }
137
+ renderSkirType(record, context, fieldClass, typeClass) {
138
+ const entries = record.fields.map((field) => {
139
+ if (field.kind === "removed") {
140
+ return ` ${fieldClass}::removed(${field.number}),`;
141
+ }
142
+ if (!field.hasPayload) {
143
+ throw new Error(`Struct field ${field.name} in ${record.identity} has no payload type.`);
144
+ }
145
+ return ` ${fieldClass}::value('${field.name}', ${field.number}, ${runtimeTypeExpression(field.type, context, this, typeClass)}),`;
146
+ }).join("\n");
147
+ return [
148
+ `public static function skirType(): ${typeClass}`,
149
+ "{",
150
+ ` return ${typeClass}::struct([`,
151
+ entries,
152
+ " ]);",
153
+ "}",
154
+ ].join("\n");
155
+ }
156
+ renderToArray(fields, context) {
157
+ return [
158
+ "/** @return array<string, mixed> */",
159
+ "public function toArray(): array",
160
+ "{",
161
+ " return [",
162
+ ...fields.map((field) => {
163
+ const property = toPropertyName(field.name);
164
+ return ` '${field.name}' => ${this.toSkirExpression(field.type, `$this->${property}`, context)},`;
165
+ }),
166
+ " ];",
167
+ "}",
168
+ ].join("\n");
169
+ }
170
+ renderFromArray(className, fields, context) {
171
+ return [
172
+ "/** @param array<string, mixed> $data */",
173
+ `public static function fromArray(array $data): ${className}`,
174
+ "{",
175
+ " return new self(",
176
+ ...fields.map((field) => (` ${toPropertyName(field.name)}: ${this.fromSkirExpression(field.type, `$data['${field.name}']`, context)},`)),
177
+ " );",
178
+ "}",
179
+ ].join("\n");
180
+ }
181
+ renderToDenseJson(denseJson) {
182
+ return [
183
+ "public function toDenseJson(): string",
184
+ "{",
185
+ ` return ${denseJson}::toJson(self::skirType(), $this->toArray());`,
186
+ "}",
187
+ ].join("\n");
188
+ }
189
+ renderFromDenseJson(className, denseJson) {
190
+ return [
191
+ `public static function fromDenseJson(string $json): ${className}`,
192
+ "{",
193
+ ` return self::fromArray(${denseJson}::fromJson(self::skirType(), $json));`,
194
+ "}",
195
+ ].join("\n");
196
+ }
197
+ renderToReadableJson(readableJson) {
198
+ return [
199
+ "public function toReadableJson(): string",
200
+ "{",
201
+ ` return ${readableJson}::toJson(self::skirType(), $this->toArray());`,
202
+ "}",
203
+ ].join("\n");
204
+ }
205
+ renderToBytes(binaryFormat) {
206
+ return [
207
+ "public function toBytes(): string",
208
+ "{",
209
+ ` return ${binaryFormat}::encode(self::skirType(), $this->toArray());`,
210
+ "}",
211
+ ].join("\n");
212
+ }
213
+ renderFromBytes(className, binaryFormat) {
214
+ return [
215
+ `public static function fromBytes(string $bytes): ${className}`,
216
+ "{",
217
+ ` return self::fromArray(${binaryFormat}::decode(self::skirType(), $bytes));`,
218
+ "}",
219
+ ].join("\n");
220
+ }
221
+ }
222
+ function nullablePhpType(type) {
223
+ const unionMembers = type.split("|");
224
+ if (type === "mixed" || type.startsWith("?") || unionMembers.includes("null")) {
225
+ return type;
226
+ }
227
+ return unionMembers.length === 1 ? `?${type}` : `${type}|null`;
228
+ }
229
+ function isStructField(field) {
230
+ return field.kind === "field" && field.hasPayload;
231
+ }
232
+ function runtimeTypeExpression(type, context, adapter, typeClass) {
233
+ if (type.kind === "array") {
234
+ return `${typeClass}::array(${runtimeTypeExpression(type.item, context, adapter, typeClass)})`;
235
+ }
236
+ if (type.kind === "optional") {
237
+ return `${typeClass}::optional(${runtimeTypeExpression(type.inner, context, adapter, typeClass)})`;
238
+ }
239
+ if (type.kind === "record") {
240
+ return `${adapter.phpType(type, context)}::skirType()`;
241
+ }
242
+ return `${typeClass}::${type.kind}()`;
243
+ }
244
+ function classNameForRecord(record, context) {
245
+ const className = context.names.namesByIdentity.get(record.identity);
246
+ if (className === undefined) {
247
+ throw new Error(`No PHP class name was resolved for struct ${record.identity}.`);
248
+ }
249
+ return className;
250
+ }
251
+ function recordTypeClassName(type, context) {
252
+ const className = context.names.namesByIdentity.get(type.recordIdentity);
253
+ if (className === undefined) {
254
+ throw new Error(`No PHP class name was resolved for record ${type.recordIdentity}.`);
255
+ }
256
+ const namespace = recordNamespace(type.recordIdentity, context.rootNamespace);
257
+ if (namespace === context.namespace) {
258
+ return className;
259
+ }
260
+ const fullyQualifiedClassName = canonicalRecordClassName(namespace, className);
261
+ const isReserved = [...context.imports.reservedNames].some((reservedName) => (reservedName.toLowerCase() === className.toLowerCase()));
262
+ if (isReserved) {
263
+ return `\\${fullyQualifiedClassName}`;
264
+ }
265
+ const existingImport = [...context.imports.imports.entries()]
266
+ .find(([localName]) => localName.toLowerCase() === className.toLowerCase());
267
+ if (existingImport === undefined) {
268
+ return importClassAs(context.imports, fullyQualifiedClassName, className);
269
+ }
270
+ return existingImport[1].toLowerCase() === fullyQualifiedClassName.toLowerCase()
271
+ ? existingImport[0]
272
+ : `\\${fullyQualifiedClassName}`;
273
+ }
274
+ function fullyQualifiedRecordClassName(type, context) {
275
+ const className = context.names.namesByIdentity.get(type.recordIdentity);
276
+ if (className === undefined) {
277
+ throw new Error(`No PHP class name was resolved for record ${type.recordIdentity}.`);
278
+ }
279
+ return canonicalRecordClassName(recordNamespace(type.recordIdentity, context.rootNamespace), className);
280
+ }
281
+ function canonicalRecordClassName(namespace, className) {
282
+ const fullyQualifiedClassName = `${namespace}\\${className}`.replace(/^\\+/u, "");
283
+ const parts = fullyQualifiedClassName.split("\\");
284
+ if (parts.some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(part))) {
285
+ throw new Error(`Invalid normalized PHP record class name ${fullyQualifiedClassName}.`);
286
+ }
287
+ if (parts.at(-1) !== className) {
288
+ throw new Error(`Invalid normalized PHP record basename ${className}.`);
289
+ }
290
+ return fullyQualifiedClassName;
291
+ }
292
+ function recordNamespace(recordIdentity, rootNamespace) {
293
+ const separatorIndex = recordIdentity.lastIndexOf("::");
294
+ if (separatorIndex === -1) {
295
+ throw new Error(`Invalid normalized record identity ${recordIdentity}.`);
296
+ }
297
+ const modulePath = recordIdentity.slice(0, separatorIndex);
298
+ const namespaceSegments = modulePath
299
+ .split("/")
300
+ .slice(0, -1)
301
+ .map((segment) => toPhpNamespaceSegment(segment))
302
+ .filter((segment) => segment !== "");
303
+ return [rootNamespace, ...namespaceSegments]
304
+ .filter((segment) => segment !== "")
305
+ .join("\\");
306
+ }
307
+ function outputPath(context, fileName) {
308
+ return context.pathPrefix === "" ? fileName : `${context.pathPrefix}/${fileName}`;
309
+ }
310
+ function renderStandardUseStatements(context) {
311
+ return [...context.imports.imports.entries()].map(([alias, fullyQualifiedClassName]) => {
312
+ const shortName = fullyQualifiedClassName.split("\\").at(-1);
313
+ return alias === shortName
314
+ ? `use ${fullyQualifiedClassName};`
315
+ : `use ${fullyQualifiedClassName} as ${alias};`;
316
+ });
317
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skir-php-generator",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Skir code generator for standard PHP data objects.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,21 +29,31 @@
29
29
  ".": "./dist/index.js"
30
30
  },
31
31
  "types": "./dist/index.d.ts",
32
+ "bin": {
33
+ "skir-php-generator": "./dist/cli.js"
34
+ },
32
35
  "files": [
33
36
  "dist"
34
37
  ],
35
38
  "scripts": {
36
39
  "build": "tsc -p tsconfig.json",
37
40
  "prepack": "npm run build",
41
+ "pretest:coverage": "npm run build",
38
42
  "pack:dry-run": "npm pack --dry-run --cache .npm-cache",
39
43
  "test": "vitest run",
44
+ "test:coverage": "vitest run --coverage --coverage.reporter=json-summary --coverage.include=src/**/*.ts",
40
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
41
46
  },
42
47
  "dependencies": {
48
+ "@php-skir/generator-core": "^0.2.0",
49
+ "jsonc-parser": "^3.3.1",
43
50
  "skir-internal": "^0.2.21",
51
+ "yaml": "^2.9.0",
44
52
  "zod": "^4.0.0"
45
53
  },
46
54
  "devDependencies": {
55
+ "@types/node": "^22.20.1",
56
+ "@vitest/coverage-v8": "^4.1.10",
47
57
  "skir": "^1.2.19",
48
58
  "typescript": "^5.9.0",
49
59
  "vitest": "^4.0.0"