@contractkit/plugin-csharp 0.0.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/.turbo/turbo-build$colon$ci.log +13 -0
- package/.turbo/turbo-build.log +12 -0
- package/.turbo/turbo-format.log +34 -0
- package/.turbo/turbo-test.log +17 -0
- package/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/codegen-client.d.ts +35 -0
- package/dist/codegen-client.d.ts.map +1 -0
- package/dist/codegen-models.d.ts +75 -0
- package/dist/codegen-models.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +13 -0
- package/dist/codegen-sdk.d.ts.map +1 -0
- package/dist/hoist.d.ts +53 -0
- package/dist/hoist.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2569 -0
- package/dist/index.js.map +1 -0
- package/dist/naming.d.ts +89 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/runtime-converters.d.ts +15 -0
- package/dist/runtime-converters.d.ts.map +1 -0
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/scaffold.d.ts +26 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +48 -0
- package/src/codegen-client.ts +680 -0
- package/src/codegen-models.ts +909 -0
- package/src/codegen-sdk.ts +52 -0
- package/src/hoist.ts +402 -0
- package/src/index.ts +373 -0
- package/src/naming.ts +262 -0
- package/src/runtime-converters.ts +147 -0
- package/src/runtime.ts +381 -0
- package/src/scaffold.ts +41 -0
- package/tests/codegen-client.test.ts +275 -0
- package/tests/codegen-models.test.ts +410 -0
- package/tests/helpers.ts +202 -0
- package/tests/hoist.test.ts +92 -0
- package/tests/index.test.ts +124 -0
- package/tests/naming.test.ts +133 -0
- package/tests/runtime.test.ts +104 -0
- package/tests/scaffold.test.ts +28 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import type {
|
|
4
|
+
ContractKitPlugin,
|
|
5
|
+
ContractRootNode,
|
|
6
|
+
ContractTypeNode,
|
|
7
|
+
IncrementalManifest,
|
|
8
|
+
IncrementalOutputFile,
|
|
9
|
+
IncrementalUnit,
|
|
10
|
+
ModelNode,
|
|
11
|
+
OpRootNode,
|
|
12
|
+
ParamSource,
|
|
13
|
+
PluginContext,
|
|
14
|
+
} from '@contractkit/core';
|
|
15
|
+
import {
|
|
16
|
+
buildModelIndex,
|
|
17
|
+
collectTransitiveModelRefs,
|
|
18
|
+
collectTypeRefs,
|
|
19
|
+
emptyIncrementalManifest,
|
|
20
|
+
hashFingerprint,
|
|
21
|
+
parseIncrementalManifest,
|
|
22
|
+
runIncrementalCodegen,
|
|
23
|
+
serializeIncrementalManifest,
|
|
24
|
+
} from '@contractkit/core';
|
|
25
|
+
import { generateCSharpModels, resolveModelsWithInput } from './codegen-models.js';
|
|
26
|
+
import { deriveClientClassName, deriveClientPropertyName, generateCSharpClient, hasPublicOperations } from './codegen-client.js';
|
|
27
|
+
import { generateSdkCs, type SdkAggregatorClient } from './codegen-sdk.js';
|
|
28
|
+
import { collectHoistedTypes } from './hoist.js';
|
|
29
|
+
import { generateRuntimeCs } from './runtime.js';
|
|
30
|
+
import { generateConvertersCs } from './runtime-converters.js';
|
|
31
|
+
import { generateCsproj } from './scaffold.js';
|
|
32
|
+
import { CSHARP_KEYWORDS, deriveCSharpFileBase } from './naming.js';
|
|
33
|
+
|
|
34
|
+
export interface CSharpSdkPluginConfig {
|
|
35
|
+
/** Output directory relative to rootDir (default: "csharp-sdk") */
|
|
36
|
+
baseDir?: string;
|
|
37
|
+
/** Root namespace for the generated sources, e.g. "Acme.Sdk" (default: "ContractKit.Sdk") */
|
|
38
|
+
namespace?: string;
|
|
39
|
+
/** Aggregator class name (default: "Sdk"). Also the assembly name when scaffolding. */
|
|
40
|
+
sdkName?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Whether to emit client methods for operations marked `internal`. Defaults to `false` —
|
|
43
|
+
* internal ops are omitted so consumers don't pick them up.
|
|
44
|
+
*/
|
|
45
|
+
includeInternal?: boolean;
|
|
46
|
+
/** Emit `<SdkName>.csproj` once, as a user-owned file. Never overwritten. */
|
|
47
|
+
scaffold?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bumped when the C# codegen output shape changes in a way that should invalidate every per-file
|
|
52
|
+
* fingerprint, so a plugin upgrade forces full regeneration even when no `.ck` file has changed.
|
|
53
|
+
*/
|
|
54
|
+
export const CSHARP_CODEGEN_VERSION = '1';
|
|
55
|
+
|
|
56
|
+
const CACHE_MANIFEST_FILENAME = 'csharp-manifest.json';
|
|
57
|
+
const DEFAULT_BASE_DIR = 'csharp-sdk';
|
|
58
|
+
const DEFAULT_NAMESPACE = 'ContractKit.Sdk';
|
|
59
|
+
const DEFAULT_SDK_NAME = 'Sdk';
|
|
60
|
+
|
|
61
|
+
const plugin: ContractKitPlugin = {
|
|
62
|
+
name: 'csharp-sdk',
|
|
63
|
+
async generateTargets(inputs, ctx) {
|
|
64
|
+
const config = ctx.options as CSharpSdkPluginConfig;
|
|
65
|
+
await runCSharpCodegen(inputs, ctx, config, ctx.rootDir);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default plugin;
|
|
70
|
+
|
|
71
|
+
export function createCSharpSdkPlugin(config: CSharpSdkPluginConfig, rootDir: string): ContractKitPlugin {
|
|
72
|
+
return {
|
|
73
|
+
name: 'csharp-sdk',
|
|
74
|
+
async generateTargets(inputs, ctx) {
|
|
75
|
+
await runCSharpCodegen(inputs, ctx, config, rootDir);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const NAMESPACE_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
81
|
+
const SDK_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reject config that would generate C# which cannot compile. These are runtime checks, not just
|
|
85
|
+
* types: config arrives as JSON, so the TypeScript interface constrains programmatic callers only.
|
|
86
|
+
*/
|
|
87
|
+
export function assertValidConfig(config: CSharpSdkPluginConfig): void {
|
|
88
|
+
const { namespace, sdkName } = config;
|
|
89
|
+
if (namespace !== undefined) {
|
|
90
|
+
if (typeof namespace !== 'string' || !NAMESPACE_RE.test(namespace)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`plugin-csharp: namespace '${String(namespace)}' is not a valid C# namespace — expected dot-separated identifiers, e.g. 'Acme.Sdk'.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const keyword = namespace.split('.').find(segment => CSHARP_KEYWORDS.has(segment));
|
|
96
|
+
if (keyword) {
|
|
97
|
+
throw new Error(`plugin-csharp: namespace '${namespace}' contains the C# keyword '${keyword}', which cannot appear in a namespace.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (sdkName !== undefined) {
|
|
101
|
+
if (typeof sdkName !== 'string' || !SDK_NAME_RE.test(sdkName)) {
|
|
102
|
+
throw new Error(`plugin-csharp: sdkName '${String(sdkName)}' is not a valid C# class name.`);
|
|
103
|
+
}
|
|
104
|
+
if (CSHARP_KEYWORDS.has(sdkName)) {
|
|
105
|
+
throw new Error(`plugin-csharp: sdkName '${sdkName}' is a C# keyword.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const key of ['includeInternal', 'scaffold'] as const) {
|
|
109
|
+
const value = config[key];
|
|
110
|
+
if (value !== undefined && typeof value !== 'boolean') {
|
|
111
|
+
throw new Error(`plugin-csharp: ${key} must be a boolean — got ${JSON.stringify(value)}.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Shared orchestration. Builds per-file fingerprints, reuses unchanged outputs from the manifest,
|
|
118
|
+
* regenerates only the affected files, and rewrites the shared runtime and aggregator every run
|
|
119
|
+
* (they are cheap and depend only on the set of public clients).
|
|
120
|
+
*
|
|
121
|
+
* Honors `ctx.cacheEnabled`, so `--force` bypasses the per-file cache.
|
|
122
|
+
*/
|
|
123
|
+
async function runCSharpCodegen(
|
|
124
|
+
inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
|
|
125
|
+
ctx: PluginContext,
|
|
126
|
+
config: CSharpSdkPluginConfig,
|
|
127
|
+
rootDir: string,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
assertValidConfig(config);
|
|
130
|
+
|
|
131
|
+
const { contractRoots } = inputs;
|
|
132
|
+
const namespaceName = config.namespace ?? DEFAULT_NAMESPACE;
|
|
133
|
+
const sdkName = config.sdkName ?? DEFAULT_SDK_NAME;
|
|
134
|
+
const outDir = resolve(rootDir, config.baseDir ?? DEFAULT_BASE_DIR);
|
|
135
|
+
const manifestPath = resolve(ctx.cacheDir, CACHE_MANIFEST_FILENAME);
|
|
136
|
+
|
|
137
|
+
// Every model shares one C# namespace, so a cross-file reference resolves by name and the only
|
|
138
|
+
// cross-file input a models unit has is which names carry an Input variant.
|
|
139
|
+
const allModels: ModelNode[] = contractRoots.flatMap(root => root.models);
|
|
140
|
+
const modelIndex = buildModelIndex(allModels);
|
|
141
|
+
// Resolved once over every model, so the hoisting pass and each file's renderer agree on which
|
|
142
|
+
// names carry an `Input` variant.
|
|
143
|
+
const modelsWithInput = resolveModelsWithInput(allModels, inputs.modelsWithInput);
|
|
144
|
+
const modelsWithInputArray = [...modelsWithInput].sort();
|
|
145
|
+
|
|
146
|
+
// Names for the anonymous shapes — unions, inline objects, field-level enums, tuples — that C#
|
|
147
|
+
// needs a declaration for. Computed across every file at once: a discriminated union declared in
|
|
148
|
+
// one file makes member records generated in other files implement its interface.
|
|
149
|
+
const hoisted = collectHoistedTypes(contractRoots, {
|
|
150
|
+
modelIndex,
|
|
151
|
+
modelsWithInput,
|
|
152
|
+
warn: (message, file) => ctx.warn?.(message, file),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const prevManifest: IncrementalManifest = ctx.cacheEnabled ? readManifest(manifestPath) : emptyIncrementalManifest(CSHARP_CODEGEN_VERSION);
|
|
156
|
+
const units: IncrementalUnit[] = [];
|
|
157
|
+
const clients: SdkAggregatorClient[] = [];
|
|
158
|
+
|
|
159
|
+
for (const root of contractRoots) {
|
|
160
|
+
const relPath = `Models/${deriveCSharpFileBase(root.file)}.cs`;
|
|
161
|
+
const ownNames = new Set(root.models.map(m => m.name));
|
|
162
|
+
const referenced = referencedModelNames(root);
|
|
163
|
+
const relevantInputModels = modelsWithInputArray.filter(name => ownNames.has(name) || referenced.has(name));
|
|
164
|
+
// A base declared in another file contributes its fields to a record generated here, so the
|
|
165
|
+
// fingerprint has to move when that base does.
|
|
166
|
+
const externalBases = [...referenced]
|
|
167
|
+
.filter(name => !ownNames.has(name))
|
|
168
|
+
.sort()
|
|
169
|
+
.map(name => modelIndex.get(name))
|
|
170
|
+
.filter((m): m is ModelNode => m !== undefined);
|
|
171
|
+
|
|
172
|
+
// Declarations this file owns, and the interfaces its records implement, are both decided by
|
|
173
|
+
// the whole project, so they belong in the fingerprint alongside the file.
|
|
174
|
+
const ownedDeclarations = (hoisted.byFile.get(root.file) ?? []).map(d => ({ kind: d.kind, name: d.name, needsInput: d.needsInput }));
|
|
175
|
+
const declaredMemberships = [...ownNames]
|
|
176
|
+
.sort()
|
|
177
|
+
.map(name => [name, hoisted.memberships.get(name) ?? []] as const)
|
|
178
|
+
.filter(([, unions]) => unions.length > 0);
|
|
179
|
+
|
|
180
|
+
const fingerprint = hashFingerprint({
|
|
181
|
+
kind: 'models',
|
|
182
|
+
v: CSHARP_CODEGEN_VERSION,
|
|
183
|
+
relPath,
|
|
184
|
+
namespace: namespaceName,
|
|
185
|
+
root,
|
|
186
|
+
externalBases,
|
|
187
|
+
modelsWithInput: relevantInputModels,
|
|
188
|
+
ownedDeclarations,
|
|
189
|
+
declaredMemberships,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
units.push({
|
|
193
|
+
key: `models::${relPath}`,
|
|
194
|
+
fingerprint,
|
|
195
|
+
render: () => [
|
|
196
|
+
{
|
|
197
|
+
relativePath: relPath,
|
|
198
|
+
content: generateCSharpModels(root, {
|
|
199
|
+
namespace: namespaceName,
|
|
200
|
+
modelsWithInput,
|
|
201
|
+
modelIndex,
|
|
202
|
+
hoisted,
|
|
203
|
+
warn: message => ctx.warn?.(message, root.file),
|
|
204
|
+
}),
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Per-op-root client files ─────────────────────────────────────────────
|
|
211
|
+
for (const root of inputs.opRoots) {
|
|
212
|
+
if (!hasPublicOperations(root, config.includeInternal)) continue;
|
|
213
|
+
const relPath = `Clients/${deriveClientClassName(root.file)}.cs`;
|
|
214
|
+
clients.push({ className: deriveClientClassName(root.file), propertyName: deriveClientPropertyName(root.file) });
|
|
215
|
+
|
|
216
|
+
const referenced = referencedOpModels(root, modelIndex);
|
|
217
|
+
const relevantInputModels = modelsWithInputArray.filter(name => referenced.has(name));
|
|
218
|
+
// A client names the models it takes and returns, so the shapes behind those names — and
|
|
219
|
+
// the declarations hoisted out of them — are part of what this file depends on.
|
|
220
|
+
const referencedModels = [...referenced]
|
|
221
|
+
.sort()
|
|
222
|
+
.map(name => modelIndex.get(name))
|
|
223
|
+
.filter((m): m is ModelNode => m !== undefined);
|
|
224
|
+
|
|
225
|
+
const fingerprint = hashFingerprint({
|
|
226
|
+
kind: 'client',
|
|
227
|
+
v: CSHARP_CODEGEN_VERSION,
|
|
228
|
+
relPath,
|
|
229
|
+
namespace: namespaceName,
|
|
230
|
+
root,
|
|
231
|
+
referencedModels,
|
|
232
|
+
modelsWithInput: relevantInputModels,
|
|
233
|
+
includeInternal: config.includeInternal ?? false,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
units.push({
|
|
237
|
+
key: `client::${relPath}`,
|
|
238
|
+
fingerprint,
|
|
239
|
+
render: () => [
|
|
240
|
+
{
|
|
241
|
+
relativePath: relPath,
|
|
242
|
+
content: generateCSharpClient(root, {
|
|
243
|
+
namespace: namespaceName,
|
|
244
|
+
modelsWithInput,
|
|
245
|
+
modelIndex,
|
|
246
|
+
hoisted,
|
|
247
|
+
includeInternal: config.includeInternal,
|
|
248
|
+
warn: message => ctx.warn?.(message, root.file),
|
|
249
|
+
}),
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The runtime is a constant, and the aggregator depends only on the list of public clients.
|
|
256
|
+
// Both are small enough that rewriting them every run beats a cache entry.
|
|
257
|
+
const globalFiles: IncrementalOutputFile[] = [
|
|
258
|
+
{ relativePath: 'Runtime/Converters.cs', content: generateConvertersCs(namespaceName) },
|
|
259
|
+
{ relativePath: 'Runtime/SdkRuntime.cs', content: generateRuntimeCs(namespaceName) },
|
|
260
|
+
{ relativePath: `${sdkName}.cs`, content: generateSdkCs(namespaceName, sdkName, clients) },
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
// `ifAbsent` marks this user-owned: written once, never overwritten, and never removed as an
|
|
264
|
+
// orphan when the generated tree changes around it.
|
|
265
|
+
if (config.scaffold) {
|
|
266
|
+
globalFiles.push({ relativePath: `${sdkName}.csproj`, content: generateCsproj(namespaceName, sdkName), ifAbsent: true });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const result = runIncrementalCodegen({
|
|
270
|
+
codegenVersion: CSHARP_CODEGEN_VERSION,
|
|
271
|
+
prevManifest,
|
|
272
|
+
globalFiles,
|
|
273
|
+
units,
|
|
274
|
+
fileExists: relPath => existsSync(resolve(outDir, relPath)),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
deleteStalePaths(outDir, result.deletedPaths);
|
|
278
|
+
|
|
279
|
+
for (const { relativePath, content, ifAbsent } of result.filesToWrite) {
|
|
280
|
+
ctx.emitFile(resolve(outDir, relativePath), content, ifAbsent ? { ifAbsent: true } : undefined);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
writeManifest(manifestPath, result.manifest);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Every model name an operations file names, transitively, so the client's inputs are covered. */
|
|
287
|
+
function referencedOpModels(root: OpRootNode, modelIndex: Map<string, ModelNode>): Set<string> {
|
|
288
|
+
const seeds: ContractTypeNode[] = [];
|
|
289
|
+
const addParamSource = (source: ParamSource | undefined): void => {
|
|
290
|
+
if (!source) return;
|
|
291
|
+
if (source.kind === 'params') seeds.push(...source.nodes.map(n => n.type));
|
|
292
|
+
else if (source.kind === 'ref') seeds.push({ kind: 'ref', name: source.name });
|
|
293
|
+
else seeds.push(source.node);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
for (const route of root.routes) {
|
|
297
|
+
addParamSource(route.params);
|
|
298
|
+
for (const op of route.operations) {
|
|
299
|
+
addParamSource(op.query);
|
|
300
|
+
addParamSource(op.headers);
|
|
301
|
+
for (const body of op.request?.bodies ?? []) seeds.push(body.bodyType);
|
|
302
|
+
for (const response of op.responses) {
|
|
303
|
+
for (const body of response.bodies) seeds.push(body.bodyType);
|
|
304
|
+
for (const header of response.headers ?? []) seeds.push(header.type);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return collectTransitiveModelRefs(seeds, modelIndex);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Every model name a contract root references but may not define, including its bases. */
|
|
313
|
+
function referencedModelNames(root: ContractRootNode): Set<string> {
|
|
314
|
+
const refs = new Set<string>();
|
|
315
|
+
for (const model of root.models) {
|
|
316
|
+
if (model.type) collectTypeRefs(model.type, refs);
|
|
317
|
+
for (const f of model.fields) collectTypeRefs(f.type, refs);
|
|
318
|
+
if (model.bases) for (const base of model.bases) refs.add(base);
|
|
319
|
+
}
|
|
320
|
+
return refs;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Read the previous run's manifest. Returns an empty manifest when missing or unreadable. */
|
|
324
|
+
function readManifest(manifestPath: string): IncrementalManifest {
|
|
325
|
+
if (!existsSync(manifestPath)) return emptyIncrementalManifest(CSHARP_CODEGEN_VERSION);
|
|
326
|
+
try {
|
|
327
|
+
return parseIncrementalManifest(readFileSync(manifestPath, 'utf-8'));
|
|
328
|
+
} catch {
|
|
329
|
+
return emptyIncrementalManifest(CSHARP_CODEGEN_VERSION);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Write the manifest. Errors are swallowed so a broken cache never blocks the build. */
|
|
334
|
+
function writeManifest(manifestPath: string, manifest: IncrementalManifest): void {
|
|
335
|
+
try {
|
|
336
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
337
|
+
writeFileSync(manifestPath, serializeIncrementalManifest(manifest), 'utf-8');
|
|
338
|
+
} catch {
|
|
339
|
+
// best-effort
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Delete paths from the prior manifest that aren't produced this run, then prune the directories
|
|
345
|
+
* they leave empty. The output tree is nested (`Models/`, `Clients/`, `Runtime/`), so a renamed
|
|
346
|
+
* `.ck` file would otherwise leave an empty directory behind.
|
|
347
|
+
*/
|
|
348
|
+
function deleteStalePaths(outDir: string, relPaths: string[]): void {
|
|
349
|
+
if (relPaths.length === 0) return;
|
|
350
|
+
const removedDirs = new Set<string>();
|
|
351
|
+
for (const rel of relPaths) {
|
|
352
|
+
const abs = resolve(outDir, rel);
|
|
353
|
+
if (existsSync(abs)) {
|
|
354
|
+
rmSync(abs, { force: true });
|
|
355
|
+
removedDirs.add(join(abs, '..'));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const dir of removedDirs) {
|
|
359
|
+
let current = dir;
|
|
360
|
+
while (current.startsWith(outDir) && current !== outDir) {
|
|
361
|
+
try {
|
|
362
|
+
if (readdirSync(current).length === 0) {
|
|
363
|
+
rmdirSync(current);
|
|
364
|
+
current = join(current, '..');
|
|
365
|
+
} else {
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identifier and file-name conversions for C# output.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the codegen modules because both the model and the client generators need the
|
|
5
|
+
* same conversions, and a mismatch between them would produce a client that references a property
|
|
6
|
+
* name the model never declared.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* C#'s reserved keywords — illegal as bare identifiers anywhere, so a name that collides with one
|
|
11
|
+
* has to be escaped with `@`. Contextual keywords (`record`, `required`, `init`, `value`, `var`,
|
|
12
|
+
* `async`, `await`, `yield`, `nameof`, `when`) are legal identifiers and are deliberately absent:
|
|
13
|
+
* escaping them would only make the generated code noisier.
|
|
14
|
+
*/
|
|
15
|
+
export const CSHARP_KEYWORDS: ReadonlySet<string> = new Set([
|
|
16
|
+
'abstract',
|
|
17
|
+
'as',
|
|
18
|
+
'base',
|
|
19
|
+
'bool',
|
|
20
|
+
'break',
|
|
21
|
+
'byte',
|
|
22
|
+
'case',
|
|
23
|
+
'catch',
|
|
24
|
+
'char',
|
|
25
|
+
'checked',
|
|
26
|
+
'class',
|
|
27
|
+
'const',
|
|
28
|
+
'continue',
|
|
29
|
+
'decimal',
|
|
30
|
+
'default',
|
|
31
|
+
'delegate',
|
|
32
|
+
'do',
|
|
33
|
+
'double',
|
|
34
|
+
'else',
|
|
35
|
+
'enum',
|
|
36
|
+
'event',
|
|
37
|
+
'explicit',
|
|
38
|
+
'extern',
|
|
39
|
+
'false',
|
|
40
|
+
'finally',
|
|
41
|
+
'fixed',
|
|
42
|
+
'float',
|
|
43
|
+
'for',
|
|
44
|
+
'foreach',
|
|
45
|
+
'goto',
|
|
46
|
+
'if',
|
|
47
|
+
'implicit',
|
|
48
|
+
'in',
|
|
49
|
+
'int',
|
|
50
|
+
'interface',
|
|
51
|
+
'internal',
|
|
52
|
+
'is',
|
|
53
|
+
'lock',
|
|
54
|
+
'long',
|
|
55
|
+
'namespace',
|
|
56
|
+
'new',
|
|
57
|
+
'null',
|
|
58
|
+
'object',
|
|
59
|
+
'operator',
|
|
60
|
+
'out',
|
|
61
|
+
'override',
|
|
62
|
+
'params',
|
|
63
|
+
'private',
|
|
64
|
+
'protected',
|
|
65
|
+
'public',
|
|
66
|
+
'readonly',
|
|
67
|
+
'ref',
|
|
68
|
+
'return',
|
|
69
|
+
'sbyte',
|
|
70
|
+
'sealed',
|
|
71
|
+
'short',
|
|
72
|
+
'sizeof',
|
|
73
|
+
'stackalloc',
|
|
74
|
+
'static',
|
|
75
|
+
'string',
|
|
76
|
+
'struct',
|
|
77
|
+
'switch',
|
|
78
|
+
'this',
|
|
79
|
+
'throw',
|
|
80
|
+
'true',
|
|
81
|
+
'try',
|
|
82
|
+
'typeof',
|
|
83
|
+
'uint',
|
|
84
|
+
'ulong',
|
|
85
|
+
'unchecked',
|
|
86
|
+
'unsafe',
|
|
87
|
+
'ushort',
|
|
88
|
+
'using',
|
|
89
|
+
'virtual',
|
|
90
|
+
'void',
|
|
91
|
+
'volatile',
|
|
92
|
+
'while',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Members the C# compiler already declares on a record, plus the ones a record's synthesized
|
|
97
|
+
* members would collide with. A contract field landing on any of these has to be renamed.
|
|
98
|
+
*/
|
|
99
|
+
const RESERVED_MEMBER_NAMES: ReadonlySet<string> = new Set(['Equals', 'GetHashCode', 'GetType', 'ToString', 'EqualityContract', 'PrintMembers']);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Prefix `name` with `@` when it is a C# keyword, so it can still be used as a parameter or local.
|
|
103
|
+
* The `@` is a lexical escape only: the identifier is still spelled `name` everywhere it matters,
|
|
104
|
+
* including in `nameof` and in reflection, so nothing downstream has to know about it.
|
|
105
|
+
*/
|
|
106
|
+
export function escapeCSharpIdentifier(name: string): string {
|
|
107
|
+
return CSHARP_KEYWORDS.has(name) ? `@${name}` : name;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert a contract field name to a C# property name in PascalCase.
|
|
112
|
+
*
|
|
113
|
+
* Separators (`-`, `_`, `.`, spaces) introduce a word boundary and are dropped, so `x-request-id`
|
|
114
|
+
* becomes `XRequestId`. A leading digit gets an underscore prefix, since C# identifiers cannot
|
|
115
|
+
* start with one. No keyword escaping is needed: every C# keyword is lowercase and this always
|
|
116
|
+
* produces an initial capital.
|
|
117
|
+
*
|
|
118
|
+
* The original name is preserved on the wire through `[JsonPropertyName]`, so this conversion is
|
|
119
|
+
* free to be lossy as long as it is deterministic.
|
|
120
|
+
*/
|
|
121
|
+
export function toCSharpPropertyName(name: string): string {
|
|
122
|
+
const words = splitWords(name);
|
|
123
|
+
if (words.length === 0) return '_';
|
|
124
|
+
let result = words.map(capitalize).join('');
|
|
125
|
+
if (/^\d/.test(result)) result = `_${result}`;
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Convert a contract parameter or path placeholder to a C# parameter name in camelCase:
|
|
131
|
+
* `invoice-id` becomes `invoiceId`. Keyword-escaped, because camelCase lands on keywords
|
|
132
|
+
* regularly — a path parameter named `event` or `params` is ordinary in a contract.
|
|
133
|
+
*/
|
|
134
|
+
export function toCSharpParameterName(name: string): string {
|
|
135
|
+
const words = splitWords(name);
|
|
136
|
+
if (words.length === 0) return '_';
|
|
137
|
+
const head = words[0]!.toLowerCase();
|
|
138
|
+
const rest = words.slice(1).map(capitalize);
|
|
139
|
+
let result = head + rest.join('');
|
|
140
|
+
if (/^\d/.test(result)) result = `_${result}`;
|
|
141
|
+
return escapeCSharpIdentifier(result);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Make a property name safe inside `ownerTypeName`.
|
|
146
|
+
*
|
|
147
|
+
* C# rejects a member whose name matches its enclosing type (CS0542), which a contract hits
|
|
148
|
+
* whenever a model has a field of its own name — `contract Invoice { invoice: ... }`. A record also
|
|
149
|
+
* synthesizes members that a contract field can collide with. Both are resolved by appending
|
|
150
|
+
* `Value`; the wire name is unaffected, since `[JsonPropertyName]` is always emitted.
|
|
151
|
+
*/
|
|
152
|
+
export function safeMemberName(propertyName: string, ownerTypeName: string): string {
|
|
153
|
+
if (propertyName === ownerTypeName || RESERVED_MEMBER_NAMES.has(propertyName)) return `${propertyName}Value`;
|
|
154
|
+
return propertyName;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Convert a name to a C# type name in PascalCase. Never escaped: type names are generated (from
|
|
159
|
+
* model names, method names, or status codes) rather than taken verbatim, so a collision with a
|
|
160
|
+
* keyword is a naming bug worth surfacing rather than papering over.
|
|
161
|
+
*/
|
|
162
|
+
export function toCSharpTypeName(name: string): string {
|
|
163
|
+
const words = splitWords(name);
|
|
164
|
+
if (words.length === 0) return '_';
|
|
165
|
+
let result = words.map(capitalize).join('');
|
|
166
|
+
if (/^\d/.test(result)) result = `_${result}`;
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Make an already-composed name safe to use as a C# type name, without re-casing it.
|
|
172
|
+
*
|
|
173
|
+
* Distinct from {@link toCSharpTypeName}, which splits a source name into words and rebuilds it:
|
|
174
|
+
* running that over a name already assembled from PascalCase parts would fold `MV` back to `Mv`.
|
|
175
|
+
*/
|
|
176
|
+
export function sanitizeCSharpTypeName(name: string): string {
|
|
177
|
+
let result = name.replace(/[^a-zA-Z0-9]/g, '');
|
|
178
|
+
if (result.length === 0) return '_';
|
|
179
|
+
result = result.charAt(0).toUpperCase() + result.slice(1);
|
|
180
|
+
if (/^\d/.test(result)) result = `_${result}`;
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Convert an enum member value to a C# enum member name in PascalCase: `in-progress` becomes
|
|
186
|
+
* `InProgress`. The value itself always travels via `[JsonStringEnumMemberName]`, so this only has
|
|
187
|
+
* to be a stable identifier.
|
|
188
|
+
*/
|
|
189
|
+
export function toCSharpEnumMemberName(value: string): string {
|
|
190
|
+
const words = splitWords(value);
|
|
191
|
+
if (words.length === 0) return '_';
|
|
192
|
+
let result = words.map(capitalize).join('');
|
|
193
|
+
if (/^\d/.test(result)) result = `_${result}`;
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Derive the PascalCase base used for a generated file's names from a `.ck` file path:
|
|
199
|
+
* `"ledger.categories.ck"` becomes `"LedgerCategories"`. Both the models file and the client class
|
|
200
|
+
* for one source file are named from this, so they stay visibly paired in the output tree.
|
|
201
|
+
*/
|
|
202
|
+
export function deriveCSharpFileBase(file: string): string {
|
|
203
|
+
const base =
|
|
204
|
+
file
|
|
205
|
+
.split('/')
|
|
206
|
+
.pop()
|
|
207
|
+
?.replace(/\.(op\.)?ck$/, '') ?? 'models';
|
|
208
|
+
return toCSharpTypeName(base);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Render `text` as an XML doc comment indented by `indent`, wrapped in `tag`. Returns `[]` for
|
|
213
|
+
* empty text so callers can splat unconditionally.
|
|
214
|
+
*
|
|
215
|
+
* `///` is a line comment, so unlike Kotlin's KDoc there is no delimiter to break out of. What does
|
|
216
|
+
* have to be handled is XML: an unescaped `&` or `<` in a description makes the doc file malformed,
|
|
217
|
+
* which the compiler reports as a warning and `-warnaserror` turns into a build failure.
|
|
218
|
+
*/
|
|
219
|
+
export function xmlDocLines(text: string, indent: string, tag = 'summary'): string[] {
|
|
220
|
+
if (text.length === 0) return [];
|
|
221
|
+
const safe = escapeXml(text);
|
|
222
|
+
const sourceLines = safe.split('\n');
|
|
223
|
+
if (sourceLines.length === 1) return [`${indent}/// <${tag}>${sourceLines[0]}</${tag}>`];
|
|
224
|
+
return [`${indent}/// <${tag}>`, ...sourceLines.map(line => `${indent}/// ${line}`.trimEnd()), `${indent}/// </${tag}>`];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Escape the three characters that would otherwise make a doc comment malformed XML. */
|
|
228
|
+
export function escapeXml(text: string): string {
|
|
229
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Render `value` as a C# string literal. No `$` handling: interpolated strings are the only place
|
|
234
|
+
* `$` is special, and no generated literal built from contract text is interpolated.
|
|
235
|
+
*/
|
|
236
|
+
export function quoteCSharpString(value: string): string {
|
|
237
|
+
const escaped = value
|
|
238
|
+
.replace(/\\/g, '\\\\')
|
|
239
|
+
.replace(/"/g, '\\"')
|
|
240
|
+
.replace(/\n/g, '\\n')
|
|
241
|
+
.replace(/\r/g, '\\r')
|
|
242
|
+
.replace(/\t/g, '\\t')
|
|
243
|
+
.replace(/\0/g, '\\0');
|
|
244
|
+
return `"${escaped}"`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Split an identifier into words on separators and camelCase boundaries.
|
|
249
|
+
* `"x-request-id"` becomes `["x", "request", "id"]`; `"createdAt"` becomes `["created", "At"]`;
|
|
250
|
+
* `"myHTTPClient"` becomes `["my", "HTTP", "Client"]`.
|
|
251
|
+
*/
|
|
252
|
+
function splitWords(name: string): string[] {
|
|
253
|
+
return name
|
|
254
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
255
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
256
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function capitalize(word: string): string {
|
|
261
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
262
|
+
}
|