@f3liz/rescript-autogen-openapi 0.1.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @f3liz/rescript-autogen-openapi might be problematic. Click here for more details.

@@ -5,13 +5,17 @@ open Types
5
5
 
6
6
  let addWarning = GenerationContext.addWarning
7
7
 
8
- let rec generateTypeWithContext = (~ctx: GenerationContext.t, ~depth=0, irType: SchemaIR.irType): string => {
8
+ // `inline` tracks whether the type appears inside a type constructor (array<_>, option<_>, etc.)
9
+ // where ReScript forbids inline record declarations and variant definitions.
10
+ // When a complex type is encountered inline, it's extracted as a separate named type.
11
+ let rec generateTypeWithContext = (~ctx: GenerationContext.t, ~depth=0, ~inline=false, irType: SchemaIR.irType): string => {
9
12
  // We keep a high depth limit just to prevent infinite recursion on circular schemas that escaped IRBuilder
10
13
  if depth > 100 {
11
14
  addWarning(ctx, DepthLimitReached({depth, path: ctx.path}))
12
15
  "JSON.t"
13
16
  } else {
14
- let recurse = nextIrType => generateTypeWithContext(~ctx, ~depth=depth + 1, nextIrType)
17
+ // Inside type constructors, records/variants can't appear; recurse as inline
18
+ let recurseInline = nextIrType => generateTypeWithContext(~ctx, ~depth=depth + 1, ~inline=true, nextIrType)
15
19
 
16
20
  switch irType {
17
21
  | String(_) => "string"
@@ -19,19 +23,30 @@ let rec generateTypeWithContext = (~ctx: GenerationContext.t, ~depth=0, irType:
19
23
  | Integer(_) => "int"
20
24
  | Boolean => "bool"
21
25
  | Null => "unit"
22
- | Array({items}) => `array<${recurse(items)}>`
26
+ | Array({items}) => `array<${recurseInline(items)}>`
23
27
  | Object({properties, additionalProperties}) =>
24
28
  if Array.length(properties) == 0 {
25
29
  switch additionalProperties {
26
- | Some(valueType) => `dict<${recurse(valueType)}>`
27
- | None => "JSON.t"
30
+ | Some(valueType) => `dict<${recurseInline(valueType)}>`
31
+ | None => "dict<JSON.t>"
28
32
  }
33
+ } else if inline {
34
+ // Extract inline record as a separate named type
35
+ let baseName = ctx.path->String.split(".")->Array.get(ctx.path->String.split(".")->Array.length - 1)->Option.getOr("item")
36
+ let typeName = GenerationContext.extractType(ctx, ~baseName, irType)
37
+ typeName
29
38
  } else {
30
39
  let fields =
31
40
  properties
32
41
  ->Array.map(((name, fieldType, isRequired)) => {
33
- let typeCode = recurse(fieldType)
34
- let finalType = isRequired ? typeCode : `option<${typeCode}>`
42
+ let typeCode = recurseInline(fieldType)
43
+ // Avoid double-option: check both generated string and IR type for nullability
44
+ let alreadyNullable = String.startsWith(typeCode, "option<") || switch fieldType {
45
+ | Option(_) => true
46
+ | Union(types) => types->Array.some(t => switch t { | Null | Literal(NullLiteral) => true | _ => false })
47
+ | _ => false
48
+ }
49
+ let finalType = isRequired || alreadyNullable ? typeCode : `option<${typeCode}>`
35
50
  let camelName = name->CodegenUtils.toCamelCase
36
51
  let escapedName = camelName->CodegenUtils.escapeKeyword
37
52
  let aliasAnnotation = escapedName != name ? `@as("${name}") ` : ""
@@ -48,102 +63,262 @@ let rec generateTypeWithContext = (~ctx: GenerationContext.t, ~depth=0, irType:
48
63
  | NullLiteral => "unit"
49
64
  }
50
65
  | Union(types) =>
51
- // Attempt to simplify common union patterns
52
- let (hasArray, hasNonArray, arrayItemType, nonArrayType) = types->Array.reduce(
53
- (false, false, None, None),
54
- ((hArr, hNonArr, arrItem, nonArr), t) =>
55
- switch t {
56
- | Array({items}) => (true, hNonArr, Some(items), nonArr)
57
- | _ => (hArr, true, arrItem, Some(t))
58
- },
66
+ // Separate Null from non-null members (handles OpenAPI 3.1 nullable via oneOf)
67
+ let nonNullTypes = types->Array.filter(t =>
68
+ switch t {
69
+ | Null | Literal(NullLiteral) => false
70
+ | _ => true
71
+ }
59
72
  )
73
+ let hasNull = Array.length(nonNullTypes) < Array.length(types)
60
74
 
61
- if (
62
- hasArray &&
63
- hasNonArray &&
64
- SchemaIR.equals(Option.getOr(arrayItemType, Unknown), Option.getOr(nonArrayType, Unknown))
65
- ) {
66
- `array<${recurse(Option.getOr(arrayItemType, Unknown))}>`
67
- } else if (
68
- types->Array.every(t =>
69
- switch t {
70
- | Literal(StringLiteral(_)) => true
71
- | _ => false
72
- }
73
- ) &&
74
- Array.length(types) > 0 &&
75
- Array.length(types) <= 50
76
- ) {
77
- let variants =
78
- types
79
- ->Array.map(t =>
80
- switch t {
81
- | Literal(StringLiteral(s)) => `#${CodegenUtils.toPascalCase(s)}`
82
- | _ => "#Unknown"
83
- }
84
- )
85
- ->Array.join(" | ")
86
- `[${variants}]`
75
+ // If the union is just [T, null], treat as option<T>
76
+ if hasNull && Array.length(nonNullTypes) == 1 {
77
+ let inner = generateTypeWithContext(~ctx, ~depth=depth + 1, ~inline=true, nonNullTypes->Array.getUnsafe(0))
78
+ `option<${inner}>`
87
79
  } else {
88
- addWarning(
89
- ctx,
90
- ComplexUnionSimplified({
91
- location: ctx.path,
92
- types: types->Array.map(SchemaIR.toString)->Array.join(" | "),
93
- }),
80
+ // Work with the non-null types (re-wrap in option at the end if hasNull)
81
+ let effectiveTypes = hasNull ? nonNullTypes : types
82
+
83
+ // Attempt to simplify common union patterns
84
+ let (hasArray, hasNonArray, arrayItemType, nonArrayType) = effectiveTypes->Array.reduce(
85
+ (false, false, None, None),
86
+ ((hArr, hNonArr, arrItem, nonArr), t) =>
87
+ switch t {
88
+ | Array({items}) => (true, hNonArr, Some(items), nonArr)
89
+ | _ => (hArr, true, arrItem, Some(t))
90
+ },
94
91
  )
95
- "JSON.t"
92
+
93
+ let result = if (
94
+ hasArray &&
95
+ hasNonArray &&
96
+ Array.length(effectiveTypes) == 2 &&
97
+ SchemaIR.equals(Option.getOr(arrayItemType, Unknown), Option.getOr(nonArrayType, Unknown))
98
+ ) {
99
+ `array<${recurseInline(Option.getOr(arrayItemType, Unknown))}>`
100
+ } else if (
101
+ effectiveTypes->Array.every(t =>
102
+ switch t {
103
+ | Literal(StringLiteral(_)) => true
104
+ | _ => false
105
+ }
106
+ ) &&
107
+ Array.length(effectiveTypes) > 0 &&
108
+ Array.length(effectiveTypes) <= 50
109
+ ) {
110
+ // Polymorphic variants: valid inline
111
+ let variants =
112
+ effectiveTypes
113
+ ->Array.map(t =>
114
+ switch t {
115
+ | Literal(StringLiteral(s)) => `#${CodegenUtils.toPascalCase(s)}`
116
+ | _ => "#Unknown"
117
+ }
118
+ )
119
+ ->Array.join(" | ")
120
+ `[${variants}]`
121
+ } else if Array.length(effectiveTypes) > 0 {
122
+ // Check if @unboxed variant is valid: each member must have a distinct runtime representation
123
+ let canUnbox = {
124
+ let runtimeKinds: Dict.t<int> = Dict.make()
125
+ effectiveTypes->Array.forEach(t => {
126
+ let kind = switch t {
127
+ | Boolean | Literal(BooleanLiteral(_)) => "boolean"
128
+ | String(_) | Literal(StringLiteral(_)) => "string"
129
+ | Number(_) | Integer(_) | Literal(NumberLiteral(_)) => "number"
130
+ | Array(_) => "array"
131
+ | Object(_) | Reference(_) | Intersection(_) => "object"
132
+ | Null | Literal(NullLiteral) => "null"
133
+ | _ => "unknown"
134
+ }
135
+ let count = runtimeKinds->Dict.get(kind)->Option.getOr(0)
136
+ runtimeKinds->Dict.set(kind, count + 1)
137
+ })
138
+ // Valid if no kind appears more than once
139
+ Dict.valuesToArray(runtimeKinds)->Array.every(count => count <= 1)
140
+ }
141
+
142
+ if canUnbox {
143
+ // Safe to use @unboxed variant
144
+ let extractIR = if hasNull {
145
+ SchemaIR.Union(effectiveTypes)
146
+ } else {
147
+ irType
148
+ }
149
+ let baseName = ctx.path->String.split(".")->Array.get(ctx.path->String.split(".")->Array.length - 1)->Option.getOr("union")
150
+ let typeName = GenerationContext.extractType(ctx, ~baseName, ~isUnboxed=true, extractIR)
151
+ typeName
152
+ } else {
153
+ // Can't use @unboxed: pick the last (most derived/specific) type
154
+ recurseInline(effectiveTypes->Array.getUnsafe(Array.length(effectiveTypes) - 1))
155
+ }
156
+ } else {
157
+ "JSON.t"
158
+ }
159
+
160
+ hasNull ? `option<${result}>` : result
96
161
  }
97
162
  | Intersection(types) =>
98
- // Basic support for intersections by picking the last reference or falling back
163
+ // Support for intersections: merge object properties or pick last reference
99
164
  if types->Array.every(t =>
100
165
  switch t {
101
166
  | Reference(_) => true
102
167
  | _ => false
103
168
  }
104
169
  ) && Array.length(types) > 0 {
105
- recurse(types->Array.get(Array.length(types) - 1)->Option.getOr(Unknown))
170
+ generateTypeWithContext(~ctx, ~depth=depth + 1, ~inline, types->Array.get(Array.length(types) - 1)->Option.getOr(Unknown))
171
+ } else if inline {
172
+ // Extract complex intersection as a separate type
173
+ let baseName = ctx.path->String.split(".")->Array.get(ctx.path->String.split(".")->Array.length - 1)->Option.getOr("intersection")
174
+ let typeName = GenerationContext.extractType(ctx, ~baseName, irType)
175
+ typeName
106
176
  } else {
107
- addWarning(
108
- ctx,
109
- IntersectionNotFullySupported({location: ctx.path, note: "Complex intersection"}),
177
+ // Try to merge all Object types in the intersection
178
+ let (objectProps, nonObjectTypes) = types->Array.reduce(
179
+ ([], []),
180
+ ((props, nonObj), t) =>
181
+ switch t {
182
+ | Object({properties}) => (Array.concat(props, properties), nonObj)
183
+ | _ => (props, Array.concat(nonObj, [t]))
184
+ },
110
185
  )
111
- "JSON.t"
186
+ if Array.length(objectProps) > 0 && Array.length(nonObjectTypes) == 0 {
187
+ // All objects: merge properties
188
+ let fields =
189
+ objectProps
190
+ ->Array.map(((name, fieldType, isRequired)) => {
191
+ let typeCode = recurseInline(fieldType)
192
+ let alreadyNullable = String.startsWith(typeCode, "option<") || switch fieldType {
193
+ | Option(_) => true
194
+ | Union(types) => types->Array.some(t => switch t { | Null | Literal(NullLiteral) => true | _ => false })
195
+ | _ => false
196
+ }
197
+ let finalType = isRequired || alreadyNullable ? typeCode : `option<${typeCode}>`
198
+ let camelName = name->CodegenUtils.toCamelCase
199
+ let escapedName = camelName->CodegenUtils.escapeKeyword
200
+ let aliasAnnotation = escapedName != name ? `@as("${name}") ` : ""
201
+ ` ${aliasAnnotation}${escapedName}: ${finalType},`
202
+ })
203
+ ->Array.join("\n")
204
+ `{\n${fields}\n}`
205
+ } else if Array.length(nonObjectTypes) > 0 && Array.length(objectProps) == 0 {
206
+ // No objects: pick last type as best effort
207
+ generateTypeWithContext(~ctx, ~depth=depth + 1, ~inline, types->Array.get(Array.length(types) - 1)->Option.getOr(Unknown))
208
+ } else {
209
+ addWarning(
210
+ ctx,
211
+ IntersectionNotFullySupported({location: ctx.path, note: "Mixed object/non-object intersection"}),
212
+ )
213
+ // Merge what we can, ignore non-object parts
214
+ let fields =
215
+ objectProps
216
+ ->Array.map(((name, fieldType, isRequired)) => {
217
+ let typeCode = recurseInline(fieldType)
218
+ let alreadyNullable = String.startsWith(typeCode, "option<") || switch fieldType {
219
+ | Option(_) => true
220
+ | Union(types) => types->Array.some(t => switch t { | Null | Literal(NullLiteral) => true | _ => false })
221
+ | _ => false
222
+ }
223
+ let finalType = isRequired || alreadyNullable ? typeCode : `option<${typeCode}>`
224
+ let camelName = name->CodegenUtils.toCamelCase
225
+ let escapedName = camelName->CodegenUtils.escapeKeyword
226
+ let aliasAnnotation = escapedName != name ? `@as("${name}") ` : ""
227
+ ` ${aliasAnnotation}${escapedName}: ${finalType},`
228
+ })
229
+ ->Array.join("\n")
230
+ `{\n${fields}\n}`
231
+ }
112
232
  }
113
- | Option(inner) => `option<${recurse(inner)}>`
233
+ | Option(inner) => `option<${recurseInline(inner)}>`
114
234
  | Reference(ref) =>
115
- let typePath = switch ctx.availableSchemas {
116
- | Some(available) =>
117
- let name =
118
- ref
119
- ->String.split("/")
120
- ->Array.get(ref->String.split("/")->Array.length - 1)
121
- ->Option.getOr("")
122
- available->Array.includes(name)
123
- ? `${CodegenUtils.toPascalCase(name)}.t`
124
- : `ComponentSchemas.${CodegenUtils.toPascalCase(name)}.t`
125
- | None =>
126
- ReferenceResolver.refToTypePath(
127
- ~insideComponentSchemas=ctx.insideComponentSchemas,
128
- ~modulePrefix=ctx.modulePrefix,
129
- ref,
130
- )->Option.getOr("JSON.t")
235
+ // After IR normalization, ref may be just the schema name (no path prefix)
236
+ // Extract the name from the ref (handles both "Name" and "#/components/schemas/Name")
237
+ let refName = if ref->String.includes("/") {
238
+ ref->String.split("/")->Array.get(ref->String.split("/")->Array.length - 1)->Option.getOr("")
239
+ } else {
240
+ ref
131
241
  }
132
- if typePath == "JSON.t" {
133
- addWarning(
134
- ctx,
135
- FallbackToJson({
136
- reason: `Unresolved ref: ${ref}`,
137
- context: {path: ctx.path, operation: "gen ref", schema: None},
138
- }),
139
- )
242
+
243
+ // Detect self-reference using selfRefName from context
244
+ let isSelfRef = switch ctx.selfRefName {
245
+ | Some(selfName) => refName == selfName
246
+ | None => false
247
+ }
248
+
249
+ if isSelfRef {
250
+ "t" // Use recursive self-reference
251
+ } else {
252
+ let typePath = switch ctx.availableSchemas {
253
+ | Some(available) =>
254
+ available->Array.includes(refName)
255
+ ? `${CodegenUtils.toPascalCase(refName)}.t`
256
+ : `ComponentSchemas.${CodegenUtils.toPascalCase(refName)}.t`
257
+ | None =>
258
+ ReferenceResolver.refToTypePath(
259
+ ~insideComponentSchemas=ctx.insideComponentSchemas,
260
+ ~modulePrefix=ctx.modulePrefix,
261
+ ref,
262
+ )->Option.getOr("JSON.t")
263
+ }
264
+ if typePath == "JSON.t" {
265
+ addWarning(
266
+ ctx,
267
+ FallbackToJson({
268
+ reason: `Unresolved ref: ${ref}`,
269
+ context: {path: ctx.path, operation: "gen ref", schema: None},
270
+ }),
271
+ )
272
+ }
273
+ typePath
140
274
  }
141
- typePath
142
275
  | Unknown => "JSON.t"
143
276
  }
144
277
  }
145
278
  }
146
279
 
280
+ // Generate @unboxed variant body from a Union IR type.
281
+ // Each member must have a distinct runtime representation (validated by canUnbox check).
282
+ let generateUnboxedVariantBody = (~ctx: GenerationContext.t, types: array<SchemaIR.irType>): string => {
283
+ let rawNames = types->Array.map(CodegenUtils.variantConstructorName)
284
+ let names = CodegenUtils.deduplicateNames(rawNames)
285
+
286
+ types->Array.mapWithIndex((irType, i) => {
287
+ let constructorName = names->Array.getUnsafe(i)
288
+ let payloadType = switch irType {
289
+ | Object({properties, additionalProperties}) =>
290
+ if Array.length(properties) == 0 {
291
+ switch additionalProperties {
292
+ | Some(valueType) => {
293
+ let innerType = generateTypeWithContext(~ctx, ~depth=1, ~inline=true, valueType)
294
+ `(dict<${innerType}>)`
295
+ }
296
+ | None => `(dict<JSON.t>)`
297
+ }
298
+ } else {
299
+ let fields = properties->Array.map(((name, fieldType, isRequired)) => {
300
+ let typeCode = generateTypeWithContext(~ctx, ~depth=1, ~inline=true, fieldType)
301
+ let alreadyNullable = String.startsWith(typeCode, "option<") || switch fieldType {
302
+ | Option(_) => true
303
+ | Union(unionTypes) => unionTypes->Array.some(t => switch t { | Null | Literal(NullLiteral) => true | _ => false })
304
+ | _ => false
305
+ }
306
+ let finalType = isRequired || alreadyNullable ? typeCode : `option<${typeCode}>`
307
+ let camelName = name->CodegenUtils.toCamelCase
308
+ let escapedName = camelName->CodegenUtils.escapeKeyword
309
+ let aliasAnnotation = escapedName != name ? `@as("${name}") ` : ""
310
+ `${aliasAnnotation}${escapedName}: ${finalType}`
311
+ })->Array.join(", ")
312
+ `({${fields}})`
313
+ }
314
+ | _ =>
315
+ let innerType = generateTypeWithContext(~ctx, ~depth=1, ~inline=true, irType)
316
+ `(${innerType})`
317
+ }
318
+ `${constructorName}${payloadType}`
319
+ })->Array.join(" | ")
320
+ }
321
+
147
322
  let generateType = (
148
323
  ~depth=0,
149
324
  ~path="",
@@ -173,9 +348,58 @@ let generateNamedType = (
173
348
  | Some(d) => CodegenUtils.generateDocString(~description=d, ())
174
349
  | None => ""
175
350
  }
351
+ let mainType = generateTypeWithContext(~ctx, ~depth=0, namedSchema.type_)
352
+
353
+ // Iteratively resolve extracted types (handles nested extraction).
354
+ // Use the same ctx so all nested extractions accumulate in ctx.extractedTypes
355
+ // and dedup works correctly.
356
+ let processed = ref(0)
357
+ while processed.contents < Array.length(ctx.extractedTypes) {
358
+ let idx = processed.contents
359
+ let {irType, isUnboxed, _}: GenerationContext.extractedType = ctx.extractedTypes->Array.getUnsafe(idx)
360
+ if !isUnboxed {
361
+ // Generate at top level to discover nested extractions
362
+ ignore(generateTypeWithContext(~ctx, ~depth=0, ~inline=false, irType))
363
+ } else {
364
+ // For unboxed variants, walk union members to discover nested extractions
365
+ switch irType {
366
+ | Union(types) =>
367
+ types->Array.forEach(memberType => {
368
+ ignore(generateTypeWithContext(~ctx, ~depth=0, ~inline=true, memberType))
369
+ })
370
+ | _ => ignore(generateTypeWithContext(~ctx, ~depth=0, ~inline=false, irType))
371
+ }
372
+ }
373
+ processed := idx + 1
374
+ }
375
+
376
+ let allExtracted = Array.copy(ctx.extractedTypes)
377
+
378
+ // Generate final code for each extracted type.
379
+ let extractedDefs = allExtracted->Array.map(({typeName, irType, isUnboxed}: GenerationContext.extractedType) => {
380
+ if isUnboxed {
381
+ switch irType {
382
+ | Union(types) =>
383
+ let body = generateUnboxedVariantBody(~ctx, types)
384
+ `@unboxed type ${typeName} = ${body}`
385
+ | _ =>
386
+ let auxType = generateTypeWithContext(~ctx, ~depth=0, irType)
387
+ `type ${typeName} = ${auxType}`
388
+ }
389
+ } else {
390
+ let auxType = generateTypeWithContext(~ctx, ~depth=0, irType)
391
+ `type ${typeName} = ${auxType}`
392
+ }
393
+ })
394
+
395
+ // Reverse so deeper-nested types are defined first (dependencies before dependents)
396
+ let reversedExtracted = allExtracted->Array.toReversed
397
+
398
+ let allDefs = Array.concat(extractedDefs->Array.toReversed, [`${doc}type ${namedSchema.name} = ${mainType}`])
176
399
  (
177
- `${doc}type ${namedSchema.name} = ${generateTypeWithContext(~ctx, ~depth=0, namedSchema.type_)}`,
400
+ allDefs->Array.join("\n\n"),
178
401
  ctx.warnings,
402
+ reversedExtracted,
179
403
  )
180
404
  }
181
405
 
@@ -185,7 +409,7 @@ let generateAllTypes = (~context: SchemaIR.schemaContext) => {
185
409
  Dict.valuesToArray(context.schemas)
186
410
  ->Array.toSorted((a, b) => String.compare(a.name, b.name))
187
411
  ->Array.map(s => {
188
- let (code, w) = generateNamedType(~namedSchema=s)
412
+ let (code, w, _) = generateNamedType(~namedSchema=s)
189
413
  warnings->Array.pushMany(w)
190
414
  code
191
415
  })
@@ -193,7 +417,7 @@ let generateAllTypes = (~context: SchemaIR.schemaContext) => {
193
417
  }
194
418
 
195
419
  let generateTypeAndSchema = (~namedSchema) => {
196
- let (tCode, tW) = generateNamedType(~namedSchema)
197
- let (sCode, sW) = IRToSuryGenerator.generateNamedSchema(~namedSchema)
420
+ let (tCode, tW, extractedTypes) = generateNamedType(~namedSchema)
421
+ let (sCode, sW) = IRToSuryGenerator.generateNamedSchema(~namedSchema, ~extractedTypes)
198
422
  ((tCode, sCode), Array.concat(tW, sW))
199
423
  }
@@ -59,7 +59,12 @@ let generateNamedType = (~namedSchema: SchemaIR.namedSchema) => {
59
59
  let typeCode = generateType(~irType=namedSchema.type_)
60
60
 
61
61
  let declaration = switch namedSchema.type_ {
62
- | Object(_) => `export interface ${namedSchema.name} ${typeCode}`
62
+ | Object(_) =>
63
+ if typeCode == "Record<string, never>" {
64
+ `export type ${namedSchema.name} = ${typeCode};`
65
+ } else {
66
+ `export interface ${namedSchema.name} ${typeCode}`
67
+ }
63
68
  | _ => `export type ${namedSchema.name} = ${typeCode};`
64
69
  }
65
70
 
@@ -8,11 +8,12 @@ let generateSchemaCodeForDict = (schemaDict: dict<jsonSchema>) =>
8
8
  ->Array.toSorted(((nameA, _), (nameB, _)) => String.compare(nameA, nameB))
9
9
  ->Array.flatMap(((name, schema)) => {
10
10
  let (ir, _) = SchemaIRParser.parseJsonSchema(schema)
11
- let (typeCode, _) = IRToTypeGenerator.generateNamedType(
11
+ let (typeCode, _, extractedTypes) = IRToTypeGenerator.generateNamedType(
12
12
  ~namedSchema={name: name, description: schema.description, type_: ir},
13
13
  )
14
14
  let (schemaCode, _) = IRToSuryGenerator.generateNamedSchema(
15
15
  ~namedSchema={name: `${name}Schema`, description: schema.description, type_: ir},
16
+ ~extractedTypes,
16
17
  )
17
18
  [typeCode->CodegenUtils.indent(2), schemaCode->CodegenUtils.indent(2), ""]
18
19
  })
@@ -4,11 +4,12 @@
4
4
 
5
5
  let generateTypeCodeAndSchemaCode = (name, schema: Types.jsonSchema) => {
6
6
  let (ir, _) = SchemaIRParser.parseJsonSchema(schema)
7
- let (typeCode, _) = IRToTypeGenerator.generateNamedType(
7
+ let (typeCode, _, extractedTypes) = IRToTypeGenerator.generateNamedType(
8
8
  ~namedSchema={name: name, description: schema.description, type_: ir},
9
9
  )
10
10
  let (schemaCode, _) = IRToSuryGenerator.generateNamedSchema(
11
11
  ~namedSchema={name: `${name}Schema`, description: schema.description, type_: ir},
12
+ ~extractedTypes,
12
13
  )
13
14
  `${typeCode}\n\n${schemaCode}`
14
15
  }
@@ -2,22 +2,55 @@
2
2
 
3
3
  // GenerationContext.res - Shared context type for IR code generators
4
4
 
5
+ // Extracted auxiliary type that was too complex to inline
6
+ type extractedType = {
7
+ typeName: string,
8
+ irType: SchemaIR.irType,
9
+ isUnboxed: bool, // Needs @unboxed annotation (for variant types from mixed unions)
10
+ }
11
+
5
12
  type t = {
6
13
  mutable warnings: array<CodegenError.Warning.t>,
14
+ mutable extractedTypes: array<extractedType>,
15
+ mutable extractCounter: int,
7
16
  path: string,
8
17
  insideComponentSchemas: bool, // Whether we're generating inside ComponentSchemas module
9
18
  availableSchemas: option<array<string>>, // Schemas available in current module (for fork schemas)
10
19
  modulePrefix: string, // Module prefix for qualified references (e.g., "MisskeyIo")
20
+ selfRefName: option<string>, // Schema name for self-referential type detection (e.g., "DriveFolder")
11
21
  }
12
22
 
13
- let make = (~path, ~insideComponentSchemas=false, ~availableSchemas=?, ~modulePrefix="", ()): t => {
23
+ let make = (~path, ~insideComponentSchemas=false, ~availableSchemas=?, ~modulePrefix="", ~selfRefName=?, ()): t => {
14
24
  warnings: [],
25
+ extractedTypes: [],
26
+ extractCounter: 0,
15
27
  path,
16
28
  insideComponentSchemas,
17
29
  availableSchemas,
18
30
  modulePrefix,
31
+ selfRefName,
19
32
  }
20
33
 
21
34
  let addWarning = (ctx: t, warning: CodegenError.Warning.t): unit => {
22
35
  ctx.warnings->Array.push(warning)
23
36
  }
37
+
38
+ let extractType = (ctx: t, ~baseName: string, ~isUnboxed=false, irType: SchemaIR.irType): string => {
39
+ // Check if this irType was already extracted (avoid duplicates)
40
+ let existing = ctx.extractedTypes->Array.find(({irType: existingIr}: extractedType) =>
41
+ SchemaIR.equals(existingIr, irType)
42
+ )
43
+ switch existing {
44
+ | Some({typeName}) => typeName
45
+ | None =>
46
+ ctx.extractCounter = ctx.extractCounter + 1
47
+ // ReScript type names must start with lowercase
48
+ let lowerBaseName = switch baseName->String.charAt(0) {
49
+ | "" => "extracted"
50
+ | first => first->String.toLowerCase ++ baseName->String.sliceToEnd(~start=1)
51
+ }
52
+ let typeName = `${lowerBaseName}_${Int.toString(ctx.extractCounter)}`
53
+ ctx.extractedTypes->Array.push({typeName, irType, isUnboxed})
54
+ typeName
55
+ }
56
+ }