Skip to content

Commit 5bf551b

Browse files
authored
Merge pull request #533 from traversable/zod-template-literal-fixes
fix(zod,zod-types): fixes a few bugs related to `zx.ToType`'s handling of `z.templateLiteral` schemas
2 parents 1485f21 + 5db0e97 commit 5bf551b

File tree

10 files changed

+346
-16
lines changed

10 files changed

+346
-16
lines changed

.changeset/sixty-sites-visit.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@traversable/registry": patch
3+
"@traversable/zod-test": patch
4+
"@traversable/zod": patch
5+
---
6+
7+
### fixes
8+
9+
- fix(zod,zod-types): fixes `zx.toType` escaping bug regarding grave quotes in `z.templateLiteral` schemas (#532)
10+
- fix(zod,zod-types): fixes `zx.toType` not properly supporing `z.enum`, `z.optional` and `z.nullable` schemas in `z.templateLiteral` (#521)

examples/sandbox/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@
2424
"@traversable/schema-to-string": "latest",
2525
"@traversable/schema-to-validator": "latest",
2626
"@traversable/arktype": "latest",
27+
"@traversable/arktype-test": "latest",
2728
"@traversable/json-schema": "latest",
29+
"@traversable/json-schema-test": "latest",
2830
"@traversable/typebox": "latest",
31+
"@traversable/typebox-test": "latest",
2932
"@traversable/valibot": "latest",
33+
"@traversable/valibot-test": "latest",
3034
"@traversable/zod": "latest",
35+
"@traversable/zod-test": "latest",
3136
"arktype": "latest",
3237
"fast-check": "latest",
3338
"react": "latest",

packages/registry/src/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export { pair } from './pair.js'
4444
export {
4545
accessor,
4646
escape,
47+
escapeCharCodes,
4748
escapeJsDoc,
4849
indexAccessor,
4950
isQuoted,

packages/registry/src/parse.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const ESC_CHAR = [
3434
/** 60-69 */ '', '', '', '', '', '', '', '', '', '',
3535
/** 60-69 */ '', '', '', '', '', '', '', '', '', '',
3636
/** 80-89 */ '', '', '', '', '', '', '', '', '', '',
37-
/** 90-92 */ '', '', '\\\\',
37+
/** 90-96 */ '', '', '\\\\', '', '', '', '\\`',
3838
]
3939

4040
/**
@@ -63,7 +63,6 @@ const ESC_CHAR = [
6363
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#utf-16_characters_unicode_code_points_and_grapheme_clusters
6464
* )
6565
*/
66-
export function escape(string: string): string
6766
export function escape(x: string): string {
6867
let prev = 0
6968
let out = ""
@@ -89,6 +88,37 @@ export function escape(x: string): string {
8988
return out
9089
}
9190

91+
export function escapeCharCodes(x: string, ...charCodes: number[]): string {
92+
let prev = 0
93+
let out = ""
94+
let pt: number
95+
for (let ix = 0, len = x.length; ix < len; ix++) {
96+
pt = x.charCodeAt(ix)
97+
if (charCodes.includes(pt)) {
98+
// using `||` instead of `??` because if the escape char listed is '', we want to manually escape
99+
out += x.slice(prev, ix) + (ESC_CHAR[pt] || '\\')
100+
prev = ix + 1
101+
}
102+
if (pt === 34 || pt === 92 || pt < 32) {
103+
out += x.slice(prev, ix) + ESC_CHAR[pt]
104+
prev = ix + 1
105+
} else if (0xdfff <= pt && pt <= 0xdfff) {
106+
if (pt <= 0xdbff && ix + 1 < x.length) {
107+
void (pt = x.charCodeAt(ix + 1))
108+
if (pt >= 0xdc00 && pt <= 0xdfff) {
109+
ix++
110+
continue
111+
}
112+
}
113+
out += x.slice(prev, ix) + "\\u" + pt.toString(16)
114+
prev = ix + 1
115+
}
116+
}
117+
out += x.slice(prev)
118+
return out
119+
}
120+
121+
92122
export function escapeJsDoc(string: string): string
93123
export function escapeJsDoc(x: string): string {
94124
let prevIndex = 0

packages/zod-test/src/generator-seed.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,18 @@ export declare namespace Seed {
151151
interface Literal extends newtype<[seed: byTag['literal'], value: z.core.util.Literal]> {}
152152
interface TemplateLiteral extends newtype<[seed: byTag['template_literal'], value: TemplateLiteral.Node[]]> {}
153153
namespace TemplateLiteral {
154-
type Node = T.Showable | Seed.Boolean | Seed.Null | Seed.Undefined | Seed.Integer | Seed.Number | Seed.BigInt | Seed.String | Seed.Literal
154+
type Node =
155+
| T.Showable
156+
| Seed.Boolean
157+
| Seed.Null
158+
| Seed.Undefined
159+
| Seed.Integer
160+
| Seed.Number
161+
| Seed.BigInt
162+
| Seed.String
163+
| Seed.Literal
164+
| Seed.Nullable
165+
| Seed.Optional
155166
}
156167
type Value = ValueMap[keyof ValueMap]
157168
type ValueMap = {

packages/zod-test/src/generator.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod'
22
import * as fc from 'fast-check'
33

4-
import type { newtype, inline } from '@traversable/registry'
4+
import type { newtype, inline, Target } from '@traversable/registry'
55
import {
66
Array_isArray,
77
fn,
@@ -367,10 +367,14 @@ const is = {
367367
string: (x: unknown): x is [byTag['number'], Bounds.string] => Array_isArray(x) && x[0] === byTag.string,
368368
literal: (x: unknown): x is [byTag['literal'], z.core.util.Literal] => Array_isArray(x) && x[0] === byTag.literal,
369369
bigint: (x: unknown): x is [byTag['number'], Bounds.bigint] => Array_isArray(x) && x[0] === byTag.bigint,
370+
nullable: (x: unknown): x is [byTag['nullable'], TemplateLiteralTerminal] => Array_isArray(x) && x[0] === byTag.nullable,
371+
optional: (x: unknown): x is [byTag['optional'], TemplateLiteralTerminal] => Array_isArray(x) && x[0] === byTag.optional,
372+
enum: (x: unknown): x is [byTag['enum'], { [x: string]: number | string }] => Array_isArray(x) && x[0] === byTag.enum,
373+
union: (x: unknown): x is [byTag['optional'], readonly TemplateLiteralTerminal[]] => Array_isArray(x) && x[0] === byTag.union,
370374
}
371375

372376
function templateLiteralNodeToPart(x: Seed.TemplateLiteral.Node): z.core.$ZodTemplateLiteralPart {
373-
if (isShowable(x)) return x
377+
if (isShowable(x)) return z.literal(x)
374378
else if (is.null(x)) return z.null()
375379
else if (is.undefined(x)) return z.undefined()
376380
else if (is.boolean(x)) return z.boolean()
@@ -379,6 +383,14 @@ function templateLiteralNodeToPart(x: Seed.TemplateLiteral.Node): z.core.$ZodTem
379383
else if (is.bigint(x)) return z_bigint(x[1])
380384
else if (is.string(x)) return z_string(x[1])
381385
else if (is.literal(x)) return z.literal(x[1])
386+
else if (is.literal(x)) return z.literal(x[1])
387+
else if (is.enum(x)) return z.enum(x[1])
388+
else if (is.nullable(x)) {
389+
return z.nullable(templateLiteralNodeToPart(x[1]) as z.ZodType) as z.core.$ZodTemplateLiteralPart
390+
}
391+
else if (is.optional(x)) {
392+
return z.optional(templateLiteralNodeToPart(x[1]) as z.ZodType) as z.core.$ZodTemplateLiteralPart
393+
}
382394
else { return fn.exhaustive(x as never) }
383395
}
384396

@@ -397,6 +409,38 @@ function templateLiteralSeed($: Config.byTypeName['template_literal']): fc.Arbit
397409
)
398410
}
399411

412+
type TemplateLiteralTerminal =
413+
| null
414+
| undefined
415+
| string
416+
| number
417+
| bigint
418+
| boolean
419+
| [40]
420+
| [50]
421+
| [15]
422+
| [200, Bounds.number]
423+
| [150, Bounds.bigint]
424+
| [250, Bounds.string]
425+
| [550, string | number | bigint | boolean]
426+
427+
const templateLiteralTerminals = fc.oneof(
428+
fc.constant(null),
429+
fc.constant(undefined),
430+
fc.constant(''),
431+
fc.boolean(),
432+
fc.integer(),
433+
fc.bigInt(),
434+
fc.string(),
435+
TerminalMap.undefined(),
436+
TerminalMap.null(),
437+
TerminalMap.boolean(),
438+
BoundableMap.bigint(),
439+
BoundableMap.number(),
440+
BoundableMap.string(),
441+
ValueMap.literal(),
442+
) satisfies fc.Arbitrary<TemplateLiteralTerminal>
443+
400444
function templateLiteralPart($: Config.byTypeName['template_literal']) {
401445
return fc.oneof(
402446
$,
@@ -414,6 +458,8 @@ function templateLiteralPart($: Config.byTypeName['template_literal']) {
414458
{ arbitrary: BoundableMap.number(), weight: 12 },
415459
{ arbitrary: BoundableMap.string(), weight: 13 },
416460
{ arbitrary: ValueMap.literal(), weight: 14 },
461+
// { arbitrary: fc.tuple(fc.constant(byTag.nullable), templateLiteralTerminals), weight: 15 },
462+
// { arbitrary: fc.tuple(fc.constant(byTag.optional), templateLiteralTerminals), weight: 16 },
417463
) satisfies fc.Arbitrary<Seed.TemplateLiteral.Node>
418464
}
419465

packages/zod/src/to-type.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { z } from 'zod'
2-
import { escape, escapeJsDoc, parseKey } from '@traversable/registry'
2+
import { escapeCharCodes, escapeJsDoc, parseKey } from '@traversable/registry'
33
import { hasTypeName, tagged, F, hasOptional, Invariant } from '@traversable/zod-types'
44
import { Json } from '@traversable/json'
55

6+
const GRAVE_CHAR_CODE = 96
7+
68
export type WithOptionalTypeName = {
79
/**
810
* ## {@link WithOptionalTypeName `toType.Options.typeName`}
@@ -145,7 +147,7 @@ function preserveJsDocsEnabled(ix: F.CompilerIndex) {
145147
}
146148

147149
function stringifyLiteral(value: unknown) {
148-
return typeof value === 'string' ? `"${escape(value)}"` : typeof value === 'bigint' ? `${value}n` : `${value}`
150+
return typeof value === 'string' ? `"${escapeCharCodes(value, GRAVE_CHAR_CODE)}"` : typeof value === 'bigint' ? `${value}n` : `${value}`
149151
}
150152

151153
const stringifyExample = Json.fold<string>((x) => {
@@ -163,7 +165,7 @@ const stringifyExample = Json.fold<string>((x) => {
163165
}
164166
})
165167

166-
const readonly = (x: F.Z.Readonly<string>, ix: F.CompilerIndex, input: z.ZodReadonly): string => {
168+
function readonly(x: F.Z.Readonly<string>, ix: F.CompilerIndex, input: z.ZodReadonly): string {
167169
const { innerType } = input._zod.def
168170
if (tagged('file', innerType)) return `Readonly<File>`
169171
else if (tagged('unknown', innerType)) return `Readonly<unknown>`
@@ -186,25 +188,57 @@ const readonly = (x: F.Z.Readonly<string>, ix: F.CompilerIndex, input: z.ZodRead
186188
else return x._zod.def.innerType
187189
}
188190

189-
function templateLiteralParts(parts: unknown[]): string[][] {
190-
let out = [Array.of<string>()]
191+
function templateLiteralParts(parts: unknown[], out: string[][] = [Array.of<string>()]): string[][] {
191192
let x = parts[0]
192193
for (let ix = 0, len = parts.length; ix < len; (void ix++, x = parts[ix])) {
193194
switch (true) {
194195
case x === undefined: out.forEach((xs) => xs.push('')); break
195196
case x === null: out.forEach((xs) => xs.push('null')); break
196-
case typeof x === 'string': out.forEach((xs) => xs.push(escape(String(x)))); break
197+
case typeof x === 'string': out.forEach((xs) => xs.push(escapeCharCodes(String(x), GRAVE_CHAR_CODE))); break
197198
case tagged('null', x): out.forEach((xs) => xs.push('null')); break
198199
case tagged('undefined', x): out.forEach((xs) => xs.push('')); break
199200
case tagged('number', x): out.forEach((xs) => xs.push('${number}')); break
200201
case tagged('string', x): out.forEach((xs) => xs.push('${string}')); break
201202
case tagged('bigint', x): out.forEach((xs) => xs.push('${bigint}')); break
202203
case tagged('boolean', x): out = out.flatMap((xs) => [[...xs, 'true'], [...xs, 'false']]); break
203204
case tagged('literal', x): {
204-
const values = x._zod.def.values.map((_) => _ === undefined ? '' : escape(String(_)))
205+
const values = x._zod.def.values.map((_) => _ === undefined ? '' : escapeCharCodes(String(_), GRAVE_CHAR_CODE))
205206
out = out.flatMap((xs) => values.map((value) => [...xs, value]))
206207
break
207208
}
209+
case tagged('nullable', x): {
210+
const { innerType } = x._zod.def
211+
if (tagged('boolean', innerType)) {
212+
out = out.flatMap((xs) => [[...xs, 'true'], [...xs, 'false'], [...xs, 'null']])
213+
break
214+
} else if (tagged('string', innerType) && ix === 0) {
215+
out.forEach((xs) => xs.push('${string}'))
216+
break
217+
} else {
218+
out = out.flatMap((xs) => [[...xs, ...templateLiteralParts([innerType]).flat()], [...xs, 'null']])
219+
break
220+
}
221+
}
222+
case tagged('optional', x): {
223+
const { innerType } = x._zod.def
224+
if (tagged('boolean', innerType)) {
225+
out = out.flatMap((xs) => [[...xs, 'true'], [...xs, 'false'], [...xs, '']])
226+
break
227+
}
228+
else if (tagged('string', innerType)) {
229+
out.forEach((xs) => xs.push('${string}'))
230+
break
231+
}
232+
else {
233+
out = out.flatMap((xs) => [[...xs, ...templateLiteralParts([innerType]).flat()], [...xs, '']])
234+
break
235+
}
236+
}
237+
case tagged('enum', x): {
238+
const values: (string | number)[] = Object.values(x._zod.def.entries)
239+
out = out.flatMap((xs) => values.map((value) => [...xs, String(value)]))
240+
break
241+
}
208242
default: out.forEach((xs) => xs.push(String(x))); break
209243
}
210244
}

packages/zod/test/to-type.fuzz.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as fs from 'node:fs'
55
import { zx } from '@traversable/zod'
66
import { zxTest } from '@traversable/zod-test'
77

8-
const NUM_RUNS = 100
8+
const NUM_RUNS = 1_000
99
const EXCLUDE = [
1010
...zx.toType.unsupported,
1111
'default',
@@ -15,7 +15,8 @@ const EXCLUDE = [
1515
'success',
1616
'readonly',
1717
] satisfies zxTest.GeneratorOptions['exclude']
18-
const OPTIONS = { exclude: EXCLUDE } satisfies zxTest.GeneratorOptions
18+
const OPTIONS = { exclude: EXCLUDE, template_literal: { minLength: 1, maxLength: 2 } } satisfies zxTest.GeneratorOptions
19+
// const OPTIONS = { exclude: EXCLUDE } satisfies zxTest.GeneratorOptions
1920

2021
export const DIR = path.join(path.resolve(), 'packages', 'zod', 'test', '__generated__')
2122
export const PATH = {
@@ -35,7 +36,8 @@ vi.describe('〖⛳️〗‹‹‹ ❲@traverable/zod❳: integration tests', ()
3536
`import * as vi from 'vitest'`,
3637
`import { z } from 'zod'`
3738
] as const satisfies string[]
38-
const seeds = fc.sample(zxTest.SeedGenerator(OPTIONS)['*'] as never, NUM_RUNS)
39+
const seeds = fc.sample(zxTest.SeedGenerator(OPTIONS)['template_literal'] as never, NUM_RUNS)
40+
// const seeds = fc.sample(zxTest.SeedGenerator(OPTIONS)['*'] as never, NUM_RUNS)
3941
const gen = seeds.map((seed) => zxTest.seedToSchema(seed as never))
4042

4143
const typeDeps = [

0 commit comments

Comments
 (0)