osury 0.16.0 → 0.17.1

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/README.md CHANGED
@@ -156,6 +156,25 @@ export type user = {
156
156
 
157
157
  The library uses **sury-ppx** for code-first approach — `@schema` annotation automatically generates runtime validators from type definitions.
158
158
 
159
+ ## Demo Playground
160
+
161
+ This repository includes a local demo website for codegen exploration.
162
+
163
+ ### Run demo
164
+
165
+ ```bash
166
+ npm run demo
167
+ ```
168
+
169
+ Open [http://localhost:4173/demo/](http://localhost:4173/demo/).
170
+
171
+ ### What it supports
172
+
173
+ - Upload OpenAPI JSON as a file
174
+ - Paste OpenAPI JSON into a text area
175
+ - Formatted ReScript output
176
+ - Formatted TypeScript output (derived from osury AST and matching generated ReScript structures)
177
+
159
178
  ### Helper Files
160
179
 
161
180
  Also generates helper files:
package/bin/osury.mjs CHANGED
@@ -4,37 +4,98 @@ import * as OpenAPIParser from "../src/OpenAPIParser.res.mjs";
4
4
  import * as Codegen from "../src/Codegen.res.mjs";
5
5
  import fs from "fs";
6
6
  import path from "path";
7
+ import { createRequire } from "module";
8
+ import { performance } from "perf_hooks";
7
9
 
8
- const args = process.argv.slice(2);
10
+ // ─── Package info ────────────────────────────────────────────────────────────
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const pkg = require("../package.json");
14
+ const VERSION = pkg.version;
15
+
16
+ // ─── Color support ───────────────────────────────────────────────────────────
17
+
18
+ const noColor =
19
+ process.env.NO_COLOR != null ||
20
+ process.argv.includes("--no-color") ||
21
+ !process.stderr.isTTY;
22
+
23
+ const fmt = (code, text) =>
24
+ noColor ? text : `\x1b[${code}m${text}\x1b[0m`;
25
+
26
+ const c = {
27
+ bold: (t) => fmt("1", t),
28
+ dim: (t) => fmt("2", t),
29
+ italic: (t) => fmt("3", t),
30
+ red: (t) => fmt("31", t),
31
+ green: (t) => fmt("32", t),
32
+ yellow: (t) => fmt("33", t),
33
+ blue: (t) => fmt("34", t),
34
+ magenta: (t) => fmt("35", t),
35
+ cyan: (t) => fmt("36", t),
36
+ white: (t) => fmt("37", t),
37
+ gray: (t) => fmt("90", t),
38
+ boldRed: (t) => fmt("1;31", t),
39
+ boldGreen: (t) => fmt("1;32", t),
40
+ boldYellow: (t) => fmt("1;33", t),
41
+ boldCyan: (t) => fmt("1;36", t),
42
+ };
43
+
44
+ // ─── Symbols ─────────────────────────────────────────────────────────────────
45
+
46
+ const sym = {
47
+ success: c.green("✓"),
48
+ error: c.red("✗"),
49
+ warning: c.yellow("⚠"),
50
+ arrow: c.dim("→"),
51
+ bullet: c.dim("·"),
52
+ bar: c.dim("│"),
53
+ };
54
+
55
+ // ─── Output helpers ──────────────────────────────────────────────────────────
56
+
57
+ const log = (...args) => console.log(...args);
58
+ const err = (...args) => console.error(...args);
59
+ const blank = () => log();
60
+
61
+ function header() {
62
+ blank();
63
+ log(` ${c.bold("osury")} ${c.dim(`v${VERSION}`)}`);
64
+ log(` ${c.dim("OpenAPI 3.x → ReScript + Sury")}`);
65
+ blank();
66
+ }
67
+
68
+ function elapsed(startMs) {
69
+ const ms = performance.now() - startMs;
70
+ if (ms < 1000) return `${Math.round(ms)}ms`;
71
+ return `${(ms / 1000).toFixed(2)}s`;
72
+ }
73
+
74
+ // ─── Help ────────────────────────────────────────────────────────────────────
9
75
 
10
76
  function printHelp() {
11
- console.log(`
12
- osury - Generate ReScript types from OpenAPI schema
13
-
14
- Usage:
15
- osury <input.json> [output.res]
16
- osury generate <input.json> -o <output.res>
17
-
18
- Arguments:
19
- input.json Path to OpenAPI/JSON Schema file
20
- output.res Output ReScript file (default: ./Generated.res)
21
-
22
- Options:
23
- -o, --output Output file path
24
- -h, --help Show this help
25
-
26
- Examples:
27
- osury openapi.json
28
- osury openapi.json src/API.res
29
- osury generate ./schema.json -o ./src/Schema.res
30
- `);
77
+ header();
78
+ log(` ${c.bold("Usage")}`);
79
+ log(` ${c.cyan("$")} osury ${c.cyan("<input.json>")} ${c.dim("[output.res]")}`);
80
+ log(` ${c.cyan("$")} osury generate ${c.cyan("<input.json>")} -o ${c.cyan("<output.res>")}`);
81
+ blank();
82
+ log(` ${c.bold("Options")}`);
83
+ log(` ${c.cyan("-o")}, ${c.cyan("--output")} Output file path ${c.dim("(default: ./Generated.res)")}`);
84
+ log(` ${c.cyan("-h")}, ${c.cyan("--help")} Show this help`);
85
+ log(` ${c.cyan("-v")}, ${c.cyan("--version")} Show version`);
86
+ log(` ${c.cyan("--no-color")} Disable colored output`);
87
+ blank();
88
+ log(` ${c.bold("Examples")}`);
89
+ log(` ${c.cyan("$")} osury openapi.json`);
90
+ log(` ${c.cyan("$")} osury openapi.json src/API.res`);
91
+ log(` ${c.cyan("$")} osury generate schema.json -o src/Schema.res`);
92
+ blank();
31
93
  }
32
94
 
95
+ // ─── Arg parsing ─────────────────────────────────────────────────────────────
96
+
33
97
  function parseArgs(args) {
34
- const options = {
35
- input: null,
36
- output: "./Generated.res",
37
- };
98
+ const options = { input: null, output: "./Generated.res" };
38
99
 
39
100
  let i = 0;
40
101
  while (i < args.length) {
@@ -43,10 +104,24 @@ function parseArgs(args) {
43
104
  if (arg === "-h" || arg === "--help") {
44
105
  printHelp();
45
106
  process.exit(0);
107
+ } else if (arg === "-v" || arg === "--version") {
108
+ log(`osury v${VERSION}`);
109
+ process.exit(0);
110
+ } else if (arg === "--no-color") {
111
+ // already handled above
46
112
  } else if (arg === "-o" || arg === "--output") {
47
- options.output = args[++i];
113
+ i++;
114
+ if (i >= args.length) {
115
+ header();
116
+ err(` ${sym.error} ${c.boldRed("Missing value for --output")}`);
117
+ blank();
118
+ err(` Expected: osury input.json ${c.cyan("-o <path>")}`);
119
+ blank();
120
+ process.exit(1);
121
+ }
122
+ options.output = args[i];
48
123
  } else if (arg === "generate") {
49
- // Skip 'generate' command word
124
+ // skip command word
50
125
  } else if (!options.input) {
51
126
  options.input = arg;
52
127
  } else if (options.output === "./Generated.res") {
@@ -58,60 +133,230 @@ function parseArgs(args) {
58
133
  return options;
59
134
  }
60
135
 
136
+ // ─── "Did you mean?" ─────────────────────────────────────────────────────────
137
+
138
+ function levenshtein(a, b) {
139
+ const m = a.length, n = b.length;
140
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
141
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
142
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
143
+ for (let i = 1; i <= m; i++)
144
+ for (let j = 1; j <= n; j++)
145
+ dp[i][j] = a[i - 1] === b[j - 1]
146
+ ? dp[i - 1][j - 1]
147
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
148
+ return dp[m][n];
149
+ }
150
+
151
+ function findSimilarFiles(target) {
152
+ const dir = path.dirname(target);
153
+ const resolvedDir = dir === "." ? process.cwd() : dir;
154
+ const base = path.basename(target).toLowerCase();
155
+
156
+ try {
157
+ const files = fs.readdirSync(resolvedDir);
158
+ return files
159
+ .filter((f) => {
160
+ const fl = f.toLowerCase();
161
+ if (
162
+ !fl.endsWith(".json") &&
163
+ !fl.endsWith(".yaml") &&
164
+ !fl.endsWith(".yml")
165
+ )
166
+ return false;
167
+ // Levenshtein distance ≤ 40% of target name length
168
+ return levenshtein(base, fl) <= Math.ceil(base.length * 0.4);
169
+ })
170
+ .sort((a, b) => levenshtein(a.toLowerCase(), base) - levenshtein(b.toLowerCase(), base))
171
+ .slice(0, 3);
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+
177
+ // ─── Error formatting ────────────────────────────────────────────────────────
178
+
179
+ function formatErrorKind(kind) {
180
+ if (!kind || !kind.TAG) return "Unknown error";
181
+ switch (kind.TAG) {
182
+ case "UnknownType":
183
+ return `Unknown type ${c.bold(`"${kind._0}"`)}`;
184
+ case "MissingRequiredField":
185
+ return `Missing required field ${c.bold(`"${kind._0}"`)}`;
186
+ case "InvalidRef":
187
+ return `Invalid reference ${c.bold(`"${kind._0}"`)}`;
188
+ case "UnsupportedFeature":
189
+ return `Unsupported feature ${c.bold(`"${kind._0}"`)}`;
190
+ case "InvalidFormat":
191
+ return `Invalid format ${c.bold(`"${kind._0}"`)}`;
192
+ case "CircularReference":
193
+ return `Circular reference ${c.bold(`"${kind._0}"`)}`;
194
+ case "AmbiguousUnion":
195
+ return `Ambiguous union (anyOf/oneOf cannot be distinguished)`;
196
+ case "InvalidJson":
197
+ return `Invalid JSON: ${kind._0}`;
198
+ default:
199
+ return kind.TAG + (kind._0 ? `: ${kind._0}` : "");
200
+ }
201
+ }
202
+
203
+ function formatParseError(error, index) {
204
+ const pathStr =
205
+ error.location?.path?.length > 0
206
+ ? c.cyan("#/" + error.location.path.join("/"))
207
+ : c.cyan("#");
208
+
209
+ const lines = [];
210
+ lines.push(` ${c.dim(`${index + 1}.`)} ${pathStr}`);
211
+ lines.push(` ${formatErrorKind(error.kind)}`);
212
+
213
+ if (error.hint) {
214
+ lines.push(` ${c.dim("Hint:")} ${c.italic(error.hint)}`);
215
+ }
216
+
217
+ return lines.join("\n");
218
+ }
219
+
220
+ // ─── Warning formatting ─────────────────────────────────────────────────────
221
+
222
+ function formatWarning(warning) {
223
+ // Warnings from collectUnionWarnings look like:
224
+ // "⚠ floatOrDict: anyOf [float, Dict] without discriminator, @tag("_tag") may not work at runtime"
225
+ // "⚠ modelInfoOrDict: anyOf without discriminator, simplified to modelInfo"
226
+ const cleaned = warning.replace(/^⚠\s*/, "");
227
+ return ` ${sym.warning} ${c.yellow(cleaned)}`;
228
+ }
229
+
230
+ // ─── Main generate ───────────────────────────────────────────────────────────
231
+
61
232
  function generate(inputPath, outputPath) {
62
- // Read input file
233
+ const start = performance.now();
234
+
235
+ header();
236
+
237
+ // ── Check input file exists ──
63
238
  if (!fs.existsSync(inputPath)) {
64
- console.error(`Error: Input file not found: ${inputPath}`);
239
+ err(` ${sym.error} ${c.boldRed("File not found:")} ${c.cyan(inputPath)}`);
240
+
241
+ const similar = findSimilarFiles(inputPath);
242
+ if (similar.length > 0) {
243
+ blank();
244
+ err(` ${c.dim("Did you mean?")}`);
245
+ similar.forEach((f) => {
246
+ err(` ${sym.bullet} ${c.cyan(f)}`);
247
+ });
248
+ }
249
+
250
+ blank();
65
251
  process.exit(1);
66
252
  }
67
253
 
68
- const doc = JSON.parse(fs.readFileSync(inputPath, "utf8"));
254
+ // ── Read & parse JSON ──
255
+ let doc;
256
+ try {
257
+ const raw = fs.readFileSync(inputPath, "utf8");
258
+ doc = JSON.parse(raw);
259
+ } catch (e) {
260
+ err(` ${sym.error} ${c.boldRed("Invalid JSON")} in ${c.cyan(inputPath)}`);
261
+ blank();
262
+
263
+ if (e instanceof SyntaxError) {
264
+ // Extract useful part of the message
265
+ const msg = e.message.replace(/^Unexpected/, "Unexpected");
266
+ err(` ${c.red(msg)}`);
267
+ } else {
268
+ err(` ${c.red(e.message)}`);
269
+ }
270
+
271
+ blank();
272
+ err(` ${c.dim("Tip:")} Validate your JSON at ${c.cyan("https://jsonlint.com")}`);
273
+ blank();
274
+ process.exit(1);
275
+ }
69
276
 
70
- // Parse and generate
277
+ // ── Parse OpenAPI document ──
71
278
  const result = OpenAPIParser.parseDocument(doc);
72
279
 
73
- if (result.TAG === "Ok") {
74
- const code = Codegen.generateModule(result._0);
280
+ if (result.TAG !== "Ok") {
281
+ const errors = result._0;
282
+ const count = errors.length;
75
283
 
76
- // Ensure output directory exists
77
- const outputDir = path.dirname(outputPath);
78
- if (outputDir && !fs.existsSync(outputDir)) {
79
- fs.mkdirSync(outputDir, { recursive: true });
80
- }
284
+ err(
285
+ ` ${sym.error} ${c.boldRed(`${count} parse error${count !== 1 ? "s" : ""}`)} in ${c.cyan(inputPath)}`
286
+ );
287
+ blank();
288
+
289
+ errors.forEach((error, i) => {
290
+ err(formatParseError(error, i));
291
+ if (i < errors.length - 1) blank();
292
+ });
81
293
 
82
- // Write main ReScript file
83
- fs.writeFileSync(outputPath, code);
294
+ blank();
295
+ process.exit(1);
296
+ }
84
297
 
85
- // Write Dict.gen.ts shim for @genType
86
- const dictShimPath = path.join(outputDir || ".", "Dict.gen.ts");
87
- fs.writeFileSync(dictShimPath, Codegen.generateDictShim());
298
+ const schemas = result._0;
299
+ const schemaCount = schemas.length;
88
300
 
89
- // Write Nullable.res module (option<T> alias for sury compatibility)
90
- const nullableResPath = path.join(outputDir || ".", "Nullable.res");
91
- fs.writeFileSync(nullableResPath, Codegen.generateNullableModule());
301
+ log(` ${sym.success} Parsed ${c.bold(String(schemaCount))} schema${schemaCount !== 1 ? "s" : ""} from ${c.cyan(inputPath)}`);
92
302
 
93
- // Write Nullable.shim.ts for @genType.import (maps to T | null)
94
- const nullableShimPath = path.join(outputDir || ".", "Nullable.shim.ts");
95
- fs.writeFileSync(nullableShimPath, Codegen.generateNullableShim());
303
+ // ── Generate code ──
304
+ const { code, warnings } = Codegen.generateModuleWithDiagnostics(schemas);
96
305
 
97
- console.log(`Generated ${result._0.length} types to ${outputPath}`);
98
- console.log(`Generated shims: ${dictShimPath}, ${nullableShimPath}`);
99
- } else {
100
- console.error("Parse errors:");
101
- result._0.forEach((err) => {
102
- const location = err.location?.path?.join(".") || "root";
103
- console.error(` [${location}] ${err.kind.TAG}: ${err.kind._0 || ""}`);
306
+ // ── Print warnings ──
307
+ if (warnings.length > 0) {
308
+ blank();
309
+ warnings.forEach((w) => {
310
+ log(formatWarning(w));
104
311
  });
105
- process.exit(1);
106
312
  }
313
+
314
+ // ── Ensure output directory exists ──
315
+ const outputDir = path.dirname(outputPath);
316
+ if (outputDir && outputDir !== "." && !fs.existsSync(outputDir)) {
317
+ fs.mkdirSync(outputDir, { recursive: true });
318
+ }
319
+
320
+ // ── Count generated types ──
321
+ const typeCount = (code.match(/^type\s/gm) || []).length;
322
+
323
+ // ── Write files ──
324
+ fs.writeFileSync(outputPath, code);
325
+
326
+ const dictShimPath = path.join(outputDir || ".", "Dict.gen.ts");
327
+ fs.writeFileSync(dictShimPath, Codegen.generateDictShim());
328
+
329
+ const nullableResPath = path.join(outputDir || ".", "Nullable.res");
330
+ fs.writeFileSync(nullableResPath, Codegen.generateNullableModule());
331
+
332
+ const nullableShimPath = path.join(outputDir || ".", "Nullable.shim.ts");
333
+ fs.writeFileSync(nullableShimPath, Codegen.generateNullableShim());
334
+
335
+ // ── Success output ──
336
+ blank();
337
+ log(` ${sym.success} Generated ${c.bold(String(typeCount))} type${typeCount !== 1 ? "s" : ""}`);
338
+ blank();
339
+ log(` ${c.dim("Files written:")}`);
340
+ log(` ${sym.bullet} ${c.cyan(outputPath)} ${c.dim("(main module)")}`);
341
+ log(` ${sym.bullet} ${c.cyan(dictShimPath)} ${c.dim("(TS shim)")}`);
342
+ log(` ${sym.bullet} ${c.cyan(nullableResPath)} ${c.dim("(ReScript module)")}`);
343
+ log(` ${sym.bullet} ${c.cyan(nullableShimPath)} ${c.dim("(TS shim)")}`);
344
+ blank();
345
+ log(` ${c.dim(`Done in ${elapsed(start)}`)}`);
346
+ blank();
107
347
  }
108
348
 
109
- // Main
110
- const options = parseArgs(args);
349
+ // ─── Entry point ─────────────────────────────────────────────────────────────
350
+
351
+ const options = parseArgs(process.argv.slice(2));
111
352
 
112
353
  if (!options.input) {
113
- console.error("Error: Input file required\n");
114
- printHelp();
354
+ header();
355
+ err(` ${sym.error} ${c.boldRed("No input file specified")}`);
356
+ blank();
357
+ err(` ${c.dim("Usage:")} osury ${c.cyan("<input.json>")} ${c.dim("[output.res]")}`);
358
+ err(` ${c.dim("Help:")} osury ${c.cyan("--help")}`);
359
+ blank();
115
360
  process.exit(1);
116
361
  }
117
362
 
package/package.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "name": "osury",
3
3
  "type": "module",
4
4
  "description": "Generate ReScript types with Sury schemas from OpenAPI specifications",
5
- "version": "0.16.0",
5
+ "version": "0.17.1",
6
6
  "license": "MIT",
7
7
  "bin": {
8
- "osury": "./bin/osury.mjs"
8
+ "osury": "bin/osury.mjs"
9
9
  },
10
10
  "files": [
11
11
  "bin/",
@@ -21,6 +21,7 @@
21
21
  "res:build": "rescript",
22
22
  "res:dev": "rescript watch",
23
23
  "codegen": "node scripts/codegen.mjs",
24
+ "demo": "npm --prefix demo run dev",
24
25
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
25
26
  "prepublishOnly": "npm run res:build && npm test"
26
27
  },
@@ -55,6 +56,6 @@
55
56
  ],
56
57
  "repository": {
57
58
  "type": "git",
58
- "url": "https://github.com/greenteamer/osury"
59
+ "url": "git+https://github.com/greenteamer/osury.git"
59
60
  }
60
61
  }
@@ -1,749 +1,14 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as Core__Array from "@rescript/core/src/Core__Array.res.mjs";
3
+ import * as CodegenShims from "./CodegenShims.res.mjs";
4
+ import * as CodegenTypes from "./CodegenTypes.res.mjs";
4
5
  import * as Core__Option from "@rescript/core/src/Core__Option.res.mjs";
6
+ import * as CodegenHelpers from "./CodegenHelpers.res.mjs";
7
+ import * as CodegenTransforms from "./CodegenTransforms.res.mjs";
5
8
 
6
- let reservedKeywords = [
7
- "type",
8
- "let",
9
- "rec",
10
- "and",
11
- "as",
12
- "open",
13
- "include",
14
- "module",
15
- "sig",
16
- "struct",
17
- "exception",
18
- "external",
19
- "if",
20
- "else",
21
- "switch",
22
- "while",
23
- "for",
24
- "try",
25
- "catch",
26
- "when",
27
- "true",
28
- "false",
29
- "assert",
30
- "lazy",
31
- "constraint",
32
- "functor",
33
- "class",
34
- "method",
35
- "object",
36
- "private",
37
- "public",
38
- "virtual",
39
- "mutable",
40
- "new",
41
- "inherit",
42
- "initializer",
43
- "val",
44
- "with",
45
- "match",
46
- "of",
47
- "fun",
48
- "function",
49
- "in",
50
- "do",
51
- "done",
52
- "begin",
53
- "end",
54
- "then",
55
- "to",
56
- "downto",
57
- "or",
58
- "land",
59
- "lor",
60
- "lxor",
61
- "lsl",
62
- "lsr",
63
- "asr",
64
- "mod",
65
- "await",
66
- "async"
67
- ];
68
-
69
- function isReservedKeyword(name) {
70
- return reservedKeywords.includes(name);
71
- }
72
-
73
- function lcFirst(s) {
74
- if (s.length === 0) {
75
- return s;
76
- }
77
- let first = s.charAt(0).toLowerCase();
78
- let rest = s.slice(1);
79
- return first + rest;
80
- }
81
-
82
- function generateType(schema) {
83
- if (typeof schema !== "object") {
84
- switch (schema) {
85
- case "String" :
86
- return "string";
87
- case "Number" :
88
- return "float";
89
- case "Integer" :
90
- return "int";
91
- case "Boolean" :
92
- return "bool";
93
- case "Null" :
94
- return "unit";
95
- }
96
- } else {
97
- switch (schema._tag) {
98
- case "Optional" :
99
- return `option<` + generateType(schema._0) + `>`;
100
- case "Nullable" :
101
- return `Nullable.t<` + generateType(schema._0) + `>`;
102
- case "Object" :
103
- return generateRecord(schema._0);
104
- case "Array" :
105
- return `array<` + generateType(schema._0) + `>`;
106
- case "Ref" :
107
- return lcFirst(schema._0);
108
- case "Enum" :
109
- let variants = schema._0.map(v => `#` + v).join(" | ");
110
- return `[` + variants + `]`;
111
- case "PolyVariant" :
112
- return generatePolyVariant(schema._0);
113
- case "Dict" :
114
- return `Dict.t<` + generateType(schema._0) + `>`;
115
- case "Union" :
116
- return generateUnion(schema._0);
117
- }
118
- }
119
- }
120
-
121
- function isOptionalType(schema) {
122
- if (typeof schema !== "object") {
123
- return false;
124
- }
125
- switch (schema._tag) {
126
- case "Optional" :
127
- case "Nullable" :
128
- return true;
129
- default:
130
- return false;
131
- }
132
- }
133
-
134
- function isNullableType(schema) {
135
- if (typeof schema !== "object") {
136
- return false;
137
- } else {
138
- return schema._tag === "Nullable";
139
- }
140
- }
141
-
142
- function generateRecord(fields) {
143
- if (fields.length === 0) {
144
- return "{}";
145
- }
146
- let fieldStrs = fields.map(field => {
147
- let typeStr = generateType(field.type);
148
- let optionalType = field.required || isOptionalType(field.type) ? typeStr : `option<` + typeStr + `>`;
149
- let finalType = isNullableType(field.type) ? `@s.null ` + optionalType : optionalType;
150
- let asAttr = reservedKeywords.includes(field.name) ? `@as("` + field.name + `") ` : "";
151
- let fieldName = reservedKeywords.includes(field.name) ? field.name + `_` : field.name;
152
- return asAttr + fieldName + `: ` + finalType;
153
- });
154
- return `{\n ` + fieldStrs.join(",\n ") + `\n}`;
155
- }
156
-
157
- function generatePolyVariant(cases) {
158
- let caseStrs = cases.map(c => {
159
- let payloadStr = generateType(c.payload);
160
- return `#` + c._tag + `(` + payloadStr + `)`;
161
- });
162
- return `[` + caseStrs.join(" | ") + `]`;
163
- }
164
-
165
- function ucFirst(s) {
166
- if (s.length === 0) {
167
- return s;
168
- }
169
- let first = s.charAt(0).toUpperCase();
170
- let rest = s.slice(1);
171
- return first + rest;
172
- }
173
-
174
- function getTagForType(t) {
175
- if (typeof t !== "object") {
176
- switch (t) {
177
- case "String" :
178
- return "String";
179
- case "Number" :
180
- return "Float";
181
- case "Integer" :
182
- return "Int";
183
- case "Boolean" :
184
- return "Bool";
185
- case "Null" :
186
- return "Null";
187
- }
188
- } else {
189
- switch (t._tag) {
190
- case "Optional" :
191
- return `Option` + getTagForType(t._0);
192
- case "Nullable" :
193
- return `Null` + getTagForType(t._0);
194
- case "Object" :
195
- return "Object";
196
- case "Array" :
197
- return `Array` + getTagForType(t._0);
198
- case "Ref" :
199
- return ucFirst(t._0);
200
- case "Enum" :
201
- return "Enum";
202
- case "PolyVariant" :
203
- return "Variant";
204
- case "Dict" :
205
- return "Dict";
206
- case "Union" :
207
- return "Union";
208
- }
209
- }
210
- }
211
-
212
- function generateUnion(types) {
213
- let caseStrs = types.map(t => {
214
- let tag = getTagForType(t);
215
- let payload = generateType(t);
216
- return `#` + tag + `(` + payload + `)`;
217
- });
218
- return `[` + caseStrs.join(" | ") + `]`;
219
- }
220
-
221
- function hasUnion(_schema) {
222
- while (true) {
223
- let schema = _schema;
224
- if (typeof schema !== "object") {
225
- return false;
226
- }
227
- switch (schema._tag) {
228
- case "Object" :
229
- return schema._0.some(f => hasUnion(f.type));
230
- case "PolyVariant" :
231
- return schema._0.some(c => hasUnion(c.payload));
232
- case "Optional" :
233
- case "Nullable" :
234
- case "Array" :
235
- case "Dict" :
236
- _schema = schema._0;
237
- continue;
238
- case "Union" :
239
- return true;
240
- default:
241
- return false;
242
- }
243
- };
244
- }
245
-
246
- function isPrimitiveOnlyUnion(types) {
247
- return types.every(t => {
248
- if (typeof t === "object") {
249
- return false;
250
- }
251
- switch (t) {
252
- case "String" :
253
- case "Number" :
254
- case "Integer" :
255
- case "Boolean" :
256
- return true;
257
- default:
258
- return false;
259
- }
260
- });
261
- }
262
-
263
- function generateInlineRecord(refName, schemasDict) {
264
- let other = schemasDict[refName];
265
- if (other !== undefined) {
266
- if (typeof other !== "object" || other._tag !== "Object") {
267
- return generateType(other);
268
- } else {
269
- return generateRecord(other._0);
270
- }
271
- } else {
272
- return lcFirst(refName);
273
- }
274
- }
275
-
276
- function generateInlineVariantBody(types, schemasDict, tagsDict) {
277
- return types.map(t => {
278
- if (typeof t === "object" && t._tag === "Ref") {
279
- let name = t._0;
280
- let tagValue = tagsDict[name];
281
- let tag = tagValue !== undefined ? ucFirst(tagValue) : ucFirst(name);
282
- let inlineRecord = generateInlineRecord(name, schemasDict);
283
- return tag + `(` + inlineRecord + `)`;
284
- }
285
- let tag$1 = getTagForType(t);
286
- let payload = generateType(t);
287
- return tag$1 + `(` + payload + `)`;
288
- }).join(" | ");
289
- }
290
-
291
- function isRefPlusDictUnion(types) {
292
- if (types.length !== 2) {
293
- return;
294
- }
295
- let hasDict = types.some(t => {
296
- if (typeof t !== "object") {
297
- return false;
298
- }
299
- if (t._tag !== "Dict") {
300
- return false;
301
- }
302
- let tmp = t._0;
303
- if (typeof tmp !== "object") {
304
- return tmp === "String";
305
- } else {
306
- return false;
307
- }
308
- });
309
- let refName = Core__Array.findMap(types, t => {
310
- if (typeof t !== "object" || t._tag !== "Ref") {
311
- return;
312
- } else {
313
- return t._0;
314
- }
315
- });
316
- if (hasDict) {
317
- return refName;
318
- }
319
- }
320
-
321
- function isPrimitivePlusDictUnion(types) {
322
- if (types.length !== 2) {
323
- return;
324
- }
325
- let hasDict = types.some(t => {
326
- if (typeof t !== "object") {
327
- return false;
328
- }
329
- if (t._tag !== "Dict") {
330
- return false;
331
- }
332
- let tmp = t._0;
333
- if (typeof tmp !== "object") {
334
- return tmp === "String";
335
- } else {
336
- return false;
337
- }
338
- });
339
- let primitiveName = Core__Array.findMap(types, t => {
340
- if (typeof t === "object") {
341
- return;
342
- }
343
- switch (t) {
344
- case "String" :
345
- return "string";
346
- case "Number" :
347
- return "float";
348
- case "Integer" :
349
- return "int";
350
- case "Boolean" :
351
- return "bool";
352
- default:
353
- return;
354
- }
355
- });
356
- if (hasDict) {
357
- return primitiveName;
358
- }
359
- }
360
-
361
- function getUnionName(types) {
362
- let names = types.map(t => {
363
- if (typeof t !== "object") {
364
- switch (t) {
365
- case "String" :
366
- return "string";
367
- case "Number" :
368
- return "float";
369
- case "Integer" :
370
- return "int";
371
- case "Boolean" :
372
- return "bool";
373
- case "Null" :
374
- return "null";
375
- }
376
- } else {
377
- switch (t._tag) {
378
- case "Array" :
379
- return "array";
380
- case "Ref" :
381
- return lcFirst(t._0);
382
- case "Dict" :
383
- return "dict";
384
- default:
385
- return "unknown";
386
- }
387
- }
388
- });
389
- if (names.length === 0) {
390
- return "emptyUnion";
391
- }
392
- let first = Core__Option.getOr(names[0], "unknown");
393
- let rest = names.slice(1);
394
- return first + rest.map(n => "Or" + ucFirst(n)).join("");
395
- }
396
-
397
- function extractUnionsFromType(_schema) {
398
- while (true) {
399
- let schema = _schema;
400
- if (typeof schema !== "object") {
401
- return [];
402
- }
403
- switch (schema._tag) {
404
- case "Object" :
405
- return schema._0.flatMap(field => extractUnionsFromType(field.type));
406
- case "Optional" :
407
- case "Nullable" :
408
- case "Array" :
409
- case "Dict" :
410
- _schema = schema._0;
411
- continue;
412
- case "Union" :
413
- let types = schema._0;
414
- let match = isRefPlusDictUnion(types);
415
- if (match !== undefined) {
416
- return [];
417
- }
418
- let name = getUnionName(types);
419
- return [{
420
- name: name,
421
- schema: schema
422
- }];
423
- default:
424
- return [];
425
- }
426
- };
427
- }
428
-
429
- function extractUnions(_parentName, schema) {
430
- if (typeof schema !== "object") {
431
- return [];
432
- } else if (schema._tag === "Object") {
433
- return schema._0.flatMap(field => extractUnionsFromType(field.type));
434
- } else {
435
- return [];
436
- }
437
- }
438
-
439
- function replaceUnionInType(schema) {
440
- if (typeof schema !== "object") {
441
- return schema;
442
- }
443
- switch (schema._tag) {
444
- case "Optional" :
445
- return {
446
- _tag: "Optional",
447
- _0: replaceUnionInType(schema._0)
448
- };
449
- case "Nullable" :
450
- return {
451
- _tag: "Nullable",
452
- _0: replaceUnionInType(schema._0)
453
- };
454
- case "Object" :
455
- let newFields = schema._0.map(field => {
456
- let newType = replaceUnionInType(field.type);
457
- return {
458
- name: field.name,
459
- type: newType,
460
- required: field.required
461
- };
462
- });
463
- return {
464
- _tag: "Object",
465
- _0: newFields
466
- };
467
- case "Array" :
468
- return {
469
- _tag: "Array",
470
- _0: replaceUnionInType(schema._0)
471
- };
472
- case "Dict" :
473
- return {
474
- _tag: "Dict",
475
- _0: replaceUnionInType(schema._0)
476
- };
477
- case "Union" :
478
- let types = schema._0;
479
- let refName = isRefPlusDictUnion(types);
480
- if (refName !== undefined) {
481
- return {
482
- _tag: "Ref",
483
- _0: refName
484
- };
485
- } else {
486
- return {
487
- _tag: "Ref",
488
- _0: getUnionName(types)
489
- };
490
- }
491
- default:
492
- return schema;
493
- }
494
- }
495
-
496
- function replaceUnions(_parentName, schema) {
497
- if (typeof schema !== "object") {
498
- return schema;
499
- }
500
- if (schema._tag !== "Object") {
501
- return schema;
502
- }
503
- let newFields = schema._0.map(field => {
504
- let newType = replaceUnionInType(field.type);
505
- return {
506
- name: field.name,
507
- type: newType,
508
- required: field.required
509
- };
510
- });
511
- return {
512
- _tag: "Object",
513
- _0: newFields
514
- };
515
- }
516
-
517
- function getDependencies(_schema) {
518
- while (true) {
519
- let schema = _schema;
520
- if (typeof schema !== "object") {
521
- return [];
522
- }
523
- switch (schema._tag) {
524
- case "Object" :
525
- return schema._0.flatMap(f => getDependencies(f.type));
526
- case "Ref" :
527
- return [schema._0];
528
- case "Enum" :
529
- return [];
530
- case "PolyVariant" :
531
- return schema._0.flatMap(c => getDependencies(c.payload));
532
- case "Optional" :
533
- case "Nullable" :
534
- case "Array" :
535
- case "Dict" :
536
- _schema = schema._0;
537
- continue;
538
- case "Union" :
539
- return schema._0.flatMap(getDependencies);
540
- }
541
- };
542
- }
543
-
544
- function topologicalSort(schemas) {
545
- let schemaMap = {};
546
- schemas.forEach(s => {
547
- schemaMap[s.name] = s;
548
- });
549
- let deps = {};
550
- schemas.forEach(s => {
551
- let refNames = getDependencies(s.schema);
552
- let validRefs = refNames.filter(name => Core__Option.isSome(schemaMap[name]));
553
- deps[s.name] = validRefs;
554
- });
555
- let outDegree = {};
556
- schemas.forEach(s => {
557
- let myDeps = Core__Option.getOr(deps[s.name], []);
558
- outDegree[s.name] = myDeps.length;
559
- });
560
- let reverseDeps = {};
561
- schemas.forEach(s => {
562
- reverseDeps[s.name] = [];
563
- });
564
- Object.entries(deps).forEach(param => {
565
- let name = param[0];
566
- param[1].forEach(refName => {
567
- let arr = reverseDeps[refName];
568
- if (arr !== undefined) {
569
- arr.push(name);
570
- return;
571
- }
572
- });
573
- });
574
- let queue = schemas.filter(s => Core__Option.getOr(outDegree[s.name], 0) === 0).map(s => s.name);
575
- let result = [];
576
- let visited = {};
577
- let process = () => {
578
- while (true) {
579
- let name = queue.shift();
580
- if (name === undefined) {
581
- return;
582
- }
583
- if (Core__Option.isNone(visited[name])) {
584
- visited[name] = true;
585
- let schema = schemaMap[name];
586
- if (schema !== undefined) {
587
- result.push(schema);
588
- }
589
- let dependents = reverseDeps[name];
590
- if (dependents !== undefined) {
591
- dependents.forEach(depName => {
592
- let current = Core__Option.getOr(outDegree[depName], 0);
593
- outDegree[depName] = current - 1 | 0;
594
- if ((current - 1 | 0) === 0) {
595
- queue.push(depName);
596
- return;
597
- }
598
- });
599
- }
600
- }
601
- continue;
602
- };
603
- };
604
- process();
605
- schemas.forEach(s => {
606
- if (Core__Option.isNone(visited[s.name])) {
607
- result.push(s);
608
- return;
609
- }
610
- });
611
- return result;
612
- }
613
-
614
- function buildSkipSchemaSet(schemas) {
615
- let skipSet = {};
616
- schemas.forEach(s => {
617
- if (hasUnion(s.schema)) {
618
- skipSet[s.name] = true;
619
- return;
620
- }
621
- });
622
- let changed = {
623
- contents: true
624
- };
625
- while (changed.contents) {
626
- changed.contents = false;
627
- schemas.forEach(s => {
628
- if (!Core__Option.isNone(skipSet[s.name])) {
629
- return;
630
- }
631
- let refs = getDependencies(s.schema);
632
- let refsSkipSchema = refs.some(refName => Core__Option.isSome(skipSet[refName]));
633
- if (refsSkipSchema) {
634
- skipSet[s.name] = true;
635
- changed.contents = true;
636
- return;
637
- }
638
- });
639
- };
640
- return skipSet;
641
- }
642
-
643
- function generateVariantBody(types) {
644
- return types.map(t => {
645
- let tag = getTagForType(t);
646
- let payload = generateType(t);
647
- return tag + `(` + payload + `)`;
648
- }).join(" | ");
649
- }
650
-
651
- function generateTypeDefWithSkipSet(namedSchema, _skipSet, schemasDict, tagsDict) {
652
- let typeName = lcFirst(namedSchema.name);
653
- let types = namedSchema.schema;
654
- if (typeof types === "object" && types._tag === "Union") {
655
- let types$1 = types._0;
656
- if (isPrimitiveOnlyUnion(types$1)) {
657
- let variantBody = generateVariantBody(types$1);
658
- return `@genType
659
- @tag("_tag")
660
- @unboxed
661
- @schema
662
- type ` + typeName + ` = ` + variantBody;
663
- }
664
- let variantBody$1 = generateInlineVariantBody(types$1, schemasDict, tagsDict);
665
- return `@genType
666
- @tag("_tag")
667
- @schema
668
- type ` + typeName + ` = ` + variantBody$1;
669
- }
670
- let typeBody = generateType(namedSchema.schema);
671
- return `@genType
672
- @schema
673
- type ` + typeName + ` = ` + typeBody;
674
- }
675
-
676
- function generateTypeDef(namedSchema) {
677
- let typeName = lcFirst(namedSchema.name);
678
- let types = namedSchema.schema;
679
- if (typeof types === "object" && types._tag === "Union") {
680
- let variantBody = generateVariantBody(types._0);
681
- return `@genType
682
- @tag("_tag")
683
- @unboxed
684
- @schema
685
- type ` + typeName + ` = ` + variantBody;
686
- }
687
- let annotations = hasUnion(namedSchema.schema) ? "@genType" : "@genType\n@schema";
688
- let typeBody = generateType(namedSchema.schema);
689
- return annotations + `
690
- type ` + typeName + ` = ` + typeBody;
691
- }
692
-
693
- function collectUnionWarnings(schemas) {
694
- let seen = {};
695
- let warnings = [];
696
- let findUnions = _schema => {
697
- while (true) {
698
- let schema = _schema;
699
- if (typeof schema !== "object") {
700
- return [];
701
- }
702
- switch (schema._tag) {
703
- case "Object" :
704
- return schema._0.flatMap(f => findUnions(f.type));
705
- case "Optional" :
706
- case "Nullable" :
707
- case "Array" :
708
- case "Dict" :
709
- _schema = schema._0;
710
- continue;
711
- case "Union" :
712
- return [schema._0];
713
- default:
714
- return [];
715
- }
716
- };
717
- };
718
- schemas.forEach(s => {
719
- let unions = findUnions(s.schema);
720
- unions.forEach(types => {
721
- let unionName = getUnionName(types);
722
- if (!Core__Option.isNone(seen[unionName])) {
723
- return;
724
- }
725
- seen[unionName] = true;
726
- let refName = isRefPlusDictUnion(types);
727
- if (refName !== undefined) {
728
- warnings.push(`⚠ ` + unionName + `: anyOf without discriminator, simplified to ` + lcFirst(refName));
729
- return;
730
- }
731
- let primName = isPrimitivePlusDictUnion(types);
732
- if (primName !== undefined) {
733
- warnings.push(`⚠ ` + unionName + `: anyOf [` + primName + `, Dict] without discriminator, @tag("_tag") may not work at runtime`);
734
- return;
735
- }
736
- });
737
- });
738
- return warnings;
739
- }
740
-
741
- function generateModule(schemas) {
742
- let warnings = collectUnionWarnings(schemas);
743
- warnings.forEach(w => {
744
- console.log(w);
745
- });
746
- let extractedUnions = schemas.flatMap(s => extractUnions(s.name, s.schema).map(extracted => ({
9
+ function generateModuleWithDiagnostics(schemas) {
10
+ let warnings = CodegenTransforms.collectUnionWarnings(schemas);
11
+ let extractedUnions = schemas.flatMap(s => CodegenTransforms.extractUnions(s.name, s.schema).map(extracted => ({
747
12
  name: extracted.name,
748
13
  schema: extracted.schema,
749
14
  discriminatorTag: undefined
@@ -759,7 +24,7 @@ function generateModule(schemas) {
759
24
  });
760
25
  let modifiedSchemas = schemas.map(s => ({
761
26
  name: s.name,
762
- schema: replaceUnions(s.name, s.schema),
27
+ schema: CodegenTransforms.replaceUnions(s.name, s.schema),
763
28
  discriminatorTag: s.discriminatorTag
764
29
  }));
765
30
  let allSchemas = uniqueUnions.concat(modifiedSchemas);
@@ -773,56 +38,92 @@ function generateModule(schemas) {
773
38
  return;
774
39
  }
775
40
  });
776
- let sorted = topologicalSort(allSchemas);
41
+ let sorted = CodegenTransforms.topologicalSort(allSchemas);
777
42
  let skipSet = {};
778
- let typeDefs = sorted.map(s => generateTypeDefWithSkipSet(s, skipSet, schemasDict, tagsDict)).join("\n\n");
779
- return "module S = Sury\n\n" + typeDefs;
43
+ let typeDefs = sorted.map(s => CodegenTypes.generateTypeDefWithSkipSet(s, skipSet, schemasDict, tagsDict)).join("\n\n");
44
+ let code = "module S = Sury\n\n" + typeDefs;
45
+ return {
46
+ code: code,
47
+ warnings: warnings
48
+ };
780
49
  }
781
50
 
782
- function generateDictShim() {
783
- return `// Generated by osury - Dict type shim for @genType
784
- export type t<T> = { [key: string]: T };
785
- `;
51
+ function generateModule(schemas) {
52
+ let result = generateModuleWithDiagnostics(schemas);
53
+ result.warnings.forEach(w => {
54
+ console.log(w);
55
+ });
56
+ return result.code;
786
57
  }
787
58
 
788
- function generateNullableShim() {
789
- return `/**
790
- * Type shim for ReScript Nullable.t
791
- *
792
- * This file is generated by osury (not by ReScript/genType).
793
- * It maps ReScript's option<T> to TypeScript's T | null for JSON nullable fields.
794
- *
795
- * ReScript side: Nullable.t<T> = option<T> (works with sury's S.null schema)
796
- * TypeScript side: t<T> = T | null (correct JSON null representation)
797
- */
798
- export type t<T> = T | null;
799
- `;
800
- }
59
+ let lcFirst = CodegenHelpers.lcFirst;
801
60
 
802
- function generateNullableModule() {
803
- return `// Generated by osury - Nullable type for JSON null values
804
- // This is option<T> internally but maps to T | null in TypeScript via shim
805
- @genType.import(("./Nullable.shim.ts", "t"))
806
- type t<'a> = option<'a>
807
- `;
808
- }
61
+ let ucFirst = CodegenHelpers.ucFirst;
62
+
63
+ let reservedKeywords = CodegenHelpers.reservedKeywords;
64
+
65
+ let isReservedKeyword = CodegenHelpers.isReservedKeyword;
66
+
67
+ let isOptionalType = CodegenHelpers.isOptionalType;
68
+
69
+ let isNullableType = CodegenHelpers.isNullableType;
70
+
71
+ let getTagForType = CodegenHelpers.getTagForType;
72
+
73
+ let hasUnion = CodegenHelpers.hasUnion;
74
+
75
+ let isPrimitiveOnlyUnion = CodegenHelpers.isPrimitiveOnlyUnion;
76
+
77
+ let isRefPlusDictUnion = CodegenTransforms.isRefPlusDictUnion;
78
+
79
+ let isPrimitivePlusDictUnion = CodegenTransforms.isPrimitivePlusDictUnion;
80
+
81
+ let getUnionName = CodegenTransforms.getUnionName;
82
+
83
+ let extractUnions = CodegenTransforms.extractUnions;
84
+
85
+ let extractUnionsFromType = CodegenTransforms.extractUnionsFromType;
86
+
87
+ let replaceUnions = CodegenTransforms.replaceUnions;
88
+
89
+ let replaceUnionInType = CodegenTransforms.replaceUnionInType;
90
+
91
+ let getDependencies = CodegenTransforms.getDependencies;
92
+
93
+ let topologicalSort = CodegenTransforms.topologicalSort;
94
+
95
+ let buildSkipSchemaSet = CodegenTransforms.buildSkipSchemaSet;
96
+
97
+ let collectUnionWarnings = CodegenTransforms.collectUnionWarnings;
98
+
99
+ let generateType = CodegenTypes.generateType;
100
+
101
+ let generateTypeDef = CodegenTypes.generateTypeDef;
102
+
103
+ let generateTypeDefWithSkipSet = CodegenTypes.generateTypeDefWithSkipSet;
104
+
105
+ let generateVariantBody = CodegenTypes.generateVariantBody;
106
+
107
+ let generateInlineVariantBody = CodegenTypes.generateInlineVariantBody;
108
+
109
+ let generateInlineRecord = CodegenTypes.generateInlineRecord;
110
+
111
+ let generateDictShim = CodegenShims.generateDictShim;
112
+
113
+ let generateNullableShim = CodegenShims.generateNullableShim;
114
+
115
+ let generateNullableModule = CodegenShims.generateNullableModule;
809
116
 
810
117
  export {
118
+ lcFirst,
119
+ ucFirst,
811
120
  reservedKeywords,
812
121
  isReservedKeyword,
813
- lcFirst,
814
- generateType,
815
122
  isOptionalType,
816
123
  isNullableType,
817
- generateRecord,
818
- generatePolyVariant,
819
- ucFirst,
820
124
  getTagForType,
821
- generateUnion,
822
125
  hasUnion,
823
126
  isPrimitiveOnlyUnion,
824
- generateInlineRecord,
825
- generateInlineVariantBody,
826
127
  isRefPlusDictUnion,
827
128
  isPrimitivePlusDictUnion,
828
129
  getUnionName,
@@ -833,13 +134,17 @@ export {
833
134
  getDependencies,
834
135
  topologicalSort,
835
136
  buildSkipSchemaSet,
836
- generateVariantBody,
837
- generateTypeDefWithSkipSet,
838
- generateTypeDef,
839
137
  collectUnionWarnings,
840
- generateModule,
138
+ generateType,
139
+ generateTypeDef,
140
+ generateTypeDefWithSkipSet,
141
+ generateVariantBody,
142
+ generateInlineVariantBody,
143
+ generateInlineRecord,
841
144
  generateDictShim,
842
145
  generateNullableShim,
843
146
  generateNullableModule,
147
+ generateModuleWithDiagnostics,
148
+ generateModule,
844
149
  }
845
150
  /* No side effect */