skir-simple-data-objects-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,370 @@
1
+ # Skir Simple Data Objects Generator
2
+
3
+ [![Tests](https://github.com/php-skir/skir-simple-data-objects-generator/actions/workflows/tests.yml/badge.svg)](https://github.com/php-skir/skir-simple-data-objects-generator/actions/workflows/tests.yml)
4
+ [![Coverage](https://raw.githubusercontent.com/php-skir/skir-simple-data-objects-generator/badges/coverage.svg)](https://github.com/php-skir/skir-simple-data-objects-generator/actions/workflows/tests.yml)
5
+ [![npm](https://img.shields.io/npm/v/skir-simple-data-objects-generator?label=npm&logo=npm)](https://www.npmjs.com/package/skir-simple-data-objects-generator)
6
+ [![Node.js](https://img.shields.io/badge/Node.js-22%20%7C%2024-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/)
7
+ [![License](https://img.shields.io/github/license/php-skir/skir-simple-data-objects-generator)](LICENSE)
8
+
9
+ Generates [`std-out/simple-data-objects`](https://github.com/std-out/simple-data-objects) DTOs, typed RPC clients, and server procedure contracts from Skir schemas.
10
+
11
+ Generated PHP uses `php-skir/runtime` for Skir wire formats and `std-out/simple-data-objects` for immutable DTO hydration, mapped property names, typed collections, serialization, and validation.
12
+
13
+ ## Quick start
14
+
15
+ Prerequisites: PHP 8.4 or newer, Composer 2, and a supported Node.js release.
16
+
17
+ Install Skir and this generator as development dependencies, then install the PHP runtime and DTO library:
18
+
19
+ ```bash
20
+ npm install --save-dev skir skir-simple-data-objects-generator
21
+ composer require php-skir/runtime std-out/simple-data-objects
22
+ ```
23
+
24
+ Create `skir-src/users.skir`:
25
+
26
+ ```skir
27
+ struct User {
28
+ user_id: int32;
29
+ email: string;
30
+ nickname: string?;
31
+ }
32
+ ```
33
+
34
+ Add the generator to the root `skir.yml`:
35
+
36
+ ```yaml
37
+ generators:
38
+ - mod: skir-simple-data-objects-generator
39
+ outDir: generated/php/skirout
40
+ config:
41
+ namespace: Skir
42
+ validation:
43
+ users.skir:
44
+ User:
45
+ email:
46
+ - email
47
+ ```
48
+
49
+ Generate the PHP files and configure Composer autoloading:
50
+
51
+ ```bash
52
+ npx skir gen
53
+ npx skir-simple-data-objects-generator configure-composer
54
+ composer dump-autoload
55
+ ```
56
+
57
+ The generated `Skir\UserData` class is now ready to use:
58
+
59
+ ```php
60
+ use Skir\UserData;
61
+
62
+ $user = UserData::makeFromSkirPayload([
63
+ 'user_id' => 400,
64
+ 'email' => 'maxim@example.com',
65
+ ]);
66
+
67
+ $json = $user->toSkirJson();
68
+ $decoded = UserData::fromSkir($json);
69
+
70
+ $decoded->userId; // 400
71
+ $decoded->nickname; // null
72
+ ```
73
+
74
+ The root namespace defaults to `Skir`, so `config.namespace` is optional when that default is suitable.
75
+
76
+ ## Generated output is owned by Skir
77
+
78
+ Skir owns the configured `outDir` and may replace or delete anything inside it. Use a dedicated path ending in `/skirout`, do not put handwritten PHP there, and never edit a generated file. Change the `.skir` source or generator configuration and regenerate instead.
79
+
80
+ Source directories below `skir-src` become PHP subnamespaces and output directories. The `.skir` filename itself does not add a namespace segment:
81
+
82
+ ```text
83
+ skir-src/admin/users.skir -> generated/php/skirout/Admin/UserData.php
84
+ -> Skir\Admin\UserData
85
+ ```
86
+
87
+ Generated struct and enum class names end in `Data`. A Skir field such as `user_id` becomes the PHP property `$userId`, with `#[MapPropertyName('user_id')]` preserving the original name for hydration and `toArray()` output.
88
+
89
+ ## Composer configuration
90
+
91
+ Run Skir generation before `configure-composer`: the command verifies that every configured output directory exists before changing `composer.json`.
92
+
93
+ ```text
94
+ skir-simple-data-objects-generator configure-composer [--root <dir>] [--mod <module>]
95
+ ```
96
+
97
+ In a normal npm project, invoke it through `npx`:
98
+
99
+ ```bash
100
+ npx skir-simple-data-objects-generator configure-composer
101
+ ```
102
+
103
+ The command reads `skir.yml` and `composer.json`, finds the exact `mod: skir-simple-data-objects-generator` entry, and adds the configured namespace and `outDir` as a Composer PSR-4 mapping. It changes only `composer.json`; run `composer dump-autoload` afterward.
104
+
105
+ Use `--root <dir>` for another project root. Use `--mod <module>` when the configured module is an exact alternative identifier, such as a local file URL:
106
+
107
+ ```bash
108
+ npx skir-simple-data-objects-generator configure-composer \
109
+ --root ../api \
110
+ --mod file:///path/to/skir-simple-data-objects-generator/dist/index.js
111
+ ```
112
+
113
+ An equivalent existing mapping is left byte-for-byte unchanged. A conflicting mapping, invalid configuration, missing output directory, or path outside the project root fails without modifying `composer.json`.
114
+
115
+ ## Generated data objects
116
+
117
+ ### Struct APIs
118
+
119
+ Every Skir struct generates a final class extending `StdOut\SimpleDataObjects\BaseData`. In addition to its readonly constructor properties, it exposes:
120
+
121
+ | Method | Purpose |
122
+ | --- | --- |
123
+ | `skirType()` | Return the runtime `Type` descriptor. |
124
+ | `makeFromSkirPayload(array $data)` | Validate raw named Skir data, recursively hydrate nested values, and return the DTO. |
125
+ | `fromSkir(string $json)` | Decode dense Skir JSON and delegate to `makeFromSkirPayload()`. |
126
+ | `toSkirArray()` | Convert DTO values to the named array representation expected by the runtime. |
127
+ | `toSkir()` | Encode the DTO as the dense PHP array representation. |
128
+ | `toSkirJson()` | Encode the DTO as dense Skir JSON. |
129
+
130
+ Generated classes inherit the current `BaseData` APIs, including `from()`, `fromValidated()`, `validate()`, `toArray()`, `toJson()`, `with()`, `equals()`, `diff()`, `only()`, `except()`, `collection()`, and `lazyCollection()`.
131
+
132
+ Use PHP property names with `with()`, but original Skir names in raw payloads and serialized arrays:
133
+
134
+ ```php
135
+ $updated = $user->with(userId: 401);
136
+
137
+ $updated->userId; // 401
138
+ $updated->toArray()['user_id']; // 401
139
+ $updated->equals($user); // false
140
+ ```
141
+
142
+ `BaseData::from()` hydrates without validation. Use `makeFromSkirPayload()` for raw Skir data: it applies the generated validation contract and performs the Skir-specific recursive conversions before calling `BaseData::from()`.
143
+
144
+ `BaseData::collection()`, `BaseData::lazyCollection()`, and `TypedDataCollection::of()` are also trusted hydration paths. Like `from()`, they do not execute generated `#[Rules]` validation. Construct collections from DTOs or other trusted values:
145
+
146
+ ```php
147
+ use Skir\AddressData;
148
+
149
+ $addresses = AddressData::collection([
150
+ AddressData::makeFromSkirPayload([
151
+ 'city' => 'Antwerp',
152
+ 'postal_codes' => ['2000'],
153
+ ]),
154
+ ]);
155
+
156
+ foreach ($addresses as $address) {
157
+ echo $address->city;
158
+ }
159
+ ```
160
+
161
+ For untrusted raw item arrays, call `AddressData::makeFromSkirPayload()` for each item before constructing a collection. When the collection belongs to a generated parent DTO or RPC request, prefer passing the complete raw parent payload through the parent's `makeFromSkirPayload()` or generated RPC hydration path; those paths validate each nested struct before collection construction.
162
+
163
+ ### Struct collections
164
+
165
+ Only a direct array of structs becomes a `TypedDataCollection`:
166
+
167
+ | Skir shape | Generated PHP type |
168
+ | --- | --- |
169
+ | `[Address]` | `TypedDataCollection` with `#[DataCollection(AddressData::class)]` |
170
+ | `[Address]?` | `?TypedDataCollection` with the same collection attribute |
171
+ | `[[Address]]` | `array` |
172
+ | `[Address?]` | `array` |
173
+ | `[string]` or `[SomeEnum]` | `array` |
174
+
175
+ Nested arrays and arrays whose item is optional remain normal PHP arrays, although supported nested structs and enum wrappers are still converted recursively.
176
+
177
+ ### Wrapper enums
178
+
179
+ Skir enums generate readonly wrapper classes with a `Data` suffix. Constant variants have parameterless named constructors; payload variants have typed named constructors. Generated enum classes expose `name()`, `payload()`, `skirType()`, `toSkirValue()`, `fromSkirValue()`, `toDenseValue()`, `fromDenseValue()`, `toDenseJson()`, and `fromDenseJson()`.
180
+
181
+ Structs, typed struct collections, nested arrays, optionals, and nested enum wrappers are converted to wire values when stored in a wrapper and converted back to target DTO values when `payload()` is read.
182
+
183
+ ## Validation
184
+
185
+ The generator emits `#[Rules]` attributes understood by Simple Data Objects and Laravel's validator. Rules are deterministic: generated structural rules come first, followed by configured string rules in their exact configured order.
186
+
187
+ ### Structural rules
188
+
189
+ Every non-optional field starts with `required`. Every optional field starts with `nullable`, so the key may be omitted or its value may be `null`. A safely representable shape rule follows when available:
190
+
191
+ | Skir type | Generated shape rule |
192
+ | --- | --- |
193
+ | `bool` | `boolean` |
194
+ | `int32`, `timestamp` | `integer` |
195
+ | `float32`, `float64` | `numeric` |
196
+ | `string`, `bytes` | `string` |
197
+ | any array | `array` |
198
+ | struct reference | `array` |
199
+ | enum reference, `mixed` | none |
200
+ | `int64`, `hash64` | none |
201
+
202
+ `int64` and `hash64` deliberately receive no narrowing rule because their PHP representation is `int|string`.
203
+
204
+ Array validation is structural only at the field level. The generator emits `array`, but not wildcard item rules such as `field.*`. Array-shaped nested structs are recursively hydrated through their own `makeFromSkirPayload()` method and therefore run their own field rules. Put additional element constraints in custom named rules or at a handwritten application boundary.
205
+
206
+ ### Validation and hydration order
207
+
208
+ `makeFromSkirPayload()` processes input in this order:
209
+
210
+ 1. Validate the raw parent array with `self::validate($data)`.
211
+ 2. Convert mapped names and recursively hydrate nested structs, collections, arrays, and enum wrappers. Each nested struct validates its own raw array before hydration.
212
+ 3. Pass the already validated and prepared values to `self::from($payload)`.
213
+
214
+ Generated RPC providers use the same raw-to-target conversion for incoming requests, and generated clients use it for responses. Direct struct requests and every recursively hydrated nested struct are validated before application code receives them.
215
+
216
+ The final `BaseData::from()` call is intentionally a hydration-only operation and does not validate by itself. Calling `SomeData::from($untrustedInput)` directly bypasses the generated raw-first validation sequence.
217
+
218
+ ### Validation overlays
219
+
220
+ Configure application-specific rules under `config.validation` in `skir.yml`:
221
+
222
+ ```yaml
223
+ generators:
224
+ - mod: skir-simple-data-objects-generator
225
+ outDir: generated/php/skirout
226
+ config:
227
+ namespace: Skir
228
+ validation:
229
+ admin/users.skir:
230
+ "Envelope.Metadata":
231
+ display_name:
232
+ - min:2
233
+ - max:80
234
+ email_address:
235
+ - email:rfc
236
+ - company_email
237
+ ```
238
+
239
+ Selectors use the original Skir identities, in this order:
240
+
241
+ 1. Module path relative to `skir-src`, such as `admin/users.skir`.
242
+ 2. Qualified record name, such as `Envelope.Metadata`.
243
+ 3. Original Skir field name, such as `display_name`, not the generated `$displayName` property.
244
+
245
+ Unknown modules, records, or fields fail generation instead of silently dropping a rule. Enum variants, removed fields, and other non-struct payload fields cannot receive validation overlays.
246
+
247
+ Only non-empty string rules can be encoded in `skir.yml`. The generator appends them after structural rules without reordering them. Register every custom named rule before generated DTO validation runs.
248
+
249
+ ### Register custom rules in Laravel
250
+
251
+ Laravel's validator is available to `BaseData` through the application container. Register custom named rules during application boot:
252
+
253
+ ```php
254
+ namespace App\Providers;
255
+
256
+ use Illuminate\Support\Facades\Validator;
257
+ use Illuminate\Support\ServiceProvider;
258
+
259
+ final class AppServiceProvider extends ServiceProvider
260
+ {
261
+ public function boot(): void
262
+ {
263
+ Validator::extend(
264
+ 'company_email',
265
+ static fn (string $attribute, mixed $value): bool =>
266
+ is_string($value) && str_ends_with($value, '@company.test'),
267
+ );
268
+ }
269
+ }
270
+ ```
271
+
272
+ Register the rule before any generated `makeFromSkirPayload()`, `fromSkir()`, or RPC hydration call that may use it.
273
+
274
+ ### Register custom rules without Laravel
275
+
276
+ Standalone applications can create and install an Illuminate validator factory:
277
+
278
+ ```php
279
+ use Illuminate\Translation\ArrayLoader;
280
+ use Illuminate\Translation\Translator;
281
+ use Illuminate\Validation\Factory as ValidatorFactory;
282
+ use StdOut\SimpleDataObjects\BaseData;
283
+
284
+ $validator = new ValidatorFactory(
285
+ new Translator(new ArrayLoader(), 'en'),
286
+ );
287
+ $validator->extend(
288
+ 'company_email',
289
+ static fn (string $attribute, mixed $value): bool =>
290
+ is_string($value) && str_ends_with($value, '@company.test'),
291
+ );
292
+
293
+ BaseData::setValidatorFactory($validator);
294
+ ```
295
+
296
+ Install the configured factory before the first generated validation call.
297
+
298
+ ### Handwritten validation boundaries
299
+
300
+ Laravel rule objects and closures cannot be represented in `skir.yml`; only strings are portable generator configuration. Keep request-specific authorization, database-aware rules, uploaded-file rules, closure rules, normalization, and other application behavior in a handwritten Laravel Form Request or equivalent application-owned validation boundary.
301
+
302
+ That boundary must live outside `skirout`, because regeneration overwrites generated files. It complements the generated wire-shape and named-rule validation instead of modifying generated DTOs.
303
+
304
+ ## RPC clients, procedures, and manifests
305
+
306
+ When a schema contains methods, the generator emits six module-scoped PHP RPC files plus `skir-server-manifest.json`:
307
+
308
+ - `SkirMethods.php` with runtime `MethodDescriptor` instances.
309
+ - A module method enum such as `AdminSkirMethod.php`.
310
+ - `SkirRpcClient.php` with typed request and response conversion.
311
+ - `SkirProcedures.php` and `AbstractSkirProcedures.php` for implementation contracts.
312
+ - `SkirProcedureProvider.php` for server registration.
313
+ - `skir-server-manifest.json` with exact module and method identities and `Data`-suffixed struct and enum classes.
314
+
315
+ Install the client package when using the generated RPC client:
316
+
317
+ ```bash
318
+ composer require php-skir/client
319
+ ```
320
+
321
+ Install the server package when using generated procedure contracts or providers:
322
+
323
+ ```bash
324
+ composer require php-skir/server
325
+ ```
326
+
327
+ RPC methods retain target types at the application boundary. Direct struct collections are accepted and returned as `TypedDataCollection`; nested collection shapes remain arrays. The generated client converts requests to raw Skir values and hydrates responses. The generated providers validate and hydrate incoming raw values before calling application code, then convert returned DTOs back to Skir values.
328
+
329
+ This generator emits contracts and conversions only. HTTP transport and server routing remain the responsibility of `php-skir/client`, `php-skir/server`, and the application.
330
+
331
+ ## Development and contributing
332
+
333
+ Install dependencies and run the complete package verification sequence:
334
+
335
+ ```bash
336
+ npm ci
337
+ npm run typecheck
338
+ npm run build
339
+ npm run pack:dry-run
340
+ SKIR_RUNTIME_PATH=../runtime npm test
341
+ git diff --check
342
+ ```
343
+
344
+ The integration tests require PHP 8.4, Composer, and a local checkout of `php-skir/runtime`. Pull requests should keep generated output behavior covered by source assertions and executable PHP integration tests.
345
+
346
+ ### Develop against a local generator core
347
+
348
+ Build the core first. Before the initial compatible core release and adapter lockfile commit, one no-save install can install this package's development dependencies and satisfy `@php-skir/generator-core` from the local checkout without creating a lockfile:
349
+
350
+ ```bash
351
+ cd ../generator-core
352
+ npm ci
353
+ npm run build
354
+
355
+ cd ../skir-simple-data-objects-generator
356
+ npm install --no-save --package-lock=false ../generator-core
357
+ ```
358
+
359
+ Once the compatible core package has been published and this repository has committed its normal `package-lock.json`, use `npm ci` instead of the local-path bootstrap.
360
+
361
+ ## Releasing
362
+
363
+ Release the shared packages in dependency order:
364
+
365
+ 1. Merge and publish `@php-skir/generator-core` with the required adapter APIs.
366
+ 2. Replace the local link with the published compatible core version and commit the resulting `package-lock.json` update.
367
+ 3. Run `npm ci`, typecheck, build, package validation, the full test suite, and `git diff --check` again.
368
+ 4. Create the Simple Data Objects generator release only after those checks pass.
369
+
370
+ The release workflow uses Node 24 and npm trusted publishing with provenance. Configure this GitHub repository as an npm trusted publisher before publishing; no release or publish action is performed by the test workflow.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { runConfigureComposerCli } from "@php-skir/generator-core";
3
+ import { GeneratorConfig, GENERATOR_MODULE } from "./config.js";
4
+ async function main() {
5
+ try {
6
+ await runConfigureComposerCli({
7
+ argv: process.argv.slice(2),
8
+ bin: "skir-simple-data-objects-generator",
9
+ module: GENERATOR_MODULE,
10
+ parseConfig: (value) => GeneratorConfig.parse(value),
11
+ namespace: (config) => config.namespace,
12
+ });
13
+ }
14
+ catch (error) {
15
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
16
+ process.exitCode = 1;
17
+ }
18
+ }
19
+ void main();
@@ -0,0 +1,2 @@
1
+ export { ComposerAutoloadConflictError, ensureComposerPsr4Mapping, MalformedComposerPsr4PrefixError, } from "@php-skir/generator-core";
2
+ export type { ComposerAutoloadPaths, ComposerPsr4MappingResult, } from "@php-skir/generator-core";
@@ -0,0 +1 @@
1
+ export { ComposerAutoloadConflictError, ensureComposerPsr4Mapping, MalformedComposerPsr4PrefixError, } from "@php-skir/generator-core";
@@ -0,0 +1,10 @@
1
+ import { DEFAULT_NAMESPACE, PhpNamespace } from "@php-skir/generator-core";
2
+ import { z } from "zod";
3
+ export declare const GENERATOR_MODULE = "skir-simple-data-objects-generator";
4
+ export { DEFAULT_NAMESPACE, PhpNamespace };
5
+ export declare const GeneratorConfig: z.ZodObject<{
6
+ namespace: z.ZodDefault<z.ZodString>;
7
+ validation: z.ZodOptional<z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>>>;
8
+ }, z.core.$strict>;
9
+ export type GeneratorConfig = z.infer<typeof GeneratorConfig>;
10
+ export type GeneratorConfigInput = z.input<typeof GeneratorConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,8 @@
1
+ import { DEFAULT_NAMESPACE, PhpNamespace, ValidationConfig, } from "@php-skir/generator-core";
2
+ import { z } from "zod";
3
+ export const GENERATOR_MODULE = "skir-simple-data-objects-generator";
4
+ export { DEFAULT_NAMESPACE, PhpNamespace };
5
+ export const GeneratorConfig = z.strictObject({
6
+ namespace: PhpNamespace.default(DEFAULT_NAMESPACE),
7
+ validation: ValidationConfig.optional(),
8
+ });
@@ -0,0 +1,6 @@
1
+ import { type ComposerPsr4MappingResult } from "@php-skir/generator-core";
2
+ export interface ConfigureComposerOptions {
3
+ readonly mod?: string;
4
+ readonly root?: string;
5
+ }
6
+ export declare function configureComposer(options?: ConfigureComposerOptions): Promise<ComposerPsr4MappingResult>;
@@ -0,0 +1,10 @@
1
+ import { configureComposer as configureCoreComposer, } from "@php-skir/generator-core";
2
+ import { GeneratorConfig, GENERATOR_MODULE } from "./config.js";
3
+ export function configureComposer(options = {}) {
4
+ return configureCoreComposer({
5
+ module: options.mod ?? GENERATOR_MODULE,
6
+ ...(options.root === undefined ? {} : { root: options.root }),
7
+ parseConfig: (value) => GeneratorConfig.parse(value),
8
+ namespace: (config) => config.namespace,
9
+ });
10
+ }
@@ -0,0 +1,9 @@
1
+ import { PHP_FILE_HEADER, type CoreGeneratorInput, type GeneratedFile } from "@php-skir/generator-core";
2
+ import { type GeneratorConfigInput } from "./config.js";
3
+ export { PHP_FILE_HEADER };
4
+ export type PhpGeneratorConfig = GeneratorConfigInput;
5
+ export interface PhpGeneratorInput extends CoreGeneratorInput {
6
+ readonly config?: PhpGeneratorConfig;
7
+ }
8
+ export declare function generateSimpleDataObjectsFiles(input: PhpGeneratorInput): GeneratedFile[];
9
+ export type { GeneratedFile, SkirField, SkirMethod, SkirModule, SkirRecord, SkirRecordLocation, SkirRecordNamePart, SkirToken, SkirType, } from "@php-skir/generator-core";
@@ -0,0 +1,12 @@
1
+ import { generatePhp, PHP_FILE_HEADER, } from "@php-skir/generator-core";
2
+ import { GeneratorConfig, } from "./config.js";
3
+ import { SimpleDataObjectsTarget } from "./target.js";
4
+ export { PHP_FILE_HEADER };
5
+ export function generateSimpleDataObjectsFiles(input) {
6
+ const { namespace, validation = {} } = GeneratorConfig.parse(input.config ?? {});
7
+ return generatePhp({
8
+ ...input,
9
+ namespace,
10
+ adapter: new SimpleDataObjectsTarget(validation),
11
+ });
12
+ }
@@ -0,0 +1,12 @@
1
+ import { type CodeGenerator } from "skir-internal";
2
+ import { GeneratorConfig } from "./config.js";
3
+ import { generateSimpleDataObjectsFiles } from "./generator.js";
4
+ declare class SimpleDataObjectsGenerator implements CodeGenerator<GeneratorConfig> {
5
+ readonly id = "skir-simple-data-objects-generator";
6
+ readonly configType: CodeGenerator<GeneratorConfig>["configType"];
7
+ generateCode(input: CodeGenerator.Input<GeneratorConfig>): CodeGenerator.Output;
8
+ }
9
+ export declare const GENERATOR: SimpleDataObjectsGenerator;
10
+ export { generateSimpleDataObjectsFiles };
11
+ export { SimpleDataObjectsTarget } from "./target.js";
12
+ export default GENERATOR;
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { GeneratorConfig, GENERATOR_MODULE } from "./config.js";
2
+ import { generateSimpleDataObjectsFiles } from "./generator.js";
3
+ class SimpleDataObjectsGenerator {
4
+ id = GENERATOR_MODULE;
5
+ configType = GeneratorConfig;
6
+ generateCode(input) {
7
+ return {
8
+ files: generateSimpleDataObjectsFiles(input),
9
+ };
10
+ }
11
+ }
12
+ export const GENERATOR = new SimpleDataObjectsGenerator();
13
+ export { generateSimpleDataObjectsFiles };
14
+ export { SimpleDataObjectsTarget } from "./target.js";
15
+ export default GENERATOR;
@@ -0,0 +1,2 @@
1
+ import { type NormalizedType } from "@php-skir/generator-core";
2
+ export declare function structuralRules(type: NormalizedType, optional?: boolean): readonly string[];
@@ -0,0 +1,26 @@
1
+ export function structuralRules(type, optional = type.kind === "optional") {
2
+ const presence = optional ? "nullable" : "required";
3
+ const inner = type.kind === "optional" ? type.inner : type;
4
+ const shape = (() => {
5
+ switch (inner.kind) {
6
+ case "bool":
7
+ return "boolean";
8
+ case "int32":
9
+ case "timestamp":
10
+ return "integer";
11
+ case "float32":
12
+ case "float64":
13
+ return "numeric";
14
+ case "string":
15
+ case "bytes":
16
+ return "string";
17
+ case "array":
18
+ return "array";
19
+ case "record":
20
+ return inner.recordType === "struct" ? "array" : null;
21
+ default:
22
+ return null;
23
+ }
24
+ })();
25
+ return shape === null ? [presence] : [presence, shape];
26
+ }
@@ -0,0 +1,29 @@
1
+ import { type GeneratedFile, type NormalizedMethod, type NormalizedRecord, type NormalizedSchema, type NormalizedType, type PhpTargetAdapter, type RenderContext, type StructRenderRequest, type ValidationConfig } from "@php-skir/generator-core";
2
+ export declare class SimpleDataObjectsTarget implements PhpTargetAdapter {
3
+ private readonly validation;
4
+ readonly id = "skir-simple-data-objects-generator";
5
+ private resolvedValidationRules;
6
+ constructor(validation?: ValidationConfig);
7
+ prepare(schema: NormalizedSchema): void;
8
+ recordClassName(record: NormalizedRecord): string;
9
+ structImports(record: NormalizedRecord): readonly string[];
10
+ enumImports(record: NormalizedRecord): readonly string[];
11
+ rpcImports(methods: readonly NormalizedMethod[]): readonly string[];
12
+ renderStruct({ record, context }: StructRenderRequest): GeneratedFile;
13
+ phpType(type: NormalizedType, context: RenderContext): string;
14
+ toSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
15
+ fromSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
16
+ clientResponseExpression(type: NormalizedType, expression: string, context: RenderContext): string;
17
+ enumPayloadToSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
18
+ enumPayloadFromSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
19
+ manifestObjectClass(type: NormalizedType, context: RenderContext): string | null;
20
+ manifestPhpType(type: NormalizedType): string | null;
21
+ private renderConstructor;
22
+ private renderSkirType;
23
+ private renderMakeFromSkirPayload;
24
+ private renderFromSkir;
25
+ private renderToSkirArray;
26
+ private renderToSkir;
27
+ private renderToSkirJson;
28
+ private toSkirValueExpression;
29
+ }
package/dist/target.js ADDED
@@ -0,0 +1,498 @@
1
+ import { importClass, importClassAs, indent, renderPhpFile, renderUseStatements, resolveValidationRules, toClassName, toPhpNamespaceSegment, toPropertyName, } from "@php-skir/generator-core";
2
+ import { GENERATOR_MODULE } from "./config.js";
3
+ import { structuralRules } from "./structural-rules.js";
4
+ const DENSE_JSON = "Skir\\Runtime\\DenseJson";
5
+ const FIELD = "Skir\\Runtime\\Field";
6
+ const TYPE = "Skir\\Runtime\\Type";
7
+ const DATA_COLLECTION = "StdOut\\SimpleDataObjects\\Attributes\\DataCollection";
8
+ const MAP_PROPERTY_NAME = "StdOut\\SimpleDataObjects\\Attributes\\MapPropertyName";
9
+ const RULES = "StdOut\\SimpleDataObjects\\Attributes\\Rules";
10
+ const BASE_DATA = "StdOut\\SimpleDataObjects\\BaseData";
11
+ const TYPED_DATA_COLLECTION = "StdOut\\SimpleDataObjects\\TypedDataCollection";
12
+ const BASE_STRUCT_IMPORTS = [DENSE_JSON, FIELD, TYPE, BASE_DATA];
13
+ export class SimpleDataObjectsTarget {
14
+ validation;
15
+ id = GENERATOR_MODULE;
16
+ resolvedValidationRules = new Map();
17
+ constructor(validation = {}) {
18
+ this.validation = validation;
19
+ }
20
+ prepare(schema) {
21
+ this.resolvedValidationRules = new Map();
22
+ this.resolvedValidationRules = resolveValidationRules(schema, this.validation);
23
+ }
24
+ recordClassName(record) {
25
+ if (record.phpClassName !== undefined) {
26
+ return record.phpClassName;
27
+ }
28
+ const className = toClassName(record.qualifiedName);
29
+ return className.endsWith("Data") ? className : `${className}Data`;
30
+ }
31
+ structImports(record) {
32
+ const fields = record.fields.filter(isStructField);
33
+ const imports = [...BASE_STRUCT_IMPORTS];
34
+ if (fields.some((field) => toPropertyName(field.name) !== field.name)) {
35
+ imports.push(MAP_PROPERTY_NAME);
36
+ }
37
+ if (fields.length > 0) {
38
+ imports.push(RULES);
39
+ }
40
+ if (fields.some((field) => dataCollectionType(field.type) !== null)) {
41
+ imports.push(DATA_COLLECTION, TYPED_DATA_COLLECTION);
42
+ }
43
+ return imports;
44
+ }
45
+ enumImports(record) {
46
+ if (record.recordType !== "enum") {
47
+ return [];
48
+ }
49
+ const needsTypedDataCollection = record.fields.some((field) => (field.kind === "field"
50
+ && field.hasPayload
51
+ && requiresTypedDataCollection(field.type)));
52
+ return needsTypedDataCollection ? [TYPED_DATA_COLLECTION] : [];
53
+ }
54
+ rpcImports(methods) {
55
+ const needsTypedDataCollection = methods.some((method) => (requiresTypedDataCollection(method.requestType)
56
+ || requiresTypedDataCollection(method.responseType)));
57
+ return needsTypedDataCollection ? [TYPED_DATA_COLLECTION] : [];
58
+ }
59
+ renderStruct({ record, context }) {
60
+ if (record.recordType !== "struct") {
61
+ throw new Error(`Cannot render non-struct record ${record.identity} as a struct.`);
62
+ }
63
+ const className = classNameForRecord(record, context);
64
+ const imports = this.structImports(record);
65
+ const denseJson = importClass(context.imports, DENSE_JSON);
66
+ const fieldClass = importClass(context.imports, FIELD);
67
+ const typeClass = importClass(context.imports, TYPE);
68
+ const baseData = importClass(context.imports, BASE_DATA);
69
+ const fields = record.fields.filter(isStructField);
70
+ const constructor = this.renderConstructor(record, fields, context);
71
+ const members = [
72
+ constructor,
73
+ this.renderSkirType(record, context, fieldClass, typeClass),
74
+ this.renderMakeFromSkirPayload(fields, context),
75
+ this.renderFromSkir(className, denseJson),
76
+ this.renderToSkirArray(fields, context),
77
+ this.renderToSkir(denseJson),
78
+ this.renderToSkirJson(denseJson),
79
+ ].filter((member) => member !== null);
80
+ const body = [
81
+ `final class ${className} extends ${baseData}`,
82
+ "{",
83
+ ...members.flatMap((member, index) => index === 0
84
+ ? [indent(member)]
85
+ : ["", indent(member)]),
86
+ "}",
87
+ ].join("\n");
88
+ return {
89
+ path: outputPath(context, `${className}.php`),
90
+ code: renderPhpFile({
91
+ namespace: context.namespace,
92
+ imports: renderUseStatements(context.imports, imports),
93
+ body,
94
+ }),
95
+ };
96
+ }
97
+ phpType(type, context) {
98
+ if (type.kind === "bool") {
99
+ return "bool";
100
+ }
101
+ if (type.kind === "int32" || type.kind === "timestamp") {
102
+ return "int";
103
+ }
104
+ if (type.kind === "int64" || type.kind === "hash64") {
105
+ return "int|string";
106
+ }
107
+ if (type.kind === "float32" || type.kind === "float64") {
108
+ return "float";
109
+ }
110
+ if (type.kind === "string" || type.kind === "bytes") {
111
+ return "string";
112
+ }
113
+ if (type.kind === "array") {
114
+ return isDirectStructCollection(type)
115
+ ? importClass(context.imports, TYPED_DATA_COLLECTION)
116
+ : "array";
117
+ }
118
+ if (type.kind === "optional") {
119
+ return nullablePhpType(this.phpType(type.inner, context));
120
+ }
121
+ if (type.kind === "record") {
122
+ return recordTypeClassName(type, context);
123
+ }
124
+ return "mixed";
125
+ }
126
+ toSkirExpression(type, expression, context) {
127
+ return this.toSkirValueExpression(type, expression, context, true);
128
+ }
129
+ fromSkirExpression(type, expression, context) {
130
+ return valueFromTargetExpression(type, expression, context);
131
+ }
132
+ clientResponseExpression(type, expression, context) {
133
+ return valueFromTargetExpression(type, expression, context);
134
+ }
135
+ enumPayloadToSkirExpression(type, expression, context) {
136
+ return this.toSkirValueExpression(type, expression, context, true);
137
+ }
138
+ enumPayloadFromSkirExpression(type, expression, context) {
139
+ return valueFromTargetExpression(type, expression, context);
140
+ }
141
+ manifestObjectClass(type, context) {
142
+ if (type.kind !== "record") {
143
+ return null;
144
+ }
145
+ const className = context.names.namesByIdentity.get(type.recordIdentity);
146
+ if (className === undefined) {
147
+ throw new Error(`No PHP class name was resolved for record ${type.recordIdentity}.`);
148
+ }
149
+ return canonicalRecordClassName(recordNamespace(type.recordIdentity, context.rootNamespace), className);
150
+ }
151
+ manifestPhpType(type) {
152
+ return isDirectStructCollection(type) ? TYPED_DATA_COLLECTION : null;
153
+ }
154
+ renderConstructor(record, fields, context) {
155
+ if (fields.length === 0) {
156
+ return null;
157
+ }
158
+ const validationRules = this.resolvedValidationRules.get(record.identity) ?? new Map();
159
+ return [
160
+ "public function __construct(",
161
+ ...fields.flatMap((field) => [
162
+ ...propertyAttributes(field, context, validationRules.get(field.name) ?? []).map((attribute) => ` ${attribute}`),
163
+ ` public readonly ${this.phpType(field.type, context)} $${toPropertyName(field.name)},`,
164
+ ]),
165
+ ") {}",
166
+ ].join("\n");
167
+ }
168
+ renderSkirType(record, context, fieldClass, typeClass) {
169
+ const entries = record.fields.map((field) => {
170
+ if (field.kind === "removed") {
171
+ return ` ${fieldClass}::removed(${field.number}),`;
172
+ }
173
+ if (!field.hasPayload) {
174
+ throw new Error(`Struct field ${field.name} in ${record.identity} has no payload type.`);
175
+ }
176
+ return ` ${fieldClass}::value(${phpSingleQuotedLiteral(field.name)}, ${field.number}, ${runtimeTypeExpression(field.type, context, this, typeClass)}),`;
177
+ });
178
+ if (entries.length === 0) {
179
+ return [
180
+ `public static function skirType(): ${typeClass}`,
181
+ "{",
182
+ ` return ${typeClass}::struct([]);`,
183
+ "}",
184
+ ].join("\n");
185
+ }
186
+ return [
187
+ `public static function skirType(): ${typeClass}`,
188
+ "{",
189
+ ` return ${typeClass}::struct([`,
190
+ entries.join("\n"),
191
+ " ]);",
192
+ "}",
193
+ ].join("\n");
194
+ }
195
+ renderMakeFromSkirPayload(fields, context) {
196
+ const payload = fields.length === 0
197
+ ? [" $payload = [];"]
198
+ : [
199
+ " $payload = [",
200
+ ...fields.map((field) => renderPayloadEntry(field, context)),
201
+ " ];",
202
+ ];
203
+ return [
204
+ "/** @param array<string, mixed> $data */",
205
+ "public static function makeFromSkirPayload(array $data): self",
206
+ "{",
207
+ " self::validate($data);",
208
+ "",
209
+ ...payload,
210
+ "",
211
+ " return self::from($payload);",
212
+ "}",
213
+ ].join("\n");
214
+ }
215
+ renderFromSkir(className, denseJson) {
216
+ return [
217
+ `public static function fromSkir(string $json): ${className}`,
218
+ "{",
219
+ ` $data = ${denseJson}::fromJson(self::skirType(), $json);`,
220
+ "",
221
+ " return self::makeFromSkirPayload($data);",
222
+ "}",
223
+ ].join("\n");
224
+ }
225
+ renderToSkirArray(fields, context) {
226
+ if (fields.length === 0) {
227
+ return [
228
+ "/** @return array<string, mixed> */",
229
+ "public function toSkirArray(): array",
230
+ "{",
231
+ " return [];",
232
+ "}",
233
+ ].join("\n");
234
+ }
235
+ return [
236
+ "/** @return array<string, mixed> */",
237
+ "public function toSkirArray(): array",
238
+ "{",
239
+ " return [",
240
+ ...fields.map((field) => {
241
+ const expression = this.toSkirExpression(field.type, `$this->${toPropertyName(field.name)}`, context);
242
+ return renderArrayEntry(field.name, expression, 8);
243
+ }),
244
+ " ];",
245
+ "}",
246
+ ].join("\n");
247
+ }
248
+ renderToSkir(denseJson) {
249
+ return [
250
+ "/** @return array<int, mixed> */",
251
+ "public function toSkir(): array",
252
+ "{",
253
+ ` return ${denseJson}::encode(self::skirType(), $this->toSkirArray());`,
254
+ "}",
255
+ ].join("\n");
256
+ }
257
+ renderToSkirJson(denseJson) {
258
+ return [
259
+ "public function toSkirJson(): string",
260
+ "{",
261
+ ` return ${denseJson}::toJson(self::skirType(), $this->toSkirArray());`,
262
+ "}",
263
+ ].join("\n");
264
+ }
265
+ toSkirValueExpression(type, expression, context, allowTypedCollection) {
266
+ if (type.kind === "record") {
267
+ return type.recordType === "enum"
268
+ ? `${expression}->toSkirValue()`
269
+ : `${expression}->toSkirArray()`;
270
+ }
271
+ if (type.kind === "optional") {
272
+ if (isRecursivelyMappedType(type.inner)) {
273
+ return `${expression} === null ? null : ${this.toSkirValueExpression(type.inner, expression, context, allowTypedCollection)}`;
274
+ }
275
+ return expression;
276
+ }
277
+ if (type.kind === "array" && isRecursivelyMappedType(type.item)) {
278
+ const source = allowTypedCollection && isDirectStructCollection(type)
279
+ ? `${expression}->all()`
280
+ : expression;
281
+ const itemExpression = this.toSkirValueExpression(type.item, "$item", context, false);
282
+ return renderArrayMap(itemExpression, source, "mixed", "mixed");
283
+ }
284
+ return expression;
285
+ }
286
+ }
287
+ function propertyAttributes(field, context, customRules) {
288
+ const attributes = [];
289
+ const propertyName = toPropertyName(field.name);
290
+ if (propertyName !== field.name) {
291
+ const mapPropertyName = importClass(context.imports, MAP_PROPERTY_NAME);
292
+ attributes.push(`#[${mapPropertyName}(${phpSingleQuotedLiteral(field.name)})]`);
293
+ }
294
+ const rules = importClass(context.imports, RULES);
295
+ const combinedRules = [...structuralRules(field.type), ...customRules];
296
+ attributes.push(`#[${rules}([${combinedRules.map(phpSingleQuotedLiteral).join(", ")}])]`);
297
+ const collectionType = dataCollectionType(field.type);
298
+ if (collectionType !== null) {
299
+ const dataCollection = importClass(context.imports, DATA_COLLECTION);
300
+ attributes.push(`#[${dataCollection}(${recordTypeClassName(collectionType, context)}::class)]`);
301
+ }
302
+ return attributes;
303
+ }
304
+ function renderPayloadEntry(field, context) {
305
+ const fieldLiteral = phpSingleQuotedLiteral(field.name);
306
+ const directExpression = `$data[${fieldLiteral}]`;
307
+ const expression = valueFromSkirPayloadExpression(field.type, field.type.kind === "optional"
308
+ ? `${directExpression} ?? null`
309
+ : directExpression, context);
310
+ return renderArrayEntry(field.name, expression, 8);
311
+ }
312
+ function renderArrayEntry(fieldName, expression, indentation) {
313
+ const padding = " ".repeat(indentation);
314
+ const continuedExpression = expression.replaceAll("\n", `\n${padding}`);
315
+ return `${padding}${phpSingleQuotedLiteral(fieldName)} => ${continuedExpression},`;
316
+ }
317
+ function valueFromSkirPayloadExpression(type, expression, context) {
318
+ if (type.kind === "record") {
319
+ const className = recordTypeClassName(type, context);
320
+ return type.recordType === "enum"
321
+ ? `${className}::fromSkirValue(${expression})`
322
+ : `${className}::makeFromSkirPayload(${expression})`;
323
+ }
324
+ if (type.kind === "optional") {
325
+ if (isRecursivelyMappedType(type.inner)) {
326
+ return `(${expression}) === null ? null : ${valueFromSkirPayloadExpression(type.inner, expression, context)}`;
327
+ }
328
+ return expression;
329
+ }
330
+ if (type.kind === "array" && isRecursivelyMappedType(type.item)) {
331
+ const itemExpression = valueFromSkirPayloadExpression(type.item, "$item", context);
332
+ const [inputType, returnType] = hydrationArrowTypes(type.item, context);
333
+ return renderArrayMap(itemExpression, expression, inputType, returnType);
334
+ }
335
+ return expression;
336
+ }
337
+ function valueFromTargetExpression(type, expression, context) {
338
+ if (isDirectStructCollection(type)) {
339
+ const typedDataCollection = importClass(context.imports, TYPED_DATA_COLLECTION);
340
+ const collectionClass = recordTypeClassName(type.item, context);
341
+ const hydratedItems = valueFromSkirPayloadExpression(type, expression, context);
342
+ return `${typedDataCollection}::of(${collectionClass}::class, ${hydratedItems})`;
343
+ }
344
+ if (type.kind === "optional") {
345
+ return `${expression} === null ? null : ${valueFromTargetExpression(type.inner, expression, context)}`;
346
+ }
347
+ return valueFromSkirPayloadExpression(type, expression, context);
348
+ }
349
+ function hydrationArrowTypes(type, context) {
350
+ if (type.kind === "record" && type.recordType === "struct") {
351
+ return ["array", recordTypeClassName(type, context)];
352
+ }
353
+ if (type.kind === "array") {
354
+ return ["array", "array"];
355
+ }
356
+ return ["mixed", "mixed"];
357
+ }
358
+ function renderArrayMap(itemExpression, sourceExpression, inputType, returnType) {
359
+ const continuedItemExpression = itemExpression.replaceAll("\n", "\n ");
360
+ const continuedSourceExpression = sourceExpression.replaceAll("\n", "\n ");
361
+ return [
362
+ "array_map(",
363
+ ` static fn (${inputType} $item): ${returnType} => ${continuedItemExpression},`,
364
+ ` ${continuedSourceExpression},`,
365
+ ")",
366
+ ].join("\n");
367
+ }
368
+ function dataCollectionType(type) {
369
+ const unwrappedType = unwrapOptionalType(type);
370
+ if (!isDirectStructCollection(unwrappedType)) {
371
+ return null;
372
+ }
373
+ return unwrappedType.item;
374
+ }
375
+ function isDirectStructCollection(type) {
376
+ return type.kind === "array"
377
+ && type.item.kind === "record"
378
+ && type.item.recordType === "struct";
379
+ }
380
+ function requiresTypedDataCollection(type) {
381
+ if (type.kind === "optional") {
382
+ return requiresTypedDataCollection(type.inner);
383
+ }
384
+ return isDirectStructCollection(type);
385
+ }
386
+ function phpSingleQuotedLiteral(value) {
387
+ const expressions = [];
388
+ let segment = "";
389
+ for (const character of value) {
390
+ if (character !== "\n" && character !== "\r") {
391
+ segment += character;
392
+ continue;
393
+ }
394
+ if (segment !== "") {
395
+ expressions.push(escapedPhpStringSegment(segment));
396
+ segment = "";
397
+ }
398
+ expressions.push(character === "\n" ? '"\\n"' : '"\\r"');
399
+ }
400
+ if (segment !== "" || expressions.length === 0) {
401
+ expressions.push(escapedPhpStringSegment(segment));
402
+ }
403
+ return expressions.join(".");
404
+ }
405
+ function escapedPhpStringSegment(value) {
406
+ return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
407
+ }
408
+ function nullablePhpType(type) {
409
+ const unionMembers = type.split("|");
410
+ if (type === "mixed" || type.startsWith("?") || unionMembers.includes("null")) {
411
+ return type;
412
+ }
413
+ return unionMembers.length === 1 ? `?${type}` : `${type}|null`;
414
+ }
415
+ function unwrapOptionalType(type) {
416
+ let unwrappedType = type;
417
+ while (unwrappedType.kind === "optional") {
418
+ unwrappedType = unwrappedType.inner;
419
+ }
420
+ return unwrappedType;
421
+ }
422
+ function isRecursivelyMappedType(type) {
423
+ return type.kind === "record" || type.kind === "optional" || type.kind === "array";
424
+ }
425
+ function isStructField(field) {
426
+ return field.kind === "field" && field.hasPayload;
427
+ }
428
+ function runtimeTypeExpression(type, context, adapter, typeClass) {
429
+ if (type.kind === "array") {
430
+ return `${typeClass}::array(${runtimeTypeExpression(type.item, context, adapter, typeClass)})`;
431
+ }
432
+ if (type.kind === "optional") {
433
+ return `${typeClass}::optional(${runtimeTypeExpression(type.inner, context, adapter, typeClass)})`;
434
+ }
435
+ if (type.kind === "record") {
436
+ return `${adapter.phpType(type, context)}::skirType()`;
437
+ }
438
+ return `${typeClass}::${type.kind}()`;
439
+ }
440
+ function classNameForRecord(record, context) {
441
+ const className = context.names.namesByIdentity.get(record.identity);
442
+ if (className === undefined) {
443
+ throw new Error(`No PHP class name was resolved for struct ${record.identity}.`);
444
+ }
445
+ return className;
446
+ }
447
+ function recordTypeClassName(type, context) {
448
+ const className = context.names.namesByIdentity.get(type.recordIdentity);
449
+ if (className === undefined) {
450
+ throw new Error(`No PHP class name was resolved for record ${type.recordIdentity}.`);
451
+ }
452
+ const namespace = recordNamespace(type.recordIdentity, context.rootNamespace);
453
+ if (namespace === context.namespace) {
454
+ return className;
455
+ }
456
+ const fullyQualifiedClassName = canonicalRecordClassName(namespace, className);
457
+ const isReserved = [...context.imports.reservedNames].some((reservedName) => (reservedName.toLowerCase() === className.toLowerCase()));
458
+ if (isReserved) {
459
+ return `\\${fullyQualifiedClassName}`;
460
+ }
461
+ const existingImport = [...context.imports.imports.entries()]
462
+ .find(([localName]) => localName.toLowerCase() === className.toLowerCase());
463
+ if (existingImport === undefined) {
464
+ return importClassAs(context.imports, fullyQualifiedClassName, className);
465
+ }
466
+ return existingImport[1].toLowerCase() === fullyQualifiedClassName.toLowerCase()
467
+ ? existingImport[0]
468
+ : `\\${fullyQualifiedClassName}`;
469
+ }
470
+ function canonicalRecordClassName(namespace, className) {
471
+ const fullyQualifiedClassName = `${namespace}\\${className}`.replace(/^\\+/u, "");
472
+ const parts = fullyQualifiedClassName.split("\\");
473
+ if (parts.some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(part))) {
474
+ throw new Error(`Invalid normalized PHP record class name ${fullyQualifiedClassName}.`);
475
+ }
476
+ if (parts.at(-1) !== className) {
477
+ throw new Error(`Invalid normalized PHP record basename ${className}.`);
478
+ }
479
+ return fullyQualifiedClassName;
480
+ }
481
+ function recordNamespace(recordIdentity, rootNamespace) {
482
+ const separatorIndex = recordIdentity.lastIndexOf("::");
483
+ if (separatorIndex === -1) {
484
+ throw new Error(`Invalid normalized record identity ${recordIdentity}.`);
485
+ }
486
+ const modulePath = recordIdentity.slice(0, separatorIndex);
487
+ const namespaceSegments = modulePath
488
+ .split("/")
489
+ .slice(0, -1)
490
+ .map((segment) => toPhpNamespaceSegment(segment))
491
+ .filter((segment) => segment !== "");
492
+ return [rootNamespace, ...namespaceSegments]
493
+ .filter((segment) => segment !== "")
494
+ .join("\\");
495
+ }
496
+ function outputPath(context, fileName) {
497
+ return context.pathPrefix === "" ? fileName : `${context.pathPrefix}/${fileName}`;
498
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "skir-simple-data-objects-generator",
3
+ "version": "0.1.0",
4
+ "description": "Skir code generator for std-out/simple-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-simple-data-objects-generator#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/php-skir/skir-simple-data-objects-generator.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/php-skir/skir-simple-data-objects-generator/issues"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "php",
24
+ "rpc",
25
+ "simple-data-objects",
26
+ "skir",
27
+ "skir-generator"
28
+ ],
29
+ "exports": {
30
+ ".": "./dist/index.js"
31
+ },
32
+ "types": "./dist/index.d.ts",
33
+ "bin": {
34
+ "skir-simple-data-objects-generator": "./dist/cli.js"
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "prepack": "npm run build",
42
+ "pretest:coverage": "npm run build",
43
+ "pretest": "npm run build",
44
+ "pack:dry-run": "npm pack --dry-run --cache .npm-cache",
45
+ "test": "vitest run",
46
+ "test:coverage": "vitest run --coverage --coverage.reporter=json-summary --coverage.include=src/**/*.ts",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit"
48
+ },
49
+ "dependencies": {
50
+ "@php-skir/generator-core": "^0.1.0",
51
+ "skir-internal": "^0.2.21",
52
+ "zod": "^4.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22.0.0",
56
+ "@vitest/coverage-v8": "^4.1.10",
57
+ "skir": "^1.2.19",
58
+ "typescript": "^5.9.0",
59
+ "vitest": "^4.0.0"
60
+ }
61
+ }