@lewishowles/cli-style 0.12.1 → 0.14.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.
@@ -0,0 +1,413 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { rendererCatalogue } from "../src/catalogue/renderer-catalogue.js";
6
+
7
+ // Generated adapter modules are kept beside their hand-written language cores.
8
+ const pythonOutputPath = join(
9
+ dirname(fileURLToPath(import.meta.url)),
10
+ "../adapters/python/cli_style/_wrappers.py",
11
+ );
12
+
13
+ const swiftOutputPath = join(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ "../adapters/swift/Sources/CliStyle/Wrappers.swift",
16
+ );
17
+
18
+ // Map catalogue types to the closest useful Python annotations.
19
+ const pythonTypes = {
20
+ boolean: "bool",
21
+ number: "int | float",
22
+ object: "dict[str, Any]",
23
+ "object[]": "list[dict[str, Any]]",
24
+ "number[]": "list[int | float]",
25
+ string: "str",
26
+ "string[]": "list[str]",
27
+ };
28
+
29
+ // Map catalogue types to the closest useful Swift annotations.
30
+ const swiftTypes = {
31
+ boolean: "Bool",
32
+ number: "Int",
33
+ "object[]": "[[String: Any]]",
34
+ "number[]": "[Double]",
35
+ string: "String",
36
+ "string[]": "[String]",
37
+ };
38
+
39
+ // Keep language-specific syntax in one descriptor so the wrapper helpers stay shared.
40
+ const languageDescriptors = {
41
+ python: {
42
+ booleanLiterals: {
43
+ false: "False",
44
+ true: "True",
45
+ },
46
+ defaultArrayPayload: (name) => `[] if ${name} is None else ${name}`,
47
+ nullableType: (type) => `${type} | None`,
48
+ nullLiteral: "None",
49
+ parameterName: (parameter) =>
50
+ parameter.singular ?? parameter.languageNames?.python ?? toSnakeCase(parameter.name),
51
+ singularPayload: (name) => `[${name}] if ${name} else []`,
52
+ types: pythonTypes,
53
+ },
54
+ swift: {
55
+ booleanLiterals: {
56
+ false: "false",
57
+ true: "true",
58
+ },
59
+ defaultArrayPayload: (name) => `${name} ?? []`,
60
+ nullableType: (type) => `${type}?`,
61
+ nullLiteral: "nil",
62
+ parameterName: (parameter) =>
63
+ parameter.singular ?? parameter.languageNames?.swift ?? parameter.name,
64
+ singularPayload: (name) => `${name}.isEmpty ? [] : [${name}]`,
65
+ types: swiftTypes,
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Convert a JavaScript API name to the snake-case name used by Python.
71
+ *
72
+ * @param {string} value
73
+ * JavaScript identifier or renderer name.
74
+ * @returns {string}
75
+ * Python identifier.
76
+ */
77
+ function toSnakeCase(value) {
78
+ return value
79
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
80
+ .replaceAll("-", "_")
81
+ .toLowerCase();
82
+ }
83
+
84
+ /**
85
+ * Return whether an adapter caller must pass the parameter, either because the
86
+ * renderer needs it or because the existing Python signature always required it.
87
+ *
88
+ * @param {object} parameter
89
+ * Catalogue parameter metadata.
90
+ * @returns {boolean}
91
+ * Whether the parameter is required by the adapter contract.
92
+ */
93
+ function isRequiredParameter(parameter) {
94
+ return parameter.adapterRequired || parameter.required;
95
+ }
96
+
97
+ /**
98
+ * Return the annotation for one catalogue parameter in the target language.
99
+ *
100
+ * @param {object} parameter
101
+ * Catalogue parameter metadata.
102
+ * @param {object} descriptor
103
+ * Target language syntax and type mappings.
104
+ * @returns {string}
105
+ * Language-specific type annotation.
106
+ */
107
+ function getType(parameter, descriptor) {
108
+ const type = descriptor.types[parameter.type];
109
+
110
+ if (type === undefined) {
111
+ throw new Error(`Unsupported catalogue type: ${parameter.type}`);
112
+ }
113
+
114
+ if (parameter.singular) {
115
+ const singularType = descriptor.types[parameter.type.replace("[]", "")];
116
+
117
+ if (singularType === undefined) {
118
+ throw new Error(
119
+ `Unsupported scalar type for singular parameter "${parameter.name}": ${parameter.type}`,
120
+ );
121
+ }
122
+
123
+ return singularType;
124
+ }
125
+
126
+ if (
127
+ !isRequiredParameter(parameter) &&
128
+ (parameter.default === null || Array.isArray(parameter.default))
129
+ ) {
130
+ return descriptor.nullableType(type);
131
+ }
132
+
133
+ return type;
134
+ }
135
+
136
+ /**
137
+ * Return the default for one optional parameter in the target language.
138
+ *
139
+ * @param {object} parameter
140
+ * Catalogue parameter metadata.
141
+ * @param {object} descriptor
142
+ * Target language syntax and type mappings.
143
+ * @returns {string}
144
+ * Language-specific default expression.
145
+ */
146
+ function getDefault(parameter, descriptor) {
147
+ if (parameter.singular) {
148
+ return '""';
149
+ }
150
+
151
+ if (parameter.default === null || Array.isArray(parameter.default)) {
152
+ return descriptor.nullLiteral;
153
+ }
154
+
155
+ if (typeof parameter.default === "boolean") {
156
+ return descriptor.booleanLiterals[parameter.default];
157
+ }
158
+
159
+ return JSON.stringify(parameter.default);
160
+ }
161
+
162
+ /**
163
+ * Return the parameter declaration in the target language.
164
+ *
165
+ * @param {object} parameter
166
+ * Catalogue parameter metadata.
167
+ * @param {object} descriptor
168
+ * Target language syntax and type mappings.
169
+ * @returns {string}
170
+ * Language-specific parameter declaration.
171
+ */
172
+ function renderParameter(parameter, descriptor) {
173
+ const name = descriptor.parameterName(parameter);
174
+ const declaration = `${name}: ${getType(parameter, descriptor)}`;
175
+
176
+ if (isRequiredParameter(parameter)) {
177
+ return declaration;
178
+ }
179
+
180
+ return `${declaration} = ${getDefault(parameter, descriptor)}`;
181
+ }
182
+
183
+ /**
184
+ * Return the expression that maps a parameter into renderer data.
185
+ *
186
+ * @param {object} parameter
187
+ * Catalogue parameter metadata.
188
+ * @param {object} descriptor
189
+ * Target language syntax and type mappings.
190
+ * @returns {string}
191
+ * Language-specific expression for the renderer payload.
192
+ */
193
+ function renderPayloadValue(parameter, descriptor) {
194
+ const name = descriptor.parameterName(parameter);
195
+
196
+ if (isRequiredParameter(parameter)) {
197
+ return name;
198
+ }
199
+
200
+ if (parameter.singular) {
201
+ return descriptor.singularPayload(name);
202
+ }
203
+
204
+ if (Array.isArray(parameter.default)) {
205
+ return descriptor.defaultArrayPayload(name);
206
+ }
207
+
208
+ return name;
209
+ }
210
+
211
+ /**
212
+ * Render one generated Python wrapper from catalogue metadata.
213
+ *
214
+ * @param {object} renderer
215
+ * Renderer catalogue entry.
216
+ * @returns {string}
217
+ * Python wrapper source.
218
+ */
219
+ function renderWrapper(renderer) {
220
+ const descriptor = languageDescriptors.python;
221
+ const parameters = renderer.params.filter((parameter) => !parameter.adapterOmit);
222
+
223
+ const parameterLines = parameters.map(
224
+ (parameter) => `\t${renderParameter(parameter, descriptor)},`,
225
+ );
226
+
227
+ const payloadParameters = parameters.filter(
228
+ (parameter) => isRequiredParameter(parameter) || parameter.default !== null,
229
+ );
230
+
231
+ const nullableParameters = parameters.filter(
232
+ (parameter) => !isRequiredParameter(parameter) && parameter.default === null,
233
+ );
234
+
235
+ const payloadLines = payloadParameters.map(
236
+ (parameter) =>
237
+ `\t\t${JSON.stringify(parameter.name)}: ${renderPayloadValue(parameter, descriptor)},`,
238
+ );
239
+
240
+ const nullablePayloadLines = nullableParameters.flatMap((parameter) => {
241
+ const name = descriptor.parameterName(parameter);
242
+
243
+ return [`\tif ${name} is not None:`, `\t\tdata[${JSON.stringify(parameter.name)}] = ${name}`];
244
+ });
245
+
246
+ return [
247
+ `def ${toSnakeCase(renderer.api)}(`,
248
+ ...parameterLines,
249
+ "\t**kwargs: Any,",
250
+ ") -> str:",
251
+ `\t"""Return ${renderer.name} output; extra keyword arguments go to render()."""`,
252
+ "\tdata = {",
253
+ ...payloadLines,
254
+ "\t}",
255
+ ...(nullablePayloadLines.length > 0 ? ["", ...nullablePayloadLines] : []),
256
+ "",
257
+ `\treturn render(${JSON.stringify(renderer.name)}, data, **kwargs)`,
258
+ ].join("\n");
259
+ }
260
+
261
+ /**
262
+ * Render the complete generated Python wrapper module.
263
+ *
264
+ * @returns {string}
265
+ * Generated Python module source.
266
+ */
267
+ function renderWrappers() {
268
+ const wrappers = rendererCatalogue.map(renderWrapper).join("\n\n\n");
269
+
270
+ return [
271
+ "# Generated by scripts/generate-adapters.js. Do not edit directly.",
272
+ "",
273
+ "from __future__ import annotations",
274
+ "",
275
+ "from typing import Any",
276
+ "",
277
+ "from .core import render",
278
+ "",
279
+ wrappers,
280
+ "",
281
+ ].join("\n");
282
+ }
283
+
284
+ /**
285
+ * Render one generated Swift wrapper from catalogue metadata.
286
+ *
287
+ * @param {object} renderer
288
+ * Renderer catalogue entry.
289
+ * @returns {string}
290
+ * Swift wrapper source.
291
+ */
292
+ function renderSwiftWrapper(renderer) {
293
+ const descriptor = languageDescriptors.swift;
294
+ const parameters = renderer.params.filter((parameter) => !parameter.adapterOmit);
295
+
296
+ const parameterLines = parameters.map(
297
+ (parameter) => `\t\t${renderParameter(parameter, descriptor)},`,
298
+ );
299
+
300
+ const payloadParameters = parameters.filter(
301
+ (parameter) => isRequiredParameter(parameter) || parameter.default !== null,
302
+ );
303
+
304
+ const nullableParameters = parameters.filter(
305
+ (parameter) => !isRequiredParameter(parameter) && parameter.default === null,
306
+ );
307
+
308
+ const payloadLines = payloadParameters.map(
309
+ (parameter) =>
310
+ `\t\t\t${JSON.stringify(parameter.name)}: ${renderPayloadValue(parameter, descriptor)},`,
311
+ );
312
+
313
+ const nullablePayloadLines = nullableParameters.flatMap((parameter) => {
314
+ const name = descriptor.parameterName(parameter);
315
+
316
+ return [
317
+ `\t\tif let ${name} = ${name} {`,
318
+ `\t\t\tdata[${JSON.stringify(parameter.name)}] = ${name}`,
319
+ "\t\t}",
320
+ ];
321
+ });
322
+
323
+ const dataDeclaration = nullablePayloadLines.length > 0 ? "var" : "let";
324
+
325
+ return [
326
+ "\t/**",
327
+ `\t * Return ${renderer.name} output from the cli-style binary; \`options\` sets the profile, width, and binary.`,
328
+ "\t */",
329
+ `\tpublic static func ${renderer.api}(`,
330
+ ...parameterLines,
331
+ "\t\toptions: CliStyleOptions = .init()",
332
+ "\t) throws -> String {",
333
+ `\t\t${dataDeclaration} data: [String: Any] = [`,
334
+ ...payloadLines,
335
+ "\t\t]",
336
+ ...(nullablePayloadLines.length > 0 ? ["", ...nullablePayloadLines] : []),
337
+ "",
338
+ `\t\treturn try render(${JSON.stringify(renderer.name)}, data: data, options: options)`,
339
+ "\t}",
340
+ ].join("\n");
341
+ }
342
+
343
+ /**
344
+ * Render the complete generated Swift wrapper module.
345
+ *
346
+ * @returns {string}
347
+ * Generated Swift module source.
348
+ */
349
+ function renderSwiftWrappers() {
350
+ const wrappers = rendererCatalogue.map(renderSwiftWrapper).join("\n\n");
351
+
352
+ return [
353
+ "// Generated by scripts/generate-adapters.js. Do not edit directly.",
354
+ "",
355
+ "import Foundation",
356
+ "",
357
+ "extension CliStyle {",
358
+ "",
359
+ wrappers,
360
+ "",
361
+ "}",
362
+ "",
363
+ ].join("\n");
364
+ }
365
+
366
+ // Build the whole module before writing so a catalogue error leaves the old file intact.
367
+ const generatedPythonSource = renderWrappers();
368
+ const generatedSwiftSource = renderSwiftWrappers();
369
+
370
+ // Each generated wrapper paired with the committed file it is written to, or checked against.
371
+ const generatedFiles = [
372
+ {
373
+ path: pythonOutputPath,
374
+ source: generatedPythonSource,
375
+ },
376
+ {
377
+ path: swiftOutputPath,
378
+ source: generatedSwiftSource,
379
+ },
380
+ ];
381
+
382
+ // `--check` reports committed wrappers that differ from the generated source and writes nothing.
383
+ const checkOnly = process.argv.includes("--check");
384
+
385
+ if (checkOnly) {
386
+ // A committed wrapper is stale when its contents differ from the generated source; a missing file counts as stale.
387
+ const staleFiles = generatedFiles.filter(({ path, source }) => {
388
+ try {
389
+ return readFileSync(path, "utf8") !== source;
390
+ } catch (error) {
391
+ if (error.code === "ENOENT") {
392
+ return true;
393
+ }
394
+
395
+ throw error;
396
+ }
397
+ });
398
+
399
+ if (staleFiles.length > 0) {
400
+ console.error("Generated adapters are stale:");
401
+
402
+ for (const { path } of staleFiles) {
403
+ console.error(`- ${path}`);
404
+ }
405
+
406
+ process.exitCode = 1;
407
+ }
408
+ } else {
409
+ for (const { path, source } of generatedFiles) {
410
+ mkdirSync(dirname(path), { recursive: true });
411
+ writeFileSync(path, source);
412
+ }
413
+ }
@@ -4,7 +4,10 @@
4
4
  # Archive layout:
5
5
  # bin/cli-style
6
6
  # adapters/bash/cli-style.sh
7
- # adapters/python/cli_style.py
7
+ # adapters/python/cli_style/
8
+ # adapters/python/pyproject.toml
9
+ # adapters/swift/Package.swift
10
+ # adapters/swift/Sources/
8
11
 
9
12
  set -euo pipefail
10
13